lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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:
Acontracts/storage/private_schema_v1.toml | 35+++++++++++++++++++++++++++++++++++
Mcrates/storage/src/private_artifact.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/storage_sqlite/Cargo.toml | 2+-
Mcrates/storage_sqlite/src/event/mod.rs | 23++++++++++++++++++++++-
Mcrates/storage_sqlite/src/lib.rs | 1+
Mcrates/storage_sqlite/src/migration.rs | 2++
Acrates/storage_sqlite/src/migration/private/0001_private.up.sql | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/storage_sqlite/src/migration/private/mod.rs | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/storage_sqlite/src/private_artifact/mod.rs | 908+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/storage_sqlite/tests/package_boundary.rs | 1+
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", ]) );