Skip to main content

nils_common/provider_runtime/
persistence.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use thiserror::Error;
5
6use crate::fs as shared_fs;
7
8use super::auth;
9use super::paths;
10use super::profile::ProviderProfile;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum TimestampPolicy {
14    Strict,
15    BestEffort,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Default)]
19pub struct SyncSecretsResult {
20    pub auth_file_present: bool,
21    pub auth_identity_present: bool,
22    pub synced: usize,
23    pub skipped: usize,
24    pub updated_files: Vec<PathBuf>,
25}
26
27impl SyncSecretsResult {
28    fn auth_file_missing() -> Self {
29        Self {
30            auth_file_present: false,
31            auth_identity_present: false,
32            synced: 0,
33            skipped: 0,
34            updated_files: Vec::new(),
35        }
36    }
37
38    fn auth_identity_missing() -> Self {
39        Self {
40            auth_file_present: true,
41            auth_identity_present: false,
42            synced: 0,
43            skipped: 0,
44            updated_files: Vec::new(),
45        }
46    }
47}
48
49#[derive(Debug, Error)]
50pub enum SyncSecretsError {
51    #[error("failed to hash auth file {path}: {source}")]
52    HashAuthFile {
53        path: PathBuf,
54        #[source]
55        source: shared_fs::FileHashError,
56    },
57    #[error("failed to read auth file {path}: {source}")]
58    ReadAuthFile {
59        path: PathBuf,
60        #[source]
61        source: std::io::Error,
62    },
63    #[error("failed to hash secret file {path}: {source}")]
64    HashSecretFile {
65        path: PathBuf,
66        #[source]
67        source: shared_fs::FileHashError,
68    },
69    #[error("failed to write secret file {path}: {source}")]
70    WriteSecretFile {
71        path: PathBuf,
72        #[source]
73        source: shared_fs::AtomicWriteError,
74    },
75    #[error("failed to write timestamp file {path}: {source}")]
76    WriteTimestampFile {
77        path: PathBuf,
78        #[source]
79        source: shared_fs::TimestampError,
80    },
81}
82
83pub fn sync_auth_to_matching_secrets(
84    profile: &ProviderProfile,
85    auth_file: &Path,
86    secret_file_mode: u32,
87    timestamp_policy: TimestampPolicy,
88) -> Result<SyncSecretsResult, SyncSecretsError> {
89    if !auth_file.is_file() {
90        return Ok(SyncSecretsResult::auth_file_missing());
91    }
92
93    let auth_key = match auth::identity_key_from_auth_file(auth_file).ok().flatten() {
94        Some(value) => value,
95        None => return Ok(SyncSecretsResult::auth_identity_missing()),
96    };
97
98    let auth_last_refresh = auth::last_refresh_from_auth_file(auth_file).ok().flatten();
99    let auth_hash =
100        shared_fs::sha256_file(auth_file).map_err(|source| SyncSecretsError::HashAuthFile {
101            path: auth_file.to_path_buf(),
102            source,
103        })?;
104    let auth_contents = fs::read(auth_file).map_err(|source| SyncSecretsError::ReadAuthFile {
105        path: auth_file.to_path_buf(),
106        source,
107    })?;
108
109    let mut result = SyncSecretsResult {
110        auth_file_present: true,
111        auth_identity_present: true,
112        synced: 0,
113        skipped: 0,
114        updated_files: Vec::new(),
115    };
116
117    if let Some(secret_dir) = paths::resolve_secret_dir(profile)
118        && let Ok(entries) = fs::read_dir(secret_dir)
119    {
120        for entry in entries.flatten() {
121            let path = entry.path();
122            if path.extension().and_then(|value| value.to_str()) != Some("json") {
123                continue;
124            }
125
126            let candidate_key = match auth::identity_key_from_auth_file(&path).ok().flatten() {
127                Some(value) => value,
128                None => {
129                    result.skipped += 1;
130                    continue;
131                }
132            };
133            if candidate_key != auth_key {
134                result.skipped += 1;
135                continue;
136            }
137
138            let secret_hash = shared_fs::sha256_file(&path).map_err(|source| {
139                SyncSecretsError::HashSecretFile {
140                    path: path.clone(),
141                    source,
142                }
143            })?;
144            if secret_hash == auth_hash {
145                result.skipped += 1;
146                continue;
147            }
148
149            shared_fs::write_atomic(&path, &auth_contents, secret_file_mode).map_err(|source| {
150                SyncSecretsError::WriteSecretFile {
151                    path: path.clone(),
152                    source,
153                }
154            })?;
155            write_timestamp_for_target(
156                profile,
157                &path,
158                auth_last_refresh.as_deref(),
159                timestamp_policy,
160            )?;
161            result.synced += 1;
162            result.updated_files.push(path);
163        }
164    }
165
166    write_timestamp_for_target(
167        profile,
168        auth_file,
169        auth_last_refresh.as_deref(),
170        timestamp_policy,
171    )?;
172
173    Ok(result)
174}
175
176fn write_timestamp_for_target(
177    profile: &ProviderProfile,
178    target_file: &Path,
179    iso: Option<&str>,
180    timestamp_policy: TimestampPolicy,
181) -> Result<(), SyncSecretsError> {
182    let Some(timestamp_path) = paths::resolve_secret_timestamp_path(profile, target_file) else {
183        return Ok(());
184    };
185    match shared_fs::write_timestamp(&timestamp_path, iso) {
186        Ok(()) => Ok(()),
187        Err(source) => match timestamp_policy {
188            TimestampPolicy::Strict => Err(SyncSecretsError::WriteTimestampFile {
189                path: timestamp_path,
190                source,
191            }),
192            TimestampPolicy::BestEffort => Ok(()),
193        },
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::{TimestampPolicy, sync_auth_to_matching_secrets};
200    use crate::provider_runtime::{
201        ExecInvocation, ExecProfile, HomePathSelection, PathsProfile, ProviderDefaults,
202        ProviderEnvKeys, ProviderProfile,
203    };
204    use nils_test_support::{EnvGuard, GlobalStateLock};
205    use pretty_assertions::assert_eq;
206    use std::sync::atomic::AtomicBool;
207
208    const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
209    const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
210    const PAYLOAD_BETA: &str = "eyJzdWIiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSIsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSJ9fQ";
211    const SECRET_HOME: &[&str] = &[".config", "test-secrets"];
212    const AUTH_HOME: &[&str] = &[".config", "test-auth.json"];
213    static WARNED_INVALID_ALLOW_DANGEROUS: AtomicBool = AtomicBool::new(false);
214
215    static TEST_PROFILE: ProviderProfile = ProviderProfile {
216        provider_name: "test",
217        env: ProviderEnvKeys {
218            model: "TEST_MODEL",
219            reasoning: "TEST_REASONING",
220            allow_dangerous_enabled: "TEST_ALLOW_DANGEROUS",
221            secret_dir: "TEST_SECRET_DIR",
222            auth_file: "TEST_AUTH_FILE",
223            secret_cache_dir: "TEST_SECRET_CACHE_DIR",
224            prompt_segment_enabled: "TEST_PROMPT_SEGMENT_ENABLED",
225            auto_refresh_enabled: "TEST_AUTO_REFRESH_ENABLED",
226            auto_refresh_min_days: "TEST_AUTO_REFRESH_MIN_DAYS",
227        },
228        defaults: ProviderDefaults {
229            model: "test-model",
230            reasoning: "medium",
231            prompt_segment_enabled: "false",
232            auto_refresh_enabled: "false",
233            auto_refresh_min_days: "5",
234        },
235        paths: PathsProfile {
236            feature_name: "test",
237            feature_tool_script: "test-tools.zsh",
238            secret_dir_home: HomePathSelection::ModernOnly(SECRET_HOME),
239            auth_file_home: HomePathSelection::ModernOnly(AUTH_HOME),
240            secret_cache_home: None,
241        },
242        exec: ExecProfile {
243            default_caller_prefix: "test",
244            missing_prompt_label: "_test_exec_dangerous",
245            binary_name: "test-bin",
246            failed_exec_message_prefix: "test-tools: failed to run test exec",
247            invocation: ExecInvocation::CodexStyle,
248            warned_invalid_allow_dangerous: &WARNED_INVALID_ALLOW_DANGEROUS,
249        },
250    };
251
252    fn token(payload: &str) -> String {
253        format!("{HEADER}.{payload}.sig")
254    }
255
256    fn auth_json(
257        payload: &str,
258        account_id: &str,
259        refresh_token: &str,
260        last_refresh: &str,
261    ) -> String {
262        format!(
263            r#"{{"tokens":{{"access_token":"{}","id_token":"{}","refresh_token":"{}","account_id":"{}"}},"last_refresh":"{}"}}"#,
264            token(payload),
265            token(payload),
266            refresh_token,
267            account_id,
268            last_refresh
269        )
270    }
271
272    #[test]
273    fn sync_auth_to_matching_secrets_updates_only_identity_matches() {
274        let lock = GlobalStateLock::new();
275        let dir = tempfile::TempDir::new().expect("tempdir");
276        let secret_dir = dir.path().join("secrets");
277        let cache_dir = dir.path().join("cache");
278        std::fs::create_dir_all(&secret_dir).expect("secrets");
279        std::fs::create_dir_all(&cache_dir).expect("cache");
280
281        let auth_file = dir.path().join("auth.json");
282        let alpha = secret_dir.join("alpha.json");
283        let beta = secret_dir.join("beta.json");
284        std::fs::write(
285            &auth_file,
286            auth_json(
287                PAYLOAD_ALPHA,
288                "acct_001",
289                "refresh_new",
290                "2025-01-20T12:34:56Z",
291            ),
292        )
293        .expect("auth");
294        std::fs::write(
295            &alpha,
296            auth_json(
297                PAYLOAD_ALPHA,
298                "acct_001",
299                "refresh_old",
300                "2025-01-19T12:34:56Z",
301            ),
302        )
303        .expect("alpha");
304        std::fs::write(
305            &beta,
306            auth_json(
307                PAYLOAD_BETA,
308                "acct_002",
309                "refresh_beta",
310                "2025-01-18T12:34:56Z",
311            ),
312        )
313        .expect("beta");
314        std::fs::write(secret_dir.join("invalid.json"), "{invalid").expect("invalid");
315
316        let _secret = EnvGuard::set(
317            &lock,
318            "TEST_SECRET_DIR",
319            secret_dir.to_string_lossy().as_ref(),
320        );
321        let _cache = EnvGuard::set(
322            &lock,
323            "TEST_SECRET_CACHE_DIR",
324            cache_dir.to_string_lossy().as_ref(),
325        );
326
327        let result = sync_auth_to_matching_secrets(
328            &TEST_PROFILE,
329            &auth_file,
330            crate::fs::SECRET_FILE_MODE,
331            TimestampPolicy::Strict,
332        )
333        .expect("sync");
334
335        assert!(result.auth_file_present);
336        assert!(result.auth_identity_present);
337        assert_eq!(result.synced, 1);
338        assert_eq!(result.skipped, 2);
339        assert_eq!(result.updated_files, vec![alpha.clone()]);
340        assert_eq!(
341            std::fs::read(&alpha).expect("alpha"),
342            std::fs::read(&auth_file).expect("auth")
343        );
344        assert_ne!(
345            std::fs::read(&beta).expect("beta"),
346            std::fs::read(&auth_file).expect("auth")
347        );
348        assert_eq!(
349            std::fs::read_to_string(cache_dir.join("alpha.json.timestamp"))
350                .expect("alpha timestamp"),
351            "2025-01-20T12:34:56Z"
352        );
353        assert_eq!(
354            std::fs::read_to_string(cache_dir.join("auth.json.timestamp")).expect("auth timestamp"),
355            "2025-01-20T12:34:56Z"
356        );
357    }
358
359    #[test]
360    fn sync_auth_to_matching_secrets_reports_missing_identity() {
361        let lock = GlobalStateLock::new();
362        let dir = tempfile::TempDir::new().expect("tempdir");
363        let secret_dir = dir.path().join("secrets");
364        std::fs::create_dir_all(&secret_dir).expect("secrets");
365
366        let auth_file = dir.path().join("auth.json");
367        std::fs::write(&auth_file, r#"{"tokens":{"refresh_token":"only"}}"#).expect("auth");
368
369        let _secret = EnvGuard::set(
370            &lock,
371            "TEST_SECRET_DIR",
372            secret_dir.to_string_lossy().as_ref(),
373        );
374        let result = sync_auth_to_matching_secrets(
375            &TEST_PROFILE,
376            &auth_file,
377            crate::fs::SECRET_FILE_MODE,
378            TimestampPolicy::BestEffort,
379        )
380        .expect("sync");
381        assert!(result.auth_file_present);
382        assert!(!result.auth_identity_present);
383        assert_eq!(result.synced, 0);
384        assert_eq!(result.skipped, 0);
385    }
386}