Skip to main content

codewhale_config/
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)]
7use std::ffi::CString;
8use std::fs::{self, File};
9use std::io::{Read as _, Write as _};
10use std::path::{Component, Path, PathBuf};
11#[cfg(not(windows))]
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::{Mutex, OnceLock};
14#[cfg(not(windows))]
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use anyhow::{Context, Result, bail};
18
19pub const XAI_OAUTH_GENERATION_PREFIX: &str = "xai-auth-";
20pub const XAI_OAUTH_GENERATION_SUFFIX: &str = ".json";
21pub const LEGACY_XAI_OAUTH_FILE_NAME: &str = "xai-auth.json";
22const XAI_OAUTH_LIFECYCLE_LOCK_FILE_NAME: &str = ".xai-oauth.lock";
23const 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)]
33pub 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)]
48pub struct XaiOAuthRevocation {
49    retired: Vec<(String, String)>,
50}
51
52#[must_use]
53pub 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
73pub 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
82pub 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.
90fn 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
110pub fn xai_oauth_generation_path(generation: &str) -> Result<PathBuf> {
111    Ok(xai_oauth_credentials_dir()?.join(validate_xai_oauth_generation(generation)?))
112}
113
114pub 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.
123pub 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.
146pub 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
170impl 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)]
307fn 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))]
356fn 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
383impl 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
430fn 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
438fn 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)]
450fn 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)]
559fn 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)]
565impl 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)]
760fn 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)]
781fn 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)]
860impl 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)]
1066fn 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)]
1095fn 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)]
1121fn 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))]
1135static WINDOWS_POST_PERSIST_VALIDATION_FAILURE: Mutex<Option<PathBuf>> = Mutex::new(None);
1136
1137#[cfg(windows)]
1138fn 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))]
1153fn 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)]
1160fn 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)]
1168fn 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)]
1189fn 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)]
1248fn 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)]
1267fn 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)]
1330fn 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)]
1402struct CurrentWindowsUser {
1403    token: windows_sys::Win32::Foundation::HANDLE,
1404    token_info: Vec<usize>,
1405}
1406
1407#[cfg(windows)]
1408impl 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)]
1467impl 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)]
1475struct WindowsLocalAllocation(*mut core::ffi::c_void);
1476
1477#[cfg(windows)]
1478impl 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)))]
1488fn 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)))]
1501impl 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)))]
1547fn 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.
1555pub 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.
1563pub fn clear_all_xai_oauth_credentials() -> Result<usize> {
1564    with_xai_oauth_lifecycle_lock(XaiOAuthCredentialStore::clear_all)
1565}
1566
1567#[cfg(test)]
1568mod 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}