disk.rs (19613B)
1 //! Explicit state-filesystem capacity inspection and admission classification. 2 3 use core::fmt; 4 use std::error::Error; 5 6 use serde::{Deserialize, Deserializer, Serialize, de::Error as _}; 7 8 #[cfg(any(target_os = "linux", target_os = "macos"))] 9 use crate::ServiceSqliteErrorKind; 10 use crate::ServiceSqlitePaths; 11 12 const MAXIMUM_MINIMUM_FREE_BYTES: u64 = i64::MAX as u64; 13 14 /// Explicit minimum free-space policy for authoritative persistence admission. 15 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] 16 #[serde(transparent)] 17 pub struct MinimumFreeBytes(u64); 18 19 impl MinimumFreeBytes { 20 /// Validates a positive threshold representable by the governed TOML integer. 21 pub const fn new(value: u64) -> Result<Self, StateFilesystemCapacityError> { 22 if value == 0 { 23 return Err(StateFilesystemCapacityError::InvalidMinimum); 24 } 25 if value > MAXIMUM_MINIMUM_FREE_BYTES { 26 return Err(StateFilesystemCapacityError::MinimumTooLarge); 27 } 28 Ok(Self(value)) 29 } 30 31 /// Returns the exact configured byte threshold. 32 #[must_use] 33 pub const fn get(self) -> u64 { 34 self.0 35 } 36 } 37 38 impl<'de> Deserialize<'de> for MinimumFreeBytes { 39 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 40 where 41 D: Deserializer<'de>, 42 { 43 let value = u64::deserialize(deserializer)?; 44 Self::new(value).map_err(D::Error::custom) 45 } 46 } 47 48 /// Closed readiness result derived from one advisory filesystem snapshot. 49 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] 50 #[serde(rename_all = "snake_case")] 51 pub enum StateFilesystemCapacityReadiness { 52 Ready, 53 LowDisk, 54 } 55 56 /// Immutable capacity snapshot safe to cache for later readiness projection. 57 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] 58 pub struct StateFilesystemCapacity { 59 available_bytes: u64, 60 minimum_free_bytes: MinimumFreeBytes, 61 readiness: StateFilesystemCapacityReadiness, 62 } 63 64 impl StateFilesystemCapacity { 65 fn new(available_bytes: u64, minimum_free_bytes: MinimumFreeBytes) -> Self { 66 let readiness = if available_bytes >= minimum_free_bytes.get() { 67 StateFilesystemCapacityReadiness::Ready 68 } else { 69 StateFilesystemCapacityReadiness::LowDisk 70 }; 71 Self { 72 available_bytes, 73 minimum_free_bytes, 74 readiness, 75 } 76 } 77 78 /// Returns bytes available to the unprivileged service user. 79 #[must_use] 80 pub const fn available_bytes(self) -> u64 { 81 self.available_bytes 82 } 83 84 /// Returns the exact policy used to classify this snapshot. 85 #[must_use] 86 pub const fn minimum_free_bytes(self) -> MinimumFreeBytes { 87 self.minimum_free_bytes 88 } 89 90 /// Returns the closed ready or low-disk classification. 91 #[must_use] 92 pub const fn readiness(self) -> StateFilesystemCapacityReadiness { 93 self.readiness 94 } 95 96 /// Returns whether this snapshot permits new authoritative admission. 97 #[must_use] 98 pub const fn allows_authoritative_admission(self) -> bool { 99 matches!(self.readiness, StateFilesystemCapacityReadiness::Ready) 100 } 101 } 102 103 /// Injected source for one synchronous state-filesystem capacity snapshot. 104 pub trait StateFilesystemCapacitySource { 105 /// Returns bytes available to the unprivileged service user. 106 fn available_bytes( 107 &self, 108 paths: &ServiceSqlitePaths, 109 ) -> Result<u64, StateFilesystemCapacityError>; 110 } 111 112 /// Production Linux/macOS source backed by retained-directory `fstatvfs`. 113 #[derive(Clone, Copy, Debug, Default)] 114 pub struct PlatformStateFilesystemCapacitySource; 115 116 impl StateFilesystemCapacitySource for PlatformStateFilesystemCapacitySource { 117 fn available_bytes( 118 &self, 119 paths: &ServiceSqlitePaths, 120 ) -> Result<u64, StateFilesystemCapacityError> { 121 #[cfg(any(target_os = "linux", target_os = "macos"))] 122 { 123 available_bytes_native(paths) 124 } 125 #[cfg(not(any(target_os = "linux", target_os = "macos")))] 126 { 127 let _ = paths; 128 Err(StateFilesystemCapacityError::UnsupportedPlatform) 129 } 130 } 131 } 132 133 /// Runs one explicit capacity measurement and applies the supplied policy. 134 pub fn inspect_state_filesystem_capacity<S: StateFilesystemCapacitySource + ?Sized>( 135 paths: &ServiceSqlitePaths, 136 minimum_free_bytes: MinimumFreeBytes, 137 source: &S, 138 ) -> Result<StateFilesystemCapacity, StateFilesystemCapacityError> { 139 source 140 .available_bytes(paths) 141 .map(|available| StateFilesystemCapacity::new(available, minimum_free_bytes)) 142 } 143 144 /// Stable source-free failures for policy and filesystem capacity inspection. 145 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 146 pub enum StateFilesystemCapacityError { 147 InvalidMinimum, 148 MinimumTooLarge, 149 MeasurementUnavailable, 150 MeasurementOverflow, 151 UnsupportedPlatform, 152 } 153 154 impl fmt::Display for StateFilesystemCapacityError { 155 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 156 formatter.write_str(match self { 157 Self::InvalidMinimum => "minimum free bytes must be positive", 158 Self::MinimumTooLarge => "minimum free bytes exceed the supported integer range", 159 Self::MeasurementUnavailable => "state filesystem capacity is unavailable", 160 Self::MeasurementOverflow => "state filesystem capacity is not representable", 161 Self::UnsupportedPlatform => { 162 "state filesystem capacity inspection is unsupported on this platform" 163 } 164 }) 165 } 166 } 167 168 impl Error for StateFilesystemCapacityError {} 169 170 #[cfg(any(test, target_os = "linux", target_os = "macos"))] 171 fn checked_available_bytes( 172 available_blocks: u64, 173 fragment_size: u64, 174 ) -> Result<u64, StateFilesystemCapacityError> { 175 if fragment_size == 0 { 176 return Err(StateFilesystemCapacityError::MeasurementUnavailable); 177 } 178 available_blocks 179 .checked_mul(fragment_size) 180 .ok_or(StateFilesystemCapacityError::MeasurementOverflow) 181 } 182 183 #[cfg(any(target_os = "linux", target_os = "macos"))] 184 fn available_bytes_native(paths: &ServiceSqlitePaths) -> Result<u64, StateFilesystemCapacityError> { 185 use rustix::fs::{Mode, OFlags, fstat, fstatvfs, open}; 186 187 let state_directory = paths 188 .state_database() 189 .parent() 190 .ok_or(StateFilesystemCapacityError::MeasurementUnavailable)?; 191 let held = open( 192 state_directory, 193 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, 194 Mode::empty(), 195 ) 196 .map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?; 197 let held_status = 198 fstat(&held).map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?; 199 validate_directory_status(&held_status)?; 200 let capacity = 201 fstatvfs(&held).map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?; 202 let available = checked_available_bytes(capacity.f_bavail, capacity.f_frsize)?; 203 204 let current = open( 205 state_directory, 206 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, 207 Mode::empty(), 208 ) 209 .map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?; 210 let current_status = 211 fstat(¤t).map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?; 212 validate_directory_status(¤t_status)?; 213 let final_held_status = 214 fstat(&held).map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?; 215 validate_directory_status(&final_held_status)?; 216 let expected_device = crate::native_metadata::device(held_status.st_dev) 217 .map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?; 218 crate::require_condition( 219 crate::native_metadata::identity_pair_matches( 220 crate::native_metadata::device(final_held_status.st_dev) 221 .map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?, 222 final_held_status.st_ino, 223 crate::native_metadata::device(current_status.st_dev) 224 .map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?, 225 current_status.st_ino, 226 expected_device, 227 held_status.st_ino, 228 ), 229 ServiceSqliteErrorKind::Authority, 230 ) 231 .map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?; 232 Ok(available) 233 } 234 235 #[cfg(any(target_os = "linux", target_os = "macos"))] 236 fn validate_directory_status( 237 status: &rustix::fs::Stat, 238 ) -> Result<(), StateFilesystemCapacityError> { 239 use rustix::fs::FileType; 240 use rustix::process::geteuid; 241 242 if !crate::native_metadata::secure_directory( 243 FileType::from_raw_mode(status.st_mode).is_dir(), 244 status.st_uid, 245 geteuid().as_raw(), 246 crate::native_metadata::mode(status.st_mode), 247 ) { 248 return Err(StateFilesystemCapacityError::MeasurementUnavailable); 249 } 250 Ok(()) 251 } 252 253 #[cfg(test)] 254 mod tests { 255 use super::*; 256 257 struct FakeSource(Result<u64, StateFilesystemCapacityError>); 258 259 impl StateFilesystemCapacitySource for FakeSource { 260 fn available_bytes( 261 &self, 262 _paths: &ServiceSqlitePaths, 263 ) -> Result<u64, StateFilesystemCapacityError> { 264 self.0 265 } 266 } 267 268 fn unused_paths() -> ServiceSqlitePaths { 269 use radroots_runtime_paths::{ 270 InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, 271 RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, 272 ServiceId, 273 }; 274 275 let root = std::path::PathBuf::from("/unused/capacity-test-root"); 276 let context = RuntimeContext::resolve( 277 &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), 278 RuntimeContextBootstrap::new( 279 RadrootsPathProfile::RepoLocal, 280 Some(root), 281 RuntimeContextSource::BootstrapCli, 282 RuntimeContextSource::BootstrapCli, 283 ) 284 .expect("bootstrap"), 285 ServiceId::new("myc").expect("service"), 286 InstanceId::new("capacity").expect("instance"), 287 ) 288 .expect("context"); 289 ServiceSqlitePaths::from_runtime_context(&context).expect("paths") 290 } 291 292 #[test] 293 fn minimum_policy_and_strict_numeric_serde_are_bounded() { 294 assert_eq!( 295 MinimumFreeBytes::new(0), 296 Err(StateFilesystemCapacityError::InvalidMinimum) 297 ); 298 for value in [1, 268_435_456, i64::MAX as u64] { 299 let policy = MinimumFreeBytes::new(value).expect("valid policy"); 300 assert_eq!(policy.get(), value); 301 let wire = value.to_string(); 302 assert_eq!(serde_json::to_string(&policy).unwrap(), wire); 303 assert_eq!( 304 serde_json::from_str::<MinimumFreeBytes>(&wire).unwrap(), 305 policy 306 ); 307 } 308 for value in [i64::MAX as u64 + 1, u64::MAX] { 309 assert_eq!( 310 MinimumFreeBytes::new(value), 311 Err(StateFilesystemCapacityError::MinimumTooLarge) 312 ); 313 assert!(serde_json::from_str::<MinimumFreeBytes>(&value.to_string()).is_err()); 314 } 315 for wire in ["0", "-1", "1.0", "\"1\"", "null", "true", "{}", "[]"] { 316 assert!(serde_json::from_str::<MinimumFreeBytes>(wire).is_err()); 317 } 318 } 319 320 #[test] 321 fn injected_values_classify_exact_boundary_and_propagate_failure() { 322 let paths = unused_paths(); 323 let minimum = MinimumFreeBytes::new(268_435_456).unwrap(); 324 for (available, readiness, allowed) in [ 325 (0, StateFilesystemCapacityReadiness::LowDisk, false), 326 ( 327 minimum.get() - 1, 328 StateFilesystemCapacityReadiness::LowDisk, 329 false, 330 ), 331 (minimum.get(), StateFilesystemCapacityReadiness::Ready, true), 332 ( 333 minimum.get() + 1, 334 StateFilesystemCapacityReadiness::Ready, 335 true, 336 ), 337 (u64::MAX, StateFilesystemCapacityReadiness::Ready, true), 338 ] { 339 let report = 340 inspect_state_filesystem_capacity(&paths, minimum, &FakeSource(Ok(available))) 341 .expect("injected measurement"); 342 assert_eq!(report.available_bytes(), available); 343 assert_eq!(report.minimum_free_bytes(), minimum); 344 assert_eq!(report.readiness(), readiness); 345 assert_eq!(report.allows_authoritative_admission(), allowed); 346 } 347 assert_eq!( 348 inspect_state_filesystem_capacity( 349 &paths, 350 minimum, 351 &FakeSource(Err(StateFilesystemCapacityError::MeasurementUnavailable,)), 352 ), 353 Err(StateFilesystemCapacityError::MeasurementUnavailable) 354 ); 355 } 356 357 #[test] 358 fn arithmetic_and_wire_projection_are_exact() { 359 assert_eq!(checked_available_bytes(7, 4), Ok(28)); 360 assert_eq!(checked_available_bytes(0, 4), Ok(0)); 361 assert_eq!( 362 checked_available_bytes(1, 0), 363 Err(StateFilesystemCapacityError::MeasurementUnavailable) 364 ); 365 assert_eq!( 366 checked_available_bytes(u64::MAX, 2), 367 Err(StateFilesystemCapacityError::MeasurementOverflow) 368 ); 369 let report = inspect_state_filesystem_capacity( 370 &unused_paths(), 371 MinimumFreeBytes::new(10).unwrap(), 372 &FakeSource(Ok(10)), 373 ) 374 .unwrap(); 375 assert_eq!( 376 serde_json::to_string(&report).unwrap(), 377 r#"{"available_bytes":10,"minimum_free_bytes":10,"readiness":"ready"}"# 378 ); 379 } 380 381 #[test] 382 fn errors_are_stable_source_free_and_redacted() { 383 use std::error::Error as _; 384 385 let sensitive = "/private/secret-state/state.sqlite"; 386 for error in [ 387 StateFilesystemCapacityError::InvalidMinimum, 388 StateFilesystemCapacityError::MinimumTooLarge, 389 StateFilesystemCapacityError::MeasurementUnavailable, 390 StateFilesystemCapacityError::MeasurementOverflow, 391 StateFilesystemCapacityError::UnsupportedPlatform, 392 ] { 393 assert!(error.source().is_none()); 394 assert!(!error.to_string().contains(sensitive)); 395 assert!(!format!("{error:?}").contains(sensitive)); 396 } 397 } 398 399 #[cfg(any(target_os = "linux", target_os = "macos"))] 400 #[test] 401 fn native_adapter_is_descriptor_bound_nonmutating_and_rejects_unsafe_shapes() { 402 use std::{ 403 fs, 404 os::unix::fs::{MetadataExt, PermissionsExt, symlink}, 405 }; 406 407 fn paths(root: &std::path::Path, instance: &str) -> ServiceSqlitePaths { 408 use radroots_runtime_paths::{ 409 InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, 410 RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, 411 ServiceId, 412 }; 413 414 let context = RuntimeContext::resolve( 415 &RadrootsPathResolver::new( 416 RadrootsPlatform::Linux, 417 RadrootsHostEnvironment::default(), 418 ), 419 RuntimeContextBootstrap::new( 420 RadrootsPathProfile::RepoLocal, 421 Some(root.to_path_buf()), 422 RuntimeContextSource::BootstrapCli, 423 RuntimeContextSource::BootstrapCli, 424 ) 425 .expect("bootstrap"), 426 ServiceId::new("myc").expect("service"), 427 InstanceId::new(instance).expect("instance"), 428 ) 429 .expect("context"); 430 ServiceSqlitePaths::from_runtime_context(&context).expect("paths") 431 } 432 433 let root = tempfile::tempdir().expect("root"); 434 let valid = paths(root.path(), "valid"); 435 let valid_directory = valid.state_database().parent().unwrap(); 436 fs::create_dir_all(valid_directory).expect("state directory"); 437 fs::set_permissions(valid_directory, fs::Permissions::from_mode(0o700)).unwrap(); 438 let before = fs::metadata(valid_directory).unwrap(); 439 let report = inspect_state_filesystem_capacity( 440 &valid, 441 MinimumFreeBytes::new(1).unwrap(), 442 &PlatformStateFilesystemCapacitySource, 443 ) 444 .expect("native measurement"); 445 assert!(report.available_bytes() > 0); 446 assert!(report.allows_authoritative_admission()); 447 let after = fs::metadata(valid_directory).unwrap(); 448 assert_eq!(before.dev(), after.dev()); 449 assert_eq!(before.ino(), after.ino()); 450 assert_eq!(before.permissions().mode(), after.permissions().mode()); 451 assert!(fs::read_dir(valid_directory).unwrap().next().is_none()); 452 for mode in [0o750, 0o755] { 453 fs::set_permissions(valid_directory, fs::Permissions::from_mode(mode)).unwrap(); 454 let report = inspect_state_filesystem_capacity( 455 &valid, 456 MinimumFreeBytes::new(1).unwrap(), 457 &PlatformStateFilesystemCapacitySource, 458 ) 459 .expect("non-writable group/other mode remains admissible"); 460 assert!(report.allows_authoritative_admission()); 461 } 462 463 let missing = paths(root.path(), "missing"); 464 assert_eq!( 465 inspect_state_filesystem_capacity( 466 &missing, 467 MinimumFreeBytes::new(1).unwrap(), 468 &PlatformStateFilesystemCapacitySource, 469 ), 470 Err(StateFilesystemCapacityError::MeasurementUnavailable) 471 ); 472 473 let file = paths(root.path(), "file"); 474 let file_directory = file.state_database().parent().unwrap(); 475 fs::create_dir_all(file_directory.parent().unwrap()).unwrap(); 476 fs::write(file_directory, b"not a directory").unwrap(); 477 assert_eq!( 478 inspect_state_filesystem_capacity( 479 &file, 480 MinimumFreeBytes::new(1).unwrap(), 481 &PlatformStateFilesystemCapacitySource, 482 ), 483 Err(StateFilesystemCapacityError::MeasurementUnavailable) 484 ); 485 486 let linked = paths(root.path(), "linked"); 487 let linked_directory = linked.state_database().parent().unwrap(); 488 fs::create_dir_all(linked_directory.parent().unwrap()).unwrap(); 489 let target = root.path().join("linked-target"); 490 fs::create_dir(&target).unwrap(); 491 symlink(&target, linked_directory).unwrap(); 492 assert_eq!( 493 inspect_state_filesystem_capacity( 494 &linked, 495 MinimumFreeBytes::new(1).unwrap(), 496 &PlatformStateFilesystemCapacitySource, 497 ), 498 Err(StateFilesystemCapacityError::MeasurementUnavailable) 499 ); 500 501 let insecure = paths(root.path(), "insecure"); 502 let insecure_directory = insecure.state_database().parent().unwrap(); 503 fs::create_dir_all(insecure_directory).unwrap(); 504 for mode in [0o720, 0o702, 0o722] { 505 fs::set_permissions(insecure_directory, fs::Permissions::from_mode(mode)).unwrap(); 506 assert_eq!( 507 inspect_state_filesystem_capacity( 508 &insecure, 509 MinimumFreeBytes::new(1).unwrap(), 510 &PlatformStateFilesystemCapacitySource, 511 ), 512 Err(StateFilesystemCapacityError::MeasurementUnavailable) 513 ); 514 } 515 } 516 }