lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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(&current).map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?;
    212     validate_directory_status(&current_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 }