返回 CodeWhale
private_directory.rs
根目录 / crates / config / src / private_directory.rs
1 //! Private directory/file mechanism extracted from xai_credentials.
2 //! Retained handles, no-follow component traversal, owner/type/link checks,
3 //! atomic publication and Windows owner-only security preserve existing policy.
4
5 #[cfg(windows)]
6 use crate::windows_identity::{CurrentWindowsUser, WindowsLocalAllocation};
7 use anyhow::{Context, Result, bail};
8 #[cfg(unix)]
9 use std::ffi::CString;
10 use std::fs::{self, File};
11 use std::io::Write as _;
12 use std::path::{Component, Path, PathBuf};
13 #[cfg(all(windows, test))]
14 use std::sync::Mutex;
15 #[cfg(not(windows))]
16 use std::time::{SystemTime, UNIX_EPOCH};
17
18 /// Anchored current-user-owned directory and file mechanism. Credential and
19 /// Runtime policies remain with their callers; this type owns no authority.
20 #[derive(Debug)]
21 pub struct PrivateDirectory {
22 pub(crate) directory: PathBuf,
23 #[cfg(unix)]
24 pub(crate) directory_handle: File,
25 #[cfg(windows)]
26 pub(crate) _component_handles: Vec<File>,
27 }
28
29 impl PrivateDirectory {
30 pub fn open(directory: &Path) -> Result<Self> {
31 open_owned_directory(directory, true, true)
32 }
33 /// Read-only admission of an existing private directory, without repair.
34 pub fn inspect(directory: &Path) -> Result<Self> {
35 open_owned_directory(directory, false, false)
36 }
37
38 /// Create missing owned directories; refuse existing non-private permissions.
39 pub fn admit(directory: &Path) -> Result<Self> {
40 open_owned_directory(directory, false, true)
41 }
42
43 /// Check the retained directory against the lexical selection without
44 /// following links, creating directories, or changing permissions.
45 #[cfg(unix)]
46 pub fn is_at_selected_path(&self) -> Result<bool> {
47 use std::os::unix::fs::MetadataExt as _;
48 let current = Self::inspect(&self.directory)?;
49 let held = self.directory_handle.metadata()?;
50 let now = current.directory_handle.metadata()?;
51 Ok(held.dev() == now.dev() && held.ino() == now.ino())
52 }
53
54 #[cfg(unix)]
55 pub fn current_user_id() -> u32 {
56 // SAFETY: geteuid has no pointer arguments.
57 unsafe { libc::geteuid() }
58 }
59 }
60
61 fn validate_private_basename(name: &str) -> Result<()> {
62 let path = Path::new(name);
63 anyhow::ensure!(
64 path.components().count() == 1
65 && matches!(path.components().next(), Some(Component::Normal(_)))
66 && path.file_name().and_then(|value| value.to_str()) == Some(name),
67 "xAI OAuth private basename must be one UTF-8 path component"
68 );
69 Ok(())
70 }
71
72 #[cfg(unix)]
73 fn open_owned_directory(directory: &Path, protect: bool, create: bool) -> Result<PrivateDirectory> {
74 use std::os::fd::FromRawFd as _;
75 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
76
77 anyhow::ensure!(
78 directory.is_absolute(),
79 "xAI OAuth credentials directory must be absolute"
80 );
81 // SAFETY: the literal root path contains no interior NUL and the returned
82 // descriptor is immediately owned by `File`.
83 let root_fd = unsafe {
84 libc::open(
85 c"/".as_ptr(),
86 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
87 )
88 };
89 if root_fd < 0 {
90 return Err(std::io::Error::last_os_error()).context("opening filesystem root");
91 }
92 // SAFETY: `root_fd` is a newly owned descriptor on the success path above.
93 let mut current = unsafe { File::from_raw_fd(root_fd) };
94 for component in directory.components() {
95 let Component::Normal(name) = component else {
96 if matches!(component, Component::RootDir) {
97 continue;
98 }
99 bail!(
100 "Codewhale credentials directory has an unsupported component: {}",
101 crate::quote_os_path(directory)
102 );
103 };
104 let name = cstring_from_os_str(name)?;
105 // SAFETY: parent borrowed from live `current`; `name` outlives the call.
106 let mut fd = unsafe {
107 libc::openat(
108 std::os::fd::AsRawFd::as_raw_fd(&current),
109 name.as_ptr(),
110 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
111 )
112 };
113 if create
114 && fd < 0
115 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::NotFound
116 {
117 // SAFETY: both the parent descriptor and component pointer remain
118 // valid for this call. `mkdirat` cannot follow the missing leaf.
119 let created = unsafe {
120 libc::mkdirat(
121 std::os::fd::AsRawFd::as_raw_fd(&current),
122 name.as_ptr(),
123 0o700,
124 )
125 };
126 if created != 0 {
127 let error = std::io::Error::last_os_error();
128 if error.kind() != std::io::ErrorKind::AlreadyExists {
129 return Err(error).with_context(|| {
130 format!(
131 "creating a component of Codewhale credentials directory {}",
132 crate::quote_os_path(directory)
133 )
134 });
135 }
136 }
137 // SAFETY: same stable parent/component arguments as above.
138 fd = unsafe {
139 libc::openat(
140 std::os::fd::AsRawFd::as_raw_fd(&current),
141 name.as_ptr(),
142 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
143 )
144 };
145 }
146 if fd < 0 {
147 return Err(std::io::Error::last_os_error()).with_context(|| {
148 format!(
149 "opening Codewhale credentials directory without following links: {}",
150 crate::quote_os_path(directory)
151 )
152 });
153 }
154 // SAFETY: `fd` is a newly owned descriptor on the success path above.
155 current = unsafe { File::from_raw_fd(fd) };
156 }
157 let metadata = current.metadata().with_context(|| {
158 format!(
159 "inspecting Codewhale credentials directory {}",
160 crate::quote_os_path(directory)
161 )
162 })?;
163 anyhow::ensure!(
164 metadata.is_dir(),
165 "Codewhale credentials path must be a directory"
166 );
167 // SAFETY: geteuid(2) dereferences no pointers.
168 anyhow::ensure!(
169 metadata.uid() == unsafe { libc::geteuid() },
170 "Codewhale credentials directory must be owned by the current user"
171 );
172 if protect {
173 current.set_permissions(fs::Permissions::from_mode(0o700))?;
174 } else {
175 anyhow::ensure!(
176 metadata.mode() & 0o077 == 0,
177 "private endpoint directory must already be current-user-only"
178 );
179 }
180 Ok(PrivateDirectory {
181 directory: directory.to_path_buf(),
182 directory_handle: current,
183 })
184 }
185
186 #[cfg(unix)]
187 fn cstring_from_os_str(value: &std::ffi::OsStr) -> Result<CString> {
188 use std::os::unix::ffi::OsStrExt as _;
189 CString::new(value.as_bytes()).context("owned xAI OAuth path contains an interior NUL")
190 }
191
192 #[cfg(unix)]
193 impl PrivateDirectory {
194 fn open_at(&self, name: &str, flags: i32, mode: libc::mode_t) -> Result<Option<File>> {
195 use std::os::fd::AsRawFd as _;
196 use std::os::fd::FromRawFd as _;
197
198 validate_private_basename(name)?;
199 let name = CString::new(name).context("xAI OAuth basename contains an interior NUL")?;
200 // SAFETY: the stable directory descriptor and component pointer remain
201 // valid for the call; a successful descriptor is transferred to File.
202 let fd = unsafe {
203 libc::openat(
204 self.directory_handle.as_raw_fd(),
205 name.as_ptr(),
206 flags | libc::O_CLOEXEC | libc::O_NOFOLLOW,
207 libc::c_uint::from(mode),
208 )
209 };
210 if fd < 0 {
211 let error = std::io::Error::last_os_error();
212 if error.kind() == std::io::ErrorKind::NotFound {
213 return Ok(None);
214 }
215 return Err(error).with_context(|| {
216 format!(
217 "opening Codewhale-owned xAI OAuth path {}",
218 crate::quote_os_path(&self.directory.join(name.to_string_lossy().as_ref()))
219 )
220 });
221 }
222 // SAFETY: `fd` is newly owned on the success path above.
223 Ok(Some(unsafe { File::from_raw_fd(fd) }))
224 }
225
226 pub fn open_owned_file_for_read(&self, name: &str) -> Result<Option<File>> {
227 self.open_at(name, libc::O_RDONLY, 0)
228 }
229
230 pub fn open_internal_file(&self, name: &str) -> Result<File> {
231 use std::os::unix::fs::PermissionsExt as _;
232 let file = self
233 .open_at(name, libc::O_RDWR | libc::O_CREAT, 0o600)?
234 .context("xAI OAuth lifecycle lock disappeared while opening")?;
235 validate_owned_file_handle(&file, &self.directory.join(name))?;
236 file.set_permissions(fs::Permissions::from_mode(0o600))?;
237 Ok(file)
238 }
239
240 pub fn write_owned_file(&self, name: &str, bytes: &[u8], allow_replace: bool) -> Result<()> {
241 use std::os::fd::AsRawFd as _;
242 use std::os::unix::fs::PermissionsExt as _;
243
244 let temp_name = format!(
245 ".xai-oauth-write-{}-{}.tmp",
246 std::process::id(),
247 SystemTime::now()
248 .duration_since(UNIX_EPOCH)
249 .unwrap_or_default()
250 .as_nanos()
251 );
252 let mut temp = self
253 .open_at(
254 &temp_name,
255 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL,
256 0o600,
257 )?
258 .context("creating private xAI OAuth temporary file")?;
259 let result = (|| -> Result<()> {
260 temp.write_all(bytes)
261 .context("writing xAI OAuth temporary file")?;
262 temp.flush().context("flushing xAI OAuth temporary file")?;
263 temp.set_permissions(fs::Permissions::from_mode(0o600))?;
264 temp.sync_all()
265 .context("syncing xAI OAuth temporary file")?;
266
267 let target =
268 CString::new(name).context("xAI OAuth basename contains an interior NUL")?;
269 let temporary =
270 CString::new(temp_name.as_str()).context("temporary basename contains NUL")?;
271 if allow_replace {
272 if let Some(existing) = self.open_owned_file_for_read(name)? {
273 validate_owned_file_handle(&existing, &self.directory.join(name))?;
274 }
275 // SAFETY: both names are relative to the same stable directory
276 // handle; rename is atomic and cannot escape that directory.
277 if unsafe {
278 libc::renameat(
279 self.directory_handle.as_raw_fd(),
280 temporary.as_ptr(),
281 self.directory_handle.as_raw_fd(),
282 target.as_ptr(),
283 )
284 } != 0
285 {
286 return Err(std::io::Error::last_os_error())
287 .context("atomically replacing xAI OAuth credentials");
288 }
289 } else {
290 // `linkat` installs the unique generation without clobbering an
291 // existing path. The temporary link is removed immediately.
292 // SAFETY: all descriptors/names remain valid for both calls.
293 if unsafe {
294 libc::linkat(
295 self.directory_handle.as_raw_fd(),
296 temporary.as_ptr(),
297 self.directory_handle.as_raw_fd(),
298 target.as_ptr(),
299 0,
300 )
301 } != 0
302 {
303 return Err(std::io::Error::last_os_error())
304 .context("installing a new xAI OAuth generation without replacement");
305 }
306 // SAFETY: same descriptor and staging name as the `linkat` above.
307 if unsafe {
308 libc::unlinkat(self.directory_handle.as_raw_fd(), temporary.as_ptr(), 0)
309 } != 0
310 {
311 let error = std::io::Error::last_os_error();
312 // The target and staging name still reference the same
313 // inode. Remove the just-installed target so the generic
314 // error cleanup can safely retire the single remaining
315 // staging link instead of leaving an inert secret with
316 // link count two.
317 // SAFETY: same descriptor; `target` was just installed above.
318 unsafe {
319 libc::unlinkat(self.directory_handle.as_raw_fd(), target.as_ptr(), 0)
320 };
321 return Err(error).context("removing xAI OAuth generation staging link");
322 }
323 }
324 self.directory_handle
325 .sync_all()
326 .context("syncing Codewhale credentials directory")?;
327 Ok(())
328 })();
329 drop(temp);
330 if result.is_err() {
331 let _ = self.remove_raw(&temp_name);
332 }
333 result
334 }
335
336 pub fn remove_raw(&self, name: &str) -> Result<bool> {
337 use std::os::fd::AsRawFd as _;
338 validate_private_basename(name)?;
339 let Some(file) = self.open_owned_file_for_read(name)? else {
340 return Ok(false);
341 };
342 validate_owned_file_handle(&file, &self.directory.join(name))?;
343 drop(file);
344 let name = CString::new(name).context("xAI OAuth basename contains an interior NUL")?;
345 // SAFETY: the name is one component relative to the stable credentials
346 // directory descriptor and was validated immediately above.
347 if unsafe { libc::unlinkat(self.directory_handle.as_raw_fd(), name.as_ptr(), 0) } != 0 {
348 let error = std::io::Error::last_os_error();
349 if error.kind() == std::io::ErrorKind::NotFound {
350 return Ok(false);
351 }
352 return Err(error).context("removing Codewhale-owned xAI OAuth file");
353 }
354 Ok(true)
355 }
356
357 pub fn rename_raw(&self, from: &str, to: &str) -> Result<()> {
358 use std::os::fd::AsRawFd as _;
359 validate_private_basename(from)?;
360 validate_private_basename(to)?;
361 let source = self
362 .open_owned_file_for_read(from)?
363 .context("xAI OAuth source disappeared before retirement")?;
364 validate_owned_file_handle(&source, &self.directory.join(from))?;
365 anyhow::ensure!(
366 self.open_owned_file_for_read(to)?.is_none(),
367 "refusing to replace an existing xAI OAuth retirement path"
368 );
369 drop(source);
370 let from = CString::new(from).context("xAI OAuth basename contains an interior NUL")?;
371 let to = CString::new(to).context("xAI OAuth basename contains an interior NUL")?;
372 // SAFETY: both names are one component relative to the same pinned
373 // directory descriptor.
374 if unsafe {
375 libc::renameat(
376 self.directory_handle.as_raw_fd(),
377 from.as_ptr(),
378 self.directory_handle.as_raw_fd(),
379 to.as_ptr(),
380 )
381 } != 0
382 {
383 return Err(std::io::Error::last_os_error()).context("retiring xAI OAuth file");
384 }
385 Ok(())
386 }
387 }
388
389 #[cfg(unix)]
390 pub(crate) fn validate_owned_file_handle(file: &File, path: &Path) -> Result<fs::Metadata> {
391 use std::os::unix::fs::MetadataExt as _;
392 let metadata = file.metadata().with_context(|| {
393 format!(
394 "inspecting Codewhale-owned xAI OAuth file {}",
395 crate::quote_os_path(path)
396 )
397 })?;
398 anyhow::ensure!(metadata.is_file(), "xAI OAuth path must be a regular file");
399 // SAFETY: geteuid(2) dereferences no pointers.
400 anyhow::ensure!(
401 metadata.uid() == unsafe { libc::geteuid() },
402 "xAI OAuth file must be owned by the current user"
403 );
404 anyhow::ensure!(
405 metadata.nlink() == 1,
406 "xAI OAuth file must not have multiple filesystem links"
407 );
408 Ok(metadata)
409 }
410
411 #[cfg(windows)]
412 fn open_owned_directory(directory: &Path, protect: bool, create: bool) -> Result<PrivateDirectory> {
413 use std::os::windows::fs::OpenOptionsExt as _;
414 use windows_sys::Win32::Storage::FileSystem::{
415 FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ,
416 FILE_SHARE_READ, FILE_SHARE_WRITE, WRITE_DAC, WRITE_OWNER,
417 };
418
419 anyhow::ensure!(
420 directory.is_absolute(),
421 "xAI OAuth credentials directory must be absolute"
422 );
423 let mut current = PathBuf::new();
424 let mut handles = Vec::new();
425 let mut created_final = false;
426 for component in directory.components() {
427 match component {
428 Component::Prefix(prefix) => current.push(prefix.as_os_str()),
429 Component::RootDir => current.push(Path::new(r"\")),
430 Component::Normal(name) => {
431 current.push(name);
432 created_final = false;
433 if create {
434 match fs::create_dir(&current) {
435 Ok(()) => {
436 created_final = true;
437 }
438 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
439 Err(error) => {
440 return Err(error).with_context(|| {
441 format!(
442 "creating a component of Codewhale credentials directory {}",
443 crate::quote_os_path(directory)
444 )
445 });
446 }
447 }
448 }
449 let mut options = fs::OpenOptions::new();
450 options
451 .read(true)
452 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
453 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT);
454 let handle = options.open(&current).with_context(|| {
455 format!(
456 "opening Codewhale credentials directory component {}",
457 crate::quote_os_path(&current)
458 )
459 })?;
460 validate_windows_handle_path(&handle, &current, true)?;
461 handles.push(handle);
462 }
463 Component::CurDir | Component::ParentDir => bail!(
464 "Codewhale credentials directory must be lexically normalized: {}",
465 crate::quote_os_path(directory)
466 ),
467 }
468 }
469 anyhow::ensure!(
470 !handles.is_empty(),
471 "Codewhale credentials directory cannot be a volume root"
472 );
473 let mut secure_options = fs::OpenOptions::new();
474 secure_options
475 .access_mode(FILE_GENERIC_READ | WRITE_DAC | WRITE_OWNER)
476 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
477 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT);
478 let final_directory = secure_options.open(directory).with_context(|| {
479 format!(
480 "opening Codewhale credentials directory for owner-only security: {}",
481 crate::quote_os_path(directory)
482 )
483 })?;
484 validate_windows_handle_path(&final_directory, directory, true)?;
485 if protect || created_final {
486 secure_windows_owner_only_handle(&final_directory, true)
487 .context("securing the current-user private directory")?;
488 }
489 verify_windows_owner_only_handle(&final_directory)
490 .context("verifying Codewhale credentials directory ownership")?;
491 handles.push(final_directory);
492 Ok(PrivateDirectory {
493 directory: directory.to_path_buf(),
494 _component_handles: handles,
495 })
496 }
497
498 #[cfg(windows)]
499 impl PrivateDirectory {
500 fn open_windows_file(&self, name: &str, read: bool, write: bool) -> Result<Option<File>> {
501 use std::os::windows::fs::OpenOptionsExt as _;
502 use windows_sys::Win32::Storage::FileSystem::{
503 FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE,
504 };
505
506 validate_private_basename(name)?;
507 let path = self.directory.join(name);
508 let mut options = fs::OpenOptions::new();
509 options
510 .read(read)
511 .write(write)
512 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
513 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
514 match options.open(&path) {
515 Ok(file) => {
516 validate_owned_file_handle(&file, &path)?;
517 Ok(Some(file))
518 }
519 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
520 Err(error) => Err(error).with_context(|| {
521 format!(
522 "opening Codewhale-owned xAI OAuth path {}",
523 crate::quote_os_path(&path)
524 )
525 }),
526 }
527 }
528
529 pub fn open_owned_file_for_read(&self, name: &str) -> Result<Option<File>> {
530 self.open_windows_file(name, true, false)
531 }
532
533 pub fn open_internal_file(&self, name: &str) -> Result<File> {
534 use std::os::windows::fs::OpenOptionsExt as _;
535 use windows_sys::Win32::Storage::FileSystem::{
536 DELETE, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
537 FILE_SHARE_READ, FILE_SHARE_WRITE, WRITE_DAC, WRITE_OWNER,
538 };
539
540 validate_private_basename(name)?;
541 let path = self.directory.join(name);
542 for _ in 0..8 {
543 if let Some(existing) = self.open_windows_file(name, true, true)? {
544 return Ok(existing);
545 }
546
547 let mut options = fs::OpenOptions::new();
548 options
549 // `access_mode` supplies the exact Win32 access mask below,
550 // while Rust still requires the portable write intent to be
551 // set before it permits `create_new`.
552 .write(true)
553 .access_mode(
554 FILE_GENERIC_READ | FILE_GENERIC_WRITE | WRITE_DAC | WRITE_OWNER | DELETE,
555 )
556 .create_new(true)
557 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
558 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
559 let file = match options.open(&path) {
560 Ok(file) => file,
561 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
562 Err(error) => {
563 return Err(error).with_context(|| {
564 format!(
565 "creating Codewhale-owned xAI OAuth lifecycle lock {}",
566 crate::quote_os_path(&path)
567 )
568 });
569 }
570 };
571 let secured = (|| -> Result<()> {
572 validate_windows_file_shape(&file, &path)?;
573 secure_windows_owner_only_handle(&file, false)
574 .context("securing a new xAI OAuth lifecycle lock")?;
575 validate_owned_file_handle(&file, &path)?;
576 Ok(())
577 })();
578 if let Err(error) = secured {
579 let cleanup = mark_windows_file_handle_for_deletion(&file);
580 return match cleanup {
581 Ok(()) => Err(error),
582 Err(cleanup) => Err(error).context(format!(
583 "also failed to delete the empty lifecycle lock: {cleanup:#}"
584 )),
585 };
586 }
587 return Ok(file);
588 }
589 bail!("xAI OAuth lifecycle lock changed repeatedly while opening")
590 }
591
592 pub fn write_owned_file(&self, name: &str, bytes: &[u8], allow_replace: bool) -> Result<()> {
593 let path = self.directory.join(name);
594 if let Some(existing) = self.open_owned_file_for_read(name)? {
595 anyhow::ensure!(
596 allow_replace,
597 "refusing to replace an existing xAI OAuth generation"
598 );
599 drop(existing);
600 }
601 let mut temporary = tempfile::NamedTempFile::new_in(&self.directory)
602 .context("creating private xAI OAuth temporary file")?;
603 let temporary_path = temporary.path().to_path_buf();
604 let security_handle =
605 reopen_windows_file_for_owner_security(temporary.as_file(), &temporary_path)?;
606 secure_windows_owner_only_handle(&security_handle, false)
607 .context("securing a new xAI OAuth temporary file before writing credentials")?;
608 validate_owned_file_handle(&security_handle, &temporary_path)
609 .context("verifying a new xAI OAuth temporary file before writing credentials")?;
610 let write_result = (|| -> Result<()> {
611 temporary
612 .write_all(bytes)
613 .context("writing xAI OAuth temporary file")?;
614 temporary
615 .flush()
616 .context("flushing xAI OAuth temporary file")?;
617 temporary
618 .as_file()
619 .sync_all()
620 .context("syncing xAI OAuth temporary file")?;
621 Ok(())
622 })();
623 if let Err(error) = write_result {
624 return Err(cleanup_windows_secret_after_error(
625 &security_handle,
626 error,
627 "temporary file",
628 ));
629 }
630 let persisted = if allow_replace {
631 match temporary.persist(&path) {
632 Ok(file) => file,
633 Err(error) => {
634 let tempfile::PersistError { error, file } = error;
635 let persistence_error = anyhow::Error::new(error)
636 .context("atomically replacing xAI OAuth credentials");
637 let error = cleanup_windows_secret_after_error(
638 &security_handle,
639 persistence_error,
640 "temporary file",
641 );
642 drop(file);
643 return Err(error);
644 }
645 }
646 } else {
647 match temporary.persist_noclobber(&path) {
648 Ok(file) => file,
649 Err(error) => {
650 let tempfile::PersistError { error, file } = error;
651 let persistence_error = anyhow::Error::new(error)
652 .context("installing a new xAI OAuth generation without replacement");
653 let error = cleanup_windows_secret_after_error(
654 &security_handle,
655 persistence_error,
656 "temporary file",
657 );
658 drop(file);
659 return Err(error);
660 }
661 }
662 };
663 if let Err(error) = validate_persisted_windows_owned_file(&persisted, &path) {
664 // MoveFileEx has already published this exact object. Delete it by
665 // handle rather than trusting the pathname again. For a refresh
666 // replacement this can leave the unchanged config pointer missing;
667 // that fail-closed availability outcome is safer than retaining a
668 // generation that failed the post-publication invariant check.
669 return Err(cleanup_windows_secret_after_error(
670 &security_handle,
671 error,
672 "rejected generation",
673 ));
674 }
675 Ok(())
676 }
677
678 pub fn remove_raw(&self, name: &str) -> Result<bool> {
679 use std::os::windows::fs::OpenOptionsExt as _;
680 use windows_sys::Win32::Storage::FileSystem::{
681 DELETE, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_SHARE_DELETE,
682 FILE_SHARE_READ, FILE_SHARE_WRITE,
683 };
684
685 validate_private_basename(name)?;
686 let path = self.directory.join(name);
687 let mut options = fs::OpenOptions::new();
688 options
689 .access_mode(FILE_GENERIC_READ | DELETE)
690 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
691 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
692 let file = match options.open(&path) {
693 Ok(file) => file,
694 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
695 Err(error) => return Err(error).context("opening xAI OAuth file for exact deletion"),
696 };
697 validate_owned_file_handle(&file, &path)?;
698 mark_windows_file_handle_for_deletion(&file)?;
699 drop(file);
700 Ok(true)
701 }
702 }
703
704 #[cfg(windows)]
705 fn reopen_windows_file_for_owner_security(file: &File, path: &Path) -> Result<File> {
706 use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _};
707 use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
708 use windows_sys::Win32::Storage::FileSystem::{
709 DELETE, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
710 FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, ReOpenFile, WRITE_DAC, WRITE_OWNER,
711 };
712
713 // ReOpenFile derives a new handle from the already-created temporary file,
714 // so no pathname can be substituted between creation and hardening.
715 // SAFETY: `file` is live; no output pointers passed.
716 let handle = unsafe {
717 ReOpenFile(
718 file.as_raw_handle(),
719 FILE_GENERIC_READ | FILE_GENERIC_WRITE | WRITE_DAC | WRITE_OWNER | DELETE,
720 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
721 FILE_FLAG_OPEN_REPARSE_POINT,
722 )
723 };
724 if handle == INVALID_HANDLE_VALUE {
725 return Err(std::io::Error::last_os_error())
726 .context("reopening a new xAI OAuth temporary file for owner-only security");
727 }
728 // SAFETY: ReOpenFile returned a newly owned handle on the success path.
729 let reopened = unsafe { File::from_raw_handle(handle) };
730 validate_windows_file_shape(&reopened, path)?;
731 Ok(reopened)
732 }
733
734 #[cfg(windows)]
735 fn mark_windows_file_handle_for_deletion(file: &File) -> Result<()> {
736 use std::os::windows::io::AsRawHandle as _;
737 use windows_sys::Win32::Storage::FileSystem::{
738 FILE_DISPOSITION_INFO, FileDispositionInfo, SetFileInformationByHandle,
739 };
740
741 let disposition = FILE_DISPOSITION_INFO { DeleteFile: true };
742 // SAFETY: the disposition buffer has the documented structure and the
743 // handle remains owned until after the call. Windows marks this exact file
744 // object delete-pending rather than resolving the path again.
745 if unsafe {
746 SetFileInformationByHandle(
747 file.as_raw_handle(),
748 FileDispositionInfo,
749 (&raw const disposition).cast(),
750 std::mem::size_of::<FILE_DISPOSITION_INFO>() as u32,
751 )
752 } == 0
753 {
754 return Err(std::io::Error::last_os_error())
755 .context("marking exact xAI OAuth file handle for deletion");
756 }
757 Ok(())
758 }
759
760 #[cfg(windows)]
761 fn cleanup_windows_secret_after_error(
762 file: &File,
763 error: anyhow::Error,
764 label: &str,
765 ) -> anyhow::Error {
766 match mark_windows_file_handle_for_deletion(file) {
767 Ok(()) => error,
768 Err(cleanup) => error.context(format!(
769 "also failed to delete the xAI OAuth {label} by exact handle: {cleanup:#}"
770 )),
771 }
772 }
773
774 #[cfg(all(windows, test))]
775 static WINDOWS_POST_PERSIST_VALIDATION_FAILURE: Mutex<Option<PathBuf>> = Mutex::new(None);
776
777 #[cfg(windows)]
778 fn validate_persisted_windows_owned_file(file: &File, path: &Path) -> Result<fs::Metadata> {
779 #[cfg(test)]
780 {
781 let mut injected = WINDOWS_POST_PERSIST_VALIDATION_FAILURE
782 .lock()
783 .unwrap_or_else(std::sync::PoisonError::into_inner);
784 if injected.as_deref() == Some(path) {
785 *injected = None;
786 bail!("injected post-persistence xAI OAuth validation failure");
787 }
788 }
789 validate_owned_file_handle(file, path)
790 }
791
792 #[cfg(all(windows, test))]
793 pub(crate) fn fail_next_windows_post_persist_validation(path: &Path) {
794 *WINDOWS_POST_PERSIST_VALIDATION_FAILURE
795 .lock()
796 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(path.to_path_buf());
797 }
798
799 #[cfg(windows)]
800 pub(crate) fn validate_owned_file_handle(file: &File, path: &Path) -> Result<fs::Metadata> {
801 let metadata = validate_windows_file_shape(file, path)?;
802 verify_windows_owner_only_handle(file)
803 .context("Codewhale-owned xAI OAuth file is not current-user-only")?;
804 Ok(metadata)
805 }
806
807 #[cfg(windows)]
808 pub(crate) fn validate_windows_file_shape(file: &File, path: &Path) -> Result<fs::Metadata> {
809 use std::os::windows::io::AsRawHandle as _;
810 use windows_sys::Win32::Storage::FileSystem::{
811 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
812 };
813
814 let metadata = validate_windows_handle_path(file, path, false)?;
815 let mut information = BY_HANDLE_FILE_INFORMATION::default();
816 // SAFETY: both pointers remain valid for the duration of the call.
817 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
818 return Err(std::io::Error::last_os_error())
819 .context("inspecting xAI OAuth file link count");
820 }
821 anyhow::ensure!(
822 information.nNumberOfLinks == 1,
823 "xAI OAuth file must not have multiple filesystem links"
824 );
825 Ok(metadata)
826 }
827
828 #[cfg(windows)]
829 fn validate_windows_handle_path(
830 file: &File,
831 expected: &Path,
832 expect_directory: bool,
833 ) -> Result<fs::Metadata> {
834 use std::os::windows::fs::MetadataExt as _;
835 use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
836
837 let metadata = file.metadata().with_context(|| {
838 format!(
839 "inspecting Codewhale-owned path {}",
840 crate::quote_os_path(expected)
841 )
842 })?;
843 anyhow::ensure!(
844 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0,
845 "Codewhale-owned xAI OAuth path must not be a reparse point"
846 );
847 anyhow::ensure!(
848 if expect_directory {
849 metadata.is_dir()
850 } else {
851 metadata.is_file()
852 },
853 "Codewhale-owned xAI OAuth path has the wrong filesystem type"
854 );
855
856 // Compare the current selection to the captured object, not its lexical
857 // spelling: Windows can select the same object through an 8.3 short name.
858 // Reparse points are refused at every component. These attribute-only
859 // handles do not pin the path (they bypass share checks); a component
860 // swapped mid-walk either fails to open or selects a different identity,
861 // which this comparison refuses.
862 let components = open_windows_selected_components(expected, expect_directory)?;
863 let selected = components
864 .last()
865 .context("private selected path unavailable")?;
866 anyhow::ensure!(
867 windows_file_identity(file)? == windows_file_identity(selected)?
868 && normalize_windows_path_for_comparison(&windows_final_handle_path(file)?)?
869 == normalize_windows_path_for_comparison(&windows_final_handle_path(selected)?)?,
870 "Codewhale-owned xAI OAuth path was redirected while opening"
871 );
872 Ok(metadata)
873 }
874
875 #[cfg(windows)]
876 fn open_windows_selected_components(expected: &Path, expect_directory: bool) -> Result<Vec<File>> {
877 use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
878 use windows_sys::Win32::Storage::FileSystem::{
879 FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
880 FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
881 };
882
883 anyhow::ensure!(
884 expected.is_absolute(),
885 "private selected path must be absolute"
886 );
887 let mut current = PathBuf::new();
888 let mut handles = Vec::new();
889 let mut components = expected.components().peekable();
890 while let Some(component) = components.next() {
891 match component {
892 Component::Prefix(prefix) => current.push(prefix.as_os_str()),
893 Component::RootDir => current.push(Path::new(r"\")),
894 Component::Normal(name) => {
895 current.push(name);
896 let directory = components.peek().is_some() || expect_directory;
897 let handle = fs::OpenOptions::new()
898 .access_mode(FILE_READ_ATTRIBUTES)
899 .share_mode(
900 FILE_SHARE_READ
901 | FILE_SHARE_WRITE
902 | if directory { 0 } else { FILE_SHARE_DELETE },
903 )
904 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
905 .open(&current)
906 .context("opening the current private path selection")?;
907 let metadata = handle.metadata()?;
908 anyhow::ensure!(
909 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0,
910 "Codewhale-owned xAI OAuth path must not be a reparse point"
911 );
912 anyhow::ensure!(
913 if directory {
914 metadata.is_dir()
915 } else {
916 metadata.is_file()
917 },
918 "Codewhale-owned xAI OAuth path has the wrong filesystem type"
919 );
920 handles.push(handle);
921 }
922 Component::CurDir | Component::ParentDir => {
923 bail!("private selected path must be lexically normalized")
924 }
925 }
926 }
927 anyhow::ensure!(
928 !handles.is_empty(),
929 "private selected path cannot be a volume root"
930 );
931 Ok(handles)
932 }
933
934 #[cfg(windows)]
935 fn windows_final_handle_path(file: &File) -> Result<PathBuf> {
936 use std::ffi::OsString;
937 use std::os::windows::ffi::OsStringExt as _;
938 use std::os::windows::io::AsRawHandle as _;
939 use windows_sys::Win32::Storage::FileSystem::{
940 FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW, VOLUME_NAME_DOS,
941 };
942
943 let flags = FILE_NAME_NORMALIZED | VOLUME_NAME_DOS;
944 let handle = file.as_raw_handle();
945 // SAFETY: null output asks only for the required UTF-16 length.
946 let needed = unsafe { GetFinalPathNameByHandleW(handle, std::ptr::null_mut(), 0, flags) };
947 if needed == 0 {
948 return Err(std::io::Error::last_os_error())
949 .context("resolving Codewhale-owned xAI OAuth handle path");
950 }
951 let mut buffer = vec![0u16; needed as usize + 1];
952 // SAFETY: the buffer is writable and the handle remains valid.
953 let written = unsafe {
954 GetFinalPathNameByHandleW(handle, buffer.as_mut_ptr(), buffer.len() as u32, flags)
955 };
956 if written == 0 || written as usize >= buffer.len() {
957 return Err(std::io::Error::last_os_error())
958 .context("resolving Codewhale-owned xAI OAuth handle path");
959 }
960 Ok(PathBuf::from(OsString::from_wide(
961 &buffer[..written as usize],
962 )))
963 }
964
965 #[cfg(all(test, windows))]
966 mod windows_tests {
967 use super::*;
968 use std::os::windows::fs::OpenOptionsExt as _;
969 use windows_sys::Win32::Storage::FileSystem::{
970 FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE,
971 FILE_SHARE_READ, FILE_SHARE_WRITE,
972 };
973
974 #[test]
975 fn selected_path_alias_keeps_identity_and_replacement_is_refused() {
976 let root = tempfile::tempdir().expect("temporary root");
977 let selected = root.path().join("credentials");
978 fs::create_dir(&selected).expect("selected directory");
979 // Hosted Windows temporary roots can contain RUNNER~1 while the kernel
980 // reports the long spelling. Both must select the same retained object.
981 let canonical = selected
982 .canonicalize()
983 .expect("canonical selected directory");
984 let held = fs::OpenOptions::new()
985 .read(true)
986 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
987 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
988 .open(&selected)
989 .expect("captured selected directory");
990 validate_windows_handle_path(&held, &selected, true).expect("selected spelling");
991 validate_windows_handle_path(&held, &canonical, true).expect("canonical spelling");
992
993 let retained = root.path().join("retained");
994 fs::rename(&selected, &retained).expect("move captured object");
995 fs::create_dir(&selected).expect("replacement at the same selected path");
996 assert!(
997 validate_windows_handle_path(&held, &selected, true).is_err(),
998 "a replacement at the selected path must not inherit the captured identity"
999 );
1000 validate_windows_handle_path(&held, &retained, true).expect("actual retained object");
1001 fs::create_dir(root.path().join("other")).expect("existing unnormalized path component");
1002 assert!(
1003 validate_windows_handle_path(&held, &root.path().join("other/../retained"), true)
1004 .is_err(),
1005 "lexically unnormalized paths remain inadmissible"
1006 );
1007 }
1008 }
1009
1010 #[cfg(windows)]
1011 pub(crate) fn normalize_windows_path_for_comparison(path: &Path) -> Result<String> {
1012 let text = path.to_str().ok_or_else(|| {
1013 anyhow::anyhow!(
1014 "xAI OAuth path {} contains invalid Unicode and cannot be compared safely",
1015 crate::quote_os_path(path)
1016 )
1017 })?;
1018 let without_device_prefix = text.strip_prefix(r"\\?\").unwrap_or(text);
1019 let normalized_prefix = without_device_prefix.strip_prefix("UNC\\").map_or_else(
1020 || without_device_prefix.to_string(),
1021 |rest| format!(r"\\{rest}"),
1022 );
1023 Ok(normalized_prefix
1024 .replace('/', "\\")
1025 .trim_end_matches('\\')
1026 .to_lowercase())
1027 }
1028
1029 #[cfg(windows)]
1030 fn secure_windows_owner_only_handle(file: &File, inherit_to_children: bool) -> Result<()> {
1031 use std::os::windows::io::AsRawHandle as _;
1032 use windows_sys::Win32::Foundation::ERROR_SUCCESS;
1033 use windows_sys::Win32::Security::Authorization::{SE_FILE_OBJECT, SetSecurityInfo};
1034 use windows_sys::Win32::Security::{
1035 DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION,
1036 };
1037 use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
1038
1039 let acl = crate::windows_identity::OwnerOnlyAcl::new(FILE_ALL_ACCESS, inherit_to_children)?;
1040 // SAFETY: the file handle remains owned by `file`, and the ACL remains
1041 // allocated for the duration of the call. The owner and protected DACL are
1042 // committed together so the verifier never observes a half-secured file.
1043 let result = unsafe {
1044 SetSecurityInfo(
1045 file.as_raw_handle(),
1046 SE_FILE_OBJECT,
1047 OWNER_SECURITY_INFORMATION
1048 | DACL_SECURITY_INFORMATION
1049 | PROTECTED_DACL_SECURITY_INFORMATION,
1050 acl.user_sid(),
1051 std::ptr::null_mut(),
1052 acl.acl(),
1053 std::ptr::null(),
1054 )
1055 };
1056 if result != ERROR_SUCCESS {
1057 return Err(std::io::Error::from_raw_os_error(result as i32))
1058 .context("applying a current-user-only DACL to Codewhale-owned xAI OAuth storage");
1059 }
1060 Ok(())
1061 }
1062
1063 #[cfg(windows)]
1064 pub(crate) fn verify_windows_owner_only_handle(file: &File) -> Result<()> {
1065 use std::os::windows::io::AsRawHandle as _;
1066 use windows_sys::Win32::Foundation::ERROR_SUCCESS;
1067 use windows_sys::Win32::Security::Authorization::{
1068 EXPLICIT_ACCESS_W, GRANT_ACCESS, GetExplicitEntriesFromAclW, GetSecurityInfo,
1069 SE_FILE_OBJECT, SET_ACCESS, TRUSTEE_IS_SID,
1070 };
1071 use windows_sys::Win32::Security::{
1072 ACL, DACL_SECURITY_INFORMATION, EqualSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR,
1073 PSID,
1074 };
1075 use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
1076
1077 let user = CurrentWindowsUser::open()?;
1078 let mut owner: PSID = std::ptr::null_mut();
1079 let mut dacl: *mut ACL = std::ptr::null_mut();
1080 let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
1081 // SAFETY: the handle remains valid and all output pointers are writable.
1082 let result = unsafe {
1083 GetSecurityInfo(
1084 file.as_raw_handle(),
1085 SE_FILE_OBJECT,
1086 OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
1087 &mut owner,
1088 std::ptr::null_mut(),
1089 &mut dacl,
1090 std::ptr::null_mut(),
1091 &mut descriptor,
1092 )
1093 };
1094 if result != ERROR_SUCCESS {
1095 return Err(std::io::Error::from_raw_os_error(result as i32))
1096 .context("reading Codewhale-owned xAI OAuth security descriptor");
1097 }
1098 let _descriptor = WindowsLocalAllocation(descriptor.cast());
1099 // SAFETY: `owner` is non-null; `user.sid()` is owned by `user`.
1100 anyhow::ensure!(
1101 !owner.is_null() && unsafe { EqualSid(owner, user.sid()) } != 0,
1102 "Codewhale-owned xAI OAuth storage owner is not the current user"
1103 );
1104 anyhow::ensure!(
1105 !dacl.is_null(),
1106 "Codewhale-owned xAI OAuth storage must have an owner-only DACL"
1107 );
1108 let mut count = 0;
1109 let mut entries: *mut EXPLICIT_ACCESS_W = std::ptr::null_mut();
1110 // SAFETY: `dacl` belongs to the live descriptor; Windows allocates the
1111 // returned entry array, released by the guard below.
1112 let result = unsafe { GetExplicitEntriesFromAclW(dacl, &mut count, &mut entries) };
1113 if result != ERROR_SUCCESS {
1114 return Err(std::io::Error::from_raw_os_error(result as i32))
1115 .context("reading Codewhale-owned xAI OAuth DACL entries");
1116 }
1117 let _entries = WindowsLocalAllocation(entries.cast());
1118 anyhow::ensure!(
1119 count == 1 && !entries.is_null(),
1120 "Codewhale-owned xAI OAuth DACL must grant only one user"
1121 );
1122 // SAFETY: `count == 1` proves the first returned entry is initialized.
1123 let entry = unsafe { &*entries };
1124 let trustee_sid: PSID = entry.Trustee.ptstrName.cast();
1125 // SAFETY: form and null checked in this expression; sid owned by `user`.
1126 anyhow::ensure!(
1127 entry.Trustee.TrusteeForm == TRUSTEE_IS_SID
1128 && !trustee_sid.is_null()
1129 && unsafe { EqualSid(trustee_sid, user.sid()) } != 0
1130 && matches!(entry.grfAccessMode, SET_ACCESS | GRANT_ACCESS)
1131 && entry.grfAccessPermissions == FILE_ALL_ACCESS,
1132 "Codewhale-owned xAI OAuth DACL is not current-user-only"
1133 );
1134 Ok(())
1135 }
1136
1137 #[cfg(not(any(unix, windows)))]
1138 fn open_owned_directory(directory: &Path, protect: bool, create: bool) -> Result<PrivateDirectory> {
1139 anyhow::ensure!(
1140 protect,
1141 "private endpoint authentication is unsupported on this platform"
1142 );
1143 if create {
1144 fs::create_dir_all(directory)?;
1145 }
1146 let metadata = fs::symlink_metadata(directory)?;
1147 anyhow::ensure!(
1148 metadata.is_dir(),
1149 "Codewhale credentials path must be a directory"
1150 );
1151 Ok(PrivateDirectory {
1152 directory: directory.to_path_buf(),
1153 })
1154 }
1155
1156 #[cfg(not(any(unix, windows)))]
1157 impl PrivateDirectory {
1158 pub fn open_owned_file_for_read(&self, name: &str) -> Result<Option<File>> {
1159 validate_private_basename(name)?;
1160 match File::open(self.directory.join(name)) {
1161 Ok(file) => Ok(Some(file)),
1162 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1163 Err(error) => Err(error.into()),
1164 }
1165 }
1166
1167 pub fn open_internal_file(&self, name: &str) -> Result<File> {
1168 validate_private_basename(name)?;
1169 Ok(fs::OpenOptions::new()
1170 .read(true)
1171 .write(true)
1172 .create(true)
1173 .open(self.directory.join(name))?)
1174 }
1175
1176 pub fn write_owned_file(&self, name: &str, bytes: &[u8], allow_replace: bool) -> Result<()> {
1177 validate_private_basename(name)?;
1178 let path = self.directory.join(name);
1179 anyhow::ensure!(
1180 allow_replace || !path.exists(),
1181 "refusing to replace xAI OAuth generation"
1182 );
1183 crate::persistence::atomic_write(&path, bytes)
1184 }
1185
1186 pub fn remove_raw(&self, name: &str) -> Result<bool> {
1187 validate_private_basename(name)?;
1188 match fs::remove_file(self.directory.join(name)) {
1189 Ok(()) => Ok(true),
1190 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1191 Err(error) => Err(error.into()),
1192 }
1193 }
1194
1195 pub fn rename_raw(&self, from: &str, to: &str) -> Result<()> {
1196 validate_private_basename(from)?;
1197 validate_private_basename(to)?;
1198 fs::rename(self.directory.join(from), self.directory.join(to))?;
1199 Ok(())
1200 }
1201 }
1202
1203 #[cfg(not(any(unix, windows)))]
1204 pub(crate) fn validate_owned_file_handle(file: &File, _path: &Path) -> Result<fs::Metadata> {
1205 let metadata = file.metadata()?;
1206 anyhow::ensure!(metadata.is_file(), "xAI OAuth path must be a regular file");
1207 Ok(metadata)
1208 }
1209
1210 /// Filesystem identity of an owned Unix socket below a retained private parent.
1211 #[cfg(unix)]
1212 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1213 pub struct PrivateSocketIdentity {
1214 device: libc::dev_t,
1215 inode: libc::ino_t,
1216 }
1217
1218 #[cfg(unix)]
1219 impl PrivateDirectory {
1220 pub fn socket_identity(&self, name: &str) -> Result<Option<PrivateSocketIdentity>> {
1221 use std::os::fd::AsRawFd as _;
1222 validate_private_basename(name)?;
1223 let name = CString::new(name)?;
1224 let mut metadata = std::mem::MaybeUninit::<libc::stat>::uninit();
1225 // SAFETY: one basename below the retained directory; output is writable.
1226 if unsafe {
1227 libc::fstatat(
1228 self.directory_handle.as_raw_fd(),
1229 name.as_ptr(),
1230 metadata.as_mut_ptr(),
1231 libc::AT_SYMLINK_NOFOLLOW,
1232 )
1233 } != 0
1234 {
1235 let error = std::io::Error::last_os_error();
1236 if error.kind() == std::io::ErrorKind::NotFound {
1237 return Ok(None);
1238 }
1239 return Err(error.into());
1240 }
1241 // SAFETY: successful fstatat initialized the entire stat object.
1242 let metadata = unsafe { metadata.assume_init() };
1243 if metadata.st_mode & libc::S_IFMT != libc::S_IFSOCK {
1244 return Err(std::io::Error::new(
1245 std::io::ErrorKind::InvalidInput,
1246 "private endpoint exists and is not a socket",
1247 )
1248 .into());
1249 }
1250 anyhow::ensure!(
1251 metadata.st_uid == Self::current_user_id(),
1252 "private socket is not owned by the current user"
1253 );
1254 Ok(Some(PrivateSocketIdentity {
1255 device: metadata.st_dev,
1256 inode: metadata.st_ino,
1257 }))
1258 }
1259
1260 /// Never follows a substituted leaf to change another object's permissions.
1261 pub fn protect_socket(&self, name: &str, identity: PrivateSocketIdentity) -> Result<()> {
1262 use std::os::fd::AsRawFd as _;
1263 anyhow::ensure!(
1264 self.socket_identity(name)? == Some(identity),
1265 "private socket changed before protection"
1266 );
1267 let name_c = CString::new(name)?;
1268 // SAFETY: retained directory, one component; no-follow is mandatory.
1269 if unsafe {
1270 libc::fchmodat(
1271 self.directory_handle.as_raw_fd(),
1272 name_c.as_ptr(),
1273 0o600,
1274 libc::AT_SYMLINK_NOFOLLOW,
1275 )
1276 } != 0
1277 {
1278 return Err(std::io::Error::last_os_error())
1279 .context("protecting private socket without following links");
1280 }
1281 anyhow::ensure!(
1282 self.socket_identity(name)? == Some(identity),
1283 "private socket changed during protection"
1284 );
1285 Ok(())
1286 }
1287
1288 #[cfg(any(target_os = "macos", target_os = "linux"))]
1289 fn move_socket_no_replace(&self, from: &str, to: &str) -> Result<()> {
1290 use std::os::fd::AsRawFd as _;
1291 validate_private_basename(from)?;
1292 validate_private_basename(to)?;
1293 let from = CString::new(from)?;
1294 let to = CString::new(to)?;
1295 #[cfg(target_os = "macos")]
1296 // SAFETY: both basenames and retained descriptors remain valid; EXCL refuses replacement.
1297 let result = unsafe {
1298 libc::renameatx_np(
1299 self.directory_handle.as_raw_fd(),
1300 from.as_ptr(),
1301 self.directory_handle.as_raw_fd(),
1302 to.as_ptr(),
1303 libc::RENAME_EXCL,
1304 )
1305 };
1306 #[cfg(target_os = "linux")]
1307 // SAFETY: same retained directory; NOREPLACE is required, never emulated by a check.
1308 let result = unsafe {
1309 libc::renameat2(
1310 self.directory_handle.as_raw_fd(),
1311 from.as_ptr(),
1312 self.directory_handle.as_raw_fd(),
1313 to.as_ptr(),
1314 libc::RENAME_NOREPLACE,
1315 )
1316 };
1317 if result != 0 {
1318 return Err(std::io::Error::last_os_error())
1319 .context("retiring endpoint without replacement");
1320 }
1321 Ok(())
1322 }
1323
1324 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1325 fn move_socket_no_replace(&self, _from: &str, _to: &str) -> Result<()> {
1326 bail!("exclusive endpoint retirement is unsupported on this platform")
1327 }
1328
1329 pub fn retire_socket(&self, name: &str, expected: PrivateSocketIdentity) -> Result<bool> {
1330 use std::os::fd::AsRawFd as _;
1331 if self.socket_identity(name)? != Some(expected) {
1332 return Ok(false);
1333 }
1334 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1335 let retired = format!(
1336 ".cw-endpoint-{}-{}-{}.retired",
1337 std::process::id(),
1338 SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(),
1339 COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1340 );
1341 self.move_socket_no_replace(name, &retired)?;
1342 if self.socket_identity(&retired)? != Some(expected) {
1343 let restored = self.move_socket_no_replace(&retired, name);
1344 return Err(anyhow::anyhow!(
1345 "endpoint identity changed; replacement preserved (restore: {restored:?})"
1346 ));
1347 }
1348 let retired_c = CString::new(retired)?;
1349 // SAFETY: the exclusively retired, matching socket is below the retained private parent.
1350 if unsafe { libc::unlinkat(self.directory_handle.as_raw_fd(), retired_c.as_ptr(), 0) } != 0
1351 {
1352 return Err(std::io::Error::last_os_error())
1353 .context("removing exact retired private socket");
1354 }
1355 self.directory_handle.sync_all()?;
1356 Ok(true)
1357 }
1358 }
1359
1360 #[cfg(all(test, unix))]
1361 mod tests {
1362 use super::*;
1363 use std::os::unix::fs::{PermissionsExt as _, symlink};
1364 use std::os::unix::net::UnixListener;
1365
1366 fn root() -> tempfile::TempDir {
1367 let selected = Path::new("/tmp").canonicalize().unwrap();
1368 tempfile::Builder::new()
1369 .prefix("cw-pd-")
1370 .tempdir_in(selected)
1371 .unwrap()
1372 }
1373
1374 #[test]
1375 fn owner_receipt_read_is_bounded_and_refuses_links_or_public_permissions() {
1376 let root = root();
1377 let parent = PrivateDirectory::admit(&root.path().join("run")).unwrap();
1378 parent
1379 .write_owned_file("owner.json", b"12345678", false)
1380 .unwrap();
1381 assert!(parent.read_private_receipt("owner.json", 7).is_err());
1382 assert_eq!(
1383 parent
1384 .read_private_receipt("owner.json", 8)
1385 .unwrap()
1386 .unwrap()
1387 .0,
1388 b"12345678"
1389 );
1390 let path = parent.directory.join("owner.json");
1391 fs::hard_link(&path, parent.directory.join("linked.json")).unwrap();
1392 assert!(parent.read_private_receipt("owner.json", 8).is_err());
1393 fs::remove_file(parent.directory.join("linked.json")).unwrap();
1394 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1395 assert!(parent.read_private_receipt("owner.json", 8).is_err());
1396 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
1397 symlink(&path, parent.directory.join("alias.json")).unwrap();
1398 assert!(parent.read_private_receipt("alias.json", 8).is_err());
1399 }
1400
1401 #[cfg(any(target_os = "macos", target_os = "linux"))]
1402 #[test]
1403 fn owner_receipt_retirement_preserves_a_replacement_and_uses_captured_file() {
1404 let root = root();
1405 let parent = PrivateDirectory::admit(&root.path().join("run")).unwrap();
1406 parent
1407 .write_owned_file("owner.json", b"captured", false)
1408 .unwrap();
1409 let (_, captured) = parent
1410 .read_private_receipt("owner.json", 8)
1411 .unwrap()
1412 .unwrap();
1413 fs::rename(
1414 parent.directory.join("owner.json"),
1415 parent.directory.join("old.json"),
1416 )
1417 .unwrap();
1418 parent
1419 .write_owned_file("owner.json", b"replaced", false)
1420 .unwrap();
1421 assert!(
1422 !parent
1423 .retire_private_receipt("owner.json", &captured)
1424 .unwrap()
1425 );
1426 assert_eq!(
1427 parent
1428 .read_private_receipt("owner.json", 8)
1429 .unwrap()
1430 .unwrap()
1431 .0,
1432 b"replaced"
1433 );
1434 assert!(
1435 parent
1436 .retire_private_receipt("old.json", &captured)
1437 .unwrap()
1438 );
1439 assert!(
1440 parent
1441 .read_private_receipt("old.json", 8)
1442 .unwrap()
1443 .is_none()
1444 );
1445 }
1446
1447 #[cfg(any(target_os = "macos", target_os = "linux"))]
1448 #[test]
1449 fn local_process_generation_is_kernel_bound_and_refuses_invalid_pid() {
1450 let first = unix_process_start(std::process::id()).unwrap();
1451 assert!(!first.is_empty());
1452 assert_eq!(unix_process_start(std::process::id()).unwrap(), first);
1453 assert!(unix_process_start(0).is_err());
1454 assert!(unix_process_start(u32::MAX).is_err());
1455 }
1456
1457 #[cfg(target_os = "linux")]
1458 #[test]
1459 fn linux_process_generation_survives_nondumpable_hardening() {
1460 const CHILD: &str = "CODEWHALE_PROCESS_IDENTITY_TEST_CHILD";
1461 if std::env::var_os(CHILD).is_some() {
1462 let pid = std::process::id();
1463 let before = unix_process_start(pid).unwrap();
1464 // SAFETY: changes only this isolated child test process.
1465 assert_eq!(unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) }, 0);
1466 // SAFETY: queries only the calling process with no pointer arguments.
1467 assert_eq!(unsafe { libc::prctl(libc::PR_GET_DUMPABLE, 0, 0, 0, 0) }, 0);
1468 if PrivateDirectory::current_user_id() != 0 {
1469 use std::os::unix::fs::MetadataExt as _;
1470 assert_eq!(fs::metadata(format!("/proc/{pid}/stat")).unwrap().uid(), 0);
1471 }
1472 assert_eq!(unix_process_start(pid).unwrap(), before);
1473 assert!(unix_process_start(0).is_err());
1474 assert!(unix_process_start(u32::MAX).is_err());
1475 println!("HARDENED_PROCESS_GENERATION_VERIFIED");
1476 return;
1477 }
1478 let output = std::process::Command::new(std::env::current_exe().unwrap())
1479 .args([
1480 "private_directory::tests::linux_process_generation_survives_nondumpable_hardening",
1481 "--exact",
1482 "--nocapture",
1483 ])
1484 .env_clear()
1485 .env(CHILD, "1")
1486 .output()
1487 .unwrap();
1488 assert!(
1489 output.status.success(),
1490 "isolated hardened process failed: {}",
1491 String::from_utf8_lossy(&output.stderr)
1492 );
1493 assert!(
1494 String::from_utf8_lossy(&output.stdout)
1495 .contains("HARDENED_PROCESS_GENERATION_VERIFIED")
1496 );
1497 }
1498
1499 #[cfg(target_os = "linux")]
1500 #[test]
1501 fn linux_process_status_refuses_foreign_or_malformed_principals() {
1502 let valid = "Name:\tfixture\nPid:\t42\nUid:\t100\t101\t102\t103\n";
1503 assert!(validate_linux_process_status(valid, 42, 101).is_ok());
1504 assert!(validate_linux_process_status(valid, 42, 100).is_err());
1505 assert!(validate_linux_process_status(valid, 43, 101).is_err());
1506 for status in [
1507 "Uid:\t100 101 102 103\n",
1508 "Pid:\t42\n",
1509 "Pid:\t42\nUid:\t100 101 102\n",
1510 "Pid:\t42\nUid:\t100 101 102 103 104\n",
1511 "Pid:\t42\nUid:\t100 invalid 102 103\n",
1512 "Pid:\t42\nPid:\t42\nUid:\t100 101 102 103\n",
1513 "Pid:\t42\nUid:\t100 101 102 103\nUid:\t100 101 102 103\n",
1514 ] {
1515 assert!(validate_linux_process_status(status, 42, 101).is_err());
1516 }
1517 }
1518
1519 #[test]
1520 fn endpoint_admission_refuses_public_existing_directory_without_repair() {
1521 let root = root();
1522 let selected = root.path().join("public");
1523 fs::create_dir(&selected).unwrap();
1524 fs::set_permissions(&selected, fs::Permissions::from_mode(0o755)).unwrap();
1525 assert!(PrivateDirectory::admit(&selected).is_err());
1526 assert_eq!(
1527 fs::metadata(&selected).unwrap().permissions().mode() & 0o777,
1528 0o755
1529 );
1530 let credentials = PrivateDirectory::open(&selected).unwrap();
1531 assert_eq!(
1532 credentials
1533 .directory_handle
1534 .metadata()
1535 .unwrap()
1536 .permissions()
1537 .mode()
1538 & 0o777,
1539 0o700
1540 );
1541 }
1542
1543 #[test]
1544 fn endpoint_admission_refuses_linked_component_and_changed_selected_parent() {
1545 let root = root();
1546 let original = root.path().join("original");
1547 let held = PrivateDirectory::admit(&original).unwrap();
1548 symlink(&original, root.path().join("alias")).unwrap();
1549 assert!(PrivateDirectory::admit(&root.path().join("alias")).is_err());
1550 fs::rename(&original, root.path().join("retained")).unwrap();
1551 PrivateDirectory::admit(&original).unwrap();
1552 assert!(!held.is_at_selected_path().unwrap());
1553 }
1554
1555 #[cfg(any(target_os = "macos", target_os = "linux"))]
1556 #[test]
1557 fn exact_endpoint_retirement_preserves_replacement_and_removes_captured_socket() {
1558 let root = root();
1559 let parent = PrivateDirectory::admit(&root.path().join("run")).unwrap();
1560 let path = parent.directory.join("owner.sock");
1561 let original = UnixListener::bind(&path).unwrap();
1562 let identity = parent.socket_identity("owner.sock").unwrap().unwrap();
1563 fs::rename(&path, parent.directory.join("old.sock")).unwrap();
1564 let replacement = UnixListener::bind(&path).unwrap();
1565 assert!(!parent.retire_socket("owner.sock", identity).unwrap());
1566 assert!(path.exists());
1567 assert_eq!(parent.socket_identity("old.sock").unwrap(), Some(identity));
1568 assert!(parent.retire_socket("old.sock", identity).unwrap());
1569 assert!(!parent.directory.join("old.sock").exists());
1570 assert!(path.exists());
1571 drop((original, replacement));
1572 }
1573
1574 #[test]
1575 fn endpoint_leaf_symlink_is_never_followed_for_protection_or_retirement() {
1576 let root = root();
1577 let parent = PrivateDirectory::admit(&root.path().join("run")).unwrap();
1578 let path = parent.directory.join("owner.sock");
1579 let original = UnixListener::bind(&path).unwrap();
1580 let identity = parent.socket_identity("owner.sock").unwrap().unwrap();
1581 fs::remove_file(&path).unwrap();
1582 let other = root.path().join("operator-file");
1583 fs::write(&other, b"preserve").unwrap();
1584 fs::set_permissions(&other, fs::Permissions::from_mode(0o644)).unwrap();
1585 symlink(&other, &path).unwrap();
1586 assert!(parent.protect_socket("owner.sock", identity).is_err());
1587 assert!(parent.retire_socket("owner.sock", identity).is_err());
1588 assert_eq!(fs::read(&other).unwrap(), b"preserve");
1589 assert_eq!(
1590 fs::metadata(&other).unwrap().permissions().mode() & 0o777,
1591 0o644
1592 );
1593 assert!(
1594 fs::symlink_metadata(&path)
1595 .unwrap()
1596 .file_type()
1597 .is_symlink()
1598 );
1599 drop(original);
1600 }
1601 }
1602
1603 /// Kernel process creation identity for the authenticated local Unix peer.
1604 /// Run on the existing bounded owner worker; PID alone is never sufficient.
1605 #[cfg(unix)]
1606 pub fn unix_process_start(pid: u32) -> Result<String> {
1607 anyhow::ensure!(pid > 0, "invalid local process PID");
1608 #[cfg(target_os = "macos")]
1609 {
1610 let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::uninit();
1611 let size = std::mem::size_of::<libc::proc_bsdinfo>();
1612 // SAFETY: kernel writes the initialized fixed-size BSD process buffer.
1613 let read = unsafe {
1614 libc::proc_pidinfo(
1615 i32::try_from(pid)?,
1616 libc::PROC_PIDTBSDINFO,
1617 0,
1618 info.as_mut_ptr().cast(),
1619 i32::try_from(size)?,
1620 )
1621 };
1622 anyhow::ensure!(
1623 usize::try_from(read).ok() == Some(size),
1624 "local process creation identity unavailable"
1625 );
1626 // SAFETY: exact-size successful kernel query initialized the buffer.
1627 let info = unsafe { info.assume_init() };
1628 anyhow::ensure!(
1629 info.pbi_pid == pid && info.pbi_uid == PrivateDirectory::current_user_id(),
1630 "local process principal changed"
1631 );
1632 Ok(format!(
1633 "macos:{}:{}",
1634 info.pbi_start_tvsec, info.pbi_start_tvusec
1635 ))
1636 }
1637 #[cfg(target_os = "linux")]
1638 {
1639 use std::io::Read as _;
1640 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1641 use std::os::unix::fs::OpenOptionsExt as _;
1642
1643 // Keep stat and credentials on one kernel process object. A retained
1644 // proc directory cannot be redirected to a reused PID after exit.
1645 let directory = fs::OpenOptions::new()
1646 .read(true)
1647 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
1648 .open(format!("/proc/{pid}"))?;
1649 let mut filesystem = std::mem::MaybeUninit::<libc::statfs>::uninit();
1650 // SAFETY: the live directory descriptor and output buffer are valid.
1651 let result = unsafe { libc::fstatfs(directory.as_raw_fd(), filesystem.as_mut_ptr()) };
1652 if result != 0 {
1653 return Err(std::io::Error::last_os_error())
1654 .context("inspecting kernel process directory");
1655 }
1656 // SAFETY: successful fstatfs initialized the output buffer.
1657 anyhow::ensure!(
1658 i128::from(unsafe { filesystem.assume_init() }.f_type)
1659 == i128::from(libc::PROC_SUPER_MAGIC),
1660 "local process identity is not on procfs"
1661 );
1662 let open = |name: &std::ffi::CStr| -> Result<File> {
1663 // SAFETY: the retained directory and constant basename remain valid.
1664 let fd = unsafe {
1665 libc::openat(
1666 directory.as_raw_fd(),
1667 name.as_ptr(),
1668 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1669 )
1670 };
1671 if fd < 0 {
1672 return Err(std::io::Error::last_os_error())
1673 .context("opening kernel process identity");
1674 }
1675 // SAFETY: openat returned a newly owned descriptor.
1676 Ok(unsafe { File::from_raw_fd(fd) })
1677 };
1678 let mut bytes = Vec::new();
1679 open(c"stat")?.take(8193).read_to_end(&mut bytes)?;
1680 anyhow::ensure!(bytes.len() <= 8192, "oversized kernel process identity");
1681 let stat = std::str::from_utf8(&bytes)?;
1682 anyhow::ensure!(
1683 stat.split_once(' ')
1684 .is_some_and(|(actual, _)| actual.parse::<u32>().ok() == Some(pid)),
1685 "kernel process PID changed"
1686 );
1687 let end = stat.rfind(')').context("invalid kernel process identity")?;
1688 let start = stat[end + 1..]
1689 .split_whitespace()
1690 .nth(19)
1691 .context("kernel process start field unavailable")?
1692 .parse::<u64>()?;
1693 // PR_SET_DUMPABLE=0 makes proc inode ownership root even for a local
1694 // user's process. Authenticate the effective UID in the kernel status
1695 // header instead; do not disable hardening or trust the inode owner.
1696 // Pid/Uid precede potentially large supplementary-group lists.
1697 let mut status = String::new();
1698 open(c"status")?.take(8192).read_to_string(&mut status)?;
1699 validate_linux_process_status(&status, pid, PrivateDirectory::current_user_id())?;
1700 let mut boot = String::new();
1701 File::open("/proc/sys/kernel/random/boot_id")?
1702 .take(65)
1703 .read_to_string(&mut boot)?;
1704 let boot = boot.trim();
1705 anyhow::ensure!(
1706 boot.len() == 36 && boot.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-'),
1707 "kernel boot identity unavailable"
1708 );
1709 Ok(format!("linux:{boot}:{start}"))
1710 }
1711 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1712 bail!("local process generation authentication unsupported on this platform")
1713 }
1714
1715 #[cfg(target_os = "linux")]
1716 fn validate_linux_process_status(status: &str, pid: u32, uid: u32) -> Result<()> {
1717 let mut actual_pid = None;
1718 let mut actual_uid = None;
1719 for line in status.lines() {
1720 if let Some(value) = line.strip_prefix("Pid:") {
1721 anyhow::ensure!(actual_pid.is_none(), "duplicate kernel process PID");
1722 actual_pid = Some(value.trim().parse::<u32>()?);
1723 } else if let Some(value) = line.strip_prefix("Uid:") {
1724 anyhow::ensure!(actual_uid.is_none(), "duplicate kernel process credentials");
1725 let values = value
1726 .split_whitespace()
1727 .map(str::parse::<u32>)
1728 .collect::<std::result::Result<Vec<_>, _>>()?;
1729 anyhow::ensure!(values.len() == 4, "invalid kernel process credentials");
1730 actual_uid = Some(values[1]);
1731 }
1732 }
1733 anyhow::ensure!(actual_pid == Some(pid), "kernel process PID changed");
1734 anyhow::ensure!(actual_uid == Some(uid), "local process principal changed");
1735 Ok(())
1736 }
1737
1738 #[cfg(unix)]
1739 impl PrivateDirectory {
1740 /// The same anchored owned-file opener used by credentials, with a bounded
1741 /// read and retained object for exact receipt retirement.
1742 pub fn read_private_receipt(&self, name: &str, max: usize) -> Result<Option<(Vec<u8>, File)>> {
1743 use std::io::Read as _;
1744 use std::os::unix::fs::MetadataExt as _;
1745 let Some(mut file) = self.open_owned_file_for_read(name)? else {
1746 return Ok(None);
1747 };
1748 let before = validate_owned_file_handle(&file, &self.directory.join(name))?;
1749 anyhow::ensure!(
1750 before.mode() & 0o077 == 0 && before.len() <= u64::try_from(max)?,
1751 "private owner receipt permissions or size refused"
1752 );
1753 let mut bytes = Vec::new();
1754 (&mut file)
1755 .take(
1756 u64::try_from(max)?
1757 .checked_add(1)
1758 .context("receipt limit overflow")?,
1759 )
1760 .read_to_end(&mut bytes)?;
1761 let after = validate_owned_file_handle(&file, &self.directory.join(name))?;
1762 anyhow::ensure!(
1763 bytes.len() <= max
1764 && u64::try_from(bytes.len())? == before.len()
1765 && before.len() == after.len()
1766 && before.mtime() == after.mtime()
1767 && before.mtime_nsec() == after.mtime_nsec(),
1768 "private owner receipt changed while reading"
1769 );
1770 Ok(Some((bytes, file)))
1771 }
1772
1773 pub fn retire_private_receipt(&self, name: &str, captured: &File) -> Result<bool> {
1774 use std::os::unix::fs::MetadataExt as _;
1775 let expected = validate_owned_file_handle(captured, &self.directory.join(name))?;
1776 let Some(named) = self.open_owned_file_for_read(name)? else {
1777 return Ok(false);
1778 };
1779 let actual = validate_owned_file_handle(&named, &self.directory.join(name))?;
1780 if (expected.dev(), expected.ino()) != (actual.dev(), actual.ino()) {
1781 return Ok(false);
1782 }
1783 let retired = format!(
1784 ".cw-owner-{}-{}.retired",
1785 std::process::id(),
1786 SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
1787 );
1788 self.move_socket_no_replace(name, &retired)?;
1789 let Some(moved) = self.open_owned_file_for_read(&retired)? else {
1790 bail!("private receipt retirement is uncertain")
1791 };
1792 let moved_metadata = validate_owned_file_handle(&moved, &self.directory.join(&retired))?;
1793 if (expected.dev(), expected.ino()) != (moved_metadata.dev(), moved_metadata.ino()) {
1794 let restored = self.move_socket_no_replace(&retired, name);
1795 bail!("private receipt changed; retaining replacement (restore: {restored:?})");
1796 }
1797 self.remove_raw(&retired)?;
1798 self.directory_handle.sync_all()?;
1799 Ok(true)
1800 }
1801 }
1802
1803 #[cfg(windows)]
1804 impl PrivateDirectory {
1805 pub fn is_at_selected_path(&self) -> Result<bool> {
1806 let current = Self::inspect(&self.directory)?;
1807 let held = self
1808 ._component_handles
1809 .last()
1810 .context("private directory handle unavailable")?;
1811 let now = current
1812 ._component_handles
1813 .last()
1814 .context("private directory handle unavailable")?;
1815 validate_windows_handle_path(held, &self.directory, true)?;
1816 Ok(windows_file_identity(held)? == windows_file_identity(now)?)
1817 }
1818 pub fn read_private_receipt(&self, name: &str, max: usize) -> Result<Option<(Vec<u8>, File)>> {
1819 use std::io::Read as _;
1820 use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
1821 use windows_sys::Win32::Storage::FileSystem::{
1822 DELETE, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_SHARE_DELETE,
1823 FILE_SHARE_READ, FILE_SHARE_WRITE,
1824 };
1825 validate_private_basename(name)?;
1826 let path = self.directory.join(name);
1827 let file = match fs::OpenOptions::new()
1828 .access_mode(FILE_GENERIC_READ | DELETE)
1829 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
1830 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
1831 .open(&path)
1832 {
1833 Ok(file) => file,
1834 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1835 Err(error) => return Err(error).context("opening private owner receipt"),
1836 };
1837 let before = validate_owned_file_handle(&file, &path)?;
1838 anyhow::ensure!(
1839 before.len() <= max as u64,
1840 "private owner receipt exceeds bound"
1841 );
1842 let mut bytes = Vec::new();
1843 (&file).take(max as u64 + 1).read_to_end(&mut bytes)?;
1844 let after = validate_owned_file_handle(&file, &path)?;
1845 anyhow::ensure!(
1846 bytes.len() as u64 == before.len()
1847 && before.len() == after.len()
1848 && before.last_write_time() == after.last_write_time(),
1849 "private owner receipt changed during bounded read"
1850 );
1851 Ok(Some((bytes, file)))
1852 }
1853 pub fn retire_private_receipt(&self, name: &str, captured: &File) -> Result<bool> {
1854 let Some((_, current)) = self.read_private_receipt(name, 16384)? else {
1855 return Ok(false);
1856 };
1857 if !Self::same_file_identity(captured, &current)? {
1858 return Ok(false);
1859 }
1860 anyhow::ensure!(
1861 self.is_at_selected_path()?,
1862 "private owner parent changed before retirement"
1863 );
1864 validate_owned_file_handle(captured, &self.directory.join(name))?;
1865 // Exact retained handle deletion cannot unlink a substituted pathname.
1866 mark_windows_file_handle_for_deletion(captured)?;
1867 Ok(true)
1868 }
1869 }
1870
1871 #[cfg(windows)]
1872 fn windows_file_identity(file: &File) -> Result<(u32, u32, u32)> {
1873 use std::os::windows::io::AsRawHandle as _;
1874 use windows_sys::Win32::Storage::FileSystem::{
1875 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
1876 };
1877 let mut info = BY_HANDLE_FILE_INFORMATION::default();
1878 anyhow::ensure!(
1879 unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } != 0,
1880 "private file identity unavailable"
1881 );
1882 Ok((
1883 info.dwVolumeSerialNumber,
1884 info.nFileIndexHigh,
1885 info.nFileIndexLow,
1886 ))
1887 }
1888
1889 impl PrivateDirectory {
1890 pub fn same_file_identity(left: &File, right: &File) -> Result<bool> {
1891 #[cfg(unix)]
1892 {
1893 use std::os::unix::fs::MetadataExt as _;
1894 let a = left.metadata()?;
1895 let b = right.metadata()?;
1896 Ok(a.dev() == b.dev() && a.ino() == b.ino())
1897 }
1898 #[cfg(windows)]
1899 {
1900 Ok(windows_file_identity(left)? == windows_file_identity(right)?)
1901 }
1902 #[cfg(not(any(unix, windows)))]
1903 bail!("private file identity unsupported on this platform")
1904 }
1905 }
1906
1906 lines RUST