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:
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(®ular_parent, b"not a directory").expect("write regular parent");
+ assert_eq!(
+ preflight(®ular_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(®ular, b"file").expect("write regular file");
+ assert!(create_secure_directory(®ular).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(®ular, 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(®ular).is_err());
+ assert!(create_recovery_directory(®ular.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(®ular, 2).is_err());
+ assert_eq!(
+ read_bounded_file(®ular, 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, ®ular).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(®ular, b"abc").expect("write regular file");
+ let link = directory.path().join("link");
+ symlink(®ular, &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(®ular, 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(®ular).is_err());
+ assert!(ensure_new_destination(®ular.join("nested")).is_err());
+ assert!(secure_read(&child).is_err());
+ assert_eq!(digest_file(®ular).expect("digest").len(), 64);
+
+ let copied = directory.path().join("copied");
+ copy_secure(®ular, &copied).expect("secure copy");
+ assert_eq!(fs::read(&copied).expect("copied bytes"), b"repair material");
+ assert!(copy_secure(®ular, &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(®ular, b"repair material").expect("write regular file");
+ let link = directory.path().join("link");
+ symlink(®ular, &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, ®ion) 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);
}
}