commit c070d4b1aaaa09db37da686c25e4120d260322d6
parent 39a6ba93872ae22c1fae115e8a48a6ec45026599
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 10:29:14 +0000
service-sqlite: add instance paths and open modes
- bind database and lock paths to immutable runtime contexts
- define explicit initialize, existing-write, and inspection modes
- redact filesystem paths and reject invalid path shapes
- defer filesystem, writer-lock, and SQLite behavior to later checkpoints
Diffstat:
5 files changed, 335 insertions(+), 5 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -3909,6 +3909,7 @@ dependencies = [
name = "radroots_service_sqlite"
version = "0.1.0-alpha"
dependencies = [
+ "radroots_runtime_paths",
"serde",
"serde_json",
]
diff --git a/crates/service_sqlite/Cargo.toml b/crates/service_sqlite/Cargo.toml
@@ -12,6 +12,7 @@ homepage.workspace = true
readme = "README.md"
[dependencies]
+radroots_runtime_paths = { workspace = true }
serde = { workspace = true, features = ["derive", "std"] }
[dev-dependencies]
diff --git a/crates/service_sqlite/src/lib.rs b/crates/service_sqlite/src/lib.rs
@@ -3,9 +3,11 @@
//! Reusable, service-neutral SQLite mechanics for Radroots services.
mod error;
+mod open;
mod status;
pub use error::{
SafeServiceSqliteError, ServiceSqliteError, ServiceSqliteErrorCode, ServiceSqliteErrorKind,
};
+pub use open::{OpenMode, ServiceSqlitePathError, ServiceSqlitePaths};
pub use status::{StorageHealth, StorageIntegrity, StorageStatus};
diff --git a/crates/service_sqlite/src/open.rs b/crates/service_sqlite/src/open.rs
@@ -0,0 +1,298 @@
+//! Instance-bound SQLite paths and declarative open modes.
+
+use core::fmt;
+use std::{
+ error::Error,
+ path::{Path, PathBuf},
+};
+
+use radroots_runtime_paths::{
+ InstanceId, RuntimeContext, ServiceId, default_service_instance_artifacts,
+};
+use serde::Serialize;
+
+/// Canonical database and writer-lock paths for one validated service instance.
+///
+/// Callers cannot forge paths or rebind the service and instance independently:
+///
+/// ```compile_fail
+/// use std::path::PathBuf;
+/// use radroots_runtime_paths::{InstanceId, ServiceId};
+/// use radroots_service_sqlite::ServiceSqlitePaths;
+///
+/// let _ = ServiceSqlitePaths {
+/// service: ServiceId::new("myc").unwrap(),
+/// instance: InstanceId::new("primary").unwrap(),
+/// state_database: PathBuf::from("/tmp/alternate.sqlite"),
+/// state_lock: PathBuf::from("/tmp/alternate.lock"),
+/// };
+/// ```
+#[derive(Clone, PartialEq, Eq)]
+pub struct ServiceSqlitePaths {
+ service: ServiceId,
+ instance: InstanceId,
+ state_database: PathBuf,
+ state_lock: PathBuf,
+}
+
+impl ServiceSqlitePaths {
+ /// Derives the fixed SQLite artifacts from one immutable runtime context.
+ pub fn from_runtime_context(context: &RuntimeContext) -> Result<Self, ServiceSqlitePathError> {
+ validate_state_directory(context.paths().state())?;
+ let artifacts = default_service_instance_artifacts(context.paths());
+ Ok(Self {
+ service: context.service().clone(),
+ instance: context.instance().clone(),
+ state_database: artifacts.state_database().to_path_buf(),
+ state_lock: artifacts.state_lock().to_path_buf(),
+ })
+ }
+
+ /// Returns the validated service identity bound to these paths.
+ #[must_use]
+ pub fn service(&self) -> &ServiceId {
+ &self.service
+ }
+
+ /// Returns the validated instance identity bound to these paths.
+ #[must_use]
+ pub fn instance(&self) -> &InstanceId {
+ &self.instance
+ }
+
+ /// Returns the canonical `state.sqlite` path.
+ #[must_use]
+ pub fn state_database(&self) -> &Path {
+ &self.state_database
+ }
+
+ /// Returns the canonical retained `state.lock` path.
+ #[must_use]
+ pub fn state_lock(&self) -> &Path {
+ &self.state_lock
+ }
+}
+
+impl fmt::Debug for ServiceSqlitePaths {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("ServiceSqlitePaths")
+ .field("service", &self.service)
+ .field("instance", &self.instance)
+ .field("state_database", &"[redacted]")
+ .field("state_lock", &"[redacted]")
+ .finish()
+ }
+}
+
+/// Path-shape failure detected before any filesystem or SQLite operation.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ServiceSqlitePathError {
+ RelativeStateDirectory,
+ MissingStateDirectoryParent,
+}
+
+impl fmt::Display for ServiceSqlitePathError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::RelativeStateDirectory => {
+ formatter.write_str("SQLite state directory must be absolute")
+ }
+ Self::MissingStateDirectoryParent => {
+ formatter.write_str("SQLite state directory must have a parent")
+ }
+ }
+ }
+}
+
+impl Error for ServiceSqlitePathError {}
+
+fn validate_state_directory(path: &Path) -> Result<(), ServiceSqlitePathError> {
+ if !path.is_absolute() {
+ return Err(ServiceSqlitePathError::RelativeStateDirectory);
+ }
+ if path.parent().is_none() {
+ return Err(ServiceSqlitePathError::MissingStateDirectoryParent);
+ }
+ Ok(())
+}
+
+/// Declarative behavior for opening one service-owned SQLite database.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum OpenMode {
+ Initialize,
+ ReadWriteExisting,
+ ReadOnlyInspection,
+}
+
+impl OpenMode {
+ /// Returns whether this mode permits creating missing state.
+ #[must_use]
+ pub const fn may_create(self) -> bool {
+ matches!(self, Self::Initialize)
+ }
+
+ /// Returns whether state must already exist before opening.
+ #[must_use]
+ pub const fn requires_existing(self) -> bool {
+ !matches!(self, Self::Initialize)
+ }
+
+ /// Returns whether exclusive writer authority is required.
+ #[must_use]
+ pub const fn requires_writer_authority(self) -> bool {
+ !matches!(self, Self::ReadOnlyInspection)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::path::PathBuf;
+
+ use radroots_runtime_paths::{
+ RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform,
+ RuntimeContextBootstrap, RuntimeContextSource,
+ };
+
+ use super::*;
+
+ fn runtime_context(
+ profile: RadrootsPathProfile,
+ repo_local_root: Option<PathBuf>,
+ service: &str,
+ instance: &str,
+ ) -> RuntimeContext {
+ let profile_source = if matches!(profile, RadrootsPathProfile::RepoLocal) {
+ RuntimeContextSource::BootstrapCli
+ } else {
+ RuntimeContextSource::SafeDefault
+ };
+ RuntimeContext::resolve(
+ &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
+ RuntimeContextBootstrap::new(
+ profile,
+ repo_local_root,
+ profile_source,
+ RuntimeContextSource::BootstrapCli,
+ )
+ .expect("valid bootstrap"),
+ ServiceId::new(service).expect("valid service"),
+ InstanceId::new(instance).expect("valid instance"),
+ )
+ .expect("valid runtime context")
+ }
+
+ #[test]
+ fn paths_bind_exact_service_host_and_repo_local_artifacts() {
+ let myc = ServiceSqlitePaths::from_runtime_context(&runtime_context(
+ RadrootsPathProfile::ServiceHost,
+ None,
+ "myc",
+ "primary",
+ ))
+ .expect("Myc paths");
+ assert_eq!(myc.service().as_str(), "myc");
+ assert_eq!(myc.instance().as_str(), "primary");
+ assert_eq!(
+ myc.state_database(),
+ Path::new("/var/lib/radroots/services/myc/primary/state.sqlite")
+ );
+ assert_eq!(
+ myc.state_lock(),
+ Path::new("/var/lib/radroots/services/myc/primary/state.lock")
+ );
+
+ let rhi = ServiceSqlitePaths::from_runtime_context(&runtime_context(
+ RadrootsPathProfile::RepoLocal,
+ Some(PathBuf::from("/repo/.local/radroots")),
+ "rhi",
+ "north-01",
+ ))
+ .expect("RHI paths");
+ assert_eq!(rhi.service().as_str(), "rhi");
+ assert_eq!(rhi.instance().as_str(), "north-01");
+ assert_eq!(
+ rhi.state_database(),
+ Path::new("/repo/.local/radroots/data/services/rhi/north-01/state.sqlite")
+ );
+ assert_eq!(
+ rhi.state_lock(),
+ Path::new("/repo/.local/radroots/data/services/rhi/north-01/state.lock")
+ );
+
+ let second = ServiceSqlitePaths::from_runtime_context(&runtime_context(
+ RadrootsPathProfile::RepoLocal,
+ Some(PathBuf::from("/repo/.local/radroots")),
+ "rhi",
+ "south-02",
+ ))
+ .expect("second RHI paths");
+ assert_ne!(rhi, second);
+ assert_ne!(rhi.state_database(), second.state_database());
+ assert_ne!(rhi.state_lock(), second.state_lock());
+ }
+
+ #[test]
+ fn path_shape_failures_are_typed_path_free_and_debug_is_redacted() {
+ assert_eq!(
+ validate_state_directory(Path::new("relative/state")),
+ Err(ServiceSqlitePathError::RelativeStateDirectory)
+ );
+ assert_eq!(
+ validate_state_directory(Path::new("/")),
+ Err(ServiceSqlitePathError::MissingStateDirectoryParent)
+ );
+
+ let error = ServiceSqlitePathError::RelativeStateDirectory;
+ assert_eq!(error.to_string(), "SQLite state directory must be absolute");
+ assert_eq!(format!("{error:?}"), "RelativeStateDirectory");
+
+ let paths = ServiceSqlitePaths::from_runtime_context(&runtime_context(
+ RadrootsPathProfile::RepoLocal,
+ Some(PathBuf::from("/sensitive/project-root")),
+ "myc",
+ "private-instance",
+ ))
+ .expect("redacted paths");
+ let debug = format!("{paths:?}");
+ assert!(debug.contains("service: ServiceId(\"myc\")"));
+ assert!(debug.contains("instance: InstanceId(\"private-instance\")"));
+ assert!(debug.contains("state_database: \"[redacted]\""));
+ assert!(debug.contains("state_lock: \"[redacted]\""));
+ assert!(!debug.contains("sensitive"));
+ assert!(!debug.contains("project-root"));
+ assert!(!debug.contains("state.sqlite"));
+ assert!(!debug.contains("state.lock"));
+ }
+
+ #[test]
+ fn open_mode_wire_inventory_and_semantics_are_exact() {
+ let inventory = [
+ (OpenMode::Initialize, "initialize", true, false, true),
+ (
+ OpenMode::ReadWriteExisting,
+ "read_write_existing",
+ false,
+ true,
+ true,
+ ),
+ (
+ OpenMode::ReadOnlyInspection,
+ "read_only_inspection",
+ false,
+ true,
+ false,
+ ),
+ ];
+ for (mode, wire, may_create, requires_existing, requires_writer) in inventory {
+ assert_eq!(
+ serde_json::to_string(&mode).unwrap(),
+ format!(r#""{wire}""#)
+ );
+ assert_eq!(mode.may_create(), may_create);
+ assert_eq!(mode.requires_existing(), requires_existing);
+ assert_eq!(mode.requires_writer_authority(), requires_writer);
+ }
+ }
+}
diff --git a/crates/service_sqlite/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs
@@ -3,6 +3,7 @@ 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 OPEN_SOURCE: &str = include_str!("../src/open.rs");
const STATUS_SOURCE: &str = include_str!("../src/status.rs");
#[test]
@@ -21,13 +22,16 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
assert_eq!(
dependency_keys(MANIFEST, "[dependencies]"),
- BTreeSet::from(["serde"])
+ BTreeSet::from(["radroots_runtime_paths", "serde"])
);
assert_eq!(
dependency_keys(MANIFEST, "[dev-dependencies]"),
BTreeSet::from(["serde_json"])
);
- assert_eq!(private_modules(ROOT), BTreeSet::from(["error", "status"]));
+ assert_eq!(
+ private_modules(ROOT),
+ BTreeSet::from(["error", "open", "status"])
+ );
assert!(public_modules(ROOT).is_empty());
for required in [
@@ -35,6 +39,9 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"ServiceSqliteErrorKind",
"SafeServiceSqliteError",
"ServiceSqliteError",
+ "ServiceSqlitePathError",
+ "ServiceSqlitePaths",
+ "OpenMode",
"StorageHealth",
"StorageIntegrity",
"StorageStatus",
@@ -47,7 +54,6 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
for forbidden in [
"radroots_service_host",
- "radroots_runtime_paths",
"sqlx",
"rusqlite",
"tokio",
@@ -58,8 +64,30 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"Pool",
] {
assert!(
- !ERROR_SOURCE.contains(forbidden) && !STATUS_SOURCE.contains(forbidden),
- "Step 052 source contains deferred surface `{forbidden}`"
+ !ERROR_SOURCE.contains(forbidden)
+ && !OPEN_SOURCE.contains(forbidden)
+ && !STATUS_SOURCE.contains(forbidden),
+ "service SQLite source contains deferred surface `{forbidden}`"
+ );
+ }
+
+ for forbidden in [
+ "Deserialize",
+ "impl Default for OpenMode",
+ "pub fn from_paths",
+ "pub fn new(",
+ "std::fs",
+ "symlink_metadata",
+ "create_dir",
+ "File::open",
+ "OpenOptions",
+ "fs2",
+ "rustix",
+ "lock_exclusive",
+ ] {
+ assert!(
+ !OPEN_SOURCE.contains(forbidden),
+ "Step 053 open source contains deferred or forgeable surface `{forbidden}`"
);
}
}