lib

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

commit 120621ba9381ea1ea06b5f500974da43fa0fed98
parent 657970b028e7ce0b61a2b5879a0d9011f3aa725a
Author: triesap <tyson@radroots.org>
Date:   Wed, 12 Aug 2026 01:15:54 +0000

service-sqlite: inspect state disk capacity

- add explicit bounded minimum-free-space policy and cached classification
- measure unprivileged capacity through a retained state-directory descriptor
- preserve passive readiness and host-independent inspection boundaries
- bind documentation, package guards, and native/cross-target qualification

Diffstat:
MAGENTS.md | 13+++++++++++++
Mcrates/service_sqlite/README.md | 25+++++++++++++++++++++++++
Mcrates/service_sqlite/src/lib.rs | 6+++++-
Dcrates/service_sqlite/src/status.rs | 136-------------------------------------------------------------------------------
Acrates/service_sqlite/src/status/disk.rs | 503+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/service_sqlite/src/status/mod.rs | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_sqlite/tests/package_boundary.rs | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
7 files changed, 764 insertions(+), 138 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -264,6 +264,19 @@ Before editing code: connection and its explicit close future until the SQLx worker terminates; retry and host close resume that cleanup before proceeding. A retry must inject a new timestamp. +- State-filesystem capacity inspection is an explicit, synchronous, + host-independent doctor and admission input. Callers must supply a positive + `MinimumFreeBytes`; there is no default threshold. The platform adapter + measures unprivileged available bytes through a retained owner-owned state + directory descriptor that is not group/other writable, and the immutable + result classifies exact equality as + ready and anything below the policy as low disk. Measurement failure is a + typed unavailable result, never fabricated low-disk evidence. Consumers may + cache a successful snapshot and project low disk to the stable + `database_low_disk` reason, but passive readiness handlers must never invoke + the adapter. Keep inspection advisory: do not add a reservation, host/pool or + SQLite dependency, ambient timer, background sampler, service default, or + status persistence to this crate. - Runtime-management flows consume a sealed `RuntimeContext` for every service instance. They must not reconstruct service paths from raw identifiers, ambient selectors, or manager-owned roots, and registries must not persist diff --git a/crates/service_sqlite/README.md b/crates/service_sqlite/README.md @@ -223,6 +223,31 @@ new check or authority release. A retry uses a newly injected wall-clock time. The strict backup and restore integrity verifier remains a separate fail-closed boundary. +State-filesystem capacity inspection is an explicit synchronous input for +doctor checks and authoritative admission. `MinimumFreeBytes` must be supplied +and is constrained to `1..=i64::MAX`; it has no default. The value +`268435456` is the exact governed configuration and test vector, not an +implicit universal threshold. On Linux and macOS the platform adapter opens the +owner-owned state directory that is not group/other writable without following +links, retains and revalidates its identity, and uses `fstatvfs` to measure +bytes available to the unprivileged service user. Other platforms fail closed. + +A successful immutable snapshot is `ready` when available bytes are greater +than or equal to the configured minimum and `low_disk` when they are below it. +Low disk rejects or pauses new authoritative admission; measurement failure is +a typed unavailable error and is never fabricated as low-disk evidence. A +consumer may cache the successful snapshot and later project low disk to the +stable `database_low_disk` readiness reason. `/readyz` remains passive and must +read only that caller-owned cached state; it never invokes the capacity +adapter. The measurement is advisory rather than a space reservation and does +not guarantee a later write. + +Capacity inspection is host-independent and performs no database open, pool +operation, SQLite query, filesystem mutation, ambient time read, timer, task, +or hidden sampling. Service configuration, threshold defaults, cache refresh, +status persistence, admission wiring, and route projection remain consumer +responsibilities. + The crate owns mechanics only. Service-specific tables, SQL, repositories, backup content policy, identity material, process lifecycle, and readiness policy remain with the consuming service. The crate does not provide callers diff --git a/crates/service_sqlite/src/lib.rs b/crates/service_sqlite/src/lib.rs @@ -49,4 +49,8 @@ pub use migration::{ }; pub use open::{OpenMode, ServiceSqlitePathError, ServiceSqlitePaths}; pub use restore::{StagedServiceRestore, finalize_staged_restore, stage_verified_restore}; -pub use status::{StorageHealth, StorageIntegrity, StorageStatus}; +pub use status::{ + MinimumFreeBytes, PlatformStateFilesystemCapacitySource, StateFilesystemCapacity, + StateFilesystemCapacityError, StateFilesystemCapacityReadiness, StateFilesystemCapacitySource, + StorageHealth, StorageIntegrity, StorageStatus, inspect_state_filesystem_capacity, +}; diff --git a/crates/service_sqlite/src/status.rs b/crates/service_sqlite/src/status.rs @@ -1,136 +0,0 @@ -//! Passive storage status values for the service-owned status envelope. - -use core::num::NonZeroU32; - -use serde::Serialize; - -/// Service-neutral storage health classification. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum StorageHealth { - Ready, - ReadOnly, - RepairRequired, - Unavailable, -} - -/// Service-neutral storage integrity classification. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum StorageIntegrity { - Verified, - VerificationRequired, - Failed, -} - -/// Passive storage facts supplied to a versioned service-status envelope. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -pub struct StorageStatus { - health: StorageHealth, - schema_version: NonZeroU32, - generation: u64, - integrity: StorageIntegrity, -} - -impl StorageStatus { - /// Constructs a passive status from already-validated storage facts. - #[must_use] - pub const fn new( - health: StorageHealth, - schema_version: NonZeroU32, - generation: u64, - integrity: StorageIntegrity, - ) -> Self { - Self { - health, - schema_version, - generation, - integrity, - } - } - - #[must_use] - pub const fn health(self) -> StorageHealth { - self.health - } - - #[must_use] - pub const fn schema_version(self) -> NonZeroU32 { - self.schema_version - } - - #[must_use] - pub const fn generation(self) -> u64 { - self.generation - } - - #[must_use] - pub const fn integrity(self) -> StorageIntegrity { - self.integrity - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn status_projection_and_enum_spellings_are_exact() { - let health = [ - (StorageHealth::Ready, "ready"), - (StorageHealth::ReadOnly, "read_only"), - (StorageHealth::RepairRequired, "repair_required"), - (StorageHealth::Unavailable, "unavailable"), - ]; - for (value, wire) in health { - assert_eq!( - serde_json::to_string(&value).unwrap(), - format!(r#""{wire}""#) - ); - } - - let integrity = [ - (StorageIntegrity::Verified, "verified"), - ( - StorageIntegrity::VerificationRequired, - "verification_required", - ), - (StorageIntegrity::Failed, "failed"), - ]; - for (value, wire) in integrity { - assert_eq!( - serde_json::to_string(&value).unwrap(), - format!(r#""{wire}""#) - ); - } - - let status = StorageStatus::new( - StorageHealth::RepairRequired, - NonZeroU32::new(1).unwrap(), - 7, - StorageIntegrity::VerificationRequired, - ); - assert_eq!(status.health(), StorageHealth::RepairRequired); - assert_eq!(status.schema_version().get(), 1); - assert_eq!(status.generation(), 7); - assert_eq!(status.integrity(), StorageIntegrity::VerificationRequired); - assert_eq!( - serde_json::to_string(&status).unwrap(), - r#"{"health":"repair_required","schema_version":1,"generation":7,"integrity":"verification_required"}"# - ); - } - - #[test] - fn zero_schema_version_cannot_cross_the_construction_boundary() { - assert!(NonZeroU32::new(0).is_none()); - let maximum = NonZeroU32::new(u32::MAX).unwrap(); - let status = StorageStatus::new( - StorageHealth::Ready, - maximum, - u64::MAX, - StorageIntegrity::Verified, - ); - assert_eq!(status.schema_version(), maximum); - assert_eq!(status.generation(), u64::MAX); - } -} diff --git a/crates/service_sqlite/src/status/disk.rs b/crates/service_sqlite/src/status/disk.rs @@ -0,0 +1,503 @@ +//! Explicit state-filesystem capacity inspection and admission classification. + +use core::fmt; +use std::error::Error; + +use serde::{Deserialize, Deserializer, Serialize, de::Error as _}; + +use crate::ServiceSqlitePaths; + +const MAXIMUM_MINIMUM_FREE_BYTES: u64 = i64::MAX as u64; + +/// Explicit minimum free-space policy for authoritative persistence admission. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(transparent)] +pub struct MinimumFreeBytes(u64); + +impl MinimumFreeBytes { + /// Validates a positive threshold representable by the governed TOML integer. + pub const fn new(value: u64) -> Result<Self, StateFilesystemCapacityError> { + if value == 0 { + return Err(StateFilesystemCapacityError::InvalidMinimum); + } + if value > MAXIMUM_MINIMUM_FREE_BYTES { + return Err(StateFilesystemCapacityError::MinimumTooLarge); + } + Ok(Self(value)) + } + + /// Returns the exact configured byte threshold. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +impl<'de> Deserialize<'de> for MinimumFreeBytes { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let value = u64::deserialize(deserializer)?; + Self::new(value).map_err(D::Error::custom) + } +} + +/// Closed readiness result derived from one advisory filesystem snapshot. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StateFilesystemCapacityReadiness { + Ready, + LowDisk, +} + +/// Immutable capacity snapshot safe to cache for later readiness projection. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct StateFilesystemCapacity { + available_bytes: u64, + minimum_free_bytes: MinimumFreeBytes, + readiness: StateFilesystemCapacityReadiness, +} + +impl StateFilesystemCapacity { + fn new(available_bytes: u64, minimum_free_bytes: MinimumFreeBytes) -> Self { + let readiness = if available_bytes >= minimum_free_bytes.get() { + StateFilesystemCapacityReadiness::Ready + } else { + StateFilesystemCapacityReadiness::LowDisk + }; + Self { + available_bytes, + minimum_free_bytes, + readiness, + } + } + + /// Returns bytes available to the unprivileged service user. + #[must_use] + pub const fn available_bytes(self) -> u64 { + self.available_bytes + } + + /// Returns the exact policy used to classify this snapshot. + #[must_use] + pub const fn minimum_free_bytes(self) -> MinimumFreeBytes { + self.minimum_free_bytes + } + + /// Returns the closed ready or low-disk classification. + #[must_use] + pub const fn readiness(self) -> StateFilesystemCapacityReadiness { + self.readiness + } + + /// Returns whether this snapshot permits new authoritative admission. + #[must_use] + pub const fn allows_authoritative_admission(self) -> bool { + matches!(self.readiness, StateFilesystemCapacityReadiness::Ready) + } +} + +/// Injected source for one synchronous state-filesystem capacity snapshot. +pub trait StateFilesystemCapacitySource { + /// Returns bytes available to the unprivileged service user. + fn available_bytes( + &self, + paths: &ServiceSqlitePaths, + ) -> Result<u64, StateFilesystemCapacityError>; +} + +/// Production Linux/macOS source backed by retained-directory `fstatvfs`. +#[derive(Clone, Copy, Debug, Default)] +pub struct PlatformStateFilesystemCapacitySource; + +impl StateFilesystemCapacitySource for PlatformStateFilesystemCapacitySource { + fn available_bytes( + &self, + paths: &ServiceSqlitePaths, + ) -> Result<u64, StateFilesystemCapacityError> { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + available_bytes_native(paths) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = paths; + Err(StateFilesystemCapacityError::UnsupportedPlatform) + } + } +} + +/// Runs one explicit capacity measurement and applies the supplied policy. +pub fn inspect_state_filesystem_capacity<S: StateFilesystemCapacitySource + ?Sized>( + paths: &ServiceSqlitePaths, + minimum_free_bytes: MinimumFreeBytes, + source: &S, +) -> Result<StateFilesystemCapacity, StateFilesystemCapacityError> { + source + .available_bytes(paths) + .map(|available| StateFilesystemCapacity::new(available, minimum_free_bytes)) +} + +/// Stable source-free failures for policy and filesystem capacity inspection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StateFilesystemCapacityError { + InvalidMinimum, + MinimumTooLarge, + MeasurementUnavailable, + MeasurementOverflow, + UnsupportedPlatform, +} + +impl fmt::Display for StateFilesystemCapacityError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidMinimum => "minimum free bytes must be positive", + Self::MinimumTooLarge => "minimum free bytes exceed the supported integer range", + Self::MeasurementUnavailable => "state filesystem capacity is unavailable", + Self::MeasurementOverflow => "state filesystem capacity is not representable", + Self::UnsupportedPlatform => { + "state filesystem capacity inspection is unsupported on this platform" + } + }) + } +} + +impl Error for StateFilesystemCapacityError {} + +#[cfg(any(test, target_os = "linux", target_os = "macos"))] +fn checked_available_bytes( + available_blocks: u64, + fragment_size: u64, +) -> Result<u64, StateFilesystemCapacityError> { + if fragment_size == 0 { + return Err(StateFilesystemCapacityError::MeasurementUnavailable); + } + available_blocks + .checked_mul(fragment_size) + .ok_or(StateFilesystemCapacityError::MeasurementOverflow) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn available_bytes_native(paths: &ServiceSqlitePaths) -> Result<u64, StateFilesystemCapacityError> { + use rustix::fs::{Mode, OFlags, fstat, fstatvfs, open}; + + let state_directory = paths + .state_database() + .parent() + .ok_or(StateFilesystemCapacityError::MeasurementUnavailable)?; + let held = open( + state_directory, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?; + let held_status = + fstat(&held).map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?; + validate_directory_status(&held_status)?; + let capacity = + fstatvfs(&held).map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?; + let available = checked_available_bytes(capacity.f_bavail, capacity.f_frsize)?; + + let current = open( + state_directory, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?; + let current_status = + fstat(&current).map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?; + validate_directory_status(&current_status)?; + let final_held_status = + fstat(&held).map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?; + validate_directory_status(&final_held_status)?; + if current_status.st_dev != held_status.st_dev + || current_status.st_ino != held_status.st_ino + || final_held_status.st_dev != held_status.st_dev + || final_held_status.st_ino != held_status.st_ino + { + return Err(StateFilesystemCapacityError::MeasurementUnavailable); + } + Ok(available) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn validate_directory_status( + status: &rustix::fs::Stat, +) -> Result<(), StateFilesystemCapacityError> { + use rustix::fs::FileType; + use rustix::process::geteuid; + + if !FileType::from_raw_mode(status.st_mode).is_dir() + || status.st_uid != geteuid().as_raw() + || u32::from(status.st_mode) & 0o022 != 0 + { + return Err(StateFilesystemCapacityError::MeasurementUnavailable); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FakeSource(Result<u64, StateFilesystemCapacityError>); + + impl StateFilesystemCapacitySource for FakeSource { + fn available_bytes( + &self, + _paths: &ServiceSqlitePaths, + ) -> Result<u64, StateFilesystemCapacityError> { + self.0 + } + } + + fn unused_paths() -> ServiceSqlitePaths { + use radroots_runtime_paths::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, + ServiceId, + }; + + let root = std::path::PathBuf::from("/unused/capacity-test-root"); + let context = RuntimeContext::resolve( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some(root), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::BootstrapCli, + ) + .expect("bootstrap"), + ServiceId::new("myc").expect("service"), + InstanceId::new("capacity").expect("instance"), + ) + .expect("context"); + ServiceSqlitePaths::from_runtime_context(&context).expect("paths") + } + + #[test] + fn minimum_policy_and_strict_numeric_serde_are_bounded() { + assert_eq!( + MinimumFreeBytes::new(0), + Err(StateFilesystemCapacityError::InvalidMinimum) + ); + for value in [1, 268_435_456, i64::MAX as u64] { + let policy = MinimumFreeBytes::new(value).expect("valid policy"); + assert_eq!(policy.get(), value); + let wire = value.to_string(); + assert_eq!(serde_json::to_string(&policy).unwrap(), wire); + assert_eq!( + serde_json::from_str::<MinimumFreeBytes>(&wire).unwrap(), + policy + ); + } + for value in [i64::MAX as u64 + 1, u64::MAX] { + assert_eq!( + MinimumFreeBytes::new(value), + Err(StateFilesystemCapacityError::MinimumTooLarge) + ); + assert!(serde_json::from_str::<MinimumFreeBytes>(&value.to_string()).is_err()); + } + for wire in ["0", "-1", "1.0", "\"1\"", "null", "true", "{}", "[]"] { + assert!(serde_json::from_str::<MinimumFreeBytes>(wire).is_err()); + } + } + + #[test] + fn injected_values_classify_exact_boundary_and_propagate_failure() { + let paths = unused_paths(); + let minimum = MinimumFreeBytes::new(268_435_456).unwrap(); + for (available, readiness, allowed) in [ + (0, StateFilesystemCapacityReadiness::LowDisk, false), + ( + minimum.get() - 1, + StateFilesystemCapacityReadiness::LowDisk, + false, + ), + (minimum.get(), StateFilesystemCapacityReadiness::Ready, true), + ( + minimum.get() + 1, + StateFilesystemCapacityReadiness::Ready, + true, + ), + (u64::MAX, StateFilesystemCapacityReadiness::Ready, true), + ] { + let report = + inspect_state_filesystem_capacity(&paths, minimum, &FakeSource(Ok(available))) + .expect("injected measurement"); + assert_eq!(report.available_bytes(), available); + assert_eq!(report.minimum_free_bytes(), minimum); + assert_eq!(report.readiness(), readiness); + assert_eq!(report.allows_authoritative_admission(), allowed); + } + assert_eq!( + inspect_state_filesystem_capacity( + &paths, + minimum, + &FakeSource(Err(StateFilesystemCapacityError::MeasurementUnavailable,)), + ), + Err(StateFilesystemCapacityError::MeasurementUnavailable) + ); + } + + #[test] + fn arithmetic_and_wire_projection_are_exact() { + assert_eq!(checked_available_bytes(7, 4), Ok(28)); + assert_eq!(checked_available_bytes(0, 4), Ok(0)); + assert_eq!( + checked_available_bytes(1, 0), + Err(StateFilesystemCapacityError::MeasurementUnavailable) + ); + assert_eq!( + checked_available_bytes(u64::MAX, 2), + Err(StateFilesystemCapacityError::MeasurementOverflow) + ); + let report = inspect_state_filesystem_capacity( + &unused_paths(), + MinimumFreeBytes::new(10).unwrap(), + &FakeSource(Ok(10)), + ) + .unwrap(); + assert_eq!( + serde_json::to_string(&report).unwrap(), + r#"{"available_bytes":10,"minimum_free_bytes":10,"readiness":"ready"}"# + ); + } + + #[test] + fn errors_are_stable_source_free_and_redacted() { + use std::error::Error as _; + + let sensitive = "/private/secret-state/state.sqlite"; + for error in [ + StateFilesystemCapacityError::InvalidMinimum, + StateFilesystemCapacityError::MinimumTooLarge, + StateFilesystemCapacityError::MeasurementUnavailable, + StateFilesystemCapacityError::MeasurementOverflow, + StateFilesystemCapacityError::UnsupportedPlatform, + ] { + assert!(error.source().is_none()); + assert!(!error.to_string().contains(sensitive)); + assert!(!format!("{error:?}").contains(sensitive)); + } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn native_adapter_is_descriptor_bound_nonmutating_and_rejects_unsafe_shapes() { + use std::{ + fs, + os::unix::fs::{MetadataExt, PermissionsExt, symlink}, + }; + + fn paths(root: &std::path::Path, instance: &str) -> ServiceSqlitePaths { + use radroots_runtime_paths::{ + InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, + RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, + ServiceId, + }; + + let context = RuntimeContext::resolve( + &RadrootsPathResolver::new( + RadrootsPlatform::Linux, + RadrootsHostEnvironment::default(), + ), + RuntimeContextBootstrap::new( + RadrootsPathProfile::RepoLocal, + Some(root.to_path_buf()), + RuntimeContextSource::BootstrapCli, + RuntimeContextSource::BootstrapCli, + ) + .expect("bootstrap"), + ServiceId::new("myc").expect("service"), + InstanceId::new(instance).expect("instance"), + ) + .expect("context"); + ServiceSqlitePaths::from_runtime_context(&context).expect("paths") + } + + let root = tempfile::tempdir().expect("root"); + let valid = paths(root.path(), "valid"); + let valid_directory = valid.state_database().parent().unwrap(); + fs::create_dir_all(valid_directory).expect("state directory"); + fs::set_permissions(valid_directory, fs::Permissions::from_mode(0o700)).unwrap(); + let before = fs::metadata(valid_directory).unwrap(); + let report = inspect_state_filesystem_capacity( + &valid, + MinimumFreeBytes::new(1).unwrap(), + &PlatformStateFilesystemCapacitySource, + ) + .expect("native measurement"); + assert!(report.available_bytes() > 0); + assert!(report.allows_authoritative_admission()); + let after = fs::metadata(valid_directory).unwrap(); + assert_eq!(before.dev(), after.dev()); + assert_eq!(before.ino(), after.ino()); + assert_eq!(before.permissions().mode(), after.permissions().mode()); + assert!(fs::read_dir(valid_directory).unwrap().next().is_none()); + for mode in [0o750, 0o755] { + fs::set_permissions(valid_directory, fs::Permissions::from_mode(mode)).unwrap(); + let report = inspect_state_filesystem_capacity( + &valid, + MinimumFreeBytes::new(1).unwrap(), + &PlatformStateFilesystemCapacitySource, + ) + .expect("non-writable group/other mode remains admissible"); + assert!(report.allows_authoritative_admission()); + } + + let missing = paths(root.path(), "missing"); + assert_eq!( + inspect_state_filesystem_capacity( + &missing, + MinimumFreeBytes::new(1).unwrap(), + &PlatformStateFilesystemCapacitySource, + ), + Err(StateFilesystemCapacityError::MeasurementUnavailable) + ); + + let file = paths(root.path(), "file"); + let file_directory = file.state_database().parent().unwrap(); + fs::create_dir_all(file_directory.parent().unwrap()).unwrap(); + fs::write(file_directory, b"not a directory").unwrap(); + assert_eq!( + inspect_state_filesystem_capacity( + &file, + MinimumFreeBytes::new(1).unwrap(), + &PlatformStateFilesystemCapacitySource, + ), + Err(StateFilesystemCapacityError::MeasurementUnavailable) + ); + + let linked = paths(root.path(), "linked"); + let linked_directory = linked.state_database().parent().unwrap(); + fs::create_dir_all(linked_directory.parent().unwrap()).unwrap(); + let target = root.path().join("linked-target"); + fs::create_dir(&target).unwrap(); + symlink(&target, linked_directory).unwrap(); + assert_eq!( + inspect_state_filesystem_capacity( + &linked, + MinimumFreeBytes::new(1).unwrap(), + &PlatformStateFilesystemCapacitySource, + ), + Err(StateFilesystemCapacityError::MeasurementUnavailable) + ); + + let insecure = paths(root.path(), "insecure"); + let insecure_directory = insecure.state_database().parent().unwrap(); + fs::create_dir_all(insecure_directory).unwrap(); + for mode in [0o720, 0o702, 0o722] { + fs::set_permissions(insecure_directory, fs::Permissions::from_mode(mode)).unwrap(); + assert_eq!( + inspect_state_filesystem_capacity( + &insecure, + MinimumFreeBytes::new(1).unwrap(), + &PlatformStateFilesystemCapacitySource, + ), + Err(StateFilesystemCapacityError::MeasurementUnavailable) + ); + } + } +} diff --git a/crates/service_sqlite/src/status/mod.rs b/crates/service_sqlite/src/status/mod.rs @@ -0,0 +1,144 @@ +//! Passive storage status values for the service-owned status envelope. + +mod disk; + +use core::num::NonZeroU32; + +use serde::Serialize; + +pub use disk::{ + MinimumFreeBytes, PlatformStateFilesystemCapacitySource, StateFilesystemCapacity, + StateFilesystemCapacityError, StateFilesystemCapacityReadiness, StateFilesystemCapacitySource, + inspect_state_filesystem_capacity, +}; + +/// Service-neutral storage health classification. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StorageHealth { + Ready, + ReadOnly, + RepairRequired, + Unavailable, +} + +/// Service-neutral storage integrity classification. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StorageIntegrity { + Verified, + VerificationRequired, + Failed, +} + +/// Passive storage facts supplied to a versioned service-status envelope. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct StorageStatus { + health: StorageHealth, + schema_version: NonZeroU32, + generation: u64, + integrity: StorageIntegrity, +} + +impl StorageStatus { + /// Constructs a passive status from already-validated storage facts. + #[must_use] + pub const fn new( + health: StorageHealth, + schema_version: NonZeroU32, + generation: u64, + integrity: StorageIntegrity, + ) -> Self { + Self { + health, + schema_version, + generation, + integrity, + } + } + + #[must_use] + pub const fn health(self) -> StorageHealth { + self.health + } + + #[must_use] + pub const fn schema_version(self) -> NonZeroU32 { + self.schema_version + } + + #[must_use] + pub const fn generation(self) -> u64 { + self.generation + } + + #[must_use] + pub const fn integrity(self) -> StorageIntegrity { + self.integrity + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_projection_and_enum_spellings_are_exact() { + let health = [ + (StorageHealth::Ready, "ready"), + (StorageHealth::ReadOnly, "read_only"), + (StorageHealth::RepairRequired, "repair_required"), + (StorageHealth::Unavailable, "unavailable"), + ]; + for (value, wire) in health { + assert_eq!( + serde_json::to_string(&value).unwrap(), + format!(r#""{wire}""#) + ); + } + + let integrity = [ + (StorageIntegrity::Verified, "verified"), + ( + StorageIntegrity::VerificationRequired, + "verification_required", + ), + (StorageIntegrity::Failed, "failed"), + ]; + for (value, wire) in integrity { + assert_eq!( + serde_json::to_string(&value).unwrap(), + format!(r#""{wire}""#) + ); + } + + let status = StorageStatus::new( + StorageHealth::RepairRequired, + NonZeroU32::new(1).unwrap(), + 7, + StorageIntegrity::VerificationRequired, + ); + assert_eq!(status.health(), StorageHealth::RepairRequired); + assert_eq!(status.schema_version().get(), 1); + assert_eq!(status.generation(), 7); + assert_eq!(status.integrity(), StorageIntegrity::VerificationRequired); + assert_eq!( + serde_json::to_string(&status).unwrap(), + r#"{"health":"repair_required","schema_version":1,"generation":7,"integrity":"verification_required"}"# + ); + } + + #[test] + fn zero_schema_version_cannot_cross_the_construction_boundary() { + assert!(NonZeroU32::new(0).is_none()); + let maximum = NonZeroU32::new(u32::MAX).unwrap(); + let status = StorageStatus::new( + StorageHealth::Ready, + maximum, + u64::MAX, + StorageIntegrity::Verified, + ); + assert_eq!(status.schema_version(), maximum); + assert_eq!(status.generation(), u64::MAX); + } +} diff --git a/crates/service_sqlite/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs @@ -22,7 +22,8 @@ const RESTORE_FINALIZE_SOURCE: &str = include_str!("../src/restore/finalize.rs") const RESTORE_RECOVER_SOURCE: &str = include_str!("../src/restore/recover.rs"); const RESTORE_ROOT_SOURCE: &str = include_str!("../src/restore/mod.rs"); const RESTORE_STAGE_SOURCE: &str = include_str!("../src/restore/stage.rs"); -const STATUS_SOURCE: &str = include_str!("../src/status.rs"); +const STATUS_SOURCE: &str = include_str!("../src/status/mod.rs"); +const DISK_SOURCE: &str = include_str!("../src/status/disk.rs"); const TRANSACTION_CONTROL_SOURCE: &str = include_str!("../src/transaction_control.rs"); #[test] @@ -220,6 +221,19 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "before any new check or authority release", "retry uses a newly injected wall-clock time", "strict backup and restore integrity verifier remains a separate fail-closed boundary", + "State-filesystem capacity inspection is an explicit synchronous input", + "`MinimumFreeBytes` must be supplied and is constrained to `1..=i64::MAX`; it has no default", + "`268435456` is the exact governed configuration and test vector, not an implicit universal threshold", + "owner-owned state directory that is not group/other writable", + "uses `fstatvfs` to measure bytes available to the unprivileged service user", + "successful immutable snapshot is `ready` when available bytes are greater than or equal", + "`low_disk` when they are below it", + "measurement failure is a typed unavailable error and is never fabricated as low-disk evidence", + "project low disk to the stable `database_low_disk` readiness reason", + "`/readyz` remains passive", + "measurement is advisory rather than a space reservation", + "performs no database open, pool operation, SQLite query, filesystem mutation, ambient time read, timer, task", + "Service configuration, threshold defaults, cache refresh, status persistence, admission wiring, and route projection remain consumer responsibilities", ] { assert!( readme_words.contains(required), @@ -257,6 +271,10 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { .split_once("#[cfg(all(test, any(target_os = \"linux\", target_os = \"macos\")))]") .map(|(production, _)| production) .expect("integrity inspection source must keep test seams separated"); + let disk_production = DISK_SOURCE + .split_once("#[cfg(test)]\nmod tests") + .map(|(production, _)| production) + .expect("disk inspection source must keep tests separated"); let restore_marker_production = RESTORE_MARKER_SOURCE .split_once("#[cfg(test)]\nmod tests") .map(|(production, _)| production) @@ -323,6 +341,13 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { "StorageHealth", "StorageIntegrity", "StorageStatus", + "MinimumFreeBytes", + "PlatformStateFilesystemCapacitySource", + "StateFilesystemCapacity", + "StateFilesystemCapacityError", + "StateFilesystemCapacityReadiness", + "StateFilesystemCapacitySource", + "inspect_state_filesystem_capacity", ] { assert!( ROOT.contains(required), @@ -331,6 +356,54 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { } for required in [ + "MAXIMUM_MINIMUM_FREE_BYTES: u64 = i64::MAX as u64", + "pub const fn new(value: u64)", + "StateFilesystemCapacityReadiness::Ready", + "StateFilesystemCapacityReadiness::LowDisk", + "available_bytes >= minimum_free_bytes.get()", + "pub trait StateFilesystemCapacitySource", + "pub struct PlatformStateFilesystemCapacitySource", + "pub fn inspect_state_filesystem_capacity", + "OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC", + "fstatvfs(&held)", + "capacity.f_bavail", + "capacity.f_frsize", + "u32::from(status.st_mode) & 0o022", + "UnsupportedPlatform", + ] { + assert!( + disk_production.contains(required), + "Step 071 disk inspection source is missing `{required}`" + ); + } + for forbidden in [ + "ServiceSqliteHost", + "readyz", + "database_low_disk", + "sqlx", + "rusqlite", + "tokio", + "SystemTime", + "Instant", + "spawn", + "sleep", + "create_dir", + "write(", + "Default for MinimumFreeBytes", + ] { + assert!( + !disk_production.contains(forbidden), + "Step 071 disk inspection source contains deferred authority `{forbidden}`" + ); + } + for forbidden in ["rustix::", "RawFd", "OwnedFd", "BorrowedFd"] { + assert!( + !ROOT.contains(forbidden), + "Step 071 crate root exposes dependency or raw descriptor `{forbidden}`" + ); + } + + for required in [ "radroots.service-backup", "BACKUP_MANIFEST_SCHEMA_VERSION: u32 = 1", "BACKUP_MANIFEST_CANONICAL_MAX_BYTES: usize = 1_024",