commit 643d8c3f3cb5a4ef2e0f1395a6283443c6c07dd9
parent c355a29324c1c03a625857122fd6c07e99bb41d8
Author: triesap <tyson@radroots.org>
Date: Sun, 2 Aug 2026 19:03:43 +0000
storage-sqlite: establish the private database schema
- govern a separate private.sqlite schema for four approved private authorities
- persist radroots_secrets envelopes with exact reference, key version, size, and commitment validation
- implement idempotent private artifact storage, expiry, retention, and durable tombstones
- prove schema integrity, no-Studio authority, encryption, corruption, and read-only behavior
Diffstat:
10 files changed, 1293 insertions(+), 2 deletions(-)
diff --git a/contracts/storage/private_schema_v1.toml b/contracts/storage/private_schema_v1.toml
@@ -0,0 +1,35 @@
+schema_version = 1
+database = "private.sqlite"
+minimum_version = 1
+current_version = 1
+migration_name = "private_artifacts"
+migration_sha256 = "07050386292ff8ce9ec0e756c9ac88e458a249d53e8654e1102caa2e361f11ab"
+forward_only = true
+raw_sql_public = false
+encrypted_envelopes = true
+
+authorities = [
+ "signing_references",
+ "private_farm_locations",
+ "private_trade_artifacts",
+ "permitted_nip46_sessions",
+]
+
+forbidden_tables = [
+ "studio",
+ "ui_state",
+]
+
+[[migrations]]
+version = 1
+name = "private_artifacts"
+sha256 = "07050386292ff8ce9ec0e756c9ac88e458a249d53e8654e1102caa2e361f11ab"
+owned_objects = [
+ "radroots_private_artifacts",
+ "radroots_private_artifacts_delete_guard",
+ "radroots_private_artifacts_envelope_guard",
+ "radroots_private_artifacts_expiry_idx",
+ "radroots_private_artifacts_identity_guard",
+ "radroots_private_artifacts_key_version_idx",
+ "radroots_private_artifacts_kind_idx",
+]
diff --git a/crates/storage/src/private_artifact.rs b/crates/storage/src/private_artifact.rs
@@ -322,6 +322,67 @@ impl PrivateArtifactMetadata {
tombstone: None,
})
}
+
+ /// Reconstructs and validates metadata at a durable backend boundary.
+ #[allow(clippy::too_many_arguments)]
+ pub fn from_durable_parts(
+ artifact_id: PrivateArtifactId,
+ kind: ArtifactKind,
+ schema_id: ArtifactSchemaId,
+ commitment: ArtifactCommitment,
+ protected_size_bytes: u64,
+ secret_reference: DurableSecretReference,
+ retention: RetentionPolicy,
+ revision: PrivateArtifactRevision,
+ stage: PrivateArtifactStage,
+ created_at_unix_ms: u64,
+ updated_at_unix_ms: u64,
+ tombstone: Option<(u64, DeletionReason, ArtifactCommitment)>,
+ ) -> Result<Self, Error> {
+ let initial = Self::new(
+ artifact_id,
+ kind,
+ schema_id,
+ commitment,
+ protected_size_bytes,
+ secret_reference,
+ retention,
+ created_at_unix_ms,
+ )?;
+ if updated_at_unix_ms < created_at_unix_ms {
+ return Err(Error::CorruptPrivateArtifactMetadata);
+ }
+ let tombstone =
+ tombstone.map(
+ |(deleted_at_unix_ms, reason, tombstone_commitment)| ArtifactTombstone {
+ deleted_at_unix_ms,
+ reason,
+ commitment: tombstone_commitment,
+ },
+ );
+ let valid = match (stage, revision.get(), tombstone) {
+ (PrivateArtifactStage::Active, 1, None) => updated_at_unix_ms == created_at_unix_ms,
+ (PrivateArtifactStage::Expired, 2, None) => retention.is_expired_at(updated_at_unix_ms),
+ (PrivateArtifactStage::Tombstoned, 2 | 3, Some(tombstone)) => {
+ tombstone.deleted_at_unix_ms == updated_at_unix_ms
+ && tombstone.commitment == commitment
+ && retention.permits_deletion_at(updated_at_unix_ms)
+ && (tombstone.reason != DeletionReason::RetentionExpired
+ || retention.is_expired_at(updated_at_unix_ms))
+ }
+ _ => false,
+ };
+ if !valid {
+ return Err(Error::CorruptPrivateArtifactMetadata);
+ }
+ Ok(Self {
+ revision,
+ stage,
+ updated_at_unix_ms,
+ tombstone,
+ ..initial
+ })
+ }
pub const fn artifact_id(&self) -> PrivateArtifactId {
self.artifact_id
}
diff --git a/crates/storage_sqlite/Cargo.toml b/crates/storage_sqlite/Cargo.toml
@@ -18,6 +18,7 @@ name = "radroots_storage_sqlite"
radroots_event_codec = { workspace = true, default-features = false, features = ["json", "std"] }
radroots_secrets = { workspace = true, default-features = false }
radroots_storage = { workspace = true, default-features = false }
+sha2 = { workspace = true, features = ["std"] }
sqlx = { workspace = true, features = ["runtime-tokio", "sqlite-bundled"] }
[dev-dependencies]
@@ -25,7 +26,6 @@ radroots_event = { workspace = true, default-features = false, features = ["std"
radroots_transport = { workspace = true, default-features = false }
serde = { workspace = true, features = ["derive", "std"] }
serde_json = { workspace = true, features = ["std"] }
-sha2 = { workspace = true, features = ["std"] }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
toml = { workspace = true }
diff --git a/crates/storage_sqlite/src/event/mod.rs b/crates/storage_sqlite/src/event/mod.rs
@@ -14,6 +14,7 @@ use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool};
#[derive(Clone)]
pub struct SqliteStorage {
pool: SqlitePool,
+ private_pool: SqlitePool,
generation: SourceGeneration,
mode: EventStoreMode,
}
@@ -26,22 +27,42 @@ struct StoredEventRow {
impl SqliteStorage {
#[allow(dead_code)] // Wired into the public open lifecycle in its ordered RCL checkpoint.
- pub(crate) const fn new(
+ pub(crate) fn new(
pool: SqlitePool,
generation: SourceGeneration,
mode: EventStoreMode,
) -> Self {
Self {
+ private_pool: pool.clone(),
pool,
generation,
mode,
}
}
+ #[allow(dead_code)] // Wired into the public open lifecycle in its ordered RCL checkpoint.
+ pub(crate) fn with_private_pool(
+ pool: SqlitePool,
+ private_pool: SqlitePool,
+ generation: SourceGeneration,
+ mode: EventStoreMode,
+ ) -> Self {
+ Self {
+ pool,
+ private_pool,
+ generation,
+ mode,
+ }
+ }
+
pub(crate) const fn pool(&self) -> &SqlitePool {
&self.pool
}
+ pub(crate) const fn private_pool(&self) -> &SqlitePool {
+ &self.private_pool
+ }
+
pub(crate) const fn event_mode(&self) -> EventStoreMode {
self.mode
}
diff --git a/crates/storage_sqlite/src/lib.rs b/crates/storage_sqlite/src/lib.rs
@@ -12,6 +12,7 @@ mod atomic;
mod event;
mod journal;
mod outbox;
+mod private_artifact;
mod projection;
pub use config::OpenOptions;
diff --git a/crates/storage_sqlite/src/migration.rs b/crates/storage_sqlite/src/migration.rs
@@ -1,4 +1,6 @@
//! Governed SQLite schema migration boundary.
+/// Versioned schema authority for `private.sqlite`.
+pub mod private;
/// Versioned schema authority for `runtime.sqlite`.
pub mod runtime;
diff --git a/crates/storage_sqlite/src/migration/private/0001_private.up.sql b/crates/storage_sqlite/src/migration/private/0001_private.up.sql
@@ -0,0 +1,85 @@
+CREATE TABLE radroots_private_artifacts (
+ artifact_id BLOB PRIMARY KEY NOT NULL CHECK (length(artifact_id) = 16),
+ artifact_kind TEXT NOT NULL CHECK (length(artifact_kind) BETWEEN 1 AND 128),
+ schema_id TEXT NOT NULL CHECK (length(schema_id) BETWEEN 1 AND 128),
+ commitment BLOB NOT NULL CHECK (length(commitment) = 32),
+ protected_size_bytes INTEGER NOT NULL CHECK (protected_size_bytes > 0),
+ secret_provider TEXT NOT NULL CHECK (length(secret_provider) BETWEEN 1 AND 64),
+ secret_reference TEXT NOT NULL CHECK (length(secret_reference) BETWEEN 1 AND 512),
+ key_version INTEGER NOT NULL CHECK (key_version BETWEEN 1 AND 4294967295),
+ envelope_version INTEGER CHECK (envelope_version IS NULL OR envelope_version > 0),
+ encrypted_envelope BLOB,
+ delete_not_before_unix_ms INTEGER CHECK (delete_not_before_unix_ms > 0),
+ expires_at_unix_ms INTEGER CHECK (expires_at_unix_ms > 0),
+ revision INTEGER NOT NULL CHECK (revision > 0),
+ stage TEXT NOT NULL CHECK (stage IN ('active', 'expired', 'tombstoned')),
+ created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0),
+ updated_at_unix_ms INTEGER NOT NULL CHECK (updated_at_unix_ms >= created_at_unix_ms),
+ deleted_at_unix_ms INTEGER,
+ deletion_reason TEXT CHECK (
+ deletion_reason IS NULL OR deletion_reason IN (
+ 'user_requested',
+ 'retention_expired',
+ 'key_revoked',
+ 'integrity_failure',
+ 'operator_requested'
+ )
+ ),
+ tombstone_commitment BLOB CHECK (
+ tombstone_commitment IS NULL OR length(tombstone_commitment) = 32
+ ),
+ CHECK (
+ (encrypted_envelope IS NULL AND envelope_version IS NULL)
+ OR (
+ encrypted_envelope IS NOT NULL
+ AND envelope_version IS NOT NULL
+ AND length(encrypted_envelope) = protected_size_bytes
+ )
+ ),
+ CHECK (
+ (stage <> 'tombstoned' AND deleted_at_unix_ms IS NULL
+ AND deletion_reason IS NULL AND tombstone_commitment IS NULL)
+ OR (stage = 'tombstoned' AND deleted_at_unix_ms = updated_at_unix_ms
+ AND deletion_reason IS NOT NULL AND tombstone_commitment = commitment
+ AND encrypted_envelope IS NULL AND envelope_version IS NULL)
+ )
+) STRICT, WITHOUT ROWID;
+
+CREATE INDEX radroots_private_artifacts_expiry_idx
+ON radroots_private_artifacts(stage, expires_at_unix_ms, artifact_id)
+WHERE stage = 'active' AND expires_at_unix_ms IS NOT NULL;
+
+CREATE INDEX radroots_private_artifacts_kind_idx
+ON radroots_private_artifacts(artifact_kind, schema_id, stage, artifact_id);
+
+CREATE INDEX radroots_private_artifacts_key_version_idx
+ON radroots_private_artifacts(secret_provider, key_version, stage, artifact_id);
+
+CREATE TRIGGER radroots_private_artifacts_delete_guard
+BEFORE DELETE ON radroots_private_artifacts
+BEGIN
+ SELECT RAISE(ABORT, 'private artifacts require durable tombstones');
+END;
+
+CREATE TRIGGER radroots_private_artifacts_identity_guard
+BEFORE UPDATE OF
+ artifact_id,
+ artifact_kind,
+ schema_id,
+ commitment,
+ protected_size_bytes,
+ secret_provider,
+ secret_reference,
+ key_version,
+ created_at_unix_ms
+ON radroots_private_artifacts
+BEGIN
+ SELECT RAISE(ABORT, 'private artifact identity is immutable');
+END;
+
+CREATE TRIGGER radroots_private_artifacts_envelope_guard
+BEFORE UPDATE OF encrypted_envelope, envelope_version ON radroots_private_artifacts
+WHEN OLD.encrypted_envelope IS NOT NULL AND NEW.encrypted_envelope IS NOT NULL
+BEGIN
+ SELECT RAISE(ABORT, 'private artifact envelopes are immutable');
+END;
diff --git a/crates/storage_sqlite/src/migration/private/mod.rs b/crates/storage_sqlite/src/migration/private/mod.rs
@@ -0,0 +1,177 @@
+//! Versioned schema authority for `private.sqlite`.
+//!
+//! The public descriptor surface exposes version and integrity metadata only.
+//! Embedded SQL remains an implementation detail of this backend.
+
+/// Lowest private schema version this package can recognize.
+pub const MINIMUM_VERSION: u32 = 1;
+/// Current private schema version created by this package.
+pub const CURRENT_VERSION: u32 = 1;
+
+#[allow(dead_code)] // Consumed by the migration executor introduced in its ordered RCL step.
+const PRIVATE_V1_SQL: &str = include_str!("0001_private.up.sql");
+
+/// Stable, non-SQL description of one forward private migration.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct MigrationDescriptor {
+ version: u32,
+ name: &'static str,
+ up_sha256: &'static str,
+ owned_objects: &'static [&'static str],
+}
+
+impl MigrationDescriptor {
+ pub const fn version(self) -> u32 {
+ self.version
+ }
+
+ pub const fn name(self) -> &'static str {
+ self.name
+ }
+
+ pub const fn up_sha256(self) -> &'static str {
+ self.up_sha256
+ }
+
+ pub const fn owned_objects(self) -> &'static [&'static str] {
+ self.owned_objects
+ }
+}
+
+const PRIVATE_V1_OBJECTS: &[&str] = &[
+ "radroots_private_artifacts",
+ "radroots_private_artifacts_delete_guard",
+ "radroots_private_artifacts_envelope_guard",
+ "radroots_private_artifacts_expiry_idx",
+ "radroots_private_artifacts_identity_guard",
+ "radroots_private_artifacts_key_version_idx",
+ "radroots_private_artifacts_kind_idx",
+];
+
+/// Ordered, immutable private migration plan.
+pub const MIGRATIONS: &[MigrationDescriptor] = &[MigrationDescriptor {
+ version: 1,
+ name: "private_artifacts",
+ up_sha256: "07050386292ff8ce9ec0e756c9ac88e458a249d53e8654e1102caa2e361f11ab",
+ owned_objects: PRIVATE_V1_OBJECTS,
+}];
+
+#[allow(dead_code)] // Keeps raw SQL crate-private until the migration executor is installed.
+pub(crate) const fn migration_sql(version: u32) -> Option<&'static str> {
+ match version {
+ 1 => Some(PRIVATE_V1_SQL),
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{CURRENT_VERSION, MIGRATIONS, MINIMUM_VERSION, migration_sql};
+ use serde::Deserialize;
+ use sha2::{Digest, Sha256};
+ use sqlx::{Connection, Row, SqliteConnection};
+
+ const PLAN_SNAPSHOT: &str =
+ include_str!("../../../../../contracts/storage/private_schema_v1.toml");
+
+ #[derive(Debug, Deserialize)]
+ struct PlanSnapshot {
+ schema_version: u32,
+ database: String,
+ minimum_version: u32,
+ current_version: u32,
+ migration_name: String,
+ migration_sha256: String,
+ forward_only: bool,
+ raw_sql_public: bool,
+ encrypted_envelopes: bool,
+ authorities: Vec<String>,
+ forbidden_tables: Vec<String>,
+ migrations: Vec<MigrationSnapshot>,
+ }
+
+ #[derive(Debug, Deserialize)]
+ struct MigrationSnapshot {
+ version: u32,
+ name: String,
+ sha256: String,
+ owned_objects: Vec<String>,
+ }
+
+ #[test]
+ fn migration_plan_matches_governed_snapshot() {
+ let snapshot = toml::from_str::<PlanSnapshot>(PLAN_SNAPSHOT).expect("valid snapshot");
+ let migration = MIGRATIONS[0];
+ assert_eq!(snapshot.schema_version, 1);
+ assert_eq!(snapshot.database, "private.sqlite");
+ assert_eq!(snapshot.minimum_version, MINIMUM_VERSION);
+ assert_eq!(snapshot.current_version, CURRENT_VERSION);
+ assert_eq!(snapshot.migration_name, migration.name());
+ assert_eq!(snapshot.migration_sha256, migration.up_sha256());
+ assert!(snapshot.forward_only);
+ assert!(!snapshot.raw_sql_public);
+ assert!(snapshot.encrypted_envelopes);
+ assert_eq!(snapshot.authorities.len(), 4);
+ assert_eq!(snapshot.forbidden_tables, ["studio", "ui_state"]);
+ assert_eq!(snapshot.migrations.len(), MIGRATIONS.len());
+ for (expected, actual) in snapshot.migrations.iter().zip(MIGRATIONS) {
+ assert_eq!(expected.version, actual.version());
+ assert_eq!(expected.name, actual.name());
+ assert_eq!(expected.sha256, actual.up_sha256());
+ assert_eq!(expected.owned_objects, actual.owned_objects());
+ }
+ }
+
+ #[test]
+ fn embedded_migration_checksum_is_pinned() {
+ let migration = MIGRATIONS[0];
+ let sql = migration_sql(migration.version()).expect("registered SQL");
+ assert_eq!(format!("{:x}", Sha256::digest(sql)), migration.up_sha256());
+ assert_eq!(migration_sql(2), None);
+ }
+
+ #[tokio::test]
+ async fn fresh_database_has_exact_private_schema_and_no_studio_authority() {
+ let mut connection = SqliteConnection::connect("sqlite::memory:")
+ .await
+ .expect("open memory SQLite");
+ for migration in MIGRATIONS {
+ sqlx::raw_sql(migration_sql(migration.version()).expect("registered SQL"))
+ .execute(&mut connection)
+ .await
+ .expect("apply private schema");
+ }
+ let rows = sqlx::query(
+ "SELECT name FROM sqlite_schema
+ WHERE name LIKE 'radroots_private_%'
+ ORDER BY name",
+ )
+ .fetch_all(&mut connection)
+ .await
+ .expect("inspect private schema");
+ let actual = rows
+ .iter()
+ .map(|row| row.get::<String, _>("name"))
+ .collect::<Vec<_>>();
+ assert_eq!(actual, MIGRATIONS[0].owned_objects());
+ let forbidden = sqlx::query_scalar::<_, i64>(
+ "SELECT COUNT(*) FROM sqlite_schema
+ WHERE lower(name) LIKE '%studio%' OR lower(name) LIKE '%ui_state%'",
+ )
+ .fetch_one(&mut connection)
+ .await
+ .expect("inspect forbidden tables");
+ assert_eq!(forbidden, 0);
+ assert!(
+ sqlx::query("DELETE FROM radroots_private_artifacts")
+ .execute(&mut connection)
+ .await
+ .is_ok()
+ );
+ let integrity = sqlx::query_scalar::<_, String>("PRAGMA integrity_check")
+ .fetch_one(&mut connection)
+ .await
+ .expect("inspect integrity");
+ assert_eq!(integrity, "ok");
+ }
+}
diff --git a/crates/storage_sqlite/src/private_artifact/mod.rs b/crates/storage_sqlite/src/private_artifact/mod.rs
@@ -0,0 +1,908 @@
+use crate::SqliteStorage;
+use radroots_secrets::EncryptedEnvelope;
+use radroots_storage::{
+ Error,
+ event::BoxFuture,
+ private_artifact::{
+ ArtifactCommitment, ArtifactKind, ArtifactSchemaId, DeletionReason, DurableSecretReference,
+ EXPIRED_ARTIFACT_QUERY_LIMIT_MAX, PrivateArtifactId, PrivateArtifactMetadata,
+ PrivateArtifactRevision, PrivateArtifactStage, PrivateArtifactStatus, PrivateArtifactStore,
+ RetentionPolicy,
+ },
+};
+use sha2::{Digest, Sha256};
+use sqlx::{Row, Sqlite};
+
+impl PrivateArtifactStore for SqliteStorage {
+ fn put_metadata(
+ &self,
+ metadata: PrivateArtifactMetadata,
+ ) -> BoxFuture<'_, Result<PrivateArtifactMetadata, Error>> {
+ Box::pin(async move {
+ self.require_private_writer()?;
+ let mut transaction = self
+ .private_pool()
+ .begin_with("BEGIN IMMEDIATE")
+ .await
+ .map_err(map_backend)?;
+ let stored = put_metadata_transaction(&mut transaction, metadata, None).await?;
+ transaction.commit().await.map_err(map_backend)?;
+ Ok(stored)
+ })
+ }
+
+ fn metadata(
+ &self,
+ artifact_id: PrivateArtifactId,
+ ) -> BoxFuture<'_, Result<Option<PrivateArtifactMetadata>, Error>> {
+ Box::pin(async move {
+ sqlx::query("SELECT * FROM radroots_private_artifacts WHERE artifact_id = ?")
+ .bind(artifact_id.as_bytes().as_slice())
+ .fetch_optional(self.private_pool())
+ .await
+ .map_err(map_backend)?
+ .as_ref()
+ .map(decode_metadata)
+ .transpose()
+ })
+ }
+
+ fn mark_expired(
+ &self,
+ artifact_id: PrivateArtifactId,
+ expected_revision: PrivateArtifactRevision,
+ at_unix_ms: u64,
+ ) -> BoxFuture<'_, Result<PrivateArtifactMetadata, Error>> {
+ Box::pin(async move {
+ self.require_private_writer()?;
+ let mut transaction = self
+ .private_pool()
+ .begin_with("BEGIN IMMEDIATE")
+ .await
+ .map_err(map_backend)?;
+ let current = load_metadata(&mut transaction, artifact_id)
+ .await?
+ .ok_or(Error::PrivateArtifactNotFound)?;
+ let next = current.mark_expired(expected_revision, at_unix_ms)?;
+ update_metadata(&mut transaction, &next, current.revision(), false).await?;
+ transaction.commit().await.map_err(map_backend)?;
+ Ok(next)
+ })
+ }
+
+ fn tombstone(
+ &self,
+ artifact_id: PrivateArtifactId,
+ expected_revision: PrivateArtifactRevision,
+ at_unix_ms: u64,
+ reason: DeletionReason,
+ ) -> BoxFuture<'_, Result<PrivateArtifactMetadata, Error>> {
+ Box::pin(async move {
+ self.require_private_writer()?;
+ let mut transaction = self
+ .private_pool()
+ .begin_with("BEGIN IMMEDIATE")
+ .await
+ .map_err(map_backend)?;
+ let current = load_metadata(&mut transaction, artifact_id)
+ .await?
+ .ok_or(Error::PrivateArtifactNotFound)?;
+ let next = current.tombstone(expected_revision, at_unix_ms, reason)?;
+ update_metadata(&mut transaction, &next, current.revision(), true).await?;
+ transaction.commit().await.map_err(map_backend)?;
+ Ok(next)
+ })
+ }
+
+ fn expired(
+ &self,
+ at_unix_ms: u64,
+ limit: u16,
+ ) -> BoxFuture<'_, Result<Vec<PrivateArtifactMetadata>, Error>> {
+ Box::pin(async move {
+ if at_unix_ms == 0 || limit == 0 || limit > EXPIRED_ARTIFACT_QUERY_LIMIT_MAX {
+ return Err(Error::InvalidExpiredArtifactQueryLimit);
+ }
+ sqlx::query(
+ "SELECT * FROM radroots_private_artifacts
+ WHERE stage = 'active' AND expires_at_unix_ms <= ?
+ ORDER BY expires_at_unix_ms, artifact_id LIMIT ?",
+ )
+ .bind(i64_from_u64(at_unix_ms)?)
+ .bind(i64::from(limit))
+ .fetch_all(self.private_pool())
+ .await
+ .map_err(map_backend)?
+ .iter()
+ .map(decode_metadata)
+ .collect()
+ })
+ }
+
+ fn status(&self) -> BoxFuture<'_, Result<PrivateArtifactStatus, Error>> {
+ Box::pin(async move {
+ let row = sqlx::query(
+ "SELECT
+ COALESCE(SUM(CASE WHEN stage = 'active' THEN 1 ELSE 0 END), 0) AS active,
+ COALESCE(SUM(CASE WHEN stage = 'expired' THEN 1 ELSE 0 END), 0) AS expired,
+ COALESCE(SUM(CASE WHEN stage = 'tombstoned' THEN 1 ELSE 0 END), 0) AS tombstoned
+ FROM radroots_private_artifacts",
+ )
+ .fetch_one(self.private_pool())
+ .await
+ .map_err(map_backend)?;
+ Ok(PrivateArtifactStatus {
+ active: count(&row, "active")?,
+ expired: count(&row, "expired")?,
+ tombstoned: count(&row, "tombstoned")?,
+ })
+ })
+ }
+}
+
+impl SqliteStorage {
+ /// Atomically stores validated metadata with its authenticated encrypted envelope.
+ pub async fn put_encrypted_private_artifact(
+ &self,
+ metadata: PrivateArtifactMetadata,
+ envelope: &EncryptedEnvelope,
+ ) -> Result<PrivateArtifactMetadata, Error> {
+ self.require_private_writer()?;
+ let encoded = validate_envelope(&metadata, envelope)?;
+ let mut transaction = self
+ .private_pool()
+ .begin_with("BEGIN IMMEDIATE")
+ .await
+ .map_err(map_backend)?;
+ let stored = put_metadata_transaction(
+ &mut transaction,
+ metadata,
+ Some((envelope.version(), encoded.as_slice())),
+ )
+ .await?;
+ transaction.commit().await.map_err(map_backend)?;
+ Ok(stored)
+ }
+
+ /// Loads and revalidates an encrypted envelope without opening its plaintext.
+ pub async fn encrypted_private_artifact(
+ &self,
+ artifact_id: PrivateArtifactId,
+ ) -> Result<Option<EncryptedEnvelope>, Error> {
+ let Some(row) =
+ sqlx::query("SELECT * FROM radroots_private_artifacts WHERE artifact_id = ?")
+ .bind(artifact_id.as_bytes().as_slice())
+ .fetch_optional(self.private_pool())
+ .await
+ .map_err(map_backend)?
+ else {
+ return Ok(None);
+ };
+ let metadata = decode_metadata(&row)?;
+ let encoded = row
+ .try_get::<Option<Vec<u8>>, _>("encrypted_envelope")
+ .map_err(map_corrupt)?;
+ let version = row
+ .try_get::<Option<i64>, _>("envelope_version")
+ .map_err(map_corrupt)?;
+ match (encoded, version) {
+ (None, None) => Ok(None),
+ (Some(encoded), Some(version)) => {
+ let envelope = EncryptedEnvelope::decode(encoded.as_slice())
+ .map_err(|_| Error::CorruptPrivateArtifactMetadata)?;
+ if u64_from_i64(version)? != u64::from(envelope.version()) {
+ return Err(Error::CorruptPrivateArtifactMetadata);
+ }
+ validate_envelope(&metadata, &envelope)
+ .map_err(|_| Error::CorruptPrivateArtifactMetadata)?;
+ Ok(Some(envelope))
+ }
+ _ => Err(Error::CorruptPrivateArtifactMetadata),
+ }
+ }
+
+ fn require_private_writer(&self) -> Result<(), Error> {
+ if self.event_mode() == radroots_storage::status::EventStoreMode::ReadOnly {
+ return Err(Error::BackendUnavailable);
+ }
+ Ok(())
+ }
+}
+
+async fn put_metadata_transaction(
+ transaction: &mut sqlx::Transaction<'_, Sqlite>,
+ metadata: PrivateArtifactMetadata,
+ envelope: Option<(u16, &[u8])>,
+) -> Result<PrivateArtifactMetadata, Error> {
+ if metadata.stage() != PrivateArtifactStage::Active
+ || metadata.revision() != PrivateArtifactRevision::INITIAL
+ {
+ return Err(Error::InvalidPrivateArtifactMetadata);
+ }
+ if let Some(row) = sqlx::query("SELECT * FROM radroots_private_artifacts WHERE artifact_id = ?")
+ .bind(metadata.artifact_id().as_bytes().as_slice())
+ .fetch_optional(&mut **transaction)
+ .await
+ .map_err(map_backend)?
+ {
+ let existing = decode_metadata(&row)?;
+ if existing != metadata {
+ return Err(Error::PrivateArtifactConflict);
+ }
+ let stored_envelope = row
+ .try_get::<Option<Vec<u8>>, _>("encrypted_envelope")
+ .map_err(map_corrupt)?;
+ return match (stored_envelope, envelope) {
+ (None, Some((version, encoded))) => {
+ let result = sqlx::query(
+ "UPDATE radroots_private_artifacts
+ SET envelope_version = ?, encrypted_envelope = ?
+ WHERE artifact_id = ? AND encrypted_envelope IS NULL",
+ )
+ .bind(i64::from(version))
+ .bind(encoded)
+ .bind(metadata.artifact_id().as_bytes().as_slice())
+ .execute(&mut **transaction)
+ .await
+ .map_err(map_backend)?;
+ if result.rows_affected() != 1 {
+ return Err(Error::PrivateArtifactConflict);
+ }
+ Ok(metadata)
+ }
+ (Some(stored), Some((_, encoded))) if stored.as_slice() == encoded => Ok(metadata),
+ (Some(_), Some(_)) => Err(Error::PrivateArtifactConflict),
+ (_, None) => Ok(metadata),
+ };
+ }
+ insert_metadata(transaction, &metadata, envelope).await?;
+ Ok(metadata)
+}
+
+async fn insert_metadata(
+ transaction: &mut sqlx::Transaction<'_, Sqlite>,
+ metadata: &PrivateArtifactMetadata,
+ envelope: Option<(u16, &[u8])>,
+) -> Result<(), Error> {
+ let tombstone = metadata.tombstone_record();
+ sqlx::query(
+ "INSERT INTO radroots_private_artifacts (
+ artifact_id, artifact_kind, schema_id, commitment, protected_size_bytes,
+ secret_provider, secret_reference, key_version, envelope_version,
+ encrypted_envelope, delete_not_before_unix_ms, expires_at_unix_ms,
+ revision, stage, created_at_unix_ms, updated_at_unix_ms,
+ deleted_at_unix_ms, deletion_reason, tombstone_commitment
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ )
+ .bind(metadata.artifact_id().as_bytes().as_slice())
+ .bind(metadata.kind().as_str())
+ .bind(metadata.schema_id().as_str())
+ .bind(metadata.commitment().as_bytes().as_slice())
+ .bind(i64_from_u64(metadata.protected_size_bytes())?)
+ .bind(metadata.secret_reference().provider())
+ .bind(metadata.secret_reference().opaque_reference())
+ .bind(i64::from(metadata.secret_reference().key_version()))
+ .bind(envelope.map(|(version, _)| i64::from(version)))
+ .bind(envelope.map(|(_, encoded)| encoded))
+ .bind(
+ metadata
+ .retention()
+ .delete_not_before_unix_ms()
+ .map(i64_from_u64)
+ .transpose()?,
+ )
+ .bind(
+ metadata
+ .retention()
+ .expires_at_unix_ms()
+ .map(i64_from_u64)
+ .transpose()?,
+ )
+ .bind(i64_from_u64(metadata.revision().get())?)
+ .bind(stage_name(metadata.stage()))
+ .bind(i64_from_u64(metadata.created_at_unix_ms())?)
+ .bind(i64_from_u64(metadata.updated_at_unix_ms())?)
+ .bind(
+ tombstone
+ .map(|value| i64_from_u64(value.deleted_at_unix_ms()))
+ .transpose()?,
+ )
+ .bind(tombstone.map(|value| deletion_name(value.reason())))
+ .bind(tombstone.map(|value| value.commitment().as_bytes().to_vec()))
+ .execute(&mut **transaction)
+ .await
+ .map_err(map_backend)?;
+ Ok(())
+}
+
+async fn load_metadata(
+ transaction: &mut sqlx::Transaction<'_, Sqlite>,
+ artifact_id: PrivateArtifactId,
+) -> Result<Option<PrivateArtifactMetadata>, Error> {
+ sqlx::query("SELECT * FROM radroots_private_artifacts WHERE artifact_id = ?")
+ .bind(artifact_id.as_bytes().as_slice())
+ .fetch_optional(&mut **transaction)
+ .await
+ .map_err(map_backend)?
+ .as_ref()
+ .map(decode_metadata)
+ .transpose()
+}
+
+async fn update_metadata(
+ transaction: &mut sqlx::Transaction<'_, Sqlite>,
+ metadata: &PrivateArtifactMetadata,
+ prior_revision: PrivateArtifactRevision,
+ remove_envelope: bool,
+) -> Result<(), Error> {
+ let tombstone = metadata.tombstone_record();
+ let mut query = if remove_envelope {
+ sqlx::query(
+ "UPDATE radroots_private_artifacts SET
+ revision = ?, stage = ?, updated_at_unix_ms = ?,
+ deleted_at_unix_ms = ?, deletion_reason = ?, tombstone_commitment = ?,
+ envelope_version = NULL, encrypted_envelope = NULL
+ WHERE artifact_id = ? AND revision = ?",
+ )
+ } else {
+ sqlx::query(
+ "UPDATE radroots_private_artifacts SET
+ revision = ?, stage = ?, updated_at_unix_ms = ?,
+ deleted_at_unix_ms = ?, deletion_reason = ?, tombstone_commitment = ?
+ WHERE artifact_id = ? AND revision = ?",
+ )
+ };
+ query = query
+ .bind(i64_from_u64(metadata.revision().get())?)
+ .bind(stage_name(metadata.stage()))
+ .bind(i64_from_u64(metadata.updated_at_unix_ms())?)
+ .bind(
+ tombstone
+ .map(|value| i64_from_u64(value.deleted_at_unix_ms()))
+ .transpose()?,
+ )
+ .bind(tombstone.map(|value| deletion_name(value.reason())))
+ .bind(tombstone.map(|value| value.commitment().as_bytes().to_vec()))
+ .bind(metadata.artifact_id().as_bytes().as_slice())
+ .bind(i64_from_u64(prior_revision.get())?);
+ let result = query
+ .execute(&mut **transaction)
+ .await
+ .map_err(map_backend)?;
+ if result.rows_affected() != 1 {
+ return Err(Error::PrivateArtifactRevisionConflict);
+ }
+ Ok(())
+}
+
+fn decode_metadata(row: &sqlx::sqlite::SqliteRow) -> Result<PrivateArtifactMetadata, Error> {
+ let artifact_id = PrivateArtifactId::new(array(
+ row.try_get::<Vec<u8>, _>("artifact_id")
+ .map_err(map_corrupt)?,
+ )?)
+ .map_err(|_| Error::CorruptPrivateArtifactMetadata)?;
+ let kind = ArtifactKind::parse(
+ row.try_get::<String, _>("artifact_kind")
+ .map_err(map_corrupt)?,
+ )
+ .map_err(|_| Error::CorruptPrivateArtifactMetadata)?;
+ let schema_id =
+ ArtifactSchemaId::parse(row.try_get::<String, _>("schema_id").map_err(map_corrupt)?)
+ .map_err(|_| Error::CorruptPrivateArtifactMetadata)?;
+ let commitment = ArtifactCommitment::new(array(
+ row.try_get::<Vec<u8>, _>("commitment")
+ .map_err(map_corrupt)?,
+ )?);
+ let secret_reference = DurableSecretReference::new(
+ row.try_get::<String, _>("secret_provider")
+ .map_err(map_corrupt)?,
+ row.try_get::<String, _>("secret_reference")
+ .map_err(map_corrupt)?,
+ u32::try_from(row.try_get::<i64, _>("key_version").map_err(map_corrupt)?)
+ .map_err(|_| Error::CorruptPrivateArtifactMetadata)?,
+ )
+ .map_err(|_| Error::CorruptPrivateArtifactMetadata)?;
+ let retention = RetentionPolicy::new(
+ optional_u64(row, "delete_not_before_unix_ms")?,
+ optional_u64(row, "expires_at_unix_ms")?,
+ )
+ .map_err(|_| Error::CorruptPrivateArtifactMetadata)?;
+ let stage = stage(
+ row.try_get::<String, _>("stage")
+ .map_err(map_corrupt)?
+ .as_str(),
+ )?;
+ let deleted_at = optional_u64(row, "deleted_at_unix_ms")?;
+ let deletion_reason = row
+ .try_get::<Option<String>, _>("deletion_reason")
+ .map_err(map_corrupt)?
+ .map(|value| deletion(value.as_str()))
+ .transpose()?;
+ let tombstone_commitment = row
+ .try_get::<Option<Vec<u8>>, _>("tombstone_commitment")
+ .map_err(map_corrupt)?
+ .map(|value| array(value).map(ArtifactCommitment::new))
+ .transpose()?;
+ let tombstone = match (deleted_at, deletion_reason, tombstone_commitment) {
+ (None, None, None) => None,
+ (Some(at), Some(reason), Some(commitment)) => Some((at, reason, commitment)),
+ _ => return Err(Error::CorruptPrivateArtifactMetadata),
+ };
+ PrivateArtifactMetadata::from_durable_parts(
+ artifact_id,
+ kind,
+ schema_id,
+ commitment,
+ u64_from_i64(row.try_get("protected_size_bytes").map_err(map_corrupt)?)?,
+ secret_reference,
+ retention,
+ PrivateArtifactRevision::new(u64_from_i64(row.try_get("revision").map_err(map_corrupt)?)?)
+ .map_err(|_| Error::CorruptPrivateArtifactMetadata)?,
+ stage,
+ u64_from_i64(row.try_get("created_at_unix_ms").map_err(map_corrupt)?)?,
+ u64_from_i64(row.try_get("updated_at_unix_ms").map_err(map_corrupt)?)?,
+ tombstone,
+ )
+ .map_err(|_| Error::CorruptPrivateArtifactMetadata)
+}
+
+fn validate_envelope(
+ metadata: &PrivateArtifactMetadata,
+ envelope: &EncryptedEnvelope,
+) -> Result<Vec<u8>, Error> {
+ if metadata.stage() != PrivateArtifactStage::Active
+ || metadata.secret_reference().opaque_reference() != envelope.reference().id().as_str()
+ || metadata.secret_reference().key_version() != envelope.reference().key_version().get()
+ {
+ return Err(Error::InvalidPrivateArtifactMetadata);
+ }
+ let encoded = envelope
+ .encode()
+ .map_err(|_| Error::InvalidPrivateArtifactMetadata)?;
+ if metadata.protected_size_bytes()
+ != u64::try_from(encoded.len()).map_err(|_| Error::InvalidPrivateArtifactMetadata)?
+ || metadata.commitment().as_bytes() != Sha256::digest(encoded.as_slice()).as_slice()
+ {
+ return Err(Error::InvalidPrivateArtifactMetadata);
+ }
+ Ok(encoded)
+}
+
+fn optional_u64(row: &sqlx::sqlite::SqliteRow, column: &str) -> Result<Option<u64>, Error> {
+ row.try_get::<Option<i64>, _>(column)
+ .map_err(map_corrupt)?
+ .map(u64_from_i64)
+ .transpose()
+}
+
+const fn stage_name(stage: PrivateArtifactStage) -> &'static str {
+ match stage {
+ PrivateArtifactStage::Active => "active",
+ PrivateArtifactStage::Expired => "expired",
+ PrivateArtifactStage::Tombstoned => "tombstoned",
+ }
+}
+
+fn stage(value: &str) -> Result<PrivateArtifactStage, Error> {
+ match value.as_bytes() {
+ b"active" => Ok(PrivateArtifactStage::Active),
+ b"expired" => Ok(PrivateArtifactStage::Expired),
+ b"tombstoned" => Ok(PrivateArtifactStage::Tombstoned),
+ _ => Err(Error::CorruptPrivateArtifactMetadata),
+ }
+}
+
+const fn deletion_name(reason: DeletionReason) -> &'static str {
+ match reason {
+ DeletionReason::UserRequested => "user_requested",
+ DeletionReason::RetentionExpired => "retention_expired",
+ DeletionReason::KeyRevoked => "key_revoked",
+ DeletionReason::IntegrityFailure => "integrity_failure",
+ DeletionReason::OperatorRequested => "operator_requested",
+ }
+}
+
+fn deletion(value: &str) -> Result<DeletionReason, Error> {
+ match value.as_bytes() {
+ b"user_requested" => Ok(DeletionReason::UserRequested),
+ b"retention_expired" => Ok(DeletionReason::RetentionExpired),
+ b"key_revoked" => Ok(DeletionReason::KeyRevoked),
+ b"integrity_failure" => Ok(DeletionReason::IntegrityFailure),
+ b"operator_requested" => Ok(DeletionReason::OperatorRequested),
+ _ => Err(Error::CorruptPrivateArtifactMetadata),
+ }
+}
+
+fn count(row: &sqlx::sqlite::SqliteRow, column: &str) -> Result<u64, Error> {
+ u64_from_i64(row.try_get::<i64, _>(column).map_err(map_corrupt)?)
+}
+
+fn array<const N: usize>(bytes: Vec<u8>) -> Result<[u8; N], Error> {
+ bytes
+ .try_into()
+ .map_err(|_| Error::CorruptPrivateArtifactMetadata)
+}
+
+fn i64_from_u64(value: u64) -> Result<i64, Error> {
+ i64::try_from(value).map_err(|_| Error::CorruptPrivateArtifactMetadata)
+}
+
+fn u64_from_i64(value: i64) -> Result<u64, Error> {
+ u64::try_from(value).map_err(|_| Error::CorruptPrivateArtifactMetadata)
+}
+
+fn map_backend(_: sqlx::Error) -> Error {
+ Error::BackendUnavailable
+}
+
+fn map_corrupt(_: sqlx::Error) -> Error {
+ Error::CorruptPrivateArtifactMetadata
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::migration::{
+ private::{MIGRATIONS as PRIVATE_MIGRATIONS, migration_sql as private_migration_sql},
+ runtime::{MIGRATIONS as RUNTIME_MIGRATIONS, migration_sql as runtime_migration_sql},
+ };
+ use radroots_secrets::{
+ Error as SecretError, KeyWrapping, SecretId, SecretRef,
+ envelope::{Nonce, SealMaterial, SealRequest},
+ error::Operation,
+ id::{BackendKind, KeyVersion},
+ wrapping::{
+ BoxFuture as SecretFuture, SecretMaterial, UnwrapRequest, WrapRequest, WrappedSecret,
+ },
+ };
+ use radroots_storage::status::EventStoreMode;
+ use sqlx::sqlite::SqlitePoolOptions;
+
+ struct VectorWrapping;
+
+ impl KeyWrapping for VectorWrapping {
+ fn wrap<'a>(
+ &'a self,
+ request: WrapRequest<'a>,
+ ) -> SecretFuture<'a, Result<WrappedSecret, SecretError>> {
+ Box::pin(async move {
+ if request.reference().id().as_str() != "private-artifact-key" {
+ return Err(SecretError::BackendFailure {
+ backend: BackendKind::Memory,
+ operation: Operation::Wrap,
+ });
+ }
+ WrappedSecret::from_bytes(request.plaintext().expose_secret(|bytes| {
+ bytes.iter().map(|byte| byte ^ 0xA5).collect::<Vec<_>>()
+ }))
+ })
+ }
+
+ fn unwrap<'a>(
+ &'a self,
+ request: UnwrapRequest<'a>,
+ ) -> SecretFuture<'a, Result<SecretMaterial, SecretError>> {
+ Box::pin(async move {
+ if request.reference().id().as_str() != "private-artifact-key" {
+ return Err(SecretError::BackendFailure {
+ backend: BackendKind::Memory,
+ operation: Operation::Unwrap,
+ });
+ }
+ let plaintext = request
+ .wrapped()
+ .as_bytes()
+ .iter()
+ .map(|byte| byte ^ 0xA5)
+ .collect::<Vec<_>>();
+ SecretMaterial::from_slice(plaintext.as_slice())
+ })
+ }
+ }
+
+ async fn store(mode: EventStoreMode) -> SqliteStorage {
+ let runtime_pool = SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect("sqlite::memory:")
+ .await
+ .expect("runtime SQLite");
+ for migration in RUNTIME_MIGRATIONS {
+ sqlx::raw_sql(runtime_migration_sql(migration.version()).expect("runtime SQL"))
+ .execute(&runtime_pool)
+ .await
+ .expect("runtime migration");
+ }
+ let private_pool = SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect("sqlite::memory:")
+ .await
+ .expect("private SQLite");
+ for migration in PRIVATE_MIGRATIONS {
+ sqlx::raw_sql(private_migration_sql(migration.version()).expect("private SQL"))
+ .execute(&private_pool)
+ .await
+ .expect("private migration");
+ }
+ SqliteStorage::with_private_pool(
+ runtime_pool,
+ private_pool,
+ radroots_storage::event::SourceGeneration::new([91; 32]).expect("generation"),
+ mode,
+ )
+ }
+
+ fn reference(version: u32) -> SecretRef {
+ SecretRef::new(
+ SecretId::parse("private-artifact-key").expect("secret id"),
+ BackendKind::Memory,
+ KeyVersion::new(version).expect("key version"),
+ )
+ }
+
+ async fn sealed_envelope(plaintext: &[u8], version: u32) -> EncryptedEnvelope {
+ let plaintext = SecretMaterial::from_slice(plaintext).expect("plaintext");
+ let data_key = SecretMaterial::from_slice(&[0x31; 32]).expect("data key");
+ EncryptedEnvelope::seal(
+ &VectorWrapping,
+ SealRequest::new(
+ reference(version),
+ &plaintext,
+ SealMaterial::new(data_key, Nonce::new([0x42; 24])),
+ ),
+ )
+ .await
+ .expect("seal envelope")
+ }
+
+ fn metadata(
+ id: u8,
+ kind: &str,
+ envelope: &EncryptedEnvelope,
+ retention: RetentionPolicy,
+ ) -> PrivateArtifactMetadata {
+ let encoded = envelope.encode().expect("encoded envelope");
+ PrivateArtifactMetadata::new(
+ PrivateArtifactId::new([id; 16]).expect("artifact id"),
+ ArtifactKind::parse(kind).expect("artifact kind"),
+ ArtifactSchemaId::parse(format!("{kind}.v1")).expect("schema id"),
+ ArtifactCommitment::new(Sha256::digest(encoded.as_slice()).into()),
+ u64::try_from(encoded.len()).expect("encoded length"),
+ DurableSecretReference::new(
+ "memory",
+ envelope.reference().id().as_str(),
+ envelope.reference().key_version().get(),
+ )
+ .expect("secret reference"),
+ retention,
+ 100,
+ )
+ .expect("metadata")
+ }
+
+ #[tokio::test]
+ async fn encrypted_envelopes_round_trip_with_exact_commitment_and_key_version() {
+ let store = store(EventStoreMode::ReadWrite).await;
+ let envelope = sealed_envelope(b"private farm coordinates", 7).await;
+ let metadata = metadata(1, "farm_location", &envelope, RetentionPolicy::indefinite());
+ let stored = store
+ .put_encrypted_private_artifact(metadata.clone(), &envelope)
+ .await
+ .expect("store encrypted artifact");
+ assert_eq!(stored, metadata);
+ store
+ .put_encrypted_private_artifact(metadata.clone(), &envelope)
+ .await
+ .expect("exact replay");
+ let loaded = store
+ .encrypted_private_artifact(metadata.artifact_id())
+ .await
+ .expect("load envelope")
+ .expect("encrypted envelope");
+ assert_eq!(
+ loaded.encode().expect("loaded bytes"),
+ envelope.encode().expect("expected bytes")
+ );
+ let opened = loaded.open(&VectorWrapping).await.expect("open envelope");
+ opened.expose_secret(|bytes| assert_eq!(bytes, b"private farm coordinates"));
+
+ let row = sqlx::query(
+ "SELECT key_version, envelope_version, encrypted_envelope
+ FROM radroots_private_artifacts WHERE artifact_id = ?",
+ )
+ .bind(metadata.artifact_id().as_bytes().as_slice())
+ .fetch_one(store.private_pool())
+ .await
+ .expect("private row");
+ assert_eq!(row.get::<i64, _>("key_version"), 7);
+ assert_eq!(row.get::<i64, _>("envelope_version"), 1);
+ let encrypted = row.get::<Vec<u8>, _>("encrypted_envelope");
+ assert!(
+ !encrypted
+ .windows(24)
+ .any(|bytes| bytes == b"private farm coordinates")
+ );
+
+ let wrong_key_envelope = sealed_envelope(b"private farm coordinates", 8).await;
+ assert_eq!(
+ store
+ .put_encrypted_private_artifact(metadata, &wrong_key_envelope)
+ .await,
+ Err(Error::InvalidPrivateArtifactMetadata)
+ );
+ }
+
+ #[tokio::test]
+ async fn expiry_and_tombstone_delete_envelope_but_preserve_commitment() {
+ let store = store(EventStoreMode::ReadWrite).await;
+ let envelope = sealed_envelope(b"private trade artifact", 3).await;
+ let metadata = metadata(
+ 2,
+ "trade_artifact",
+ &envelope,
+ RetentionPolicy::new(Some(400), Some(300)).expect("retention"),
+ );
+ store
+ .put_encrypted_private_artifact(metadata.clone(), &envelope)
+ .await
+ .expect("store artifact");
+ assert!(
+ store
+ .expired(299, 10)
+ .await
+ .expect("not expired")
+ .is_empty()
+ );
+ assert_eq!(
+ store.expired(300, 10).await.expect("expired query"),
+ vec![metadata.clone()]
+ );
+ let expired = store
+ .mark_expired(metadata.artifact_id(), metadata.revision(), 300)
+ .await
+ .expect("mark expired");
+ assert_eq!(expired.stage(), PrivateArtifactStage::Expired);
+ assert_eq!(
+ store
+ .tombstone(
+ expired.artifact_id(),
+ expired.revision(),
+ 399,
+ DeletionReason::RetentionExpired,
+ )
+ .await,
+ Err(Error::PrivateArtifactRetentionActive)
+ );
+ let tombstoned = store
+ .tombstone(
+ expired.artifact_id(),
+ expired.revision(),
+ 400,
+ DeletionReason::RetentionExpired,
+ )
+ .await
+ .expect("tombstone");
+ assert_eq!(tombstoned.stage(), PrivateArtifactStage::Tombstoned);
+ assert_eq!(tombstoned.commitment(), metadata.commitment());
+ assert!(
+ store
+ .encrypted_private_artifact(metadata.artifact_id())
+ .await
+ .expect("deleted envelope")
+ .is_none()
+ );
+ assert_eq!(
+ store.status().await.expect("status"),
+ PrivateArtifactStatus {
+ active: 0,
+ expired: 0,
+ tombstoned: 1,
+ }
+ );
+ assert!(
+ sqlx::query("DELETE FROM radroots_private_artifacts WHERE artifact_id = ?")
+ .bind(metadata.artifact_id().as_bytes().as_slice())
+ .execute(store.private_pool())
+ .await
+ .is_err()
+ );
+ }
+
+ #[tokio::test]
+ async fn all_private_authorities_are_metadata_only_without_an_envelope() {
+ let store = store(EventStoreMode::ReadWrite).await;
+ for (id, kind) in [
+ (10, "signing_reference"),
+ (11, "farm_location"),
+ (12, "trade_artifact"),
+ (13, "nip46_session"),
+ ] {
+ let envelope = sealed_envelope(kind.as_bytes(), 1).await;
+ let metadata = metadata(id, kind, &envelope, RetentionPolicy::indefinite());
+ store
+ .put_metadata(metadata.clone())
+ .await
+ .expect("put metadata");
+ assert_eq!(
+ store
+ .metadata(metadata.artifact_id())
+ .await
+ .expect("metadata lookup"),
+ Some(metadata.clone())
+ );
+ assert!(
+ store
+ .encrypted_private_artifact(metadata.artifact_id())
+ .await
+ .expect("envelope lookup")
+ .is_none()
+ );
+ store
+ .put_encrypted_private_artifact(metadata.clone(), &envelope)
+ .await
+ .expect("attach envelope");
+ }
+ assert_eq!(store.status().await.expect("status").active, 4);
+ let forbidden = sqlx::query_scalar::<_, i64>(
+ "SELECT COUNT(*) FROM sqlite_schema
+ WHERE lower(name) LIKE '%studio%' OR lower(name) LIKE '%ui_state%'",
+ )
+ .fetch_one(store.private_pool())
+ .await
+ .expect("forbidden schema count");
+ assert_eq!(forbidden, 0);
+ }
+
+ #[tokio::test]
+ async fn conflicts_corruption_and_read_only_mode_fail_closed() {
+ let writable_store = store(EventStoreMode::ReadWrite).await;
+ let envelope = sealed_envelope(b"signing reference", 5).await;
+ let stored_metadata = metadata(
+ 20,
+ "signing_reference",
+ &envelope,
+ RetentionPolicy::indefinite(),
+ );
+ writable_store
+ .put_encrypted_private_artifact(stored_metadata.clone(), &envelope)
+ .await
+ .expect("store artifact");
+ let other_envelope = sealed_envelope(b"another signing reference", 5).await;
+ let conflicting = metadata(
+ 20,
+ "signing_reference",
+ &other_envelope,
+ RetentionPolicy::indefinite(),
+ );
+ assert_eq!(
+ writable_store.put_metadata(conflicting).await,
+ Err(Error::PrivateArtifactConflict)
+ );
+ sqlx::query("PRAGMA ignore_check_constraints = ON")
+ .execute(writable_store.private_pool())
+ .await
+ .expect("disable checks");
+ sqlx::query("UPDATE radroots_private_artifacts SET stage = 'invalid'")
+ .execute(writable_store.private_pool())
+ .await
+ .expect("corrupt stage");
+ assert_eq!(
+ writable_store.metadata(stored_metadata.artifact_id()).await,
+ Err(Error::CorruptPrivateArtifactMetadata)
+ );
+
+ let read_only = store(EventStoreMode::ReadOnly).await;
+ let envelope = sealed_envelope(b"read only", 1).await;
+ let metadata = metadata(
+ 21,
+ "nip46_session",
+ &envelope,
+ RetentionPolicy::indefinite(),
+ );
+ assert_eq!(
+ read_only
+ .put_encrypted_private_artifact(metadata, &envelope)
+ .await,
+ Err(Error::BackendUnavailable)
+ );
+ }
+}
diff --git a/crates/storage_sqlite/tests/package_boundary.rs b/crates/storage_sqlite/tests/package_boundary.rs
@@ -23,6 +23,7 @@ fn sqlite_storage_declares_the_final_backend_boundaries() {
"radroots_event_codec",
"radroots_secrets",
"radroots_storage",
+ "sha2",
"sqlx",
])
);