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(×tamp_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}