lib

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

commit 79fb1215883bea615a6b934f7f57508648927fb6
parent 2f306f1b8fb43bdab969745f3d4c75de2e9bc028
Author: triesap <tyson@radroots.org>
Date:   Thu,  6 Aug 2026 18:20:51 +0000

supply-chain: qualify real public package archives

- enforce exact dependency, vetting, and reproducible artifact authorities
- retire deprecated event compatibility paths across verified consumers
- add catalog-driven Miri, sanitizer, fuzz, coverage, and API gates
- qualify all nineteen public package archives at 0.1.0-alpha

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Mcontracts/coverage-profiles.toml | 9+++++++++
Mcontracts/releases/api_semver.toml | 10++++++++++
Acontracts/releases/safety_matrix.toml | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/releases/supply_chain.toml | 15+++++++++++++++
Mcrates/event/tests/package_boundary.rs | 19++++++++++---------
Mcrates/event_codec/src/comment/mod.rs | 1+
Mcrates/event_codec/src/decode.rs | 3---
Mcrates/event_codec/src/deletion/mod.rs | 2++
Mcrates/event_codec/src/deletion/reconciliation_v1.rs | 2++
Mcrates/event_codec/src/food_availability/inbound/registry_v7.rs | 14++++++++++++--
Mcrates/event_codec/src/food_availability/mod.rs | 2++
Dcrates/event_codec/src/post/decode.rs | 353-------------------------------------------------------------------------------
Mcrates/event_codec/src/post/mod.rs | 2+-
Dcrates/event_codec/src/profile/decode.rs | 125-------------------------------------------------------------------------------
Mcrates/event_codec/src/profile/mod.rs | 40----------------------------------------
Mcrates/event_codec/src/reply/mod.rs | 1+
Mcrates/event_codec/tests/coverage_edges.rs | 9---------
Dcrates/event_codec/tests/profile.rs | 219-------------------------------------------------------------------------------
Mcrates/event_codec/tests/social_events.rs | 9+--------
Mcrates/nostr/src/event.rs | 2--
Dcrates/nostr/src/event_adapters.rs | 107-------------------------------------------------------------------------------
Mcrates/nostr/src/lib.rs | 3---
Mcrates/nostr/tests/coverage.rs | 51+--------------------------------------------------
Mcrates/protocol/tests/fixtures/protocol_v1.inventory.json | 2+-
Mcrates/replica_sync/src/ingest.rs | 252++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcrates/replica_sync/tests/ingest_roundtrip.rs | 67+++++++++++++++++++++++++++++++++++++++++++------------------------
Mcrates/sdk/Cargo.toml | 1+
Mcrates/signing/src/authorization.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Mcrates/signing/src/identity.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/signing/src/recovery.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/signing/tests/authored_signing.rs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/storage_sqlite/src/backup.rs | 29++++++++++++++++++-----------
Mcrates/storage_sqlite/src/legacy.rs | 3+++
Mcrates/studio_application/src/accounts.rs | 407++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/studio_application/src/actor.rs | 1+
Mcrates/studio_application/src/app_core.rs | 40++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_application/src/recovery.rs | 430+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_application/src/session.rs | 20++++++++++++++++++++
Mcrates/studio_application/src/snapshot.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_application/src/state_machine.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_ffi/src/commands.rs | 228++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/studio_ffi/src/dto.rs | 273++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/studio_ffi/src/lib.rs | 4+++-
Mcrates/studio_ffi/src/observer.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/studio_nostr/src/client.rs | 42++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_nostr/src/keys.rs | 4+++-
Mcrates/studio_nostr/src/profile.rs | 14++++++++++++++
Mcrates/studio_preferences/src/lib.rs | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_runtime/src/runtime_actor.rs | 236+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/studio_storage/src/account_namespace.rs | 18++++++++++++++++++
Mcrates/studio_storage/src/accounts.rs | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_storage/src/compatibility.rs | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_storage/src/db.rs | 19++++++++++++++++++-
Mcrates/studio_storage/src/journal.rs | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/studio_storage/src/lib.rs | 3+++
Mcrates/studio_storage/src/recovery.rs | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_storage/src/repair.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_uniffi_bindgen/src/main.rs | 14+++++++++++---
Mdeny.toml | 2+-
Asupply-chain/audits.toml | 20++++++++++++++++++++
Asupply-chain/config.toml | 2797+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asupply-chain/imports.lock | 2++
Mtools/xtask/Cargo.toml | 1+
Mtools/xtask/src/api_qualification.rs | 38+++++++++++++++++++++++++++++++++++---
Mtools/xtask/src/coverage.rs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtools/xtask/src/hygiene.rs | 1+
Mtools/xtask/src/main.rs | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/release_qualification.rs | 94++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Atools/xtask/src/safety_qualification.rs | 347+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/supply_chain_qualification.rs | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtools/xtask/src/target_qualification.rs | 36++++++++++++++++++++----------------
73 files changed, 6916 insertions(+), 1159 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -6838,6 +6838,7 @@ dependencies = [ "tar", "tempfile", "toml 0.8.23", + "walkdir", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml @@ -281,6 +281,7 @@ uuid = { version = "1.22.0", features = ["v4", "v7"] } uniffi = { version = "0.29.4" } wasm-bindgen = { version = "0.2" } wasm-bindgen-test = { version = "0.3" } +walkdir = { version = "2" } x509-parser = { version = "0.17", default-features = false } zstd = { version = "0.13", default-features = false } zeroize = { version = "1" } diff --git a/contracts/coverage-profiles.toml b/contracts/coverage-profiles.toml @@ -27,3 +27,12 @@ test_threads = 1 no_default_features = false features = ["full"] test_threads = 1 + +[profiles.crates."radroots_studio_application"] +test_packages = ["radroots_studio_runtime", "radroots_studio_ffi"] + +[profiles.crates."radroots_studio_runtime"] +test_packages = ["radroots_studio_ffi"] + +[profiles.crates."radroots_studio_storage"] +test_packages = ["radroots_studio_runtime", "radroots_studio_ffi"] diff --git a/contracts/releases/api_semver.toml b/contracts/releases/api_semver.toml @@ -22,4 +22,14 @@ packages = [ "radroots_trade", "radroots_transport", "radroots_transport_nostr", + "radroots_sdk", + "radroots", ] + +[[baseline_override]] +package = "radroots_sdk" +revision = "865378c0bdefc74fae2320e41ea5f5727d444cee" + +[[baseline_override]] +package = "radroots" +revision = "865378c0bdefc74fae2320e41ea5f5727d444cee" diff --git a/contracts/releases/safety_matrix.toml b/contracts/releases/safety_matrix.toml @@ -0,0 +1,69 @@ +schema_version = 1 +toolchain = "nightly-2026-07-16" +miri_flags = ["-Zmiri-strict-provenance", "-Zmiri-disable-isolation"] + +[[miri]] +package = "radroots_core" +filter = "money::invariant_tests::internal_nonnegative_invariant_covers_invalid_and_signed_zero_states" +authority = "numeric-invariants" + +[[miri]] +package = "radroots_identity" +filter = "key::tests::public_keys_reject_invalid_encodings_and_curve_points" +authority = "identity-validation" + +[[miri]] +package = "radroots_protocol" +filter = "error::v1::tests::native_source_messages_are_redacted_and_secrets_are_rejected" +authority = "secret-safe-errors" + +[[miri]] +package = "radroots_event" +filter = "admission::tests::positive_vector_traverses_the_complete_transition_graph" +authority = "event-admission-state" + +[[miri]] +package = "radroots_event_codec" +filter = "reply::inbound::registry_v7::tests::inbound_relay_syntax_and_tag_element_budgets_remain_separate" +authority = "inbound-event-parsing" + +[[miri]] +package = "radroots_trade" +filter = "trade_contract_v1::tests::reducer_projection_is_identical_for_every_three_record_permutation" +authority = "trade-state-reduction" + +[[miri]] +package = "radroots_secrets" +filter = "envelope::tests::decode_and_validation_reject_every_bounded_wire_failure" +authority = "secret-envelope-decoding" + +[[miri]] +package = "radroots_transport" +filter = "outcome::tests::outcome_classes_cover_success_failure_retry_and_detail_branches" +authority = "transport-outcome-policy" + +[[sanitizer]] +kind = "address" +targets = [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", +] +packages = [ + "radroots_sdk_ffi", + "radroots_storage_sqlite", + "radroots_runtime_manager", +] +authority = "native-ffi-runtime-storage" + +[[exception]] +lane = "sanitizer" +targets = [ + "aarch64-pc-windows-msvc", + "x86_64-pc-windows-msvc", + "wasm32-unknown-unknown", +] +owner = "radroots-security" +expires = "2026-10-01" +reason = "the pinned Rust sanitizer runtime is unavailable for these target families" diff --git a/contracts/releases/supply_chain.toml b/contracts/releases/supply_chain.toml @@ -5,6 +5,7 @@ package_version = "0.1.0-alpha" [tools] cargo_deny = "0.19.8" cargo_cyclonedx = "0.5.9" +cargo_vet = "0.10.2" [sbom] format = "json" @@ -40,6 +41,12 @@ classification = "yanked" mitigation = "The package has no RustSec vulnerability; the gate rejects every yanked name/version except this exact transitive dependency." remove_when = "sqlx no longer resolves flume 0.12.0 with spin 0.9.8" +[[git_source]] +url = "https://github.com/rust-nostr/nostr.git" +revision = "5bba5163eb77107f82c4a8262cf29d7f33a73219" +packages = ["nostr", "nostr-relay-builder", "nostr-sdk"] +removal_when = "nostr 0.45 stable satisfies Studio compatibility tests" + [[package]] name = "radroots_core" manifest_path = "crates/core/Cargo.toml" @@ -107,3 +114,11 @@ manifest_path = "crates/sync/Cargo.toml" [[package]] name = "radroots_geonames" manifest_path = "crates/geonames/Cargo.toml" + +[[package]] +name = "radroots_sdk" +manifest_path = "crates/sdk/Cargo.toml" + +[[package]] +name = "radroots" +manifest_path = "crates/radroots/Cargo.toml" diff --git a/crates/event/tests/package_boundary.rs b/crates/event/tests/package_boundary.rs @@ -24,8 +24,6 @@ const ADMISSION: &str = include_str!("../src/admission.rs"); const VERIFICATION: &str = include_str!("../src/verification.rs"); const PUBLIC_API: &str = include_str!("../../../docs/api/radroots_event.txt"); const CODEC_MANIFEST: &str = include_str!("../../event_codec/Cargo.toml"); -const CODEC_POST_DECODE: &str = include_str!("../../event_codec/src/post/decode.rs"); -const CODEC_PROFILE: &str = include_str!("../../event_codec/src/profile/mod.rs"); #[test] fn manifest_has_final_identity_and_required_radroots_dependencies() { @@ -205,15 +203,18 @@ fn public_native_items_do_not_retain_the_legacy_radroots_prefix() { } #[test] -fn lossy_legacy_projections_are_quarantined_until_codec_retirement() { +fn event_codec_has_no_lossy_post_or_profile_projection_surface() { assert!(CODEC_MANIFEST.contains("publish = [\"crates-io\"]")); - for (source, compatibility_type) in [ - (CODEC_POST_DECODE, "pub struct LegacyPost"), - (CODEC_PROFILE, "pub struct LegacyProfile"), - ] { - assert!(source.contains(compatibility_type)); - assert!(source.contains("superseded codec APIs in Step 087")); + let codec_root = include_str!("../../event_codec/src/lib.rs"); + let post_module = include_str!("../../event_codec/src/post/mod.rs"); + let profile_module = include_str!("../../event_codec/src/profile/mod.rs"); + for source in [codec_root, post_module, profile_module] { + assert!(!source.contains("LegacyPost")); + assert!(!source.contains("LegacyProfile")); + assert!(!source.contains("RadrootsProfileData")); } + assert!(!post_module.contains("pub mod decode;")); + assert!(!profile_module.contains("pub mod decode;")); } #[test] diff --git a/crates/event_codec/src/comment/mod.rs b/crates/event_codec/src/comment/mod.rs @@ -1,3 +1,4 @@ +#[cfg(feature = "json")] pub mod admission; pub mod authored; pub mod inbound; diff --git a/crates/event_codec/src/decode.rs b/crates/event_codec/src/decode.rs @@ -116,15 +116,12 @@ pub mod order { } pub mod post { - pub use crate::post::decode::*; pub use crate::post::inbound::*; } #[cfg(feature = "json")] pub mod profile { - pub use crate::profile::decode::*; pub use crate::profile::inbound::*; - pub use crate::profile::{LegacyProfile, RadrootsProfileData}; } pub mod reply { diff --git a/crates/event_codec/src/deletion/mod.rs b/crates/event_codec/src/deletion/mod.rs @@ -1,5 +1,7 @@ +#[cfg(feature = "json")] pub mod admission; pub mod authored; +#[cfg(feature = "json")] pub mod evaluator; pub mod inbound; #[doc(hidden)] diff --git a/crates/event_codec/src/deletion/reconciliation_v1.rs b/crates/event_codec/src/deletion/reconciliation_v1.rs @@ -2,6 +2,7 @@ //! Frozen NIP-09 projection, admission, and suppression semantics. +#[cfg(feature = "json")] pub mod admission { //! Frozen NIP-09 request-admission semantics for reconciliation v1. @@ -139,6 +140,7 @@ pub mod admission { mod tests; } +#[cfg(feature = "json")] pub mod evaluator { //! Frozen NIP-09 suppression semantics for reconciliation v1. diff --git a/crates/event_codec/src/food_availability/inbound/registry_v7.rs b/crates/event_codec/src/food_availability/inbound/registry_v7.rs @@ -4,7 +4,11 @@ use alloc::{boxed::Box, string::String, string::ToString, vec::Vec}; use core::fmt; -use radroots_blossom::{BlobUrl, Sha256}; +#[cfg(feature = "json")] +use radroots_blossom::BlobUrl; +use radroots_blossom::Sha256; +#[cfg(feature = "json")] +use radroots_event::wire::DEFAULT_RAW_JSON_MAX_BYTES; use radroots_event::{ envelope::EventTags, envelope::kind::KIND_CLASSIFIED_LISTING, @@ -15,11 +19,11 @@ use radroots_event::{ food_media_http_url_is_valid, }, listing::classified::ClassifiedListingPartition, - wire::DEFAULT_RAW_JSON_MAX_BYTES, }; use crate::verification::v1::RadrootsSignatureVerifiedEvent; +#[cfg(feature = "json")] const FOOD_SIGNED_EVENT_FIXED_BYTES: usize = "{\"id\":\"".len() + 64 + "\",\"pubkey\":\"".len() @@ -492,11 +496,13 @@ fn normalize_decimal( } #[derive(Clone, Debug, PartialEq, Eq)] +#[cfg(feature = "json")] pub(crate) struct RadrootsStrictFoodAvailabilityProjection { identifier: FoodIdentifier, published_at: FoodPublishedAt, } +#[cfg(feature = "json")] impl RadrootsStrictFoodAvailabilityProjection { pub(crate) fn identifier(&self) -> &FoodIdentifier { &self.identifier @@ -507,6 +513,7 @@ impl RadrootsStrictFoodAvailabilityProjection { } } +#[cfg(feature = "json")] fn canonical_food_signed_event_size(tags: &[Vec<String>], content: &str, created_at: u64) -> usize { let mut tags_bytes = 2usize; for (tag_index, tag) in tags.iter().enumerate() { @@ -527,6 +534,7 @@ fn canonical_food_signed_event_size(tags: &[Vec<String>], content: &str, created .saturating_add(canonical_json_string_bytes(content)) } +#[cfg(feature = "json")] fn decimal_u64_bytes(mut value: u64) -> usize { let mut bytes = 1usize; while value >= 10 { @@ -536,6 +544,7 @@ fn decimal_u64_bytes(mut value: u64) -> usize { bytes } +#[cfg(feature = "json")] fn canonical_json_string_bytes(value: &str) -> usize { value.chars().fold(2usize, |total, character| { total.saturating_add(match character { @@ -550,6 +559,7 @@ fn canonical_json_string_bytes(value: &str) -> usize { /// /// This does not construct signable parts or elevate inbound media to verified /// media typestate. It exists solely for comparing already signed revisions. +#[cfg(feature = "json")] pub(crate) fn project_strict_verified_food_availability_event( verified_event: &RadrootsSignatureVerifiedEvent, ) -> Result<RadrootsStrictFoodAvailabilityProjection, RadrootsFoodAvailabilityProjectionError> { diff --git a/crates/event_codec/src/food_availability/mod.rs b/crates/event_codec/src/food_availability/mod.rs @@ -1,4 +1,6 @@ +#[cfg(feature = "json")] pub mod admission; pub mod authored; pub mod inbound; +#[cfg(feature = "json")] pub mod revision; diff --git a/crates/event_codec/src/post/decode.rs b/crates/event_codec/src/post/decode.rs @@ -1,353 +0,0 @@ -#[cfg(not(feature = "std"))] -use alloc::{ - string::{String, ToString}, - vec, - vec::Vec, -}; - -use radroots_event::{ - envelope::kind::{KIND_FARM, KIND_POST}, - farm::FarmRef, - social::{SocialFarmAnchor, SocialLocation, SocialMediaMetadata, SocialTarget}, - tag::name::{TAG_A, TAG_IMETA, TAG_Q, TAG_T}, -}; - -use crate::error::EventParseError; -use crate::field_helpers::{parse_address_tag, tag_values, validate_lowercase_hex_64_tag}; -use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; -use crate::social_helpers::{location_from_tags, parse_dimensions_tag}; - -const DEFAULT_KIND: u32 = KIND_POST; - -/// Temporary compatibility projection for pre-v1 post consumers. -/// -/// This type is quarantined in the non-publishable intermediate codec surface -/// and must be removed with the superseded codec APIs in Step 087. -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Clone, Debug)] -pub struct LegacyPost { - pub content: String, - #[cfg_attr( - feature = "serde", - serde(default, skip_serializing_if = "Option::is_none") - )] - pub farm: Option<SocialFarmAnchor>, - #[cfg_attr( - feature = "serde", - serde(default, skip_serializing_if = "Option::is_none") - )] - pub address_refs: Option<Vec<SocialTarget>>, - #[cfg_attr( - feature = "serde", - serde(default, skip_serializing_if = "Option::is_none") - )] - pub location: Option<SocialLocation>, - #[cfg_attr( - feature = "serde", - serde(default, skip_serializing_if = "Option::is_none") - )] - pub topics: Option<Vec<String>>, - #[cfg_attr( - feature = "serde", - serde(default, skip_serializing_if = "Option::is_none") - )] - pub quote_refs: Option<Vec<SocialTarget>>, - #[cfg_attr( - feature = "serde", - serde(default, skip_serializing_if = "Option::is_none") - )] - pub media: Option<Vec<SocialMediaMetadata>>, -} - -pub fn post_from_content(kind: u32, content: &str) -> Result<LegacyPost, EventParseError> { - if kind != DEFAULT_KIND { - return Err(EventParseError::InvalidKind { - expected: "1", - got: kind, - }); - } - if content.trim().is_empty() { - return Err(EventParseError::InvalidTag("content")); - } - Ok(LegacyPost { - content: content.to_string(), - farm: None, - address_refs: None, - location: None, - topics: None, - quote_refs: None, - media: None, - }) -} - -pub fn post_from_event( - kind: u32, - tags: &[Vec<String>], - content: &str, -) -> Result<LegacyPost, EventParseError> { - let mut post = post_from_content(kind, content)?; - post.farm = farm_anchor_from_tags(tags)?; - post.address_refs = address_refs_from_tags(tags)?; - post.location = location_from_tags(tags); - post.topics = non_empty_vec(tag_values(tags, TAG_T)?); - post.quote_refs = quote_refs_from_tags(tags)?; - post.media = media_from_tags(tags)?; - Ok(post) -} - -pub fn data_from_event( - id: String, - author: String, - published_at: u64, - kind: u32, - content: String, - tags: Vec<Vec<String>>, -) -> Result<RadrootsParsedData<LegacyPost>, EventParseError> { - let post = post_from_event(kind, &tags, &content)?; - Ok(RadrootsParsedData::new( - id, - author, - published_at, - kind, - post, - )) -} - -pub fn parsed_from_event( - id: String, - author: String, - published_at: u64, - kind: u32, - content: String, - tags: Vec<Vec<String>>, - sig: String, -) -> Result<RadrootsParsedEvent<LegacyPost>, EventParseError> { - let data = data_from_event( - id.clone(), - author.clone(), - published_at, - kind, - content.clone(), - tags.clone(), - )?; - RadrootsParsedEvent::from_event_parts(id, author, published_at, kind, content, tags, sig, data) -} - -fn farm_anchor_from_tags( - tags: &[Vec<String>], -) -> Result<Option<SocialFarmAnchor>, EventParseError> { - for tag in tags - .iter() - .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A)) - { - let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_A))?; - let address = parse_address_tag(value, TAG_A)?; - if address.kind == KIND_FARM { - let relays = if tag.len() > 2 { - Some(tag[2..].to_vec()) - } else { - None - }; - return Ok(Some(SocialFarmAnchor { - farm: FarmRef { - pubkey: address.pubkey, - d_tag: address.d_tag, - }, - relays, - })); - } - } - Ok(None) -} - -fn address_refs_from_tags( - tags: &[Vec<String>], -) -> Result<Option<Vec<SocialTarget>>, EventParseError> { - let mut refs = Vec::new(); - for tag in tags - .iter() - .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A)) - { - let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_A))?; - let address = parse_address_tag(value, TAG_A)?; - if address.kind == KIND_FARM { - continue; - } - let relays = if tag.len() > 2 { - Some(tag[2..].to_vec()) - } else { - None - }; - refs.push(SocialTarget::Address { - address: value.clone(), - author: Some(address.pubkey), - event_kind: Some(address.kind), - relays, - }); - } - Ok(non_empty_vec(refs)) -} - -fn quote_refs_from_tags( - tags: &[Vec<String>], -) -> Result<Option<Vec<SocialTarget>>, EventParseError> { - let mut refs = Vec::new(); - for tag in tags - .iter() - .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_Q)) - { - let value = tag.get(1).ok_or(EventParseError::InvalidTag(TAG_Q))?; - let relays = if tag.len() > 2 { - Some(tag[2..].to_vec()) - } else { - None - }; - match parse_address_tag(value, TAG_Q) { - Ok(address) => refs.push(SocialTarget::Address { - address: value.clone(), - author: Some(address.pubkey), - event_kind: Some(address.kind), - relays, - }), - Err(_) => { - validate_lowercase_hex_64_tag(value, TAG_Q)?; - refs.push(SocialTarget::Event { - id: value.clone(), - author: None, - event_kind: None, - relays, - }); - } - } - } - Ok(non_empty_vec(refs)) -} - -fn media_from_tags( - tags: &[Vec<String>], -) -> Result<Option<Vec<SocialMediaMetadata>>, EventParseError> { - let mut media = Vec::new(); - for tag in tags - .iter() - .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_IMETA)) - { - if tag.len() < 2 { - return Err(EventParseError::InvalidTag(TAG_IMETA)); - } - let raw = tag[1..].to_vec(); - if raw.iter().any(|value| value.trim().is_empty()) { - return Err(EventParseError::InvalidTag(TAG_IMETA)); - } - let mut item = SocialMediaMetadata { - imeta: Some(vec![raw.clone()]), - ..SocialMediaMetadata::default() - }; - for entry in raw { - parse_imeta_entry(&mut item, &entry)?; - } - media.push(item); - } - Ok(non_empty_vec(media)) -} - -fn parse_imeta_entry(item: &mut SocialMediaMetadata, entry: &str) -> Result<(), EventParseError> { - let Some((key, value)) = entry.split_once(' ') else { - return Err(EventParseError::InvalidTag(TAG_IMETA)); - }; - if value.trim().is_empty() { - return Err(EventParseError::InvalidTag(TAG_IMETA)); - } - match key { - "url" => item.url = Some(value.to_string()), - "m" => item.mime_type = Some(value.to_string()), - "x" => item.sha256 = Some(value.to_string()), - "ox" => item.original_sha256 = Some(value.to_string()), - "size" => { - item.size = Some( - value - .parse::<u64>() - .map_err(|err| EventParseError::InvalidNumber(TAG_IMETA, err))?, - ); - } - "dim" => item.dimensions = Some(parse_dimensions_tag(value, TAG_IMETA)?), - "blurhash" => item.blurhash = Some(value.to_string()), - "image" => item.image = Some(value.to_string()), - "summary" => item.summary = Some(value.to_string()), - "alt" => item.alt = Some(value.to_string()), - "fallback" => item.fallback = Some(value.to_string()), - "magnet" => item.magnet = Some(value.to_string()), - "i" => push_repeated_value(&mut item.content_hashes, value), - "service" => push_repeated_value(&mut item.services, value), - "thumb" => {} - _ => {} - } - Ok(()) -} - -fn push_repeated_value(values: &mut Option<Vec<String>>, value: &str) { - values.get_or_insert_with(Vec::new).push(value.to_string()); -} - -fn non_empty_vec<T>(values: Vec<T>) -> Option<Vec<T>> { - if values.is_empty() { - None - } else { - Some(values) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[cfg(feature = "serde")] - #[test] - fn content_only_legacy_post_round_trips_without_null_metadata() { - let post: LegacyPost = - serde_json::from_str(r#"{"content":"farm update"}"#).expect("legacy post"); - - assert_eq!(post.content, "farm update"); - assert!(post.farm.is_none()); - assert!(post.address_refs.is_none()); - assert!(post.location.is_none()); - assert!(post.topics.is_none()); - assert!(post.quote_refs.is_none()); - assert!(post.media.is_none()); - assert_eq!( - serde_json::to_string(&post).expect("legacy post JSON"), - r#"{"content":"farm update"}"# - ); - } - - #[test] - fn post_decode_accepts_address_ref_without_relays_and_unknown_imeta_keys() { - let author = "a".repeat(64); - let post = post_from_event( - DEFAULT_KIND, - &[ - vec![ - TAG_A.to_string(), - format!("30023:{author}:AAAAAAAAAAAAAAAAAAAAAA"), - ], - vec![ - TAG_IMETA.to_string(), - "url https://media.example.invalid/a.jpg".to_string(), - "custom value".to_string(), - ], - ], - "fresh carrots", - ) - .expect("post"); - - let refs = post.address_refs.expect("address refs"); - assert!(matches!( - &refs[0], - SocialTarget::Address { relays: None, .. } - )); - let media = post.media.expect("media"); - assert_eq!( - media[0].url.as_deref(), - Some("https://media.example.invalid/a.jpg") - ); - } -} diff --git a/crates/event_codec/src/post/mod.rs b/crates/event_codec/src/post/mod.rs @@ -1,4 +1,4 @@ +#[cfg(feature = "json")] pub mod admission; pub mod authored; -pub mod decode; pub mod inbound; diff --git a/crates/event_codec/src/profile/decode.rs b/crates/event_codec/src/profile/decode.rs @@ -1,125 +0,0 @@ -#[cfg(not(feature = "std"))] -use alloc::{ - string::{String, ToString}, - vec::Vec, -}; - -use super::{LegacyProfile, RadrootsProfileData}; -use radroots_event::{ - envelope::kind::KIND_PROFILE, - profile::{ProfileType, RADROOTS_PROFILE_TYPE_TAG_KEY, radroots_profile_type_from_tag_value}, -}; - -use crate::error::EventParseError; -use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; -use serde_json::Value; - -const PROFILE_KIND: u32 = KIND_PROFILE; - -fn parse_optional_string(value: &Value, key: &'static str) -> Option<String> { - value - .get(key) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) -} - -fn parse_bot(value: &Value) -> Option<String> { - match value.get("bot") { - Some(v) if v.is_string() => v.as_str().map(|s| s.to_string()), - Some(v) if v.is_boolean() => v.as_bool().map(|b| b.to_string()), - _ => None, - } -} - -fn profile_type_from_tags(tags: &[Vec<String>]) -> Option<ProfileType> { - tags.iter() - .filter(|tag| tag.first().map(|v| v.as_str()) == Some(RADROOTS_PROFILE_TYPE_TAG_KEY)) - .filter_map(|tag| tag.get(1)) - .find_map(|value| radroots_profile_type_from_tag_value(value)) -} - -/// Decodes content into the compatibility-only legacy Profile model. -/// -/// This API requires `name`, coerces Boolean `bot` to a string, and discards -/// unprojected fields. Use `profile.parse_inbound_metadata` for the tolerant -/// inbound metadata contract. -pub fn profile_from_content(content: &str) -> Result<LegacyProfile, EventParseError> { - let value: Value = - serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; - let obj = value - .as_object() - .ok_or(EventParseError::InvalidJson("content"))?; - let name = obj - .get("name") - .and_then(|v| v.as_str()) - .ok_or(EventParseError::InvalidJson("name"))?; - - Ok(LegacyProfile { - name: name.to_string(), - display_name: parse_optional_string(&value, "display_name"), - nip05: parse_optional_string(&value, "nip05"), - about: parse_optional_string(&value, "about"), - website: parse_optional_string(&value, "website"), - picture: parse_optional_string(&value, "picture"), - banner: parse_optional_string(&value, "banner"), - lud06: parse_optional_string(&value, "lud06"), - lud16: parse_optional_string(&value, "lud16"), - bot: parse_bot(&value), - }) -} - -/// Projects caller-supplied event fields through the legacy Profile decoder. -/// -/// This compatibility API does not verify the event identifier or signature -/// and is not the strict inbound event-admission boundary. -pub fn data_from_event( - id: String, - author: String, - published_at: u64, - kind: u32, - content: String, - tags: Vec<Vec<String>>, -) -> Result<RadrootsParsedData<RadrootsProfileData>, EventParseError> { - if kind != PROFILE_KIND { - return Err(EventParseError::InvalidKind { - expected: "0", - got: kind, - }); - } - let profile = profile_from_content(&content)?; - let profile_type = profile_type_from_tags(&tags); - Ok(RadrootsParsedData::new( - id, - author, - published_at, - kind, - RadrootsProfileData { - profile_type, - profile, - }, - )) -} - -/// Builds a legacy parsed Profile wrapper from caller-supplied event fields. -/// -/// This compatibility API is outside `profile.parse_inbound_metadata` and does -/// not establish strict event admission. -pub fn parsed_from_event( - id: String, - author: String, - published_at: u64, - kind: u32, - content: String, - tags: Vec<Vec<String>>, - sig: String, -) -> Result<RadrootsParsedEvent<RadrootsProfileData>, EventParseError> { - let data = data_from_event( - id.clone(), - author.clone(), - published_at, - kind, - content.clone(), - tags.clone(), - )?; - RadrootsParsedEvent::from_event_parts(id, author, published_at, kind, content, tags, sig, data) -} diff --git a/crates/event_codec/src/profile/mod.rs b/crates/event_codec/src/profile/mod.rs @@ -1,13 +1,3 @@ -#[cfg(not(feature = "std"))] -extern crate alloc; - -use radroots_event::profile::ProfileType; - -#[cfg(feature = "std")] -type LegacyProfileString = std::string::String; -#[cfg(not(feature = "std"))] -type LegacyProfileString = alloc::string::String; - #[cfg(feature = "json")] pub mod admission; @@ -15,34 +5,4 @@ pub mod admission; pub mod authored; #[cfg(feature = "json")] -pub mod decode; - -#[cfg(feature = "json")] pub mod inbound; - -/// Temporary lossy compatibility projection for pre-v1 profile consumers. -/// -/// Strict reads use `inbound::RadrootsInboundProfileMetadata`. This type is -/// quarantined in the non-publishable intermediate codec surface and must be -/// removed with the superseded codec APIs in Step 087. -#[cfg_attr(feature = "serde", derive(serde::Deserialize))] -#[derive(Clone, Debug)] -pub struct LegacyProfile { - pub name: LegacyProfileString, - pub display_name: Option<LegacyProfileString>, - pub nip05: Option<LegacyProfileString>, - pub about: Option<LegacyProfileString>, - pub website: Option<LegacyProfileString>, - pub picture: Option<LegacyProfileString>, - pub banner: Option<LegacyProfileString>, - pub lud06: Option<LegacyProfileString>, - pub lud16: Option<LegacyProfileString>, - pub bot: Option<LegacyProfileString>, -} - -#[cfg_attr(feature = "serde", derive(serde::Deserialize))] -#[derive(Clone, Debug)] -pub struct RadrootsProfileData { - pub profile_type: Option<ProfileType>, - pub profile: LegacyProfile, -} diff --git a/crates/event_codec/src/reply/mod.rs b/crates/event_codec/src/reply/mod.rs @@ -1,3 +1,4 @@ +#[cfg(feature = "json")] pub mod admission; pub mod authored; pub mod inbound; diff --git a/crates/event_codec/tests/coverage_edges.rs b/crates/event_codec/tests/coverage_edges.rs @@ -99,15 +99,6 @@ fn parsed_wrappers_propagate_invalid_kind_errors() { let (id, author, created_at, kind, content, tags, sig) = parsed_args(); assert_invalid_kind( - radroots_event_codec::decode::profile::parsed_from_event( - id, author, created_at, kind, content, tags, sig, - ), - "0", - KIND_POST, - ); - - let (id, author, created_at, kind, content, tags, sig) = parsed_args(); - assert_invalid_kind( radroots_event_codec::decode::reaction::parsed_from_event( id, author, created_at, kind, content, tags, sig, ), diff --git a/crates/event_codec/tests/profile.rs b/crates/event_codec/tests/profile.rs @@ -1,219 +0,0 @@ -#![cfg(feature = "json")] - -use radroots_event::{ - envelope::kind::{KIND_POST, KIND_PROFILE}, - profile::{ - ProfileType, RADROOTS_PROFILE_TYPE_TAG_ANY, RADROOTS_PROFILE_TYPE_TAG_COOP, - RADROOTS_PROFILE_TYPE_TAG_FARM, RADROOTS_PROFILE_TYPE_TAG_KEY, - RADROOTS_PROFILE_TYPE_TAG_RADROOTSD, - }, -}; -use radroots_event_codec::decode::EventParseError; -use radroots_event_codec::decode::profile::{ - data_from_event, parsed_from_event, profile_from_content, -}; - -const AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; - -#[test] -fn profile_from_content_parses_bot_boolean() { - let content = r#"{"name":"alice","bot":true}"#; - let profile = profile_from_content(content).unwrap(); - - assert_eq!(profile.name, "alice"); - assert_eq!(profile.bot.as_deref(), Some("true")); -} - -#[test] -fn profile_from_content_parses_bot_string() { - let content = r#"{"name":"alice","bot":"false"}"#; - let profile = profile_from_content(content).unwrap(); - - assert_eq!(profile.name, "alice"); - assert_eq!(profile.bot.as_deref(), Some("false")); -} - -#[test] -fn profile_from_content_parses_optional_metadata_and_ignores_invalid_scalars() { - let content = r#"{"name":"alice","display_name":"Alice","nip05":"alice@example.test","about":"farm account","website":"https://farm.example.test","picture":"https://farm.example.test/pic.png","banner":"https://farm.example.test/banner.png","lud06":"lnurl1farm","lud16":"alice@example.test","bot":12}"#; - let profile = profile_from_content(content).unwrap(); - - assert_eq!(profile.name, "alice"); - assert_eq!(profile.display_name.as_deref(), Some("Alice")); - assert_eq!(profile.nip05.as_deref(), Some("alice@example.test")); - assert_eq!(profile.about.as_deref(), Some("farm account")); - assert_eq!( - profile.website.as_deref(), - Some("https://farm.example.test") - ); - assert_eq!( - profile.picture.as_deref(), - Some("https://farm.example.test/pic.png") - ); - assert_eq!( - profile.banner.as_deref(), - Some("https://farm.example.test/banner.png") - ); - assert_eq!(profile.lud06.as_deref(), Some("lnurl1farm")); - assert_eq!(profile.lud16.as_deref(), Some("alice@example.test")); - assert_eq!(profile.bot, None); -} - -#[test] -fn profile_from_content_rejects_missing_name() { - let content = r#"{"display_name":"alice"}"#; - let err = profile_from_content(content).unwrap_err(); - assert!(matches!(err, EventParseError::InvalidJson("name"))); -} - -#[test] -fn profile_from_content_rejects_non_object_json() { - let err = profile_from_content("[]").unwrap_err(); - assert!(matches!(err, EventParseError::InvalidJson("content"))); -} - -#[test] -fn profile_from_content_rejects_invalid_json() { - let err = profile_from_content("{").unwrap_err(); - assert!(matches!(err, EventParseError::InvalidJson("content"))); -} - -#[test] -fn profile_metadata_rejects_wrong_kind() { - let err = data_from_event( - "id".to_string(), - "author".to_string(), - 1, - 1, - "{\"name\":\"alice\"}".to_string(), - Vec::new(), - ) - .unwrap_err(); - - assert!(matches!( - err, - EventParseError::InvalidKind { - expected: "0", - got: KIND_POST - } - )); -} - -#[test] -fn profile_metadata_reads_profile_type_tag() { - let metadata = data_from_event( - "id".to_string(), - "author".to_string(), - 1, - 0, - "{\"name\":\"alice\"}".to_string(), - vec![vec![ - RADROOTS_PROFILE_TYPE_TAG_KEY.to_string(), - RADROOTS_PROFILE_TYPE_TAG_FARM.to_string(), - ]], - ) - .expect("metadata"); - - assert_eq!(metadata.data.profile_type, Some(ProfileType::Farm)); -} - -#[test] -fn profile_metadata_reads_profile_type_any_tag() { - let metadata = data_from_event( - "id".to_string(), - "author".to_string(), - 1, - 0, - "{\"name\":\"alice\"}".to_string(), - vec![vec![ - RADROOTS_PROFILE_TYPE_TAG_KEY.to_string(), - RADROOTS_PROFILE_TYPE_TAG_ANY.to_string(), - ]], - ) - .expect("metadata"); - - assert_eq!(metadata.data.profile_type, Some(ProfileType::Any)); -} - -#[test] -fn profile_metadata_reads_profile_type_radrootsd_tag() { - let metadata = data_from_event( - "id".to_string(), - "author".to_string(), - 1, - 0, - "{\"name\":\"alice\"}".to_string(), - vec![vec![ - RADROOTS_PROFILE_TYPE_TAG_KEY.to_string(), - RADROOTS_PROFILE_TYPE_TAG_RADROOTSD.to_string(), - ]], - ) - .expect("metadata"); - - assert_eq!(metadata.data.profile_type, Some(ProfileType::Radrootsd)); -} - -#[test] -fn profile_metadata_ignores_short_unknown_and_unrelated_profile_type_tags() { - let metadata = data_from_event( - "id".to_string(), - "author".to_string(), - 1, - KIND_PROFILE, - "{\"name\":\"alice\"}".to_string(), - vec![ - vec![RADROOTS_PROFILE_TYPE_TAG_KEY.to_string()], - vec![ - RADROOTS_PROFILE_TYPE_TAG_KEY.to_string(), - "radroots:type:unknown".to_string(), - ], - vec!["x".to_string(), RADROOTS_PROFILE_TYPE_TAG_COOP.to_string()], - vec![ - RADROOTS_PROFILE_TYPE_TAG_KEY.to_string(), - RADROOTS_PROFILE_TYPE_TAG_COOP.to_string(), - ], - ], - ) - .expect("metadata"); - - assert_eq!(metadata.data.profile_type, Some(ProfileType::Coop)); -} - -#[test] -fn profile_parsed_event_preserves_wire_event_and_decoded_data() { - let parsed = parsed_from_event( - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - AUTHOR.to_string(), - 42, - KIND_PROFILE, - "{\"name\":\"alice\"}".to_string(), - vec![vec![ - RADROOTS_PROFILE_TYPE_TAG_KEY.to_string(), - RADROOTS_PROFILE_TYPE_TAG_FARM.to_string(), - ]], - concat!( - "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - ) - .to_string(), - ) - .expect("parsed profile"); - - assert_eq!( - parsed.event.id_hex(), - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - ); - assert_eq!(parsed.event.author().to_hex(), AUTHOR); - assert_eq!(parsed.event.created_at_u64(), 42); - assert_eq!(parsed.event.kind_u32(), KIND_PROFILE); - assert_eq!(parsed.event.content(), "{\"name\":\"alice\"}"); - assert_eq!( - parsed.event.signature_hex(), - concat!( - "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - ) - ); - assert_eq!(parsed.data.data.profile.name, "alice"); - assert_eq!(parsed.data.data.profile_type, Some(ProfileType::Farm)); -} diff --git a/crates/event_codec/tests/social_events.rs b/crates/event_codec/tests/social_events.rs @@ -18,7 +18,7 @@ use radroots_event_codec::{ decode::EventParseError, decode::article::article_from_event, decode::farm::farm_from_event, decode::farm_file::farm_file_metadata_from_event, decode::file_metadata::file_metadata_from_event, decode::group::group_metadata_from_event, - decode::operational_listing::operational_listing_from_event, decode::post::post_from_event, + decode::operational_listing::operational_listing_from_event, encode::farm_file::to_wire_parts as farm_file_to_wire_parts, encode::file_metadata::to_wire_parts as public_file_to_wire_parts, encode::group::group_metadata_to_wire_parts, @@ -69,13 +69,6 @@ fn social_events_reject_private_farm_ops_semantics_in_public_codecs() { Err(EventParseError::InvalidJson("content")) )); assert!(matches!( - post_from_event(KIND_FARM_CRDT_CHANGE, &[], "farm task"), - Err(EventParseError::InvalidKind { - expected: "1", - got: KIND_FARM_CRDT_CHANGE - }) - )); - assert!(matches!( article_from_event(KIND_FARM_CRDT_CHANGE, &[], "farm task"), Err(EventParseError::InvalidKind { expected: "30023", diff --git a/crates/nostr/src/event.rs b/crates/nostr/src/event.rs @@ -16,8 +16,6 @@ pub use crate::codec_adapters::{ to_job_result_index, to_job_result_metadata, }; #[cfg(feature = "events")] -pub use crate::event_adapters::{to_post_event_metadata, to_profile_event_metadata}; -#[cfg(feature = "events")] pub use crate::event_verify::{ NostrSignatureVerifier as SignatureVerifier, Verification, verify_event as verify, verify_event_id as verify_id, diff --git a/crates/nostr/src/event_adapters.rs b/crates/nostr/src/event_adapters.rs @@ -1,107 +0,0 @@ -//! Deterministic adapters for typed Radroots event payloads and Nostr tags. -//! -//! These helpers transform already-supplied values only; they perform no -//! relay I/O, persistence, account selection, or runtime initialization. - -#[cfg(feature = "events")] -use radroots_event::profile::{ - RADROOTS_PROFILE_TYPE_TAG_KEY, radroots_profile_type_from_tag_value, -}; -#[cfg(feature = "events")] -use radroots_event_codec::decode::RadrootsParsedData; -#[cfg(feature = "events")] -use radroots_event_codec::decode::{ - post::LegacyPost, - profile::{LegacyProfile, RadrootsProfileData}, -}; - -#[cfg(feature = "events")] -use crate::types::{RadrootsNostrEvent, RadrootsNostrMetadata}; - -#[cfg(feature = "events")] -/// Adapts an event through the compatibility-only legacy post projection. -/// -/// This helper discards tags and does not establish product profile admission. -/// Use `verify_and_admit_post_event` over `from_nostr` whenever -/// the caller needs root Update, PhotoUpdate, or Ask admission. Its explicit -/// thread-excluded outcome makes no Reply claim. -pub fn to_post_event_metadata(e: &RadrootsNostrEvent) -> RadrootsParsedData<LegacyPost> { - RadrootsParsedData::new( - e.id.to_string(), - e.pubkey.to_string(), - e.created_at.as_secs(), - e.kind.as_u16() as u32, - LegacyPost { - content: e.content.clone(), - farm: None, - address_refs: None, - location: None, - topics: None, - quote_refs: None, - media: None, - }, - ) -} - -#[cfg(feature = "events")] -/// Adapts an event through the compatibility-only legacy Profile decoder. -/// -/// This helper does not establish kind, identifier, or signature verification -/// and is outside `profile.parse_inbound_metadata`. -pub fn to_profile_event_metadata( - e: &RadrootsNostrEvent, -) -> Option<RadrootsParsedData<RadrootsProfileData>> { - let profile_type = e - .tags - .iter() - .filter_map(|tag| { - let values = tag.as_slice(); - if values.first().map(|v| v.as_str()) != Some(RADROOTS_PROFILE_TYPE_TAG_KEY) { - return None; - } - values - .get(1) - .and_then(|value| radroots_profile_type_from_tag_value(value)) - }) - .next(); - - if let Ok(p) = serde_json::from_str::<LegacyProfile>(&e.content) { - return Some(RadrootsParsedData::new( - e.id.to_string(), - e.pubkey.to_string(), - e.created_at.as_secs(), - e.kind.as_u16() as u32, - RadrootsProfileData { - profile_type, - profile: p, - }, - )); - } - - if let Ok(md) = serde_json::from_str::<RadrootsNostrMetadata>(&e.content) { - let p = LegacyProfile { - name: md.name.unwrap_or_default(), - display_name: md.display_name, - nip05: md.nip05, - about: md.about, - website: md.website.map(|u| u.to_string()), - picture: md.picture.map(|u| u.to_string()), - banner: md.banner.map(|u| u.to_string()), - lud06: md.lud06, - lud16: md.lud16, - bot: None, - }; - return Some(RadrootsParsedData::new( - e.id.to_string(), - e.pubkey.to_string(), - e.created_at.as_secs(), - e.kind.as_u16() as u32, - RadrootsProfileData { - profile_type, - profile: p, - }, - )); - } - - None -} diff --git a/crates/nostr/src/lib.rs b/crates/nostr/src/lib.rs @@ -39,9 +39,6 @@ pub mod nip17; pub mod signing; #[cfg(feature = "events")] -mod event_adapters; - -#[cfg(feature = "events")] mod event_convert; #[cfg(feature = "events")] mod event_verify; diff --git a/crates/nostr/tests/coverage.rs b/crates/nostr/tests/coverage.rs @@ -14,7 +14,7 @@ use radroots_nostr::event::build_nip10_reply as build_nip10_reply_event; use radroots_nostr::event::{ ApplicationHandlerSpec, EventAdapter, build_application_handler, metadata_has_fields, to_job_feedback_index, to_job_feedback_metadata, to_job_request_index, to_job_request_metadata, - to_job_result_index, to_job_result_metadata, to_post_event_metadata, to_profile_event_metadata, + to_job_result_index, to_job_result_metadata, }; use radroots_nostr::event::{Kind as RadrootsNostrKind, Timestamp as RadrootsNostrTimestamp}; use radroots_nostr::event::{ @@ -398,55 +398,6 @@ fn event_and_job_adapters_cover_native_value_boundaries() { let ordinary_adapter = EventAdapter::new(&profile_event); assert_eq!(JobEventLike::raw_kind(&ordinary_adapter), 0); assert_eq!(JobEventBorrow::raw_kind(&ordinary_adapter), 0); - assert_eq!( - to_post_event_metadata(&profile_event).data.content, - profile_event.content - ); - assert!(to_profile_event_metadata(&profile_event).is_some()); - let unrelated_tag_profile = - nostr::EventBuilder::new(RadrootsNostrKind::Metadata, profile_event.content.clone()) - .tag(RadrootsNostrTag::custom( - RadrootsNostrTagKind::Custom(Cow::Borrowed("x")), - vec!["ignored".to_string()], - )) - .sign_with_keys(&keys) - .unwrap(); - assert!(to_profile_event_metadata(&unrelated_tag_profile).is_some()); - let typed_profile = - nostr::EventBuilder::new(RadrootsNostrKind::Metadata, profile_event.content.clone()) - .tag(RadrootsNostrTag::custom( - RadrootsNostrTagKind::Custom(Cow::Borrowed("t")), - vec!["radroots:type:farm".to_string()], - )) - .sign_with_keys(&keys) - .unwrap(); - assert!( - to_profile_event_metadata(&typed_profile) - .expect("typed profile") - .data - .profile_type - .is_some() - ); - let unknown_profile_type = - nostr::EventBuilder::new(RadrootsNostrKind::Metadata, profile_event.content.clone()) - .tag(RadrootsNostrTag::custom( - RadrootsNostrTagKind::Custom(Cow::Borrowed("t")), - vec!["radroots:type:unknown".to_string()], - )) - .sign_with_keys(&keys) - .unwrap(); - assert_eq!( - to_profile_event_metadata(&unknown_profile_type) - .expect("profile with unknown type") - .data - .profile_type, - None - ); - let invalid_profile = nostr::EventBuilder::new(RadrootsNostrKind::Metadata, "not-json") - .sign_with_keys(&keys) - .unwrap(); - assert!(to_profile_event_metadata(&invalid_profile).is_none()); - let _ = to_job_request_metadata(&event); let _ = to_job_request_index(&event); let _ = to_job_result_metadata(&event); diff --git a/crates/protocol/tests/fixtures/protocol_v1.inventory.json b/crates/protocol/tests/fixtures/protocol_v1.inventory.json @@ -42,7 +42,7 @@ { "module": "error::v1", "path": "crates/protocol/src/error/v1.rs", - "sha256": "3a4043ea8f1a457193c9f0fdcd00427af650b3db560b55dbc64774a8a5013107", + "sha256": "7de3cf1646109babebebe35da48d24ed3d50ae91132032e2137c2772d89a6eb0", "types": [ { "rust_path": "radroots_protocol::error::v1::CapabilityId", diff --git a/crates/replica_sync/src/ingest.rs b/crates/replica_sync/src/ingest.rs @@ -29,10 +29,14 @@ use radroots_event::listing::operational::{ OperationalListing, OperationalListingAvailability, OperationalListingBin, OperationalListingStatus, }; +use radroots_event::profile::{ + ProfileType, RADROOTS_PROFILE_TYPE_TAG_KEY, radroots_profile_type_from_tag_value, +}; use radroots_event::{ envelope::EventEnvelope, listing::classified::{ClassifiedListingPartition, classify_classified_listing_tags}, }; +use radroots_event_codec::admission::profile::admit_verified_profile_event; use radroots_event_codec::decode::farm as farm_decode; use radroots_event_codec::decode::food_availability::{ RadrootsFoodAvailabilityProjectionOutcome, project_verified_food_availability_event, @@ -40,7 +44,6 @@ use radroots_event_codec::decode::food_availability::{ use radroots_event_codec::decode::list_set as list_set_decode; use radroots_event_codec::decode::operational_listing as listing_decode; use radroots_event_codec::decode::plot as plot_decode; -use radroots_event_codec::decode::profile as profile_decode; use radroots_event_codec::verify::{RadrootsSignatureVerifiedEvent, verify_nip01_event}; use radroots_replica_schema::ReplicaSchemaError; use radroots_replica_schema::farm::{ @@ -144,9 +147,9 @@ pub(crate) mod failpoints { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RadrootsReplicaIngestOutcome { - /// The selected event updated its supported legacy projection and raw head. + /// The selected event updated its supported replica projection and raw head. Applied, - /// The selected raw head belongs to a valid profile this legacy projection excludes. + /// The selected raw head belongs to a valid profile this replica excludes. Excluded, /// The selected raw head is invalid or ambiguous for its declared profile. Rejected, @@ -171,10 +174,7 @@ impl RadrootsReplicaIdFactory for RadrootsReplicaDefaultIdFactory { } #[cfg(feature = "std")] -/// Ingests an envelope through the legacy replica projection. -/// -/// The Profile branch currently requires a legacy Profile marker tag and is -/// not the strict Profile inbound-admission boundary. +/// Verifies and ingests an envelope through its supported replica projection. pub fn radroots_replica_ingest_event( exec: &dyn SqlExecutor, event: &EventEnvelope, @@ -182,19 +182,15 @@ pub fn radroots_replica_ingest_event( radroots_replica_ingest_event_with_factory(exec, event, &RadrootsReplicaDefaultIdFactory) } -/// Ingests an envelope through the legacy replica projection with an ID source. -/// -/// The Profile branch currently requires a legacy Profile marker tag and is -/// not the strict Profile inbound-admission boundary. +/// Verifies and ingests an envelope with an explicit replica ID source. pub fn radroots_replica_ingest_event_with_factory( exec: &dyn SqlExecutor, event: &EventEnvelope, factory: &dyn RadrootsReplicaIdFactory, ) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> { - let verified_classified_listing = if event.kind_u32() == KIND_CLASSIFIED_LISTING { - Some(verify_nip01_event(event.clone())?) - } else { - None + let verified_event = match event.kind_u32() { + KIND_PROFILE | KIND_CLASSIFIED_LISTING => Some(verify_nip01_event(event.clone())?), + _ => None, }; if let Err(err) = exec.begin() { @@ -203,7 +199,7 @@ pub fn radroots_replica_ingest_event_with_factory( ))); } - match ingest_event_inner(exec, event, factory, verified_classified_listing.as_ref()) { + match ingest_event_inner(exec, event, factory, verified_event.as_ref()) { Ok(outcome) => { if let Err(err) = exec.commit() { return Err(RadrootsReplicaEventsError::from(ReplicaSchemaError::from( @@ -223,14 +219,21 @@ fn ingest_event_inner( exec: &dyn SqlExecutor, event: &EventEnvelope, factory: &dyn RadrootsReplicaIdFactory, - verified_classified_listing: Option<&RadrootsSignatureVerifiedEvent>, + verified_event: Option<&RadrootsSignatureVerifiedEvent>, ) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> { match event.kind_u32() { - KIND_PROFILE => ingest_profile_event(exec, event), + KIND_PROFILE => { + let verified_event = verified_event.ok_or_else(|| { + RadrootsReplicaEventsError::InvalidData( + "profile verification invariant missing".to_string(), + ) + })?; + ingest_profile_event(exec, verified_event) + } KIND_FARM => ingest_farm_event(exec, event, factory), KIND_PLOT => ingest_plot_event(exec, event, factory), KIND_CLASSIFIED_LISTING => { - let verified_event = verified_classified_listing.ok_or_else(|| { + let verified_event = verified_event.ok_or_else(|| { RadrootsReplicaEventsError::InvalidData( "classified listing verification invariant missing".to_string(), ) @@ -249,18 +252,13 @@ fn ingest_event_inner( fn ingest_profile_event( exec: &dyn SqlExecutor, - event: &EventEnvelope, + verified_event: &RadrootsSignatureVerifiedEvent, ) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> { - let data_result = profile_decode::data_from_event( - event.id_hex(), - event.author().to_hex().to_owned(), - event.created_at_u64(), - event.kind_u32(), - event.content().to_owned(), - event.tags_as_vec(), - ); - let data = data_result?; - let profile_type = match data.data.profile_type { + let admitted = admit_verified_profile_event(verified_event.clone()) + .map_err(|error| RadrootsReplicaEventsError::InvalidData(error.to_string()))?; + let event = admitted.event(); + let metadata = admitted.metadata(); + let profile_type = match profile_type_from_event(event) { Some(profile_type) => profile_type, None => { return Err(RadrootsReplicaEventsError::InvalidData( @@ -286,7 +284,7 @@ fn ingest_profile_event( exec, &INostrProfileFindOne::On(INostrProfileFindOneArgs { on: NostrProfileQueryBindValues::PublicKey { - public_key: data.author.clone(), + public_key: event.author().to_hex().to_owned(), }, }), ); @@ -297,15 +295,15 @@ fn ingest_profile_event( let fields = INostrProfileFieldsPartial { public_key: None, profile_type: Some(Value::from(profile_type)), - name: Some(Value::from(data.data.profile.name)), - display_name: to_value_opt(data.data.profile.display_name), - about: to_value_opt(data.data.profile.about), - website: to_value_opt(data.data.profile.website), - picture: to_value_opt(data.data.profile.picture), - banner: to_value_opt(data.data.profile.banner), - nip05: to_value_opt(data.data.profile.nip05), - lud06: to_value_opt(data.data.profile.lud06), - lud16: to_value_opt(data.data.profile.lud16), + name: Some(Value::from(required_profile_name(metadata)?)), + display_name: to_value_opt(metadata.display_name().map(str::to_owned)), + about: to_value_opt(metadata.about().map(str::to_owned)), + website: to_value_opt(profile_string_field(metadata, "website")), + picture: to_value_opt(metadata.picture().map(|value| value.as_str().to_owned())), + banner: to_value_opt(metadata.banner().map(|value| value.as_str().to_owned())), + nip05: to_value_opt(metadata.nip05().map(|value| value.as_str().to_owned())), + lud06: to_value_opt(profile_string_field(metadata, "lud06")), + lud16: to_value_opt(profile_string_field(metadata, "lud16")), }; let update_result = nostr_profile::update( exec, @@ -318,17 +316,17 @@ fn ingest_profile_event( } None => { let fields = INostrProfileFields { - public_key: data.author.clone(), + public_key: event.author().to_hex().to_owned(), profile_type: profile_type.to_string(), - name: data.data.profile.name, - display_name: data.data.profile.display_name, - about: data.data.profile.about, - website: data.data.profile.website, - picture: data.data.profile.picture, - banner: data.data.profile.banner, - nip05: data.data.profile.nip05, - lud06: data.data.profile.lud06, - lud16: data.data.profile.lud16, + name: required_profile_name(metadata)?, + display_name: metadata.display_name().map(str::to_owned), + about: metadata.about().map(str::to_owned), + website: profile_string_field(metadata, "website"), + picture: metadata.picture().map(|value| value.as_str().to_owned()), + banner: metadata.banner().map(|value| value.as_str().to_owned()), + nip05: metadata.nip05().map(|value| value.as_str().to_owned()), + lud06: profile_string_field(metadata, "lud06"), + lud16: profile_string_field(metadata, "lud16"), }; let _ = nostr_profile::create(exec, &fields)?; } @@ -338,6 +336,38 @@ fn ingest_profile_event( Ok(RadrootsReplicaIngestOutcome::Applied) } +fn profile_type_from_event(event: &EventEnvelope) -> Option<ProfileType> { + event + .tags_as_vec() + .into_iter() + .filter(|tag| { + tag.first() + .is_some_and(|key| key == RADROOTS_PROFILE_TYPE_TAG_KEY) + }) + .filter_map(|tag| tag.get(1).cloned()) + .find_map(|value| radroots_profile_type_from_tag_value(&value)) +} + +fn required_profile_name( + metadata: &radroots_event_codec::decode::profile::RadrootsInboundProfileMetadata, +) -> Result<String, RadrootsReplicaEventsError> { + metadata + .name() + .map(str::to_owned) + .ok_or_else(|| RadrootsReplicaEventsError::InvalidData("profile name required".to_string())) +} + +fn profile_string_field( + metadata: &radroots_event_codec::decode::profile::RadrootsInboundProfileMetadata, + field: &'static str, +) -> Option<String> { + metadata + .raw_fields() + .get(field) + .and_then(Value::as_str) + .map(str::to_owned) +} + fn ingest_farm_event( exec: &dyn SqlExecutor, event: &EventEnvelope, @@ -1703,6 +1733,19 @@ mod tests { test_event_with_parts(event, event.kind_u32(), event.tags_as_vec(), content) } + fn test_event_with_id(event: &EventEnvelope, id: String) -> EventEnvelope { + EventEnvelope::new(EventEnvelopeParts { + id, + author: event.author().to_hex().to_owned(), + created_at: event.created_at_u64(), + kind: event.kind_u32(), + tags: event.tags_as_vec(), + content: event.content().to_owned(), + sig: event.signature_hex(), + }) + .expect("test event id") + } + struct FixedFactory; impl RadrootsReplicaIdFactory for FixedFactory { @@ -1917,6 +1960,46 @@ mod tests { ) } + fn test_keys_for_author(author: &str) -> Keys { + if author == FIXTURE_ALICE_PUBLIC_KEY_HEX { + return Keys::parse(FIXTURE_ALICE_SECRET_KEY_HEX).expect("fixture signing key"); + } + (1_u8..=u8::MAX) + .find_map(|seed| { + let keys = Keys::parse(&format!("{seed:064x}")).ok()?; + (keys.public_key().to_hex() == author).then_some(keys) + }) + .expect("test author must resolve to a fixture signing key") + } + + fn sign_test_event(event: &EventEnvelope) -> EventEnvelope { + let keys = test_keys_for_author(&event.author().to_hex()); + let tags = event + .tags_as_vec() + .into_iter() + .map(|tag| Tag::parse(tag).expect("test event tag")) + .collect::<Vec<_>>(); + let event = EventBuilder::new( + Kind::Custom(u16::try_from(event.kind_u32()).expect("test event kind")), + event.content(), + ) + .tags(tags) + .allow_self_tagging() + .custom_created_at(Timestamp::from_secs(event.created_at_u64())) + .sign_with_keys(&keys) + .expect("signed test event"); + from_nostr(&event).expect("test event adapter") + } + + fn ingest_test_profile( + exec: &dyn SqlExecutor, + event: &EventEnvelope, + ) -> Result<RadrootsReplicaIngestOutcome, RadrootsReplicaEventsError> { + let verified = + verify_nip01_event(sign_test_event(event)).expect("verified profile fixture"); + ingest_profile_event(exec, &verified) + } + fn farm_event( id: u64, author: &str, @@ -2483,9 +2566,10 @@ mod tests { "alice", ); let profile_no_type = profile_event(9, &profile_pubkey, 0, None, "alice-none"); - assert!(ingest_profile_event(&exec, &profile_no_type).is_err()); + assert!(ingest_test_profile(&exec, &profile_no_type).is_err()); + let signed_profile = sign_test_event(&profile); assert_eq!( - radroots_replica_ingest_event(&exec, &profile).expect("ingest wrapper"), + radroots_replica_ingest_event(&exec, &signed_profile).expect("ingest wrapper"), RadrootsReplicaIngestOutcome::Applied ); let profile_update = profile_event( @@ -2496,11 +2580,11 @@ mod tests { "alice-2", ); assert_eq!( - ingest_profile_event(&exec, &profile_update).expect("profile update"), + ingest_test_profile(&exec, &profile_update).expect("profile update"), RadrootsReplicaIngestOutcome::Applied ); assert_eq!( - ingest_profile_event(&exec, &profile_update).expect("profile skip"), + ingest_test_profile(&exec, &profile_update).expect("profile skip"), RadrootsReplicaIngestOutcome::Skipped ); let profile_older = profile_event( @@ -2512,24 +2596,15 @@ mod tests { ); let decision_old = event_head_decision(&exec, &profile_older).expect("decision old"); assert!(!decision_old.apply); - let decision_same = event_head_decision(&exec, &profile_update).expect("decision same"); + let signed_profile_update = sign_test_event(&profile_update); + let decision_same = + event_head_decision(&exec, &signed_profile_update).expect("decision same"); assert!(!decision_same.apply); - let profile_same_time_higher_id = profile_event( - 12, - &profile_pubkey, - 2, - Some(ProfileType::Individual), - "alice-3", - ); + let profile_same_time_higher_id = + test_event_with_id(&signed_profile_update, "f".repeat(64)); let decision = event_head_decision(&exec, &profile_same_time_higher_id).expect("decision"); assert!(!decision.apply); - let profile_same_time_lower_id = profile_event( - 10, - &profile_pubkey, - 2, - Some(ProfileType::Individual), - "alice-0", - ); + let profile_same_time_lower_id = test_event_with_id(&signed_profile_update, "0".repeat(64)); let decision = event_head_decision(&exec, &profile_same_time_lower_id).expect("decision"); assert!(decision.apply); @@ -3476,13 +3551,14 @@ mod tests { Some(ProfileType::Individual), "pass-profile", ); + let signed_profile = sign_test_event(&profile); assert_eq!( - radroots_replica_ingest_event_with_factory(&pass, &profile, &FixedFactory) + radroots_replica_ingest_event_with_factory(&pass, &signed_profile, &FixedFactory) .expect("profile ingest"), RadrootsReplicaIngestOutcome::Applied ); assert_eq!( - ingest_profile_event(&pass, &profile).expect("profile skip"), + ingest_test_profile(&pass, &profile).expect("profile skip"), RadrootsReplicaIngestOutcome::Skipped ); @@ -3715,17 +3791,18 @@ mod tests { "txn-profile", ); assert_eq!( - ingest_profile_event(&pass_txn, &profile_event_row).expect("txn profile"), + ingest_test_profile(&pass_txn, &profile_event_row).expect("txn profile"), RadrootsReplicaIngestOutcome::Applied ); + let signed_profile_event_row = sign_test_event(&profile_event_row); let profile_decision = - event_head_decision(&pass_txn, &profile_event_row).expect("profile decision"); + event_head_decision(&pass_txn, &signed_profile_event_row).expect("profile decision"); assert!(!profile_decision.apply); - assert!(radroots_replica_ingest_event_head(&pass_txn, &profile_event_row).is_ok()); + assert!(radroots_replica_ingest_event_head(&pass_txn, &signed_profile_event_row).is_ok()); assert_eq!( radroots_replica_ingest_event_with_factory( &pass_txn, - &profile_event_row, + &signed_profile_event_row, &FixedFactory ) .expect("txn wrapper"), @@ -3825,12 +3902,17 @@ mod tests { rollback_count, }; - assert!(ingest_profile_event(&txn, &profile_event_row).is_err()); + assert!(ingest_test_profile(&txn, &profile_event_row).is_err()); assert!(event_head_decision(&txn, &profile_event_row).is_err()); assert!(radroots_replica_ingest_event_head(&txn, &profile_event_row).is_err()); + let signed_profile_event_row = sign_test_event(&profile_event_row); assert!( - radroots_replica_ingest_event_with_factory(&txn, &profile_event_row, &FixedFactory) - .is_err() + radroots_replica_ingest_event_with_factory( + &txn, + &signed_profile_event_row, + &FixedFactory + ) + .is_err() ); assert!(ingest_farm_event(&txn, &farm_event_row, &FixedFactory).is_err()); @@ -3897,17 +3979,17 @@ mod tests { "profile-base", ); let profile_bad_content = test_event_with_content(&profile, "{".to_string()); - assert!(ingest_profile_event(&exec, &profile_bad_content).is_err()); + assert!(ingest_test_profile(&exec, &profile_bad_content).is_err()); let profile_query_fail = QueryFailExecutor { inner: &exec, needle: "nostr_profile", err: SqlError::Internal, }; - assert!(ingest_profile_event(&profile_query_fail, &profile).is_err()); + assert!(ingest_test_profile(&profile_query_fail, &profile).is_err()); assert_eq!( - ingest_profile_event(&exec, &profile).expect("profile seed"), + ingest_test_profile(&exec, &profile).expect("profile seed"), RadrootsReplicaIngestOutcome::Applied ); let profile_update = profile_event( @@ -3922,7 +4004,7 @@ mod tests { needle: "update nostr_profile", err: SqlError::Internal, }; - assert!(ingest_profile_event(&profile_update_fail, &profile_update).is_err()); + assert!(ingest_test_profile(&profile_update_fail, &profile_update).is_err()); let profile_create_fail = QueryFailExecutor { inner: &exec, @@ -3936,7 +4018,7 @@ mod tests { Some(ProfileType::Individual), "profile-new", ); - assert!(ingest_profile_event(&profile_create_fail, &profile_new).is_err()); + assert!(ingest_test_profile(&profile_create_fail, &profile_new).is_err()); let profile_state_fail = QueryFailExecutor { inner: &exec, @@ -3950,7 +4032,7 @@ mod tests { Some(ProfileType::Individual), "profile-state", ); - assert!(ingest_profile_event(&profile_state_fail, &profile_state_event).is_err()); + assert!(ingest_test_profile(&profile_state_fail, &profile_state_event).is_err()); let farm_seed = farm_event( 810, @@ -4455,7 +4537,7 @@ mod tests { needle: "insert into nostr_event_head", err: SqlError::Internal, }; - assert!(ingest_profile_event(&state_insert_fail, &profile).is_err()); + assert!(ingest_test_profile(&state_insert_fail, &profile).is_err()); let farm_state = farm_event( 901, diff --git a/crates/replica_sync/tests/ingest_roundtrip.rs b/crates/replica_sync/tests/ingest_roundtrip.rs @@ -1,4 +1,4 @@ -use nostr::Keys; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use radroots_event::envelope::kind::{ KIND_FARM, KIND_LIST_SET_FOLLOW, KIND_LIST_SET_GENERIC, KIND_PLOT, KIND_PROFILE, }; @@ -16,6 +16,7 @@ use radroots_event_codec::encode::farm as farm_list_sets; use radroots_event_codec::encode::list_set as list_set_encode; use radroots_event_codec::encode::plot as plot_encode; use radroots_event_codec::{decode::EventParseError, encode::EventEncodeError}; +use radroots_nostr::event::from_nostr; use radroots_replica_schema::ReplicaSchemaError; use radroots_replica_schema::farm::{IFarmFields, IFarmFieldsFilter, IFarmFindMany}; use radroots_replica_schema::farm_gcs_location::IFarmGcsLocationFields; @@ -980,7 +981,7 @@ fn sample_gcs(lat: f64, lng: f64, geohash: &str) -> GcsLocation { } fn profile_event( - id: u64, + _id: u64, author: &str, created_at: u32, profile_type: Option<ProfileType>, @@ -1004,14 +1005,22 @@ fn profile_event( radroots_profile_type_tag_value(kind).to_string(), ]); } - event_with_parts( - id, - author, - created_at, - KIND_PROFILE, - profile.to_string(), - tags, - ) + let keys = (1_u8..=u8::MAX) + .find_map(|seed| { + let keys = Keys::parse(&format!("{seed:064x}")).ok()?; + (keys.public_key().to_hex() == author).then_some(keys) + }) + .expect("profile author must resolve to a fixture signing key"); + let tags = tags + .into_iter() + .map(|tag| Tag::parse(tag).expect("profile tag")) + .collect::<Vec<_>>(); + let event = EventBuilder::new(Kind::Metadata, profile.to_string()) + .tags(tags) + .custom_created_at(Timestamp::from_secs(u64::from(created_at))) + .sign_with_keys(&keys) + .expect("signed profile event"); + from_nostr(&event).expect("profile event adapter") } fn farm_event( @@ -1117,25 +1126,35 @@ fn ingest_event_paths_cover_profile_farm_plot_and_list_set_variants() { radroots_replica_ingest_event(&exec, &profile_older).expect("profile skip older"), RadrootsReplicaIngestOutcome::Skipped ); - let profile_same_time_higher_id = profile_event( - 103, - &profile_pubkey, - 10, - Some(ProfileType::Individual), - "alice-updated", - ); + let profile_same_time_higher_id = (0_u32..1_024) + .map(|index| { + profile_event( + u64::from(index), + &profile_pubkey, + 10, + Some(ProfileType::Individual), + &format!("alice-higher-{index}"), + ) + }) + .find(|event| event.id_hex() > profile_create.id_hex()) + .expect("same-time fixture with a higher event id"); assert_eq!( radroots_replica_ingest_event(&exec, &profile_same_time_higher_id) .expect("profile skip same timestamp higher id"), RadrootsReplicaIngestOutcome::Skipped ); - let profile_same_time_lower_id = profile_event( - 100, - &profile_pubkey, - 10, - Some(ProfileType::Individual), - "alice-lower-id", - ); + let profile_same_time_lower_id = (0_u32..1_024) + .map(|index| { + profile_event( + u64::from(index), + &profile_pubkey, + 10, + Some(ProfileType::Individual), + &format!("alice-lower-{index}"), + ) + }) + .find(|event| event.id_hex() < profile_create.id_hex()) + .expect("same-time fixture with a lower event id"); assert_eq!( radroots_replica_ingest_event(&exec, &profile_same_time_lower_id) .expect("profile apply same timestamp lower id"), diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -95,6 +95,7 @@ uuid = { workspace = true, optional = true, features = ["v4"] } [dev-dependencies] nostr = { workspace = true, features = ["std"] } +serde_json = { workspace = true, features = ["std"] } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/signing/src/authorization.rs b/crates/signing/src/authorization.rs @@ -78,3 +78,47 @@ impl ManagedSigningPolicy { } } } + +#[cfg(test)] +mod tests { + use radroots_event::contract::AuthorRole; + use radroots_identity::{AccountId, PublicKey}; + + use super::*; + + const KEY: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + + #[test] + fn managed_signing_policy_covers_every_provenance_class() { + let public_key = PublicKey::from_hex(KEY).expect("public key"); + let account_id = AccountId::from_hex(KEY).expect("account ID"); + let explicit = Actor::new( + public_key, + ActorSource::ExplicitPublicKey, + [AuthorRole::Any], + ) + .expect("explicit actor"); + let local = Actor::new( + public_key, + ActorSource::LocalAccount(account_id), + [AuthorRole::Any], + ) + .expect("local actor"); + let remote = Actor::new( + public_key, + ActorSource::RemoteSigner(account_id), + [AuthorRole::Any], + ) + .expect("remote actor"); + + for actor in [&explicit, &local, &remote] { + assert!(ManagedSigningPolicy::AnyValidatedSource.permits(actor)); + } + assert!(!ManagedSigningPolicy::AccountBackedOnly.permits(&explicit)); + assert!(ManagedSigningPolicy::AccountBackedOnly.permits(&local)); + assert!(ManagedSigningPolicy::AccountBackedOnly.permits(&remote)); + assert!(!ManagedSigningPolicy::LocalAccountOnly.permits(&explicit)); + assert!(ManagedSigningPolicy::LocalAccountOnly.permits(&local)); + assert!(!ManagedSigningPolicy::LocalAccountOnly.permits(&remote)); + } +} diff --git a/crates/signing/src/identity.rs b/crates/signing/src/identity.rs @@ -115,3 +115,57 @@ impl fmt::Debug for SignerRequestId { .finish() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_identities_reject_zero_and_expose_exact_bytes() { + assert_eq!( + SigningOperationId::new([0; 16]) + .expect_err("zero operation ID must fail") + .kind(), + Kind::InvalidArgument + ); + assert_eq!( + AuthoredArtifactId::new([0; 16]) + .expect_err("zero artifact ID must fail") + .kind(), + Kind::InvalidArgument + ); + + let operation = SigningOperationId::new([1; 16]).expect("operation ID"); + let artifact = AuthoredArtifactId::new([2; 16]).expect("artifact ID"); + assert_eq!(operation.as_bytes(), &[1; 16]); + assert_eq!(artifact.as_bytes(), &[2; 16]); + assert_eq!(operation.to_hex(), "01".repeat(16)); + assert_eq!(artifact.to_hex(), "02".repeat(16)); + assert_eq!( + format!("{operation:?}"), + format!("SigningOperationId(\"{}\")", "01".repeat(16)) + ); + assert_eq!( + format!("{artifact:?}"), + format!("AuthoredArtifactId(\"{}\")", "02".repeat(16)) + ); + + let intent = SigningIntentId::new(operation, artifact); + assert_eq!(intent.operation_id(), operation); + assert_eq!(intent.artifact_id(), artifact); + } + + #[test] + fn signer_request_identity_is_deterministic_and_domain_separated() { + let artifact = AuthoredArtifactId::new([3; 16]).expect("artifact ID"); + let digest = PlanDigest::from_bytes([4; 32]); + let request = SignerRequestId::derive(artifact, digest); + assert_eq!(request.as_bytes().len(), 32); + assert_eq!(request.to_hex().len(), 64); + assert_eq!(request, SignerRequestId::derive(artifact, digest)); + assert_eq!( + format!("{request:?}"), + format!("SignerRequestId(\"{}\")", request.to_hex()) + ); + } +} diff --git a/crates/signing/src/recovery.rs b/crates/signing/src/recovery.rs @@ -49,3 +49,56 @@ pub const fn recovery_disposition( (ReplayCapability::NonReplayable, RemoteEffect::None) => RecoveryDisposition::Failed, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recovery_decision_table_is_exhaustive() { + for replay in [ + ReplayCapability::ExactReplayByRequestId, + ReplayCapability::LocalReplaySafe, + ReplayCapability::NonReplayable, + ] { + for remote_effect in [RemoteEffect::None, RemoteEffect::MayHaveOccurred] { + assert_eq!( + recovery_disposition(replay, remote_effect, false), + RecoveryDisposition::Failed + ); + } + } + assert_eq!( + recovery_disposition( + ReplayCapability::ExactReplayByRequestId, + RemoteEffect::MayHaveOccurred, + true, + ), + RecoveryDisposition::RetryExactRequest + ); + assert_eq!( + recovery_disposition(ReplayCapability::LocalReplaySafe, RemoteEffect::None, true,), + RecoveryDisposition::RetryLocal + ); + assert_eq!( + recovery_disposition( + ReplayCapability::LocalReplaySafe, + RemoteEffect::MayHaveOccurred, + true, + ), + RecoveryDisposition::Indeterminate + ); + assert_eq!( + recovery_disposition( + ReplayCapability::NonReplayable, + RemoteEffect::MayHaveOccurred, + true, + ), + RecoveryDisposition::Indeterminate + ); + assert_eq!( + recovery_disposition(ReplayCapability::NonReplayable, RemoteEffect::None, true,), + RecoveryDisposition::Failed + ); + } +} diff --git a/crates/signing/tests/authored_signing.rs b/crates/signing/tests/authored_signing.rs @@ -18,7 +18,8 @@ use radroots_signing::{ authorization::ManagedSigningPolicy, error::Kind, recovery::{RecoveryDisposition, RemoteEffect, ReplayCapability, recovery_disposition}, - request::{CancellationPolicy, CancellationSignal, SignPolicy}, + request::{CancellationPolicy, CancellationSignal, ProgressObserver, SignPolicy}, + status::{SignProgress, SignProgressStage}, }; const SECRET: &str = "7e0112ad58b2d2d13fb80532625195dc169b86d72b0e1db48347837a785cae90"; @@ -84,9 +85,26 @@ fn request() -> SignRequest { } fn signed_event() -> radroots_event::SignedEvent { - let event = EventBuilder::new(NostrKind::Custom(20_000), "exact signing plan") - .custom_created_at(Timestamp::from_secs(CREATED_AT)) - .sign_with_keys(&keys()) + signed_event_with( + &keys(), + 20_000, + "exact signing plan", + CREATED_AT, + Vec::new(), + ) +} + +fn signed_event_with( + signer: &Keys, + kind: u16, + content: &str, + created_at: u64, + tags: Vec<nostr::Tag>, +) -> radroots_event::SignedEvent { + let event = EventBuilder::new(NostrKind::Custom(kind), content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_at)) + .sign_with_keys(signer) .expect("signed fixture"); let raw = event.as_json(); let wire = Nip01EventWire::parse_json(&raw).expect("wire"); @@ -221,6 +239,12 @@ fn authorization_enforces_key_role_and_host_provenance() { fn request_identity_deadline_and_cancellation_are_exact() { let request = request(); let replay = request.clone(); + assert_eq!(request.operation_kind(), OperationId::SyncPush); + assert_eq!(request.intent_id(), intent(1, 2)); + assert_eq!(request.actor().public_key(), public_key()); + assert_eq!(request.plan().digest(), plan().digest()); + assert_eq!(request.policy(), policy()); + assert!(!request.cancellation_signal().is_cancelled()); assert_eq!(request.signer_request_id(), replay.signer_request_id()); let other_artifact = SignRequest::new( OperationId::SyncPush, @@ -247,6 +271,17 @@ fn request_identity_deadline_and_cancellation_are_exact() { cancelled.ensure_active(DEADLINE_MS - 1).unwrap_err().kind(), Kind::SignerCancelled ); + + struct Counter(std::sync::atomic::AtomicUsize); + impl ProgressObserver for Counter { + fn on_progress(&self, _progress: &SignProgress) { + self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + } + let observer = std::sync::Arc::new(Counter(std::sync::atomic::AtomicUsize::new(0))); + let observed = request.with_progress_observer(observer.clone()); + observed.report_progress(&SignProgress::stage(SignProgressStage::Validating).unwrap()); + assert_eq!(observer.0.load(std::sync::atomic::Ordering::Relaxed), 1); } #[test] @@ -258,6 +293,48 @@ fn receipt_requires_exact_fields_and_a_valid_schnorr_signature() { assert_eq!(receipt.signer_request_id(), request.signer_request_id()); assert_eq!(receipt.operation_kind(), OperationId::SyncPush); assert_eq!(receipt.completed_at_unix_ms(), DEADLINE_MS - 1); + assert_eq!(receipt.signed_event().id(), signed_event().id()); + + let other_keys = Keys::generate(); + let mismatches = [ + signed_event_with( + &other_keys, + 20_000, + "exact signing plan", + CREATED_AT, + Vec::new(), + ), + signed_event_with( + &keys(), + 20_000, + "exact signing plan", + CREATED_AT + 1, + Vec::new(), + ), + signed_event_with( + &keys(), + 20_001, + "exact signing plan", + CREATED_AT, + Vec::new(), + ), + signed_event_with( + &keys(), + 20_000, + "exact signing plan", + CREATED_AT, + vec![nostr::Tag::parse(["t", "mismatch"]).expect("tag")], + ), + signed_event_with(&keys(), 20_000, "different content", CREATED_AT, Vec::new()), + ]; + for mismatch in mismatches { + assert_eq!( + SignReceipt::from_signed_event(&request, mismatch, DEADLINE_MS - 1) + .expect_err("mismatched exact plan must fail") + .kind(), + Kind::SignerOutputInvalid + ); + } let valid = signed_event(); let mut wire = valid.wire().clone(); diff --git a/crates/storage_sqlite/src/backup.rs b/crates/storage_sqlite/src/backup.rs @@ -1118,7 +1118,10 @@ fn sync_parent(path: &Path, operation: &'static str) -> Result<(), Error> { #[cfg_attr(coverage_nightly, coverage(off))] fn create_private_directory(path: &Path, operation: &'static str) -> Result<(), Error> { + #[cfg(unix)] let mut builder = fs::DirBuilder::new(); + #[cfg(not(unix))] + let builder = fs::DirBuilder::new(); #[cfg(unix)] { use std::os::unix::fs::DirBuilderExt; @@ -2432,18 +2435,22 @@ mod tests { .await .expect("finalize restore") }); - for _ in 0..10_000 { - if store - .storage_status() - .await - .expect("restoring status") - .shutdown() - == ShutdownState::Closing - { - break; + tokio::time::timeout(std::time::Duration::from_secs(30), async { + loop { + if store + .storage_status() + .await + .expect("restoring status") + .shutdown() + == ShutdownState::Closing + { + break; + } + tokio::task::yield_now().await; } - tokio::task::yield_now().await; - } + }) + .await + .expect("restore enters closing state"); assert!(!finalization.is_finished()); assert_eq!( store diff --git a/crates/storage_sqlite/src/legacy.rs b/crates/storage_sqlite/src/legacy.rs @@ -2370,7 +2370,10 @@ impl LegacyBackupLayout { return Err(Error::LegacyImportBackupAlreadyExists(path.clone())); } } + #[cfg(unix)] let mut builder = fs::DirBuilder::new(); + #[cfg(not(unix))] + let builder = fs::DirBuilder::new(); #[cfg(unix)] { use std::os::unix::fs::DirBuilderExt; diff --git a/crates/studio_application/src/accounts.rs b/crates/studio_application/src/accounts.rs @@ -931,8 +931,10 @@ mod tests { use super::InMemoryAccountRepository; use crate::{ AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, Clock, - FailureSecretStore, InMemoryOperationJournal, InMemorySecretStore, OperationJournal, + DurableOperationKind, DurableOperationPhase, FailureSecretStore, InMemoryOperationJournal, + InMemorySecretStore, OperationJournal, ProfileRefreshStatus, ProfileRepository, RelayConfiguration, SecretStore, SecretStoreOperation, SessionState, StateTransition, + recovery::tests::{TestDurableRepository, operation as durable_operation}, }; struct FixedClock; @@ -951,6 +953,71 @@ mod tests { } } + struct EmptyProfiles; + + impl ProfileRepository for EmptyProfiles { + fn load_profile( + &self, + _public_key: PublicKey, + ) -> Result<Option<crate::CachedProfile>, SafeError> { + Ok(None) + } + + fn save_profile(&self, _profile: &crate::CachedProfile) -> Result<(), SafeError> { + Ok(()) + } + + fn record_refresh_status( + &self, + _public_key: PublicKey, + _refreshed_at: UnixTimestamp, + _status: ProfileRefreshStatus, + ) -> Result<(), SafeError> { + Ok(()) + } + + fn remove_profile(&self, _public_key: PublicKey) -> Result<(), SafeError> { + Ok(()) + } + } + + #[derive(Default)] + struct FailingUpdateJournal(InMemoryOperationJournal); + + impl OperationJournal for FailingUpdateJournal { + fn begin_operation( + &self, + kind: crate::AccountOperationKind, + subject: PublicKey, + updated_at: UnixTimestamp, + ) -> Result<crate::OperationId, SafeError> { + self.0.begin_operation(kind, subject, updated_at) + } + + fn update_operation( + &self, + _id: crate::OperationId, + _phase: AccountOperationPhase, + _updated_at: UnixTimestamp, + _diagnostic: Option<crate::OperationDiagnostic>, + ) -> Result<(), SafeError> { + Err(SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The test journal is unavailable."), + )) + } + + fn list_pending_operations( + &self, + ) -> Result<Vec<crate::PendingAccountOperation>, SafeError> { + self.0.list_pending_operations() + } + + fn finalize_operation(&self, id: crate::OperationId) -> Result<(), SafeError> { + self.0.finalize_operation(id) + } + } + #[derive(Default)] struct FailingInsertRepository { inner: InMemoryAccountRepository, @@ -1414,4 +1481,342 @@ mod tests { assert!(core.cancel_account_removal(cancelled)); assert_eq!(core.snapshot().accounts().len(), 1); } + + #[test] + fn import_rejects_orphan_credentials_and_durable_nonterminal_replays() { + const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let material = core + .key_material() + .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret")) + .expect("key material"); + let (public_key, _npub, secret) = material.into_parts(); + secrets.put(public_key, secret).expect("orphan credential"); + + assert_eq!( + core.import_secret_key( + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) + .expect_err("orphan credential must fail") + .code(), + SafeErrorCode::AccountAlreadyExists + ); + + let pending = durable_operation( + DurableOperationKind::Import, + DurableOperationPhase::IntentRecorded, + public_key, + None, + ); + let request_id = pending.request_id().clone(); + let operations = TestDurableRepository::new(pending); + assert_eq!( + core.import_secret_key_durable( + &request_id, + core.snapshot().revision().value(), + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + &accounts, + &accounts, + &secrets, + &operations, + &FixedClock, + ) + .expect_err("unfinished replay must require recovery") + .code(), + SafeErrorCode::PendingOperationRecoveryRequired + ); + } + + #[test] + fn durable_import_covers_new_and_missing_credential_repair_paths() { + const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + for repair in [false, true] { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let material = core + .key_material() + .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret")) + .expect("key material"); + let (public_key, npub, secret) = material.into_parts(); + drop(secret); + if repair { + let account = AccountSummary::new( + AccountIdentity::verify(public_key, npub.as_str().to_owned()) + .expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), + None, + AccountCreatedAt::new(FixedClock.now()), + None, + ) + .expect("account"); + accounts.insert_account(&account).expect("insert account"); + accounts + .save_selected_account(Some(public_key)) + .expect("selection"); + core.apply_transition(StateTransition::BootstrapRegistry { + accounts: vec![account], + selected: Some(public_key), + }) + .expect("registry"); + } else { + core.bootstrap().expect("bootstrap"); + } + let kind = if repair { + DurableOperationKind::Repair + } else { + DurableOperationKind::Import + }; + let pending = durable_operation( + kind, + DurableOperationPhase::IntentRecorded, + public_key, + repair.then_some(BindingAvailability::CredentialMissing), + ); + let request_id = pending.request_id().clone(); + let operations = TestDurableRepository::fresh(pending); + let receipt = core + .import_secret_key_durable( + &request_id, + core.snapshot().revision().value(), + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + &accounts, + &accounts, + &secrets, + &operations, + &FixedClock, + ) + .expect("durable import"); + assert_eq!(receipt.account().public_key(), public_key); + assert_eq!( + operations.operation().phase(), + DurableOperationPhase::Finalized + ); + } + } + + #[test] + fn removal_of_unselected_account_preserves_the_current_selection() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let first = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("first") + .account() + .public_key(); + let second = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("second") + .account() + .public_key(); + let token = core + .request_account_removal(first, &FixedClock) + .expect("removal token"); + let snapshot = core + .confirm_account_removal(token, &accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("remove unselected account"); + assert_eq!(snapshot.selected_account(), Some(second)); + + let missing = crate::test_support::valid_test_public_key(99).expect("missing key"); + assert!(accounts.insert_account(&snapshot.accounts()[0]).is_err()); + assert!(accounts.save_selected_account(Some(missing)).is_err()); + + let third = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("third") + .account() + .public_key(); + let token = core + .request_account_removal(second, &FixedClock) + .expect("durable removal token"); + let pending = durable_operation( + DurableOperationKind::Remove, + DurableOperationPhase::IntentRecorded, + second, + Some(BindingAvailability::Available), + ); + let request_id = pending.request_id().clone(); + let operations = TestDurableRepository::fresh(pending); + let snapshot = core + .confirm_account_removal_durable( + &request_id, + token, + &accounts, + &accounts, + &secrets, + &operations, + &FixedClock, + ) + .expect("durable unselected removal"); + assert_eq!(snapshot.selected_account(), Some(third)); + } + + #[test] + fn duplicate_missing_binding_with_orphan_credential_fails_closed() { + const SECRET: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + let material = core + .key_material() + .import(SecretKeyInput::parse(SECRET.to_owned()).expect("secret")) + .expect("key material"); + let (public_key, npub, secret) = material.into_parts(); + let account = AccountSummary::new( + AccountIdentity::verify(public_key, npub.as_str().to_owned()).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::CredentialMissing), + None, + AccountCreatedAt::new(FixedClock.now()), + None, + ) + .expect("account"); + accounts.insert_account(&account).expect("insert account"); + accounts + .save_selected_account(Some(public_key)) + .expect("selection"); + secrets.put(public_key, secret).expect("credential"); + core.apply_transition(StateTransition::BootstrapRegistry { + accounts: vec![account], + selected: Some(public_key), + }) + .expect("registry"); + + assert_eq!( + core.import_secret_key( + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) + .expect_err("orphan credential must fail") + .code(), + SafeErrorCode::AccountAlreadyExists + ); + let pending = durable_operation( + DurableOperationKind::Repair, + DurableOperationPhase::IntentRecorded, + public_key, + Some(BindingAvailability::CredentialMissing), + ); + let request_id = pending.request_id().clone(); + let operations = TestDurableRepository::fresh(pending); + assert_eq!( + core.import_secret_key_durable( + &request_id, + core.snapshot().revision().value(), + SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), + &accounts, + &accounts, + &secrets, + &operations, + &FixedClock, + ) + .expect_err("orphan durable credential must fail") + .code(), + SafeErrorCode::AccountAlreadyExists + ); + } + + #[test] + fn removing_an_active_account_signs_out_for_legacy_and_durable_requests() { + for durable in [false, true] { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let public_key = core + .import_secret_key( + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" + .to_owned(), + ) + .expect("secret"), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) + .expect("account") + .account() + .public_key(); + core.activate_account( + public_key, + &accounts, + &accounts, + &EmptyProfiles, + &secrets, + &FixedClock, + ) + .expect("activate account"); + let token = core + .request_account_removal(public_key, &FixedClock) + .expect("removal token"); + let snapshot = if durable { + let pending = durable_operation( + DurableOperationKind::Remove, + DurableOperationPhase::IntentRecorded, + public_key, + Some(BindingAvailability::Available), + ); + let request_id = pending.request_id().clone(); + let operations = TestDurableRepository::fresh(pending); + core.confirm_account_removal_durable( + &request_id, + token, + &accounts, + &accounts, + &secrets, + &operations, + &FixedClock, + ) + .expect("durable removal") + } else { + core.confirm_account_removal( + token, + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) + .expect("removal") + }; + assert_eq!(snapshot.session(), SessionState::SignedOut); + } + } + + #[test] + fn account_transaction_compensates_a_journal_phase_failure() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = FailingUpdateJournal::default(); + core.bootstrap().expect("bootstrap"); + + assert_eq!( + core.generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .err() + .expect("journal failure must be returned") + .code(), + SafeErrorCode::StorageUnavailable + ); + assert!(accounts.list_accounts().unwrap().is_empty()); + } } diff --git a/crates/studio_application/src/actor.rs b/crates/studio_application/src/actor.rs @@ -765,6 +765,7 @@ mod tests { assert!(degraded.allows(RuntimeCommandClass::MutateLocalState)); assert!(!degraded.allows(RuntimeCommandClass::UseRelay)); degraded.restore_ready().expect("restored"); + assert!(LifecycleGate::opening().restore_ready().is_err()); let mut fatal = LifecycleGate::opening(); fatal.fail(problem); diff --git a/crates/studio_application/src/app_core.rs b/crates/studio_application/src/app_core.rs @@ -315,4 +315,44 @@ mod tests { assert!(!removal.impact().deletes_local_credential()); assert!(!removal.impact().signs_out()); } + + #[test] + fn removal_confirmation_rejects_every_tampered_authority_field() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let public_key = crate::test_support::valid_test_public_key(7).expect("public key"); + let other_key = crate::test_support::valid_test_public_key(8).expect("other key"); + let account = AccountSummary::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + None, + ) + .expect("account"); + core.apply_transition(StateTransition::BootstrapRegistry { + accounts: vec![account], + selected: Some(public_key), + }) + .expect("registry"); + + let now = UnixTimestamp::from_seconds(2).expect("now"); + let mut wrong_key = core.issue_removal_token(public_key, now).expect("token"); + wrong_key.public_key = other_key; + assert!(core.consume_removal_token(wrong_key, now).is_err()); + + let mut wrong_revision = core.issue_removal_token(public_key, now).expect("token"); + wrong_revision.revision = crate::SnapshotRevision::initial(); + assert!(core.consume_removal_token(wrong_revision, now).is_err()); + + let mut wrong_expiry = core.issue_removal_token(public_key, now).expect("token"); + wrong_expiry.expires_at = UnixTimestamp::from_seconds(999).expect("expiry"); + assert!(core.consume_removal_token(wrong_expiry, now).is_err()); + + let mut wrong_impact = core.issue_removal_token(public_key, now).expect("token"); + wrong_impact.impact = super::RemovalImpact { + deletes_local_credential: false, + signs_out: false, + }; + assert!(core.consume_removal_token(wrong_impact, now).is_err()); + } } diff --git a/crates/studio_application/src/recovery.rs b/crates/studio_application/src/recovery.rs @@ -356,3 +356,433 @@ fn removal_fallback( .or_else(|| index.checked_sub(1).and_then(|before| registry.get(before))) .map(radroots_studio_domain::AccountSummary::public_key) } + +#[cfg(test)] +pub(crate) mod tests { + use std::sync::{Mutex, MutexGuard}; + + use radroots_studio_domain::{ + BindingAvailability, PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, + UnixTimestamp, + }; + + use super::*; + use crate::{ + DurableOperationReceipt, DurableOperationStart, DurableRequestId, FailureSecretStore, + InMemoryAccountRepository, InMemoryOperationJournal, InMemorySecretStore, + RelayConfiguration, SecretStore, SecretStoreOperation, + }; + + struct FixedClock; + + impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(10).expect("time") + } + } + + pub(crate) struct TestDurableRepository { + operation: Mutex<DurableAccountOperation>, + return_existing: bool, + } + + impl TestDurableRepository { + pub(crate) fn new(operation: DurableAccountOperation) -> Self { + Self { + operation: Mutex::new(operation), + return_existing: true, + } + } + + pub(crate) fn fresh(operation: DurableAccountOperation) -> Self { + Self { + operation: Mutex::new(operation), + return_existing: false, + } + } + + pub(crate) fn operation(&self) -> MutexGuard<'_, DurableAccountOperation> { + self.operation + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn replace( + current: &DurableAccountOperation, + phase: DurableOperationPhase, + diagnostic: Option<crate::OperationDiagnostic>, + terminal: Option<DurableOperationReceipt>, + ) -> DurableAccountOperation { + DurableAccountOperation::new( + current.request_id().clone(), + current.kind(), + current.account(), + current.expected_revision(), + phase, + current.prior(), + current.updated_at(), + diagnostic, + terminal, + ) + } + } + + impl DurableOperationRepository for TestDurableRepository { + fn begin_durable_operation( + &self, + _request_id: &DurableRequestId, + _kind: DurableOperationKind, + _account: PublicKey, + _expected_revision: Option<u64>, + _prior: crate::OperationPriorState, + _updated_at: UnixTimestamp, + ) -> Result<DurableOperationStart, SafeError> { + let operation = self.operation().clone(); + Ok(if self.return_existing { + DurableOperationStart::Existing(operation) + } else { + DurableOperationStart::Started(operation) + }) + } + + fn load_durable_operation( + &self, + request_id: &DurableRequestId, + ) -> Result<Option<DurableAccountOperation>, SafeError> { + if !self.return_existing { + return Ok(None); + } + let operation = self.operation(); + Ok((operation.request_id() == request_id).then(|| operation.clone())) + } + + fn advance_durable_operation( + &self, + request_id: &DurableRequestId, + expected_phase: DurableOperationPhase, + next_phase: DurableOperationPhase, + _updated_at: UnixTimestamp, + diagnostic: Option<crate::OperationDiagnostic>, + ) -> Result<DurableAccountOperation, SafeError> { + let mut operation = self.operation(); + if operation.request_id() != request_id || operation.phase() != expected_phase { + return Err(conflict()); + } + *operation = Self::replace(&operation, next_phase, diagnostic, None); + Ok(operation.clone()) + } + + fn finalize_durable_operation( + &self, + request_id: &DurableRequestId, + expected_phase: DurableOperationPhase, + outcome: DurableTerminalOutcome, + resulting_revision: Option<u64>, + _updated_at: UnixTimestamp, + ) -> Result<DurableOperationReceipt, SafeError> { + let mut operation = self.operation(); + if operation.request_id() != request_id || operation.phase() != expected_phase { + return Err(conflict()); + } + let receipt = DurableOperationReceipt::new( + request_id.clone(), + operation.account(), + outcome, + resulting_revision, + ); + *operation = Self::replace( + &operation, + DurableOperationPhase::Finalized, + operation.diagnostic(), + Some(receipt.clone()), + ); + Ok(receipt) + } + + fn list_unfinished_durable_operations( + &self, + ) -> Result<Vec<DurableAccountOperation>, SafeError> { + Ok(vec![self.operation().clone()]) + } + } + + fn conflict() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The test durable operation conflicted."), + ) + } + + fn seeded() -> ( + AppCore, + InMemoryAccountRepository, + InMemorySecretStore, + InMemoryOperationJournal, + PublicKey, + ) { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + let receipt = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("seed account"); + ( + core, + accounts, + secrets, + journal, + receipt.account().public_key(), + ) + } + + pub(crate) fn operation( + kind: DurableOperationKind, + phase: DurableOperationPhase, + account: PublicKey, + prior_availability: Option<BindingAvailability>, + ) -> DurableAccountOperation { + DurableAccountOperation::new( + DurableRequestId::parse(format!("{kind:?}-{phase:?}")).expect("durable request ID"), + kind, + account, + Some(1), + phase, + crate::OperationPriorState::new(None, prior_availability), + FixedClock.now(), + None, + None, + ) + } + + fn run_durable( + core: &AppCore, + accounts: &InMemoryAccountRepository, + secrets: &InMemorySecretStore, + operation: DurableAccountOperation, + ) -> DurableAccountOperation { + let repository = TestDurableRepository::new(operation); + core.recover_durable_operations(accounts, accounts, secrets, &repository, &FixedClock) + .expect("durable recovery"); + repository.operation().clone() + } + + #[test] + fn durable_recovery_exercises_every_removal_phase_and_presence_branch() { + for phase in [ + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialDeleted, + DurableOperationPhase::MetadataDeleted, + DurableOperationPhase::SelectionCommitted, + DurableOperationPhase::Finalized, + ] { + let (core, accounts, secrets, _journal, public_key) = seeded(); + if phase != DurableOperationPhase::IntentRecorded { + secrets.delete(public_key).expect("delete credential"); + } + if matches!( + phase, + DurableOperationPhase::MetadataDeleted + | DurableOperationPhase::SelectionCommitted + | DurableOperationPhase::Finalized + ) { + accounts.remove_account(public_key).expect("remove account"); + } + let recovered = run_durable( + &core, + &accounts, + &secrets, + operation(DurableOperationKind::Remove, phase, public_key, None), + ); + assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); + } + + let (core, accounts, secrets, _journal, public_key) = seeded(); + secrets.delete(public_key).expect("delete credential"); + accounts.remove_account(public_key).expect("remove account"); + let recovered = run_durable( + &core, + &accounts, + &secrets, + operation( + DurableOperationKind::Remove, + DurableOperationPhase::IntentRecorded, + public_key, + None, + ), + ); + assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); + } + + #[test] + fn durable_recovery_exercises_every_addition_phase_and_compensation_shape() { + for phase in [ + DurableOperationPhase::IntentRecorded, + DurableOperationPhase::CredentialWritten, + DurableOperationPhase::MetadataCommitted, + DurableOperationPhase::SelectionCommitted, + DurableOperationPhase::CredentialDeleted, + DurableOperationPhase::MetadataDeleted, + DurableOperationPhase::Finalized, + ] { + let (core, accounts, secrets, _journal, public_key) = seeded(); + if phase == DurableOperationPhase::IntentRecorded { + accounts + .remove_account(public_key) + .expect("remove metadata"); + } + let recovered = run_durable( + &core, + &accounts, + &secrets, + operation(DurableOperationKind::Create, phase, public_key, None), + ); + assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); + } + + for (prior, retain_metadata, retain_secret) in [ + (Some(BindingAvailability::CredentialMissing), true, true), + (Some(BindingAvailability::CredentialMissing), false, true), + (None, true, true), + (None, false, false), + ] { + let (core, accounts, secrets, _journal, public_key) = seeded(); + if !retain_metadata { + accounts + .remove_account(public_key) + .expect("remove metadata"); + } + if !retain_secret { + secrets.delete(public_key).expect("delete credential"); + } + let recovered = run_durable( + &core, + &accounts, + &secrets, + operation( + DurableOperationKind::Repair, + DurableOperationPhase::CompensationPending, + public_key, + prior, + ), + ); + assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); + } + + let (core, accounts, secrets, _journal, public_key) = seeded(); + accounts + .remove_account(public_key) + .expect("remove metadata"); + let recovered = run_durable( + &core, + &accounts, + &secrets, + operation( + DurableOperationKind::Import, + DurableOperationPhase::CredentialWritten, + public_key, + None, + ), + ); + assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); + + let (core, accounts, secrets, _journal, public_key) = seeded(); + accounts + .remove_account(public_key) + .expect("remove metadata"); + secrets.delete(public_key).expect("delete credential"); + let recovered = run_durable( + &core, + &accounts, + &secrets, + operation( + DurableOperationKind::Create, + DurableOperationPhase::IntentRecorded, + public_key, + None, + ), + ); + assert_eq!(recovered.phase(), DurableOperationPhase::Finalized); + } + + #[test] + fn pending_recovery_exercises_removal_and_addition_presence_branches() { + for credential_present in [true, false] { + let (core, accounts, secrets, journal, public_key) = seeded(); + if !credential_present { + secrets.delete(public_key).expect("delete credential"); + accounts + .save_selected_account(None) + .expect("clear selection"); + } + journal + .begin_operation(AccountOperationKind::Remove, public_key, FixedClock.now()) + .expect("removal intent"); + core.recover_pending_operations(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("removal recovery"); + assert!(journal.list_pending_operations().unwrap().is_empty()); + } + + for (kind, metadata_present, credential_present) in [ + (AccountOperationKind::Add, false, true), + (AccountOperationKind::Import, false, false), + (AccountOperationKind::Add, true, true), + ] { + let (core, accounts, secrets, journal, public_key) = seeded(); + if !metadata_present { + accounts + .remove_account(public_key) + .expect("remove metadata"); + } + if !credential_present { + secrets.delete(public_key).expect("delete credential"); + } + let id = journal + .begin_operation(kind, public_key, FixedClock.now()) + .expect("addition intent"); + journal + .update_operation( + id, + AccountOperationPhase::CredentialWritten, + FixedClock.now(), + None, + ) + .expect("credential phase"); + core.recover_pending_operations(&accounts, &accounts, &secrets, &journal, &FixedClock) + .expect("addition recovery"); + assert!(journal.list_pending_operations().unwrap().is_empty()); + } + + let (core, accounts, _secrets, journal, public_key) = seeded(); + accounts + .remove_account(public_key) + .expect("remove metadata"); + let secrets = FailureSecretStore::default(); + secrets + .put( + public_key, + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("secret"), + ) + .expect("store credential"); + secrets.fail_next(SecretStoreOperation::Delete); + let id = journal + .begin_operation(AccountOperationKind::Add, public_key, FixedClock.now()) + .expect("addition intent"); + journal + .update_operation( + id, + AccountOperationPhase::CredentialWritten, + FixedClock.now(), + None, + ) + .expect("credential phase"); + assert!( + core.recover_pending_operations(&accounts, &accounts, &secrets, &journal, &FixedClock,) + .is_err() + ); + } +} diff --git a/crates/studio_application/src/session.rs b/crates/studio_application/src/session.rs @@ -197,6 +197,26 @@ mod tests { error.code(), radroots_studio_domain::SafeErrorCode::CredentialMissing ); + secrets + .put( + second, + input("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"), + ) + .expect("mismatched credential"); + let invalid = core + .activate_account( + second, + &accounts, + &accounts, + &profiles, + &secrets, + &FixedClock, + ) + .expect_err("mismatched credential"); + assert_eq!( + invalid.code(), + radroots_studio_domain::SafeErrorCode::InvalidSecretKey + ); assert_eq!(core.snapshot().session(), SessionState::Active); assert_eq!( core.snapshot() diff --git a/crates/studio_application/src/snapshot.rs b/crates/studio_application/src/snapshot.rs @@ -414,5 +414,66 @@ mod tests { ) .is_err() ); + + let missing = account(3); + for result in [ + AppSnapshot::ready( + SnapshotRevision::initial(), + RelayConfiguration::default(), + Vec::new(), + Some(missing.public_key()), + SessionState::SignedOut, + None, + None, + ), + AppSnapshot::ready( + SnapshotRevision::initial(), + RelayConfiguration::default(), + vec![second.clone()], + None, + SessionState::SignedOut, + None, + None, + ), + AppSnapshot::ready( + SnapshotRevision::initial(), + RelayConfiguration::default(), + vec![second.clone()], + Some(missing.public_key()), + SessionState::SignedOut, + None, + None, + ), + AppSnapshot::ready( + SnapshotRevision::initial(), + RelayConfiguration::default(), + vec![second.clone()], + Some(second.public_key()), + SessionState::SignedOut, + Some(ActiveAccountSnapshot::new( + missing, + RelayConnectionState::Disconnected, + ProfileLoadState::Empty, + None, + )), + None, + ), + AppSnapshot::ready( + SnapshotRevision::initial(), + RelayConfiguration::default(), + vec![second.clone()], + Some(second.public_key()), + SessionState::SignedOut, + Some(ActiveAccountSnapshot::new( + second, + RelayConnectionState::Disconnected, + ProfileLoadState::Empty, + None, + )), + None, + ), + ] { + assert!(result.is_err()); + } } } diff --git a/crates/studio_application/src/state_machine.rs b/crates/studio_application/src/state_machine.rs @@ -506,4 +506,111 @@ mod tests { assert_eq!(signed_out.session(), SessionState::SignedOut); assert!(signed_out.active_account().is_none()); } + + #[test] + fn activation_state_policy_rejects_every_stale_or_mismatched_transition() { + let first = account(1); + let second = account(2); + let relays = RelayConfiguration::default(); + let problem = SafeError::new( + SafeErrorCode::CredentialMissing, + SafeMessage::new("The account credential is missing."), + ); + let mut machine = StateMachine::booting(); + machine + .apply( + StateTransition::BootstrapRegistry { + accounts: vec![first.clone(), second.clone()], + selected: Some(first.public_key()), + }, + &relays, + ) + .expect("registry"); + let unchanged = machine + .apply( + StateTransition::BootstrapRegistry { + accounts: Vec::new(), + selected: None, + }, + &relays, + ) + .expect("repeated bootstrap is idempotent"); + assert_eq!(unchanged.accounts().len(), 2); + + assert!( + machine + .apply( + StateTransition::ActivationSucceeded(Box::new(active(first.clone()))), + &relays, + ) + .is_err() + ); + assert!( + machine + .apply(StateTransition::ActivationFailed(problem), &relays) + .is_err() + ); + machine + .apply( + StateTransition::BeginActivation(first.public_key()), + &relays, + ) + .expect("begin activation"); + assert!( + machine + .apply( + StateTransition::BeginActivation(second.public_key()), + &relays, + ) + .is_err() + ); + assert!( + machine + .apply( + StateTransition::ActivationSucceeded(Box::new(active(second.clone()))), + &relays, + ) + .is_err() + ); + machine + .apply( + StateTransition::ActivationSucceeded(Box::new(active(first.clone()))), + &relays, + ) + .expect("activate first"); + + for (expected, candidate) in [ + (second.public_key(), first.clone()), + (first.public_key(), second.clone()), + ] { + assert!( + machine + .apply( + StateTransition::UpdateActiveAccount { + expected, + active_account: Box::new(active(candidate)), + problem: None, + }, + &relays, + ) + .is_err() + ); + } + + machine + .apply(StateTransition::SignOut, &relays) + .expect("sign out"); + assert!( + machine + .apply( + StateTransition::UpdateActiveAccount { + expected: first.public_key(), + active_account: Box::new(active(first)), + problem: None, + }, + &relays, + ) + .is_err() + ); + } } diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_ffi/src/commands.rs @@ -35,21 +35,24 @@ const DEVELOPMENT_DATA_DIR_ENVIRONMENT: &str = "RADROOTS_STUDIO_DEVELOPMENT_DATA pub(crate) const ACTOR_MAILBOX_CAPACITY: usize = 64; const MAX_COMMAND_DEADLINE_MILLIS: u64 = 30_000; -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct RequestContextDto { pub request_id: String, pub expected_revision: u64, pub deadline_millis: u64, } -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct AccountCommandReceiptDto { pub request_id: String, pub committed_revision: u64, pub snapshot: AppSnapshotDto, } -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct CompatibilityDescriptor { pub product_version: String, pub cargo_package_version: String, @@ -60,7 +63,8 @@ pub struct CompatibilityDescriptor { pub current_schema_version: u32, } -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct CompatibilityExpectation { pub contract_major: u16, pub minimum_contract_minor: u16, @@ -69,7 +73,7 @@ pub struct CompatibilityExpectation { pub maximum_schema_version: u32, } -#[uniffi::export] +#[cfg_attr(not(coverage_nightly), uniffi::export)] pub fn compatibility_descriptor() -> CompatibilityDescriptor { CompatibilityDescriptor { product_version: PRODUCT_VERSION.to_owned(), @@ -82,7 +86,8 @@ pub fn compatibility_descriptor() -> CompatibilityDescriptor { } } -#[derive(Debug, uniffi::Error)] +#[derive(Debug)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Error))] pub enum StudioError { Failure { code: WireErrorCode, @@ -132,13 +137,13 @@ impl StudioError { } } -#[derive(uniffi::Object)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))] pub struct GeneratedRecoveryRequest { handle: GeneratedKeyRecoveryHandle, resolved: AtomicBool, } -#[uniffi::export] +#[cfg_attr(not(coverage_nightly), uniffi::export)] impl GeneratedRecoveryRequest { pub fn account(&self) -> AccountDto { self.handle.view().account().into() @@ -161,7 +166,7 @@ impl GeneratedRecoveryRequest { } } -#[derive(uniffi::Object)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))] pub struct RemovalRequest { public_key_hex: String, deletes_local_credential: bool, @@ -170,7 +175,7 @@ pub struct RemovalRequest { token: Mutex<Option<RemovalConfirmationToken>>, } -#[uniffi::export] +#[cfg_attr(not(coverage_nightly), uniffi::export)] impl RemovalRequest { pub fn public_key_hex(&self) -> String { self.public_key_hex.clone() @@ -224,19 +229,19 @@ impl RuntimeCore { } } -#[derive(uniffi::Object)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))] pub struct StudioAppCore { pub(crate) inner: Arc<RuntimeCore>, } -#[uniffi::export] +#[cfg_attr(not(coverage_nightly), uniffi::export)] impl StudioAppCore { /// Verifies the static contract before touching the application data path. /// /// # Errors /// /// Returns a safe compatibility error without opening or migrating storage. - #[uniffi::constructor] + #[cfg_attr(not(coverage_nightly), uniffi::constructor)] #[allow(clippy::needless_pass_by_value)] pub fn open_compatible( expectation: CompatibilityExpectation, @@ -525,6 +530,10 @@ impl StudioAppCore { Self::open_path(path, development_mode) } + // The concrete product opener binds operating-system paths, keyrings, and + // SQLite ownership. Platform installation lanes exercise this adapter; + // deterministic coverage owns the compatibility and runtime policies. + #[cfg_attr(coverage_nightly, coverage(off))] fn open_path(path: &Path, development_mode: bool) -> Result<Arc<Self>, StudioError> { let mode = if development_mode { RelayRuntimeMode::Development @@ -580,6 +589,8 @@ impl Clock for SystemClock { } } +// ProjectDirs and the process environment are host integration boundaries. +#[cfg_attr(coverage_nightly, coverage(off))] fn application_database_path(development_mode: bool) -> Result<PathBuf, StudioError> { if development_mode && let Some(directory) = std::env::var_os(DEVELOPMENT_DATA_DIR_ENVIRONMENT) { @@ -700,6 +711,7 @@ fn compatibility_mismatch() -> StudioError { } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { use std::num::NonZeroUsize; use std::sync::Arc; @@ -717,7 +729,9 @@ mod tests { ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, DATABASE_APPLICATION, DATABASE_FILENAME, DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, FFI_CONTRACT_MINOR, RequestContextDto, RuntimeCore, StudioAppCore, StudioError, - SystemClock, compatibility_descriptor, local_first_relay_configuration, runtime, + SystemClock, WireErrorCategory, WireErrorCode, WireRecoveryAction, actor_mailbox_capacity, + compatibility_descriptor, confirmation_expired, generated_commit_failed, + local_first_relay_configuration, path_unavailable, runtime, runtime_unavailable, verify_compatibility, }; @@ -813,6 +827,192 @@ mod tests { )); } + #[tokio::test] + async fn account_lifecycle_and_one_use_removal_are_exercised_through_the_ffi_boundary() { + let core = in_memory_core().await; + let initial = core.bootstrap().await.expect("bootstrap"); + let imported = core + .import_account_v2( + RequestContextDto { + request_id: "ffi-lifecycle-import".to_owned(), + expected_revision: initial.revision, + deadline_millis: 5_000, + }, + b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_vec(), + ) + .await + .expect("import account"); + let public_key = imported.snapshot.accounts[0].public_key_hex.clone(); + + let selected = core + .select_account(public_key.clone()) + .await + .expect("select account"); + let active = core + .activate_account(public_key.clone()) + .await + .expect("activate account"); + assert!(active.revision > selected.revision); + let signed_out = core.sign_out().await.expect("sign out"); + assert!(signed_out.revision > active.revision); + let refreshed = core + .refresh_active_profile() + .await + .expect("signed-out refresh is a stable no-op"); + assert_eq!(refreshed.revision, signed_out.revision); + + let removal = core + .request_account_removal(public_key.clone()) + .await + .expect("request removal"); + assert_eq!(removal.public_key_hex(), public_key); + assert!(removal.deletes_local_credential()); + assert!(!removal.signs_out()); + assert!(removal.expires_at_seconds() > 0); + let removed = core + .confirm_account_removal( + RequestContextDto { + request_id: "ffi-lifecycle-remove".to_owned(), + expected_revision: signed_out.revision, + deadline_millis: 5_000, + }, + Arc::clone(&removal), + ) + .await + .expect("confirm removal"); + assert!(removed.accounts.is_empty()); + assert!( + core.confirm_account_removal( + RequestContextDto { + request_id: "ffi-lifecycle-remove-repeated".to_owned(), + expected_revision: removed.revision, + deadline_millis: 5_000, + }, + removal, + ) + .await + .is_err() + ); + assert!( + core.select_account("not-a-public-key".to_owned()) + .await + .is_err() + ); + } + + #[tokio::test] + async fn generated_recovery_cancellation_and_request_validation_fail_closed() { + let core = in_memory_core().await; + let recovery = core + .begin_generated_account_v2() + .await + .expect("begin generated account"); + assert_eq!(recovery.account().public_key_hex.len(), 64); + assert!(recovery.expires_at_seconds() > 0); + assert!( + core.cancel_generated_account_v2(Arc::clone(&recovery)) + .await + .expect("first cancellation") + ); + assert!( + !core + .cancel_generated_account_v2(recovery) + .await + .expect("second cancellation") + ); + + for context in [ + RequestContextDto { + request_id: String::new(), + expected_revision: 0, + deadline_millis: 5_000, + }, + RequestContextDto { + request_id: "ffi-zero-deadline".to_owned(), + expected_revision: 0, + deadline_millis: 0, + }, + RequestContextDto { + request_id: "ffi-long-deadline".to_owned(), + expected_revision: 0, + deadline_millis: 30_001, + }, + ] { + assert!(core.import_account_v2(context, vec![0; 32]).await.is_err()); + } + assert!( + core.import_account_v2( + RequestContextDto { + request_id: "ffi-invalid-secret".to_owned(), + expected_revision: 0, + deadline_millis: 5_000, + }, + vec![0; 31], + ) + .await + .is_err() + ); + } + + #[test] + fn boundary_failures_remain_typed_and_secret_safe() { + assert_eq!(actor_mailbox_capacity().expect("capacity").get(), 64); + for (error, code, category, retryable, recovery, message) in [ + ( + runtime_unavailable(), + WireErrorCode::InvalidApplicationState, + WireErrorCategory::Lifecycle, + true, + WireRecoveryAction::RestartApplication, + "The application runtime is unavailable.", + ), + ( + path_unavailable(), + WireErrorCode::StorageUnavailable, + WireErrorCategory::Storage, + true, + WireRecoveryAction::RestartApplication, + "The application data directory is unavailable.", + ), + ( + confirmation_expired(), + WireErrorCode::InvalidApplicationState, + WireErrorCategory::Lifecycle, + false, + WireRecoveryAction::None, + "The account removal confirmation is no longer valid.", + ), + ( + generated_commit_failed(SafeError::new( + radroots_studio_domain::SafeErrorCode::StorageUnavailable, + radroots_studio_domain::SafeMessage::new("internal detail"), + )), + WireErrorCode::StorageUnavailable, + WireErrorCategory::Storage, + false, + WireRecoveryAction::None, + "The generated account could not be saved. Import the recovery key you saved to try again.", + ), + ] { + assert_eq!(error.to_string(), message); + assert!(matches!( + error, + StudioError::Failure { + code: actual_code, + category: actual_category, + retryable: actual_retryable, + recovery_action: actual_recovery, + correlation_id: None, + safe_message, + } if actual_code == code + && actual_category == category + && actual_retryable == retryable + && actual_recovery == recovery + && safe_message == message + )); + } + } + #[test] fn compatibility_matrix_rejects_before_storage_mutation() { let actual = compatibility_descriptor(); diff --git a/crates/studio_ffi/src/dto.rs b/crates/studio_ffi/src/dto.rs @@ -6,7 +6,8 @@ use radroots_studio_domain::{ AccountSummary, BindingAvailability, ProfileMetadata, SafeError, SafeErrorCode, }; -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] pub enum WireErrorCode { InvalidPublicKey, InvalidSecretKey, @@ -33,7 +34,8 @@ pub enum WireErrorCode { Internal, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] pub enum WireErrorCategory { Input, Conflict, @@ -45,7 +47,8 @@ pub enum WireErrorCategory { Internal, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] pub enum WireRecoveryAction { None, Retry, @@ -58,7 +61,8 @@ pub enum WireRecoveryAction { UpdateApplication, } -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct SafeErrorDto { pub code: WireErrorCode, pub category: WireErrorCategory, @@ -67,7 +71,8 @@ pub struct SafeErrorDto { pub message: String, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] pub enum AppLifecycleDto { Opening, CompatibilityChecking, @@ -82,7 +87,8 @@ pub enum AppLifecycleDto { Fatal, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] pub enum SessionStateDto { SignedOut, Activating, @@ -91,7 +97,8 @@ pub enum SessionStateDto { Failed, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] pub enum RelayConnectionStateDto { Disconnected, Connecting, @@ -100,7 +107,8 @@ pub enum RelayConnectionStateDto { Error, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] pub enum ProfileLoadStateDto { Empty, Loading, @@ -109,14 +117,16 @@ pub enum ProfileLoadStateDto { Error, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] pub enum SignerKindDto { LocalSecret, WatchOnly, RemoteNip46, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Enum))] pub enum KeyAvailabilityDto { Available, CredentialMissing, @@ -124,7 +134,8 @@ pub enum KeyAvailabilityDto { NotRequired, } -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct ProfileDto { pub name: Option<String>, pub display_name: Option<String>, @@ -133,7 +144,8 @@ pub struct ProfileDto { pub picture: Option<String>, } -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct AccountDto { pub public_key_hex: String, pub npub: String, @@ -144,7 +156,8 @@ pub struct AccountDto { pub last_used_at_seconds: Option<i64>, } -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct ActiveAccountDto { pub account: AccountDto, pub relay_state: RelayConnectionStateDto, @@ -152,7 +165,8 @@ pub struct ActiveAccountDto { pub profile: Option<ProfileDto>, } -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct AppSnapshotDto { pub revision: u64, pub lifecycle: AppLifecycleDto, @@ -427,15 +441,26 @@ impl From<ProfileLoadState> for ProfileLoadStateDto { } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { use std::sync::Arc; - use radroots_studio_application::{AppCore, RelayConfiguration}; + use radroots_studio_application::{ + AppCore, ProfileLoadState, RelayConfiguration, RelayConnectionState, RuntimeLifecycle, + }; use radroots_studio_nostr::NostrKeyMaterialProvider; - use radroots_studio_domain::{SafeErrorCode, SafeMessage}; + use radroots_studio_domain::{BindingAvailability, SafeError, SafeErrorCode, SafeMessage}; - use super::{AppSnapshotDto, SafeErrorDto, WireErrorCode, WireRecoveryAction}; + use super::{ + AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileLoadStateDto, + RelayConnectionStateDto, SafeErrorDto, WireErrorCategory, WireErrorCode, + WireRecoveryAction, error_policy, + }; + + fn safe_error(code: SafeErrorCode) -> SafeError { + SafeError::new(code, SafeMessage::new("Safe compatibility failure.")) + } #[test] fn snapshot_dto_is_revisioned_public_and_secret_free() { @@ -487,4 +512,218 @@ mod tests { assert!(!dto.retryable); } } + + #[test] + fn every_safe_error_has_an_explicit_wire_code_and_policy() { + let cases = [ + ( + SafeErrorCode::InvalidPublicKey, + WireErrorCode::InvalidPublicKey, + ), + ( + SafeErrorCode::InvalidSecretKey, + WireErrorCode::InvalidSecretKey, + ), + ( + SafeErrorCode::InvalidAccountMetadata, + WireErrorCode::InvalidAccountMetadata, + ), + ( + SafeErrorCode::InvalidProfileMetadata, + WireErrorCode::InvalidProfileMetadata, + ), + ( + SafeErrorCode::InvalidApplicationState, + WireErrorCode::InvalidApplicationState, + ), + ( + SafeErrorCode::AccountAlreadyExists, + WireErrorCode::AccountAlreadyExists, + ), + ( + SafeErrorCode::AccountNotFound, + WireErrorCode::AccountNotFound, + ), + ( + SafeErrorCode::KeyringUnavailable, + WireErrorCode::KeyringUnavailable, + ), + ( + SafeErrorCode::CredentialMissing, + WireErrorCode::CredentialMissing, + ), + ( + SafeErrorCode::StorageUnavailable, + WireErrorCode::StorageUnavailable, + ), + (SafeErrorCode::StorageCorrupt, WireErrorCode::StorageCorrupt), + ( + SafeErrorCode::StorageQuarantined, + WireErrorCode::StorageQuarantined, + ), + ( + SafeErrorCode::StorageBackupInvalid, + WireErrorCode::StorageBackupInvalid, + ), + ( + SafeErrorCode::UnsupportedSchemaVersion, + WireErrorCode::UnsupportedSchemaVersion, + ), + ( + SafeErrorCode::RepairUnauthorized, + WireErrorCode::RepairUnauthorized, + ), + ( + SafeErrorCode::PendingOperationRecoveryRequired, + WireErrorCode::PendingOperationRecoveryRequired, + ), + ( + SafeErrorCode::InvalidRelayConfiguration, + WireErrorCode::InvalidRelayConfiguration, + ), + ( + SafeErrorCode::RelayConnectionFailed, + WireErrorCode::RelayConnectionFailed, + ), + ( + SafeErrorCode::ProfileRefreshFailed, + WireErrorCode::ProfileRefreshFailed, + ), + ( + SafeErrorCode::ObserverRegistrationFailed, + WireErrorCode::ObserverRegistrationFailed, + ), + ( + SafeErrorCode::NativeLibraryLoadFailed, + WireErrorCode::NativeLibraryLoadFailed, + ), + ]; + for (code, expected_wire_code) in cases { + let dto = SafeErrorDto::from(safe_error(code)); + assert_eq!(dto.code, expected_wire_code); + assert_eq!( + (dto.category, dto.retryable, dto.recovery_action), + error_policy(code) + ); + } + assert_eq!( + error_policy(SafeErrorCode::KeyringUnavailable), + ( + WireErrorCategory::Credential, + true, + WireRecoveryAction::Retry, + ) + ); + assert_eq!( + error_policy(SafeErrorCode::NativeLibraryLoadFailed), + ( + WireErrorCategory::Internal, + false, + WireRecoveryAction::RestartApplication, + ) + ); + } + + #[test] + fn runtime_and_connection_states_map_exhaustively_to_wire_states() { + let core = AppCore::new( + RelayConfiguration::default(), + Arc::new(NostrKeyMaterialProvider), + ); + let snapshot = core.bootstrap().expect("bootstrap"); + for (runtime, expected) in [ + (RuntimeLifecycle::Opening, AppLifecycleDto::Opening), + ( + RuntimeLifecycle::CompatibilityChecking, + AppLifecycleDto::CompatibilityChecking, + ), + ( + RuntimeLifecycle::AcquiringOwnership, + AppLifecycleDto::AcquiringOwnership, + ), + (RuntimeLifecycle::Migrating, AppLifecycleDto::Migrating), + (RuntimeLifecycle::Recovering, AppLifecycleDto::Recovering), + (RuntimeLifecycle::Ready, AppLifecycleDto::Ready), + ( + RuntimeLifecycle::Degraded(safe_error(SafeErrorCode::RelayConnectionFailed)), + AppLifecycleDto::Degraded, + ), + ( + RuntimeLifecycle::Blocked(safe_error(SafeErrorCode::StorageUnavailable)), + AppLifecycleDto::Blocked, + ), + ( + RuntimeLifecycle::ShuttingDown, + AppLifecycleDto::ShuttingDown, + ), + (RuntimeLifecycle::Closed, AppLifecycleDto::Closed), + ( + RuntimeLifecycle::Fatal(safe_error(SafeErrorCode::StorageCorrupt)), + AppLifecycleDto::Fatal, + ), + ] { + let dto = AppSnapshotDto::from_runtime(&snapshot, runtime); + assert_eq!(dto.lifecycle, expected); + assert_eq!( + dto.lifecycle_error.is_some(), + matches!( + expected, + AppLifecycleDto::Degraded | AppLifecycleDto::Blocked | AppLifecycleDto::Fatal + ) + ); + } + + for (source, expected) in [ + ( + BindingAvailability::Available, + KeyAvailabilityDto::Available, + ), + ( + BindingAvailability::CredentialMissing, + KeyAvailabilityDto::CredentialMissing, + ), + ( + BindingAvailability::StoreUnavailable, + KeyAvailabilityDto::StoreUnavailable, + ), + ] { + assert_eq!(KeyAvailabilityDto::from(source), expected); + } + for (source, expected) in [ + ( + RelayConnectionState::Disconnected, + RelayConnectionStateDto::Disconnected, + ), + ( + RelayConnectionState::Connecting, + RelayConnectionStateDto::Connecting, + ), + ( + RelayConnectionState::Connected, + RelayConnectionStateDto::Connected, + ), + ( + RelayConnectionState::Degraded, + RelayConnectionStateDto::Degraded, + ), + ( + RelayConnectionState::Error(safe_error(SafeErrorCode::RelayConnectionFailed)), + RelayConnectionStateDto::Error, + ), + ] { + assert_eq!(RelayConnectionStateDto::from(source), expected); + } + for (source, expected) in [ + (ProfileLoadState::Empty, ProfileLoadStateDto::Empty), + (ProfileLoadState::Loading, ProfileLoadStateDto::Loading), + (ProfileLoadState::Cached, ProfileLoadStateDto::Cached), + (ProfileLoadState::Fresh, ProfileLoadStateDto::Fresh), + ( + ProfileLoadState::Error(safe_error(SafeErrorCode::ProfileRefreshFailed)), + ProfileLoadStateDto::Error, + ), + ] { + assert_eq!(ProfileLoadStateDto::from(source), expected); + } + } } diff --git a/crates/studio_ffi/src/lib.rs b/crates/studio_ffi/src/lib.rs @@ -1,4 +1,5 @@ #![doc = "Radroots Studio `UniFFI` boundary."] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] mod commands; mod contract; @@ -24,13 +25,14 @@ pub use observer::{ uniffi::setup_scaffolding!(); -#[uniffi::export] +#[cfg_attr(not(coverage_nightly), uniffi::export)] #[must_use] pub fn native_runtime_version() -> String { PRODUCT_VERSION.to_owned() } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { #[test] fn native_runtime_reports_the_product_version_independently() { diff --git a/crates/studio_ffi/src/observer.rs b/crates/studio_ffi/src/observer.rs @@ -11,30 +11,32 @@ use crate::{AppSnapshotDto, StudioAppCore, StudioError}; const OBSERVER_CHANGE_CAPACITY: NonZeroUsize = NonZeroUsize::MIN.saturating_add(63); const MAX_OBSERVERS: usize = 32; -#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +#[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct SnapshotChangeDto { pub snapshot: AppSnapshotDto, pub previous_revision: Option<u64>, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Record))] pub struct ShutdownReceiptDto { pub final_revision: u64, pub closed: bool, } -#[uniffi::export(callback_interface)] +#[cfg_attr(not(coverage_nightly), uniffi::export(callback_interface))] pub trait StudioChangeObserver: Send + Sync { fn on_change(&self, change: SnapshotChangeDto); } -#[derive(uniffi::Object)] +#[cfg_attr(not(coverage_nightly), derive(uniffi::Object))] pub struct ObserverSubscription { core: Weak<RuntimeCore>, id: Mutex<Option<ChangeSubscriptionId>>, } -#[uniffi::export] +#[cfg_attr(not(coverage_nightly), uniffi::export)] impl ObserverSubscription { pub async fn unsubscribe(&self) { let id = self @@ -59,7 +61,7 @@ impl ObserverSubscription { } } -#[uniffi::export] +#[cfg_attr(not(coverage_nightly), uniffi::export)] impl StudioAppCore { /// Subscribes to ordered revision changes including predecessor metadata. /// @@ -207,6 +209,7 @@ fn observer_registration_error() -> StudioError { } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { use std::num::NonZeroUsize; use std::sync::{Arc, Mutex}; @@ -291,7 +294,6 @@ mod tests { .subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))) .await .expect("subscribe"); - wait_for_snapshot_count(&observer, 1).await; core.inner .actor @@ -300,6 +302,7 @@ mod tests { .expect("idempotent bootstrap"); assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1); subscription.unsubscribe().await; + subscription.unsubscribe().await; core.inner.actor.sign_out().await.expect("sign out"); assert_eq!(observer.snapshots.lock().expect("snapshots").len(), 1); }); @@ -310,12 +313,32 @@ mod tests { runtime().expect("runtime").block_on(async { let core = core().await; let observer = Arc::new(RecordingObserver::default()); - let _subscription = core + let subscription = core .subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))) .await .expect("subscribe"); + let _active_subscription = core + .subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))) + .await + .expect("second subscription"); + let id = subscription + .id + .lock() + .expect("subscription id") + .expect("active subscription id"); + let handle = core + .inner + .observers + .lock() + .expect("observers") + .get_mut(&id) + .expect("registered observer") + .take() + .expect("observer task"); + handle.abort(); core.shutdown_v2().await.expect("shutdown"); + assert!(core.shutdown_v2().await.is_err()); assert!( core.subscribe_changes_v2(Box::new(ArcObserver(observer))) @@ -327,6 +350,22 @@ mod tests { } #[test] + fn subscription_unsubscribe_tolerates_a_dropped_runtime_core() { + runtime().expect("runtime").block_on(async { + let core = core().await; + let observer = Arc::new(RecordingObserver::default()); + let subscription = core + .subscribe_changes_v2(Box::new(ArcObserver(observer.clone()))) + .await + .expect("subscribe"); + wait_for_snapshot_count(&observer, 1).await; + + drop(core); + subscription.unsubscribe().await; + }); + } + + #[test] fn observer_registration_is_bounded_and_callback_panics_are_contained() { runtime().expect("runtime").block_on(async { let core = core().await; diff --git a/crates/studio_nostr/src/client.rs b/crates/studio_nostr/src/client.rs @@ -238,6 +238,34 @@ mod tests { .expect_err("empty relay list"); assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); + + let relay = RelayUrl::parse("wss://relay.example.test", RelayDestinationPolicy::Public) + .expect("relay URL"); + let too_many = vec![relay; radroots_studio_application::MAX_CONFIGURED_RELAYS + 1]; + let error = SdkNostrClient::new(Duration::from_millis(10)) + .fetch_profile( + PublicKey::from_bytes([7; 32]).expect("valid public key"), + &too_many, + std::time::Instant::now() + Duration::from_millis(10), + ) + .await + .expect_err("oversized relay list"); + assert_eq!(error.code(), SafeErrorCode::InvalidRelayConfiguration); + } + + #[tokio::test] + async fn sdk_client_fails_when_no_configured_relay_completes() { + let relay = RelayUrl::parse("ws://127.0.0.1:1", RelayDestinationPolicy::Local) + .expect("unavailable relay"); + let error = SdkNostrClient::new(Duration::from_millis(25)) + .fetch_profile( + PublicKey::from_bytes([7; 32]).expect("valid public key"), + &[relay], + std::time::Instant::now() + Duration::from_millis(50), + ) + .await + .expect_err("all relays unavailable"); + assert_eq!(error.code(), SafeErrorCode::RelayConnectionFailed); } #[tokio::test] @@ -293,5 +321,19 @@ mod tests { super::canonical_policy(RelayDestinationPolicy::PrivateNetwork), radroots_transport_nostr::RelayUrlPolicy::PrivateNetwork ); + assert_eq!(super::timeout_millis(Duration::ZERO), 1); + assert_eq!( + super::timeout_millis(Duration::from_secs(1_000_000)), + 120_000 + ); + assert!(super::unix_deadline(Duration::from_secs(1)).is_ok()); + assert_eq!( + super::invalid_relay_configuration().code(), + SafeErrorCode::InvalidRelayConfiguration + ); + assert_eq!( + super::relay_connection_failed().code(), + SafeErrorCode::RelayConnectionFailed + ); } } diff --git a/crates/studio_nostr/src/keys.rs b/crates/studio_nostr/src/keys.rs @@ -72,7 +72,7 @@ mod tests { use radroots_studio_application::KeyMaterialProvider; - use super::NostrKeyMaterialProvider; + use super::{NostrKeyMaterialProvider, invalid_public_key, invalid_secret_key}; const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; const NSEC: &str = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; @@ -119,5 +119,7 @@ mod tests { .err() .expect("invalid checksum"); assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); + assert_eq!(invalid_secret_key().code(), SafeErrorCode::InvalidSecretKey); + assert_eq!(invalid_public_key().code(), SafeErrorCode::InvalidPublicKey); } } diff --git a/crates/studio_nostr/src/profile.rs b/crates/studio_nostr/src/profile.rs @@ -164,5 +164,19 @@ mod tests { .code(), SafeErrorCode::InvalidProfileMetadata ); + assert_eq!( + parse_verified_kind0(&"x".repeat(64 * 1_024 + 1), author) + .expect_err("oversized event") + .code(), + SafeErrorCode::ProfileRefreshFailed + ); + assert_eq!( + super::invalid_event().code(), + SafeErrorCode::ProfileRefreshFailed + ); + assert_eq!( + super::invalid_metadata().code(), + SafeErrorCode::InvalidProfileMetadata + ); } } diff --git a/crates/studio_preferences/src/lib.rs b/crates/studio_preferences/src/lib.rs @@ -220,6 +220,49 @@ mod tests { } #[test] + fn every_boolean_and_channel_change_updates_exactly_one_revision() { + let mut state = PreferencesState::default(); + let changes = [ + PreferenceChange::AllowIncomingConnections(false), + PreferenceChange::UseRadrootsDns(false), + PreferenceChange::UseRadrootsSubnets(false), + PreferenceChange::LaunchAtLogin(false), + PreferenceChange::VpnOnDemandEnabled(true), + PreferenceChange::RunAsExitNode(true), + PreferenceChange::AllowLocalNetworkAccess(true), + PreferenceChange::AutomaticallyCheckForUpdates(false), + PreferenceChange::UpdateChannel(UpdateChannel::Preview), + ]; + for (index, change) in changes.into_iter().enumerate() { + assert!(state.apply(change).expect("valid preference change")); + assert_eq!(state.revision(), index as u64 + 2); + } + let preferences = state.preferences(); + assert!(!preferences.allow_incoming_connections); + assert!(!preferences.use_radroots_dns); + assert!(!preferences.use_radroots_subnets); + assert!(!preferences.launch_at_login); + assert!(preferences.vpn_on_demand_enabled); + assert!(preferences.run_as_exit_node); + assert!(preferences.allow_local_network_access); + assert!(!preferences.automatically_check_for_updates); + assert_eq!(preferences.update_channel, UpdateChannel::Preview); + } + + #[test] + fn revision_overflow_is_rejected_without_mutation() { + let mut state = PreferencesState { + revision: u64::MAX, + ..PreferencesState::default() + }; + assert_eq!( + state.apply(PreferenceChange::HideDockIcon(true)), + Err(PreferencesError::RevisionExhausted) + ); + assert!(!state.preferences().hide_dock_icon); + } + + #[test] fn alternate_server_is_trimmed_canonical_and_credential_free() { let mut state = PreferencesState::default(); state @@ -234,14 +277,27 @@ mod tests { for invalid in [ "http://example.com", "https://user@example.com", + "https://user:password@example.com", "https://example.com/#fragment", "not a URL", + "https://example.com/a\nb", ] { assert_eq!( state.apply(PreferenceChange::AlternateServerUrl(invalid.to_owned())), Err(PreferencesError::InvalidAlternateServerUrl) ); } + state + .apply(PreferenceChange::AlternateServerUrl(" ".to_owned())) + .expect("empty URL resets the override"); + assert!(state.preferences().alternate_server_url.is_empty()); + assert_eq!( + state.apply(PreferenceChange::AlternateServerUrl(format!( + "https://example.com/{}", + "x".repeat(MAX_SERVER_URL_BYTES) + ))), + Err(PreferencesError::InvalidAlternateServerUrl) + ); } #[test] @@ -262,5 +318,11 @@ mod tests { )), Err(PreferencesError::InvalidSummary) ); + assert_eq!( + state.apply(PreferenceChange::LastUpdateCheckSummary( + "x".repeat(MAX_SUMMARY_BYTES + 1) + )), + Err(PreferencesError::InvalidSummary) + ); } } diff --git a/crates/studio_runtime/src/runtime_actor.rs b/crates/studio_runtime/src/runtime_actor.rs @@ -1370,15 +1370,18 @@ mod tests { use std::time::{Duration, Instant}; use radroots_studio_application::{ - BoxFuture, Clock, FailureSecretStore, InMemorySecretStore, NostrClient, ProfileFetchResult, - RelayConfiguration, RuntimeLifecycle, SecretStore, SecretStoreOperation, SessionState, + BoxFuture, Clock, DurableRequestId, FailureSecretStore, ForegroundSessionBinding, + InMemorySecretStore, NostrClient, ProfileFetchResult, RelayConfiguration, RuntimeLifecycle, + SecretStore, SecretStoreOperation, SessionGeneration, SessionState, SnapshotRevision, }; use radroots_studio_domain::{ - PublicKey, RelayDestinationPolicy, RelayUrl, SafeError, SafeErrorCode, SecretKeyInput, - UnixTimestamp, + AccountIdentity, BindingAvailability, LocalSignerBinding, PublicKey, + RelayDestinationPolicy, RelayUrl, SafeError, SafeErrorCode, SecretKeyInput, UnixTimestamp, }; - use super::{RuntimeActorHandle, RuntimeDependencies}; + use super::{ + DEFAULT_COMMAND_TIMEOUT, RuntimeActorHandle, RuntimeDependencies, command_unavailable, + }; use crate::{InstallationIdentity, InstallationIdentitySource, UuidInstallationIdentitySource}; struct FixedInstallationIdentity(&'static str); @@ -1619,6 +1622,86 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] + async fn public_actor_commands_cover_generation_selection_and_empty_profile_refresh() { + let (actor, _) = actor().await; + let unchanged = actor + .refresh_active_profile() + .await + .expect("refresh without an active account"); + assert!(unchanged.active_account().is_none()); + + let generated = actor + .generate_account( + DurableRequestId::parse("test:generate:public-surface").expect("request"), + actor.snapshot().revision(), + DEFAULT_COMMAND_TIMEOUT, + ) + .await + .expect("generate account"); + let selected = actor + .select_account(generated.account().public_key()) + .await + .expect("select generated account"); + assert_eq!( + selected.selected_account(), + Some(generated.account().public_key()) + ); + + let missing = + PublicKey::from_hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + .expect("public key"); + let error = match actor.request_account_removal(missing).await { + Ok(_) => panic!("unknown account removal must fail"), + Err(error) => error, + }; + assert_eq!(error.code(), SafeErrorCode::AccountNotFound); + } + + #[tokio::test(flavor = "multi_thread")] + async fn staged_recovery_rejects_a_stale_expected_revision_before_commit() { + let (actor, _) = actor().await; + let handle = actor + .begin_generated_key_stage() + .await + .expect("generated key stage"); + let stale = SnapshotRevision::from_value(actor.snapshot().revision().value() + 1); + let error = actor + .acknowledge_generated_key_stage( + handle.id(), + DurableRequestId::parse("test:generate:stale-revision").expect("request"), + stale, + DEFAULT_COMMAND_TIMEOUT, + ) + .await + .expect_err("stale revision must conflict"); + assert_eq!( + error.message().as_str(), + "The command conflicts with newer application state." + ); + assert!(actor.cancel_generated_key_stage().await.expect("cancel")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fatal_lifecycle_rejects_commands_before_execution() { + let (actor, _) = actor().await; + actor + .lifecycle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .fail(command_unavailable()); + + let error = actor + .bootstrap() + .await + .expect_err("fatal lifecycle must reject command admission"); + assert_eq!( + error.message().as_str(), + "The command is unavailable in the current runtime state." + ); + assert!(matches!(actor.lifecycle(), RuntimeLifecycle::Fatal(_))); + } + + #[tokio::test(flavor = "multi_thread")] async fn generated_key_stage_is_exclusive_cancelable_and_snapshot_free() { let (actor, secrets) = actor().await; let initial = actor.snapshot(); @@ -1762,6 +1845,143 @@ mod tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn profile_refresh_rejects_stale_bindings_and_discards_stale_completions() { + let client = Arc::new(BlockingNostr::new()); + let actor = RuntimeActorHandle::in_memory( + RelayConfiguration::new(vec![ + RelayUrl::parse("ws://localhost:8080", RelayDestinationPolicy::Local) + .expect("relay"), + ]) + .expect("relay configuration"), + dependencies(Arc::new(InMemorySecretStore::default()), client.clone()), + NonZeroUsize::new(8).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .await + .expect("actor"); + let imported = actor + .import_secret_key_test(secret( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7", + )) + .await + .expect("import"); + let public_key = imported.account().public_key(); + actor.activate_account(public_key).await.expect("activate"); + let binding = actor.foreground_session().expect("foreground binding"); + let stale_binding = ForegroundSessionBinding::new( + AccountIdentity::derive(public_key).expect("identity"), + LocalSignerBinding::new(public_key, BindingAvailability::Available), + SessionGeneration::from_value(binding.generation().value() + 1), + ) + .expect("stale binding fixture"); + let other_public_key = + PublicKey::from_hex("c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5") + .expect("other public key"); + let other_binding = ForegroundSessionBinding::new( + AccountIdentity::derive(other_public_key).expect("other identity"), + LocalSignerBinding::new(other_public_key, BindingAvailability::Available), + binding.generation(), + ) + .expect("other binding fixture"); + + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stale_binding.clone()); + let error = actor + .refresh_active_profile() + .await + .expect_err("stale generation must reject before relay work"); + assert_eq!( + error.message().as_str(), + "The active account binding changed before profile refresh." + ); + + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(other_binding.clone()); + let error = actor + .refresh_active_profile() + .await + .expect_err("different account binding must reject before relay work"); + assert_eq!( + error.message().as_str(), + "The active account binding changed before profile refresh." + ); + + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding.clone()); + let refresh_actor = actor.clone(); + let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await }); + let started = client.started.acquire().await.expect("refresh started"); + started.forget(); + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(stale_binding); + client.release.add_permits(1); + let unchanged = refresh + .await + .expect("refresh task") + .expect("stale completion returns current snapshot"); + assert_eq!(unchanged.revision(), actor.snapshot().revision()); + + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding.clone()); + let refresh_actor = actor.clone(); + let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await }); + let started = client + .started + .acquire() + .await + .expect("second refresh started"); + started.forget(); + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + client.release.add_permits(1); + let unchanged = refresh + .await + .expect("refresh task") + .expect("missing binding returns current snapshot"); + assert_eq!(unchanged.revision(), actor.snapshot().revision()); + + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding.clone()); + let refresh_actor = actor.clone(); + let refresh = tokio::spawn(async move { refresh_actor.refresh_active_profile().await }); + let started = client + .started + .acquire() + .await + .expect("third refresh started"); + started.forget(); + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(other_binding); + client.release.add_permits(1); + let unchanged = refresh + .await + .expect("refresh task") + .expect("different account binding returns current snapshot"); + assert_eq!(unchanged.revision(), actor.snapshot().revision()); + + *actor + .foreground_session + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(binding); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn bounded_runtime_rejects_saturation_without_dropping_accepted_commands() { let secrets = Arc::new(BlockingSecretStore::new()); let actor = RuntimeActorHandle::in_memory( @@ -1904,6 +2124,12 @@ mod tests { .await .expect("unsubscribe") ); + assert!( + !actor + .unsubscribe_changes(subscription.id()) + .await + .expect("second unsubscribe") + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] diff --git a/crates/studio_storage/src/account_namespace.rs b/crates/studio_storage/src/account_namespace.rs @@ -168,4 +168,22 @@ mod tests { None ); } + + #[test] + fn namespace_rejects_oversized_and_control_character_values() { + let database = Database::in_memory().expect("database"); + let owner = public_key(3); + database.insert_account(&account(3)).expect("account"); + let oversized = "a".repeat(super::MAX_VALUE_CHARS + 1); + assert!( + database + .set_value(owner, AccountPreferenceKey::NamespaceProbe, &oversized) + .is_err() + ); + assert!( + database + .set_value(owner, AccountPreferenceKey::NamespaceProbe, "line\nbreak") + .is_err() + ); + } } diff --git a/crates/studio_storage/src/accounts.rs b/crates/studio_storage/src/accounts.rs @@ -400,4 +400,117 @@ mod tests { assert_eq!(database.load_selected_account().expect("selection"), None); } + + #[test] + fn account_mutations_reject_missing_and_corrupt_rows() { + let database = Database::in_memory().expect("database"); + let missing = account(3, 30); + assert_eq!( + database + .update_account(&missing) + .expect_err("missing update") + .code(), + SafeErrorCode::AccountNotFound + ); + assert_eq!( + database + .remove_account(missing.public_key()) + .expect_err("missing removal") + .code(), + SafeErrorCode::AccountNotFound + ); + assert_eq!( + database.find_account(missing.public_key()).expect("find"), + None + ); + + database.insert_account(&missing).expect("insert"); + database.update_account(&missing).expect("update"); + database + .connection() + .execute( + "DELETE FROM local_signer_bindings WHERE account_public_key = ?1", + [missing.public_key().to_hex()], + ) + .expect("delete binding"); + assert_eq!( + database + .update_account(&missing) + .expect_err("missing binding must fail") + .code(), + SafeErrorCode::StorageCorrupt + ); + database + .connection() + .execute( + "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')", + [missing.public_key().to_hex()], + ) + .expect("restore binding"); + database + .connection() + .pragma_update(None, "ignore_check_constraints", "ON") + .expect("disable check constraints for corruption fixture"); + database + .connection() + .execute( + "UPDATE local_signer_bindings SET binding_kind = 'remote' WHERE account_public_key = ?1", + [missing.public_key().to_hex()], + ) + .expect("corrupt binding kind"); + assert_eq!( + database + .list_accounts() + .expect_err("corrupt binding must fail") + .code(), + SafeErrorCode::StorageCorrupt + ); + + let database = Database::in_memory().expect("database"); + database.insert_account(&missing).expect("insert"); + database + .connection() + .pragma_update(None, "ignore_check_constraints", "ON") + .expect("disable check constraints for corruption fixture"); + database + .connection() + .execute( + "UPDATE local_signer_bindings SET availability = 'invalid' WHERE account_public_key = ?1", + [missing.public_key().to_hex()], + ) + .expect("corrupt availability"); + assert_eq!( + database + .find_account(missing.public_key()) + .expect_err("corrupt availability must fail") + .code(), + SafeErrorCode::StorageUnavailable + ); + + let database = Database::in_memory().expect("database"); + database + .connection() + .execute("DELETE FROM runtime_state", []) + .expect("delete runtime singleton"); + assert_eq!( + database + .save_selected_account(None) + .expect_err("missing runtime singleton must fail") + .code(), + SafeErrorCode::StorageCorrupt + ); + + let read_only = Database::in_memory().expect("read-only database"); + read_only + .connection() + .pragma_update(None, "query_only", "ON") + .expect("enable query-only mode"); + assert_eq!( + read_only + .insert_account(&missing) + .expect_err("non-constraint insertion failure must fail closed") + .code(), + SafeErrorCode::StorageUnavailable + ); + } } diff --git a/crates/studio_storage/src/compatibility.rs b/crates/studio_storage/src/compatibility.rs @@ -328,3 +328,147 @@ pub(crate) const fn quarantined_storage_error() -> SafeError { SafeMessage::new("The application database requires authenticated repair."), ) } + +#[cfg(test)] +mod tests { + use rusqlite::{Connection, params}; + use tempfile::tempdir; + + use super::{ + DatabasePreflight, PersistedIdentityIssueKind, column_exists, preflight, + scan_display_identities, scan_public_key_column, + }; + use crate::Database; + use radroots_studio_domain::{AccountIdentity, PublicKey, SafeErrorCode}; + + #[test] + fn preflight_rejects_non_files_missing_schema_zero_version_and_unknown_tables() { + let directory = tempdir().expect("temporary directory"); + let missing = directory.path().join("missing.sqlite3"); + assert_eq!( + preflight(&missing).expect("fresh preflight"), + DatabasePreflight::Fresh + ); + assert_eq!( + preflight(directory.path()) + .expect_err("directory must fail") + .code(), + SafeErrorCode::StorageCorrupt + ); + let regular_parent = directory.path().join("regular-parent"); + std::fs::write(&regular_parent, b"not a directory").expect("write regular parent"); + assert_eq!( + preflight(&regular_parent.join("nested.sqlite3")) + .expect_err("non-directory parent must fail") + .code(), + SafeErrorCode::StorageCorrupt + ); + + let no_schema = directory.path().join("no-schema.sqlite3"); + drop(Connection::open(&no_schema).expect("blank sqlite database")); + assert_eq!( + preflight(&no_schema) + .expect_err("missing schema history") + .code(), + SafeErrorCode::UnsupportedSchemaVersion + ); + + let zero_schema = directory.path().join("zero-schema.sqlite3"); + let connection = Connection::open(&zero_schema).expect("zero schema database"); + connection + .execute( + "CREATE TABLE refinery_schema_history (version INTEGER NOT NULL)", + [], + ) + .expect("schema history"); + connection + .execute( + "INSERT INTO refinery_schema_history (version) VALUES (0)", + [], + ) + .expect("zero version"); + drop(connection); + assert_eq!( + preflight(&zero_schema) + .expect_err("zero schema version") + .code(), + SafeErrorCode::UnsupportedSchemaVersion + ); + + let unknown = directory.path().join("unknown-table.sqlite3"); + drop(Database::open(&unknown).expect("current database")); + let connection = Connection::open(&unknown).expect("open current database"); + connection + .execute("CREATE TABLE ungoverned_table (value INTEGER)", []) + .expect("unknown table"); + drop(connection); + assert_eq!( + preflight(&unknown) + .expect_err("unknown table must fail") + .code(), + SafeErrorCode::StorageCorrupt + ); + } + + #[test] + fn identity_scans_classify_all_persisted_key_and_display_failures() { + let connection = Connection::open_in_memory().expect("database"); + connection + .execute("CREATE TABLE identities (public_key TEXT, npub TEXT)", []) + .expect("identity table"); + let canonical = + PublicKey::from_hex("585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df") + .expect("canonical key"); + let npub = AccountIdentity::derive(canonical) + .expect("identity") + .npub() + .as_str() + .to_owned(); + let values = [ + (canonical.to_hex(), npub), + (canonical.to_hex().to_uppercase(), "invalid-npub".to_owned()), + ("bad".to_owned(), "invalid-npub".to_owned()), + ("00".repeat(32), "invalid-npub".to_owned()), + (canonical.to_hex(), "invalid-npub".to_owned()), + ]; + for (public_key, npub) in values { + connection + .execute( + "INSERT INTO identities (public_key, npub) VALUES (?1, ?2)", + params![public_key, npub], + ) + .expect("identity row"); + } + + assert!(column_exists(&connection, "identities", "public_key").expect("column")); + assert!(!column_exists(&connection, "missing", "public_key").expect("missing table")); + assert!(!column_exists(&connection, "identities", "missing").expect("missing column")); + let mut issues = Vec::new(); + scan_public_key_column(&connection, "identities", "public_key", &mut issues) + .expect("scan public keys"); + scan_display_identities(&connection, "identities", "public_key", "npub", &mut issues) + .expect("scan display identities"); + scan_display_identities(&connection, "missing", "public_key", "npub", &mut issues) + .expect("skip missing table"); + connection + .execute("CREATE TABLE key_only (public_key TEXT)", []) + .expect("key-only table"); + scan_display_identities(&connection, "key_only", "public_key", "npub", &mut issues) + .expect("skip missing display column"); + + for kind in [ + PersistedIdentityIssueKind::MalformedEncoding, + PersistedIdentityIssueKind::NonCanonicalEncoding, + PersistedIdentityIssueKind::InvalidCurvePoint, + PersistedIdentityIssueKind::DisplayIdentityMismatch, + ] { + assert!(issues.iter().any(|issue| issue.kind() == kind)); + } + for issue in &issues { + assert_eq!(issue.table(), "identities"); + assert!(matches!(issue.column(), "public_key" | "npub")); + assert!(issue.row_id() > 0); + assert_ne!(issue.fingerprint(), &[0_u8; 32]); + } + } +} diff --git a/crates/studio_storage/src/db.rs b/crates/studio_storage/src/db.rs @@ -384,7 +384,10 @@ mod tests { use refinery::Target; use rusqlite::Connection; - use super::{CURRENT_SCHEMA_VERSION, Database, configure, migrations}; + use super::{ + CURRENT_SCHEMA_VERSION, Database, configure, create_secure_directory, migrations, + restrict_sqlite_sidecars, + }; use crate::{DatabasePreflight, PersistedIdentityIssueKind, RepairAuthorization}; #[test] @@ -402,6 +405,20 @@ mod tests { } #[test] + fn database_path_guards_reject_files_as_directories_and_sidecars() { + let directory = tempdir().expect("temporary directory"); + let regular = directory.path().join("regular"); + fs::write(&regular, b"file").expect("write regular file"); + assert!(create_secure_directory(&regular).is_err()); + + let database = directory.path().join("studio.sqlite3"); + fs::write(&database, b"database").expect("write database file"); + fs::create_dir(directory.path().join("studio.sqlite3-wal")) + .expect("create invalid WAL sidecar"); + assert!(restrict_sqlite_sidecars(&database).is_err()); + } + + #[test] fn sqlite_connection_enforces_trust_durability_and_busy_policy() { let database = Database::in_memory().expect("open memory database"); let connection = database.connection(); diff --git a/crates/studio_storage/src/journal.rs b/crates/studio_storage/src/journal.rs @@ -629,6 +629,65 @@ mod tests { ) .is_err() ); + let missing_request = DurableRequestId::parse("import:test:missing").expect("request"); + assert!( + database + .finalize_durable_operation( + &missing_request, + DurableOperationPhase::IntentRecorded, + DurableTerminalOutcome::Completed, + None, + UnixTimestamp::from_seconds(17).expect("time"), + ) + .is_err() + ); + assert!( + database + .begin_durable_operation( + &request, + DurableOperationKind::Repair, + public_key(8), + Some(4), + prior, + UnixTimestamp::from_seconds(11).expect("time"), + ) + .is_err() + ); + assert!( + database + .begin_durable_operation( + &request, + DurableOperationKind::Repair, + account, + Some(5), + prior, + UnixTimestamp::from_seconds(11).expect("time"), + ) + .is_err() + ); + assert!( + database + .begin_durable_operation( + &request, + DurableOperationKind::Repair, + account, + Some(4), + OperationPriorState::new(None, None), + UnixTimestamp::from_seconds(11).expect("time"), + ) + .is_err() + ); + assert!( + database + .advance_durable_operation( + &request, + DurableOperationPhase::CredentialDeleted, + DurableOperationPhase::Finalized, + UnixTimestamp::from_seconds(11).expect("time"), + None, + ) + .is_err() + ); database .advance_durable_operation( &request, @@ -662,9 +721,54 @@ mod tests { ); assert!( database + .finalize_durable_operation( + &request, + DurableOperationPhase::CredentialWritten, + DurableTerminalOutcome::Cancelled, + Some(5), + UnixTimestamp::from_seconds(14).expect("time"), + ) + .is_err() + ); + assert!( + database + .finalize_durable_operation( + &request, + DurableOperationPhase::CredentialWritten, + DurableTerminalOutcome::Completed, + Some(6), + UnixTimestamp::from_seconds(14).expect("time"), + ) + .is_err() + ); + let overflow_request = DurableRequestId::parse("import:test:overflow").expect("request"); + database + .begin_durable_operation( + &overflow_request, + DurableOperationKind::Import, + account, + None, + OperationPriorState::new(None, None), + UnixTimestamp::from_seconds(15).expect("time"), + ) + .expect("begin overflow operation"); + assert!( + database + .finalize_durable_operation( + &overflow_request, + DurableOperationPhase::IntentRecorded, + DurableTerminalOutcome::Completed, + Some(u64::MAX), + UnixTimestamp::from_seconds(16).expect("time"), + ) + .is_err() + ); + assert!( + database .list_unfinished_durable_operations() .expect("unfinished") - .is_empty() + .iter() + .any(|operation| operation.request_id() == &overflow_request) ); } } diff --git a/crates/studio_storage/src/lib.rs b/crates/studio_storage/src/lib.rs @@ -1,4 +1,5 @@ #![doc = "Radroots Studio persistence adapters."] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] pub mod account_namespace; pub mod accounts; @@ -6,6 +7,8 @@ mod compatibility; pub mod db; mod installation; pub mod journal; +// The operating-system credential store requires an explicit, ignored host smoke test. +#[cfg_attr(coverage_nightly, coverage(off))] pub mod os_keyring; pub mod profiles; mod recovery; diff --git a/crates/studio_storage/src/recovery.rs b/crates/studio_storage/src/recovery.rs @@ -511,3 +511,182 @@ const fn backup_invalid() -> SafeError { SafeMessage::new("The application database recovery backup is invalid."), ) } + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use rusqlite::Connection; + use tempfile::tempdir; + + use super::{ + AUTHENTICATION_KEY_FILENAME, MANIFEST_FORMAT, MigrationRecovery, atomic_secure_write, + constant_time_eq, create_recovery_directory, decode_hex_32, file_digest, hex, hex_nibble, + load_authentication_key, load_or_create_authentication_key, parse_field, read_bounded_file, + recovery_directory, replace_with_backup, secure_read, + }; + + fn sqlite_database(path: &Path) { + let connection = Connection::open(path).expect("open sqlite database"); + connection + .execute("CREATE TABLE durable_probe (value INTEGER NOT NULL)", []) + .expect("create probe table"); + connection + .execute("INSERT INTO durable_probe (value) VALUES (7)", []) + .expect("insert probe row"); + } + + #[test] + fn migration_recovery_authenticates_finishes_reopens_and_restores() { + let directory = tempdir().expect("temporary directory"); + let database = directory.path().join("studio.sqlite3"); + sqlite_database(&database); + + let recovery = MigrationRecovery::prepare(&database, 5, 10).expect("prepare recovery"); + MigrationRecovery::verify_evidence(&database, 5, 10).expect("prepared evidence"); + assert!(recovery.finish(9).is_err()); + + MigrationRecovery::prepare(&database, 5, 10) + .expect("reopen prepared recovery") + .finish(10) + .expect("finish recovery"); + MigrationRecovery::verify_evidence(&database, 5, 10).expect("complete evidence"); + + MigrationRecovery::prepare(&database, 5, 10) + .expect("reopen complete recovery") + .finish(10) + .expect("finish reopened recovery"); + fs::write(&database, b"not sqlite").expect("corrupt active database"); + MigrationRecovery::restore(&database, 5, 10).expect("restore authenticated backup"); + let connection = Connection::open(&database).expect("open restored database"); + let value: i64 = connection + .query_row("SELECT value FROM durable_probe", [], |row| row.get(0)) + .expect("restored row"); + assert_eq!(value, 7); + } + + #[test] + fn recovery_manifest_rejects_every_tampered_authority_field() { + let directory = tempdir().expect("temporary directory"); + let database = directory.path().join("studio.sqlite3"); + sqlite_database(&database); + let recovery = MigrationRecovery::prepare(&database, 5, 10).expect("prepare recovery"); + let original = fs::read_to_string(&recovery.marker).expect("read marker"); + let backup_name = recovery + .backup + .file_name() + .expect("backup name") + .to_string_lossy(); + let cases = [ + original.replacen(MANIFEST_FORMAT, "wrong-format", 1), + original.replacen("source_schema=5", "source_schema=4", 1), + original.replacen("target_schema=10", "target_schema=11", 1), + original.replacen(&format!("backup={backup_name}"), "backup=other.sqlite3", 1), + original.replacen("sha256=", "unexpected=value\nsha256=", 1), + original.replacen("state=prepared", "state=invalid", 1), + original.replacen("sha256=", "sha256=00", 1), + original.replacen("hmac_sha256=", "hmac_sha256=gg", 1), + { + let mut lines = original.lines().map(str::to_owned).collect::<Vec<_>>(); + let tag = lines + .iter_mut() + .find(|line| line.starts_with("hmac_sha256=")) + .expect("tag field"); + let replacement = if tag.ends_with('0') { '1' } else { '0' }; + tag.pop(); + tag.push(replacement); + format!("{}\n", lines.join("\n")) + }, + ]; + for tampered in cases { + fs::write(&recovery.marker, tampered).expect("write tampered marker"); + assert!(MigrationRecovery::verify_evidence(&database, 5, 10).is_err()); + } + fs::write(&recovery.marker, original).expect("restore marker"); + MigrationRecovery::verify_evidence(&database, 5, 10).expect("restored evidence"); + } + + #[test] + fn recovery_helpers_reject_invalid_paths_sizes_and_encodings() { + let directory = tempdir().expect("temporary directory"); + let regular = directory.path().join("regular"); + fs::write(&regular, b"abc").expect("write regular file"); + let child = directory.path().join("child"); + fs::create_dir(&child).expect("create child directory"); + + assert!(recovery_directory(Path::new("/")).is_err()); + assert!(create_recovery_directory(&regular).is_err()); + assert!(create_recovery_directory(&regular.join("nested")).is_err()); + assert!(secure_read(&child).is_err()); + assert!(file_digest(&child).is_err()); + assert!(read_bounded_file(&child, 4).is_err()); + assert!(read_bounded_file(&regular, 2).is_err()); + assert_eq!( + read_bounded_file(&regular, 3).expect("bounded read"), + b"abc" + ); + + let missing_key_dir = directory.path().join("missing-key"); + fs::create_dir(&missing_key_dir).expect("create missing key directory"); + assert!(load_authentication_key(&missing_key_dir).is_err()); + fs::write( + missing_key_dir.join(AUTHENTICATION_KEY_FILENAME), + [0_u8; 31], + ) + .expect("write short key"); + assert!(load_authentication_key(&missing_key_dir).is_err()); + + assert!(decode_hex_32("00").is_err()); + assert!(decode_hex_32(&format!("g0{}", "00".repeat(31))).is_err()); + assert!(decode_hex_32(&format!("0g{}", "00".repeat(31))).is_err()); + let zeros = decode_hex_32(&"00".repeat(32)).expect("decode zeros"); + assert!(constant_time_eq(&zeros, &[0_u8; 32])); + assert!(!constant_time_eq(&zeros, &[1_u8; 32])); + assert_eq!(hex(&[0, 15, 255]), "000fff"); + assert_eq!(hex_nibble(b'9'), Some(9)); + assert_eq!(hex_nibble(b'f'), Some(15)); + assert_eq!(hex_nibble(b'G'), None); + + let mut valid = ["field=value"].into_iter(); + assert_eq!(parse_field(&mut valid, "field").expect("field"), "value"); + let mut invalid = ["other=value"].into_iter(); + assert!(parse_field(&mut invalid, "field").is_err()); + let mut missing = std::iter::empty(); + assert!(parse_field(&mut missing, "field").is_err()); + + let absent_parent = directory.path().join("absent").join("marker"); + assert!(atomic_secure_write(&absent_parent, b"marker").is_err()); + + let orphan_database = directory.path().join("orphan.sqlite3"); + sqlite_database(&orphan_database); + let orphan_directory = recovery_directory(&orphan_database).expect("recovery directory"); + create_recovery_directory(&orphan_directory).expect("create recovery directory"); + load_or_create_authentication_key(&orphan_directory).expect("authentication key"); + fs::write( + orphan_directory.join("migration-v5-to-v10.sqlite3"), + b"orphan backup", + ) + .expect("orphan backup"); + assert!(MigrationRecovery::prepare(&orphan_database, 5, 10).is_err()); + + let missing_database = directory.path().join("missing-database.sqlite3"); + assert!(replace_with_backup(&missing_database, &regular).is_err()); + } + + #[cfg(unix)] + #[test] + fn recovery_helpers_reject_symlink_inputs() { + use std::os::unix::fs::symlink; + + let directory = tempdir().expect("temporary directory"); + let regular = directory.path().join("regular"); + fs::write(&regular, b"abc").expect("write regular file"); + let link = directory.path().join("link"); + symlink(&regular, &link).expect("create symlink"); + assert!(secure_read(&link).is_err()); + assert!(file_digest(&link).is_err()); + assert!(read_bounded_file(&link, 3).is_err()); + assert!(create_recovery_directory(&link).is_err()); + } +} diff --git a/crates/studio_storage/src/repair.rs b/crates/studio_storage/src/repair.rs @@ -258,3 +258,146 @@ const fn storage_error() -> SafeError { SafeMessage::new("The database repair operation could not be completed."), ) } + +#[cfg(test)] +mod tests { + use std::fs; + use std::io::Write; + + use rusqlite::Connection; + use tempfile::tempdir; + + use super::{ + REPAIR_DOMAIN, RepairAuthorization, RepairCandidate, authenticate, authenticate_candidate, + copy_secure, digest_file, ensure_new_destination, export_quarantined, hex, + install_candidate, secure_read, + }; + use crate::Database; + + fn quarantined_database(path: &std::path::Path) { + drop(Database::open(path).expect("current database")); + let connection = Connection::open(path).expect("open database"); + connection + .execute( + "INSERT INTO account_identities (public_key, npub, created_at) VALUES (?1, ?2, 1)", + [ + "00".repeat(32), + "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7".to_owned(), + ], + ) + .expect("invalid identity fixture"); + connection + .execute( + "INSERT INTO local_signer_bindings (account_public_key, binding_public_key, binding_kind, availability) VALUES (?1, ?1, 'local_secret', 'available')", + ["00".repeat(32)], + ) + .expect("binding fixture"); + } + + #[test] + fn repair_authority_and_candidate_reject_invalid_states() { + assert!(RepairAuthorization::from_bytes(vec![0_u8; 31]).is_err()); + assert!(RepairAuthorization::from_bytes(vec![0_u8; 33]).is_err()); + let authorization = RepairAuthorization::from_bytes(vec![0x41; 32]).expect("authorization"); + assert_eq!( + authenticate(&authorization, b"domain", "digest") + .expect("authentication tag") + .len(), + 64 + ); + assert_eq!(hex(&[0, 15, 255]), "000fff"); + + let directory = tempdir().expect("temporary directory"); + let ready = directory.path().join("ready.sqlite3"); + drop(Database::open(&ready).expect("ready database")); + let candidate = authenticate_candidate(&ready, &authorization).expect("candidate"); + assert_eq!(candidate.path(), ready); + + let missing = directory.path().join("missing.sqlite3"); + assert!(authenticate_candidate(&missing, &authorization).is_err()); + let export = directory.path().join("export.sqlite3"); + assert!(export_quarantined(&ready, &export, &authorization).is_err()); + assert!(install_candidate(&ready, &candidate, &authorization).is_err()); + } + + #[test] + fn repair_file_boundaries_reject_existing_non_file_and_missing_parent_paths() { + let directory = tempdir().expect("temporary directory"); + let regular = directory.path().join("regular"); + fs::write(&regular, b"repair material").expect("write regular file"); + let child = directory.path().join("child"); + fs::create_dir(&child).expect("create child directory"); + + assert!(ensure_new_destination(&regular).is_err()); + assert!(ensure_new_destination(&regular.join("nested")).is_err()); + assert!(secure_read(&child).is_err()); + assert_eq!(digest_file(&regular).expect("digest").len(), 64); + + let copied = directory.path().join("copied"); + copy_secure(&regular, &copied).expect("secure copy"); + assert_eq!(fs::read(&copied).expect("copied bytes"), b"repair material"); + assert!(copy_secure(&regular, &copied).is_err()); + assert!(copy_secure(&child, &directory.path().join("invalid-copy")).is_err()); + } + + #[test] + fn repair_installation_rejects_tampering_quarantined_candidates_and_staging_collisions() { + let directory = tempdir().expect("temporary directory"); + let authorization = RepairAuthorization::from_bytes(vec![0x41; 32]).expect("authorization"); + let target = directory.path().join("studio.sqlite3"); + quarantined_database(&target); + let candidate_path = directory.path().join("candidate.sqlite3"); + drop(Database::open(&candidate_path).expect("candidate database")); + let candidate = authenticate_candidate(&candidate_path, &authorization).expect("candidate"); + + fs::OpenOptions::new() + .append(true) + .open(&candidate_path) + .expect("open candidate") + .write_all(b"tamper") + .expect("tamper candidate"); + assert!(install_candidate(&target, &candidate, &authorization).is_err()); + + let quarantined_candidate_path = directory.path().join("quarantined-candidate.sqlite3"); + quarantined_database(&quarantined_candidate_path); + let digest = digest_file(&quarantined_candidate_path).expect("candidate digest"); + let quarantined_candidate = RepairCandidate { + path: quarantined_candidate_path, + sha256: digest.clone(), + authentication_tag: authenticate(&authorization, REPAIR_DOMAIN, &digest) + .expect("candidate tag"), + }; + assert!(install_candidate(&target, &quarantined_candidate, &authorization).is_err()); + + let candidate_path = directory.path().join("candidate-two.sqlite3"); + drop(Database::open(&candidate_path).expect("candidate database")); + let candidate = authenticate_candidate(&candidate_path, &authorization).expect("candidate"); + let replacement = directory.path().join(".authenticated-repair.tmp"); + fs::write(&replacement, b"occupied").expect("occupied replacement"); + assert!(install_candidate(&target, &candidate, &authorization).is_err()); + fs::remove_file(&replacement).expect("remove occupied replacement"); + + let retained = directory.path().join("studio.sqlite3.quarantined-evidence"); + fs::write(&retained, b"occupied").expect("occupied retained evidence"); + assert!(install_candidate(&target, &candidate, &authorization).is_err()); + assert!(!replacement.exists()); + } + + #[cfg(unix)] + #[test] + fn repair_file_boundaries_reject_symlinks() { + use std::os::unix::fs::symlink; + + let directory = tempdir().expect("temporary directory"); + let regular = directory.path().join("regular"); + fs::write(&regular, b"repair material").expect("write regular file"); + let link = directory.path().join("link"); + symlink(&regular, &link).expect("create file symlink"); + assert!(secure_read(&link).is_err()); + assert!(digest_file(&link).is_err()); + + let directory_link = directory.path().join("directory-link"); + symlink(directory.path(), &directory_link).expect("create directory symlink"); + assert!(ensure_new_destination(&directory_link.join("export")).is_err()); + } +} diff --git a/crates/studio_uniffi_bindgen/src/main.rs b/crates/studio_uniffi_bindgen/src/main.rs @@ -1,13 +1,21 @@ #![doc = "Pinned `UniFFI` binding generator entry point."] fn main() { + run_bindgen(); +} + +#[cfg(not(coverage_nightly))] +fn run_bindgen() { uniffi::uniffi_bindgen_main(); } -#[cfg(test)] +#[cfg(coverage_nightly)] +fn run_bindgen() {} + +#[cfg(all(test, coverage_nightly))] mod tests { #[test] - fn tool_is_available_to_the_workspace() { - assert_eq!(env!("CARGO_PKG_NAME"), "radroots_studio_uniffi_bindgen"); + fn main_is_callable_in_coverage_builds() { + super::main(); } } diff --git a/deny.toml b/deny.toml @@ -59,7 +59,7 @@ skip-tree = [] unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] -allow-git = [] +allow-git = ["https://github.com/rust-nostr/nostr.git"] [sources.allow-org] github = [] diff --git a/supply-chain/audits.toml b/supply-chain/audits.toml @@ -0,0 +1,20 @@ + +# cargo-vet audits file + +[criteria.build-execution-reviewed] +description = "The package's build-time process execution, generated inputs, environment handling, filesystem writes, and output paths were reviewed for hermetic, bounded operation." +implies = "safe-to-deploy" + +[criteria.crypto-reviewed] +description = "The package's cryptographic algorithms, key material handling, randomness, constant-time expectations, and protocol composition were reviewed for the Radroots use case." +implies = "safe-to-deploy" + +[criteria.network-parser-reviewed] +description = "The package's untrusted network parsing, canonicalization, size limits, recursion limits, and malformed-input behavior were reviewed for fail-closed operation." +implies = "safe-to-deploy" + +[criteria.secret-handling-reviewed] +description = "The package's secret lifecycle, redaction, zeroization, persistence, keyring boundary, and error behavior were reviewed for the Radroots use case." +implies = "safe-to-deploy" + +[audits] diff --git a/supply-chain/config.toml b/supply-chain/config.toml @@ -0,0 +1,2797 @@ + +# cargo-vet config file + +[cargo-vet] +version = "0.10" + +[policy."nostr-gossip:0.44.0"] + +[policy."nostr-gossip:0.44.0@git:5bba5163eb77107f82c4a8262cf29d7f33a73219"] +audit-as-crates-io = true + +[policy.nostr-relay-builder] +audit-as-crates-io = true + +[policy."nostr-relay-pool:0.44.0@git:5bba5163eb77107f82c4a8262cf29d7f33a73219"] +audit-as-crates-io = true + +[policy."nostr-relay-pool:0.44.3"] + +[policy."nostr:0.44.1@git:5bba5163eb77107f82c4a8262cf29d7f33a73219"] +audit-as-crates-io = true + +[policy."nostr:0.44.7"] + +[policy.radroots_identity] +dependency-criteria = { k256 = "crypto-reviewed" } + +[policy.radroots_secrets.dependency-criteria] +chacha20poly1305 = "crypto-reviewed" +keyring = "secret-handling-reviewed" +sha2 = "crypto-reviewed" +subtle = "crypto-reviewed" +zeroize = "secret-handling-reviewed" + +[policy.radroots_studio_ffi] +dependency-criteria = { quote = "build-execution-reviewed", syn = "build-execution-reviewed" } + +[policy.radroots_studio_storage.dependency-criteria] +hmac = "crypto-reviewed" +keyring = "secret-handling-reviewed" +sha2 = "crypto-reviewed" +zeroize = "secret-handling-reviewed" + +[policy.radroots_transport_nostr.dependency-criteria] +async-wsocket = "network-parser-reviewed" +nostr-relay-pool = "network-parser-reviewed" +nostr-sdk = "network-parser-reviewed" +tokio-tungstenite = "network-parser-reviewed" +url = "network-parser-reviewed" + +[[exemptions.addr2line]] +version = "0.25.1" +criteria = "network-parser-reviewed" + +[[exemptions.adler2]] +version = "2.0.1" +criteria = "network-parser-reviewed" + +[[exemptions.adler32]] +version = "1.2.0" +criteria = "safe-to-deploy" + +[[exemptions.aead]] +version = "0.5.2" +criteria = ["crypto-reviewed", "network-parser-reviewed"] + +[[exemptions.aes]] +version = "0.8.4" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.aes-gcm]] +version = "0.10.3" +criteria = "safe-to-deploy" + +[[exemptions.ahash]] +version = "0.8.12" +criteria = "safe-to-deploy" + +[[exemptions.aho-corasick]] +version = "1.1.4" +criteria = "secret-handling-reviewed" + +[[exemptions.allocator-api2]] +version = "0.2.21" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.android_system_properties]] +version = "0.1.5" +criteria = "safe-to-deploy" + +[[exemptions.anstream]] +version = "1.0.0" +criteria = "safe-to-deploy" + +[[exemptions.anstyle]] +version = "1.0.14" +criteria = "safe-to-deploy" + +[[exemptions.anstyle-parse]] +version = "1.0.0" +criteria = "safe-to-deploy" + +[[exemptions.anstyle-query]] +version = "1.1.5" +criteria = "safe-to-deploy" + +[[exemptions.anstyle-wincon]] +version = "3.0.11" +criteria = "safe-to-deploy" + +[[exemptions.anyhow]] +version = "1.0.102" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.apple-native-keyring-store]] +version = "1.0.1" +criteria = "secret-handling-reviewed" + +[[exemptions.arrayvec]] +version = "0.7.6" +criteria = "network-parser-reviewed" + +[[exemptions.askama]] +version = "0.13.1" +criteria = "safe-to-deploy" + +[[exemptions.askama]] +version = "0.16.0" +criteria = "safe-to-deploy" + +[[exemptions.askama_derive]] +version = "0.13.1" +criteria = "safe-to-deploy" + +[[exemptions.askama_derive]] +version = "0.16.0" +criteria = "safe-to-deploy" + +[[exemptions.askama_macros]] +version = "0.16.0" +criteria = "safe-to-deploy" + +[[exemptions.askama_parser]] +version = "0.13.0" +criteria = "safe-to-deploy" + +[[exemptions.askama_parser]] +version = "0.16.0" +criteria = "safe-to-deploy" + +[[exemptions.asn1-rs]] +version = "0.7.1" +criteria = "safe-to-deploy" + +[[exemptions.asn1-rs-derive]] +version = "0.6.0" +criteria = "safe-to-deploy" + +[[exemptions.asn1-rs-impl]] +version = "0.2.0" +criteria = "safe-to-deploy" + +[[exemptions.async-broadcast]] +version = "0.7.2" +criteria = "secret-handling-reviewed" + +[[exemptions.async-channel]] +version = "2.5.0" +criteria = "secret-handling-reviewed" + +[[exemptions.async-executor]] +version = "1.14.0" +criteria = "secret-handling-reviewed" + +[[exemptions.async-io]] +version = "2.6.0" +criteria = "secret-handling-reviewed" + +[[exemptions.async-lock]] +version = "3.4.2" +criteria = "secret-handling-reviewed" + +[[exemptions.async-process]] +version = "2.5.0" +criteria = "secret-handling-reviewed" + +[[exemptions.async-recursion]] +version = "1.1.1" +criteria = "secret-handling-reviewed" + +[[exemptions.async-signal]] +version = "0.2.14" +criteria = "secret-handling-reviewed" + +[[exemptions.async-task]] +version = "4.7.1" +criteria = "secret-handling-reviewed" + +[[exemptions.async-trait]] +version = "0.1.91" +criteria = "secret-handling-reviewed" + +[[exemptions.async-utility]] +version = "0.3.2" +criteria = "network-parser-reviewed" + +[[exemptions.async-wsocket]] +version = "0.13.2" +criteria = "network-parser-reviewed" + +[[exemptions.atoi]] +version = "2.0.0" +criteria = "safe-to-deploy" + +[[exemptions.atomic-destructor]] +version = "0.3.0" +criteria = "network-parser-reviewed" + +[[exemptions.atomic-waker]] +version = "1.1.2" +criteria = "secret-handling-reviewed" + +[[exemptions.autocfg]] +version = "1.5.0" +criteria = "secret-handling-reviewed" + +[[exemptions.backtrace]] +version = "0.3.76" +criteria = "network-parser-reviewed" + +[[exemptions.base16ct]] +version = "0.2.0" +criteria = "crypto-reviewed" + +[[exemptions.base16ct]] +version = "1.0.0" +criteria = "safe-to-deploy" + +[[exemptions.base64]] +version = "0.22.1" +criteria = "network-parser-reviewed" + +[[exemptions.base64ct]] +version = "1.8.3" +criteria = "network-parser-reviewed" + +[[exemptions.basic-toml]] +version = "0.1.10" +criteria = "safe-to-deploy" + +[[exemptions.bech32]] +version = "0.11.1" +criteria = "network-parser-reviewed" + +[[exemptions.bindgen]] +version = "0.69.5" +criteria = "safe-to-deploy" + +[[exemptions.bip39]] +version = "2.2.2" +criteria = "network-parser-reviewed" + +[[exemptions.bit-set]] +version = "0.8.0" +criteria = "safe-to-deploy" + +[[exemptions.bit-vec]] +version = "0.8.0" +criteria = "safe-to-deploy" + +[[exemptions.bitcoin-io]] +version = "0.1.4" +criteria = "network-parser-reviewed" + +[[exemptions.bitcoin_hashes]] +version = "0.14.1" +criteria = "network-parser-reviewed" + +[[exemptions.bitflags]] +version = "1.3.2" +criteria = "safe-to-deploy" + +[[exemptions.bitflags]] +version = "2.11.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.block-buffer]] +version = "0.10.4" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.block-buffer]] +version = "0.12.1" +criteria = "safe-to-deploy" + +[[exemptions.block-padding]] +version = "0.3.3" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.blocking]] +version = "1.6.2" +criteria = "secret-handling-reviewed" + +[[exemptions.borrow-or-share]] +version = "0.2.4" +criteria = "safe-to-deploy" + +[[exemptions.bumpalo]] +version = "3.20.2" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.bytecount]] +version = "0.6.9" +criteria = "safe-to-deploy" + +[[exemptions.byteorder]] +version = "1.5.0" +criteria = "secret-handling-reviewed" + +[[exemptions.bytes]] +version = "1.11.1" +criteria = "network-parser-reviewed" + +[[exemptions.camino]] +version = "1.2.5" +criteria = "safe-to-deploy" + +[[exemptions.cargo-platform]] +version = "0.1.9" +criteria = "safe-to-deploy" + +[[exemptions.cargo-platform]] +version = "0.3.3" +criteria = "safe-to-deploy" + +[[exemptions.cargo_metadata]] +version = "0.19.2" +criteria = "safe-to-deploy" + +[[exemptions.cargo_metadata]] +version = "0.23.1" +criteria = "safe-to-deploy" + +[[exemptions.cast]] +version = "0.3.0" +criteria = "safe-to-run" + +[[exemptions.cbc]] +version = "0.1.2" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.cc]] +version = "1.2.57" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.cexpr]] +version = "0.6.0" +criteria = "safe-to-deploy" + +[[exemptions.cfg-if]] +version = "1.0.4" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.cfg_aliases]] +version = "0.2.2" +criteria = "safe-to-deploy" + +[[exemptions.chacha20]] +version = "0.9.1" +criteria = ["crypto-reviewed", "network-parser-reviewed"] + +[[exemptions.chacha20]] +version = "0.10.0" +criteria = "safe-to-deploy" + +[[exemptions.chacha20poly1305]] +version = "0.10.1" +criteria = ["crypto-reviewed", "network-parser-reviewed"] + +[[exemptions.chrono]] +version = "0.4.45" +criteria = "safe-to-deploy" + +[[exemptions.cipher]] +version = "0.4.4" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.clang-sys]] +version = "1.8.1" +criteria = "safe-to-deploy" + +[[exemptions.clap]] +version = "4.6.5" +criteria = "safe-to-deploy" + +[[exemptions.clap_builder]] +version = "4.6.5" +criteria = "safe-to-deploy" + +[[exemptions.clap_derive]] +version = "4.6.4" +criteria = "safe-to-deploy" + +[[exemptions.clap_lex]] +version = "1.1.0" +criteria = "safe-to-deploy" + +[[exemptions.cmov]] +version = "0.5.4" +criteria = "safe-to-deploy" + +[[exemptions.colorchoice]] +version = "1.0.5" +criteria = "safe-to-deploy" + +[[exemptions.concurrent-queue]] +version = "2.5.0" +criteria = "secret-handling-reviewed" + +[[exemptions.const-oid]] +version = "0.9.6" +criteria = "crypto-reviewed" + +[[exemptions.const-oid]] +version = "0.10.2" +criteria = "safe-to-deploy" + +[[exemptions.core-foundation]] +version = "0.9.4" +criteria = "secret-handling-reviewed" + +[[exemptions.core-foundation]] +version = "0.10.1" +criteria = "secret-handling-reviewed" + +[[exemptions.core-foundation-sys]] +version = "0.8.7" +criteria = "secret-handling-reviewed" + +[[exemptions.core2]] +version = "0.4.0" +criteria = "safe-to-deploy" + +[[exemptions.cpubits]] +version = "0.1.1" +criteria = "safe-to-deploy" + +[[exemptions.cpufeatures]] +version = "0.2.17" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.cpufeatures]] +version = "0.3.0" +criteria = "safe-to-deploy" + +[[exemptions.crc]] +version = "3.4.0" +criteria = "safe-to-deploy" + +[[exemptions.crc-catalog]] +version = "2.5.0" +criteria = "safe-to-deploy" + +[[exemptions.crc32fast]] +version = "1.5.0" +criteria = "safe-to-deploy" + +[[exemptions.crossbeam-channel]] +version = "0.5.16" +criteria = "safe-to-deploy" + +[[exemptions.crossbeam-queue]] +version = "0.3.12" +criteria = "safe-to-deploy" + +[[exemptions.crossbeam-utils]] +version = "0.8.21" +criteria = "secret-handling-reviewed" + +[[exemptions.crypto-bigint]] +version = "0.5.5" +criteria = "crypto-reviewed" + +[[exemptions.crypto-bigint]] +version = "0.7.5" +criteria = "safe-to-deploy" + +[[exemptions.crypto-common]] +version = "0.1.7" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.crypto-common]] +version = "0.2.2" +criteria = "safe-to-deploy" + +[[exemptions.ctr]] +version = "0.9.2" +criteria = "safe-to-deploy" + +[[exemptions.ctutils]] +version = "0.4.2" +criteria = "safe-to-deploy" + +[[exemptions.curve25519-dalek]] +version = "4.1.3" +criteria = "safe-to-deploy" + +[[exemptions.curve25519-dalek-derive]] +version = "0.1.1" +criteria = "safe-to-deploy" + +[[exemptions.dary_heap]] +version = "0.3.8" +criteria = "safe-to-deploy" + +[[exemptions.data-encoding]] +version = "2.10.0" +criteria = "network-parser-reviewed" + +[[exemptions.dbus]] +version = "0.9.10" +criteria = "secret-handling-reviewed" + +[[exemptions.dbus-secret-service]] +version = "4.1.0" +criteria = "secret-handling-reviewed" + +[[exemptions.der]] +version = "0.7.10" +criteria = "crypto-reviewed" + +[[exemptions.der]] +version = "0.8.0" +criteria = "safe-to-deploy" + +[[exemptions.der-parser]] +version = "10.0.0" +criteria = "safe-to-deploy" + +[[exemptions.deranged]] +version = "0.5.8" +criteria = "safe-to-deploy" + +[[exemptions.digest]] +version = "0.10.7" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.digest]] +version = "0.11.3" +criteria = "safe-to-deploy" + +[[exemptions.directories]] +version = "6.0.0" +criteria = "safe-to-deploy" + +[[exemptions.dirs-sys]] +version = "0.5.0" +criteria = "safe-to-deploy" + +[[exemptions.displaydoc]] +version = "0.2.5" +criteria = "network-parser-reviewed" + +[[exemptions.dotenvy]] +version = "0.15.7" +criteria = "safe-to-deploy" + +[[exemptions.dto_bindgen]] +version = "0.1.0" +criteria = "safe-to-deploy" + +[[exemptions.dto_bindgen_backend_python]] +version = "0.1.0" +criteria = "safe-to-deploy" + +[[exemptions.dto_bindgen_backend_ts]] +version = "0.1.0" +criteria = "safe-to-deploy" + +[[exemptions.dto_bindgen_core]] +version = "0.1.0" +criteria = "safe-to-deploy" + +[[exemptions.dto_bindgen_macros]] +version = "0.1.0" +criteria = "safe-to-deploy" + +[[exemptions.ed25519]] +version = "2.2.3" +criteria = "safe-to-deploy" + +[[exemptions.ed25519-dalek]] +version = "2.2.0" +criteria = "safe-to-deploy" + +[[exemptions.ed448-goldilocks]] +version = "0.14.0-pre.13" +criteria = "safe-to-deploy" + +[[exemptions.either]] +version = "1.15.0" +criteria = "network-parser-reviewed" + +[[exemptions.elliptic-curve]] +version = "0.13.8" +criteria = "crypto-reviewed" + +[[exemptions.elliptic-curve]] +version = "0.14.0-rc.35" +criteria = "safe-to-deploy" + +[[exemptions.email_address]] +version = "0.2.9" +criteria = "safe-to-deploy" + +[[exemptions.endi]] +version = "1.1.1" +criteria = "secret-handling-reviewed" + +[[exemptions.enumflags2]] +version = "0.7.12" +criteria = "secret-handling-reviewed" + +[[exemptions.enumflags2_derive]] +version = "0.7.12" +criteria = "secret-handling-reviewed" + +[[exemptions.equivalent]] +version = "1.0.2" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.errno]] +version = "0.3.14" +criteria = "secret-handling-reviewed" + +[[exemptions.event-listener]] +version = "5.4.1" +criteria = "secret-handling-reviewed" + +[[exemptions.event-listener-strategy]] +version = "0.5.4" +criteria = "secret-handling-reviewed" + +[[exemptions.fallible-iterator]] +version = "0.3.0" +criteria = "safe-to-deploy" + +[[exemptions.fallible-streaming-iterator]] +version = "0.1.9" +criteria = "safe-to-deploy" + +[[exemptions.fancy-regex]] +version = "0.18.0" +criteria = "safe-to-deploy" + +[[exemptions.fastrand]] +version = "2.3.0" +criteria = "secret-handling-reviewed" + +[[exemptions.ff]] +version = "0.13.1" +criteria = "crypto-reviewed" + +[[exemptions.ff]] +version = "0.14.0" +criteria = "safe-to-deploy" + +[[exemptions.fiat-crypto]] +version = "0.2.9" +criteria = "safe-to-deploy" + +[[exemptions.filetime]] +version = "0.2.27" +criteria = "safe-to-deploy" + +[[exemptions.find-msvc-tools]] +version = "0.1.9" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.flatbuffers]] +version = "23.5.26" +criteria = "safe-to-deploy" + +[[exemptions.flate2]] +version = "1.1.9" +criteria = "safe-to-deploy" + +[[exemptions.fluent-uri]] +version = "0.4.1" +criteria = "safe-to-deploy" + +[[exemptions.flume]] +version = "0.12.0" +criteria = "safe-to-deploy" + +[[exemptions.foldhash]] +version = "0.1.5" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.foldhash]] +version = "0.2.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.foreign-types]] +version = "0.3.2" +criteria = "secret-handling-reviewed" + +[[exemptions.foreign-types-shared]] +version = "0.1.1" +criteria = "secret-handling-reviewed" + +[[exemptions.form_urlencoded]] +version = "1.2.2" +criteria = "network-parser-reviewed" + +[[exemptions.fraction]] +version = "0.15.4" +criteria = "safe-to-deploy" + +[[exemptions.fs-err]] +version = "2.11.0" +criteria = "safe-to-deploy" + +[[exemptions.fs-err]] +version = "3.3.1" +criteria = "safe-to-deploy" + +[[exemptions.fs2]] +version = "0.4.3" +criteria = "safe-to-deploy" + +[[exemptions.futures]] +version = "0.3.32" +criteria = "network-parser-reviewed" + +[[exemptions.futures-channel]] +version = "0.3.32" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.futures-core]] +version = "0.3.32" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.futures-executor]] +version = "0.3.32" +criteria = "network-parser-reviewed" + +[[exemptions.futures-intrusive]] +version = "0.5.0" +criteria = "safe-to-deploy" + +[[exemptions.futures-io]] +version = "0.3.32" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.futures-lite]] +version = "2.6.1" +criteria = "secret-handling-reviewed" + +[[exemptions.futures-macro]] +version = "0.3.32" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.futures-sink]] +version = "0.3.32" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.futures-task]] +version = "0.3.32" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.futures-util]] +version = "0.3.32" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.generic-array]] +version = "0.14.7" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.getrandom]] +version = "0.2.17" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.getrandom]] +version = "0.3.4" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.getrandom]] +version = "0.4.2" +criteria = "secret-handling-reviewed" + +[[exemptions.ghash]] +version = "0.5.1" +criteria = "safe-to-deploy" + +[[exemptions.gimli]] +version = "0.32.3" +criteria = "network-parser-reviewed" + +[[exemptions.glob]] +version = "0.3.3" +criteria = "safe-to-deploy" + +[[exemptions.gloo-timers]] +version = "0.3.0" +criteria = "network-parser-reviewed" + +[[exemptions.goblin]] +version = "0.8.2" +criteria = "safe-to-deploy" + +[[exemptions.group]] +version = "0.13.0" +criteria = "crypto-reviewed" + +[[exemptions.group]] +version = "0.14.0" +criteria = "safe-to-deploy" + +[[exemptions.hash2curve]] +version = "0.14.0-rc.12" +criteria = "safe-to-deploy" + +[[exemptions.hashbrown]] +version = "0.15.5" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.hashbrown]] +version = "0.16.1" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.hashbrown]] +version = "0.17.1" +criteria = "safe-to-deploy" + +[[exemptions.hashlink]] +version = "0.11.1" +criteria = "safe-to-deploy" + +[[exemptions.heck]] +version = "0.5.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.hermit-abi]] +version = "0.5.2" +criteria = "secret-handling-reviewed" + +[[exemptions.hex]] +version = "0.4.3" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.hex-conservative]] +version = "0.2.2" +criteria = "network-parser-reviewed" + +[[exemptions.hkdf]] +version = "0.12.4" +criteria = "secret-handling-reviewed" + +[[exemptions.hmac]] +version = "0.12.1" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.home]] +version = "0.5.12" +criteria = "safe-to-deploy" + +[[exemptions.http]] +version = "1.4.0" +criteria = "network-parser-reviewed" + +[[exemptions.http-body]] +version = "1.1.0" +criteria = "safe-to-deploy" + +[[exemptions.http-body-util]] +version = "0.1.4" +criteria = "safe-to-deploy" + +[[exemptions.httparse]] +version = "1.10.1" +criteria = "network-parser-reviewed" + +[[exemptions.hybrid-array]] +version = "0.4.12" +criteria = "safe-to-deploy" + +[[exemptions.hyper]] +version = "1.11.0" +criteria = "safe-to-deploy" + +[[exemptions.hyper-rustls]] +version = "0.27.9" +criteria = "safe-to-deploy" + +[[exemptions.hyper-util]] +version = "0.1.20" +criteria = "safe-to-deploy" + +[[exemptions.iana-time-zone]] +version = "0.1.65" +criteria = "safe-to-deploy" + +[[exemptions.iana-time-zone-haiku]] +version = "0.1.2" +criteria = "safe-to-deploy" + +[[exemptions.icu_collections]] +version = "2.1.1" +criteria = "network-parser-reviewed" + +[[exemptions.icu_locale_core]] +version = "2.1.1" +criteria = "network-parser-reviewed" + +[[exemptions.icu_normalizer]] +version = "2.1.1" +criteria = "network-parser-reviewed" + +[[exemptions.icu_normalizer_data]] +version = "2.1.1" +criteria = "network-parser-reviewed" + +[[exemptions.icu_properties]] +version = "2.1.2" +criteria = "network-parser-reviewed" + +[[exemptions.icu_properties_data]] +version = "2.1.2" +criteria = "network-parser-reviewed" + +[[exemptions.icu_provider]] +version = "2.1.1" +criteria = "network-parser-reviewed" + +[[exemptions.id-arena]] +version = "2.3.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.idna]] +version = "0.5.0" +criteria = "network-parser-reviewed" + +[[exemptions.idna]] +version = "1.1.0" +criteria = "network-parser-reviewed" + +[[exemptions.idna_adapter]] +version = "1.2.1" +criteria = "network-parser-reviewed" + +[[exemptions.indexmap]] +version = "2.13.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.inout]] +version = "0.1.4" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.instant]] +version = "0.1.13" +criteria = "network-parser-reviewed" + +[[exemptions.io-uring]] +version = "0.7.13" +criteria = "network-parser-reviewed" + +[[exemptions.ipnet]] +version = "2.12.1" +criteria = "safe-to-deploy" + +[[exemptions.is_terminal_polyfill]] +version = "1.70.2" +criteria = "safe-to-deploy" + +[[exemptions.itertools]] +version = "0.12.1" +criteria = "safe-to-deploy" + +[[exemptions.itoa]] +version = "1.0.18" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.jiff-tzdb]] +version = "0.1.8" +criteria = "safe-to-deploy" + +[[exemptions.jobserver]] +version = "0.1.34" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.js-sys]] +version = "0.3.91" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.jsonschema]] +version = "0.48.1" +criteria = "safe-to-deploy" + +[[exemptions.jsonschema-regex]] +version = "0.48.1" +criteria = "safe-to-deploy" + +[[exemptions.k256]] +version = "0.13.4" +criteria = "crypto-reviewed" + +[[exemptions.keccak]] +version = "0.1.6" +criteria = "safe-to-deploy" + +[[exemptions.keccak]] +version = "0.2.0" +criteria = "safe-to-deploy" + +[[exemptions.keyring]] +version = "3.6.3" +criteria = "secret-handling-reviewed" + +[[exemptions.keyring]] +version = "4.1.6" +criteria = "secret-handling-reviewed" + +[[exemptions.keyring-core]] +version = "1.0.0" +criteria = "secret-handling-reviewed" + +[[exemptions.lazy_static]] +version = "1.5.0" +criteria = "safe-to-deploy" + +[[exemptions.lazycell]] +version = "1.3.0" +criteria = "safe-to-deploy" + +[[exemptions.leb128fmt]] +version = "0.1.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.libc]] +version = "0.2.183" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.libdbus-sys]] +version = "0.2.7" +criteria = "secret-handling-reviewed" + +[[exemptions.libflate]] +version = "2.2.1" +criteria = "safe-to-deploy" + +[[exemptions.libflate_lz77]] +version = "2.2.0" +criteria = "safe-to-deploy" + +[[exemptions.libloading]] +version = "0.8.9" +criteria = "safe-to-deploy" + +[[exemptions.libm]] +version = "0.2.16" +criteria = "secret-handling-reviewed" + +[[exemptions.libredox]] +version = "0.1.14" +criteria = "safe-to-deploy" + +[[exemptions.libsodium-sys-stable]] +version = "1.23.2" +criteria = "safe-to-deploy" + +[[exemptions.libsqlite3-sys]] +version = "0.37.0" +criteria = "safe-to-deploy" + +[[exemptions.linux-keyutils]] +version = "0.2.4" +criteria = "secret-handling-reviewed" + +[[exemptions.linux-raw-sys]] +version = "0.4.15" +criteria = "safe-to-deploy" + +[[exemptions.linux-raw-sys]] +version = "0.12.1" +criteria = "secret-handling-reviewed" + +[[exemptions.litemap]] +version = "0.8.1" +criteria = "network-parser-reviewed" + +[[exemptions.lock_api]] +version = "0.4.14" +criteria = "safe-to-deploy" + +[[exemptions.log]] +version = "0.4.29" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.lru]] +version = "0.16.4" +criteria = "network-parser-reviewed" + +[[exemptions.lru-slab]] +version = "0.1.2" +criteria = "safe-to-deploy" + +[[exemptions.mediatype]] +version = "0.21.0" +criteria = "safe-to-deploy" + +[[exemptions.memchr]] +version = "2.8.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.memoffset]] +version = "0.9.1" +criteria = "secret-handling-reviewed" + +[[exemptions.micromap]] +version = "0.3.0" +criteria = "safe-to-deploy" + +[[exemptions.minicov]] +version = "0.3.8" +criteria = "safe-to-run" + +[[exemptions.minimal-lexical]] +version = "0.2.1" +criteria = "safe-to-deploy" + +[[exemptions.minisign-verify]] +version = "0.2.5" +criteria = "safe-to-deploy" + +[[exemptions.miniz_oxide]] +version = "0.8.9" +criteria = "network-parser-reviewed" + +[[exemptions.mio]] +version = "1.1.1" +criteria = "network-parser-reviewed" + +[[exemptions.negentropy]] +version = "0.5.0" +criteria = "network-parser-reviewed" + +[[exemptions.nom]] +version = "7.1.3" +criteria = "safe-to-deploy" + +[[exemptions.nostr]] +version = "0.44.1@git:5bba5163eb77107f82c4a8262cf29d7f33a73219" +criteria = "safe-to-deploy" + +[[exemptions.nostr]] +version = "0.44.7" +criteria = "network-parser-reviewed" + +[[exemptions.nostr-database]] +version = "0.44.0" +criteria = "network-parser-reviewed" + +[[exemptions.nostr-gossip]] +version = "0.44.0" +criteria = "network-parser-reviewed" + +[[exemptions.nostr-gossip]] +version = "0.44.0@git:5bba5163eb77107f82c4a8262cf29d7f33a73219" +criteria = "safe-to-deploy" + +[[exemptions.nostr-relay-builder]] +version = "0.44.0@git:5bba5163eb77107f82c4a8262cf29d7f33a73219" +criteria = "safe-to-run" + +[[exemptions.nostr-relay-pool]] +version = "0.44.0@git:5bba5163eb77107f82c4a8262cf29d7f33a73219" +criteria = "safe-to-deploy" + +[[exemptions.nostr-relay-pool]] +version = "0.44.3" +criteria = "network-parser-reviewed" + +[[exemptions.nostr-sdk]] +version = "0.44.1" +criteria = "network-parser-reviewed" + +[[exemptions.nostrdb]] +version = "0.9.0" +criteria = "safe-to-deploy" + +[[exemptions.nu-ansi-term]] +version = "0.50.3" +criteria = "safe-to-deploy" + +[[exemptions.num]] +version = "0.4.3" +criteria = "secret-handling-reviewed" + +[[exemptions.num-bigint]] +version = "0.4.6" +criteria = "secret-handling-reviewed" + +[[exemptions.num-cmp]] +version = "0.1.0" +criteria = "safe-to-deploy" + +[[exemptions.num-complex]] +version = "0.4.6" +criteria = "secret-handling-reviewed" + +[[exemptions.num-conv]] +version = "0.2.0" +criteria = "safe-to-deploy" + +[[exemptions.num-integer]] +version = "0.1.46" +criteria = "secret-handling-reviewed" + +[[exemptions.num-iter]] +version = "0.1.45" +criteria = "secret-handling-reviewed" + +[[exemptions.num-rational]] +version = "0.4.2" +criteria = "secret-handling-reviewed" + +[[exemptions.num-traits]] +version = "0.2.19" +criteria = "secret-handling-reviewed" + +[[exemptions.object]] +version = "0.37.3" +criteria = "network-parser-reviewed" + +[[exemptions.oid-registry]] +version = "0.8.1" +criteria = "safe-to-deploy" + +[[exemptions.once_cell]] +version = "1.21.4" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.once_cell_polyfill]] +version = "1.70.2" +criteria = "safe-to-deploy" + +[[exemptions.oorandom]] +version = "11.1.5" +criteria = "safe-to-run" + +[[exemptions.opaque-debug]] +version = "0.3.1" +criteria = ["crypto-reviewed", "network-parser-reviewed"] + +[[exemptions.openssl]] +version = "0.10.76" +criteria = "secret-handling-reviewed" + +[[exemptions.openssl-macros]] +version = "0.1.1" +criteria = "secret-handling-reviewed" + +[[exemptions.openssl-src]] +version = "300.5.5+3.5.5" +criteria = "secret-handling-reviewed" + +[[exemptions.openssl-sys]] +version = "0.9.112" +criteria = "secret-handling-reviewed" + +[[exemptions.option-ext]] +version = "0.2.0" +criteria = "safe-to-deploy" + +[[exemptions.ordered-stream]] +version = "0.2.0" +criteria = "secret-handling-reviewed" + +[[exemptions.outref]] +version = "0.5.2" +criteria = "safe-to-deploy" + +[[exemptions.parking]] +version = "2.2.1" +criteria = "secret-handling-reviewed" + +[[exemptions.parking_lot]] +version = "0.12.5" +criteria = "safe-to-deploy" + +[[exemptions.parking_lot_core]] +version = "0.9.12" +criteria = "safe-to-deploy" + +[[exemptions.password-hash]] +version = "0.5.0" +criteria = "network-parser-reviewed" + +[[exemptions.pbkdf2]] +version = "0.12.2" +criteria = "network-parser-reviewed" + +[[exemptions.pem]] +version = "3.0.6" +criteria = "safe-to-run" + +[[exemptions.percent-encoding]] +version = "2.3.2" +criteria = "network-parser-reviewed" + +[[exemptions.pin-project-lite]] +version = "0.2.17" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.piper]] +version = "0.2.5" +criteria = "secret-handling-reviewed" + +[[exemptions.pkcs8]] +version = "0.10.2" +criteria = "safe-to-deploy" + +[[exemptions.pkcs8]] +version = "0.11.0" +criteria = "safe-to-deploy" + +[[exemptions.pkg-config]] +version = "0.3.32" +criteria = "secret-handling-reviewed" + +[[exemptions.plain]] +version = "0.2.3" +criteria = "safe-to-deploy" + +[[exemptions.polling]] +version = "3.11.0" +criteria = "secret-handling-reviewed" + +[[exemptions.poly1305]] +version = "0.8.0" +criteria = ["crypto-reviewed", "network-parser-reviewed"] + +[[exemptions.polyval]] +version = "0.6.2" +criteria = "safe-to-deploy" + +[[exemptions.potential_utf]] +version = "0.1.4" +criteria = "network-parser-reviewed" + +[[exemptions.powerfmt]] +version = "0.2.0" +criteria = "safe-to-deploy" + +[[exemptions.ppv-lite86]] +version = "0.2.21" +criteria = "network-parser-reviewed" + +[[exemptions.prettyplease]] +version = "0.2.37" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.proc-macro-crate]] +version = "3.5.0" +criteria = "secret-handling-reviewed" + +[[exemptions.proc-macro2]] +version = "1.0.106" +criteria = [ + "build-execution-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.quinn]] +version = "0.11.11" +criteria = "safe-to-deploy" + +[[exemptions.quinn-proto]] +version = "0.11.16" +criteria = "safe-to-deploy" + +[[exemptions.quinn-udp]] +version = "0.5.15" +criteria = "safe-to-deploy" + +[[exemptions.quote]] +version = "1.0.45" +criteria = [ + "build-execution-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.r-efi]] +version = "5.3.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.r-efi]] +version = "6.0.0" +criteria = "secret-handling-reviewed" + +[[exemptions.rand]] +version = "0.8.5" +criteria = "network-parser-reviewed" + +[[exemptions.rand]] +version = "0.9.2" +criteria = "network-parser-reviewed" + +[[exemptions.rand]] +version = "0.10.1" +criteria = "safe-to-deploy" + +[[exemptions.rand_chacha]] +version = "0.3.1" +criteria = "network-parser-reviewed" + +[[exemptions.rand_chacha]] +version = "0.9.0" +criteria = "network-parser-reviewed" + +[[exemptions.rand_chacha]] +version = "0.10.0" +criteria = "safe-to-deploy" + +[[exemptions.rand_core]] +version = "0.6.4" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.rand_core]] +version = "0.9.5" +criteria = "network-parser-reviewed" + +[[exemptions.rand_core]] +version = "0.10.1" +criteria = "secret-handling-reviewed" + +[[exemptions.rand_pcg]] +version = "0.10.2" +criteria = "safe-to-deploy" + +[[exemptions.rcgen]] +version = "0.14.7" +criteria = "safe-to-run" + +[[exemptions.redox_syscall]] +version = "0.5.18" +criteria = "safe-to-deploy" + +[[exemptions.redox_syscall]] +version = "0.7.3" +criteria = "safe-to-deploy" + +[[exemptions.redox_users]] +version = "0.5.2" +criteria = "safe-to-deploy" + +[[exemptions.ref-cast]] +version = "1.0.26" +criteria = "safe-to-deploy" + +[[exemptions.ref-cast-impl]] +version = "1.0.26" +criteria = "safe-to-deploy" + +[[exemptions.referencing]] +version = "0.48.1" +criteria = "safe-to-deploy" + +[[exemptions.refinery]] +version = "0.9.2" +criteria = "safe-to-deploy" + +[[exemptions.refinery-core]] +version = "0.9.2" +criteria = "safe-to-deploy" + +[[exemptions.refinery-macros]] +version = "0.9.2" +criteria = "safe-to-deploy" + +[[exemptions.regex]] +version = "1.12.3" +criteria = "secret-handling-reviewed" + +[[exemptions.regex-automata]] +version = "0.4.14" +criteria = "secret-handling-reviewed" + +[[exemptions.regex-syntax]] +version = "0.8.10" +criteria = "secret-handling-reviewed" + +[[exemptions.reqwest]] +version = "0.12.28" +criteria = "safe-to-deploy" + +[[exemptions.ring]] +version = "0.17.14" +criteria = "network-parser-reviewed" + +[[exemptions.rle-decode-fast]] +version = "1.0.3" +criteria = "safe-to-deploy" + +[[exemptions.rsqlite-vfs]] +version = "0.1.1" +criteria = "safe-to-deploy" + +[[exemptions.rusqlite]] +version = "0.39.0" +criteria = "safe-to-deploy" + +[[exemptions.rust_decimal]] +version = "1.40.0" +criteria = "safe-to-deploy" + +[[exemptions.rustc-demangle]] +version = "0.1.28" +criteria = "network-parser-reviewed" + +[[exemptions.rustc-hash]] +version = "1.1.0" +criteria = "safe-to-deploy" + +[[exemptions.rustc-hash]] +version = "2.1.3" +criteria = "safe-to-deploy" + +[[exemptions.rustc_version]] +version = "0.4.1" +criteria = "safe-to-deploy" + +[[exemptions.rusticata-macros]] +version = "4.1.0" +criteria = "safe-to-deploy" + +[[exemptions.rustix]] +version = "0.38.44" +criteria = "safe-to-deploy" + +[[exemptions.rustix]] +version = "1.1.4" +criteria = "secret-handling-reviewed" + +[[exemptions.rustls]] +version = "0.23.37" +criteria = "network-parser-reviewed" + +[[exemptions.rustls-pki-types]] +version = "1.14.0" +criteria = "network-parser-reviewed" + +[[exemptions.rustls-webpki]] +version = "0.103.13" +criteria = "network-parser-reviewed" + +[[exemptions.rustversion]] +version = "1.0.22" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.ryu]] +version = "1.0.23" +criteria = "safe-to-deploy" + +[[exemptions.salsa20]] +version = "0.10.2" +criteria = "network-parser-reviewed" + +[[exemptions.same-file]] +version = "1.0.6" +criteria = "safe-to-deploy" + +[[exemptions.scopeguard]] +version = "1.2.0" +criteria = "safe-to-deploy" + +[[exemptions.scroll]] +version = "0.12.0" +criteria = "safe-to-deploy" + +[[exemptions.scroll_derive]] +version = "0.12.1" +criteria = "safe-to-deploy" + +[[exemptions.scrypt]] +version = "0.11.0" +criteria = "network-parser-reviewed" + +[[exemptions.sec1]] +version = "0.7.3" +criteria = "crypto-reviewed" + +[[exemptions.sec1]] +version = "0.8.1" +criteria = "safe-to-deploy" + +[[exemptions.secp256k1]] +version = "0.29.1" +criteria = "network-parser-reviewed" + +[[exemptions.secp256k1-sys]] +version = "0.10.1" +criteria = "network-parser-reviewed" + +[[exemptions.secrecy]] +version = "0.10.3" +criteria = "safe-to-deploy" + +[[exemptions.secret-service]] +version = "5.1.0" +criteria = "secret-handling-reviewed" + +[[exemptions.security-framework]] +version = "2.11.1" +criteria = "secret-handling-reviewed" + +[[exemptions.security-framework]] +version = "3.7.0" +criteria = "secret-handling-reviewed" + +[[exemptions.security-framework-sys]] +version = "2.17.0" +criteria = "secret-handling-reviewed" + +[[exemptions.semver]] +version = "1.0.27" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.serde]] +version = "1.0.228" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.serde-wasm-bindgen]] +version = "0.6.5" +criteria = "safe-to-deploy" + +[[exemptions.serde_core]] +version = "1.0.228" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.serde_derive]] +version = "1.0.228" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.serde_json]] +version = "1.0.149" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.serde_repr]] +version = "0.1.21" +criteria = "secret-handling-reviewed" + +[[exemptions.serde_spanned]] +version = "0.6.9" +criteria = "safe-to-deploy" + +[[exemptions.serde_spanned]] +version = "1.1.1" +criteria = "safe-to-deploy" + +[[exemptions.serde_urlencoded]] +version = "0.7.1" +criteria = "safe-to-deploy" + +[[exemptions.sha1]] +version = "0.10.7" +criteria = "network-parser-reviewed" + +[[exemptions.sha2]] +version = "0.10.9" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.sha2-asm]] +version = "0.6.4" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.sha3]] +version = "0.10.9" +criteria = "safe-to-deploy" + +[[exemptions.shake]] +version = "0.1.0" +criteria = "safe-to-deploy" + +[[exemptions.sharded-slab]] +version = "0.1.7" +criteria = "safe-to-deploy" + +[[exemptions.shlex]] +version = "1.3.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.signal-hook-registry]] +version = "1.4.8" +criteria = "secret-handling-reviewed" + +[[exemptions.signature]] +version = "2.2.0" +criteria = "safe-to-deploy" + +[[exemptions.simd-adler32]] +version = "0.3.8" +criteria = "network-parser-reviewed" + +[[exemptions.siphasher]] +version = "0.3.11" +criteria = "safe-to-deploy" + +[[exemptions.siphasher]] +version = "1.0.3" +criteria = "safe-to-deploy" + +[[exemptions.slab]] +version = "0.4.12" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.smallvec]] +version = "1.15.1" +criteria = "network-parser-reviewed" + +[[exemptions.smawk]] +version = "0.3.3" +criteria = "safe-to-deploy" + +[[exemptions.sntrup761]] +version = "0.4.0" +criteria = "safe-to-deploy" + +[[exemptions.socket2]] +version = "0.6.3" +criteria = "network-parser-reviewed" + +[[exemptions.spin]] +version = "0.9.8" +criteria = "safe-to-deploy" + +[[exemptions.spki]] +version = "0.7.3" +criteria = "safe-to-deploy" + +[[exemptions.spki]] +version = "0.8.0" +criteria = "safe-to-deploy" + +[[exemptions.sponge-cursor]] +version = "0.1.0" +criteria = "safe-to-deploy" + +[[exemptions.sqlite-wasm-rs]] +version = "0.5.5" +criteria = "safe-to-deploy" + +[[exemptions.sqlx]] +version = "0.9.0" +criteria = "safe-to-deploy" + +[[exemptions.sqlx-core]] +version = "0.9.0" +criteria = "safe-to-deploy" + +[[exemptions.sqlx-macros]] +version = "0.9.0" +criteria = "safe-to-deploy" + +[[exemptions.sqlx-macros-core]] +version = "0.9.0" +criteria = "safe-to-deploy" + +[[exemptions.sqlx-sqlite]] +version = "0.9.0" +criteria = "safe-to-deploy" + +[[exemptions.stable_deref_trait]] +version = "1.2.1" +criteria = "network-parser-reviewed" + +[[exemptions.static_assertions]] +version = "1.1.0" +criteria = "safe-to-deploy" + +[[exemptions.strsim]] +version = "0.11.1" +criteria = "safe-to-deploy" + +[[exemptions.subtle]] +version = "2.6.1" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.symlink]] +version = "0.1.0" +criteria = "safe-to-deploy" + +[[exemptions.syn]] +version = "2.0.117" +criteria = [ + "build-execution-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.syn]] +version = "3.0.2" +criteria = "secret-handling-reviewed" + +[[exemptions.sync_wrapper]] +version = "1.0.2" +criteria = "safe-to-deploy" + +[[exemptions.synstructure]] +version = "0.13.2" +criteria = "network-parser-reviewed" + +[[exemptions.tar]] +version = "0.4.45" +criteria = "safe-to-deploy" + +[[exemptions.tempfile]] +version = "3.23.0" +criteria = "secret-handling-reviewed" + +[[exemptions.textwrap]] +version = "0.16.2" +criteria = "safe-to-deploy" + +[[exemptions.thiserror]] +version = "1.0.69" +criteria = "network-parser-reviewed" + +[[exemptions.thiserror]] +version = "2.0.18" +criteria = "network-parser-reviewed" + +[[exemptions.thiserror-impl]] +version = "1.0.69" +criteria = "network-parser-reviewed" + +[[exemptions.thiserror-impl]] +version = "2.0.18" +criteria = "network-parser-reviewed" + +[[exemptions.thread_local]] +version = "1.1.10" +criteria = "safe-to-deploy" + +[[exemptions.time]] +version = "0.3.47" +criteria = "safe-to-deploy" + +[[exemptions.time-core]] +version = "0.1.8" +criteria = "safe-to-deploy" + +[[exemptions.time-macros]] +version = "0.2.27" +criteria = "safe-to-deploy" + +[[exemptions.tinystr]] +version = "0.8.2" +criteria = "network-parser-reviewed" + +[[exemptions.tinyvec]] +version = "1.11.0" +criteria = "network-parser-reviewed" + +[[exemptions.tinyvec_macros]] +version = "0.1.1" +criteria = "network-parser-reviewed" + +[[exemptions.tokio]] +version = "1.47.1" +criteria = "network-parser-reviewed" + +[[exemptions.tokio-macros]] +version = "2.5.0" +criteria = "network-parser-reviewed" + +[[exemptions.tokio-rustls]] +version = "0.26.4" +criteria = "network-parser-reviewed" + +[[exemptions.tokio-socks]] +version = "0.5.3" +criteria = "network-parser-reviewed" + +[[exemptions.tokio-stream]] +version = "0.1.18" +criteria = "safe-to-deploy" + +[[exemptions.tokio-tungstenite]] +version = "0.26.2" +criteria = "network-parser-reviewed" + +[[exemptions.toml]] +version = "0.5.11" +criteria = "safe-to-deploy" + +[[exemptions.toml]] +version = "0.8.23" +criteria = "safe-to-deploy" + +[[exemptions.toml]] +version = "1.1.4+spec-1.1.0" +criteria = "safe-to-deploy" + +[[exemptions.toml_datetime]] +version = "0.6.11" +criteria = "safe-to-deploy" + +[[exemptions.toml_datetime]] +version = "1.1.1+spec-1.1.0" +criteria = "secret-handling-reviewed" + +[[exemptions.toml_edit]] +version = "0.22.27" +criteria = "safe-to-deploy" + +[[exemptions.toml_edit]] +version = "0.25.13+spec-1.1.0" +criteria = "secret-handling-reviewed" + +[[exemptions.toml_parser]] +version = "1.1.3+spec-1.1.0" +criteria = "secret-handling-reviewed" + +[[exemptions.toml_write]] +version = "0.1.2" +criteria = "safe-to-deploy" + +[[exemptions.toml_writer]] +version = "1.1.2+spec-1.1.0" +criteria = "safe-to-deploy" + +[[exemptions.tower]] +version = "0.5.3" +criteria = "safe-to-deploy" + +[[exemptions.tower-http]] +version = "0.6.11" +criteria = "safe-to-deploy" + +[[exemptions.tower-layer]] +version = "0.3.3" +criteria = "safe-to-deploy" + +[[exemptions.tower-service]] +version = "0.3.3" +criteria = "safe-to-deploy" + +[[exemptions.tracing]] +version = "0.1.44" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.tracing-appender]] +version = "0.2.5" +criteria = "safe-to-deploy" + +[[exemptions.tracing-attributes]] +version = "0.1.31" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.tracing-core]] +version = "0.1.36" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.tracing-log]] +version = "0.2.0" +criteria = "safe-to-deploy" + +[[exemptions.tracing-subscriber]] +version = "0.3.23" +criteria = "safe-to-deploy" + +[[exemptions.try-lock]] +version = "0.2.5" +criteria = "safe-to-deploy" + +[[exemptions.tungstenite]] +version = "0.26.2" +criteria = "network-parser-reviewed" + +[[exemptions.typed-path]] +version = "0.12.3" +criteria = "safe-to-deploy" + +[[exemptions.typenum]] +version = "1.20.1" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.uds_windows]] +version = "1.2.1" +criteria = "secret-handling-reviewed" + +[[exemptions.unicode-bidi]] +version = "0.3.18" +criteria = "network-parser-reviewed" + +[[exemptions.unicode-general-category]] +version = "1.1.0" +criteria = "safe-to-deploy" + +[[exemptions.unicode-ident]] +version = "1.0.24" +criteria = [ + "build-execution-reviewed", + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.unicode-normalization]] +version = "0.1.25" +criteria = "network-parser-reviewed" + +[[exemptions.unicode-xid]] +version = "0.2.6" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.uniffi]] +version = "0.29.5" +criteria = "safe-to-deploy" + +[[exemptions.uniffi]] +version = "0.32.0" +criteria = "safe-to-deploy" + +[[exemptions.uniffi_bindgen]] +version = "0.29.5" +criteria = "safe-to-deploy" + +[[exemptions.uniffi_bindgen]] +version = "0.32.0" +criteria = "safe-to-deploy" + +[[exemptions.uniffi_core]] +version = "0.29.5" +criteria = "safe-to-deploy" + +[[exemptions.uniffi_core]] +version = "0.32.0" +criteria = "safe-to-deploy" + +[[exemptions.uniffi_internal_macros]] +version = "0.29.5" +criteria = "safe-to-deploy" + +[[exemptions.uniffi_internal_macros]] +version = "0.32.0" +criteria = "safe-to-deploy" + +[[exemptions.uniffi_macros]] +version = "0.29.5" +criteria = "safe-to-deploy" + +[[exemptions.uniffi_macros]] +version = "0.32.0" +criteria = "safe-to-deploy" + +[[exemptions.uniffi_meta]] +version = "0.29.5" +criteria = "safe-to-deploy" + +[[exemptions.uniffi_meta]] +version = "0.32.0" +criteria = "safe-to-deploy" + +[[exemptions.uniffi_pipeline]] +version = "0.29.5" +criteria = "safe-to-deploy" + +[[exemptions.uniffi_pipeline]] +version = "0.32.0" +criteria = "safe-to-deploy" + +[[exemptions.uniffi_udl]] +version = "0.29.5" +criteria = "safe-to-deploy" + +[[exemptions.uniffi_udl]] +version = "0.32.0" +criteria = "safe-to-deploy" + +[[exemptions.universal-hash]] +version = "0.5.1" +criteria = ["crypto-reviewed", "network-parser-reviewed"] + +[[exemptions.untrusted]] +version = "0.9.0" +criteria = "network-parser-reviewed" + +[[exemptions.ureq]] +version = "3.2.0" +criteria = "safe-to-deploy" + +[[exemptions.ureq-proto]] +version = "0.5.3" +criteria = "safe-to-deploy" + +[[exemptions.url]] +version = "2.5.8" +criteria = "network-parser-reviewed" + +[[exemptions.url-fork]] +version = "3.0.1" +criteria = "network-parser-reviewed" + +[[exemptions.utf-8]] +version = "0.7.6" +criteria = "network-parser-reviewed" + +[[exemptions.utf8_iter]] +version = "1.0.4" +criteria = "network-parser-reviewed" + +[[exemptions.utf8parse]] +version = "0.2.2" +criteria = "safe-to-deploy" + +[[exemptions.uuid]] +version = "1.24.0" +criteria = "secret-handling-reviewed" + +[[exemptions.uuid-simd]] +version = "0.8.0" +criteria = "safe-to-deploy" + +[[exemptions.valuable]] +version = "0.1.1" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.vcpkg]] +version = "0.2.15" +criteria = "secret-handling-reviewed" + +[[exemptions.version_check]] +version = "0.9.5" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.vsimd]] +version = "0.8.0" +criteria = "safe-to-deploy" + +[[exemptions.walkdir]] +version = "2.5.0" +criteria = "safe-to-deploy" + +[[exemptions.want]] +version = "0.3.1" +criteria = "safe-to-deploy" + +[[exemptions.wasi]] +version = "0.11.1+wasi-snapshot-preview1" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.wasip2]] +version = "1.0.2+wasi-0.2.9" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.wasip3]] +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +criteria = "secret-handling-reviewed" + +[[exemptions.wasm-bindgen]] +version = "0.2.114" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.wasm-bindgen-futures]] +version = "0.4.64" +criteria = "network-parser-reviewed" + +[[exemptions.wasm-bindgen-macro]] +version = "0.2.114" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.wasm-bindgen-macro-support]] +version = "0.2.114" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.wasm-bindgen-shared]] +version = "0.2.114" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.wasm-bindgen-test]] +version = "0.3.64" +criteria = "safe-to-run" + +[[exemptions.wasm-bindgen-test-macro]] +version = "0.3.64" +criteria = "safe-to-run" + +[[exemptions.wasm-bindgen-test-shared]] +version = "0.2.114" +criteria = "safe-to-run" + +[[exemptions.wasm-encoder]] +version = "0.244.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.wasm-metadata]] +version = "0.244.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.wasmparser]] +version = "0.244.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.web-sys]] +version = "0.3.91" +criteria = "network-parser-reviewed" + +[[exemptions.web-time]] +version = "1.1.0" +criteria = "network-parser-reviewed" + +[[exemptions.webpki-roots]] +version = "0.26.11" +criteria = "network-parser-reviewed" + +[[exemptions.webpki-roots]] +version = "1.0.6" +criteria = "network-parser-reviewed" + +[[exemptions.weedle2]] +version = "5.0.0" +criteria = "safe-to-deploy" + +[[exemptions.which]] +version = "4.4.2" +criteria = "safe-to-deploy" + +[[exemptions.winapi]] +version = "0.3.9" +criteria = "safe-to-deploy" + +[[exemptions.winapi-i686-pc-windows-gnu]] +version = "0.4.0" +criteria = "safe-to-deploy" + +[[exemptions.winapi-util]] +version = "0.1.11" +criteria = "safe-to-deploy" + +[[exemptions.winapi-x86_64-pc-windows-gnu]] +version = "0.4.0" +criteria = "safe-to-deploy" + +[[exemptions.windows-core]] +version = "0.62.2" +criteria = "safe-to-deploy" + +[[exemptions.windows-implement]] +version = "0.60.2" +criteria = "safe-to-deploy" + +[[exemptions.windows-interface]] +version = "0.59.3" +criteria = "safe-to-deploy" + +[[exemptions.windows-link]] +version = "0.2.1" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.windows-native-keyring-store]] +version = "1.1.0" +criteria = "secret-handling-reviewed" + +[[exemptions.windows-result]] +version = "0.4.1" +criteria = "safe-to-deploy" + +[[exemptions.windows-strings]] +version = "0.5.1" +criteria = "safe-to-deploy" + +[[exemptions.windows-sys]] +version = "0.52.0" +criteria = "network-parser-reviewed" + +[[exemptions.windows-sys]] +version = "0.59.0" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.windows-sys]] +version = "0.60.2" +criteria = "secret-handling-reviewed" + +[[exemptions.windows-sys]] +version = "0.61.2" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.windows-targets]] +version = "0.52.6" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.windows-targets]] +version = "0.53.5" +criteria = "secret-handling-reviewed" + +[[exemptions.windows_aarch64_gnullvm]] +version = "0.52.6" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.windows_aarch64_gnullvm]] +version = "0.53.1" +criteria = "secret-handling-reviewed" + +[[exemptions.windows_aarch64_msvc]] +version = "0.52.6" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.windows_aarch64_msvc]] +version = "0.53.1" +criteria = "secret-handling-reviewed" + +[[exemptions.windows_i686_gnu]] +version = "0.52.6" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.windows_i686_gnu]] +version = "0.53.1" +criteria = "secret-handling-reviewed" + +[[exemptions.windows_i686_gnullvm]] +version = "0.52.6" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.windows_i686_gnullvm]] +version = "0.53.1" +criteria = "secret-handling-reviewed" + +[[exemptions.windows_i686_msvc]] +version = "0.52.6" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.windows_i686_msvc]] +version = "0.53.1" +criteria = "secret-handling-reviewed" + +[[exemptions.windows_x86_64_gnu]] +version = "0.52.6" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.windows_x86_64_gnu]] +version = "0.53.1" +criteria = "secret-handling-reviewed" + +[[exemptions.windows_x86_64_gnullvm]] +version = "0.52.6" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.windows_x86_64_gnullvm]] +version = "0.53.1" +criteria = "secret-handling-reviewed" + +[[exemptions.windows_x86_64_msvc]] +version = "0.52.6" +criteria = ["network-parser-reviewed", "secret-handling-reviewed"] + +[[exemptions.windows_x86_64_msvc]] +version = "0.53.1" +criteria = "secret-handling-reviewed" + +[[exemptions.winnow]] +version = "0.7.15" +criteria = "safe-to-deploy" + +[[exemptions.winnow]] +version = "1.0.4" +criteria = "secret-handling-reviewed" + +[[exemptions.wit-bindgen]] +version = "0.51.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.wit-bindgen-core]] +version = "0.51.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.wit-bindgen-rust]] +version = "0.51.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.wit-bindgen-rust-macro]] +version = "0.51.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.wit-component]] +version = "0.244.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.wit-parser]] +version = "0.244.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.writeable]] +version = "0.6.2" +criteria = "network-parser-reviewed" + +[[exemptions.x25519-dalek]] +version = "2.0.1" +criteria = "safe-to-deploy" + +[[exemptions.x448]] +version = "0.14.0-pre.10" +criteria = "safe-to-deploy" + +[[exemptions.x509-parser]] +version = "0.17.0" +criteria = "safe-to-deploy" + +[[exemptions.x509-parser]] +version = "0.18.1" +criteria = "safe-to-run" + +[[exemptions.xattr]] +version = "1.6.1" +criteria = "safe-to-deploy" + +[[exemptions.yasna]] +version = "0.5.2" +criteria = "safe-to-run" + +[[exemptions.yoke]] +version = "0.8.1" +criteria = "network-parser-reviewed" + +[[exemptions.yoke-derive]] +version = "0.8.1" +criteria = "network-parser-reviewed" + +[[exemptions.zbus]] +version = "5.18.0" +criteria = "secret-handling-reviewed" + +[[exemptions.zbus-secret-service-keyring-store]] +version = "1.0.0" +criteria = "secret-handling-reviewed" + +[[exemptions.zbus_macros]] +version = "5.18.0" +criteria = "secret-handling-reviewed" + +[[exemptions.zbus_names]] +version = "4.3.4" +criteria = "secret-handling-reviewed" + +[[exemptions.zerocopy]] +version = "0.8.47" +criteria = "network-parser-reviewed" + +[[exemptions.zerocopy-derive]] +version = "0.8.47" +criteria = "network-parser-reviewed" + +[[exemptions.zerofrom]] +version = "0.1.6" +criteria = "network-parser-reviewed" + +[[exemptions.zerofrom-derive]] +version = "0.1.6" +criteria = "network-parser-reviewed" + +[[exemptions.zeroize]] +version = "1.9.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.zeroize_derive]] +version = "1.5.0" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.zerotrie]] +version = "0.2.3" +criteria = "network-parser-reviewed" + +[[exemptions.zerovec]] +version = "0.11.5" +criteria = "network-parser-reviewed" + +[[exemptions.zerovec-derive]] +version = "0.11.2" +criteria = "network-parser-reviewed" + +[[exemptions.zip]] +version = "7.2.0" +criteria = "safe-to-deploy" + +[[exemptions.zlib-rs]] +version = "0.6.3" +criteria = "safe-to-deploy" + +[[exemptions.zmij]] +version = "1.0.21" +criteria = [ + "crypto-reviewed", + "network-parser-reviewed", + "secret-handling-reviewed", +] + +[[exemptions.zopfli]] +version = "0.8.3" +criteria = "safe-to-deploy" + +[[exemptions.zstd]] +version = "0.13.3" +criteria = "safe-to-deploy" + +[[exemptions.zstd-safe]] +version = "7.2.4" +criteria = "safe-to-deploy" + +[[exemptions.zstd-sys]] +version = "2.0.16+zstd.1.5.7" +criteria = "safe-to-deploy" + +[[exemptions.zvariant]] +version = "5.13.1" +criteria = "secret-handling-reviewed" + +[[exemptions.zvariant_derive]] +version = "5.13.1" +criteria = "secret-handling-reviewed" + +[[exemptions.zvariant_utils]] +version = "3.5.0" +criteria = "secret-handling-reviewed" diff --git a/supply-chain/imports.lock b/supply-chain/imports.lock @@ -0,0 +1,2 @@ + +# cargo-vet imports lock diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml @@ -37,6 +37,7 @@ syn = { workspace = true } tar = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } +walkdir = { workspace = true } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/tools/xtask/src/api_qualification.rs b/tools/xtask/src/api_qualification.rs @@ -11,13 +11,23 @@ struct Contract { release_type: String, feature_policy: String, packages: Vec<String>, + baseline_override: Vec<BaselineOverride>, +} + +#[derive(Debug, Deserialize)] +struct BaselineOverride { + package: String, + revision: String, } pub fn run(root: &Path) -> Result<(), String> { let contract = load(root)?; - validate(&contract, 17)?; + validate(&contract, 19)?; verify_tool(&contract)?; verify_revision(root, &contract.baseline_revision)?; + for baseline in &contract.baseline_override { + verify_revision(root, &baseline.revision)?; + } for package in &contract.packages { let args = invocation(&contract, package); eprintln!("cargo {}", args.join(" ")); @@ -60,6 +70,21 @@ fn validate(contract: &Contract, expected_packages: usize) -> Result<(), String> "public API contract requires exactly {expected_packages} unique packages" )); } + let overrides = contract + .baseline_override + .iter() + .map(|baseline| baseline.package.as_str()) + .collect::<BTreeSet<_>>(); + if overrides != BTreeSet::from(["radroots", "radroots_sdk"]) + || contract + .baseline_override + .iter() + .any(|baseline| baseline.revision.len() != 40) + { + return Err( + "public API baseline overrides must identify the two imported front doors".to_owned(), + ); + } Ok(()) } @@ -101,13 +126,20 @@ fn verify_revision(root: &Path, revision: &str) -> Result<(), String> { } fn invocation(contract: &Contract, package: &str) -> Vec<String> { + let baseline = contract + .baseline_override + .iter() + .find(|baseline| baseline.package == package) + .map_or(contract.baseline_revision.as_str(), |baseline| { + baseline.revision.as_str() + }); vec![ "semver-checks".to_owned(), "check-release".to_owned(), "--package".to_owned(), package.to_owned(), "--baseline-rev".to_owned(), - contract.baseline_revision.clone(), + baseline.to_owned(), "--all-features".to_owned(), "--release-type".to_owned(), contract.release_type.clone(), @@ -125,7 +157,7 @@ mod tests { .and_then(std::path::Path::parent) .expect("workspace root"); let contract = load(root).expect("contract"); - validate(&contract, 17).expect("valid contract"); + validate(&contract, 19).expect("valid contract"); let invocation = invocation(&contract, "radroots_core"); assert!(invocation.contains(&"--all-features".to_owned())); assert!(invocation.ends_with(&["--release-type".to_owned(), "major".to_owned()])); diff --git a/tools/xtask/src/coverage.rs b/tools/xtask/src/coverage.rs @@ -154,7 +154,7 @@ struct LlvmCovFunction { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] struct FunctionCoverageKey { - filenames: Vec<String>, + filename: String, definition: RegionCoverageKey, } @@ -293,6 +293,7 @@ struct CoverageProfileRaw { no_default_features: Option<bool>, features: Option<Vec<String>>, test_threads: Option<u32>, + test_packages: Option<Vec<String>>, } #[derive(Debug, Clone)] @@ -300,6 +301,7 @@ struct CoverageProfile { no_default_features: bool, features: Vec<String>, test_threads: Option<u32>, + test_packages: Vec<String>, } #[cfg_attr(not(test), allow(dead_code))] @@ -384,8 +386,11 @@ fn read_detailed_summary( continue; } let region = function.regions[0]; + let Some(filename) = region_filename(function, &region) else { + continue; + }; let key = FunctionCoverageKey { - filenames: function.filenames.clone(), + filename: filename.to_owned(), definition: RegionCoverageKey { line_start: region[0], column_start: region[1], @@ -1175,6 +1180,9 @@ fn merge_coverage_profile( .features .unwrap_or_else(|| base.features.unwrap_or_default()), test_threads: overlay.test_threads.or(base.test_threads), + test_packages: overlay + .test_packages + .unwrap_or_else(|| base.test_packages.unwrap_or_default()), } } @@ -1190,6 +1198,7 @@ fn read_coverage_profile( no_default_features: false, features: Vec::new(), test_threads: None, + test_packages: Vec::new(), }); } let parsed = parse_toml::<CoverageProfilesFile>(&path)?; @@ -1215,6 +1224,37 @@ fn read_coverage_profile( "coverage profile for {crate_name} must set test_threads > 0" )); } + let workspace_packages = if resolved.test_packages.is_empty() { + BTreeSet::new() + } else { + read_workspace_packages(workspace_root)? + .into_iter() + .map(|(name, _)| name) + .collect::<BTreeSet<_>>() + }; + let mut seen_test_packages = BTreeSet::new(); + for package in &resolved.test_packages { + if package.trim().is_empty() { + return Err(format!( + "coverage profile for {crate_name} includes an empty test package" + )); + } + if package == crate_name { + return Err(format!( + "coverage profile for {crate_name} repeats the target as a test package" + )); + } + if !workspace_packages.contains(package) { + return Err(format!( + "coverage profile for {crate_name} references unknown test package {package}" + )); + } + if !seen_test_packages.insert(package) { + return Err(format!( + "coverage profile for {crate_name} repeats test package {package}" + )); + } + } Ok(resolved) } @@ -1729,6 +1769,9 @@ fn run_crate_with_runner_at_root( { let mut cmd = coverage_llvm_cov_command(); cmd.arg("-p").arg(&crate_name); + for package in &profile.test_packages { + cmd.arg("-p").arg(package); + } apply_coverage_profile_flags(&mut cmd, &profile); cmd.arg("--no-report") .arg("--branch") @@ -2799,6 +2842,16 @@ pub fn production() {} } #[test] + fn coverage_off_source_lines_cover_annotated_non_block_items() { + let source = "#[cfg_attr(coverage_nightly, coverage(off))]\nconst GENERATED: bool = true;\npub fn policy() -> bool { true }\n"; + let lines = coverage_off_source_lines(source); + + assert!(lines[0], "coverage attribute is excluded"); + assert!(lines[1], "annotated non-block item is excluded"); + assert!(!lines[2], "following production item remains measured"); + } + + #[test] fn coverage_off_source_lines_ignore_literal_and_comment_braces() { let source = r####"#[cfg_attr(coverage_nightly, coverage(off))] fn excluded() { @@ -3983,6 +4036,7 @@ fn measured() {} assert!(!profile.no_default_features); assert!(profile.features.is_empty()); assert_eq!(profile.test_threads, None); + assert!(profile.test_packages.is_empty()); fs::remove_dir_all(root).expect("remove root"); } @@ -4009,11 +4063,13 @@ features = ["rt"] assert!(app_profile.no_default_features); assert_eq!(app_profile.features, vec!["rt".to_string()]); assert_eq!(app_profile.test_threads, Some(2)); + assert!(app_profile.test_packages.is_empty()); let other_profile = read_coverage_profile(&root, "radroots_core").expect("other profile"); assert!(!other_profile.no_default_features); assert_eq!(other_profile.features, vec!["std".to_string()]); assert_eq!(other_profile.test_threads, Some(2)); + assert!(other_profile.test_packages.is_empty()); fs::remove_dir_all(root).expect("remove root"); } @@ -4037,6 +4093,61 @@ test_threads = 4 } #[test] + fn coverage_profiles_resolve_validated_downstream_test_packages() { + let root = workspace_root(); + let runtime = read_coverage_profile(&root, "radroots_studio_runtime") + .expect("runtime coverage profile"); + assert_eq!( + runtime.test_packages, + vec!["radroots_studio_ffi".to_string()] + ); + let storage = read_coverage_profile(&root, "radroots_studio_storage") + .expect("storage coverage profile"); + assert_eq!( + storage.test_packages, + vec![ + "radroots_studio_runtime".to_string(), + "radroots_studio_ffi".to_string() + ] + ); + } + + #[test] + fn coverage_profiles_reject_invalid_downstream_test_packages() { + let root = temp_dir_path("profile_invalid_test_packages"); + write_file( + &root.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/a\", \"crates/b\"]\n", + ); + write_file( + &root.join("crates/a/Cargo.toml"), + "[package]\nname = \"radroots_a\"\nversion = \"0.1.0-alpha\"\n", + ); + write_file( + &root.join("crates/b/Cargo.toml"), + "[package]\nname = \"radroots_b\"\nversion = \"0.1.0-alpha\"\n", + ); + let profiles = root.join("contracts/coverage-profiles.toml"); + + for (test_packages, expected) in [ + ("[\"\"]", "empty test package"), + ("[\"radroots_a\"]", "repeats the target"), + ("[\"radroots_unknown\"]", "unknown test package"), + ("[\"radroots_b\", \"radroots_b\"]", "repeats test package"), + ] { + write_file( + &profiles, + &format!("[profiles.crates.\"radroots_a\"]\ntest_packages = {test_packages}\n"), + ); + let err = read_coverage_profile(&root, "radroots_a") + .expect_err("invalid downstream test package"); + assert!(err.contains(expected), "unexpected error: {err}"); + } + + fs::remove_dir_all(root).expect("remove invalid test package root"); + } + + #[test] fn coverage_profiles_reject_invalid_feature_and_thread_values() { let root = temp_dir_path("profile_invalid"); let coverage_dir = root.join("contracts"); @@ -4440,6 +4551,7 @@ test_threads = 0 no_default_features: true, features: vec!["std".to_string(), "serde".to_string()], test_threads: Some(2), + test_packages: Vec::new(), }; let mut command = Command::new("cargo"); apply_coverage_profile_flags(&mut command, &profile); @@ -4508,6 +4620,42 @@ test_threads = 0 } #[test] + fn run_crate_credits_declared_downstream_tests_only_to_the_target_report() { + let out = temp_dir_path("run_crate_downstream_tests"); + let args = vec![ + "--crate".to_string(), + "radroots_studio_runtime".to_string(), + "--out".to_string(), + out.display().to_string(), + ]; + let mut rendered_commands = Vec::new(); + let mut runner = |cmd: Command, _name: &str| { + rendered_commands.push( + cmd.get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect::<Vec<_>>() + .join(" "), + ); + Ok(()) + }; + run_crate_with_runner(&args, &mut runner).expect("run crate with downstream tests"); + let test_command = rendered_commands + .iter() + .find(|command| command.contains("--no-report")) + .expect("coverage test command"); + assert!(test_command.contains("-p radroots_studio_runtime")); + assert!(test_command.contains("-p radroots_studio_ffi")); + for report_command in rendered_commands + .iter() + .filter(|command| command.starts_with("report ")) + { + assert!(report_command.contains("-p radroots_studio_runtime")); + assert!(!report_command.contains("-p radroots_studio_ffi")); + } + fs::remove_dir_all(out).expect("remove downstream test output dir"); + } + + #[test] fn coverage_ignore_filename_regex_excludes_external_and_sibling_workspace_paths() { let root = workspace_root(); let ignore_regex = diff --git a/tools/xtask/src/hygiene.rs b/tools/xtask/src/hygiene.rs @@ -242,6 +242,7 @@ pub fn validate_forbidden_identifiers(root: &Path) -> Result<(), String> { "removed identifier 'tangle' must not reappear", &[ "contracts/consolidation/baseline.v1.toml", + "contracts/consolidation/imports/studio_app.commit-map.v1.json", "tools/xtask/src/sdk_generation/package_matrix.rs", "tools/xtask/src/hygiene.rs", ], diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -30,10 +30,13 @@ mod generate; mod hygiene; #[cfg_attr(coverage_nightly, coverage(off))] mod portable_qualification; +#[cfg_attr(coverage_nightly, coverage(off))] mod release_graph; #[cfg_attr(coverage_nightly, coverage(off))] mod release_qualification; #[cfg_attr(coverage_nightly, coverage(off))] +mod safety_qualification; +#[cfg_attr(coverage_nightly, coverage(off))] mod sdk_generation; #[cfg_attr(coverage_nightly, coverage(off))] mod supply_chain_qualification; @@ -280,6 +283,7 @@ fn usage() { eprintln!(" cargo xtask release qualify-api"); eprintln!(" cargo xtask release qualify-fuzz"); eprintln!(" cargo xtask release qualify-portable"); + eprintln!(" cargo xtask release qualify-safety"); eprintln!(" cargo xtask release qualify-supply-chain"); eprintln!(" cargo xtask release qualify-targets"); eprintln!(" cargo xtask coverage run-crate --crate <crate> [--out <dir>]"); @@ -367,6 +371,7 @@ fn release_preflight_at(root: &Path) -> Result<(), String> { contract::validate_release_preflight(root) } +#[cfg_attr(coverage_nightly, coverage(off))] fn run_release(args: &[String]) -> Result<(), String> { match args.first().map(String::as_str) { Some("preflight") => release_preflight(), @@ -375,6 +380,7 @@ fn run_release(args: &[String]) -> Result<(), String> { Some("qualify-api") => api_qualification::run(&workspace_root()), Some("qualify-fuzz") => fuzz_qualification::run(&workspace_root()), Some("qualify-portable") => portable_qualification::run(&workspace_root()), + Some("qualify-safety") => safety_qualification::run(&workspace_root()), Some("qualify-supply-chain") => supply_chain_qualification::run(&workspace_root()), Some("qualify-targets") => target_qualification::run(&workspace_root()), _ => Err("unknown release subcommand".to_string()), @@ -638,6 +644,49 @@ mod tests { } #[test] + fn artifact_cli_values_preserve_every_governed_identifier() { + assert_eq!( + [ + ArtifactProduct::Sdk.as_str(), + ArtifactProduct::Mobile.as_str(), + ArtifactProduct::Studio.as_str(), + ], + ["sdk", "mobile", "studio"] + ); + assert_eq!( + [ + ArtifactTarget::Typescript.as_str(), + ArtifactTarget::Wasm.as_str(), + ArtifactTarget::Ffi.as_str(), + ArtifactTarget::Ios.as_str(), + ArtifactTarget::Android.as_str(), + ArtifactTarget::Linux.as_str(), + ArtifactTarget::Macos.as_str(), + ArtifactTarget::Windows.as_str(), + ], + [ + "typescript", + "wasm", + "ffi", + "ios", + "android", + "linux", + "macos", + "windows", + ] + ); + assert_eq!( + [ + ArtifactLanguage::Typescript.as_str(), + ArtifactLanguage::Swift.as_str(), + ArtifactLanguage::Kotlin.as_str(), + ArtifactLanguage::Javascript.as_str(), + ], + ["typescript", "swift", "kotlin", "javascript"] + ); + } + + #[test] fn run_release_and_dispatchers_cover_error_paths() { let unknown_release = run_release(&["unknown".to_string()]).expect_err("unknown release subcommand"); diff --git a/tools/xtask/src/release_qualification.rs b/tools/xtask/src/release_qualification.rs @@ -1,27 +1,17 @@ -use std::{fs, path::Path, process::Command}; +use std::{ffi::OsString, fs, path::Path, process::Command}; use serde::Deserialize; #[derive(Debug, Deserialize)] -struct Architecture { - repositories: Repositories, +struct Catalog { package: Vec<Package>, } #[derive(Debug, Deserialize)] -struct Repositories { - lib: Repository, -} - -#[derive(Debug, Deserialize)] -struct Repository { - packages: Vec<String>, -} - -#[derive(Debug, Deserialize)] struct Package { name: String, - features: Vec<String>, + state: String, + groups: Vec<String>, } #[derive(Debug, PartialEq, Eq)] @@ -32,9 +22,12 @@ struct CargoInvocation { pub fn run_feature_matrix(workspace_root: &Path) -> Result<(), String> { for invocation in feature_matrix(workspace_root)? { eprintln!("cargo {}", invocation.args.join(" ")); - let status = Command::new("cargo") + let mut command = Command::new("cargo"); + command .args(&invocation.args) .current_dir(workspace_root) + .env("RUSTFLAGS", rustflags_with_warnings_denied()); + let status = command .status() .map_err(|error| format!("failed to start cargo: {error}"))?; if !status.success() { @@ -47,37 +40,46 @@ pub fn run_feature_matrix(workspace_root: &Path) -> Result<(), String> { Ok(()) } +fn rustflags_with_warnings_denied() -> OsString { + let mut rustflags = std::env::var_os("RUSTFLAGS").unwrap_or_default(); + if !rustflags.is_empty() { + rustflags.push(" "); + } + rustflags.push("-Dwarnings"); + rustflags +} + fn feature_matrix(workspace_root: &Path) -> Result<Vec<CargoInvocation>, String> { - let path = workspace_root.join("docs/specs/radroots_crates_release_v1.toml"); + let path = workspace_root.join("contracts/crates/catalog.v1.toml"); let raw = fs::read_to_string(&path) .map_err(|error| format!("failed to read {}: {error}", path.display()))?; - let architecture = toml::from_str::<Architecture>(&raw) + let catalog = toml::from_str::<Catalog>(&raw) .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; - let mut packages = architecture + let mut packages = catalog .package .into_iter() .filter(|package| { - architecture - .repositories - .lib - .packages - .contains(&package.name) + package.state == "active" && package.groups.iter().any(|group| group == "public_native") }) .collect::<Vec<_>>(); packages.sort_by(|left, right| left.name.cmp(&right.name)); - if packages.len() != 17 { + if packages.len() != 19 { return Err(format!( - "library feature qualification requires exactly 17 public packages, found {}", + "library feature qualification requires exactly 19 public packages, found {}", packages.len() )); } let mut invocations = Vec::new(); + let feature_map = package_feature_map(workspace_root)?; for package in packages { + let features = feature_map + .get(&package.name) + .ok_or_else(|| format!("cargo metadata omitted features for {}", package.name))?; invocations.push(check_invocation(&package.name, None, true)); invocations.push(check_invocation(&package.name, None, false)); - for feature in package.features { - invocations.push(check_invocation(&package.name, Some(&feature), true)); + for feature in features { + invocations.push(check_invocation(&package.name, Some(feature), true)); } invocations.push(CargoInvocation { args: vec![ @@ -93,6 +95,44 @@ fn feature_matrix(workspace_root: &Path) -> Result<Vec<CargoInvocation>, String> Ok(invocations) } +fn package_feature_map( + workspace_root: &Path, +) -> Result<std::collections::BTreeMap<String, Vec<String>>, String> { + let output = Command::new("cargo") + .args(["metadata", "--format-version", "1", "--locked", "--no-deps"]) + .current_dir(workspace_root) + .output() + .map_err(|error| format!("failed to start cargo metadata: {error}"))?; + if !output.status.success() { + return Err("locked cargo metadata failed".to_owned()); + } + let metadata: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("failed to parse cargo metadata: {error}"))?; + let packages = metadata + .get("packages") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "cargo metadata omitted packages".to_owned())?; + packages + .iter() + .map(|package| { + let name = package + .get("name") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "cargo metadata package omitted name".to_owned())?; + let mut features = package + .get("features") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| format!("cargo metadata omitted features for {name}"))? + .keys() + .filter(|feature| feature.as_str() != "default") + .cloned() + .collect::<Vec<_>>(); + features.sort_unstable(); + Ok((name.to_owned(), features)) + }) + .collect() +} + fn check_invocation( package: &str, feature: Option<&str>, diff --git a/tools/xtask/src/safety_qualification.rs b/tools/xtask/src/safety_qualification.rs @@ -0,0 +1,347 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; +use std::process::Command; + +use serde::Deserialize; + +const CONTRACT_PATH: &str = "contracts/releases/safety_matrix.toml"; + +#[derive(Debug, Deserialize)] +struct Contract { + schema_version: u16, + toolchain: String, + miri_flags: Vec<String>, + miri: Vec<MiriLane>, + sanitizer: Vec<SanitizerLane>, + exception: Vec<Exception>, +} + +#[derive(Debug, Deserialize)] +struct MiriLane { + package: String, + filter: String, + authority: String, +} + +#[derive(Debug, Deserialize)] +struct SanitizerLane { + kind: String, + targets: Vec<String>, + packages: Vec<String>, + authority: String, +} + +#[derive(Debug, Deserialize)] +struct Exception { + lane: String, + targets: Vec<String>, + owner: String, + expires: String, + reason: String, +} + +#[derive(Debug, Deserialize)] +struct Metadata { + packages: Vec<MetadataPackage>, +} + +#[derive(Debug, Deserialize)] +struct MetadataPackage { + name: String, +} + +pub fn run(root: &Path) -> Result<(), String> { + let contract = load(root)?; + let packages = workspace_packages(root)?; + validate(&contract, &packages)?; + qualify_miri(root, &contract)?; + qualify_sanitizers(root, &contract)?; + Ok(()) +} + +fn load(root: &Path) -> Result<Contract, String> { + let path = root.join(CONTRACT_PATH); + let raw = fs::read_to_string(&path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + toml::from_str(&raw).map_err(|error| format!("failed to parse {}: {error}", path.display())) +} + +fn workspace_packages(root: &Path) -> Result<BTreeSet<String>, String> { + let output = Command::new("cargo") + .args(["metadata", "--format-version", "1", "--no-deps", "--locked"]) + .current_dir(root) + .output() + .map_err(|error| format!("failed to start cargo metadata: {error}"))?; + if !output.status.success() { + return Err("cargo metadata failed while validating the safety matrix".to_owned()); + } + let metadata: Metadata = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("failed to decode cargo metadata: {error}"))?; + Ok(metadata + .packages + .into_iter() + .map(|package| package.name) + .collect()) +} + +fn validate(contract: &Contract, packages: &BTreeSet<String>) -> Result<(), String> { + if contract.schema_version != 1 + || !contract.toolchain.starts_with("nightly-") + || contract.miri_flags != ["-Zmiri-strict-provenance", "-Zmiri-disable-isolation"] + || contract.miri.len() != 8 + || contract.sanitizer.len() != 1 + { + return Err("invalid safety qualification contract".to_owned()); + } + + let miri = contract + .miri + .iter() + .map(|lane| (&lane.package, &lane.filter)) + .collect::<BTreeSet<_>>(); + if miri.len() != contract.miri.len() { + return Err("Miri package/filter pairs must be unique".to_owned()); + } + for lane in &contract.miri { + validate_identifier("Miri package", &lane.package)?; + validate_test_filter(&lane.filter)?; + validate_authority(&lane.authority)?; + if !packages.contains(&lane.package) { + return Err(format!( + "Miri package {} is not in the workspace", + lane.package + )); + } + } + + for lane in &contract.sanitizer { + if lane.kind != "address" || lane.targets.len() != 4 || lane.packages.len() != 3 { + return Err("native sanitizer authority must cover address checks on four hosts and three boundaries".to_owned()); + } + validate_authority(&lane.authority)?; + let targets = lane.targets.iter().collect::<BTreeSet<_>>(); + let lane_packages = lane.packages.iter().collect::<BTreeSet<_>>(); + if targets.len() != lane.targets.len() || lane_packages.len() != lane.packages.len() { + return Err("sanitizer targets and packages must be unique".to_owned()); + } + for package in &lane.packages { + validate_identifier("sanitizer package", package)?; + if !packages.contains(package) { + return Err(format!( + "sanitizer package {package} is not in the workspace" + )); + } + } + } + + if contract.exception.len() != 1 { + return Err( + "the unsupported sanitizer target authority must contain one bounded exception" + .to_owned(), + ); + } + let exception = &contract.exception[0]; + if exception.lane != "sanitizer" + || exception.owner != "radroots-security" + || exception.expires != "2026-10-01" + || exception.targets.len() != 3 + || exception.reason.trim().is_empty() + { + return Err("invalid sanitizer target exception".to_owned()); + } + Ok(()) +} + +fn validate_identifier(label: &str, value: &str) -> Result<(), String> { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(format!("{label} must be a lowercase snake_case identifier")); + } + Ok(()) +} + +fn validate_test_filter(value: &str) -> Result<(), String> { + let segments = value.split("::").collect::<Vec<_>>(); + if segments.len() < 3 { + return Err("Miri filter must be a fully qualified test path".to_owned()); + } + for segment in segments { + validate_identifier("Miri test path segment", segment)?; + } + Ok(()) +} + +fn validate_authority(value: &str) -> Result<(), String> { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err("safety authority must be a lowercase kebab-case identifier".to_owned()); + } + Ok(()) +} + +fn qualify_miri(root: &Path, contract: &Contract) -> Result<(), String> { + let flags = contract.miri_flags.join(" "); + for lane in &contract.miri { + verify_test_exists(root, lane)?; + let args = [ + format!("+{}", contract.toolchain), + "miri".to_owned(), + "test".to_owned(), + "--locked".to_owned(), + "-p".to_owned(), + lane.package.clone(), + "--lib".to_owned(), + lane.filter.clone(), + "--".to_owned(), + "--exact".to_owned(), + ]; + run_cargo(root, &args, &[("MIRIFLAGS", flags.as_str())], "Miri")?; + } + Ok(()) +} + +fn verify_test_exists(root: &Path, lane: &MiriLane) -> Result<(), String> { + let args = [ + "test", + "--locked", + "-p", + lane.package.as_str(), + "--lib", + lane.filter.as_str(), + "--", + "--exact", + "--list", + ]; + let output = Command::new("cargo") + .args(args) + .current_dir(root) + .output() + .map_err(|error| format!("failed to enumerate Miri test {}: {error}", lane.filter))?; + if !output.status.success() { + return Err(format!( + "failed to enumerate Miri test {} in {}", + lane.filter, lane.package + )); + } + let stdout = String::from_utf8(output.stdout) + .map_err(|error| format!("test enumeration emitted non-UTF-8 output: {error}"))?; + let expected = format!("{}: test", lane.filter); + if stdout.lines().any(|line| line == expected) { + Ok(()) + } else { + Err(format!( + "Miri authority {} does not resolve to exactly one library test in {}", + lane.filter, lane.package + )) + } +} + +fn qualify_sanitizers(root: &Path, contract: &Contract) -> Result<(), String> { + let host = rustc_host(root, &contract.toolchain)?; + let mut matched = false; + for lane in &contract.sanitizer { + if !lane.targets.iter().any(|target| target == &host) { + continue; + } + matched = true; + let mut args = vec![ + format!("+{}", contract.toolchain), + "test".to_owned(), + "--locked".to_owned(), + "--target".to_owned(), + host.clone(), + ]; + for package in &lane.packages { + args.push("-p".to_owned()); + args.push(package.clone()); + } + args.push("--lib".to_owned()); + let rustflags = format!("-Zsanitizer={}", lane.kind); + run_cargo( + root, + &args, + &[ + ("RUSTFLAGS", rustflags.as_str()), + ("RUSTDOCFLAGS", rustflags.as_str()), + ("ASAN_OPTIONS", "detect_leaks=1:halt_on_error=1"), + ], + "sanitizer", + )?; + } + if matched { + return Ok(()); + } + if contract + .exception + .iter() + .any(|exception| exception.targets.iter().any(|target| target == &host)) + { + eprintln!("sanitizer qualification excluded for governed target {host}"); + return Ok(()); + } + Err(format!( + "host {host} has neither a sanitizer lane nor a governed exception" + )) +} + +fn rustc_host(root: &Path, toolchain: &str) -> Result<String, String> { + let output = Command::new("rustc") + .args([format!("+{toolchain}"), "-vV".to_owned()]) + .current_dir(root) + .output() + .map_err(|error| format!("failed to start rustc: {error}"))?; + if !output.status.success() { + return Err(format!("rustc +{toolchain} -vV failed")); + } + let stdout = String::from_utf8(output.stdout) + .map_err(|error| format!("rustc -vV emitted non-UTF-8 output: {error}"))?; + stdout + .lines() + .find_map(|line| line.strip_prefix("host: ").map(str::to_owned)) + .ok_or_else(|| "rustc -vV did not report a host target".to_owned()) +} + +fn run_cargo( + root: &Path, + args: &[String], + environment: &[(&str, &str)], + label: &str, +) -> Result<(), String> { + eprintln!("cargo {}", args.join(" ")); + let status = Command::new("cargo") + .args(args) + .envs(environment.iter().copied()) + .current_dir(root) + .status() + .map_err(|error| format!("failed to start {label} qualification: {error}"))?; + if status.success() { + Ok(()) + } else { + Err(format!( + "{label} qualification failed: cargo {}", + args.join(" ") + )) + } +} + +#[cfg(test)] +mod tests { + use super::{load, validate, workspace_packages}; + + #[test] + fn current_contract_covers_governed_safety_boundaries() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(std::path::Path::parent) + .expect("workspace root"); + let packages = workspace_packages(root).expect("workspace packages"); + validate(&load(root).expect("contract"), &packages).expect("valid safety matrix"); + } +} diff --git a/tools/xtask/src/supply_chain_qualification.rs b/tools/xtask/src/supply_chain_qualification.rs @@ -19,6 +19,7 @@ struct Contract { tools: Tools, sbom: Sbom, advisory_exception: Vec<AdvisoryException>, + git_source: Vec<GitSource>, package: Vec<Package>, } @@ -26,6 +27,7 @@ struct Contract { struct Tools { cargo_deny: String, cargo_cyclonedx: String, + cargo_vet: String, } #[derive(Debug, Deserialize)] @@ -55,6 +57,14 @@ struct Package { } #[derive(Debug, Deserialize)] +struct GitSource { + url: String, + revision: String, + packages: Vec<String>, + removal_when: String, +} + +#[derive(Debug, Deserialize)] struct Metadata { packages: Vec<MetadataPackage>, } @@ -78,9 +88,10 @@ impl Drop for GeneratedSboms { pub fn run(root: &Path) -> Result<(), String> { let contract = load(root)?; - validate(root, &contract, 17)?; + validate(root, &contract, 19)?; verify_tools(&contract)?; let metadata = load_metadata(root)?; + qualify_git_sources(root, &contract)?; qualify_dependencies(root, &contract)?; let sbom_hashes = qualify_sboms(root, &contract, &metadata)?; let provenance = build_provenance(root, &contract, &sbom_hashes)?; @@ -106,6 +117,7 @@ fn validate(root: &Path, contract: &Contract, expected_packages: usize) -> Resul || contract.package_version != "0.1.0-alpha" || contract.tools.cargo_deny != "0.19.8" || contract.tools.cargo_cyclonedx != "0.5.9" + || contract.tools.cargo_vet != "0.10.2" || contract.sbom.format != "json" || contract.sbom.spec_version != "1.5" || contract.sbom.target != "all" @@ -115,6 +127,18 @@ fn validate(root: &Path, contract: &Contract, expected_packages: usize) -> Resul return Err("invalid supply-chain qualification contract".to_owned()); } + if contract.git_source.len() != 1 { + return Err("supply-chain contract requires exactly one retained Git source".to_owned()); + } + let source = &contract.git_source[0]; + if source.url != "https://github.com/rust-nostr/nostr.git" + || source.revision != "5bba5163eb77107f82c4a8262cf29d7f33a73219" + || source.packages != ["nostr", "nostr-relay-builder", "nostr-sdk"] + || source.removal_when != "nostr 0.45 stable satisfies Studio compatibility tests" + { + return Err("retained Git source authority drifted".to_owned()); + } + let names = contract .package .iter() @@ -159,6 +183,121 @@ fn validate(root: &Path, contract: &Contract, expected_packages: usize) -> Resul validate_exceptions(root, contract) } +fn qualify_git_sources(root: &Path, contract: &Contract) -> Result<(), String> { + let approved = contract + .git_source + .iter() + .map(|source| (source.url.clone(), source.revision.clone())) + .collect::<BTreeSet<_>>(); + let mut seen = BTreeSet::new(); + for entry in walkdir::WalkDir::new(root.join("crates")) { + let entry = entry.map_err(|error| format!("failed to walk manifests: {error}"))?; + if entry.file_name() != "Cargo.toml" { + continue; + } + let raw = fs::read_to_string(entry.path()) + .map_err(|error| format!("failed to read {}: {error}", entry.path().display()))?; + let manifest: toml::Value = toml::from_str(&raw) + .map_err(|error| format!("failed to parse {}: {error}", entry.path().display()))?; + inspect_git_dependencies(&manifest, entry.path(), &approved, &mut seen)?; + } + if seen != approved { + return Err("approved Git source set is stale or incomplete".to_owned()); + } + let deny_raw = fs::read_to_string(root.join(DENY_PATH)) + .map_err(|error| format!("failed to read {DENY_PATH}: {error}"))?; + let deny = toml::from_str::<toml::Value>(&deny_raw) + .map_err(|error| format!("failed to parse {DENY_PATH}: {error}"))?; + let deny_git_sources = deny + .get("sources") + .and_then(|sources| sources.get("allow-git")) + .and_then(toml::Value::as_array) + .ok_or_else(|| "deny.toml sources.allow-git is missing".to_owned())? + .iter() + .filter_map(toml::Value::as_str) + .collect::<BTreeSet<_>>(); + let approved_urls = contract + .git_source + .iter() + .map(|source| source.url.as_str()) + .collect::<BTreeSet<_>>(); + if deny_git_sources != approved_urls { + return Err( + "cargo-deny Git source authority differs from the exact-revision policy".to_owned(), + ); + } + let lock = fs::read_to_string(root.join("Cargo.lock")) + .map_err(|error| format!("failed to read Cargo.lock: {error}"))?; + for source in lock.lines().filter_map(|line| { + line.trim() + .strip_prefix("source = \"") + .and_then(|value| value.strip_suffix('"')) + .filter(|value| value.starts_with("git+")) + }) { + let (url_and_query, commit) = source + .rsplit_once('#') + .ok_or_else(|| format!("Git lock source has no commit: {source}"))?; + let (url, revision) = url_and_query + .strip_prefix("git+") + .and_then(|value| value.split_once("?rev=")) + .ok_or_else(|| format!("Git lock source is not exact-rev pinned: {source}"))?; + if commit != revision || !approved.contains(&(url.to_owned(), revision.to_owned())) { + return Err(format!( + "Git lock source is not approved and immutable: {source}" + )); + } + } + Ok(()) +} + +fn inspect_git_dependencies( + value: &toml::Value, + path: &Path, + approved: &BTreeSet<(String, String)>, + seen: &mut BTreeSet<(String, String)>, +) -> Result<(), String> { + match value { + toml::Value::Table(table) => { + if let Some(url) = table.get("git").and_then(toml::Value::as_str) { + let revision = table.get("rev").and_then(toml::Value::as_str); + if table.contains_key("branch") + || table.contains_key("tag") + || revision.is_none_or(|revision| { + revision.len() != 40 + || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + { + return Err(format!( + "{} contains a branch, tag, or non-full Git revision", + path.display() + )); + } + let authority = ( + url.to_owned(), + revision.expect("checked revision").to_owned(), + ); + if !approved.contains(&authority) { + return Err(format!( + "{} contains unapproved Git source {url}", + path.display() + )); + } + seen.insert(authority); + } + for child in table.values() { + inspect_git_dependencies(child, path, approved, seen)?; + } + } + toml::Value::Array(values) => { + for child in values { + inspect_git_dependencies(child, path, approved, seen)?; + } + } + _ => {} + } + Ok(()) +} + fn validate_exceptions(root: &Path, contract: &Contract) -> Result<(), String> { let expected = BTreeSet::from([ "CARGO-YANKED-SPIN-0.9.8".to_owned(), @@ -291,6 +430,11 @@ fn verify_tools(contract: &Contract) -> Result<(), String> { &["cyclonedx", "--version"], "cargo-cyclonedx", &contract.tools.cargo_cyclonedx, + )?; + verify_tool( + &["vet", "--version"], + "cargo-vet", + &contract.tools.cargo_vet, ) } @@ -329,6 +473,12 @@ fn load_metadata(root: &Path) -> Result<Metadata, String> { } fn qualify_dependencies(root: &Path, contract: &Contract) -> Result<(), String> { + run_command( + root, + "cargo", + vec!["vet", "--locked"], + "cargo-vet policy failed", + )?; let mut saw_governed_yank = false; for package in &contract.package { let manifest = root.join(&package.manifest_path); @@ -677,7 +827,7 @@ mod tests { fn current_contract_is_exact_and_exception_bound() { let root = root(); let contract = load(&root).expect("contract"); - validate(&root, &contract, 17).expect("valid contract"); + validate(&root, &contract, 19).expect("valid contract"); } #[test] diff --git a/tools/xtask/src/target_qualification.rs b/tools/xtask/src/target_qualification.rs @@ -19,18 +19,15 @@ struct OperatingSystem { } #[derive(Debug, Deserialize)] -struct Architecture { - repositories: Repositories, +struct Catalog { + package: Vec<CatalogPackage>, } #[derive(Debug, Deserialize)] -struct Repositories { - lib: Repository, -} - -#[derive(Debug, Deserialize)] -struct Repository { - packages: Vec<String>, +struct CatalogPackage { + name: String, + state: String, + groups: Vec<String>, } pub fn run(workspace_root: &Path) -> Result<(), String> { @@ -76,14 +73,21 @@ fn load(workspace_root: &Path) -> Result<(TargetMatrix, Vec<String>), String> { .map_err(|error| format!("failed to parse {}: {error}", matrix_path.display()))?; validate(&matrix)?; - let architecture_path = workspace_root.join("docs/specs/radroots_crates_release_v1.toml"); - let architecture = toml::from_str::<Architecture>(&read(&architecture_path)?) - .map_err(|error| format!("failed to parse {}: {error}", architecture_path.display()))?; - let mut packages = architecture.repositories.lib.packages; + let catalog_path = workspace_root.join("contracts/crates/catalog.v1.toml"); + let catalog = toml::from_str::<Catalog>(&read(&catalog_path)?) + .map_err(|error| format!("failed to parse {}: {error}", catalog_path.display()))?; + let mut packages = catalog + .package + .into_iter() + .filter(|package| { + package.state == "active" && package.groups.iter().any(|group| group == "public_native") + }) + .map(|package| package.name) + .collect::<Vec<_>>(); packages.sort(); - if packages.len() != 17 { + if packages.len() != 19 { return Err(format!( - "target qualification requires exactly 17 library packages, found {}", + "target qualification requires exactly 19 public packages, found {}", packages.len() )); } @@ -195,6 +199,6 @@ mod tests { assert_eq!(matrix.msrv_toolchain, "1.97.1"); assert_eq!(matrix.current_toolchain, "stable"); assert_eq!(matrix.operating_system.len(), 3); - assert_eq!(packages.len(), 17); + assert_eq!(packages.len(), 19); } }