Skip to main content

agent_first_psql/
secret_config.rs

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            "file": self.file,
33            "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    // afdata size-caps the read (rejecting an oversized or non-regular file
46    // before reading a byte) and, for a secret-bearing file, gives content-free
47    // errors: `redacted_message` drops any parser detail that could echo the
48    // source. `value_at` collapses read → parse → dot-path traverse into one call.
49    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        // A real DSN percent-encodes reserved characters in its userinfo
155        // (%40 = '@', %3A = ':') and joins query params with '&'/'='. afdata must
156        // return the string byte-for-byte: it must NOT percent-decode. The field
157        // also uses afdata's `_secret` suffix — the CLI would refuse to print it,
158        // but the document *library* value_at reads raw, which is exactly what
159        // afpsql relies on to obtain the connection string.
160        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        // dotenv, unquoted: no escape processing and no `$` variable expansion,
166        // so a literal '$' in the password survives untouched.
167        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}