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