provision.rs (40361B)
1 //! Explicit provisioning for one canonical service-instance state directory. 2 3 use core::fmt; 4 use std::{ 5 error::Error, 6 ffi::{OsStr, OsString}, 7 path::{Component, Path, PathBuf}, 8 }; 9 10 use crate::{InstanceId, RadrootsPathProfile, RuntimeContext, ServiceId}; 11 12 #[cfg(any(target_os = "linux", target_os = "macos"))] 13 use std::fs::File; 14 15 #[cfg(any(target_os = "linux", target_os = "macos"))] 16 use rustix::{ 17 fs::{AtFlags, FileType, Mode, OFlags, fstat, mkdirat, open, openat, statat, unlinkat}, 18 process::geteuid, 19 }; 20 21 const SERVICES_COMPONENT: &str = "services"; 22 23 /// A sealed plan for validating or creating one canonical state directory. 24 /// 25 /// Construction is available only from [`RuntimeContext::state_directory_plan`]. 26 /// The plan owns no ambient-environment lookup and performs no filesystem I/O 27 /// until [`Self::provision`] is called. 28 /// 29 /// ```compile_fail 30 /// use radroots_runtime_paths::{RadrootsPathProfile, RuntimeStateDirectoryPlan}; 31 /// 32 /// let _ = RuntimeStateDirectoryPlan { 33 /// profile: RadrootsPathProfile::RepoLocal, 34 /// state_root: "/tmp/alternate".into(), 35 /// service: todo!(), 36 /// instance: todo!(), 37 /// }; 38 /// ``` 39 #[derive(Clone, PartialEq, Eq)] 40 pub struct RuntimeStateDirectoryPlan { 41 profile: RadrootsPathProfile, 42 state_root: PathBuf, 43 service: ServiceId, 44 instance: InstanceId, 45 } 46 47 impl RuntimeStateDirectoryPlan { 48 pub(crate) fn from_context( 49 context: &RuntimeContext, 50 ) -> Result<Self, StateDirectoryProvisionError> { 51 let state_root = context.paths().state_root(); 52 validate_absolute_root(state_root)?; 53 let expected = state_root 54 .join(SERVICES_COMPONENT) 55 .join(context.service().as_str()) 56 .join(context.instance().as_str()); 57 if expected != context.paths().state() { 58 return Err(StateDirectoryProvisionError::InvalidPlan); 59 } 60 Ok(Self { 61 profile: context.profile(), 62 state_root: state_root.to_path_buf(), 63 service: context.service().clone(), 64 instance: context.instance().clone(), 65 }) 66 } 67 68 /// Returns the path profile whose creation policy is frozen by this plan. 69 #[must_use] 70 pub fn profile(&self) -> RadrootsPathProfile { 71 self.profile 72 } 73 74 /// Validates or creates the exact `services/<service>/<instance>` suffix. 75 /// 76 /// `InteractiveUser` and `RepoLocal` plans may create missing suffix 77 /// directories. `ServiceHost` plans validate an already-provisioned suffix 78 /// and never create it. Existing directories are never permission-repaired. 79 /// Every traversal and creation is descriptor-relative and rejects symlinks. 80 pub fn provision(&self) -> Result<(), StateDirectoryProvisionError> { 81 provision_supported(self) 82 } 83 84 fn components(&self) -> [&OsStr; 3] { 85 [ 86 OsStr::new(SERVICES_COMPONENT), 87 OsStr::new(self.service.as_str()), 88 OsStr::new(self.instance.as_str()), 89 ] 90 } 91 92 fn permits_creation(&self) -> Result<bool, StateDirectoryProvisionError> { 93 match self.profile { 94 RadrootsPathProfile::InteractiveUser | RadrootsPathProfile::RepoLocal => Ok(true), 95 RadrootsPathProfile::ServiceHost => Ok(false), 96 RadrootsPathProfile::MobileNative => { 97 Err(StateDirectoryProvisionError::UnsupportedProfile) 98 } 99 } 100 } 101 } 102 103 impl fmt::Debug for RuntimeStateDirectoryPlan { 104 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 105 formatter 106 .debug_struct("RuntimeStateDirectoryPlan") 107 .field("profile", &self.profile) 108 .field("state_root", &"[redacted]") 109 .field("service", &"[redacted]") 110 .field("instance", &"[redacted]") 111 .finish() 112 } 113 } 114 115 /// Stable path-free failures from state-directory planning or provisioning. 116 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 117 pub enum StateDirectoryProvisionError { 118 InvalidPlan, 119 UnsupportedPlatform, 120 UnsupportedProfile, 121 StateRootUnavailable, 122 MissingDirectory, 123 DirectoryConflict, 124 UnsafeDirectory, 125 Filesystem, 126 Cleanup, 127 } 128 129 impl fmt::Display for StateDirectoryProvisionError { 130 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 131 formatter.write_str(match self { 132 Self::InvalidPlan => "runtime state-directory plan is invalid", 133 Self::UnsupportedPlatform => { 134 "runtime state-directory provisioning is unsupported on this platform" 135 } 136 Self::UnsupportedProfile => { 137 "runtime state-directory provisioning is unsupported for this profile" 138 } 139 Self::StateRootUnavailable => "runtime state-directory root is unavailable or unsafe", 140 Self::MissingDirectory => "runtime state directory must already exist for this profile", 141 Self::DirectoryConflict => { 142 "runtime state-directory entry conflicts with the canonical plan" 143 } 144 Self::UnsafeDirectory => "runtime state directory failed security validation", 145 Self::Filesystem => "runtime state-directory filesystem operation failed", 146 Self::Cleanup => "runtime state-directory cleanup could not be proven complete", 147 }) 148 } 149 } 150 151 impl Error for StateDirectoryProvisionError {} 152 153 fn validate_absolute_root(root: &Path) -> Result<(), StateDirectoryProvisionError> { 154 if !root.is_absolute() || root.parent().is_none() { 155 return Err(StateDirectoryProvisionError::InvalidPlan); 156 } 157 let mut saw_root = false; 158 let mut saw_normal = false; 159 for component in root.components() { 160 match component { 161 Component::RootDir if !saw_root && !saw_normal => saw_root = true, 162 Component::Normal(_) if saw_root => saw_normal = true, 163 Component::Prefix(_) 164 | Component::CurDir 165 | Component::ParentDir 166 | Component::RootDir => { 167 return Err(StateDirectoryProvisionError::InvalidPlan); 168 } 169 Component::Normal(_) => return Err(StateDirectoryProvisionError::InvalidPlan), 170 } 171 } 172 if saw_normal { 173 Ok(()) 174 } else { 175 Err(StateDirectoryProvisionError::InvalidPlan) 176 } 177 } 178 179 #[cfg(not(any(target_os = "linux", target_os = "macos")))] 180 fn provision_supported( 181 _plan: &RuntimeStateDirectoryPlan, 182 ) -> Result<(), StateDirectoryProvisionError> { 183 Err(StateDirectoryProvisionError::UnsupportedPlatform) 184 } 185 186 #[cfg(any(target_os = "linux", target_os = "macos"))] 187 fn provision_supported( 188 plan: &RuntimeStateDirectoryPlan, 189 ) -> Result<(), StateDirectoryProvisionError> { 190 provision_with_operations(plan, &SystemProvisionOperations) 191 } 192 193 #[cfg(any(target_os = "linux", target_os = "macos"))] 194 trait ProvisionOperations { 195 fn after_create(&self, _component_index: usize) -> Result<(), StateDirectoryProvisionError> { 196 Ok(()) 197 } 198 } 199 200 #[cfg(any(target_os = "linux", target_os = "macos"))] 201 struct SystemProvisionOperations; 202 203 #[cfg(any(target_os = "linux", target_os = "macos"))] 204 impl ProvisionOperations for SystemProvisionOperations {} 205 206 #[cfg(any(target_os = "linux", target_os = "macos"))] 207 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 208 struct DirectoryIdentity { 209 device: u64, 210 inode: u64, 211 } 212 213 #[cfg(any(target_os = "linux", target_os = "macos"))] 214 struct CreatedDirectory { 215 parent: File, 216 name: OsString, 217 held: File, 218 identity: DirectoryIdentity, 219 } 220 221 #[cfg(any(target_os = "linux", target_os = "macos"))] 222 struct CreationJournal { 223 state_root_path: PathBuf, 224 state_root: File, 225 state_root_identity: DirectoryIdentity, 226 entries: Vec<CreatedDirectory>, 227 committed: bool, 228 } 229 230 #[cfg(any(target_os = "linux", target_os = "macos"))] 231 impl CreationJournal { 232 fn new( 233 state_root_path: PathBuf, 234 state_root: File, 235 state_root_identity: DirectoryIdentity, 236 ) -> Self { 237 Self { 238 state_root_path, 239 state_root, 240 state_root_identity, 241 entries: Vec::new(), 242 committed: false, 243 } 244 } 245 246 fn fail( 247 &mut self, 248 failure: StateDirectoryProvisionError, 249 ) -> Result<(), StateDirectoryProvisionError> { 250 match self.cleanup() { 251 Ok(()) => Err(failure), 252 Err(()) => Err(StateDirectoryProvisionError::Cleanup), 253 } 254 } 255 256 fn cleanup(&mut self) -> Result<(), ()> { 257 if validate_absolute_directory_binding( 258 &self.state_root_path, 259 &self.state_root, 260 self.state_root_identity, 261 ) 262 .is_err() 263 { 264 return Err(()); 265 } 266 let mut clean = true; 267 for entry in self.entries.iter().rev() { 268 if cleanup_created_directory(entry).is_err() { 269 clean = false; 270 break; 271 } 272 } 273 if clean { 274 self.entries.clear(); 275 self.committed = true; 276 Ok(()) 277 } else { 278 Err(()) 279 } 280 } 281 } 282 283 #[cfg(any(target_os = "linux", target_os = "macos"))] 284 impl Drop for CreationJournal { 285 fn drop(&mut self) { 286 if !self.committed { 287 let _ = self.cleanup(); 288 } 289 } 290 } 291 292 #[cfg(any(target_os = "linux", target_os = "macos"))] 293 fn provision_with_operations( 294 plan: &RuntimeStateDirectoryPlan, 295 operations: &dyn ProvisionOperations, 296 ) -> Result<(), StateDirectoryProvisionError> { 297 let permits_creation = plan.permits_creation()?; 298 let state_root = open_absolute_directory(&plan.state_root)?; 299 let state_root_identity = validate_secure_directory(&state_root, false) 300 .map_err(|_| StateDirectoryProvisionError::StateRootUnavailable)?; 301 let mut current = state_root 302 .try_clone() 303 .map_err(|_| StateDirectoryProvisionError::Filesystem)?; 304 let mut journal = 305 CreationJournal::new(plan.state_root.clone(), state_root, state_root_identity); 306 let mut bindings = Vec::with_capacity(3); 307 308 for (component_index, component) in plan.components().into_iter().enumerate() { 309 if validate_absolute_directory_binding( 310 &journal.state_root_path, 311 &journal.state_root, 312 journal.state_root_identity, 313 ) 314 .is_err() 315 { 316 return journal.fail(StateDirectoryProvisionError::StateRootUnavailable); 317 } 318 let parent = match current.try_clone() { 319 Ok(parent) => parent, 320 Err(_) => return journal.fail(StateDirectoryProvisionError::Filesystem), 321 }; 322 match open_directory_at(¤t, component) { 323 Ok(next) => { 324 let identity = match validate_secure_directory(&next, false) { 325 Ok(identity) => identity, 326 Err(failure) => return journal.fail(failure), 327 }; 328 let held = match next.try_clone() { 329 Ok(held) => held, 330 Err(_) => return journal.fail(StateDirectoryProvisionError::Filesystem), 331 }; 332 bindings.push(DirectoryBinding { 333 parent, 334 name: component.to_os_string(), 335 held, 336 identity, 337 exact_owner_mode: false, 338 }); 339 current = next; 340 } 341 Err(rustix::io::Errno::NOENT) if !permits_creation => { 342 return journal.fail(StateDirectoryProvisionError::MissingDirectory); 343 } 344 Err(rustix::io::Errno::NOENT) => { 345 match mkdirat(&parent, component, Mode::RUSR | Mode::WUSR | Mode::XUSR) { 346 Ok(()) => {} 347 Err(rustix::io::Errno::EXIST) => match open_directory_at(&parent, component) { 348 Ok(next) => { 349 let identity = match validate_secure_directory(&next, false) { 350 Ok(identity) => identity, 351 Err(failure) => return journal.fail(failure), 352 }; 353 let held = match next.try_clone() { 354 Ok(held) => held, 355 Err(_) => { 356 return journal.fail(StateDirectoryProvisionError::Filesystem); 357 } 358 }; 359 bindings.push(DirectoryBinding { 360 parent, 361 name: component.to_os_string(), 362 held, 363 identity, 364 exact_owner_mode: false, 365 }); 366 current = next; 367 continue; 368 } 369 Err(_) => { 370 return journal.fail(StateDirectoryProvisionError::DirectoryConflict); 371 } 372 }, 373 Err(_) => return journal.fail(StateDirectoryProvisionError::Filesystem), 374 } 375 376 let created_identity = match created_directory_identity(&parent, component) { 377 Ok(identity) => identity, 378 Err(failure) => return journal.fail(failure), 379 }; 380 let held = match open_directory_at(&parent, component) { 381 Ok(held) => held, 382 Err(_) => return journal.fail(StateDirectoryProvisionError::DirectoryConflict), 383 }; 384 let opened_identity = match validate_secure_directory(&held, true) { 385 Ok(identity) => identity, 386 Err(failure) => return journal.fail(failure), 387 }; 388 if opened_identity != created_identity { 389 return journal.fail(StateDirectoryProvisionError::DirectoryConflict); 390 } 391 journal.entries.push(CreatedDirectory { 392 parent, 393 name: component.to_os_string(), 394 held, 395 identity: created_identity, 396 }); 397 let Some(created) = journal.entries.last() else { 398 return journal.fail(StateDirectoryProvisionError::Filesystem); 399 }; 400 let next = match created.held.try_clone() { 401 Ok(next) => next, 402 Err(_) => return journal.fail(StateDirectoryProvisionError::Filesystem), 403 }; 404 let binding_parent = match created.parent.try_clone() { 405 Ok(binding_parent) => binding_parent, 406 Err(_) => return journal.fail(StateDirectoryProvisionError::Filesystem), 407 }; 408 let binding_held = match created.held.try_clone() { 409 Ok(binding_held) => binding_held, 410 Err(_) => return journal.fail(StateDirectoryProvisionError::Filesystem), 411 }; 412 if created.parent.sync_all().is_err() { 413 return journal.fail(StateDirectoryProvisionError::Filesystem); 414 } 415 if let Err(failure) = operations.after_create(component_index) { 416 return journal.fail(failure); 417 } 418 bindings.push(DirectoryBinding { 419 parent: binding_parent, 420 name: component.to_os_string(), 421 held: binding_held, 422 identity: created_identity, 423 exact_owner_mode: true, 424 }); 425 current = next; 426 } 427 Err(_) => return journal.fail(StateDirectoryProvisionError::DirectoryConflict), 428 } 429 } 430 431 if validate_absolute_directory_binding( 432 &journal.state_root_path, 433 &journal.state_root, 434 journal.state_root_identity, 435 ) 436 .is_err() 437 { 438 return journal.fail(StateDirectoryProvisionError::StateRootUnavailable); 439 } 440 for binding in &bindings { 441 if validate_directory_binding(binding).is_err() { 442 return journal.fail(StateDirectoryProvisionError::DirectoryConflict); 443 } 444 } 445 446 journal.committed = true; 447 Ok(()) 448 } 449 450 #[cfg(any(target_os = "linux", target_os = "macos"))] 451 struct DirectoryBinding { 452 parent: File, 453 name: OsString, 454 held: File, 455 identity: DirectoryIdentity, 456 exact_owner_mode: bool, 457 } 458 459 #[cfg(any(target_os = "linux", target_os = "macos"))] 460 fn validate_directory_binding(binding: &DirectoryBinding) -> Result<(), ()> { 461 let current = open_directory_at(&binding.parent, &binding.name).map_err(|_| ())?; 462 let held_identity = 463 validate_secure_directory(&binding.held, binding.exact_owner_mode).map_err(|_| ())?; 464 let current_identity = 465 validate_secure_directory(¤t, binding.exact_owner_mode).map_err(|_| ())?; 466 if held_identity == binding.identity && current_identity == binding.identity { 467 Ok(()) 468 } else { 469 Err(()) 470 } 471 } 472 473 #[cfg(any(target_os = "linux", target_os = "macos"))] 474 fn validate_absolute_directory_binding( 475 path: &Path, 476 held: &File, 477 expected: DirectoryIdentity, 478 ) -> Result<(), ()> { 479 let current = open_absolute_directory(path).map_err(|_| ())?; 480 let held_identity = validate_secure_directory(held, false).map_err(|_| ())?; 481 let current_identity = validate_secure_directory(¤t, false).map_err(|_| ())?; 482 if held_identity == expected && current_identity == expected { 483 Ok(()) 484 } else { 485 Err(()) 486 } 487 } 488 489 #[cfg(any(target_os = "linux", target_os = "macos"))] 490 fn open_absolute_directory(root: &Path) -> Result<File, StateDirectoryProvisionError> { 491 let mut current = File::from( 492 open( 493 Path::new("/"), 494 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, 495 Mode::empty(), 496 ) 497 .map_err(|_| StateDirectoryProvisionError::StateRootUnavailable)?, 498 ); 499 for component in root.components() { 500 match component { 501 Component::RootDir => {} 502 Component::Normal(name) => { 503 current = open_directory_at(¤t, name) 504 .map_err(|_| StateDirectoryProvisionError::StateRootUnavailable)?; 505 } 506 Component::Prefix(_) | Component::CurDir | Component::ParentDir => { 507 return Err(StateDirectoryProvisionError::InvalidPlan); 508 } 509 } 510 } 511 Ok(current) 512 } 513 514 #[cfg(any(target_os = "linux", target_os = "macos"))] 515 fn open_directory_at(parent: &File, name: &OsStr) -> Result<File, rustix::io::Errno> { 516 openat( 517 parent, 518 name, 519 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, 520 Mode::empty(), 521 ) 522 .map(File::from) 523 } 524 525 #[cfg(any(target_os = "linux", target_os = "macos"))] 526 fn created_directory_identity( 527 parent: &File, 528 name: &OsStr, 529 ) -> Result<DirectoryIdentity, StateDirectoryProvisionError> { 530 let status = statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) 531 .map_err(|_| StateDirectoryProvisionError::DirectoryConflict)?; 532 validate_directory_status( 533 FileType::from_raw_mode(status.st_mode).is_dir(), 534 status.st_uid, 535 normalize_mode(status.st_mode), 536 true, 537 )?; 538 Ok(DirectoryIdentity { 539 device: normalize_device(status.st_dev) 540 .map_err(|_| StateDirectoryProvisionError::UnsafeDirectory)?, 541 inode: status.st_ino, 542 }) 543 } 544 545 #[cfg(any(target_os = "linux", target_os = "macos"))] 546 fn validate_secure_directory( 547 directory: &File, 548 exact_owner_mode: bool, 549 ) -> Result<DirectoryIdentity, StateDirectoryProvisionError> { 550 let status = fstat(directory).map_err(|_| StateDirectoryProvisionError::Filesystem)?; 551 validate_directory_status( 552 FileType::from_raw_mode(status.st_mode).is_dir(), 553 status.st_uid, 554 normalize_mode(status.st_mode), 555 exact_owner_mode, 556 )?; 557 Ok(DirectoryIdentity { 558 device: normalize_device(status.st_dev) 559 .map_err(|_| StateDirectoryProvisionError::UnsafeDirectory)?, 560 inode: status.st_ino, 561 }) 562 } 563 564 #[cfg(any(target_os = "linux", target_os = "macos"))] 565 fn validate_directory_status( 566 is_directory: bool, 567 owner: u32, 568 mode: u32, 569 exact_owner_mode: bool, 570 ) -> Result<(), StateDirectoryProvisionError> { 571 let permissions = mode & 0o777; 572 let permissions_valid = if exact_owner_mode { 573 permissions == 0o700 574 } else { 575 permissions & 0o022 == 0 && permissions & 0o500 == 0o500 576 }; 577 if is_directory && owner == geteuid().as_raw() && permissions_valid { 578 Ok(()) 579 } else { 580 Err(StateDirectoryProvisionError::UnsafeDirectory) 581 } 582 } 583 584 #[cfg(any(target_os = "linux", target_os = "macos"))] 585 fn cleanup_created_directory(entry: &CreatedDirectory) -> Result<(), ()> { 586 let current = open_directory_at(&entry.parent, &entry.name).map_err(|_| ())?; 587 let held_identity = validate_secure_directory(&entry.held, true).map_err(|_| ())?; 588 let current_identity = validate_secure_directory(¤t, true).map_err(|_| ())?; 589 if held_identity != entry.identity || current_identity != entry.identity { 590 return Err(()); 591 } 592 unlinkat(&entry.parent, &entry.name, AtFlags::REMOVEDIR).map_err(|_| ())?; 593 entry.parent.sync_all().map_err(|_| ()) 594 } 595 596 #[cfg(any(target_os = "linux", target_os = "macos"))] 597 fn normalize_mode<T: Into<u32>>(raw: T) -> u32 { 598 raw.into() 599 } 600 601 #[cfg(any(target_os = "linux", target_os = "macos"))] 602 fn normalize_device<T: TryInto<u64>>(raw: T) -> Result<u64, T::Error> { 603 raw.try_into() 604 } 605 606 #[cfg(all(test, any(target_os = "linux", target_os = "macos")))] 607 mod tests { 608 use std::{ 609 error::Error as _, 610 ffi::OsString, 611 fs, 612 os::unix::fs::{MetadataExt, PermissionsExt, symlink}, 613 path::{Path, PathBuf}, 614 sync::Mutex, 615 }; 616 617 use tempfile::TempDir; 618 619 use super::{ 620 ProvisionOperations, RuntimeStateDirectoryPlan, StateDirectoryProvisionError, 621 provision_with_operations, 622 }; 623 use crate::{ 624 InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, 625 RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId, 626 }; 627 628 fn context( 629 platform: RadrootsPlatform, 630 profile: RadrootsPathProfile, 631 root: &Path, 632 ) -> RuntimeContext { 633 let environment = match platform { 634 RadrootsPlatform::Linux => RadrootsHostEnvironment { 635 xdg_data_home: Some(root.to_path_buf()), 636 xdg_config_home: Some(root.join("config-root")), 637 xdg_state_home: Some(root.join("state-root")), 638 xdg_cache_home: Some(root.join("cache-root")), 639 xdg_runtime_dir: Some(root.join("runtime-root")), 640 ..RadrootsHostEnvironment::default() 641 }, 642 RadrootsPlatform::Macos => RadrootsHostEnvironment { 643 home_dir: Some(root.to_path_buf()), 644 ..RadrootsHostEnvironment::default() 645 }, 646 _ => RadrootsHostEnvironment::default(), 647 }; 648 let repo_local_root = 649 matches!(profile, RadrootsPathProfile::RepoLocal).then(|| root.to_path_buf()); 650 let bootstrap = RuntimeContextBootstrap::new( 651 profile, 652 repo_local_root, 653 if matches!(profile, RadrootsPathProfile::RepoLocal) { 654 RuntimeContextSource::BootstrapCli 655 } else { 656 RuntimeContextSource::SafeDefault 657 }, 658 RuntimeContextSource::BootstrapCli, 659 ) 660 .expect("bootstrap"); 661 RuntimeContext::resolve( 662 &RadrootsPathResolver::new(platform, environment), 663 bootstrap, 664 ServiceId::new("myc").expect("service"), 665 InstanceId::new("primary").expect("instance"), 666 ) 667 .expect("context") 668 } 669 670 fn prepare_state_root(context: &RuntimeContext) -> PathBuf { 671 let root = context.paths().state_root().to_path_buf(); 672 fs::create_dir_all(&root).expect("state root"); 673 fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).expect("root mode"); 674 root 675 } 676 677 #[test] 678 fn root_and_owner_validation_rejects_invalid_inputs() { 679 for root in ["relative", "/", "/valid/../other"] { 680 assert_eq!( 681 super::validate_absolute_root(Path::new(root)), 682 Err(StateDirectoryProvisionError::InvalidPlan) 683 ); 684 } 685 super::validate_absolute_root(Path::new("/valid/root")).unwrap(); 686 let owner = rustix::process::geteuid().as_raw(); 687 for (directory, uid) in [(false, owner), (true, owner.wrapping_add(1))] { 688 assert_eq!( 689 super::validate_directory_status(directory, uid, 0o700, true), 690 Err(StateDirectoryProvisionError::UnsafeDirectory) 691 ); 692 } 693 } 694 695 #[test] 696 fn retained_directory_checks_reject_a_descriptor_for_another_directory() { 697 let temporary = TempDir::new().unwrap(); 698 let root = temporary.path().canonicalize().unwrap(); 699 for name in ["expected", "other"] { 700 fs::create_dir(root.join(name)).unwrap(); 701 fs::set_permissions(root.join(name), fs::Permissions::from_mode(0o700)).unwrap(); 702 } 703 let parent = super::open_absolute_directory(&root).unwrap(); 704 let held = super::open_directory_at(&parent, "expected".as_ref()).unwrap(); 705 let expected = super::validate_secure_directory(&held, true).unwrap(); 706 let mut binding = super::DirectoryBinding { 707 parent, 708 name: "expected".into(), 709 held, 710 identity: expected, 711 exact_owner_mode: true, 712 }; 713 super::validate_directory_binding(&binding).unwrap(); 714 super::validate_absolute_directory_binding(&root.join("expected"), &binding.held, expected) 715 .unwrap(); 716 binding.held = super::open_directory_at(&binding.parent, "other".as_ref()).unwrap(); 717 assert!(super::validate_directory_binding(&binding).is_err()); 718 assert!( 719 super::validate_absolute_directory_binding( 720 &root.join("expected"), 721 &binding.held, 722 expected 723 ) 724 .is_err() 725 ); 726 assert!(root.join("expected").is_dir()); 727 assert!(root.join("other").is_dir()); 728 } 729 730 struct ReplaceAfterCreation { 731 component: usize, 732 original: PathBuf, 733 displaced: PathBuf, 734 } 735 736 impl ProvisionOperations for ReplaceAfterCreation { 737 fn after_create(&self, component_index: usize) -> Result<(), StateDirectoryProvisionError> { 738 if component_index == self.component { 739 fs::rename(&self.original, &self.displaced).unwrap(); 740 fs::create_dir(&self.original).unwrap(); 741 fs::set_permissions(&self.original, fs::Permissions::from_mode(0o700)).unwrap(); 742 } 743 Ok(()) 744 } 745 } 746 747 #[test] 748 fn successful_creation_hooks_cannot_hide_root_or_suffix_replacement() { 749 for (component, replace_root) in [(0, true), (2, true), (2, false)] { 750 let temporary = TempDir::new().unwrap(); 751 let context = context( 752 RadrootsPlatform::Linux, 753 RadrootsPathProfile::RepoLocal, 754 temporary.path(), 755 ); 756 let root = prepare_state_root(&context); 757 let original = if replace_root { 758 root.clone() 759 } else { 760 root.join("services") 761 }; 762 let displaced = temporary.path().join("displaced"); 763 let operations = ReplaceAfterCreation { 764 component, 765 original: original.clone(), 766 displaced: displaced.clone(), 767 }; 768 assert_eq!( 769 provision_with_operations(&context.state_directory_plan().unwrap(), &operations), 770 Err(StateDirectoryProvisionError::Cleanup) 771 ); 772 assert!(original.is_dir()); 773 assert!(displaced.is_dir()); 774 assert_ne!( 775 fs::metadata(&original).unwrap().ino(), 776 fs::metadata(&displaced).unwrap().ino() 777 ); 778 } 779 } 780 781 #[test] 782 fn repo_local_creates_only_the_exact_canonical_suffix() { 783 let temporary = TempDir::new().expect("temporary root"); 784 let context = context( 785 RadrootsPlatform::Linux, 786 RadrootsPathProfile::RepoLocal, 787 temporary.path(), 788 ); 789 let state_root = prepare_state_root(&context); 790 791 context 792 .state_directory_plan() 793 .expect("plan") 794 .provision() 795 .expect("provision"); 796 797 let expected = state_root.join("services/myc/primary"); 798 assert_eq!(context.paths().state(), expected); 799 for directory in [ 800 state_root.join("services"), 801 state_root.join("services/myc"), 802 expected, 803 ] { 804 let metadata = fs::metadata(directory).expect("created directory"); 805 assert!(metadata.is_dir()); 806 assert_eq!(metadata.permissions().mode() & 0o777, 0o700); 807 } 808 assert_eq!( 809 fs::read_dir(temporary.path()) 810 .expect("base inventory") 811 .map(|entry| entry.expect("entry").file_name()) 812 .collect::<Vec<_>>(), 813 vec![OsString::from("data")], 814 ); 815 } 816 817 #[test] 818 fn linux_and_macos_interactive_profiles_create_the_exact_suffix() { 819 let linux = TempDir::new().expect("linux root"); 820 let linux_context = context( 821 RadrootsPlatform::Linux, 822 RadrootsPathProfile::InteractiveUser, 823 linux.path(), 824 ); 825 let linux_root = prepare_state_root(&linux_context); 826 linux_context 827 .state_directory_plan() 828 .expect("linux plan") 829 .provision() 830 .expect("linux provision"); 831 assert_eq!( 832 linux_context.paths().state(), 833 linux_root.join("services/myc/primary") 834 ); 835 836 let macos = TempDir::new().expect("macOS home"); 837 let macos_context = context( 838 RadrootsPlatform::Macos, 839 RadrootsPathProfile::InteractiveUser, 840 macos.path(), 841 ); 842 let macos_root = prepare_state_root(&macos_context); 843 macos_context 844 .state_directory_plan() 845 .expect("macOS plan") 846 .provision() 847 .expect("macOS provision"); 848 assert_eq!( 849 macos_context.paths().state(), 850 macos_root.join("services/myc/primary") 851 ); 852 } 853 854 #[test] 855 fn existing_directories_are_validated_without_permission_repair() { 856 let temporary = TempDir::new().expect("temporary root"); 857 let context = context( 858 RadrootsPlatform::Linux, 859 RadrootsPathProfile::RepoLocal, 860 temporary.path(), 861 ); 862 let root = prepare_state_root(&context); 863 fs::create_dir_all(context.paths().state()).expect("existing suffix"); 864 fs::set_permissions(root.join("services/myc"), fs::Permissions::from_mode(0o755)) 865 .expect("safe existing mode"); 866 fs::set_permissions(context.paths().state(), fs::Permissions::from_mode(0o770)) 867 .expect("unsafe mode"); 868 869 assert_eq!( 870 context.state_directory_plan().expect("plan").provision(), 871 Err(StateDirectoryProvisionError::UnsafeDirectory) 872 ); 873 assert_eq!( 874 fs::metadata(context.paths().state()) 875 .expect("state metadata") 876 .permissions() 877 .mode() 878 & 0o777, 879 0o770 880 ); 881 assert_eq!( 882 fs::metadata(root.join("services/myc")) 883 .expect("service metadata") 884 .permissions() 885 .mode() 886 & 0o777, 887 0o755 888 ); 889 } 890 891 #[test] 892 fn symlink_entries_are_rejected_without_following_or_repairing() { 893 let temporary = TempDir::new().expect("temporary root"); 894 let context = context( 895 RadrootsPlatform::Linux, 896 RadrootsPathProfile::RepoLocal, 897 temporary.path(), 898 ); 899 let root = prepare_state_root(&context); 900 let outside = temporary.path().join("outside"); 901 fs::create_dir(&outside).expect("outside"); 902 symlink(&outside, root.join("services")).expect("symlink"); 903 904 assert_eq!( 905 context.state_directory_plan().expect("plan").provision(), 906 Err(StateDirectoryProvisionError::DirectoryConflict) 907 ); 908 assert!( 909 outside 910 .read_dir() 911 .expect("outside inventory") 912 .next() 913 .is_none() 914 ); 915 } 916 917 #[test] 918 fn service_host_is_existing_only() { 919 let temporary = TempDir::new().expect("temporary root"); 920 let plan = RuntimeStateDirectoryPlan { 921 profile: RadrootsPathProfile::ServiceHost, 922 state_root: temporary.path().join("state-root"), 923 service: ServiceId::new("myc").expect("service"), 924 instance: InstanceId::new("primary").expect("instance"), 925 }; 926 fs::create_dir(&plan.state_root).expect("state root"); 927 fs::set_permissions(&plan.state_root, fs::Permissions::from_mode(0o700)) 928 .expect("root mode"); 929 930 assert_eq!( 931 plan.provision(), 932 Err(StateDirectoryProvisionError::MissingDirectory) 933 ); 934 assert!(!plan.state_root.join("services").exists()); 935 936 fs::create_dir_all(plan.state_root.join("services/myc/primary")) 937 .expect("preprovisioned suffix"); 938 for directory in [ 939 plan.state_root.join("services"), 940 plan.state_root.join("services/myc"), 941 plan.state_root.join("services/myc/primary"), 942 ] { 943 fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("suffix mode"); 944 } 945 plan.provision().expect("existing-only validation"); 946 } 947 948 struct FailingOperations { 949 fail_after: usize, 950 } 951 952 impl ProvisionOperations for FailingOperations { 953 fn after_create(&self, component_index: usize) -> Result<(), StateDirectoryProvisionError> { 954 if component_index == self.fail_after { 955 Err(StateDirectoryProvisionError::Filesystem) 956 } else { 957 Ok(()) 958 } 959 } 960 } 961 962 #[test] 963 fn partial_creation_failure_removes_only_exact_created_identities() { 964 let temporary = TempDir::new().expect("temporary root"); 965 let context = context( 966 RadrootsPlatform::Linux, 967 RadrootsPathProfile::RepoLocal, 968 temporary.path(), 969 ); 970 let root = prepare_state_root(&context); 971 let plan = context.state_directory_plan().expect("plan"); 972 973 assert_eq!( 974 provision_with_operations(&plan, &FailingOperations { fail_after: 1 }), 975 Err(StateDirectoryProvisionError::Filesystem) 976 ); 977 assert!(!root.join("services").exists()); 978 } 979 980 struct ReplacingOperations { 981 created: PathBuf, 982 displaced: PathBuf, 983 ran: Mutex<bool>, 984 } 985 986 impl ProvisionOperations for ReplacingOperations { 987 fn after_create(&self, component_index: usize) -> Result<(), StateDirectoryProvisionError> { 988 if component_index == 0 { 989 fs::rename(&self.created, &self.displaced).expect("displace created directory"); 990 fs::create_dir(&self.created).expect("replacement directory"); 991 fs::set_permissions(&self.created, fs::Permissions::from_mode(0o700)) 992 .expect("replacement mode"); 993 *self.ran.lock().expect("replacement flag") = true; 994 return Err(StateDirectoryProvisionError::Filesystem); 995 } 996 Ok(()) 997 } 998 } 999 1000 #[test] 1001 fn cleanup_preserves_a_replacement_with_an_unmatched_identity() { 1002 let temporary = TempDir::new().expect("temporary root"); 1003 let context = context( 1004 RadrootsPlatform::Linux, 1005 RadrootsPathProfile::RepoLocal, 1006 temporary.path(), 1007 ); 1008 let root = prepare_state_root(&context); 1009 let operations = ReplacingOperations { 1010 created: root.join("services"), 1011 displaced: root.join("displaced-services"), 1012 ran: Mutex::new(false), 1013 }; 1014 1015 assert_eq!( 1016 provision_with_operations(&context.state_directory_plan().expect("plan"), &operations,), 1017 Err(StateDirectoryProvisionError::Cleanup) 1018 ); 1019 assert!(*operations.ran.lock().expect("replacement flag")); 1020 assert!(operations.created.is_dir()); 1021 assert_ne!( 1022 fs::metadata(&operations.created) 1023 .expect("replacement") 1024 .ino(), 1025 fs::metadata(&operations.displaced) 1026 .expect("displaced") 1027 .ino(), 1028 ); 1029 } 1030 1031 struct ReplacingRootOperations { 1032 root: PathBuf, 1033 displaced: PathBuf, 1034 } 1035 1036 impl ProvisionOperations for ReplacingRootOperations { 1037 fn after_create(&self, component_index: usize) -> Result<(), StateDirectoryProvisionError> { 1038 if component_index == 0 { 1039 fs::rename(&self.root, &self.displaced).expect("displace state root"); 1040 fs::create_dir(&self.root).expect("replacement state root"); 1041 fs::set_permissions(&self.root, fs::Permissions::from_mode(0o700)) 1042 .expect("replacement root mode"); 1043 return Err(StateDirectoryProvisionError::Filesystem); 1044 } 1045 Ok(()) 1046 } 1047 } 1048 1049 #[test] 1050 fn state_root_replacement_blocks_cleanup_and_preserves_both_identities() { 1051 let temporary = TempDir::new().expect("temporary root"); 1052 let context = context( 1053 RadrootsPlatform::Linux, 1054 RadrootsPathProfile::RepoLocal, 1055 temporary.path(), 1056 ); 1057 let root = prepare_state_root(&context); 1058 let displaced = temporary.path().join("displaced-data"); 1059 let operations = ReplacingRootOperations { 1060 root: root.clone(), 1061 displaced: displaced.clone(), 1062 }; 1063 1064 assert_eq!( 1065 provision_with_operations(&context.state_directory_plan().expect("plan"), &operations,), 1066 Err(StateDirectoryProvisionError::Cleanup) 1067 ); 1068 assert!(root.is_dir()); 1069 assert!(displaced.join("services").is_dir()); 1070 assert_ne!( 1071 fs::metadata(root).expect("replacement root").ino(), 1072 fs::metadata(displaced).expect("displaced root").ino(), 1073 ); 1074 } 1075 1076 #[test] 1077 fn plan_and_errors_do_not_render_paths_or_identities() { 1078 let temporary = TempDir::new().expect("temporary root"); 1079 let context = context( 1080 RadrootsPlatform::Linux, 1081 RadrootsPathProfile::RepoLocal, 1082 temporary.path(), 1083 ); 1084 let plan = context.state_directory_plan().expect("plan"); 1085 let rendered = format!("{plan:?}"); 1086 assert!(!rendered.contains(temporary.path().to_string_lossy().as_ref())); 1087 assert!(!rendered.contains("myc")); 1088 assert!(!rendered.contains("primary")); 1089 for failure in [ 1090 StateDirectoryProvisionError::InvalidPlan, 1091 StateDirectoryProvisionError::UnsupportedPlatform, 1092 StateDirectoryProvisionError::UnsupportedProfile, 1093 StateDirectoryProvisionError::StateRootUnavailable, 1094 StateDirectoryProvisionError::MissingDirectory, 1095 StateDirectoryProvisionError::DirectoryConflict, 1096 StateDirectoryProvisionError::UnsafeDirectory, 1097 StateDirectoryProvisionError::Filesystem, 1098 StateDirectoryProvisionError::Cleanup, 1099 ] { 1100 assert!(failure.source().is_none()); 1101 assert!(!format!("{failure:?} {failure}").contains("/")); 1102 } 1103 } 1104 }