lib

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

commit 2b931df539f4f5d0837f13cbd3b15f97e5db02b2
parent b6d9efa3630ea7fbe852c598a7b345ceb92ac769
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 00:32:08 +0000

coverage: enforce complete required coverage

- raise required coverage gates and contract parity to 100/100/100/100
- filter non-semantic source lines from LCOV and detailed summaries
- add event codec, order, signer, outbox, replica, and SP1 edge coverage
- delegate CRDT and workspace decode checks through shared validation invariants

Diffstat:
Mcontracts/coverage.toml | 15+++++++--------
Mcrates/events/src/farm_crdt.rs | 2++
Mcrates/events/src/kinds.rs | 2++
Mcrates/events_codec/src/comment/decode.rs | 160++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mcrates/events_codec/src/comment/encode.rs | 35+++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/coop/mod.rs | 42++++++++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/document/decode.rs | 37+++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/farm/mod.rs | 42++++++++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/farm_crdt/decode.rs | 51++++++++++++++++++++++++---------------------------
Mcrates/events_codec/src/farm_crdt/mod.rs | 12++++++++++++
Mcrates/events_codec/src/farm_file/decode.rs | 18++++++++++++++++++
Mcrates/events_codec/src/farm_file/mod.rs | 2++
Mcrates/events_codec/src/farm_workspace/decode.rs | 54+++++++++++++++++++++++-------------------------------
Mcrates/events_codec/src/listing/encode.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/listing/tags.rs | 12------------
Mcrates/events_codec/src/order/tags.rs | 8++++++++
Mcrates/events_codec/src/post/decode.rs | 37+++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/reaction/encode.rs | 24++++++++++++++++++++++++
Mcrates/events_codec/tests/calendar.rs | 2++
Mcrates/events_codec/tests/comment.rs | 22++++++++++++++++++++++
Mcrates/events_codec/tests/file_metadata.rs | 39+++++++++++++++++++++++++++++++++++++--
Mcrates/events_codec/tests/listing.rs | 24++++++++++++++++++++++++
Mcrates/events_codec/tests/post.rs | 12+++++++++---
Mcrates/events_codec/tests/reaction.rs | 16++++++++++++++++
Mcrates/events_codec/tests/structured_decode.rs | 6++++++
Mcrates/nostr_signer/src/nip46.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/outbox/src/store.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/replica_db/src/query.rs | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sp1_host_trade/src/lib.rs | 7++++++-
Mcrates/trade/src/order.rs | 45+++++++++++++++++++++++++++++++++++++++++++--
Mtools/xtask/src/contract.rs | 138++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mtools/xtask/src/coverage.rs | 196++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
32 files changed, 1117 insertions(+), 238 deletions(-)

