1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5use crate::core::handoff_ledger::HandoffLedgerV1;
6
7const MAX_BUNDLE_BYTES: usize = 350_000;
8const MAX_PROOF_FILES: usize = 50;
9const MAX_ARTIFACT_ITEMS: usize = 80;
10const MAX_LEDGER_SNAPSHOT_CHARS: usize = 80_000;
11const MAX_CURATED_REF_CHARS: usize = 20_000;
12const MAX_DECISION_CHARS: usize = 2_000;
13const MAX_FINDING_CHARS: usize = 2_000;
14const MAX_NEXT_STEP_CHARS: usize = 1_000;
15const MAX_TASK_CHARS: usize = 4_000;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum BundlePrivacyV1 {
19 Redacted,
20 Full,
21}
22
23impl BundlePrivacyV1 {
24 pub fn parse(s: Option<&str>) -> Self {
25 match s.unwrap_or("redacted").trim().to_lowercase().as_str() {
26 "full" => Self::Full,
27 _ => Self::Redacted,
28 }
29 }
30
31 pub(crate) fn as_str(&self) -> &'static str {
32 match self {
33 Self::Redacted => "redacted",
34 Self::Full => "full",
35 }
36 }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct HandoffTransferBundleV1 {
41 pub schema_version: u32,
42 pub exported_at: DateTime<Utc>,
43 pub privacy: String,
44 pub project: ProjectIdentityV1,
45 pub ledger: HandoffLedgerV1,
46 pub artifacts: ArtifactsExcerptV1,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub signature: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub signer_public_key: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub signer_agent_id: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub signature_scheme: Option<String>,
58}
59
60pub const BOUND_SIGNATURE_SCHEME: &str = "ed25519-bound-v2";
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ProjectIdentityV1 {
65 pub project_root_hash: Option<String>,
66 pub project_identity_hash: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, Default)]
70pub struct ArtifactsExcerptV1 {
71 pub resolved: Vec<crate::core::artifacts::ResolvedArtifact>,
72 pub proof_files: Vec<ProofFileV1>,
73 pub warnings: Vec<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ProofFileV1 {
78 pub name: String,
79 pub md5: String,
80 pub bytes: u64,
81}
82
83pub fn build_bundle_v1(
84 mut ledger: HandoffLedgerV1,
85 project_root: Option<&str>,
86 privacy: BundlePrivacyV1,
87) -> HandoffTransferBundleV1 {
88 let role_name = crate::core::roles::active_role_name();
89 let effective_privacy = match privacy {
90 BundlePrivacyV1::Full
91 if role_name == "admin"
92 && !crate::core::redaction::redaction_enabled_for_active_role() =>
93 {
94 BundlePrivacyV1::Full
95 }
96 _ => BundlePrivacyV1::Redacted,
97 };
98
99 let (project_root_hash, project_identity_hash) = project_root.map_or((None, None), |root| {
100 let root_hash = crate::core::project_hash::hash_project_root(root);
101 let identity = crate::core::project_hash::project_identity(root);
102 let identity_hash = identity.as_deref().map(crate::core::hasher::hash_str);
103 (Some(root_hash), identity_hash)
104 });
105
106 cap_ledger_in_place(&mut ledger);
107
108 match effective_privacy {
109 BundlePrivacyV1::Full => {}
110 BundlePrivacyV1::Redacted => {
111 redact_ledger_in_place(&mut ledger);
112 }
113 }
114
115 ledger.content_md5 = crate::core::handoff_ledger::compute_content_md5_for_ledger(&ledger);
117
118 let artifacts = project_root
119 .map(Path::new)
120 .map(build_artifacts_excerpt_v1)
121 .unwrap_or_default();
122
123 HandoffTransferBundleV1 {
128 schema_version: crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION,
129 exported_at: Utc::now(),
130 privacy: effective_privacy.as_str().to_string(),
131 project: ProjectIdentityV1 {
132 project_root_hash,
133 project_identity_hash,
134 },
135 ledger,
136 artifacts,
137 signature: None,
138 signer_public_key: None,
139 signer_agent_id: None,
140 signature_scheme: None,
141 }
142}
143
144pub fn sign_bundle(bundle: &mut HandoffTransferBundleV1, agent_id: &str) -> Result<(), String> {
145 let key = crate::core::agent_identity::get_or_create_keypair(agent_id)?;
149
150 bundle.signature = None;
153 bundle.signer_public_key = Some(crate::core::agent_identity::hex_encode(
154 &key.verifying_key().to_bytes(),
155 ));
156 bundle.signer_agent_id = Some(agent_id.to_string());
157 bundle.signature_scheme = Some(BOUND_SIGNATURE_SCHEME.to_string());
158
159 let canonical =
160 serde_json::to_string(bundle).map_err(|e| format!("serialize for signing: {e}"))?;
161 let sig_bytes = crate::core::agent_identity::sign_bytes_with(&key, canonical.as_bytes());
162 bundle.signature = Some(crate::core::agent_identity::hex_encode(&sig_bytes));
163 Ok(())
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct VerifiedSignature {
172 pub claimed_signer: String,
174 pub key_fingerprint: String,
176 pub signer_bound: bool,
179 pub trusted: bool,
182}
183
184impl VerifiedSignature {
185 pub fn describe(&self) -> String {
187 let fp = &self.key_fingerprint;
188 let who = &self.claimed_signer;
189 if self.trusted {
190 format!("verified (signer={who}, key {fp}, trusted)")
191 } else if self.signer_bound {
192 format!("integrity ok, signer key {fp} (untrusted; claimed signer={who})")
193 } else {
194 format!(
195 "integrity ok, legacy signature (signer id not covered), signer key {fp} \
196 (untrusted; claimed signer={who})"
197 )
198 }
199 }
200
201 #[cfg(any(test, feature = "http-server"))]
203 pub fn status(&self) -> &'static str {
204 if self.trusted {
205 "verified"
206 } else if self.signer_bound {
207 "untrusted_signer"
208 } else {
209 "legacy_unbound"
210 }
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
220pub enum BundleSignatureStatus {
221 Signed(VerifiedSignature),
224 Unsigned,
226 Invalid(String),
228}
229
230#[must_use]
232pub fn check_bundle_signature(bundle: &HandoffTransferBundleV1) -> BundleSignatureStatus {
233 if bundle.signature.is_none()
234 && bundle.signer_public_key.is_none()
235 && bundle.signer_agent_id.is_none()
236 && bundle.signature_scheme.is_none()
237 {
238 return BundleSignatureStatus::Unsigned;
239 }
240 match verify_bundle_signature(bundle) {
241 Ok(verified) => BundleSignatureStatus::Signed(verified),
242 Err(e) => BundleSignatureStatus::Invalid(e),
243 }
244}
245
246pub(crate) fn verify_bundle_signature(
247 bundle: &HandoffTransferBundleV1,
248) -> Result<VerifiedSignature, String> {
249 let sig_hex = bundle
250 .signature
251 .as_deref()
252 .ok_or_else(|| "bundle has no signature".to_string())?;
253 let pk_hex = bundle
254 .signer_public_key
255 .as_deref()
256 .ok_or_else(|| "bundle has no signer_public_key".to_string())?;
257 let agent_id = bundle
258 .signer_agent_id
259 .as_deref()
260 .ok_or_else(|| "bundle has no signer_agent_id".to_string())?;
261
262 let sig_bytes = crate::core::agent_identity::hex_decode(sig_hex)?;
263 let pk_bytes = crate::core::agent_identity::hex_decode(pk_hex)?;
264
265 let mut verify_bundle = bundle.clone();
266 verify_bundle.signature = None;
267 let signer_bound = match bundle.signature_scheme.as_deref() {
268 Some(BOUND_SIGNATURE_SCHEME) => true,
269 None => {
270 verify_bundle.signer_public_key = None;
272 verify_bundle.signer_agent_id = None;
273 false
274 }
275 Some(other) => return Err(format!("unsupported signature scheme `{other}`")),
276 };
277
278 let canonical =
279 serde_json::to_string(&verify_bundle).map_err(|e| format!("serialize for verify: {e}"))?;
280
281 if !crate::core::agent_identity::verify_signature(&pk_bytes, canonical.as_bytes(), &sig_bytes) {
282 return Err("signature verification failed".to_string());
283 }
284 Ok(VerifiedSignature {
285 claimed_signer: agent_id.to_string(),
286 key_fingerprint: crate::core::signer_trust::key_fingerprint(pk_hex),
287 signer_bound,
288 trusted: signer_bound && crate::core::signer_trust::is_trusted(agent_id, pk_hex),
289 })
290}
291
292pub fn serialize_bundle_v1_pretty(bundle: &HandoffTransferBundleV1) -> Result<String, String> {
293 let json = serde_json::to_string_pretty(bundle).map_err(|e| e.to_string())?;
294 if json.len() > MAX_BUNDLE_BYTES {
295 return Err(format!(
296 "ERROR: bundle too large ({} bytes > max {}). Use privacy=redacted and/or reduce curated refs.",
297 json.len(),
298 MAX_BUNDLE_BYTES
299 ));
300 }
301 Ok(json)
302}
303
304pub fn parse_bundle_v1(json: &str) -> Result<HandoffTransferBundleV1, String> {
305 let b: HandoffTransferBundleV1 = serde_json::from_str(json).map_err(|e| e.to_string())?;
306 if b.schema_version != crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION {
307 return Err(format!(
308 "ERROR: unsupported schema_version {} (expected {})",
309 b.schema_version,
310 crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION
311 ));
312 }
313 Ok(b)
314}
315
316pub fn write_bundle_v1(path: &Path, json: &str) -> Result<(), String> {
317 let parent = path
318 .parent()
319 .ok_or_else(|| "ERROR: invalid path".to_string())?;
320 if !parent.exists() {
321 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
322 }
323 let tmp = parent.join(format!(
324 ".{}.tmp",
325 path.file_name()
326 .and_then(|s| s.to_str())
327 .unwrap_or("bundle")
328 ));
329 std::fs::write(&tmp, json).map_err(|e| e.to_string())?;
330 std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
331 Ok(())
332}
333
334pub fn read_bundle_v1(path: &Path) -> Result<HandoffTransferBundleV1, String> {
335 let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
336 if json.len() > MAX_BUNDLE_BYTES {
337 return Err(format!(
338 "ERROR: bundle file too large ({} bytes > max {})",
339 json.len(),
340 MAX_BUNDLE_BYTES
341 ));
342 }
343 parse_bundle_v1(&json)
344}
345
346pub fn project_identity_warning(
347 bundle: &HandoffTransferBundleV1,
348 project_root: &str,
349) -> Option<String> {
350 let current_root_hash = crate::core::project_hash::hash_project_root(project_root);
351 let current_identity_hash = crate::core::project_hash::project_identity(project_root)
352 .as_deref()
353 .map(crate::core::hasher::hash_str);
354
355 if let Some(ref exported) = bundle.project.project_root_hash
356 && exported != ¤t_root_hash
357 {
358 return Some(
359 "WARNING: project_root_hash mismatch (importing into different project root)."
360 .to_string(),
361 );
362 }
363
364 if let (Some(exported), Some(current)) = (
365 bundle.project.project_identity_hash.as_ref(),
366 current_identity_hash.as_ref(),
367 ) && exported != current
368 {
369 return Some(
370 "WARNING: project_identity_hash mismatch (importing into different project identity)."
371 .to_string(),
372 );
373 }
374
375 None
376}
377
378fn build_artifacts_excerpt_v1(project_root: &Path) -> ArtifactsExcerptV1 {
379 let mut out = ArtifactsExcerptV1::default();
380
381 let resolved = crate::core::artifacts::load_resolved(project_root);
382 out.warnings.extend(resolved.warnings);
383 out.resolved = resolved
384 .artifacts
385 .into_iter()
386 .take(MAX_ARTIFACT_ITEMS)
387 .collect();
388
389 let proofs_dir = match crate::core::pathutil::safe_project_data_dir(project_root) {
390 Ok(d) => d.join("proofs"),
391 Err(_) => return out,
392 };
393 if let Ok(rd) = std::fs::read_dir(&proofs_dir) {
394 let mut files = Vec::new();
395 for e in rd.flatten() {
396 let p = e.path();
397 if !p.is_file() {
398 continue;
399 }
400 let name = p
401 .file_name()
402 .map(|n| n.to_string_lossy().to_string())
403 .unwrap_or_default();
404 if name.is_empty() {
405 continue;
406 }
407 let bytes = p.metadata().map_or(0, |m| m.len());
408 let md5 = match std::fs::read(&p) {
409 Ok(b) => crate::core::hasher::hash_hex(&b),
410 Err(e) => {
411 out.warnings
412 .push(format!("proof read failed: {} ({e})", p.display()));
413 continue;
414 }
415 };
416 files.push(ProofFileV1 { name, md5, bytes });
417 }
418 files.sort_by(|a, b| a.name.cmp(&b.name));
419 out.proof_files = files.into_iter().take(MAX_PROOF_FILES).collect();
420 }
421
422 out
423}
424
425fn cap_ledger_in_place(ledger: &mut HandoffLedgerV1) {
426 if ledger.session_snapshot.len() > MAX_LEDGER_SNAPSHOT_CHARS {
427 ledger.session_snapshot =
428 truncate_chars(&ledger.session_snapshot, MAX_LEDGER_SNAPSHOT_CHARS);
429 }
430
431 if let Some(ref mut task) = ledger.session.task {
432 *task = truncate_chars(task, MAX_TASK_CHARS);
433 }
434
435 for d in &mut ledger.session.decisions {
436 *d = truncate_chars(d, MAX_DECISION_CHARS);
437 }
438 for f in &mut ledger.session.findings {
439 *f = truncate_chars(f, MAX_FINDING_CHARS);
440 }
441 for s in &mut ledger.session.next_steps {
442 *s = truncate_chars(s, MAX_NEXT_STEP_CHARS);
443 }
444
445 for r in &mut ledger.curated_refs {
446 if r.content.len() > MAX_CURATED_REF_CHARS {
447 r.content = truncate_chars(&r.content, MAX_CURATED_REF_CHARS);
448 }
449 }
450}
451
452fn redact_ledger_in_place(ledger: &mut HandoffLedgerV1) {
453 ledger.project_root = None;
454 ledger.session_snapshot.clear();
455
456 if let Some(ref mut task) = ledger.session.task {
457 *task = crate::core::redaction::redact_text(task);
458 }
459 for d in &mut ledger.session.decisions {
460 *d = crate::core::redaction::redact_text(d);
461 }
462 for f in &mut ledger.session.findings {
463 *f = crate::core::redaction::redact_text(f);
464 }
465 for s in &mut ledger.session.next_steps {
466 *s = crate::core::redaction::redact_text(s);
467 }
468
469 for fact in &mut ledger.knowledge.facts {
470 fact.value = crate::core::redaction::redact_text(&fact.value);
471 }
472
473 for r in &mut ledger.curated_refs {
474 r.content = crate::core::redaction::redact_text(&r.content);
475 }
476}
477
478fn truncate_chars(s: &str, max: usize) -> String {
479 if s.chars().count() <= max {
480 return s.to_string();
481 }
482 s.chars().take(max).collect::<String>()
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 fn sample_ledger() -> HandoffLedgerV1 {
490 HandoffLedgerV1 {
491 schema_version: crate::core::contracts::HANDOFF_LEDGER_V1_SCHEMA_VERSION,
492 created_at: "20260503T000000Z".to_string(),
493 content_md5: "old".to_string(),
494 manifest_md5: "m".to_string(),
495 project_root: Some("/abs/project".to_string()),
496 agent_id: Some("a".to_string()),
497 client_name: Some("cursor".to_string()),
498 workflow: None,
499 session_snapshot: "snapshot".to_string(),
500 session: crate::core::handoff_ledger::SessionExcerpt {
501 id: "s".to_string(),
502 task: Some("task".to_string()),
503 decisions: vec!["d1".to_string()],
504 findings: vec!["f1".to_string()],
505 next_steps: vec!["n1".to_string()],
506 },
507 tool_calls: crate::core::handoff_ledger::ToolCallsSummary::default(),
508 evidence_keys: vec!["tool:ctx_read".to_string()],
509 knowledge: crate::core::handoff_ledger::KnowledgeExcerpt {
510 project_hash: None,
511 facts: vec![crate::core::handoff_ledger::KnowledgeFactMini {
512 category: "c".to_string(),
513 key: "k".to_string(),
514 value: "secret=abcdef0123456789abcdef0123456789".to_string(),
515 confidence: 0.9,
516 }],
517 },
518 curated_refs: vec![crate::core::handoff_ledger::CuratedRef {
519 path: "src/lib.rs".to_string(),
520 mode: "signatures".to_string(),
521 content_md5: "x".to_string(),
522 content: "fn a() {}".to_string(),
523 }],
524 active_overlays: Vec::new(),
525 merged_from: Vec::new(),
526 request_id: None,
527 request_digest: None,
528 }
529 }
530
531 #[test]
532 fn redacted_bundle_removes_sensitive_fields() {
533 let ledger = sample_ledger();
534 let b = build_bundle_v1(ledger, None, BundlePrivacyV1::Redacted);
535 assert_eq!(b.privacy, "redacted");
536 assert!(b.ledger.project_root.is_none());
537 assert!(
538 b.ledger.session_snapshot.is_empty(),
539 "{:?}",
540 b.ledger.session_snapshot
541 );
542 }
543
544 #[test]
545 fn serialize_parse_roundtrip() {
546 let ledger = sample_ledger();
547 let b = build_bundle_v1(ledger, None, BundlePrivacyV1::Redacted);
548 let json = serialize_bundle_v1_pretty(&b).expect("json");
549 assert!(json.len() < MAX_BUNDLE_BYTES);
550 let parsed = parse_bundle_v1(&json).expect("parse");
551 assert_eq!(parsed.schema_version, b.schema_version);
552 assert_eq!(parsed.privacy, "redacted");
553 }
554
555 #[test]
556 fn unicode_signature_material_is_invalid() {
557 for malformed in ["\u{1f512}", "0\u{20ac}", "\u{e9}\u{e9}"] {
558 for signature_is_malformed in [true, false] {
559 let mut bundle = build_bundle_v1(sample_ledger(), None, BundlePrivacyV1::Redacted);
560 bundle.signer_agent_id = Some("unicode-regression".to_string());
561 bundle.signature = Some(if signature_is_malformed {
562 malformed.to_string()
563 } else {
564 "00".repeat(64)
565 });
566 bundle.signer_public_key = Some(if signature_is_malformed {
567 "00".repeat(32)
568 } else {
569 malformed.to_string()
570 });
571 let serialized = serialize_bundle_v1_pretty(&bundle).unwrap();
572 let parsed = parse_bundle_v1(&serialized).unwrap();
573 assert_eq!(
574 check_bundle_signature(&parsed),
575 BundleSignatureStatus::Invalid("non-ASCII hex string".to_string())
576 );
577 }
578 }
579 }
580
581 #[test]
585 fn import_signature_check_verified_unsigned_invalid() {
586 let _lock = crate::core::data_dir::test_env_lock();
590 let data_dir = tempfile::tempdir().unwrap();
591 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
592
593 let unsigned = build_bundle_v1(sample_ledger(), None, BundlePrivacyV1::Redacted);
594 assert_eq!(
595 check_bundle_signature(&unsigned),
596 BundleSignatureStatus::Unsigned
597 );
598
599 let mut signed = build_bundle_v1(sample_ledger(), None, BundlePrivacyV1::Redacted);
600 sign_bundle(&mut signed, "handoff-sig-test-agent").expect("sign");
601 match check_bundle_signature(&signed) {
603 BundleSignatureStatus::Signed(v) => {
604 assert_eq!(v.claimed_signer, "handoff-sig-test-agent");
605 assert!(v.signer_bound && v.trusted, "{v:?}");
606 assert_eq!(v.status(), "verified");
607 }
608 other => panic!("expected Signed, got {other:?}"),
609 }
610
611 let mut relabelled = signed.clone();
613 relabelled.signer_agent_id = Some("trusted-release-bot".to_string());
614 assert!(matches!(
615 check_bundle_signature(&relabelled),
616 BundleSignatureStatus::Invalid(_)
617 ));
618 let mut downgraded = signed.clone();
620 downgraded.signature_scheme = None;
621 assert!(matches!(
622 check_bundle_signature(&downgraded),
623 BundleSignatureStatus::Invalid(_)
624 ));
625
626 let mut tampered = signed.clone();
628 tampered.ledger.session.task = Some("tampered task".to_string());
629 assert!(matches!(
630 check_bundle_signature(&tampered),
631 BundleSignatureStatus::Invalid(_)
632 ));
633
634 let mut partial = signed;
636 partial.signer_public_key = None;
637 assert!(matches!(
638 check_bundle_signature(&partial),
639 BundleSignatureStatus::Invalid(_)
640 ));
641
642 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
643 }
644
645 #[test]
648 fn foreign_and_legacy_signatures_are_not_verified_identities() {
649 let _lock = crate::core::data_dir::test_env_lock();
650 let data_dir = tempfile::tempdir().unwrap();
651 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
652 let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
653 let pk_hex = crate::core::agent_identity::hex_encode(&key.verifying_key().to_bytes());
654
655 let mut bound = build_bundle_v1(sample_ledger(), None, BundlePrivacyV1::Redacted);
657 bound.signer_public_key = Some(pk_hex.clone());
658 bound.signer_agent_id = Some("trusted-release-bot".to_string());
659 bound.signature_scheme = Some(BOUND_SIGNATURE_SCHEME.to_string());
660 let canonical = serde_json::to_string(&bound).unwrap();
661 bound.signature = Some(crate::core::agent_identity::hex_encode(
662 &crate::core::agent_identity::sign_bytes_with(&key, canonical.as_bytes()),
663 ));
664 let BundleSignatureStatus::Signed(v) = check_bundle_signature(&bound) else {
665 panic!("bound signature must verify");
666 };
667 assert!(v.signer_bound && !v.trusted);
668 assert_eq!(v.status(), "untrusted_signer");
669 assert!(v.describe().contains("(untrusted"), "{}", v.describe());
670 assert!(!v.describe().starts_with("verified"));
671
672 crate::core::signer_trust::pin_if_absent("trusted-release-bot", &pk_hex, "test").unwrap();
674 let BundleSignatureStatus::Signed(v) = check_bundle_signature(&bound) else {
675 panic!("bound signature must verify");
676 };
677 assert!(v.trusted);
678
679 let mut legacy = build_bundle_v1(sample_ledger(), None, BundlePrivacyV1::Redacted);
682 let canonical = serde_json::to_string(&legacy).unwrap();
683 legacy.signature = Some(crate::core::agent_identity::hex_encode(
684 &crate::core::agent_identity::sign_bytes_with(&key, canonical.as_bytes()),
685 ));
686 legacy.signer_public_key = Some(pk_hex);
687 legacy.signer_agent_id = Some("trusted-release-bot".to_string());
688 let BundleSignatureStatus::Signed(v) = check_bundle_signature(&legacy) else {
689 panic!("legacy signature must still verify");
690 };
691 assert!(!v.signer_bound && !v.trusted);
692 assert_eq!(v.status(), "legacy_unbound");
693 assert!(v.describe().contains("legacy signature"));
694
695 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
696 }
697}