radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit f99415f606ffaab96d324f59f4c31beacaf924c9
parent c9e133e76e58062989113571fdcdf6dcd060bd37
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Jul 2026 07:19:25 +0000

transport: enforce clean publish schema

- replace runtime principal schema repair with strict current-schema validation
- add typed schema errors for malformed transport publish tables
- prove stale principal schemas fail closed without mutation or version bumps
- guard daemon transport sources against reintroduced schema repair

Diffstat:
Msrc/core/transport_publish.rs | 260++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mtests/source_boundary.rs | 28++++++++++++++++++++++++++++
2 files changed, 265 insertions(+), 23 deletions(-)

diff --git a/src/core/transport_publish.rs b/src/core/transport_publish.rs @@ -65,6 +65,8 @@ pub enum TransportPublishError { Relay(#[from] RadrootsRelayTransportError), #[error("transport publish transport error: {0}")] Transport(String), + #[error("transport publish schema incompatible for table `{table}`: {detail}")] + Schema { table: &'static str, detail: String }, #[error("transport publish job state validation failed: {0}")] InvalidPublishJobState(String), #[error("transport publish concurrency limit reached")] @@ -916,7 +918,7 @@ impl TransportPublishStore { ); "#, )?; - ensure_transport_publish_schema(&connection)?; + validate_transport_publish_schema(&connection)?; recover_interrupted_publish_jobs(&connection)?; connection.pragma_update(None, "user_version", SCHEMA_VERSION)?; Ok(Self { @@ -1362,18 +1364,185 @@ struct PublishJobRow { view: TransportPublishJobView, } -fn ensure_transport_publish_schema(connection: &Connection) -> Result<(), TransportPublishError> { - let has_explicit_kinds = connection - .prepare("PRAGMA table_info(transport_publish_principals)")? - .query_map([], |row| row.get::<_, String>(1))? - .collect::<Result<Vec<_>, _>>()? - .into_iter() - .any(|column| column == "allowed_explicit_transport_kinds_json"); - if !has_explicit_kinds { - connection.execute( - "ALTER TABLE transport_publish_principals ADD COLUMN allowed_explicit_transport_kinds_json TEXT NOT NULL DEFAULT '[]'", - [], - )?; +fn validate_transport_publish_schema(connection: &Connection) -> Result<(), TransportPublishError> { + validate_table_columns( + connection, + "transport_publish_principals", + &[ + RequiredColumn::text_not_null("principal_id"), + RequiredColumn::text_not_null("label"), + RequiredColumn::text_not_null("token_hash"), + RequiredColumn::text_not_null("allowed_pubkeys_json"), + RequiredColumn::text_not_null("allowed_kinds_json"), + RequiredColumn::text_not_null("allowed_target_policies_json"), + RequiredColumn::text_not_null("allowed_explicit_transport_kinds_json"), + RequiredColumn::text_not_null("allowed_nostr_source_policies_json"), + RequiredColumn::integer_not_null("allow_request_targets"), + RequiredColumn::text_not_null("job_visibility"), + RequiredColumn::integer_nullable("expires_at_unix"), + RequiredColumn::integer_nullable("revoked_at_unix"), + RequiredColumn::integer_not_null("created_at_unix"), + ], + )?; + validate_table_columns( + connection, + "transport_publish_jobs", + &[ + RequiredColumn::text_not_null("job_id"), + RequiredColumn::text_not_null("principal_id"), + RequiredColumn::text_nullable("idempotency_key"), + RequiredColumn::text_not_null("request_fingerprint"), + RequiredColumn::text_not_null("status"), + RequiredColumn::text_not_null("event_id"), + RequiredColumn::text_not_null("event_pubkey"), + RequiredColumn::integer_not_null("event_kind"), + RequiredColumn::text_not_null("target_policy_json"), + RequiredColumn::text_not_null("delivery_policy_json"), + RequiredColumn::integer_not_null("requested_target_count"), + RequiredColumn::integer_not_null("effective_target_count"), + RequiredColumn::text_not_null("request_json"), + RequiredColumn::integer_not_null("requested_at_ms"), + RequiredColumn::integer_not_null("updated_at_ms"), + RequiredColumn::integer_nullable("completed_at_ms"), + RequiredColumn::text_nullable("last_error"), + ], + )?; + validate_table_columns( + connection, + "transport_publish_target_results", + &[ + RequiredColumn::text_not_null("job_id"), + RequiredColumn::text_not_null("transport_kind"), + RequiredColumn::text_not_null("endpoint_uri"), + RequiredColumn::text_not_null("source"), + RequiredColumn::integer_not_null("attempted"), + RequiredColumn::text_not_null("outcome_kind"), + RequiredColumn::text_nullable("message"), + RequiredColumn::integer_nullable("latency_ms"), + RequiredColumn::integer_not_null("updated_at_ms"), + ], + )?; + validate_table_columns( + connection, + "transport_publish_target_snapshots", + &[ + RequiredColumn::text_not_null("job_id"), + RequiredColumn::integer_not_null("target_index"), + RequiredColumn::text_not_null("transport_kind"), + RequiredColumn::text_not_null("endpoint_uri"), + RequiredColumn::text_not_null("source"), + RequiredColumn::integer_not_null("attempted"), + RequiredColumn::text_not_null("outcome_kind"), + RequiredColumn::text_nullable("message"), + RequiredColumn::integer_nullable("latency_ms"), + RequiredColumn::integer_not_null("created_at_ms"), + ], + )?; + validate_table_columns( + connection, + "transport_publish_nostr_author_cache", + &[ + RequiredColumn::text_not_null("pubkey"), + RequiredColumn::text_not_null("relays_json"), + RequiredColumn::integer_not_null("updated_at_ms"), + ], + ) +} + +#[derive(Clone, Copy)] +struct RequiredColumn { + name: &'static str, + column_type: &'static str, + not_null: bool, +} + +impl RequiredColumn { + const fn text_not_null(name: &'static str) -> Self { + Self { + name, + column_type: "TEXT", + not_null: true, + } + } + + const fn text_nullable(name: &'static str) -> Self { + Self { + name, + column_type: "TEXT", + not_null: false, + } + } + + const fn integer_not_null(name: &'static str) -> Self { + Self { + name, + column_type: "INTEGER", + not_null: true, + } + } + + const fn integer_nullable(name: &'static str) -> Self { + Self { + name, + column_type: "INTEGER", + not_null: false, + } + } +} + +fn validate_table_columns( + connection: &Connection, + table: &'static str, + required_columns: &[RequiredColumn], +) -> Result<(), TransportPublishError> { + let columns = connection + .prepare(format!("PRAGMA table_info({table})").as_str())? + .query_map([], |row| { + Ok(( + row.get::<_, String>(1)?, + ( + row.get::<_, String>(2)?.to_ascii_uppercase(), + row.get::<_, i64>(3)? != 0, + ), + )) + })? + .collect::<Result<BTreeMap<_, _>, _>>()?; + if columns.is_empty() { + return Err(TransportPublishError::Schema { + table, + detail: "table is missing".to_owned(), + }); + } + for required in required_columns { + let Some((column_type, not_null)) = columns.get(required.name) else { + return Err(TransportPublishError::Schema { + table, + detail: format!("missing required column `{}`", required.name), + }); + }; + if column_type != required.column_type { + return Err(TransportPublishError::Schema { + table, + detail: format!( + "column `{}` must have type {}, got {}", + required.name, required.column_type, column_type + ), + }); + } + if *not_null != required.not_null { + return Err(TransportPublishError::Schema { + table, + detail: format!( + "column `{}` must be {}", + required.name, + if required.not_null { + "NOT NULL" + } else { + "nullable" + } + ), + }); + } } Ok(()) } @@ -3026,7 +3195,34 @@ mod tests { } #[test] - fn transport_store_open_migrates_principal_schema_with_empty_explicit_kind_allowlist() { + fn transport_store_open_validates_current_principal_schema() { + let directory = tempfile::tempdir().expect("tempdir"); + let database_path = directory.path().join("publish-proxy-current.sqlite"); + let store = TransportPublishStore::open(database_path).expect("open current schema"); + let connection = store + .inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let columns = connection + .prepare("PRAGMA table_info(transport_publish_principals)") + .expect("prepare schema") + .query_map([], |row| row.get::<_, String>(1)) + .expect("query schema") + .collect::<Result<Vec<_>, _>>() + .expect("columns"); + assert!( + columns + .iter() + .any(|column| column == "allowed_explicit_transport_kinds_json") + ); + let version = connection + .pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) + .expect("user version"); + assert_eq!(version, SCHEMA_VERSION); + } + + #[test] + fn transport_store_open_rejects_legacy_principal_schema_without_explicit_kind_allowlist() { let directory = tempfile::tempdir().expect("tempdir"); let database_path = directory.path().join("publish-proxy-v1.sqlite"); let token_hash = hash_bearer_token(generate_bearer_token().as_str()); @@ -3092,17 +3288,35 @@ mod tests { .expect("principal"); } - let store = TransportPublishStore::open(database_path.clone()).expect("migrate"); - let principal = store - .principal_by_id("principal-v1") - .expect("lookup") - .expect("principal"); - assert!(principal.allowed_explicit_transport_kinds.is_empty()); - let version = rusqlite::Connection::open(database_path.as_path()) - .expect("open migrated") + let error = match TransportPublishStore::open(database_path.clone()) { + Ok(_) => panic!("legacy schema opened"), + Err(error) => error, + }; + match error { + TransportPublishError::Schema { table, detail } => { + assert_eq!(table, "transport_publish_principals"); + assert!(detail.contains("allowed_explicit_transport_kinds_json")); + } + error => panic!("unexpected error: {error}"), + } + + let connection = rusqlite::Connection::open(database_path.as_path()).expect("open legacy"); + let columns = connection + .prepare("PRAGMA table_info(transport_publish_principals)") + .expect("prepare schema") + .query_map([], |row| row.get::<_, String>(1)) + .expect("query schema") + .collect::<Result<Vec<_>, _>>() + .expect("columns"); + assert!( + !columns + .iter() + .any(|column| column == "allowed_explicit_transport_kinds_json") + ); + let version = connection .pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) .expect("user version"); - assert_eq!(version, SCHEMA_VERSION); + assert_eq!(version, 1); } #[tokio::test] diff --git a/tests/source_boundary.rs b/tests/source_boundary.rs @@ -132,6 +132,34 @@ fn transport_publish_sources_require_principal_explicit_kind_scope() { } #[test] +fn transport_publish_sources_reject_runtime_principal_schema_repair() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let daemon_source = read_source(manifest_dir.join("src/core/transport_publish.rs").as_path()); + + for required in [ + "validate_transport_publish_schema", + "TransportPublishError::Schema", + "transport_store_open_validates_current_principal_schema", + "transport_store_open_rejects_legacy_principal_schema_without_explicit_kind_allowlist", + ] { + assert!( + daemon_source.contains(required), + "daemon transport publish source must retain strict schema validation witness `{required}`" + ); + } + + for forbidden in [ + concat!("ensure_transport_publish", "_schema"), + concat!("ALTER TABLE ", "transport_publish_principals"), + ] { + assert!( + !daemon_source.contains(forbidden), + "daemon transport publish source must not contain runtime schema repair `{forbidden}`" + ); + } +} + +#[test] fn transport_publish_store_egress_requires_protocol_validation() { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); let daemon_source = read_source(manifest_dir.join("src/core/transport_publish.rs").as_path());