返回 CodeWhale
xai_credentials.rs
根目录 / crates / config / src / xai_credentials.rs
1 //! Naming and cleanup policy for Codewhale-owned xAI OAuth generations.
2 //!
3 //! Config stores only a validated basename. Callers can therefore never turn
4 //! the generation pointer into an arbitrary path read or deletion primitive.
5
6 #[cfg(any(not(unix), test))]
7 use std::fs;
8 use std::fs::File;
9 use std::io::Read as _;
10 use std::path::{Component, Path, PathBuf};
11 #[cfg(not(windows))]
12 use std::sync::atomic::{AtomicU64, Ordering};
13 use std::sync::{Mutex, OnceLock};
14 #[cfg(not(windows))]
15 use std::time::{SystemTime, UNIX_EPOCH};
16
17 use crate::private_directory::validate_owned_file_handle;
18 #[cfg(all(windows, test))]
19 use crate::private_directory::{
20 fail_next_windows_post_persist_validation, normalize_windows_path_for_comparison,
21 verify_windows_owner_only_handle,
22 };
23 use anyhow::{Context, Result, bail};
24
25 pub const XAI_OAUTH_GENERATION_PREFIX: &str = "xai-auth-";
26 pub const XAI_OAUTH_GENERATION_SUFFIX: &str = ".json";
27 pub const LEGACY_XAI_OAUTH_FILE_NAME: &str = "xai-auth.json";
28 pub const CHATGPT_OAUTH_GENERATION_PREFIX: &str = "chatgpt-auth-";
29 pub const CHATGPT_OAUTH_GENERATION_SUFFIX: &str = ".json";
30 pub const LEGACY_CHATGPT_OAUTH_FILE_NAME: &str = "chatgpt-oauth.json";
31 /// Stable local host and registration metadata, retained across token logout.
32 pub const CHATGPT_HOST_FILE_NAME: &str = "chatgpt-host.json";
33 const XAI_OAUTH_LIFECYCLE_LOCK_FILE_NAME: &str = ".xai-oauth.lock";
34 const XAI_OAUTH_FILE_LIMIT: u64 = 1024 * 1024;
35
36 /// Stable handle to Codewhale's private xAI OAuth directory.
37 ///
38 /// The lexical `$CODEWHALE_HOME/credentials` boundary is retained verbatim.
39 /// Unix opens every component relative to the preceding directory with
40 /// `O_NOFOLLOW`; Windows keeps non-delete-shared handles to every component and
41 /// rejects reparse points. Holding this value therefore pins the directory
42 /// identity for the duration of one lifecycle operation.
43 #[derive(Debug)]
44 pub struct XaiOAuthCredentialStore {
45 directory: PathBuf,
46 private: crate::private_directory::PrivateDirectory,
47 }
48
49 /// Files retired from an active xAI OAuth epoch before a mode switch commits.
50 ///
51 /// Unix hides original names behind private tombstones immediately. Windows
52 /// relies on the lifecycle lock, then deletes exact handles after commit. A
53 /// failed config mutation restores or retains the prior files; a successful
54 /// mutation removes them.
55 #[derive(Debug)]
56 pub struct XaiOAuthRevocation {
57 retired: Vec<(String, String)>,
58 }
59
60 #[must_use]
61 pub fn is_valid_xai_oauth_generation(value: &str) -> bool {
62 is_valid_owned_oauth_generation(
63 value,
64 XAI_OAUTH_GENERATION_PREFIX,
65 XAI_OAUTH_GENERATION_SUFFIX,
66 )
67 }
68
69 pub fn validate_xai_oauth_generation(value: &str) -> Result<&str> {
70 if !is_valid_xai_oauth_generation(value) {
71 bail!(
72 "invalid Codewhale-owned xAI OAuth generation; expected xai-auth-<32 lowercase hex>.json"
73 );
74 }
75 Ok(value)
76 }
77
78 #[must_use]
79 pub fn is_valid_chatgpt_oauth_generation(value: &str) -> bool {
80 is_valid_owned_oauth_generation(
81 value,
82 CHATGPT_OAUTH_GENERATION_PREFIX,
83 CHATGPT_OAUTH_GENERATION_SUFFIX,
84 )
85 }
86
87 pub fn validate_chatgpt_oauth_generation(value: &str) -> Result<&str> {
88 if !is_valid_chatgpt_oauth_generation(value) {
89 bail!(
90 "invalid Codewhale-owned ChatGPT OAuth generation; expected chatgpt-auth-<32 lowercase hex>.json"
91 );
92 }
93 Ok(value)
94 }
95
96 fn is_valid_owned_oauth_generation(value: &str, prefix: &str, suffix: &str) -> bool {
97 let path = Path::new(value);
98 if path.components().count() != 1
99 || !matches!(path.components().next(), Some(Component::Normal(_)))
100 || path.file_name().and_then(|name| name.to_str()) != Some(value)
101 {
102 return false;
103 }
104 let Some(id) = value
105 .strip_prefix(prefix)
106 .and_then(|value| value.strip_suffix(suffix))
107 else {
108 return false;
109 };
110 id.len() == 32
111 && id
112 .bytes()
113 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
114 }
115
116 pub fn xai_oauth_credentials_dir() -> Result<PathBuf> {
117 lexical_absolute_path(&crate::codewhale_home()?.join("credentials"))
118 }
119
120 /// Make an owned path absolute without resolving any filesystem component.
121 /// Canonicalization is deliberately forbidden here: following an existing
122 /// `credentials` symlink would erase the lexical Codewhale-owned boundary and
123 /// turn an external directory into an apparently valid destination.
124 fn lexical_absolute_path(path: &Path) -> Result<PathBuf> {
125 let absolute = if path.is_absolute() {
126 path.to_path_buf()
127 } else {
128 std::env::current_dir()
129 .context("resolving the Codewhale credentials directory")?
130 .join(path)
131 };
132 if absolute
133 .components()
134 .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
135 {
136 bail!(
137 "Codewhale credentials directory must be lexically normalized: {}",
138 crate::quote_os_path(&absolute)
139 );
140 }
141 Ok(absolute)
142 }
143
144 pub fn xai_oauth_generation_path(generation: &str) -> Result<PathBuf> {
145 Ok(xai_oauth_credentials_dir()?.join(validate_xai_oauth_generation(generation)?))
146 }
147
148 pub fn legacy_xai_oauth_path() -> Result<PathBuf> {
149 Ok(xai_oauth_credentials_dir()?.join(LEGACY_XAI_OAUTH_FILE_NAME))
150 }
151
152 pub fn chatgpt_oauth_generation_path(generation: &str) -> Result<PathBuf> {
153 Ok(xai_oauth_credentials_dir()?.join(validate_chatgpt_oauth_generation(generation)?))
154 }
155
156 pub fn legacy_chatgpt_oauth_path() -> Result<PathBuf> {
157 Ok(xai_oauth_credentials_dir()?.join(LEGACY_CHATGPT_OAUTH_FILE_NAME))
158 }
159
160 /// Serialize every Codewhale-owned xAI OAuth lifecycle mutation across threads
161 /// and processes while pinning the lexical credentials directory.
162 ///
163 /// Lock order is always xAI lifecycle first, then config document. Callers must
164 /// not invoke this function recursively.
165 pub fn with_xai_oauth_lifecycle_lock<T>(
166 operation: impl FnOnce(&XaiOAuthCredentialStore) -> Result<T>,
167 ) -> Result<T> {
168 static PROCESS_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
169 let _process_guard = PROCESS_LOCK
170 .get_or_init(|| Mutex::new(()))
171 .lock()
172 .map_err(|_| anyhow::anyhow!("xAI OAuth lifecycle lock was poisoned"))?;
173 let store = XaiOAuthCredentialStore::open()?;
174 let lock_file = store.open_lock_file()?;
175 let mut lock = fd_lock::RwLock::new(lock_file);
176 let _guard = lock.write().with_context(|| {
177 format!(
178 "failed to acquire xAI OAuth lifecycle lock in {}",
179 crate::quote_os_path(store.directory())
180 )
181 })?;
182 operation(&store)
183 }
184
185 /// Run an authority mode switch while the prior owned OAuth epoch is hidden
186 /// from concurrent Codewhale readers. A failed authority mutation restores the
187 /// old files; a successful mutation permanently removes them.
188 pub fn with_xai_oauth_revocation_transaction<T>(
189 operation: impl FnOnce() -> Result<T>,
190 ) -> Result<T> {
191 with_xai_oauth_lifecycle_lock(|store| {
192 let revocation = store.stage_revocation()?;
193 match operation() {
194 Ok(value) => {
195 revocation.commit(store).context(
196 "xAI OAuth authority changed, but retired owned credentials could not be removed",
197 )?;
198 Ok(value)
199 }
200 Err(error) => {
201 if let Err(rollback) = revocation.rollback(store) {
202 return Err(error).context(format!(
203 "also failed to restore the prior xAI OAuth epoch: {rollback:#}"
204 ));
205 }
206 Err(error)
207 }
208 }
209 })
210 }
211
212 impl XaiOAuthCredentialStore {
213 fn open() -> Result<Self> {
214 let directory = xai_oauth_credentials_dir()?;
215 open_owned_credentials_directory(&directory)
216 }
217
218 #[must_use]
219 pub fn directory(&self) -> &Path {
220 &self.directory
221 }
222
223 pub fn path_for(&self, name: &str) -> Result<PathBuf> {
224 validate_owned_auth_name(name)?;
225 Ok(self.directory.join(name))
226 }
227
228 pub fn read_to_string(&self, name: &str) -> Result<Option<String>> {
229 validate_owned_auth_name(name)?;
230 let Some(mut file) = self.open_owned_file_for_read(name)? else {
231 return Ok(None);
232 };
233 let metadata = validate_owned_file_handle(&file, &self.directory.join(name))?;
234 if metadata.len() > XAI_OAUTH_FILE_LIMIT {
235 bail!(
236 "Codewhale-owned xAI OAuth file {} exceeds the {} byte limit",
237 crate::quote_os_path(&self.directory.join(name)),
238 XAI_OAUTH_FILE_LIMIT
239 );
240 }
241 let mut bytes = Vec::with_capacity(metadata.len() as usize);
242 (&mut file)
243 .take(XAI_OAUTH_FILE_LIMIT + 1)
244 .read_to_end(&mut bytes)
245 .with_context(|| {
246 format!(
247 "reading Codewhale-owned xAI OAuth file {}",
248 crate::quote_os_path(&self.directory.join(name))
249 )
250 })?;
251 if bytes.len() as u64 > XAI_OAUTH_FILE_LIMIT {
252 bail!(
253 "Codewhale-owned xAI OAuth file {} exceeds the {} byte limit",
254 crate::quote_os_path(&self.directory.join(name)),
255 XAI_OAUTH_FILE_LIMIT
256 );
257 }
258 String::from_utf8(bytes).map(Some).map_err(|_| {
259 anyhow::anyhow!(
260 "Codewhale-owned xAI OAuth file {} is not valid UTF-8",
261 crate::quote_os_path(&self.directory.join(name))
262 )
263 })
264 }
265
266 pub fn write(&self, name: &str, bytes: &[u8], allow_replace: bool) -> Result<()> {
267 validate_owned_auth_name(name)?;
268 anyhow::ensure!(
269 bytes.len() as u64 <= XAI_OAUTH_FILE_LIMIT,
270 "refusing oversized xAI OAuth credential payload"
271 );
272 self.write_owned_file(name, bytes, allow_replace)
273 }
274
275 pub fn remove(&self, name: &str) -> Result<bool> {
276 validate_owned_auth_name(name)?;
277 self.remove_raw(name)
278 }
279
280 pub fn clear_all(&self) -> Result<usize> {
281 let mut removed = 0;
282 for name in self.owned_auth_names()? {
283 if self.remove(&name)? {
284 removed += 1;
285 }
286 }
287 Ok(removed)
288 }
289
290 pub fn clear_chatgpt(&self) -> Result<usize> {
291 let mut removed = 0;
292 for name in chatgpt_auth_names_in_store(self)? {
293 if self.remove(&name)? {
294 removed += 1;
295 }
296 }
297 Ok(removed)
298 }
299
300 /// Stage every active owned credential before a config mode switch. The
301 /// generation basename is the OAuth epoch; the lifecycle lock prevents a
302 /// stale Codewhale reader from using it while authority changes.
303 pub fn stage_revocation(&self) -> Result<XaiOAuthRevocation> {
304 #[cfg(windows)]
305 {
306 // Every Codewhale reader/writer takes the lifecycle lock, so a
307 // Windows mode switch can retain the exact active basenames until
308 // the config commit succeeds. `commit` then opens each leaf with
309 // DELETE access and marks that exact handle for deletion. This
310 // avoids path-based rename races and makes rollback a no-op.
311 Ok(XaiOAuthRevocation {
312 retired: self
313 .owned_auth_names()?
314 .into_iter()
315 .map(|name| (name, String::new()))
316 .collect(),
317 })
318 }
319
320 #[cfg(not(windows))]
321 {
322 static COUNTER: AtomicU64 = AtomicU64::new(0);
323 let nonce = SystemTime::now()
324 .duration_since(UNIX_EPOCH)
325 .unwrap_or_default()
326 .as_nanos();
327 let mut retired = Vec::new();
328 for (index, name) in self.owned_auth_names()?.into_iter().enumerate() {
329 let tombstone = format!(
330 ".xai-oauth-retired-{}-{nonce}-{}-{index}.tmp",
331 std::process::id(),
332 COUNTER.fetch_add(1, Ordering::Relaxed)
333 );
334 if let Err(error) = self.rename_raw(&name, &tombstone) {
335 let rollback = XaiOAuthRevocation { retired };
336 if let Err(rollback_error) = rollback.rollback(self) {
337 return Err(error).context(format!(
338 "also failed to restore previously retired xAI OAuth files: {rollback_error:#}"
339 ));
340 }
341 return Err(error);
342 }
343 retired.push((name, tombstone));
344 }
345 Ok(XaiOAuthRevocation { retired })
346 }
347 }
348
349 fn owned_auth_names(&self) -> Result<Vec<String>> {
350 owned_auth_names_in_store(self)
351 }
352
353 fn open_lock_file(&self) -> Result<File> {
354 self.open_internal_file(XAI_OAUTH_LIFECYCLE_LOCK_FILE_NAME)
355 }
356 }
357
358 #[cfg(unix)]
359 fn owned_auth_names_in_store(store: &XaiOAuthCredentialStore) -> Result<Vec<String>> {
360 use std::ffi::CStr;
361 use std::os::fd::AsRawFd as _;
362
363 // `fdopendir` consumes its descriptor, so enumerate through a duplicate of
364 // the pinned directory handle. No pathname is resolved after the store is
365 // opened, even if the lexical directory is renamed or replaced.
366 // SAFETY: duplicates the live store descriptor; CLOEXEC keeps it out of children.
367 let duplicated = unsafe {
368 libc::fcntl(
369 store.private.directory_handle.as_raw_fd(),
370 libc::F_DUPFD_CLOEXEC,
371 0,
372 )
373 };
374 if duplicated < 0 {
375 return Err(std::io::Error::last_os_error())
376 .context("duplicating Codewhale credentials directory handle");
377 }
378 // SAFETY: `duplicated` is an owned directory descriptor. `closedir` below
379 // assumes ownership on the successful conversion.
380 let stream = unsafe { libc::fdopendir(duplicated) };
381 if stream.is_null() {
382 let error = std::io::Error::last_os_error();
383 // SAFETY: `fdopendir` failed and therefore did not consume the fd.
384 unsafe { libc::close(duplicated) };
385 return Err(error).context("enumerating Codewhale credentials directory");
386 }
387 let mut names = Vec::new();
388 loop {
389 // SAFETY: `stream` remains live until `closedir`; each returned entry
390 // is valid until the next call and copied before then.
391 let entry = unsafe { libc::readdir(stream) };
392 if entry.is_null() {
393 break;
394 }
395 // SAFETY: POSIX `dirent::d_name` is NUL terminated.
396 let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) };
397 let Ok(name) = name.to_str() else {
398 continue;
399 };
400 if name == LEGACY_XAI_OAUTH_FILE_NAME || is_valid_xai_oauth_generation(name) {
401 names.push(name.to_string());
402 }
403 }
404 // SAFETY: `stream` is still owned and has not previously been closed.
405 if unsafe { libc::closedir(stream) } != 0 {
406 return Err(std::io::Error::last_os_error())
407 .context("closing Codewhale credentials directory enumeration");
408 }
409 names.sort();
410 Ok(names)
411 }
412
413 #[cfg(unix)]
414 fn chatgpt_auth_names_in_store(store: &XaiOAuthCredentialStore) -> Result<Vec<String>> {
415 use std::ffi::CStr;
416 use std::os::fd::AsRawFd as _;
417
418 // SAFETY: duplicates the live store descriptor; CLOEXEC keeps it out of children.
419 let duplicated = unsafe {
420 libc::fcntl(
421 store.private.directory_handle.as_raw_fd(),
422 libc::F_DUPFD_CLOEXEC,
423 0,
424 )
425 };
426 if duplicated < 0 {
427 return Err(std::io::Error::last_os_error())
428 .context("duplicating Codewhale credentials directory handle");
429 }
430 // SAFETY: `duplicated` is an owned directory descriptor. `closedir` below
431 // assumes ownership on the successful conversion.
432 let stream = unsafe { libc::fdopendir(duplicated) };
433 if stream.is_null() {
434 let error = std::io::Error::last_os_error();
435 // SAFETY: `fdopendir` failed and therefore did not consume the fd.
436 unsafe { libc::close(duplicated) };
437 return Err(error).context("enumerating Codewhale credentials directory");
438 }
439 let mut names = Vec::new();
440 loop {
441 // SAFETY: `stream` remains live until `closedir`; each returned entry
442 // is valid until the next call and copied before then.
443 let entry = unsafe { libc::readdir(stream) };
444 if entry.is_null() {
445 break;
446 }
447 // SAFETY: POSIX `dirent::d_name` is NUL terminated.
448 let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) };
449 let Ok(name) = name.to_str() else {
450 continue;
451 };
452 if is_chatgpt_owned_auth_name(name) {
453 names.push(name.to_string());
454 }
455 }
456 // SAFETY: `stream` is still owned and has not previously been closed.
457 if unsafe { libc::closedir(stream) } != 0 {
458 return Err(std::io::Error::last_os_error())
459 .context("closing Codewhale credentials directory enumeration");
460 }
461 names.sort();
462 Ok(names)
463 }
464
465 #[cfg(not(unix))]
466 fn owned_auth_names_in_store(store: &XaiOAuthCredentialStore) -> Result<Vec<String>> {
467 let mut names = Vec::new();
468 let entries = fs::read_dir(&store.directory).with_context(|| {
469 format!(
470 "failed to inspect Codewhale credentials directory {}",
471 crate::quote_os_path(&store.directory)
472 )
473 })?;
474 for entry in entries {
475 let entry = entry.with_context(|| {
476 format!(
477 "failed to inspect Codewhale credentials directory {}",
478 crate::quote_os_path(&store.directory)
479 )
480 })?;
481 let name = entry.file_name();
482 let Some(name) = name.to_str() else {
483 continue;
484 };
485 if name == LEGACY_XAI_OAUTH_FILE_NAME || is_valid_xai_oauth_generation(name) {
486 names.push(name.to_string());
487 }
488 }
489 names.sort();
490 Ok(names)
491 }
492
493 #[cfg(not(unix))]
494 fn chatgpt_auth_names_in_store(store: &XaiOAuthCredentialStore) -> Result<Vec<String>> {
495 let mut names = Vec::new();
496 let entries = fs::read_dir(&store.directory).with_context(|| {
497 format!(
498 "failed to inspect Codewhale credentials directory {}",
499 crate::quote_os_path(&store.directory)
500 )
501 })?;
502 for entry in entries {
503 let entry = entry.with_context(|| {
504 format!(
505 "failed to inspect Codewhale credentials directory {}",
506 crate::quote_os_path(&store.directory)
507 )
508 })?;
509 let name = entry.file_name();
510 let Some(name) = name.to_str() else {
511 continue;
512 };
513 if is_chatgpt_owned_auth_name(name) {
514 names.push(name.to_string());
515 }
516 }
517 names.sort();
518 Ok(names)
519 }
520
521 impl XaiOAuthRevocation {
522 /// Restore the old epoch after a config mutation fails. Restoration is
523 /// fail-closed: an unexpected replacement at an original name is never
524 /// overwritten.
525 pub fn rollback(self, store: &XaiOAuthCredentialStore) -> Result<()> {
526 #[cfg(windows)]
527 {
528 let _ = store;
529 Ok(())
530 }
531 #[cfg(not(windows))]
532 {
533 let mut first_error = None;
534 for (original, tombstone) in self.retired.into_iter().rev() {
535 let result = store.rename_raw(&tombstone, &original).with_context(|| {
536 format!(
537 "restoring retired xAI OAuth file {}",
538 crate::quote_os_path(&store.directory.join(original))
539 )
540 });
541 if result.is_err() && first_error.is_none() {
542 first_error = result.err();
543 }
544 }
545 if let Some(error) = first_error {
546 return Err(error);
547 }
548 Ok(())
549 }
550 }
551
552 /// Permanently remove retired bytes after the replacement config commits.
553 pub fn commit(self, store: &XaiOAuthCredentialStore) -> Result<usize> {
554 let mut removed = 0;
555 for (_original, _tombstone) in self.retired {
556 #[cfg(windows)]
557 let target = _original;
558 #[cfg(not(windows))]
559 let target = _tombstone;
560 if store.remove_raw(&target)? {
561 removed += 1;
562 }
563 }
564 Ok(removed)
565 }
566 }
567
568 fn validate_owned_auth_name(name: &str) -> Result<()> {
569 anyhow::ensure!(
570 name == LEGACY_XAI_OAUTH_FILE_NAME
571 || name == LEGACY_CHATGPT_OAUTH_FILE_NAME
572 || name == CHATGPT_HOST_FILE_NAME
573 || is_valid_xai_oauth_generation(name)
574 || is_valid_chatgpt_oauth_generation(name),
575 "invalid Codewhale-owned OAuth basename"
576 );
577 Ok(())
578 }
579
580 fn is_chatgpt_owned_auth_name(name: &str) -> bool {
581 name == LEGACY_CHATGPT_OAUTH_FILE_NAME || is_valid_chatgpt_oauth_generation(name)
582 }
583
584 fn open_owned_credentials_directory(directory: &Path) -> Result<XaiOAuthCredentialStore> {
585 Ok(XaiOAuthCredentialStore {
586 directory: directory.to_path_buf(),
587 private: crate::private_directory::PrivateDirectory::open(directory)?,
588 })
589 }
590
591 impl XaiOAuthCredentialStore {
592 fn open_owned_file_for_read(&self, name: &str) -> Result<Option<File>> {
593 self.private.open_owned_file_for_read(name)
594 }
595 fn open_internal_file(&self, name: &str) -> Result<File> {
596 self.private.open_internal_file(name)
597 }
598 fn write_owned_file(&self, name: &str, bytes: &[u8], allow_replace: bool) -> Result<()> {
599 self.private.write_owned_file(name, bytes, allow_replace)
600 }
601 fn remove_raw(&self, name: &str) -> Result<bool> {
602 self.private.remove_raw(name)
603 }
604 #[cfg(not(windows))]
605 fn rename_raw(&self, from: &str, to: &str) -> Result<()> {
606 self.private.rename_raw(from, to)
607 }
608 }
609
610 /// Delete one superseded generation after its replacement pointer committed.
611 /// The basename is validated before any filesystem access.
612 pub fn remove_xai_oauth_generation(generation: &str) -> Result<bool> {
613 let generation = validate_xai_oauth_generation(generation)?;
614 with_xai_oauth_lifecycle_lock(|store| store.remove(generation))
615 }
616
617 /// Explicit logout policy: remove the legacy Codewhale-owned file and every
618 /// valid generated xAI OAuth file. Unknown files in the credentials directory
619 /// are never touched.
620 pub fn clear_all_xai_oauth_credentials() -> Result<usize> {
621 with_xai_oauth_lifecycle_lock(XaiOAuthCredentialStore::clear_all)
622 }
623
624 pub fn clear_all_chatgpt_oauth_credentials() -> Result<usize> {
625 with_xai_oauth_lifecycle_lock(XaiOAuthCredentialStore::clear_chatgpt)
626 }
627
628 /// Clears every Codewhale-owned ChatGPT OAuth file without re-entering the
629 /// lifecycle lock. Only for code already running inside
630 /// [`with_xai_oauth_lifecycle_lock`] or [`with_xai_oauth_revocation_transaction`];
631 /// everyone else must call [`clear_all_chatgpt_oauth_credentials`], which
632 /// takes the lock. The lifecycle lock is a non-reentrant in-process mutex, so
633 /// a nested call would deadlock the logout path (seen as CI hangs on the
634 /// `logout_*` tests).
635 pub fn clear_all_chatgpt_oauth_credentials_locked() -> Result<usize> {
636 let store = XaiOAuthCredentialStore::open()?;
637 XaiOAuthCredentialStore::clear_chatgpt(&store)
638 }
639
640 pub fn remove_chatgpt_oauth_generation(generation: &str) -> Result<bool> {
641 let generation = validate_chatgpt_oauth_generation(generation)?;
642 with_xai_oauth_lifecycle_lock(|store| store.remove(generation))
643 }
644
645 #[cfg(test)]
646 mod tests {
647 use super::*;
648
649 #[test]
650 fn credentials_directory_preserves_lexical_identity() {
651 let directory = tempfile::tempdir().expect("temp dir");
652 let lexical = directory.path().join("missing").join("credentials");
653 assert_eq!(
654 lexical_absolute_path(&lexical).expect("preserve lexical path"),
655 lexical
656 );
657 assert!(!lexical.exists());
658 assert!(
659 lexical_absolute_path(&directory.path().join("missing/../escape")).is_err(),
660 "owned credential roots must reject traversal components"
661 );
662 }
663
664 #[test]
665 fn generation_names_are_strict_basenames() {
666 let valid = "xai-auth-0123456789abcdef0123456789abcdef.json";
667 assert!(is_valid_xai_oauth_generation(valid));
668 for invalid in [
669 "../xai-auth-0123456789abcdef0123456789abcdef.json",
670 "/tmp/xai-auth-0123456789abcdef0123456789abcdef.json",
671 "xai-auth-0123456789ABCDEF0123456789ABCDEF.json",
672 "xai-auth-short.json",
673 "xai-auth.json",
674 ] {
675 assert!(!is_valid_xai_oauth_generation(invalid), "{invalid}");
676 }
677 let chatgpt = "chatgpt-auth-0123456789abcdef0123456789abcdef.json";
678 assert!(is_valid_chatgpt_oauth_generation(chatgpt));
679 assert!(!is_valid_chatgpt_oauth_generation(valid));
680 assert!(!is_valid_xai_oauth_generation(chatgpt));
681 assert!(!is_valid_chatgpt_oauth_generation(
682 "../chatgpt-auth-0123456789abcdef0123456789abcdef.json"
683 ));
684 }
685
686 #[test]
687 fn logout_cleanup_removes_only_owned_xai_files() {
688 let directory = tempfile::tempdir().expect("temp dir");
689 let directory = directory.path().canonicalize().expect("canonical temp dir");
690 let store = open_owned_credentials_directory(&directory).expect("open store");
691 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
692 store
693 .write(generation, b"secret", false)
694 .expect("generation");
695 store
696 .write(LEGACY_XAI_OAUTH_FILE_NAME, b"legacy", false)
697 .expect("legacy");
698 fs::write(directory.join("other-provider.json"), "keep").expect("other provider");
699
700 assert_eq!(store.clear_all().expect("clear"), 2);
701 assert!(directory.join("other-provider.json").exists());
702 assert!(!directory.join(generation).exists());
703 assert!(!directory.join("xai-auth.json").exists());
704 }
705
706 #[test]
707 fn logout_cleanup_removes_chatgpt_files_without_touching_xai() {
708 let directory = tempfile::tempdir().expect("temp dir");
709 let directory = directory.path().canonicalize().expect("canonical temp dir");
710 let store = open_owned_credentials_directory(&directory).expect("open store");
711 let chatgpt = "chatgpt-auth-0123456789abcdef0123456789abcdef.json";
712 let xai = "xai-auth-0123456789abcdef0123456789abcdef.json";
713 store.write(chatgpt, b"chatgpt", false).expect("chatgpt");
714 store.write(xai, b"xai", false).expect("xai");
715 store
716 .write(CHATGPT_HOST_FILE_NAME, b"host", false)
717 .expect("host");
718 store
719 .write(LEGACY_CHATGPT_OAUTH_FILE_NAME, b"legacy", false)
720 .expect("legacy chatgpt");
721
722 assert_eq!(store.clear_chatgpt().expect("clear chatgpt"), 2);
723 assert!(directory.join(xai).exists());
724 assert!(directory.join(CHATGPT_HOST_FILE_NAME).exists());
725 assert!(!directory.join(chatgpt).exists());
726 assert!(!directory.join(LEGACY_CHATGPT_OAUTH_FILE_NAME).exists());
727 }
728
729 #[cfg(unix)]
730 #[test]
731 fn unix_store_pins_directory_identity_across_lexical_path_swap() {
732 use std::os::unix::fs::symlink;
733
734 let root = tempfile::tempdir().expect("temp dir");
735 let root = root.path().canonicalize().expect("canonical temp root");
736 let credentials = root.join("credentials");
737 fs::create_dir(&credentials).expect("credentials");
738 let store = open_owned_credentials_directory(&credentials).expect("open pinned store");
739 let parked = root.join("parked-credentials");
740 let external = root.join("external-owner");
741 fs::create_dir(&external).expect("external directory");
742 fs::rename(&credentials, &parked).expect("park credentials");
743 symlink(&external, &credentials).expect("replace lexical path with symlink");
744
745 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
746 store
747 .write(generation, b"pinned bytes", false)
748 .expect("write through pinned directory handle");
749
750 assert_eq!(fs::read(parked.join(generation)).unwrap(), b"pinned bytes");
751 assert!(!external.join(generation).exists());
752 assert_eq!(
753 store.read_to_string(generation).unwrap().as_deref(),
754 Some("pinned bytes")
755 );
756 }
757
758 #[cfg(unix)]
759 #[test]
760 fn unix_store_rejects_symlinked_root_component_and_hardlinked_leaf() {
761 use std::os::unix::fs::symlink;
762
763 let root = tempfile::tempdir().expect("temp dir");
764 let root = root.path().canonicalize().expect("canonical temp root");
765 let real = root.join("real-home");
766 fs::create_dir(&real).expect("real home");
767 let linked = root.join("linked-home");
768 symlink(&real, &linked).expect("home symlink");
769 assert!(
770 open_owned_credentials_directory(&linked.join("credentials")).is_err(),
771 "owned roots must reject every symlink component"
772 );
773
774 let credentials = real.join("credentials");
775 let store = open_owned_credentials_directory(&credentials).expect("safe store");
776 let generation = "xai-auth-fedcba9876543210fedcba9876543210.json";
777 store
778 .write(generation, b"secret", false)
779 .expect("seed generation");
780 fs::hard_link(
781 credentials.join(generation),
782 credentials.join("attacker-hardlink"),
783 )
784 .expect("hardlink fixture");
785 assert!(
786 store.read_to_string(generation).is_err(),
787 "owned reads must reject multiply-linked credential files"
788 );
789 }
790
791 #[cfg(windows)]
792 #[test]
793 fn windows_owned_path_identity_is_case_insensitive_and_lossless() {
794 use std::ffi::OsString;
795 use std::os::windows::ffi::OsStringExt as _;
796
797 assert_eq!(
798 normalize_windows_path_for_comparison(Path::new(r"C:\Users\Alice\Credentials"))
799 .unwrap(),
800 normalize_windows_path_for_comparison(Path::new(r"\\?\c:\users\ALICE\credentials"))
801 .unwrap()
802 );
803 let invalid = PathBuf::from(OsString::from_wide(&[
804 b'C' as u16,
805 b':' as u16,
806 b'\\' as u16,
807 0xd800,
808 ]));
809 assert!(normalize_windows_path_for_comparison(&invalid).is_err());
810 }
811
812 #[cfg(windows)]
813 #[test]
814 fn windows_post_persist_failure_exact_deletes_new_and_replacement_generations() {
815 fn assert_directory_empty(path: &Path) {
816 let entries = fs::read_dir(path)
817 .expect("read credentials directory")
818 .collect::<std::io::Result<Vec<_>>>()
819 .expect("read credential entries");
820 assert!(
821 entries.is_empty(),
822 "rejected credential bytes must leave no durable file: {entries:?}"
823 );
824 }
825
826 let root = tempfile::tempdir().expect("temp dir");
827 let root = root.path().canonicalize().expect("canonical temp root");
828 let credentials = root.join("new-credentials");
829 let store = open_owned_credentials_directory(&credentials).expect("open secure store");
830 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
831 let generation_path = credentials.join(generation);
832 fail_next_windows_post_persist_validation(&generation_path);
833 let error = store
834 .write(generation, b"new credential bytes", false)
835 .expect_err("post-persist validation must fail");
836 assert!(error.to_string().contains("injected post-persistence"));
837 assert_directory_empty(&credentials);
838
839 let replacement_credentials = root.join("replacement-credentials");
840 let replacement_store = open_owned_credentials_directory(&replacement_credentials)
841 .expect("open replacement store");
842 let replacement_path = replacement_credentials.join(generation);
843 replacement_store
844 .write(generation, b"prior credential bytes", false)
845 .expect("seed prior generation");
846 fail_next_windows_post_persist_validation(&replacement_path);
847 let error = replacement_store
848 .write(generation, b"replacement credential bytes", true)
849 .expect_err("replacement validation must fail");
850 assert!(error.to_string().contains("injected post-persistence"));
851 assert!(
852 !replacement_path.exists(),
853 "a rejected replacement deliberately fails closed instead of restoring by path"
854 );
855 assert_directory_empty(&replacement_credentials);
856 }
857
858 #[cfg(windows)]
859 #[test]
860 fn windows_store_secures_every_new_owned_object_for_the_current_user() {
861 let root = tempfile::tempdir().expect("temp dir");
862 let root = root.path().canonicalize().expect("canonical temp root");
863 let credentials = root.join("credentials");
864 let store = open_owned_credentials_directory(&credentials).expect("open secure store");
865
866 let directory = store
867 .private
868 ._component_handles
869 .last()
870 .expect("final credentials directory handle");
871 verify_windows_owner_only_handle(directory).expect("current-user-only directory");
872
873 let lock = store.open_lock_file().expect("create lifecycle lock");
874 validate_owned_file_handle(&lock, &credentials.join(XAI_OAUTH_LIFECYCLE_LOCK_FILE_NAME))
875 .expect("current-user-only lifecycle lock");
876
877 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
878 store
879 .write(generation, b"credential bytes", false)
880 .expect("write secure generation");
881 let generation_file = store
882 .open_owned_file_for_read(generation)
883 .expect("open generation")
884 .expect("generation exists");
885 validate_owned_file_handle(&generation_file, &credentials.join(generation))
886 .expect("current-user-only generation");
887 }
888
889 #[cfg(windows)]
890 #[test]
891 fn windows_store_rejects_reparse_component_and_hardlinked_leaf() {
892 let root = tempfile::tempdir().expect("temp dir");
893 let root = root.path().canonicalize().expect("canonical temp root");
894 let real = root.join("real-home");
895 fs::create_dir(&real).expect("real home");
896 let linked = root.join("linked-home");
897 if std::os::windows::fs::symlink_dir(&real, &linked).is_ok() {
898 assert!(
899 open_owned_credentials_directory(&linked.join("credentials")).is_err(),
900 "owned roots must reject junctions and directory reparse points"
901 );
902 }
903
904 let credentials = real.join("credentials");
905 let store = open_owned_credentials_directory(&credentials).expect("safe store");
906 let generation = "xai-auth-fedcba9876543210fedcba9876543210.json";
907 store
908 .write(generation, b"secret", false)
909 .expect("seed generation");
910 fs::hard_link(
911 credentials.join(generation),
912 credentials.join("attacker-hardlink"),
913 )
914 .expect("hardlink fixture");
915 assert!(
916 store.read_to_string(generation).is_err(),
917 "owned reads must reject multiply-linked credential files"
918 );
919 }
920 }
921
921 lines RUST