commit a1204b53aea99527c401575e85b82b93d830c0f5
parent 61b7527d16b78f464194853d5d53c514d9a84075
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 17:48:47 +0000
service-sqlite: add backup manifest
- add strict canonical v1 manifest parsing and typed backup identity
- bind one state member to exact generation schema time length and digests
- reject ambiguous encodings protected material and invalid integrity claims
- freeze canonical bytes digest vectors diagnostics and package boundaries
Diffstat:
7 files changed, 919 insertions(+), 2 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -173,6 +173,13 @@ Before editing code:
be resumed through the host-owned connect/checkpoint/connection-close driver;
Drop is not an asynchronous close or completion proof. Do not add public
checkpoint knobs, background close tasks, or Drop-based async cleanup.
+- `ServiceBackupManifest` is the sole v1 backup-manifest model. Preserve its
+ exact 1,024-byte compact canonical JSON, raw canonical-byte SHA-256, typed
+ service/instance/source-generation/schema/time binding, singleton
+ `state.sqlite` inventory, exact `ok` integrity projection, and mandatory
+ protected-material exclusion. Parsing is structural only; do not let the
+ model perform filesystem capture, SQLite backup, file/digest verification,
+ restore, ambient clock access, or public dependency-owned error exposure.
- Runtime-management flows consume a sealed `RuntimeContext` for every service
instance. They must not reconstruct service paths from raw identifiers,
ambient selectors, or manager-owned roots, and registries must not persist
diff --git a/crates/service_sqlite/Cargo.toml b/crates/service_sqlite/Cargo.toml
@@ -18,12 +18,12 @@ radroots_runtime_paths = { workspace = true }
radroots_storage = { workspace = true }
rustix = { workspace = true }
serde = { workspace = true, features = ["derive", "std"] }
+serde_json = { workspace = true }
sha2 = { workspace = true }
sqlx = { workspace = true, features = ["runtime-tokio", "sqlite-bundled"] }
tokio = { workspace = true, features = ["sync"] }
[dev-dependencies]
-serde_json = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
diff --git a/crates/service_sqlite/README.md b/crates/service_sqlite/README.md
@@ -38,6 +38,19 @@ release is proven, the stable outer result is cached for every later call.
Dropping a host performs no asynchronous close work and is not proof that the
governed checkpoint and authority-release sequence completed.
+`ServiceBackupManifest` is the stable model-only v1 backup identity. It admits
+only compact canonical UTF-8 JSON in the frozen field order, capped at 1,024
+bytes, and computes the external manifest SHA-256 over those exact bytes. The
+v1 member array contains exactly one `state.sqlite` member with a nonzero byte
+length and lowercase SHA-256; service, instance, nonzero source generation,
+state schema, and injected creation time are explicit. SQLite and foreign-key
+integrity are exactly `ok`, and protected material is always excluded.
+Parsing proves only the strict structural and canonical contract. It rejects
+unknown, duplicate, null, reordered, whitespace-altered, or version-drifted
+input; member bytes, digest, SQLite identity, and actual integrity remain the
+separate backup-verification boundary. This crate does no backup filesystem or
+SQLite capture work while constructing or parsing the manifest model.
+
The crate owns mechanics only. Service-specific tables, SQL, repositories,
backup content policy, identity material, process lifecycle, and readiness
policy remain with the consuming service. The crate does not provide callers
diff --git a/crates/service_sqlite/src/backup/manifest.rs b/crates/service_sqlite/src/backup/manifest.rs
@@ -0,0 +1,817 @@
+//! Canonical v1 service backup manifest model.
+
+use core::{fmt, num::NonZeroU32};
+use std::error::Error;
+
+use radroots_runtime_paths::{InstanceId, ServiceId};
+use radroots_storage::event::SourceGeneration;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+
+use crate::ServiceDatabaseMetadata;
+
+/// Exact v1 backup manifest schema identifier.
+pub const BACKUP_MANIFEST_SCHEMA: &str = "radroots.service-backup";
+/// Exact v1 backup manifest schema version.
+pub const BACKUP_MANIFEST_SCHEMA_VERSION: u32 = 1;
+/// Maximum accepted or emitted canonical manifest size.
+pub const BACKUP_MANIFEST_CANONICAL_MAX_BYTES: usize = 1_024;
+/// Sole member name admitted by the v1 manifest.
+pub const BACKUP_STATE_MEMBER_NAME: &str = "state.sqlite";
+
+const INTEGRITY_OK: &str = "ok";
+
+/// Injected positive backup creation time in Unix milliseconds.
+#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
+pub struct BackupCreatedAtUnixMs(u64);
+
+impl BackupCreatedAtUnixMs {
+ /// Validates a timestamp representable by SQLite's signed integer range.
+ pub const fn new(value: u64) -> Result<Self, BackupManifestContractError> {
+ if value == 0 || value > i64::MAX as u64 {
+ return Err(BackupManifestContractError::InvalidCreationTime);
+ }
+ Ok(Self(value))
+ }
+
+ /// Returns milliseconds since the Unix epoch.
+ #[must_use]
+ pub const fn get(self) -> u64 {
+ self.0
+ }
+}
+
+/// Exact SHA-256 of one backup member.
+#[derive(Clone, Copy, Hash, PartialEq, Eq)]
+pub struct BackupMemberSha256([u8; 32]);
+
+impl BackupMemberSha256 {
+ /// Constructs a digest from independently computed bytes.
+ #[must_use]
+ pub const fn from_bytes(bytes: [u8; 32]) -> Self {
+ Self(bytes)
+ }
+
+ /// Returns the exact digest bytes.
+ #[must_use]
+ pub const fn as_bytes(&self) -> &[u8; 32] {
+ &self.0
+ }
+}
+
+impl fmt::Debug for BackupMemberSha256 {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("BackupMemberSha256([redacted])")
+ }
+}
+
+/// SHA-256 of the exact canonical manifest bytes.
+#[derive(Clone, Copy, Hash, PartialEq, Eq)]
+pub struct BackupManifestSha256([u8; 32]);
+
+impl BackupManifestSha256 {
+ /// Returns the exact digest bytes.
+ #[must_use]
+ pub const fn as_bytes(&self) -> &[u8; 32] {
+ &self.0
+ }
+}
+
+impl fmt::Debug for BackupManifestSha256 {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("BackupManifestSha256([redacted])")
+ }
+}
+
+/// Exact successful integrity projection frozen by the v1 manifest.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct BackupManifestIntegrity {
+ _private: (),
+}
+
+impl BackupManifestIntegrity {
+ /// Returns the exact SQLite integrity result.
+ #[must_use]
+ pub const fn sqlite(self) -> &'static str {
+ INTEGRITY_OK
+ }
+
+ /// Returns the exact foreign-key integrity result.
+ #[must_use]
+ pub const fn foreign_keys(self) -> &'static str {
+ INTEGRITY_OK
+ }
+}
+
+impl fmt::Debug for BackupManifestIntegrity {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("BackupManifestIntegrity")
+ }
+}
+
+/// Sole state database member carried by a v1 manifest.
+#[derive(Clone, PartialEq, Eq)]
+pub struct ServiceBackupMember {
+ byte_length: u64,
+ sha256: BackupMemberSha256,
+}
+
+impl ServiceBackupMember {
+ /// Returns the fixed v1 member name.
+ #[must_use]
+ pub const fn name(&self) -> &'static str {
+ BACKUP_STATE_MEMBER_NAME
+ }
+
+ /// Returns the exact nonzero captured byte length.
+ #[must_use]
+ pub const fn byte_length(&self) -> u64 {
+ self.byte_length
+ }
+
+ /// Returns the exact captured member digest.
+ #[must_use]
+ pub const fn sha256(&self) -> BackupMemberSha256 {
+ self.sha256
+ }
+}
+
+impl fmt::Debug for ServiceBackupMember {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("ServiceBackupMember")
+ .field("name", &BACKUP_STATE_MEMBER_NAME)
+ .field("byte_length", &self.byte_length)
+ .field("sha256", &"[redacted]")
+ .finish()
+ }
+}
+
+/// Structurally valid canonical v1 backup manifest.
+#[derive(Clone, PartialEq, Eq)]
+pub struct ServiceBackupManifest {
+ service: ServiceId,
+ instance: InstanceId,
+ source_generation: SourceGeneration,
+ state_schema_version: NonZeroU32,
+ created_at_unix_ms: BackupCreatedAtUnixMs,
+ member: ServiceBackupMember,
+ canonical_bytes: Box<[u8]>,
+ digest: BackupManifestSha256,
+}
+
+impl ServiceBackupManifest {
+ /// Parses only exact compact canonical v1 bytes.
+ ///
+ /// Structural parsing does not verify a member file. Step 065's verifier
+ /// 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);
+ }
+ 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);
+ }
+ Ok(manifest)
+ }
+
+ /// Returns the exact compact canonical UTF-8 JSON bytes.
+ #[must_use]
+ pub fn canonical_bytes(&self) -> &[u8] {
+ &self.canonical_bytes
+ }
+
+ /// Returns SHA-256 over the exact canonical bytes.
+ #[must_use]
+ pub const fn digest(&self) -> BackupManifestSha256 {
+ self.digest
+ }
+
+ #[must_use]
+ pub const fn schema(&self) -> &'static str {
+ BACKUP_MANIFEST_SCHEMA
+ }
+
+ #[must_use]
+ pub const fn schema_version(&self) -> u32 {
+ BACKUP_MANIFEST_SCHEMA_VERSION
+ }
+
+ #[must_use]
+ pub fn service(&self) -> &ServiceId {
+ &self.service
+ }
+
+ #[must_use]
+ pub fn instance(&self) -> &InstanceId {
+ &self.instance
+ }
+
+ #[must_use]
+ pub const fn source_generation(&self) -> SourceGeneration {
+ self.source_generation
+ }
+
+ #[must_use]
+ pub const fn state_schema_version(&self) -> NonZeroU32 {
+ self.state_schema_version
+ }
+
+ #[must_use]
+ pub const fn created_at_unix_ms(&self) -> BackupCreatedAtUnixMs {
+ self.created_at_unix_ms
+ }
+
+ #[must_use]
+ pub fn members(&self) -> &[ServiceBackupMember] {
+ core::slice::from_ref(&self.member)
+ }
+
+ #[must_use]
+ pub const fn integrity(&self) -> BackupManifestIntegrity {
+ BackupManifestIntegrity { _private: () }
+ }
+
+ #[must_use]
+ pub const fn protected_material_included(&self) -> bool {
+ false
+ }
+
+ #[allow(dead_code)]
+ pub(crate) fn from_capture(
+ metadata: &ServiceDatabaseMetadata,
+ created_at_unix_ms: BackupCreatedAtUnixMs,
+ state_byte_length: u64,
+ state_sha256: BackupMemberSha256,
+ ) -> Result<Self, BackupManifestContractError> {
+ Self::build(
+ metadata.service().clone(),
+ metadata.instance().clone(),
+ metadata.source_generation(),
+ metadata.state_schema_version(),
+ created_at_unix_ms,
+ state_byte_length,
+ state_sha256,
+ )
+ }
+
+ 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);
+ }
+ let service = ServiceId::new(wire.service)
+ .map_err(|_| BackupManifestContractError::InvalidServiceIdentity)?;
+ let instance = InstanceId::new(wire.instance)
+ .map_err(|_| BackupManifestContractError::InvalidInstanceIdentity)?;
+ let source_generation = decode_hex_32(&wire.source_generation)
+ .and_then(|value| SourceGeneration::new(value).ok())
+ .ok_or(BackupManifestContractError::InvalidSourceGeneration)?;
+ let state_schema_version = NonZeroU32::new(wire.state_schema_version)
+ .ok_or(BackupManifestContractError::InvalidStateSchemaVersion)?;
+ let created_at_unix_ms = BackupCreatedAtUnixMs::new(wire.created_at_unix_ms)?;
+ 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);
+ }
+ 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);
+ }
+ Self::build(
+ service,
+ instance,
+ source_generation,
+ state_schema_version,
+ created_at_unix_ms,
+ member.byte_length,
+ member_sha256,
+ )
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ fn build(
+ service: ServiceId,
+ instance: InstanceId,
+ source_generation: SourceGeneration,
+ state_schema_version: NonZeroU32,
+ created_at_unix_ms: BackupCreatedAtUnixMs,
+ state_byte_length: u64,
+ state_sha256: BackupMemberSha256,
+ ) -> Result<Self, BackupManifestContractError> {
+ if state_byte_length == 0 {
+ return Err(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 {
+ schema: BACKUP_MANIFEST_SCHEMA,
+ schema_version: BACKUP_MANIFEST_SCHEMA_VERSION,
+ service: service.as_str(),
+ instance: instance.as_str(),
+ source_generation: &source_generation_hex,
+ state_schema_version: state_schema_version.get(),
+ created_at_unix_ms: created_at_unix_ms.get(),
+ members: [CanonicalMember {
+ name: BACKUP_STATE_MEMBER_NAME,
+ byte_length: state_byte_length,
+ sha256: &state_sha256_hex,
+ }],
+ integrity: CanonicalIntegrity {
+ sqlite: INTEGRITY_OK,
+ foreign_keys: INTEGRITY_OK,
+ },
+ protected_material_included: false,
+ };
+ 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);
+ }
+ let digest = BackupManifestSha256(Sha256::digest(&canonical_bytes).into());
+ Ok(Self {
+ service,
+ instance,
+ source_generation,
+ state_schema_version,
+ created_at_unix_ms,
+ member: ServiceBackupMember {
+ byte_length: state_byte_length,
+ sha256: state_sha256,
+ },
+ canonical_bytes: canonical_bytes.into_boxed_slice(),
+ digest,
+ })
+ }
+}
+
+impl fmt::Debug for ServiceBackupManifest {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("ServiceBackupManifest")
+ .field("schema_version", &BACKUP_MANIFEST_SCHEMA_VERSION)
+ .field("service", &"[redacted]")
+ .field("instance", &"[redacted]")
+ .field("source_generation", &"[redacted]")
+ .field("state_schema_version", &self.state_schema_version)
+ .field("created_at_unix_ms", &self.created_at_unix_ms)
+ .field("member", &self.member)
+ .field("digest", &"[redacted]")
+ .field("protected_material_included", &false)
+ .finish()
+ }
+}
+
+/// Stable, source-free backup manifest validation failure.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum BackupManifestContractError {
+ ManifestTooLarge,
+ MalformedEncoding,
+ NonCanonicalEncoding,
+ EncodingFailure,
+ InvalidSchema,
+ UnsupportedVersion,
+ InvalidServiceIdentity,
+ InvalidInstanceIdentity,
+ InvalidSourceGeneration,
+ InvalidStateSchemaVersion,
+ InvalidCreationTime,
+ InvalidMemberInventory,
+ InvalidMemberName,
+ InvalidMemberLength,
+ InvalidMemberDigest,
+ InvalidIntegrity,
+ ProtectedMaterialIncluded,
+}
+
+impl fmt::Display for BackupManifestContractError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ Self::ManifestTooLarge => "backup manifest exceeds its byte limit",
+ Self::MalformedEncoding => "backup manifest encoding is malformed",
+ Self::NonCanonicalEncoding => "backup manifest encoding is not canonical",
+ Self::EncodingFailure => "backup manifest could not be encoded",
+ Self::InvalidSchema => "backup manifest schema is invalid",
+ Self::UnsupportedVersion => "backup manifest version is unsupported",
+ Self::InvalidServiceIdentity => "backup manifest service identity is invalid",
+ Self::InvalidInstanceIdentity => "backup manifest instance identity is invalid",
+ Self::InvalidSourceGeneration => "backup manifest source generation is invalid",
+ Self::InvalidStateSchemaVersion => "backup manifest state schema version is invalid",
+ Self::InvalidCreationTime => "backup manifest creation time is invalid",
+ Self::InvalidMemberInventory => "backup manifest member inventory is invalid",
+ Self::InvalidMemberName => "backup manifest member name is invalid",
+ Self::InvalidMemberLength => "backup manifest member length is invalid",
+ Self::InvalidMemberDigest => "backup manifest member digest is invalid",
+ Self::InvalidIntegrity => "backup manifest integrity result is invalid",
+ Self::ProtectedMaterialIncluded => "backup manifest includes protected material",
+ })
+ }
+}
+
+impl Error for BackupManifestContractError {}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct WireManifest {
+ schema: String,
+ schema_version: u32,
+ service: String,
+ instance: String,
+ source_generation: String,
+ state_schema_version: u32,
+ created_at_unix_ms: u64,
+ members: Vec<WireMember>,
+ integrity: WireIntegrity,
+ protected_material_included: bool,
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct WireMember {
+ name: String,
+ byte_length: u64,
+ sha256: String,
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct WireIntegrity {
+ sqlite: String,
+ foreign_keys: String,
+}
+
+#[derive(Serialize)]
+struct CanonicalManifest<'a> {
+ schema: &'a str,
+ schema_version: u32,
+ service: &'a str,
+ instance: &'a str,
+ source_generation: &'a str,
+ state_schema_version: u32,
+ created_at_unix_ms: u64,
+ members: [CanonicalMember<'a>; 1],
+ integrity: CanonicalIntegrity<'a>,
+ protected_material_included: bool,
+}
+
+#[derive(Serialize)]
+struct CanonicalMember<'a> {
+ name: &'a str,
+ byte_length: u64,
+ sha256: &'a str,
+}
+
+#[derive(Serialize)]
+struct CanonicalIntegrity<'a> {
+ sqlite: &'a str,
+ foreign_keys: &'a str,
+}
+
+fn encode_hex(bytes: &[u8; 32]) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut encoded = String::with_capacity(64);
+ for byte in bytes {
+ encoded.push(char::from(HEX[usize::from(byte >> 4)]));
+ encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
+ }
+ encoded
+}
+
+fn decode_hex_32(value: &str) -> Option<[u8; 32]> {
+ if value.len() != 64 {
+ return None;
+ }
+ let mut decoded = [0_u8; 32];
+ for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
+ let high = decode_lower_hex(pair[0])?;
+ let low = decode_lower_hex(pair[1])?;
+ decoded[index] = (high << 4) | low;
+ }
+ Some(decoded)
+}
+
+const fn decode_lower_hex(value: u8) -> Option<u8> {
+ match value {
+ b'0'..=b'9' => Some(value - b'0'),
+ b'a'..=b'f' => Some(value - b'a' + 10),
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{ServiceSqliteApplicationId, ServiceSqlitePaths};
+ use radroots_runtime_paths::{
+ RadrootsHostEnvironment, RadrootsPathProfile, RadrootsPathResolver, RadrootsPlatform,
+ RuntimeContext, RuntimeContextBootstrap, RuntimeContextSource,
+ };
+ use std::path::PathBuf;
+
+ const GENERATION: &str = "0101010101010101010101010101010101010101010101010101010101010101";
+ const MEMBER_SHA: &str = "abababababababababababababababababababababababababababababababab";
+ const CANONICAL: &str = concat!(
+ "{\"schema\":\"radroots.service-backup\",\"schema_version\":1,",
+ "\"service\":\"myc\",\"instance\":\"primary\",",
+ "\"source_generation\":\"0101010101010101010101010101010101010101010101010101010101010101\",",
+ "\"state_schema_version\":1,\"created_at_unix_ms\":1700000000000,",
+ "\"members\":[{\"name\":\"state.sqlite\",\"byte_length\":12345,",
+ "\"sha256\":\"abababababababababababababababababababababababababababababababab\"}],",
+ "\"integrity\":{\"sqlite\":\"ok\",\"foreign_keys\":\"ok\"},",
+ "\"protected_material_included\":false}"
+ );
+
+ fn parsed() -> ServiceBackupManifest {
+ ServiceBackupManifest::from_canonical_bytes(CANONICAL.as_bytes())
+ .expect("canonical backup manifest")
+ }
+
+ fn replace_once(old: &str, new: &str) -> String {
+ CANONICAL.replacen(old, new, 1)
+ }
+
+ fn paths() -> ServiceSqlitePaths {
+ let context = RuntimeContext::resolve(
+ &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()),
+ RuntimeContextBootstrap::new(
+ RadrootsPathProfile::RepoLocal,
+ Some(PathBuf::from("/isolated/backup-manifest")),
+ RuntimeContextSource::BootstrapCli,
+ RuntimeContextSource::BootstrapCli,
+ )
+ .expect("bootstrap"),
+ ServiceId::new("myc").expect("service"),
+ InstanceId::new("primary").expect("instance"),
+ )
+ .expect("runtime context");
+ ServiceSqlitePaths::from_runtime_context(&context).expect("SQLite paths")
+ }
+
+ #[test]
+ fn canonical_bytes_and_digest_are_frozen() {
+ let manifest = parsed();
+ assert_eq!(manifest.canonical_bytes(), CANONICAL.as_bytes());
+ assert_eq!(manifest.schema(), BACKUP_MANIFEST_SCHEMA);
+ assert_eq!(manifest.schema_version(), BACKUP_MANIFEST_SCHEMA_VERSION);
+ assert_eq!(manifest.service().as_str(), "myc");
+ assert_eq!(manifest.instance().as_str(), "primary");
+ assert_eq!(
+ encode_hex(manifest.source_generation().as_bytes()),
+ GENERATION
+ );
+ assert_eq!(manifest.state_schema_version().get(), 1);
+ assert_eq!(manifest.created_at_unix_ms().get(), 1_700_000_000_000);
+ assert_eq!(manifest.members().len(), 1);
+ assert_eq!(manifest.members()[0].name(), BACKUP_STATE_MEMBER_NAME);
+ assert_eq!(manifest.members()[0].byte_length(), 12_345);
+ assert_eq!(
+ encode_hex(manifest.members()[0].sha256().as_bytes()),
+ MEMBER_SHA
+ );
+ assert_eq!(manifest.integrity().sqlite(), INTEGRITY_OK);
+ assert_eq!(manifest.integrity().foreign_keys(), INTEGRITY_OK);
+ assert!(!manifest.protected_material_included());
+ assert_eq!(
+ encode_hex(manifest.digest().as_bytes()),
+ "1e9212b4a8e8db0d96134fc9fc10392b63de1ebea2e84cc201615bbb75cd7fd8"
+ );
+ }
+
+ #[test]
+ fn capture_constructor_reuses_exact_database_identity() {
+ let paths = paths();
+ let metadata = ServiceDatabaseMetadata::new(
+ &paths,
+ SourceGeneration::new([1; 32]).expect("generation"),
+ NonZeroU32::new(1).expect("schema"),
+ 1_600_000_000_000,
+ ServiceSqliteApplicationId::new(7).expect("application ID"),
+ )
+ .expect("metadata");
+ let manifest = ServiceBackupManifest::from_capture(
+ &metadata,
+ BackupCreatedAtUnixMs::new(1_700_000_000_000).expect("backup time"),
+ 12_345,
+ BackupMemberSha256::from_bytes([0xab; 32]),
+ )
+ .expect("captured manifest model");
+ assert_eq!(manifest.canonical_bytes(), CANONICAL.as_bytes());
+ }
+
+ #[test]
+ fn noncanonical_and_ambiguous_encodings_fail_closed() {
+ let reordered = CANONICAL.replacen(
+ "\"schema\":\"radroots.service-backup\",\"schema_version\":1",
+ "\"schema_version\":1,\"schema\":\"radroots.service-backup\"",
+ 1,
+ );
+ for bytes in [format!(" {CANONICAL}"), format!("{CANONICAL}\n"), reordered] {
+ assert_eq!(
+ ServiceBackupManifest::from_canonical_bytes(bytes.as_bytes()),
+ Err(BackupManifestContractError::NonCanonicalEncoding)
+ );
+ }
+ for bytes in [
+ format!("\u{feff}{CANONICAL}"),
+ replace_once(
+ "\"schema_version\":1",
+ "\"schema_version\":1,\"schema_version\":1",
+ ),
+ replace_once(
+ "\"schema_version\":1",
+ "\"schema_version\":1,\"unknown\":false",
+ ),
+ replace_once("\"service\":\"myc\"", "\"service\":null"),
+ ] {
+ assert_eq!(
+ ServiceBackupManifest::from_canonical_bytes(bytes.as_bytes()),
+ Err(BackupManifestContractError::MalformedEncoding)
+ );
+ }
+ assert_eq!(
+ ServiceBackupManifest::from_canonical_bytes(&vec![b'x'; 1_025]),
+ Err(BackupManifestContractError::ManifestTooLarge)
+ );
+ }
+
+ #[test]
+ fn schema_identity_generation_version_and_time_are_exact() {
+ for (old, new, expected) in [
+ (
+ "radroots.service-backup",
+ "radroots.other-backup",
+ BackupManifestContractError::InvalidSchema,
+ ),
+ (
+ "\"schema_version\":1",
+ "\"schema_version\":2",
+ BackupManifestContractError::UnsupportedVersion,
+ ),
+ (
+ "\"service\":\"myc\"",
+ "\"service\":\"../myc\"",
+ BackupManifestContractError::InvalidServiceIdentity,
+ ),
+ (
+ "\"instance\":\"primary\"",
+ "\"instance\":\"PRIMARY\"",
+ BackupManifestContractError::InvalidInstanceIdentity,
+ ),
+ (
+ "\"state_schema_version\":1",
+ "\"state_schema_version\":0",
+ BackupManifestContractError::InvalidStateSchemaVersion,
+ ),
+ (
+ "\"created_at_unix_ms\":1700000000000",
+ "\"created_at_unix_ms\":0",
+ BackupManifestContractError::InvalidCreationTime,
+ ),
+ ] {
+ assert_eq!(
+ ServiceBackupManifest::from_canonical_bytes(replace_once(old, new).as_bytes()),
+ Err(expected)
+ );
+ }
+ for generation in [
+ "0".repeat(64),
+ "AB".repeat(32),
+ "01".repeat(31),
+ format!("{}g", &GENERATION[..63]),
+ ] {
+ assert_eq!(
+ ServiceBackupManifest::from_canonical_bytes(
+ replace_once(GENERATION, &generation).as_bytes()
+ ),
+ Err(BackupManifestContractError::InvalidSourceGeneration)
+ );
+ }
+ assert_eq!(
+ BackupCreatedAtUnixMs::new(i64::MAX as u64).map(BackupCreatedAtUnixMs::get),
+ Ok(i64::MAX as u64)
+ );
+ assert_eq!(
+ BackupCreatedAtUnixMs::new(i64::MAX as u64 + 1),
+ Err(BackupManifestContractError::InvalidCreationTime)
+ );
+ }
+
+ #[test]
+ fn member_inventory_length_and_digest_are_exact() {
+ let singleton = format!(
+ "[{{\"name\":\"state.sqlite\",\"byte_length\":12345,\"sha256\":\"{MEMBER_SHA}\"}}]"
+ );
+ for (members, expected) in [
+ (
+ "[]".to_owned(),
+ BackupManifestContractError::InvalidMemberInventory,
+ ),
+ (
+ format!(
+ "[{{\"name\":\"state.sqlite\",\"byte_length\":12345,\"sha256\":\"{MEMBER_SHA}\"}},{{\"name\":\"state.sqlite\",\"byte_length\":12345,\"sha256\":\"{MEMBER_SHA}\"}}]"
+ ),
+ BackupManifestContractError::InvalidMemberInventory,
+ ),
+ ] {
+ assert_eq!(
+ ServiceBackupManifest::from_canonical_bytes(
+ replace_once(&singleton, &members).as_bytes()
+ ),
+ Err(expected)
+ );
+ }
+ for name in ["", "state.db", "../state.sqlite"] {
+ assert_eq!(
+ ServiceBackupManifest::from_canonical_bytes(
+ replace_once("state.sqlite", name).as_bytes()
+ ),
+ Err(BackupManifestContractError::InvalidMemberName)
+ );
+ }
+ assert_eq!(
+ ServiceBackupManifest::from_canonical_bytes(
+ replace_once("\"byte_length\":12345", "\"byte_length\":0").as_bytes()
+ ),
+ Err(BackupManifestContractError::InvalidMemberLength)
+ );
+ assert!(
+ ServiceBackupManifest::from_canonical_bytes(
+ replace_once(
+ "\"byte_length\":12345",
+ "\"byte_length\":18446744073709551615"
+ )
+ .as_bytes()
+ )
+ .is_ok()
+ );
+ for digest in [
+ MEMBER_SHA.to_uppercase(),
+ "ab".repeat(31),
+ format!("{}g", &MEMBER_SHA[..63]),
+ ] {
+ assert_eq!(
+ ServiceBackupManifest::from_canonical_bytes(
+ replace_once(MEMBER_SHA, &digest).as_bytes()
+ ),
+ Err(BackupManifestContractError::InvalidMemberDigest)
+ );
+ }
+ }
+
+ #[test]
+ fn integrity_and_protected_material_fail_closed() {
+ for invalid in [
+ replace_once("\"sqlite\":\"ok\"", "\"sqlite\":\"failed\""),
+ replace_once("\"foreign_keys\":\"ok\"", "\"foreign_keys\":\"failed\""),
+ ] {
+ assert_eq!(
+ ServiceBackupManifest::from_canonical_bytes(invalid.as_bytes()),
+ Err(BackupManifestContractError::InvalidIntegrity)
+ );
+ }
+ assert_eq!(
+ ServiceBackupManifest::from_canonical_bytes(
+ replace_once(
+ "\"protected_material_included\":false",
+ "\"protected_material_included\":true"
+ )
+ .as_bytes()
+ ),
+ Err(BackupManifestContractError::ProtectedMaterialIncluded)
+ );
+ }
+
+ #[test]
+ fn diagnostics_are_source_free_and_redacted() {
+ let debug = format!("{:?}", parsed());
+ for forbidden in ["myc", "primary", GENERATION, MEMBER_SHA] {
+ assert!(!debug.contains(forbidden));
+ }
+ let error = BackupManifestContractError::InvalidMemberDigest;
+ assert!(error.source().is_none());
+ assert_eq!(
+ error.to_string(),
+ "backup manifest member digest is invalid"
+ );
+ }
+}
diff --git a/crates/service_sqlite/src/backup/mod.rs b/crates/service_sqlite/src/backup/mod.rs
@@ -0,0 +1,10 @@
+//! Stable service backup manifest identity.
+
+mod manifest;
+
+pub use manifest::{
+ BACKUP_MANIFEST_CANONICAL_MAX_BYTES, BACKUP_MANIFEST_SCHEMA, BACKUP_MANIFEST_SCHEMA_VERSION,
+ BACKUP_STATE_MEMBER_NAME, BackupCreatedAtUnixMs, BackupManifestContractError,
+ BackupManifestIntegrity, BackupManifestSha256, BackupMemberSha256, ServiceBackupManifest,
+ ServiceBackupMember,
+};
diff --git a/crates/service_sqlite/src/lib.rs b/crates/service_sqlite/src/lib.rs
@@ -3,6 +3,7 @@
//! Reusable, service-neutral SQLite mechanics for Radroots services.
mod authority;
+mod backup;
mod config;
mod connection;
mod error;
@@ -15,6 +16,12 @@ mod status;
mod transaction_control;
pub use authority::WriterAuthority;
+pub use backup::{
+ BACKUP_MANIFEST_CANONICAL_MAX_BYTES, BACKUP_MANIFEST_SCHEMA, BACKUP_MANIFEST_SCHEMA_VERSION,
+ BACKUP_STATE_MEMBER_NAME, BackupCreatedAtUnixMs, BackupManifestContractError,
+ BackupManifestIntegrity, BackupManifestSha256, BackupMemberSha256, ServiceBackupManifest,
+ ServiceBackupMember,
+};
pub use config::{ServiceSqliteConnectionOptions, ServiceSqliteConnectionOptionsError};
pub use connection::{
ServiceSqliteHost, ServiceSqliteTransaction, ServiceSqliteTransactionError,
diff --git a/crates/service_sqlite/tests/package_boundary.rs b/crates/service_sqlite/tests/package_boundary.rs
@@ -4,6 +4,7 @@ const MANIFEST: &str = include_str!("../Cargo.toml");
const README: &str = include_str!("../README.md");
const ROOT: &str = include_str!("../src/lib.rs");
const AUTHORITY_SOURCE: &str = include_str!("../src/authority.rs");
+const BACKUP_SOURCE: &str = include_str!("../src/backup/manifest.rs");
const CONFIG_SOURCE: &str = include_str!("../src/config.rs");
const CONNECTION_SOURCE: &str = include_str!("../src/connection.rs");
const ERROR_SOURCE: &str = include_str!("../src/error.rs");
@@ -40,6 +41,7 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"radroots_storage",
"rustix",
"serde",
+ "serde_json",
"sha2",
"sqlx",
"tokio"
@@ -47,12 +49,13 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
);
assert_eq!(
dependency_keys(MANIFEST, "[dev-dependencies]"),
- BTreeSet::from(["serde_json", "tempfile", "tokio"])
+ BTreeSet::from(["tempfile", "tokio"])
);
assert_eq!(
private_modules(ROOT),
BTreeSet::from([
"authority",
+ "backup",
"config",
"connection",
"error",
@@ -95,6 +98,14 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"a later call resumes close",
"stable outer result is cached",
"Dropping a host performs no asynchronous close work",
+ "`ServiceBackupManifest` is the stable model-only v1 backup identity",
+ "compact canonical UTF-8 JSON in the frozen field order, capped at 1,024 bytes",
+ "external manifest SHA-256 over those exact bytes",
+ "exactly one `state.sqlite` member",
+ "integrity are exactly `ok`, and protected material is always excluded",
+ "Parsing proves only the strict structural and canonical contract",
+ "unknown, duplicate, null, reordered, whitespace-altered, or version-drifted input",
+ "does no backup filesystem or SQLite capture work",
] {
assert!(
readme_words.contains(required),
@@ -117,6 +128,10 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
.split_once("#[cfg(test)]\nmod tests")
.map(|(production, _)| production)
.expect("connection source must keep tests separated");
+ let backup_production = BACKUP_SOURCE
+ .split_once("#[cfg(test)]")
+ .map(|(production, _)| production)
+ .expect("backup source must keep tests separated");
for required in [
"ServiceSqliteErrorCode",
@@ -124,6 +139,17 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
"SafeServiceSqliteError",
"ServiceSqliteError",
"WriterAuthority",
+ "BackupCreatedAtUnixMs",
+ "BackupManifestContractError",
+ "BackupManifestIntegrity",
+ "BackupManifestSha256",
+ "BackupMemberSha256",
+ "ServiceBackupManifest",
+ "ServiceBackupMember",
+ "BACKUP_MANIFEST_CANONICAL_MAX_BYTES",
+ "BACKUP_MANIFEST_SCHEMA",
+ "BACKUP_MANIFEST_SCHEMA_VERSION",
+ "BACKUP_STATE_MEMBER_NAME",
"ServiceSqliteConnectionOptions",
"ServiceSqliteConnectionOptionsError",
"initialize_database",
@@ -170,6 +196,43 @@ fn service_sqlite_is_unpublished_lint_governed_and_dependency_bounded() {
}
for required in [
+ "radroots.service-backup",
+ "BACKUP_MANIFEST_SCHEMA_VERSION: u32 = 1",
+ "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",
+ "serde(deny_unknown_fields)",
+ "Sha256::digest(&canonical_bytes)",
+ "pub(crate) fn from_capture",
+ "ServiceDatabaseMetadata",
+ "InvalidMemberInventory",
+ "ProtectedMaterialIncluded",
+ ] {
+ assert!(
+ backup_production.contains(required),
+ "Step 063 backup source is missing `{required}`"
+ );
+ }
+ for forbidden in [
+ "impl Serialize for ServiceBackupManifest",
+ "impl<'de> Deserialize<'de> for ServiceBackupManifest",
+ "pub fn from_capture",
+ "std::fs",
+ "sqlx",
+ "tokio",
+ "SystemTime",
+ "WallClock",
+ "PathBuf",
+ "OpenOptions",
+ ] {
+ assert!(
+ !backup_production.contains(forbidden),
+ "Step 063 backup source contains deferred or forgeable surface `{forbidden}`"
+ );
+ }
+
+ for required in [
"radroots.service_sqlite.migration_content.v1\\0",
"radroots.service_sqlite.migration_catalog.v1\\0",
"MAX_MIGRATION_NAME_UTF8_BYTES",