fix(rust): read .env like python-dotenv; dry run in the service environment
CI / Format & lint (push) Successful in 18s
CI / Test suite (push) Successful in 45s
Rust / fmt, clippy, test, build (push) Successful in 7m4s

dotenvy aborted on the production .env (a quote in an unquoted value makes it
read to the end of the file) and echoed the offending line, password
included. envfile.rs ports python-dotenv 1.1.1 (parser + ${VAR} interpolation,
no override): unparsable statements are skipped with a warning that names only
the line number. Verified against python-dotenv on 614 generated inputs
(testdata/). The .env is loaded before the tokio runtime starts.

install_collector.sh now runs the dry run through systemd-run with the unit's
User, WorkingDirectory and EnvironmentFile, i.e. exactly what the service sees.
This commit is contained in:
grabowski committed 2026-10-17 22:58:12 +07:00
1 parent 63353f1e80
commit c8e89840b5
9 files changed
+7455 -11

No files matched your search

-1
View File
@@ -1163,7 +1163,6 @@ dependencies = [
"anyhow",
"chrono",
"clap",
"dotenvy",
"reqwest",
"serde",
"serde_json",
-1
View File
@@ -15,7 +15,6 @@ repository = "https://git.b4l.co.th/B4L/Northern-Thailand-Ping-River-Monitor"
anyhow = "1"
chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }
clap = { version = "4", features = ["derive"] }
dotenvy = "0.15"
# native-tls (OpenSSL on Linux, SChannel on Windows), not rustls: app.rid.go.th (dams)
# offers only TLS 1.2 CBC suites (ECDHE-RSA-AES256-SHA384), which rustls refuses.
reqwest = { version = "0.12", default-features = false, features = ["native-tls", "json"] }
+4 -1
View File
@@ -25,7 +25,10 @@ ping-collector check-db # connect, create missing tables, row counts
```
Configuration is the Python app's `.env` (read from the working directory, or
`--env-file`); real environment variables win. Used keys:
`--env-file`) with python-dotenv's rules, verified on 614 generated inputs
(`collector/testdata/`): an unparsable line is skipped with a warning giving
only its line number, never its value. Variables already set (e.g. by systemd's
`EnvironmentFile=`) win. Used keys:
`POSTGRES_CONNECTION_STRING` (or `POSTGRES_HOST/PORT/DB/USER/PASSWORD`),
`RID_POLL_OFFSET_MINUTES` (5), `RID_RETRY_MINUTES` (10),
`GAP_FILL_DAYS_ON_START` (14), `ENABLE_HII_COLLECTION`,
-1
View File
@@ -12,7 +12,6 @@ repository.workspace = true
anyhow.workspace = true
chrono.workspace = true
clap.workspace = true
dotenvy.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
+488
View File
@@ -0,0 +1,488 @@
//! `.env` loading with python-dotenv 1.1.1 semantics (`load_dotenv()`:
//! interpolate `${VAR}` / `${VAR:-default}`, never override variables that
//! are already set).
//!
//! The Python app reads the same file, so the two must agree on every value.
//! dotenvy (the usual crate) is stricter: a stray `'` or `"` inside an
//! unquoted value makes it swallow the rest of the file and abort, and its
//! error message echoes the line, password included. Here an unparsable
//! statement is skipped with a warning naming only its line number, exactly
//! like python-dotenv. Port of dotenv/parser.py + variables.py; the tests
//! below are python-dotenv's own output for the same inputs.
use std::collections::HashMap;
use std::path::Path;
/// Python's `\s` (str.isspace), which includes \x1c-\x1f.
fn is_ws(c: char) -> bool {
c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c)
}
/// `[^\S\r\n]`: whitespace other than line breaks.
fn is_hspace(c: char) -> bool {
is_ws(c) && c != '\r' && c != '\n'
}
struct Reader {
s: Vec<char>,
pos: usize,
}
struct Fail;
impl Reader {
fn peek(&self) -> Option<char> {
self.s.get(self.pos).copied()
}
fn at(&self, i: usize) -> Option<char> {
self.s.get(i).copied()
}
fn skip_while(&mut self, f: impl Fn(char) -> bool) {
while self.peek().is_some_and(&f) {
self.pos += 1;
}
}
fn starts_with(&self, lit: &str) -> bool {
lit.chars()
.enumerate()
.all(|(i, c)| self.at(self.pos + i) == Some(c))
}
fn line_of(&self, pos: usize) -> usize {
// python-dotenv counts \r\n, \n and \r as one newline each
let mut line = 1;
let mut i = 0;
while i < pos {
match self.s[i] {
'\r' if self.at(i + 1) == Some('\n') && i + 1 < pos => {
line += 1;
i += 1;
}
'\r' | '\n' => line += 1,
_ => {}
}
i += 1;
}
line
}
/// `(?:export[^\S\r\n]+)?`
fn export(&mut self) {
if self.starts_with("export") && self.at(self.pos + 6).is_some_and(is_hspace) {
self.pos += 6;
self.skip_while(is_hspace);
}
}
/// `'([^']+)'` or `([^=\#\s]+)`; None for a comment line.
fn key(&mut self) -> Result<Option<String>, Fail> {
match self.peek() {
Some('#') => Ok(None),
Some('\'') => {
let start = self.pos + 1;
let mut i = start;
while self.at(i).is_some_and(|c| c != '\'') {
i += 1;
}
if i == start || self.at(i) != Some('\'') {
return Err(Fail);
}
self.pos = i + 1;
Ok(Some(self.s[start..i].iter().collect()))
}
_ => {
let start = self.pos;
self.skip_while(|c| c != '=' && c != '#' && !is_ws(c));
if self.pos == start {
return Err(Fail);
}
Ok(Some(self.s[start..self.pos].iter().collect()))
}
}
}
/// `Q((?:\\Q|[^Q])*)Q` with regex backtracking: the greedy scan stops at
/// an unescaped quote; at end of input the last `\Q` is re-read as a
/// literal backslash followed by the closing quote.
fn quoted(&mut self, q: char) -> Result<String, Fail> {
let start = self.pos + 1;
let mut i = start;
let mut last_escaped_quote = None;
loop {
match self.at(i) {
None => {
let Some(bs) = last_escaped_quote else {
return Err(Fail);
};
self.pos = bs + 2;
return Ok(self.s[start..=bs].iter().collect());
}
Some(c) if c == q => {
self.pos = i + 1;
return Ok(self.s[start..i].iter().collect());
}
Some('\\') if self.at(i + 1) == Some(q) => {
last_escaped_quote = Some(i);
i += 2;
}
Some(_) => i += 1,
}
}
}
/// parse_value
fn value(&mut self) -> Result<String, Fail> {
match self.peek() {
Some('\'') => Ok(decode_escapes(&self.quoted('\'')?, "\\'")),
Some('"') => Ok(decode_escapes(&self.quoted('"')?, "\\'\"abfnrtv")),
None | Some('\n') | Some('\r') => Ok(String::new()),
Some(_) => {
let start = self.pos;
self.skip_while(|c| c != '\r' && c != '\n');
let part: Vec<char> = self.s[start..self.pos].to_vec();
Ok(strip_inline_comment(&part))
}
}
}
/// `(?:[^\S\r\n]*#[^\r\n]*)?` then `[^\S\r\n]*(?:\r\n|\n|\r|$)`
fn comment_and_eol(&mut self) -> Result<(), Fail> {
let save = self.pos;
self.skip_while(is_hspace);
if self.peek() == Some('#') {
self.skip_while(|c| c != '\r' && c != '\n');
} else {
self.pos = save;
}
self.skip_while(is_hspace);
match self.peek() {
None => Ok(()),
Some('\r') => {
self.pos += if self.at(self.pos + 1) == Some('\n') {
2
} else {
1
};
Ok(())
}
Some('\n') => {
self.pos += 1;
Ok(())
}
Some(_) => Err(Fail),
}
}
/// `[^\r\n]*(?:\r|\n|\r\n)?` (the `\r` alternative wins for `\r\n`)
fn rest_of_line(&mut self) {
self.skip_while(|c| c != '\r' && c != '\n');
if self.peek().is_some() {
self.pos += 1;
}
}
}
/// Unquoted values: `re.sub(r"\s+#.*", "", part).rstrip()`.
fn strip_inline_comment(part: &[char]) -> String {
let mut end = part.len();
let mut i = 0;
while i < part.len() {
if is_ws(part[i]) {
let mut j = i;
while j < part.len() && is_ws(part[j]) {
j += 1;
}
if part.get(j) == Some(&'#') {
end = i;
break;
}
i = j;
} else {
i += 1;
}
}
let kept: String = part[..end].iter().collect();
kept.trim_end_matches(is_ws).to_string()
}
/// `\\[<allowed>]` -> codecs 'unicode-escape' of that pair.
fn decode_escapes(s: &str, allowed: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\'
&& let Some(&n) = chars.peek()
&& allowed.contains(n)
{
chars.next();
out.push(match n {
'a' => '\u{7}',
'b' => '\u{8}',
'f' => '\u{c}',
'n' => '\n',
'r' => '\r',
't' => '\t',
'v' => '\u{b}',
other => other, // \\ \' \"
});
} else {
out.push(c);
}
}
out
}
/// Statements in file order: (key, value or None for `KEY` without `=`).
/// Unparsable statements are reported as line numbers in `errors`.
pub fn parse(content: &str) -> (Vec<(String, Option<String>)>, Vec<usize>) {
let mut r = Reader {
s: content.chars().collect(),
pos: 0,
};
let mut out = Vec::new();
let mut errors = Vec::new();
while r.pos < r.s.len() {
r.skip_while(is_ws); // _multiline_whitespace
if r.pos >= r.s.len() {
break;
}
let statement_start = r.pos;
let binding = (|| {
r.export();
let key = r.key()?;
r.skip_while(is_hspace);
let value = if r.peek() == Some('=') {
r.pos += 1;
r.skip_while(is_hspace);
Some(r.value()?)
} else {
None
};
r.comment_and_eol()?;
Ok::<_, Fail>(key.map(|k| (k, value)))
})();
match binding {
Ok(Some(kv)) => out.push(kv),
Ok(None) => {}
Err(Fail) => {
errors.push(r.line_of(statement_start));
r.rest_of_line();
}
}
}
(out, errors)
}
/// `${NAME}` / `${NAME:-default}` expansion, python-dotenv `parse_variables`.
fn interpolate(value: &str, lookup: &impl Fn(&str) -> Option<String>) -> String {
let v: Vec<char> = value.chars().collect();
let mut out = String::new();
let mut i = 0;
while i < v.len() {
if v[i] == '$' && v.get(i + 1) == Some(&'{') {
let mut j = i + 2;
while j < v.len() && v[j] != '}' && v[j] != ':' {
j += 1;
}
let name: String = v[i + 2..j].iter().collect();
let mut default = None;
let mut k = j;
if v.get(j) == Some(&':') && v.get(j + 1) == Some(&'-') {
k = j + 2;
while k < v.len() && v[k] != '}' {
k += 1;
}
default = Some(v[j + 2..k].iter().collect::<String>());
}
if v.get(k) == Some(&'}') {
out.push_str(&lookup(&name).or(default).unwrap_or_default());
i = k + 1;
continue;
}
}
out.push(v[i]);
i += 1;
}
out
}
/// python-dotenv `dotenv_values` with override=False: what `load_dotenv()`
/// would set. `env` looks up the process environment.
pub fn resolve(
content: &str,
env: impl Fn(&str) -> Option<String>,
) -> (Vec<(String, Option<String>)>, Vec<usize>) {
let (raw, errors) = parse(content);
let mut resolved: Vec<(String, Option<String>)> = Vec::new();
let mut by_key: HashMap<String, usize> = HashMap::new();
for (key, value) in raw {
let result = value.map(|v| {
// override=False: the process environment wins over the file
let lookup = |name: &str| {
env(name).or_else(|| {
by_key
.get(name)
.and_then(|&i| resolved[i].1.clone())
.or_else(|| {
by_key.contains_key(name).then(String::new) // KEY without '='
})
})
};
interpolate(&v, &lookup)
});
match by_key.get(&key) {
Some(&i) => resolved[i].1 = result,
None => {
by_key.insert(key.clone(), resolved.len());
resolved.push((key, result));
}
}
}
(resolved, errors)
}
/// `load_dotenv(path)`: set every variable that is not set yet. Must run
/// before any other thread exists (`set_var`). Returns how many were set.
pub fn load(path: &Path) -> std::io::Result<usize> {
let content = std::fs::read_to_string(path)?;
let (values, errors) = resolve(&content, |k| std::env::var(k).ok());
for line in errors {
eprintln!(
"warning: {}: could not parse the statement on line {line}; skipped (as python-dotenv does)",
path.display()
);
}
let mut set = 0;
for (key, value) in values {
if std::env::var_os(&key).is_some() {
continue;
}
if let Some(value) = value {
// SAFETY: called from main() before the tokio runtime (or any
// other thread) is started.
unsafe { std::env::set_var(&key, value) };
set += 1;
}
}
Ok(set)
}
#[cfg(test)]
mod tests {
use super::*;
fn vals(content: &str) -> Vec<(String, Option<String>)> {
resolve(content, |k| {
(k == "HOME_FROM_ENV").then(|| "/home/x".into())
})
.0
}
fn kv(k: &str, v: &str) -> (String, Option<String>) {
(k.into(), Some(v.into()))
}
// Expected values below are python-dotenv 1.1.1 `DotEnv(stream,
// interpolate=True, override=False).dict()` for the same input.
#[test]
fn stray_quotes_in_unquoted_values_are_literal() {
assert_eq!(
vals("A=abc'def\nB=x\"y\nC=1\n"),
vec![kv("A", "abc'def"), kv("B", "x\"y"), kv("C", "1")]
);
}
#[test]
fn unterminated_quote_skips_only_that_line() {
let (v, errors) = resolve("A=1\nPOSTGRES_PASSWORD='abc\nB=2\n", |_| None);
assert_eq!(v, vec![kv("A", "1"), kv("B", "2")]);
assert_eq!(errors, vec![2]);
}
#[test]
fn whitespace_comments_and_export() {
assert_eq!(
vals(" export A = spaced value # comment\nB=a#b\n# c\n\nC= \nD\n"),
vec![
kv("A", "spaced value"),
kv("B", "a#b"),
kv("C", ""),
("D".into(), None)
]
);
}
#[test]
fn quoted_values_and_escapes() {
assert_eq!(
vals("A='x\\'y'\nB=\"l1\\nl2\\t\\\"q\\\"\"\nC='multi\nline'\nD='a\\\\b'\n"),
vec![
kv("A", "x'y"),
kv("B", "l1\nl2\t\"q\""),
kv("C", "multi\nline"),
kv("D", "a\\b")
]
);
}
#[test]
fn trailing_escaped_quote_backtracks_like_the_regex() {
// python: '((?:\\'|[^'])*)' on "'ab\'" matches with value "ab\"
assert_eq!(vals("A='ab\\'"), vec![kv("A", "ab\\")]);
}
#[test]
fn junk_after_a_quoted_value_is_an_error() {
let (v, errors) = resolve("A='x'y\nB=1\n", |_| None);
assert_eq!(v, vec![kv("B", "1")]);
assert_eq!(errors, vec![1]);
}
#[test]
fn interpolation_prefers_the_environment() {
assert_eq!(
vals(
"HOME_FROM_ENV=file\nA=${HOME_FROM_ENV}/x\nB=${A}-${MISSING:-dflt}-${NOPE}\nC=$A ${A:x}\n"
),
vec![
kv("HOME_FROM_ENV", "file"),
kv("A", "/home/x/x"),
kv("B", "/home/x/x-dflt-"),
kv("C", "$A ${A:x}")
]
);
}
/// 614 inputs (the cases above + 600 random ones built from quotes,
/// escapes, comments, `${}` and odd separators/line ends) with
/// python-dotenv 1.1.1's result; regenerate with testdata/gen_envfile_cases.py.
#[test]
fn matches_python_dotenv_on_generated_cases() {
let fixture: serde_json::Value =
serde_json::from_str(include_str!("../testdata/envfile_python_dotenv.json")).unwrap();
let cases = fixture["cases"].as_array().unwrap();
assert!(cases.len() > 600);
for case in cases {
let input = case["input"].as_str().unwrap();
let want: Vec<(String, Option<String>)> = case["expected"]
.as_array()
.unwrap()
.iter()
.map(|kv| {
(
kv[0].as_str().unwrap().to_string(),
kv[1].as_str().map(str::to_string),
)
})
.collect();
let got = resolve(input, |k| (k == "HOME_FROM_ENV").then(|| "/home/x".into())).0;
assert_eq!(got, want, "input {input:?}");
}
}
#[test]
fn crlf_and_duplicate_keys() {
assert_eq!(
vals("A=1\r\nB=2\r\nA=3\r\n"),
vec![kv("A", "3"), kv("B", "2")]
);
}
}
+21 -6
View File
@@ -10,6 +10,7 @@ mod dams;
mod db;
#[cfg(test)]
mod db_tests;
mod envfile;
mod hii;
mod rain;
mod rid;
@@ -24,8 +25,8 @@ use std::path::PathBuf;
#[derive(Parser)]
#[command(version, about)]
struct Cli {
/// .env file to load (default: ./.env when present). Real environment
/// variables take precedence.
/// .env file to load (default: ./.env when present), read like python-dotenv;
/// variables already in the environment take precedence.
#[arg(long, global = true)]
env_file: Option<PathBuf>,
#[command(subcommand)]
@@ -48,15 +49,23 @@ enum Command {
CheckDb,
}
#[tokio::main]
async fn main() -> Result<()> {
fn main() -> Result<()> {
let cli = Cli::parse();
// .env before the runtime starts: setting variables is only sound while
// the process is single-threaded.
match &cli.env_file {
Some(path) => {
dotenvy::from_path(path)?;
envfile::load(path).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
}
None => {
dotenvy::dotenv().ok();
// Under systemd the variables come from EnvironmentFile=; an
// unreadable ./.env is not fatal there.
let path = std::path::Path::new(".env");
if path.exists()
&& let Err(e) = envfile::load(path)
{
eprintln!("warning: .env not loaded: {e}");
}
}
}
let level = std::env::var("RUST_LOG")
@@ -68,7 +77,13 @@ async fn main() -> Result<()> {
)))
.with_target(false)
.init();
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(run(cli))
}
async fn run(cli: Cli) -> Result<()> {
let cfg = config::Config::from_env()?;
tracing::info!(
"ping-collector {} -> {}",
File diff suppressed because it is too large. Load diff
+122
View File
@@ -0,0 +1,122 @@
"""Generate rust/collector/testdata/envfile_python_dotenv.json: python-dotenv's
own parse + interpolation of tricky .env inputs, used by the Rust envfile
tests so the collector reads .env exactly as the Python app does.
uv run python rust/collector/testdata/gen_envfile_cases.py
"""
import io
import json
import os
import random
from pathlib import Path
import dotenv
from dotenv.main import DotEnv
FIXED = [
"A=abc'def\nB=x\"y\nC=1\n",
"A=1\nPOSTGRES_PASSWORD='abc\nB=2\n",
"A=1\nPOSTGRES_PASSWORD= \nB=2\n",
"POSTGRES_PASSWORD=\"ab'c\nB=2\n",
" export A = spaced value # comment\nB=a#b\n# c\n\nC= \nD\n",
"A='x\\'y'\nB=\"l1\\nl2\\t\\\"q\\\"\"\nC='multi\nline'\nD='a\\\\b'\n",
"A='ab\\'",
"A='x'y\nB=1\n",
"HOME_FROM_ENV=file\nA=${HOME_FROM_ENV}/x\nB=${A}-${MISSING:-dflt}-${NOPE}\nC=$A ${A:x}\n",
"A=1\r\nB=2\r\nA=3\r\n",
"C= # c\nD=#x\nE= #x\n",
"A#B=1\nexport\nexportA=2\n'q k'=3\n",
"\ufeffA=bom\n",
"A=\u00e9\u0e01\u0e02 # thai\n",
]
KEYS = [
"A",
"B",
"C",
"D",
"E",
"export A",
"'A B'",
"HOME_FROM_ENV",
"#A",
" A",
"A B",
]
SEPS = ["=", " = ", "= ", "", "\t=\t", "=="]
VALUE_BITS = [
"abc",
"'",
'"',
"\\",
"#",
" #c",
"${A}",
"${B:-d}",
"${",
"}",
" ",
"\t",
"'q'",
'"q"',
"\\'",
'\\"',
"\\n",
"x y",
"${HOME_FROM_ENV}",
"${:-z}",
"$A",
"\u0e01",
]
EOLS = ["\n", "\r\n", "\r", ""]
def random_case(rng: random.Random) -> str:
lines = []
for _ in range(rng.randint(1, 5)):
kind = rng.random()
if kind < 0.1:
lines.append(rng.choice(["", " ", "# comment", " # c ' \""]))
else:
value = "".join(rng.choice(VALUE_BITS) for _ in range(rng.randint(0, 4)))
lines.append(rng.choice(KEYS) + rng.choice(SEPS) + value)
lines.append(rng.choice(EOLS))
return "".join(lines)
def expected(content: str):
values = DotEnv(
None, stream=io.StringIO(content), interpolate=True, override=False
).dict()
return [[k, v] for k, v in values.items()]
def main():
os.environ["HOME_FROM_ENV"] = "/home/x"
for k in ("A", "B", "C", "D", "E", "MISSING", "NOPE"):
os.environ.pop(k, None)
rng = random.Random(20261018)
inputs = FIXED + [random_case(rng) for _ in range(600)]
cases = [{"input": c, "expected": expected(c)} for c in inputs]
out = Path(__file__).with_name("envfile_python_dotenv.json")
out.write_text(
json.dumps(
{
"python_dotenv": dotenv.__dict__.get("__version__", "1.1.1"),
"env": {"HOME_FROM_ENV": "/home/x"},
"cases": cases,
},
ensure_ascii=True,
indent=0,
),
encoding="utf-8",
)
print(f"{len(cases)} cases -> {out}")
if __name__ == "__main__":
import logging
logging.disable(logging.WARNING) # python-dotenv warns on every bad line
main()
+11 -1
View File
@@ -17,6 +17,7 @@
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/thailand-water-monitor}"
SERVICE_USER="${SERVICE_USER:-water-monitor}" # as in scripts/ping-collector.service
ENV_FILE="${APP_DIR}/.env"
STATUS_FILE="${APP_DIR}/collector-status.json"
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -59,10 +60,19 @@ systemctl daemon-reload
# the app dir: the units use PrivateTmp).
set_env SCRAPING_STATUS_FILE "$STATUS_FILE"
touch "$STATUS_FILE"
chown "$(stat -c %U:%G "$APP_DIR")" "$STATUS_FILE"
chown "${SERVICE_USER}:$(id -gn "$SERVICE_USER")" "$STATUS_FILE"
log "Dry run against the configured database (writes nothing)"
# Run it the way the service will: same user, working directory and
# EnvironmentFile parsing (systemd's, not a .env library's).
if systemd-run --version >/dev/null 2>&1; then
systemd-run --quiet --pipe --wait --collect \
-p User="$SERVICE_USER" -p Group="$(id -gn "$SERVICE_USER")" \
-p WorkingDirectory="$APP_DIR" -p EnvironmentFile="$ENV_FILE" \
"${APP_DIR}/bin/ping-collector" once --dry-run
else
(cd "$APP_DIR" && "${APP_DIR}/bin/ping-collector" --env-file "$ENV_FILE" once --dry-run)
fi
if [ "$MODE" = "--cutover" ]; then
log "Cutover: ping-collector collects, the Python app forecasts and notifies"