1pub fn read_token_file(path: &str) -> Result<String, String> {
27 let raw = std::fs::read_to_string(path)
28 .map_err(|e| format!("cannot read secret file {path}: {e}"))?;
29 Ok(trim_token(&raw).to_string())
30}
31
32fn trim_token(s: &str) -> &str {
35 s.strip_suffix('\n')
36 .map(|t| t.strip_suffix('\r').unwrap_or(t))
37 .unwrap_or(s)
38}
39
40pub fn resolve(template: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<String, String> {
50 if !template.contains("{{") {
51 return Ok(template.to_string());
52 }
53 let mut out = String::with_capacity(template.len());
54 let mut rest = template;
55 while let Some(open) = rest.find("{{") {
56 out.push_str(&rest[..open]);
57 let after = &rest[open + 2..];
58 let close = after
59 .find("}}")
60 .ok_or_else(|| "unterminated secret ref '{{' (want '{{secret:NAME}}')".to_string())?;
61 let token = after[..close].trim();
62 out.push_str(&resolve_one(token, env)?);
63 rest = &after[close + 2..];
64 }
65 out.push_str(rest);
66 Ok(out)
67}
68
69fn resolve_one(token: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<String, String> {
74 if let Some(path) = token.strip_prefix("secret-file:") {
75 let path = path.trim();
76 if path.is_empty() {
77 return Err("empty {{secret-file:}} path".to_string());
78 }
79 read_token_file(path)
80 } else if let Some(name) = token.strip_prefix("secret:") {
81 let name = name.trim();
82 if name.is_empty() {
83 return Err("empty {{secret:}} name".to_string());
84 }
85 env(name).ok_or_else(|| format!("{{{{secret:{name}}}}} is not set in the environment"))
86 } else {
87 Err(format!(
88 "unknown interpolation token '{{{{{token}}}}}' (want {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}})"
89 ))
90 }
91}
92
93pub fn has_secret_ref(value: &str) -> bool {
97 value.contains("{{secret:") || value.contains("{{secret-file:")
98}
99
100pub fn refs_resolvable(template: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<(), String> {
107 resolve(template, env).map(|_| ())
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use std::io::Write;
114
115 fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
116 move |k: &str| {
117 pairs
118 .iter()
119 .find(|(n, _)| *n == k)
120 .map(|(_, v)| (*v).to_string())
121 }
122 }
123
124 #[test]
125 fn trims_one_trailing_newline_only() {
126 assert_eq!(trim_token("tok\n"), "tok");
127 assert_eq!(trim_token("tok\r\n"), "tok");
128 assert_eq!(trim_token("tok"), "tok");
129 assert_eq!(trim_token("a b\n\n"), "a b\n");
131 assert_eq!(trim_token(" tok \n"), " tok ");
133 }
134
135 #[test]
136 fn read_token_file_reads_and_trims() {
137 let mut f = tempfile::NamedTempFile::new().unwrap();
138 writeln!(f, "super-secret").unwrap();
139 let v = read_token_file(f.path().to_str().unwrap()).unwrap();
140 assert_eq!(v, "super-secret");
141 }
142
143 #[test]
144 fn read_token_file_missing_is_error_without_contents() {
145 let e = read_token_file("/no/such/secret/file").unwrap_err();
146 assert!(e.contains("cannot read secret file"));
147 }
148
149 #[test]
150 fn resolve_passthrough_and_env_ref() {
151 let env = env_of(&[("ANTHROPIC_API_KEY", "k-123")]);
152 assert_eq!(resolve("plain text", &env).unwrap(), "plain text");
153 assert_eq!(
154 resolve("x-api-key: {{secret:ANTHROPIC_API_KEY}}", &env).unwrap(),
155 "x-api-key: k-123"
156 );
157 assert_eq!(
159 resolve("Bearer {{secret:ANTHROPIC_API_KEY}}!", &env).unwrap(),
160 "Bearer k-123!"
161 );
162 }
163
164 #[test]
165 fn resolve_file_ref_reads_fresh_and_trims() {
166 let mut f = tempfile::NamedTempFile::new().unwrap();
167 writeln!(f, "file-tok").unwrap();
168 let path = f.path().to_str().unwrap();
169 let env = env_of(&[]);
170 let tmpl = format!("Bearer {{{{secret-file:{path}}}}}");
171 assert_eq!(resolve(&tmpl, &env).unwrap(), "Bearer file-tok");
172 }
173
174 #[test]
175 fn resolve_missing_env_is_error_and_does_not_leak_value() {
176 let env = env_of(&[]);
177 let e = resolve("{{secret:NOPE}}", &env).unwrap_err();
178 assert!(e.contains("NOPE"));
179 assert!(e.contains("not set"));
180 }
181
182 #[test]
183 fn resolve_unknown_token_and_unterminated_are_errors() {
184 let env = env_of(&[]);
185 assert!(resolve("{{bogus:x}}", &env).is_err());
186 assert!(resolve("{{secret:", &env).is_err());
187 assert!(resolve("{{secret:}}", &env).is_err());
188 assert!(resolve("{{secret-file:}}", &env).is_err());
189 }
190
191 #[test]
192 fn has_secret_ref_detects_both_kinds() {
193 assert!(has_secret_ref("{{secret:X}}"));
194 assert!(has_secret_ref("Bearer {{secret-file:/p}}"));
195 assert!(!has_secret_ref("plain value"));
196 assert!(!has_secret_ref("2023-06-01"));
197 }
198}