1use agent_first_data::document::{DocumentFile, Format, Value};
2use std::path::PathBuf;
3
4const MAX_CONFIG_BYTES: u64 = 16 * 1024 * 1024;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct SecretConfigRef {
8 pub file: PathBuf,
9 pub path: String,
10}
11
12impl SecretConfigRef {
13 pub fn from_values(flag: &str, values: Option<Vec<String>>) -> Result<Option<Self>, String> {
14 let Some(values) = values else {
15 return Ok(None);
16 };
17 let [file, path]: [String; 2] = values
18 .try_into()
19 .map_err(|_| format!("{flag} requires exactly two values: <FILE> <DOT_PATH>"))?;
20 if path.is_empty() {
21 return Err(format!("{flag} requires a non-empty DOT_PATH"));
22 }
23 Ok(Some(Self {
24 file: PathBuf::from(file),
25 path,
26 }))
27 }
28
29 pub fn safe_metadata(&self) -> serde_json::Value {
30 serde_json::json!({
31 "kind": "config",
32 "config_file_path": self.file,
33 "dot_path": self.path,
34 })
35 }
36}
37
38pub fn resolve_config_secret(flag: &str, reference: &SecretConfigRef) -> Result<String, String> {
39 let format = Format::detect(&reference.file).ok_or_else(|| {
40 format!(
41 "{flag} cannot detect config format for {}",
42 reference.file.display()
43 )
44 })?;
45 let doc = DocumentFile::open_capped(&reference.file, Some(format), MAX_CONFIG_BYTES).map_err(
50 |error| {
51 format!(
52 "{flag} cannot read {} config {}: {}",
53 format.name(),
54 reference.file.display(),
55 error.redacted_message()
56 )
57 },
58 )?;
59 let resolved = doc.value_at(&reference.path).map_err(|error| {
60 if error.code() == "document_path_not_found" {
61 format!(
62 "{flag} path {} was not found in {}",
63 reference.path,
64 reference.file.display()
65 )
66 } else {
67 format!(
68 "{flag} cannot resolve path {} in {}",
69 reference.path,
70 reference.file.display()
71 )
72 }
73 })?;
74 match resolved {
75 Value::String(secret) if secret.is_empty() => Err(format!(
76 "{flag} resolved an empty string from {} at path {}",
77 reference.file.display(),
78 reference.path
79 )),
80 Value::String(secret) => Ok(secret),
81 other => Err(format!(
82 "{flag} requires a string at {} in {}; found {}",
83 reference.path,
84 reference.file.display(),
85 other.kind_name()
86 )),
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 fn temp_config(name: &str, extension: &str, content: &str) -> PathBuf {
95 let path = std::env::temp_dir().join(format!(
96 "afpsql-secret-config-{name}-{}.{extension}",
97 std::process::id()
98 ));
99 std::fs::write(&path, content).expect("write test config");
100 path
101 }
102
103 fn resolve(path: PathBuf, dot_path: &str) -> Result<String, String> {
104 let result = resolve_config_secret(
105 "--dsn-secret-config",
106 &SecretConfigRef {
107 file: path.clone(),
108 path: dot_path.to_string(),
109 },
110 );
111 std::fs::remove_file(path).expect("remove test config");
112 result
113 }
114
115 #[test]
116 fn resolves_all_supported_formats_without_trimming() {
117 for (name, extension, content, dot_path, expected) in [
118 (
119 "json",
120 "json",
121 r#"{"database":{"url":" postgresql://json "}}"#,
122 "database.url",
123 " postgresql://json ",
124 ),
125 (
126 "toml",
127 "toml",
128 "[database]\nurl = 'postgresql://toml'\n",
129 "database.url",
130 "postgresql://toml",
131 ),
132 (
133 "yaml",
134 "yaml",
135 "database:\n url: postgresql://yaml\n",
136 "database.url",
137 "postgresql://yaml",
138 ),
139 (
140 "dotenv",
141 "env",
142 "DATABASE_URL=postgresql://dotenv\n",
143 "DATABASE_URL",
144 "postgresql://dotenv",
145 ),
146 ] {
147 let path = temp_config(name, extension, content);
148 assert_eq!(resolve(path, dot_path), Ok(expected.to_string()));
149 }
150 }
151
152 #[test]
153 fn resolves_secret_named_and_percent_encoded_urls_verbatim() {
154 let dsn = "postgresql://user:p%40ss%3Aw0rd@host.example:5432/mydb?sslmode=require&application_name=af";
161 let json = format!(r#"{{"database":{{"url_secret":"{dsn}"}}}}"#);
162 let path = temp_config("json-url", "json", &json);
163 assert_eq!(resolve(path, "database.url_secret"), Ok(dsn.to_string()));
164
165 let env_dsn = "postgresql://user:se$cret@host/db?sslmode=require";
168 let env = format!("DATABASE_URL={env_dsn}\n");
169 let path = temp_config("dotenv-url", "env", &env);
170 assert_eq!(resolve(path, "DATABASE_URL"), Ok(env_dsn.to_string()));
171 }
172
173 #[test]
174 fn rejects_missing_non_string_empty_malformed_and_unknown_sources_safely() {
175 for (name, content, dot_path, expected) in [
176 ("missing", r#"{"database":{}}"#, "database.url", "not found"),
177 ("object", r#"{"value":{}}"#, "value", "found object"),
178 ("array", r#"{"value":[]}"#, "value", "found array"),
179 ("bool", r#"{"value":true}"#, "value", "found boolean"),
180 ("integer", r#"{"value":5432}"#, "value", "found integer"),
181 ("null", r#"{"value":null}"#, "value", "found null"),
182 ("empty", r#"{"value":""}"#, "value", "empty string"),
183 ] {
184 let path = temp_config(name, "json", content);
185 let error = resolve(path, dot_path).expect_err("source should fail");
186 assert!(error.contains(expected), "{name}: {error}");
187 assert!(
188 !error.contains(content),
189 "source leaked for {name}: {error}"
190 );
191 }
192
193 let canary = "AFPSQL_PARSE_CANARY_SECRET";
194 let path = temp_config("malformed", "yaml", &format!("secret: [ {canary}"));
195 let error = resolve(path, "secret").expect_err("malformed source should fail");
196 assert!(
197 !error.contains(canary),
198 "parse error leaked source: {error}"
199 );
200
201 let path = temp_config("unknown", "txt", "SECRET=canary");
202 let error = resolve(path, "SECRET").expect_err("unknown format should fail");
203 assert!(error.contains("cannot detect config format"));
204 assert!(!error.contains("canary"));
205 }
206}