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:
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(¤t).map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?;
+ validate_directory_status(¤t_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",