diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/Cargo.toml b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/Cargo.toml index 71fc842e6..baae8d262 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/Cargo.toml +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/Cargo.toml @@ -18,6 +18,4 @@ serde_json = { workspace = true } thiserror.workspace = true chrono = { workspace = true } blake3 = { workspace = true } - -[dev-dependencies] ed25519-dalek = { workspace = true } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs index edb4b8dd4..9ea617b23 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs @@ -17,6 +17,11 @@ //! warning, notification and final report as a hash-linked filing chain, //! each filing a leaf in a Merkle log with an inclusion-proof receipt, and //! each stage's statutory deadline read off the chain. See [`cra`]. +//! - **EU Product Liability Directive disclosure** — one contiguous range of a +//! transparency log disclosed as a bundle, every record in it disclosed or +//! withheld on a stated ground, each proven against a signed head, with a +//! crosswalk from its records to the provisions they bear on. See +//! [`product_liability`]. //! //! Every passport links a [`passport::MeasurementMethod`] to a //! [`jouleclaw_energy::Provenance`] tag — measurement honesty travels @@ -45,6 +50,7 @@ pub mod banding; pub mod passport; pub mod eu_compliance; pub mod cra; +pub mod product_liability; pub use cra::{ Art14Subject, ChainError, DeadlineState, FiledReceipt, Filing, FilingChain, FilingStage, diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/product_liability.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/product_liability.rs new file mode 100644 index 000000000..f3103c98a --- /dev/null +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/product_liability.rs @@ -0,0 +1,758 @@ +//! EU Product Liability Directive: a scoped disclosure bundle over a +//! transparency log, and a crosswalk from its records to the provisions they +//! bear on. +//! +//! Directive (EU) 2024/2853 applies to products placed on the market or put +//! into service after 9 December 2026 (Art. 2(1)). Where a claimant has +//! presented facts and evidence sufficient to support the plausibility of a +//! claim, a national court may require the defendant to disclose relevant +//! evidence at its disposal (Art. 9(1)). Disclosure is limited to what is +//! necessary and proportionate (Art. 9(3)). Courts weigh the legitimate +//! interests of all parties, in particular the protection of confidential +//! information and trade secrets (Art. 9(4)), and may take measures to +//! preserve a trade secret's confidentiality (Art. 9(5)). They may require +//! evidence to be presented in an easily accessible and easily understandable +//! manner (Art. 9(6)). A defendant that fails to disclose relevant evidence +//! under Art. 9(1) is presumed to have supplied a defective product (Art. +//! 10(2)(a)). +//! +//! ## The bundle +//! +//! A [`DisclosureBundle`] discloses one contiguous range of an append-only +//! Merkle log whose leaves are the BLAKE3 content hashes of records. It holds +//! every record in the range, and leaves none out. Each [`Member`] is either +//! disclosed, with its bytes, or withheld, with its content hash and the +//! [`Ground`] it is withheld on. Each carries an inclusion proof against a +//! signed tree head. +//! +//! [`DisclosureBundle::verify`] establishes, from the bundle and the log's +//! public key alone: +//! +//! - the head is signed by the log's key; +//! - the members are the range exactly: every index from `start` to `end`, in +//! order, none missing and none repeated; +//! - each member is the leaf at its index: disclosed bytes hash to it, and a +//! withheld member's hash is it; +//! - where the verifying party holds an earlier signed head, the log grew from +//! it only by appending. +//! +//! A bundle therefore cannot leave a record in its scope out unseen. A record +//! not disclosed is listed as withheld, under a stated ground, and its content +//! hash fixes what a later order to disclose it must produce +//! ([`Member::opened_by`]). +//! +//! It does not establish that the scope is the one a court would set (Art. +//! 9(3) is the court's call), that a withheld record is in fact a trade +//! secret, or anything about defectiveness. +//! +//! ## The crosswalk +//! +//! [`crosswalk`] lists the provisions such records bear on, and which kinds of +//! record bear on each. It is a reading aid. It says what a party can produce +//! and check, and not what follows from it: the defendant may rebut any of +//! the presumptions (Art. 10(5)), and whether any record establishes or rebuts +//! one is for the court. + +use std::fmt::Write as _; + +use ed25519_dalek::{SigningKey, VerifyingKey}; +use jouleclaw_transparency_log::{ + ConsistencyProof, InclusionProof, LogError, MerkleTree, SignedTreeHead, TreeHead, +}; +use serde::{Deserialize, Serialize}; + +use crate::cra::FilingChain; + +/// The day from which the Directive applies to products placed on the market +/// or put into service (Art. 2(1): products placed after 9 December 2026). +pub const APPLIES_TO_PRODUCTS_PLACED_AFTER: &str = "2026-12-09"; + +/// What a record is, as its producer labels it. The label is the producer's +/// statement; a bundle verifies the record's bytes, not its label. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum EvidenceKind { + /// A signed record of one unit of work: what was asked, on what, under + /// which authority, at what cost. + WorkReceipt, + /// An output with what opens its receipt's commitment to it. + OutputDisclosure, + /// The result of re-executing a recorded unit of work. + ReplayVerdict, + /// An entry of a model change record: a change of weights, or a plan for + /// changes recorded before them. + ModelChange, + /// A filing under Regulation (EU) 2024/2847, Art. 14 (see [`crate::cra`]). + VulnerabilityOrIncidentFiling, + /// A conformance or validation result. + ConformanceResult, + /// The grant or policy under which work was authorised. + Authority, + /// Anything else, as the producer names it. + Other(String), +} + +impl EvidenceKind { + /// The kind in plain words. + pub fn describe(&self) -> &str { + match self { + Self::WorkReceipt => "work receipt", + Self::OutputDisclosure => "output disclosure", + Self::ReplayVerdict => "replay verdict", + Self::ModelChange => "model change record", + Self::VulnerabilityOrIncidentFiling => "vulnerability or incident filing", + Self::ConformanceResult => "conformance result", + Self::Authority => "authority", + Self::Other(s) => s, + } + } +} + +/// Why a record in a bundle's scope is withheld rather than disclosed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Ground { + /// A trade secret within Art. 4(17) (Directive (EU) 2016/943, Art. 2(1)), + /// for the court to weigh under Art. 9(4) and protect under Art. 9(5). + TradeSecret, + /// Other confidential information, for the court to weigh under Art. 9(4). + Confidential, + /// The legitimate interests of a third party, such as another person's + /// records, for the court to weigh under Art. 9(4). + ThirdParty, + /// Beyond what is necessary and proportionate to the claim (Art. 9(3)). + NotNecessary, +} + +impl Ground { + /// The ground in plain words, with the provision it rests on. + pub fn describe(self) -> &'static str { + match self { + Self::TradeSecret => "trade secret (Art. 9(4), 9(5))", + Self::Confidential => "confidential information (Art. 9(4))", + Self::ThirdParty => "a third party's interests (Art. 9(4))", + Self::NotNecessary => "not necessary and proportionate (Art. 9(3))", + } + } +} + +/// A member's content: the record itself, or what pins it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Content { + /// The record's bytes. + Disclosed(Vec), + /// The record's BLAKE3 content hash, and why it is withheld. + Withheld { + /// The BLAKE3 hash of the withheld record's bytes: the log's leaf. + content_hash: [u8; 32], + /// Why it is withheld. + ground: Ground, + }, +} + +/// One record of a bundle's range. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Member { + /// Its leaf index in the log. + pub index: u64, + /// What it is, as its producer labels it. + pub kind: EvidenceKind, + /// A plain description of it (Art. 9(6)). + pub label: String, + /// The record, or what pins it. + pub content: Content, + /// That its content hash is the leaf at `index` under the bundle's head. + pub proof: InclusionProof, +} + +impl Member { + /// The content hash this member stands for: the log's leaf at its index. + pub fn content_hash(&self) -> [u8; 32] { + match &self.content { + Content::Disclosed(bytes) => *blake3::hash(bytes).as_bytes(), + Content::Withheld { content_hash, .. } => *content_hash, + } + } + + /// Whether `bytes` are the record this member stands for: how bytes + /// produced later, under an order to disclose a withheld record, are + /// checked against the bundle that withheld them. + pub fn opened_by(&self, bytes: &[u8]) -> bool { + blake3::hash(bytes).as_bytes() == &self.content_hash() + } + + /// The ground it is withheld on, or `None` when it is disclosed. + pub fn withheld_on(&self) -> Option { + match self.content { + Content::Disclosed(_) => None, + Content::Withheld { ground, .. } => Some(ground), + } + } +} + +/// A member as its producer offers it, before it has a proof. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Offer { + /// What it is. + pub kind: EvidenceKind, + /// A plain description of it. + pub label: String, + /// The record, or what pins it. + pub content: Content, +} + +/// A log a bundle can be cut from: one whose leaves are the BLAKE3 content +/// hashes of its records. +pub trait ProvableLog { + /// The log's head now, for its operator to sign. + fn head(&self, timestamp_unix: u64) -> TreeHead; + /// A proof that leaf `index` is in the log under its current root. + fn inclusion_proof(&self, index: u64) -> Result; + /// A proof that the log as it stood at `old_size` is a prefix of it now. + fn consistency_proof(&self, old_size: u64) -> Result; +} + +impl ProvableLog for MerkleTree { + fn head(&self, timestamp_unix: u64) -> TreeHead { + TreeHead::of(self, timestamp_unix) + } + fn inclusion_proof(&self, index: u64) -> Result { + MerkleTree::inclusion_proof(self, index) + } + fn consistency_proof(&self, old_size: u64) -> Result { + MerkleTree::consistency_proof(self, old_size) + } +} + +impl ProvableLog for FilingChain { + fn head(&self, timestamp_unix: u64) -> TreeHead { + self.tree_head(timestamp_unix) + } + fn inclusion_proof(&self, index: u64) -> Result { + FilingChain::inclusion_proof(self, index) + } + fn consistency_proof(&self, old_size: u64) -> Result { + FilingChain::consistency_proof(self, old_size) + } +} + +/// Why a bundle cannot be cut, or does not verify. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DisclosureError { + /// The bundle's head does not verify under the log's key. + #[error("the tree head does not verify: {0}")] + Head(String), + /// The range is empty or runs past the log. + #[error("the range {start}..{end} is not a non-empty range of a log of {size} leaves")] + Range { + /// First leaf of the range. + start: u64, + /// One past the last leaf. + end: u64, + /// The log's size at the head. + size: u64, + }, + /// The bundle does not hold one member per leaf of the range. + #[error("the bundle holds {found} members for a range of {expected} leaves")] + Count { + /// Leaves in the range. + expected: u64, + /// Members held. + found: usize, + }, + /// A member is not the leaf the range has at its position: one is + /// missing, repeated or out of order. + #[error("member {position} is leaf {found}, where the range has leaf {expected}")] + NotTheRange { + /// Its position in the bundle. + position: usize, + /// The leaf the range has there. + expected: u64, + /// The leaf it claims to be. + found: u64, + }, + /// A member's content is not the leaf at its index under the head. + #[error("leaf {index}: not the leaf under the head: {detail}")] + NotIncluded { + /// The member's leaf index. + index: u64, + /// Why. + detail: String, + }, + /// The earlier head the verifying party holds does not verify. + #[error("the earlier head does not verify: {0}")] + EarlierHead(String), + /// The verifying party holds an earlier head, and the bundle carries no + /// proof that the log grew from it. + #[error("the bundle carries no proof that the log grew from the earlier head")] + NoExtension, + /// The log did not grow from the earlier head by appending alone. + #[error("the log did not grow from the earlier head by appending: {0}")] + NotExtended(String), +} + +/// One contiguous range of a log, every record in it disclosed or withheld, +/// each proven against one signed head (see the module docs). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DisclosureBundle { + /// What the disclosure answers, such as a court's order, in the + /// producer's words. + pub answers: String, + /// The range's first leaf. + pub start: u64, + /// One past the range's last leaf. + pub end: u64, + /// The log's signed head the members are proven against. + pub head: SignedTreeHead, + /// One member per leaf of the range, in order. + pub members: Vec, + /// A proof that the log at `head` extends it as it stood at an earlier + /// size, for a party that holds the head of that size. + pub extends: Option, +} + +impl DisclosureBundle { + /// Cut a bundle for the leaves `range` of `log`, one offer per leaf in + /// order, under a head signed now by `log_key`, the log operator's key. + /// With `extends_from`, it also carries a proof that the log grew from its + /// state at that size. + /// + /// The bundle is verified before it is returned, so an offer that is not + /// the leaf at its index is refused here rather than found later. + pub fn cut( + log: &impl ProvableLog, + answers: impl Into, + range: std::ops::Range, + offers: Vec, + log_key: &SigningKey, + timestamp_unix: u64, + extends_from: Option, + ) -> Result { + let (start, end) = (range.start, range.end); + let head = log.head(timestamp_unix); + let size = head.size; + if start >= end || end > size { + return Err(DisclosureError::Range { start, end, size }); + } + if offers.len() as u64 != end - start { + return Err(DisclosureError::Count { expected: end - start, found: offers.len() }); + } + let members = (start..end) + .zip(offers) + .map(|(index, o)| { + let proof = log + .inclusion_proof(index) + .map_err(|e| DisclosureError::NotIncluded { index, detail: e.to_string() })?; + Ok(Member { index, kind: o.kind, label: o.label, content: o.content, proof }) + }) + .collect::, DisclosureError>>()?; + let extends = extends_from + .map(|m| log.consistency_proof(m).map_err(|e| DisclosureError::NotExtended(e.to_string()))) + .transpose()?; + let head = head.sign(log_key).map_err(|e| DisclosureError::Head(e.to_string()))?; + let bundle = Self { answers: answers.into(), start, end, head, members, extends }; + bundle.verify(&log_key.verifying_key(), None)?; + Ok(bundle) + } + + /// Verify the bundle against the log's key and, where the verifying party + /// holds one, an earlier signed head of the same log (see the module + /// docs for what this establishes, and what it does not). + pub fn verify(&self, log_key: &VerifyingKey, held: Option<&SignedTreeHead>) -> Result<(), DisclosureError> { + let root = self.head.verify(log_key).map_err(|e| DisclosureError::Head(e.to_string()))?; + let size = self.head.head.size; + if self.start >= self.end || self.end > size { + return Err(DisclosureError::Range { start: self.start, end: self.end, size }); + } + if self.members.len() as u64 != self.end - self.start { + return Err(DisclosureError::Count { expected: self.end - self.start, found: self.members.len() }); + } + for (position, (m, expected)) in self.members.iter().zip(self.start..self.end).enumerate() { + if m.index != expected { + return Err(DisclosureError::NotTheRange { position, expected, found: m.index }); + } + if m.proof.leaf_index != m.index || m.proof.tree_size != size { + return Err(DisclosureError::NotIncluded { + index: m.index, + detail: format!("its proof is for leaf {} of {}", m.proof.leaf_index, m.proof.tree_size), + }); + } + m.proof + .verify(&m.content_hash(), &root) + .map_err(|e| DisclosureError::NotIncluded { index: m.index, detail: e.to_string() })?; + } + if let Some(earlier) = held { + let old_root = earlier.verify(log_key).map_err(|e| DisclosureError::EarlierHead(e.to_string()))?; + let proof = self.extends.as_ref().ok_or(DisclosureError::NoExtension)?; + if proof.old_size != earlier.head.size || proof.new_size != size { + return Err(DisclosureError::NotExtended(format!( + "the proof runs from {} to {}, the heads from {} to {}", + proof.old_size, proof.new_size, earlier.head.size, size + ))); + } + proof.verify(&old_root, &root).map_err(|e| DisclosureError::NotExtended(e.to_string()))?; + } + Ok(()) + } + + /// The members withheld, with their grounds. + pub fn withheld(&self) -> Vec<(u64, Ground)> { + self.members.iter().filter_map(|m| m.withheld_on().map(|g| (m.index, g))).collect() + } + + /// The leaf indices of the members that bear on `provision`: every member + /// for a provision that bears on the bundle as a whole. + pub fn bearing_on(&self, provision: &Provision) -> Vec { + self.members + .iter() + .filter(|m| provision.kinds.is_empty() || provision.kinds.contains(&m.kind)) + .map(|m| m.index) + .collect() + } + + /// A plain index of the bundle, one line per member, for presenting it in + /// an easily accessible and easily understandable manner (Art. 9(6)). + pub fn plain_index(&self) -> String { + let mut out = format!( + "{}\nLeaves {} to {} of a log of {}, every one listed.\n", + self.answers, + self.start, + self.end - 1, + self.head.head.size + ); + for m in &self.members { + let state = m.withheld_on().map_or_else(|| "disclosed".to_string(), |g| format!("withheld: {}", g.describe())); + let _ = writeln!(out, "{:>6} {} — {} — {}", m.index, m.kind.describe(), m.label, state); + } + out + } +} + +/// A provision of Directive (EU) 2024/2853 that records in a bundle bear on. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Provision { + /// Where it is, such as `Art. 10(2)(a)`. + pub article: &'static str, + /// What it provides, paraphrased. + pub provides: &'static str, + /// What a bundle can carry that bears on it. + pub bears: &'static str, + /// The kinds of record that carry it. Empty: the bundle as a whole. + pub kinds: &'static [EvidenceKind], +} + +const CROSSWALK: &[Provision] = &[ + Provision { + article: "Art. 10(2)(a)", + provides: "Defectiveness is presumed where the defendant fails to disclose relevant evidence under Art. 9(1).", + bears: "The bundle as a whole. Every record in its scope is disclosed or listed as withheld, with its ground, \ + and a withheld record's content hash fixes what a later order to disclose it must produce. Nothing in \ + the scope can be left out unseen.", + kinds: &[], + }, + Provision { + article: "Art. 10(2)(b), with Art. 7(2)(f)", + provides: "Defectiveness is presumed where the claimant shows the product does not comply with mandatory product \ + safety requirements, including safety-relevant cybersecurity requirements, that protect against the \ + risk of the damage.", + bears: "Filings under Regulation (EU) 2024/2847, Art. 14, and conformance or validation results: what was \ + reported and when, and what was tested.", + kinds: &[EvidenceKind::VulnerabilityOrIncidentFiling, EvidenceKind::ConformanceResult], + }, + Provision { + article: "Art. 10(2)(c)", + provides: "Defectiveness is presumed where the claimant shows the damage was caused by an obvious malfunction \ + during reasonably foreseeable use or under ordinary circumstances.", + bears: "Work receipts, output disclosures and the authority work ran under: what was asked, on which model, \ + with what permission, at what cost, and what came back, checked against the receipt's commitment to it.", + kinds: &[EvidenceKind::WorkReceipt, EvidenceKind::OutputDisclosure, EvidenceKind::Authority], + }, + Provision { + article: "Art. 10(3)", + provides: "The causal link is presumed where the product is defective and the damage is of a kind typically \ + consistent with the defect.", + bears: "Receipts that bind a request to its output and to the effect it had, so the record shows which output \ + led to which effect.", + kinds: &[EvidenceKind::WorkReceipt, EvidenceKind::OutputDisclosure], + }, + Provision { + article: "Art. 10(4)", + provides: "Defectiveness, the causal link or both are presumed where, despite disclosure, the claimant faces \ + excessive difficulties, in particular through technical or scientific complexity, and shows they \ + are likely.", + bears: "Replay verdicts, and the records a party needs to re-execute a deterministic unit of work itself, so an \ + account of what the product did need not rest on either party's word.", + kinds: &[EvidenceKind::ReplayVerdict, EvidenceKind::WorkReceipt, EvidenceKind::OutputDisclosure], + }, + Provision { + article: "Art. 11(2)(b) to (d), with Art. 4(18) and Art. 7(2)(c), (e)", + provides: "No exemption under Art. 11(1)(c), that the defect did not exist when the product was placed on the \ + market, where the defect is due to software, including updates or upgrades, to a lack of updates \ + needed to maintain safety, or to a substantial modification, within the manufacturer's control. A \ + modification is substantial under Art. 4(18)(b) where, among other things, it was not foreseen in \ + the manufacturer's initial risk assessment.", + bears: "Model change records: which weights served when, from which change, and whether a plan recorded before \ + the change foresaw it.", + kinds: &[EvidenceKind::ModelChange], + }, +]; + +/// The provisions records in a bundle bear on (see the module docs). +pub fn crosswalk() -> &'static [Provision] { + CROSSWALK +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cra::{Art14Subject, Filing, FilingStage, ProductStatement, ProductStatus}; + use chrono::TimeZone as _; + + fn operator() -> SigningKey { + SigningKey::from_bytes(&[11u8; 32]) + } + + fn record(i: u64) -> Vec { + format!("record {i}").into_bytes() + } + + /// A log of `n` records, each leaf the BLAKE3 of the record's bytes. + fn log_of(n: u64) -> MerkleTree { + let mut t = MerkleTree::new(); + for i in 0..n { + t.append(blake3::hash(&record(i)).as_bytes().to_vec()); + } + t + } + + fn disclosed(i: u64) -> Offer { + Offer { kind: EvidenceKind::WorkReceipt, label: format!("serve {i}"), content: Content::Disclosed(record(i)) } + } + + fn withheld(i: u64, ground: Ground) -> Offer { + Offer { + kind: EvidenceKind::ModelChange, + label: format!("weights change {i}"), + content: Content::Withheld { content_hash: *blake3::hash(&record(i)).as_bytes(), ground }, + } + } + + /// Leaves 3..7 of a 10-leaf log: two disclosed, one withheld as a trade + /// secret, one disclosed. + fn bundle() -> DisclosureBundle { + let offers = vec![disclosed(3), disclosed(4), withheld(5, Ground::TradeSecret), disclosed(6)]; + DisclosureBundle::cut(&log_of(10), "order 2027/123", 3..7, offers, &operator(), 1_800_000_000, None).unwrap() + } + + /// A bundle cut from a log verifies from the log's key alone, lists what + /// it withholds, and a withheld record produced later opens its member. + #[test] + fn a_bundle_verifies_and_pins_what_it_withholds() { + let b = bundle(); + b.verify(&operator().verifying_key(), None).unwrap(); + assert_eq!(b.withheld(), vec![(5, Ground::TradeSecret)]); + let withheld = &b.members[2]; + assert!(withheld.opened_by(&record(5)), "the record ordered disclosed later is the one it withheld"); + assert!(!withheld.opened_by(b"another record")); + let index = b.plain_index(); + assert!(index.contains("Leaves 3 to 6 of a log of 10, every one listed.")); + assert!(index.contains("withheld: trade secret (Art. 9(4), 9(5))")); + assert_eq!(index.lines().count(), 2 + 4, "a line per member"); + } + + /// A record in scope cannot be left out unseen: dropping, repeating or + /// reordering a member, or narrowing the range under it, does not verify. + #[test] + fn a_bundle_cannot_leave_a_record_out_unseen() { + let vk = operator().verifying_key(); + let b = bundle(); + + let mut dropped = b.clone(); + dropped.members.remove(1); + assert!(matches!(dropped.verify(&vk, None), Err(DisclosureError::Count { expected: 4, found: 3 }))); + + let mut dropped_and_narrowed = dropped.clone(); + dropped_and_narrowed.end = 6; + assert!(matches!( + dropped_and_narrowed.verify(&vk, None), + Err(DisclosureError::NotTheRange { position: 1, expected: 4, found: 5 }) + )); + + let mut repeated = b.clone(); + repeated.members[1] = repeated.members[0].clone(); + assert!(matches!(repeated.verify(&vk, None), Err(DisclosureError::NotTheRange { position: 1, .. }))); + + let mut reordered = b.clone(); + reordered.members.swap(1, 2); + assert!(matches!(reordered.verify(&vk, None), Err(DisclosureError::NotTheRange { position: 1, .. }))); + + let mut past_the_log = b.clone(); + past_the_log.end = 11; + assert!(matches!(past_the_log.verify(&vk, None), Err(DisclosureError::Range { .. }))); + let mut empty = b.clone(); + (empty.start, empty.end, empty.members) = (3, 3, vec![]); + assert!(matches!(empty.verify(&vk, None), Err(DisclosureError::Range { .. }))); + } + + /// A member is the leaf at its index: altered bytes, a substituted hash, + /// or a proof for another leaf or another tree do not verify, and neither + /// does a head another key signed. + #[test] + fn a_member_is_the_leaf_at_its_index() { + let vk = operator().verifying_key(); + let b = bundle(); + + let mut altered = b.clone(); + altered.members[0].content = Content::Disclosed(b"record 3, amended".to_vec()); + assert!(matches!(altered.verify(&vk, None), Err(DisclosureError::NotIncluded { index: 3, .. }))); + + let mut substituted = b.clone(); + substituted.members[2].content = + Content::Withheld { content_hash: *blake3::hash(b"something else").as_bytes(), ground: Ground::TradeSecret }; + assert!(matches!(substituted.verify(&vk, None), Err(DisclosureError::NotIncluded { index: 5, .. }))); + + // Leaf 3, content and proof, presented as leaf 4: its proof holds, for + // another leaf, and leaf 4 would be missing unseen. + let mut relabeled = b.clone(); + relabeled.members[1].content = b.members[0].content.clone(); + relabeled.members[1].proof = b.members[0].proof.clone(); + assert!(matches!(relabeled.verify(&vk, None), Err(DisclosureError::NotIncluded { index: 4, .. }))); + + let mut borrowed = b.clone(); + borrowed.members[1].proof = b.members[0].proof.clone(); + assert!(matches!(borrowed.verify(&vk, None), Err(DisclosureError::NotIncluded { index: 4, .. }))); + + let mut other_tree = b.clone(); + other_tree.members[1].proof = log_of(12).inclusion_proof(4).unwrap(); + assert!(matches!(other_tree.verify(&vk, None), Err(DisclosureError::NotIncluded { index: 4, .. }))); + + let stranger = SigningKey::from_bytes(&[12u8; 32]).verifying_key(); + assert!(matches!(b.verify(&stranger, None), Err(DisclosureError::Head(_)))); + } + + /// A proof is read against the head's own tree size. Under a 3-leaf root, + /// leaf 2 has a valid proof as "leaf 1 of a 2-leaf tree", because the + /// 3-leaf root is H(H(leaf 0, leaf 1), leaf 2). Taken at its word, that + /// proof would let leaf 2's record stand in for leaf 1's, and leaf 1 would + /// go missing unseen. + #[test] + fn a_proof_is_read_at_the_heads_size() { + let three = log_of(3); + let b = DisclosureBundle::cut(&three, "order", 0..3, vec![disclosed(0), disclosed(1), disclosed(2)], &operator(), 0, None) + .unwrap(); + let lh = |i: u64| jouleclaw_transparency_log::leaf_hash(blake3::hash(&record(i)).as_bytes()); + let crafted = InclusionProof { + leaf_index: 1, + tree_size: 2, + audit_path: vec![jouleclaw_transparency_log::node_hash(&lh(0), &lh(1))], + }; + assert!( + crafted.verify(blake3::hash(&record(2)).as_bytes(), &three.root()).is_ok(), + "the crafted proof is valid against the root" + ); + let mut stand_in = b.clone(); + stand_in.members[1].content = Content::Disclosed(record(2)); + stand_in.members[1].proof = crafted; + let refused = stand_in.verify(&operator().verifying_key(), None); + assert!(matches!(refused, Err(DisclosureError::NotIncluded { index: 1, .. }))); + } + + /// A party holding an earlier head of the log checks that the log grew + /// from it by appending alone, and a bundle that cannot show it is refused. + #[test] + fn a_bundle_shows_the_log_grew_from_a_head_the_party_holds() { + let vk = operator().verifying_key(); + let earlier = log_of(5).head(1_700_000_000).sign(&operator()).unwrap(); + let offers = vec![disclosed(6), disclosed(7)]; + let b = DisclosureBundle::cut(&log_of(10), "order", 6..8, offers.clone(), &operator(), 1_800_000_000, Some(5)) + .unwrap(); + b.verify(&vk, Some(&earlier)).unwrap(); + + let without = DisclosureBundle::cut(&log_of(10), "order", 6..8, offers.clone(), &operator(), 1_800_000_000, None) + .unwrap(); + assert!(matches!(without.verify(&vk, Some(&earlier)), Err(DisclosureError::NoExtension))); + + // The party held a head of a log that was later rewritten. + let mut rewritten = MerkleTree::new(); + for i in 0..5 { + rewritten.append(blake3::hash(format!("rewritten {i}").as_bytes()).as_bytes().to_vec()); + } + let rewritten_head = rewritten.head(1_700_000_000).sign(&operator()).unwrap(); + assert!(matches!(b.verify(&vk, Some(&rewritten_head)), Err(DisclosureError::NotExtended(_)))); + + let other_size = log_of(4).head(1_700_000_000).sign(&operator()).unwrap(); + let refused = b.verify(&vk, Some(&other_size)); + assert!(matches!(refused, Err(DisclosureError::NotExtended(d)) if d.contains("runs from 5 to 10, the heads from 4"))); + + let foreign = log_of(5).head(1_700_000_000).sign(&SigningKey::from_bytes(&[12u8; 32])).unwrap(); + assert!(matches!(b.verify(&vk, Some(&foreign)), Err(DisclosureError::EarlierHead(_)))); + } + + /// A producer cannot cut a bundle whose offers are not the log's records, + /// nor one that does not cover its range. + #[test] + fn a_bundle_is_refused_at_the_cut() { + let log = log_of(10); + let wrong = vec![disclosed(3), disclosed(3)]; + assert!(matches!( + DisclosureBundle::cut(&log, "order", 3..5, wrong, &operator(), 0, None), + Err(DisclosureError::NotIncluded { index: 4, .. }) + )); + assert!(matches!( + DisclosureBundle::cut(&log, "order", 3..5, vec![disclosed(3)], &operator(), 0, None), + Err(DisclosureError::Count { expected: 2, found: 1 }) + )); + assert!(matches!( + DisclosureBundle::cut(&log, "order", 3..4, vec![disclosed(3), disclosed(4)], &operator(), 0, None), + Err(DisclosureError::Count { expected: 1, found: 2 }) + )); + assert!(matches!( + DisclosureBundle::cut(&log, "order", 9..11, vec![disclosed(9), disclosed(10)], &operator(), 0, None), + Err(DisclosureError::Range { .. }) + )); + } + + /// A CRA Art. 14 case's filing chain is a log a bundle can be cut from. + #[test] + fn a_filing_chain_can_be_disclosed() { + let at = |h: u32| chrono::Utc.with_ymd_and_hms(2026, 10, 1, h, 0, 0).unwrap(); + let mut chain = FilingChain::new("case-1", Art14Subject::SevereIncident); + let warning = Filing { + case_id: "case-1".into(), + subject: Art14Subject::SevereIncident, + stage: FilingStage::EarlyWarning, + became_aware_at: at(1), + filed_at: at(2), + corrective_measure_available_at: None, + statements: vec![ProductStatement { product: "product-1".into(), status: ProductStatus::UnderInvestigation }], + also_provides: vec![], + summary: "early warning".into(), + supersedes: None, + }; + chain.file(warning.clone()).unwrap(); + let offer = Offer { + kind: EvidenceKind::VulnerabilityOrIncidentFiling, + label: "early warning".into(), + content: Content::Disclosed(warning.canonical_bytes()), + }; + let b = DisclosureBundle::cut(&chain, "order", 0..1, vec![offer], &operator(), 0, None).unwrap(); + b.verify(&operator().verifying_key(), None).unwrap(); + let cra_row = crosswalk().iter().find(|p| p.article.starts_with("Art. 10(2)(b)")).unwrap(); + assert_eq!(b.bearing_on(cra_row), vec![0]); + } + + /// The crosswalk names each provision once. A provision on the bundle as + /// a whole reaches every member; the others reach the kinds they list. + #[test] + fn the_crosswalk_maps_records_to_provisions() { + let rows = crosswalk(); + let articles: std::collections::BTreeSet<_> = rows.iter().map(|p| p.article).collect(); + assert_eq!(articles.len(), rows.len()); + + let b = bundle(); + let whole = rows.iter().find(|p| p.article == "Art. 10(2)(a)").unwrap(); + assert_eq!(b.bearing_on(whole), vec![3, 4, 5, 6]); + let malfunction = rows.iter().find(|p| p.article == "Art. 10(2)(c)").unwrap(); + assert_eq!(b.bearing_on(malfunction), vec![3, 4, 6]); + let change = rows.iter().find(|p| p.article.starts_with("Art. 11(2)")).unwrap(); + assert_eq!(b.bearing_on(change), vec![5], "the withheld change still bears on it, by its hash"); + } +}