lib

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

commit 39a6ba93872ae22c1fae115e8a48a6ec45026599
parent 8bbfba3f9b89e7373f8d0e76a99c496bdab3ee4e
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 10:17:21 +0000

service-sqlite: add error and status contracts

- define stable SQLite error kinds, codes, and safe projections
- add sealed storage health, integrity, and status contracts
- preserve trusted causes behind redacted display and debug surfaces
- verify exact exports, dependency boundaries, and wire inventories

Diffstat:
MCargo.lock | 4++++
Mcrates/service_sqlite/Cargo.toml | 4++++
Acrates/service_sqlite/src/error.rs | 320+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_sqlite/src/lib.rs | 11++++++++---
Acrates/service_sqlite/src/status.rs | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_sqlite/tests/package_boundary.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
6 files changed, 534 insertions(+), 9 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3908,6 +3908,10 @@ dependencies = [ [[package]] name = "radroots_service_sqlite" version = "0.1.0-alpha" +dependencies = [ + "serde", + "serde_json", +] [[package]] name = "radroots_signing" diff --git a/crates/service_sqlite/Cargo.toml b/crates/service_sqlite/Cargo.toml @@ -12,6 +12,10 @@ homepage.workspace = true readme = "README.md" [dependencies] +serde = { workspace = true, features = ["derive", "std"] } + +[dev-dependencies] +serde_json = { workspace = true } [lints] workspace = true diff --git a/crates/service_sqlite/src/error.rs b/crates/service_sqlite/src/error.rs @@ -0,0 +1,320 @@ +//! Stable safe errors for shared SQLite mechanics. + +use core::fmt; +use std::error::Error; + +use serde::{Serialize, Serializer}; + +const MAX_SAFE_ERROR_MESSAGE_BYTES: usize = 96; + +/// Stable public codes for service-neutral SQLite failures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ServiceSqliteErrorCode { + Authority, + Open, + Create, + Pragma, + Metadata, + Migration, + Backup, + Restore, + Integrity, + Recovery, +} + +impl ServiceSqliteErrorCode { + /// Returns the stable lowercase wire representation. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Authority => "sqlite_authority", + Self::Open => "sqlite_open", + Self::Create => "sqlite_create", + Self::Pragma => "sqlite_pragma", + Self::Metadata => "sqlite_metadata", + Self::Migration => "sqlite_migration", + Self::Backup => "sqlite_backup", + Self::Restore => "sqlite_restore", + Self::Integrity => "sqlite_integrity", + Self::Recovery => "sqlite_recovery", + } + } +} + +impl fmt::Display for ServiceSqliteErrorCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl Serialize for ServiceSqliteErrorCode { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +/// Service-neutral failure classes used by the SQLite mechanism boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ServiceSqliteErrorKind { + Authority, + Open, + Create, + Pragma, + Metadata, + Migration, + Backup, + Restore, + Integrity, + Recovery, +} + +impl ServiceSqliteErrorKind { + /// Returns the stable public code for this failure class. + #[must_use] + pub const fn code(self) -> ServiceSqliteErrorCode { + match self { + Self::Authority => ServiceSqliteErrorCode::Authority, + Self::Open => ServiceSqliteErrorCode::Open, + Self::Create => ServiceSqliteErrorCode::Create, + Self::Pragma => ServiceSqliteErrorCode::Pragma, + Self::Metadata => ServiceSqliteErrorCode::Metadata, + Self::Migration => ServiceSqliteErrorCode::Migration, + Self::Backup => ServiceSqliteErrorCode::Backup, + Self::Restore => ServiceSqliteErrorCode::Restore, + Self::Integrity => ServiceSqliteErrorCode::Integrity, + Self::Recovery => ServiceSqliteErrorCode::Recovery, + } + } + + /// Returns the bounded projection safe for logs, status, and wire envelopes. + #[must_use] + pub const fn safe_error(self) -> SafeServiceSqliteError { + let message = match self { + Self::Authority => "SQLite writer authority could not be established", + Self::Open => "SQLite state could not be opened", + Self::Create => "SQLite state could not be created", + Self::Pragma => "SQLite pragma policy could not be applied", + Self::Metadata => "SQLite metadata is invalid", + Self::Migration => "SQLite migration history is invalid", + Self::Backup => "SQLite backup failed", + Self::Restore => "SQLite restore failed", + Self::Integrity => "SQLite integrity verification failed", + Self::Recovery => "SQLite recovery failed", + }; + SafeServiceSqliteError::new(self.code(), message) + } +} + +/// Bounded service-neutral SQLite error safe for public observation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct SafeServiceSqliteError { + code: ServiceSqliteErrorCode, + message: &'static str, +} + +impl SafeServiceSqliteError { + const fn new(code: ServiceSqliteErrorCode, message: &'static str) -> Self { + assert!(message.is_ascii()); + assert!(message.len() <= MAX_SAFE_ERROR_MESSAGE_BYTES); + Self { code, message } + } + + /// Returns the typed stable error code. + #[must_use] + pub const fn code(self) -> ServiceSqliteErrorCode { + self.code + } + + /// Returns the stable serialized code. + #[must_use] + pub const fn code_str(self) -> &'static str { + self.code.as_str() + } + + /// Returns the bounded safe message. + #[must_use] + pub const fn message(self) -> &'static str { + self.message + } +} + +impl fmt::Display for SafeServiceSqliteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +/// SQLite mechanism failure with an optional cause for trusted inspection. +pub struct ServiceSqliteError { + kind: ServiceSqliteErrorKind, + source: Option<Box<dyn Error + Send + Sync + 'static>>, +} + +impl ServiceSqliteError { + /// Creates a failure without an upstream cause. + #[must_use] + pub const fn new(kind: ServiceSqliteErrorKind) -> Self { + Self { kind, source: None } + } + + /// Creates a failure while retaining its cause for trusted inspection. + pub fn with_source( + kind: ServiceSqliteErrorKind, + source: impl Error + Send + Sync + 'static, + ) -> Self { + Self { + kind, + source: Some(Box::new(source)), + } + } + + /// Returns the stable service-neutral failure class. + #[must_use] + pub const fn kind(&self) -> ServiceSqliteErrorKind { + self.kind + } + + /// Returns the bounded projection safe for public observation. + #[must_use] + pub const fn safe_error(&self) -> SafeServiceSqliteError { + self.kind.safe_error() + } +} + +impl fmt::Debug for ServiceSqliteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ServiceSqliteError") + .field("kind", &self.kind) + .field("safe_error", &self.safe_error()) + .field("source", &self.source.as_ref().map(|_| "[redacted]")) + .finish() + } +} + +impl fmt::Display for ServiceSqliteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.safe_error().fmt(formatter) + } +} + +impl Error for ServiceSqliteError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source + .as_deref() + .map(|source| source as &(dyn Error + 'static)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug)] + struct SensitiveCause; + + impl fmt::Display for SensitiveCause { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("secret path=/private/state.sqlite") + } + } + + impl Error for SensitiveCause {} + + #[test] + fn error_inventory_codes_messages_and_serialization_are_exact() { + let inventory = [ + ( + ServiceSqliteErrorKind::Authority, + "sqlite_authority", + "SQLite writer authority could not be established", + ), + ( + ServiceSqliteErrorKind::Open, + "sqlite_open", + "SQLite state could not be opened", + ), + ( + ServiceSqliteErrorKind::Create, + "sqlite_create", + "SQLite state could not be created", + ), + ( + ServiceSqliteErrorKind::Pragma, + "sqlite_pragma", + "SQLite pragma policy could not be applied", + ), + ( + ServiceSqliteErrorKind::Metadata, + "sqlite_metadata", + "SQLite metadata is invalid", + ), + ( + ServiceSqliteErrorKind::Migration, + "sqlite_migration", + "SQLite migration history is invalid", + ), + ( + ServiceSqliteErrorKind::Backup, + "sqlite_backup", + "SQLite backup failed", + ), + ( + ServiceSqliteErrorKind::Restore, + "sqlite_restore", + "SQLite restore failed", + ), + ( + ServiceSqliteErrorKind::Integrity, + "sqlite_integrity", + "SQLite integrity verification failed", + ), + ( + ServiceSqliteErrorKind::Recovery, + "sqlite_recovery", + "SQLite recovery failed", + ), + ]; + + for (kind, code, message) in inventory { + let safe = kind.safe_error(); + assert_eq!(kind.code().as_str(), code); + assert_eq!(kind.code().to_string(), code); + assert_eq!(safe.code(), kind.code()); + assert_eq!(safe.code_str(), code); + assert_eq!(safe.message(), message); + assert!(message.is_ascii()); + assert!(message.len() <= MAX_SAFE_ERROR_MESSAGE_BYTES); + assert_eq!( + serde_json::to_string(&kind.code()).unwrap(), + format!(r#""{code}""#) + ); + assert_eq!( + serde_json::to_string(&safe).unwrap(), + format!(r#"{{"code":"{code}","message":"{message}"}}"#) + ); + } + } + + #[test] + fn raw_error_redacts_but_preserves_its_trusted_source() { + let error = ServiceSqliteError::with_source(ServiceSqliteErrorKind::Open, SensitiveCause); + let display = error.to_string(); + let debug = format!("{error:?}"); + let serialized = serde_json::to_string(&error.safe_error()).unwrap(); + + for public in [&display, &debug, &serialized] { + assert!(!public.contains("secret")); + assert!(!public.contains("private")); + assert!(!public.contains("state.sqlite")); + } + assert_eq!( + error.source().map(ToString::to_string).as_deref(), + Some("secret path=/private/state.sqlite") + ); + assert_eq!(error.kind(), ServiceSqliteErrorKind::Open); + } +} diff --git a/crates/service_sqlite/src/lib.rs b/crates/service_sqlite/src/lib.rs @@ -1,6 +1,11 @@ #![forbid(unsafe_code)] //! Reusable, service-neutral SQLite mechanics for Radroots services. -//! -//! This crate is intentionally empty while its public modules are introduced -//! as independently reviewed contract checkpoints. + +mod error; +mod status; + +pub use error::{ + SafeServiceSqliteError, ServiceSqliteError, ServiceSqliteErrorCode, ServiceSqliteErrorKind, +}; +pub use status::{StorageHealth, StorageIntegrity, StorageStatus}; diff --git a/crates/service_sqlite/src/status.rs b/crates/service_sqlite/src/status.rs @@ -0,0 +1,136 @@ +//! 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/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs @@ -2,9 +2,11 @@ use std::collections::BTreeSet; const MANIFEST: &str = include_str!("../Cargo.toml"); const ROOT: &str = include_str!("../src/lib.rs"); +const ERROR_SOURCE: &str = include_str!("../src/error.rs"); +const STATUS_SOURCE: &str = include_str!("../src/status.rs"); #[test] -fn service_sqlite_is_unpublished_lint_governed_and_dependency_free() { +fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() { for required in [ "name = \"radroots_service_sqlite\"", "publish = false", @@ -17,17 +19,71 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_free() { ); } - assert_eq!(dependency_keys(MANIFEST), BTreeSet::new()); - assert!(!ROOT.contains("pub mod ")); + assert_eq!( + dependency_keys(MANIFEST, "[dependencies]"), + BTreeSet::from(["serde"]) + ); + assert_eq!( + dependency_keys(MANIFEST, "[dev-dependencies]"), + BTreeSet::from(["serde_json"]) + ); + assert_eq!(private_modules(ROOT), BTreeSet::from(["error", "status"])); + assert!(public_modules(ROOT).is_empty()); + + for required in [ + "ServiceSqliteErrorCode", + "ServiceSqliteErrorKind", + "SafeServiceSqliteError", + "ServiceSqliteError", + "StorageHealth", + "StorageIntegrity", + "StorageStatus", + ] { + assert!( + ROOT.contains(required), + "crate root is missing `{required}`" + ); + } + + for forbidden in [ + "radroots_service_host", + "radroots_runtime_paths", + "sqlx", + "rusqlite", + "tokio", + "std::fs", + "OpenOptions", + "create_dir", + "Connection", + "Pool", + ] { + assert!( + !ERROR_SOURCE.contains(forbidden) && !STATUS_SOURCE.contains(forbidden), + "Step 052 source contains deferred surface `{forbidden}`" + ); + } +} + +fn public_modules(root: &str) -> BTreeSet<&str> { + root.lines() + .filter_map(|line| line.strip_prefix("pub mod ")) + .filter_map(|module| module.strip_suffix(';')) + .collect() +} + +fn private_modules(root: &str) -> BTreeSet<&str> { + root.lines() + .filter_map(|line| line.strip_prefix("mod ")) + .filter_map(|module| module.strip_suffix(';')) + .collect() } -fn dependency_keys(manifest: &str) -> BTreeSet<&str> { +fn dependency_keys<'a>(manifest: &'a str, section: &str) -> BTreeSet<&'a str> { manifest - .split_once("[dependencies]") + .split_once(section) .map(|(_, dependencies)| dependencies) .unwrap_or_default() .lines() - .skip(1) .take_while(|line| !line.starts_with('[')) .filter_map(|line| line.split_once('=').map(|(key, _)| key.trim())) .filter(|key| !key.is_empty())