Skip to main content

lean_ctx/core/
customer_proof_v2.rs

1//! V2 customer-proof assembly from already materialized local observations.
2//!
3//! This is deliberately not a second verifier and does not reinterpret the
4//! frozen V1 ZIP contract. It builds canonical V2 JSON plus a bounded local
5//! sidecar directory; `leanctx-verify v2` remains the independent authority
6//! for proof eligibility.
7
8use std::collections::BTreeSet;
9use std::fmt::Write as _;
10use std::fs;
11use std::path::{Component, Path};
12
13use base64::{Engine as _, engine::general_purpose::STANDARD};
14use ed25519_dalek::{Signer, SigningKey};
15use serde_json::{Value, json};
16use sha2::{Digest, Sha256};
17
18const SCHEMA_VERSION: &str = "leanctx.customer-proof-evidence-bundle/v2";
19const MAX_ITEMS: usize = 128;
20const MAX_ITEM_BYTES: usize = 8 * 1024 * 1024;
21const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024;
22
23/// The V2 inventory kinds. Inputs are materialized bytes, never references
24/// back into an ambient data directory.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CustomerProofArtifactKind {
27    ArmReceipt,
28    ReceiptPredecessor,
29    QualityMeasurement,
30    ReplayInput,
31    ReplayResult,
32    RunMetadata,
33    ClaimBasis,
34    FrozenAuditBundleV1,
35    TaskEnvelope,
36    ExecutionPlan,
37    EngineInvocation,
38    EngineObservation,
39    AcceptedOutcome,
40    Measurement,
41    Assumption,
42    Formula,
43    PriceTable,
44    Invoice,
45    AcceptanceEvidence,
46}
47
48impl CustomerProofArtifactKind {
49    fn as_str(self) -> &'static str {
50        match self {
51            Self::ArmReceipt => "arm_receipt",
52            Self::ReceiptPredecessor => "receipt_predecessor",
53            Self::QualityMeasurement => "quality_measurement",
54            Self::ReplayInput => "replay_input",
55            Self::ReplayResult => "replay_result",
56            Self::RunMetadata => "run_metadata",
57            Self::ClaimBasis => "claim_basis",
58            Self::FrozenAuditBundleV1 => "frozen_audit_bundle_v1",
59            Self::TaskEnvelope => "task_envelope",
60            Self::ExecutionPlan => "execution_plan",
61            Self::EngineInvocation => "engine_invocation",
62            Self::EngineObservation => "engine_observation",
63            Self::AcceptedOutcome => "accepted_outcome",
64            Self::Measurement => "measurement",
65            Self::Assumption => "assumption",
66            Self::Formula => "formula",
67            Self::PriceTable => "price_table",
68            Self::Invoice => "invoice",
69            Self::AcceptanceEvidence => "acceptance_evidence",
70        }
71    }
72}
73
74/// The redaction declaration attached to one artifact. It is evidence
75/// provenance; assembly never silently claims redaction happened.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum CustomerProofRedactionClass {
78    None,
79    Pseudonymized,
80    MetadataOnly,
81    ContentRemoved,
82    SecretRemoved,
83    Aggregated,
84}
85
86impl CustomerProofRedactionClass {
87    fn as_str(self) -> &'static str {
88        match self {
89            Self::None => "none",
90            Self::Pseudonymized => "pseudonymized",
91            Self::MetadataOnly => "metadata_only",
92            Self::ContentRemoved => "content_removed",
93            Self::SecretRemoved => "secret_removed",
94            Self::Aggregated => "aggregated",
95        }
96    }
97}
98
99/// One present sidecar artifact. The assembler derives its content reference
100/// and inventory digest from these exact bytes.
101#[derive(Debug, Clone)]
102pub struct CustomerProofArtifact {
103    pub kind: CustomerProofArtifactKind,
104    pub path: String,
105    pub redaction_class: CustomerProofRedactionClass,
106    pub bytes: Vec<u8>,
107}
108
109impl CustomerProofArtifact {
110    #[must_use]
111    pub(crate) fn digest(&self) -> String {
112        sha256_digest(&self.bytes)
113    }
114
115    #[must_use]
116    pub(crate) fn reference(&self) -> String {
117        format!("id:{}", self.digest())
118    }
119}
120
121/// Fields supplied by the caller before inventory and signing are assembled.
122///
123/// This internal adapter intentionally keeps the evolving body as JSON until
124/// Profile/Kit contracts become public in W5. The assembler owns the stable
125/// canonical identity, inventory, and signing fields now and rejects callers
126/// attempting to inject them.
127#[derive(Debug, Clone)]
128pub struct CustomerProofDraftV2 {
129    pub created_at: String,
130    pub status: String,
131    pub subject: Value,
132    pub matched_arms: Value,
133    pub quality: Value,
134    pub replay: Value,
135    pub limitations: Value,
136    pub redaction: Value,
137    pub claims: Value,
138}
139
140/// Only a customer-configured or out-of-band key can be used to assemble a
141/// customer-proof candidate. A local identity alone is not sufficient.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum CustomerProofTrustBasis {
144    CustomerConfigured,
145    OutOfBand,
146}
147
148impl CustomerProofTrustBasis {
149    fn as_str(self) -> &'static str {
150        match self {
151            Self::CustomerConfigured => "customer_configured",
152            Self::OutOfBand => "out_of_band",
153        }
154    }
155}
156
157/// Explicit signing input. Trust is never embedded as a public key in V2; an
158/// independent verifier requires a matching external trust store.
159#[derive(Clone, Copy)]
160pub struct CustomerProofSigner<'a> {
161    pub signing_key: &'a SigningKey,
162    pub trust_basis: CustomerProofTrustBasis,
163}
164
165/// Pure assembly result, suitable for a caller that wants to hand the bytes
166/// straight to a separate verifier before writing anything.
167#[derive(Debug, Clone)]
168pub struct AssembledCustomerProofV2 {
169    pub bundle_id: String,
170    pub bundle_digest: String,
171    pub(crate) canonical_json: Vec<u8>,
172    artifacts: Vec<CustomerProofArtifact>,
173}
174
175impl AssembledCustomerProofV2 {
176    /// Writes one atomic local proof directory containing `customer-proof.json`
177    /// and only the inventory files whose bytes were signed into the document.
178    pub fn write_to(&self, output: &Path) -> Result<(), String> {
179        if output.exists() {
180            return Err(format!(
181                "refusing to overwrite existing proof directory {}",
182                output.display()
183            ));
184        }
185        let parent = output
186            .parent()
187            .filter(|parent| !parent.as_os_str().is_empty())
188            .ok_or_else(|| format!("proof output {} has no parent directory", output.display()))?;
189        fs::create_dir_all(parent).map_err(|error| format!("create proof parent: {error}"))?;
190        let leaf = output
191            .file_name()
192            .and_then(|name| name.to_str())
193            .filter(|name| !name.is_empty())
194            .ok_or_else(|| "proof output must have a UTF-8 directory name".to_string())?;
195        let staging = parent.join(format!(".{leaf}.{}.staging", uuid::Uuid::new_v4().simple()));
196        fs::create_dir(&staging)
197            .map_err(|error| format!("create proof staging directory: {error}"))?;
198        let result = self.write_staging(&staging);
199        if let Err(error) = result {
200            let _ = fs::remove_dir_all(&staging);
201            return Err(error);
202        }
203        fs::rename(&staging, output).map_err(|error| {
204            let _ = fs::remove_dir_all(&staging);
205            format!("atomically publish proof directory: {error}")
206        })
207    }
208
209    fn write_staging(&self, staging: &Path) -> Result<(), String> {
210        fs::write(staging.join("customer-proof.json"), &self.canonical_json)
211            .map_err(|error| format!("write canonical proof JSON: {error}"))?;
212        for artifact in &self.artifacts {
213            let destination = staging.join(&artifact.path);
214            let parent = destination
215                .parent()
216                .ok_or_else(|| "artifact destination has no parent".to_string())?;
217            fs::create_dir_all(parent)
218                .map_err(|error| format!("create artifact directory: {error}"))?;
219            fs::write(destination, &artifact.bytes)
220                .map_err(|error| format!("write proof artifact: {error}"))?;
221        }
222        Ok(())
223    }
224}
225
226/// Build a V2 document from explicit evidence bytes. This is deterministic for
227/// the same draft, artifact bytes, and signing key.
228pub fn assemble_customer_proof_v2(
229    draft: &CustomerProofDraftV2,
230    artifacts: Vec<CustomerProofArtifact>,
231    signer: CustomerProofSigner<'_>,
232) -> Result<AssembledCustomerProofV2, String> {
233    validate_draft(draft)?;
234    let inventory = build_inventory(&artifacts)?;
235    validate_declared_artifact_refs(draft, &inventory.references)?;
236
237    let key_id = format!(
238        "id:{}",
239        sha256_digest(signer.signing_key.verifying_key().as_bytes())
240    );
241    let mut document = json!({
242        "schema_version": SCHEMA_VERSION,
243        "bundle_kind": "customer-proof",
244        "created_at": draft.created_at,
245        "status": draft.status,
246        "subject": draft.subject,
247        "matched_arms": draft.matched_arms,
248        "inventory": inventory.value,
249        "quality": draft.quality,
250        "replay": draft.replay,
251        "limitations": draft.limitations,
252        "redaction": draft.redaction,
253        "claims": draft.claims,
254        "signing": {
255            "algorithm": "Ed25519",
256            "trusted_signer_ref": format!("signer:{key_id}"),
257            "key_id": key_id,
258            "trust_basis": signer.trust_basis.as_str(),
259            "signed_digest": "",
260            "signature": ""
261        }
262    });
263    let unsigned = unsigned_document(&document)?;
264    let bundle_digest = sha256_digest(&crate::core::canonical::canonical_json_bytes(&unsigned));
265    let root = document
266        .as_object_mut()
267        .ok_or_else(|| "assembled proof must be an object".to_string())?;
268    root.insert(
269        "bundle_id".to_string(),
270        Value::String(format!("id:{bundle_digest}")),
271    );
272    root.insert(
273        "bundle_digest".to_string(),
274        Value::String(bundle_digest.clone()),
275    );
276    let signing = root
277        .get_mut("signing")
278        .and_then(Value::as_object_mut)
279        .ok_or_else(|| "assembled proof signing must be an object".to_string())?;
280    signing.insert(
281        "signed_digest".to_string(),
282        Value::String(bundle_digest.clone()),
283    );
284    signing.insert(
285        "signature".to_string(),
286        Value::String(
287            STANDARD.encode(
288                signer
289                    .signing_key
290                    .sign(&crate::core::canonical::canonical_json_bytes(&unsigned))
291                    .to_bytes(),
292            ),
293        ),
294    );
295    Ok(AssembledCustomerProofV2 {
296        bundle_id: format!("id:{bundle_digest}"),
297        bundle_digest,
298        canonical_json: crate::core::canonical::canonical_json_bytes(&document),
299        artifacts,
300    })
301}
302
303struct Inventory {
304    value: Value,
305    references: BTreeSet<String>,
306}
307
308fn build_inventory(artifacts: &[CustomerProofArtifact]) -> Result<Inventory, String> {
309    if artifacts.is_empty() || artifacts.len() > MAX_ITEMS {
310        return Err("customer proof requires 1..=128 present artifacts".to_string());
311    }
312    let mut artifacts = artifacts.to_vec();
313    artifacts.sort_by(|left, right| left.path.cmp(&right.path));
314    let mut paths = BTreeSet::new();
315    let mut references = BTreeSet::new();
316    let mut total_bytes = 0_usize;
317    let items: Result<Vec<Value>, String> = artifacts
318        .iter()
319        .map(|artifact| {
320            if !is_safe_relative_path(&artifact.path) {
321                return Err(format!("unsafe proof artifact path {}", artifact.path));
322            }
323            if artifact.bytes.len() > MAX_ITEM_BYTES {
324                return Err(format!("proof artifact {} exceeds 8 MiB", artifact.path));
325            }
326            if !paths.insert(artifact.path.clone()) {
327                return Err(format!("duplicate proof artifact path {}", artifact.path));
328            }
329            total_bytes = total_bytes
330                .checked_add(artifact.bytes.len())
331                .ok_or_else(|| "proof artifact total overflows".to_string())?;
332            if total_bytes > MAX_TOTAL_BYTES {
333                return Err("proof artifacts exceed 64 MiB".to_string());
334            }
335            let digest = artifact.digest();
336            let reference = artifact.reference();
337            if !references.insert(reference.clone()) {
338                return Err(
339                    "proof artifacts must not have duplicate content references".to_string()
340                );
341            }
342            Ok(json!({
343                "ref": reference,
344                "kind": artifact.kind.as_str(),
345                "path": artifact.path,
346                "digest": digest,
347                "size_bytes": artifact.bytes.len(),
348                "availability": "present",
349                "redaction_class": artifact.redaction_class.as_str(),
350            }))
351        })
352        .collect();
353    Ok(Inventory {
354        value: json!({
355            "max_items": MAX_ITEMS,
356            "item_count": artifacts.len(),
357            "total_bytes": total_bytes,
358            "items": items?,
359        }),
360        references,
361    })
362}
363
364fn validate_draft(draft: &CustomerProofDraftV2) -> Result<(), String> {
365    if !is_timestamp(&draft.created_at) {
366        return Err("customer proof created_at must be a UTC second timestamp".to_string());
367    }
368    if !matches!(draft.status.as_str(), "complete" | "partial" | "invalid") {
369        return Err("customer proof status is invalid".to_string());
370    }
371    for (name, value) in [
372        ("subject", &draft.subject),
373        ("matched_arms", &draft.matched_arms),
374        ("quality", &draft.quality),
375        ("replay", &draft.replay),
376        ("limitations", &draft.limitations),
377        ("redaction", &draft.redaction),
378    ] {
379        if !value.is_object() {
380            return Err(format!("customer proof {name} must be an object"));
381        }
382    }
383    if !draft.claims.is_array() {
384        return Err("customer proof claims must be an array".to_string());
385    }
386    Ok(())
387}
388
389fn validate_declared_artifact_refs(
390    draft: &CustomerProofDraftV2,
391    references: &BTreeSet<String>,
392) -> Result<(), String> {
393    let mut declared = Vec::new();
394    for arm in ["control", "treatment"] {
395        let refs = draft
396            .matched_arms
397            .pointer(&format!("/{arm}/evidence_refs"))
398            .ok_or_else(|| format!("matched_arms.{arm}.evidence_refs is required"))?;
399        declared.extend(string_refs(
400            refs,
401            &format!("matched_arms.{arm}.evidence_refs"),
402        )?);
403    }
404    declared.extend(string_refs(
405        draft
406            .quality
407            .get("evidence_refs")
408            .ok_or_else(|| "quality.evidence_refs is required".to_string())?,
409        "quality.evidence_refs",
410    )?);
411    for field in ["input_refs", "result_refs"] {
412        declared.extend(string_refs(
413            draft
414                .replay
415                .get(field)
416                .ok_or_else(|| format!("replay.{field} is required"))?,
417            &format!("replay.{field}"),
418        )?);
419    }
420    let claims = draft
421        .claims
422        .as_array()
423        .ok_or_else(|| "claims must be an array".to_string())?;
424    for (index, claim) in claims.iter().enumerate() {
425        declared.extend(string_refs(
426            claim
427                .get("basis_refs")
428                .ok_or_else(|| format!("claims[{index}].basis_refs is required"))?,
429            &format!("claims[{index}].basis_refs"),
430        )?);
431    }
432    if let Some(missing) = declared
433        .iter()
434        .find(|reference| !references.contains(*reference))
435    {
436        return Err(format!(
437            "proof body references artifact not supplied to assembler: {missing}"
438        ));
439    }
440    Ok(())
441}
442
443fn string_refs(value: &Value, label: &str) -> Result<Vec<String>, String> {
444    let values = value
445        .as_array()
446        .ok_or_else(|| format!("{label} must be an array"))?;
447    values
448        .iter()
449        .map(|value| {
450            value
451                .as_str()
452                .map(str::to_owned)
453                .ok_or_else(|| format!("{label} must contain only strings"))
454        })
455        .collect()
456}
457
458fn unsigned_document(document: &Value) -> Result<Value, String> {
459    let mut unsigned = document.clone();
460    let root = unsigned
461        .as_object_mut()
462        .ok_or_else(|| "proof document must be an object".to_string())?;
463    root.remove("bundle_id");
464    root.remove("bundle_digest");
465    let signing = root
466        .get_mut("signing")
467        .and_then(Value::as_object_mut)
468        .ok_or_else(|| "proof document signing must be an object".to_string())?;
469    signing.remove("signed_digest");
470    signing.remove("signature");
471    Ok(unsigned)
472}
473
474fn sha256_digest(bytes: &[u8]) -> String {
475    let digest = Sha256::digest(bytes);
476    let mut hex = String::with_capacity(digest.len() * 2);
477    for byte in digest {
478        write!(&mut hex, "{byte:02x}").expect("writing to a String cannot fail");
479    }
480    format!("sha256:{hex}")
481}
482
483fn is_safe_relative_path(path: &str) -> bool {
484    !path.is_empty()
485        && path.len() <= 256
486        && path
487            .bytes()
488            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/'))
489        && !Path::new(path).is_absolute()
490        && Path::new(path)
491            .components()
492            .all(|component| matches!(component, Component::Normal(_)))
493}
494
495fn is_timestamp(value: &str) -> bool {
496    let bytes = value.as_bytes();
497    bytes.len() == 20
498        && bytes[4] == b'-'
499        && bytes[7] == b'-'
500        && bytes[10] == b'T'
501        && bytes[13] == b':'
502        && bytes[16] == b':'
503        && bytes[19] == b'Z'
504        && bytes
505            .iter()
506            .enumerate()
507            .filter(|(index, _)| !matches!(index, 4 | 7 | 10 | 13 | 16 | 19))
508            .all(|(_, byte)| byte.is_ascii_digit())
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    fn artifact(
516        path: &str,
517        kind: CustomerProofArtifactKind,
518        bytes: &[u8],
519    ) -> CustomerProofArtifact {
520        CustomerProofArtifact {
521            kind,
522            path: path.to_string(),
523            redaction_class: CustomerProofRedactionClass::MetadataOnly,
524            bytes: bytes.to_vec(),
525        }
526    }
527
528    fn draft(artifacts: &[CustomerProofArtifact]) -> CustomerProofDraftV2 {
529        let control = artifacts[0].reference();
530        let treatment = artifacts[1].reference();
531        let quality = artifacts[2].reference();
532        let replay_input = artifacts[3].reference();
533        let replay_result = artifacts[4].reference();
534        CustomerProofDraftV2 {
535            created_at: "2026-08-22T09:00:00Z".to_string(),
536            status: "complete".to_string(),
537            subject: json!({
538                "customer_ref": "customer:acme-labs",
539                "project_ref": "project:lean-ctx",
540                "workload_ref": "id:sha256:42d8afd8fea1184e1cb2a42075e602611597f2486636929f0a128fc907f489d5"
541            }),
542            matched_arms: json!({
543                "match_id": "id:sha256:edcaf288f759371516be564ba8e346981a0ed7068581bb11d1da3fd38e585864",
544                "match_basis": ["provider", "model", "source_commit", "workload_digest"],
545                "shared_identity": {"provider":"openai","model":"gpt-5.6-luna","source_commit":"git:6d4fefa595000000000000000000000000000000","workload_digest":"sha256:42d8afd8fea1184e1cb2a42075e602611597f2486636929f0a128fc907f489d5"},
546                "control": {"role":"control","arm_id":"id:sha256:660fb46674b8a16166026551e50b032a1ccf58a4d260c4bb4e42182350660830","identity":{"provider":"openai","model":"gpt-5.6-luna","source_commit":"git:6d4fefa595000000000000000000000000000000","workload_digest":"sha256:42d8afd8fea1184e1cb2a42075e602611597f2486636929f0a128fc907f489d5"},"status":"complete","measurements":{"input_tokens":12000,"cached_input_tokens":0,"output_tokens":900,"latency_ms":820,"cost":{"currency":"USD","amount_micros":1000000,"status":"observed"},"status":"observed"},"evidence_refs":[control]},
547                "treatment": {"role":"treatment","arm_id":"id:sha256:b90873fbdffe04dad346c267e9dbdcf3c7a6f121e917620bc80a2531b78e4691","identity":{"provider":"openai","model":"gpt-5.6-luna","source_commit":"git:6d4fefa595000000000000000000000000000000","workload_digest":"sha256:42d8afd8fea1184e1cb2a42075e602611597f2486636929f0a128fc907f489d5"},"status":"complete","measurements":{"input_tokens":8000,"cached_input_tokens":4000,"output_tokens":900,"latency_ms":790,"cost":{"currency":"USD","amount_micros":700000,"status":"observed"},"status":"observed"},"evidence_refs":[treatment]}
548            }),
549            quality: json!({"status":"preserved","metric":"score_milli","control_score_milli":920,"treatment_score_milli":920,"confidence":"high","method":"automated","evidence_refs":[quality]}),
550            replay: json!({"status":"partial","mode":"offline","determinism":"same_inputs_expected","input_refs":[replay_input],"result_refs":[replay_result],"notes":"fixture"}),
551            limitations: json!({"known_limitations":[],"unproven":["omission_before_capture"]}),
552            redaction: json!({"class":"metadata_only","policy":"content_minimization","reversible":false,"notes":"fixture"}),
553            claims: json!([
554                {"claim_id":"id:sha256:178e80b928614a26b58c35af69d77e01f917bbb85263ee1251106b050f0b8bfc","claim_type":"cost_reduction","statement":"Treatment cost was lower.","claim_validity":"supported","scope":"matched_run","basis_refs":[artifacts[0].reference(), artifacts[1].reference()]},
555                {"claim_id":"id:sha256:528e6c9b4f275560ef2456e5ca68601b124e215c3494055187467cc30a670371","claim_type":"quality_preserved","statement":"Treatment quality was preserved.","claim_validity":"supported","scope":"matched_run","basis_refs":[artifacts[2].reference()]}
556            ]),
557        }
558    }
559
560    fn artifacts() -> Vec<CustomerProofArtifact> {
561        vec![
562            artifact(
563                "arms/control.json",
564                CustomerProofArtifactKind::ArmReceipt,
565                b"control",
566            ),
567            artifact(
568                "arms/treatment.json",
569                CustomerProofArtifactKind::ArmReceipt,
570                b"treatment",
571            ),
572            artifact(
573                "quality/comparison.json",
574                CustomerProofArtifactKind::QualityMeasurement,
575                b"quality",
576            ),
577            artifact(
578                "replay/input.json",
579                CustomerProofArtifactKind::ReplayInput,
580                b"input",
581            ),
582            artifact(
583                "replay/result.json",
584                CustomerProofArtifactKind::ReplayResult,
585                b"result",
586            ),
587        ]
588    }
589
590    #[test]
591    fn assembly_is_deterministic_and_signs_canonical_body() {
592        let artifacts = artifacts();
593        let draft = draft(&artifacts);
594        let key = SigningKey::from_bytes(&[17; 32]);
595        let first = assemble_customer_proof_v2(
596            &draft,
597            artifacts.clone(),
598            CustomerProofSigner {
599                signing_key: &key,
600                trust_basis: CustomerProofTrustBasis::OutOfBand,
601            },
602        )
603        .unwrap();
604        let second = assemble_customer_proof_v2(
605            &draft,
606            artifacts,
607            CustomerProofSigner {
608                signing_key: &key,
609                trust_basis: CustomerProofTrustBasis::OutOfBand,
610            },
611        )
612        .unwrap();
613        assert_eq!(first.canonical_json, second.canonical_json);
614        assert_eq!(first.bundle_id, format!("id:{}", first.bundle_digest));
615    }
616
617    #[test]
618    fn write_to_publishes_a_complete_new_proof_directory() {
619        let artifacts = artifacts();
620        let signing_key = SigningKey::from_bytes(&[7_u8; 32]);
621        let assembled = assemble_customer_proof_v2(
622            &draft(&artifacts),
623            artifacts.clone(),
624            CustomerProofSigner {
625                signing_key: &signing_key,
626                trust_basis: CustomerProofTrustBasis::CustomerConfigured,
627            },
628        )
629        .unwrap();
630        let temporary = tempfile::tempdir().unwrap();
631        let output = temporary.path().join("proof");
632
633        assembled.write_to(&output).unwrap();
634
635        assert_eq!(
636            fs::read(output.join("customer-proof.json")).unwrap(),
637            assembled.canonical_json
638        );
639        assert_eq!(
640            fs::read(output.join(&artifacts[0].path)).unwrap(),
641            artifacts[0].bytes
642        );
643        assert!(assembled.write_to(&output).is_err());
644    }
645
646    #[test]
647    fn assembly_rejects_dangling_or_unsafe_artifact_inputs() {
648        let mut artifacts = artifacts();
649        artifacts[0].path = "../escape.json".to_string();
650        let draft = draft(&artifacts);
651        let key = SigningKey::from_bytes(&[17; 32]);
652        let error = assemble_customer_proof_v2(
653            &draft,
654            artifacts,
655            CustomerProofSigner {
656                signing_key: &key,
657                trust_basis: CustomerProofTrustBasis::CustomerConfigured,
658            },
659        )
660        .unwrap_err();
661        assert!(error.contains("unsafe proof artifact path"));
662    }
663}