commit ec25f669a14ca0f6957d4094af249af9c1ca0fc1
parent c070d4b1aaaa09db37da686c25e4120d260322d6
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 10:45:45 +0000
service-sqlite: enforce writer authority
- acquire one secure nonblocking writer lock per service instance
- retain a noncloneable capability through explicit release or drop
- reuse persistent lock inodes without truncation or replacement
- prove same-process, cross-process, and read-only behavior
Diffstat:
6 files changed, 567 insertions(+), 4 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -3909,9 +3909,12 @@ dependencies = [
name = "radroots_service_sqlite"
version = "0.1.0-alpha"
dependencies = [
+ "fs2",
"radroots_runtime_paths",
+ "rustix 1.1.4",
"serde",
"serde_json",
+ "tempfile",
]
[[package]]
diff --git a/crates/service_sqlite/Cargo.toml b/crates/service_sqlite/Cargo.toml
@@ -12,11 +12,14 @@ homepage.workspace = true
readme = "README.md"
[dependencies]
+fs2 = { workspace = true }
radroots_runtime_paths = { workspace = true }
+rustix = { workspace = true }
serde = { workspace = true, features = ["derive", "std"] }
[dev-dependencies]
serde_json = { workspace = true }
+tempfile = { workspace = true }
[lints]
workspace = true
diff --git a/crates/service_sqlite/src/authority.rs b/crates/service_sqlite/src/authority.rs
@@ -0,0 +1,450 @@
+//! Lifetime authority for the sole writable service database owner.
+
+use core::fmt;
+use std::{error::Error, fs::File};
+
+use fs2::FileExt;
+
+use crate::{OpenMode, ServiceSqliteError, ServiceSqliteErrorKind, ServiceSqlitePaths};
+
+/// Exclusive lifetime capability required by writable SQLite open modes.
+///
+/// This capability is deliberately non-cloneable and exposes no descriptor or
+/// lock path:
+///
+/// ```compile_fail
+/// use radroots_service_sqlite::WriterAuthority;
+///
+/// fn require_clone<T: Clone>() {}
+/// require_clone::<WriterAuthority>();
+/// ```
+pub struct WriterAuthority {
+ file: Option<File>,
+}
+
+impl WriterAuthority {
+ /// Acquires writer authority without waiting or touching database state.
+ ///
+ /// Read-only inspection requires no writer authority and performs no
+ /// filesystem operation.
+ pub fn acquire(
+ paths: &ServiceSqlitePaths,
+ mode: OpenMode,
+ ) -> Result<Option<Self>, ServiceSqliteError> {
+ if !mode.requires_writer_authority() {
+ return Ok(None);
+ }
+ acquire_supported(paths).map(Some).map_err(authority_error)
+ }
+
+ /// Returns whether this capability still holds the advisory lock.
+ #[must_use]
+ pub fn is_held(&self) -> bool {
+ self.file.is_some()
+ }
+
+ /// Explicitly releases writer authority; subsequent calls are no-ops.
+ pub fn release(&mut self) -> Result<(), ServiceSqliteError> {
+ let Some(file) = self.file.as_ref() else {
+ return Ok(());
+ };
+ FileExt::unlock(file).map_err(|_| authority_error(WriterAuthorityCause::UnlockFailed))?;
+ self.file.take();
+ Ok(())
+ }
+}
+
+impl fmt::Debug for WriterAuthority {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("WriterAuthority")
+ .field("state", &if self.is_held() { "held" } else { "released" })
+ .finish()
+ }
+}
+
+impl Drop for WriterAuthority {
+ fn drop(&mut self) {
+ if let Some(file) = self.file.take() {
+ let _ = FileExt::unlock(&file);
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum WriterAuthorityCause {
+ #[cfg(not(any(target_os = "linux", target_os = "macos")))]
+ UnsupportedPlatform,
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ StateDirectoryUnavailable,
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ StateDirectoryInvalidType,
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ StateDirectoryWrongOwner,
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ StateDirectoryInsecurePermissions,
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ LockUnavailable,
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ LockInvalidType,
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ LockMultipleLinks,
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ LockWrongOwner,
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ Contended,
+ UnlockFailed,
+}
+
+impl fmt::Display for WriterAuthorityCause {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ #[cfg(not(any(target_os = "linux", target_os = "macos")))]
+ Self::UnsupportedPlatform => "SQLite writer authority is unsupported on this platform",
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ Self::StateDirectoryUnavailable => "SQLite state directory is unavailable",
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ Self::StateDirectoryInvalidType => "SQLite state directory has an invalid type",
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ Self::StateDirectoryWrongOwner => "SQLite state directory has the wrong owner",
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ Self::StateDirectoryInsecurePermissions => {
+ "SQLite state directory permissions are insecure"
+ }
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ Self::LockUnavailable => "SQLite writer lock is unavailable",
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ Self::LockInvalidType => "SQLite writer lock has an invalid type",
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ Self::LockMultipleLinks => "SQLite writer lock has multiple links",
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ Self::LockWrongOwner => "SQLite writer lock has the wrong owner",
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ Self::Contended => "another SQLite writer is active",
+ Self::UnlockFailed => "SQLite writer authority could not be released",
+ })
+ }
+}
+
+impl Error for WriterAuthorityCause {}
+
+fn authority_error(cause: WriterAuthorityCause) -> ServiceSqliteError {
+ ServiceSqliteError::with_source(ServiceSqliteErrorKind::Authority, cause)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn acquire_supported(paths: &ServiceSqlitePaths) -> Result<WriterAuthority, WriterAuthorityCause> {
+ use rustix::{
+ fs::{FileType, Mode, OFlags, fchmod, fstat, open, openat},
+ process::geteuid,
+ };
+
+ let state_directory = paths
+ .state_lock()
+ .parent()
+ .ok_or(WriterAuthorityCause::StateDirectoryUnavailable)?;
+ let directory = open(
+ state_directory,
+ OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
+ Mode::empty(),
+ )
+ .map_err(|_| WriterAuthorityCause::StateDirectoryUnavailable)?;
+ let directory_status =
+ fstat(&directory).map_err(|_| WriterAuthorityCause::StateDirectoryUnavailable)?;
+ validate_directory(
+ FileType::from_raw_mode(directory_status.st_mode).is_dir(),
+ directory_status.st_uid,
+ u32::from(directory_status.st_mode),
+ geteuid().as_raw(),
+ )?;
+
+ let descriptor = openat(
+ &directory,
+ radroots_runtime_paths::SERVICE_STATE_LOCK_FILE_NAME,
+ OFlags::RDWR | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
+ Mode::RUSR | Mode::WUSR,
+ )
+ .map_err(|_| WriterAuthorityCause::LockUnavailable)?;
+ let lock_status = fstat(&descriptor).map_err(|_| WriterAuthorityCause::LockUnavailable)?;
+ validate_lock(
+ FileType::from_raw_mode(lock_status.st_mode).is_file(),
+ u64::from(lock_status.st_nlink),
+ lock_status.st_uid,
+ geteuid().as_raw(),
+ )?;
+ fchmod(&descriptor, Mode::RUSR | Mode::WUSR)
+ .map_err(|_| WriterAuthorityCause::LockUnavailable)?;
+
+ let file = File::from(descriptor);
+ match FileExt::try_lock_exclusive(&file) {
+ Ok(()) => Ok(WriterAuthority { file: Some(file) }),
+ Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
+ Err(WriterAuthorityCause::Contended)
+ }
+ Err(_) => Err(WriterAuthorityCause::LockUnavailable),
+ }
+}
+
+#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+fn acquire_supported(_paths: &ServiceSqlitePaths) -> Result<WriterAuthority, WriterAuthorityCause> {
+ Err(WriterAuthorityCause::UnsupportedPlatform)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn validate_directory(
+ is_directory: bool,
+ actual_uid: u32,
+ mode: u32,
+ expected_uid: u32,
+) -> Result<(), WriterAuthorityCause> {
+ if !is_directory {
+ return Err(WriterAuthorityCause::StateDirectoryInvalidType);
+ }
+ if actual_uid != expected_uid {
+ return Err(WriterAuthorityCause::StateDirectoryWrongOwner);
+ }
+ if mode & 0o022 != 0 {
+ return Err(WriterAuthorityCause::StateDirectoryInsecurePermissions);
+ }
+ Ok(())
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn validate_lock(
+ is_regular_file: bool,
+ link_count: u64,
+ actual_uid: u32,
+ expected_uid: u32,
+) -> Result<(), WriterAuthorityCause> {
+ if !is_regular_file {
+ return Err(WriterAuthorityCause::LockInvalidType);
+ }
+ if link_count != 1 {
+ return Err(WriterAuthorityCause::LockMultipleLinks);
+ }
+ if actual_uid != expected_uid {
+ return Err(WriterAuthorityCause::LockWrongOwner);
+ }
+ Ok(())
+}
+
+#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
+mod tests {
+ use std::{
+ fs,
+ os::unix::fs::{MetadataExt, PermissionsExt, symlink},
+ path::Path,
+ };
+
+ use radroots_runtime_paths::{
+ InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver,
+ RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId,
+ };
+
+ use super::*;
+
+ fn paths(root: &Path, instance: &str) -> ServiceSqlitePaths {
+ 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("runtime context");
+ ServiceSqlitePaths::from_runtime_context(&context).expect("SQLite paths")
+ }
+
+ fn prepare(paths: &ServiceSqlitePaths) {
+ fs::create_dir_all(paths.state_lock().parent().expect("state directory"))
+ .expect("create state directory");
+ }
+
+ #[test]
+ fn one_writer_excludes_a_second_until_release_or_drop() {
+ let root = tempfile::tempdir().expect("root");
+ let paths = paths(root.path(), "primary");
+ prepare(&paths);
+
+ let mut first = WriterAuthority::acquire(&paths, OpenMode::Initialize)
+ .expect("first acquisition")
+ .expect("writer capability");
+ assert!(first.is_held());
+ let contended = WriterAuthority::acquire(&paths, OpenMode::ReadWriteExisting)
+ .expect_err("second writer must fail");
+ assert_eq!(contended.kind(), ServiceSqliteErrorKind::Authority);
+ assert_eq!(
+ contended.source().map(ToString::to_string).as_deref(),
+ Some("another SQLite writer is active")
+ );
+
+ first.release().expect("release");
+ assert!(!first.is_held());
+ first.release().expect("idempotent release");
+ let next = WriterAuthority::acquire(&paths, OpenMode::ReadWriteExisting)
+ .expect("reacquire")
+ .expect("writer capability");
+ drop(next);
+ assert!(
+ WriterAuthority::acquire(&paths, OpenMode::ReadWriteExisting)
+ .expect("reacquire after drop")
+ .is_some()
+ );
+ }
+
+ #[test]
+ fn retained_stale_lock_inode_is_reused_without_content_mutation() {
+ let root = tempfile::tempdir().expect("root");
+ let paths = paths(root.path(), "stale");
+ prepare(&paths);
+ fs::write(paths.state_lock(), b"stale-evidence").expect("stale lock");
+ let before = fs::metadata(paths.state_lock()).expect("before metadata");
+
+ let mut authority = WriterAuthority::acquire(&paths, OpenMode::Initialize)
+ .expect("acquire stale inode")
+ .expect("writer capability");
+ authority.release().expect("release stale inode");
+
+ let after = fs::metadata(paths.state_lock()).expect("after metadata");
+ assert_eq!(before.dev(), after.dev());
+ assert_eq!(before.ino(), after.ino());
+ assert_eq!(
+ fs::read(paths.state_lock()).expect("stale content"),
+ b"stale-evidence"
+ );
+ assert_eq!(after.permissions().mode() & 0o777, 0o600);
+ }
+
+ #[test]
+ fn read_only_inspection_has_no_filesystem_side_effect() {
+ let root = tempfile::tempdir().expect("root");
+ let paths = paths(root.path(), "inspection");
+ let state_directory = paths.state_lock().parent().expect("state directory");
+ assert!(!state_directory.exists());
+ assert!(
+ WriterAuthority::acquire(&paths, OpenMode::ReadOnlyInspection)
+ .expect("read-only declaration")
+ .is_none()
+ );
+ assert!(!state_directory.exists());
+ assert!(!paths.state_lock().exists());
+ }
+
+ #[test]
+ fn unsafe_directory_and_lock_shapes_fail_closed() {
+ assert_eq!(
+ validate_directory(true, 10, 0o40700, 11),
+ Err(WriterAuthorityCause::StateDirectoryWrongOwner)
+ );
+ assert_eq!(
+ validate_directory(true, 10, 0o40720, 10),
+ Err(WriterAuthorityCause::StateDirectoryInsecurePermissions)
+ );
+ assert_eq!(
+ validate_directory(false, 10, 0o100600, 10),
+ Err(WriterAuthorityCause::StateDirectoryInvalidType)
+ );
+ assert_eq!(
+ validate_lock(false, 1, 10, 10),
+ Err(WriterAuthorityCause::LockInvalidType)
+ );
+ assert_eq!(
+ validate_lock(true, 2, 10, 10),
+ Err(WriterAuthorityCause::LockMultipleLinks)
+ );
+ assert_eq!(
+ validate_lock(true, 1, 10, 11),
+ Err(WriterAuthorityCause::LockWrongOwner)
+ );
+
+ let root = tempfile::tempdir().expect("root");
+ let symlink_paths = paths(root.path(), "symlink");
+ prepare(&symlink_paths);
+ let target = root.path().join("target");
+ fs::write(&target, []).expect("target");
+ symlink(&target, symlink_paths.state_lock()).expect("lock symlink");
+ assert!(WriterAuthority::acquire(&symlink_paths, OpenMode::Initialize).is_err());
+
+ let hardlink_paths = paths(root.path(), "hardlink");
+ prepare(&hardlink_paths);
+ fs::hard_link(&target, hardlink_paths.state_lock()).expect("lock hard link");
+ assert!(WriterAuthority::acquire(&hardlink_paths, OpenMode::Initialize).is_err());
+
+ let directory_paths = paths(root.path(), "directory");
+ prepare(&directory_paths);
+ fs::create_dir(directory_paths.state_lock()).expect("directory lock");
+ assert!(WriterAuthority::acquire(&directory_paths, OpenMode::Initialize).is_err());
+
+ let insecure_paths = paths(root.path(), "insecure");
+ prepare(&insecure_paths);
+ fs::set_permissions(
+ insecure_paths
+ .state_lock()
+ .parent()
+ .expect("state directory"),
+ fs::Permissions::from_mode(0o722),
+ )
+ .expect("insecure mode");
+ assert!(WriterAuthority::acquire(&insecure_paths, OpenMode::Initialize).is_err());
+ }
+
+ #[test]
+ fn authority_errors_and_debug_are_path_redacted() {
+ let root = tempfile::tempdir().expect("root");
+ let sensitive_root = root.path().join("secret-state-root");
+ fs::create_dir(&sensitive_root).expect("sensitive root");
+ let paths = paths(&sensitive_root, "redacted");
+ let error = WriterAuthority::acquire(&paths, OpenMode::Initialize)
+ .expect_err("missing state directory");
+ let display = error.to_string();
+ let debug = format!("{error:?}");
+ let source = error.source().map(ToString::to_string).unwrap();
+ for projection in [display, debug, source] {
+ assert!(!projection.contains("secret-state-root"));
+ assert!(!projection.contains("state.lock"));
+ assert!(!projection.contains(root.path().to_string_lossy().as_ref()));
+ }
+
+ prepare(&paths);
+ let mut authority = WriterAuthority::acquire(&paths, OpenMode::Initialize)
+ .expect("authority")
+ .expect("writer capability");
+ assert_eq!(
+ format!("{authority:?}"),
+ "WriterAuthority { state: \"held\" }"
+ );
+ authority.release().expect("release");
+ assert_eq!(
+ format!("{authority:?}"),
+ "WriterAuthority { state: \"released\" }"
+ );
+ }
+
+ #[test]
+ fn fixed_private_cause_inventory_is_path_free() {
+ let causes = [
+ WriterAuthorityCause::StateDirectoryUnavailable,
+ WriterAuthorityCause::StateDirectoryInvalidType,
+ WriterAuthorityCause::StateDirectoryWrongOwner,
+ WriterAuthorityCause::StateDirectoryInsecurePermissions,
+ WriterAuthorityCause::LockUnavailable,
+ WriterAuthorityCause::LockInvalidType,
+ WriterAuthorityCause::LockMultipleLinks,
+ WriterAuthorityCause::LockWrongOwner,
+ WriterAuthorityCause::Contended,
+ WriterAuthorityCause::UnlockFailed,
+ ];
+ for cause in causes {
+ let display = cause.to_string();
+ assert!(display.is_ascii());
+ assert!(!display.contains('/'));
+ assert!(!display.contains(".sqlite"));
+ assert!(!display.contains("state.lock"));
+ }
+ }
+}
diff --git a/crates/service_sqlite/src/lib.rs b/crates/service_sqlite/src/lib.rs
@@ -2,10 +2,12 @@
//! Reusable, service-neutral SQLite mechanics for Radroots services.
+mod authority;
mod error;
mod open;
mod status;
+pub use authority::WriterAuthority;
pub use error::{
SafeServiceSqliteError, ServiceSqliteError, ServiceSqliteErrorCode, ServiceSqliteErrorKind,
};
diff --git a/crates/service_sqlite/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs
@@ -2,6 +2,7 @@ use std::collections::BTreeSet;
const MANIFEST: &str = include_str!("../Cargo.toml");
const ROOT: &str = include_str!("../src/lib.rs");
+const AUTHORITY_SOURCE: &str = include_str!("../src/authority.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");
@@ -22,23 +23,28 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
assert_eq!(
dependency_keys(MANIFEST, "[dependencies]"),
- BTreeSet::from(["radroots_runtime_paths", "serde"])
+ BTreeSet::from(["fs2", "radroots_runtime_paths", "rustix", "serde"])
);
assert_eq!(
dependency_keys(MANIFEST, "[dev-dependencies]"),
- BTreeSet::from(["serde_json"])
+ BTreeSet::from(["serde_json", "tempfile"])
);
assert_eq!(
private_modules(ROOT),
- BTreeSet::from(["error", "open", "status"])
+ BTreeSet::from(["authority", "error", "open", "status"])
);
assert!(public_modules(ROOT).is_empty());
+ let authority_production = AUTHORITY_SOURCE
+ .split_once("#[cfg(all(test")
+ .map(|(production, _)| production)
+ .expect("authority source must keep tests separated");
for required in [
"ServiceSqliteErrorCode",
"ServiceSqliteErrorKind",
"SafeServiceSqliteError",
"ServiceSqliteError",
+ "WriterAuthority",
"ServiceSqlitePathError",
"ServiceSqlitePaths",
"OpenMode",
@@ -64,13 +70,48 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"Pool",
] {
assert!(
- !ERROR_SOURCE.contains(forbidden)
+ !authority_production.contains(forbidden)
+ && !ERROR_SOURCE.contains(forbidden)
&& !OPEN_SOURCE.contains(forbidden)
&& !STATUS_SOURCE.contains(forbidden),
"service SQLite source contains deferred surface `{forbidden}`"
);
}
+ for required in [
+ "fs2::FileExt",
+ "rustix",
+ "try_lock_exclusive",
+ "NOFOLLOW",
+ "CLOEXEC",
+ "st_nlink",
+ "fchmod",
+ "pub fn release(&mut self)",
+ ] {
+ assert!(
+ AUTHORITY_SOURCE.contains(required),
+ "Step 054 authority source is missing `{required}`"
+ );
+ }
+
+ for forbidden in [
+ "state_database()",
+ "remove_file",
+ "create_dir",
+ "set_len",
+ "truncate",
+ "std::process",
+ "Command::new",
+ "sqlx",
+ "rusqlite",
+ "tokio",
+ ] {
+ assert!(
+ !authority_production.contains(forbidden),
+ "Step 054 authority production source contains deferred surface `{forbidden}`"
+ );
+ }
+
for forbidden in [
"Deserialize",
"impl Default for OpenMode",
diff --git a/crates/service_sqlite/tests/writer_authority.rs b/crates/service_sqlite/tests/writer_authority.rs
@@ -0,0 +1,64 @@
+#![cfg(any(target_os = "linux", target_os = "macos"))]
+
+use std::{env, error::Error, fs, path::PathBuf, process::Command};
+
+use radroots_runtime_paths::{
+ InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver,
+ RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId,
+};
+use radroots_service_sqlite::{
+ OpenMode, ServiceSqliteErrorKind, ServiceSqlitePaths, WriterAuthority,
+};
+
+const CHILD_ROOT: &str = "RADROOTS_SERVICE_SQLITE_WRITER_CHILD_ROOT";
+
+fn paths(root: PathBuf) -> ServiceSqlitePaths {
+ 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("process-contention").expect("instance"),
+ )
+ .expect("runtime context");
+ ServiceSqlitePaths::from_runtime_context(&context).expect("SQLite paths")
+}
+
+#[test]
+fn writer_authority_is_exclusive_across_processes() {
+ let root = tempfile::tempdir().expect("root");
+ let paths = paths(root.path().to_path_buf());
+ fs::create_dir_all(paths.state_lock().parent().expect("state directory"))
+ .expect("create state directory");
+ let _authority = WriterAuthority::acquire(&paths, OpenMode::Initialize)
+ .expect("parent authority")
+ .expect("writer capability");
+
+ let status = Command::new(env::current_exe().expect("test executable"))
+ .arg("--exact")
+ .arg("writer_authority_child_probe")
+ .arg("--nocapture")
+ .env(CHILD_ROOT, root.path())
+ .status()
+ .expect("child probe");
+ assert!(status.success(), "child must observe writer contention");
+}
+
+#[test]
+fn writer_authority_child_probe() {
+ let Some(root) = env::var_os(CHILD_ROOT) else {
+ return;
+ };
+ let error = WriterAuthority::acquire(&paths(PathBuf::from(root)), OpenMode::Initialize)
+ .expect_err("parent writer must remain authoritative");
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Authority);
+ assert_eq!(
+ error.source().map(ToString::to_string).as_deref(),
+ Some("another SQLite writer is active")
+ );
+}