Skip to main content

lenso_contracts/
catalog.rs

1use crate::{ArtifactReference, AttestationReference, ModuleDelivery, ModuleRelease, digest_json};
2use chrono::{DateTime, Duration, Utc};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet};
6
7pub const CATALOG_SNAPSHOT_PROTOCOL: &str = "lenso.catalog-snapshot.v1";
8pub const VERIFICATION_PROFILE_PROTOCOL: &str = "lenso.module-verification-profile.v1";
9pub const VERIFICATION_RECEIPT_PROTOCOL: &str = "lenso.module-verification-receipt.v1";
10pub const LINKED_PROVENANCE_RECEIPT_PROTOCOL: &str = "lenso.linked-provenance-receipt.v1";
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
13#[serde(deny_unknown_fields)]
14pub struct GitHubIdentity {
15    pub login: String,
16    pub user_id: u64,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20#[serde(rename_all = "snake_case")]
21pub enum PublisherStatus {
22    Active,
23    Suspended,
24    Archived,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
28#[serde(deny_unknown_fields)]
29pub struct PublisherRecord {
30    pub publisher_id: String,
31    pub namespaces: Vec<String>,
32    pub owner: GitHubIdentity,
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub maintainers: Vec<GitHubIdentity>,
35    pub github_owner_id: u64,
36    pub github_repository_id: u64,
37    pub repository: String,
38    pub publishing_workflow: String,
39    pub security_contact: String,
40    pub status: PublisherStatus,
41    pub source_revision: String,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45#[serde(deny_unknown_fields)]
46pub struct CatalogApproval {
47    pub actor: GitHubIdentity,
48    pub source_revision: String,
49    pub approved_at: DateTime<Utc>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
53#[serde(tag = "kind", rename_all = "snake_case")]
54pub enum PublisherGovernanceAction {
55    OrdinaryChange,
56    NormalTransfer {
57        receiving_owner: GitHubIdentity,
58    },
59    RecoveryTransfer {
60        receiving_owner: GitHubIdentity,
61        waiting_started_at: DateTime<Utc>,
62    },
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
66#[serde(deny_unknown_fields)]
67pub struct PublisherGovernanceDecision {
68    pub approved: bool,
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub reason_codes: Vec<String>,
71}
72
73pub fn evaluate_publisher_governance(
74    publisher: &PublisherRecord,
75    submitter_user_id: u64,
76    catalog_maintainer_ids: &BTreeSet<u64>,
77    proposed_source_revision: &str,
78    action: &PublisherGovernanceAction,
79    approvals: &[CatalogApproval],
80    now: DateTime<Utc>,
81) -> PublisherGovernanceDecision {
82    let publisher_actor_ids = std::iter::once(publisher.owner.user_id)
83        .chain(
84            publisher
85                .maintainers
86                .iter()
87                .map(|maintainer| maintainer.user_id),
88        )
89        .collect::<BTreeSet<_>>();
90    let valid_approvers = approvals
91        .iter()
92        .filter(|approval| approval.source_revision == proposed_source_revision)
93        .map(|approval| approval.actor.user_id)
94        .collect::<BTreeSet<_>>();
95    let independent_catalog_approvers = valid_approvers
96        .iter()
97        .filter(|actor| {
98            catalog_maintainer_ids.contains(actor)
99                && !publisher_actor_ids.contains(actor)
100                && **actor != submitter_user_id
101        })
102        .copied()
103        .collect::<BTreeSet<_>>();
104    let mut reasons = Vec::new();
105
106    if proposed_source_revision.trim().is_empty() {
107        reasons.push("source_revision_missing".to_owned());
108    }
109    let self_approval_attempted = catalog_maintainer_ids.contains(&submitter_user_id)
110        && valid_approvers.contains(&submitter_user_id);
111
112    match action {
113        PublisherGovernanceAction::OrdinaryChange => {
114            if independent_catalog_approvers.is_empty() {
115                reasons.push("independent_catalog_approval_missing".to_owned());
116                if self_approval_attempted {
117                    reasons.push("self_approval_forbidden".to_owned());
118                }
119            }
120        }
121        PublisherGovernanceAction::NormalTransfer { receiving_owner } => {
122            if submitter_user_id != publisher.owner.user_id {
123                reasons.push("only_current_owner_may_transfer".to_owned());
124            }
125            if !valid_approvers.contains(&publisher.owner.user_id) {
126                reasons.push("current_owner_approval_missing".to_owned());
127            }
128            if !valid_approvers.contains(&receiving_owner.user_id) {
129                reasons.push("receiving_owner_approval_missing".to_owned());
130            }
131            if independent_catalog_approvers.is_empty() {
132                reasons.push("independent_catalog_approval_missing".to_owned());
133                if self_approval_attempted {
134                    reasons.push("self_approval_forbidden".to_owned());
135                }
136            }
137        }
138        PublisherGovernanceAction::RecoveryTransfer {
139            waiting_started_at, ..
140        } => {
141            let catalog_approvers = valid_approvers
142                .intersection(catalog_maintainer_ids)
143                .filter(|actor| **actor != submitter_user_id)
144                .count();
145            if catalog_approvers < 2 {
146                reasons.push("two_catalog_approvals_required".to_owned());
147                if self_approval_attempted {
148                    reasons.push("self_approval_forbidden".to_owned());
149                }
150            }
151            if now.signed_duration_since(*waiting_started_at) < Duration::days(14) {
152                reasons.push("recovery_waiting_period_incomplete".to_owned());
153            }
154        }
155    }
156    reasons.sort();
157    reasons.dedup();
158    PublisherGovernanceDecision {
159        approved: reasons.is_empty(),
160        reason_codes: reasons,
161    }
162}
163
164#[derive(
165    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
166)]
167#[serde(rename_all = "snake_case")]
168pub enum ModuleDeliveryKind {
169    Linked,
170    Service,
171}
172
173impl From<&ModuleDelivery> for ModuleDeliveryKind {
174    fn from(delivery: &ModuleDelivery) -> Self {
175        match delivery {
176            ModuleDelivery::Linked(_) => Self::Linked,
177            ModuleDelivery::Service(_) => Self::Service,
178        }
179    }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
183#[serde(deny_unknown_fields)]
184pub struct CatalogReleaseRecord {
185    pub publisher_id: String,
186    pub module_id: String,
187    pub version: String,
188    pub release_digest: String,
189    pub release: ArtifactReference,
190    pub delivery_kind: ModuleDeliveryKind,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
194#[serde(deny_unknown_fields)]
195pub struct CatalogModuleMetadata {
196    pub module_id: String,
197    pub description: String,
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub categories: Vec<String>,
200    #[serde(default, skip_serializing_if = "Vec::is_empty")]
201    pub documentation: Vec<String>,
202    pub source_revision: String,
203}
204
205#[derive(
206    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
207)]
208#[serde(rename_all = "snake_case")]
209pub enum ModuleLifecycleFacet {
210    Deprecated,
211    Yanked,
212    SecurityBlocked,
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
216#[serde(rename_all = "snake_case")]
217pub enum ModuleLifecycleChange {
218    Set,
219    Clear,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
223#[serde(deny_unknown_fields)]
224pub struct ModuleLifecycleRecord {
225    pub release_digest: String,
226    pub facet: ModuleLifecycleFacet,
227    pub change: ModuleLifecycleChange,
228    pub reason_code: String,
229    pub evidence_reference: String,
230    pub actor: GitHubIdentity,
231    pub source_revision: String,
232    pub sequence: u64,
233    pub effective_at: DateTime<Utc>,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub replacement_module_id: Option<String>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub guidance: Option<String>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub recovery_conditions: Option<String>,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
243#[serde(deny_unknown_fields)]
244pub struct TrustedPublishingEvidence {
245    pub repository: String,
246    pub repository_id: u64,
247    pub workflow: String,
248    pub run_id: u64,
249    pub commit_sha: String,
250    pub oidc_issuer: String,
251    pub runner_environment: String,
252    pub runner_image_digest: String,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
256#[serde(deny_unknown_fields)]
257pub struct ArtifactAttestationEvidence {
258    pub attestation: AttestationReference,
259    pub repository: String,
260    pub repository_id: u64,
261    pub workflow: String,
262    pub run_id: u64,
263    pub commit_sha: String,
264    pub oidc_issuer: String,
265    pub runner_environment: String,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
269#[serde(deny_unknown_fields)]
270pub struct CleanBuildEvidence {
271    pub lenso_version: String,
272    pub cli_version: String,
273    pub starter_digest: String,
274    pub toolchain: String,
275    pub application_lock_digest: String,
276    pub runner_image_digest: String,
277    pub builder: VerifierIdentity,
278    pub commands: Vec<String>,
279    pub checks: Vec<VerificationCheck>,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
283#[serde(deny_unknown_fields)]
284pub struct LinkedProvenanceReceipt {
285    pub protocol: String,
286    pub receipt_id: String,
287    pub issued_at: DateTime<Utc>,
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub supersedes_receipt_digest: Option<String>,
290    pub publisher_id: String,
291    pub module_release_digest: String,
292    pub package: String,
293    pub crate_version: String,
294    pub archive_size: u64,
295    pub archive_checksum: String,
296    pub trusted_publishing: TrustedPublishingEvidence,
297    pub artifact_attestation: ArtifactAttestationEvidence,
298    pub clean_build: CleanBuildEvidence,
299}
300
301#[derive(
302    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
303)]
304#[serde(rename_all = "snake_case")]
305pub enum VerificationOperation {
306    FreshInstall,
307    Upgrade,
308    Restore,
309    Uninstall,
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
313#[serde(rename_all = "snake_case")]
314pub enum VerificationCheckOutcome {
315    Passed,
316    Failed,
317    NotApplicable,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
321#[serde(deny_unknown_fields)]
322pub struct VerificationCheck {
323    pub check_id: String,
324    pub outcome: VerificationCheckOutcome,
325    pub duration_ms: u64,
326    #[serde(default, skip_serializing_if = "Vec::is_empty")]
327    pub evidence: Vec<ArtifactReference>,
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
331#[serde(deny_unknown_fields)]
332pub struct VerificationProfile {
333    pub protocol: String,
334    pub profile_id: String,
335    pub policy_revision: String,
336    pub required_checks: BTreeMap<VerificationOperation, Vec<String>>,
337    pub accepted_verifier_repository_ids: Vec<u64>,
338    pub accepted_verifier_workflows: Vec<String>,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
342#[serde(deny_unknown_fields)]
343pub struct ModuleVerificationCell {
344    pub module_release_digest: String,
345    pub operation: VerificationOperation,
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub source_release_digest: Option<String>,
348    pub lenso_version: String,
349    pub host_version: String,
350    pub cli_version: String,
351    pub starter_digest: String,
352    pub management_engine_version: String,
353    pub delivery_digest: String,
354    #[serde(default, skip_serializing_if = "Vec::is_empty")]
355    pub features: Vec<String>,
356    pub target: String,
357    pub os: String,
358    pub architecture: String,
359    pub runner_image_digest: String,
360    pub rust_version: String,
361    pub cargo_version: String,
362    pub store_engine: String,
363    pub store_version: String,
364    #[serde(default, skip_serializing_if = "Vec::is_empty")]
365    pub protocol_digests: Vec<String>,
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub console_artifact_digest: Option<String>,
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub console_host_api_version: Option<String>,
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub node_version: Option<String>,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub package_manager_version: Option<String>,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub console_lock_digest: Option<String>,
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
379#[serde(rename_all = "snake_case")]
380pub enum VerificationOutcome {
381    Passed,
382    Failed,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
386#[serde(deny_unknown_fields)]
387pub struct VerificationToolchainEvidence {
388    pub application_lock_digest: String,
389    pub cargo_lock_digest: String,
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub console_lock_digest: Option<String>,
392    pub config_input_digest: String,
393    pub migration_history_digest: String,
394}
395
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
397#[serde(deny_unknown_fields)]
398pub struct VerifierIdentity {
399    pub repository: String,
400    pub repository_id: u64,
401    pub workflow: String,
402    pub run_id: u64,
403    pub commit_sha: String,
404    pub oidc_issuer: String,
405    pub signer: String,
406}
407
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
409#[serde(deny_unknown_fields)]
410pub struct ModuleVerificationReceipt {
411    pub protocol: String,
412    pub receipt_id: String,
413    pub publisher_id: String,
414    pub manifest_digest: String,
415    pub catalog_snapshot_digest: String,
416    pub verification_profile_digest: String,
417    pub cell: ModuleVerificationCell,
418    pub outcome: VerificationOutcome,
419    pub started_at: DateTime<Utc>,
420    pub completed_at: DateTime<Utc>,
421    #[serde(default, skip_serializing_if = "Vec::is_empty")]
422    pub issues: Vec<String>,
423    pub toolchain_evidence: VerificationToolchainEvidence,
424    pub commands: Vec<String>,
425    pub checks: Vec<VerificationCheck>,
426    pub verifier: VerifierIdentity,
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
430#[serde(deny_unknown_fields)]
431pub struct AttestedVerificationReceipt {
432    pub receipt: ModuleVerificationReceipt,
433    pub receipt_digest: String,
434    pub attestation: AttestationReference,
435}
436
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
438#[serde(deny_unknown_fields)]
439pub struct VerificationRevocation {
440    pub receipt_digest: String,
441    pub reason_code: String,
442    pub evidence_reference: String,
443    pub actor: GitHubIdentity,
444    pub source_revision: String,
445    pub effective_at: DateTime<Utc>,
446}
447
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
449#[serde(deny_unknown_fields)]
450pub struct CatalogSnapshot {
451    pub protocol: String,
452    pub source_revision: String,
453    pub generated_at: DateTime<Utc>,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub previous_snapshot_digest: Option<String>,
456    pub verification_profile: VerificationProfile,
457    pub verification_profile_digest: String,
458    pub publishers: Vec<PublisherRecord>,
459    #[serde(default, skip_serializing_if = "Vec::is_empty")]
460    pub metadata: Vec<CatalogModuleMetadata>,
461    pub releases: Vec<CatalogReleaseRecord>,
462    #[serde(default, skip_serializing_if = "Vec::is_empty")]
463    pub lifecycle: Vec<ModuleLifecycleRecord>,
464    #[serde(default, skip_serializing_if = "Vec::is_empty")]
465    pub linked_provenance: Vec<LinkedProvenanceReceipt>,
466    #[serde(default, skip_serializing_if = "Vec::is_empty")]
467    pub verification_receipts: Vec<AttestedVerificationReceipt>,
468    #[serde(default, skip_serializing_if = "Vec::is_empty")]
469    pub verification_revocations: Vec<VerificationRevocation>,
470}
471
472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
473#[serde(deny_unknown_fields)]
474pub struct CatalogSnapshotEnvelope {
475    pub snapshot: CatalogSnapshot,
476    pub snapshot_digest: String,
477    pub attestation: AttestationReference,
478}
479
480#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
481#[serde(deny_unknown_fields)]
482pub struct CatalogAttestationTrustPolicy {
483    pub trusted_issuers: Vec<String>,
484    pub trusted_signers: Vec<String>,
485}
486
487#[derive(Debug, Clone, Copy)]
488pub struct VerifiedCatalogSnapshot<'a> {
489    snapshot: &'a CatalogSnapshot,
490    snapshot_digest: &'a str,
491}
492
493impl<'a> VerifiedCatalogSnapshot<'a> {
494    #[must_use]
495    pub fn snapshot(&self) -> &'a CatalogSnapshot {
496        self.snapshot
497    }
498
499    #[must_use]
500    pub fn snapshot_digest(&self) -> &'a str {
501        self.snapshot_digest
502    }
503}
504
505#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct CatalogContractIssue {
507    pub path: String,
508    pub message: String,
509}
510
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
512#[serde(rename_all = "snake_case")]
513pub enum DeclaredCompatibilityState {
514    Compatible,
515    Incompatible,
516    Unknown,
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
520#[serde(rename_all = "snake_case")]
521pub enum VerificationState {
522    Verified,
523    Failed,
524    Unknown,
525}
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
528#[serde(deny_unknown_fields)]
529pub struct VerificationEvaluation {
530    pub state: VerificationState,
531    pub reason_code: String,
532    #[serde(default, skip_serializing_if = "Vec::is_empty")]
533    pub receipt_digests: Vec<String>,
534}
535
536#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
537#[serde(deny_unknown_fields)]
538pub struct ModuleLifecycleState {
539    pub deprecated: bool,
540    pub yanked: bool,
541    pub security_blocked: bool,
542}
543
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
545#[serde(rename_all = "snake_case")]
546pub enum CatalogAction {
547    Discover,
548    Install,
549    Update,
550    Restore,
551    Continue,
552}
553
554#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
555#[serde(rename_all = "snake_case")]
556pub enum ModuleEligibilityState {
557    Eligible,
558    EligibleWithWarning,
559    Blocked,
560    BreakGlassOnly,
561}
562
563#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
564#[serde(deny_unknown_fields)]
565pub struct ModuleTrustPolicy {
566    pub verification: VerificationRequirement,
567    pub maximum_mutation_age_seconds: u64,
568    pub stale_snapshot: StaleSnapshotPolicy,
569    pub compatibility: CompatibilityPolicy,
570    pub security_restore: SecurityRestorePolicy,
571}
572
573#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
574#[serde(rename_all = "snake_case")]
575pub enum VerificationRequirement {
576    AllowUnknown,
577    RequireVerified,
578}
579
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
581#[serde(rename_all = "snake_case")]
582pub enum StaleSnapshotPolicy {
583    Reject,
584    AllowOffline,
585}
586
587#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
588#[serde(rename_all = "snake_case")]
589pub enum CompatibilityPolicy {
590    Strict,
591    AllowOverride,
592}
593
594#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
595#[serde(rename_all = "snake_case")]
596pub enum SecurityRestorePolicy {
597    Block,
598    BreakGlass,
599}
600
601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
602#[serde(deny_unknown_fields)]
603pub struct ModuleEligibility {
604    pub action: CatalogAction,
605    pub state: ModuleEligibilityState,
606    pub declared_compatibility: DeclaredCompatibilityState,
607    pub verification: VerificationEvaluation,
608    pub lifecycle: ModuleLifecycleState,
609    pub snapshot_age_seconds: u64,
610    pub snapshot_fresh: bool,
611    #[serde(default, skip_serializing_if = "Vec::is_empty")]
612    pub reason_codes: Vec<String>,
613}
614
615pub fn validate_catalog_snapshot(
616    envelope: &CatalogSnapshotEnvelope,
617    releases: &[ModuleRelease],
618) -> Vec<CatalogContractIssue> {
619    let mut issues = Vec::new();
620    let snapshot = &envelope.snapshot;
621    if snapshot.protocol != CATALOG_SNAPSHOT_PROTOCOL {
622        push_issue(
623            &mut issues,
624            "$.snapshot.protocol",
625            "unsupported catalog protocol",
626        );
627    }
628    if snapshot.source_revision.trim().is_empty() {
629        push_issue(
630            &mut issues,
631            "$.snapshot.source_revision",
632            "Catalog source revision must be non-empty",
633        );
634    }
635    if let Some(previous) = &snapshot.previous_snapshot_digest {
636        validate_digest(previous, "$.snapshot.previous_snapshot_digest", &mut issues);
637    }
638    match digest_json(snapshot) {
639        Ok(digest) if digest == envelope.snapshot_digest => {}
640        Ok(_) => push_issue(&mut issues, "$.snapshot_digest", "snapshot digest mismatch"),
641        Err(error) => push_issue(
642            &mut issues,
643            "$.snapshot",
644            format!("snapshot cannot be canonicalized: {error}"),
645        ),
646    }
647    validate_digest(&envelope.snapshot_digest, "$.snapshot_digest", &mut issues);
648    if envelope.attestation.digest != envelope.snapshot_digest {
649        push_issue(
650            &mut issues,
651            "$.attestation.digest",
652            "snapshot attestation must bind the exact snapshot digest",
653        );
654    }
655    if envelope.attestation.locator.trim().is_empty()
656        || envelope.attestation.issuer.trim().is_empty()
657        || envelope.attestation.signer.trim().is_empty()
658    {
659        push_issue(
660            &mut issues,
661            "$.attestation",
662            "snapshot attestation identity fields must be non-empty",
663        );
664    }
665    if snapshot.verification_profile.protocol != VERIFICATION_PROFILE_PROTOCOL {
666        push_issue(
667            &mut issues,
668            "$.snapshot.verification_profile.protocol",
669            "unsupported verification profile protocol",
670        );
671    }
672    match digest_json(&snapshot.verification_profile) {
673        Ok(digest) if digest == snapshot.verification_profile_digest => {}
674        _ => push_issue(
675            &mut issues,
676            "$.snapshot.verification_profile_digest",
677            "verification profile digest mismatch",
678        ),
679    }
680    validate_digest(
681        &snapshot.verification_profile_digest,
682        "$.snapshot.verification_profile_digest",
683        &mut issues,
684    );
685    validate_verification_profile(&snapshot.verification_profile, &mut issues);
686
687    validate_publishers(snapshot, &mut issues);
688    validate_metadata(snapshot, &mut issues);
689    validate_releases(snapshot, releases, &mut issues);
690    validate_provenance(snapshot, releases, &mut issues);
691    validate_receipts(snapshot, releases, &mut issues);
692    validate_lifecycle(snapshot, &mut issues);
693    issues
694}
695
696pub fn admit_catalog_snapshot<'a>(
697    envelope: &'a CatalogSnapshotEnvelope,
698    releases: &[ModuleRelease],
699    trust_policy: &CatalogAttestationTrustPolicy,
700    cryptographic_attestation_verified: bool,
701) -> Result<VerifiedCatalogSnapshot<'a>, Vec<CatalogContractIssue>> {
702    let mut issues = validate_catalog_snapshot(envelope, releases);
703    if !cryptographic_attestation_verified {
704        push_issue(
705            &mut issues,
706            "$.attestation",
707            "snapshot attestation has not been cryptographically verified",
708        );
709    }
710    if !trust_policy
711        .trusted_issuers
712        .contains(&envelope.attestation.issuer)
713    {
714        push_issue(
715            &mut issues,
716            "$.attestation.issuer",
717            "snapshot attestation issuer is not trusted",
718        );
719    }
720    if !trust_policy
721        .trusted_signers
722        .contains(&envelope.attestation.signer)
723    {
724        push_issue(
725            &mut issues,
726            "$.attestation.signer",
727            "snapshot attestation signer is not trusted",
728        );
729    }
730    if issues.is_empty() {
731        Ok(VerifiedCatalogSnapshot {
732            snapshot: &envelope.snapshot,
733            snapshot_digest: &envelope.snapshot_digest,
734        })
735    } else {
736        Err(issues)
737    }
738}
739
740pub fn verification_evaluation(
741    snapshot: &CatalogSnapshot,
742    requested: &ModuleVerificationCell,
743) -> VerificationEvaluation {
744    let revoked = snapshot
745        .verification_revocations
746        .iter()
747        .map(|record| record.receipt_digest.as_str())
748        .collect::<BTreeSet<_>>();
749    let mut passed = Vec::new();
750    let mut failed = Vec::new();
751    let mut obsolete = false;
752    for accepted in &snapshot.verification_receipts {
753        if accepted.receipt.cell != *requested || revoked.contains(accepted.receipt_digest.as_str())
754        {
755            continue;
756        }
757        if accepted.receipt.verification_profile_digest != snapshot.verification_profile_digest {
758            obsolete = true;
759            continue;
760        }
761        match accepted.receipt.outcome {
762            VerificationOutcome::Passed => passed.push(accepted.receipt_digest.clone()),
763            VerificationOutcome::Failed => failed.push(accepted.receipt_digest.clone()),
764        }
765    }
766    passed.sort();
767    failed.sort();
768    if !passed.is_empty() && !failed.is_empty() {
769        let mut receipts = passed;
770        receipts.extend(failed);
771        receipts.sort();
772        return VerificationEvaluation {
773            state: VerificationState::Unknown,
774            reason_code: "receipt_conflict".to_owned(),
775            receipt_digests: receipts,
776        };
777    }
778    if !passed.is_empty() {
779        return VerificationEvaluation {
780            state: VerificationState::Verified,
781            reason_code: "exact_cell_passed".to_owned(),
782            receipt_digests: passed,
783        };
784    }
785    if !failed.is_empty() {
786        return VerificationEvaluation {
787            state: VerificationState::Failed,
788            reason_code: "exact_cell_failed".to_owned(),
789            receipt_digests: failed,
790        };
791    }
792    VerificationEvaluation {
793        state: VerificationState::Unknown,
794        reason_code: if obsolete {
795            "obsolete_profile"
796        } else {
797            "missing_receipt"
798        }
799        .to_owned(),
800        receipt_digests: Vec::new(),
801    }
802}
803
804pub fn lifecycle_state(snapshot: &CatalogSnapshot, release_digest: &str) -> ModuleLifecycleState {
805    let mut records = snapshot
806        .lifecycle
807        .iter()
808        .filter(|record| record.release_digest == release_digest)
809        .collect::<Vec<_>>();
810    records.sort_by_key(|record| (record.effective_at, record.sequence));
811    let mut state = ModuleLifecycleState::default();
812    for record in records {
813        let active = record.change == ModuleLifecycleChange::Set;
814        match record.facet {
815            ModuleLifecycleFacet::Deprecated => state.deprecated = active,
816            ModuleLifecycleFacet::Yanked => state.yanked = active,
817            ModuleLifecycleFacet::SecurityBlocked => state.security_blocked = active,
818        }
819    }
820    state
821}
822
823#[allow(clippy::too_many_arguments)]
824pub fn evaluate_module_eligibility(
825    verified_snapshot: &VerifiedCatalogSnapshot<'_>,
826    release_digest: &str,
827    requested_cell: &ModuleVerificationCell,
828    declared_compatibility: DeclaredCompatibilityState,
829    action: CatalogAction,
830    policy: &ModuleTrustPolicy,
831    now: DateTime<Utc>,
832    offline: bool,
833) -> ModuleEligibility {
834    let snapshot = verified_snapshot.snapshot();
835    let verification = verification_evaluation(snapshot, requested_cell);
836    let lifecycle = lifecycle_state(snapshot, release_digest);
837    let age = u64::try_from(
838        now.signed_duration_since(snapshot.generated_at)
839            .num_seconds(),
840    )
841    .unwrap_or(0);
842    let mutation = !matches!(action, CatalogAction::Discover | CatalogAction::Continue);
843    let fresh = age <= policy.maximum_mutation_age_seconds;
844    let stale_allowed = offline && policy.stale_snapshot == StaleSnapshotPolicy::AllowOffline;
845    let mut state = ModuleEligibilityState::Eligible;
846    let mut reasons = Vec::new();
847
848    apply_lifecycle_eligibility(&lifecycle, action, policy, &mut state, &mut reasons);
849
850    if mutation && !fresh && !stale_allowed {
851        state = ModuleEligibilityState::Blocked;
852        reasons.push("snapshot_stale".to_owned());
853    } else if mutation && !fresh {
854        warn(&mut state);
855        reasons.push("snapshot_stale_offline_override".to_owned());
856    }
857
858    if mutation {
859        match declared_compatibility {
860            DeclaredCompatibilityState::Compatible => {}
861            DeclaredCompatibilityState::Incompatible
862                if policy.compatibility == CompatibilityPolicy::AllowOverride =>
863            {
864                warn(&mut state);
865                reasons.push("compatibility_override".to_owned());
866            }
867            DeclaredCompatibilityState::Incompatible => {
868                state = ModuleEligibilityState::Blocked;
869                reasons.push("declared_incompatible".to_owned());
870            }
871            DeclaredCompatibilityState::Unknown
872                if policy.verification == VerificationRequirement::RequireVerified =>
873            {
874                state = ModuleEligibilityState::Blocked;
875                reasons.push("declared_compatibility_unknown".to_owned());
876            }
877            DeclaredCompatibilityState::Unknown => {
878                warn(&mut state);
879                reasons.push("declared_compatibility_unknown".to_owned());
880            }
881        }
882        match verification.state {
883            VerificationState::Verified => {}
884            VerificationState::Failed
885                if policy.compatibility == CompatibilityPolicy::AllowOverride =>
886            {
887                warn(&mut state);
888                reasons.push("verification_override".to_owned());
889            }
890            VerificationState::Failed => {
891                state = ModuleEligibilityState::Blocked;
892                reasons.push("verification_failed".to_owned());
893            }
894            VerificationState::Unknown
895                if policy.verification == VerificationRequirement::RequireVerified =>
896            {
897                state = ModuleEligibilityState::Blocked;
898                reasons.push(verification.reason_code.clone());
899            }
900            VerificationState::Unknown => {
901                warn(&mut state);
902                reasons.push(verification.reason_code.clone());
903            }
904        }
905    }
906    reasons.sort();
907    reasons.dedup();
908    ModuleEligibility {
909        action,
910        state,
911        declared_compatibility,
912        verification,
913        lifecycle,
914        snapshot_age_seconds: age,
915        snapshot_fresh: fresh,
916        reason_codes: reasons,
917    }
918}
919
920fn apply_lifecycle_eligibility(
921    lifecycle: &ModuleLifecycleState,
922    action: CatalogAction,
923    policy: &ModuleTrustPolicy,
924    state: &mut ModuleEligibilityState,
925    reasons: &mut Vec<String>,
926) {
927    if lifecycle.security_blocked {
928        reasons.push("security_blocked".to_owned());
929        *state = match action {
930            CatalogAction::Restore
931                if policy.security_restore == SecurityRestorePolicy::BreakGlass =>
932            {
933                ModuleEligibilityState::EligibleWithWarning
934            }
935            CatalogAction::Restore => ModuleEligibilityState::BreakGlassOnly,
936            CatalogAction::Install | CatalogAction::Update => ModuleEligibilityState::Blocked,
937            CatalogAction::Discover | CatalogAction::Continue => {
938                ModuleEligibilityState::EligibleWithWarning
939            }
940        };
941        return;
942    }
943    if lifecycle.yanked {
944        reasons.push("yanked".to_owned());
945        match action {
946            CatalogAction::Discover | CatalogAction::Install | CatalogAction::Update => {
947                *state = ModuleEligibilityState::Blocked;
948            }
949            CatalogAction::Restore | CatalogAction::Continue => warn(state),
950        }
951    }
952    if lifecycle.deprecated {
953        reasons.push("deprecated".to_owned());
954        warn(state);
955    }
956}
957
958fn warn(state: &mut ModuleEligibilityState) {
959    if *state == ModuleEligibilityState::Eligible {
960        *state = ModuleEligibilityState::EligibleWithWarning;
961    }
962}
963
964fn validate_publishers(snapshot: &CatalogSnapshot, issues: &mut Vec<CatalogContractIssue>) {
965    let mut publisher_ids = BTreeSet::new();
966    let mut namespaces = BTreeSet::new();
967    if !snapshot
968        .publishers
969        .windows(2)
970        .all(|pair| pair[0].publisher_id < pair[1].publisher_id)
971    {
972        push_issue(
973            issues,
974            "$.snapshot.publishers",
975            "Publishers must be sorted by Publisher identity",
976        );
977    }
978    for (index, publisher) in snapshot.publishers.iter().enumerate() {
979        if !publisher_ids.insert(publisher.publisher_id.as_str()) {
980            push_issue(
981                issues,
982                format!("$.snapshot.publishers[{index}].publisher_id"),
983                "duplicate Publisher identity",
984            );
985        }
986        if publisher.namespaces.is_empty() || !sorted_unique(&publisher.namespaces) {
987            push_issue(
988                issues,
989                format!("$.snapshot.publishers[{index}].namespaces"),
990                "Publisher namespaces must be sorted and unique",
991            );
992        }
993        for namespace in &publisher.namespaces {
994            if !valid_namespace(namespace) || !namespaces.insert(namespace.as_str()) {
995                push_issue(
996                    issues,
997                    format!("$.snapshot.publishers[{index}].namespaces"),
998                    "Publisher namespace must be valid and uniquely owned",
999                );
1000            }
1001        }
1002        let mut actors = BTreeSet::new();
1003        actors.insert(publisher.owner.user_id);
1004        for maintainer in &publisher.maintainers {
1005            if !actors.insert(maintainer.user_id) {
1006                push_issue(
1007                    issues,
1008                    format!("$.snapshot.publishers[{index}].maintainers"),
1009                    "Publisher actors must have unique numeric GitHub identities",
1010                );
1011            }
1012        }
1013        if publisher.publisher_id.trim().is_empty()
1014            || publisher.repository.trim().is_empty()
1015            || publisher.publishing_workflow.trim().is_empty()
1016            || publisher.security_contact.trim().is_empty()
1017        {
1018            push_issue(
1019                issues,
1020                format!("$.snapshot.publishers[{index}]"),
1021                "Publisher trust fields must be non-empty",
1022            );
1023        }
1024    }
1025}
1026
1027fn validate_metadata(snapshot: &CatalogSnapshot, issues: &mut Vec<CatalogContractIssue>) {
1028    let release_module_ids = snapshot
1029        .releases
1030        .iter()
1031        .map(|release| release.module_id.as_str())
1032        .collect::<BTreeSet<_>>();
1033    if !snapshot
1034        .metadata
1035        .windows(2)
1036        .all(|pair| pair[0].module_id < pair[1].module_id)
1037    {
1038        push_issue(
1039            issues,
1040            "$.snapshot.metadata",
1041            "Module metadata must be sorted by Module identity",
1042        );
1043    }
1044    let mut module_ids = BTreeSet::new();
1045    for (index, metadata) in snapshot.metadata.iter().enumerate() {
1046        if !module_ids.insert(metadata.module_id.as_str()) {
1047            push_issue(
1048                issues,
1049                format!("$.snapshot.metadata[{index}].module_id"),
1050                "duplicate Module metadata",
1051            );
1052        }
1053        if !release_module_ids.contains(metadata.module_id.as_str()) {
1054            push_issue(
1055                issues,
1056                format!("$.snapshot.metadata[{index}].module_id"),
1057                "Module metadata references no indexed release",
1058            );
1059        }
1060        if metadata.description.trim().is_empty() || metadata.source_revision.trim().is_empty() {
1061            push_issue(
1062                issues,
1063                format!("$.snapshot.metadata[{index}]"),
1064                "Module metadata description and source revision must be non-empty",
1065            );
1066        }
1067        if !sorted_unique(&metadata.categories) || !sorted_unique(&metadata.documentation) {
1068            push_issue(
1069                issues,
1070                format!("$.snapshot.metadata[{index}]"),
1071                "Module metadata lists must be sorted and unique",
1072            );
1073        }
1074    }
1075}
1076
1077fn validate_releases(
1078    snapshot: &CatalogSnapshot,
1079    releases: &[ModuleRelease],
1080    issues: &mut Vec<CatalogContractIssue>,
1081) {
1082    let release_by_digest = releases
1083        .iter()
1084        .filter_map(|release| digest_json(release).ok().map(|digest| (digest, release)))
1085        .collect::<BTreeMap<_, _>>();
1086    let publishers = snapshot
1087        .publishers
1088        .iter()
1089        .map(|publisher| (publisher.publisher_id.as_str(), publisher))
1090        .collect::<BTreeMap<_, _>>();
1091    let mut identities = BTreeSet::new();
1092    let mut digests = BTreeSet::new();
1093    if !snapshot
1094        .releases
1095        .windows(2)
1096        .all(|pair| (&pair[0].module_id, &pair[0].version) < (&pair[1].module_id, &pair[1].version))
1097    {
1098        push_issue(
1099            issues,
1100            "$.snapshot.releases",
1101            "Catalog releases must be sorted by Module identity and version",
1102        );
1103    }
1104    for (index, record) in snapshot.releases.iter().enumerate() {
1105        validate_digest(
1106            &record.release_digest,
1107            &format!("$.snapshot.releases[{index}].release_digest"),
1108            issues,
1109        );
1110        if record.release.digest != record.release_digest {
1111            push_issue(
1112                issues,
1113                format!("$.snapshot.releases[{index}].release.digest"),
1114                "release locator must bind the indexed digest",
1115            );
1116        }
1117        if !identities.insert((record.module_id.as_str(), record.version.as_str())) {
1118            push_issue(
1119                issues,
1120                format!("$.snapshot.releases[{index}]"),
1121                "Module release identity is immutable and unique",
1122            );
1123        }
1124        if !digests.insert(record.release_digest.as_str()) {
1125            push_issue(
1126                issues,
1127                format!("$.snapshot.releases[{index}].release_digest"),
1128                "duplicate release digest",
1129            );
1130        }
1131        let Some(publisher) = publishers.get(record.publisher_id.as_str()) else {
1132            push_issue(
1133                issues,
1134                format!("$.snapshot.releases[{index}].publisher_id"),
1135                "unknown Publisher",
1136            );
1137            continue;
1138        };
1139        let namespace = record
1140            .module_id
1141            .split_once('/')
1142            .map(|(namespace, _)| namespace);
1143        if !namespace
1144            .is_some_and(|namespace| publisher.namespaces.iter().any(|owned| owned == namespace))
1145        {
1146            push_issue(
1147                issues,
1148                format!("$.snapshot.releases[{index}].module_id"),
1149                "Module namespace is not owned by the Publisher",
1150            );
1151        }
1152        let Some(release) = release_by_digest.get(&record.release_digest) else {
1153            push_issue(
1154                issues,
1155                format!("$.snapshot.releases[{index}].release_digest"),
1156                "referenced Module Release bytes are unavailable",
1157            );
1158            continue;
1159        };
1160        if release.module_id != record.module_id
1161            || release.version != record.version
1162            || ModuleDeliveryKind::from(&release.delivery) != record.delivery_kind
1163        {
1164            push_issue(
1165                issues,
1166                format!("$.snapshot.releases[{index}]"),
1167                "Catalog projection does not match referenced Module Release bytes",
1168            );
1169        }
1170    }
1171}
1172
1173#[allow(clippy::too_many_lines)]
1174fn validate_provenance(
1175    snapshot: &CatalogSnapshot,
1176    releases: &[ModuleRelease],
1177    issues: &mut Vec<CatalogContractIssue>,
1178) {
1179    let release_by_digest = releases
1180        .iter()
1181        .filter_map(|release| digest_json(release).ok().map(|digest| (digest, release)))
1182        .collect::<BTreeMap<_, _>>();
1183    let catalog_releases = snapshot
1184        .releases
1185        .iter()
1186        .map(|release| (release.release_digest.as_str(), release))
1187        .collect::<BTreeMap<_, _>>();
1188    let publishers = snapshot
1189        .publishers
1190        .iter()
1191        .map(|publisher| (publisher.publisher_id.as_str(), publisher))
1192        .collect::<BTreeMap<_, _>>();
1193    let receipt_digests = snapshot
1194        .linked_provenance
1195        .iter()
1196        .map(digest_json)
1197        .collect::<Result<Vec<_>, _>>()
1198        .unwrap_or_default();
1199    let receipt_by_digest = receipt_digests
1200        .iter()
1201        .zip(&snapshot.linked_provenance)
1202        .map(|(digest, receipt)| (digest.as_str(), receipt))
1203        .collect::<BTreeMap<_, _>>();
1204    let superseded_receipt_digests = snapshot
1205        .linked_provenance
1206        .iter()
1207        .filter_map(|receipt| receipt.supersedes_receipt_digest.as_deref())
1208        .collect::<BTreeSet<_>>();
1209    let mut receipt_ids = BTreeSet::new();
1210    let mut unique_receipt_digests = BTreeSet::new();
1211    if !snapshot.linked_provenance.windows(2).all(|pair| {
1212        (
1213            &pair[0].module_release_digest,
1214            pair[0].issued_at,
1215            &pair[0].receipt_id,
1216        ) < (
1217            &pair[1].module_release_digest,
1218            pair[1].issued_at,
1219            &pair[1].receipt_id,
1220        )
1221    }) {
1222        push_issue(
1223            issues,
1224            "$.snapshot.linked_provenance",
1225            "Linked provenance receipts must be sorted by release, issue time, and receipt identity",
1226        );
1227    }
1228    for (index, receipt) in snapshot.linked_provenance.iter().enumerate() {
1229        if receipt.protocol != LINKED_PROVENANCE_RECEIPT_PROTOCOL {
1230            push_issue(
1231                issues,
1232                format!("$.snapshot.linked_provenance[{index}].protocol"),
1233                "unsupported linked provenance protocol",
1234            );
1235        }
1236        validate_digest(
1237            &receipt.module_release_digest,
1238            &format!("$.snapshot.linked_provenance[{index}].module_release_digest"),
1239            issues,
1240        );
1241        if !receipt_ids.insert(receipt.receipt_id.as_str())
1242            || receipt_digests
1243                .get(index)
1244                .is_some_and(|digest| !unique_receipt_digests.insert(digest.as_str()))
1245        {
1246            push_issue(
1247                issues,
1248                format!("$.snapshot.linked_provenance[{index}].receipt_id"),
1249                "duplicate Linked provenance receipt identity or bytes",
1250            );
1251        }
1252        if let Some(superseded_digest) = &receipt.supersedes_receipt_digest {
1253            validate_digest(
1254                superseded_digest,
1255                &format!("$.snapshot.linked_provenance[{index}].supersedes_receipt_digest"),
1256                issues,
1257            );
1258            match receipt_by_digest.get(superseded_digest.as_str()) {
1259                Some(superseded)
1260                    if superseded.module_release_digest == receipt.module_release_digest
1261                        && superseded.issued_at < receipt.issued_at => {}
1262                Some(_) => push_issue(
1263                    issues,
1264                    format!("$.snapshot.linked_provenance[{index}].supersedes_receipt_digest"),
1265                    "superseded provenance must be an earlier receipt for the same release",
1266                ),
1267                None => push_issue(
1268                    issues,
1269                    format!("$.snapshot.linked_provenance[{index}].supersedes_receipt_digest"),
1270                    "superseded provenance receipt is absent from the snapshot",
1271                ),
1272            }
1273        }
1274        let active = receipt_digests
1275            .get(index)
1276            .is_some_and(|digest| !superseded_receipt_digests.contains(digest.as_str()));
1277        if !active {
1278            continue;
1279        }
1280        if let Some(catalog_release) = catalog_releases.get(receipt.module_release_digest.as_str())
1281        {
1282            if catalog_release.publisher_id != receipt.publisher_id {
1283                push_issue(
1284                    issues,
1285                    format!("$.snapshot.linked_provenance[{index}].publisher_id"),
1286                    "provenance Publisher does not own the indexed release",
1287                );
1288            }
1289            if let Some(publisher) = publishers.get(receipt.publisher_id.as_str())
1290                && (receipt.trusted_publishing.repository != publisher.repository
1291                    || receipt.trusted_publishing.repository_id != publisher.github_repository_id
1292                    || receipt.trusted_publishing.workflow != publisher.publishing_workflow)
1293            {
1294                push_issue(
1295                    issues,
1296                    format!("$.snapshot.linked_provenance[{index}].trusted_publishing"),
1297                    "trusted publishing identity does not match the pinned Publisher repository",
1298                );
1299            }
1300        }
1301        validate_digest(
1302            &receipt.archive_checksum,
1303            &format!("$.snapshot.linked_provenance[{index}].archive_checksum"),
1304            issues,
1305        );
1306        let Some(release) = release_by_digest.get(&receipt.module_release_digest) else {
1307            continue;
1308        };
1309        match &release.delivery {
1310            ModuleDelivery::Linked(linked)
1311                if linked.package == receipt.package
1312                    && linked.crate_version == receipt.crate_version
1313                    && linked.archive_checksum == receipt.archive_checksum => {}
1314            _ => push_issue(
1315                issues,
1316                format!("$.snapshot.linked_provenance[{index}]"),
1317                "provenance does not match the exact Linked delivery",
1318            ),
1319        }
1320        if receipt.artifact_attestation.attestation.digest != receipt.archive_checksum {
1321            push_issue(
1322                issues,
1323                format!(
1324                    "$.snapshot.linked_provenance[{index}].artifact_attestation.attestation.digest"
1325                ),
1326                "attestation must bind the exact crates.io archive checksum",
1327            );
1328        }
1329        if receipt.artifact_attestation.repository != receipt.trusted_publishing.repository
1330            || receipt.artifact_attestation.repository_id
1331                != receipt.trusted_publishing.repository_id
1332            || receipt.artifact_attestation.workflow != receipt.trusted_publishing.workflow
1333            || receipt.artifact_attestation.run_id != receipt.trusted_publishing.run_id
1334            || receipt.artifact_attestation.commit_sha != receipt.trusted_publishing.commit_sha
1335            || receipt.artifact_attestation.oidc_issuer != receipt.trusted_publishing.oidc_issuer
1336            || receipt.artifact_attestation.runner_environment
1337                != receipt.trusted_publishing.runner_environment
1338            || receipt.artifact_attestation.attestation.issuer
1339                != receipt.artifact_attestation.oidc_issuer
1340        {
1341            push_issue(
1342                issues,
1343                format!("$.snapshot.linked_provenance[{index}].artifact_attestation"),
1344                "artifact attestation identity must match trusted publishing identity",
1345            );
1346        }
1347        if receipt.trusted_publishing.repository_id == 0
1348            || receipt.trusted_publishing.run_id == 0
1349            || receipt.artifact_attestation.repository_id == 0
1350            || receipt.artifact_attestation.run_id == 0
1351            || receipt.archive_size == 0
1352        {
1353            push_issue(
1354                issues,
1355                format!("$.snapshot.linked_provenance[{index}]"),
1356                "provenance numeric identities and archive size must be non-zero",
1357            );
1358        }
1359        if receipt.trusted_publishing.runner_environment != "github-hosted" {
1360            push_issue(
1361                issues,
1362                format!(
1363                    "$.snapshot.linked_provenance[{index}].trusted_publishing.runner_environment"
1364                ),
1365                "Linked trusted publishing must use a GitHub-hosted runner",
1366            );
1367        }
1368        for (field, digest) in [
1369            (
1370                "trusted_publishing.runner_image_digest",
1371                receipt.trusted_publishing.runner_image_digest.as_str(),
1372            ),
1373            (
1374                "clean_build.starter_digest",
1375                receipt.clean_build.starter_digest.as_str(),
1376            ),
1377            (
1378                "clean_build.application_lock_digest",
1379                receipt.clean_build.application_lock_digest.as_str(),
1380            ),
1381            (
1382                "clean_build.runner_image_digest",
1383                receipt.clean_build.runner_image_digest.as_str(),
1384            ),
1385        ] {
1386            validate_digest(
1387                digest,
1388                &format!("$.snapshot.linked_provenance[{index}].{field}"),
1389                issues,
1390            );
1391        }
1392        if receipt.clean_build.commands.is_empty()
1393            || receipt.clean_build.checks.is_empty()
1394            || receipt
1395                .clean_build
1396                .commands
1397                .iter()
1398                .any(|command| command.trim().is_empty())
1399        {
1400            push_issue(
1401                issues,
1402                format!("$.snapshot.linked_provenance[{index}].clean_build"),
1403                "clean build provenance requires exact non-empty commands and checks",
1404            );
1405        }
1406        if !receipt.clean_build.checks.iter().all(|check| {
1407            matches!(
1408                check.outcome,
1409                VerificationCheckOutcome::Passed | VerificationCheckOutcome::NotApplicable
1410            )
1411        }) {
1412            push_issue(
1413                issues,
1414                format!("$.snapshot.linked_provenance[{index}].clean_build.checks"),
1415                "clean build provenance contains a failed check",
1416            );
1417        }
1418    }
1419    for (index, release) in snapshot.releases.iter().enumerate() {
1420        if release.delivery_kind != ModuleDeliveryKind::Linked {
1421            continue;
1422        }
1423        let active_receipts = receipt_digests
1424            .iter()
1425            .zip(&snapshot.linked_provenance)
1426            .filter(|(digest, receipt)| {
1427                receipt.module_release_digest == release.release_digest
1428                    && !superseded_receipt_digests.contains(digest.as_str())
1429            })
1430            .count();
1431        if active_receipts != 1 {
1432            push_issue(
1433                issues,
1434                format!("$.snapshot.releases[{index}].release_digest"),
1435                "Linked release requires exactly one active provenance receipt",
1436            );
1437        }
1438    }
1439}
1440
1441#[allow(clippy::too_many_lines)]
1442fn validate_receipts(
1443    snapshot: &CatalogSnapshot,
1444    releases: &[ModuleRelease],
1445    issues: &mut Vec<CatalogContractIssue>,
1446) {
1447    let profile = &snapshot.verification_profile;
1448    let release_digests = snapshot
1449        .releases
1450        .iter()
1451        .map(|record| record.release_digest.as_str())
1452        .collect::<BTreeSet<_>>();
1453    let release_by_digest = releases
1454        .iter()
1455        .filter_map(|release| digest_json(release).ok().map(|digest| (digest, release)))
1456        .collect::<BTreeMap<_, _>>();
1457    let catalog_release_by_digest = snapshot
1458        .releases
1459        .iter()
1460        .map(|release| (release.release_digest.as_str(), release))
1461        .collect::<BTreeMap<_, _>>();
1462    let mut receipt_digests = BTreeSet::new();
1463    if !snapshot
1464        .verification_receipts
1465        .windows(2)
1466        .all(|pair| pair[0].receipt_digest < pair[1].receipt_digest)
1467    {
1468        push_issue(
1469            issues,
1470            "$.snapshot.verification_receipts",
1471            "verification receipts must be sorted by receipt digest",
1472        );
1473    }
1474    for (index, accepted) in snapshot.verification_receipts.iter().enumerate() {
1475        let receipt = &accepted.receipt;
1476        if receipt.protocol != VERIFICATION_RECEIPT_PROTOCOL {
1477            push_issue(
1478                issues,
1479                format!("$.snapshot.verification_receipts[{index}].receipt.protocol"),
1480                "unsupported verification receipt protocol",
1481            );
1482        }
1483        match digest_json(receipt) {
1484            Ok(digest) if digest == accepted.receipt_digest => {}
1485            _ => push_issue(
1486                issues,
1487                format!("$.snapshot.verification_receipts[{index}].receipt_digest"),
1488                "verification receipt digest mismatch",
1489            ),
1490        }
1491        validate_digest(
1492            &accepted.receipt_digest,
1493            &format!("$.snapshot.verification_receipts[{index}].receipt_digest"),
1494            issues,
1495        );
1496        if !receipt_digests.insert(accepted.receipt_digest.as_str()) {
1497            push_issue(
1498                issues,
1499                format!("$.snapshot.verification_receipts[{index}].receipt_digest"),
1500                "duplicate verification receipt",
1501            );
1502        }
1503        if accepted.attestation.digest != accepted.receipt_digest {
1504            push_issue(
1505                issues,
1506                format!("$.snapshot.verification_receipts[{index}].attestation.digest"),
1507                "receipt attestation must bind exact receipt bytes",
1508            );
1509        }
1510        if !release_digests.contains(receipt.cell.module_release_digest.as_str()) {
1511            push_issue(
1512                issues,
1513                format!(
1514                    "$.snapshot.verification_receipts[{index}].receipt.cell.module_release_digest"
1515                ),
1516                "receipt references an unknown Module Release",
1517            );
1518        }
1519        if let Some(release) = release_by_digest.get(&receipt.cell.module_release_digest)
1520            && release.manifest_digest != receipt.manifest_digest
1521        {
1522            push_issue(
1523                issues,
1524                format!("$.snapshot.verification_receipts[{index}].receipt.manifest_digest"),
1525                "receipt manifest digest does not match the exact Module Release",
1526            );
1527        }
1528        if let Some(catalog_release) =
1529            catalog_release_by_digest.get(receipt.cell.module_release_digest.as_str())
1530            && catalog_release.publisher_id != receipt.publisher_id
1531        {
1532            push_issue(
1533                issues,
1534                format!("$.snapshot.verification_receipts[{index}].receipt.publisher_id"),
1535                "receipt Publisher does not own the verified release",
1536            );
1537        }
1538        if receipt.completed_at < receipt.started_at {
1539            push_issue(
1540                issues,
1541                format!("$.snapshot.verification_receipts[{index}].receipt.completed_at"),
1542                "receipt completion precedes start",
1543            );
1544        }
1545        for (field, digest) in [
1546            ("manifest_digest", receipt.manifest_digest.as_str()),
1547            (
1548                "catalog_snapshot_digest",
1549                receipt.catalog_snapshot_digest.as_str(),
1550            ),
1551            (
1552                "verification_profile_digest",
1553                receipt.verification_profile_digest.as_str(),
1554            ),
1555            (
1556                "toolchain_evidence.application_lock_digest",
1557                receipt.toolchain_evidence.application_lock_digest.as_str(),
1558            ),
1559            (
1560                "toolchain_evidence.cargo_lock_digest",
1561                receipt.toolchain_evidence.cargo_lock_digest.as_str(),
1562            ),
1563            (
1564                "toolchain_evidence.config_input_digest",
1565                receipt.toolchain_evidence.config_input_digest.as_str(),
1566            ),
1567            (
1568                "toolchain_evidence.migration_history_digest",
1569                receipt.toolchain_evidence.migration_history_digest.as_str(),
1570            ),
1571        ] {
1572            validate_digest(
1573                digest,
1574                &format!("$.snapshot.verification_receipts[{index}].receipt.{field}"),
1575                issues,
1576            );
1577        }
1578        if receipt.commands.is_empty()
1579            || receipt.checks.is_empty()
1580            || receipt
1581                .commands
1582                .iter()
1583                .any(|command| command.trim().is_empty())
1584        {
1585            push_issue(
1586                issues,
1587                format!("$.snapshot.verification_receipts[{index}].receipt"),
1588                "verification receipt requires exact non-empty commands and checks",
1589            );
1590        }
1591        let required = profile
1592            .required_checks
1593            .get(&receipt.cell.operation)
1594            .cloned()
1595            .unwrap_or_default();
1596        let checks = receipt
1597            .checks
1598            .iter()
1599            .map(|check| check.check_id.as_str())
1600            .collect::<BTreeSet<_>>();
1601        if required
1602            .iter()
1603            .any(|check| !checks.contains(check.as_str()))
1604        {
1605            push_issue(
1606                issues,
1607                format!("$.snapshot.verification_receipts[{index}].receipt.checks"),
1608                "receipt does not cover every required profile check",
1609            );
1610        }
1611        let duplicate_check_ids = receipt
1612            .checks
1613            .iter()
1614            .map(|check| check.check_id.as_str())
1615            .collect::<Vec<_>>();
1616        if !sorted_unique(&duplicate_check_ids) {
1617            push_issue(
1618                issues,
1619                format!("$.snapshot.verification_receipts[{index}].receipt.checks"),
1620                "receipt checks must be sorted and unique",
1621            );
1622        }
1623        if !profile
1624            .accepted_verifier_repository_ids
1625            .contains(&receipt.verifier.repository_id)
1626            || !profile
1627                .accepted_verifier_workflows
1628                .contains(&receipt.verifier.workflow)
1629        {
1630            push_issue(
1631                issues,
1632                format!("$.snapshot.verification_receipts[{index}].receipt.verifier"),
1633                "receipt verifier is not accepted by the profile",
1634            );
1635        }
1636        if receipt.outcome == VerificationOutcome::Passed
1637            && receipt.checks.iter().any(|check| {
1638                check.outcome == VerificationCheckOutcome::Failed
1639                    || (required.contains(&check.check_id)
1640                        && check.outcome != VerificationCheckOutcome::Passed)
1641            })
1642        {
1643            push_issue(
1644                issues,
1645                format!("$.snapshot.verification_receipts[{index}].receipt.outcome"),
1646                "passed receipt contains a failed check",
1647            );
1648        }
1649        validate_cell(
1650            &receipt.cell,
1651            &format!("$.snapshot.verification_receipts[{index}].receipt.cell"),
1652            issues,
1653        );
1654    }
1655    for (index, revocation) in snapshot.verification_revocations.iter().enumerate() {
1656        validate_digest(
1657            &revocation.receipt_digest,
1658            &format!("$.snapshot.verification_revocations[{index}].receipt_digest"),
1659            issues,
1660        );
1661        if !receipt_digests.contains(revocation.receipt_digest.as_str()) {
1662            push_issue(
1663                issues,
1664                format!("$.snapshot.verification_revocations[{index}].receipt_digest"),
1665                "revocation references an unknown receipt",
1666            );
1667        }
1668    }
1669}
1670
1671fn validate_lifecycle(snapshot: &CatalogSnapshot, issues: &mut Vec<CatalogContractIssue>) {
1672    let releases = snapshot
1673        .releases
1674        .iter()
1675        .map(|record| record.release_digest.as_str())
1676        .collect::<BTreeSet<_>>();
1677    let mut identities = BTreeSet::new();
1678    if !snapshot.lifecycle.windows(2).all(|pair| {
1679        (
1680            &pair[0].release_digest,
1681            pair[0].effective_at,
1682            pair[0].sequence,
1683        ) < (
1684            &pair[1].release_digest,
1685            pair[1].effective_at,
1686            pair[1].sequence,
1687        )
1688    }) {
1689        push_issue(
1690            issues,
1691            "$.snapshot.lifecycle",
1692            "lifecycle records must be sorted by release, time, and sequence",
1693        );
1694    }
1695    for (index, record) in snapshot.lifecycle.iter().enumerate() {
1696        if !releases.contains(record.release_digest.as_str()) {
1697            push_issue(
1698                issues,
1699                format!("$.snapshot.lifecycle[{index}].release_digest"),
1700                "lifecycle record references an unknown release",
1701            );
1702        }
1703        if record.reason_code.trim().is_empty()
1704            || record.evidence_reference.trim().is_empty()
1705            || record.source_revision.trim().is_empty()
1706        {
1707            push_issue(
1708                issues,
1709                format!("$.snapshot.lifecycle[{index}]"),
1710                "lifecycle evidence fields must be non-empty",
1711            );
1712        }
1713        if !identities.insert((
1714            record.release_digest.as_str(),
1715            record.facet,
1716            record.sequence,
1717        )) {
1718            push_issue(
1719                issues,
1720                format!("$.snapshot.lifecycle[{index}].sequence"),
1721                "lifecycle sequence must be unique per release and facet",
1722            );
1723        }
1724    }
1725}
1726
1727fn validate_cell(
1728    cell: &ModuleVerificationCell,
1729    path: &str,
1730    issues: &mut Vec<CatalogContractIssue>,
1731) {
1732    for (field, digest) in [
1733        ("module_release_digest", cell.module_release_digest.as_str()),
1734        ("starter_digest", cell.starter_digest.as_str()),
1735        ("delivery_digest", cell.delivery_digest.as_str()),
1736        ("runner_image_digest", cell.runner_image_digest.as_str()),
1737    ] {
1738        validate_digest(digest, &format!("{path}.{field}"), issues);
1739    }
1740    for (field, digest) in [
1741        (
1742            "source_release_digest",
1743            cell.source_release_digest.as_deref(),
1744        ),
1745        (
1746            "console_artifact_digest",
1747            cell.console_artifact_digest.as_deref(),
1748        ),
1749        ("console_lock_digest", cell.console_lock_digest.as_deref()),
1750    ] {
1751        if let Some(digest) = digest {
1752            validate_digest(digest, &format!("{path}.{field}"), issues);
1753        }
1754    }
1755    for (index, digest) in cell.protocol_digests.iter().enumerate() {
1756        validate_digest(digest, &format!("{path}.protocol_digests[{index}]"), issues);
1757    }
1758    if cell.operation == VerificationOperation::Upgrade && cell.source_release_digest.is_none() {
1759        push_issue(
1760            issues,
1761            format!("{path}.source_release_digest"),
1762            "upgrade verification requires an exact source release",
1763        );
1764    }
1765    if cell.operation != VerificationOperation::Upgrade && cell.source_release_digest.is_some() {
1766        push_issue(
1767            issues,
1768            format!("{path}.source_release_digest"),
1769            "only upgrade verification accepts a source release",
1770        );
1771    }
1772    if !sorted_unique(&cell.features) || !sorted_unique(&cell.protocol_digests) {
1773        push_issue(
1774            issues,
1775            path,
1776            "verification cell set dimensions must be sorted and unique",
1777        );
1778    }
1779}
1780
1781fn validate_verification_profile(
1782    profile: &VerificationProfile,
1783    issues: &mut Vec<CatalogContractIssue>,
1784) {
1785    if profile.profile_id.trim().is_empty() || profile.policy_revision.trim().is_empty() {
1786        push_issue(
1787            issues,
1788            "$.snapshot.verification_profile",
1789            "verification profile identity fields must be non-empty",
1790        );
1791    }
1792    if !sorted_unique(&profile.accepted_verifier_repository_ids)
1793        || !sorted_unique(&profile.accepted_verifier_workflows)
1794        || profile.accepted_verifier_repository_ids.is_empty()
1795        || profile.accepted_verifier_workflows.is_empty()
1796    {
1797        push_issue(
1798            issues,
1799            "$.snapshot.verification_profile",
1800            "accepted verifier identities must be non-empty, sorted, and unique",
1801        );
1802    }
1803    for (operation, checks) in &profile.required_checks {
1804        if checks.is_empty() || !sorted_unique(checks) {
1805            push_issue(
1806                issues,
1807                format!("$.snapshot.verification_profile.required_checks.{operation:?}"),
1808                "required checks must be non-empty, sorted, and unique",
1809            );
1810        }
1811    }
1812}
1813
1814fn valid_namespace(value: &str) -> bool {
1815    !value.is_empty()
1816        && value
1817            .bytes()
1818            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
1819        && !value.starts_with('-')
1820        && !value.ends_with('-')
1821}
1822
1823fn sorted_unique<T: Ord>(values: &[T]) -> bool {
1824    values.windows(2).all(|pair| pair[0] < pair[1])
1825}
1826
1827fn validate_digest(value: &str, path: &str, issues: &mut Vec<CatalogContractIssue>) {
1828    let valid = value.strip_prefix("sha256:").is_some_and(|hex| {
1829        hex.len() == 64
1830            && hex
1831                .bytes()
1832                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1833    });
1834    if !valid {
1835        push_issue(
1836            issues,
1837            path,
1838            "digest must be sha256 followed by 64 lowercase hexadecimal characters",
1839        );
1840    }
1841}
1842
1843fn push_issue(
1844    issues: &mut Vec<CatalogContractIssue>,
1845    path: impl Into<String>,
1846    message: impl Into<String>,
1847) {
1848    issues.push(CatalogContractIssue {
1849        path: path.into(),
1850        message: message.into(),
1851    });
1852}
1853
1854#[cfg(test)]
1855mod tests {
1856    use super::*;
1857    use crate::{LinkedModuleDelivery, ModuleManifest};
1858    use chrono::TimeZone as _;
1859
1860    fn digest(character: char) -> String {
1861        format!("sha256:{}", character.to_string().repeat(64))
1862    }
1863
1864    fn identity(login: &str, user_id: u64) -> GitHubIdentity {
1865        GitHubIdentity {
1866            login: login.to_owned(),
1867            user_id,
1868        }
1869    }
1870
1871    fn publisher() -> PublisherRecord {
1872        PublisherRecord {
1873            publisher_id: "publisher-acme".to_owned(),
1874            namespaces: vec!["acme".to_owned()],
1875            owner: identity("owner", 1),
1876            maintainers: vec![identity("maintainer", 2)],
1877            github_owner_id: 10,
1878            github_repository_id: 11,
1879            repository: "acme/modules".to_owned(),
1880            publishing_workflow: ".github/workflows/publish.yml".to_owned(),
1881            security_contact: "security@acme.test".to_owned(),
1882            status: PublisherStatus::Active,
1883            source_revision: "old".to_owned(),
1884        }
1885    }
1886
1887    fn approval(login: &str, user_id: u64, revision: &str) -> CatalogApproval {
1888        CatalogApproval {
1889            actor: identity(login, user_id),
1890            source_revision: revision.to_owned(),
1891            approved_at: Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
1892        }
1893    }
1894
1895    fn profile() -> VerificationProfile {
1896        VerificationProfile {
1897            protocol: VERIFICATION_PROFILE_PROTOCOL.to_owned(),
1898            profile_id: "default".to_owned(),
1899            policy_revision: "policy-1".to_owned(),
1900            required_checks: BTreeMap::from([(
1901                VerificationOperation::FreshInstall,
1902                vec!["build".to_owned()],
1903            )]),
1904            accepted_verifier_repository_ids: vec![100],
1905            accepted_verifier_workflows: vec!["verify.yml".to_owned()],
1906        }
1907    }
1908
1909    fn cell() -> ModuleVerificationCell {
1910        ModuleVerificationCell {
1911            module_release_digest: digest('a'),
1912            operation: VerificationOperation::FreshInstall,
1913            source_release_digest: None,
1914            lenso_version: "0.3.16".to_owned(),
1915            host_version: "1.0.0".to_owned(),
1916            cli_version: "0.2.13".to_owned(),
1917            starter_digest: digest('b'),
1918            management_engine_version: "1.0.0".to_owned(),
1919            delivery_digest: digest('c'),
1920            features: vec!["postgres".to_owned()],
1921            target: "x86_64-unknown-linux-gnu".to_owned(),
1922            os: "linux".to_owned(),
1923            architecture: "x86_64".to_owned(),
1924            runner_image_digest: digest('d'),
1925            rust_version: "1.88.0".to_owned(),
1926            cargo_version: "1.88.0".to_owned(),
1927            store_engine: "postgres".to_owned(),
1928            store_version: "17".to_owned(),
1929            protocol_digests: vec![digest('e')],
1930            console_artifact_digest: None,
1931            console_host_api_version: None,
1932            node_version: None,
1933            package_manager_version: None,
1934            console_lock_digest: None,
1935        }
1936    }
1937
1938    fn snapshot() -> CatalogSnapshot {
1939        let profile = profile();
1940        CatalogSnapshot {
1941            protocol: CATALOG_SNAPSHOT_PROTOCOL.to_owned(),
1942            source_revision: "catalog-commit".to_owned(),
1943            generated_at: Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
1944            previous_snapshot_digest: None,
1945            verification_profile_digest: digest_json(&profile).unwrap(),
1946            verification_profile: profile,
1947            publishers: vec![publisher()],
1948            metadata: Vec::new(),
1949            releases: Vec::new(),
1950            lifecycle: Vec::new(),
1951            linked_provenance: Vec::new(),
1952            verification_receipts: Vec::new(),
1953            verification_revocations: Vec::new(),
1954        }
1955    }
1956
1957    fn verified(snapshot: &CatalogSnapshot) -> VerifiedCatalogSnapshot<'_> {
1958        VerifiedCatalogSnapshot {
1959            snapshot,
1960            snapshot_digest: "test-only",
1961        }
1962    }
1963
1964    fn linked_release() -> ModuleRelease {
1965        ModuleRelease::new(
1966            "acme/support-ticket",
1967            "1.2.3",
1968            ModuleManifest::builder("acme/support-ticket").build(),
1969            ModuleDelivery::Linked(LinkedModuleDelivery {
1970                package: "acme-support-ticket".to_owned(),
1971                crate_version: "1.2.3".to_owned(),
1972                archive_checksum: digest('a'),
1973                default_features: false,
1974                features: vec!["postgres".to_owned()],
1975                binding: "support_ticket".to_owned(),
1976                attestations: Vec::new(),
1977                migrations: Vec::new(),
1978            }),
1979        )
1980        .unwrap()
1981    }
1982
1983    fn linked_provenance(module_release_digest: String) -> LinkedProvenanceReceipt {
1984        let builder = VerifierIdentity {
1985            repository: "lenso/catalog".to_owned(),
1986            repository_id: 100,
1987            workflow: "verify.yml".to_owned(),
1988            run_id: 201,
1989            commit_sha: "builder-commit".to_owned(),
1990            oidc_issuer: "https://token.actions.githubusercontent.com".to_owned(),
1991            signer: "catalog-verifier".to_owned(),
1992        };
1993        let attestation = AttestationReference {
1994            locator: "oci://attestations/crate".to_owned(),
1995            digest: digest('a'),
1996            issuer: "https://token.actions.githubusercontent.com".to_owned(),
1997            signer: "acme-publisher".to_owned(),
1998        };
1999        LinkedProvenanceReceipt {
2000            protocol: LINKED_PROVENANCE_RECEIPT_PROTOCOL.to_owned(),
2001            receipt_id: "linked-provenance-1".to_owned(),
2002            issued_at: Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
2003            supersedes_receipt_digest: None,
2004            publisher_id: "publisher-acme".to_owned(),
2005            module_release_digest,
2006            package: "acme-support-ticket".to_owned(),
2007            crate_version: "1.2.3".to_owned(),
2008            archive_size: 42,
2009            archive_checksum: digest('a'),
2010            trusted_publishing: TrustedPublishingEvidence {
2011                repository: "acme/modules".to_owned(),
2012                repository_id: 11,
2013                workflow: ".github/workflows/publish.yml".to_owned(),
2014                run_id: 300,
2015                commit_sha: "release-commit".to_owned(),
2016                oidc_issuer: "https://token.actions.githubusercontent.com".to_owned(),
2017                runner_environment: "github-hosted".to_owned(),
2018                runner_image_digest: digest('b'),
2019            },
2020            artifact_attestation: ArtifactAttestationEvidence {
2021                attestation,
2022                repository: "acme/modules".to_owned(),
2023                repository_id: 11,
2024                workflow: ".github/workflows/publish.yml".to_owned(),
2025                run_id: 300,
2026                commit_sha: "release-commit".to_owned(),
2027                oidc_issuer: "https://token.actions.githubusercontent.com".to_owned(),
2028                runner_environment: "github-hosted".to_owned(),
2029            },
2030            clean_build: CleanBuildEvidence {
2031                lenso_version: "0.3.16".to_owned(),
2032                cli_version: "0.2.13".to_owned(),
2033                starter_digest: digest('c'),
2034                toolchain: "rust-1.88.0".to_owned(),
2035                application_lock_digest: digest('d'),
2036                runner_image_digest: digest('e'),
2037                builder,
2038                commands: vec!["cargo build --locked".to_owned()],
2039                checks: vec![VerificationCheck {
2040                    check_id: "build".to_owned(),
2041                    outcome: VerificationCheckOutcome::Passed,
2042                    duration_ms: 1,
2043                    evidence: Vec::new(),
2044                }],
2045            },
2046        }
2047    }
2048
2049    fn receipt(
2050        outcome: VerificationOutcome,
2051        receipt_digest: String,
2052    ) -> AttestedVerificationReceipt {
2053        let snapshot = snapshot();
2054        let timestamp = Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap();
2055        AttestedVerificationReceipt {
2056            receipt: ModuleVerificationReceipt {
2057                protocol: VERIFICATION_RECEIPT_PROTOCOL.to_owned(),
2058                receipt_id: format!("receipt-{outcome:?}"),
2059                publisher_id: "publisher-acme".to_owned(),
2060                manifest_digest: digest('f'),
2061                catalog_snapshot_digest: digest('9'),
2062                verification_profile_digest: snapshot.verification_profile_digest,
2063                cell: cell(),
2064                outcome,
2065                started_at: timestamp,
2066                completed_at: timestamp,
2067                issues: Vec::new(),
2068                toolchain_evidence: VerificationToolchainEvidence {
2069                    application_lock_digest: digest('1'),
2070                    cargo_lock_digest: digest('2'),
2071                    console_lock_digest: None,
2072                    config_input_digest: digest('3'),
2073                    migration_history_digest: digest('4'),
2074                },
2075                commands: vec!["cargo test --locked".to_owned()],
2076                checks: vec![VerificationCheck {
2077                    check_id: "build".to_owned(),
2078                    outcome: if outcome == VerificationOutcome::Passed {
2079                        VerificationCheckOutcome::Passed
2080                    } else {
2081                        VerificationCheckOutcome::Failed
2082                    },
2083                    duration_ms: 1,
2084                    evidence: Vec::new(),
2085                }],
2086                verifier: VerifierIdentity {
2087                    repository: "lenso/catalog".to_owned(),
2088                    repository_id: 100,
2089                    workflow: "verify.yml".to_owned(),
2090                    run_id: 200,
2091                    commit_sha: "abc".to_owned(),
2092                    oidc_issuer: "https://token.actions.githubusercontent.com".to_owned(),
2093                    signer: "catalog-verifier".to_owned(),
2094                },
2095            },
2096            receipt_digest: receipt_digest.clone(),
2097            attestation: AttestationReference {
2098                locator: "oci://receipts/example".to_owned(),
2099                digest: receipt_digest,
2100                issuer: "https://token.actions.githubusercontent.com".to_owned(),
2101                signer: "catalog-verifier".to_owned(),
2102            },
2103        }
2104    }
2105
2106    #[test]
2107    fn publisher_changes_require_independent_approval_and_forbid_self_approval() {
2108        let maintainers = BTreeSet::from([90]);
2109        let decision = evaluate_publisher_governance(
2110            &publisher(),
2111            90,
2112            &maintainers,
2113            "new",
2114            &PublisherGovernanceAction::OrdinaryChange,
2115            &[approval("catalog", 90, "new")],
2116            Utc.with_ymd_and_hms(2026, 7, 20, 0, 0, 0).unwrap(),
2117        );
2118
2119        assert!(!decision.approved);
2120        assert!(
2121            decision
2122                .reason_codes
2123                .contains(&"self_approval_forbidden".to_owned())
2124        );
2125    }
2126
2127    #[test]
2128    fn publisher_transfer_requires_both_owners_and_independent_catalog_approval() {
2129        let maintainers = BTreeSet::from([90]);
2130        let decision = evaluate_publisher_governance(
2131            &publisher(),
2132            1,
2133            &maintainers,
2134            "new",
2135            &PublisherGovernanceAction::NormalTransfer {
2136                receiving_owner: identity("receiver", 3),
2137            },
2138            &[
2139                approval("owner", 1, "new"),
2140                approval("receiver", 3, "new"),
2141                approval("catalog", 90, "new"),
2142            ],
2143            Utc.with_ymd_and_hms(2026, 7, 20, 0, 0, 0).unwrap(),
2144        );
2145
2146        assert!(decision.approved);
2147    }
2148
2149    #[test]
2150    fn recovery_requires_two_catalog_maintainers_and_fourteen_days() {
2151        let maintainers = BTreeSet::from([90, 91]);
2152        let action = PublisherGovernanceAction::RecoveryTransfer {
2153            receiving_owner: identity("receiver", 3),
2154            waiting_started_at: Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
2155        };
2156        let approvals = [
2157            approval("catalog-a", 90, "new"),
2158            approval("catalog-b", 91, "new"),
2159        ];
2160        let early = evaluate_publisher_governance(
2161            &publisher(),
2162            90,
2163            &maintainers,
2164            "new",
2165            &action,
2166            &approvals,
2167            Utc.with_ymd_and_hms(2026, 7, 14, 23, 59, 59).unwrap(),
2168        );
2169        assert!(!early.approved);
2170        assert!(
2171            early
2172                .reason_codes
2173                .contains(&"recovery_waiting_period_incomplete".to_owned())
2174        );
2175
2176        let approved = evaluate_publisher_governance(
2177            &publisher(),
2178            92,
2179            &maintainers,
2180            "new",
2181            &action,
2182            &approvals,
2183            Utc.with_ymd_and_hms(2026, 7, 15, 0, 0, 0).unwrap(),
2184        );
2185        assert!(approved.approved);
2186    }
2187
2188    #[test]
2189    fn verification_requires_an_exact_cell_and_conflicts_are_unknown() {
2190        let mut catalog = snapshot();
2191        catalog.verification_receipts = vec![
2192            receipt(VerificationOutcome::Passed, digest('6')),
2193            receipt(VerificationOutcome::Failed, digest('7')),
2194        ];
2195        let conflict = verification_evaluation(&catalog, &cell());
2196        assert_eq!(conflict.state, VerificationState::Unknown);
2197        assert_eq!(conflict.reason_code, "receipt_conflict");
2198
2199        let mut different = cell();
2200        different.store_version = "16".to_owned();
2201        let missing = verification_evaluation(&catalog, &different);
2202        assert_eq!(missing.state, VerificationState::Unknown);
2203        assert_eq!(missing.reason_code, "missing_receipt");
2204    }
2205
2206    #[test]
2207    fn mutation_snapshot_admission_requires_cryptographic_and_identity_trust() {
2208        let snapshot = snapshot();
2209        let snapshot_digest = digest_json(&snapshot).unwrap();
2210        let envelope = CatalogSnapshotEnvelope {
2211            snapshot,
2212            snapshot_digest: snapshot_digest.clone(),
2213            attestation: AttestationReference {
2214                locator: "oci://catalog/snapshot".to_owned(),
2215                digest: snapshot_digest,
2216                issuer: "https://token.actions.githubusercontent.com".to_owned(),
2217                signer: "catalog-publisher".to_owned(),
2218            },
2219        };
2220        let policy = CatalogAttestationTrustPolicy {
2221            trusted_issuers: vec!["https://token.actions.githubusercontent.com".to_owned()],
2222            trusted_signers: vec!["catalog-publisher".to_owned()],
2223        };
2224
2225        assert!(admit_catalog_snapshot(&envelope, &[], &policy, false).is_err());
2226        let admitted = admit_catalog_snapshot(&envelope, &[], &policy, true).unwrap();
2227        assert_eq!(admitted.snapshot_digest(), envelope.snapshot_digest);
2228    }
2229
2230    #[test]
2231    fn linked_provenance_binds_crate_and_publisher_numeric_identity() {
2232        let release = linked_release();
2233        let release_digest = digest_json(&release).unwrap();
2234        let mut snapshot = snapshot();
2235        snapshot.releases.push(CatalogReleaseRecord {
2236            publisher_id: "publisher-acme".to_owned(),
2237            module_id: "acme/support-ticket".to_owned(),
2238            version: "1.2.3".to_owned(),
2239            release_digest: release_digest.clone(),
2240            release: ArtifactReference {
2241                locator: "oci://catalog/module-releases/acme-support-ticket-1.2.3.json".to_owned(),
2242                digest: release_digest.clone(),
2243            },
2244            delivery_kind: ModuleDeliveryKind::Linked,
2245        });
2246        snapshot
2247            .linked_provenance
2248            .push(linked_provenance(release_digest));
2249        let snapshot_digest = digest_json(&snapshot).unwrap();
2250        let mut envelope = CatalogSnapshotEnvelope {
2251            snapshot,
2252            snapshot_digest: snapshot_digest.clone(),
2253            attestation: AttestationReference {
2254                locator: "oci://catalog/snapshot".to_owned(),
2255                digest: snapshot_digest,
2256                issuer: "https://token.actions.githubusercontent.com".to_owned(),
2257                signer: "catalog-publisher".to_owned(),
2258            },
2259        };
2260
2261        assert!(validate_catalog_snapshot(&envelope, std::slice::from_ref(&release)).is_empty());
2262        envelope.snapshot.linked_provenance[0]
2263            .trusted_publishing
2264            .repository_id = 999;
2265        assert!(
2266            validate_catalog_snapshot(&envelope, &[release])
2267                .iter()
2268                .any(|issue| issue.path.ends_with(".trusted_publishing"))
2269        );
2270    }
2271
2272    #[test]
2273    fn lifecycle_and_snapshot_policy_are_action_specific() {
2274        let mut catalog = snapshot();
2275        catalog.lifecycle.push(ModuleLifecycleRecord {
2276            release_digest: digest('a'),
2277            facet: ModuleLifecycleFacet::SecurityBlocked,
2278            change: ModuleLifecycleChange::Set,
2279            reason_code: "cve".to_owned(),
2280            evidence_reference: "https://advisories.example/cve".to_owned(),
2281            actor: identity("security", 99),
2282            source_revision: "security-change".to_owned(),
2283            sequence: 1,
2284            effective_at: Utc.with_ymd_and_hms(2026, 7, 2, 0, 0, 0).unwrap(),
2285            replacement_module_id: None,
2286            guidance: None,
2287            recovery_conditions: Some("operator acknowledgement".to_owned()),
2288        });
2289        let mut policy = ModuleTrustPolicy {
2290            verification: VerificationRequirement::AllowUnknown,
2291            maximum_mutation_age_seconds: 86_400,
2292            stale_snapshot: StaleSnapshotPolicy::Reject,
2293            compatibility: CompatibilityPolicy::Strict,
2294            security_restore: SecurityRestorePolicy::Block,
2295        };
2296        let restore = evaluate_module_eligibility(
2297            &verified(&catalog),
2298            &digest('a'),
2299            &cell(),
2300            DeclaredCompatibilityState::Compatible,
2301            CatalogAction::Restore,
2302            &policy,
2303            Utc.with_ymd_and_hms(2026, 7, 2, 0, 0, 0).unwrap(),
2304            false,
2305        );
2306        assert_eq!(restore.state, ModuleEligibilityState::BreakGlassOnly);
2307
2308        policy.security_restore = SecurityRestorePolicy::BreakGlass;
2309        let restore = evaluate_module_eligibility(
2310            &verified(&catalog),
2311            &digest('a'),
2312            &cell(),
2313            DeclaredCompatibilityState::Compatible,
2314            CatalogAction::Restore,
2315            &policy,
2316            Utc.with_ymd_and_hms(2026, 7, 2, 0, 0, 0).unwrap(),
2317            false,
2318        );
2319        assert_eq!(restore.state, ModuleEligibilityState::EligibleWithWarning);
2320
2321        let install = evaluate_module_eligibility(
2322            &verified(&catalog),
2323            &digest('a'),
2324            &cell(),
2325            DeclaredCompatibilityState::Compatible,
2326            CatalogAction::Install,
2327            &policy,
2328            Utc.with_ymd_and_hms(2026, 7, 20, 0, 0, 0).unwrap(),
2329            false,
2330        );
2331        assert_eq!(install.state, ModuleEligibilityState::Blocked);
2332        assert!(install.reason_codes.contains(&"snapshot_stale".to_owned()));
2333        assert!(
2334            install
2335                .reason_codes
2336                .contains(&"security_blocked".to_owned())
2337        );
2338
2339        let stale_restore = evaluate_module_eligibility(
2340            &verified(&catalog),
2341            &digest('a'),
2342            &cell(),
2343            DeclaredCompatibilityState::Compatible,
2344            CatalogAction::Restore,
2345            &policy,
2346            Utc.with_ymd_and_hms(2026, 7, 20, 0, 0, 0).unwrap(),
2347            false,
2348        );
2349        assert_eq!(stale_restore.state, ModuleEligibilityState::Blocked);
2350        assert!(
2351            stale_restore
2352                .reason_codes
2353                .contains(&"snapshot_stale".to_owned())
2354        );
2355    }
2356
2357    #[test]
2358    fn yanked_and_deprecated_facets_follow_the_action_matrix() {
2359        let mut catalog = snapshot();
2360        let timestamp = Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap();
2361        for (sequence, facet) in [
2362            (1, ModuleLifecycleFacet::Deprecated),
2363            (2, ModuleLifecycleFacet::Yanked),
2364        ] {
2365            catalog.lifecycle.push(ModuleLifecycleRecord {
2366                release_digest: digest('a'),
2367                facet,
2368                change: ModuleLifecycleChange::Set,
2369                reason_code: format!("{facet:?}"),
2370                evidence_reference: "https://catalog.example/evidence".to_owned(),
2371                actor: identity("catalog", 90),
2372                source_revision: "lifecycle-change".to_owned(),
2373                sequence,
2374                effective_at: timestamp,
2375                replacement_module_id: None,
2376                guidance: None,
2377                recovery_conditions: None,
2378            });
2379        }
2380        let policy = ModuleTrustPolicy {
2381            verification: VerificationRequirement::AllowUnknown,
2382            maximum_mutation_age_seconds: 86_400,
2383            stale_snapshot: StaleSnapshotPolicy::Reject,
2384            compatibility: CompatibilityPolicy::Strict,
2385            security_restore: SecurityRestorePolicy::Block,
2386        };
2387        for action in [
2388            CatalogAction::Discover,
2389            CatalogAction::Install,
2390            CatalogAction::Update,
2391        ] {
2392            let result = evaluate_module_eligibility(
2393                &verified(&catalog),
2394                &digest('a'),
2395                &cell(),
2396                DeclaredCompatibilityState::Compatible,
2397                action,
2398                &policy,
2399                timestamp,
2400                false,
2401            );
2402            assert_eq!(result.state, ModuleEligibilityState::Blocked);
2403        }
2404        for action in [CatalogAction::Restore, CatalogAction::Continue] {
2405            let result = evaluate_module_eligibility(
2406                &verified(&catalog),
2407                &digest('a'),
2408                &cell(),
2409                DeclaredCompatibilityState::Compatible,
2410                action,
2411                &policy,
2412                timestamp,
2413                false,
2414            );
2415            assert_eq!(result.state, ModuleEligibilityState::EligibleWithWarning);
2416        }
2417    }
2418}