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