返回 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(unix)]
7 use std::ffi::CString;
8 use std::fs::{self, File};
9 use std::io::{Read as _, Write 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 anyhow::{Context, Result, bail};
18
19 pub const XAI_OAUTH_GENERATION_PREFIX: &str = "xai-auth-";
20 pub const XAI_OAUTH_GENERATION_SUFFIX: &str = ".json";
21 pub const LEGACY_XAI_OAUTH_FILE_NAME: &str = "xai-auth.json";
22 pub const CHATGPT_OAUTH_GENERATION_PREFIX: &str = "chatgpt-auth-";
23 pub const CHATGPT_OAUTH_GENERATION_SUFFIX: &str = ".json";
24 pub const LEGACY_CHATGPT_OAUTH_FILE_NAME: &str = "chatgpt-oauth.json";
25 const XAI_OAUTH_LIFECYCLE_LOCK_FILE_NAME: &str = ".xai-oauth.lock";
26 const XAI_OAUTH_FILE_LIMIT: u64 = 1024 * 1024;
27
28 /// Stable handle to Codewhale's private xAI OAuth directory.
29 ///
30 /// The lexical `$CODEWHALE_HOME/credentials` boundary is retained verbatim.
31 /// Unix opens every component relative to the preceding directory with
32 /// `O_NOFOLLOW`; Windows keeps non-delete-shared handles to every component and
33 /// rejects reparse points. Holding this value therefore pins the directory
34 /// identity for the duration of one lifecycle operation.
35 #[derive(Debug)]
36 pub struct XaiOAuthCredentialStore {
37 directory: PathBuf,
38 #[cfg(unix)]
39 directory_handle: File,
40 #[cfg(windows)]
41 _component_handles: Vec<File>,
42 }
43
44 /// Files retired from an active xAI OAuth epoch before a mode switch commits.
45 ///
46 /// Unix hides original names behind private tombstones immediately. Windows
47 /// relies on the lifecycle lock, then deletes exact handles after commit. A
48 /// failed config mutation restores or retains the prior files; a successful
49 /// mutation removes them.
50 #[derive(Debug)]
51 pub struct XaiOAuthRevocation {
52 retired: Vec<(String, String)>,
53 }
54
55 #[must_use]
56 pub fn is_valid_xai_oauth_generation(value: &str) -> bool {
57 is_valid_owned_oauth_generation(
58 value,
59 XAI_OAUTH_GENERATION_PREFIX,
60 XAI_OAUTH_GENERATION_SUFFIX,
61 )
62 }
63
64 pub fn validate_xai_oauth_generation(value: &str) -> Result<&str> {
65 if !is_valid_xai_oauth_generation(value) {
66 bail!(
67 "invalid Codewhale-owned xAI OAuth generation; expected xai-auth-<32 lowercase hex>.json"
68 );
69 }
70 Ok(value)
71 }
72
73 #[must_use]
74 pub fn is_valid_chatgpt_oauth_generation(value: &str) -> bool {
75 is_valid_owned_oauth_generation(
76 value,
77 CHATGPT_OAUTH_GENERATION_PREFIX,
78 CHATGPT_OAUTH_GENERATION_SUFFIX,
79 )
80 }
81
82 pub fn validate_chatgpt_oauth_generation(value: &str) -> Result<&str> {
83 if !is_valid_chatgpt_oauth_generation(value) {
84 bail!(
85 "invalid Codewhale-owned ChatGPT OAuth generation; expected chatgpt-auth-<32 lowercase hex>.json"
86 );
87 }
88 Ok(value)
89 }
90
91 fn is_valid_owned_oauth_generation(value: &str, prefix: &str, suffix: &str) -> bool {
92 let path = Path::new(value);
93 if path.components().count() != 1
94 || !matches!(path.components().next(), Some(Component::Normal(_)))
95 || path.file_name().and_then(|name| name.to_str()) != Some(value)
96 {
97 return false;
98 }
99 let Some(id) = value
100 .strip_prefix(prefix)
101 .and_then(|value| value.strip_suffix(suffix))
102 else {
103 return false;
104 };
105 id.len() == 32
106 && id
107 .bytes()
108 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
109 }
110
111 pub fn xai_oauth_credentials_dir() -> Result<PathBuf> {
112 lexical_absolute_path(&crate::codewhale_home()?.join("credentials"))
113 }
114
115 /// Make an owned path absolute without resolving any filesystem component.
116 /// Canonicalization is deliberately forbidden here: following an existing
117 /// `credentials` symlink would erase the lexical Codewhale-owned boundary and
118 /// turn an external directory into an apparently valid destination.
119 fn lexical_absolute_path(path: &Path) -> Result<PathBuf> {
120 let absolute = if path.is_absolute() {
121 path.to_path_buf()
122 } else {
123 std::env::current_dir()
124 .context("resolving the Codewhale credentials directory")?
125 .join(path)
126 };
127 if absolute
128 .components()
129 .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
130 {
131 bail!(
132 "Codewhale credentials directory must be lexically normalized: {}",
133 crate::quote_os_path(&absolute)
134 );
135 }
136 Ok(absolute)
137 }
138
139 pub fn xai_oauth_generation_path(generation: &str) -> Result<PathBuf> {
140 Ok(xai_oauth_credentials_dir()?.join(validate_xai_oauth_generation(generation)?))
141 }
142
143 pub fn legacy_xai_oauth_path() -> Result<PathBuf> {
144 Ok(xai_oauth_credentials_dir()?.join(LEGACY_XAI_OAUTH_FILE_NAME))
145 }
146
147 pub fn chatgpt_oauth_generation_path(generation: &str) -> Result<PathBuf> {
148 Ok(xai_oauth_credentials_dir()?.join(validate_chatgpt_oauth_generation(generation)?))
149 }
150
151 pub fn legacy_chatgpt_oauth_path() -> Result<PathBuf> {
152 Ok(xai_oauth_credentials_dir()?.join(LEGACY_CHATGPT_OAUTH_FILE_NAME))
153 }
154
155 /// Serialize every Codewhale-owned xAI OAuth lifecycle mutation across threads
156 /// and processes while pinning the lexical credentials directory.
157 ///
158 /// Lock order is always xAI lifecycle first, then config document. Callers must
159 /// not invoke this function recursively.
160 pub fn with_xai_oauth_lifecycle_lock<T>(
161 operation: impl FnOnce(&XaiOAuthCredentialStore) -> Result<T>,
162 ) -> Result<T> {
163 static PROCESS_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
164 let _process_guard = PROCESS_LOCK
165 .get_or_init(|| Mutex::new(()))
166 .lock()
167 .map_err(|_| anyhow::anyhow!("xAI OAuth lifecycle lock was poisoned"))?;
168 let store = XaiOAuthCredentialStore::open()?;
169 let lock_file = store.open_lock_file()?;
170 let mut lock = fd_lock::RwLock::new(lock_file);
171 let _guard = lock.write().with_context(|| {
172 format!(
173 "failed to acquire xAI OAuth lifecycle lock in {}",
174 crate::quote_os_path(store.directory())
175 )
176 })?;
177 operation(&store)
178 }
179
180 /// Run an authority mode switch while the prior owned OAuth epoch is hidden
181 /// from concurrent Codewhale readers. A failed authority mutation restores the
182 /// old files; a successful mutation permanently removes them.
183 pub fn with_xai_oauth_revocation_transaction<T>(
184 operation: impl FnOnce() -> Result<T>,
185 ) -> Result<T> {
186 with_xai_oauth_lifecycle_lock(|store| {
187 let revocation = store.stage_revocation()?;
188 match operation() {
189 Ok(value) => {
190 revocation.commit(store).context(
191 "xAI OAuth authority changed, but retired owned credentials could not be removed",
192 )?;
193 Ok(value)
194 }
195 Err(error) => {
196 if let Err(rollback) = revocation.rollback(store) {
197 return Err(error).context(format!(
198 "also failed to restore the prior xAI OAuth epoch: {rollback:#}"
199 ));
200 }
201 Err(error)
202 }
203 }
204 })
205 }
206
207 impl XaiOAuthCredentialStore {
208 fn open() -> Result<Self> {
209 let directory = xai_oauth_credentials_dir()?;
210 open_owned_credentials_directory(&directory)
211 }
212
213 #[must_use]
214 pub fn directory(&self) -> &Path {
215 &self.directory
216 }
217
218 pub fn path_for(&self, name: &str) -> Result<PathBuf> {
219 validate_owned_auth_name(name)?;
220 Ok(self.directory.join(name))
221 }
222
223 pub fn read_to_string(&self, name: &str) -> Result<Option<String>> {
224 validate_owned_auth_name(name)?;
225 let Some(mut file) = self.open_owned_file_for_read(name)? else {
226 return Ok(None);
227 };
228 let metadata = validate_owned_file_handle(&file, &self.directory.join(name))?;
229 if metadata.len() > XAI_OAUTH_FILE_LIMIT {
230 bail!(
231 "Codewhale-owned xAI OAuth file {} exceeds the {} byte limit",
232 crate::quote_os_path(&self.directory.join(name)),
233 XAI_OAUTH_FILE_LIMIT
234 );
235 }
236 let mut bytes = Vec::with_capacity(metadata.len() as usize);
237 (&mut file)
238 .take(XAI_OAUTH_FILE_LIMIT + 1)
239 .read_to_end(&mut bytes)
240 .with_context(|| {
241 format!(
242 "reading Codewhale-owned xAI OAuth file {}",
243 crate::quote_os_path(&self.directory.join(name))
244 )
245 })?;
246 if bytes.len() as u64 > XAI_OAUTH_FILE_LIMIT {
247 bail!(
248 "Codewhale-owned xAI OAuth file {} exceeds the {} byte limit",
249 crate::quote_os_path(&self.directory.join(name)),
250 XAI_OAUTH_FILE_LIMIT
251 );
252 }
253 String::from_utf8(bytes).map(Some).map_err(|_| {
254 anyhow::anyhow!(
255 "Codewhale-owned xAI OAuth file {} is not valid UTF-8",
256 crate::quote_os_path(&self.directory.join(name))
257 )
258 })
259 }
260
261 pub fn write(&self, name: &str, bytes: &[u8], allow_replace: bool) -> Result<()> {
262 validate_owned_auth_name(name)?;
263 anyhow::ensure!(
264 bytes.len() as u64 <= XAI_OAUTH_FILE_LIMIT,
265 "refusing oversized xAI OAuth credential payload"
266 );
267 self.write_owned_file(name, bytes, allow_replace)
268 }
269
270 pub fn remove(&self, name: &str) -> Result<bool> {
271 validate_owned_auth_name(name)?;
272 self.remove_raw(name)
273 }
274
275 pub fn clear_all(&self) -> Result<usize> {
276 let mut removed = 0;
277 for name in self.owned_auth_names()? {
278 if self.remove(&name)? {
279 removed += 1;
280 }
281 }
282 Ok(removed)
283 }
284
285 pub fn clear_chatgpt(&self) -> Result<usize> {
286 let mut removed = 0;
287 for name in chatgpt_auth_names_in_store(self)? {
288 if self.remove(&name)? {
289 removed += 1;
290 }
291 }
292 Ok(removed)
293 }
294
295 /// Stage every active owned credential before a config mode switch. The
296 /// generation basename is the OAuth epoch; the lifecycle lock prevents a
297 /// stale Codewhale reader from using it while authority changes.
298 pub fn stage_revocation(&self) -> Result<XaiOAuthRevocation> {
299 #[cfg(windows)]
300 {
301 // Every Codewhale reader/writer takes the lifecycle lock, so a
302 // Windows mode switch can retain the exact active basenames until
303 // the config commit succeeds. `commit` then opens each leaf with
304 // DELETE access and marks that exact handle for deletion. This
305 // avoids path-based rename races and makes rollback a no-op.
306 Ok(XaiOAuthRevocation {
307 retired: self
308 .owned_auth_names()?
309 .into_iter()
310 .map(|name| (name, String::new()))
311 .collect(),
312 })
313 }
314
315 #[cfg(not(windows))]
316 {
317 static COUNTER: AtomicU64 = AtomicU64::new(0);
318 let nonce = SystemTime::now()
319 .duration_since(UNIX_EPOCH)
320 .unwrap_or_default()
321 .as_nanos();
322 let mut retired = Vec::new();
323 for (index, name) in self.owned_auth_names()?.into_iter().enumerate() {
324 let tombstone = format!(
325 ".xai-oauth-retired-{}-{nonce}-{}-{index}.tmp",
326 std::process::id(),
327 COUNTER.fetch_add(1, Ordering::Relaxed)
328 );
329 if let Err(error) = self.rename_raw(&name, &tombstone) {
330 let rollback = XaiOAuthRevocation { retired };
331 if let Err(rollback_error) = rollback.rollback(self) {
332 return Err(error).context(format!(
333 "also failed to restore previously retired xAI OAuth files: {rollback_error:#}"
334 ));
335 }
336 return Err(error);
337 }
338 retired.push((name, tombstone));
339 }
340 Ok(XaiOAuthRevocation { retired })
341 }
342 }
343
344 fn owned_auth_names(&self) -> Result<Vec<String>> {
345 owned_auth_names_in_store(self)
346 }
347
348 fn open_lock_file(&self) -> Result<File> {
349 self.open_internal_file(XAI_OAUTH_LIFECYCLE_LOCK_FILE_NAME)
350 }
351 }
352
353 #[cfg(unix)]
354 fn owned_auth_names_in_store(store: &XaiOAuthCredentialStore) -> Result<Vec<String>> {
355 use std::ffi::CStr;
356 use std::os::fd::AsRawFd as _;
357
358 // `fdopendir` consumes its descriptor, so enumerate through a duplicate of
359 // the pinned directory handle. No pathname is resolved after the store is
360 // opened, even if the lexical directory is renamed or replaced.
361 // SAFETY: duplicates the live store descriptor; CLOEXEC keeps it out of children.
362 let duplicated =
363 unsafe { libc::fcntl(store.directory_handle.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
364 if duplicated < 0 {
365 return Err(std::io::Error::last_os_error())
366 .context("duplicating Codewhale credentials directory handle");
367 }
368 // SAFETY: `duplicated` is an owned directory descriptor. `closedir` below
369 // assumes ownership on the successful conversion.
370 let stream = unsafe { libc::fdopendir(duplicated) };
371 if stream.is_null() {
372 let error = std::io::Error::last_os_error();
373 // SAFETY: `fdopendir` failed and therefore did not consume the fd.
374 unsafe { libc::close(duplicated) };
375 return Err(error).context("enumerating Codewhale credentials directory");
376 }
377 let mut names = Vec::new();
378 loop {
379 // SAFETY: `stream` remains live until `closedir`; each returned entry
380 // is valid until the next call and copied before then.
381 let entry = unsafe { libc::readdir(stream) };
382 if entry.is_null() {
383 break;
384 }
385 // SAFETY: POSIX `dirent::d_name` is NUL terminated.
386 let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) };
387 let Ok(name) = name.to_str() else {
388 continue;
389 };
390 if name == LEGACY_XAI_OAUTH_FILE_NAME || is_valid_xai_oauth_generation(name) {
391 names.push(name.to_string());
392 }
393 }
394 // SAFETY: `stream` is still owned and has not previously been closed.
395 if unsafe { libc::closedir(stream) } != 0 {
396 return Err(std::io::Error::last_os_error())
397 .context("closing Codewhale credentials directory enumeration");
398 }
399 names.sort();
400 Ok(names)
401 }
402
403 #[cfg(unix)]
404 fn chatgpt_auth_names_in_store(store: &XaiOAuthCredentialStore) -> Result<Vec<String>> {
405 use std::ffi::CStr;
406 use std::os::fd::AsRawFd as _;
407
408 // SAFETY: duplicates the live store descriptor; CLOEXEC keeps it out of children.
409 let duplicated =
410 unsafe { libc::fcntl(store.directory_handle.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
411 if duplicated < 0 {
412 return Err(std::io::Error::last_os_error())
413 .context("duplicating Codewhale credentials directory handle");
414 }
415 // SAFETY: `duplicated` is an owned directory descriptor. `closedir` below
416 // assumes ownership on the successful conversion.
417 let stream = unsafe { libc::fdopendir(duplicated) };
418 if stream.is_null() {
419 let error = std::io::Error::last_os_error();
420 // SAFETY: `fdopendir` failed and therefore did not consume the fd.
421 unsafe { libc::close(duplicated) };
422 return Err(error).context("enumerating Codewhale credentials directory");
423 }
424 let mut names = Vec::new();
425 loop {
426 // SAFETY: `stream` remains live until `closedir`; each returned entry
427 // is valid until the next call and copied before then.
428 let entry = unsafe { libc::readdir(stream) };
429 if entry.is_null() {
430 break;
431 }
432 // SAFETY: POSIX `dirent::d_name` is NUL terminated.
433 let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) };
434 let Ok(name) = name.to_str() else {
435 continue;
436 };
437 if is_chatgpt_owned_auth_name(name) {
438 names.push(name.to_string());
439 }
440 }
441 // SAFETY: `stream` is still owned and has not previously been closed.
442 if unsafe { libc::closedir(stream) } != 0 {
443 return Err(std::io::Error::last_os_error())
444 .context("closing Codewhale credentials directory enumeration");
445 }
446 names.sort();
447 Ok(names)
448 }
449
450 #[cfg(not(unix))]
451 fn owned_auth_names_in_store(store: &XaiOAuthCredentialStore) -> Result<Vec<String>> {
452 let mut names = Vec::new();
453 let entries = fs::read_dir(&store.directory).with_context(|| {
454 format!(
455 "failed to inspect Codewhale credentials directory {}",
456 crate::quote_os_path(&store.directory)
457 )
458 })?;
459 for entry in entries {
460 let entry = entry.with_context(|| {
461 format!(
462 "failed to inspect Codewhale credentials directory {}",
463 crate::quote_os_path(&store.directory)
464 )
465 })?;
466 let name = entry.file_name();
467 let Some(name) = name.to_str() else {
468 continue;
469 };
470 if name == LEGACY_XAI_OAUTH_FILE_NAME || is_valid_xai_oauth_generation(name) {
471 names.push(name.to_string());
472 }
473 }
474 names.sort();
475 Ok(names)
476 }
477
478 #[cfg(not(unix))]
479 fn chatgpt_auth_names_in_store(store: &XaiOAuthCredentialStore) -> Result<Vec<String>> {
480 let mut names = Vec::new();
481 let entries = fs::read_dir(&store.directory).with_context(|| {
482 format!(
483 "failed to inspect Codewhale credentials directory {}",
484 crate::quote_os_path(&store.directory)
485 )
486 })?;
487 for entry in entries {
488 let entry = entry.with_context(|| {
489 format!(
490 "failed to inspect Codewhale credentials directory {}",
491 crate::quote_os_path(&store.directory)
492 )
493 })?;
494 let name = entry.file_name();
495 let Some(name) = name.to_str() else {
496 continue;
497 };
498 if is_chatgpt_owned_auth_name(name) {
499 names.push(name.to_string());
500 }
501 }
502 names.sort();
503 Ok(names)
504 }
505
506 impl XaiOAuthRevocation {
507 /// Restore the old epoch after a config mutation fails. Restoration is
508 /// fail-closed: an unexpected replacement at an original name is never
509 /// overwritten.
510 pub fn rollback(self, store: &XaiOAuthCredentialStore) -> Result<()> {
511 #[cfg(windows)]
512 {
513 let _ = store;
514 Ok(())
515 }
516 #[cfg(not(windows))]
517 {
518 let mut first_error = None;
519 for (original, tombstone) in self.retired.into_iter().rev() {
520 let result = store.rename_raw(&tombstone, &original).with_context(|| {
521 format!(
522 "restoring retired xAI OAuth file {}",
523 crate::quote_os_path(&store.directory.join(original))
524 )
525 });
526 if result.is_err() && first_error.is_none() {
527 first_error = result.err();
528 }
529 }
530 if let Some(error) = first_error {
531 return Err(error);
532 }
533 Ok(())
534 }
535 }
536
537 /// Permanently remove retired bytes after the replacement config commits.
538 pub fn commit(self, store: &XaiOAuthCredentialStore) -> Result<usize> {
539 let mut removed = 0;
540 for (_original, _tombstone) in self.retired {
541 #[cfg(windows)]
542 let target = _original;
543 #[cfg(not(windows))]
544 let target = _tombstone;
545 if store.remove_raw(&target)? {
546 removed += 1;
547 }
548 }
549 Ok(removed)
550 }
551 }
552
553 fn validate_owned_auth_name(name: &str) -> Result<()> {
554 anyhow::ensure!(
555 name == LEGACY_XAI_OAUTH_FILE_NAME
556 || name == LEGACY_CHATGPT_OAUTH_FILE_NAME
557 || is_valid_xai_oauth_generation(name)
558 || is_valid_chatgpt_oauth_generation(name),
559 "invalid Codewhale-owned OAuth basename"
560 );
561 Ok(())
562 }
563
564 fn is_chatgpt_owned_auth_name(name: &str) -> bool {
565 name == LEGACY_CHATGPT_OAUTH_FILE_NAME || is_valid_chatgpt_oauth_generation(name)
566 }
567
568 fn validate_private_basename(name: &str) -> Result<()> {
569 let path = Path::new(name);
570 anyhow::ensure!(
571 path.components().count() == 1
572 && matches!(path.components().next(), Some(Component::Normal(_)))
573 && path.file_name().and_then(|value| value.to_str()) == Some(name),
574 "xAI OAuth private basename must be one UTF-8 path component"
575 );
576 Ok(())
577 }
578
579 #[cfg(unix)]
580 fn open_owned_credentials_directory(directory: &Path) -> Result<XaiOAuthCredentialStore> {
581 use std::os::fd::FromRawFd as _;
582 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
583
584 anyhow::ensure!(
585 directory.is_absolute(),
586 "xAI OAuth credentials directory must be absolute"
587 );
588 // SAFETY: the literal root path contains no interior NUL and the returned
589 // descriptor is immediately owned by `File`.
590 let root_fd = unsafe {
591 libc::open(
592 c"/".as_ptr(),
593 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
594 )
595 };
596 if root_fd < 0 {
597 return Err(std::io::Error::last_os_error()).context("opening filesystem root");
598 }
599 // SAFETY: `root_fd` is a newly owned descriptor on the success path above.
600 let mut current = unsafe { File::from_raw_fd(root_fd) };
601 for component in directory.components() {
602 let Component::Normal(name) = component else {
603 if matches!(component, Component::RootDir) {
604 continue;
605 }
606 bail!(
607 "Codewhale credentials directory has an unsupported component: {}",
608 crate::quote_os_path(directory)
609 );
610 };
611 let name = cstring_from_os_str(name)?;
612 // SAFETY: parent borrowed from live `current`; `name` outlives the call.
613 let mut fd = unsafe {
614 libc::openat(
615 std::os::fd::AsRawFd::as_raw_fd(&current),
616 name.as_ptr(),
617 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
618 )
619 };
620 if fd < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::NotFound {
621 // SAFETY: both the parent descriptor and component pointer remain
622 // valid for this call. `mkdirat` cannot follow the missing leaf.
623 let created = unsafe {
624 libc::mkdirat(
625 std::os::fd::AsRawFd::as_raw_fd(&current),
626 name.as_ptr(),
627 0o700,
628 )
629 };
630 if created != 0 {
631 let error = std::io::Error::last_os_error();
632 if error.kind() != std::io::ErrorKind::AlreadyExists {
633 return Err(error).with_context(|| {
634 format!(
635 "creating a component of Codewhale credentials directory {}",
636 crate::quote_os_path(directory)
637 )
638 });
639 }
640 }
641 // SAFETY: same stable parent/component arguments as above.
642 fd = unsafe {
643 libc::openat(
644 std::os::fd::AsRawFd::as_raw_fd(&current),
645 name.as_ptr(),
646 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
647 )
648 };
649 }
650 if fd < 0 {
651 return Err(std::io::Error::last_os_error()).with_context(|| {
652 format!(
653 "opening Codewhale credentials directory without following links: {}",
654 crate::quote_os_path(directory)
655 )
656 });
657 }
658 // SAFETY: `fd` is a newly owned descriptor on the success path above.
659 current = unsafe { File::from_raw_fd(fd) };
660 }
661 let metadata = current.metadata().with_context(|| {
662 format!(
663 "inspecting Codewhale credentials directory {}",
664 crate::quote_os_path(directory)
665 )
666 })?;
667 anyhow::ensure!(
668 metadata.is_dir(),
669 "Codewhale credentials path must be a directory"
670 );
671 // SAFETY: geteuid(2) dereferences no pointers.
672 anyhow::ensure!(
673 metadata.uid() == unsafe { libc::geteuid() },
674 "Codewhale credentials directory must be owned by the current user"
675 );
676 current
677 .set_permissions(fs::Permissions::from_mode(0o700))
678 .with_context(|| {
679 format!(
680 "securing Codewhale credentials directory {}",
681 crate::quote_os_path(directory)
682 )
683 })?;
684 Ok(XaiOAuthCredentialStore {
685 directory: directory.to_path_buf(),
686 directory_handle: current,
687 })
688 }
689
690 #[cfg(unix)]
691 fn cstring_from_os_str(value: &std::ffi::OsStr) -> Result<CString> {
692 use std::os::unix::ffi::OsStrExt as _;
693 CString::new(value.as_bytes()).context("owned xAI OAuth path contains an interior NUL")
694 }
695
696 #[cfg(unix)]
697 impl XaiOAuthCredentialStore {
698 fn open_at(&self, name: &str, flags: i32, mode: libc::mode_t) -> Result<Option<File>> {
699 use std::os::fd::AsRawFd as _;
700 use std::os::fd::FromRawFd as _;
701
702 validate_private_basename(name)?;
703 let name = CString::new(name).context("xAI OAuth basename contains an interior NUL")?;
704 // SAFETY: the stable directory descriptor and component pointer remain
705 // valid for the call; a successful descriptor is transferred to File.
706 let fd = unsafe {
707 libc::openat(
708 self.directory_handle.as_raw_fd(),
709 name.as_ptr(),
710 flags | libc::O_CLOEXEC | libc::O_NOFOLLOW,
711 libc::c_uint::from(mode),
712 )
713 };
714 if fd < 0 {
715 let error = std::io::Error::last_os_error();
716 if error.kind() == std::io::ErrorKind::NotFound {
717 return Ok(None);
718 }
719 return Err(error).with_context(|| {
720 format!(
721 "opening Codewhale-owned xAI OAuth path {}",
722 crate::quote_os_path(&self.directory.join(name.to_string_lossy().as_ref()))
723 )
724 });
725 }
726 // SAFETY: `fd` is newly owned on the success path above.
727 Ok(Some(unsafe { File::from_raw_fd(fd) }))
728 }
729
730 fn open_owned_file_for_read(&self, name: &str) -> Result<Option<File>> {
731 self.open_at(name, libc::O_RDONLY, 0)
732 }
733
734 fn open_internal_file(&self, name: &str) -> Result<File> {
735 use std::os::unix::fs::PermissionsExt as _;
736 let file = self
737 .open_at(name, libc::O_RDWR | libc::O_CREAT, 0o600)?
738 .context("xAI OAuth lifecycle lock disappeared while opening")?;
739 validate_owned_file_handle(&file, &self.directory.join(name))?;
740 file.set_permissions(fs::Permissions::from_mode(0o600))?;
741 Ok(file)
742 }
743
744 fn write_owned_file(&self, name: &str, bytes: &[u8], allow_replace: bool) -> Result<()> {
745 use std::os::fd::AsRawFd as _;
746 use std::os::unix::fs::PermissionsExt as _;
747
748 let temp_name = format!(
749 ".xai-oauth-write-{}-{}.tmp",
750 std::process::id(),
751 SystemTime::now()
752 .duration_since(UNIX_EPOCH)
753 .unwrap_or_default()
754 .as_nanos()
755 );
756 let mut temp = self
757 .open_at(
758 &temp_name,
759 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL,
760 0o600,
761 )?
762 .context("creating private xAI OAuth temporary file")?;
763 let result = (|| -> Result<()> {
764 temp.write_all(bytes)
765 .context("writing xAI OAuth temporary file")?;
766 temp.flush().context("flushing xAI OAuth temporary file")?;
767 temp.set_permissions(fs::Permissions::from_mode(0o600))?;
768 temp.sync_all()
769 .context("syncing xAI OAuth temporary file")?;
770
771 let target =
772 CString::new(name).context("xAI OAuth basename contains an interior NUL")?;
773 let temporary =
774 CString::new(temp_name.as_str()).context("temporary basename contains NUL")?;
775 if allow_replace {
776 if let Some(existing) = self.open_owned_file_for_read(name)? {
777 validate_owned_file_handle(&existing, &self.directory.join(name))?;
778 }
779 // SAFETY: both names are relative to the same stable directory
780 // handle; rename is atomic and cannot escape that directory.
781 if unsafe {
782 libc::renameat(
783 self.directory_handle.as_raw_fd(),
784 temporary.as_ptr(),
785 self.directory_handle.as_raw_fd(),
786 target.as_ptr(),
787 )
788 } != 0
789 {
790 return Err(std::io::Error::last_os_error())
791 .context("atomically replacing xAI OAuth credentials");
792 }
793 } else {
794 // `linkat` installs the unique generation without clobbering an
795 // existing path. The temporary link is removed immediately.
796 // SAFETY: all descriptors/names remain valid for both calls.
797 if unsafe {
798 libc::linkat(
799 self.directory_handle.as_raw_fd(),
800 temporary.as_ptr(),
801 self.directory_handle.as_raw_fd(),
802 target.as_ptr(),
803 0,
804 )
805 } != 0
806 {
807 return Err(std::io::Error::last_os_error())
808 .context("installing a new xAI OAuth generation without replacement");
809 }
810 // SAFETY: same descriptor and staging name as the `linkat` above.
811 if unsafe {
812 libc::unlinkat(self.directory_handle.as_raw_fd(), temporary.as_ptr(), 0)
813 } != 0
814 {
815 let error = std::io::Error::last_os_error();
816 // The target and staging name still reference the same
817 // inode. Remove the just-installed target so the generic
818 // error cleanup can safely retire the single remaining
819 // staging link instead of leaving an inert secret with
820 // link count two.
821 // SAFETY: same descriptor; `target` was just installed above.
822 unsafe {
823 libc::unlinkat(self.directory_handle.as_raw_fd(), target.as_ptr(), 0)
824 };
825 return Err(error).context("removing xAI OAuth generation staging link");
826 }
827 }
828 self.directory_handle
829 .sync_all()
830 .context("syncing Codewhale credentials directory")?;
831 Ok(())
832 })();
833 drop(temp);
834 if result.is_err() {
835 let _ = self.remove_raw(&temp_name);
836 }
837 result
838 }
839
840 fn remove_raw(&self, name: &str) -> Result<bool> {
841 use std::os::fd::AsRawFd as _;
842 validate_private_basename(name)?;
843 let Some(file) = self.open_owned_file_for_read(name)? else {
844 return Ok(false);
845 };
846 validate_owned_file_handle(&file, &self.directory.join(name))?;
847 drop(file);
848 let name = CString::new(name).context("xAI OAuth basename contains an interior NUL")?;
849 // SAFETY: the name is one component relative to the stable credentials
850 // directory descriptor and was validated immediately above.
851 if unsafe { libc::unlinkat(self.directory_handle.as_raw_fd(), name.as_ptr(), 0) } != 0 {
852 let error = std::io::Error::last_os_error();
853 if error.kind() == std::io::ErrorKind::NotFound {
854 return Ok(false);
855 }
856 return Err(error).context("removing Codewhale-owned xAI OAuth file");
857 }
858 Ok(true)
859 }
860
861 fn rename_raw(&self, from: &str, to: &str) -> Result<()> {
862 use std::os::fd::AsRawFd as _;
863 validate_private_basename(from)?;
864 validate_private_basename(to)?;
865 let source = self
866 .open_owned_file_for_read(from)?
867 .context("xAI OAuth source disappeared before retirement")?;
868 validate_owned_file_handle(&source, &self.directory.join(from))?;
869 anyhow::ensure!(
870 self.open_owned_file_for_read(to)?.is_none(),
871 "refusing to replace an existing xAI OAuth retirement path"
872 );
873 drop(source);
874 let from = CString::new(from).context("xAI OAuth basename contains an interior NUL")?;
875 let to = CString::new(to).context("xAI OAuth basename contains an interior NUL")?;
876 // SAFETY: both names are one component relative to the same pinned
877 // directory descriptor.
878 if unsafe {
879 libc::renameat(
880 self.directory_handle.as_raw_fd(),
881 from.as_ptr(),
882 self.directory_handle.as_raw_fd(),
883 to.as_ptr(),
884 )
885 } != 0
886 {
887 return Err(std::io::Error::last_os_error()).context("retiring xAI OAuth file");
888 }
889 Ok(())
890 }
891 }
892
893 #[cfg(unix)]
894 fn validate_owned_file_handle(file: &File, path: &Path) -> Result<fs::Metadata> {
895 use std::os::unix::fs::MetadataExt as _;
896 let metadata = file.metadata().with_context(|| {
897 format!(
898 "inspecting Codewhale-owned xAI OAuth file {}",
899 crate::quote_os_path(path)
900 )
901 })?;
902 anyhow::ensure!(metadata.is_file(), "xAI OAuth path must be a regular file");
903 // SAFETY: geteuid(2) dereferences no pointers.
904 anyhow::ensure!(
905 metadata.uid() == unsafe { libc::geteuid() },
906 "xAI OAuth file must be owned by the current user"
907 );
908 anyhow::ensure!(
909 metadata.nlink() == 1,
910 "xAI OAuth file must not have multiple filesystem links"
911 );
912 Ok(metadata)
913 }
914
915 #[cfg(windows)]
916 fn open_owned_credentials_directory(directory: &Path) -> Result<XaiOAuthCredentialStore> {
917 use std::os::windows::fs::OpenOptionsExt as _;
918 use windows_sys::Win32::Storage::FileSystem::{
919 FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ,
920 FILE_SHARE_READ, FILE_SHARE_WRITE, WRITE_DAC, WRITE_OWNER,
921 };
922
923 anyhow::ensure!(
924 directory.is_absolute(),
925 "xAI OAuth credentials directory must be absolute"
926 );
927 let mut current = PathBuf::new();
928 let mut handles = Vec::new();
929 for component in directory.components() {
930 match component {
931 Component::Prefix(prefix) => current.push(prefix.as_os_str()),
932 Component::RootDir => current.push(Path::new(r"\")),
933 Component::Normal(name) => {
934 current.push(name);
935 match fs::create_dir(&current) {
936 Ok(()) => {}
937 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
938 Err(error) => {
939 return Err(error).with_context(|| {
940 format!(
941 "creating a component of Codewhale credentials directory {}",
942 crate::quote_os_path(directory)
943 )
944 });
945 }
946 }
947 let mut options = fs::OpenOptions::new();
948 options
949 .read(true)
950 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
951 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT);
952 let handle = options.open(&current).with_context(|| {
953 format!(
954 "opening Codewhale credentials directory component {}",
955 crate::quote_os_path(&current)
956 )
957 })?;
958 validate_windows_handle_path(&handle, &current, true)?;
959 handles.push(handle);
960 }
961 Component::CurDir | Component::ParentDir => bail!(
962 "Codewhale credentials directory must be lexically normalized: {}",
963 crate::quote_os_path(directory)
964 ),
965 }
966 }
967 anyhow::ensure!(
968 !handles.is_empty(),
969 "Codewhale credentials directory cannot be a volume root"
970 );
971 let mut secure_options = fs::OpenOptions::new();
972 secure_options
973 .access_mode(FILE_GENERIC_READ | WRITE_DAC | WRITE_OWNER)
974 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
975 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT);
976 let final_directory = secure_options.open(directory).with_context(|| {
977 format!(
978 "opening Codewhale credentials directory for owner-only security: {}",
979 crate::quote_os_path(directory)
980 )
981 })?;
982 validate_windows_handle_path(&final_directory, directory, true)?;
983 secure_windows_owner_only_handle(&final_directory, true)
984 .context("securing Codewhale credentials directory for the current user")?;
985 verify_windows_owner_only_handle(&final_directory)
986 .context("verifying Codewhale credentials directory ownership")?;
987 handles.push(final_directory);
988 Ok(XaiOAuthCredentialStore {
989 directory: directory.to_path_buf(),
990 _component_handles: handles,
991 })
992 }
993
994 #[cfg(windows)]
995 impl XaiOAuthCredentialStore {
996 fn open_windows_file(&self, name: &str, read: bool, write: bool) -> Result<Option<File>> {
997 use std::os::windows::fs::OpenOptionsExt as _;
998 use windows_sys::Win32::Storage::FileSystem::{
999 FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE,
1000 };
1001
1002 validate_private_basename(name)?;
1003 let path = self.directory.join(name);
1004 let mut options = fs::OpenOptions::new();
1005 options
1006 .read(read)
1007 .write(write)
1008 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
1009 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
1010 match options.open(&path) {
1011 Ok(file) => {
1012 validate_owned_file_handle(&file, &path)?;
1013 Ok(Some(file))
1014 }
1015 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1016 Err(error) => Err(error).with_context(|| {
1017 format!(
1018 "opening Codewhale-owned xAI OAuth path {}",
1019 crate::quote_os_path(&path)
1020 )
1021 }),
1022 }
1023 }
1024
1025 fn open_owned_file_for_read(&self, name: &str) -> Result<Option<File>> {
1026 self.open_windows_file(name, true, false)
1027 }
1028
1029 fn open_internal_file(&self, name: &str) -> Result<File> {
1030 use std::os::windows::fs::OpenOptionsExt as _;
1031 use windows_sys::Win32::Storage::FileSystem::{
1032 DELETE, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
1033 FILE_SHARE_READ, FILE_SHARE_WRITE, WRITE_DAC, WRITE_OWNER,
1034 };
1035
1036 validate_private_basename(name)?;
1037 let path = self.directory.join(name);
1038 for _ in 0..8 {
1039 if let Some(existing) = self.open_windows_file(name, true, true)? {
1040 return Ok(existing);
1041 }
1042
1043 let mut options = fs::OpenOptions::new();
1044 options
1045 // `access_mode` supplies the exact Win32 access mask below,
1046 // while Rust still requires the portable write intent to be
1047 // set before it permits `create_new`.
1048 .write(true)
1049 .access_mode(
1050 FILE_GENERIC_READ | FILE_GENERIC_WRITE | WRITE_DAC | WRITE_OWNER | DELETE,
1051 )
1052 .create_new(true)
1053 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
1054 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
1055 let file = match options.open(&path) {
1056 Ok(file) => file,
1057 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1058 Err(error) => {
1059 return Err(error).with_context(|| {
1060 format!(
1061 "creating Codewhale-owned xAI OAuth lifecycle lock {}",
1062 crate::quote_os_path(&path)
1063 )
1064 });
1065 }
1066 };
1067 let secured = (|| -> Result<()> {
1068 validate_windows_file_shape(&file, &path)?;
1069 secure_windows_owner_only_handle(&file, false)
1070 .context("securing a new xAI OAuth lifecycle lock")?;
1071 validate_owned_file_handle(&file, &path)?;
1072 Ok(())
1073 })();
1074 if let Err(error) = secured {
1075 let cleanup = mark_windows_file_handle_for_deletion(&file);
1076 return match cleanup {
1077 Ok(()) => Err(error),
1078 Err(cleanup) => Err(error).context(format!(
1079 "also failed to delete the empty lifecycle lock: {cleanup:#}"
1080 )),
1081 };
1082 }
1083 return Ok(file);
1084 }
1085 bail!("xAI OAuth lifecycle lock changed repeatedly while opening")
1086 }
1087
1088 fn write_owned_file(&self, name: &str, bytes: &[u8], allow_replace: bool) -> Result<()> {
1089 let path = self.directory.join(name);
1090 if let Some(existing) = self.open_owned_file_for_read(name)? {
1091 anyhow::ensure!(
1092 allow_replace,
1093 "refusing to replace an existing xAI OAuth generation"
1094 );
1095 drop(existing);
1096 }
1097 let mut temporary = tempfile::NamedTempFile::new_in(&self.directory)
1098 .context("creating private xAI OAuth temporary file")?;
1099 let temporary_path = temporary.path().to_path_buf();
1100 let security_handle =
1101 reopen_windows_file_for_owner_security(temporary.as_file(), &temporary_path)?;
1102 secure_windows_owner_only_handle(&security_handle, false)
1103 .context("securing a new xAI OAuth temporary file before writing credentials")?;
1104 validate_owned_file_handle(&security_handle, &temporary_path)
1105 .context("verifying a new xAI OAuth temporary file before writing credentials")?;
1106 let write_result = (|| -> Result<()> {
1107 temporary
1108 .write_all(bytes)
1109 .context("writing xAI OAuth temporary file")?;
1110 temporary
1111 .flush()
1112 .context("flushing xAI OAuth temporary file")?;
1113 temporary
1114 .as_file()
1115 .sync_all()
1116 .context("syncing xAI OAuth temporary file")?;
1117 Ok(())
1118 })();
1119 if let Err(error) = write_result {
1120 return Err(cleanup_windows_secret_after_error(
1121 &security_handle,
1122 error,
1123 "temporary file",
1124 ));
1125 }
1126 let persisted = if allow_replace {
1127 match temporary.persist(&path) {
1128 Ok(file) => file,
1129 Err(error) => {
1130 let tempfile::PersistError { error, file } = error;
1131 let persistence_error = anyhow::Error::new(error)
1132 .context("atomically replacing xAI OAuth credentials");
1133 let error = cleanup_windows_secret_after_error(
1134 &security_handle,
1135 persistence_error,
1136 "temporary file",
1137 );
1138 drop(file);
1139 return Err(error);
1140 }
1141 }
1142 } else {
1143 match temporary.persist_noclobber(&path) {
1144 Ok(file) => file,
1145 Err(error) => {
1146 let tempfile::PersistError { error, file } = error;
1147 let persistence_error = anyhow::Error::new(error)
1148 .context("installing a new xAI OAuth generation without replacement");
1149 let error = cleanup_windows_secret_after_error(
1150 &security_handle,
1151 persistence_error,
1152 "temporary file",
1153 );
1154 drop(file);
1155 return Err(error);
1156 }
1157 }
1158 };
1159 if let Err(error) = validate_persisted_windows_owned_file(&persisted, &path) {
1160 // MoveFileEx has already published this exact object. Delete it by
1161 // handle rather than trusting the pathname again. For a refresh
1162 // replacement this can leave the unchanged config pointer missing;
1163 // that fail-closed availability outcome is safer than retaining a
1164 // generation that failed the post-publication invariant check.
1165 return Err(cleanup_windows_secret_after_error(
1166 &security_handle,
1167 error,
1168 "rejected generation",
1169 ));
1170 }
1171 Ok(())
1172 }
1173
1174 fn remove_raw(&self, name: &str) -> Result<bool> {
1175 use std::os::windows::fs::OpenOptionsExt as _;
1176 use windows_sys::Win32::Storage::FileSystem::{
1177 DELETE, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_SHARE_DELETE,
1178 FILE_SHARE_READ, FILE_SHARE_WRITE,
1179 };
1180
1181 validate_private_basename(name)?;
1182 let path = self.directory.join(name);
1183 let mut options = fs::OpenOptions::new();
1184 options
1185 .access_mode(FILE_GENERIC_READ | DELETE)
1186 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
1187 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
1188 let file = match options.open(&path) {
1189 Ok(file) => file,
1190 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1191 Err(error) => return Err(error).context("opening xAI OAuth file for exact deletion"),
1192 };
1193 validate_owned_file_handle(&file, &path)?;
1194 mark_windows_file_handle_for_deletion(&file)?;
1195 drop(file);
1196 Ok(true)
1197 }
1198 }
1199
1200 #[cfg(windows)]
1201 fn reopen_windows_file_for_owner_security(file: &File, path: &Path) -> Result<File> {
1202 use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _};
1203 use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
1204 use windows_sys::Win32::Storage::FileSystem::{
1205 DELETE, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
1206 FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, ReOpenFile, WRITE_DAC, WRITE_OWNER,
1207 };
1208
1209 // ReOpenFile derives a new handle from the already-created temporary file,
1210 // so no pathname can be substituted between creation and hardening.
1211 // SAFETY: `file` is live; no output pointers passed.
1212 let handle = unsafe {
1213 ReOpenFile(
1214 file.as_raw_handle(),
1215 FILE_GENERIC_READ | FILE_GENERIC_WRITE | WRITE_DAC | WRITE_OWNER | DELETE,
1216 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
1217 FILE_FLAG_OPEN_REPARSE_POINT,
1218 )
1219 };
1220 if handle == INVALID_HANDLE_VALUE {
1221 return Err(std::io::Error::last_os_error())
1222 .context("reopening a new xAI OAuth temporary file for owner-only security");
1223 }
1224 // SAFETY: ReOpenFile returned a newly owned handle on the success path.
1225 let reopened = unsafe { File::from_raw_handle(handle) };
1226 validate_windows_file_shape(&reopened, path)?;
1227 Ok(reopened)
1228 }
1229
1230 #[cfg(windows)]
1231 fn mark_windows_file_handle_for_deletion(file: &File) -> Result<()> {
1232 use std::os::windows::io::AsRawHandle as _;
1233 use windows_sys::Win32::Storage::FileSystem::{
1234 FILE_DISPOSITION_INFO, FileDispositionInfo, SetFileInformationByHandle,
1235 };
1236
1237 let disposition = FILE_DISPOSITION_INFO { DeleteFile: true };
1238 // SAFETY: the disposition buffer has the documented structure and the
1239 // handle remains owned until after the call. Windows marks this exact file
1240 // object delete-pending rather than resolving the path again.
1241 if unsafe {
1242 SetFileInformationByHandle(
1243 file.as_raw_handle(),
1244 FileDispositionInfo,
1245 (&raw const disposition).cast(),
1246 std::mem::size_of::<FILE_DISPOSITION_INFO>() as u32,
1247 )
1248 } == 0
1249 {
1250 return Err(std::io::Error::last_os_error())
1251 .context("marking exact xAI OAuth file handle for deletion");
1252 }
1253 Ok(())
1254 }
1255
1256 #[cfg(windows)]
1257 fn cleanup_windows_secret_after_error(
1258 file: &File,
1259 error: anyhow::Error,
1260 label: &str,
1261 ) -> anyhow::Error {
1262 match mark_windows_file_handle_for_deletion(file) {
1263 Ok(()) => error,
1264 Err(cleanup) => error.context(format!(
1265 "also failed to delete the xAI OAuth {label} by exact handle: {cleanup:#}"
1266 )),
1267 }
1268 }
1269
1270 #[cfg(all(windows, test))]
1271 static WINDOWS_POST_PERSIST_VALIDATION_FAILURE: Mutex<Option<PathBuf>> = Mutex::new(None);
1272
1273 #[cfg(windows)]
1274 fn validate_persisted_windows_owned_file(file: &File, path: &Path) -> Result<fs::Metadata> {
1275 #[cfg(test)]
1276 {
1277 let mut injected = WINDOWS_POST_PERSIST_VALIDATION_FAILURE
1278 .lock()
1279 .unwrap_or_else(std::sync::PoisonError::into_inner);
1280 if injected.as_deref() == Some(path) {
1281 *injected = None;
1282 bail!("injected post-persistence xAI OAuth validation failure");
1283 }
1284 }
1285 validate_owned_file_handle(file, path)
1286 }
1287
1288 #[cfg(all(windows, test))]
1289 fn fail_next_windows_post_persist_validation(path: &Path) {
1290 *WINDOWS_POST_PERSIST_VALIDATION_FAILURE
1291 .lock()
1292 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(path.to_path_buf());
1293 }
1294
1295 #[cfg(windows)]
1296 fn validate_owned_file_handle(file: &File, path: &Path) -> Result<fs::Metadata> {
1297 let metadata = validate_windows_file_shape(file, path)?;
1298 verify_windows_owner_only_handle(file)
1299 .context("Codewhale-owned xAI OAuth file is not current-user-only")?;
1300 Ok(metadata)
1301 }
1302
1303 #[cfg(windows)]
1304 fn validate_windows_file_shape(file: &File, path: &Path) -> Result<fs::Metadata> {
1305 use std::os::windows::io::AsRawHandle as _;
1306 use windows_sys::Win32::Storage::FileSystem::{
1307 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
1308 };
1309
1310 let metadata = validate_windows_handle_path(file, path, false)?;
1311 let mut information = BY_HANDLE_FILE_INFORMATION::default();
1312 // SAFETY: both pointers remain valid for the duration of the call.
1313 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
1314 return Err(std::io::Error::last_os_error())
1315 .context("inspecting xAI OAuth file link count");
1316 }
1317 anyhow::ensure!(
1318 information.nNumberOfLinks == 1,
1319 "xAI OAuth file must not have multiple filesystem links"
1320 );
1321 Ok(metadata)
1322 }
1323
1324 #[cfg(windows)]
1325 fn validate_windows_handle_path(
1326 file: &File,
1327 expected: &Path,
1328 expect_directory: bool,
1329 ) -> Result<fs::Metadata> {
1330 use std::ffi::OsString;
1331 use std::os::windows::ffi::OsStringExt as _;
1332 use std::os::windows::fs::MetadataExt as _;
1333 use std::os::windows::io::AsRawHandle as _;
1334 use windows_sys::Win32::Storage::FileSystem::{
1335 FILE_ATTRIBUTE_REPARSE_POINT, FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW,
1336 VOLUME_NAME_DOS,
1337 };
1338
1339 let metadata = file.metadata().with_context(|| {
1340 format!(
1341 "inspecting Codewhale-owned path {}",
1342 crate::quote_os_path(expected)
1343 )
1344 })?;
1345 anyhow::ensure!(
1346 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0,
1347 "Codewhale-owned xAI OAuth path must not be a reparse point"
1348 );
1349 anyhow::ensure!(
1350 if expect_directory {
1351 metadata.is_dir()
1352 } else {
1353 metadata.is_file()
1354 },
1355 "Codewhale-owned xAI OAuth path has the wrong filesystem type"
1356 );
1357 let flags = FILE_NAME_NORMALIZED | VOLUME_NAME_DOS;
1358 let handle = file.as_raw_handle();
1359 // SAFETY: null output asks only for the required UTF-16 length.
1360 let needed = unsafe { GetFinalPathNameByHandleW(handle, std::ptr::null_mut(), 0, flags) };
1361 if needed == 0 {
1362 return Err(std::io::Error::last_os_error())
1363 .context("resolving Codewhale-owned xAI OAuth handle path");
1364 }
1365 let mut buffer = vec![0u16; needed as usize + 1];
1366 // SAFETY: the buffer is writable and the handle remains valid.
1367 let written = unsafe {
1368 GetFinalPathNameByHandleW(handle, buffer.as_mut_ptr(), buffer.len() as u32, flags)
1369 };
1370 if written == 0 || written as usize >= buffer.len() {
1371 return Err(std::io::Error::last_os_error())
1372 .context("resolving Codewhale-owned xAI OAuth handle path");
1373 }
1374 let actual = OsString::from_wide(&buffer[..written as usize]);
1375 anyhow::ensure!(
1376 normalize_windows_path_for_comparison(Path::new(&actual))?
1377 == normalize_windows_path_for_comparison(expected)?,
1378 "Codewhale-owned xAI OAuth path was redirected while opening"
1379 );
1380 Ok(metadata)
1381 }
1382
1383 #[cfg(windows)]
1384 fn normalize_windows_path_for_comparison(path: &Path) -> Result<String> {
1385 let text = path.to_str().ok_or_else(|| {
1386 anyhow::anyhow!(
1387 "xAI OAuth path {} contains invalid Unicode and cannot be compared safely",
1388 crate::quote_os_path(path)
1389 )
1390 })?;
1391 let without_device_prefix = text.strip_prefix(r"\\?\").unwrap_or(text);
1392 let normalized_prefix = without_device_prefix.strip_prefix("UNC\\").map_or_else(
1393 || without_device_prefix.to_string(),
1394 |rest| format!(r"\\{rest}"),
1395 );
1396 Ok(normalized_prefix
1397 .replace('/', "\\")
1398 .trim_end_matches('\\')
1399 .to_lowercase())
1400 }
1401
1402 #[cfg(windows)]
1403 fn secure_windows_owner_only_handle(file: &File, inherit_to_children: bool) -> Result<()> {
1404 use std::os::windows::io::AsRawHandle as _;
1405 use windows_sys::Win32::Foundation::ERROR_SUCCESS;
1406 use windows_sys::Win32::Security::Authorization::{
1407 EXPLICIT_ACCESS_W, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, SetSecurityInfo,
1408 TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
1409 };
1410 use windows_sys::Win32::Security::{
1411 DACL_SECURITY_INFORMATION, NO_INHERITANCE, OWNER_SECURITY_INFORMATION,
1412 PROTECTED_DACL_SECURITY_INFORMATION, SUB_CONTAINERS_AND_OBJECTS_INHERIT,
1413 };
1414 use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
1415
1416 let user = CurrentWindowsUser::open()?;
1417 let entry = EXPLICIT_ACCESS_W {
1418 grfAccessPermissions: FILE_ALL_ACCESS,
1419 grfAccessMode: SET_ACCESS,
1420 grfInheritance: if inherit_to_children {
1421 SUB_CONTAINERS_AND_OBJECTS_INHERIT
1422 } else {
1423 NO_INHERITANCE
1424 },
1425 Trustee: TRUSTEE_W {
1426 pMultipleTrustee: std::ptr::null_mut(),
1427 MultipleTrusteeOperation: 0,
1428 TrusteeForm: TRUSTEE_IS_SID,
1429 TrusteeType: TRUSTEE_IS_USER,
1430 ptstrName: user.sid().cast::<u16>(),
1431 },
1432 };
1433 let mut acl = std::ptr::null_mut();
1434 // SAFETY: `entry` and the returned ACL remain live through the following
1435 // handle-relative security update.
1436 let result = unsafe { SetEntriesInAclW(1, &raw const entry, std::ptr::null(), &mut acl) };
1437 if result != ERROR_SUCCESS {
1438 return Err(std::io::Error::from_raw_os_error(result as i32))
1439 .context("building a current-user-only DACL for Codewhale-owned xAI OAuth storage");
1440 }
1441 let _acl = WindowsLocalAllocation(acl.cast());
1442 // SAFETY: the file handle remains owned by `file`, and the ACL remains
1443 // allocated for the duration of the call. The owner and protected DACL are
1444 // committed together so the verifier never observes a half-secured file.
1445 let result = unsafe {
1446 SetSecurityInfo(
1447 file.as_raw_handle(),
1448 SE_FILE_OBJECT,
1449 OWNER_SECURITY_INFORMATION
1450 | DACL_SECURITY_INFORMATION
1451 | PROTECTED_DACL_SECURITY_INFORMATION,
1452 user.sid(),
1453 std::ptr::null_mut(),
1454 acl,
1455 std::ptr::null(),
1456 )
1457 };
1458 if result != ERROR_SUCCESS {
1459 return Err(std::io::Error::from_raw_os_error(result as i32))
1460 .context("applying a current-user-only DACL to Codewhale-owned xAI OAuth storage");
1461 }
1462 Ok(())
1463 }
1464
1465 #[cfg(windows)]
1466 fn verify_windows_owner_only_handle(file: &File) -> Result<()> {
1467 use std::os::windows::io::AsRawHandle as _;
1468 use windows_sys::Win32::Foundation::ERROR_SUCCESS;
1469 use windows_sys::Win32::Security::Authorization::{
1470 EXPLICIT_ACCESS_W, GRANT_ACCESS, GetExplicitEntriesFromAclW, GetSecurityInfo,
1471 SE_FILE_OBJECT, SET_ACCESS, TRUSTEE_IS_SID,
1472 };
1473 use windows_sys::Win32::Security::{
1474 ACL, DACL_SECURITY_INFORMATION, EqualSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR,
1475 PSID,
1476 };
1477 use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
1478
1479 let user = CurrentWindowsUser::open()?;
1480 let mut owner: PSID = std::ptr::null_mut();
1481 let mut dacl: *mut ACL = std::ptr::null_mut();
1482 let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
1483 // SAFETY: the handle remains valid and all output pointers are writable.
1484 let result = unsafe {
1485 GetSecurityInfo(
1486 file.as_raw_handle(),
1487 SE_FILE_OBJECT,
1488 OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
1489 &mut owner,
1490 std::ptr::null_mut(),
1491 &mut dacl,
1492 std::ptr::null_mut(),
1493 &mut descriptor,
1494 )
1495 };
1496 if result != ERROR_SUCCESS {
1497 return Err(std::io::Error::from_raw_os_error(result as i32))
1498 .context("reading Codewhale-owned xAI OAuth security descriptor");
1499 }
1500 let _descriptor = WindowsLocalAllocation(descriptor.cast());
1501 // SAFETY: `owner` is non-null; `user.sid()` is owned by `user`.
1502 anyhow::ensure!(
1503 !owner.is_null() && unsafe { EqualSid(owner, user.sid()) } != 0,
1504 "Codewhale-owned xAI OAuth storage owner is not the current user"
1505 );
1506 anyhow::ensure!(
1507 !dacl.is_null(),
1508 "Codewhale-owned xAI OAuth storage must have an owner-only DACL"
1509 );
1510 let mut count = 0;
1511 let mut entries: *mut EXPLICIT_ACCESS_W = std::ptr::null_mut();
1512 // SAFETY: `dacl` belongs to the live descriptor; Windows allocates the
1513 // returned entry array, released by the guard below.
1514 let result = unsafe { GetExplicitEntriesFromAclW(dacl, &mut count, &mut entries) };
1515 if result != ERROR_SUCCESS {
1516 return Err(std::io::Error::from_raw_os_error(result as i32))
1517 .context("reading Codewhale-owned xAI OAuth DACL entries");
1518 }
1519 let _entries = WindowsLocalAllocation(entries.cast());
1520 anyhow::ensure!(
1521 count == 1 && !entries.is_null(),
1522 "Codewhale-owned xAI OAuth DACL must grant only one user"
1523 );
1524 // SAFETY: `count == 1` proves the first returned entry is initialized.
1525 let entry = unsafe { &*entries };
1526 let trustee_sid: PSID = entry.Trustee.ptstrName.cast();
1527 // SAFETY: form and null checked in this expression; sid owned by `user`.
1528 anyhow::ensure!(
1529 entry.Trustee.TrusteeForm == TRUSTEE_IS_SID
1530 && !trustee_sid.is_null()
1531 && unsafe { EqualSid(trustee_sid, user.sid()) } != 0
1532 && matches!(entry.grfAccessMode, SET_ACCESS | GRANT_ACCESS)
1533 && entry.grfAccessPermissions == FILE_ALL_ACCESS,
1534 "Codewhale-owned xAI OAuth DACL is not current-user-only"
1535 );
1536 Ok(())
1537 }
1538
1539 #[cfg(windows)]
1540 struct CurrentWindowsUser {
1541 token: windows_sys::Win32::Foundation::HANDLE,
1542 token_info: Vec<usize>,
1543 }
1544
1545 #[cfg(windows)]
1546 impl CurrentWindowsUser {
1547 fn open() -> Result<Self> {
1548 use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, HANDLE};
1549 use windows_sys::Win32::Security::{
1550 GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser,
1551 };
1552 use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
1553
1554 let mut token: HANDLE = std::ptr::null_mut();
1555 // SAFETY: the pseudo-process handle is valid and `token` is writable.
1556 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
1557 return Err(std::io::Error::last_os_error())
1558 .context("opening current Windows user token");
1559 }
1560 let mut needed = 0;
1561 // SAFETY: a null buffer/zero length asks for the required size.
1562 let _ =
1563 unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed) };
1564 if needed == 0 {
1565 let error = std::io::Error::from_raw_os_error(unsafe { GetLastError() } as i32);
1566 // SAFETY: the token is owned on this error path.
1567 unsafe { CloseHandle(token) };
1568 return Err(error).context("sizing current Windows user token information");
1569 }
1570 let words = (needed as usize).div_ceil(std::mem::size_of::<usize>());
1571 let mut token_info = vec![0usize; words];
1572 // SAFETY: the aligned buffer contains at least `needed` writable bytes.
1573 if unsafe {
1574 GetTokenInformation(
1575 token,
1576 TokenUser,
1577 token_info.as_mut_ptr().cast(),
1578 needed,
1579 &mut needed,
1580 )
1581 } == 0
1582 {
1583 let error = std::io::Error::last_os_error();
1584 // SAFETY: the token is owned on this error path.
1585 unsafe { CloseHandle(token) };
1586 return Err(error).context("reading current Windows user token information");
1587 }
1588 let user = unsafe { &*token_info.as_ptr().cast::<TOKEN_USER>() };
1589 if user.User.Sid.is_null() {
1590 // SAFETY: the token is owned on this error path.
1591 unsafe { CloseHandle(token) };
1592 bail!("current Windows user token has no SID");
1593 }
1594 Ok(Self { token, token_info })
1595 }
1596
1597 fn sid(&self) -> windows_sys::Win32::Security::PSID {
1598 use windows_sys::Win32::Security::TOKEN_USER;
1599 // SAFETY: the aligned token buffer remains owned by `self`.
1600 unsafe { (*self.token_info.as_ptr().cast::<TOKEN_USER>()).User.Sid }
1601 }
1602 }
1603
1604 #[cfg(windows)]
1605 impl Drop for CurrentWindowsUser {
1606 fn drop(&mut self) {
1607 // SAFETY: `token` is owned by this guard and closed exactly once.
1608 unsafe { windows_sys::Win32::Foundation::CloseHandle(self.token) };
1609 }
1610 }
1611
1612 #[cfg(windows)]
1613 struct WindowsLocalAllocation(*mut core::ffi::c_void);
1614
1615 #[cfg(windows)]
1616 impl Drop for WindowsLocalAllocation {
1617 fn drop(&mut self) {
1618 if !self.0.is_null() {
1619 // SAFETY: Windows allocated this block for a LocalFree caller.
1620 unsafe { windows_sys::Win32::Foundation::LocalFree(self.0) };
1621 }
1622 }
1623 }
1624
1625 #[cfg(not(any(unix, windows)))]
1626 fn open_owned_credentials_directory(directory: &Path) -> Result<XaiOAuthCredentialStore> {
1627 fs::create_dir_all(directory)?;
1628 let metadata = fs::symlink_metadata(directory)?;
1629 anyhow::ensure!(
1630 metadata.is_dir(),
1631 "Codewhale credentials path must be a directory"
1632 );
1633 Ok(XaiOAuthCredentialStore {
1634 directory: directory.to_path_buf(),
1635 })
1636 }
1637
1638 #[cfg(not(any(unix, windows)))]
1639 impl XaiOAuthCredentialStore {
1640 fn open_owned_file_for_read(&self, name: &str) -> Result<Option<File>> {
1641 validate_private_basename(name)?;
1642 match File::open(self.directory.join(name)) {
1643 Ok(file) => Ok(Some(file)),
1644 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1645 Err(error) => Err(error.into()),
1646 }
1647 }
1648
1649 fn open_internal_file(&self, name: &str) -> Result<File> {
1650 validate_private_basename(name)?;
1651 Ok(fs::OpenOptions::new()
1652 .read(true)
1653 .write(true)
1654 .create(true)
1655 .open(self.directory.join(name))?)
1656 }
1657
1658 fn write_owned_file(&self, name: &str, bytes: &[u8], allow_replace: bool) -> Result<()> {
1659 let path = self.directory.join(name);
1660 anyhow::ensure!(
1661 allow_replace || !path.exists(),
1662 "refusing to replace xAI OAuth generation"
1663 );
1664 crate::persistence::atomic_write(&path, bytes)
1665 }
1666
1667 fn remove_raw(&self, name: &str) -> Result<bool> {
1668 validate_private_basename(name)?;
1669 match fs::remove_file(self.directory.join(name)) {
1670 Ok(()) => Ok(true),
1671 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1672 Err(error) => Err(error.into()),
1673 }
1674 }
1675
1676 fn rename_raw(&self, from: &str, to: &str) -> Result<()> {
1677 validate_private_basename(from)?;
1678 validate_private_basename(to)?;
1679 fs::rename(self.directory.join(from), self.directory.join(to))?;
1680 Ok(())
1681 }
1682 }
1683
1684 #[cfg(not(any(unix, windows)))]
1685 fn validate_owned_file_handle(file: &File, _path: &Path) -> Result<fs::Metadata> {
1686 let metadata = file.metadata()?;
1687 anyhow::ensure!(metadata.is_file(), "xAI OAuth path must be a regular file");
1688 Ok(metadata)
1689 }
1690
1691 /// Delete one superseded generation after its replacement pointer committed.
1692 /// The basename is validated before any filesystem access.
1693 pub fn remove_xai_oauth_generation(generation: &str) -> Result<bool> {
1694 let generation = validate_xai_oauth_generation(generation)?;
1695 with_xai_oauth_lifecycle_lock(|store| store.remove(generation))
1696 }
1697
1698 /// Explicit logout policy: remove the legacy Codewhale-owned file and every
1699 /// valid generated xAI OAuth file. Unknown files in the credentials directory
1700 /// are never touched.
1701 pub fn clear_all_xai_oauth_credentials() -> Result<usize> {
1702 with_xai_oauth_lifecycle_lock(XaiOAuthCredentialStore::clear_all)
1703 }
1704
1705 pub fn clear_all_chatgpt_oauth_credentials() -> Result<usize> {
1706 with_xai_oauth_lifecycle_lock(XaiOAuthCredentialStore::clear_chatgpt)
1707 }
1708
1709 /// Clears every Codewhale-owned ChatGPT OAuth file without re-entering the
1710 /// lifecycle lock. Only for code already running inside
1711 /// [`with_xai_oauth_lifecycle_lock`] or [`with_xai_oauth_revocation_transaction`];
1712 /// everyone else must call [`clear_all_chatgpt_oauth_credentials`], which
1713 /// takes the lock. The lifecycle lock is a non-reentrant in-process mutex, so
1714 /// a nested call would deadlock the logout path (seen as CI hangs on the
1715 /// `logout_*` tests).
1716 pub fn clear_all_chatgpt_oauth_credentials_locked() -> Result<usize> {
1717 let store = XaiOAuthCredentialStore::open()?;
1718 XaiOAuthCredentialStore::clear_chatgpt(&store)
1719 }
1720
1721 pub fn remove_chatgpt_oauth_generation(generation: &str) -> Result<bool> {
1722 let generation = validate_chatgpt_oauth_generation(generation)?;
1723 with_xai_oauth_lifecycle_lock(|store| store.remove(generation))
1724 }
1725
1726 #[cfg(test)]
1727 mod tests {
1728 use super::*;
1729
1730 #[test]
1731 fn credentials_directory_preserves_lexical_identity() {
1732 let directory = tempfile::tempdir().expect("temp dir");
1733 let lexical = directory.path().join("missing").join("credentials");
1734 assert_eq!(
1735 lexical_absolute_path(&lexical).expect("preserve lexical path"),
1736 lexical
1737 );
1738 assert!(!lexical.exists());
1739 assert!(
1740 lexical_absolute_path(&directory.path().join("missing/../escape")).is_err(),
1741 "owned credential roots must reject traversal components"
1742 );
1743 }
1744
1745 #[test]
1746 fn generation_names_are_strict_basenames() {
1747 let valid = "xai-auth-0123456789abcdef0123456789abcdef.json";
1748 assert!(is_valid_xai_oauth_generation(valid));
1749 for invalid in [
1750 "../xai-auth-0123456789abcdef0123456789abcdef.json",
1751 "/tmp/xai-auth-0123456789abcdef0123456789abcdef.json",
1752 "xai-auth-0123456789ABCDEF0123456789ABCDEF.json",
1753 "xai-auth-short.json",
1754 "xai-auth.json",
1755 ] {
1756 assert!(!is_valid_xai_oauth_generation(invalid), "{invalid}");
1757 }
1758 let chatgpt = "chatgpt-auth-0123456789abcdef0123456789abcdef.json";
1759 assert!(is_valid_chatgpt_oauth_generation(chatgpt));
1760 assert!(!is_valid_chatgpt_oauth_generation(valid));
1761 assert!(!is_valid_xai_oauth_generation(chatgpt));
1762 assert!(!is_valid_chatgpt_oauth_generation(
1763 "../chatgpt-auth-0123456789abcdef0123456789abcdef.json"
1764 ));
1765 }
1766
1767 #[test]
1768 fn logout_cleanup_removes_only_owned_xai_files() {
1769 let directory = tempfile::tempdir().expect("temp dir");
1770 let directory = directory.path().canonicalize().expect("canonical temp dir");
1771 let store = open_owned_credentials_directory(&directory).expect("open store");
1772 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
1773 store
1774 .write(generation, b"secret", false)
1775 .expect("generation");
1776 store
1777 .write(LEGACY_XAI_OAUTH_FILE_NAME, b"legacy", false)
1778 .expect("legacy");
1779 fs::write(directory.join("other-provider.json"), "keep").expect("other provider");
1780
1781 assert_eq!(store.clear_all().expect("clear"), 2);
1782 assert!(directory.join("other-provider.json").exists());
1783 assert!(!directory.join(generation).exists());
1784 assert!(!directory.join("xai-auth.json").exists());
1785 }
1786
1787 #[test]
1788 fn logout_cleanup_removes_chatgpt_files_without_touching_xai() {
1789 let directory = tempfile::tempdir().expect("temp dir");
1790 let directory = directory.path().canonicalize().expect("canonical temp dir");
1791 let store = open_owned_credentials_directory(&directory).expect("open store");
1792 let chatgpt = "chatgpt-auth-0123456789abcdef0123456789abcdef.json";
1793 let xai = "xai-auth-0123456789abcdef0123456789abcdef.json";
1794 store.write(chatgpt, b"chatgpt", false).expect("chatgpt");
1795 store.write(xai, b"xai", false).expect("xai");
1796 store
1797 .write(LEGACY_CHATGPT_OAUTH_FILE_NAME, b"legacy", false)
1798 .expect("legacy chatgpt");
1799
1800 assert_eq!(store.clear_chatgpt().expect("clear chatgpt"), 2);
1801 assert!(directory.join(xai).exists());
1802 assert!(!directory.join(chatgpt).exists());
1803 assert!(!directory.join(LEGACY_CHATGPT_OAUTH_FILE_NAME).exists());
1804 }
1805
1806 #[cfg(unix)]
1807 #[test]
1808 fn unix_store_pins_directory_identity_across_lexical_path_swap() {
1809 use std::os::unix::fs::symlink;
1810
1811 let root = tempfile::tempdir().expect("temp dir");
1812 let root = root.path().canonicalize().expect("canonical temp root");
1813 let credentials = root.join("credentials");
1814 fs::create_dir(&credentials).expect("credentials");
1815 let store = open_owned_credentials_directory(&credentials).expect("open pinned store");
1816 let parked = root.join("parked-credentials");
1817 let external = root.join("external-owner");
1818 fs::create_dir(&external).expect("external directory");
1819 fs::rename(&credentials, &parked).expect("park credentials");
1820 symlink(&external, &credentials).expect("replace lexical path with symlink");
1821
1822 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
1823 store
1824 .write(generation, b"pinned bytes", false)
1825 .expect("write through pinned directory handle");
1826
1827 assert_eq!(fs::read(parked.join(generation)).unwrap(), b"pinned bytes");
1828 assert!(!external.join(generation).exists());
1829 assert_eq!(
1830 store.read_to_string(generation).unwrap().as_deref(),
1831 Some("pinned bytes")
1832 );
1833 }
1834
1835 #[cfg(unix)]
1836 #[test]
1837 fn unix_store_rejects_symlinked_root_component_and_hardlinked_leaf() {
1838 use std::os::unix::fs::symlink;
1839
1840 let root = tempfile::tempdir().expect("temp dir");
1841 let root = root.path().canonicalize().expect("canonical temp root");
1842 let real = root.join("real-home");
1843 fs::create_dir(&real).expect("real home");
1844 let linked = root.join("linked-home");
1845 symlink(&real, &linked).expect("home symlink");
1846 assert!(
1847 open_owned_credentials_directory(&linked.join("credentials")).is_err(),
1848 "owned roots must reject every symlink component"
1849 );
1850
1851 let credentials = real.join("credentials");
1852 let store = open_owned_credentials_directory(&credentials).expect("safe store");
1853 let generation = "xai-auth-fedcba9876543210fedcba9876543210.json";
1854 store
1855 .write(generation, b"secret", false)
1856 .expect("seed generation");
1857 fs::hard_link(
1858 credentials.join(generation),
1859 credentials.join("attacker-hardlink"),
1860 )
1861 .expect("hardlink fixture");
1862 assert!(
1863 store.read_to_string(generation).is_err(),
1864 "owned reads must reject multiply-linked credential files"
1865 );
1866 }
1867
1868 #[cfg(windows)]
1869 #[test]
1870 fn windows_owned_path_identity_is_case_insensitive_and_lossless() {
1871 use std::ffi::OsString;
1872 use std::os::windows::ffi::OsStringExt as _;
1873
1874 assert_eq!(
1875 normalize_windows_path_for_comparison(Path::new(r"C:\Users\Alice\Credentials"))
1876 .unwrap(),
1877 normalize_windows_path_for_comparison(Path::new(r"\\?\c:\users\ALICE\credentials"))
1878 .unwrap()
1879 );
1880 let invalid = PathBuf::from(OsString::from_wide(&[
1881 b'C' as u16,
1882 b':' as u16,
1883 b'\\' as u16,
1884 0xd800,
1885 ]));
1886 assert!(normalize_windows_path_for_comparison(&invalid).is_err());
1887 }
1888
1889 #[cfg(windows)]
1890 #[test]
1891 fn windows_post_persist_failure_exact_deletes_new_and_replacement_generations() {
1892 fn assert_directory_empty(path: &Path) {
1893 let entries = fs::read_dir(path)
1894 .expect("read credentials directory")
1895 .collect::<std::io::Result<Vec<_>>>()
1896 .expect("read credential entries");
1897 assert!(
1898 entries.is_empty(),
1899 "rejected credential bytes must leave no durable file: {entries:?}"
1900 );
1901 }
1902
1903 let root = tempfile::tempdir().expect("temp dir");
1904 let root = root.path().canonicalize().expect("canonical temp root");
1905 let credentials = root.join("new-credentials");
1906 let store = open_owned_credentials_directory(&credentials).expect("open secure store");
1907 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
1908 let generation_path = credentials.join(generation);
1909 fail_next_windows_post_persist_validation(&generation_path);
1910 let error = store
1911 .write(generation, b"new credential bytes", false)
1912 .expect_err("post-persist validation must fail");
1913 assert!(error.to_string().contains("injected post-persistence"));
1914 assert_directory_empty(&credentials);
1915
1916 let replacement_credentials = root.join("replacement-credentials");
1917 let replacement_store = open_owned_credentials_directory(&replacement_credentials)
1918 .expect("open replacement store");
1919 let replacement_path = replacement_credentials.join(generation);
1920 replacement_store
1921 .write(generation, b"prior credential bytes", false)
1922 .expect("seed prior generation");
1923 fail_next_windows_post_persist_validation(&replacement_path);
1924 let error = replacement_store
1925 .write(generation, b"replacement credential bytes", true)
1926 .expect_err("replacement validation must fail");
1927 assert!(error.to_string().contains("injected post-persistence"));
1928 assert!(
1929 !replacement_path.exists(),
1930 "a rejected replacement deliberately fails closed instead of restoring by path"
1931 );
1932 assert_directory_empty(&replacement_credentials);
1933 }
1934
1935 #[cfg(windows)]
1936 #[test]
1937 fn windows_store_secures_every_new_owned_object_for_the_current_user() {
1938 let root = tempfile::tempdir().expect("temp dir");
1939 let root = root.path().canonicalize().expect("canonical temp root");
1940 let credentials = root.join("credentials");
1941 let store = open_owned_credentials_directory(&credentials).expect("open secure store");
1942
1943 let directory = store
1944 ._component_handles
1945 .last()
1946 .expect("final credentials directory handle");
1947 verify_windows_owner_only_handle(directory).expect("current-user-only directory");
1948
1949 let lock = store.open_lock_file().expect("create lifecycle lock");
1950 validate_owned_file_handle(&lock, &credentials.join(XAI_OAUTH_LIFECYCLE_LOCK_FILE_NAME))
1951 .expect("current-user-only lifecycle lock");
1952
1953 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
1954 store
1955 .write(generation, b"credential bytes", false)
1956 .expect("write secure generation");
1957 let generation_file = store
1958 .open_owned_file_for_read(generation)
1959 .expect("open generation")
1960 .expect("generation exists");
1961 validate_owned_file_handle(&generation_file, &credentials.join(generation))
1962 .expect("current-user-only generation");
1963 }
1964
1965 #[cfg(windows)]
1966 #[test]
1967 fn windows_store_rejects_reparse_component_and_hardlinked_leaf() {
1968 let root = tempfile::tempdir().expect("temp dir");
1969 let root = root.path().canonicalize().expect("canonical temp root");
1970 let real = root.join("real-home");
1971 fs::create_dir(&real).expect("real home");
1972 let linked = root.join("linked-home");
1973 if std::os::windows::fs::symlink_dir(&real, &linked).is_ok() {
1974 assert!(
1975 open_owned_credentials_directory(&linked.join("credentials")).is_err(),
1976 "owned roots must reject junctions and directory reparse points"
1977 );
1978 }
1979
1980 let credentials = real.join("credentials");
1981 let store = open_owned_credentials_directory(&credentials).expect("safe store");
1982 let generation = "xai-auth-fedcba9876543210fedcba9876543210.json";
1983 store
1984 .write(generation, b"secret", false)
1985 .expect("seed generation");
1986 fs::hard_link(
1987 credentials.join(generation),
1988 credentials.join("attacker-hardlink"),
1989 )
1990 .expect("hardlink fixture");
1991 assert!(
1992 store.read_to_string(generation).is_err(),
1993 "owned reads must reject multiply-linked credential files"
1994 );
1995 }
1996 }
1997
1997 lines RUST