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}