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