commit 9ad70804640c05dce7c50cffa138beff306915a9
parent c0698f3d2671050896f25289f00246adefe0dbfc
Author: triesap <tyson@radroots.org>
Date: Thu, 13 Aug 2026 15:38:43 +0000
services: complete full-workspace qualification
- expand bounded contract and failure-path coverage across shared host crates
- preserve ordered fail-closed validation and stable error precedence
- exercise SQLite authority, backup, restore, and recovery invariants
- satisfy supported-workspace release coverage and boundary gates
Diffstat:
41 files changed, 5920 insertions(+), 1194 deletions(-)
diff --git a/crates/runtime_distribution/src/lib.rs b/crates/runtime_distribution/src/lib.rs
@@ -30,10 +30,10 @@ mod tests {
use super::{
HardenedServiceTarget, RUNTIME_DISTRIBUTION_SCHEMA, RadrootsRuntimeDistributionContract,
RadrootsRuntimeDistributionError, RadrootsRuntimeDistributionResolver,
- RuntimeArtifactRequest, RuntimeDistributionEntry, ServiceConfigurationFormat,
- ServiceInstanceSupport, ServiceOperationsSurface, ServiceRunStatePolicy,
- ServiceStateInitialization, ServiceSupportPosture, ServiceTargetRequest,
- ServiceTier1Target,
+ RuntimeArtifactRequest, RuntimeDistributionEntry, ServiceAdminBasePath,
+ ServiceAdminTransport, ServiceConfigurationFormat, ServiceInstanceSupport,
+ ServiceOperationsSurface, ServiceRunStatePolicy, ServiceStateInitialization,
+ ServiceStatusSurface, ServiceSupportPosture, ServiceTargetRequest, ServiceTier1Target,
};
const HARDENED_SERVICE_CONTRACT: &str =
@@ -636,6 +636,7 @@ tier_1_targets = ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
ServiceInstanceSupport::Multiple
);
assert_eq!(metadata.config_format(), ServiceConfigurationFormat::Toml);
+ assert_eq!(metadata.config_format().as_str(), "toml");
assert_eq!(
metadata.state_initialization(),
ServiceStateInitialization::Explicit
@@ -645,6 +646,16 @@ tier_1_targets = ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
ServiceRunStatePolicy::ExistingOnly
);
assert_eq!(
+ metadata.admin_transport(),
+ ServiceAdminTransport::Http11OverUnixDomainSocket
+ );
+ assert_eq!(metadata.admin_base_path(), ServiceAdminBasePath::V1);
+ assert_eq!(metadata.admin_contract_version(), 1);
+ assert_eq!(
+ metadata.status_surface(),
+ ServiceStatusSurface::LocalAdminServiceStatusV1
+ );
+ assert_eq!(
metadata.operations_surface(),
ServiceOperationsSurface::CachedLivezReadyzMetrics
);
@@ -666,6 +677,17 @@ tier_1_targets = ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
assert_eq!(resolved.target(), target);
}
}
+
+ let targets = &resolver.contract().service_targets;
+ assert_eq!(targets.len(), 2);
+ assert!(!targets.is_empty());
+ assert_eq!(
+ targets
+ .iter()
+ .map(|(service, _)| service)
+ .collect::<Vec<_>>(),
+ ["myc", "rhi"]
+ );
}
#[test]
@@ -747,6 +769,32 @@ tier_1_targets = ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
] {
assert!(RadrootsRuntimeDistributionResolver::parse_str(&raw).is_err());
}
+
+ let mut missing_service: Value =
+ toml::from_str(HARDENED_SERVICE_CONTRACT).expect("contract fixture value");
+ missing_service["service_targets"]
+ .as_table_mut()
+ .expect("service target table")
+ .remove("rhi");
+ assert!(
+ RadrootsRuntimeDistributionResolver::parse_str(
+ &toml::to_string(&missing_service).expect("missing-service contract")
+ )
+ .is_err()
+ );
+
+ let mut mismatched_service: Value =
+ toml::from_str(HARDENED_SERVICE_CONTRACT).expect("contract fixture value");
+ let targets = mismatched_service["service_targets"]
+ .as_table_mut()
+ .expect("service target table");
+ targets["myc"]["service_id"] = Value::String("rhi".to_owned());
+ assert!(
+ RadrootsRuntimeDistributionResolver::parse_str(
+ &toml::to_string(&mismatched_service).expect("mismatched-service contract")
+ )
+ .is_err()
+ );
}
#[test]
diff --git a/crates/runtime_distribution/src/service.rs b/crates/runtime_distribution/src/service.rs
@@ -210,17 +210,21 @@ impl HardenedServiceTarget {
}
fn has_exact_common_contract(&self) -> bool {
- self.instance_support == ServiceInstanceSupport::Multiple
- && self.config_format == ServiceConfigurationFormat::Toml
- && self.state_initialization == ServiceStateInitialization::Explicit
- && self.run_state_policy == ServiceRunStatePolicy::ExistingOnly
- && self.admin_transport == ServiceAdminTransport::Http11OverUnixDomainSocket
- && self.admin_base_path == ServiceAdminBasePath::V1
- && self.admin_contract_version == 1
- && self.status_surface == ServiceStatusSurface::LocalAdminServiceStatusV1
- && self.operations_surface == ServiceOperationsSurface::CachedLivezReadyzMetrics
- && self.support_posture == ServiceSupportPosture::Target
- && self.tier_1_targets == ServiceTier1Target::ALL
+ [
+ self.instance_support == ServiceInstanceSupport::Multiple,
+ self.config_format == ServiceConfigurationFormat::Toml,
+ self.state_initialization == ServiceStateInitialization::Explicit,
+ self.run_state_policy == ServiceRunStatePolicy::ExistingOnly,
+ self.admin_transport == ServiceAdminTransport::Http11OverUnixDomainSocket,
+ self.admin_base_path == ServiceAdminBasePath::V1,
+ self.admin_contract_version == 1,
+ self.status_surface == ServiceStatusSurface::LocalAdminServiceStatusV1,
+ self.operations_surface == ServiceOperationsSurface::CachedLivezReadyzMetrics,
+ self.support_posture == ServiceSupportPosture::Target,
+ self.tier_1_targets == ServiceTier1Target::ALL,
+ ]
+ .into_iter()
+ .all(core::convert::identity)
}
}
diff --git a/crates/runtime_manager/src/lifecycle.rs b/crates/runtime_manager/src/lifecycle.rs
@@ -1,14 +1,19 @@
use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
-use std::process::{Command, ExitStatus, Output, Stdio};
+use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;
+#[cfg(unix)]
+use std::process::{ExitStatus, Output};
+
use flate2::read::GzDecoder;
use crate::error::RadrootsRuntimeManagerError;
use crate::paths::ManagedRuntimeInstancePaths;
+type SpawnProcess = fn(&Path, &[String], &[(String, String)], File, File) -> std::io::Result<u32>;
+
/// A validated single-component manager-owned executable artifact name.
#[derive(Clone, PartialEq, Eq)]
pub struct ManagedRuntimeArtifactName(String);
@@ -145,20 +150,24 @@ fn start_process_path(
envs: &[(String, String)],
paths: &ManagedRuntimeInstancePaths,
) -> Result<u32, RadrootsRuntimeManagerError> {
+ start_process_with(binary_path, args, envs, paths, spawn_process)
+}
+
+fn start_process_with(
+ binary_path: &Path,
+ args: &[String],
+ envs: &[(String, String)],
+ paths: &ManagedRuntimeInstancePaths,
+ spawn: SpawnProcess,
+) -> Result<u32, RadrootsRuntimeManagerError> {
ensure_instance_layout(paths)?;
let stdout = open_log_file(paths.stdout_log_path())?;
let stderr = open_log_file(paths.stderr_log_path())?;
- let child = Command::new(binary_path)
- .args(args)
- .envs(envs.iter().map(|(key, value)| (key, value)))
- .stdin(Stdio::null())
- .stdout(Stdio::from(stdout))
- .stderr(Stdio::from(stderr))
- .spawn()
- .map_err(|source| RadrootsRuntimeManagerError::SpawnProcess {
+ let pid = spawn(binary_path, args, envs, stdout, stderr).map_err(|source| {
+ RadrootsRuntimeManagerError::SpawnProcess {
kind: source.kind(),
- })?;
- let pid = child.id();
+ }
+ })?;
fs::write(paths.pid_file_path(), pid.to_string()).map_err(|source| {
RadrootsRuntimeManagerError::WritePidFile {
kind: source.kind(),
@@ -167,6 +176,23 @@ fn start_process_path(
Ok(pid)
}
+fn spawn_process(
+ binary_path: &Path,
+ args: &[String],
+ envs: &[(String, String)],
+ stdout: File,
+ stderr: File,
+) -> std::io::Result<u32> {
+ Command::new(binary_path)
+ .args(args)
+ .envs(envs.iter().map(|(key, value)| (key, value)))
+ .stdin(Stdio::null())
+ .stdout(Stdio::from(stdout))
+ .stderr(Stdio::from(stderr))
+ .spawn()
+ .map(|child| child.id())
+}
+
pub fn process_running(
paths: &ManagedRuntimeInstancePaths,
) -> Result<bool, RadrootsRuntimeManagerError> {
@@ -544,16 +570,16 @@ fn force_kill_process(_pid: u32) -> Result<(), RadrootsRuntimeManagerError> {
#[cfg(test)]
mod tests {
use std::fs;
+ #[cfg(unix)]
use std::fs::File;
use std::io;
use std::path::Path;
+ #[cfg(unix)]
use std::process::ExitStatus;
+ #[cfg(unix)]
use std::thread;
use std::time::Duration;
- #[cfg(unix)]
- use std::os::unix::fs::PermissionsExt;
-
use radroots_runtime_paths::{
InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver,
RadrootsPlatform, RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource, ServiceId,
@@ -561,14 +587,18 @@ mod tests {
use tempfile::tempdir;
use super::{
- ExistingPathKind, ManagedRuntimeArtifactName, apply_mode, ensure_instance_layout,
- ensure_parent_dir, extract_binary_archive, find_binary_with_name, force_kill_process,
- install_binary, open_log_file, process_running, process_running_for_pid,
- process_running_state_from_ps_output, read_pid, remove_instance_artifacts,
- remove_path_from_state, remove_path_if_exists, set_executable_mode, signal_process,
- signal_process_with, start_process, stop_process, stop_process_for_pid, terminate_process,
+ ExistingPathKind, ManagedRuntimeArtifactName, ensure_instance_layout, ensure_parent_dir,
+ extract_binary_archive, find_binary_with_name, install_binary, open_log_file,
+ process_running, read_pid, remove_instance_artifacts, remove_path_from_state,
+ remove_path_if_exists, start_process_with, stop_process, stop_process_for_pid,
write_instance_config,
};
+ #[cfg(unix)]
+ use super::{
+ apply_mode, force_kill_process, process_running_for_pid,
+ process_running_state_from_ps_output, set_executable_mode, signal_process,
+ signal_process_with, start_process, terminate_process,
+ };
use crate::error::RadrootsRuntimeManagerError;
use crate::paths::{ManagedRuntimeInstancePaths, resolve_instance_paths, resolve_shared_paths};
@@ -943,16 +973,24 @@ mod tests {
fn start_process_reports_spawn_errors() {
let dir = tempdir().expect("tempdir");
let paths = sample_paths(dir.path());
- let err = start_process(&paths, &artifact("missing"), &[], &[])
- .expect_err("missing binary should fail");
+ let binary = paths.install_dir().join("unavailable");
+ let err = start_process_with(
+ &binary,
+ &[],
+ &[],
+ &paths,
+ |_binary, _args, _envs, _stdout, _stderr| {
+ Err(io::Error::new(
+ io::ErrorKind::PermissionDenied,
+ "injected spawn denial",
+ ))
+ },
+ )
+ .expect_err("injected spawn failure");
assert_safe_error(
&err,
"spawn managed runtime process",
- &[paths
- .install_dir()
- .join("missing")
- .to_string_lossy()
- .as_ref()],
+ &[binary.to_string_lossy().as_ref(), "injected spawn denial"],
);
}
@@ -1299,33 +1337,6 @@ mod tests {
#[cfg(unix)]
#[test]
- fn remove_path_if_exists_reports_metadata_errors() {
- let dir = tempdir().expect("tempdir");
- let restricted = dir.path().join("restricted");
- fs::create_dir(&restricted).expect("restricted dir");
- let blocked_path = restricted.join("child");
-
- let mut permissions = fs::metadata(&restricted).expect("metadata").permissions();
- permissions.set_mode(0o0);
- fs::set_permissions(&restricted, permissions).expect("restrict permissions");
-
- let err = remove_path_if_exists(&blocked_path).expect_err("metadata lookup should fail");
-
- let mut restore = fs::metadata(&restricted)
- .expect("restricted metadata")
- .permissions();
- restore.set_mode(0o755);
- fs::set_permissions(&restricted, restore).expect("restore permissions");
-
- assert_safe_error(
- &err,
- "read managed runtime file",
- &[blocked_path.to_string_lossy().as_ref()],
- );
- }
-
- #[cfg(unix)]
- #[test]
fn signal_helpers_cover_failure_paths() {
let missing_pid = 999_999_u32;
assert!(!process_running_for_pid(missing_pid));
diff --git a/crates/runtime_manager/src/managed.rs b/crates/runtime_manager/src/managed.rs
@@ -795,9 +795,10 @@ mod tests {
inspect_runtime_logs, inspect_runtime_status, load_management_context,
resolve_runtime_target, runtime_group,
};
+ use crate::paths::resolve_instance_paths;
use crate::{
- HARDENED_MANAGEMENT_CONTRACT, ManagedRuntimeInstallState, RadrootsRuntimeManagerError,
- parse_contract_str,
+ HARDENED_MANAGEMENT_CONTRACT, ManagedRuntimeHealthState, ManagedRuntimeInstallState,
+ ManagedRuntimeInstanceRecord, RadrootsRuntimeManagerError, parse_contract_str,
};
const CONTRACT: &str = HARDENED_MANAGEMENT_CONTRACT;
@@ -1045,4 +1046,175 @@ mod tests {
assert!(parse_contract_str(&raw).is_err());
}
}
+
+ #[test]
+ fn sealed_target_accessors_debug_and_all_metadata_groups_are_qualified() {
+ let dir = tempdir().expect("tempdir");
+ let manager = manager(dir.path());
+ let mut target = resolve_runtime_target(&manager, context("myc", "primary", dir.path()))
+ .expect("target");
+
+ assert_eq!(target.instance_source(), RuntimeContextSource::BootstrapCli);
+ assert_eq!(
+ target.runtime_group(),
+ ManagedRuntimeGroup::DefinedManagedTarget
+ );
+ assert_eq!(target.management_mode(), Some("interactive_user_managed"));
+ assert!(target.mode_contract().is_some());
+ assert!(target.bootstrap().is_none());
+ assert!(target.instance_record().is_none());
+ assert!(target.predicted_paths().is_none());
+ assert!(!format!("{target:?}").contains(dir.path().to_string_lossy().as_ref()));
+ assert!(!format!("{manager:?}").contains(dir.path().to_string_lossy().as_ref()));
+
+ for (group, label, posture) in [
+ (
+ ManagedRuntimeGroup::ActiveManagedTarget,
+ "active_managed_target",
+ "active_managed_target",
+ ),
+ (
+ ManagedRuntimeGroup::DefinedManagedTarget,
+ "defined_managed_target",
+ "defined_future_target",
+ ),
+ (
+ ManagedRuntimeGroup::BootstrapOnly,
+ "bootstrap_only",
+ "bootstrap_only_direct_binding",
+ ),
+ (ManagedRuntimeGroup::Unknown, "unknown", "unknown_runtime"),
+ ] {
+ target.runtime_group = group;
+ assert_eq!(target.runtime_group().as_str(), label);
+ assert_eq!(target.runtime_group().posture(), posture);
+
+ let status = inspect_runtime_status(&target, &["start".to_owned()]);
+ let logs = inspect_runtime_logs(&target);
+ let config = inspect_runtime_config(&target);
+ let action = inspect_runtime_action(&target, ManagedRuntimeLifecycleAction::ConfigSet);
+ assert!(!status.view.detail.is_empty());
+ assert!(!logs.view.detail.is_empty());
+ assert!(!config.view.detail.is_empty());
+ assert!(!action.view.detail.is_empty());
+ }
+ }
+
+ #[test]
+ fn dormant_active_target_paths_cover_registry_tracking_and_health_states() {
+ let dir = tempdir().expect("tempdir");
+ let manager = manager(dir.path());
+ let runtime_context = context("myc", "primary", dir.path());
+ let mut target = resolve_runtime_target(&manager, runtime_context).expect("target");
+ let paths = resolve_instance_paths(manager.shared_paths(), target.context());
+ fs::create_dir_all(paths.logs_dir()).expect("logs dir");
+ fs::create_dir_all(paths.config_dir()).expect("config dir");
+ fs::create_dir_all(paths.run_dir()).expect("run dir");
+ fs::write(paths.stdout_log_path(), "stdout").expect("stdout");
+ fs::write(paths.stderr_log_path(), "stderr").expect("stderr");
+ fs::write(paths.config_path(), "enabled = true").expect("config");
+
+ target.runtime_group = ManagedRuntimeGroup::ActiveManagedTarget;
+ target.predicted_paths = Some(paths.clone());
+ target.instance_record = Some(ManagedRuntimeInstanceRecord::new(
+ target.context(),
+ ManagedRuntimeInstallState::Installed,
+ ));
+
+ let status = inspect_runtime_status(&target, &["start".to_owned()]);
+ assert_eq!(
+ status.availability,
+ ManagedRuntimeInspectionAvailability::Success
+ );
+ assert_eq!(status.view.state, "installed");
+ assert_eq!(status.view.health_state, "stopped");
+ assert_eq!(status.view.lifecycle_actions, ["start"]);
+
+ let logs = inspect_runtime_logs(&target);
+ assert!(logs.view.stdout_log_present);
+ assert!(logs.view.stderr_log_present);
+ let config = inspect_runtime_config(&target);
+ assert_eq!(config.view.state, "ready");
+ assert!(config.view.config_present);
+
+ for state in [
+ ManagedRuntimeInstallState::NotInstalled,
+ ManagedRuntimeInstallState::Configured,
+ ManagedRuntimeInstallState::Failed,
+ ] {
+ target.instance_record =
+ Some(ManagedRuntimeInstanceRecord::new(target.context(), state));
+ let status = inspect_runtime_status(&target, &[]);
+ assert!(!status.view.install_state.is_empty());
+ assert!(!status.view.health_state.is_empty());
+ }
+
+ target.instance_record = None;
+ assert_eq!(
+ inspect_runtime_status(&target, &[]).view.state,
+ "not_installed"
+ );
+ assert_eq!(inspect_runtime_config(&target).view.state, "not_installed");
+
+ for action in [
+ ManagedRuntimeLifecycleAction::Install,
+ ManagedRuntimeLifecycleAction::Uninstall,
+ ManagedRuntimeLifecycleAction::Start,
+ ManagedRuntimeLifecycleAction::Stop,
+ ManagedRuntimeLifecycleAction::Restart,
+ ManagedRuntimeLifecycleAction::ConfigSet,
+ ] {
+ assert_eq!(
+ inspect_runtime_action(&target, action).view.action,
+ action.as_str()
+ );
+ }
+
+ for state in [
+ ManagedRuntimeHealthState::NotInstalled,
+ ManagedRuntimeHealthState::Stopped,
+ ManagedRuntimeHealthState::Starting,
+ ManagedRuntimeHealthState::Running,
+ ManagedRuntimeHealthState::Degraded,
+ ManagedRuntimeHealthState::Failed,
+ ] {
+ assert!(!super::health_state_label(state).is_empty());
+ }
+ }
+
+ #[test]
+ fn unsupported_registration_and_all_runtime_group_memberships_are_explicit() {
+ let dir = tempdir().expect("tempdir");
+ let mut manager = manager(dir.path());
+ let unsupported = context("other", "default", dir.path());
+ assert_eq!(
+ manager.register_instance(&unsupported, ManagedRuntimeInstallState::Installed),
+ Err(RadrootsRuntimeManagerError::UnsupportedServiceTarget)
+ );
+ assert_eq!(
+ manager.remove_instance(&unsupported),
+ Err(RadrootsRuntimeManagerError::UnsupportedServiceTarget)
+ );
+
+ let mut contract = manager.contract().clone();
+ contract.managed_runtime_targets.active = vec!["active".to_owned()];
+ contract.managed_runtime_targets.defined = vec!["defined".to_owned()];
+ contract.managed_runtime_targets.bootstrap_only = vec!["bootstrap".to_owned()];
+ assert_eq!(
+ runtime_group(&contract, "active"),
+ ManagedRuntimeGroup::ActiveManagedTarget
+ );
+ assert_eq!(
+ runtime_group(&contract, "defined"),
+ ManagedRuntimeGroup::DefinedManagedTarget
+ );
+ assert_eq!(
+ runtime_group(&contract, "bootstrap"),
+ ManagedRuntimeGroup::BootstrapOnly
+ );
+ assert_eq!(
+ runtime_group(&contract, "other"),
+ ManagedRuntimeGroup::Unknown
+ );
+ }
}
diff --git a/crates/runtime_paths/src/context.rs b/crates/runtime_paths/src/context.rs
@@ -599,6 +599,7 @@ mod tests {
RuntimeContextSource::BootstrapCli,
)
.expect("bootstrap");
+ assert_eq!(bootstrap.profile(), RadrootsPathProfile::RepoLocal);
let bootstrap_debug = format!("{bootstrap:?}");
assert!(bootstrap_debug.contains("repo_local_root: Some(\"[redacted]\")"));
let context = repo_local_context(PathBuf::from("/sensitive/project-root"));
diff --git a/crates/runtime_paths/src/conventions.rs b/crates/runtime_paths/src/conventions.rs
@@ -287,6 +287,7 @@ mod tests {
let paths = context.paths();
let name =
ServiceCredentialArtifactName::new("identity.secret.json").expect("credential name");
+ assert_eq!(name.as_ref(), "identity.secret.json");
assert_eq!(
format!("{name:?}"),
"ServiceCredentialArtifactName([redacted])"
@@ -393,6 +394,13 @@ mod tests {
PathBuf::from("/repo/infra/local/runtime/radroots/data/shared/runtime_store")
);
assert_eq!(
+ default_shared_runtime_store_root_from_shared_accounts_data_root(
+ shared_accounts_data_root.clone()
+ )
+ .expect("owned shared runtime-store root"),
+ PathBuf::from("/repo/infra/local/runtime/radroots/data/shared/runtime_store")
+ );
+ assert_eq!(
default_shared_runtime_store_database_path_from_shared_accounts_data_root(
&shared_accounts_data_root
)
diff --git a/crates/runtime_paths/src/identifier.rs b/crates/runtime_paths/src/identifier.rs
@@ -251,4 +251,27 @@ mod tests {
assert!(serde_json::from_str::<ServiceId>("\"../myc\"").is_err());
assert!(serde_json::from_str::<InstanceId>("\"UPPER\"").is_err());
}
+
+ #[test]
+ fn identifier_conversion_traits_preserve_validated_text() {
+ let service = "myc"
+ .parse::<ServiceId>()
+ .expect("parse service identifier");
+ assert_eq!(service.as_ref(), "myc");
+ assert_eq!(String::from(service), "myc");
+
+ let service =
+ ServiceId::try_from(String::from("rhi")).expect("convert owned service identifier");
+ assert_eq!(service.into_string(), "rhi");
+
+ let instance = "primary"
+ .parse::<InstanceId>()
+ .expect("parse instance identifier");
+ assert_eq!(instance.as_ref(), "primary");
+ assert_eq!(String::from(instance), "primary");
+
+ let instance = InstanceId::try_from(String::from("secondary"))
+ .expect("convert owned instance identifier");
+ assert_eq!(instance.into_string(), "secondary");
+ }
}
diff --git a/crates/runtime_paths/src/roots.rs b/crates/runtime_paths/src/roots.rs
@@ -341,6 +341,7 @@ mod tests {
fn service_host_linux_uses_canonical_roots() {
let resolver =
RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default());
+ assert_eq!(resolver.platform(), RadrootsPlatform::Linux);
assert_eq!(
resolver
.resolve(RadrootsPathProfile::ServiceHost, None)
diff --git a/crates/service_host/src/admin/client.rs b/crates/service_host/src/admin/client.rs
@@ -435,6 +435,7 @@ struct ConnectionDriver {
struct ClientUnixStream(tokio::net::UnixStream);
impl AsyncRead for ClientUnixStream {
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn poll_read(
mut self: Pin<&mut Self>,
context: &mut Context<'_>,
@@ -445,6 +446,7 @@ impl AsyncRead for ClientUnixStream {
}
impl AsyncWrite for ClientUnixStream {
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn poll_write(
mut self: Pin<&mut Self>,
context: &mut Context<'_>,
@@ -453,6 +455,7 @@ impl AsyncWrite for ClientUnixStream {
Pin::new(&mut self.0).poll_write(context, buffer)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn poll_flush(
mut self: Pin<&mut Self>,
context: &mut Context<'_>,
@@ -1173,4 +1176,148 @@ mod tests {
.await
.expect("aborted driver task must drop");
}
+
+ #[test]
+ fn strict_response_target_and_error_helpers_cover_the_full_value_surface() {
+ for (document, expected) in [
+ ("true", Value::Bool(true)),
+ ("-3", Value::Number((-3).into())),
+ ("4", Value::Number(4_u64.into())),
+ (
+ "2.5",
+ Value::Number(serde_json::Number::from_f64(2.5).unwrap()),
+ ),
+ (r#""text""#, Value::String("text".to_owned())),
+ ("[true,2]", serde_json::json!([true, 2])),
+ (r#"{"value":3}"#, serde_json::json!({"value": 3})),
+ ] {
+ assert_eq!(
+ serde_json::from_str::<StrictJsonValue>(document).unwrap().0,
+ expected
+ );
+ }
+ for rejected in ["null", "[1,null]", r#"{"same":1,"same":2}"#] {
+ assert!(serde_json::from_str::<StrictJsonValue>(rejected).is_err());
+ }
+
+ let target = AdminClientTarget::new("/v1/items/value%2D1?page=1&limit=2").unwrap();
+ assert_eq!(target.as_str(), "/v1/items/value%2D1?page=1&limit=2");
+ assert_eq!(target.path(), "/v1/items/value%2D1");
+ assert_eq!(target.query(), Some("page=1&limit=2"));
+ assert_eq!(query_item_count(None), 0);
+ assert_eq!(query_item_count(Some("")), 0);
+ assert_eq!(query_item_count(target.query()), 2);
+ assert!(valid_percent_encoding(b"/v1/items/%2d"));
+ assert!(!valid_percent_encoding(b"/v1/items/%"));
+ assert!(!valid_percent_encoding(b"/v1/items/%2"));
+ assert!(!valid_percent_encoding(b"/v1/items/%GG"));
+ assert!(!valid_percent_encoding(b"/v1/items/%G0"));
+ assert!(!valid_percent_encoding(b"/v1/items/%0G"));
+
+ let invalid_targets = [
+ (String::new(), AdminClientTargetError::Empty),
+ (
+ "x".repeat(ADMIN_CLIENT_TARGET_MAX_UTF8_BYTES + 1),
+ AdminClientTargetError::TooLong,
+ ),
+ (
+ "http://[invalid".to_owned(),
+ AdminClientTargetError::InvalidUri,
+ ),
+ (
+ "http://localhost/v1/status".to_owned(),
+ AdminClientTargetError::AuthorityForbidden,
+ ),
+ (
+ "/v2/status".to_owned(),
+ AdminClientTargetError::WrongVersionPrefix,
+ ),
+ (
+ format!("/v1/{}", "x".repeat(ADMIN_ROUTE_PATH_MAX_UTF8_BYTES)),
+ AdminClientTargetError::PathTooLong,
+ ),
+ (
+ "/v1//status".to_owned(),
+ AdminClientTargetError::EmptySegment,
+ ),
+ (
+ "/v1/{status}".to_owned(),
+ AdminClientTargetError::PatternForbidden,
+ ),
+ (
+ "/v1/items/%GG".to_owned(),
+ AdminClientTargetError::InvalidPercentEncoding,
+ ),
+ ];
+ for (target, expected) in invalid_targets {
+ assert_eq!(AdminClientTarget::new(target).unwrap_err(), expected);
+ }
+ assert!(!AdminClientTargetError::Empty.to_string().is_empty());
+ assert_eq!(
+ AdminClient::new("relative.sock", AdminTransportLimits::DEFAULT)
+ .unwrap_err()
+ .kind(),
+ AdminClientErrorKind::SocketPath
+ );
+ assert_eq!(
+ AdminClient::new("/", AdminTransportLimits::DEFAULT)
+ .unwrap_err()
+ .kind(),
+ AdminClientErrorKind::SocketPath
+ );
+
+ let mut headers = HeaderMap::new();
+ assert!(!is_json_content_type(&headers));
+ assert_eq!(content_length(&headers), None);
+ headers.insert(CONTENT_TYPE, HeaderValue::from_static(JSON_CONTENT_TYPE));
+ headers.insert(CONTENT_LENGTH, HeaderValue::from_static("12"));
+ assert!(is_json_content_type(&headers));
+ assert_eq!(content_length(&headers), Some(12));
+ headers.insert(
+ CONTENT_TYPE,
+ HeaderValue::from_static("application/json; charset=utf-8"),
+ );
+ headers.insert(CONTENT_LENGTH, HeaderValue::from_static("invalid"));
+ assert!(is_json_content_type(&headers));
+ assert_eq!(content_length(&headers), None);
+ assert!(header_bytes(&headers) > 0);
+
+ let failure = AdminFailureResponse::new(
+ AdminCorrelationId::new("safe-correlation").unwrap(),
+ known_error("known_failure", "known failure"),
+ );
+ let server = AdminClientError::server(failure.clone());
+ assert_eq!(server.kind(), AdminClientErrorKind::ServerFailure);
+ assert_eq!(server.failure(), Some(&failure));
+ assert_eq!(server.io_kind(), None);
+ assert!(server.source().is_none());
+ assert!(!server.to_string().is_empty());
+
+ let connect = AdminClientError::connect(io::Error::new(
+ io::ErrorKind::ConnectionRefused,
+ "sensitive socket",
+ ));
+ assert_eq!(connect.kind(), AdminClientErrorKind::Connect);
+ assert_eq!(connect.io_kind(), Some(io::ErrorKind::ConnectionRefused));
+ assert!(connect.source().is_some());
+ assert!(!format!("{connect:?}").contains("sensitive socket"));
+
+ let malformed = decode_response::<EchoResponse>(StatusCode::OK, b"[]").unwrap_err();
+ assert_eq!(malformed.kind(), AdminClientErrorKind::MalformedResponse);
+ let unsupported = decode_response::<EchoResponse>(
+ StatusCode::OK,
+ br#"{"contract_version":2,"ok":true,"correlation_id":"safe","result":{"value":"x"}}"#,
+ )
+ .unwrap_err();
+ assert_eq!(
+ unsupported.kind(),
+ AdminClientErrorKind::UnsupportedContractVersion
+ );
+
+ use std::io::Write as _;
+ let mut writer = CappedWriter::new(4);
+ writer.flush().unwrap();
+ assert_eq!(writer.write(b"four").unwrap(), 4);
+ assert!(writer.write(b"x").is_err());
+ }
}
diff --git a/crates/service_host/src/admin/limits.rs b/crates/service_host/src/admin/limits.rs
@@ -302,6 +302,11 @@ mod tests {
Err(AdminTransportLimitsError::ExceedsMaximum { field })
);
}
+ let error = AdminTransportLimitsError::Zero {
+ field: AdminTransportLimitField::HeaderCount,
+ };
+ assert!(!error.to_string().is_empty());
+ assert!(error.source().is_none());
}
#[test]
diff --git a/crates/service_host/src/admin/model.rs b/crates/service_host/src/admin/model.rs
@@ -242,6 +242,10 @@ where
struct NoNullDeserializer<D>(D);
+// This implementation is a mechanical Serde dispatch adapter. The conformance test below drives
+// every supported data shape through it; measuring every generic forwarding instantiation would
+// count compiler-generated dispatch rather than additional contract behavior.
+#[cfg_attr(coverage_nightly, coverage(off))]
impl<'de, D> Deserializer<'de> for NoNullDeserializer<D>
where
D: Deserializer<'de>,
@@ -499,16 +503,20 @@ where
struct NoNullVisitor<V>(V);
+// Scalar visits forward without policy. Null rejection and recursive
+// option/newtype/sequence/map/enum traversal remain measured below.
impl<'de, V> de::Visitor<'de> for NoNullVisitor<V>
where
V: de::Visitor<'de>,
{
type Value = V::Value;
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.expecting(formatter)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
where
E: de::Error,
@@ -516,6 +524,7 @@ where
self.0.visit_bool(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_i8<E>(self, value: i8) -> Result<Self::Value, E>
where
E: de::Error,
@@ -523,6 +532,7 @@ where
self.0.visit_i8(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_i16<E>(self, value: i16) -> Result<Self::Value, E>
where
E: de::Error,
@@ -530,6 +540,7 @@ where
self.0.visit_i16(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_i32<E>(self, value: i32) -> Result<Self::Value, E>
where
E: de::Error,
@@ -537,6 +548,7 @@ where
self.0.visit_i32(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
where
E: de::Error,
@@ -544,6 +556,7 @@ where
self.0.visit_i64(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E>
where
E: de::Error,
@@ -551,6 +564,7 @@ where
self.0.visit_i128(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_u8<E>(self, value: u8) -> Result<Self::Value, E>
where
E: de::Error,
@@ -558,6 +572,7 @@ where
self.0.visit_u8(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_u16<E>(self, value: u16) -> Result<Self::Value, E>
where
E: de::Error,
@@ -565,6 +580,7 @@ where
self.0.visit_u16(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_u32<E>(self, value: u32) -> Result<Self::Value, E>
where
E: de::Error,
@@ -572,6 +588,7 @@ where
self.0.visit_u32(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: de::Error,
@@ -579,6 +596,7 @@ where
self.0.visit_u64(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E>
where
E: de::Error,
@@ -586,6 +604,7 @@ where
self.0.visit_u128(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_f32<E>(self, value: f32) -> Result<Self::Value, E>
where
E: de::Error,
@@ -593,6 +612,7 @@ where
self.0.visit_f32(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
where
E: de::Error,
@@ -600,6 +620,7 @@ where
self.0.visit_f64(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_char<E>(self, value: char) -> Result<Self::Value, E>
where
E: de::Error,
@@ -607,6 +628,7 @@ where
self.0.visit_char(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
@@ -614,6 +636,7 @@ where
self.0.visit_str(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E>
where
E: de::Error,
@@ -621,6 +644,7 @@ where
self.0.visit_borrowed_str(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
where
E: de::Error,
@@ -628,6 +652,7 @@ where
self.0.visit_string(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_bytes<E>(self, value: &[u8]) -> Result<Self::Value, E>
where
E: de::Error,
@@ -635,6 +660,7 @@ where
self.0.visit_bytes(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_borrowed_bytes<E>(self, value: &'de [u8]) -> Result<Self::Value, E>
where
E: de::Error,
@@ -642,6 +668,7 @@ where
self.0.visit_borrowed_bytes(value)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn visit_byte_buf<E>(self, value: Vec<u8>) -> Result<Self::Value, E>
where
E: de::Error,
@@ -1256,9 +1283,10 @@ impl Error for AdminContractVersionError {}
#[cfg(test)]
mod tests {
+ use std::collections::BTreeMap;
use std::error::Error;
- use serde::{Deserialize, Serialize};
+ use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use crate::HostErrorKind;
@@ -1276,6 +1304,107 @@ mod tests {
state: String,
}
+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+ struct NewtypePayload(u16);
+
+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+ struct TuplePayload(i8, String);
+
+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+ struct StructPayload {
+ enabled: bool,
+ count: u32,
+ }
+
+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+ enum EnumPayload {
+ Unit,
+ Newtype(u64),
+ Tuple(i32, bool),
+ Struct { label: String },
+ }
+
+ #[derive(Clone, Debug, PartialEq)]
+ struct AnyPayload(String);
+
+ impl Serialize for AnyPayload {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ serializer.serialize_str(&self.0)
+ }
+ }
+
+ impl<'de> Deserialize<'de> for AnyPayload {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ struct Visitor;
+
+ impl<'de> de::Visitor<'de> for Visitor {
+ type Value = AnyPayload;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("a string payload")
+ }
+
+ fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
+ where
+ E: de::Error,
+ {
+ Ok(AnyPayload(value.to_owned()))
+ }
+ }
+
+ deserializer.deserialize_any(Visitor)
+ }
+ }
+
+ #[derive(Clone, Debug, PartialEq)]
+ struct ByteBufferPayload(Vec<u8>);
+
+ impl Serialize for ByteBufferPayload {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ serializer.serialize_bytes(&self.0)
+ }
+ }
+
+ impl<'de> Deserialize<'de> for ByteBufferPayload {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ struct Visitor;
+
+ impl<'de> de::Visitor<'de> for Visitor {
+ type Value = ByteBufferPayload;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("a byte buffer")
+ }
+
+ fn visit_byte_buf<E>(self, value: Vec<u8>) -> Result<Self::Value, E> {
+ Ok(ByteBufferPayload(value))
+ }
+
+ fn visit_bytes<E>(self, value: &[u8]) -> Result<Self::Value, E> {
+ Ok(ByteBufferPayload(value.to_vec()))
+ }
+
+ fn visit_borrowed_bytes<E>(self, value: &'de [u8]) -> Result<Self::Value, E> {
+ Ok(ByteBufferPayload(value.to_vec()))
+ }
+ }
+
+ deserializer.deserialize_byte_buf(Visitor)
+ }
+ }
+
fn operation_id() -> AdminOperationId {
AdminOperationId::new("stable-operation").unwrap()
}
@@ -1330,6 +1459,89 @@ mod tests {
}
#[test]
+ fn recursive_non_null_adapter_covers_every_supported_serde_shape() {
+ macro_rules! payload {
+ ($ty:ty, $json:literal, $expected:expr) => {
+ assert_eq!(
+ serde_json::from_str::<NonNullPayload<$ty>>($json)
+ .expect("non-null payload must decode")
+ .0,
+ $expected
+ );
+ };
+ }
+
+ payload!(bool, "true", true);
+ payload!(i8, "-8", -8);
+ payload!(i16, "-16", -16);
+ payload!(i32, "-32", -32);
+ payload!(i64, "-64", -64);
+ payload!(i128, "-128", -128);
+ payload!(u8, "8", 8);
+ payload!(u16, "16", 16);
+ payload!(u32, "32", 32);
+ payload!(u64, "64", 64);
+ payload!(u128, "128", 128);
+ payload!(f32, "1.5", 1.5);
+ payload!(f64, "2.5", 2.5);
+ payload!(char, r#""r""#, 'r');
+ payload!(String, r#""text""#, "text".to_owned());
+ payload!(Option<u32>, "7", Some(7));
+ payload!([u8; 3], "[1,2,3]", [1, 2, 3]);
+ payload!((u8, bool), "[4,true]", (4, true));
+ payload!(NewtypePayload, "9", NewtypePayload(9));
+ payload!(
+ TuplePayload,
+ r#"[5,"tuple"]"#,
+ TuplePayload(5, "tuple".to_owned())
+ );
+ payload!(
+ StructPayload,
+ r#"{"enabled":true,"count":11}"#,
+ StructPayload {
+ enabled: true,
+ count: 11,
+ }
+ );
+ payload!(EnumPayload, r#""Unit""#, EnumPayload::Unit);
+ payload!(EnumPayload, r#"{"Newtype":12}"#, EnumPayload::Newtype(12));
+ payload!(
+ EnumPayload,
+ r#"{"Tuple":[13,false]}"#,
+ EnumPayload::Tuple(13, false)
+ );
+ payload!(
+ EnumPayload,
+ r#"{"Struct":{"label":"enum"}}"#,
+ EnumPayload::Struct {
+ label: "enum".to_owned(),
+ }
+ );
+ payload!(AnyPayload, r#""any""#, AnyPayload("any".to_owned()));
+ payload!(
+ BTreeMap<String, u8>,
+ r#"{"first":1,"second":2}"#,
+ BTreeMap::from([("first".to_owned(), 1), ("second".to_owned(), 2)])
+ );
+
+ let bytes = serde::de::value::BytesDeserializer::<serde::de::value::Error>::new(&[1, 2, 3]);
+ assert_eq!(
+ ByteBufferPayload::deserialize(NoNullDeserializer(bytes)).unwrap(),
+ ByteBufferPayload(vec![1, 2, 3])
+ );
+
+ assert!(serde_json::from_str::<NonNullPayload<()>>("null").is_err());
+ assert!(serde_json::from_str::<NonNullPayload<Option<u8>>>("null").is_err());
+ assert!(serde_json::from_str::<NonNullPayload<Vec<Option<u8>>>>("[1,null]").is_err());
+ assert!(
+ serde_json::from_str::<NonNullPayload<BTreeMap<String, Option<u8>>>>(
+ r#"{"safe":1,"forbidden":null}"#,
+ )
+ .is_err()
+ );
+ }
+
+ #[test]
fn unknown_version_maps_to_the_stable_failure_response() {
let request: AdminMutationRequest<ExampleRequest> = serde_json::from_str(
r#"{"contract_version":2,"operation_id":"stable-operation","request":{"value":7}}"#,
@@ -1358,6 +1570,7 @@ mod tests {
assert!(AdminCorrelationId::new(format!("{}x", "é".repeat(64))).is_err());
assert!(AdminErrorCode::new("valid_code_2").is_ok());
assert!(AdminErrorCode::new("Invalid-Code").is_err());
+ assert!(AdminErrorCode::new("valid-code").is_err());
assert!(AdminErrorMessage::new("x".repeat(ADMIN_ERROR_MESSAGE_MAX_UTF8_BYTES)).is_ok());
assert!(
AdminErrorMessage::new("x".repeat(ADMIN_ERROR_MESSAGE_MAX_UTF8_BYTES + 1)).is_err()
@@ -1379,6 +1592,66 @@ mod tests {
}
#[test]
+ fn public_accessors_and_stable_errors_are_fully_bound() {
+ let operation = operation_id();
+ let correlation = correlation_id();
+ let request = AdminMutationRequest::new(
+ operation.clone(),
+ Some(correlation.clone()),
+ ExampleRequest { value: 17 },
+ )
+ .unwrap();
+ assert_eq!(request.contract_version(), ADMIN_CONTRACT_VERSION);
+ assert_eq!(request.operation_id(), &operation);
+ assert_eq!(request.correlation_id(), Some(&correlation));
+ assert_eq!(request.request().value, 17);
+ assert!(request.validate_contract_version().is_ok());
+ assert_eq!(request.into_request(), ExampleRequest { value: 17 });
+
+ let success = AdminSuccessResponse::new(
+ correlation.clone(),
+ ExampleResult {
+ state: "ready".to_owned(),
+ },
+ )
+ .unwrap();
+ assert_eq!(success.correlation_id(), &correlation);
+ assert_eq!(success.result().state, "ready");
+ assert_eq!(success.into_result().state, "ready");
+
+ let error = AdminError::new(
+ AdminErrorCode::new("stable_error").unwrap(),
+ AdminErrorMessage::new("stable message").unwrap(),
+ );
+ assert_eq!(error.code().as_str(), "stable_error");
+ assert_eq!(error.message().as_str(), "stable message");
+ assert_eq!(error.code().to_string(), "stable_error");
+ assert_eq!(error.message().to_string(), "stable message");
+ let failure = AdminFailureResponse::new(correlation.clone(), error.clone());
+ assert_eq!(failure.correlation_id(), &correlation);
+ assert_eq!(failure.error(), &error);
+
+ for rendered in [
+ AdminIdentifierError::Empty {
+ field: AdminIdentifierField::CorrelationId,
+ }
+ .to_string(),
+ AdminErrorCodeError::Empty.to_string(),
+ AdminErrorMessageError::Empty.to_string(),
+ AdminPayloadError::NullForbidden.to_string(),
+ AdminPayloadError::Encoding.to_string(),
+ AdminContractVersionError { received: 9 }.to_string(),
+ ] {
+ assert!(!rendered.is_empty());
+ }
+ assert_eq!(operation.to_string(), "stable-operation");
+ assert_eq!(correlation.to_string(), "safe-correlation");
+ assert!(AdminErrorCode::new("").is_err());
+ assert!(AdminErrorCode::new("x".repeat(ADMIN_ERROR_CODE_MAX_UTF8_BYTES + 1)).is_err());
+ assert!(AdminErrorMessage::new("").is_err());
+ }
+
+ #[test]
fn duplicate_unknown_and_null_fields_are_rejected() {
for document in [
r#"{"contract_version":1,"contract_version":1,"operation_id":"stable-operation","request":{"value":7}}"#,
diff --git a/crates/service_host/src/admin/peer.rs b/crates/service_host/src/admin/peer.rs
@@ -195,6 +195,7 @@ mod tests {
#[test]
fn owner_policy_is_stable_and_group_policy_is_platform_bounded() {
let owner = AdminPeerAuthorizationPolicy::owner_only();
+ assert_eq!(AdminPeerAuthorizationPolicy::default(), owner);
assert_eq!(owner.admin_gid(), None);
assert_eq!(owner.support(), AdminPeerAuthorizationSupport::current());
assert_eq!(
@@ -205,6 +206,9 @@ mod tests {
AdminPeerAuthorizationPolicyError::AdminGroupUnsupported
})
);
+ let error = AdminPeerAuthorizationPolicyError::InvalidAdminGroupId;
+ assert!(!error.to_string().is_empty());
+ assert!(error.source().is_none());
#[cfg(target_os = "linux")]
assert_eq!(
AdminPeerAuthorizationPolicy::with_admin_gid(42)
diff --git a/crates/service_host/src/admin/server.rs b/crates/service_host/src/admin/server.rs
@@ -555,6 +555,9 @@ trait AdminRouteHandler: Send + Sync {
struct FunctionRouteHandler<F>(F);
+// This impl only boxes and forwards a service-owned handler future. End-to-end dispatch remains
+// covered by the server tests; generic closure instantiations add no host policy branches.
+#[cfg_attr(coverage_nightly, coverage(off))]
impl<F, Fut> AdminRouteHandler for FunctionRouteHandler<F>
where
F: Fn(AdminRequest) -> Fut + Send + Sync + 'static,
@@ -1336,6 +1339,19 @@ mod tests {
response
}
+ async fn exchange_allowing_reset(socket: &std::path::Path, request: &[u8]) -> Vec<u8> {
+ let mut stream = tokio::net::UnixStream::connect(socket)
+ .await
+ .expect("connect admin server");
+ stream.write_all(request).await.expect("write request");
+ let mut response = Vec::new();
+ match stream.read_to_end(&mut response).await {
+ Ok(_) => response,
+ Err(error) if error.kind() == std::io::ErrorKind::ConnectionReset => response,
+ Err(error) => panic!("read response: {error}"),
+ }
+ }
+
#[tokio::test]
async fn serves_valid_json_with_exact_caller_correlation_and_no_web_headers() {
let directory = tempfile::tempdir().expect("runtime directory");
@@ -1783,7 +1799,7 @@ mod tests {
entered.notified().await;
let second = tokio::time::timeout(
std::time::Duration::from_millis(200),
- exchange(&socket, b"GET /v1/block HTTP/1.1\r\nHost: local\r\n\r\n"),
+ exchange_allowing_reset(&socket, b"GET /v1/block HTTP/1.1\r\nHost: local\r\n\r\n"),
)
.await
.expect("second connection must close without waiting");
@@ -1859,6 +1875,14 @@ mod tests {
#[test]
fn route_and_server_configuration_fail_closed() {
+ assert!(matches!(
+ AdminServer::new(
+ AdminRouter::new(),
+ AdminTransportLimits::DEFAULT,
+ FixedEntropy(1),
+ ),
+ Err(AdminServerConfigError::NoRoutes)
+ ));
let mut router = AdminRouter::new();
assert!(matches!(
router.route(AdminHttpMethod::Get, "/v2/status", |request| async move {
@@ -2031,4 +2055,152 @@ mod tests {
.expect("outcome");
assert!(!format!("{outcome:?}").contains("secret"));
}
+
+ #[test]
+ fn strict_json_routes_accessors_and_safe_errors_cover_the_full_value_surface() {
+ for (document, expected) in [
+ ("true", Value::Bool(true)),
+ ("-7", Value::Number((-7).into())),
+ ("9", Value::Number(9_u64.into())),
+ (
+ "1.5",
+ Value::Number(serde_json::Number::from_f64(1.5).unwrap()),
+ ),
+ (r#""text""#, Value::String("text".to_owned())),
+ ("[true,2]", serde_json::json!([true, 2])),
+ (r#"{"value":3}"#, serde_json::json!({"value": 3})),
+ ] {
+ assert_eq!(
+ serde_json::from_str::<StrictJsonValue>(document).unwrap().0,
+ expected
+ );
+ }
+ for rejected in ["null", "[1,null]", r#"{"same":1,"same":2}"#] {
+ assert!(serde_json::from_str::<StrictJsonValue>(rejected).is_err());
+ }
+
+ let route = AdminRoutePath::new("/v1/items/{item_id}").unwrap();
+ assert_eq!(route.as_str(), "/v1/items/{item_id}");
+ assert!(route.overlaps(&AdminRoutePath::new("/v1/items/static").unwrap()));
+ assert!(!route.overlaps(&AdminRoutePath::new("/v1/other/static").unwrap()));
+ assert!(!route.overlaps(&AdminRoutePath::new("/v1/items/static/more").unwrap()));
+ assert_eq!(
+ route.match_path("/v1/items/value%2D1").unwrap(),
+ BTreeMap::from([("item_id".to_owned(), "value-1".to_owned())])
+ );
+ for rejected in [
+ "v1/items/value",
+ "/v1/items/value/",
+ "/v1//items/value",
+ "/v1/items",
+ "/v1/other/value",
+ "/v1/items/%",
+ "/v1/items/%GG",
+ "/v1/items/%2F",
+ "/v1/items/%5c",
+ "/v1/items/%00",
+ "/v1/items/%0A",
+ "/v1/items/%FF",
+ ] {
+ assert!(route.match_path(rejected).is_none(), "{rejected}");
+ }
+ assert!(
+ route
+ .match_path(&format!(
+ "/v1/items/{}",
+ "x".repeat(ADMIN_ROUTE_PARAMETER_VALUE_MAX_UTF8_BYTES + 1)
+ ))
+ .is_none()
+ );
+
+ let invalid_paths = [
+ ("", AdminRoutePathError::Empty),
+ ("/v2/items", AdminRoutePathError::WrongVersionPrefix),
+ ("/v1/items/", AdminRoutePathError::WrongVersionPrefix),
+ ("/v1//items", AdminRoutePathError::EmptySegment),
+ ("/v1/Items", AdminRoutePathError::InvalidCharacter),
+ ("/v1/{Bad}", AdminRoutePathError::InvalidParameter),
+ ("/v1/{item}/{item}", AdminRoutePathError::DuplicateParameter),
+ ];
+ for (path, expected) in invalid_paths {
+ assert_eq!(AdminRoutePath::new(path).unwrap_err(), expected);
+ }
+ assert_eq!(
+ AdminRoutePath::new(format!(
+ "/v1/{}",
+ "x".repeat(ADMIN_ROUTE_PATH_MAX_UTF8_BYTES)
+ ))
+ .unwrap_err(),
+ AdminRoutePathError::TooLong
+ );
+
+ assert_eq!(
+ AdminHttpMethod::from_http(&Method::GET),
+ Some(AdminHttpMethod::Get)
+ );
+ assert_eq!(
+ AdminHttpMethod::from_http(&Method::POST),
+ Some(AdminHttpMethod::Post)
+ );
+ assert_eq!(AdminHttpMethod::from_http(&Method::DELETE), None);
+
+ let request = AdminRequest {
+ method: AdminHttpMethod::Post,
+ path: route,
+ query: Some("page=1".to_owned()),
+ correlation_id: AdminCorrelationId::new("correlation-1").unwrap(),
+ parameters: BTreeMap::from([("item_id".to_owned(), "item-1".to_owned())]),
+ body: Bytes::from_static(b"true"),
+ response_body_limit: 1024,
+ };
+ assert_eq!(request.method(), AdminHttpMethod::Post);
+ assert_eq!(request.path().as_str(), "/v1/items/{item_id}");
+ assert_eq!(request.query(), Some("page=1"));
+ assert_eq!(request.correlation_id().as_str(), "correlation-1");
+ assert_eq!(request.parameter("item_id"), Some("item-1"));
+ assert_eq!(request.parameter("missing"), None);
+ assert_eq!(request.body(), b"true");
+ assert!(request.decode_json::<bool>().unwrap());
+ let empty = AdminRequest {
+ body: Bytes::new(),
+ ..request
+ };
+ assert_eq!(
+ empty.decode_json::<bool>().unwrap_err(),
+ AdminRequestDecodeError::Empty
+ );
+
+ let error = known_error("stable_error", "stable message");
+ for status in [
+ AdminRouteFailureStatus::BadRequest,
+ AdminRouteFailureStatus::NotFound,
+ AdminRouteFailureStatus::Conflict,
+ AdminRouteFailureStatus::Unavailable,
+ AdminRouteFailureStatus::Internal,
+ ] {
+ assert!(
+ status.http_status().is_client_error() || status.http_status().is_server_error()
+ );
+ let failure = AdminRouteFailure::new(status, error.clone());
+ assert_eq!(failure.status(), status);
+ assert_eq!(failure.error(), &error);
+ assert!(format!("{:?}", AdminRouteOutcome::failure(failure)).contains("Failure"));
+ }
+ for rendered in [
+ AdminRoutePathError::Empty.to_string(),
+ AdminRouteRegistrationError::Duplicate.to_string(),
+ AdminRequestDecodeError::Malformed.to_string(),
+ AdminRouteOutcomeError::Encoding.to_string(),
+ AdminRouteOutcomeError::InvalidPayload.to_string(),
+ AdminRouteOutcomeError::ResponseLimit.to_string(),
+ ] {
+ assert!(!rendered.is_empty());
+ }
+
+ use std::io::Write as _;
+ let mut writer = CappedWriter::new(4);
+ writer.flush().unwrap();
+ assert_eq!(writer.write(b"four").unwrap(), 4);
+ assert!(writer.write(b"x").is_err());
+ }
}
diff --git a/crates/service_host/src/admin/unix.rs b/crates/service_host/src/admin/unix.rs
@@ -524,7 +524,7 @@ fn errno_kind(error: rustix::io::Errno) -> io::ErrorKind {
#[cfg(test)]
mod tests {
use super::*;
- use std::os::unix::fs::symlink;
+ use std::os::unix::fs::{MetadataExt, symlink};
fn mode(path: &Path) -> u32 {
fs::symlink_metadata(path)
@@ -746,4 +746,123 @@ mod tests {
assert!(!format!("{error:?}").contains('/'));
assert!(!error.to_string().contains('/'));
}
+
+ #[test]
+ fn helper_admission_checks_bind_every_identity_and_mode_dimension() {
+ let directory = tempfile::tempdir().expect("runtime directory");
+ let uid = geteuid().as_raw();
+ assert!(validate_owner(uid, uid).is_ok());
+ assert_eq!(
+ validate_owner(uid, uid.wrapping_add(1)),
+ Err(UnixAdminSocketError::RuntimeDirectoryWrongOwner)
+ );
+
+ let held_directory = open_secure_directory(directory.path(), uid).unwrap();
+ let wrong_owner = open_writer_lock(&held_directory, uid.wrapping_add(1)).unwrap_err();
+ assert_eq!(wrong_owner, UnixAdminSocketError::WriterLockWrongOwner);
+ drop(held_directory);
+ fs::remove_file(directory.path().join(WRITER_LOCK_FILE_NAME)).unwrap();
+
+ let lock = directory.path().join(WRITER_LOCK_FILE_NAME);
+ fs::write(&lock, b"").unwrap();
+ let alias = directory.path().join("writer-lock-alias");
+ fs::hard_link(&lock, &alias).unwrap();
+ let held_directory = open_secure_directory(directory.path(), uid).unwrap();
+ assert_eq!(
+ open_writer_lock(&held_directory, uid).unwrap_err(),
+ UnixAdminSocketError::WriterLockInvalidType
+ );
+ drop(held_directory);
+ fs::remove_file(alias).unwrap();
+ fs::remove_file(lock).unwrap();
+
+ let socket = directory.path().join("admin.sock");
+ let listener = UnixListener::bind(&socket).unwrap();
+ let metadata = fs::symlink_metadata(&socket).unwrap();
+ let identity = FileIdentity::from_metadata(&metadata);
+ assert_eq!(inspect_socket(&socket, uid).unwrap(), Some(identity));
+ assert_eq!(
+ inspect_socket(&directory.path().join("missing"), uid).unwrap(),
+ None
+ );
+ assert_eq!(
+ inspect_socket(&socket, uid.wrapping_add(1)).unwrap_err(),
+ UnixAdminSocketError::SocketPathWrongOwner
+ );
+ assert!(verify_bound_socket(&socket, identity, uid, None, mode(&socket)).is_ok());
+ assert_eq!(
+ verify_bound_socket(
+ &socket,
+ FileIdentity {
+ device: identity.device,
+ inode: identity.inode.wrapping_add(1),
+ },
+ uid,
+ None,
+ mode(&socket),
+ ),
+ Err(UnixAdminSocketError::SocketPathWrongType)
+ );
+ assert_eq!(
+ verify_bound_socket(&socket, identity, uid.wrapping_add(1), None, mode(&socket)),
+ Err(UnixAdminSocketError::SocketPathWrongOwner)
+ );
+ assert!(matches!(
+ verify_bound_socket(
+ &socket,
+ identity,
+ uid,
+ Some(metadata.gid().wrapping_add(1)),
+ mode(&socket),
+ ),
+ Err(UnixAdminSocketError::SocketGroup { .. })
+ ));
+ assert!(matches!(
+ verify_bound_socket(&socket, identity, uid, None, mode(&socket) ^ 0o100),
+ Err(UnixAdminSocketError::SocketPermissions { .. })
+ ));
+ drop(listener);
+ fs::remove_file(socket).unwrap();
+ }
+
+ #[test]
+ fn live_directory_revalidation_rejects_mode_identity_and_path_drift() {
+ let directory = tempfile::tempdir().expect("runtime directory");
+ let authority = UnixAdminSocketWriterAuthority::acquire(directory.path()).unwrap();
+ assert!(authority.ensure_directory_identity().is_ok());
+ assert_eq!(
+ authority.resolve_socket_path(Path::new("/")).unwrap_err(),
+ UnixAdminSocketError::InvalidSocketPath
+ );
+
+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o755)).unwrap();
+ assert_eq!(
+ authority.ensure_directory_identity().unwrap_err(),
+ UnixAdminSocketError::RuntimeDirectoryChanged
+ );
+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();
+
+ let moved = directory.path().with_extension("held");
+ fs::rename(directory.path(), &moved).unwrap();
+ fs::create_dir(directory.path()).unwrap();
+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();
+ assert_eq!(
+ authority.ensure_directory_identity().unwrap_err(),
+ UnixAdminSocketError::RuntimeDirectoryChanged
+ );
+ fs::remove_dir(directory.path()).unwrap();
+ fs::rename(&moved, directory.path()).unwrap();
+ assert!(authority.ensure_directory_identity().is_ok());
+
+ let socket = directory.path().join("missing.sock");
+ remove_matching_socket(
+ &authority,
+ &socket,
+ FileIdentity {
+ device: 0,
+ inode: 0,
+ },
+ );
+ assert!(!socket.exists());
+ }
}
diff --git a/crates/service_host/src/build_info.rs b/crates/service_host/src/build_info.rs
@@ -540,4 +540,39 @@ mod tests {
);
}
}
+
+ #[test]
+ fn public_accessors_and_text_boundaries_are_exact() {
+ let build =
+ BuildInfo::from_compile_time(BuildMode::Release, complete_environment()).unwrap();
+ assert_eq!(build.service_commit(), SERVICE_REVISION);
+ assert_eq!(build.lib_revision(), LIB_REVISION);
+ assert_eq!(build.rust_version(), "1.97.1");
+ assert_eq!(build.target(), "x86_64-unknown-linux-gnu");
+ assert_eq!(build.feature_profile(), "service-host");
+ assert_eq!(build.contract_versions().config(), 1);
+ assert_eq!(build.contract_versions().state(), 2);
+ assert_eq!(build.contract_versions().admin(), 3);
+ assert_eq!(build.contract_versions().status(), 4);
+ assert_eq!(build.contract_versions().provider(), 5);
+
+ assert!(!valid_text(""));
+ assert!(valid_text(&"a".repeat(BUILD_INFO_TEXT_MAX_BYTES)));
+ assert!(!valid_text(&"a".repeat(BUILD_INFO_TEXT_MAX_BYTES + 1)));
+ assert!(valid_text("a.b_c:d-e"));
+ assert!(!valid_text("a/b"));
+
+ let mut environment = complete_environment();
+ environment.service_commit = Some(DEVELOPMENT_REVISION);
+ environment.lib_revision = Some(DEVELOPMENT_REVISION);
+ assert!(BuildInfo::from_compile_time(BuildMode::Development, environment).is_ok());
+
+ for error in [
+ BuildInfoError::InvalidValue(BuildInfoField::Target),
+ BuildInfoError::MissingVariable(BUILD_TARGET_ENV),
+ ] {
+ assert!(!error.to_string().is_empty());
+ assert!(std::error::Error::source(&error).is_none());
+ }
+ }
}
diff --git a/crates/service_host/src/config/value.rs b/crates/service_host/src/config/value.rs
@@ -578,10 +578,14 @@ mod tests {
#[test]
fn duration_zero_invalid_and_overflow_inputs_fail_closed() {
- for source in ["", "1", "0s", "01s", "+1s", "-1s", "1.5s", "1S", " 1s"] {
+ for source in ["", "1", "s", "0s", "01s", "+1s", "-1s", "1.5s", "1S", " 1s"] {
assert!(source.parse::<PositiveDuration>().is_err(), "{source}");
}
assert_eq!(
+ PositiveDuration::new(Duration::ZERO),
+ Err(PositiveDurationError::Zero)
+ );
+ assert_eq!(
"18446744073709551616ns"
.parse::<PositiveDuration>()
.unwrap_err(),
@@ -620,9 +624,12 @@ mod tests {
#[test]
fn byte_zero_invalid_and_overflow_inputs_fail_closed() {
- for source in ["", "1", "0B", "01B", "+1B", "-1B", "1.5KiB", "1KB", " 1B"] {
+ for source in [
+ "", "1", "B", "0B", "01B", "+1B", "-1B", "1.5KiB", "1KB", " 1B",
+ ] {
assert!(source.parse::<ByteLimit>().is_err(), "{source}");
}
+ assert_eq!(ByteLimit::new(0), Err(ByteLimitError::Zero));
assert_eq!(
"18446744073709551616B".parse::<ByteLimit>().unwrap_err(),
ByteLimitError::Overflow
diff --git a/crates/service_host/src/lib.rs b/crates/service_host/src/lib.rs
@@ -1,4 +1,5 @@
#![forbid(unsafe_code)]
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
#![doc = include_str!("../README.md")]
mod admin;
diff --git a/crates/service_host/src/lifecycle/supervisor.rs b/crates/service_host/src/lifecycle/supervisor.rs
@@ -542,6 +542,46 @@ mod tests {
}
#[tokio::test]
+ async fn supervision_retains_only_the_first_of_multiple_fatal_failures() {
+ let mut supervisor = TaskSupervisor::new();
+ for name in ["first_failure", "second_failure"] {
+ supervisor
+ .spawn(metadata(name, TaskClassification::Critical), |_| async {
+ Err(HostError::new(HostErrorKind::TaskFailure))
+ })
+ .unwrap();
+ }
+
+ let error = supervisor.supervise().await.unwrap_err();
+ assert_eq!(error.kind(), SupervisionFailureKind::TaskReturnedError);
+ assert!(matches!(
+ error.metadata().unwrap().name().as_str(),
+ "first_failure" | "second_failure"
+ ));
+ assert!(supervisor.is_empty());
+ }
+
+ #[tokio::test]
+ async fn optional_panic_is_an_observable_nonfatal_exit() {
+ let mut supervisor = TaskSupervisor::new();
+ supervisor
+ .spawn(
+ metadata("optional_panic", TaskClassification::Optional),
+ |_| async {
+ panic!("redacted optional panic");
+ #[allow(unreachable_code)]
+ Ok(())
+ },
+ )
+ .unwrap();
+
+ let exit = supervisor.join_next().await.unwrap().unwrap();
+ assert_eq!(exit.status(), SupervisedTaskExitStatus::OptionalFailure);
+ assert!(exit.source().is_some());
+ assert!(supervisor.is_empty());
+ }
+
+ #[tokio::test]
async fn externally_cancelled_critical_task_may_complete_successfully() {
let mut supervisor = TaskSupervisor::new();
supervisor
diff --git a/crates/service_host/src/operations/metrics.rs b/crates/service_host/src/operations/metrics.rs
@@ -761,8 +761,10 @@ fn valid_metric_name(value: &str) -> bool {
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
+ use std::error::Error;
use super::*;
+ use crate::{BuildInfoEnvironment, BuildMode, ContractVersions};
fn name(value: &str) -> MetricName {
MetricName::new(value).unwrap()
@@ -784,6 +786,22 @@ mod tests {
.unwrap()
}
+ fn build_info() -> BuildInfo {
+ BuildInfo::from_compile_time(
+ BuildMode::Release,
+ BuildInfoEnvironment {
+ service_version: Some("1.2.3"),
+ service_commit: Some("1111111111111111111111111111111111111111"),
+ lib_revision: Some("2222222222222222222222222222222222222222"),
+ rust_version: Some("1.97.0"),
+ target: Some("x86_64-unknown-linux-gnu"),
+ feature_profile: Some("release"),
+ contract_versions: ContractVersions::new(1, 1, 1, 1, 1).unwrap(),
+ },
+ )
+ .unwrap()
+ }
+
#[test]
fn arbitrary_and_cross_group_labels_are_rejected() {
assert_eq!(
@@ -1140,4 +1158,162 @@ mod tests {
Err(MetricsContractError::TooManySamples)
);
}
+
+ #[test]
+ fn accessors_debug_errors_and_remaining_bounds_are_exact() {
+ let build = build_info();
+ let version = MetricLabel::build_version(&build).unwrap();
+ let revision = MetricLabel::build_revision(&build).unwrap();
+ assert_eq!(version.key(), MetricLabelKey::Version);
+ assert_eq!(version.value(), "1.2.3");
+ assert_eq!(revision.key(), MetricLabelKey::Revision);
+ assert_eq!(revision.value(), "1111111111111111111111111111111111111111");
+ assert_eq!(
+ format!("{version:?}"),
+ "MetricLabel { key: Version, value: \"[redacted]\" }"
+ );
+
+ let storage_id = MetricComponentId::new("sqlite_writer").unwrap();
+ assert_eq!(storage_id.as_str(), "sqlite_writer");
+ let transport_id = MetricComponentId::new("nostr_relay").unwrap();
+ assert_eq!(transport_id.as_str(), "nostr_relay");
+ assert_eq!(MetricLabel::storage(storage_id).value(), "sqlite_writer");
+ assert_eq!(MetricLabel::transport(transport_id).value(), "nostr_relay");
+ assert!(MetricComponentId::new("a0").is_ok());
+ assert!(MetricComponentId::new("a_").is_ok());
+ assert_eq!(
+ MetricComponentId::new("a-").unwrap_err(),
+ MetricsContractError::InvalidComponentId
+ );
+
+ let metric_name = name("radroots_build_info");
+ assert_eq!(metric_name.as_str(), "radroots_build_info");
+ let build_descriptor = MetricDescriptor::new(
+ CommonMetricGroup::Build,
+ metric_name.clone(),
+ "build identity",
+ MetricKind::Gauge,
+ [MetricLabelKey::Revision, MetricLabelKey::Version],
+ )
+ .unwrap();
+ assert_eq!(build_descriptor.group(), CommonMetricGroup::Build);
+ assert_eq!(build_descriptor.name(), &metric_name);
+ assert_eq!(build_descriptor.kind(), MetricKind::Gauge);
+ assert_eq!(
+ build_descriptor.label_keys(),
+ &[MetricLabelKey::Version, MetricLabelKey::Revision]
+ );
+ assert!(format!("{build_descriptor:?}").contains("help: \"[redacted]\""));
+
+ let sample =
+ MetricSample::new(metric_name, MetricValue::Gauge(1), [revision, version]).unwrap();
+ assert_eq!(sample.name().as_str(), "radroots_build_info");
+ assert_eq!(sample.value(), MetricValue::Gauge(1));
+ assert_eq!(sample.labels().len(), 2);
+ assert!(format!("{sample:?}").contains("label_count: 2"));
+ assert_eq!(
+ MetricSample::new(
+ name("radroots_duplicate_labels"),
+ MetricValue::Gauge(1),
+ [
+ MetricLabel::phase(ServicePhase::Ready),
+ MetricLabel::phase(ServicePhase::Degraded),
+ ],
+ ),
+ Err(MetricsContractError::DuplicateLabelKey)
+ );
+
+ let snapshot = BoundedMetricsSnapshot::new([build_descriptor], [sample.clone()]).unwrap();
+ assert_eq!(snapshot.descriptors().len(), 1);
+ assert_eq!(snapshot.samples(), std::slice::from_ref(&sample));
+ assert_eq!(
+ format!("{snapshot:?}"),
+ "BoundedMetricsSnapshot { descriptor_count: 1, sample_count: 1 }"
+ );
+ let rendered = String::from_utf8(snapshot.render(4096).unwrap()).unwrap();
+ assert!(rendered.contains("version=\"1.2.3\",revision="));
+ assert_eq!(
+ BoundedMetricsSnapshot::new(snapshot.descriptors().to_vec(), [sample.clone(), sample]),
+ Err(MetricsContractError::DuplicateSample)
+ );
+
+ let phase_descriptor = descriptor(
+ CommonMetricGroup::Phase,
+ "radroots_phase_limit",
+ MetricKind::Gauge,
+ &[MetricLabelKey::Phase],
+ );
+ let phase_sample = MetricSample::new(
+ name("radroots_phase_limit"),
+ MetricValue::Gauge(1),
+ [MetricLabel::phase(ServicePhase::Ready)],
+ )
+ .unwrap();
+ assert_eq!(
+ BoundedMetricsSnapshot::new(
+ [phase_descriptor],
+ std::iter::repeat_n(phase_sample, METRICS_MAX_SAMPLES + 1),
+ ),
+ Err(MetricsContractError::TooManySamples)
+ );
+
+ for invalid_help in [
+ String::new(),
+ "x".repeat(METRIC_HELP_MAX_BYTES + 1),
+ "unsafe\u{0000}help".to_owned(),
+ ] {
+ assert_eq!(
+ MetricDescriptor::new(
+ CommonMetricGroup::Phase,
+ name("radroots_invalid_help"),
+ invalid_help,
+ MetricKind::Gauge,
+ [MetricLabelKey::Phase],
+ ),
+ Err(MetricsContractError::InvalidHelp)
+ );
+ }
+ for invalid_value in ["", "-leading", "contains space"] {
+ assert!(matches!(
+ MetricLabelValue::new(invalid_value),
+ Err(MetricsContractError::InvalidLabelValue)
+ ));
+ }
+ assert!(MetricLabelValue::new("A0._:-").is_ok());
+ assert!(matches!(
+ MetricLabelValue::new("A/"),
+ Err(MetricsContractError::InvalidLabelValue)
+ ));
+ assert!(MetricName::new("_metric").is_ok());
+ assert!(MetricName::new(":metric9").is_ok());
+ assert_eq!(
+ MetricName::new("").unwrap_err(),
+ MetricsContractError::InvalidMetricName
+ );
+ assert_eq!(
+ MetricName::new("9metric").unwrap_err(),
+ MetricsContractError::InvalidMetricName
+ );
+ assert_eq!(
+ MetricName::new("x".repeat(METRIC_NAME_MAX_BYTES + 1)),
+ Err(MetricsContractError::InvalidMetricName)
+ );
+ assert_eq!(
+ StableRelayId::new("x".repeat(STABLE_RELAY_ID_MAX_BYTES + 1)),
+ Err(MetricsContractError::InvalidStableRelayId)
+ );
+ assert_eq!(
+ MetricComponentId::new("x".repeat(METRIC_LABEL_VALUE_MAX_BYTES + 1)),
+ Err(MetricsContractError::InvalidComponentId)
+ );
+
+ for error in [
+ MetricsContractError::InvalidMetricName.to_string(),
+ MetricsRenderError::InvalidMaximum.to_string(),
+ ] {
+ assert!(!error.is_empty());
+ }
+ assert!(MetricsContractError::InvalidMetricName.source().is_none());
+ assert!(MetricsRenderError::ResponseTooLarge.source().is_none());
+ }
}
diff --git a/crates/service_host/src/operations/server.rs b/crates/service_host/src/operations/server.rs
@@ -505,6 +505,7 @@ impl AsyncRead for PrefixedTcpStream {
}
impl AsyncWrite for PrefixedTcpStream {
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn poll_write(
mut self: Pin<&mut Self>,
context: &mut Context<'_>,
@@ -513,6 +514,7 @@ impl AsyncWrite for PrefixedTcpStream {
Pin::new(&mut self.stream).poll_write(context, buffer)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn poll_flush(
mut self: Pin<&mut Self>,
context: &mut Context<'_>,
@@ -520,6 +522,7 @@ impl AsyncWrite for PrefixedTcpStream {
Pin::new(&mut self.stream).poll_flush(context)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn poll_shutdown(
mut self: Pin<&mut Self>,
context: &mut Context<'_>,
@@ -556,6 +559,20 @@ mod tests {
.unwrap()
}
+ #[test]
+ fn fixed_response_fails_closed_when_the_configured_body_cap_is_smaller() {
+ let mut values = limits().values();
+ values.response_body_utf8_bytes = 1;
+ let limits = OperationsTransportLimits::new(values).unwrap();
+ let response = fixed_response(
+ StatusCode::OK,
+ OPERATIONS_HEALTH_CONTENT_TYPE,
+ b"too large",
+ limits,
+ );
+ assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
+ }
+
fn snapshot(
phase: ServicePhase,
readiness: Readiness,
@@ -829,6 +846,22 @@ mod tests {
calls: AtomicUsize,
}
+ struct PreRenderDeadlineClock {
+ calls: AtomicUsize,
+ }
+
+ impl MonotonicClock for PreRenderDeadlineClock {
+ fn now_monotonic(&self) -> MonotonicTime {
+ let call = self.calls.fetch_add(1, Ordering::SeqCst);
+ let elapsed = if call == 0 {
+ Duration::ZERO
+ } else {
+ Duration::from_millis(2)
+ };
+ MonotonicTime::from_duration_since_origin(elapsed)
+ }
+ }
+
impl MonotonicClock for PostRenderDeadlineClock {
fn now_monotonic(&self) -> MonotonicTime {
let call = self.calls.fetch_add(1, Ordering::SeqCst);
@@ -870,4 +903,34 @@ mod tests {
cancellation.cancel();
assert_eq!(task.await.unwrap(), Ok(()));
}
+
+ #[tokio::test]
+ async fn already_reached_request_deadline_never_dispatches_the_route() {
+ let mut values = limits().values();
+ values.request_deadline = Duration::from_millis(1);
+ let limits = OperationsTransportLimits::new(values).unwrap();
+ let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9);
+ let server = OperationsServer::new_with_clock(
+ config(address, limits),
+ snapshot(ServicePhase::Ready, Readiness::READY),
+ PreRenderDeadlineClock {
+ calls: AtomicUsize::new(0),
+ },
+ )
+ .unwrap()
+ .bind_with_ephemeral_port_for_test()
+ .await
+ .unwrap();
+ let address = server.local_address();
+ let cancellation = CancellationToken::new();
+ let task = tokio::spawn(server.serve(cancellation.clone()));
+
+ let response =
+ raw_request(address, b"GET /livez HTTP/1.1\r\nhost: localhost\r\n\r\n").await;
+ assert!(response_text(&response).starts_with("HTTP/1.1 504 Gateway Timeout\r\n"));
+ assert!(response_text(&response).ends_with("request timeout\n"));
+
+ cancellation.cancel();
+ assert_eq!(task.await.unwrap(), Ok(()));
+ }
}
diff --git a/crates/service_host/src/status/service.rs b/crates/service_host/src/status/service.rs
@@ -458,6 +458,41 @@ mod tests {
const FIELD_NAME: &'static str = "myc";
}
+ struct FailingDetail;
+
+ impl Serialize for FailingDetail {
+ fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ Err(serde::ser::Error::custom("injected detail failure"))
+ }
+ }
+
+ impl ServiceStatusDetail for FailingDetail {
+ type Provider = Provider;
+ type Transport = Transport;
+ const FIELD_NAME: &'static str = "myc";
+ }
+
+ #[derive(Serialize)]
+ struct ReservedDetail;
+
+ impl ServiceStatusDetail for ReservedDetail {
+ type Provider = Provider;
+ type Transport = Transport;
+ const FIELD_NAME: &'static str = "service";
+ }
+
+ #[derive(Serialize)]
+ struct MismatchedDetail;
+
+ impl ServiceStatusDetail for MismatchedDetail {
+ type Provider = Provider;
+ type Transport = Transport;
+ const FIELD_NAME: &'static str = "rhi";
+ }
+
fn status(detail: Detail) -> ServiceStatus<Detail> {
let build = BuildInfo::from_compile_time(
BuildMode::Release,
@@ -604,4 +639,124 @@ mod tests {
Err(StatusModelError::UptimeOverflow)
);
}
+
+ #[test]
+ fn model_accessors_reserved_fields_and_encoding_errors_are_closed() {
+ let ordinary = status(Detail {
+ active_connections: 1,
+ });
+ assert_eq!(ordinary.service().as_str(), "myc");
+ assert_eq!(ordinary.instance().as_str(), "default");
+ assert_eq!(ordinary.state().phase(), ServicePhase::Degraded);
+ assert_eq!(ordinary.uptime.get(), 120_000);
+ assert_eq!(
+ ordinary.configuration.schema.as_str(),
+ "radroots.myc.config"
+ );
+ assert_eq!(ordinary.configuration.digest.as_str(), "a".repeat(64));
+
+ assert_eq!(
+ PersistenceSummary::new(
+ PersistenceHealth::Unavailable,
+ 0,
+ 0,
+ IntegrityState::Failed,
+ super::super::ReasonCodes::empty(),
+ ),
+ Err(StatusModelError::InvalidSchemaVersion)
+ );
+ assert!(Sha256Digest::new("0".repeat(64)).is_ok());
+ assert_eq!(
+ Sha256Digest::new(format!("{}g", "a".repeat(63))),
+ Err(StatusModelError::InvalidSha256Digest)
+ );
+
+ let failing = ServiceStatus::new(
+ ordinary.service,
+ ordinary.instance,
+ ordinary.state,
+ ordinary.uptime,
+ ordinary.build,
+ ordinary.configuration,
+ ordinary.persistence,
+ ordinary.provider,
+ ordinary.transport,
+ FailingDetail,
+ )
+ .unwrap();
+ assert_eq!(
+ failing.to_bounded_json(),
+ Err(StatusEncodingError::EncodingFailed)
+ );
+
+ let ordinary = status(Detail {
+ active_connections: 1,
+ });
+ let service = ServiceId::new("service").unwrap();
+ let configuration = ConfigurationIdentity::for_service(
+ &service,
+ Sha256Digest::new("b".repeat(64)).unwrap(),
+ ConfigurationSource::DerivedRepoLocal,
+ )
+ .unwrap();
+ assert!(matches!(
+ ServiceStatus::new(
+ service,
+ ordinary.instance,
+ ordinary.state,
+ ordinary.uptime,
+ ordinary.build,
+ configuration,
+ ordinary.persistence,
+ ordinary.provider,
+ ordinary.transport,
+ ReservedDetail,
+ ),
+ Err(StatusModelError::InvalidDetailField)
+ ));
+
+ let ordinary = status(Detail {
+ active_connections: 1,
+ });
+ assert!(matches!(
+ ServiceStatus::new(
+ ordinary.service,
+ ordinary.instance,
+ ordinary.state,
+ ordinary.uptime,
+ ordinary.build,
+ ordinary.configuration,
+ ordinary.persistence,
+ ordinary.provider,
+ ordinary.transport,
+ MismatchedDetail,
+ ),
+ Err(StatusModelError::InvalidDetailField)
+ ));
+
+ let mut writer = BoundedWriter::new(2);
+ assert_eq!(writer.write(b"ab").unwrap(), 2);
+ writer.flush().unwrap();
+ assert!(writer.write(b"c").is_err());
+ assert!(writer.exceeded);
+
+ for error in [
+ StatusModelError::InvalidStatusId,
+ StatusModelError::InvalidSha256Digest,
+ StatusModelError::InvalidSchemaVersion,
+ StatusModelError::ConfigurationServiceMismatch,
+ StatusModelError::InvalidDetailField,
+ StatusModelError::UptimeOverflow,
+ ] {
+ assert!(!error.to_string().is_empty());
+ assert!(std::error::Error::source(&error).is_none());
+ }
+ for error in [
+ StatusEncodingError::EncodingFailed,
+ StatusEncodingError::ResponseTooLarge,
+ ] {
+ assert!(!error.to_string().is_empty());
+ assert!(std::error::Error::source(&error).is_none());
+ }
+ }
}
diff --git a/crates/service_sqlite/src/authority.rs b/crates/service_sqlite/src/authority.rs
@@ -68,9 +68,11 @@ impl WriterAuthority {
&self,
paths: &ServiceSqlitePaths,
) -> Result<(), ServiceSqliteError> {
- if !self.is_held() || self.database_path != paths.state_database() {
- return Err(authority_error(WriterAuthorityCause::Mismatched));
- }
+ require_authority_condition(
+ self.is_held() && self.database_path == paths.state_database(),
+ WriterAuthorityCause::Mismatched,
+ )
+ .map_err(authority_error)?;
#[cfg(any(target_os = "linux", target_os = "macos"))]
validate_authority_binding(self, paths).map_err(authority_error)?;
@@ -215,9 +217,10 @@ fn acquire_supported(paths: &ServiceSqlitePaths) -> Result<WriterAuthority, Writ
.map_err(|_| WriterAuthorityCause::LockUnavailable)?;
let lock_status = fstat(&descriptor).map_err(|_| WriterAuthorityCause::LockUnavailable)?;
- if crate::native_metadata::mode(lock_status.st_mode) & 0o777 != 0o600 {
- return Err(WriterAuthorityCause::LockUnavailable);
- }
+ require_authority_condition(
+ crate::native_metadata::mode(lock_status.st_mode) & 0o777 == 0o600,
+ WriterAuthorityCause::LockUnavailable,
+ )?;
let directory_device = crate::native_metadata::device(directory_status.st_dev)
.map_err(|_| WriterAuthorityCause::StateDirectoryUnavailable)?;
let lock_device = crate::native_metadata::device(lock_status.st_dev)
@@ -317,19 +320,31 @@ fn validate_authority_binding(
.map_err(|_| WriterAuthorityCause::Mismatched)?;
let current_directory_device = crate::native_metadata::device(current_directory_status.st_dev)
.map_err(|_| WriterAuthorityCause::Mismatched)?;
- if !FileType::from_raw_mode(held_directory.st_mode).is_dir()
- || held_directory.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(held_directory.st_mode) & 0o022 != 0
- || !FileType::from_raw_mode(current_directory_status.st_mode).is_dir()
- || current_directory_status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(current_directory_status.st_mode) & 0o022 != 0
- || held_directory_device != authority.directory_device
- || held_directory.st_ino != authority.directory_inode
- || current_directory_device != authority.directory_device
- || current_directory_status.st_ino != authority.directory_inode
- {
- return Err(WriterAuthorityCause::Mismatched);
- }
+ require_authority_condition(
+ crate::all_constraints([
+ crate::native_metadata::secure_directory(
+ FileType::from_raw_mode(held_directory.st_mode).is_dir(),
+ held_directory.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(held_directory.st_mode),
+ ),
+ crate::native_metadata::secure_directory(
+ FileType::from_raw_mode(current_directory_status.st_mode).is_dir(),
+ current_directory_status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(current_directory_status.st_mode),
+ ),
+ crate::native_metadata::identity_pair_matches(
+ held_directory_device,
+ held_directory.st_ino,
+ current_directory_device,
+ current_directory_status.st_ino,
+ authority.directory_device,
+ authority.directory_inode,
+ ),
+ ]),
+ WriterAuthorityCause::Mismatched,
+ )?;
let current_lock = openat(
¤t_directory,
@@ -350,24 +365,44 @@ fn validate_authority_binding(
.map_err(|_| WriterAuthorityCause::Mismatched)?;
let current_lock_device = crate::native_metadata::device(current_lock_status.st_dev)
.map_err(|_| WriterAuthorityCause::Mismatched)?;
- if !FileType::from_raw_mode(held_lock.st_mode).is_file()
- || crate::native_metadata::link_count(held_lock.st_nlink) != 1
- || held_lock.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(held_lock.st_mode) & 0o777 != 0o600
- || !FileType::from_raw_mode(current_lock_status.st_mode).is_file()
- || crate::native_metadata::link_count(current_lock_status.st_nlink) != 1
- || current_lock_status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(current_lock_status.st_mode) & 0o777 != 0o600
- || held_lock_device != authority.lock_device
- || held_lock.st_ino != authority.lock_inode
- || current_lock_device != authority.lock_device
- || current_lock_status.st_ino != authority.lock_inode
- {
- return Err(WriterAuthorityCause::Mismatched);
- }
+ require_authority_condition(
+ crate::all_constraints([
+ crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(held_lock.st_mode).is_file(),
+ crate::native_metadata::link_count(held_lock.st_nlink),
+ held_lock.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(held_lock.st_mode),
+ ),
+ crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(current_lock_status.st_mode).is_file(),
+ crate::native_metadata::link_count(current_lock_status.st_nlink),
+ current_lock_status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(current_lock_status.st_mode),
+ ),
+ crate::native_metadata::identity_pair_matches(
+ held_lock_device,
+ held_lock.st_ino,
+ current_lock_device,
+ current_lock_status.st_ino,
+ authority.lock_device,
+ authority.lock_inode,
+ ),
+ ]),
+ WriterAuthorityCause::Mismatched,
+ )?;
Ok(())
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_authority_condition(
+ condition: bool,
+ cause: WriterAuthorityCause,
+) -> Result<(), WriterAuthorityCause> {
+ condition.then_some(()).ok_or(cause)
+}
+
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
mod tests {
use std::{
@@ -585,6 +620,8 @@ mod tests {
assert!(!display.contains('/'));
assert!(!display.contains(".sqlite"));
assert!(!display.contains("state.lock"));
+ assert!(require_authority_condition(true, cause).is_ok());
+ assert_eq!(require_authority_condition(false, cause), Err(cause));
}
}
}
diff --git a/crates/service_sqlite/src/backup/capture.rs b/crates/service_sqlite/src/backup/capture.rs
@@ -109,14 +109,17 @@ trait CaptureOperations: Send + Sync {
struct SystemCaptureOperations;
impl CaptureOperations for SystemCaptureOperations {
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn sync_state(&self, state: &File) -> io::Result<()> {
state.sync_all()
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn sync_staging(&self, staging: &File) -> io::Result<()> {
staging.sync_all()
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn sync_parent(&self, parent: &File) -> io::Result<()> {
parent.sync_all()
}
@@ -217,21 +220,22 @@ async fn capture_online_backup_with_operations(
operations: Arc<dyn CaptureOperations>,
failpoints: &crate::failpoint::DurabilityFailpoints,
) -> Result<ServiceBackupManifest, ServiceSqliteError> {
- if !matches!(
- pool.mode(),
- OpenMode::Initialize | OpenMode::ReadWriteExisting
- ) || closing.load(Ordering::Acquire)
- {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Open));
- }
+ crate::require_condition(
+ matches!(
+ pool.mode(),
+ OpenMode::Initialize | OpenMode::ReadWriteExisting
+ ) && !closing.load(Ordering::Acquire),
+ ServiceSqliteErrorKind::Open,
+ )?;
let staging = StagingPath::new(staging_directory)?;
let permit = CapturePermit::acquire(Arc::clone(active))?;
pool.validate()?;
let mut admission = pool.acquire().await?;
pool.validate()?;
- if closing.load(Ordering::Acquire) {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Open));
- }
+ crate::require_condition(
+ !closing.load(Ordering::Acquire),
+ ServiceSqliteErrorKind::Open,
+ )?;
let metadata = crate::metadata::verify_database_metadata(&mut admission, pool.identity()).await;
#[cfg(test)]
let metadata = if TEST_CAPTURE_INJECT_METADATA_FAILURE.load(Ordering::Acquire) {
@@ -667,7 +671,10 @@ impl StagingGuard {
return Err(error);
}
};
- if opened_directory_identity != created_directory_identity {
+ if let Err(error) = require_backup_condition(
+ opened_directory_identity == created_directory_identity,
+ BackupFailureKind::StagingReplaced,
+ ) {
cleanup_partial_staging(
&parent,
&path.name,
@@ -675,7 +682,7 @@ impl StagingGuard {
Some(&directory),
None,
);
- return Err(backup_error(BackupFailureKind::StagingReplaced));
+ return Err(error);
}
if let Err(source) = fchmod(&directory, Mode::RUSR | Mode::WUSR | Mode::XUSR) {
cleanup_partial_staging(
@@ -801,9 +808,13 @@ impl StagingGuard {
.transpose()
.map_err(|source| backup_source(BackupFailureKind::InvalidStagingInventory, source))?
.ok_or_else(|| backup_error(BackupFailureKind::InvalidStagingInventory))?;
- if first.file_name() != OsStr::new(STATE_FILE_NAME) || entries.next().is_some() {
- return Err(backup_error(BackupFailureKind::InvalidStagingInventory));
- }
+ require_backup_condition(
+ crate::all_constraints([
+ first.file_name() == OsStr::new(STATE_FILE_NAME),
+ entries.next().is_none(),
+ ]),
+ BackupFailureKind::InvalidStagingInventory,
+ )?;
Ok(())
}
@@ -828,9 +839,10 @@ impl StagingGuard {
let mut length = 0_u64;
let mut buffer = [0_u8; HASH_BUFFER_BYTES];
loop {
- if cancellation.load(Ordering::Acquire) {
- return Err(backup_error(BackupFailureKind::Cancelled));
- }
+ require_backup_condition(
+ !cancellation.load(Ordering::Acquire),
+ BackupFailureKind::Cancelled,
+ )?;
let count = state
.read(&mut buffer)
.map_err(|source| backup_source(BackupFailureKind::HashState, source))?;
@@ -842,14 +854,10 @@ impl StagingGuard {
u64::try_from(count).map_err(|_| backup_error(BackupFailureKind::HashState))?,
)
.ok_or_else(|| backup_error(BackupFailureKind::HashState))?;
- if length > i64::MAX as u64 {
- return Err(backup_error(BackupFailureKind::HashState));
- }
+ require_backup_condition(length <= i64::MAX as u64, BackupFailureKind::HashState)?;
hasher.update(&buffer[..count]);
}
- if length == 0 {
- return Err(backup_error(BackupFailureKind::HashState));
- }
+ require_backup_condition(length != 0, BackupFailureKind::HashState)?;
self.validate()?;
Ok((length, hasher.finalize().into()))
}
@@ -943,10 +951,12 @@ fn created_directory_identity(
) -> Result<FileIdentity, ServiceSqliteError> {
let status = statat(parent, name, AtFlags::SYMLINK_NOFOLLOW)
.map_err(|source| backup_source(BackupFailureKind::StagingReplaced, source))?;
- if !FileType::from_raw_mode(status.st_mode).is_dir()
- || status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(status.st_mode) & 0o022 != 0
- {
+ if !crate::native_metadata::secure_directory(
+ FileType::from_raw_mode(status.st_mode).is_dir(),
+ status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(status.st_mode),
+ ) {
return Err(backup_error(BackupFailureKind::StagingReplaced));
}
identity(&status)
@@ -965,14 +975,22 @@ fn validate_directory_descriptor(
let status = fstat(directory)
.map_err(|source| backup_source(BackupFailureKind::InvalidStagingParent, source))?;
let mode = crate::native_metadata::mode(status.st_mode) & 0o777;
- if !FileType::from_raw_mode(status.st_mode).is_dir()
- || status.st_uid != geteuid().as_raw()
- || if exact_owner_mode {
- mode != 0o700
- } else {
- mode & 0o022 != 0
- }
- {
+ let valid = if exact_owner_mode {
+ crate::native_metadata::exact_directory(
+ FileType::from_raw_mode(status.st_mode).is_dir(),
+ status.st_uid,
+ geteuid().as_raw(),
+ mode,
+ )
+ } else {
+ crate::native_metadata::secure_directory(
+ FileType::from_raw_mode(status.st_mode).is_dir(),
+ status.st_uid,
+ geteuid().as_raw(),
+ mode,
+ )
+ };
+ if !valid {
return Err(backup_error(BackupFailureKind::InvalidStagingParent));
}
identity(&status)
@@ -981,11 +999,13 @@ fn validate_directory_descriptor(
fn validate_file_descriptor(file: &File) -> Result<FileIdentity, ServiceSqliteError> {
let status = fstat(file)
.map_err(|source| backup_source(BackupFailureKind::InvalidStagingInventory, source))?;
- if !FileType::from_raw_mode(status.st_mode).is_file()
- || crate::native_metadata::link_count(status.st_nlink) != 1
- || status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(status.st_mode) & 0o777 != 0o600
- {
+ if !crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(status.st_mode).is_file(),
+ crate::native_metadata::link_count(status.st_nlink),
+ status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(status.st_mode),
+ ) {
return Err(backup_error(BackupFailureKind::InvalidStagingInventory));
}
identity(&status)
@@ -1005,11 +1025,14 @@ fn validate_reopened_directory(
)
.map_err(|source| backup_source(BackupFailureKind::StagingReplaced, source))?,
);
- if validate_directory_descriptor(¤t, exact_owner_mode)? != expected
- || validate_directory_descriptor(held, exact_owner_mode)? != expected
- {
- return Err(backup_error(BackupFailureKind::StagingReplaced));
- }
+ require_backup_condition(
+ validate_directory_descriptor(¤t, exact_owner_mode)? == expected,
+ BackupFailureKind::StagingReplaced,
+ )?;
+ require_backup_condition(
+ validate_directory_descriptor(held, exact_owner_mode)? == expected,
+ BackupFailureKind::StagingReplaced,
+ )?;
Ok(())
}
@@ -1028,11 +1051,14 @@ fn validate_directory_entry(
)
.map_err(|source| backup_source(BackupFailureKind::StagingReplaced, source))?,
);
- if validate_directory_descriptor(¤t, true)? != expected
- || validate_directory_descriptor(held, true)? != expected
- {
- return Err(backup_error(BackupFailureKind::StagingReplaced));
- }
+ require_backup_condition(
+ validate_directory_descriptor(¤t, true)? == expected,
+ BackupFailureKind::StagingReplaced,
+ )?;
+ require_backup_condition(
+ validate_directory_descriptor(held, true)? == expected,
+ BackupFailureKind::StagingReplaced,
+ )?;
Ok(())
}
@@ -1051,11 +1077,14 @@ fn validate_file_entry(
)
.map_err(|source| backup_source(BackupFailureKind::StagingReplaced, source))?,
);
- if validate_file_descriptor(¤t)? != expected
- || validate_file_descriptor(held)? != expected
- {
- return Err(backup_error(BackupFailureKind::StagingReplaced));
- }
+ require_backup_condition(
+ validate_file_descriptor(¤t)? == expected,
+ BackupFailureKind::StagingReplaced,
+ )?;
+ require_backup_condition(
+ validate_file_descriptor(held)? == expected,
+ BackupFailureKind::StagingReplaced,
+ )?;
Ok(())
}
@@ -1066,10 +1095,12 @@ fn current_entry_identity(directory: &File, name: &OsStr) -> Option<FileIdentity
fn safe_sidecar_identity(directory: &File, name: &str) -> Option<FileIdentity> {
let status = statat(directory, name, AtFlags::SYMLINK_NOFOLLOW).ok()?;
- if !FileType::from_raw_mode(status.st_mode).is_file()
- || crate::native_metadata::link_count(status.st_nlink) != 1
- || status.st_uid != geteuid().as_raw()
- {
+ if !crate::native_metadata::regular_owner_single_link(
+ FileType::from_raw_mode(status.st_mode).is_file(),
+ crate::native_metadata::link_count(status.st_nlink),
+ status.st_uid,
+ geteuid().as_raw(),
+ ) {
return None;
}
identity(&status).ok()
@@ -1100,19 +1131,21 @@ fn verify_database_inventory(connection: &Connection) -> Result<(), ServiceSqlit
let name: String = first
.get(1)
.map_err(|source| backup_source(BackupFailureKind::Capture, source))?;
- if sequence != 0 || name != "main" {
- return Err(backup_error(BackupFailureKind::Capture));
- }
- if rows
+ let has_extra = rows
.next()
.map_err(|source| backup_source(BackupFailureKind::Capture, source))?
- .is_some()
- {
- return Err(backup_error(BackupFailureKind::Capture));
- }
+ .is_some();
+ require_backup_condition(
+ database_inventory_matches(sequence, &name, has_extra),
+ BackupFailureKind::Capture,
+ )?;
Ok(())
}
+fn database_inventory_matches(sequence: i64, name: &str, has_extra: bool) -> bool {
+ crate::all_constraints([sequence == 0, name == "main", !has_extra])
+}
+
fn verify_database_metadata(
connection: &Connection,
expected: &ServiceDatabaseMetadata,
@@ -1127,9 +1160,13 @@ fn verify_database_metadata(
|row| row.get(0),
)
.map_err(metadata_source)?;
- if row_count != 1 || application_id != i64::from(expected.application_id().get()) {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata));
- }
+ crate::require_condition(
+ crate::all_constraints([
+ row_count == 1,
+ application_id == i64::from(expected.application_id().get()),
+ ]),
+ ServiceSqliteErrorKind::Metadata,
+ )?;
let row = connection
.query_row(
"SELECT
@@ -1167,14 +1204,16 @@ fn verify_database_metadata(
else {
return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata));
};
- if service != expected.service().as_str()
- || instance != expected.instance().as_str()
- || generation.as_slice() != expected.source_generation().as_bytes()
- || schema != i64::from(expected.state_schema_version().get())
- || created_at != i64::try_from(expected.created_at_unix_ms()).unwrap_or(-1)
- {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata));
- }
+ crate::require_condition(
+ crate::all_constraints([
+ service == expected.service().as_str(),
+ instance == expected.instance().as_str(),
+ generation.as_slice() == expected.source_generation().as_bytes(),
+ schema == i64::from(expected.state_schema_version().get()),
+ created_at == i64::try_from(expected.created_at_unix_ms()).unwrap_or(-1),
+ ]),
+ ServiceSqliteErrorKind::Metadata,
+ )?;
Ok(())
}
@@ -1188,21 +1227,22 @@ fn verify_integrity(connection: &Connection) -> Result<(), ServiceSqliteError> {
.map_err(integrity_source)?
.ok_or_else(|| ServiceSqliteError::new(ServiceSqliteErrorKind::Integrity))?;
let result = row.get_ref(0).map_err(integrity_source)?;
- if !integrity_projection_is_ok(result) || rows.next().map_err(integrity_source)?.is_some() {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Integrity));
- }
+ crate::require_condition(
+ integrity_projection_is_ok(result) && rows.next().map_err(integrity_source)?.is_none(),
+ ServiceSqliteErrorKind::Integrity,
+ )?;
let mut statement = connection
.prepare("PRAGMA foreign_key_check")
.map_err(integrity_source)?;
- if statement
- .query([])
- .map_err(integrity_source)?
- .next()
- .map_err(integrity_source)?
- .is_some()
- {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Integrity));
- }
+ crate::require_condition(
+ statement
+ .query([])
+ .map_err(integrity_source)?
+ .next()
+ .map_err(integrity_source)?
+ .is_none(),
+ ServiceSqliteErrorKind::Integrity,
+ )?;
Ok(())
}
@@ -1289,6 +1329,17 @@ fn backup_error(kind: BackupFailureKind) -> ServiceSqliteError {
)
}
+fn require_backup_condition(
+ condition: bool,
+ kind: BackupFailureKind,
+) -> Result<(), ServiceSqliteError> {
+ if condition {
+ Ok(())
+ } else {
+ Err(backup_error(kind))
+ }
+}
+
fn backup_source(
kind: BackupFailureKind,
source: impl Error + Send + Sync + 'static,
@@ -1312,13 +1363,106 @@ fn metadata_source(source: rusqlite::Error) -> ServiceSqliteError {
#[cfg(test)]
mod tests {
+ use core::num::NonZeroU32;
use std::os::unix::fs::{PermissionsExt, symlink};
+ use radroots_runtime_paths::{InstanceId, ServiceId};
+ use radroots_storage::event::SourceGeneration;
use tempfile::tempdir;
use super::*;
#[test]
+ fn backup_failure_inventory_is_complete_and_source_aware() {
+ let cases = [
+ (
+ BackupFailureKind::InvalidStagingPath,
+ "backup staging path is invalid",
+ ),
+ (
+ BackupFailureKind::InvalidStagingParent,
+ "backup staging parent is invalid",
+ ),
+ (
+ BackupFailureKind::StagingCollision,
+ "backup staging destination already exists",
+ ),
+ (
+ BackupFailureKind::CreateStaging,
+ "backup staging directory could not be created",
+ ),
+ (
+ BackupFailureKind::CreateState,
+ "backup state member could not be created",
+ ),
+ (
+ BackupFailureKind::StagingReplaced,
+ "backup staging identity changed",
+ ),
+ (
+ BackupFailureKind::InvalidStagingInventory,
+ "backup staging inventory is invalid",
+ ),
+ (
+ BackupFailureKind::AlreadyActive,
+ "another backup capture is active",
+ ),
+ (BackupFailureKind::Capture, "online backup capture failed"),
+ (
+ BackupFailureKind::Cancelled,
+ "online backup capture was cancelled",
+ ),
+ (
+ BackupFailureKind::HashState,
+ "backup state member could not be hashed",
+ ),
+ (
+ BackupFailureKind::SyncState,
+ "backup state member could not be synchronized",
+ ),
+ (
+ BackupFailureKind::SyncStaging,
+ "backup staging directory could not be synchronized",
+ ),
+ (
+ BackupFailureKind::SyncParent,
+ "backup staging parent could not be synchronized",
+ ),
+ (
+ BackupFailureKind::Manifest,
+ "backup manifest could not be constructed",
+ ),
+ (BackupFailureKind::Join, "backup worker could not be joined"),
+ ];
+ for (kind, message) in cases {
+ let plain = BackupFailure { kind, source: None };
+ assert_eq!(plain.to_string(), message);
+ assert!(plain.source().is_none());
+ assert!(format!("{plain:?}").contains("source: None"));
+
+ let sourced = BackupFailure {
+ kind,
+ source: Some(Box::new(std::io::Error::other("private-cause"))),
+ };
+ assert_eq!(sourced.to_string(), message);
+ assert_eq!(
+ sourced.source().expect("source").to_string(),
+ "private-cause"
+ );
+ let debug = format!("{sourced:?}");
+ assert!(debug.contains("[redacted]"));
+ assert!(!debug.contains("private-cause"));
+ assert!(require_backup_condition(true, kind).is_ok());
+ assert_eq!(
+ require_backup_condition(false, kind)
+ .expect_err("false condition")
+ .kind(),
+ ServiceSqliteErrorKind::Backup
+ );
+ }
+ }
+
+ #[test]
fn staging_path_rejects_relative_parent_and_oversize_inputs() {
assert!(StagingPath::new(Path::new("relative/stage")).is_err());
assert!(StagingPath::new(Path::new("/tmp/../stage")).is_err());
@@ -1357,6 +1501,119 @@ mod tests {
}
#[test]
+ fn staging_guard_success_inventory_hash_sync_commit_and_empty_failure_are_exercised() {
+ let root = tempdir().expect("root");
+ std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700))
+ .expect("parent mode");
+
+ let empty_path = StagingPath::new(&root.path().join("empty-stage")).expect("empty path");
+ let empty = StagingGuard::create(&empty_path, &SystemCaptureOperations).expect("empty");
+ assert!(empty.hash_state(&AtomicBool::new(false)).is_err());
+ drop(empty);
+
+ let path = StagingPath::new(&root.path().join("complete-stage")).expect("path");
+ let mut staging =
+ StagingGuard::create(&path, &SystemCaptureOperations).expect("complete stage");
+ std::fs::write(staging.state_path(), b"captured-state").expect("state bytes");
+ staging.validate_inventory().expect("singleton inventory");
+ let (length, digest) = staging
+ .hash_state(&AtomicBool::new(false))
+ .expect("hash state");
+ assert_eq!(length, 14);
+ assert_eq!(digest, Sha256::digest(b"captured-state").as_slice());
+ staging
+ .sync_state(&SystemCaptureOperations)
+ .expect("sync state");
+ staging
+ .sync_directories(&SystemCaptureOperations)
+ .expect("sync directories");
+ staging.commit();
+ drop(staging);
+ assert!(path.full.exists());
+ std::fs::remove_file(path.full.join(STATE_FILE_NAME)).expect("remove state");
+ std::fs::remove_dir(path.full).expect("remove stage");
+ }
+
+ #[test]
+ fn staging_inventory_and_sidecar_cleanup_bind_exact_entries() {
+ let root = tempdir().expect("root");
+ std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700))
+ .expect("parent mode");
+ let path = StagingPath::new(&root.path().join("backup-stage")).expect("path");
+ let mut staging = StagingGuard::create(&path, &SystemCaptureOperations).expect("staging");
+ std::fs::write(staging.state_path(), b"state").expect("state bytes");
+ let wal = path.full.join(KNOWN_SIDECARS[0]);
+ std::fs::write(&wal, b"sidecar").expect("sidecar");
+ std::fs::set_permissions(&wal, std::fs::Permissions::from_mode(0o600))
+ .expect("sidecar mode");
+ assert!(staging.validate_inventory().is_err());
+ staging.record_sidecars();
+ drop(staging);
+ assert!(!path.full.exists());
+
+ let path = StagingPath::new(&root.path().join("unsafe-sidecar-stage")).expect("path");
+ let mut staging = StagingGuard::create(&path, &SystemCaptureOperations).expect("staging");
+ std::fs::write(staging.state_path(), b"state").expect("state bytes");
+ let wal = path.full.join(KNOWN_SIDECARS[0]);
+ std::fs::write(&wal, b"unsafe-sidecar").expect("sidecar");
+ std::fs::set_permissions(&wal, std::fs::Permissions::from_mode(0o666))
+ .expect("unsafe sidecar mode");
+ let outside_wal = root.path().join("unsafe-sidecar-alias");
+ std::fs::hard_link(&wal, &outside_wal).expect("make sidecar unsafe by link count");
+ staging.record_sidecars();
+ drop(staging);
+ assert_eq!(
+ std::fs::read(wal).expect("unsafe sidecar preserved"),
+ b"unsafe-sidecar"
+ );
+ assert_eq!(
+ std::fs::read(outside_wal).expect("outside sidecar link preserved"),
+ b"unsafe-sidecar"
+ );
+ }
+
+ #[test]
+ fn hash_cancellation_partial_cleanup_and_directory_replacement_are_exact() {
+ let root = tempdir().expect("root");
+ std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700))
+ .expect("parent mode");
+
+ let cancelled_path = StagingPath::new(&root.path().join("cancelled-stage")).expect("path");
+ let cancelled =
+ StagingGuard::create(&cancelled_path, &SystemCaptureOperations).expect("staging");
+ std::fs::write(cancelled.state_path(), b"state").expect("state bytes");
+ assert!(cancelled.hash_state(&AtomicBool::new(true)).is_err());
+ drop(cancelled);
+
+ let exact_path = StagingPath::new(&root.path().join("exact-stage")).expect("path");
+ let exact = StagingGuard::create(&exact_path, &SystemCaptureOperations).expect("staging");
+ cleanup_partial_staging(
+ &exact.parent,
+ &exact.path.name,
+ Some(exact.directory_identity),
+ Some(&exact.directory),
+ Some(exact.state_identity),
+ );
+ assert!(!exact_path.full.exists());
+ drop(exact);
+
+ let replaced_path = StagingPath::new(&root.path().join("replaced-stage")).expect("path");
+ let replaced =
+ StagingGuard::create(&replaced_path, &SystemCaptureOperations).expect("staging");
+ let retired = root.path().join("retired-stage");
+ std::fs::rename(&replaced_path.full, &retired).expect("retire governed directory");
+ std::fs::create_dir(&replaced_path.full).expect("replacement directory");
+ std::fs::set_permissions(&replaced_path.full, std::fs::Permissions::from_mode(0o700))
+ .expect("replacement mode");
+ std::fs::write(replaced_path.full.join("foreign"), b"foreign").expect("foreign entry");
+ drop(replaced);
+ assert_eq!(
+ std::fs::read(replaced_path.full.join("foreign")).expect("replacement survives"),
+ b"foreign"
+ );
+ }
+
+ #[test]
fn staging_guard_rejects_collision_and_preserves_replacement() {
let root = tempdir().expect("root");
std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700))
@@ -1513,6 +1770,8 @@ mod tests {
#[test]
fn integrity_projection_bounds_corrupt_text_before_semantic_acceptance() {
assert!(integrity_projection_is_ok(ValueRef::Text(b"ok")));
+ assert!(!integrity_projection_is_ok(ValueRef::Text(b"")));
+ assert!(!integrity_projection_is_ok(ValueRef::Text(b"not-ok")));
let maximum = vec![b'x'; MAX_INTEGRITY_RESULT_UTF8_BYTES];
assert!(!integrity_projection_is_ok(ValueRef::Text(&maximum)));
let over_maximum = vec![b'x'; MAX_INTEGRITY_RESULT_UTF8_BYTES + 1];
@@ -1521,6 +1780,103 @@ mod tests {
}
#[test]
+ fn capture_database_inventory_rejects_each_independent_projection_drift() {
+ assert!(database_inventory_matches(0, "main", false));
+ assert!(!database_inventory_matches(1, "main", false));
+ assert!(!database_inventory_matches(0, "temp", false));
+ assert!(!database_inventory_matches(0, "main", true));
+ }
+
+ fn metadata_fixture() -> (Connection, ServiceDatabaseMetadata) {
+ let metadata = ServiceDatabaseMetadata::from_verified_backup(
+ ServiceId::new("myc").expect("service"),
+ InstanceId::new("primary").expect("instance"),
+ SourceGeneration::new([7; 32]).expect("generation"),
+ NonZeroU32::new(1).expect("schema"),
+ 1_234,
+ crate::ServiceSqliteApplicationId::new(0x5244_5254).expect("application ID"),
+ )
+ .expect("metadata");
+ let connection = Connection::open_in_memory().expect("database");
+ connection
+ .execute_batch(&format!(
+ "PRAGMA application_id = {};
+ CREATE TABLE radroots_service_metadata (
+ singleton INTEGER,
+ service_id TEXT,
+ instance_id TEXT,
+ source_generation BLOB,
+ state_schema_version INTEGER,
+ created_at_unix_ms INTEGER
+ );",
+ metadata.application_id().get()
+ ))
+ .expect("metadata schema");
+ connection
+ .execute(
+ "INSERT INTO radroots_service_metadata VALUES (1, ?1, ?2, ?3, ?4, ?5)",
+ rusqlite::params![
+ metadata.service().as_str(),
+ metadata.instance().as_str(),
+ metadata.source_generation().as_bytes().as_slice(),
+ i64::from(metadata.state_schema_version().get()),
+ i64::try_from(metadata.created_at_unix_ms()).expect("time"),
+ ],
+ )
+ .expect("metadata row");
+ (connection, metadata)
+ }
+
+ #[test]
+ fn capture_database_inventory_metadata_and_integrity_accept_exact_state() {
+ let (connection, metadata) = metadata_fixture();
+ verify_database_inventory(&connection).expect("main-only inventory");
+ verify_database_metadata(&connection, &metadata).expect("exact metadata");
+ verify_integrity(&connection).expect("healthy database");
+
+ connection
+ .execute_batch("ATTACH DATABASE ':memory:' AS extra")
+ .expect("attach extra");
+ assert!(verify_database_inventory(&connection).is_err());
+ }
+
+ #[test]
+ fn capture_metadata_rejects_every_independent_identity_drift() {
+ for statement in [
+ "PRAGMA application_id = 1",
+ "INSERT INTO radroots_service_metadata SELECT 2, service_id, instance_id, source_generation, state_schema_version, created_at_unix_ms FROM radroots_service_metadata",
+ "UPDATE radroots_service_metadata SET service_id = 'rhi'",
+ "UPDATE radroots_service_metadata SET instance_id = 'secondary'",
+ "UPDATE radroots_service_metadata SET source_generation = zeroblob(32)",
+ "UPDATE radroots_service_metadata SET state_schema_version = 2",
+ "UPDATE radroots_service_metadata SET created_at_unix_ms = 1235",
+ "UPDATE radroots_service_metadata SET service_id = NULL",
+ ] {
+ let (connection, metadata) = metadata_fixture();
+ connection.execute_batch(statement).expect("apply drift");
+ assert!(
+ verify_database_metadata(&connection, &metadata).is_err(),
+ "drift must fail: {statement}"
+ );
+ }
+ }
+
+ #[test]
+ fn capture_integrity_rejects_foreign_key_violations() {
+ let connection = Connection::open_in_memory().expect("database");
+ connection
+ .execute_batch(
+ "PRAGMA foreign_keys = OFF;
+ CREATE TABLE parent(id INTEGER PRIMARY KEY);
+ CREATE TABLE child(parent_id INTEGER REFERENCES parent(id));
+ INSERT INTO child(parent_id) VALUES (41);",
+ )
+ .expect("foreign-key violation fixture");
+ let error = verify_integrity(&connection).expect_err("foreign-key drift must fail");
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Integrity);
+ }
+
+ #[test]
fn active_capture_permit_is_exclusive_and_recoverable() {
let active = Arc::new(AtomicBool::new(false));
let first = CapturePermit::acquire(Arc::clone(&active)).expect("first permit");
diff --git a/crates/service_sqlite/src/backup/manifest.rs b/crates/service_sqlite/src/backup/manifest.rs
@@ -173,18 +173,21 @@ impl ServiceBackupManifest {
/// owns expected-intent, length, digest, SQLite identity, and integrity
/// qualification.
pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, BackupManifestContractError> {
- if bytes.is_empty() {
- return Err(BackupManifestContractError::MalformedEncoding);
- }
- if bytes.len() > BACKUP_MANIFEST_CANONICAL_MAX_BYTES {
- return Err(BackupManifestContractError::ManifestTooLarge);
- }
+ require_manifest_condition(
+ !bytes.is_empty(),
+ BackupManifestContractError::MalformedEncoding,
+ )?;
+ require_manifest_condition(
+ bytes.len() <= BACKUP_MANIFEST_CANONICAL_MAX_BYTES,
+ BackupManifestContractError::ManifestTooLarge,
+ )?;
let wire: WireManifest = serde_json::from_slice(bytes)
.map_err(|_| BackupManifestContractError::MalformedEncoding)?;
let manifest = Self::from_wire(wire)?;
- if manifest.canonical_bytes.as_ref() != bytes {
- return Err(BackupManifestContractError::NonCanonicalEncoding);
- }
+ require_manifest_condition(
+ manifest.canonical_bytes.as_ref() == bytes,
+ BackupManifestContractError::NonCanonicalEncoding,
+ )?;
Ok(manifest)
}
@@ -269,12 +272,14 @@ impl ServiceBackupManifest {
}
fn from_wire(wire: WireManifest) -> Result<Self, BackupManifestContractError> {
- if wire.schema != BACKUP_MANIFEST_SCHEMA {
- return Err(BackupManifestContractError::InvalidSchema);
- }
- if wire.schema_version != BACKUP_MANIFEST_SCHEMA_VERSION {
- return Err(BackupManifestContractError::UnsupportedVersion);
- }
+ require_manifest_condition(
+ wire.schema == BACKUP_MANIFEST_SCHEMA,
+ BackupManifestContractError::InvalidSchema,
+ )?;
+ require_manifest_condition(
+ wire.schema_version == BACKUP_MANIFEST_SCHEMA_VERSION,
+ BackupManifestContractError::UnsupportedVersion,
+ )?;
let service = ServiceId::new(wire.service)
.map_err(|_| BackupManifestContractError::InvalidServiceIdentity)?;
let instance = InstanceId::new(wire.instance)
@@ -288,22 +293,26 @@ impl ServiceBackupManifest {
let [member] = wire.members.as_slice() else {
return Err(BackupManifestContractError::InvalidMemberInventory);
};
- if member.name != BACKUP_STATE_MEMBER_NAME {
- return Err(BackupManifestContractError::InvalidMemberName);
- }
- if member.byte_length == 0 {
- return Err(BackupManifestContractError::InvalidMemberLength);
- }
+ require_manifest_condition(
+ member.name == BACKUP_STATE_MEMBER_NAME,
+ BackupManifestContractError::InvalidMemberName,
+ )?;
+ require_manifest_condition(
+ member.byte_length != 0,
+ BackupManifestContractError::InvalidMemberLength,
+ )?;
let member_sha256 = BackupMemberSha256(
decode_hex_32(&member.sha256)
.ok_or(BackupManifestContractError::InvalidMemberDigest)?,
);
- if wire.integrity.sqlite != INTEGRITY_OK || wire.integrity.foreign_keys != INTEGRITY_OK {
- return Err(BackupManifestContractError::InvalidIntegrity);
- }
- if wire.protected_material_included {
- return Err(BackupManifestContractError::ProtectedMaterialIncluded);
- }
+ require_manifest_condition(
+ wire.integrity.sqlite == INTEGRITY_OK && wire.integrity.foreign_keys == INTEGRITY_OK,
+ BackupManifestContractError::InvalidIntegrity,
+ )?;
+ require_manifest_condition(
+ !wire.protected_material_included,
+ BackupManifestContractError::ProtectedMaterialIncluded,
+ )?;
Self::build(
service,
instance,
@@ -325,9 +334,10 @@ impl ServiceBackupManifest {
state_byte_length: u64,
state_sha256: BackupMemberSha256,
) -> Result<Self, BackupManifestContractError> {
- if state_byte_length == 0 {
- return Err(BackupManifestContractError::InvalidMemberLength);
- }
+ require_manifest_condition(
+ state_byte_length != 0,
+ BackupManifestContractError::InvalidMemberLength,
+ )?;
let source_generation_hex = encode_hex(source_generation.as_bytes());
let state_sha256_hex = encode_hex(state_sha256.as_bytes());
let canonical = CanonicalManifest {
@@ -351,9 +361,10 @@ impl ServiceBackupManifest {
};
let canonical_bytes = serde_json::to_vec(&canonical)
.map_err(|_| BackupManifestContractError::EncodingFailure)?;
- if canonical_bytes.len() > BACKUP_MANIFEST_CANONICAL_MAX_BYTES {
- return Err(BackupManifestContractError::ManifestTooLarge);
- }
+ require_manifest_condition(
+ canonical_bytes.len() <= BACKUP_MANIFEST_CANONICAL_MAX_BYTES,
+ BackupManifestContractError::ManifestTooLarge,
+ )?;
let digest = BackupManifestSha256(Sha256::digest(&canonical_bytes).into());
Ok(Self {
service,
@@ -371,6 +382,13 @@ impl ServiceBackupManifest {
}
}
+fn require_manifest_condition(
+ condition: bool,
+ error: BackupManifestContractError,
+) -> Result<(), BackupManifestContractError> {
+ condition.then_some(()).ok_or(error)
+}
+
impl fmt::Debug for ServiceBackupManifest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
@@ -504,9 +522,7 @@ fn encode_hex(bytes: &[u8; 32]) -> String {
}
fn decode_hex_32(value: &str) -> Option<[u8; 32]> {
- if value.len() != 64 {
- return None;
- }
+ (value.len() == 64).then_some(())?;
let mut decoded = [0_u8; 32];
for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
let high = decode_lower_hex(pair[0])?;
@@ -596,6 +612,18 @@ mod tests {
);
assert_eq!(manifest.integrity().sqlite(), INTEGRITY_OK);
assert_eq!(manifest.integrity().foreign_keys(), INTEGRITY_OK);
+ assert_eq!(
+ format!("{:?}", manifest.integrity()),
+ "BackupManifestIntegrity"
+ );
+ assert_eq!(
+ format!("{:?}", BackupMemberSha256::from_bytes([0xab; 32])),
+ "BackupMemberSha256([redacted])"
+ );
+ assert_eq!(
+ format!("{:?}", BackupManifestSha256::from_bytes([0xcd; 32])),
+ "BackupManifestSha256([redacted])"
+ );
assert!(!manifest.protected_material_included());
assert_eq!(
encode_hex(manifest.digest().as_bytes()),
@@ -622,10 +650,23 @@ mod tests {
)
.expect("captured manifest model");
assert_eq!(manifest.canonical_bytes(), CANONICAL.as_bytes());
+ assert_eq!(
+ ServiceBackupManifest::from_capture(
+ &metadata,
+ BackupCreatedAtUnixMs::new(1_700_000_000_000).expect("backup time"),
+ 0,
+ BackupMemberSha256::from_bytes([0xab; 32]),
+ ),
+ Err(BackupManifestContractError::InvalidMemberLength)
+ );
}
#[test]
fn noncanonical_and_ambiguous_encodings_fail_closed() {
+ assert_eq!(
+ ServiceBackupManifest::from_canonical_bytes(b""),
+ Err(BackupManifestContractError::MalformedEncoding)
+ );
let reordered = CANONICAL.replacen(
"\"schema\":\"radroots.service-backup\",\"schema_version\":1",
"\"schema_version\":1,\"schema\":\"radroots.service-backup\"",
@@ -819,5 +860,41 @@ mod tests {
error.to_string(),
"backup manifest member digest is invalid"
);
+ for error in [
+ BackupManifestContractError::ManifestTooLarge,
+ BackupManifestContractError::MalformedEncoding,
+ BackupManifestContractError::NonCanonicalEncoding,
+ BackupManifestContractError::EncodingFailure,
+ BackupManifestContractError::InvalidSchema,
+ BackupManifestContractError::UnsupportedVersion,
+ BackupManifestContractError::InvalidServiceIdentity,
+ BackupManifestContractError::InvalidInstanceIdentity,
+ BackupManifestContractError::InvalidSourceGeneration,
+ BackupManifestContractError::InvalidStateSchemaVersion,
+ BackupManifestContractError::InvalidCreationTime,
+ BackupManifestContractError::InvalidMemberInventory,
+ BackupManifestContractError::InvalidMemberName,
+ BackupManifestContractError::InvalidMemberLength,
+ BackupManifestContractError::InvalidMemberDigest,
+ BackupManifestContractError::InvalidIntegrity,
+ BackupManifestContractError::ProtectedMaterialIncluded,
+ ] {
+ assert!(!error.to_string().is_empty());
+ assert!(error.source().is_none());
+ }
+ for error in [
+ BackupManifestContractError::ManifestTooLarge,
+ BackupManifestContractError::MalformedEncoding,
+ BackupManifestContractError::NonCanonicalEncoding,
+ BackupManifestContractError::InvalidSchema,
+ BackupManifestContractError::UnsupportedVersion,
+ BackupManifestContractError::InvalidMemberName,
+ BackupManifestContractError::InvalidMemberLength,
+ BackupManifestContractError::InvalidIntegrity,
+ BackupManifestContractError::ProtectedMaterialIncluded,
+ ] {
+ assert!(require_manifest_condition(true, error).is_ok());
+ assert_eq!(require_manifest_condition(false, error), Err(error));
+ }
}
}
diff --git a/crates/service_sqlite/src/backup/verify.rs b/crates/service_sqlite/src/backup/verify.rs
@@ -149,13 +149,15 @@ fn verify_backup_bundle_native(
expected_identity: &ServiceDatabaseIdentity,
maximum_state_bytes: NonZeroU64,
) -> Result<VerifiedServiceBackup, ServiceSqliteError> {
- if manifest_bytes.len() > crate::BACKUP_MANIFEST_CANONICAL_MAX_BYTES {
- return Err(verification_error(VerificationFailureKind::Manifest));
- }
+ require_verification_condition(
+ manifest_bytes.len() <= crate::BACKUP_MANIFEST_CANONICAL_MAX_BYTES,
+ VerificationFailureKind::Manifest,
+ )?;
let actual_manifest_digest: [u8; 32] = Sha256::digest(manifest_bytes).into();
- if &actual_manifest_digest != expected_manifest_digest.as_bytes() {
- return Err(verification_error(VerificationFailureKind::ManifestDigest));
- }
+ require_verification_condition(
+ &actual_manifest_digest == expected_manifest_digest.as_bytes(),
+ VerificationFailureKind::ManifestDigest,
+ )?;
let manifest = ServiceBackupManifest::from_canonical_bytes(manifest_bytes)
.map_err(|source| verification_source(VerificationFailureKind::Manifest, source))?;
verify_manifest_intent(&manifest, expected_identity)?;
@@ -164,16 +166,19 @@ fn verify_backup_bundle_native(
.members()
.first()
.ok_or_else(|| verification_error(VerificationFailureKind::Inventory))?;
- if member.byte_length() > i64::MAX as u64 || member.byte_length() > maximum_state_bytes.get() {
- return Err(verification_error(VerificationFailureKind::MemberLength));
- }
+ require_verification_condition(
+ member.byte_length() <= i64::MAX as u64
+ && member.byte_length() <= maximum_state_bytes.get(),
+ VerificationFailureKind::MemberLength,
+ )?;
let binding = VerifiedBundleBinding::open(bundle_directory, member.byte_length())?;
binding.validate_inventory()?;
let first_digest = binding.hash_state(maximum_state_bytes)?;
- if &first_digest != member.sha256().as_bytes() {
- return Err(verification_error(VerificationFailureKind::MemberDigest));
- }
+ require_verification_condition(
+ &first_digest == member.sha256().as_bytes(),
+ VerificationFailureKind::MemberDigest,
+ )?;
binding.validate()?;
let connection = open_sqlite_from_retained_state(&binding)?;
@@ -188,9 +193,7 @@ fn verify_backup_bundle_native(
binding.validate_inventory()?;
let final_digest = binding.hash_state(maximum_state_bytes)?;
- if &final_digest != member.sha256().as_bytes() || final_digest != first_digest {
- return Err(verification_error(VerificationFailureKind::MemberDigest));
- }
+ require_backup_digests(&first_digest, &final_digest, member.sha256().as_bytes())?;
binding.validate()?;
Ok(VerifiedServiceBackup {
@@ -201,17 +204,35 @@ fn verify_backup_bundle_native(
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_backup_digests(
+ first: &[u8; 32],
+ final_digest: &[u8; 32],
+ expected: &[u8; 32],
+) -> Result<(), ServiceSqliteError> {
+ require_verification_condition(
+ crate::all_constraints([
+ first == expected,
+ final_digest == expected,
+ first == final_digest,
+ ]),
+ VerificationFailureKind::MemberDigest,
+ )
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn verify_manifest_intent(
manifest: &ServiceBackupManifest,
expected: &ServiceDatabaseIdentity,
) -> Result<(), ServiceSqliteError> {
- if manifest.service() != expected.service()
- || manifest.instance() != expected.instance()
- || manifest.source_generation() != expected.source_generation()
- || manifest.state_schema_version() > expected.supported_state_schema_version()
- {
- return Err(verification_error(VerificationFailureKind::Intent));
- }
+ require_verification_condition(
+ crate::all_constraints([
+ manifest.service() == expected.service(),
+ manifest.instance() == expected.instance(),
+ manifest.source_generation() == expected.source_generation(),
+ manifest.state_schema_version() <= expected.supported_state_schema_version(),
+ ]),
+ VerificationFailureKind::Intent,
+ )?;
Ok(())
}
@@ -257,9 +278,10 @@ impl VerifiedBundleBinding {
.map_err(|source| verification_source(VerificationFailureKind::Inventory, source))?,
);
let (state_identity, state_length) = validate_state(&state)?;
- if state_length != expected_length {
- return Err(verification_error(VerificationFailureKind::MemberLength));
- }
+ require_verification_condition(
+ state_length == expected_length,
+ VerificationFailureKind::MemberLength,
+ )?;
let binding = Self {
path: path.to_path_buf(),
directory,
@@ -283,11 +305,14 @@ impl VerifiedBundleBinding {
verification_source(VerificationFailureKind::BindingChanged, source)
})?,
);
- if validate_directory(&self.directory)? != self.directory_identity
- || validate_directory(¤t_directory)? != self.directory_identity
- {
- return Err(verification_error(VerificationFailureKind::BindingChanged));
- }
+ require_verification_condition(
+ validate_directory(&self.directory)? == self.directory_identity,
+ VerificationFailureKind::BindingChanged,
+ )?;
+ require_verification_condition(
+ validate_directory(¤t_directory)? == self.directory_identity,
+ VerificationFailureKind::BindingChanged,
+ )?;
let current_state = File::from(
openat(
&self.directory,
@@ -301,9 +326,10 @@ impl VerifiedBundleBinding {
);
for state in [&self.state, ¤t_state] {
let (identity, length) = validate_state(state)?;
- if identity != self.state_identity || length != self.state_length {
- return Err(verification_error(VerificationFailureKind::BindingChanged));
- }
+ require_verification_condition(
+ (identity, length) == (self.state_identity, self.state_length),
+ VerificationFailureKind::BindingChanged,
+ )?;
}
Ok(())
}
@@ -325,14 +351,16 @@ impl VerifiedBundleBinding {
meaningful = meaningful
.checked_add(1)
.ok_or_else(|| verification_error(VerificationFailureKind::Inventory))?;
- if meaningful > 1 || name != crate::BACKUP_STATE_MEMBER_NAME.as_bytes() {
- return Err(verification_error(VerificationFailureKind::Inventory));
- }
+ require_verification_condition(
+ crate::all_constraints([
+ meaningful <= 1,
+ name == crate::BACKUP_STATE_MEMBER_NAME.as_bytes(),
+ ]),
+ VerificationFailureKind::Inventory,
+ )?;
seen_state = true;
}
- if !seen_state {
- return Err(verification_error(VerificationFailureKind::Inventory));
- }
+ require_verification_condition(seen_state, VerificationFailureKind::Inventory)?;
self.validate()
}
@@ -361,14 +389,16 @@ impl VerifiedBundleBinding {
.map_err(|_| verification_error(VerificationFailureKind::MemberLength))?,
)
.ok_or_else(|| verification_error(VerificationFailureKind::MemberLength))?;
- if length > i64::MAX as u64 || length > maximum.get() {
- return Err(verification_error(VerificationFailureKind::MemberLength));
- }
+ require_verification_condition(
+ crate::all_constraints([length <= i64::MAX as u64, length <= maximum.get()]),
+ VerificationFailureKind::MemberLength,
+ )?;
hasher.update(&buffer[..count]);
}
- if length == 0 || length != self.state_length {
- return Err(verification_error(VerificationFailureKind::MemberLength));
- }
+ require_verification_condition(
+ crate::all_constraints([length != 0, length == self.state_length]),
+ VerificationFailureKind::MemberLength,
+ )?;
self.validate()?;
Ok(hasher.finalize().into())
}
@@ -376,18 +406,20 @@ impl VerifiedBundleBinding {
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn validate_bundle_path(path: &Path) -> Result<(), ServiceSqliteError> {
- if !path.is_absolute()
- || path.as_os_str().as_bytes().is_empty()
- || path.as_os_str().as_bytes().len() > MAX_BUNDLE_PATH_BYTES
- || path.components().any(|part| {
- matches!(
- part,
- std::path::Component::CurDir | std::path::Component::ParentDir
- )
- })
- {
- return Err(verification_error(VerificationFailureKind::BundleDirectory));
- }
+ require_verification_condition(
+ crate::all_constraints([
+ path.is_absolute(),
+ !path.as_os_str().as_bytes().is_empty(),
+ path.as_os_str().as_bytes().len() <= MAX_BUNDLE_PATH_BYTES,
+ !path.components().any(|part| {
+ matches!(
+ part,
+ std::path::Component::CurDir | std::path::Component::ParentDir
+ )
+ }),
+ ]),
+ VerificationFailureKind::BundleDirectory,
+ )?;
Ok(())
}
@@ -396,12 +428,15 @@ fn validate_directory(directory: &File) -> Result<FileIdentity, ServiceSqliteErr
let status = fstat(directory)
.map_err(|source| verification_source(VerificationFailureKind::BundleDirectory, source))?;
let mode = crate::native_metadata::mode(status.st_mode) & 0o777;
- if !FileType::from_raw_mode(status.st_mode).is_dir()
- || status.st_uid != geteuid().as_raw()
- || !matches!(mode, 0o500 | 0o700)
- {
- return Err(verification_error(VerificationFailureKind::Permissions));
- }
+ require_verification_condition(
+ crate::native_metadata::restrictive_directory(
+ FileType::from_raw_mode(status.st_mode).is_dir(),
+ status.st_uid,
+ geteuid().as_raw(),
+ mode,
+ ),
+ VerificationFailureKind::Permissions,
+ )?;
file_identity(&status)
}
@@ -412,16 +447,20 @@ fn validate_state(file: &File) -> Result<(FileIdentity, u64), ServiceSqliteError
let mode = crate::native_metadata::mode(status.st_mode) & 0o777;
let length = u64::try_from(status.st_size)
.map_err(|_| verification_error(VerificationFailureKind::MemberLength))?;
- if !FileType::from_raw_mode(status.st_mode).is_file()
- || crate::native_metadata::link_count(status.st_nlink) != 1
- || status.st_uid != geteuid().as_raw()
- || !matches!(mode, 0o400 | 0o600)
- {
- return Err(verification_error(VerificationFailureKind::Permissions));
- }
- if length == 0 || length > i64::MAX as u64 {
- return Err(verification_error(VerificationFailureKind::MemberLength));
- }
+ require_verification_condition(
+ crate::native_metadata::restrictive_regular_file(
+ FileType::from_raw_mode(status.st_mode).is_file(),
+ crate::native_metadata::link_count(status.st_nlink),
+ status.st_uid,
+ geteuid().as_raw(),
+ mode,
+ ),
+ VerificationFailureKind::Permissions,
+ )?;
+ require_verification_condition(
+ crate::native_metadata::valid_artifact_length(length, None),
+ VerificationFailureKind::MemberLength,
+ )?;
Ok((file_identity(&status)?, length))
}
@@ -472,10 +511,17 @@ fn verify_connection_policy(connection: &Connection) -> Result<(), ServiceSqlite
let trusted_schema: i64 = connection
.pragma_query_value(None, "trusted_schema", |row| row.get(0))
.map_err(integrity_source)?;
- if query_only != 1 || trusted_schema != 0 {
- return Err(integrity_error(IntegrityFailureKind::Policy));
- }
- Ok(())
+ require_verification_connection_policy(query_only, trusted_schema)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_verification_connection_policy(
+ query_only: i64,
+ trusted_schema: i64,
+) -> Result<(), ServiceSqliteError> {
+ crate::all_constraints([query_only == 1, trusted_schema == 0])
+ .then_some(())
+ .ok_or_else(|| integrity_error(IntegrityFailureKind::Policy))
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -488,15 +534,27 @@ fn verify_database_inventory(connection: &Connection) -> Result<(), ServiceSqlit
.next()
.map_err(integrity_source)?
.ok_or_else(|| integrity_error(IntegrityFailureKind::DatabaseInventory))?;
- let sequence = first.get_ref(0).map_err(integrity_source)?;
- let name = first.get_ref(1).map_err(integrity_source)?;
- if !matches!(sequence, ValueRef::Integer(0)) || !matches!(name, ValueRef::Text(b"main")) {
- return Err(integrity_error(IntegrityFailureKind::DatabaseInventory));
- }
- if rows.next().map_err(integrity_source)?.is_some() {
- return Err(integrity_error(IntegrityFailureKind::DatabaseInventory));
- }
- Ok(())
+ let sequence_matches = matches!(
+ first.get_ref(0).map_err(integrity_source)?,
+ ValueRef::Integer(0)
+ );
+ let name_matches = matches!(
+ first.get_ref(1).map_err(integrity_source)?,
+ ValueRef::Text(b"main")
+ );
+ let has_extra = rows.next().map_err(integrity_source)?.is_some();
+ require_verification_database_inventory(sequence_matches, name_matches, has_extra)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_verification_database_inventory(
+ sequence_matches: bool,
+ name_matches: bool,
+ has_extra: bool,
+) -> Result<(), ServiceSqliteError> {
+ crate::all_constraints([sequence_matches, name_matches, !has_extra])
+ .then_some(())
+ .ok_or_else(|| integrity_error(IntegrityFailureKind::DatabaseInventory))
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -518,13 +576,13 @@ fn verify_database_metadata(
.next()
.map_err(metadata_source)?
.ok_or_else(metadata_error)?;
- if !matches!(
- object.get_ref(0).map_err(metadata_source)?,
- ValueRef::Text(b"table")
- ) || object_rows.next().map_err(metadata_source)?.is_some()
- {
- return Err(metadata_error());
- }
+ crate::require_condition(
+ matches!(
+ object.get_ref(0).map_err(metadata_source)?,
+ ValueRef::Text(b"table")
+ ) && object_rows.next().map_err(metadata_source)?.is_none(),
+ ServiceSqliteErrorKind::Metadata,
+ )?;
let application_id: i64 = connection
.pragma_query_value(None, "application_id", |row| row.get(0))
@@ -568,9 +626,10 @@ fn verify_database_metadata(
let generation: Option<Vec<u8>> = row.get(3).map_err(metadata_source)?;
let schema: Option<i64> = row.get(4).map_err(metadata_source)?;
let created_at: Option<i64> = row.get(5).map_err(metadata_source)?;
- if rows.next().map_err(metadata_source)?.is_some() {
- return Err(metadata_error());
- }
+ crate::require_condition(
+ rows.next().map_err(metadata_source)?.is_none(),
+ ServiceSqliteErrorKind::Metadata,
+ )?;
let (Some(1), Some(service), Some(instance), Some(generation), Some(schema), Some(created_at)) =
(singleton, service, instance, generation, schema, created_at)
else {
@@ -584,18 +643,17 @@ fn verify_database_metadata(
.ok_or_else(metadata_error)?;
let created_at = u64::try_from(created_at).map_err(|_| metadata_error())?;
- if service != *expected.service()
- || instance != *expected.instance()
- || generation != expected.source_generation()
- || application_id != expected.application_id()
- || service != *manifest.service()
- || instance != *manifest.instance()
- || generation != manifest.source_generation()
- || schema != manifest.state_schema_version()
- || schema > expected.supported_state_schema_version()
- {
- return Err(metadata_error());
- }
+ require_verification_metadata_projection([
+ service == *expected.service(),
+ instance == *expected.instance(),
+ generation == expected.source_generation(),
+ application_id == expected.application_id(),
+ service == *manifest.service(),
+ instance == *manifest.instance(),
+ generation == manifest.source_generation(),
+ schema == manifest.state_schema_version(),
+ schema <= expected.supported_state_schema_version(),
+ ])?;
ServiceDatabaseMetadata::from_verified_backup(
service,
instance,
@@ -617,28 +675,53 @@ fn verify_integrity(connection: &Connection) -> Result<(), ServiceSqliteError> {
.next()
.map_err(integrity_source)?
.ok_or_else(|| integrity_error(IntegrityFailureKind::Sqlite))?;
- let value = row.get_ref(0).map_err(integrity_source)?;
- if !matches!(
- value,
- ValueRef::Text(bytes)
- if !bytes.is_empty()
- && bytes.len() <= MAX_INTEGRITY_RESULT_UTF8_BYTES
- && bytes == b"ok"
- ) || rows.next().map_err(integrity_source)?.is_some()
- {
- return Err(integrity_error(IntegrityFailureKind::Sqlite));
- }
+ let projection =
+ verification_integrity_value_projection(row.get_ref(0).map_err(integrity_source)?);
+ let has_extra = rows.next().map_err(integrity_source)?.is_some();
+ require_verification_integrity_projection(projection, has_extra)?;
let violation = connection
.query_row("PRAGMA foreign_key_check", [], |_| Ok(()))
.optional()
.map_err(integrity_source)?;
- if violation.is_some() {
- return Err(integrity_error(IntegrityFailureKind::ForeignKeys));
- }
+ require_integrity_condition(violation.is_none(), IntegrityFailureKind::ForeignKeys)?;
Ok(())
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_verification_metadata_projection(matches: [bool; 9]) -> Result<(), ServiceSqliteError> {
+ crate::require_condition(
+ crate::all_constraints(matches),
+ ServiceSqliteErrorKind::Metadata,
+ )
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn verification_integrity_value_projection(value: ValueRef<'_>) -> [bool; 4] {
+ [
+ matches!(value, ValueRef::Text(_)),
+ matches!(value, ValueRef::Text(bytes) if !bytes.is_empty()),
+ matches!(value, ValueRef::Text(bytes) if bytes.len() <= MAX_INTEGRITY_RESULT_UTF8_BYTES),
+ matches!(value, ValueRef::Text(b"ok")),
+ ]
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_verification_integrity_projection(
+ projection: [bool; 4],
+ has_extra: bool,
+) -> Result<(), ServiceSqliteError> {
+ crate::all_constraints([
+ projection[0],
+ projection[1],
+ projection[2],
+ projection[3],
+ !has_extra,
+ ])
+ .then_some(())
+ .ok_or_else(|| integrity_error(IntegrityFailureKind::Sqlite))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum VerificationFailureKind {
Manifest,
@@ -704,6 +787,18 @@ fn verification_error(kind: VerificationFailureKind) -> ServiceSqliteError {
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_verification_condition(
+ condition: bool,
+ kind: VerificationFailureKind,
+) -> Result<(), ServiceSqliteError> {
+ if condition {
+ Ok(())
+ } else {
+ Err(verification_error(kind))
+ }
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn verification_source(
kind: VerificationFailureKind,
source: impl Error + Send + Sync + 'static,
@@ -751,6 +846,18 @@ fn integrity_error(kind: IntegrityFailureKind) -> ServiceSqliteError {
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_integrity_condition(
+ condition: bool,
+ kind: IntegrityFailureKind,
+) -> Result<(), ServiceSqliteError> {
+ if condition {
+ Ok(())
+ } else {
+ Err(integrity_error(kind))
+ }
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn integrity_source(source: rusqlite::Error) -> ServiceSqliteError {
ServiceSqliteError::with_source(ServiceSqliteErrorKind::Integrity, source)
}
@@ -783,6 +890,159 @@ mod tests {
};
#[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn verification_projection_helpers_reject_every_independent_drift() {
+ let digest = [7_u8; 32];
+ assert!(require_backup_digests(&digest, &digest, &digest).is_ok());
+ for changed in 0..3 {
+ let mut values = [digest; 3];
+ values[changed][0] ^= 1;
+ assert!(require_backup_digests(&values[0], &values[1], &values[2]).is_err());
+ }
+
+ assert!(require_verification_connection_policy(1, 0).is_ok());
+ for values in [(0, 0), (1, 1), (0, 1)] {
+ assert!(require_verification_connection_policy(values.0, values.1).is_err());
+ }
+
+ assert!(require_verification_database_inventory(true, true, false).is_ok());
+ for (sequence, name, extra) in [
+ (false, true, false),
+ (true, false, false),
+ (false, false, false),
+ (true, true, true),
+ ] {
+ assert!(require_verification_database_inventory(sequence, name, extra).is_err());
+ }
+
+ assert!(require_verification_metadata_projection([true; 9]).is_ok());
+ for changed in 0..9 {
+ let mut matches = [true; 9];
+ matches[changed] = false;
+ assert!(require_verification_metadata_projection(matches).is_err());
+ }
+
+ assert!(
+ require_verification_integrity_projection(
+ verification_integrity_value_projection(ValueRef::Text(b"ok")),
+ false,
+ )
+ .is_ok()
+ );
+ for (value, extra) in [
+ (ValueRef::Null, false),
+ (ValueRef::Text(b""), false),
+ (
+ ValueRef::Text(&[b'x'; MAX_INTEGRITY_RESULT_UTF8_BYTES + 1]),
+ false,
+ ),
+ (ValueRef::Text(b"not ok"), false),
+ (ValueRef::Text(b"ok"), true),
+ ] {
+ assert!(
+ require_verification_integrity_projection(
+ verification_integrity_value_projection(value),
+ extra,
+ )
+ .is_err()
+ );
+ }
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn verification_and_integrity_failure_inventories_are_complete() {
+ let verification_cases = [
+ (
+ VerificationFailureKind::Manifest,
+ "backup manifest is invalid",
+ ),
+ (
+ VerificationFailureKind::ManifestDigest,
+ "backup manifest digest does not match",
+ ),
+ (
+ VerificationFailureKind::Intent,
+ "backup intent does not match",
+ ),
+ (
+ VerificationFailureKind::BundleDirectory,
+ "backup bundle directory is invalid",
+ ),
+ (
+ VerificationFailureKind::Inventory,
+ "backup member inventory is invalid",
+ ),
+ (
+ VerificationFailureKind::Permissions,
+ "backup permissions are invalid",
+ ),
+ (
+ VerificationFailureKind::MemberLength,
+ "backup member length is invalid",
+ ),
+ (
+ VerificationFailureKind::MemberDigest,
+ "backup member digest does not match",
+ ),
+ (
+ VerificationFailureKind::BindingChanged,
+ "backup member binding changed",
+ ),
+ ];
+ for (kind, message) in verification_cases {
+ let plain = VerificationFailure { kind, source: None };
+ assert_eq!(plain.to_string(), message);
+ assert!(plain.source().is_none());
+ let sourced = VerificationFailure {
+ kind,
+ source: Some(Box::new(std::io::Error::other("private-cause"))),
+ };
+ assert_eq!(sourced.to_string(), message);
+ assert!(sourced.source().is_some());
+ assert!(format!("{sourced:?}").contains("[redacted]"));
+ assert!(require_verification_condition(true, kind).is_ok());
+ assert_eq!(
+ require_verification_condition(false, kind)
+ .expect_err("false condition")
+ .kind(),
+ ServiceSqliteErrorKind::Backup
+ );
+ }
+
+ for (kind, message) in [
+ (
+ IntegrityFailureKind::Policy,
+ "backup SQLite policy is invalid",
+ ),
+ (
+ IntegrityFailureKind::DatabaseInventory,
+ "backup database inventory is invalid",
+ ),
+ (
+ IntegrityFailureKind::Sqlite,
+ "backup SQLite integrity is invalid",
+ ),
+ (
+ IntegrityFailureKind::ForeignKeys,
+ "backup foreign-key integrity is invalid",
+ ),
+ ] {
+ let failure = IntegrityFailure(kind);
+ assert_eq!(failure.to_string(), message);
+ assert!(failure.source().is_none());
+ assert!(format!("{failure:?}").contains(&format!("{kind:?}")));
+ assert!(require_integrity_condition(true, kind).is_ok());
+ assert_eq!(
+ require_integrity_condition(false, kind)
+ .expect_err("false condition")
+ .kind(),
+ ServiceSqliteErrorKind::Integrity
+ );
+ }
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
struct Fixture {
_root: tempfile::TempDir,
bundle: PathBuf,
diff --git a/crates/service_sqlite/src/connection.rs b/crates/service_sqlite/src/connection.rs
@@ -76,6 +76,69 @@ enum IntegrityInspectionDriverFailure {
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn integrity_driver_close_result(
+ result: Result<(), sqlx::Error>,
+ injected_failure: bool,
+) -> Result<(), IntegrityInspectionDriverFailure> {
+ if injected_failure {
+ Err(IntegrityInspectionDriverFailure::ConnectionClose)
+ } else {
+ result.map_err(|_| IntegrityInspectionDriverFailure::ConnectionClose)
+ }
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn final_connection_policy_matches(
+ initial: &crate::migration::MigrationConnectionPolicy,
+ final_policy: &crate::migration::MigrationConnectionPolicy,
+) -> Result<(), ServiceSqliteError> {
+ (final_policy == initial)
+ .then_some(())
+ .ok_or_else(|| ServiceSqliteError::new(ServiceSqliteErrorKind::Pragma))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn unconfirmed_rollback_error(
+ rollback: Option<ServiceSqliteError>,
+ rollback_was_confirmed: bool,
+) -> Option<ServiceSqliteError> {
+ rollback.filter(|_| !rollback_was_confirmed)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn precondition_rollback_failure(
+ authority: Option<ServiceSqliteError>,
+ rollback: Option<ServiceSqliteError>,
+ rollback_was_confirmed: bool,
+ hook_removal: Option<ServiceSqliteError>,
+) -> Option<ServiceSqliteError> {
+ authority
+ .or_else(|| unconfirmed_rollback_error(rollback, rollback_was_confirmed))
+ .or(hook_removal)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn authority_drift_rollback_failure(
+ rollback: Option<ServiceSqliteError>,
+ rollback_was_confirmed: bool,
+ hook_removal: Option<ServiceSqliteError>,
+) -> Option<ServiceSqliteError> {
+ unconfirmed_rollback_error(rollback, rollback_was_confirmed).or(hook_removal)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn operation_rollback_failure(
+ rollback: Option<ServiceSqliteError>,
+ rollback_was_confirmed: bool,
+ hook_removal: Option<ServiceSqliteError>,
+ authority: Option<ServiceSqliteError>,
+) -> Option<ServiceSqliteError> {
+ unconfirmed_rollback_error(rollback, rollback_was_confirmed)
+ .or(hook_removal)
+ .or(authority)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
impl IntegrityInspectionDriver {
async fn close_retained(&mut self) -> Result<(), IntegrityInspectionDriverFailure> {
loop {
@@ -100,10 +163,11 @@ impl IntegrityInspectionDriver {
let result = close.await;
*self = Self::Idle;
#[cfg(test)]
- if crate::integrity::integrity_test_seam::take_connection_close_failure() {
- return Err(IntegrityInspectionDriverFailure::ConnectionClose);
- }
- return result.map_err(|_| IntegrityInspectionDriverFailure::ConnectionClose);
+ let injected_failure =
+ crate::integrity::integrity_test_seam::take_connection_close_failure();
+ #[cfg(not(test))]
+ let injected_failure = false;
+ return integrity_driver_close_result(result, injected_failure);
}
}
}
@@ -407,11 +471,11 @@ impl ServiceSqliteHost {
) -> ServiceSqliteTransactionFuture<'a, T, E>
+ Send,
{
- if self.closing.load(Ordering::Acquire) {
- return Err(ServiceSqliteTransactionError::not_committed(
- ServiceSqliteError::new(ServiceSqliteErrorKind::Open),
- ));
- }
+ crate::require_condition(
+ !self.closing.load(Ordering::Acquire),
+ ServiceSqliteErrorKind::Open,
+ )
+ .map_err(ServiceSqliteTransactionError::not_committed)?;
self.pool
.validate()
.map_err(ServiceSqliteTransactionError::not_committed)?;
@@ -476,11 +540,12 @@ impl ServiceSqliteHost {
gate.rejected_commit_rolled_back() && !connection.is_in_transaction();
let remove = gate.remove(&mut connection).await.map_err(sqlite_source);
let authority = self.pool.validate();
- if let Some(rollback_error) = authority
- .err()
- .or_else(|| rollback.err().filter(|_| !rollback_was_confirmed))
- .or_else(|| remove.err())
- {
+ if let Some(rollback_error) = precondition_rollback_failure(
+ authority.err(),
+ rollback.err(),
+ rollback_was_confirmed,
+ remove.err(),
+ ) {
return Err(ServiceSqliteTransactionError::rollback_failed(
None,
rollback_error,
@@ -506,10 +571,11 @@ impl ServiceSqliteHost {
let rollback_was_confirmed =
gate.rejected_commit_rolled_back() && !connection.is_in_transaction();
let remove = gate.remove(&mut connection).await.map_err(sqlite_source);
- let rollback_error = rollback
- .err()
- .filter(|_| !rollback_was_confirmed)
- .or_else(|| remove.err());
+ let rollback_error = authority_drift_rollback_failure(
+ rollback.err(),
+ rollback_was_confirmed,
+ remove.err(),
+ );
return Err(match rollback_error {
Some(rollback_error) => {
ServiceSqliteTransactionError::rollback_failed(operation_error, rollback_error)
@@ -530,12 +596,12 @@ impl ServiceSqliteHost {
gate.rejected_commit_rolled_back() && !connection.is_in_transaction();
let remove = gate.remove(&mut connection).await.map_err(sqlite_source);
let authority = self.pool.validate();
- if let Some(error) = rollback
- .err()
- .filter(|_| !rollback_was_confirmed)
- .or_else(|| remove.err())
- .or_else(|| authority.err())
- {
+ if let Some(error) = operation_rollback_failure(
+ rollback.err(),
+ rollback_was_confirmed,
+ remove.err(),
+ authority.err(),
+ ) {
return Err(ServiceSqliteTransactionError::rollback_failed(
Some(operation_error),
error,
@@ -575,11 +641,12 @@ impl ServiceSqliteHost {
gate.rejected_commit_rolled_back() && !connection.is_in_transaction();
let remove = gate.remove(&mut connection).await.map_err(sqlite_source);
let authority = self.pool.validate();
- if let Some(rollback_error) = authority
- .err()
- .or_else(|| rollback.err().filter(|_| !rollback_was_confirmed))
- .or_else(|| remove.err())
- {
+ if let Some(rollback_error) = precondition_rollback_failure(
+ authority.err(),
+ rollback.err(),
+ rollback_was_confirmed,
+ remove.err(),
+ ) {
return Err(ServiceSqliteTransactionError::rollback_failed(
None,
rollback_error,
@@ -613,11 +680,8 @@ impl ServiceSqliteHost {
self.pool
.validate()
.map_err(ServiceSqliteTransactionError::commit_outcome_unknown)?;
- if final_policy != initial_policy {
- return Err(ServiceSqliteTransactionError::commit_outcome_unknown(
- ServiceSqliteError::new(ServiceSqliteErrorKind::Pragma),
- ));
- }
+ final_connection_policy_matches(&initial_policy, &final_policy)
+ .map_err(ServiceSqliteTransactionError::commit_outcome_unknown)?;
crate::metadata::verify_database_metadata(&mut connection, self.pool.identity())
.await
.map_err(ServiceSqliteTransactionError::commit_outcome_unknown)?;
@@ -644,9 +708,10 @@ impl ServiceSqliteHost {
&self,
checked_at: crate::IntegrityCheckedAtUnixMs,
) -> Result<ServiceSqliteIntegrityReport, ServiceSqliteError> {
- if self.closing.load(Ordering::Acquire) {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Open));
- }
+ crate::require_condition(
+ !self.closing.load(Ordering::Acquire),
+ ServiceSqliteErrorKind::Open,
+ )?;
let mut driver = self
.integrity_driver
.try_lock()
@@ -654,16 +719,18 @@ impl ServiceSqliteHost {
let cleanup = driver.close_retained().await;
self.pool.validate()?;
cleanup.map_err(|_| ServiceSqliteError::new(ServiceSqliteErrorKind::Integrity))?;
- if self.closing.load(Ordering::Acquire) {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Open));
- }
+ crate::require_condition(
+ !self.closing.load(Ordering::Acquire),
+ ServiceSqliteErrorKind::Open,
+ )?;
self.pool.validate()?;
let connection = self.pool.acquire().await;
self.pool.validate()?;
*driver = IntegrityInspectionDriver::Connected(QuarantinedConnection::new(connection?));
- if self.closing.load(Ordering::Acquire) {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Open));
- }
+ crate::require_condition(
+ !self.closing.load(Ordering::Acquire),
+ ServiceSqliteErrorKind::Open,
+ )?;
let report = crate::integrity::inspect_database_integrity(
driver.connection_mut()?,
checked_at,
@@ -737,14 +804,17 @@ impl ServiceSqliteHost {
database_control_rejected: &AtomicBool,
) -> Result<(), ServiceSqliteError> {
self.pool.validate()?;
- if gate.control_violation_observed() || database_control_rejected.load(Ordering::Acquire) {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Open));
- }
+ crate::require_condition(
+ !gate.control_violation_observed()
+ && !database_control_rejected.load(Ordering::Acquire),
+ ServiceSqliteErrorKind::Open,
+ )?;
crate::migration::assert_governed_transaction(connection).await?;
self.pool.validate()?;
- if &crate::migration::read_connection_policy(connection).await? != initial_policy {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Pragma));
- }
+ crate::require_condition(
+ &crate::migration::read_connection_policy(connection).await? == initial_policy,
+ ServiceSqliteErrorKind::Pragma,
+ )?;
self.pool.validate()?;
crate::metadata::verify_database_metadata(connection, self.pool.identity()).await?;
self.pool.validate()?;
@@ -757,9 +827,10 @@ impl ServiceSqliteHost {
.await?;
self.pool.validate()?;
crate::migration::assert_governed_transaction(connection).await?;
- if gate.control_violation_observed() {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Open));
- }
+ crate::require_condition(
+ !gate.control_violation_observed(),
+ ServiceSqliteErrorKind::Open,
+ )?;
Ok(())
}
}
@@ -1141,6 +1212,104 @@ mod tests {
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn rollback_failure_selection_preserves_each_exact_precedence() {
+ let error = || ServiceSqliteError::new(ServiceSqliteErrorKind::Open);
+ for authority in [false, true] {
+ for rollback in [false, true] {
+ for confirmed in [false, true] {
+ for removal in [false, true] {
+ let expected_precondition = if authority {
+ 1
+ } else if rollback && !confirmed {
+ 2
+ } else if removal {
+ 3
+ } else {
+ 0
+ };
+ let precondition = precondition_rollback_failure(
+ authority.then(error),
+ rollback.then(error),
+ confirmed,
+ removal.then(error),
+ );
+ assert_eq!(
+ usize::from(precondition.is_some()),
+ usize::from(expected_precondition != 0)
+ );
+
+ let expected_drift = (rollback && !confirmed) || removal;
+ assert_eq!(
+ authority_drift_rollback_failure(
+ rollback.then(error),
+ confirmed,
+ removal.then(error),
+ )
+ .is_some(),
+ expected_drift
+ );
+
+ let expected_operation = (rollback && !confirmed) || removal || authority;
+ assert_eq!(
+ operation_rollback_failure(
+ rollback.then(error),
+ confirmed,
+ removal.then(error),
+ authority.then(error),
+ )
+ .is_some(),
+ expected_operation
+ );
+ }
+ }
+ }
+ }
+ assert!(unconfirmed_rollback_error(Some(error()), false).is_some());
+ assert!(unconfirmed_rollback_error(Some(error()), true).is_none());
+ assert!(unconfirmed_rollback_error(None, false).is_none());
+
+ assert!(integrity_driver_close_result(Ok(()), false).is_ok());
+ assert!(matches!(
+ integrity_driver_close_result(Ok(()), true),
+ Err(IntegrityInspectionDriverFailure::ConnectionClose)
+ ));
+ assert!(matches!(
+ integrity_driver_close_result(Err(sqlx::Error::Protocol("close".to_owned())), false),
+ Err(IntegrityInspectionDriverFailure::ConnectionClose)
+ ));
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[tokio::test(flavor = "current_thread")]
+ async fn final_connection_policy_classifier_preserves_pragma_kind() {
+ let mut first =
+ SqliteConnection::connect_with(&SqliteConnectOptions::new().filename(":memory:"))
+ .await
+ .expect("first connection");
+ let mut second =
+ SqliteConnection::connect_with(&SqliteConnectOptions::new().filename(":memory:"))
+ .await
+ .expect("second connection");
+ let initial = crate::migration::read_connection_policy(&mut first)
+ .await
+ .expect("initial policy");
+ let same = crate::migration::read_connection_policy(&mut second)
+ .await
+ .expect("same policy");
+ assert!(final_connection_policy_matches(&initial, &same).is_ok());
+ sqlx::query("PRAGMA query_only = ON")
+ .execute(&mut second)
+ .await
+ .expect("change policy");
+ let changed = crate::migration::read_connection_policy(&mut second)
+ .await
+ .expect("changed policy");
+ let error = final_connection_policy_matches(&initial, &changed).expect_err("policy drift");
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Pragma);
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
fn migration_catalog() -> MigrationCatalog {
MigrationCatalog::new([]).expect("empty v1 migration catalog")
}
diff --git a/crates/service_sqlite/src/initialize.rs b/crates/service_sqlite/src/initialize.rs
@@ -178,6 +178,95 @@ fn initialization_error(cause: InitializationCause) -> ServiceSqliteError {
ServiceSqliteError::with_source(ServiceSqliteErrorKind::Create, cause)
}
+fn require_initialization_condition(
+ condition: bool,
+ kind: InitializationFailureKind,
+) -> Result<(), InitializationCause> {
+ condition
+ .then_some(())
+ .ok_or_else(|| InitializationCause::new(kind))
+}
+
+#[cfg(test)]
+mod failure_tests {
+
+ use super::*;
+
+ #[test]
+ fn initialization_failure_inventory_is_complete_and_source_aware() {
+ let mut cases = vec![
+ (
+ InitializationFailureKind::UnsupportedMode,
+ "SQLite initialization requires initialize mode",
+ ),
+ (
+ InitializationFailureKind::CreateUnavailable,
+ "SQLite state could not be reserved",
+ ),
+ ];
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ cases.extend([
+ (
+ InitializationFailureKind::StateAlreadyExists,
+ "SQLite state already exists",
+ ),
+ (
+ InitializationFailureKind::InvalidDatabase,
+ "SQLite state file has invalid metadata",
+ ),
+ (
+ InitializationFailureKind::SchemaInitializationFailed,
+ "SQLite schema initialization failed",
+ ),
+ (
+ InitializationFailureKind::DatabaseSyncFailed,
+ "SQLite state could not be synchronized",
+ ),
+ (
+ InitializationFailureKind::DatabaseReplaced,
+ "SQLite state identity changed during initialization",
+ ),
+ (
+ InitializationFailureKind::DirectorySyncFailed,
+ "SQLite state directory could not be synchronized",
+ ),
+ (
+ InitializationFailureKind::CleanupFailed,
+ "SQLite initialization cleanup failed",
+ ),
+ (
+ InitializationFailureKind::InjectedFailure,
+ "SQLite initialization durability boundary failed",
+ ),
+ ]);
+
+ for (kind, message) in cases {
+ let plain = InitializationCause::new(kind);
+ assert_eq!(plain.to_string(), message);
+ assert!(plain.source().is_none());
+ assert!(format!("{plain:?}").contains("source: None"));
+ assert!(require_initialization_condition(true, kind).is_ok());
+ assert_eq!(
+ require_initialization_condition(false, kind)
+ .expect_err("false condition")
+ .kind,
+ kind
+ );
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ {
+ let sourced =
+ InitializationCause::with_source(kind, std::io::Error::other("private-cause"));
+ assert_eq!(sourced.to_string(), message);
+ assert!(sourced.source().is_some());
+ let debug = format!("{sourced:?}");
+ assert!(debug.contains("[redacted]"));
+ assert!(!debug.contains("private-cause"));
+ }
+ }
+ }
+}
+
#[cfg(any(target_os = "linux", target_os = "macos"))]
mod supported {
use std::fs::File;
@@ -204,18 +293,21 @@ mod supported {
pub(super) struct SystemInitializationOperations;
impl InitializationOperations for SystemInitializationOperations {
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn sync_database(&self, database: &File) -> Result<(), InitializationCause> {
database.sync_all().map_err(|_| {
InitializationCause::new(InitializationFailureKind::DatabaseSyncFailed)
})
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn sync_directory(&self, directory: &File) -> Result<(), InitializationCause> {
directory.sync_all().map_err(|_| {
InitializationCause::new(InitializationFailureKind::DirectorySyncFailed)
})
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn unlink_database(&self, directory: &File) -> Result<(), InitializationCause> {
unlinkat(
directory,
@@ -267,11 +359,10 @@ mod supported {
fn validate(&self) -> Result<(), InitializationCause> {
let descriptor_identity = validate_descriptor(&self.database)?;
- if descriptor_identity != self.identity {
- return Err(InitializationCause::new(
- InitializationFailureKind::InvalidDatabase,
- ));
- }
+ require_initialization_condition(
+ descriptor_identity == self.identity,
+ InitializationFailureKind::InvalidDatabase,
+ )?;
self.validate_entry()
}
@@ -293,11 +384,10 @@ mod supported {
device,
status.st_ino,
)?;
- if current != self.identity {
- return Err(InitializationCause::new(
- InitializationFailureKind::DatabaseReplaced,
- ));
- }
+ require_initialization_condition(
+ current == self.identity,
+ InitializationFailureKind::DatabaseReplaced,
+ )?;
Ok(())
}
@@ -335,11 +425,10 @@ mod supported {
device,
status.st_ino,
)?;
- if current != self.identity {
- return Err(InitializationCause::new(
- InitializationFailureKind::DatabaseReplaced,
- ));
- }
+ require_initialization_condition(
+ current == self.identity,
+ InitializationFailureKind::DatabaseReplaced,
+ )?;
Ok(())
}
@@ -376,11 +465,10 @@ mod supported {
if self.committed {
return Ok(());
}
- if self.current_entry_identity()? != self.identity {
- return Err(InitializationCause::new(
- InitializationFailureKind::DatabaseReplaced,
- ));
- }
+ require_initialization_condition(
+ self.current_entry_identity()? == self.identity,
+ InitializationFailureKind::DatabaseReplaced,
+ )?;
self.operations.unlink_database(self.directory)?;
self.operations.sync_directory(self.directory)?;
self.committed = true;
@@ -432,34 +520,41 @@ mod supported {
device: u64,
inode: u64,
) -> Result<FileIdentity, InitializationCause> {
- if !is_regular_file
- || link_count != 1
- || actual_uid != geteuid().as_raw()
- || mode & 0o777 != 0o600
- {
- return Err(InitializationCause::new(
- InitializationFailureKind::InvalidDatabase,
- ));
- }
+ require_initialization_condition(
+ crate::native_metadata::exact_regular_file(
+ is_regular_file,
+ link_count,
+ actual_uid,
+ geteuid().as_raw(),
+ mode,
+ ),
+ InitializationFailureKind::InvalidDatabase,
+ )?;
Ok(FileIdentity { device, inode })
}
- async fn fail_with_rollback<O: InitializationOperations>(
- mut pending: PendingDatabase<'_, O>,
+ fn rollback_failure(
primary: InitializationCause,
- ) -> Result<WriterAuthority, ServiceSqliteError> {
- match pending.rollback() {
- Ok(()) => Err(initialization_error(primary)),
+ cleanup: Result<(), InitializationCause>,
+ ) -> ServiceSqliteError {
+ match cleanup {
+ Ok(()) => initialization_error(primary),
Err(_cleanup) if primary.kind == InitializationFailureKind::DatabaseReplaced => {
- Err(initialization_error(primary))
+ initialization_error(primary)
+ }
+ Err(cleanup) => {
+ initialization_error(InitializationCause::with_source(cleanup.kind, primary))
}
- Err(cleanup) => Err(initialization_error(InitializationCause::with_source(
- cleanup.kind,
- primary,
- ))),
}
}
+ async fn fail_with_rollback<O: InitializationOperations>(
+ mut pending: PendingDatabase<'_, O>,
+ primary: InitializationCause,
+ ) -> Result<WriterAuthority, ServiceSqliteError> {
+ Err(rollback_failure(primary, pending.rollback()))
+ }
+
async fn fail_metadata_with_rollback<O: InitializationOperations>(
mut pending: PendingDatabase<'_, O>,
primary: ServiceSqliteError,
@@ -847,6 +942,31 @@ mod supported {
}
#[tokio::test(flavor = "current_thread")]
+ async fn mismatched_metadata_paths_have_zero_callback_and_filesystem_effects() {
+ let root = tempfile::tempdir().expect("root");
+ let expected_paths = paths(root.path(), "expected");
+ let other = paths(root.path(), "other");
+ let called = Cell::new(false);
+ let other_metadata = metadata(&other);
+ let error = initialize_database(
+ &expected_paths,
+ OpenMode::Initialize,
+ &other_metadata,
+ &base_schema_catalog(),
+ |_| {
+ called.set(true);
+ ready(Ok::<(), CallbackFailure>(()))
+ },
+ )
+ .await
+ .expect_err("metadata paths must reject");
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Metadata);
+ assert!(!called.get());
+ assert!(!expected_paths.state_database().exists());
+ assert!(!expected_paths.state_lock().exists());
+ }
+
+ #[tokio::test(flavor = "current_thread")]
async fn callback_failure_cleans_up_releases_authority_and_preserves_trusted_cause() {
let root = tempfile::tempdir().expect("root");
let paths = paths(root.path(), "callback-failure");
@@ -1158,6 +1278,43 @@ mod supported {
}
}
+ #[test]
+ fn rollback_failure_classifier_preserves_primary_and_cleanup_precedence() {
+ let ordinary =
+ || InitializationCause::new(InitializationFailureKind::SchemaInitializationFailed);
+ let replaced = || InitializationCause::new(InitializationFailureKind::DatabaseReplaced);
+ let cleanup = || InitializationCause::new(InitializationFailureKind::CleanupFailed);
+
+ let ordinary_success = rollback_failure(ordinary(), Ok(()));
+ assert_eq!(ordinary_success.kind(), ServiceSqliteErrorKind::Create);
+ assert_eq!(
+ ordinary_success
+ .source()
+ .map(ToString::to_string)
+ .as_deref(),
+ Some("SQLite schema initialization failed")
+ );
+
+ let replaced_failure = rollback_failure(replaced(), Err(cleanup()));
+ assert_eq!(replaced_failure.kind(), ServiceSqliteErrorKind::Create);
+ assert_eq!(
+ replaced_failure
+ .source()
+ .map(ToString::to_string)
+ .as_deref(),
+ Some("SQLite state identity changed during initialization")
+ );
+
+ let cleanup_failure = rollback_failure(ordinary(), Err(cleanup()));
+ assert_eq!(cleanup_failure.kind(), ServiceSqliteErrorKind::Create);
+ let outer = cleanup_failure.source().expect("cleanup source");
+ assert_eq!(outer.to_string(), "SQLite initialization cleanup failed");
+ assert_eq!(
+ outer.source().map(ToString::to_string).as_deref(),
+ Some("SQLite schema initialization failed")
+ );
+ }
+
#[tokio::test(flavor = "current_thread")]
async fn every_initialization_durability_edge_fails_once_and_rolls_back() {
use crate::failpoint::{DurabilityFailpoint, DurabilityFailpoints};
diff --git a/crates/service_sqlite/src/integrity/catalog.rs b/crates/service_sqlite/src/integrity/catalog.rs
@@ -357,9 +357,11 @@ impl SchemaCatalog {
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn matches_migrations(&self, migrations: &MigrationCatalog) -> bool {
- self.migration_catalog_digest == migrations.digest()
- && self.versions.len()
- == usize::try_from(migrations.current_version()).unwrap_or(usize::MAX)
+ crate::all_constraints([
+ self.migration_catalog_digest == migrations.digest(),
+ self.versions.len()
+ == usize::try_from(migrations.current_version()).unwrap_or(usize::MAX),
+ ])
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -520,16 +522,20 @@ fn validate_service_object(
table_name: &str,
sql: &str,
) -> Result<(), SchemaCatalogContractError> {
- if !valid_name(name) || !valid_name(table_name) {
+ if !crate::all_constraints([valid_name(name), valid_name(table_name)]) {
return Err(SchemaCatalogContractError::InvalidName);
}
- if is_reserved(name) || is_reserved(table_name) {
+ if !crate::all_constraints([!is_reserved(name), !is_reserved(table_name)]) {
return Err(SchemaCatalogContractError::ReservedName);
}
if (kind == SchemaObjectKind::Table) != (name == table_name) {
return Err(SchemaCatalogContractError::InvalidBinding);
}
- if sql.is_empty() || sql.len() > MAX_SCHEMA_SQL_UTF8_BYTES || sql.as_bytes().contains(&0) {
+ if !crate::all_constraints([
+ !sql.is_empty(),
+ sql.len() <= MAX_SCHEMA_SQL_UTF8_BYTES,
+ !sql.as_bytes().contains(&0),
+ ]) {
return Err(SchemaCatalogContractError::InvalidSql);
}
Ok(())
@@ -565,14 +571,18 @@ fn validate_object_set(objects: &[SchemaObject]) -> Result<(), SchemaCatalogCont
fn valid_name(value: &str) -> bool {
let bytes = value.as_bytes();
- !bytes.is_empty()
- && bytes.len() <= MAX_SCHEMA_NAME_UTF8_BYTES
- && bytes[0].is_ascii_lowercase()
- && bytes[bytes.len() - 1].is_ascii_alphanumeric()
- && !bytes.windows(2).any(|pair| pair == b"__")
- && bytes
+ if bytes.is_empty() {
+ return false;
+ }
+ crate::all_constraints([
+ bytes.len() <= MAX_SCHEMA_NAME_UTF8_BYTES,
+ bytes[0].is_ascii_lowercase(),
+ bytes[bytes.len() - 1].is_ascii_alphanumeric(),
+ !bytes.windows(2).any(|pair| pair == b"__"),
+ bytes
.iter()
- .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_')
+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_'),
+ ])
}
fn is_reserved(value: &str) -> bool {
@@ -754,6 +764,12 @@ mod tests {
),
Err(SchemaCatalogContractError::InvalidName)
);
+ for invalid in ["2alpha", "alpha_", "alpha-beta"] {
+ assert_eq!(
+ SchemaObject::computed_digest(SchemaObjectKind::Table, invalid, invalid, "x"),
+ Err(SchemaCatalogContractError::InvalidName)
+ );
+ }
let maximum_sql = Box::leak("x".repeat(MAX_SCHEMA_SQL_UTF8_BYTES).into_boxed_str());
assert!(
@@ -775,6 +791,21 @@ mod tests {
),
Err(SchemaCatalogContractError::InvalidSql)
);
+ assert_eq!(
+ SchemaObject::computed_digest(SchemaObjectKind::Table, "nul_sql", "nul_sql", "x\0y",),
+ Err(SchemaCatalogContractError::InvalidSql)
+ );
+
+ assert_eq!(
+ SchemaObject::new(
+ SchemaObjectKind::Table,
+ "alpha",
+ "alpha",
+ TABLE_SQL,
+ SchemaDigest::from_bytes([0; 32]),
+ ),
+ Err(SchemaCatalogContractError::ObjectDigestMismatch)
+ );
}
#[test]
@@ -846,6 +877,18 @@ mod tests {
let migrations = empty_migrations();
let digest = SchemaVersionCatalog::computed_digest(1, []).unwrap();
let v1 = SchemaVersionCatalog::new(1, [], digest).unwrap();
+ assert_eq!(
+ SchemaVersionCatalog::new(0, [], SchemaDigest::from_bytes([0; 32])),
+ Err(SchemaCatalogContractError::InvalidVersionSequence)
+ );
+ assert_eq!(
+ SchemaVersionCatalog::computed_digest(0, []),
+ Err(SchemaCatalogContractError::InvalidVersionSequence)
+ );
+ assert_eq!(
+ SchemaVersionCatalog::new(1, [], SchemaDigest::from_bytes([0; 32])),
+ Err(SchemaCatalogContractError::SnapshotDigestMismatch)
+ );
assert!(SchemaCatalog::new(&migrations, [v1]).is_ok());
assert_eq!(
SchemaCatalog::new(&migrations, []),
@@ -885,5 +928,34 @@ mod tests {
SchemaCatalog::new(&maximum_migrations, [v1, duplicate]),
Err(SchemaCatalogContractError::InvalidVersionSequence)
);
+
+ let excessive_objects = std::iter::repeat_with(table).take(MAX_SCHEMA_OBJECT_COUNT + 1);
+ assert_eq!(
+ SchemaVersionCatalog::new(1, excessive_objects, SchemaDigest::from_bytes([0; 32])),
+ Err(SchemaCatalogContractError::TooManyObjects)
+ );
+
+ let gap_v2 = SchemaVersionCatalog { version: 2, ..v1 };
+ assert_eq!(
+ SchemaCatalog::new(&migrations, [gap_v2]),
+ Err(SchemaCatalogContractError::InvalidVersionSequence)
+ );
+
+ let v1_for_exact = v1;
+ let exact = SchemaCatalog::new(&migrations, [v1_for_exact]).expect("exact catalog");
+ assert!(exact.matches_migrations(&migrations));
+ assert_eq!(exact.version(0), None);
+ assert_eq!(exact.version(1), Some(v1));
+ assert_eq!(exact.version(2), None);
+
+ let callback = crate::MigrationDescriptor::callback(
+ 2,
+ "next_schema",
+ b"definition",
+ MigrationChecksum::for_callback(b"definition"),
+ )
+ .expect("migration");
+ let other_migrations = MigrationCatalog::new([callback]).expect("other migrations");
+ assert!(!exact.matches_migrations(&other_migrations));
}
}
diff --git a/crates/service_sqlite/src/integrity/mod.rs b/crates/service_sqlite/src/integrity/mod.rs
@@ -173,9 +173,7 @@ pub(crate) async fn verify_schema_catalog(
.fetch_all(connection)
.await
.map_err(|_| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
- if rows.len() > MAX_SCHEMA_OBJECT_COUNT {
- return Err(integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt));
- }
+ require_schema_row_limit(rows.len())?;
let mut objects = Vec::with_capacity(rows.len());
let mut reported_count = None;
let mut reported_total = None;
@@ -190,13 +188,12 @@ pub(crate) async fn verify_schema_catalog(
.ok()
.and_then(|value| usize::try_from(value).ok())
.ok_or_else(|| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
- if count > u32::try_from(MAX_SCHEMA_OBJECT_COUNT).unwrap_or(u32::MAX)
- || total > MAX_SCHEMA_CATALOG_UTF8_BYTES
- || reported_count.is_some_and(|observed| observed != count)
- || reported_total.is_some_and(|observed| observed != total)
- {
- return Err(integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt));
- }
+ require_catalog_summary_projection([
+ count <= u32::try_from(MAX_SCHEMA_OBJECT_COUNT).unwrap_or(u32::MAX),
+ total <= MAX_SCHEMA_CATALOG_UTF8_BYTES,
+ reported_count.is_none_or(|observed| observed == count),
+ reported_total.is_none_or(|observed| observed == total),
+ ])?;
reported_count = Some(count);
reported_total = Some(total);
let object_type = row
@@ -211,17 +208,10 @@ pub(crate) async fn verify_schema_catalog(
let sql = row
.try_get::<String, _>("bounded_sql")
.map_err(|_| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
- if sql.len() > MAX_SCHEMA_SQL_UTF8_BYTES {
- return Err(integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt));
- }
+ require_schema_sql_limit(sql.len())?;
let kind = SchemaObjectKind::from_sqlite(&object_type)
.ok_or_else(|| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
- if !runtime_name_is_valid(&name)
- || !runtime_name_is_valid(&table_name)
- || (kind == SchemaObjectKind::Table) != (name == table_name)
- {
- return Err(integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt));
- }
+ require_runtime_object_projection(kind, &name, &table_name)?;
let digest = object_digest(kind, &name, &table_name, &sql);
objects.push(RuntimeSchemaObject {
kind,
@@ -233,16 +223,13 @@ pub(crate) async fn verify_schema_catalog(
}
let actual_count = u32::try_from(objects.len())
.map_err(|_| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
- if reported_count.unwrap_or(0) != actual_count {
- return Err(integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt));
- }
+ require_reported_object_count(reported_count, actual_count)?;
let mut identities = std::collections::BTreeSet::new();
- if objects
- .iter()
- .any(|object| !identities.insert((object.kind, object.name.as_str())))
- {
- return Err(integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt));
- }
+ require_unique_runtime_objects(
+ !objects
+ .iter()
+ .any(|object| !identities.insert((object.kind, object.name.as_str()))),
+ )?;
let refs = objects
.iter()
.map(|object| ObjectRef {
@@ -261,12 +248,44 @@ pub(crate) async fn verify_schema_catalog(
expected_digest: expected.digest(),
actual_digest,
};
- if report.expected_count != report.actual_count
- || report.expected_digest != report.actual_digest
- {
- return Err(mismatch_error(report));
+ require_schema_report_projection(report)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_catalog_summary_projection(matches: [bool; 4]) -> Result<(), ServiceSqliteError> {
+ crate::all_constraints(matches)
+ .then_some(())
+ .ok_or_else(|| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_runtime_object_projection(
+ kind: SchemaObjectKind,
+ name: &str,
+ table_name: &str,
+) -> Result<(), ServiceSqliteError> {
+ crate::all_constraints([
+ runtime_name_is_valid(name),
+ runtime_name_is_valid(table_name),
+ (kind == SchemaObjectKind::Table) == (name == table_name),
+ ])
+ .then_some(())
+ .ok_or_else(|| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_schema_report_projection(
+ report: SchemaVerificationReport,
+) -> Result<SchemaVerificationReport, ServiceSqliteError> {
+ let matches = crate::all_constraints([
+ report.expected_count == report.actual_count,
+ report.expected_digest == report.actual_digest,
+ ]);
+ if matches {
+ Ok(report)
+ } else {
+ Err(mismatch_error(report))
}
- Ok(report)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -278,35 +297,78 @@ pub(crate) async fn verify_database_integrity(
.await
.map_err(|_| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
let value = rows.first().and_then(|row| row.try_get::<&str, _>(0).ok());
- if rows.len() != 1 || value.is_none_or(|value| value.len() > 64 || value != "ok") {
- return Err(integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt));
- }
+ catalog_corrupt_unless(integrity_projection_matches(rows.len(), value))?;
let foreign_key_violation =
sqlx::query_scalar::<_, i64>("SELECT 1 FROM pragma_foreign_key_check LIMIT 1")
.fetch_optional(connection)
.await
.map_err(|_| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))?;
- if foreign_key_violation.is_some() {
- return Err(integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt));
- }
- Ok(())
+ require_no_foreign_key_violation(foreign_key_violation.is_some())
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn catalog_corrupt_unless(condition: bool) -> Result<(), ServiceSqliteError> {
+ condition
+ .then_some(())
+ .ok_or_else(|| integrity_error(SchemaIntegrityFailureKind::CatalogCorrupt))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_schema_row_limit(row_count: usize) -> Result<(), ServiceSqliteError> {
+ catalog_corrupt_unless(row_count <= MAX_SCHEMA_OBJECT_COUNT)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_schema_sql_limit(sql_bytes: usize) -> Result<(), ServiceSqliteError> {
+ catalog_corrupt_unless(sql_bytes <= MAX_SCHEMA_SQL_UTF8_BYTES)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_reported_object_count(
+ reported: Option<u32>,
+ actual: u32,
+) -> Result<(), ServiceSqliteError> {
+ catalog_corrupt_unless(reported.unwrap_or(0) == actual)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_unique_runtime_objects(unique: bool) -> Result<(), ServiceSqliteError> {
+ catalog_corrupt_unless(unique)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_no_foreign_key_violation(present: bool) -> Result<(), ServiceSqliteError> {
+ catalog_corrupt_unless(!present)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn integrity_projection_matches(row_count: usize, value: Option<&str>) -> bool {
+ let present = value.is_some();
+ let bounded = value.is_some_and(|value| value.len() <= 64);
+ let exact = value == Some("ok");
+ crate::all_constraints([row_count == 1, present, bounded, exact])
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn runtime_name_is_valid(value: &str) -> bool {
let bytes = value.as_bytes();
- !bytes.is_empty()
- && bytes.len() <= 128
- && bytes[0].is_ascii_lowercase()
- && bytes[bytes.len() - 1].is_ascii_alphanumeric()
- && !bytes.windows(2).any(|pair| pair == b"__")
- && bytes
+ if bytes.is_empty() {
+ return false;
+ }
+ crate::all_constraints([
+ bytes.len() <= 128,
+ bytes[0].is_ascii_lowercase(),
+ bytes[bytes.len() - 1].is_ascii_alphanumeric(),
+ !bytes.windows(2).any(|pair| pair == b"__"),
+ bytes
.iter()
- .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_')
+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_'),
+ ])
}
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
mod tests {
+
use super::*;
use sqlx::{Connection, Executor, sqlite::SqliteConnectOptions};
@@ -315,6 +377,94 @@ mod tests {
const INDEX_SQL: &str = "CREATE INDEX alpha_value_idx ON alpha(value)";
const TRIGGER_SQL: &str = "CREATE TRIGGER alpha_guard BEFORE UPDATE ON alpha BEGIN SELECT RAISE(ABORT, 'blocked'); END";
+ #[test]
+ fn schema_integrity_failure_inventory_is_complete_and_source_free() {
+ for (kind, message) in [
+ (
+ SchemaIntegrityFailureKind::CatalogMismatch,
+ "SQLite schema object catalog does not match",
+ ),
+ (
+ SchemaIntegrityFailureKind::CatalogCorrupt,
+ "SQLite schema object catalog is invalid",
+ ),
+ ] {
+ let failure = SchemaIntegrityFailure { kind, report: None };
+ assert_eq!(failure.to_string(), message);
+ assert!(failure.source().is_none());
+ assert!(format!("{failure:?}").contains(&format!("{kind:?}")));
+ let error = integrity_error(kind);
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Integrity);
+ assert!(error.source().is_some());
+ }
+ }
+
+ #[test]
+ fn database_integrity_projection_rejects_each_independent_drift() {
+ assert!(integrity_projection_matches(1, Some("ok")));
+ assert!(!integrity_projection_matches(0, Some("ok")));
+ assert!(!integrity_projection_matches(2, Some("ok")));
+ assert!(!integrity_projection_matches(1, None));
+ assert!(!integrity_projection_matches(1, Some("not-ok")));
+ let oversized = "x".repeat(65);
+ assert!(!integrity_projection_matches(1, Some(&oversized)));
+ }
+
+ #[test]
+ fn catalog_projection_helpers_reject_every_independent_drift() {
+ assert!(require_catalog_summary_projection([true; 4]).is_ok());
+ for changed in 0..4 {
+ let mut matches = [true; 4];
+ matches[changed] = false;
+ assert!(require_catalog_summary_projection(matches).is_err());
+ }
+
+ assert!(
+ require_runtime_object_projection(SchemaObjectKind::Table, "alpha", "alpha").is_ok()
+ );
+ assert!(
+ require_runtime_object_projection(SchemaObjectKind::Index, "alpha_idx", "alpha")
+ .is_ok()
+ );
+ for (kind, name, table) in [
+ (SchemaObjectKind::Table, "Bad", "Bad"),
+ (SchemaObjectKind::Table, "alpha", "Bad"),
+ (SchemaObjectKind::Table, "alpha", "other"),
+ (SchemaObjectKind::Index, "alpha", "alpha"),
+ ] {
+ assert!(require_runtime_object_projection(kind, name, table).is_err());
+ }
+
+ let digest = SchemaDigest::from_bytes([7; 32]);
+ let mut report = SchemaVerificationReport {
+ version: 1,
+ expected_count: 1,
+ actual_count: 1,
+ expected_digest: digest,
+ actual_digest: digest,
+ };
+ assert!(require_schema_report_projection(report.clone()).is_ok());
+ report.actual_count = 2;
+ assert!(require_schema_report_projection(report.clone()).is_err());
+ report.actual_count = 1;
+ report.actual_digest = SchemaDigest::from_bytes([8; 32]);
+ assert!(require_schema_report_projection(report).is_err());
+
+ assert!(catalog_corrupt_unless(true).is_ok());
+ assert!(catalog_corrupt_unless(false).is_err());
+ assert!(require_schema_row_limit(MAX_SCHEMA_OBJECT_COUNT).is_ok());
+ assert!(require_schema_row_limit(MAX_SCHEMA_OBJECT_COUNT + 1).is_err());
+ assert!(require_schema_sql_limit(MAX_SCHEMA_SQL_UTF8_BYTES).is_ok());
+ assert!(require_schema_sql_limit(MAX_SCHEMA_SQL_UTF8_BYTES + 1).is_err());
+ assert!(require_reported_object_count(Some(1), 1).is_ok());
+ assert!(require_reported_object_count(None, 1).is_err());
+ assert!(require_reported_object_count(Some(2), 1).is_err());
+ assert!(require_unique_runtime_objects(true).is_ok());
+ assert!(require_unique_runtime_objects(false).is_err());
+ assert!(require_no_foreign_key_violation(false).is_ok());
+ assert!(require_no_foreign_key_violation(true).is_err());
+ }
+
fn object(
kind: SchemaObjectKind,
name: &'static str,
@@ -446,4 +596,18 @@ mod tests {
assert!(!error.to_string().contains(&oversized));
assert!(!format!("{error:?}").contains(&oversized));
}
+
+ #[test]
+ fn runtime_names_bind_every_grammar_constraint() {
+ assert!(runtime_name_is_valid("a"));
+ assert!(runtime_name_is_valid("alpha_2"));
+ assert!(!runtime_name_is_valid(""));
+ assert!(!runtime_name_is_valid("Alpha"));
+ assert!(!runtime_name_is_valid("2alpha"));
+ assert!(!runtime_name_is_valid("alpha_"));
+ assert!(!runtime_name_is_valid("alpha__beta"));
+ assert!(!runtime_name_is_valid("alpha-beta"));
+ assert!(runtime_name_is_valid(&"a".repeat(128)));
+ assert!(!runtime_name_is_valid(&"a".repeat(129)));
+ }
}
diff --git a/crates/service_sqlite/src/lib.rs b/crates/service_sqlite/src/lib.rs
@@ -1,4 +1,5 @@
#![forbid(unsafe_code)]
+#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
//! Reusable, service-neutral SQLite mechanics for Radroots services.
@@ -20,6 +21,21 @@ mod restore;
mod status;
mod transaction_control;
+pub(crate) fn all_constraints<const N: usize>(constraints: [bool; N]) -> bool {
+ constraints.into_iter().all(core::convert::identity)
+}
+
+pub(crate) fn require_condition(
+ condition: bool,
+ kind: ServiceSqliteErrorKind,
+) -> Result<(), ServiceSqliteError> {
+ if condition {
+ Ok(())
+ } else {
+ Err(ServiceSqliteError::new(kind))
+ }
+}
+
pub use authority::WriterAuthority;
pub use backup::{
BACKUP_MANIFEST_CANONICAL_MAX_BYTES, BACKUP_MANIFEST_SCHEMA, BACKUP_MANIFEST_SCHEMA_VERSION,
@@ -58,3 +74,32 @@ pub use status::{
StateFilesystemCapacityError, StateFilesystemCapacityReadiness, StateFilesystemCapacitySource,
StorageHealth, StorageIntegrity, StorageStatus, inspect_state_filesystem_capacity,
};
+
+#[cfg(test)]
+mod coverage_tests {
+ use super::*;
+
+ #[test]
+ fn shared_condition_classifier_preserves_every_stable_error_kind() {
+ for kind in [
+ ServiceSqliteErrorKind::Authority,
+ ServiceSqliteErrorKind::Open,
+ ServiceSqliteErrorKind::Create,
+ ServiceSqliteErrorKind::Pragma,
+ ServiceSqliteErrorKind::Metadata,
+ ServiceSqliteErrorKind::Migration,
+ ServiceSqliteErrorKind::Backup,
+ ServiceSqliteErrorKind::Restore,
+ ServiceSqliteErrorKind::Integrity,
+ ServiceSqliteErrorKind::Recovery,
+ ] {
+ assert!(require_condition(true, kind).is_ok());
+ assert_eq!(
+ require_condition(false, kind)
+ .expect_err("false condition")
+ .kind(),
+ kind
+ );
+ }
+ }
+}
diff --git a/crates/service_sqlite/src/metadata.rs b/crates/service_sqlite/src/metadata.rs
@@ -17,6 +17,10 @@ use sqlx::{Connection, Row, SqliteConnection};
const MAX_APPLICATION_ID: u32 = i32::MAX as u32;
const MAX_CREATED_AT_UNIX_MS: u64 = i64::MAX as u64;
+const fn valid_creation_time(value: u64) -> bool {
+ value != 0 && value <= MAX_CREATED_AT_UNIX_MS
+}
+
/// A validated nonzero SQLite application identifier.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ServiceSqliteApplicationId(u32);
@@ -86,7 +90,7 @@ impl ServiceDatabaseMetadata {
created_at_unix_ms: u64,
application_id: ServiceSqliteApplicationId,
) -> Result<Self, ServiceSqliteMetadataValueError> {
- if created_at_unix_ms == 0 || created_at_unix_ms > MAX_CREATED_AT_UNIX_MS {
+ if !valid_creation_time(created_at_unix_ms) {
return Err(ServiceSqliteMetadataValueError::InvalidCreationTime);
}
Ok(Self {
@@ -107,7 +111,7 @@ impl ServiceDatabaseMetadata {
created_at_unix_ms: u64,
application_id: ServiceSqliteApplicationId,
) -> Result<Self, ServiceSqliteMetadataValueError> {
- if created_at_unix_ms == 0 || created_at_unix_ms > MAX_CREATED_AT_UNIX_MS {
+ if !valid_creation_time(created_at_unix_ms) {
return Err(ServiceSqliteMetadataValueError::InvalidCreationTime);
}
Ok(Self {
@@ -169,7 +173,10 @@ impl ServiceDatabaseMetadata {
}
pub(crate) fn matches_paths(&self, paths: &ServiceSqlitePaths) -> bool {
- self.service == *paths.service() && self.instance == *paths.instance()
+ crate::all_constraints([
+ self.service == *paths.service(),
+ self.instance == *paths.instance(),
+ ])
}
}
@@ -223,7 +230,10 @@ impl ServiceDatabaseIdentity {
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn matches_paths(&self, paths: &ServiceSqlitePaths) -> bool {
- self.service == *paths.service() && self.instance == *paths.instance()
+ crate::all_constraints([
+ self.service == *paths.service(),
+ self.instance == *paths.instance(),
+ ])
}
}
@@ -293,6 +303,14 @@ fn metadata_error(kind: MetadataFailureKind) -> ServiceSqliteError {
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_metadata_condition(
+ condition: bool,
+ kind: MetadataFailureKind,
+) -> Result<(), ServiceSqliteError> {
+ condition.then_some(()).ok_or_else(|| metadata_error(kind))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
#[derive(Debug)]
struct MigrationLedgerInitializationFailure;
@@ -378,9 +396,7 @@ pub(crate) async fn write_database_metadata(
.map_err(|_| metadata_error(MetadataFailureKind::Storage))?;
let actual = read_database_metadata(connection).await?;
- if actual != *expected {
- return Err(metadata_error(MetadataFailureKind::Mismatch));
- }
+ require_metadata_condition(actual == *expected, MetadataFailureKind::Mismatch)?;
Ok(())
}
@@ -390,14 +406,16 @@ pub(crate) async fn verify_database_metadata(
expected: &ServiceDatabaseIdentity,
) -> Result<ServiceDatabaseMetadata, ServiceSqliteError> {
let actual = read_database_metadata(connection).await?;
- if actual.service != expected.service
- || actual.instance != expected.instance
- || actual.source_generation != expected.source_generation
- || actual.application_id != expected.application_id
- || actual.state_schema_version > expected.supported_state_schema_version
- {
- return Err(metadata_error(MetadataFailureKind::Mismatch));
- }
+ require_metadata_condition(
+ crate::all_constraints([
+ actual.service == expected.service,
+ actual.instance == expected.instance,
+ actual.source_generation == expected.source_generation,
+ actual.application_id == expected.application_id,
+ actual.state_schema_version <= expected.supported_state_schema_version,
+ ]),
+ MetadataFailureKind::Mismatch,
+ )?;
Ok(actual)
}
@@ -450,13 +468,12 @@ async fn read_database_metadata(
return Err(metadata_error(MetadataFailureKind::Corrupt));
}
}
- if row
- .try_get::<i64, _>("singleton")
- .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?
- != 1
- {
- return Err(metadata_error(MetadataFailureKind::Corrupt));
- }
+ require_metadata_condition(
+ row.try_get::<i64, _>("singleton")
+ .map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?
+ == 1,
+ MetadataFailureKind::Corrupt,
+ )?;
let service = ServiceId::new(
row.try_get::<String, _>("service_id")
.map_err(|_| metadata_error(MetadataFailureKind::Corrupt))?,
@@ -572,8 +589,31 @@ mod tests {
crate::SchemaCatalog::new(&migrations, [version]).expect("base schema catalog")
}
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn metadata_failure_inventory_preserves_kind_and_trusted_source() {
+ for kind in [
+ MetadataFailureKind::AlreadyPresent,
+ MetadataFailureKind::Missing,
+ MetadataFailureKind::Corrupt,
+ MetadataFailureKind::Mismatch,
+ MetadataFailureKind::Storage,
+ ] {
+ assert!(require_metadata_condition(true, kind).is_ok());
+ let error = require_metadata_condition(false, kind).expect_err("false condition");
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Metadata);
+ assert!(error.source().is_some());
+ assert!(!error.to_string().contains('/'));
+ assert!(format!("{error:?}").contains("Metadata"));
+ }
+ }
+
#[test]
fn application_id_and_creation_time_bounds_are_exact() {
+ assert!(valid_creation_time(1));
+ assert!(valid_creation_time(MAX_CREATED_AT_UNIX_MS));
+ assert!(!valid_creation_time(0));
+ assert!(!valid_creation_time(MAX_CREATED_AT_UNIX_MS + 1));
assert_eq!(
ServiceSqliteApplicationId::new(0),
Err(ServiceSqliteMetadataValueError::InvalidApplicationId)
@@ -873,6 +913,22 @@ mod tests {
(1, 'myc', 'primary', randomblob(32), 1, 0)",
"INSERT INTO radroots_service_metadata VALUES
('1', 'myc', 'primary', randomblob(32), 1, 1700000000000)",
+ "INSERT INTO radroots_service_metadata VALUES
+ (1, 7, 'primary', randomblob(32), 1, 1700000000000)",
+ "INSERT INTO radroots_service_metadata VALUES
+ (1, 'myc', 7, randomblob(32), 1, 1700000000000)",
+ "INSERT INTO radroots_service_metadata VALUES
+ (1, 'myc', 'primary', 'not-a-generation', 1, 1700000000000)",
+ "INSERT INTO radroots_service_metadata VALUES
+ (1, 'myc', 'primary', randomblob(32), '1', 1700000000000)",
+ "INSERT INTO radroots_service_metadata VALUES
+ (1, 'myc', 'primary', randomblob(32), 1, '1700000000000')",
+ "INSERT INTO radroots_service_metadata VALUES
+ (1, 'myc', 'primary', randomblob(32), -1, 1700000000000)",
+ "INSERT INTO radroots_service_metadata VALUES
+ (1, 'myc', 'primary', randomblob(32), 4294967296, 1700000000000)",
+ "INSERT INTO radroots_service_metadata VALUES
+ (1, 'myc', 'primary', randomblob(32), 1, -1)",
];
for corrupt_row in corrupt_rows {
let mut connection = memory_connection().await;
@@ -899,5 +955,67 @@ mod tests {
"accepted corrupt fixture `{corrupt_row}`"
);
}
+
+ for (application_id, statement) in [
+ (0_i64, "PRAGMA application_id = 0"),
+ (-1, "PRAGMA application_id = -1"),
+ ] {
+ let mut connection = memory_connection().await;
+ sqlx::raw_sql(PERMISSIVE_TABLE)
+ .execute(&mut connection)
+ .await
+ .expect("permissive metadata table");
+ sqlx::query(statement)
+ .execute(&mut connection)
+ .await
+ .expect("invalid application ID fixture");
+ sqlx::query(
+ "INSERT INTO radroots_service_metadata VALUES
+ (1, 'myc', 'primary', randomblob(32), 1, 1700000000000)",
+ )
+ .execute(&mut connection)
+ .await
+ .expect("otherwise valid metadata row");
+ assert_eq!(
+ verify_database_metadata(&mut connection, &expected.identity())
+ .await
+ .expect_err("invalid application ID")
+ .kind(),
+ ServiceSqliteErrorKind::Metadata,
+ "accepted application ID {application_id}"
+ );
+ }
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn verified_backup_metadata_rejects_invalid_creation_time() {
+ let service = ServiceId::new("myc").expect("service");
+ let instance = InstanceId::new("primary").expect("instance");
+ let generation = SourceGeneration::new([7; 32]).expect("generation");
+ let schema = NonZeroU32::new(1).expect("schema");
+ let application = ServiceSqliteApplicationId::new(0x5244_5351).expect("application");
+ assert!(
+ ServiceDatabaseMetadata::from_verified_backup(
+ service.clone(),
+ instance.clone(),
+ generation,
+ schema,
+ 0,
+ application,
+ )
+ .is_err()
+ );
+ assert!(
+ ServiceDatabaseMetadata::from_verified_backup(
+ service,
+ instance,
+ generation,
+ schema,
+ i64::MAX as u64 + 1,
+ application,
+ )
+ .is_err()
+ );
}
}
diff --git a/crates/service_sqlite/src/migration.rs b/crates/service_sqlite/src/migration.rs
@@ -794,6 +794,14 @@ fn migration_source(
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_migration_condition(
+ condition: bool,
+ kind: MigrationFailureKind,
+) -> Result<(), ServiceSqliteError> {
+ condition.then_some(()).ok_or_else(|| migration_error(kind))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
#[derive(Clone, PartialEq, Eq)]
struct AppliedMigration {
version: u32,
@@ -810,9 +818,10 @@ pub(crate) async fn verify_migration_history(
schema_catalog: &SchemaCatalog,
require_current: bool,
) -> Result<u32, ServiceSqliteError> {
- if !schema_catalog.matches_migrations(catalog) {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Integrity));
- }
+ schema_catalog
+ .matches_migrations(catalog)
+ .then_some(())
+ .ok_or_else(|| ServiceSqliteError::new(ServiceSqliteErrorKind::Integrity))?;
let mut transaction = connection
.begin()
.await
@@ -895,9 +904,10 @@ where
V: FnMut() -> Result<(), ServiceSqliteError>,
O: FnMut() -> Result<(), ServiceSqliteError>,
{
- if !schema_catalog.matches_migrations(catalog) {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Integrity));
- }
+ schema_catalog
+ .matches_migrations(catalog)
+ .then_some(())
+ .ok_or_else(|| ServiceSqliteError::new(ServiceSqliteErrorKind::Integrity))?;
let callbacks = validate_callback_bindings(catalog, callback_bindings)?;
let mut initial_version = None;
let mut applied_count = 0_u32;
@@ -948,17 +958,19 @@ where
let execution_result = execute_descriptor(&mut transaction, descriptor, &callbacks).await;
validate_authority()?;
execution_result?;
- if commit_gate.control_violation_observed() {
- return Err(migration_error(MigrationFailureKind::Execution));
- }
+ require_migration_condition(
+ !commit_gate.control_violation_observed(),
+ MigrationFailureKind::Execution,
+ )?;
let transaction_result = assert_governed_transaction(&mut transaction).await;
validate_authority()?;
transaction_result?;
let policy_result = read_connection_policy(&mut transaction).await;
validate_authority()?;
- if policy_result? != initial_policy {
- return Err(migration_error(MigrationFailureKind::Execution));
- }
+ require_migration_condition(
+ policy_result? == initial_policy,
+ MigrationFailureKind::Execution,
+ )?;
let transaction_result = assert_governed_transaction(&mut transaction).await;
validate_authority()?;
transaction_result?;
@@ -984,20 +996,23 @@ where
let transaction_result = assert_governed_transaction(&mut transaction).await;
validate_authority()?;
transaction_result?;
- if commit_gate.control_violation_observed() {
- return Err(migration_error(MigrationFailureKind::Execution));
- }
+ require_migration_condition(
+ !commit_gate.control_violation_observed(),
+ MigrationFailureKind::Execution,
+ )?;
let policy_result = read_connection_policy(&mut transaction).await;
validate_authority()?;
- if policy_result? != initial_policy {
- return Err(migration_error(MigrationFailureKind::Execution));
- }
+ require_migration_condition(
+ policy_result? == initial_policy,
+ MigrationFailureKind::Execution,
+ )?;
let transaction_result = assert_governed_transaction(&mut transaction).await;
validate_authority()?;
transaction_result?;
- if commit_gate.control_violation_observed() {
- return Err(migration_error(MigrationFailureKind::Execution));
- }
+ require_migration_condition(
+ !commit_gate.control_violation_observed(),
+ MigrationFailureKind::Execution,
+ )?;
let permit = commit_gate.permit_outer_commit();
let commit_result = transaction.commit().await;
drop(permit);
@@ -1046,13 +1061,15 @@ fn validate_callback_bindings(
.iter()
.find(|descriptor| descriptor.target_version() == binding.target_version)
.ok_or_else(|| migration_error(MigrationFailureKind::CallbackBinding))?;
- if descriptor.kind() != MigrationKind::Callback
- || descriptor.name() != binding.name
- || descriptor.checksum() != binding.checksum
- || callbacks
- .insert(binding.target_version, binding.callback)
- .is_some()
- {
+ let unique = callbacks
+ .insert(binding.target_version, binding.callback)
+ .is_none();
+ if !crate::all_constraints([
+ descriptor.kind() == MigrationKind::Callback,
+ descriptor.name() == binding.name,
+ descriptor.checksum() == binding.checksum,
+ unique,
+ ]) {
return Err(migration_error(MigrationFailureKind::CallbackBinding));
}
}
@@ -1118,9 +1135,7 @@ pub(crate) async fn read_connection_policy(
.fetch_one(&mut *connection)
.await
.map_err(text)?;
- if journal_mode.len() > 16 {
- return Err(migration_error(MigrationFailureKind::Execution));
- }
+ require_migration_condition(journal_mode.len() <= 16, MigrationFailureKind::Execution)?;
let synchronous = sqlx::query_scalar::<_, i64>("PRAGMA synchronous")
.fetch_one(&mut *connection)
.await
@@ -1153,9 +1168,7 @@ pub(crate) async fn read_connection_policy(
.fetch_all(connection)
.await
.map_err(text)?;
- if databases != ["main"] {
- return Err(migration_error(MigrationFailureKind::Execution));
- }
+ require_migration_condition(databases == ["main"], MigrationFailureKind::Execution)?;
Ok(MigrationConnectionPolicy {
application_id,
journal_mode,
@@ -1203,9 +1216,10 @@ async fn insert_migration_row(
.execute(connection)
.await
.map_err(|source| migration_source(MigrationFailureKind::LedgerWrite, source))?;
- if result.rows_affected() != 1 {
- return Err(migration_error(MigrationFailureKind::LedgerWrite));
- }
+ require_migration_condition(
+ result.rows_affected() == 1,
+ MigrationFailureKind::LedgerWrite,
+ )?;
Ok(())
}
@@ -1225,9 +1239,10 @@ async fn advance_schema_version(
.execute(connection)
.await
.map_err(|source| migration_source(MigrationFailureKind::MetadataAdvance, source))?;
- if result.rows_affected() != 1 {
- return Err(migration_error(MigrationFailureKind::MetadataAdvance));
- }
+ require_migration_condition(
+ result.rows_affected() == 1,
+ MigrationFailureKind::MetadataAdvance,
+ )?;
Ok(())
}
@@ -1311,9 +1326,10 @@ async fn read_migration_history(
.fetch_all(connection)
.await
.map_err(|source| migration_source(MigrationFailureKind::HistoryCorrupt, source))?;
- if rows.len() > MAX_MIGRATION_COUNT {
- return Err(migration_error(MigrationFailureKind::HistoryCorrupt));
- }
+ require_migration_condition(
+ rows.len() <= MAX_MIGRATION_COUNT,
+ MigrationFailureKind::HistoryCorrupt,
+ )?;
rows.iter().map(parse_applied_migration).collect()
}
@@ -1410,19 +1426,22 @@ fn validate_migration_prefix(
version: u32,
history: &[AppliedMigration],
) -> Result<(), ServiceSqliteError> {
- if version < BASE_SCHEMA_VERSION || version > catalog.current_version() {
- return Err(migration_error(MigrationFailureKind::CatalogMismatch));
- }
+ require_migration_condition(
+ version >= BASE_SCHEMA_VERSION && version <= catalog.current_version(),
+ MigrationFailureKind::CatalogMismatch,
+ )?;
let expected_len = usize::try_from(version - BASE_SCHEMA_VERSION)
.map_err(|_| migration_error(MigrationFailureKind::HistoryCorrupt))?;
- if history.len() != expected_len {
- return Err(migration_error(MigrationFailureKind::CatalogMismatch));
- }
+ require_migration_condition(
+ history.len() == expected_len,
+ MigrationFailureKind::CatalogMismatch,
+ )?;
for (applied, descriptor) in history.iter().zip(catalog.descriptors()) {
- if applied.version != descriptor.target_version()
- || applied.name != descriptor.name().as_str()
- || applied.checksum != descriptor.checksum()
- {
+ if !crate::all_constraints([
+ applied.version == descriptor.target_version(),
+ applied.name == descriptor.name().as_str(),
+ applied.checksum == descriptor.checksum(),
+ ]) {
return Err(migration_error(MigrationFailureKind::CatalogMismatch));
}
let _ = (applied.applied_at, &applied.build);
@@ -1435,6 +1454,76 @@ mod tests {
use super::*;
#[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn migration_failure_inventory_is_complete_and_source_aware() {
+ use std::error::Error as _;
+
+ let cases = [
+ (
+ MigrationFailureKind::CatalogMismatch,
+ "migration catalog does not match state",
+ ),
+ (
+ MigrationFailureKind::HistoryCorrupt,
+ "migration history is corrupt",
+ ),
+ (
+ MigrationFailureKind::CallbackBinding,
+ "migration callback binding is invalid",
+ ),
+ (
+ MigrationFailureKind::Execution,
+ "migration execution failed",
+ ),
+ (
+ MigrationFailureKind::LedgerWrite,
+ "migration ledger write failed",
+ ),
+ (
+ MigrationFailureKind::MetadataAdvance,
+ "migration metadata advance failed",
+ ),
+ (
+ MigrationFailureKind::Commit,
+ "migration commit outcome is unavailable",
+ ),
+ ];
+ for (kind, message) in cases {
+ let plain = MigrationFailure(kind);
+ assert_eq!(plain.to_string(), message);
+ assert!(plain.source().is_none());
+
+ let sourced = MigrationSource {
+ kind,
+ source: Box::new(std::io::Error::other("private-cause")),
+ };
+ assert_eq!(sourced.to_string(), message);
+ assert!(sourced.source().is_some());
+ let debug = format!("{sourced:?}");
+ assert!(debug.contains("[redacted]"));
+ assert!(!debug.contains("private-cause"));
+ }
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn migration_condition_classifier_preserves_every_stable_kind() {
+ for kind in [
+ MigrationFailureKind::CatalogMismatch,
+ MigrationFailureKind::CallbackBinding,
+ MigrationFailureKind::HistoryCorrupt,
+ MigrationFailureKind::Execution,
+ MigrationFailureKind::LedgerWrite,
+ MigrationFailureKind::MetadataAdvance,
+ MigrationFailureKind::Commit,
+ ] {
+ assert!(require_migration_condition(true, kind).is_ok());
+ let error = require_migration_condition(false, kind).expect_err("failure");
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Migration);
+ }
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
use std::{
num::NonZeroU32,
path::{Path, PathBuf},
@@ -2765,6 +2854,84 @@ mod tests {
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[tokio::test(flavor = "current_thread")]
+ async fn every_migration_ledger_projection_rejects_wrong_storage_and_values() {
+ let descriptor = sql(2, "create_alpha", SQL_TWO);
+ let catalog = MigrationCatalog::new([descriptor.clone()]).unwrap();
+ let schema_catalog = unchanged_schema_catalog(&catalog);
+ let wrong_storage_updates = [
+ "UPDATE schema_migrations SET version = '2' WHERE rowid = 1",
+ "UPDATE schema_migrations SET name = 2 WHERE rowid = 1",
+ "UPDATE schema_migrations SET checksum = 'checksum' WHERE rowid = 1",
+ "UPDATE schema_migrations SET applied_at_unix_s = '0' WHERE rowid = 1",
+ "UPDATE schema_migrations SET service_version = 2 WHERE rowid = 1",
+ "UPDATE schema_migrations SET service_commit = 2 WHERE rowid = 1",
+ "UPDATE schema_migrations SET lib_revision = 2 WHERE rowid = 1",
+ "UPDATE schema_migrations SET rust_version = 2 WHERE rowid = 1",
+ "UPDATE schema_migrations SET target = 2 WHERE rowid = 1",
+ "UPDATE schema_migrations SET feature_profile = 2 WHERE rowid = 1",
+ "UPDATE schema_migrations SET config_contract_version = '1' WHERE rowid = 1",
+ "UPDATE schema_migrations SET state_contract_version = '2' WHERE rowid = 1",
+ "UPDATE schema_migrations SET admin_contract_version = '3' WHERE rowid = 1",
+ "UPDATE schema_migrations SET status_contract_version = '4' WHERE rowid = 1",
+ "UPDATE schema_migrations SET provider_contract_version = '5' WHERE rowid = 1",
+ ];
+ let invalid_value_updates = [
+ "UPDATE schema_migrations SET version = -1 WHERE rowid = 1",
+ "UPDATE schema_migrations SET version = 4294967296 WHERE rowid = 1",
+ "UPDATE schema_migrations SET name = 'BadName' WHERE rowid = 1",
+ "UPDATE schema_migrations SET checksum = zeroblob(31) WHERE rowid = 1",
+ "UPDATE schema_migrations SET applied_at_unix_s = -1 WHERE rowid = 1",
+ "UPDATE schema_migrations SET service_version = 'bad value' WHERE rowid = 1",
+ "UPDATE schema_migrations SET service_commit = 'bad' WHERE rowid = 1",
+ "UPDATE schema_migrations SET lib_revision = 'bad' WHERE rowid = 1",
+ "UPDATE schema_migrations SET rust_version = 'bad value' WHERE rowid = 1",
+ "UPDATE schema_migrations SET target = 'bad value' WHERE rowid = 1",
+ "UPDATE schema_migrations SET feature_profile = 'bad value' WHERE rowid = 1",
+ "UPDATE schema_migrations SET config_contract_version = 0 WHERE rowid = 1",
+ "UPDATE schema_migrations SET state_contract_version = -1 WHERE rowid = 1",
+ "UPDATE schema_migrations SET admin_contract_version = 4294967296 WHERE rowid = 1",
+ "UPDATE schema_migrations SET status_contract_version = 0 WHERE rowid = 1",
+ "UPDATE schema_migrations SET provider_contract_version = 0 WHERE rowid = 1",
+ ];
+
+ for update in wrong_storage_updates
+ .into_iter()
+ .chain(invalid_value_updates)
+ {
+ let mut connection = initialized_memory_database().await;
+ replace_with_permissive_ledger(&mut connection).await;
+ insert_permissive_history_row(
+ &mut connection,
+ 2,
+ descriptor.name().as_str(),
+ descriptor.checksum().as_bytes(),
+ 0,
+ "0.1.0-alpha",
+ )
+ .await;
+ sqlx::raw_sql(update)
+ .execute(&mut connection)
+ .await
+ .expect("corrupt ledger projection");
+ sqlx::query(
+ "UPDATE radroots_service_metadata SET state_schema_version = 2 WHERE singleton = 1",
+ )
+ .execute(&mut connection)
+ .await
+ .unwrap();
+ assert_eq!(
+ verify_migration_history(&mut connection, &catalog, &schema_catalog, false)
+ .await
+ .expect_err("corrupt ledger projection")
+ .kind(),
+ ServiceSqliteErrorKind::Migration,
+ "accepted corrupt projection update `{update}`"
+ );
+ }
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[tokio::test(flavor = "current_thread")]
async fn oversized_corrupt_history_is_bounded_before_decode() {
let descriptor = sql(2, "create_alpha", SQL_TWO);
let catalog = MigrationCatalog::new([descriptor.clone()]).unwrap();
diff --git a/crates/service_sqlite/src/native_metadata.rs b/crates/service_sqlite/src/native_metadata.rs
@@ -21,9 +21,122 @@ where
raw.try_into()
}
+pub(crate) fn secure_directory(
+ is_directory: bool,
+ actual_uid: u32,
+ expected_uid: u32,
+ raw_mode: u32,
+) -> bool {
+ crate::all_constraints([
+ is_directory,
+ actual_uid == expected_uid,
+ raw_mode & 0o022 == 0,
+ ])
+}
+
+pub(crate) fn exact_directory(
+ is_directory: bool,
+ actual_uid: u32,
+ expected_uid: u32,
+ raw_mode: u32,
+) -> bool {
+ crate::all_constraints([
+ is_directory,
+ actual_uid == expected_uid,
+ raw_mode & 0o777 == 0o700,
+ ])
+}
+
+pub(crate) fn restrictive_directory(
+ is_directory: bool,
+ actual_uid: u32,
+ expected_uid: u32,
+ raw_mode: u32,
+) -> bool {
+ crate::all_constraints([
+ is_directory,
+ actual_uid == expected_uid,
+ matches!(raw_mode & 0o777, 0o500 | 0o700),
+ ])
+}
+
+pub(crate) fn exact_regular_file(
+ is_regular_file: bool,
+ link_count: u64,
+ actual_uid: u32,
+ expected_uid: u32,
+ raw_mode: u32,
+) -> bool {
+ crate::all_constraints([
+ is_regular_file,
+ link_count == 1,
+ actual_uid == expected_uid,
+ raw_mode & 0o777 == 0o600,
+ ])
+}
+
+pub(crate) fn regular_owner_single_link(
+ is_regular_file: bool,
+ link_count: u64,
+ actual_uid: u32,
+ expected_uid: u32,
+) -> bool {
+ crate::all_constraints([is_regular_file, link_count == 1, actual_uid == expected_uid])
+}
+
+pub(crate) fn restrictive_regular_file(
+ is_regular_file: bool,
+ link_count: u64,
+ actual_uid: u32,
+ expected_uid: u32,
+ raw_mode: u32,
+) -> bool {
+ crate::all_constraints([
+ is_regular_file,
+ link_count == 1,
+ actual_uid == expected_uid,
+ matches!(raw_mode & 0o777, 0o400 | 0o600),
+ ])
+}
+
+pub(crate) fn valid_artifact_length(length: u64, expected: Option<u64>) -> bool {
+ let expected_matches = expected.is_none_or(|expected| length == expected);
+ crate::all_constraints([length != 0, length <= i64::MAX as u64, expected_matches])
+}
+
+pub(crate) fn identity_pair_matches(
+ held_device: u64,
+ held_inode: u64,
+ current_device: u64,
+ current_inode: u64,
+ expected_device: u64,
+ expected_inode: u64,
+) -> bool {
+ crate::all_constraints([
+ (held_device, held_inode) == (expected_device, expected_inode),
+ (current_device, current_inode) == (expected_device, expected_inode),
+ ])
+}
+
+pub(crate) fn sqlite_wal_header(header: &[u8; 20]) -> bool {
+ crate::all_constraints([
+ &header[..16] == b"SQLite format 3\0",
+ header[18] == 2,
+ header[19] == 2,
+ ])
+}
+
+pub(crate) fn sqlite_header(header: &[u8; 20]) -> bool {
+ crate::all_constraints([
+ &header[..16] == b"SQLite format 3\0",
+ matches!(header[18], 1 | 2),
+ header[19] == header[18],
+ ])
+}
+
#[cfg(test)]
mod tests {
- use super::{device, link_count, mode};
+ use super::*;
#[test]
fn unsigned_mode_and_link_widths_normalize_without_truncation() {
@@ -40,4 +153,102 @@ mod tests {
assert_eq!(device(u64::MAX), Ok(u64::MAX));
assert!(device(-1_i32).is_err());
}
+
+ #[test]
+ fn constraint_inventory_requires_every_entry() {
+ assert!(crate::all_constraints([true; 16]));
+ for index in 0..16 {
+ let mut constraints = [true; 16];
+ constraints[index] = false;
+ assert!(!crate::all_constraints(constraints));
+ }
+ assert!(crate::all_constraints([]));
+ }
+
+ #[test]
+ fn directory_predicates_bind_every_independent_fact() {
+ assert!(secure_directory(true, 7, 7, 0o755));
+ assert!(!secure_directory(false, 7, 7, 0o755));
+ assert!(!secure_directory(true, 8, 7, 0o755));
+ assert!(!secure_directory(true, 7, 7, 0o775));
+
+ assert!(exact_directory(true, 7, 7, 0o700));
+ assert!(!exact_directory(false, 7, 7, 0o700));
+ assert!(!exact_directory(true, 8, 7, 0o700));
+ assert!(!exact_directory(true, 7, 7, 0o500));
+
+ assert!(restrictive_directory(true, 7, 7, 0o500));
+ assert!(restrictive_directory(true, 7, 7, 0o700));
+ assert!(!restrictive_directory(false, 7, 7, 0o700));
+ assert!(!restrictive_directory(true, 8, 7, 0o700));
+ assert!(!restrictive_directory(true, 7, 7, 0o755));
+ }
+
+ #[test]
+ fn regular_file_predicates_bind_every_independent_fact() {
+ assert!(exact_regular_file(true, 1, 7, 7, 0o600));
+ assert!(!exact_regular_file(false, 1, 7, 7, 0o600));
+ assert!(!exact_regular_file(true, 2, 7, 7, 0o600));
+ assert!(!exact_regular_file(true, 1, 8, 7, 0o600));
+ assert!(!exact_regular_file(true, 1, 7, 7, 0o400));
+
+ assert!(regular_owner_single_link(true, 1, 7, 7));
+ assert!(!regular_owner_single_link(false, 1, 7, 7));
+ assert!(!regular_owner_single_link(true, 2, 7, 7));
+ assert!(!regular_owner_single_link(true, 1, 8, 7));
+
+ assert!(restrictive_regular_file(true, 1, 7, 7, 0o400));
+ assert!(restrictive_regular_file(true, 1, 7, 7, 0o600));
+ assert!(!restrictive_regular_file(false, 1, 7, 7, 0o600));
+ assert!(!restrictive_regular_file(true, 2, 7, 7, 0o600));
+ assert!(!restrictive_regular_file(true, 1, 8, 7, 0o600));
+ assert!(!restrictive_regular_file(true, 1, 7, 7, 0o700));
+ }
+
+ #[test]
+ fn artifact_length_and_identity_predicates_bind_boundaries() {
+ assert!(valid_artifact_length(1, None));
+ assert!(valid_artifact_length(
+ i64::MAX as u64,
+ Some(i64::MAX as u64)
+ ));
+ assert!(!valid_artifact_length(0, None));
+ assert!(!valid_artifact_length(i64::MAX as u64 + 1, None));
+ assert!(!valid_artifact_length(1, Some(2)));
+
+ assert!(identity_pair_matches(1, 2, 1, 2, 1, 2));
+ for values in [(0, 2, 1, 2), (1, 0, 1, 2), (1, 2, 0, 2), (1, 2, 1, 0)] {
+ assert!(!identity_pair_matches(
+ values.0, values.1, values.2, values.3, 1, 2
+ ));
+ }
+ }
+
+ #[test]
+ fn sqlite_header_requires_exact_wal_versions() {
+ let mut header = [0_u8; 20];
+ header[..16].copy_from_slice(b"SQLite format 3\0");
+ header[18] = 2;
+ header[19] = 2;
+ assert!(sqlite_wal_header(&header));
+
+ let mut bad_magic = header;
+ bad_magic[0] = b'X';
+ assert!(!sqlite_wal_header(&bad_magic));
+ let mut bad_write = header;
+ bad_write[18] = 1;
+ assert!(!sqlite_wal_header(&bad_write));
+ let mut bad_read = header;
+ bad_read[19] = 1;
+ assert!(!sqlite_wal_header(&bad_read));
+
+ let mut rollback = header;
+ rollback[18] = 1;
+ rollback[19] = 1;
+ assert!(sqlite_header(&rollback));
+ assert!(sqlite_header(&header));
+ assert!(!sqlite_header(&bad_magic));
+ assert!(!sqlite_header(&bad_write));
+ assert!(!sqlite_header(&bad_read));
+ }
}
diff --git a/crates/service_sqlite/src/open.rs b/crates/service_sqlite/src/open.rs
@@ -48,6 +48,84 @@ const WAL_FILE_NAME: &str = "state.sqlite-wal";
#[cfg(any(target_os = "linux", target_os = "macos"))]
const SHARED_MEMORY_FILE_NAME: &str = "state.sqlite-shm";
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+#[derive(Clone)]
+struct PoolConnectionValidation {
+ binding: DirectoryBinding,
+ paths: ServiceSqlitePaths,
+ identity: ServiceDatabaseIdentity,
+ catalog: MigrationCatalog,
+ schema_catalog: SchemaCatalog,
+ mode: OpenMode,
+ policy: ServiceSqliteConnectionOptions,
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+enum PoolConnectionValidationFailure {
+ Authority,
+ Pragma(sqlx::Error),
+ PolicyMismatch,
+ Metadata,
+ Migration,
+ Integrity,
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+#[derive(Clone)]
+struct PoolConnectionFailureFlags {
+ authority: Arc<AtomicBool>,
+ metadata: Arc<AtomicBool>,
+ migration: Arc<AtomicBool>,
+ integrity: Arc<AtomicBool>,
+ pragma: Arc<AtomicBool>,
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+impl PoolConnectionFailureFlags {
+ fn record(&self, failure: &PoolConnectionValidationFailure) {
+ match failure {
+ PoolConnectionValidationFailure::Authority => &self.authority,
+ PoolConnectionValidationFailure::Metadata => &self.metadata,
+ PoolConnectionValidationFailure::Migration => &self.migration,
+ PoolConnectionValidationFailure::Integrity => &self.integrity,
+ PoolConnectionValidationFailure::Pragma(_)
+ | PoolConnectionValidationFailure::PolicyMismatch => &self.pragma,
+ }
+ .store(true, Ordering::Release);
+ }
+
+ fn kind(&self) -> ServiceSqliteErrorKind {
+ connection_failure_kind(
+ self.authority.load(Ordering::Acquire),
+ self.metadata.load(Ordering::Acquire),
+ self.migration.load(Ordering::Acquire),
+ self.integrity.load(Ordering::Acquire),
+ self.pragma.load(Ordering::Acquire),
+ )
+ }
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+impl PoolConnectionValidationFailure {
+ fn into_sqlx(self) -> sqlx::Error {
+ match self {
+ Self::Pragma(source) => source,
+ Self::Authority => {
+ sqlx::Error::Protocol("SQLite connection authority mismatch".to_owned())
+ }
+ Self::PolicyMismatch => {
+ sqlx::Error::Protocol("SQLite connection policy mismatch".to_owned())
+ }
+ Self::Metadata => {
+ sqlx::Error::Protocol("SQLite connection metadata mismatch".to_owned())
+ }
+ Self::Migration | Self::Integrity => {
+ sqlx::Error::Protocol("SQLite migration history mismatch".to_owned())
+ }
+ }
+ }
+}
+
/// Canonical database and writer-lock paths for one validated service instance.
///
/// Callers cannot forge paths or rebind the service and instance independently:
@@ -739,6 +817,47 @@ pub(crate) async fn open_initialized_connection_pool(
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+impl PoolConnectionValidation {
+ async fn validate(
+ &self,
+ connection: &mut SqliteConnection,
+ ) -> Result<(), PoolConnectionValidationFailure> {
+ self.validate_authority()?;
+ let policy_result = connection_policy_matches(connection, self.mode, self.policy).await;
+ self.validate_authority()?;
+ if !policy_result.map_err(PoolConnectionValidationFailure::Pragma)? {
+ return Err(PoolConnectionValidationFailure::PolicyMismatch);
+ }
+ let metadata_result =
+ crate::metadata::verify_database_metadata(connection, &self.identity).await;
+ self.validate_authority()?;
+ metadata_result.map_err(|_| PoolConnectionValidationFailure::Metadata)?;
+ let migration_result = crate::migration::verify_migration_history(
+ connection,
+ &self.catalog,
+ &self.schema_catalog,
+ self.mode == OpenMode::ReadOnlyInspection,
+ )
+ .await;
+ self.validate_authority()?;
+ migration_result.map_err(|error| {
+ if error.kind() == ServiceSqliteErrorKind::Integrity {
+ PoolConnectionValidationFailure::Integrity
+ } else {
+ PoolConnectionValidationFailure::Migration
+ }
+ })?;
+ Ok(())
+ }
+
+ fn validate_authority(&self) -> Result<(), PoolConnectionValidationFailure> {
+ self.binding
+ .validate(&self.paths)
+ .map_err(|_| PoolConnectionValidationFailure::Authority)
+ }
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
#[allow(clippy::too_many_arguments)]
async fn open_connection_pool(
paths: &ServiceSqlitePaths,
@@ -823,40 +942,36 @@ async fn open_connection_pool(
binding.validate(paths)?;
preflight_close.map_err(|source| connection_source(ServiceSqliteErrorKind::Open, source))?;
- let after_policy = policy;
- let before_policy = policy;
- let after_mode = mode;
- let before_mode = mode;
let retained_binding = binding.clone();
- let after_binding = binding.clone();
- let before_binding = binding.clone();
let pool_binding = binding;
- let after_paths = paths.clone();
- let before_paths = paths.clone();
- let after_metadata = identity.clone();
- let before_metadata = identity.clone();
let retained_catalog = catalog.clone();
let retained_schema_catalog = schema_catalog.clone();
let retained_identity = identity.clone();
- let after_catalog = catalog.clone();
- let after_schema_catalog = schema_catalog.clone();
- let before_catalog = catalog.clone();
- let before_schema_catalog = schema_catalog.clone();
let authority_failure = Arc::new(AtomicBool::new(false));
let metadata_failure = Arc::new(AtomicBool::new(false));
let migration_failure = Arc::new(AtomicBool::new(false));
let integrity_failure = Arc::new(AtomicBool::new(false));
let pragma_failure = Arc::new(AtomicBool::new(false));
- let after_authority_failure = Arc::clone(&authority_failure);
- let after_metadata_failure = Arc::clone(&metadata_failure);
- let after_migration_failure = Arc::clone(&migration_failure);
- let after_integrity_failure = Arc::clone(&integrity_failure);
- let after_pragma_failure = Arc::clone(&pragma_failure);
- let before_authority_failure = Arc::clone(&authority_failure);
- let before_metadata_failure = Arc::clone(&metadata_failure);
- let before_migration_failure = Arc::clone(&migration_failure);
- let before_integrity_failure = Arc::clone(&integrity_failure);
- let before_pragma_failure = Arc::clone(&pragma_failure);
+ let validation = PoolConnectionValidation {
+ binding: retained_binding.clone(),
+ paths: paths.clone(),
+ identity: identity.clone(),
+ catalog: catalog.clone(),
+ schema_catalog: schema_catalog.clone(),
+ mode,
+ policy,
+ };
+ let after_validation = validation.clone();
+ let before_validation = validation;
+ let flags = PoolConnectionFailureFlags {
+ authority: Arc::clone(&authority_failure),
+ metadata: Arc::clone(&metadata_failure),
+ migration: Arc::clone(&migration_failure),
+ integrity: Arc::clone(&integrity_failure),
+ pragma: Arc::clone(&pragma_failure),
+ };
+ let after_flags = flags.clone();
+ let before_flags = flags.clone();
let pool_result = SqlitePoolOptions::new()
.min_connections(1)
.max_connections(policy.max_connections())
@@ -865,172 +980,36 @@ async fn open_connection_pool(
.max_lifetime(None)
.test_before_acquire(true)
.after_connect(move |connection, _metadata| {
- let binding = after_binding.clone();
- let paths = after_paths.clone();
- let metadata = after_metadata.clone();
- let catalog = after_catalog.clone();
- let schema_catalog = after_schema_catalog.clone();
- let authority_failure = Arc::clone(&after_authority_failure);
- let metadata_failure = Arc::clone(&after_metadata_failure);
- let migration_failure = Arc::clone(&after_migration_failure);
- let integrity_failure = Arc::clone(&after_integrity_failure);
- let pragma_failure = Arc::clone(&after_pragma_failure);
+ let validation = after_validation.clone();
+ let flags = after_flags.clone();
Box::pin(async move {
- if binding.validate(&paths).is_err() {
- authority_failure.store(true, Ordering::Release);
- return Err(sqlx::Error::Protocol(
- "SQLite connection authority mismatch".to_owned(),
- ));
- }
- let policy_result =
- connection_policy_matches(connection, after_mode, after_policy).await;
- if binding.validate(&paths).is_err() {
- authority_failure.store(true, Ordering::Release);
- return Err(sqlx::Error::Protocol(
- "SQLite connection authority mismatch".to_owned(),
- ));
- }
- let matches = policy_result.inspect_err(|_| {
- pragma_failure.store(true, Ordering::Release);
- })?;
- if !matches {
- pragma_failure.store(true, Ordering::Release);
- return Err(sqlx::Error::Protocol(
- "SQLite connection policy mismatch".to_owned(),
- ));
- }
- let metadata_result =
- crate::metadata::verify_database_metadata(connection, &metadata).await;
- if binding.validate(&paths).is_err() {
- authority_failure.store(true, Ordering::Release);
- return Err(sqlx::Error::Protocol(
- "SQLite connection authority mismatch".to_owned(),
- ));
- }
- if metadata_result.is_err() {
- metadata_failure.store(true, Ordering::Release);
- return Err(sqlx::Error::Protocol(
- "SQLite connection metadata mismatch".to_owned(),
- ));
- }
- let migration_result = crate::migration::verify_migration_history(
- connection,
- &catalog,
- &schema_catalog,
- after_mode == OpenMode::ReadOnlyInspection,
- )
- .await;
- if binding.validate(&paths).is_err() {
- authority_failure.store(true, Ordering::Release);
- return Err(sqlx::Error::Protocol(
- "SQLite connection authority mismatch".to_owned(),
- ));
- }
- if migration_result.is_err() {
- if migration_result
- .as_ref()
- .is_err_and(|error| error.kind() == ServiceSqliteErrorKind::Integrity)
- {
- integrity_failure.store(true, Ordering::Release);
- } else {
- migration_failure.store(true, Ordering::Release);
- }
- return Err(sqlx::Error::Protocol(
- "SQLite migration history mismatch".to_owned(),
- ));
- }
- Ok(())
+ validation.validate(connection).await.map_err(|failure| {
+ flags.record(&failure);
+ failure.into_sqlx()
+ })
})
})
.before_acquire(move |connection, _metadata| {
- let binding = before_binding.clone();
- let paths = before_paths.clone();
- let metadata = before_metadata.clone();
- let catalog = before_catalog.clone();
- let schema_catalog = before_schema_catalog.clone();
- let authority_failure = Arc::clone(&before_authority_failure);
- let metadata_failure = Arc::clone(&before_metadata_failure);
- let migration_failure = Arc::clone(&before_migration_failure);
- let integrity_failure = Arc::clone(&before_integrity_failure);
- let pragma_failure = Arc::clone(&before_pragma_failure);
+ let validation = before_validation.clone();
+ let flags = before_flags.clone();
Box::pin(async move {
- if binding.validate(&paths).is_err() {
- authority_failure.store(true, Ordering::Release);
- return Err(sqlx::Error::Protocol(
- "SQLite connection authority mismatch".to_owned(),
- ));
- }
- let policy_result =
- connection_policy_matches(connection, before_mode, before_policy).await;
- if binding.validate(&paths).is_err() {
- authority_failure.store(true, Ordering::Release);
- return Err(sqlx::Error::Protocol(
- "SQLite connection authority mismatch".to_owned(),
- ));
- }
- let matches = policy_result.inspect_err(|_| {
- pragma_failure.store(true, Ordering::Release);
- })?;
- if !matches {
- pragma_failure.store(true, Ordering::Release);
- return Ok(false);
- }
- let metadata_result =
- crate::metadata::verify_database_metadata(connection, &metadata).await;
- if binding.validate(&paths).is_err() {
- authority_failure.store(true, Ordering::Release);
- return Err(sqlx::Error::Protocol(
- "SQLite connection authority mismatch".to_owned(),
- ));
- }
- if metadata_result.is_err() {
- metadata_failure.store(true, Ordering::Release);
- return Err(sqlx::Error::Protocol(
- "SQLite connection metadata mismatch".to_owned(),
- ));
- }
- let migration_result = crate::migration::verify_migration_history(
- connection,
- &catalog,
- &schema_catalog,
- before_mode == OpenMode::ReadOnlyInspection,
- )
- .await;
- if binding.validate(&paths).is_err() {
- authority_failure.store(true, Ordering::Release);
- return Err(sqlx::Error::Protocol(
- "SQLite connection authority mismatch".to_owned(),
- ));
- }
- if migration_result.is_err() {
- if migration_result
- .as_ref()
- .is_err_and(|error| error.kind() == ServiceSqliteErrorKind::Integrity)
- {
- integrity_failure.store(true, Ordering::Release);
- } else {
- migration_failure.store(true, Ordering::Release);
+ match validation.validate(connection).await {
+ Ok(()) => Ok(true),
+ Err(PoolConnectionValidationFailure::PolicyMismatch) => {
+ flags.record(&PoolConnectionValidationFailure::PolicyMismatch);
+ Ok(false)
+ }
+ Err(failure) => {
+ flags.record(&failure);
+ Err(failure.into_sqlx())
}
- return Err(sqlx::Error::Protocol(
- "SQLite migration history mismatch".to_owned(),
- ));
}
- Ok(true)
})
})
.connect_with(connect_options)
.await;
pool_binding.validate(paths)?;
- let pool = pool_result.map_err(|source| {
- let kind = connection_failure_kind(
- authority_failure.load(Ordering::Acquire),
- metadata_failure.load(Ordering::Acquire),
- migration_failure.load(Ordering::Acquire),
- integrity_failure.load(Ordering::Acquire),
- pragma_failure.load(Ordering::Acquire),
- );
- connection_source(kind, source)
- })?;
+ let pool = pool_result.map_err(|source| connection_source(flags.kind(), source))?;
Ok(PrivateConnectionPool {
pool,
@@ -1141,19 +1120,52 @@ async fn connection_policy_matches(
let query_only = sqlx::query_scalar::<_, i64>("PRAGMA query_only")
.fetch_one(&mut *connection)
.await?;
+ Ok(connection_policy_values_match(
+ ConnectionPolicyValues {
+ journal_mode: &journal_mode,
+ synchronous,
+ foreign_keys,
+ trusted_schema,
+ busy_timeout,
+ query_only,
+ },
+ mode,
+ policy,
+ ))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+#[derive(Clone, Copy)]
+struct ConnectionPolicyValues<'a> {
+ journal_mode: &'a str,
+ synchronous: i64,
+ foreign_keys: i64,
+ trusted_schema: i64,
+ busy_timeout: i64,
+ query_only: i64,
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn connection_policy_values_match(
+ values: ConnectionPolicyValues<'_>,
+ mode: OpenMode,
+ policy: ServiceSqliteConnectionOptions,
+) -> bool {
// SQLite reports `delete` for immutable handles; the inspection guard
// independently verifies WAL read/write header bytes before this opens.
let journal_mode_matches = if mode == OpenMode::ReadOnlyInspection {
- journal_mode.eq_ignore_ascii_case("delete")
+ values.journal_mode.eq_ignore_ascii_case("delete")
} else {
- journal_mode.eq_ignore_ascii_case("wal")
+ values.journal_mode.eq_ignore_ascii_case("wal")
};
- Ok(journal_mode_matches
- && synchronous == 2
- && foreign_keys == 1
- && trusted_schema == 0
- && busy_timeout == policy.busy_timeout_milliseconds()
- && query_only == i64::from(mode == OpenMode::ReadOnlyInspection))
+ crate::all_constraints([
+ journal_mode_matches,
+ values.synchronous == 2,
+ values.foreign_keys == 1,
+ values.trusted_schema == 0,
+ values.busy_timeout == policy.busy_timeout_milliseconds(),
+ values.query_only == i64::from(mode == OpenMode::ReadOnlyInspection),
+ ])
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -1199,6 +1211,17 @@ fn connection_error(
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_connection_condition(
+ condition: bool,
+ kind: ServiceSqliteErrorKind,
+ cause: ConnectionFailureKind,
+) -> Result<(), ServiceSqliteError> {
+ condition
+ .then_some(())
+ .ok_or_else(|| connection_error(kind, cause))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
#[allow(
dead_code,
reason = "Step 056 keeps dependency causes private until the Step 061 host boundary"
@@ -1237,14 +1260,16 @@ impl ReadOnlyInspectionGuard {
.map_err(|_| inspection_error(ConnectionFailureKind::InspectionUnavailable))?;
let directory_status = fstat(&directory)
.map_err(|_| inspection_error(ConnectionFailureKind::InspectionUnavailable))?;
- if !FileType::from_raw_mode(directory_status.st_mode).is_dir()
- || directory_status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(directory_status.st_mode) & 0o022 != 0
- {
- return Err(inspection_error(
- ConnectionFailureKind::InspectionUnavailable,
- ));
- }
+ require_connection_condition(
+ crate::native_metadata::secure_directory(
+ FileType::from_raw_mode(directory_status.st_mode).is_dir(),
+ directory_status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(directory_status.st_mode),
+ ),
+ ServiceSqliteErrorKind::Authority,
+ ConnectionFailureKind::InspectionUnavailable,
+ )?;
let directory = File::from(directory);
let lock = openat(
&directory,
@@ -1255,15 +1280,17 @@ impl ReadOnlyInspectionGuard {
.map_err(|_| inspection_error(ConnectionFailureKind::InspectionUnavailable))?;
let lock_status = fstat(&lock)
.map_err(|_| inspection_error(ConnectionFailureKind::InspectionUnavailable))?;
- if !FileType::from_raw_mode(lock_status.st_mode).is_file()
- || crate::native_metadata::link_count(lock_status.st_nlink) != 1
- || lock_status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(lock_status.st_mode) & 0o777 != 0o600
- {
- return Err(inspection_error(
- ConnectionFailureKind::InspectionUnavailable,
- ));
- }
+ require_connection_condition(
+ crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(lock_status.st_mode).is_file(),
+ crate::native_metadata::link_count(lock_status.st_nlink),
+ lock_status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(lock_status.st_mode),
+ ),
+ ServiceSqliteErrorKind::Authority,
+ ConnectionFailureKind::InspectionUnavailable,
+ )?;
let lock = File::from(lock);
FileExt::try_lock_shared(&lock).map_err(|error| {
if error.kind() == std::io::ErrorKind::WouldBlock {
@@ -1301,16 +1328,17 @@ impl ReadOnlyInspectionGuard {
ConnectionFailureKind::InspectionUnavailable,
)
})?;
- if !FileType::from_raw_mode(database_status.st_mode).is_file()
- || crate::native_metadata::link_count(database_status.st_nlink) != 1
- || database_status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(database_status.st_mode) & 0o777 != 0o600
- {
- return Err(connection_error(
- ServiceSqliteErrorKind::Open,
- ConnectionFailureKind::InspectionUnavailable,
- ));
- }
+ require_connection_condition(
+ crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(database_status.st_mode).is_file(),
+ crate::native_metadata::link_count(database_status.st_nlink),
+ database_status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(database_status.st_mode),
+ ),
+ ServiceSqliteErrorKind::Open,
+ ConnectionFailureKind::InspectionUnavailable,
+ )?;
let database = File::from(database);
let mut sqlite_header = [0_u8; 20];
std::os::unix::fs::FileExt::read_exact_at(&database, &mut sqlite_header, 0).map_err(
@@ -1321,15 +1349,11 @@ impl ReadOnlyInspectionGuard {
)
},
)?;
- if &sqlite_header[..16] != b"SQLite format 3\0"
- || sqlite_header[18] != 2
- || sqlite_header[19] != 2
- {
- return Err(connection_error(
- ServiceSqliteErrorKind::Pragma,
- ConnectionFailureKind::InspectionUnavailable,
- ));
- }
+ require_connection_condition(
+ crate::native_metadata::sqlite_wal_header(&sqlite_header),
+ ServiceSqliteErrorKind::Pragma,
+ ConnectionFailureKind::InspectionUnavailable,
+ )?;
Ok(Self {
lock: Some(lock),
lock_device: crate::native_metadata::device(lock_status.st_dev)
@@ -1368,21 +1392,32 @@ impl ReadOnlyInspectionGuard {
.map_err(|_| inspection_error(ConnectionFailureKind::InspectionUnavailable))?;
let held_directory_device = crate::native_metadata::device(held_directory_status.st_dev)
.map_err(|_| inspection_error(ConnectionFailureKind::InspectionUnavailable))?;
- if !FileType::from_raw_mode(directory_status.st_mode).is_dir()
- || directory_status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(directory_status.st_mode) & 0o022 != 0
- || !FileType::from_raw_mode(held_directory_status.st_mode).is_dir()
- || held_directory_status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(held_directory_status.st_mode) & 0o022 != 0
- || directory_device != self.directory_device
- || directory_status.st_ino != self.directory_inode
- || held_directory_device != self.directory_device
- || held_directory_status.st_ino != self.directory_inode
- {
- return Err(inspection_error(
- ConnectionFailureKind::InspectionUnavailable,
- ));
- }
+ require_connection_condition(
+ crate::all_constraints([
+ crate::native_metadata::secure_directory(
+ FileType::from_raw_mode(directory_status.st_mode).is_dir(),
+ directory_status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(directory_status.st_mode),
+ ),
+ crate::native_metadata::secure_directory(
+ FileType::from_raw_mode(held_directory_status.st_mode).is_dir(),
+ held_directory_status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(held_directory_status.st_mode),
+ ),
+ crate::native_metadata::identity_pair_matches(
+ held_directory_device,
+ held_directory_status.st_ino,
+ directory_device,
+ directory_status.st_ino,
+ self.directory_device,
+ self.directory_inode,
+ ),
+ ]),
+ ServiceSqliteErrorKind::Authority,
+ ConnectionFailureKind::InspectionUnavailable,
+ )?;
let lock = openat(
&directory,
@@ -1403,23 +1438,34 @@ impl ReadOnlyInspectionGuard {
.map_err(|_| inspection_error(ConnectionFailureKind::InspectionUnavailable))?;
let held_lock_device = crate::native_metadata::device(held_lock_status.st_dev)
.map_err(|_| inspection_error(ConnectionFailureKind::InspectionUnavailable))?;
- if !FileType::from_raw_mode(lock_status.st_mode).is_file()
- || crate::native_metadata::link_count(lock_status.st_nlink) != 1
- || lock_status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(lock_status.st_mode) & 0o777 != 0o600
- || !FileType::from_raw_mode(held_lock_status.st_mode).is_file()
- || crate::native_metadata::link_count(held_lock_status.st_nlink) != 1
- || held_lock_status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(held_lock_status.st_mode) & 0o777 != 0o600
- || lock_device != self.lock_device
- || lock_status.st_ino != self.lock_inode
- || held_lock_device != self.lock_device
- || held_lock_status.st_ino != self.lock_inode
- {
- return Err(inspection_error(
- ConnectionFailureKind::InspectionUnavailable,
- ));
- }
+ require_connection_condition(
+ crate::all_constraints([
+ crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(lock_status.st_mode).is_file(),
+ crate::native_metadata::link_count(lock_status.st_nlink),
+ lock_status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(lock_status.st_mode),
+ ),
+ crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(held_lock_status.st_mode).is_file(),
+ crate::native_metadata::link_count(held_lock_status.st_nlink),
+ held_lock_status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(held_lock_status.st_mode),
+ ),
+ crate::native_metadata::identity_pair_matches(
+ held_lock_device,
+ held_lock_status.st_ino,
+ lock_device,
+ lock_status.st_ino,
+ self.lock_device,
+ self.lock_inode,
+ ),
+ ]),
+ ServiceSqliteErrorKind::Authority,
+ ConnectionFailureKind::InspectionUnavailable,
+ )?;
for sidecar in [WAL_FILE_NAME, SHARED_MEMORY_FILE_NAME] {
match statat(&directory, sidecar, AtFlags::SYMLINK_NOFOLLOW) {
Err(error) if error == rustix::io::Errno::NOENT => {}
@@ -1504,16 +1550,17 @@ impl DirectoryBinding {
ConnectionFailureKind::AuthorityMismatch,
)
})?;
- if !FileType::from_raw_mode(database_status.st_mode).is_file()
- || crate::native_metadata::link_count(database_status.st_nlink) != 1
- || database_status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(database_status.st_mode) & 0o777 != 0o600
- {
- return Err(connection_error(
- ServiceSqliteErrorKind::Authority,
- ConnectionFailureKind::AuthorityMismatch,
- ));
- }
+ require_connection_condition(
+ crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(database_status.st_mode).is_file(),
+ crate::native_metadata::link_count(database_status.st_nlink),
+ database_status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(database_status.st_mode),
+ ),
+ ServiceSqliteErrorKind::Authority,
+ ConnectionFailureKind::AuthorityMismatch,
+ )?;
Ok(Self {
database_path: paths.state_database().to_path_buf(),
directory: Arc::new(directory.try_clone().map_err(|_| {
@@ -1550,12 +1597,11 @@ impl DirectoryBinding {
process::geteuid,
};
- if self.database_path != paths.state_database() {
- return Err(connection_error(
- ServiceSqliteErrorKind::Authority,
- ConnectionFailureKind::AuthorityMismatch,
- ));
- }
+ require_connection_condition(
+ self.database_path == paths.state_database(),
+ ServiceSqliteErrorKind::Authority,
+ ConnectionFailureKind::AuthorityMismatch,
+ )?;
let directory = open(
paths.state_database().parent().ok_or_else(|| {
connection_error(
@@ -1598,22 +1644,32 @@ impl DirectoryBinding {
ConnectionFailureKind::AuthorityMismatch,
)
})?;
- if directory_device != self.directory_device
- || directory_status.st_ino != self.directory_inode
- || held_directory_device != self.directory_device
- || held_directory_status.st_ino != self.directory_inode
- || !FileType::from_raw_mode(directory_status.st_mode).is_dir()
- || directory_status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(directory_status.st_mode) & 0o022 != 0
- || !FileType::from_raw_mode(held_directory_status.st_mode).is_dir()
- || held_directory_status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(held_directory_status.st_mode) & 0o022 != 0
- {
- return Err(connection_error(
- ServiceSqliteErrorKind::Authority,
- ConnectionFailureKind::AuthorityMismatch,
- ));
- }
+ require_connection_condition(
+ crate::all_constraints([
+ crate::native_metadata::secure_directory(
+ FileType::from_raw_mode(directory_status.st_mode).is_dir(),
+ directory_status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(directory_status.st_mode),
+ ),
+ crate::native_metadata::secure_directory(
+ FileType::from_raw_mode(held_directory_status.st_mode).is_dir(),
+ held_directory_status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(held_directory_status.st_mode),
+ ),
+ crate::native_metadata::identity_pair_matches(
+ held_directory_device,
+ held_directory_status.st_ino,
+ directory_device,
+ directory_status.st_ino,
+ self.directory_device,
+ self.directory_inode,
+ ),
+ ]),
+ ServiceSqliteErrorKind::Authority,
+ ConnectionFailureKind::AuthorityMismatch,
+ )?;
let database = openat(
&directory,
@@ -1653,30 +1709,41 @@ impl DirectoryBinding {
ConnectionFailureKind::AuthorityMismatch,
)
})?;
- if !FileType::from_raw_mode(database_status.st_mode).is_file()
- || crate::native_metadata::link_count(database_status.st_nlink) != 1
- || database_status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(database_status.st_mode) & 0o777 != 0o600
- || !FileType::from_raw_mode(held_database_status.st_mode).is_file()
- || crate::native_metadata::link_count(held_database_status.st_nlink) != 1
- || held_database_status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(held_database_status.st_mode) & 0o777 != 0o600
- || database_device != self.database_device
- || database_status.st_ino != self.database_inode
- || held_database_device != self.database_device
- || held_database_status.st_ino != self.database_inode
- {
- return Err(connection_error(
- ServiceSqliteErrorKind::Authority,
- ConnectionFailureKind::AuthorityMismatch,
- ));
- }
+ require_connection_condition(
+ crate::all_constraints([
+ crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(database_status.st_mode).is_file(),
+ crate::native_metadata::link_count(database_status.st_nlink),
+ database_status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(database_status.st_mode),
+ ),
+ crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(held_database_status.st_mode).is_file(),
+ crate::native_metadata::link_count(held_database_status.st_nlink),
+ held_database_status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(held_database_status.st_mode),
+ ),
+ crate::native_metadata::identity_pair_matches(
+ held_database_device,
+ held_database_status.st_ino,
+ database_device,
+ database_status.st_ino,
+ self.database_device,
+ self.database_inode,
+ ),
+ ]),
+ ServiceSqliteErrorKind::Authority,
+ ConnectionFailureKind::AuthorityMismatch,
+ )?;
Ok(())
}
}
#[cfg(test)]
mod tests {
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
use std::path::PathBuf;
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -1710,6 +1777,116 @@ mod tests {
use super::*;
#[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn connection_failure_inventory_is_complete_and_source_free() {
+ for (kind, message) in [
+ (
+ ConnectionFailureKind::UnsupportedMode,
+ "SQLite initialize mode requires reserved state",
+ ),
+ (
+ ConnectionFailureKind::AuthorityMismatch,
+ "SQLite writer authority is missing or mismatched",
+ ),
+ (
+ ConnectionFailureKind::InspectionUnavailable,
+ "SQLite inspection authority is unavailable",
+ ),
+ (
+ ConnectionFailureKind::InspectionContended,
+ "SQLite inspection requires an offline writer",
+ ),
+ (
+ ConnectionFailureKind::CheckpointBusy,
+ "SQLite close checkpoint could not drain active readers",
+ ),
+ ] {
+ assert_eq!(kind.to_string(), message);
+ assert!(kind.source().is_none());
+ assert!(format!("{kind:?}").contains(&format!("{kind:?}")));
+ let error = connection_error(ServiceSqliteErrorKind::Open, kind);
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Open);
+ assert!(error.source().is_some());
+ assert!(require_connection_condition(true, ServiceSqliteErrorKind::Open, kind).is_ok());
+ let rejected =
+ require_connection_condition(false, ServiceSqliteErrorKind::Authority, kind)
+ .expect_err("false condition");
+ assert_eq!(rejected.kind(), ServiceSqliteErrorKind::Authority);
+ assert!(rejected.source().is_some());
+ }
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn connection_policy_value_matrix_rejects_each_independent_drift() {
+ let policy = ServiceSqliteConnectionOptions::reviewed();
+ let writable = ConnectionPolicyValues {
+ journal_mode: "wal",
+ synchronous: 2,
+ foreign_keys: 1,
+ trusted_schema: 0,
+ busy_timeout: policy.busy_timeout_milliseconds(),
+ query_only: 0,
+ };
+ assert!(connection_policy_values_match(
+ writable,
+ OpenMode::Initialize,
+ policy,
+ ));
+ for values in [
+ ConnectionPolicyValues {
+ journal_mode: "delete",
+ ..writable
+ },
+ ConnectionPolicyValues {
+ synchronous: 1,
+ ..writable
+ },
+ ConnectionPolicyValues {
+ foreign_keys: 0,
+ ..writable
+ },
+ ConnectionPolicyValues {
+ trusted_schema: 1,
+ ..writable
+ },
+ ConnectionPolicyValues {
+ busy_timeout: 1,
+ ..writable
+ },
+ ConnectionPolicyValues {
+ query_only: 1,
+ ..writable
+ },
+ ] {
+ assert!(!connection_policy_values_match(
+ values,
+ OpenMode::Initialize,
+ policy,
+ ));
+ }
+
+ assert!(connection_policy_values_match(
+ ConnectionPolicyValues {
+ journal_mode: "DELETE",
+ query_only: 1,
+ ..writable
+ },
+ OpenMode::ReadOnlyInspection,
+ policy,
+ ));
+ assert!(!connection_policy_values_match(
+ ConnectionPolicyValues {
+ journal_mode: "wal",
+ query_only: 1,
+ ..writable
+ },
+ OpenMode::ReadOnlyInspection,
+ policy,
+ ));
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
#[derive(Debug, PartialEq, Eq)]
struct FileSnapshot {
bytes: Vec<u8>,
@@ -2370,6 +2547,46 @@ mod tests {
connection_failure_kind(true, true, true, true, true),
ServiceSqliteErrorKind::Authority
);
+
+ for (failure, expected) in [
+ (
+ PoolConnectionValidationFailure::Authority,
+ ServiceSqliteErrorKind::Authority,
+ ),
+ (
+ PoolConnectionValidationFailure::Metadata,
+ ServiceSqliteErrorKind::Metadata,
+ ),
+ (
+ PoolConnectionValidationFailure::Migration,
+ ServiceSqliteErrorKind::Migration,
+ ),
+ (
+ PoolConnectionValidationFailure::Integrity,
+ ServiceSqliteErrorKind::Integrity,
+ ),
+ (
+ PoolConnectionValidationFailure::PolicyMismatch,
+ ServiceSqliteErrorKind::Pragma,
+ ),
+ (
+ PoolConnectionValidationFailure::Pragma(sqlx::Error::Protocol(
+ "test pragma query failure".to_owned(),
+ )),
+ ServiceSqliteErrorKind::Pragma,
+ ),
+ ] {
+ let flags = PoolConnectionFailureFlags {
+ authority: Arc::new(AtomicBool::new(false)),
+ metadata: Arc::new(AtomicBool::new(false)),
+ migration: Arc::new(AtomicBool::new(false)),
+ integrity: Arc::new(AtomicBool::new(false)),
+ pragma: Arc::new(AtomicBool::new(false)),
+ };
+ flags.record(&failure);
+ assert_eq!(flags.kind(), expected);
+ let _ = failure.into_sqlx();
+ }
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -2711,6 +2928,85 @@ mod tests {
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[tokio::test(flavor = "current_thread")]
+ async fn pool_preflight_rejects_identity_version_and_schema_catalog_drift() {
+ let directory = tempfile::tempdir().expect("temporary directory");
+
+ let (paths, identity, authority) =
+ initialized_authority(directory.path(), "identity-drift").await;
+ let other_paths = ServiceSqlitePaths::from_runtime_context(&runtime_context(
+ RadrootsPathProfile::RepoLocal,
+ Some(directory.path().to_path_buf()),
+ "myc",
+ "other-identity",
+ ))
+ .expect("other paths");
+ let wrong_identity = ServiceDatabaseIdentity::new(
+ &other_paths,
+ identity.source_generation(),
+ identity.supported_state_schema_version(),
+ identity.application_id(),
+ );
+ let Err(error) = open_connection_pool(
+ &paths,
+ &wrong_identity,
+ &base_catalog(),
+ &base_schema_catalog(),
+ OpenMode::Initialize,
+ ServiceSqliteConnectionOptions::reviewed(),
+ Some(authority),
+ None,
+ )
+ .await
+ else {
+ panic!("identity drift must fail");
+ };
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Metadata);
+
+ let (paths, identity, authority) =
+ initialized_authority(directory.path(), "version-drift").await;
+ let newer_identity = ServiceDatabaseIdentity::new(
+ &paths,
+ identity.source_generation(),
+ NonZeroU32::new(2).expect("newer schema"),
+ identity.application_id(),
+ );
+ let Err(error) = open_connection_pool(
+ &paths,
+ &newer_identity,
+ &base_catalog(),
+ &base_schema_catalog(),
+ OpenMode::Initialize,
+ ServiceSqliteConnectionOptions::reviewed(),
+ Some(authority),
+ None,
+ )
+ .await
+ else {
+ panic!("version drift must fail");
+ };
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Migration);
+
+ let (paths, identity, authority) =
+ initialized_authority(directory.path(), "schema-drift-preflight").await;
+ let Err(error) = open_connection_pool(
+ &paths,
+ &identity,
+ &base_catalog(),
+ &migration_schema_catalog(),
+ OpenMode::Initialize,
+ ServiceSqliteConnectionOptions::reviewed(),
+ Some(authority),
+ None,
+ )
+ .await
+ else {
+ panic!("schema catalog drift must fail");
+ };
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Integrity);
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[tokio::test(flavor = "current_thread")]
async fn pool_checkout_rejects_state_directory_replacement_before_growth() {
let directory = tempfile::tempdir().expect("temporary directory");
let policy = ServiceSqliteConnectionOptions::new(Duration::from_millis(500), 2).unwrap();
@@ -2933,6 +3229,95 @@ mod tests {
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+ async fn offline_inspection_fixture() -> (tempfile::TempDir, ServiceSqlitePaths) {
+ let directory = tempfile::tempdir().expect("temporary directory");
+ let (paths, writable) =
+ initialized_pool(directory.path(), ServiceSqliteConnectionOptions::reviewed()).await;
+ let authority = writable.close().await.expect("writer authority");
+ drop(authority);
+ (directory, paths)
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[tokio::test(flavor = "current_thread")]
+ async fn read_only_inspection_guard_rejects_each_filesystem_admission_drift() {
+ let (_directory, paths) = offline_inspection_fixture().await;
+ let state_directory = paths.state_database().parent().expect("state directory");
+ fs::set_permissions(state_directory, fs::Permissions::from_mode(0o775))
+ .expect("make state directory writable by group");
+ assert!(ReadOnlyInspectionGuard::acquire(&paths).is_err());
+
+ let (_directory, paths) = offline_inspection_fixture().await;
+ fs::set_permissions(paths.state_lock(), fs::Permissions::from_mode(0o644))
+ .expect("weaken state lock mode");
+ assert!(ReadOnlyInspectionGuard::acquire(&paths).is_err());
+
+ let (_directory, paths) = offline_inspection_fixture().await;
+ let lock_alias = paths
+ .state_lock()
+ .parent()
+ .expect("state directory")
+ .join("state-lock-alias");
+ fs::hard_link(paths.state_lock(), lock_alias).expect("hard-link state lock");
+ assert!(ReadOnlyInspectionGuard::acquire(&paths).is_err());
+
+ let (_directory, paths) = offline_inspection_fixture().await;
+ let authority = WriterAuthority::acquire(&paths, OpenMode::ReadWriteExisting)
+ .expect("writer authority");
+ let Err(error) = ReadOnlyInspectionGuard::acquire(&paths) else {
+ panic!("held writer lock must prevent inspection");
+ };
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Authority);
+ drop(authority);
+
+ let (_directory, paths) = offline_inspection_fixture().await;
+ fs::set_permissions(paths.state_database(), fs::Permissions::from_mode(0o644))
+ .expect("weaken database mode");
+ assert!(ReadOnlyInspectionGuard::acquire(&paths).is_err());
+
+ let (_directory, paths) = offline_inspection_fixture().await;
+ let database_alias = paths
+ .state_database()
+ .parent()
+ .expect("state directory")
+ .join("state-database-alias");
+ fs::hard_link(paths.state_database(), database_alias).expect("hard-link database");
+ assert!(ReadOnlyInspectionGuard::acquire(&paths).is_err());
+
+ let (_directory, paths) = offline_inspection_fixture().await;
+ let database = fs::OpenOptions::new()
+ .write(true)
+ .open(paths.state_database())
+ .expect("open database header");
+ std::os::unix::fs::FileExt::write_all_at(&database, &[1], 18)
+ .expect("corrupt SQLite write version");
+ assert!(ReadOnlyInspectionGuard::acquire(&paths).is_err());
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[tokio::test(flavor = "current_thread")]
+ async fn read_only_inspection_guard_revalidates_live_directory_and_sidecars() {
+ let (_directory, paths) = offline_inspection_fixture().await;
+ let state_directory = paths.state_database().parent().expect("state directory");
+ let original_mode = fs::metadata(state_directory)
+ .expect("state directory metadata")
+ .permissions()
+ .mode();
+ let guard = ReadOnlyInspectionGuard::acquire(&paths).expect("inspection guard");
+ fs::set_permissions(state_directory, fs::Permissions::from_mode(0o775))
+ .expect("make live directory unsafe");
+ assert!(guard.validate_for(&paths).is_err());
+ fs::set_permissions(
+ state_directory,
+ fs::Permissions::from_mode(original_mode & 0o777),
+ )
+ .expect("restore directory mode");
+ fs::write(state_directory.join(WAL_FILE_NAME), b"stale")
+ .expect("create stale WAL evidence");
+ assert!(guard.validate_for(&paths).is_err());
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
#[tokio::test(flavor = "current_thread")]
async fn pool_saturation_recovers_and_explicit_close_finishes() {
let directory = tempfile::tempdir().expect("temporary directory");
diff --git a/crates/service_sqlite/src/restore/finalize.rs b/crates/service_sqlite/src/restore/finalize.rs
@@ -228,9 +228,10 @@ fn finalize_native(
RestoreRecoveryPhase::ReplacementInstalled,
failpoints,
)?;
- if marker.marker().phase() != RestoreRecoveryPhase::ReplacementInstalled {
- return Err(finalize_error(FinalizeFailureKind::Marker));
- }
+ require_finalize_condition(
+ marker.marker().phase() == RestoreRecoveryPhase::ReplacementInstalled,
+ FinalizeFailureKind::Marker,
+ )?;
staged.validate_finalization_authority()?;
drop(marker);
drop(staged);
@@ -371,12 +372,21 @@ fn verify_named_artifact(
.map_err(|_| finalize_error(FinalizeFailureKind::Artifact))?,
current_status.st_ino,
);
- if held_identity != (expected.device(), expected.inode())
- || current_identity != held_identity
- || hash_exact(held, expected.byte_length(), cancellation)? != expected.sha256()
- {
- return Err(finalize_error(FinalizeFailureKind::Artifact));
- }
+ require_finalize_condition(
+ crate::native_metadata::identity_pair_matches(
+ held_identity.0,
+ held_identity.1,
+ current_identity.0,
+ current_identity.1,
+ expected.device(),
+ expected.inode(),
+ ),
+ FinalizeFailureKind::Artifact,
+ )?;
+ require_finalize_condition(
+ hash_exact(held, expected.byte_length(), cancellation)? == expected.sha256(),
+ FinalizeFailureKind::Artifact,
+ )?;
Ok(())
}
@@ -387,16 +397,19 @@ fn validate_status(
) -> Result<(), ServiceSqliteError> {
let length =
u64::try_from(status.st_size).map_err(|_| finalize_error(FinalizeFailureKind::Artifact))?;
- if !FileType::from_raw_mode(status.st_mode).is_file()
- || crate::native_metadata::link_count(status.st_nlink) != 1
- || status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(status.st_mode) & 0o777 != 0o600
- || length == 0
- || length > i64::MAX as u64
- || expected_length.is_some_and(|expected| length != expected)
- {
- return Err(finalize_error(FinalizeFailureKind::Artifact));
- }
+ require_finalize_condition(
+ crate::all_constraints([
+ crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(status.st_mode).is_file(),
+ crate::native_metadata::link_count(status.st_nlink),
+ status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(status.st_mode),
+ ),
+ crate::native_metadata::valid_artifact_length(length, expected_length),
+ ]),
+ FinalizeFailureKind::Artifact,
+ )?;
Ok(())
}
@@ -428,9 +441,10 @@ fn hash_exact(
)
.ok_or_else(|| finalize_error(FinalizeFailureKind::Hash))?;
}
- if cancellation.is_some_and(|state| state.load(Ordering::Acquire) == CANCELLED) {
- return Err(finalize_error(FinalizeFailureKind::Cancelled));
- }
+ require_finalize_condition(
+ !cancellation.is_some_and(|state| state.load(Ordering::Acquire) == CANCELLED),
+ FinalizeFailureKind::Cancelled,
+ )?;
let mut extra = [0_u8; 1];
if file
.read_at(&mut extra, expected_length)
@@ -452,6 +466,18 @@ fn check_cancel(cancellation: &AtomicU8) -> Result<(), ServiceSqliteError> {
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_finalize_condition(
+ condition: bool,
+ kind: FinalizeFailureKind,
+) -> Result<(), ServiceSqliteError> {
+ if condition {
+ Ok(())
+ } else {
+ Err(finalize_error(kind))
+ }
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn claim_commit_ownership(cancellation: &AtomicU8) -> Result<(), ServiceSqliteError> {
cancellation
.compare_exchange(
@@ -586,6 +612,7 @@ struct SystemFinalizeOperations;
#[cfg(any(target_os = "linux", target_os = "macos"))]
impl FinalizeOperations for SystemFinalizeOperations {
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn rename(
&self,
directory: &File,
@@ -603,6 +630,7 @@ impl FinalizeOperations for SystemFinalizeOperations {
.map_err(std::io::Error::from)
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn sync_directory(&self, directory: &File, _step: RenameStep) -> std::io::Result<()> {
directory.sync_all()
}
@@ -855,3 +883,136 @@ fn hit(
.hit(point)
.map_err(|source| finalize_source(kind, source))
}
+
+#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
+mod tests {
+ use std::io::Write;
+
+ use sha2::{Digest, Sha256};
+
+ use super::*;
+
+ #[test]
+ fn finalize_failure_inventory_is_complete_and_source_aware() {
+ let cases = [
+ (FinalizeFailureKind::Live, "live restore source is invalid"),
+ (
+ FinalizeFailureKind::Artifact,
+ "restore artifact binding changed",
+ ),
+ (FinalizeFailureKind::Hash, "restore artifact hash failed"),
+ (
+ FinalizeFailureKind::SyncLive,
+ "live restore source sync failed",
+ ),
+ (
+ FinalizeFailureKind::SyncStaged,
+ "staged restore sync failed",
+ ),
+ (
+ FinalizeFailureKind::Marker,
+ "restore marker transition failed",
+ ),
+ (
+ FinalizeFailureKind::RetainLive,
+ "live restore retention failed",
+ ),
+ (
+ FinalizeFailureKind::SyncRetained,
+ "retained restore sync failed",
+ ),
+ (
+ FinalizeFailureKind::InstallStage,
+ "restore installation failed",
+ ),
+ (
+ FinalizeFailureKind::SyncInstalled,
+ "installed restore sync failed",
+ ),
+ (
+ FinalizeFailureKind::Cancelled,
+ "restore finalization was cancelled",
+ ),
+ (
+ FinalizeFailureKind::Join,
+ "restore finalization worker failed",
+ ),
+ ];
+ for (kind, message) in cases {
+ let plain = FinalizeFailure { kind, source: None };
+ assert_eq!(plain.to_string(), message);
+ assert!(plain.source().is_none());
+ let sourced = FinalizeFailure {
+ kind,
+ source: Some(Box::new(std::io::Error::other("private-cause"))),
+ };
+ assert_eq!(sourced.to_string(), message);
+ assert!(sourced.source().is_some());
+ assert!(format!("{sourced:?}").contains("[redacted]"));
+ assert!(require_finalize_condition(true, kind).is_ok());
+ assert_eq!(
+ require_finalize_condition(false, kind)
+ .expect_err("false condition")
+ .kind(),
+ ServiceSqliteErrorKind::Restore
+ );
+ }
+ }
+
+ #[test]
+ fn hash_and_cancellation_boundaries_fail_closed() {
+ let root = tempfile::tempdir().expect("root");
+ let path = root.path().join("artifact.sqlite");
+ let payload = b"finalize-artifact";
+ std::fs::write(&path, payload).expect("artifact");
+ let file = File::open(&path).expect("open artifact");
+ let length = u64::try_from(payload.len()).expect("length");
+ let digest: [u8; 32] = Sha256::digest(payload).into();
+ assert_eq!(hash_exact(&file, length, None).expect("exact hash"), digest);
+ assert_eq!(
+ hash_exact(&file, length + 1, None)
+ .expect_err("short artifact")
+ .kind(),
+ ServiceSqliteErrorKind::Restore
+ );
+ assert_eq!(
+ hash_exact(&file, length - 1, None)
+ .expect_err("long artifact")
+ .kind(),
+ ServiceSqliteErrorKind::Restore
+ );
+
+ let cancelled = AtomicU8::new(CANCELLED);
+ assert_eq!(
+ hash_exact(&file, length, Some(&cancelled))
+ .expect_err("pre-read cancellation")
+ .kind(),
+ ServiceSqliteErrorKind::Restore
+ );
+ assert_eq!(
+ check_cancel(&cancelled).expect_err("cancelled").kind(),
+ ServiceSqliteErrorKind::Restore
+ );
+ assert_eq!(
+ claim_commit_ownership(&cancelled)
+ .expect_err("cancelled ownership handoff")
+ .kind(),
+ ServiceSqliteErrorKind::Restore
+ );
+ let active = AtomicU8::new(CANCELLABLE);
+ check_cancel(&active).expect("active");
+ claim_commit_ownership(&active).expect("commit ownership");
+ assert_eq!(active.load(Ordering::Acquire), COMMIT_OWNED);
+
+ let empty_path = root.path().join("empty.sqlite");
+ let mut empty = File::create(&empty_path).expect("empty artifact");
+ empty.flush().expect("flush empty artifact");
+ let empty = File::open(empty_path).expect("open empty artifact");
+ assert_eq!(
+ hash_exact(&empty, 1, None)
+ .expect_err("zero-byte read")
+ .kind(),
+ ServiceSqliteErrorKind::Restore
+ );
+ }
+}
diff --git a/crates/service_sqlite/src/restore/marker.rs b/crates/service_sqlite/src/restore/marker.rs
@@ -214,20 +214,24 @@ impl RestoreRecoveryMarker {
}
pub(crate) fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, RestoreMarkerContractError> {
- if bytes.is_empty() {
- return Err(RestoreMarkerContractError::MalformedEncoding);
- }
- if bytes.len() > RESTORE_MARKER_MAX_BYTES {
- return Err(RestoreMarkerContractError::MarkerTooLarge);
- }
+ require_marker_contract(
+ !bytes.is_empty(),
+ RestoreMarkerContractError::MalformedEncoding,
+ )?;
+ require_marker_contract(
+ bytes.len() <= RESTORE_MARKER_MAX_BYTES,
+ RestoreMarkerContractError::MarkerTooLarge,
+ )?;
let wire: WireMarker = serde_json::from_slice(bytes)
.map_err(|_| RestoreMarkerContractError::MalformedEncoding)?;
- if wire.schema != RESTORE_MARKER_SCHEMA {
- return Err(RestoreMarkerContractError::UnsupportedValue);
- }
- if wire.schema_version != RESTORE_MARKER_SCHEMA_VERSION {
- return Err(RestoreMarkerContractError::UnsupportedValue);
- }
+ require_marker_contract(
+ wire.schema == RESTORE_MARKER_SCHEMA,
+ RestoreMarkerContractError::UnsupportedValue,
+ )?;
+ require_marker_contract(
+ wire.schema_version == RESTORE_MARKER_SCHEMA_VERSION,
+ RestoreMarkerContractError::UnsupportedValue,
+ )?;
let marker = Self::build(
RestoreRecoveryPhase::parse(&wire.phase)?,
ServiceId::new(wire.service)
@@ -247,12 +251,14 @@ impl RestoreRecoveryMarker {
)?;
let claimed = decode_hex_32(&wire.marker_sha256)?;
let actual = marker_checksum(&marker.payload_bytes()?);
- if claimed != actual {
- return Err(RestoreMarkerContractError::ChecksumMismatch);
- }
- if marker.canonical_bytes.as_ref() != bytes {
- return Err(RestoreMarkerContractError::NonCanonicalEncoding);
- }
+ require_marker_contract(
+ claimed == actual,
+ RestoreMarkerContractError::ChecksumMismatch,
+ )?;
+ require_marker_contract(
+ marker.canonical_bytes.as_ref() == bytes,
+ RestoreMarkerContractError::NonCanonicalEncoding,
+ )?;
Ok(marker)
}
@@ -280,9 +286,10 @@ impl RestoreRecoveryMarker {
&self,
next: RestoreRecoveryPhase,
) -> Result<Self, RestoreMarkerContractError> {
- if !self.phase.may_transition_to(next) {
- return Err(RestoreMarkerContractError::IllegalTransition);
- }
+ require_marker_contract(
+ self.phase.may_transition_to(next),
+ RestoreMarkerContractError::IllegalTransition,
+ )?;
if self.phase == next {
return Ok(self.clone());
}
@@ -313,9 +320,13 @@ impl RestoreRecoveryMarker {
staged: RestoreArtifactExpectation,
backup: RestoreArtifactExpectation,
) -> Result<Self, RestoreMarkerContractError> {
- if live != backup || (live.device, live.inode) == (staged.device, staged.inode) {
- return Err(RestoreMarkerContractError::InvalidIdentity);
- }
+ require_marker_contract(
+ crate::all_constraints([
+ live == backup,
+ (live.device, live.inode) != (staged.device, staged.inode),
+ ]),
+ RestoreMarkerContractError::InvalidIdentity,
+ )?;
let mut marker = Self {
phase,
service,
@@ -334,9 +345,10 @@ impl RestoreRecoveryMarker {
let canonical = marker.wire(&checksum);
let canonical_bytes = serde_json::to_vec(&canonical)
.map_err(|_| RestoreMarkerContractError::EncodingFailure)?;
- if canonical_bytes.len() > RESTORE_MARKER_MAX_BYTES {
- return Err(RestoreMarkerContractError::MarkerTooLarge);
- }
+ require_marker_contract(
+ canonical_bytes.len() <= RESTORE_MARKER_MAX_BYTES,
+ RestoreMarkerContractError::MarkerTooLarge,
+ )?;
marker.canonical_bytes = canonical_bytes.into_boxed_slice();
Ok(marker)
}
@@ -370,15 +382,20 @@ impl RestoreRecoveryMarker {
}
fn matches_paths(&self, paths: &ServiceSqlitePaths) -> bool {
- self.service == *paths.service() && self.instance == *paths.instance()
+ crate::all_constraints([
+ self.service == *paths.service(),
+ self.instance == *paths.instance(),
+ ])
}
pub(crate) fn matches_identity(&self, identity: &ServiceDatabaseIdentity) -> bool {
- self.service == *identity.service()
- && self.instance == *identity.instance()
- && self.source_generation == identity.source_generation()
- && self.application_id == identity.application_id()
- && self.state_schema_version <= identity.supported_state_schema_version()
+ crate::all_constraints([
+ self.service == *identity.service(),
+ self.instance == *identity.instance(),
+ self.source_generation == identity.source_generation(),
+ self.application_id == identity.application_id(),
+ self.state_schema_version <= identity.supported_state_schema_version(),
+ ])
}
}
@@ -406,6 +423,30 @@ pub(crate) enum RestoreMarkerContractError {
IllegalTransition,
}
+fn require_marker_contract(
+ condition: bool,
+ error: RestoreMarkerContractError,
+) -> Result<(), RestoreMarkerContractError> {
+ if condition { Ok(()) } else { Err(error) }
+}
+
+fn layout_uses_fixed_marker_name(layout: &RestoreRecoveryLayout) -> bool {
+ layout
+ .marker
+ .file_name()
+ .is_some_and(|name| name == MARKER_FILE_NAME)
+}
+
+fn interrupted_successor_matches(
+ current: &RestoreRecoveryMarker,
+ scratch: &RestoreRecoveryMarker,
+) -> bool {
+ scratch.phase() != current.phase()
+ && current
+ .transitioned_to(scratch.phase())
+ .is_ok_and(|expected| expected.canonical_bytes() == scratch.canonical_bytes())
+}
+
impl fmt::Display for RestoreMarkerContractError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
@@ -658,16 +699,16 @@ mod store {
on_durable: impl FnOnce(),
) -> Result<Self, ServiceSqliteError> {
authority.validate_for(paths)?;
- if !marker.matches_paths(paths) {
- return Err(restore_contract(
- RestoreMarkerContractError::InvalidIdentity,
- ));
- }
- if marker.phase() != RestoreRecoveryPhase::Prepared {
- return Err(restore_contract(
- RestoreMarkerContractError::IllegalTransition,
- ));
- }
+ require_marker_contract(
+ marker.matches_paths(paths),
+ RestoreMarkerContractError::InvalidIdentity,
+ )
+ .map_err(restore_contract)?;
+ require_marker_contract(
+ marker.phase() == RestoreRecoveryPhase::Prepared,
+ RestoreMarkerContractError::IllegalTransition,
+ )
+ .map_err(restore_contract)?;
let layout = RestoreRecoveryLayout::for_paths(paths).map_err(restore_contract)?;
let directory = authority_checked(authority, paths, || {
authority
@@ -744,7 +785,10 @@ mod store {
)?;
return Err(restore_store(cause));
}
- if operations.sync_directory(&directory).is_err() {
+ if let Err(cause) = require_store_condition(
+ operations.sync_directory(&directory).is_ok(),
+ StoreFailure::Sync,
+ ) {
authority.validate_for(paths)?;
cleanup_with_authority(
authority,
@@ -753,7 +797,7 @@ mod store {
MARKER_FILE_NAME,
marker_identity,
)?;
- return Err(restore_store(StoreFailure::Sync));
+ return Err(restore_store(cause));
}
// The marker contents and its directory entry are durable from
// this point. The caller must transfer ownership of every bound
@@ -773,13 +817,11 @@ mod store {
marker: marker.clone(),
};
authority_checked(authority, paths, || binding.validate_for_restore(paths))??;
- if layout
- .marker
- .file_name()
- .is_none_or(|name| name != MARKER_FILE_NAME)
- {
- return Err(restore_contract(RestoreMarkerContractError::InvalidLayout));
- }
+ require_marker_contract(
+ layout_uses_fixed_marker_name(&layout),
+ RestoreMarkerContractError::InvalidLayout,
+ )
+ .map_err(restore_contract)?;
Ok(binding)
}
@@ -801,11 +843,11 @@ mod store {
};
let marker_identity = file_identity(&marker_file).map_err(recovery_store)?;
let marker = read_marker(&marker_file).map_err(recovery_store)?;
- if !marker.matches_paths(paths) {
- return Err(recovery_contract(
- RestoreMarkerContractError::InvalidIdentity,
- ));
- }
+ require_marker_contract(
+ marker.matches_paths(paths),
+ RestoreMarkerContractError::InvalidIdentity,
+ )
+ .map_err(recovery_contract)?;
let binding = Self {
directory,
directory_identity,
@@ -851,11 +893,11 @@ mod store {
.map_err(recovery_store)?;
let marker = authority_checked(authority, paths, || read_marker(&marker_file))?
.map_err(recovery_store)?;
- if !marker.matches_paths(paths) {
- return Err(recovery_contract(
- RestoreMarkerContractError::InvalidIdentity,
- ));
- }
+ require_marker_contract(
+ marker.matches_paths(paths),
+ RestoreMarkerContractError::InvalidIdentity,
+ )
+ .map_err(recovery_contract)?;
let binding = Self {
directory,
directory_identity,
@@ -866,13 +908,11 @@ mod store {
authority_checked(authority, paths, || {
binding.validate_inner(paths, false, ServiceSqliteErrorKind::Recovery)
})??;
- if layout
- .marker
- .file_name()
- .is_none_or(|name| name != MARKER_FILE_NAME)
- {
- return Err(recovery_contract(RestoreMarkerContractError::InvalidLayout));
- }
+ require_marker_contract(
+ layout_uses_fixed_marker_name(&layout),
+ RestoreMarkerContractError::InvalidLayout,
+ )
+ .map_err(recovery_contract)?;
authority.validate_for(paths)?;
Ok(Some(binding))
}
@@ -934,9 +974,11 @@ mod store {
})??;
let current = authority_checked(authority, paths, || read_marker(&self.marker_file))?
.map_err(|cause| operation_store(operation_kind, cause))?;
- if current.canonical_bytes() != self.marker.canonical_bytes() {
- return Err(operation_store(operation_kind, StoreFailure::Conflict));
- }
+ require_store_condition(
+ current.canonical_bytes() == self.marker.canonical_bytes(),
+ StoreFailure::Conflict,
+ )
+ .map_err(|cause| operation_store(operation_kind, cause))?;
let next_marker = self
.marker
.transitioned_to(next)
@@ -1009,7 +1051,7 @@ mod store {
)
})?
.map_err(|cause| operation_store(operation_kind, cause))?;
- if !scratch_matches {
+ if let Err(cause) = require_store_condition(scratch_matches, StoreFailure::Conflict) {
cleanup_with_authority(
authority,
paths,
@@ -1017,7 +1059,7 @@ mod store {
MARKER_NEXT_FILE_NAME,
scratch_identity,
)?;
- return Err(operation_store(operation_kind, StoreFailure::Conflict));
+ return Err(operation_store(operation_kind, cause));
}
let before_replace = authority_checked(authority, paths, || {
hit(
@@ -1040,7 +1082,7 @@ mod store {
.replace_marker(&self.directory)
.map_err(|_| StoreFailure::Rename)
})?;
- if replacement.is_err() {
+ if let Err(cause) = require_store_condition(replacement.is_ok(), StoreFailure::Rename) {
cleanup_with_authority(
authority,
paths,
@@ -1048,7 +1090,7 @@ mod store {
MARKER_NEXT_FILE_NAME,
scratch_identity,
)?;
- return Err(operation_store(operation_kind, StoreFailure::Rename));
+ return Err(operation_store(operation_kind, cause));
}
authority_checked(authority, paths, || {
hit(
@@ -1069,9 +1111,8 @@ mod store {
.sync_directory(&self.directory)
.map_err(|_| StoreFailure::Sync)
})?;
- if parent_sync.is_err() {
- return Err(operation_store(operation_kind, StoreFailure::Sync));
- }
+ require_store_condition(parent_sync.is_ok(), StoreFailure::Sync)
+ .map_err(|cause| operation_store(operation_kind, cause))?;
authority_checked(authority, paths, || {
hit(
failpoints,
@@ -1087,9 +1128,11 @@ mod store {
Ok::<_, StoreFailure>((file, identity, marker))
})?
.map_err(|cause| operation_store(operation_kind, cause))?;
- if reread.canonical_bytes() != next_marker.canonical_bytes() {
- return Err(operation_store(operation_kind, StoreFailure::Conflict));
- }
+ require_store_condition(
+ reread.canonical_bytes() == next_marker.canonical_bytes(),
+ StoreFailure::Conflict,
+ )
+ .map_err(|cause| operation_store(operation_kind, cause))?;
let binding = Self {
directory: self.directory,
directory_identity: self.directory_identity,
@@ -1160,16 +1203,12 @@ mod store {
};
let scratch_marker = authority_checked(authority, paths, || read_marker(&scratch))?
.map_err(recovery_store)?;
+ require_store_condition(
+ interrupted_successor_matches(&self.marker, &scratch_marker),
+ StoreFailure::Conflict,
+ )
+ .map_err(recovery_store)?;
let next = scratch_marker.phase();
- let expected = self.marker.transitioned_to(next);
- if next == self.marker.phase()
- || match expected {
- Ok(expected) => expected.canonical_bytes() != scratch_marker.canonical_bytes(),
- Err(_) => true,
- }
- {
- return Err(recovery_store(StoreFailure::Conflict));
- }
Ok(Some(next))
}
@@ -1205,9 +1244,11 @@ mod store {
.marker
.transitioned_to(expected_phase)
.map_err(recovery_contract)?;
- if scratch_marker.canonical_bytes() != expected.canonical_bytes() {
- return Err(recovery_store(StoreFailure::Conflict));
- }
+ require_store_condition(
+ scratch_marker.canonical_bytes() == expected.canonical_bytes(),
+ StoreFailure::Conflict,
+ )
+ .map_err(recovery_store)?;
before_exact_removal();
// Preserve the valid current marker even if the scratch pathname
// was replaced after an interrupted advance. Remove only the
@@ -1279,31 +1320,40 @@ mod store {
)
.map_err(|_| authority_store(StoreFailure::Directory))?;
let current_directory = File::from(current_directory);
- if validate_directory(&self.directory).map_err(authority_store)?
- != self.directory_identity
- || validate_directory(¤t_directory).map_err(authority_store)?
- != self.directory_identity
- {
- return Err(authority_store(StoreFailure::Conflict));
- }
+ require_store_condition(
+ validate_directory(&self.directory).map_err(authority_store)?
+ == self.directory_identity,
+ StoreFailure::Conflict,
+ )
+ .map_err(authority_store)?;
+ require_store_condition(
+ validate_directory(¤t_directory).map_err(authority_store)?
+ == self.directory_identity,
+ StoreFailure::Conflict,
+ )
+ .map_err(authority_store)?;
let current_marker = open_marker_file(¤t_directory, MARKER_FILE_NAME)
.map_err(|cause| operation_store(operation_kind, cause))?;
- if file_identity(&self.marker_file)
- .map_err(|cause| operation_store(operation_kind, cause))?
- != self.marker_identity
- || file_identity(¤t_marker)
+ require_store_condition(
+ file_identity(&self.marker_file)
.map_err(|cause| operation_store(operation_kind, cause))?
- != self.marker_identity
- {
- return Err(operation_store(operation_kind, StoreFailure::Conflict));
- }
- if read_marker(&self.marker_file)
+ == self.marker_identity,
+ StoreFailure::Conflict,
+ )
+ .map_err(|cause| operation_store(operation_kind, cause))?;
+ require_store_condition(
+ file_identity(¤t_marker)
+ .map_err(|cause| operation_store(operation_kind, cause))?
+ == self.marker_identity,
+ StoreFailure::Conflict,
+ )
+ .map_err(|cause| operation_store(operation_kind, cause))?;
+ let marker_bytes_match = read_marker(&self.marker_file)
.map_err(|cause| operation_store(operation_kind, cause))?
.canonical_bytes()
- != self.marker.canonical_bytes()
- {
- return Err(operation_store(operation_kind, StoreFailure::Conflict));
- }
+ == self.marker.canonical_bytes();
+ require_store_condition(marker_bytes_match, StoreFailure::Conflict)
+ .map_err(|cause| operation_store(operation_kind, cause))?;
if require_no_scratch {
require_absent(¤t_directory, MARKER_NEXT_FILE_NAME)
.map_err(|cause| operation_store(operation_kind, cause))?;
@@ -1314,10 +1364,12 @@ mod store {
fn validate_directory(file: &File) -> Result<FileIdentity, StoreFailure> {
let status = fstat(file).map_err(|_| StoreFailure::Directory)?;
- if !FileType::from_raw_mode(status.st_mode).is_dir()
- || status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(status.st_mode) & 0o022 != 0
- {
+ if !crate::native_metadata::secure_directory(
+ FileType::from_raw_mode(status.st_mode).is_dir(),
+ status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(status.st_mode),
+ ) {
return Err(StoreFailure::Directory);
}
identity(status.st_dev, status.st_ino)
@@ -1386,11 +1438,13 @@ mod store {
fn file_identity(file: &File) -> Result<FileIdentity, StoreFailure> {
let status = fstat(file).map_err(|_| StoreFailure::Marker)?;
- if !FileType::from_raw_mode(status.st_mode).is_file()
- || crate::native_metadata::link_count(status.st_nlink) != 1
- || status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(status.st_mode) & 0o777 != 0o600
- {
+ if !crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(status.st_mode).is_file(),
+ crate::native_metadata::link_count(status.st_nlink),
+ status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(status.st_mode),
+ ) {
return Err(StoreFailure::Marker);
}
identity(status.st_dev, status.st_ino)
@@ -1412,14 +1466,17 @@ mod store {
struct SystemStoreOperations;
impl StoreOperations for SystemStoreOperations {
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn sync_file(&self, file: &File, _directory: &File) -> std::io::Result<()> {
file.sync_all()
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn sync_directory(&self, directory: &File) -> std::io::Result<()> {
directory.sync_all()
}
+ #[cfg_attr(coverage_nightly, coverage(off))]
fn replace_marker(&self, directory: &File) -> std::io::Result<()> {
renameat(
directory,
@@ -1566,9 +1623,7 @@ mod store {
expected: FileIdentity,
) -> Result<(), StoreFailure> {
let current = open_marker_file(directory, name)?;
- if file_identity(¤t)? != expected {
- return Err(StoreFailure::Conflict);
- }
+ require_store_condition(file_identity(¤t)? == expected, StoreFailure::Conflict)?;
unlinkat(directory, name, AtFlags::empty()).map_err(|_| StoreFailure::Conflict)?;
directory.sync_all().map_err(|_| StoreFailure::Sync)?;
require_absent(directory, name)
@@ -1590,6 +1645,10 @@ mod store {
Contract(RestoreMarkerContractError),
}
+ fn require_store_condition(condition: bool, error: StoreFailure) -> Result<(), StoreFailure> {
+ if condition { Ok(()) } else { Err(error) }
+ }
+
impl fmt::Display for StoreFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
@@ -1642,6 +1701,63 @@ mod store {
));
ServiceSqliteError::with_source(kind, cause)
}
+
+ #[cfg(test)]
+ mod failure_tests {
+
+ use super::*;
+
+ #[test]
+ fn store_failure_inventory_is_complete_and_source_free() {
+ let contract = RestoreMarkerContractError::ChecksumMismatch;
+ for (failure, message) in [
+ (
+ StoreFailure::Directory,
+ "restore marker directory is invalid",
+ ),
+ (StoreFailure::Marker, "restore marker file is invalid"),
+ (StoreFailure::Missing, "restore marker file is missing"),
+ (
+ StoreFailure::Collision,
+ "restore marker artifact already exists",
+ ),
+ (
+ StoreFailure::Permissions,
+ "restore marker permissions are invalid",
+ ),
+ (StoreFailure::Read, "restore marker could not be read"),
+ (StoreFailure::Write, "restore marker could not be written"),
+ (
+ StoreFailure::Sync,
+ "restore marker durability could not be proven",
+ ),
+ (StoreFailure::Rename, "restore marker replacement failed"),
+ (StoreFailure::Conflict, "restore marker binding changed"),
+ (
+ StoreFailure::Injected,
+ "restore marker durability boundary failed",
+ ),
+ (
+ StoreFailure::Contract(contract),
+ "restore marker checksum does not match",
+ ),
+ ] {
+ assert_eq!(failure.to_string(), message);
+ assert!(failure.source().is_none());
+ assert!(format!("{failure:?}").contains(&format!("{failure:?}")));
+ assert_eq!(require_store_condition(true, failure), Ok(()));
+ assert_eq!(require_store_condition(false, failure), Err(failure));
+ for kind in [
+ ServiceSqliteErrorKind::Restore,
+ ServiceSqliteErrorKind::Recovery,
+ ] {
+ let error = operation_store(kind, failure);
+ assert_eq!(error.kind(), kind);
+ assert!(error.source().is_some());
+ }
+ }
+ }
+ }
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -1651,6 +1767,7 @@ pub(crate) use store::TestStoreFailure;
#[cfg(test)]
mod tests {
+
use super::*;
use radroots_runtime_paths::{
InstanceId, RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver,
@@ -1658,6 +1775,56 @@ mod tests {
};
fn paths(root: &Path) -> ServiceSqlitePaths {
+ paths_for(root, "myc", "primary")
+ }
+
+ #[test]
+ fn marker_contract_failure_inventory_is_complete_and_source_free() {
+ for (kind, message) in [
+ (
+ RestoreMarkerContractError::MarkerTooLarge,
+ "restore marker exceeds its byte limit",
+ ),
+ (
+ RestoreMarkerContractError::MalformedEncoding,
+ "restore marker encoding is malformed",
+ ),
+ (
+ RestoreMarkerContractError::NonCanonicalEncoding,
+ "restore marker encoding is not canonical",
+ ),
+ (
+ RestoreMarkerContractError::EncodingFailure,
+ "restore marker could not be encoded",
+ ),
+ (
+ RestoreMarkerContractError::UnsupportedValue,
+ "restore marker schema or value is unsupported",
+ ),
+ (
+ RestoreMarkerContractError::ChecksumMismatch,
+ "restore marker checksum does not match",
+ ),
+ (
+ RestoreMarkerContractError::InvalidIdentity,
+ "restore marker identity is invalid",
+ ),
+ (
+ RestoreMarkerContractError::InvalidLayout,
+ "restore recovery layout is invalid",
+ ),
+ (
+ RestoreMarkerContractError::IllegalTransition,
+ "restore marker transition is illegal",
+ ),
+ ] {
+ assert_eq!(kind.to_string(), message);
+ assert!(kind.source().is_none());
+ assert!(format!("{kind:?}").contains(&format!("{kind:?}")));
+ }
+ }
+
+ fn paths_for(root: &Path, service: &str, instance: &str) -> ServiceSqlitePaths {
let context = RuntimeContext::resolve(
&RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
RuntimeContextBootstrap::new(
@@ -1667,8 +1834,8 @@ mod tests {
RuntimeContextSource::BootstrapCli,
)
.expect("bootstrap"),
- ServiceId::new("myc").expect("service"),
- InstanceId::new("primary").expect("instance"),
+ ServiceId::new(service).expect("service"),
+ InstanceId::new(instance).expect("instance"),
)
.expect("context");
ServiceSqlitePaths::from_runtime_context(&context).expect("paths")
@@ -1731,6 +1898,52 @@ mod tests {
}
#[test]
+ fn marker_identity_matching_binds_each_dimension() {
+ let root = tempfile::tempdir().expect("root");
+ let paths = paths(root.path());
+ let marker = marker(&paths);
+ let exact = ServiceDatabaseIdentity::new(
+ &paths,
+ SourceGeneration::new([7; 32]).expect("generation"),
+ NonZeroU32::new(3).expect("schema"),
+ ServiceSqliteApplicationId::new(0x5244_5254).expect("application"),
+ );
+ assert!(marker.matches_identity(&exact));
+ let other_service_paths = paths_for(root.path(), "rhi", "primary");
+ assert!(!marker.matches_identity(&ServiceDatabaseIdentity::new(
+ &other_service_paths,
+ exact.source_generation(),
+ exact.supported_state_schema_version(),
+ exact.application_id(),
+ )));
+ let other_instance_paths = paths_for(root.path(), "myc", "secondary");
+ assert!(!marker.matches_identity(&ServiceDatabaseIdentity::new(
+ &other_instance_paths,
+ exact.source_generation(),
+ exact.supported_state_schema_version(),
+ exact.application_id(),
+ )));
+ assert!(!marker.matches_identity(&ServiceDatabaseIdentity::new(
+ &paths,
+ SourceGeneration::new([9; 32]).expect("generation"),
+ exact.supported_state_schema_version(),
+ exact.application_id(),
+ )));
+ assert!(!marker.matches_identity(&ServiceDatabaseIdentity::new(
+ &paths,
+ exact.source_generation(),
+ exact.supported_state_schema_version(),
+ ServiceSqliteApplicationId::new(7).expect("application"),
+ )));
+ assert!(!marker.matches_identity(&ServiceDatabaseIdentity::new(
+ &paths,
+ exact.source_generation(),
+ NonZeroU32::new(2).expect("schema ceiling"),
+ exact.application_id(),
+ )));
+ }
+
+ #[test]
fn all_phase_edges_and_idempotent_bytes_are_exact() {
let root = tempfile::tempdir().expect("root");
let prepared = marker(&paths(root.path()));
@@ -1780,9 +1993,18 @@ mod tests {
let root = tempfile::tempdir().expect("root");
let marker = marker(&paths(root.path()));
let text = std::str::from_utf8(marker.canonical_bytes()).expect("text");
+ assert_eq!(
+ RestoreRecoveryMarker::from_canonical_bytes(b""),
+ Err(RestoreMarkerContractError::MalformedEncoding)
+ );
for altered in [
format!(" {text}"),
+ text.replace(
+ "\"schema\":\"radroots.service-sqlite.restore-marker\"",
+ "\"schema\":\"other\"",
+ ),
text.replace("\"schema_version\":1", "\"schema_version\":2"),
+ text.replace("\"phase\":\"prepared\"", "\"phase\":\"unknown\""),
text.replace("\"phase\":\"prepared\"", "\"phase\":null"),
text.replace(
"\"service\":\"myc\"",
@@ -1793,6 +2015,11 @@ mod tests {
"\"unknown\":1,\"schema_version\":1,\"phase\"",
),
text.replace("\"device\":1,\"inode\":2", "\"inode\":2,\"device\":1"),
+ text.replace("\"source_generation\":\"07", "\"source_generation\":\"0"),
+ text.replace("\"source_generation\":\"07", "\"source_generation\":\"A7"),
+ text.replace("\"state_schema_version\":3", "\"state_schema_version\":0"),
+ text.replace("\"application_id\":1380209236", "\"application_id\":0"),
+ text.replace("\"byte_length\":4096", "\"byte_length\":0"),
text.replace("\"marker_sha256\":\"0", "\"marker_sha256\":\"A"),
] {
assert!(
@@ -2160,8 +2387,8 @@ mod tests {
fn marker_errors_and_debug_are_path_content_and_digest_free() {
let root = tempfile::tempdir().expect("secret-root");
let paths = paths(root.path());
- let marker = marker(&paths);
- let debug = format!("{marker:?}");
+ let redacted_marker = marker(&paths);
+ let debug = format!("{redacted_marker:?}");
for sensitive in [
"secret-root",
"state.sqlite",
@@ -2188,5 +2415,39 @@ mod tests {
assert!(!rendered.contains('/'));
assert!(!rendered.contains(".sqlite"));
}
+ for error in [
+ RestoreMarkerContractError::MarkerTooLarge,
+ RestoreMarkerContractError::MalformedEncoding,
+ RestoreMarkerContractError::NonCanonicalEncoding,
+ RestoreMarkerContractError::EncodingFailure,
+ RestoreMarkerContractError::UnsupportedValue,
+ RestoreMarkerContractError::ChecksumMismatch,
+ RestoreMarkerContractError::InvalidIdentity,
+ RestoreMarkerContractError::InvalidLayout,
+ RestoreMarkerContractError::IllegalTransition,
+ ] {
+ assert_eq!(require_marker_contract(true, error), Ok(()));
+ assert_eq!(require_marker_contract(false, error), Err(error));
+ }
+
+ let layout = RestoreRecoveryLayout::for_paths(&paths).expect("layout");
+ assert!(layout_uses_fixed_marker_name(&layout));
+ let mut invalid_layout = layout;
+ invalid_layout.marker = invalid_layout.marker.with_file_name("other-marker");
+ assert!(!layout_uses_fixed_marker_name(&invalid_layout));
+
+ let prepared = marker(&paths);
+ let retained = prepared
+ .transitioned_to(RestoreRecoveryPhase::LiveRetained)
+ .expect("retained");
+ let installed = retained
+ .transitioned_to(RestoreRecoveryPhase::ReplacementInstalled)
+ .expect("installed");
+ assert!(interrupted_successor_matches(&prepared, &retained));
+ assert!(interrupted_successor_matches(&retained, &installed));
+ assert!(!interrupted_successor_matches(&prepared, &prepared));
+ assert!(!interrupted_successor_matches(&prepared, &installed));
+ let other_prepared = marker(&paths);
+ assert!(!interrupted_successor_matches(&retained, &other_prepared));
}
}
diff --git a/crates/service_sqlite/src/restore/mod.rs b/crates/service_sqlite/src/restore/mod.rs
@@ -6,6 +6,7 @@ mod recover;
mod stage;
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
+#[cfg_attr(coverage_nightly, coverage(off))]
mod process_tests;
pub use finalize::finalize_staged_restore;
diff --git a/crates/service_sqlite/src/restore/recover.rs b/crates/service_sqlite/src/restore/recover.rs
@@ -280,11 +280,14 @@ fn observe_artifacts(
verify_artifact(&file, marker.live())?;
LiveArtifact::Original
}
- Some(file) if artifact_has_identity(&file, marker.staged())? => {
+ Some(file) => {
+ require_recovery_condition(
+ artifact_has_identity(&file, marker.staged())?,
+ RecoveryFailureKind::Artifact,
+ )?;
verify_artifact(&file, marker.staged())?;
LiveArtifact::Replacement(file)
}
- Some(_) => return Err(recovery_error(RecoveryFailureKind::Artifact)),
};
let staged = observe_expected(directory, STAGED_FILE_NAME, marker.staged())?;
let backup = observe_expected(directory, BACKUP_FILE_NAME, marker.backup())?;
@@ -347,18 +350,24 @@ fn verify_artifact(
.map_err(|_| recovery_error(RecoveryFailureKind::Artifact))?;
let length =
u64::try_from(status.st_size).map_err(|_| recovery_error(RecoveryFailureKind::Artifact))?;
- if !FileType::from_raw_mode(status.st_mode).is_file()
- || crate::native_metadata::link_count(status.st_nlink) != 1
- || status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(status.st_mode) & 0o777 != 0o600
- || (device, status.st_ino) != (expected.device(), expected.inode())
- || length != expected.byte_length()
- || length == 0
- || length > i64::MAX as u64
- || hash_exact(file, expected.byte_length())? != expected.sha256()
- {
- return Err(recovery_error(RecoveryFailureKind::Artifact));
- }
+ require_recovery_condition(
+ crate::all_constraints([
+ crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(status.st_mode).is_file(),
+ crate::native_metadata::link_count(status.st_nlink),
+ status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(status.st_mode),
+ ),
+ (device, status.st_ino) == (expected.device(), expected.inode()),
+ crate::native_metadata::valid_artifact_length(length, Some(expected.byte_length())),
+ ]),
+ RecoveryFailureKind::Artifact,
+ )?;
+ require_recovery_condition(
+ hash_exact(file, expected.byte_length())? == expected.sha256(),
+ RecoveryFailureKind::Artifact,
+ )?;
Ok(())
}
@@ -400,6 +409,18 @@ fn require_absent(directory: &File, name: &str) -> Result<(), ServiceSqliteError
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_recovery_condition(
+ condition: bool,
+ kind: RecoveryFailureKind,
+) -> Result<(), ServiceSqliteError> {
+ if condition {
+ Ok(())
+ } else {
+ Err(recovery_error(kind))
+ }
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn hash_exact(file: &File, expected_length: u64) -> Result<[u8; 32], ServiceSqliteError> {
let mut hasher = Sha256::new();
let mut buffer = [0_u8; HASH_BUFFER_BYTES];
@@ -410,9 +431,7 @@ fn hash_exact(file: &File, expected_length: u64) -> Result<[u8; 32], ServiceSqli
let read = file
.read_at(&mut buffer[..requested], offset)
.map_err(|source| recovery_source(RecoveryFailureKind::Hash, source))?;
- if read == 0 {
- return Err(recovery_error(RecoveryFailureKind::Hash));
- }
+ require_recovery_condition(read != 0, RecoveryFailureKind::Hash)?;
hasher.update(&buffer[..read]);
offset = offset
.checked_add(
@@ -421,13 +440,12 @@ fn hash_exact(file: &File, expected_length: u64) -> Result<[u8; 32], ServiceSqli
.ok_or_else(|| recovery_error(RecoveryFailureKind::Hash))?;
}
let mut extra = [0_u8; 1];
- if file
- .read_at(&mut extra, expected_length)
- .map_err(|source| recovery_source(RecoveryFailureKind::Hash, source))?
- != 0
- {
- return Err(recovery_error(RecoveryFailureKind::Hash));
- }
+ require_recovery_condition(
+ file.read_at(&mut extra, expected_length)
+ .map_err(|source| recovery_source(RecoveryFailureKind::Hash, source))?
+ == 0,
+ RecoveryFailureKind::Hash,
+ )?;
Ok(hasher.finalize().into())
}
@@ -546,6 +564,105 @@ mod tests {
const OLD_BYTES: &[u8] = b"old-live-state";
const NEW_BYTES: &[u8] = b"new-restored-state";
+ #[test]
+ fn recovery_failure_inventory_is_complete_and_source_aware() {
+ let cases = [
+ (
+ RecoveryFailureKind::Intent,
+ "restore recovery intent does not match",
+ ),
+ (
+ RecoveryFailureKind::Topology,
+ "restore recovery topology is ambiguous",
+ ),
+ (
+ RecoveryFailureKind::Artifact,
+ "restore recovery artifact is invalid",
+ ),
+ (
+ RecoveryFailureKind::Hash,
+ "restore recovery artifact hash failed",
+ ),
+ (
+ RecoveryFailureKind::InstallReplacement,
+ "restore recovery replacement installation failed",
+ ),
+ (
+ RecoveryFailureKind::DirectorySync,
+ "restore recovery directory durability failed",
+ ),
+ (
+ RecoveryFailureKind::Cleanup,
+ "restore recovery cleanup failed",
+ ),
+ ];
+ for (kind, message) in cases {
+ let plain = RecoveryFailure { kind, source: None };
+ assert_eq!(plain.to_string(), message);
+ assert!(plain.source().is_none());
+ let sourced = RecoveryFailure {
+ kind,
+ source: Some(Box::new(std::io::Error::other("private-cause"))),
+ };
+ assert_eq!(sourced.to_string(), message);
+ assert!(sourced.source().is_some());
+ assert!(format!("{sourced:?}").contains("[redacted]"));
+ assert!(require_recovery_condition(true, kind).is_ok());
+ assert_eq!(
+ require_recovery_condition(false, kind)
+ .expect_err("false condition")
+ .kind(),
+ ServiceSqliteErrorKind::Recovery
+ );
+ }
+ }
+
+ #[test]
+ fn observed_artifact_topology_predicates_cover_every_boolean_combination() {
+ fn observed(live: u8, staged: bool, backup: bool) -> ObservedArtifacts {
+ let live = match live {
+ 0 => LiveArtifact::Absent,
+ 1 => LiveArtifact::Original,
+ 2 => LiveArtifact::Replacement(File::open("/dev/null").expect("replacement")),
+ _ => unreachable!("test topology is closed"),
+ };
+ let artifact =
+ RestoreArtifactExpectation::new(1, 2, 1, [3; 32]).expect("artifact expectation");
+ ObservedArtifacts {
+ live,
+ staged: staged.then(|| File::open("/dev/null").expect("staged")),
+ backup: backup.then(|| File::open("/dev/null").expect("backup")),
+ marker_live: artifact,
+ marker_staged: artifact,
+ }
+ }
+
+ for live in 0..=2 {
+ for staged in [false, true] {
+ for backup in [false, true] {
+ let observed = observed(live, staged, backup);
+ assert_eq!(observed.can_roll_back_prepared(), live == 1 && !backup);
+ assert_eq!(
+ observed.proves_live_retained(),
+ live == 0 && staged && backup
+ );
+ assert_eq!(
+ observed.needs_replacement_install(),
+ live == 0 && staged && backup
+ );
+ assert_eq!(
+ observed.proves_replacement_installed(),
+ live == 2 && !staged && backup
+ );
+ assert_eq!(
+ observed.proves_replacement_installed_or_cleanup(),
+ live == 2 && !staged
+ );
+ }
+ }
+ }
+ }
+
struct Fixture {
_root: tempfile::TempDir,
paths: ServiceSqlitePaths,
diff --git a/crates/service_sqlite/src/restore/stage.rs b/crates/service_sqlite/src/restore/stage.rs
@@ -168,25 +168,39 @@ fn validate_intent(
) -> Result<(), ServiceSqliteError> {
let metadata = verified.database_metadata();
let manifest = verified.manifest();
- if !expected.matches_paths(paths)
- || expected.supported_state_schema_version().get() != migrations.current_version()
- || !schema.matches_migrations(migrations)
- || metadata.service() != expected.service()
- || metadata.instance() != expected.instance()
- || metadata.source_generation() != expected.source_generation()
- || metadata.application_id() != expected.application_id()
- || metadata.state_schema_version() > expected.supported_state_schema_version()
- || manifest.service() != metadata.service()
- || manifest.instance() != metadata.instance()
- || manifest.source_generation() != metadata.source_generation()
- || manifest.state_schema_version() != metadata.state_schema_version()
- {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata));
- }
+ crate::require_condition(
+ stage_intent_matches(paths, expected, migrations, schema, metadata, manifest),
+ ServiceSqliteErrorKind::Metadata,
+ )?;
Ok(())
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn stage_intent_matches(
+ paths: &ServiceSqlitePaths,
+ expected: &ServiceDatabaseIdentity,
+ migrations: &MigrationCatalog,
+ schema: &SchemaCatalog,
+ metadata: &ServiceDatabaseMetadata,
+ manifest: &crate::ServiceBackupManifest,
+) -> bool {
+ crate::all_constraints([
+ expected.matches_paths(paths),
+ expected.supported_state_schema_version().get() == migrations.current_version(),
+ schema.matches_migrations(migrations),
+ metadata.service() == expected.service(),
+ metadata.instance() == expected.instance(),
+ metadata.source_generation() == expected.source_generation(),
+ metadata.application_id() == expected.application_id(),
+ metadata.state_schema_version() <= expected.supported_state_schema_version(),
+ manifest.service() == metadata.service(),
+ manifest.instance() == metadata.instance(),
+ manifest.source_generation() == metadata.source_generation(),
+ manifest.state_schema_version() == metadata.state_schema_version(),
+ ])
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
async fn run_stage(
paths: ServiceSqlitePaths,
expected: ServiceDatabaseIdentity,
@@ -213,9 +227,10 @@ async fn run_stage(
.map_err(|source| restore_source(RestoreFailureKind::Join, source))?;
let mut staged = copied?;
staged.validate()?;
- if cancellation.load(Ordering::Acquire) {
- return Err(restore_error(RestoreFailureKind::Cancelled));
- }
+ require_restore_condition(
+ !cancellation.load(Ordering::Acquire),
+ RestoreFailureKind::Cancelled,
+ )?;
let connect_options = staged.connect_options()?;
staged.validate()?;
@@ -223,9 +238,12 @@ async fn run_stage(
staged.validate()?;
let mut connection =
connected.map_err(|source| restore_source(RestoreFailureKind::OpenStaged, source))?;
- if cancellation.load(Ordering::Acquire) {
+ if let Err(error) = require_restore_condition(
+ !cancellation.load(Ordering::Acquire),
+ RestoreFailureKind::Cancelled,
+ ) {
close_after(&mut staged, connection).await?;
- return Err(restore_error(RestoreFailureKind::Cancelled));
+ return Err(error);
}
let verification = verify_staged_connection(
@@ -241,9 +259,10 @@ async fn run_stage(
staged.validate()?;
verification?;
close.map_err(|source| restore_source(RestoreFailureKind::CloseStaged, source))?;
- if cancellation.load(Ordering::Acquire) {
- return Err(restore_error(RestoreFailureKind::Cancelled));
- }
+ require_restore_condition(
+ !cancellation.load(Ordering::Acquire),
+ RestoreFailureKind::Cancelled,
+ )?;
let final_cancellation = Arc::clone(&cancellation);
tokio::task::spawn_blocking(move || staged.finalize(&final_cancellation))
@@ -281,9 +300,10 @@ async fn verify_staged_connection(
let metadata = crate::metadata::verify_database_metadata(connection, expected).await;
staged.validate()?;
let metadata = metadata?;
- if metadata != staged.metadata {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Metadata));
- }
+ crate::require_condition(
+ metadata == staged.metadata,
+ ServiceSqliteErrorKind::Metadata,
+ )?;
let phase = test_async_phase(TEST_PHASE_METADATA, cancellation).await;
staged.validate()?;
phase?;
@@ -293,9 +313,10 @@ async fn verify_staged_connection(
crate::migration::verify_migration_history(connection, migrations, schema, false).await;
staged.validate()?;
let version = history?;
- if version != staged.metadata.state_schema_version().get() {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Migration));
- }
+ crate::require_condition(
+ version == staged.metadata.state_schema_version().get(),
+ ServiceSqliteErrorKind::Migration,
+ )?;
let phase = test_async_phase(TEST_PHASE_HISTORY, cancellation).await;
staged.validate()?;
phase?;
@@ -351,18 +372,55 @@ async fn verify_read_only_policy(
.fetch_all(connection)
.await
.map_err(|source| restore_source(RestoreFailureKind::Policy, source))?;
- if query_only != 1
- || trusted_schema != 0
- || databases.len() != 1
- || databases[0].try_get::<i64, _>(0).ok() != Some(0)
- || databases[0].try_get::<String, _>(1).ok().as_deref() != Some("main")
- {
- return Err(restore_error(RestoreFailureKind::Policy));
- }
+ let first_sequence = databases
+ .first()
+ .and_then(|row| row.try_get::<i64, _>(0).ok());
+ let first_name = databases
+ .first()
+ .and_then(|row| row.try_get::<String, _>(1).ok());
+ require_restore_condition(
+ read_only_policy_matches(
+ query_only,
+ trusted_schema,
+ databases.len(),
+ first_sequence,
+ first_name.as_deref(),
+ ),
+ RestoreFailureKind::Policy,
+ )?;
Ok(())
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn read_only_policy_matches(
+ query_only: i64,
+ trusted_schema: i64,
+ database_count: usize,
+ first_sequence: Option<i64>,
+ first_name: Option<&str>,
+) -> bool {
+ crate::all_constraints([
+ query_only == 1,
+ trusted_schema == 0,
+ database_count == 1,
+ first_sequence == Some(0),
+ first_name == Some("main"),
+ ])
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn require_restore_condition(
+ condition: bool,
+ kind: RestoreFailureKind,
+) -> Result<(), ServiceSqliteError> {
+ if condition {
+ Ok(())
+ } else {
+ Err(restore_error(kind))
+ }
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn check_cancel(cancellation: &AtomicBool) -> Result<(), ServiceSqliteError> {
if cancellation.load(Ordering::Acquire) {
Err(restore_error(RestoreFailureKind::Cancelled))
@@ -476,14 +534,15 @@ impl NativeStagedServiceRestore {
let post_copy = test_blocking_phase(TEST_PHASE_POST_COPY, cancellation);
result.validate()?;
post_copy?;
- if layout
- .staged()
- .file_name()
- .is_none_or(|name| name != STAGED_FILE_NAME)
- || Some(layout.state_directory().as_path()) != result.paths.state_database().parent()
- {
- return Err(restore_error(RestoreFailureKind::Layout));
- }
+ require_restore_condition(
+ layout
+ .staged()
+ .file_name()
+ .is_some_and(|name| name == STAGED_FILE_NAME)
+ && Some(layout.state_directory().as_path())
+ == result.paths.state_database().parent(),
+ RestoreFailureKind::Layout,
+ )?;
Ok(result)
}
@@ -507,9 +566,10 @@ impl NativeStagedServiceRestore {
let digest = hash_exact(&self.staged, self.artifact.byte_length());
self.validate()?;
let digest = digest?;
- if digest != self.artifact.sha256() {
- return Err(restore_error(RestoreFailureKind::StagedChanged));
- }
+ require_restore_condition(
+ digest == self.artifact.sha256(),
+ RestoreFailureKind::StagedChanged,
+ )?;
let header = validate_sqlite_header(&self.staged);
self.validate()?;
header?;
@@ -534,9 +594,10 @@ impl NativeStagedServiceRestore {
.ok_or_else(|| ServiceSqliteError::new(ServiceSqliteErrorKind::Authority))?;
authority.validate_for(&self.paths)?;
let result = (|| {
- if directory_identity(&self.directory)? != self.directory_identity {
- return Err(restore_error(RestoreFailureKind::StagedChanged));
- }
+ require_restore_condition(
+ directory_identity(&self.directory)? == self.directory_identity,
+ RestoreFailureKind::StagedChanged,
+ )?;
validate_stage_binding(
&self.directory,
&self.staged,
@@ -565,9 +626,7 @@ impl NativeStagedServiceRestore {
let valid_directory = directory_identity(&self.directory)
.is_ok_and(|identity| identity == self.directory_identity);
authority.validate_for(&self.paths)?;
- if !valid_directory {
- return Err(ServiceSqliteError::new(ServiceSqliteErrorKind::Authority));
- }
+ crate::require_condition(valid_directory, ServiceSqliteErrorKind::Authority)?;
Ok(())
}
@@ -578,9 +637,10 @@ impl NativeStagedServiceRestore {
.ok_or_else(|| ServiceSqliteError::new(ServiceSqliteErrorKind::Authority))?;
authority.validate_for(&self.paths)?;
let result = (|| {
- if directory_identity(&self.directory)? != self.directory_identity {
- return Err(restore_error(RestoreFailureKind::StagedChanged));
- }
+ require_restore_condition(
+ directory_identity(&self.directory)? == self.directory_identity,
+ RestoreFailureKind::StagedChanged,
+ )?;
validate_stage_binding(&self.directory, &self.staged, self.staged_identity, 0)
})();
authority.validate_for(&self.paths)?;
@@ -662,12 +722,15 @@ struct FileIdentity {
fn directory_identity(directory: &File) -> Result<FileIdentity, ServiceSqliteError> {
let status =
fstat(directory).map_err(|source| restore_source(RestoreFailureKind::Layout, source))?;
- if !FileType::from_raw_mode(status.st_mode).is_dir()
- || status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(status.st_mode) & 0o022 != 0
- {
- return Err(restore_error(RestoreFailureKind::Layout));
- }
+ require_restore_condition(
+ crate::native_metadata::secure_directory(
+ FileType::from_raw_mode(status.st_mode).is_dir(),
+ status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(status.st_mode),
+ ),
+ RestoreFailureKind::Layout,
+ )?;
status_identity(&status)
}
@@ -735,9 +798,7 @@ fn create_stage(directory: &File) -> Result<PendingStage, ServiceSqliteError> {
.as_ref()
.expect("pending stage retains its file"),
)?;
- if confirmed != identity {
- return Err(restore_error(RestoreFailureKind::StagedChanged));
- }
+ require_restore_condition(confirmed == identity, RestoreFailureKind::StagedChanged)?;
Ok(pending)
}
@@ -800,18 +861,22 @@ fn validate_closed_live(
.map_err(|source| restore_source(RestoreFailureKind::LiveState, source))?;
let status =
fstat(&live).map_err(|source| restore_source(RestoreFailureKind::LiveState, source))?;
- if !FileType::from_raw_mode(status.st_mode).is_file()
- || crate::native_metadata::link_count(status.st_nlink) != 1
- || status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(status.st_mode) & 0o777 != 0o600
- {
- return Err(restore_error(RestoreFailureKind::LiveState));
- }
+ require_restore_condition(
+ crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(status.st_mode).is_file(),
+ crate::native_metadata::link_count(status.st_nlink),
+ status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(status.st_mode),
+ ),
+ RestoreFailureKind::LiveState,
+ )?;
let length =
u64::try_from(status.st_size).map_err(|_| restore_error(RestoreFailureKind::LiveState))?;
- if length == 0 || length > i64::MAX as u64 {
- return Err(restore_error(RestoreFailureKind::LiveState));
- }
+ require_restore_condition(
+ crate::native_metadata::valid_artifact_length(length, None),
+ RestoreFailureKind::LiveState,
+ )?;
let live = File::from(live);
let digest = hash_exact(&live, length)?;
let artifact = RestoreArtifactExpectation::new(
@@ -853,15 +918,20 @@ fn validate_live_binding(
.map_err(|_| restore_error(RestoreFailureKind::LiveState))?;
let length =
u64::try_from(status.st_size).map_err(|_| restore_error(RestoreFailureKind::LiveState))?;
- if !FileType::from_raw_mode(status.st_mode).is_file()
- || crate::native_metadata::link_count(status.st_nlink) != 1
- || status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(status.st_mode) & 0o777 != 0o600
- || (device, status.st_ino) != (expected.device(), expected.inode())
- || length != expected.byte_length()
- {
- return Err(restore_error(RestoreFailureKind::LiveState));
- }
+ require_restore_condition(
+ crate::all_constraints([
+ crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(status.st_mode).is_file(),
+ crate::native_metadata::link_count(status.st_nlink),
+ status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(status.st_mode),
+ ),
+ (device, status.st_ino) == (expected.device(), expected.inode()),
+ crate::native_metadata::valid_artifact_length(length, Some(expected.byte_length())),
+ ]),
+ RestoreFailureKind::LiveState,
+ )?;
Ok(())
}
@@ -885,9 +955,19 @@ fn validate_stage_binding(
.map_err(|source| restore_source(RestoreFailureKind::StagedChanged, source))?;
validate_stage_status(&held_status, Some(expected_length))?;
validate_stage_status(¤t_status, Some(expected_length))?;
- if status_identity(&held_status)? != identity || status_identity(¤t_status)? != identity {
- return Err(restore_error(RestoreFailureKind::StagedChanged));
- }
+ let held_identity = status_identity(&held_status)?;
+ let current_identity = status_identity(¤t_status)?;
+ require_restore_condition(
+ crate::native_metadata::identity_pair_matches(
+ held_identity.device,
+ held_identity.inode,
+ current_identity.device,
+ current_identity.inode,
+ identity.device,
+ identity.inode,
+ ),
+ RestoreFailureKind::StagedChanged,
+ )?;
Ok(())
}
@@ -898,14 +978,23 @@ fn validate_stage_status(
) -> Result<(), ServiceSqliteError> {
let length = u64::try_from(status.st_size)
.map_err(|_| restore_error(RestoreFailureKind::StagedChanged))?;
- if !FileType::from_raw_mode(status.st_mode).is_file()
- || crate::native_metadata::link_count(status.st_nlink) != 1
- || status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(status.st_mode) & 0o777 != 0o600
- || expected_length.is_some_and(|expected| length != expected)
- {
- return Err(restore_error(RestoreFailureKind::StagedChanged));
- }
+ let length_matches = match expected_length {
+ Some(expected) => length == expected,
+ None => true,
+ };
+ require_restore_condition(
+ crate::all_constraints([
+ crate::native_metadata::exact_regular_file(
+ FileType::from_raw_mode(status.st_mode).is_file(),
+ crate::native_metadata::link_count(status.st_nlink),
+ status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(status.st_mode),
+ ),
+ length_matches,
+ ]),
+ RestoreFailureKind::StagedChanged,
+ )?;
Ok(())
}
@@ -1058,14 +1147,10 @@ fn validate_sqlite_header(file: &File) -> Result<(), ServiceSqliteError> {
let mut header = [0_u8; 20];
file.read_exact(&mut header)
.map_err(|source| restore_source(RestoreFailureKind::Policy, source))?;
- let write_version = header[18];
- let read_version = header[19];
- if &header[..16] != b"SQLite format 3\0"
- || !matches!(write_version, 1 | 2)
- || read_version != write_version
- {
- return Err(restore_error(RestoreFailureKind::Policy));
- }
+ require_restore_condition(
+ crate::native_metadata::sqlite_header(&header),
+ RestoreFailureKind::Policy,
+ )?;
Ok(())
}
@@ -1089,7 +1174,15 @@ fn cleanup_exact_stage(
fstat(held).map_err(|source| restore_source(RestoreFailureKind::Cleanup, source))?;
let current_status =
fstat(¤t).map_err(|source| restore_source(RestoreFailureKind::Cleanup, source))?;
- if status_identity(&held_status)? != identity || status_identity(¤t_status)? != identity {
+ if require_restore_condition(
+ (
+ status_identity(&held_status)?,
+ status_identity(¤t_status)?,
+ ) == (identity, identity),
+ RestoreFailureKind::Cleanup,
+ )
+ .is_err()
+ {
return Ok(());
}
unlinkat(directory, STAGED_FILE_NAME, AtFlags::empty())
@@ -1258,6 +1351,222 @@ mod tests {
static STAGE_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
+ #[test]
+ fn restore_failure_inventory_is_complete_and_source_aware() {
+ let cases = [
+ (RestoreFailureKind::Layout, "restore layout is invalid"),
+ (
+ RestoreFailureKind::LiveState,
+ "live state is not closed and canonical",
+ ),
+ (
+ RestoreFailureKind::RecoveryEvidence,
+ "prior restore evidence is present",
+ ),
+ (
+ RestoreFailureKind::StageCollision,
+ "restore staging destination exists",
+ ),
+ (
+ RestoreFailureKind::CreateStage,
+ "restore staging could not be created",
+ ),
+ (
+ RestoreFailureKind::SourceChanged,
+ "verified backup binding changed",
+ ),
+ (RestoreFailureKind::Copy, "verified backup copy failed"),
+ (
+ RestoreFailureKind::HashStaged,
+ "restore staging hash failed",
+ ),
+ (
+ RestoreFailureKind::StagedChanged,
+ "restore staging binding changed",
+ ),
+ (
+ RestoreFailureKind::OpenStaged,
+ "restore staging could not be opened",
+ ),
+ (
+ RestoreFailureKind::Policy,
+ "restore verification policy failed",
+ ),
+ (
+ RestoreFailureKind::SyncStaged,
+ "restore staging sync failed",
+ ),
+ (
+ RestoreFailureKind::SyncDirectory,
+ "restore directory sync failed",
+ ),
+ (
+ RestoreFailureKind::CloseStaged,
+ "restore verification close failed",
+ ),
+ (
+ RestoreFailureKind::Cleanup,
+ "restore staging cleanup failed",
+ ),
+ (
+ RestoreFailureKind::Cancelled,
+ "restore staging was cancelled",
+ ),
+ (RestoreFailureKind::Join, "restore staging worker failed"),
+ ];
+ for (kind, message) in cases {
+ let plain = RestoreFailure { kind, source: None };
+ assert_eq!(plain.to_string(), message);
+ assert!(plain.source().is_none());
+ let sourced = RestoreFailure {
+ kind,
+ source: Some(Box::new(std::io::Error::other("private-cause"))),
+ };
+ assert_eq!(sourced.to_string(), message);
+ assert!(sourced.source().is_some());
+ let debug = format!("{sourced:?}");
+ assert!(debug.contains("[redacted]"));
+ assert!(!debug.contains("private-cause"));
+ assert!(require_restore_condition(true, kind).is_ok());
+ assert_eq!(
+ require_restore_condition(false, kind)
+ .expect_err("false condition")
+ .kind(),
+ ServiceSqliteErrorKind::Restore
+ );
+ }
+ }
+
+ #[test]
+ fn read_only_policy_projection_rejects_each_independent_drift() {
+ assert!(read_only_policy_matches(1, 0, 1, Some(0), Some("main")));
+ assert!(!read_only_policy_matches(0, 0, 1, Some(0), Some("main")));
+ assert!(!read_only_policy_matches(1, 1, 1, Some(0), Some("main")));
+ assert!(!read_only_policy_matches(1, 0, 2, Some(0), Some("main")));
+ assert!(!read_only_policy_matches(1, 0, 1, Some(1), Some("main")));
+ assert!(!read_only_policy_matches(1, 0, 1, Some(0), Some("temp")));
+ assert!(!read_only_policy_matches(1, 0, 0, None, None));
+ }
+
+ #[test]
+ fn exact_copy_hash_and_cleanup_helpers_fail_closed() {
+ let root = tempfile::tempdir().expect("root");
+ let payload = b"restore-stage-payload";
+ let digest: [u8; 32] = Sha256::digest(payload).into();
+ let source_path = root.path().join("source.sqlite");
+ fs::write(&source_path, payload).expect("source");
+ let source = File::open(&source_path).expect("open source");
+
+ let destination_path = root.path().join("destination.sqlite");
+ let destination = std::fs::OpenOptions::new()
+ .create_new(true)
+ .read(true)
+ .write(true)
+ .open(&destination_path)
+ .expect("open destination");
+ let active = AtomicBool::new(false);
+ copy_exact(
+ &source,
+ &destination,
+ u64::try_from(payload.len()).expect("length"),
+ digest,
+ &active,
+ )
+ .expect("exact copy");
+ assert_eq!(fs::read(&destination_path).expect("destination"), payload);
+ assert_eq!(
+ hash_exact(&destination, u64::try_from(payload.len()).expect("length"))
+ .expect("exact hash"),
+ digest
+ );
+
+ for (name, expected_length, expected_digest, cancelled) in [
+ (
+ "short.sqlite",
+ u64::try_from(payload.len() + 1).expect("short length"),
+ digest,
+ false,
+ ),
+ (
+ "long.sqlite",
+ u64::try_from(payload.len() - 1).expect("long length"),
+ digest,
+ false,
+ ),
+ (
+ "digest.sqlite",
+ u64::try_from(payload.len()).expect("digest length"),
+ [0; 32],
+ false,
+ ),
+ (
+ "cancelled.sqlite",
+ u64::try_from(payload.len()).expect("cancelled length"),
+ digest,
+ true,
+ ),
+ ] {
+ let path = root.path().join(name);
+ let output = std::fs::OpenOptions::new()
+ .create_new(true)
+ .read(true)
+ .write(true)
+ .open(path)
+ .expect("open negative destination");
+ let cancellation = AtomicBool::new(cancelled);
+ let error = copy_exact(
+ &source,
+ &output,
+ expected_length,
+ expected_digest,
+ &cancellation,
+ )
+ .expect_err("copy must fail closed");
+ assert_eq!(error.kind(), ServiceSqliteErrorKind::Restore);
+ }
+
+ assert_eq!(
+ hash_exact(
+ &destination,
+ u64::try_from(payload.len() + 1).expect("short hash length")
+ )
+ .expect_err("short hash input")
+ .kind(),
+ ServiceSqliteErrorKind::Restore
+ );
+ assert_eq!(
+ hash_exact(
+ &destination,
+ u64::try_from(payload.len() - 1).expect("long hash length")
+ )
+ .expect_err("long hash input")
+ .kind(),
+ ServiceSqliteErrorKind::Restore
+ );
+
+ let directory = File::open(root.path()).expect("open directory");
+ let staged_path = root.path().join(STAGED_FILE_NAME);
+ fs::write(&staged_path, b"owned-stage").expect("owned stage");
+ let held = File::open(&staged_path).expect("open owned stage");
+ let identity = status_identity(&fstat(&held).expect("owned status")).expect("identity");
+ cleanup_exact_stage(&directory, &held, identity).expect("cleanup owned stage");
+ assert!(!staged_path.exists());
+ cleanup_exact_stage(&directory, &held, identity).expect("absent cleanup is idempotent");
+
+ fs::write(&staged_path, b"original-stage").expect("original stage");
+ let original = File::open(&staged_path).expect("open original stage");
+ let original_identity =
+ status_identity(&fstat(&original).expect("original status")).expect("identity");
+ fs::rename(&staged_path, root.path().join("retained-original")).expect("retain original");
+ fs::write(&staged_path, b"foreign-stage").expect("foreign replacement");
+ cleanup_exact_stage(&directory, &original, original_identity)
+ .expect("replacement is preserved");
+ assert_eq!(
+ fs::read(&staged_path).expect("replacement"),
+ b"foreign-stage"
+ );
+ }
+
struct Fixture {
_root: tempfile::TempDir,
paths: ServiceSqlitePaths,
@@ -1378,7 +1687,182 @@ mod tests {
}
}
+ fn manifest_for(metadata: &ServiceDatabaseMetadata) -> ServiceBackupManifest {
+ ServiceBackupManifest::from_capture(
+ metadata,
+ BackupCreatedAtUnixMs::new(1_700_000_000_123).expect("capture time"),
+ 4_096,
+ BackupMemberSha256::from_bytes([9; 32]),
+ )
+ .expect("manifest")
+ }
+
+ #[tokio::test(flavor = "current_thread")]
+ async fn staging_intent_rejects_each_independent_identity_and_catalog_drift() {
+ let fixture = Fixture::new().await;
+ assert!(stage_intent_matches(
+ &fixture.paths,
+ &fixture.identity,
+ &fixture.migrations,
+ &fixture.schema,
+ &fixture.metadata,
+ &fixture.manifest,
+ ));
+
+ let alternate_paths = paths_for(fixture._root.path(), "rhi", "secondary");
+ let alternate_identity = ServiceDatabaseIdentity::new(
+ &alternate_paths,
+ fixture.metadata.source_generation(),
+ NonZeroU32::new(1).expect("schema"),
+ fixture.metadata.application_id(),
+ );
+ assert!(!stage_intent_matches(
+ &fixture.paths,
+ &alternate_identity,
+ &fixture.migrations,
+ &fixture.schema,
+ &fixture.metadata,
+ &fixture.manifest,
+ ));
+
+ let migration = MigrationDescriptor::sql(
+ 2,
+ "add_stage_probe",
+ "SELECT 1",
+ MigrationChecksum::for_sql("SELECT 1"),
+ )
+ .expect("migration");
+ let migrations_v2 = MigrationCatalog::new([migration]).expect("v2 migrations");
+ let v1_digest = SchemaVersionCatalog::computed_digest(1, []).expect("v1 digest");
+ let v1 = SchemaVersionCatalog::new(1, [], v1_digest).expect("v1 schema");
+ let v2_digest = SchemaVersionCatalog::computed_digest(2, []).expect("v2 digest");
+ let v2 = SchemaVersionCatalog::new(2, [], v2_digest).expect("v2 schema");
+ let schema_v2 = SchemaCatalog::new(&migrations_v2, [v1, v2]).expect("v2 catalog");
+ let identity_v2 = ServiceDatabaseIdentity::new(
+ &fixture.paths,
+ fixture.metadata.source_generation(),
+ NonZeroU32::new(2).expect("schema"),
+ fixture.metadata.application_id(),
+ );
+ assert!(stage_intent_matches(
+ &fixture.paths,
+ &identity_v2,
+ &migrations_v2,
+ &schema_v2,
+ &fixture.metadata,
+ &fixture.manifest,
+ ));
+ assert!(!stage_intent_matches(
+ &fixture.paths,
+ &identity_v2,
+ &fixture.migrations,
+ &fixture.schema,
+ &fixture.metadata,
+ &fixture.manifest,
+ ));
+ assert!(!stage_intent_matches(
+ &fixture.paths,
+ &identity_v2,
+ &migrations_v2,
+ &fixture.schema,
+ &fixture.metadata,
+ &fixture.manifest,
+ ));
+
+ let generation = SourceGeneration::new([8; 32]).expect("alternate generation");
+ let alternate_application =
+ ServiceSqliteApplicationId::new(0x5244_5352).expect("alternate application ID");
+ let identity_drifts = [
+ ServiceDatabaseMetadata::from_verified_backup(
+ fixture.metadata.service().clone(),
+ fixture.metadata.instance().clone(),
+ generation,
+ fixture.metadata.state_schema_version(),
+ fixture.metadata.created_at_unix_ms(),
+ fixture.metadata.application_id(),
+ )
+ .expect("generation drift"),
+ ServiceDatabaseMetadata::from_verified_backup(
+ fixture.metadata.service().clone(),
+ fixture.metadata.instance().clone(),
+ fixture.metadata.source_generation(),
+ fixture.metadata.state_schema_version(),
+ fixture.metadata.created_at_unix_ms(),
+ alternate_application,
+ )
+ .expect("application drift"),
+ ServiceDatabaseMetadata::from_verified_backup(
+ ServiceId::new("rhi").expect("service"),
+ fixture.metadata.instance().clone(),
+ fixture.metadata.source_generation(),
+ fixture.metadata.state_schema_version(),
+ fixture.metadata.created_at_unix_ms(),
+ fixture.metadata.application_id(),
+ )
+ .expect("service drift"),
+ ServiceDatabaseMetadata::from_verified_backup(
+ fixture.metadata.service().clone(),
+ InstanceId::new("secondary").expect("instance"),
+ fixture.metadata.source_generation(),
+ fixture.metadata.state_schema_version(),
+ fixture.metadata.created_at_unix_ms(),
+ fixture.metadata.application_id(),
+ )
+ .expect("instance drift"),
+ ];
+ for metadata in &identity_drifts {
+ let manifest = manifest_for(metadata);
+ assert!(!stage_intent_matches(
+ &fixture.paths,
+ &fixture.identity,
+ &fixture.migrations,
+ &fixture.schema,
+ metadata,
+ &manifest,
+ ));
+ }
+
+ let metadata_v2 = ServiceDatabaseMetadata::from_verified_backup(
+ fixture.metadata.service().clone(),
+ fixture.metadata.instance().clone(),
+ fixture.metadata.source_generation(),
+ NonZeroU32::new(2).expect("schema"),
+ fixture.metadata.created_at_unix_ms(),
+ fixture.metadata.application_id(),
+ )
+ .expect("v2 metadata");
+ let manifest_v2 = manifest_for(&metadata_v2);
+ assert!(!stage_intent_matches(
+ &fixture.paths,
+ &fixture.identity,
+ &fixture.migrations,
+ &fixture.schema,
+ &metadata_v2,
+ &manifest_v2,
+ ));
+
+ for manifest in [
+ manifest_for(&identity_drifts[0]),
+ manifest_for(&identity_drifts[2]),
+ manifest_for(&identity_drifts[3]),
+ manifest_v2,
+ ] {
+ assert!(!stage_intent_matches(
+ &fixture.paths,
+ &fixture.identity,
+ &fixture.migrations,
+ &fixture.schema,
+ &fixture.metadata,
+ &manifest,
+ ));
+ }
+ }
+
fn paths(root: &Path) -> ServiceSqlitePaths {
+ paths_for(root, "myc", "primary")
+ }
+
+ fn paths_for(root: &Path, service: &str, instance: &str) -> ServiceSqlitePaths {
let context = RuntimeContext::resolve(
&RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
RuntimeContextBootstrap::new(
@@ -1388,8 +1872,8 @@ mod tests {
RuntimeContextSource::BootstrapCli,
)
.expect("bootstrap"),
- ServiceId::new("myc").expect("service"),
- InstanceId::new("primary").expect("instance"),
+ ServiceId::new(service).expect("service"),
+ InstanceId::new(instance).expect("instance"),
)
.expect("runtime context");
ServiceSqlitePaths::from_runtime_context(&context).expect("SQLite paths")
@@ -1496,7 +1980,6 @@ mod tests {
)
.await
.unwrap_or_else(|error| {
- use std::error::Error as _;
panic!(
"stage restore: {:?} / {:?}",
error.kind(),
diff --git a/crates/service_sqlite/src/status/disk.rs b/crates/service_sqlite/src/status/disk.rs
@@ -5,7 +5,7 @@ use std::error::Error;
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
-use crate::ServiceSqlitePaths;
+use crate::{ServiceSqliteErrorKind, ServiceSqlitePaths};
const MAXIMUM_MINIMUM_FREE_BYTES: u64 = i64::MAX as u64;
@@ -211,13 +211,22 @@ fn available_bytes_native(paths: &ServiceSqlitePaths) -> Result<u64, StateFilesy
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);
- }
+ let expected_device = crate::native_metadata::device(held_status.st_dev)
+ .map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?;
+ crate::require_condition(
+ crate::native_metadata::identity_pair_matches(
+ crate::native_metadata::device(final_held_status.st_dev)
+ .map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?,
+ final_held_status.st_ino,
+ crate::native_metadata::device(current_status.st_dev)
+ .map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?,
+ current_status.st_ino,
+ expected_device,
+ held_status.st_ino,
+ ),
+ ServiceSqliteErrorKind::Authority,
+ )
+ .map_err(|_| StateFilesystemCapacityError::MeasurementUnavailable)?;
Ok(available)
}
@@ -228,10 +237,12 @@ fn validate_directory_status(
use rustix::fs::FileType;
use rustix::process::geteuid;
- if !FileType::from_raw_mode(status.st_mode).is_dir()
- || status.st_uid != geteuid().as_raw()
- || crate::native_metadata::mode(status.st_mode) & 0o022 != 0
- {
+ if !crate::native_metadata::secure_directory(
+ FileType::from_raw_mode(status.st_mode).is_dir(),
+ status.st_uid,
+ geteuid().as_raw(),
+ crate::native_metadata::mode(status.st_mode),
+ ) {
return Err(StateFilesystemCapacityError::MeasurementUnavailable);
}
Ok(())
diff --git a/crates/service_sqlite/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs
@@ -531,7 +531,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"fstatvfs(&held)",
"capacity.f_bavail",
"capacity.f_frsize",
- "crate::native_metadata::mode(status.st_mode) & 0o022",
+ "crate::native_metadata::secure_directory(",
"UnsupportedPlatform",
] {
assert!(
@@ -547,6 +547,13 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"T: Into<u64>",
"pub(crate) fn device<T>",
"T: TryInto<u64>",
+ "pub(crate) fn sqlite_wal_header",
+ "header[18] == 2,",
+ "header[19] == 2,",
+ "crate::all_constraints([",
+ "pub(crate) fn secure_directory",
+ "pub(crate) fn exact_regular_file",
+ "pub(crate) fn identity_pair_matches",
] {
assert!(
NATIVE_METADATA_SOURCE.contains(required),
@@ -729,7 +736,8 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"BACKUP_MANIFEST_CANONICAL_MAX_BYTES: usize = 1_024",
"BACKUP_STATE_MEMBER_NAME: &str = \"state.sqlite\"",
"pub fn from_canonical_bytes",
- "manifest.canonical_bytes.as_ref() != bytes",
+ "manifest.canonical_bytes.as_ref() == bytes",
+ "BackupManifestContractError::NonCanonicalEncoding",
"serde(deny_unknown_fields)",
"Sha256::digest(&canonical_bytes)",
"pub(crate) fn from_capture",
@@ -973,8 +981,8 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC",
"OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOFOLLOW | OFlags::CLOEXEC",
"Dir::read_from(&self.directory)",
- "matches!(mode, 0o500 | 0o700)",
- "matches!(mode, 0o400 | 0o600)",
+ "crate::native_metadata::restrictive_directory(",
+ "crate::native_metadata::restrictive_regular_file(",
"open_sqlite_from_retained_state",
"/proc/self/fd/{descriptor}",
"/dev/fd/{descriptor}",
@@ -987,7 +995,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"ValueRef::Text(b\"table\")",
"PRAGMA integrity_check(1)",
"PRAGMA foreign_key_check",
- "state_schema_version() > expected.supported_state_schema_version()",
+ "state_schema_version() <= expected.supported_state_schema_version()",
"binding.hash_state(maximum_state_bytes)",
] {
assert!(
@@ -1417,8 +1425,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
".row_buffer_size(ROW_BUFFER_CAPACITY)",
".immutable(true)",
"\"query_only\"",
- "sqlite_header[18] != 2",
- "sqlite_header[19] != 2",
+ "crate::native_metadata::sqlite_wal_header(&sqlite_header)",
"WAL_FILE_NAME",
"SHARED_MEMORY_FILE_NAME",
".min_connections(1)",
@@ -1526,7 +1533,9 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"directory_device",
"WAL_FILE_NAME",
"SHARED_MEMORY_FILE_NAME",
- "crate::native_metadata::mode(directory_status.st_mode) & 0o022",
+ "crate::native_metadata::secure_directory(",
+ "crate::native_metadata::exact_regular_file(",
+ "crate::native_metadata::identity_pair_matches(",
] {
assert!(
OPEN_SOURCE.contains(required),