use std::collections::BTreeMap;
use dora_message::descriptor::{Descriptor, EnvValue};
use eyre::{Context, Result, bail};
pub fn parse_env_overrides(flags: &[String]) -> Result<BTreeMap<String, EnvValue>> {
let mut overrides = BTreeMap::new();
for flag in flags {
let Some((key, value)) = flag.split_once('=') else {
bail!("invalid --env `{flag}`: expected KEY=VALUE");
};
if key.is_empty() {
bail!("invalid --env `{flag}`: empty key");
}
ensure_wire_safe(key, value)?;
overrides.insert(key.to_string(), EnvValue::String(value.to_string()));
}
Ok(overrides)
}
fn ensure_wire_safe(key: &str, value: &str) -> Result<()> {
if value.contains('$') {
bail!(
"--env `{key}={value}` contains `$`, which cannot be carried in a \
dataflow descriptor: the process that receives it (coordinator, \
then daemon) expands the value against ITS OWN environment, so \
`$VAR` would resolve to that host's variable rather than \
yours.\n\n \
hint: expand it in your shell first (`--env {key}=\"$VAR\"` \
without quotes around the `$`), or set this one in the node's \
`env:` block in the dataflow YAML."
);
}
let encoded = serde_json::to_string(&EnvValue::String(value.to_string()))
.with_context(|| format!("failed to encode --env `{key}`"))?;
match serde_json::from_str::<EnvValue>(&encoded) {
Ok(decoded) if decoded.to_string() == value => Ok(()),
Ok(decoded) => bail!(
"--env `{key}={value}` would not survive the dataflow descriptor's \
encoding: nodes would receive `{decoded}` instead.\n\n \
hint: numeric-looking values are coerced (`1.10` -> `1.1`, \
`01234` -> `1234`). Set this one in the node's `env:` block in \
the dataflow YAML instead."
),
Err(err) => bail!(
"--env `{key}={value}` cannot be represented in the dataflow \
descriptor: {err}.\n\n \
hint: set this one in the node's `env:` block in the dataflow \
YAML instead."
),
}
}
pub fn apply_env_overrides(descriptor: &mut Descriptor, overrides: BTreeMap<String, EnvValue>) {
if overrides.is_empty() {
return;
}
descriptor
.env
.get_or_insert_with(Default::default)
.extend(overrides);
}
#[cfg(test)]
mod tests {
use super::*;
use dora_core::descriptor::DescriptorExt;
fn flags(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn parses_key_value_pairs() {
let parsed = parse_env_overrides(&flags(&["FOO=bar", "SEED=42"])).unwrap();
assert_eq!(parsed["FOO"], EnvValue::String("bar".into()));
assert_eq!(parsed["SEED"], EnvValue::String("42".into()));
}
#[test]
fn value_may_contain_equals() {
let parsed = parse_env_overrides(&flags(&["OPTS=--level=debug"])).unwrap();
assert_eq!(parsed["OPTS"], EnvValue::String("--level=debug".into()));
}
#[test]
fn empty_value_is_allowed() {
let parsed = parse_env_overrides(&flags(&["FOO="])).unwrap();
assert_eq!(parsed["FOO"], EnvValue::String(String::new()));
}
#[test]
fn missing_equals_is_rejected() {
let result = parse_env_overrides(&flags(&["JUSTAKEY"]));
assert!(result.is_err(), "expected Err, got {result:?}");
}
#[test]
fn empty_key_is_rejected() {
let result = parse_env_overrides(&flags(&["=value"]));
assert!(result.is_err(), "expected Err, got {result:?}");
}
#[test]
fn repeated_key_keeps_the_last_occurrence() {
let parsed = parse_env_overrides(&flags(&["FOO=first", "FOO=second"])).unwrap();
assert_eq!(parsed["FOO"], EnvValue::String("second".into()));
}
#[test]
fn rejects_values_that_would_be_env_expanded_on_the_wire() {
for value in [
"LEAK=${DORA_TEST_2919_UNSET}", "LEAK=${PATH}", "LEAK=$PATH", "LEAK=prefix-${PATH}-suffix", ] {
let result = parse_env_overrides(&flags(&[value]));
let err = result.expect_err("a wire-expandable value must be rejected");
assert!(
err.to_string().contains("LEAK"),
"the error must name the offending key: {err}"
);
}
}
#[test]
fn self_referential_local_variable_cannot_smuggle_a_dollar_through() {
let exe = std::env::current_exe().expect("test binary path");
let output = std::process::Command::new(exe)
.args(["--exact", "env_overrides::tests::self_referential_child"])
.env("DORA_TEST_2919_SELFREF", "${DORA_TEST_2919_SELFREF}")
.env("RUST_TEST_NOCAPTURE", "0")
.output()
.expect("failed to re-run test binary");
assert!(
output.status.success(),
"the self-referential value was NOT rejected — a `$` reached the \
wire.\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
#[test]
fn self_referential_child() {
let Ok(value) = std::env::var("DORA_TEST_2919_SELFREF") else {
return; };
assert_eq!(value, "${DORA_TEST_2919_SELFREF}", "child env not set up");
let result = parse_env_overrides(&flags(&["LEAK=${DORA_TEST_2919_SELFREF}"]));
assert!(
result.is_err(),
"a self-referential expansion round-trips to itself, so it must be \
caught syntactically rather than by comparison, got {result:?}"
);
}
#[test]
fn rejects_values_containing_a_literal_dollar() {
let result = parse_env_overrides(&flags(&["PW=p$ssw0rd"]));
assert!(result.is_err(), "expected Err, got {result:?}");
}
#[test]
fn rejects_values_the_wire_encoding_would_coerce() {
for value in ["VERSION=1.10", "ZIP=01234", "SCI=1e5"] {
let result = parse_env_overrides(&flags(&[value]));
assert!(
result.is_err(),
"`{value}` is silently coerced on the wire and must be rejected, got {result:?}"
);
}
}
#[test]
fn accepts_values_that_survive_the_wire_encoding() {
let parsed = parse_env_overrides(&flags(&[
"PLAIN=hello world",
"PATHISH=/usr/local/bin:/usr/bin",
"UNICODE=café ☕",
"DASHED=--level=debug",
"TRUEISH=truthy",
"EMPTY=",
]))
.expect("these values are wire-safe");
assert_eq!(parsed["PLAIN"], EnvValue::String("hello world".into()));
assert_eq!(parsed["UNICODE"], EnvValue::String("café ☕".into()));
assert_eq!(parsed["EMPTY"], EnvValue::String(String::new()));
}
#[test]
fn no_overrides_leaves_descriptor_untouched() {
let mut descriptor: Descriptor = Descriptor::parse(b"nodes: []".to_vec()).unwrap();
assert!(descriptor.env.is_none());
apply_env_overrides(&mut descriptor, BTreeMap::new());
assert!(
descriptor.env.is_none(),
"a no-op --env must not materialize an empty env block"
);
}
#[test]
fn overrides_win_over_dataflow_level_env() {
let yaml = b"
env:
FROM_YAML: yaml
SHARED: yaml
nodes: []
"
.to_vec();
let mut descriptor: Descriptor = Descriptor::parse(yaml).unwrap();
let overrides = parse_env_overrides(&flags(&["SHARED=cli", "CLI_ONLY=cli"])).unwrap();
apply_env_overrides(&mut descriptor, overrides);
let env = descriptor.env.as_ref().unwrap();
assert_eq!(env["SHARED"], EnvValue::String("cli".into()));
assert_eq!(env["CLI_ONLY"], EnvValue::String("cli".into()));
assert_eq!(
env["FROM_YAML"],
EnvValue::String("yaml".into()),
"non-conflicting yaml keys must survive"
);
}
#[test]
fn applying_overrides_changes_the_descriptor_fingerprint() {
let mut descriptor: Descriptor = Descriptor::parse(b"nodes: []".to_vec()).unwrap();
let before = serde_yaml::to_string(&descriptor).unwrap();
apply_env_overrides(
&mut descriptor,
parse_env_overrides(&flags(&["FOO=bar"])).unwrap(),
);
let after = serde_yaml::to_string(&descriptor).unwrap();
assert_ne!(
before, after,
"if --env did not alter the descriptor, the merge-placement \
constraint in `dora start` would be vacuous"
);
}
#[test]
fn node_env_still_wins_after_overrides() {
let yaml = b"
env:
VAR: yaml
nodes:
- id: probe
path: probe.bin
env:
VAR: node
outputs:
- value
"
.to_vec();
let mut descriptor: Descriptor = Descriptor::parse(yaml).unwrap();
let overrides = parse_env_overrides(&flags(&["VAR=cli"])).unwrap();
apply_env_overrides(&mut descriptor, overrides);
let resolved = descriptor.resolve_aliases_and_set_defaults().unwrap();
let node = &resolved[&"probe".to_string().into()];
assert_eq!(
node.env.as_ref().unwrap()["VAR"],
EnvValue::String("node".into()),
"node-level env must win over --env"
);
}
}