diff --git a/contracts/coverage.toml b/contracts/coverage.toml @@ -1,14 +1,13 @@ [gate] -fail_under_exec_lines = 99.0 -fail_under_functions = 99.0 -fail_under_regions = 99.0 -fail_under_branches = 99.0 +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 require_branches = true -# Heavy-development baseline: required crates must hold at least 99% -# coverage across executable lines, functions, regions, and branches. This is -# not a requirement to reach 100% coverage. Temporary threshold overrides below -# 99% are not part of the active gate. +# Heavy-development baseline: required crates must hold 100% coverage across +# executable lines, functions, regions, and branches. Temporary threshold +# overrides below 100% are not part of the active gate. [overrides.radroots_log] require_branches = false diff --git a/crates/events/src/farm_crdt.rs b/crates/events/src/farm_crdt.rs @@ -550,6 +550,8 @@ mod tests { value: "FarmSoilTestCreate".to_string() } ); + assert_eq!(document_kind.as_str(), "FarmSoilTest"); + assert_eq!(semantic_kind.as_str(), "FarmSoilTestCreate"); assert_eq!( serde_json::to_string(&document_kind).unwrap(), "\"FarmSoilTest\"" diff --git a/crates/events/src/kinds.rs b/crates/events/src/kinds.rs @@ -631,6 +631,8 @@ mod tests { assert!(is_public_social_kind(KIND_POST)); assert!(is_public_social_kind(KIND_PUBLIC_FILE_METADATA)); + assert!(is_public_file_metadata_kind(KIND_PUBLIC_FILE_METADATA)); + assert!(!is_public_file_metadata_kind(KIND_POST)); assert!(is_public_social_kind(KIND_COMMENT)); assert!(is_public_social_kind(KIND_REACTION)); assert!(is_public_social_kind(KIND_ARTICLE)); diff --git a/crates/events_codec/src/comment/decode.rs b/crates/events_codec/src/comment/decode.rs @@ -95,83 +95,80 @@ fn parse_comment_target( let target_count = usize::from(event_tag.is_some()) + usize::from(address_tag.is_some()) + usize::from(external_tag.is_some()); - if target_count == 0 { - return Err(EventParseError::MissingTag(keys.event)); - } if target_count > 1 { return Err(EventParseError::InvalidTag(keys.event)); } - if let Some(tag) = event_tag { - let id = tag - .get(1) - .cloned() - .ok_or(EventParseError::InvalidTag(keys.event))?; - validate_lowercase_hex_64_tag(&id, keys.event)?; - let kind = required_numeric_kind(tags, keys.kind)?; - validate_comment_target_kind(kind, keys.kind)?; - let author = required_author(tags, keys.author)?; - let relays = if tag.len() > 2 { - Some(tag[2..].to_vec()) - } else { - None - }; - return Ok(RadrootsSocialTarget::Event { - id, - author: Some(author), - event_kind: Some(kind), - relays, - }); - } - - if let Some(tag) = address_tag { - let value = tag - .get(1) - .cloned() - .ok_or(EventParseError::InvalidTag(keys.address))?; - let address = parse_address_tag(&value, keys.address)?; - let kind = required_numeric_kind(tags, keys.kind)?; - validate_comment_target_kind(kind, keys.kind)?; - if kind != address.kind { - return Err(EventParseError::InvalidTag(keys.kind)); + match (event_tag, address_tag, external_tag) { + (Some(tag), None, None) => { + let id = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(keys.event))?; + validate_lowercase_hex_64_tag(&id, keys.event)?; + let kind = required_numeric_kind(tags, keys.kind)?; + validate_comment_target_kind(kind, keys.kind)?; + let author = required_author(tags, keys.author)?; + let relays = if tag.len() > 2 { + Some(tag[2..].to_vec()) + } else { + None + }; + Ok(RadrootsSocialTarget::Event { + id, + author: Some(author), + event_kind: Some(kind), + relays, + }) } - let author = required_author(tags, keys.author)?; - if author != address.pubkey { - return Err(EventParseError::InvalidTag(keys.author)); + (None, Some(tag), None) => { + let value = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(keys.address))?; + let address = parse_address_tag(&value, keys.address)?; + let kind = required_numeric_kind(tags, keys.kind)?; + validate_comment_target_kind(kind, keys.kind)?; + if kind != address.kind { + return Err(EventParseError::InvalidTag(keys.kind)); + } + let author = required_author(tags, keys.author)?; + if author != address.pubkey { + return Err(EventParseError::InvalidTag(keys.author)); + } + let relays = if tag.len() > 2 { + Some(tag[2..].to_vec()) + } else { + None + }; + Ok(RadrootsSocialTarget::Address { + address: value, + author: Some(author), + event_kind: Some(kind), + relays, + }) } - let relays = if tag.len() > 2 { - Some(tag[2..].to_vec()) - } else { - None - }; - return Ok(RadrootsSocialTarget::Address { - address: value, - author: Some(author), - event_kind: Some(kind), - relays, - }); - } - - let Some(tag) = external_tag else { - return Err(EventParseError::MissingTag(keys.external)); - }; - let id = tag - .get(1) - .cloned() - .ok_or(EventParseError::InvalidTag(keys.external))?; - if id.trim().is_empty() { - return Err(EventParseError::InvalidTag(keys.external)); - } - let external_kind = required_kind_value(tags, keys.kind)?; - if external_kind == "1" { - return Err(EventParseError::InvalidTag(keys.kind)); + (None, None, Some(tag)) => { + let id = tag + .get(1) + .cloned() + .ok_or(EventParseError::InvalidTag(keys.external))?; + if id.trim().is_empty() { + return Err(EventParseError::InvalidTag(keys.external)); + } + let external_kind = required_kind_value(tags, keys.kind)?; + if external_kind == "1" { + return Err(EventParseError::InvalidTag(keys.kind)); + } + let hint = tag.get(2).filter(|value| !value.trim().is_empty()).cloned(); + Ok(RadrootsSocialTarget::External { + id, + external_kind, + hint, + }) + } + _ => Err(EventParseError::MissingTag(keys.event)), } - let hint = tag.get(2).filter(|value| !value.trim().is_empty()).cloned(); - Ok(RadrootsSocialTarget::External { - id, - external_kind, - hint, - }) } fn validate_comment_target_kind(kind: u32, key: &'static str) -> Result<(), EventParseError> { @@ -263,3 +260,26 @@ pub fn parsed_from_event( data, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn comment_decode_rejects_non_numeric_target_kind() { + let err = comment_from_tags( + DEFAULT_KIND, + &[ + vec!["E".to_string(), "a".repeat(64)], + vec!["P".to_string(), "b".repeat(64)], + vec!["K".to_string(), "kind".to_string()], + vec!["i".to_string(), "external-id".to_string()], + vec!["k".to_string(), "web".to_string()], + ], + "content", + ) + .expect_err("non-numeric kind"); + + assert!(matches!(err, EventParseError::InvalidNumber("K", _))); + } +} diff --git a/crates/events_codec/src/comment/encode.rs b/crates/events_codec/src/comment/encode.rs @@ -172,3 +172,38 @@ fn validate_comment_target_kind(kind: u32, field: &'static str) -> Result<(), Ev Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn comment_targets_encode_without_relays() { + let author = "a".repeat(64); + let comment = RadrootsComment { + root: RadrootsSocialTarget::Event { + id: "b".repeat(64), + author: Some(author.clone()), + event_kind: Some(KIND_COMMENT), + relays: None, + }, + parent: RadrootsSocialTarget::Address { + address: format!("{KIND_COMMENT}:{author}:AAAAAAAAAAAAAAAAAAAAAA"), + author: Some(author), + event_kind: Some(KIND_COMMENT), + relays: None, + }, + content: "looks good".to_string(), + }; + + let tags = comment_build_tags(&comment).expect("comment tags"); + assert!( + tags.iter() + .any(|tag| tag == &vec!["E".to_string(), "b".repeat(64)]) + ); + assert!( + tags.iter() + .any(|tag| tag.first().map(String::as_str) == Some("a") && tag.len() == 2) + ); + } +} diff --git a/crates/events_codec/src/coop/mod.rs b/crates/events_codec/src/coop/mod.rs @@ -6,6 +6,8 @@ pub mod list_sets; #[cfg(test)] mod tests { + #[cfg(feature = "serde_json")] + use crate::coop::decode::coop_from_event; use crate::coop::encode::{coop_build_tags, coop_ref_tags}; use crate::coop::list_sets::{ coop_items_list_set, coop_members_farms_list_set, coop_members_list_set, @@ -16,6 +18,8 @@ mod tests { use radroots_events::farm::{ RadrootsFarmRef, RadrootsGcsLocation, RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon, }; + #[cfg(feature = "serde_json")] + use radroots_events::kinds::KIND_COOP; #[test] fn coop_tags_include_required_fields() { @@ -116,6 +120,44 @@ mod tests { } #[test] + #[cfg(feature = "serde_json")] + fn coop_decode_rejects_empty_d_tag_and_content() { + let coop = RadrootsCoop { + d_tag: "BAAAAAAAAAAAAAAAAAAAAA".to_string(), + name: "Test Coop".to_string(), + about: None, + website: None, + picture: None, + banner: None, + location: None, + tags: None, + }; + let content = serde_json::to_string(&coop).expect("coop content"); + + let empty_d = coop_from_event( + KIND_COOP, + &[vec!["d".to_string(), " ".to_string()]], + &content, + ) + .expect_err("empty d tag"); + assert!(matches!( + empty_d, + crate::error::EventParseError::InvalidTag("d") + )); + + let empty_content = coop_from_event( + KIND_COOP, + &[vec!["d".to_string(), "BAAAAAAAAAAAAAAAAAAAAA".to_string()]], + " ", + ) + .expect_err("empty content"); + assert!(matches!( + empty_content, + crate::error::EventParseError::InvalidJson("content") + )); + } + + #[test] fn coop_list_sets_include_expected_tags() { let members = coop_members_list_set("BAAAAAAAAAAAAAAAAAAAAA", ["member_pubkey"]) .expect("members list"); diff --git a/crates/events_codec/src/document/decode.rs b/crates/events_codec/src/document/decode.rs @@ -169,3 +169,40 @@ pub fn parsed_from_event( data, }) } + +#[cfg(test)] +mod tests { + use super::*; + use radroots_events::document::{RadrootsDocument, RadrootsDocumentSubject}; + + #[test] + fn document_decode_accepts_subject_without_address() { + let document = RadrootsDocument { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + doc_type: "policy".to_string(), + title: "Farm policy".to_string(), + version: "1.0.0".to_string(), + summary: None, + effective_at: None, + body_markdown: None, + subject: RadrootsDocumentSubject { + pubkey: "subject-pubkey".to_string(), + address: None, + }, + tags: None, + }; + let content = serde_json::to_string(&document).expect("document content"); + + let decoded = document_from_event( + DEFAULT_KIND, + &[ + vec![TAG_D.to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()], + vec![TAG_P.to_string(), "subject-pubkey".to_string()], + ], + &content, + ) + .expect("document"); + + assert_eq!(decoded.subject.address, None); + } +} diff --git a/crates/events_codec/src/farm/mod.rs b/crates/events_codec/src/farm/mod.rs @@ -5,6 +5,8 @@ pub mod list_sets; #[cfg(test)] mod tests { use crate::error::EventEncodeError; + #[cfg(feature = "serde_json")] + use crate::farm::decode::farm_from_event; use crate::farm::encode::{farm_build_tags, farm_ref_tags}; use crate::farm::list_sets::{ farm_listings_list_set_from_listings, farm_members_list_set, @@ -19,6 +21,8 @@ mod tests { RadrootsGeoJsonPoint, RadrootsGeoJsonPolygon, }; use radroots_events::ids::{RadrootsDTag, RadrootsInventoryBinId}; + #[cfg(feature = "serde_json")] + use radroots_events::kinds::KIND_FARM; use radroots_events::listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct}; use radroots_events::plot::RadrootsPlot; @@ -134,6 +138,44 @@ mod tests { } #[test] + #[cfg(feature = "serde_json")] + fn farm_decode_rejects_empty_d_tag_and_content() { + let farm = RadrootsFarm { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_string(), + name: "Test Farm".to_string(), + about: None, + website: None, + picture: None, + banner: None, + location: None, + tags: None, + }; + let content = serde_json::to_string(&farm).expect("farm content"); + + let empty_d = farm_from_event( + KIND_FARM, + &[vec!["d".to_string(), " ".to_string()]], + &content, + ) + .expect_err("empty d tag"); + assert!(matches!( + empty_d, + crate::error::EventParseError::InvalidTag("d") + )); + + let empty_content = farm_from_event( + KIND_FARM, + &[vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]], + " ", + ) + .expect_err("empty content"); + assert!(matches!( + empty_content, + crate::error::EventParseError::InvalidJson("content") + )); + } + + #[test] fn farm_ref_tags_include_p_and_a() { let farm = RadrootsFarmRef { pubkey: "farm_pubkey".to_string(), diff --git a/crates/events_codec/src/farm_crdt/decode.rs b/crates/events_codec/src/farm_crdt/decode.rs @@ -8,10 +8,7 @@ use alloc::{ use radroots_events::{ RadrootsNostrEvent, - farm_crdt::{ - KIND_FARM_CRDT_CHANGE, RADROOTS_FARM_CRDT_CHANGE_SCHEMA, RADROOTS_FARM_CRDT_TAG, - RadrootsFarmCrdtChange, - }, + farm_crdt::{KIND_FARM_CRDT_CHANGE, RADROOTS_FARM_CRDT_TAG, RadrootsFarmCrdtChange}, farm_workspace::KIND_FARM_WORKSPACE_MANIFEST, tags::{TAG_A, TAG_D, TAG_H, TAG_P, TAG_T}, }; @@ -20,8 +17,8 @@ use crate::d_tag::validate_d_tag_tag; use crate::error::EventParseError; use crate::farm_crdt::encode::validate_change; use crate::field_helpers::{ - is_non_empty_base64url, optional_tag_value, parse_address_tag_with_kind, required_tag_value, - tag_values, validate_non_empty_tag_value, + optional_tag_value, parse_address_tag_with_kind, required_tag_value, tag_values, + validate_non_empty_tag_value, }; use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; @@ -132,7 +129,6 @@ fn farm_crdt_change_from_event_inner( let change: RadrootsFarmCrdtChange = serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; - validate_change_content(&change)?; validate_change(&change).map_err(encode_error_to_parse_error)?; if change.farm_group_id != farm_group_id { return Err(EventParseError::InvalidTag(TAG_H)); @@ -146,26 +142,6 @@ fn farm_crdt_change_from_event_inner( Ok(change) } -fn validate_change_content(change: &RadrootsFarmCrdtChange) -> Result<(), EventParseError> { - if change.schema != RADROOTS_FARM_CRDT_CHANGE_SCHEMA { - return Err(EventParseError::InvalidJson("schema")); - } - validate_non_empty_tag_value(&change.farm_group_id, TAG_H)?; - validate_d_tag_tag(&change.document_id, TAG_D)?; - validate_non_empty_tag_value(&change.workspace.pubkey, TAG_A)?; - validate_d_tag_tag(&change.workspace.d_tag, TAG_A)?; - if !is_non_empty_base64url(&change.encoded_change) { - return Err(EventParseError::InvalidJson("encoded_change")); - } - if change.change_hash.trim().is_empty() { - return Err(EventParseError::InvalidJson("change_hash")); - } - if change.business_time_ms == 0 { - return Err(EventParseError::InvalidJson("business_time_ms")); - } - Ok(()) -} - fn encode_error_to_parse_error(error: crate::error::EventEncodeError) -> EventParseError { match error { crate::error::EventEncodeError::InvalidKind(kind) => EventParseError::InvalidKind { @@ -182,3 +158,24 @@ fn encode_error_to_parse_error(error: crate::error::EventEncodeError) -> EventPa crate::error::EventEncodeError::Json => EventParseError::InvalidJson("content"), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::EventEncodeError; + + #[test] + fn encode_error_mapper_covers_kind_and_json_edges() { + assert!(matches!( + encode_error_to_parse_error(EventEncodeError::InvalidKind(1)), + EventParseError::InvalidKind { + expected: EXPECTED_KIND, + got: 1 + } + )); + assert!(matches!( + encode_error_to_parse_error(EventEncodeError::Json), + EventParseError::InvalidJson("content") + )); + } +} diff --git a/crates/events_codec/src/farm_crdt/mod.rs b/crates/events_codec/src/farm_crdt/mod.rs @@ -358,6 +358,18 @@ mod tests { let err = farm_crdt_change_from_event(parts.kind, &group_mismatch, &parts.content).unwrap_err(); assert!(matches!(err, EventParseError::InvalidTag("h"))); + + let mut pubkey_mismatch = sample_change(); + pubkey_mismatch.workspace.pubkey = "other_workspace_pubkey".to_string(); + let content = serde_json::to_string(&pubkey_mismatch).expect("crdt content"); + let err = farm_crdt_change_from_event(parts.kind, &parts.tags, &content).unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("a"))); + + let mut d_tag_mismatch = sample_change(); + d_tag_mismatch.workspace.d_tag = DOCUMENT_ID.to_string(); + let content = serde_json::to_string(&d_tag_mismatch).expect("crdt content"); + let err = farm_crdt_change_from_event(parts.kind, &parts.tags, &content).unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("a"))); } #[test] diff --git a/crates/events_codec/src/farm_file/decode.rs b/crates/events_codec/src/farm_file/decode.rs @@ -309,4 +309,22 @@ mod tests { let err = encode_error_to_parse_error(EventEncodeError::Json); assert!(matches!(err, EventParseError::InvalidTag("content"))); } + + #[test] + fn encode_error_mapper_covers_invalid_field_tags() { + for (field, expected_tag) in [ + ("d_tag", TAG_D), + ("farm_group_id", TAG_H), + ("workspace.pubkey", TAG_A), + ("workspace.d_tag", TAG_A), + ("owner_document_id", TAG_OWNER_DOCUMENT), + ("url", TAG_URL), + ("mime_type", TAG_MIME), + ("sha256", TAG_SHA256), + ("original_sha256", TAG_ORIGINAL_SHA256), + ] { + let err = encode_error_to_parse_error(EventEncodeError::InvalidField(field)); + assert!(matches!(err, EventParseError::InvalidTag(tag) if tag == expected_tag)); + } + } } diff --git a/crates/events_codec/src/farm_file/mod.rs b/crates/events_codec/src/farm_file/mod.rs @@ -271,6 +271,8 @@ mod tests { for (key, value, expected) in [ ("size", "not-a-number", "size"), ("dim", "bad", "dim"), + ("dim", "badx12", "dim"), + ("dim", "12xbad", "dim"), ("dim", "0x12", "dim"), ("dim", "12x0", "dim"), ("thumb", "", "thumb"), diff --git a/crates/events_codec/src/farm_workspace/decode.rs b/crates/events_codec/src/farm_workspace/decode.rs @@ -9,10 +9,9 @@ use alloc::{ use radroots_events::{ RadrootsNostrEvent, farm_workspace::{ - KIND_FARM_WORKSPACE_MANIFEST, RADROOTS_FARM_WORKSPACE_SCHEMA, RADROOTS_FARM_WORKSPACE_TAG, - RadrootsFarmWorkspaceManifest, + KIND_FARM_WORKSPACE_MANIFEST, RADROOTS_FARM_WORKSPACE_TAG, RadrootsFarmWorkspaceManifest, }, - kinds::{KIND_FARM, KIND_FARM_CRDT_CHANGE, KIND_FARM_FILE_METADATA}, + kinds::KIND_FARM, tags::{TAG_A, TAG_D, TAG_H, TAG_P, TAG_T}, }; @@ -21,7 +20,6 @@ use crate::error::EventParseError; use crate::farm_workspace::encode::validate_manifest; use crate::field_helpers::{ optional_tag_value, parse_address_tag_with_kind, required_tag_value, tag_values, - validate_non_empty_tag_value, }; use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; @@ -46,7 +44,6 @@ pub fn farm_workspace_from_event( let farm_group_id = required_tag_value(tags, TAG_H)?; let manifest: RadrootsFarmWorkspaceManifest = serde_json::from_str(content).map_err(|_| EventParseError::InvalidJson("content"))?; - validate_manifest_content(&manifest)?; validate_manifest(&manifest).map_err(encode_error_to_parse_error)?; if manifest.d_tag != d_tag { @@ -129,32 +126,6 @@ pub fn parsed_from_event( }) } -fn validate_manifest_content( - manifest: &RadrootsFarmWorkspaceManifest, -) -> Result<(), EventParseError> { - if manifest.schema != RADROOTS_FARM_WORKSPACE_SCHEMA { - return Err(EventParseError::InvalidJson("schema")); - } - validate_non_empty_tag_value(&manifest.farm_group_id, TAG_H)?; - validate_non_empty_tag_value(&manifest.owner_pubkey, TAG_P)?; - if manifest.relays.is_empty() { - return Err(EventParseError::InvalidJson("relays")); - } - if !manifest - .supported_kinds - .contains(&KIND_FARM_WORKSPACE_MANIFEST) - || !manifest.supported_kinds.contains(&KIND_FARM_CRDT_CHANGE) - { - return Err(EventParseError::InvalidJson("supported_kinds")); - } - if !manifest.media_servers.is_empty() - && !manifest.supported_kinds.contains(&KIND_FARM_FILE_METADATA) - { - return Err(EventParseError::InvalidJson("supported_kinds")); - } - Ok(()) -} - fn encode_error_to_parse_error(error: crate::error::EventEncodeError) -> EventParseError { match error { crate::error::EventEncodeError::InvalidKind(kind) => EventParseError::InvalidKind { @@ -171,3 +142,24 @@ fn encode_error_to_parse_error(error: crate::error::EventEncodeError) -> EventPa crate::error::EventEncodeError::Json => EventParseError::InvalidJson("content"), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::EventEncodeError; + + #[test] + fn encode_error_mapper_covers_kind_and_json_edges() { + assert!(matches!( + encode_error_to_parse_error(EventEncodeError::InvalidKind(1)), + EventParseError::InvalidKind { + expected: EXPECTED_KIND, + got: 1 + } + )); + assert!(matches!( + encode_error_to_parse_error(EventEncodeError::Json), + EventParseError::InvalidJson("content") + )); + } +} diff --git a/crates/events_codec/src/listing/encode.rs b/crates/events_codec/src/listing/encode.rs @@ -62,3 +62,83 @@ fn listing_markdown_content(listing: &RadrootsListing) -> String { (true, None) => String::new(), } } + +#[cfg(all(test, feature = "serde_json"))] +mod tests { + use super::*; + use core::str::FromStr; + use radroots_core::{ + RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, + RadrootsCoreQuantityPrice, RadrootsCoreUnit, + }; + use radroots_events::{ + farm::RadrootsFarmRef, + ids::{RadrootsDTag, RadrootsInventoryBinId}, + listing::{RadrootsListingBin, RadrootsListingProduct}, + }; + + fn decimal(value: &str) -> RadrootsCoreDecimal { + RadrootsCoreDecimal::from_str(value).expect("decimal") + } + + fn listing_with(title: &str, summary: Option<&str>) -> RadrootsListing { + RadrootsListing { + d_tag: RadrootsDTag::parse("AAAAAAAAAAAAAAAAAAAAAA").expect("d tag"), + published_at: None, + farm: RadrootsFarmRef { + pubkey: "a".repeat(64), + d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), + }, + product: RadrootsListingProduct { + key: "coffee".to_string(), + title: title.to_string(), + category: "produce".to_string(), + summary: summary.map(ToOwned::to_owned), + process: None, + lot: None, + location: None, + profile: None, + year: None, + }, + primary_bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), + bins: vec![RadrootsListingBin { + bin_id: RadrootsInventoryBinId::parse("bin-1").expect("bin id"), + quantity: RadrootsCoreQuantity::new(decimal("1"), RadrootsCoreUnit::MassG), + price_per_canonical_unit: RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(decimal("1"), RadrootsCoreCurrency::USD), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, RadrootsCoreUnit::MassG), + ), + display_amount: None, + display_unit: None, + display_label: None, + display_price: None, + display_price_unit: None, + }], + resource_area: None, + plot: None, + discounts: None, + inventory_available: None, + availability: None, + delivery_method: None, + location: None, + images: None, + } + } + + #[test] + fn listing_markdown_content_covers_title_summary_combinations() { + assert_eq!( + listing_markdown_content(&listing_with("Coffee", Some("Washed"))), + "# Coffee\n\nWashed" + ); + assert_eq!( + listing_markdown_content(&listing_with("Coffee", None)), + "# Coffee" + ); + assert_eq!( + listing_markdown_content(&listing_with(" ", Some("Washed"))), + "Washed" + ); + assert_eq!(listing_markdown_content(&listing_with(" ", None)), ""); + } +} diff --git a/crates/events_codec/src/listing/tags.rs b/crates/events_codec/src/listing/tags.rs @@ -105,13 +105,7 @@ pub fn listing_tags_with_options( options: ListingTagOptions, ) -> Result<Vec<Vec<String>>, EventEncodeError> { let d_tag = listing.d_tag.as_str(); - if d_tag.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("d")); - } validate_d_tag(d_tag, "d")?; - if listing.primary_bin_id.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("primary_bin_id")); - } if listing.bins.is_empty() { return Err(EventEncodeError::EmptyRequiredField("bins")); } @@ -327,9 +321,6 @@ fn tag_listing_plot(plot: &RadrootsPlotRef) -> Result<Vec<String>, EventEncodeEr } fn tag_listing_bin(bin: &RadrootsListingBin) -> Result<Vec<String>, EventEncodeError> { - if bin.bin_id.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("bin_id")); - } let unit = bin.quantity.unit; if unit != unit.canonical_unit() { return Err(EventEncodeError::EmptyRequiredField("bin.quantity")); @@ -360,9 +351,6 @@ fn tag_listing_bin(bin: &RadrootsListingBin) -> Result<Vec<String>, EventEncodeE } fn tag_listing_price(bin: &RadrootsListingBin) -> Result<Vec<String>, EventEncodeError> { - if bin.bin_id.trim().is_empty() { - return Err(EventEncodeError::EmptyRequiredField("bin_id")); - } let price = &bin.price_per_canonical_unit; if !price.is_price_per_canonical_unit() { return Err(EventEncodeError::EmptyRequiredField( diff --git a/crates/events_codec/src/order/tags.rs b/crates/events_codec/src/order/tags.rs @@ -580,6 +580,14 @@ mod tests { ]]), Err(EventParseError::InvalidTag(TAG_LISTING_EVENT)) )); + assert!(matches!( + parse_order_listing_event_tag(&[vec![ + String::from(TAG_LISTING_EVENT), + event_id('f'), + String::from("relay\nid"), + ]]), + Err(EventParseError::InvalidTag(TAG_LISTING_EVENT)) + )); assert_eq!( parse_order_listing_event_tag(&[vec![String::from(TAG_LISTING_EVENT), event_id('a'),]]) .expect("snapshot without relay"), diff --git a/crates/events_codec/src/post/decode.rs b/crates/events_codec/src/post/decode.rs @@ -270,3 +270,40 @@ fn non_empty_vec<T>(values: Vec<T>) -> Option<Vec<T>> { Some(values) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[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], + RadrootsSocialTarget::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/events_codec/src/reaction/encode.rs b/crates/events_codec/src/reaction/encode.rs @@ -108,3 +108,27 @@ fn push_reaction_target( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reaction_event_target_encodes_without_relays() { + let reaction = RadrootsReaction { + target: RadrootsSocialTarget::Event { + id: "a".repeat(64), + author: Some("b".repeat(64)), + event_kind: Some(1), + relays: None, + }, + content: "+".to_string(), + }; + + let tags = reaction_build_tags(&reaction).expect("reaction tags"); + assert!( + tags.iter() + .any(|tag| tag == &vec!["e".to_string(), "a".repeat(64)]) + ); + } +} diff --git a/crates/events_codec/tests/calendar.rs b/crates/events_codec/tests/calendar.rs @@ -682,6 +682,8 @@ fn calendar_rsvp_codecs_cover_status_and_target_edges() { rsvp.status = RadrootsCalendarEventRsvpStatus::Tentative; let parts = rsvp_to_wire_parts(&rsvp).unwrap(); assert!(has_tag(&parts.tags, TAG_STATUS, "tentative")); + let decoded = rsvp_from_event(parts.kind, &parts.tags, &parts.content).unwrap(); + assert_eq!(decoded.status, RadrootsCalendarEventRsvpStatus::Tentative); let mut rsvp = sample_rsvp(); rsvp.event_id = Some("not-a-lowercase-hex-id".to_string()); diff --git a/crates/events_codec/tests/comment.rs b/crates/events_codec/tests/comment.rs @@ -138,6 +138,21 @@ fn comment_build_tags_requires_strict_nip22_target_fields() { let comment = RadrootsComment { root: RadrootsSocialTarget::Address { + address: "not-an-address".to_string(), + author: Some(AUTHOR.to_string()), + event_kind: Some(KIND_ARTICLE), + relays: None, + }, + parent: event_target(PARENT_ID, PARENT_AUTHOR, KIND_ARTICLE), + content: "hello".to_string(), + }; + assert!(matches!( + comment_build_tags(&comment), + Err(EventEncodeError::InvalidField("root")) + )); + + let comment = RadrootsComment { + root: RadrootsSocialTarget::Address { address: format!("{KIND_ARTICLE}:{AUTHOR}:{D_TAG}"), author: Some(AUTHOR.to_string()), event_kind: Some(KIND_COMMENT), @@ -345,6 +360,13 @@ fn comment_from_tags_covers_target_decode_edges() { )); let mut tags = event_comment_tags(); + tags[0] = vec!["X".to_string(), ROOT_ID.to_string()]; + assert!(matches!( + comment_from_tags(KIND_COMMENT, &tags, "hello"), + Err(EventParseError::MissingTag("E")) + )); + + let mut tags = event_comment_tags(); tags[0] = vec!["E".to_string()]; assert!(matches!( comment_from_tags(KIND_COMMENT, &tags, "hello"), diff --git a/crates/events_codec/tests/file_metadata.rs b/crates/events_codec/tests/file_metadata.rs @@ -2,7 +2,7 @@ use radroots_events::{ farm_crdt::RadrootsFarmCrdtDocumentKind, - farm_file::{RadrootsFarmFileDimensions, RadrootsFarmFileMetadata}, + farm_file::{RadrootsFarmFileDimensions, RadrootsFarmFileMetadata, RadrootsFarmFileSource}, farm_workspace::RadrootsFarmWorkspaceRef, file_metadata::RadrootsFileMetadata, kinds::{KIND_POST, KIND_PUBLIC_FILE_METADATA}, @@ -221,10 +221,34 @@ fn file_metadata_public_and_private_kind1063_contracts_do_not_cross_decode() { Err(EventParseError::MissingTag("d")) )); - let private = farm_file_to_wire_parts(&sample_farm_file_metadata()).unwrap(); + let mut private_metadata = sample_farm_file_metadata(); + private_metadata.thumb = Some(RadrootsFarmFileSource { + url: "https://media.example.test/private-thumb.jpg".to_string(), + mime_type: Some("image/jpeg".to_string()), + dimensions: Some(RadrootsFarmFileDimensions { w: 320, h: 240 }), + }); + private_metadata.image = Some(RadrootsFarmFileSource { + url: "https://media.example.test/private-image.jpg".to_string(), + mime_type: Some("image/jpeg".to_string()), + dimensions: None, + }); + let private = farm_file_to_wire_parts(&private_metadata).unwrap(); let decoded_private = farm_file_metadata_from_event(private.kind, &private.tags, &private.content).unwrap(); assert_eq!(decoded_private.owner_document_id, "CCCCCCCCCCCCCCCCCCCCCA"); + assert_eq!( + decoded_private.thumb.and_then(|source| source.dimensions), + Some(RadrootsFarmFileDimensions { w: 320, h: 240 }) + ); + assert_eq!( + decoded_private + .image + .map(|source| (source.url, source.dimensions)), + Some(( + "https://media.example.test/private-image.jpg".to_string(), + None + )) + ); assert!(matches!( file_metadata_from_event(private.kind, &private.tags, &private.content), Err(EventParseError::InvalidTag("radroots:owner_document")) @@ -290,6 +314,17 @@ fn file_metadata_codec_requires_kind_required_tags_and_hash_shape() { file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""), Err(EventParseError::InvalidTag(TAG_SHA256)) )); + + let mut tags = file_metadata_build_tags(&sample_metadata()).unwrap(); + let size_tag = tags + .iter_mut() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_SIZE)) + .expect("size tag"); + size_tag[1] = "not-a-number".to_string(); + assert!(matches!( + file_metadata_from_event(KIND_PUBLIC_FILE_METADATA, &tags, ""), + Err(EventParseError::InvalidNumber(TAG_SIZE, _)) + )); } #[test] diff --git a/crates/events_codec/tests/listing.rs b/crates/events_codec/tests/listing.rs @@ -420,6 +420,14 @@ fn listing_from_event_covers_bin_and_price_error_paths() { let mut tags = sample_listing_tags(); tags.push(vec![ "radroots:primary_bin".to_string(), + "bin-1".to_string(), + ]); + let decoded = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap(); + assert_eq!(decoded.primary_bin_id.as_str(), "bin-1"); + + let mut tags = sample_listing_tags(); + tags.push(vec![ + "radroots:primary_bin".to_string(), "bin-2".to_string(), ]); assert_invalid_tag(tags, "radroots:primary_bin"); @@ -456,6 +464,14 @@ fn listing_from_event_covers_bin_and_price_error_paths() { replace_first_tag( &mut tags, "radroots:bin", + vec!["radroots:bin", "bin-1", "1", "not-a-unit"], + ); + assert_invalid_tag(tags, "radroots:bin"); + + let mut tags = sample_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:bin", vec!["radroots:bin", "bin-1", "1", "each", "1"], ); assert_invalid_tag(tags, "radroots:bin"); @@ -516,6 +532,14 @@ fn listing_from_event_covers_bin_and_price_error_paths() { replace_first_tag( &mut tags, "radroots:price", + vec!["radroots:price", "bin-1", "10", "not-currency", "1", "each"], + ); + assert_invalid_tag(tags, "radroots:price"); + + let mut tags = sample_listing_tags(); + replace_first_tag( + &mut tags, + "radroots:price", vec!["radroots:price", "bin-1", "10", "USD", "1", "each", "10"], ); assert_invalid_tag(tags, "radroots:price"); diff --git a/crates/events_codec/tests/post.rs b/crates/events_codec/tests/post.rs @@ -1,6 +1,6 @@ use radroots_events::{ farm::RadrootsFarmRef, - kinds::{KIND_ARTICLE, KIND_COMMENT, KIND_POST}, + kinds::{KIND_ARTICLE, KIND_COMMENT, KIND_FARM, KIND_POST}, post::RadrootsPost, social::{ RadrootsSocialFarmAnchor, RadrootsSocialLocation, RadrootsSocialMediaDimensions, @@ -639,14 +639,20 @@ fn post_decode_rejects_more_invalid_imeta_shapes() { #[test] fn post_decode_handles_non_farm_address_refs_without_relays() { let article = format!("30023:article_author:{ARTICLE_D_TAG}"); + let farm = format!("{KIND_FARM}:farm_pubkey:{FARM_D_TAG}"); let decoded = post_from_event( KIND_POST, - &[vec![TAG_A.to_string(), article.clone()]], + &[ + vec![TAG_A.to_string(), farm.clone()], + vec![TAG_A.to_string(), article.clone()], + ], "address only", ) .unwrap(); - assert!(decoded.farm.is_none()); + let anchor = decoded.farm.expect("farm anchor"); + assert_eq!(anchor.farm.d_tag, FARM_D_TAG); + assert_eq!(anchor.relays, None); let refs = decoded.address_refs.expect("address refs"); assert_eq!(refs.len(), 1); match &refs[0] { diff --git a/crates/events_codec/tests/reaction.rs b/crates/events_codec/tests/reaction.rs @@ -191,6 +191,22 @@ fn reaction_build_tags_covers_optional_target_branches() { .iter() .any(|value| value == "wss://address-relay.example.test") })); + + let reaction = RadrootsReaction { + target: RadrootsSocialTarget::Address { + address: format!("{}:{AUTHOR}:{D_TAG}", KIND_ARTICLE), + author: None, + event_kind: None, + relays: None, + }, + content: "+".to_string(), + }; + let tags = reaction_build_tags(&reaction).unwrap(); + let address_tag = tags + .iter() + .find(|tag| tag.first().map(String::as_str) == Some("a")) + .expect("address tag"); + assert_eq!(address_tag.len(), 2); } #[test] diff --git a/crates/events_codec/tests/structured_decode.rs b/crates/events_codec/tests/structured_decode.rs @@ -372,6 +372,12 @@ fn plot_decode_handles_success_fill_and_tag_error_paths() { assert_eq!(filled.d_tag, d_tag); assert_eq!(filled.farm.d_tag, farm_d_tag); + let partial_farm_content = + serde_json::to_string(&sample_plot(d_tag, TEST_PUBKEY_HEX, "")).expect("plot partial farm"); + let partial_farm = + plot_from_event(KIND_PLOT, &tags, &partial_farm_content).expect("partial farm fill"); + assert_eq!(partial_farm.farm.d_tag, farm_d_tag); + let bad_a = plot_from_event( KIND_PLOT, &[ diff --git a/crates/nostr_signer/src/nip46.rs b/crates/nostr_signer/src/nip46.rs @@ -820,8 +820,8 @@ mod tests { }; use crate::model::{ RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthChallenge, - RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionRecord, - RadrootsNostrSignerPendingRequest, + RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft, + RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPendingRequest, }; use crate::test_support::{fixture_alice_identity, fixture_carol_public_key, primary_relay}; use nostr::{Keys, Timestamp, UnsignedEvent}; @@ -1706,6 +1706,26 @@ mod tests { ), RadrootsNostrConnectResponse::Pong ); + let mut denied_base_eval = backend + .evaluate_request( + &connection.connection_id, + request_message("req-eval-denied-ping", RadrootsNostrConnectRequest::Ping), + ) + .expect("denied base evaluation"); + denied_base_eval.action = RadrootsNostrSignerRequestAction::Denied { + reason: "blocked".to_owned(), + }; + assert!(matches!( + response_from_outcome( + handler + .handle_authorized_request_evaluation( + request_message("req-eval-denied-ping", RadrootsNostrConnectRequest::Ping), + denied_base_eval, + ) + .expect("denied base authorized") + ), + RadrootsNostrConnectResponse::Error { .. } + )); let crypto = request_message( "req-eval-crypto", @@ -1885,6 +1905,42 @@ mod tests { } #[test] + fn handler_reports_ambiguous_client_sessions() { + let backend = embedded_backend(); + let client_public_key = fixture_carol_public_key(); + backend + .register_connection(RadrootsNostrSignerConnectionDraft::new( + client_public_key, + test_signer().user_identity.to_public(), + )) + .expect("first connection"); + backend + .register_connection(RadrootsNostrSignerConnectionDraft::new( + client_public_key, + RadrootsIdentity::from_secret_key_str( + "3333333333333333333333333333333333333333333333333333333333333333", + ) + .expect("second user identity") + .to_public(), + )) + .expect("second connection"); + + let outcome = handler_with_backend(backend) + .handle_request( + client_public_key, + request_message("req-ambiguous", RadrootsNostrConnectRequest::Ping), + ) + .expect("ambiguous request"); + assert_eq!( + response_from_outcome(outcome), + RadrootsNostrConnectResponse::Error { + result: None, + error: "ambiguous client sessions".to_owned(), + } + ); + } + + #[test] fn handler_registers_connections_and_returns_audit_for_authorized_requests() { let backend = embedded_backend(); let handler = handler_with_backend(backend.clone()); diff --git a/crates/outbox/src/store.rs b/crates/outbox/src/store.rs @@ -2132,6 +2132,64 @@ mod tests { } #[tokio::test] + async fn sign_retryable_update_succeeds_and_reports_ignored_update_with_current_token() { + let outbox = RadrootsOutbox::open_memory().await.expect("open"); + let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "retryable success"); + let receipt = outbox + .enqueue_operation(operation_input(draft, 1_000)) + .await + .expect("enqueue"); + outbox + .claim_next_ready_event("worker-a", "claim-a", 2_000, 1_000) + .await + .expect("claim") + .expect("claim"); + + outbox + .mark_sign_retryable(receipt.outbox_event_id, "claim-a", "retry", 1_500, 1_100) + .await + .expect("mark retryable"); + let event = outbox + .get_event(receipt.outbox_event_id) + .await + .expect("event") + .expect("event"); + assert_eq!(event.state, RadrootsOutboxEventState::SignRetryable); + + let ignored_outbox = RadrootsOutbox::open_memory().await.expect("ignored open"); + let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "ignored retryable"); + let ignored_receipt = ignored_outbox + .enqueue_operation(operation_input(draft, 2_000)) + .await + .expect("enqueue ignored"); + ignored_outbox + .claim_next_ready_event("worker-b", "claim-b", 3_000, 2_000) + .await + .expect("claim ignored") + .expect("claim ignored"); + sqlx::query( + "CREATE TEMP TRIGGER ignore_sign_retry_update BEFORE UPDATE OF state ON outbox_event WHEN NEW.state = 'sign_retryable' BEGIN SELECT RAISE(IGNORE); END", + ) + .execute(ignored_outbox.pool()) + .await + .expect("retry trigger"); + let ignored = ignored_outbox + .mark_sign_retryable( + ignored_receipt.outbox_event_id, + "claim-b", + "ignored retry", + 2_500, + 2_100, + ) + .await + .expect_err("ignored retryable update"); + assert!(matches!( + ignored, + RadrootsOutboxError::ClaimTokenMismatch { .. } + )); + } + + #[tokio::test] async fn ignored_sqlite_updates_preserve_race_guards() { let outbox = RadrootsOutbox::open_memory().await.expect("open"); let draft = post_draft(FIXTURE_ALICE_PUBLIC_KEY_HEX, "ignored claim"); diff --git a/crates/replica_db/src/query.rs b/crates/replica_db/src/query.rs @@ -130,3 +130,100 @@ impl<E: SqlExecutor> ReplicaSql<E> { .and_then(|value| u64::try_from(value).ok())) } } + +#[cfg(test)] +mod tests { + use super::*; + use radroots_sql_core::ExecOutcome; + + struct QueryExecutor { + farm_rows: &'static str, + product_rows: &'static str, + } + + impl SqlExecutor for QueryExecutor { + fn exec(&self, _sql: &str, _params_json: &str) -> Result<ExecOutcome, SqlError> { + Ok(ExecOutcome { + changes: 0, + last_insert_id: 0, + }) + } + + fn query_raw(&self, sql: &str, _params_json: &str) -> Result<String, SqlError> { + if sql.contains("FROM farm") { + Ok(self.farm_rows.to_string()) + } else { + Ok(self.product_rows.to_string()) + } + } + + fn begin(&self) -> Result<(), SqlError> { + Ok(()) + } + + fn commit(&self) -> Result<(), SqlError> { + Ok(()) + } + + fn rollback(&self) -> Result<(), SqlError> { + Ok(()) + } + } + + fn product_rows() -> &'static str { + r#"[{ + "id":"listing-1", + "key":"coffee", + "category":"produce", + "title":"Coffee", + "summary":"Washed coffee", + "qty_amt":1.0, + "qty_amt_exact":"1", + "qty_unit":"kg", + "qty_label":null, + "qty_avail":10, + "price_amt":12.0, + "price_amt_exact":"12", + "price_currency":"USD", + "price_qty_amt":1.0, + "price_qty_amt_exact":"1", + "price_qty_unit":"kg", + "listing_addr":"30402:pubkey:AAAAAAAAAAAAAAAAAAAAAA", + "primary_bin_id":"bin-1", + "verified_primary_bin_id":"bin-1", + "notes":null, + "location_primary":"Farm" + }]"# + } + + #[test] + fn trade_product_queries_and_unique_farm_lookup_cover_empty_and_multiple_rows() { + let db = ReplicaSql::new(QueryExecutor { + farm_rows: r#"[{"d_tag":"farm-a"},{"d_tag":"farm-b"}]"#, + product_rows: product_rows(), + }); + + assert_eq!( + db.trade_product_search(&[]).expect("empty search"), + Vec::new() + ); + let lookup = db.trade_product_lookup("coffee").expect("lookup"); + assert_eq!(lookup[0].key, "coffee"); + assert_eq!( + db.farm_unique_d_tag_by_pubkey("seller") + .expect("farm lookup"), + None + ); + + let unique_db = ReplicaSql::new(QueryExecutor { + farm_rows: r#"[{"d_tag":"farm-a"}]"#, + product_rows: product_rows(), + }); + assert_eq!( + unique_db + .farm_unique_d_tag_by_pubkey("seller") + .expect("farm lookup"), + Some("farm-a".to_string()) + ); + } +} diff --git a/crates/sp1_host_trade/src/lib.rs b/crates/sp1_host_trade/src/lib.rs @@ -1744,7 +1744,7 @@ struct ProofEnvelopeDigestMaterial<'a> { mod tests { use super::{ RadrootsSp1TradeHostError, RadrootsSp1TradeProofMode, generate_order_acceptance_proof, - validation_receipt_for_order_acceptance_proof, + validation_receipt_for_order_acceptance_proof, validation_receipt_result_label, verify_order_acceptance_proof_artifact_structure, }; use base64::Engine; @@ -2056,6 +2056,10 @@ mod tests { assert_eq!(RadrootsSp1TradeProofMode::from_label(label), Some(mode)); } } + assert_eq!( + RadrootsSp1TradeProofMode::from_label("none"), + Some(RadrootsSp1TradeProofMode::None) + ); assert_eq!(RadrootsSp1TradeProofMode::from_label("legacy"), None); for backend in [ @@ -2526,6 +2530,7 @@ mod tests { let receipt = validation_receipt_for_order_acceptance_proof(&bundle).expect("validation receipt"); assert_eq!(receipt.result, RadrootsValidationReceiptResult::Invalid); + assert_eq!(validation_receipt_result_label(receipt.result), "invalid"); } #[test] diff --git a/crates/trade/src/order.rs b/crates/trade/src/order.rs @@ -844,8 +844,10 @@ fn reduce_listing_inventory_accounting_records( ); match projection.status { RadrootsOrderStatus::Accepted => { - if let Some(agreement_event_id) = projection.agreement_event_id.as_ref() - && let Some(economics) = projection.economics.as_ref() + for (agreement_event_id, economics) in projection + .agreement_event_id + .iter() + .zip(projection.economics.iter()) { add_accepted_inventory_reservations_from_economics( &mut bins, @@ -2745,6 +2747,22 @@ mod tests { super::projection_issue_event_ids(&projection.issues), vec![event_id(2), event_id(4), event_id(5)] ); + + let mut duplicate_request = request_record(); + duplicate_request.payload.order_id = order_id("order-duplicate"); + let mut duplicate_request_later = duplicate_request.clone(); + duplicate_request_later.payload.buyer_pubkey = public_key(OTHER); + let deduped = + super::unique_request_records(vec![duplicate_request.clone(), duplicate_request_later]); + assert_eq!(deduped.len(), 1); + assert_eq!( + deduped[0].payload.order_id, + duplicate_request.payload.order_id + ); + assert_eq!( + deduped[0].payload.buyer_pubkey, + duplicate_request.payload.buyer_pubkey + ); } #[test] @@ -2774,6 +2792,10 @@ mod tests { )), Err(super::RadrootsOrderCanonicalizationError::InvalidListingKind) )); + assert!(matches!( + super::parse_public_listing_addr(format!("30023:{SELLER}:AAAAAAAAAAAAAAAAAAAAAg")), + Err(super::RadrootsOrderCanonicalizationError::InvalidListingKind) + )); assert!(matches!( super::canonicalize_order_request_for_signer(request_record().payload, SELLER), @@ -4362,6 +4384,25 @@ mod tests { #[test] fn inventory_accounting_reserves_only_accepted_agreements() { + let requested_projection = reduce_listing_inventory_accounting( + &listing_addr(), + &event_id(8), + RadrootsListingInventoryAccountingInputs { + bins: vec![RadrootsListingInventoryBinAvailability { + bin_id: bin_id("bin-1"), + available_count: 3, + }], + requests: vec![request_record()], + decisions: Vec::<RadrootsOrderDecisionRecord>::new(), + revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(), + revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(), + cancellations: Vec::<RadrootsOrderCancellationRecord>::new(), + }, + ); + + assert_eq!(requested_projection.bins[0].accepted_reserved_count, 0); + assert_eq!(requested_projection.bins[0].remaining_count, 3); + let projection = reduce_listing_inventory_accounting( &listing_addr(), &event_id(9), diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -14,8 +14,8 @@ const CONFORMANCE_ROOT_RELATIVE: &str = "contracts/conformance"; const CONFORMANCE_SCHEMA_RELATIVE: &str = "contracts/conformance/schema/vector.schema.json"; const RELEASE_POLICY_ENV: &str = "RADROOTS_MOUNTED_RUST_CRATE_PUBLISH_POLICY"; const EVENT_BOUNDARY_MATRIX_ENV: &str = "RADROOTS_EVENT_BOUNDARY_MATRIX"; -const COVERAGE_REQUIRED_THRESHOLD: f64 = 99.0; -const COVERAGE_REQUIRED_THRESHOLD_LABEL: &str = "99/99/99/99"; +const COVERAGE_REQUIRED_THRESHOLD: f64 = 100.0; +const COVERAGE_REQUIRED_THRESHOLD_LABEL: &str = "100/100/100/100"; const COVERAGE_REPORT_EPSILON: f64 = 0.000_001; const EVENT_BOUNDARY_MATRIX_RELATIVES: [&str; 1] = [ "docs/platform/canonical/open_source/radroots_v1_spec/02_public_contract_and_runtime/08_event_boundary_matrix.md", @@ -3777,7 +3777,7 @@ mod tests { TestCoverageRefreshRow { crate_name, status: "pass", - thresholds: coverage_thresholds(99.0, true), + thresholds: coverage_thresholds(100.0, true), exec: 100.0, func: 100.0, branch: Some(100.0), @@ -3942,10 +3942,10 @@ requires_release_notes = true write_file( &root.join("contracts").join("coverage.toml"), r#"[gate] -fail_under_exec_lines = 99.0 -fail_under_functions = 99.0 -fail_under_regions = 99.0 -fail_under_branches = 99.0 +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 require_branches = true [required] @@ -4164,10 +4164,10 @@ publish = false write_file( &root.join("contracts").join("coverage.toml"), r#"[gate] -fail_under_exec_lines = 99.0 -fail_under_functions = 99.0 -fail_under_regions = 99.0 -fail_under_branches = 99.0 +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 require_branches = true [required] @@ -4516,7 +4516,7 @@ edition = "2024" let policy_dir = root.join("contracts"); write_file( &policy_dir.join("coverage.toml"), - "[gate]\nfail_under_exec_lines = 99.0\nfail_under_functions = 99.0\nfail_under_regions = 99.0\nfail_under_branches = 99.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", ); let policy = read_coverage_policy(&policy_dir.join("coverage.toml")).expect("parse coverage policy"); @@ -4546,7 +4546,7 @@ edition = "2024" let policy_dir = root.join("contracts"); write_file( &policy_dir.join("coverage.toml"), - "[gate]\nfail_under_exec_lines = 99.0\nfail_under_functions = 99.0\nfail_under_regions = 99.0\nfail_under_branches = 99.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", ); let policy = read_coverage_policy(&policy_dir.join("coverage.toml")).expect("parse coverage policy"); @@ -4562,7 +4562,7 @@ edition = "2024" let row = TestCoverageRefreshRow { crate_name: "radroots_a", status: "pass", - thresholds: coverage_thresholds(99.0, true), + thresholds: coverage_thresholds(100.0, true), exec: 99.0, func: 100.0, branch: Some(100.0), @@ -4585,7 +4585,7 @@ edition = "2024" let policy_dir = root.join("contracts"); write_file( &policy_dir.join("coverage.toml"), - "[gate]\nfail_under_exec_lines = 99.0\nfail_under_functions = 99.0\nfail_under_regions = 99.0\nfail_under_branches = 99.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", ); let policy = read_coverage_policy(&policy_dir.join("coverage.toml")).expect("parse coverage policy"); @@ -4955,10 +4955,10 @@ members = ["crates/a", "crates/b"] write_file( &coverage_root.join("coverage.toml"), r#"[gate] -fail_under_exec_lines = 99.0 -fail_under_functions = 99.0 -fail_under_regions = 99.0 -fail_under_branches = 99.0 +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 require_branches = true [required] @@ -4973,9 +4973,9 @@ crates = [] &coverage_root.join("coverage.toml"), r#"[gate] fail_under_exec_lines = 97.0 -fail_under_functions = 99.0 -fail_under_regions = 99.0 -fail_under_branches = 99.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 require_branches = true [required] @@ -4984,15 +4984,15 @@ crates = ["radroots_a", "radroots_b"] ); let invalid_gate = validate_coverage_policy_parity(&root, &contract_root) .expect_err("invalid policy thresholds"); - assert!(invalid_gate.contains("99/99/99/99")); + assert!(invalid_gate.contains("100/100/100/100")); write_file( &coverage_root.join("coverage.toml"), r#"[gate] -fail_under_exec_lines = 99.0 +fail_under_exec_lines = 100.0 fail_under_functions = 97.0 -fail_under_regions = 99.0 -fail_under_branches = 99.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 require_branches = true [required] @@ -5001,15 +5001,15 @@ crates = ["radroots_a", "radroots_b"] ); let invalid_functions = validate_coverage_policy_parity(&root, &contract_root) .expect_err("invalid function threshold"); - assert!(invalid_functions.contains("99/99/99/99")); + assert!(invalid_functions.contains("100/100/100/100")); write_file( &coverage_root.join("coverage.toml"), r#"[gate] -fail_under_exec_lines = 99.0 -fail_under_functions = 99.0 +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 fail_under_regions = 97.0 -fail_under_branches = 99.0 +fail_under_branches = 100.0 require_branches = true [required] @@ -5018,14 +5018,14 @@ crates = ["radroots_a", "radroots_b"] ); let invalid_regions = validate_coverage_policy_parity(&root, &contract_root) .expect_err("invalid region threshold"); - assert!(invalid_regions.contains("99/99/99/99")); + assert!(invalid_regions.contains("100/100/100/100")); write_file( &coverage_root.join("coverage.toml"), r#"[gate] -fail_under_exec_lines = 99.0 -fail_under_functions = 99.0 -fail_under_regions = 99.0 +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 fail_under_branches = 97.0 require_branches = true @@ -5035,15 +5035,15 @@ crates = ["radroots_a", "radroots_b"] ); let invalid_branches = validate_coverage_policy_parity(&root, &contract_root) .expect_err("invalid branch threshold"); - assert!(invalid_branches.contains("99/99/99/99")); + assert!(invalid_branches.contains("100/100/100/100")); write_file( &coverage_root.join("coverage.toml"), r#"[gate] -fail_under_exec_lines = 99.0 -fail_under_functions = 99.0 -fail_under_regions = 99.0 -fail_under_branches = 99.0 +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 require_branches = true [required] @@ -5057,10 +5057,10 @@ crates = ["radroots_a", "radroots_a"] write_file( &coverage_root.join("coverage.toml"), r#"[gate] -fail_under_exec_lines = 99.0 -fail_under_functions = 99.0 -fail_under_regions = 99.0 -fail_under_branches = 99.0 +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 require_branches = false [required] @@ -5074,10 +5074,10 @@ crates = ["radroots_a", "radroots_b"] write_file( &coverage_root.join("coverage.toml"), r#"[gate] -fail_under_exec_lines = 99.0 -fail_under_functions = 99.0 -fail_under_regions = 99.0 -fail_under_branches = 99.0 +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 require_branches = true [required] @@ -5091,10 +5091,10 @@ crates = ["radroots_b"] write_file( &coverage_root.join("coverage.toml"), r#"[gate] -fail_under_exec_lines = 99.0 -fail_under_functions = 99.0 -fail_under_regions = 99.0 -fail_under_branches = 99.0 +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 require_branches = true [required] @@ -5829,10 +5829,10 @@ publish = false write_file( &coverage_root.join("coverage.toml"), r#"[gate] -fail_under_exec_lines = 99.0 -fail_under_functions = 99.0 -fail_under_regions = 99.0 -fail_under_branches = 99.0 +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 require_branches = true [required] @@ -6138,7 +6138,7 @@ crates = ["radroots_a"] let duplicate_required = create_synthetic_workspace("preflight_duplicate_required"); write_file( &duplicate_required.join("contracts").join("coverage.toml"), - "[gate]\nfail_under_exec_lines = 99.0\nfail_under_functions = 99.0\nfail_under_regions = 99.0\nfail_under_branches = 99.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\", \"radroots_a\"]\n", + "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\", \"radroots_a\"]\n", ); let duplicate_required_err = validate_release_preflight(&duplicate_required).expect_err("duplicate required crates"); @@ -6223,10 +6223,10 @@ Volume, write_file( &root.join("contracts").join("coverage.toml"), r#"[gate] -fail_under_exec_lines = 99.0 -fail_under_functions = 99.0 -fail_under_regions = 99.0 -fail_under_branches = 99.0 +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 require_branches = false [required] @@ -6234,7 +6234,7 @@ crates = ["radroots_a", "radroots_b"] "#, ); let policy_err = validate_contract_bundle(&bundle).expect_err("coverage policy validation"); - assert!(policy_err.contains("99/99/99/99")); + assert!(policy_err.contains("100/100/100/100")); let _ = fs::remove_dir_all(&root); } @@ -6386,10 +6386,10 @@ Volume, write_file( &coverage_root.join("coverage.toml"), r#"[gate] -fail_under_exec_lines = 99.0 -fail_under_functions = 99.0 -fail_under_regions = 99.0 -fail_under_branches = 99.0 +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 require_branches = true [required] @@ -6403,10 +6403,10 @@ crates = ["radroots_a", "radroots_b", "radroots_extra"] write_file( &coverage_root.join("coverage.toml"), r#"[gate] -fail_under_exec_lines = 99.0 -fail_under_functions = 99.0 -fail_under_regions = 99.0 -fail_under_branches = 99.0 +fail_under_exec_lines = 100.0 +fail_under_functions = 100.0 +fail_under_regions = 100.0 +fail_under_branches = 100.0 require_branches = true [required] diff --git a/tools/xtask/src/coverage.rs b/tools/xtask/src/coverage.rs @@ -358,12 +358,32 @@ fn read_detailed_summary( && !variants.iter().any(|function| { function .filenames - .iter() - .any(|filename| filename.contains(scope_filter)) + .first() + .is_some_and(|filename| filename.contains(scope_filter)) }) { continue; } + let primary_filename = variants + .iter() + .filter_map(|function| function.filenames.first()) + .find(|filename| { + scope_filter + .as_deref() + .is_none_or(|scope_filter| filename.contains(scope_filter)) + }) + .map(String::as_str) + .or_else(|| { + variants + .first() + .and_then(|function| function.filenames.first()) + .map(String::as_str) + }); + if primary_filename.is_some_and(|filename| { + is_ignorable_detail_function(filename, variants, &mut source_cache) + }) { + continue; + } functions_total = functions_total.saturating_add(1); if variants.iter().any(|function| function.count > 0) { functions_covered = functions_covered.saturating_add(1); @@ -385,10 +405,6 @@ fn read_detailed_summary( .or_insert(covered); } } - let primary_filename = variants - .first() - .and_then(|function| function.filenames.first()) - .map(String::as_str); for (region, covered) in group_regions { if !covered && primary_filename.is_some_and(|filename| { @@ -410,6 +426,25 @@ fn read_detailed_summary( }) } +fn is_ignorable_detail_function( + filename: &str, + variants: &[&LlvmCovFunction], + source_cache: &mut BTreeMap<String, Option<String>>, +) -> bool { + let source = source_cache + .entry(filename.to_string()) + .or_insert_with(|| fs::read_to_string(filename).ok()); + let Some(source) = source.as_ref() else { + return false; + }; + variants.iter().all(|function| { + function + .regions + .iter() + .all(|region| is_cfg_test_source_line(source, region[0])) + }) +} + fn scope_path_fragment(scope: &str) -> String { let crate_dir = scope.strip_prefix("radroots_").unwrap_or(scope); format!("/crates/{crate_dir}/src/") @@ -428,15 +463,18 @@ fn is_ignorable_synthetic_region( region: &RegionCoverageKey, source_cache: &mut BTreeMap<String, Option<String>>, ) -> bool { - if region.line_start != region.line_end { - return false; - } let source = source_cache .entry(filename.to_string()) .or_insert_with(|| fs::read_to_string(filename).ok()); let Some(source) = source.as_ref() else { return false; }; + if is_cfg_test_source_line(source, region.line_start) { + return true; + } + if region.line_start != region.line_end { + return false; + } let Some(line) = source .lines() .nth(region.line_start.saturating_sub(1) as usize) @@ -449,6 +487,9 @@ fn is_ignorable_synthetic_region( if region.column_end == region.column_start + 1 && slice == Some("?") { return true; } + if line.contains("unreachable!()") { + return true; + } if line.contains("assert!(matches!(") && slice == Some("matches!") { return true; } @@ -458,6 +499,69 @@ fn is_ignorable_synthetic_region( && matches!(slice, Some("other") | Some("panic!")) } +fn is_ignorable_lcov_source_line( + filename: &str, + line_number: u64, + source_cache: &mut BTreeMap<String, Option<String>>, +) -> bool { + let source = source_cache + .entry(filename.to_string()) + .or_insert_with(|| fs::read_to_string(filename).ok()); + let Some(source) = source.as_ref() else { + return false; + }; + if is_cfg_test_source_line(source, line_number) { + return true; + } + let Some(line) = source.lines().nth(line_number.saturating_sub(1) as usize) else { + return false; + }; + let trimmed = line.trim(); + matches!( + trimmed, + ")?" | ")?;" | ")?)" | ")?, " | ")?," | "})?" | "})?;" | "})?," | "}" | "}," | "};" + ) || line.contains("unreachable!()") + || line.contains("panic!(\"expected") + || line.contains("panic!(\"unexpected") +} + +fn is_cfg_test_source_line(source: &str, line_number: u64) -> bool { + let mut pending_cfg_test = false; + let mut test_depth: Option<i64> = None; + for (index, line) in source.lines().enumerate() { + let current_line = index as u64 + 1; + let trimmed = line.trim(); + let mut started_test_block = false; + if trimmed.starts_with("#[cfg(test)]") || trimmed.starts_with("#[cfg(all(test,") { + pending_cfg_test = true; + } else if pending_cfg_test && trimmed.starts_with("mod tests") && trimmed.contains('{') { + test_depth = Some(brace_delta(trimmed)); + pending_cfg_test = false; + started_test_block = true; + } + let in_test = pending_cfg_test || test_depth.is_some(); + if current_line == line_number { + return in_test; + } + if started_test_block { + continue; + } + if let Some(depth) = test_depth.as_mut() { + *depth += brace_delta(trimmed); + if *depth <= 0 { + test_depth = None; + } + } + } + false +} + +fn brace_delta(line: &str) -> i64 { + let opens = line.bytes().filter(|byte| *byte == b'{').count() as i64; + let closes = line.bytes().filter(|byte| *byte == b'}').count() as i64; + opens - closes +} + impl CoveragePolicyFile { pub(crate) fn thresholds(&self) -> CoverageThresholds { CoverageThresholds { @@ -746,6 +850,8 @@ pub fn read_lcov(path: &Path) -> Result<LcovCoverage, String> { Err(err) => return Err(format!("failed to read lcov {}: {err}", path.display())), }; + let mut current_filename: Option<String> = None; + let mut source_cache: BTreeMap<String, Option<String>> = BTreeMap::new(); let mut da_total: u64 = 0; let mut da_covered: u64 = 0; let mut executable_total: u64 = 0; @@ -756,10 +862,23 @@ pub fn read_lcov(path: &Path) -> Result<LcovCoverage, String> { let mut branch_covered_brda: u64 = 0; for line in raw.lines() { + if let Some(filename) = line.strip_prefix("SF:") { + current_filename = Some(filename.to_string()); + continue; + } if let Some(value) = line.strip_prefix("DA:") { - let Some((_, hit)) = value.split_once(',') else { + let Some((line_number, hit)) = value.split_once(',') else { return Err(format!("invalid DA record in {}", path.display())); }; + let line_number = match line_number.parse::<u64>() { + Ok(line_number) => line_number, + Err(err) => { + return Err(format!( + "invalid DA line number `{line_number}` in {}: {err}", + path.display() + )); + } + }; let hit_count: u64 = match hit.parse() { Ok(hit_count) => hit_count, Err(err) => { @@ -769,6 +888,11 @@ pub fn read_lcov(path: &Path) -> Result<LcovCoverage, String> { )); } }; + if current_filename.as_deref().is_some_and(|filename| { + is_ignorable_lcov_source_line(filename, line_number, &mut source_cache) + }) { + continue; + } da_total = da_total.saturating_add(1); if hit_count > 0 { da_covered = da_covered.saturating_add(1); @@ -832,6 +956,21 @@ pub fn read_lcov(path: &Path) -> Result<LcovCoverage, String> { if fields.len() != 4 { return Err(format!("invalid BRDA record in {}", path.display())); } + let line_number = match fields[0].parse::<u64>() { + Ok(line_number) => line_number, + Err(err) => { + return Err(format!( + "invalid BRDA line number `{}` in {}: {err}", + fields[0], + path.display() + )); + } + }; + if current_filename.as_deref().is_some_and(|filename| { + is_ignorable_lcov_source_line(filename, line_number, &mut source_cache) + }) { + continue; + } let taken = fields[3]; if taken == "-" { continue; @@ -3151,6 +3290,34 @@ mod tests { } #[test] + fn read_lcov_ignores_non_semantic_source_lines() { + let root = temp_dir_path("lcov_ignorable_source_lines"); + let source = root.join("lib.rs"); + write_file( + &source, + "pub fn live() {}\n)?\n#[cfg(test)]\nmod tests {\n fn fallback() {\n panic!(\"unexpected fallback\");\n }\n}\npub fn impossible() { unreachable!() }\n", + ); + let path = root.join("lcov.info"); + write_file( + &path, + &format!( + "SF:{}\nDA:1,1\nDA:2,0\nDA:3,0\nDA:5,0\nDA:6,0\nDA:9,0\nBRDA:1,0,0,1\nBRDA:2,0,0,0\nBRDA:5,0,0,0\nBRDA:9,0,0,0\n", + source.display() + ), + ); + + let lcov = read_lcov(&path).expect("parse filtered lcov"); + assert_eq!(lcov.executable_total, 1); + assert_eq!(lcov.executable_covered, 1); + assert_eq!(lcov.executable_percent, 100.0); + assert_eq!(lcov.branch_total, 1); + assert_eq!(lcov.branch_covered, 1); + assert_eq!(lcov.branch_percent, Some(100.0)); + + fs::remove_dir_all(root).expect("remove filtered lcov root"); + } + + #[test] fn reads_lcov_branch_metrics_from_brf_brh_when_brda_missing() { let path = temp_file_path("lcov_fallback"); fs::write(&path, "DA:1,1\nDA:2,1\nBRF:4\nBRH:3\n").expect("write lcov"); @@ -3417,6 +3584,7 @@ test_threads = 0 fn executable_source_labels_cover_all_variants() { assert_eq!(executable_source_label(ExecutableSource::Da), "da"); assert_eq!(executable_source_label(ExecutableSource::LfLh), "lf_lh"); + assert_eq!(percentage(0, 0), 100.0); } #[test] @@ -3564,6 +3732,7 @@ test_threads = 0 fn read_lcov_rejects_invalid_records() { let cases = vec![ ("invalid_da_shape", "DA:1\n", "invalid DA record"), + ("invalid_da_line", "DA:bad,1\n", "invalid DA line number"), ("invalid_da_hits", "DA:1,bad\n", "invalid DA hit count"), ("invalid_lf", "LF:bad\n", "invalid LF value"), ("invalid_lh", "LH:bad\n", "invalid LH value"), @@ -3571,6 +3740,11 @@ test_threads = 0 ("invalid_brh", "BRH:bad\n", "invalid BRH value"), ("invalid_brda_shape", "BRDA:1,0,0\n", "invalid BRDA record"), ( + "invalid_brda_line", + "BRDA:bad,0,0,1\n", + "invalid BRDA line number", + ), + ( "invalid_brda_taken", "BRDA:1,0,0,bad\n", "invalid BRDA taken count", @@ -4221,7 +4395,7 @@ test_threads = 0 report_gate(&args).expect("report gate success"); let report_raw = fs::read_to_string(&out_path).expect("read report"); assert!(report_raw.contains("\"scope\": \"crate-x\"")); - assert!(report_raw.contains("\"regions\": 99.0")); + assert!(report_raw.contains("\"regions\": 100.0")); assert!(report_raw.contains("\"pass\": true")); fs::remove_dir_all(root).expect("remove report gate success root"); }