| 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(¤t), |
| 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(¤t), |
| 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(¤t), |
| 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(¤t) { |
| 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(¤t).with_context(|| { |
| 455 | format!( |
| 456 | "opening Codewhale credentials directory component {}", |
| 457 | crate::quote_os_path(¤t) |
| 458 | ) |
| 459 | })?; |
| 460 | validate_windows_handle_path(&handle, ¤t, 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(¤t) |
| 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, ¤t)? { |
| 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 |