Skip to content

Commit 600d540

Browse files
committed
fix: preserve value integrity with writing env file from secrets
1 parent e9c5b48 commit 600d540

File tree

1 file changed

+95
-49
lines changed

1 file changed

+95
-49
lines changed

crates/tracel-xtask/src/commands/secrets.rs

Lines changed: 95 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ pub fn env_file(args: SecretsEnvFileSubCmdArgs) -> anyhow::Result<()> {
320320
}
321321

322322
// Expand variable inside values
323-
let merged = expand_env_map(&merged)?;
323+
let merged = expand_env_map(&merged);
324324

325325
// Sort the env vars for deterministic ordering
326326
let mut entries: Vec<(String, String)> = merged.into_iter().collect();
@@ -593,29 +593,50 @@ fn confirm_push() -> anyhow::Result<bool> {
593593
Ok(answer == "y" || answer == "yes")
594594
}
595595

596-
/// Expand ${VAR} placeholders in a merged env map using dotenvy
597-
fn expand_env_map(merged: &HashMap<String, String>) -> anyhow::Result<HashMap<String, String>> {
598-
// Seed process env so dotenvy can expand ${VAR} from our map
599-
for (key, value) in merged {
600-
std::env::set_var(key, value);
601-
}
602-
// build a buffer buffer from the merged map
603-
let mut keys: Vec<_> = merged.keys().cloned().collect();
604-
keys.sort();
605-
let mut buf = String::new();
606-
for key in &keys {
607-
let val = &merged[key];
608-
writeln!(&mut buf, "{key}={val}")?;
609-
}
610-
// Parse with dotenvy with expansion enabled
611-
let iter = dotenvy::from_read_iter(buf.as_bytes());
612-
let mut expanded: HashMap<String, String> = HashMap::new();
613-
for item in iter {
614-
let (key, value) = item?;
615-
expanded.insert(key, value);
596+
/// Expand `${VAR}` placeholders in values using the given map.
597+
fn expand_value(input: &str, vars: &HashMap<String, String>) -> String {
598+
let mut out = String::new();
599+
let mut rest = input;
600+
601+
while let Some(start) = rest.find("${") {
602+
// keep everything before the placeholder
603+
out.push_str(&rest[..start]);
604+
let after = &rest[start + 2..];
605+
// find the closing brace
606+
if let Some(end_rel) = after.find('}') {
607+
let var_name = &after[..end_rel];
608+
if let Some(val) = vars.get(var_name) {
609+
// known var: substitute
610+
out.push_str(val);
611+
} else {
612+
// unknown var: keep the placeholder as-is
613+
out.push_str("${");
614+
out.push_str(var_name);
615+
out.push('}');
616+
}
617+
// continue after the closing brace
618+
rest = &after[end_rel + 1..];
619+
} else {
620+
// no closing brace, keep the rest as-is
621+
out.push_str(&rest[start..]);
622+
rest = "";
623+
break;
624+
}
616625
}
626+
// trailing part without placeholders
627+
out.push_str(rest);
628+
out
629+
}
617630

618-
Ok(expanded)
631+
/// Expand `${VAR}` placeholders in a merged env map.
632+
/// Note: this is a single pass expansion, preserving quotes and formatting.
633+
fn expand_env_map(merged: &HashMap<String, String>) -> HashMap<String, String> {
634+
let mut expanded = HashMap::new();
635+
for (key, value) in merged {
636+
let new_val = expand_value(value, merged);
637+
expanded.insert(key.clone(), new_val);
638+
}
639+
expanded
619640
}
620641

621642
#[cfg(test)]
@@ -627,7 +648,7 @@ mod tests {
627648
#[test]
628649
#[serial]
629650
fn test_expand_env_map_simple_expansion() {
630-
// Clean any prior values
651+
// Clean any prior values that might confuse debugging
631652
env::remove_var("LOG_LEVEL_TEST");
632653
env::remove_var("RUST_LOG_TEST");
633654

@@ -638,8 +659,7 @@ mod tests {
638659
"xtask=${LOG_LEVEL_TEST},server=${LOG_LEVEL_TEST}".to_string(),
639660
);
640661

641-
let expanded =
642-
expand_env_map(&merged).expect("expand_env_map should succeed for simple map");
662+
let expanded = expand_env_map(&merged);
643663

644664
let log_level = expanded
645665
.get("LOG_LEVEL_TEST")
@@ -648,19 +668,14 @@ mod tests {
648668
.get("RUST_LOG_TEST")
649669
.expect("RUST_LOG_TEST should be present after expansion");
650670

651-
// 1) LOG_LEVEL_TEST should be unchanged
652671
assert_eq!(
653672
log_level, "info",
654673
"LOG_LEVEL_TEST should keep its literal value after expansion"
655674
);
656-
657-
// 2) RUST_LOG_TEST should not contain the raw placeholder anymore
658675
assert!(
659676
!rust_log.contains("${LOG_LEVEL_TEST}"),
660677
"RUST_LOG_TEST should not contain the raw placeholder '${{LOG_LEVEL_TEST}}', got: {rust_log}"
661678
);
662-
663-
// 3) RUST_LOG_TEST should contain the expanded value
664679
assert!(
665680
rust_log.contains(log_level),
666681
"RUST_LOG_TEST should contain the expanded LOG_LEVEL_TEST value; LOG_LEVEL_TEST={log_level}, RUST_LOG_TEST={rust_log}"
@@ -682,8 +697,7 @@ mod tests {
682697
);
683698
merged.insert("PLAIN_KEY_TEST".to_string(), "no_placeholders".to_string());
684699

685-
let expanded =
686-
expand_env_map(&merged).expect("expand_env_map should succeed for mixed map");
700+
let expanded = expand_env_map(&merged);
687701

688702
let log_level = expanded
689703
.get("LOG_LEVEL_TEST")
@@ -695,13 +709,7 @@ mod tests {
695709
.get("PLAIN_KEY_TEST")
696710
.expect("PLAIN_KEY_TEST should be present after expansion");
697711

698-
// LOG_LEVEL_TEST should be unchanged
699-
assert_eq!(
700-
log_level, "debug",
701-
"LOG_LEVEL_TEST should keep its literal value after expansion"
702-
);
703-
704-
// RUST_LOG_TEST should be expanded and not contain placeholder
712+
assert_eq!(log_level, "debug");
705713
assert!(
706714
!rust_log.contains("${LOG_LEVEL_TEST}"),
707715
"RUST_LOG_TEST should not contain the raw placeholder '${{LOG_LEVEL_TEST}}', got: {rust_log}"
@@ -710,8 +718,6 @@ mod tests {
710718
rust_log.contains(log_level),
711719
"RUST_LOG_TEST should contain the expanded LOG_LEVEL_TEST value; LOG_LEVEL_TEST={log_level}, RUST_LOG_TEST={rust_log}"
712720
);
713-
714-
// PLAIN_KEY_TEST should be unchanged
715721
assert_eq!(
716722
plain, "no_placeholders",
717723
"PLAIN_KEY_TEST should remain unchanged when there are no placeholders"
@@ -730,20 +736,60 @@ mod tests {
730736
"value=${UNKNOWN_PLACEHOLDER_TEST}".to_string(),
731737
);
732738

733-
let expanded = expand_env_map(&merged)
734-
.expect("expand_env_map should succeed with unknown placeholder");
739+
let expanded = expand_env_map(&merged);
735740

736741
let uses_unknown = expanded
737742
.get("USES_UNKNOWN_TEST")
738743
.expect("USES_UNKNOWN_TEST should be present after expansion");
739744

740-
// dotenvy expansion will simply leave unknown ${VAR} as-is
741-
// this assertion documents the current behaviour we rely on:
742-
// we do not require unknown vars to expand.
745+
// Unknown placeholders should be preserved exactly
746+
assert_eq!(
747+
uses_unknown, "value=${UNKNOWN_PLACEHOLDER_TEST}",
748+
"Unknown placeholder should be left intact"
749+
);
750+
}
751+
752+
#[test]
753+
#[serial]
754+
fn test_expand_env_map_preserves_quotes_around_values() {
755+
env::remove_var("LOG_LEVEL_TEST");
756+
env::remove_var("RUST_LOG_QUOTED_TEST");
757+
env::remove_var("CRON_TEST");
758+
759+
let mut merged: HashMap<String, String> = HashMap::new();
760+
merged.insert("LOG_LEVEL_TEST".to_string(), "info".to_string());
761+
// placeholder inside double quotes
762+
merged.insert(
763+
"RUST_LOG_QUOTED_TEST".to_string(),
764+
" \"xtask=${LOG_LEVEL_TEST},server=${LOG_LEVEL_TEST}\" ".to_string(),
765+
);
766+
// value that contains spaces and is already quoted
767+
merged.insert("CRON_TEST".to_string(), "'0 0 0 * * *'".to_string());
768+
769+
let expanded = expand_env_map(&merged);
770+
771+
let rust_log = expanded
772+
.get("RUST_LOG_QUOTED_TEST")
773+
.expect("RUST_LOG_QUOTED_TEST should be present after expansion");
774+
let cron = expanded
775+
.get("CRON_TEST")
776+
.expect("CRON_TEST should be present after expansion");
777+
778+
// RUST_LOG_QUOTED_TEST should still start and end with a double quote (after trimming)
779+
let rust_trimmed = rust_log.trim();
743780
assert!(
744-
uses_unknown.contains("${UNKNOWN_PLACEHOLDER_TEST}")
745-
|| !uses_unknown.contains("UNKNOWN_PLACEHOLDER_TEST"),
746-
"USES_UNKNOWN_TEST should not crash expansion; got: {uses_unknown}"
781+
rust_trimmed.starts_with('"') && rust_trimmed.ends_with('"'),
782+
"RUST_LOG_QUOTED_TEST should still be double-quoted, got: {rust_log}"
783+
);
784+
assert!(
785+
rust_trimmed.contains("xtask=info"),
786+
"RUST_LOG_QUOTED_TEST should contain the expanded value; got: {rust_trimmed}"
787+
);
788+
789+
// CRON_TEST should be unchanged, quotes preserved
790+
assert_eq!(
791+
cron, "'0 0 0 * * *'",
792+
"CRON_TEST should keep its single quotes and content"
747793
);
748794
}
749795
}

0 commit comments

Comments
 (0)