Skip to main content

rch_common/
fleet_provenance.rs

1//! Release provenance, signature/checksum verification, and deploy audit for
2//! fleet binary updates (bd-session-history-remediation-ocv9i.7.4).
3//!
4//! This is the pure, deterministic, I/O-free foundation. It answers, BEFORE a
5//! fleet `rch-wkr` binary is pushed to a worker:
6//!
7//!   1. Is THIS artifact the intended release artifact for THIS worker
8//!      (target-triple match + expected checksum + signature/provenance)?
9//!   2. Under the active policy, may we proceed (verified), proceed with an
10//!      explicit dev-artifact note, or must we fail closed?
11//!
12//! The expensive, side-effecting parts — computing the local checksum and
13//! running the real cosign/sigstore verification — happen in the fleet deploy
14//! path (`rch/src/fleet/executor.rs`, reusing `rch/src/update/verify.rs`).
15//! Their *results* are fed into [`verify_artifact_provenance`] so the decision
16//! logic stays a pure function that is exhaustively unit-testable, mirroring
17//! the post-deploy validation classifiers (`classify_post_deploy`,
18//! `classify_capabilities_handshake`) from sibling bead `.7.3`.
19//!
20//! Stable reason-code tokens are plain `&'static str` (see [`reason_code`]),
21//! NOT [`crate::incident::IncidentReasonCode`] variants — the same choice the
22//! `.7.3` post-deploy classifiers made (`os_arch_mismatch`,
23//! `capabilities_handshake_failed`). The fleet path keeps one stable vocabulary
24//! without churning the schema-pinned incident-ledger enum; a deploy site that
25//! wants an incident-ledger entry maps the token at its boundary.
26
27use serde::{Deserialize, Serialize};
28
29use crate::schema_versions::{SchemaComponent, current_version};
30
31/// Schema version for the persisted [`FleetDeployAuditRecord`].
32pub const FLEET_DEPLOY_AUDIT_SCHEMA_VERSION: &str =
33    current_version(SchemaComponent::FleetDeployAudit);
34
35/// Stable reason-code tokens for provenance verification outcomes.
36///
37/// Append-only: never renumber or repurpose an existing token (dashboards, the
38/// validation matrix, and the deploy audit trail key off these exact strings).
39pub mod reason_code {
40    /// Artifact targets a different OS/arch triple than the worker.
41    pub const WRONG_TARGET_TRIPLE: &str = "provenance_wrong_target_triple";
42    /// Expected checksum is known but the artifact's actual checksum differs.
43    pub const CHECKSUM_MISMATCH: &str = "provenance_checksum_mismatch";
44    /// Policy requires a checksum but none could be compared.
45    pub const CHECKSUM_MISSING: &str = "provenance_checksum_missing";
46    /// Signature material is present but failed cryptographic verification.
47    pub const SIGNATURE_INVALID: &str = "provenance_signature_invalid";
48    /// Policy requires a signature but the artifact has none.
49    pub const SIGNATURE_MISSING: &str = "provenance_signature_missing";
50    /// No verifiable material at all and policy forbids dev artifacts.
51    pub const UNVERIFIABLE: &str = "provenance_unverifiable";
52    /// No material, but policy explicitly permits a dev/local artifact.
53    pub const DEV_ARTIFACT_ALLOWED: &str = "provenance_dev_artifact_allowed";
54}
55
56/// Signature / provenance material attached to a release artifact, as the
57/// artifact resolver records it from a release manifest.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct SignatureMaterial {
60    /// Reference to the signature bundle (e.g. a cosign bundle path or URL).
61    pub bundle_ref: String,
62    /// Certificate identity pattern the bundle must match (sigstore OIDC),
63    /// e.g. the release-workflow identity regex used by `update/verify.rs`.
64    pub identity_pattern: String,
65}
66
67/// Release provenance material the artifact resolver records for one fleet
68/// binary / worker platform. Everything known about the *intended* release
69/// artifact, used to prove the selected binary is the intended one.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct ArtifactProvenance {
72    /// Stable artifact identity (e.g. the artifact file name).
73    pub artifact_id: String,
74    /// Source release / build id (git tag or release id). `None` for a
75    /// locally-built dev artifact.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub release_id: Option<String>,
78    /// The Rust target triple this artifact targets.
79    pub target_triple: String,
80    /// Expected SHA-256 (lowercase hex, 64 chars) from the release manifest.
81    /// `None` for an unverified dev artifact.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub expected_sha256: Option<String>,
84    /// Signature / provenance material, when the release publishes one.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub signature: Option<SignatureMaterial>,
87    /// Builder / controller identity that produced the artifact (host or CI id).
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub builder_identity: Option<String>,
90    /// Worker protocol version this artifact expects to speak. `None` when the
91    /// binary predates protocol negotiation.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub expected_protocol_version: Option<u32>,
94}
95
96impl ArtifactProvenance {
97    /// A minimal dev-artifact record: a locally-built binary for `triple` with
98    /// no release id, checksum, or signature.
99    #[must_use]
100    pub fn dev_artifact(artifact_id: impl Into<String>, triple: impl Into<String>) -> Self {
101        Self {
102            artifact_id: artifact_id.into(),
103            release_id: None,
104            target_triple: triple.into(),
105            expected_sha256: None,
106            signature: None,
107            builder_identity: None,
108            expected_protocol_version: None,
109        }
110    }
111}
112
113/// Outcome of the real signature check performed by the deploy path (cosign /
114/// sigstore). Fed into [`verify_artifact_provenance`] to keep that pure.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum SignatureCheck {
118    /// No signature material was present on the artifact.
119    Absent,
120    /// Signature material present and cryptographically verified.
121    Valid,
122    /// Signature material present but failed verification.
123    Invalid,
124}
125
126/// Policy controlling how strict provenance verification is for a deploy.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128pub struct ProvenancePolicy {
129    /// Require a valid signature; fail closed if absent or invalid.
130    pub require_signature: bool,
131    /// Require an expected checksum to compare against; fail closed if it
132    /// cannot be compared.
133    pub require_checksum: bool,
134    /// Allow an unsigned / unverified locally-built dev artifact to proceed,
135    /// recording an explicit reason. Has no effect when `require_signature` or
136    /// `require_checksum` force a hard requirement.
137    pub allow_dev_artifacts: bool,
138}
139
140impl ProvenancePolicy {
141    /// Strict release policy: signature + checksum required, no dev artifacts.
142    pub const STRICT: Self = Self {
143        require_signature: true,
144        require_checksum: true,
145        allow_dev_artifacts: false,
146    };
147
148    /// Dev-friendly fleet policy: verify whatever material exists (fail closed
149    /// on a MISMATCH or an INVALID signature), but permit an explicitly-noted
150    /// dev artifact when no material is available.
151    #[must_use]
152    pub const fn dev_friendly() -> Self {
153        Self {
154            require_signature: false,
155            require_checksum: false,
156            allow_dev_artifacts: true,
157        }
158    }
159
160    /// Resolve a policy from an environment-variable value (the contents of
161    /// `RCH_FLEET_PROVENANCE`). A case-insensitive, trimmed `strict` selects
162    /// [`Self::STRICT`]; an unset or any other value falls back to the
163    /// dev-friendly default, so a fleet of locally-built binaries keeps
164    /// deploying (fail-open) while still recording an explicit dev-artifact
165    /// reason rather than refusing.
166    #[must_use]
167    pub fn from_env_value(value: Option<&str>) -> Self {
168        match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
169            Some("strict") => Self::STRICT,
170            _ => Self::dev_friendly(),
171        }
172    }
173}
174
175impl Default for ProvenancePolicy {
176    fn default() -> Self {
177        Self::dev_friendly()
178    }
179}
180
181/// The pure verdict of provenance verification, computed BEFORE transfer.
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(tag = "status", rename_all = "snake_case")]
184pub enum ProvenanceVerdict {
185    /// Fully verified: triple matches and at least one piece of provenance
186    /// material (checksum and/or a valid signature) was positively checked.
187    /// Safe to transfer.
188    Verified,
189    /// Material was absent but policy permits a dev artifact. Proceed, but the
190    /// caller MUST record `reason` in the audit trail.
191    DevArtifactAllowed { reason: String },
192    /// Fail closed: do not transfer. Carries a stable `reason_code` token and a
193    /// human `detail`.
194    Rejected { reason_code: String, detail: String },
195}
196
197impl ProvenanceVerdict {
198    /// Whether the artifact may be transferred to the worker.
199    #[must_use]
200    pub fn may_transfer(&self) -> bool {
201        !matches!(self, Self::Rejected { .. })
202    }
203
204    /// Stable `verification_status` token for the audit trail.
205    #[must_use]
206    pub fn verification_status(&self) -> &'static str {
207        match self {
208            Self::Verified => "verified",
209            Self::DevArtifactAllowed { .. } => "dev_allowed",
210            Self::Rejected { .. } => "rejected",
211        }
212    }
213
214    /// Stable reason-code token, if any. `Verified` has none.
215    #[must_use]
216    pub fn reason_code(&self) -> Option<&str> {
217        match self {
218            Self::Verified => None,
219            Self::DevArtifactAllowed { .. } => Some(reason_code::DEV_ARTIFACT_ALLOWED),
220            Self::Rejected { reason_code, .. } => Some(reason_code.as_str()),
221        }
222    }
223}
224
225/// Verify an artifact's provenance against a worker platform and policy.
226///
227/// `actual_sha256` is the checksum the deploy path computed from the local
228/// artifact file (lowercase hex), or `None` if it could not be computed.
229/// `signature_check` is the result of the real cosign/sigstore verification.
230/// Both are inputs so this function stays pure and exhaustively testable.
231///
232/// Checks run in fail-closed order; the FIRST failure rejects:
233///   1. target triple must match the worker triple,
234///   2. checksum: if an expected value is present it must equal `actual`
235///      (mismatch rejects); a `require_checksum` policy with nothing to compare
236///      rejects,
237///   3. signature: `Invalid` rejects; `Absent` under `require_signature`
238///      rejects,
239///   4. otherwise `Verified` if any material was positively checked, else
240///      `DevArtifactAllowed` when policy permits, else `Rejected`
241///      (`UNVERIFIABLE`).
242#[must_use]
243pub fn verify_artifact_provenance(
244    provenance: &ArtifactProvenance,
245    worker_triple: &str,
246    actual_sha256: Option<&str>,
247    signature_check: SignatureCheck,
248    policy: &ProvenancePolicy,
249) -> ProvenanceVerdict {
250    // 1. Target triple: a binary for the wrong OS/arch is wrong regardless of
251    //    its hash, so this gate is first and unconditional.
252    if provenance.target_triple != worker_triple {
253        return ProvenanceVerdict::Rejected {
254            reason_code: reason_code::WRONG_TARGET_TRIPLE.to_string(),
255            detail: format!(
256                "artifact {} targets {} but worker platform is {}",
257                provenance.artifact_id, provenance.target_triple, worker_triple
258            ),
259        };
260    }
261
262    // 2. Checksum.
263    let checksum_checked = match (provenance.expected_sha256.as_deref(), actual_sha256) {
264        (Some(expected), Some(actual)) => {
265            if !expected.eq_ignore_ascii_case(actual) {
266                return ProvenanceVerdict::Rejected {
267                    reason_code: reason_code::CHECKSUM_MISMATCH.to_string(),
268                    detail: format!(
269                        "artifact {} checksum mismatch (expected {expected}, got {actual})",
270                        provenance.artifact_id
271                    ),
272                };
273            }
274            true
275        }
276        (Some(_), None) => {
277            if policy.require_checksum {
278                return ProvenanceVerdict::Rejected {
279                    reason_code: reason_code::CHECKSUM_MISSING.to_string(),
280                    detail: format!(
281                        "artifact {} has an expected checksum but the actual checksum could not be computed",
282                        provenance.artifact_id
283                    ),
284                };
285            }
286            false
287        }
288        (None, _) => {
289            if policy.require_checksum {
290                return ProvenanceVerdict::Rejected {
291                    reason_code: reason_code::CHECKSUM_MISSING.to_string(),
292                    detail: format!(
293                        "no expected checksum is available for artifact {}",
294                        provenance.artifact_id
295                    ),
296                };
297            }
298            false
299        }
300    };
301
302    // 3. Signature.
303    let signature_valid = match signature_check {
304        SignatureCheck::Valid => true,
305        SignatureCheck::Invalid => {
306            return ProvenanceVerdict::Rejected {
307                reason_code: reason_code::SIGNATURE_INVALID.to_string(),
308                detail: format!(
309                    "artifact {} signature failed verification",
310                    provenance.artifact_id
311                ),
312            };
313        }
314        SignatureCheck::Absent => {
315            if policy.require_signature {
316                return ProvenanceVerdict::Rejected {
317                    reason_code: reason_code::SIGNATURE_MISSING.to_string(),
318                    detail: format!(
319                        "artifact {} has no signature material and strict policy requires a valid signature",
320                        provenance.artifact_id
321                    ),
322                };
323            }
324            false
325        }
326    };
327
328    // 4. Outcome: at least one positive check => verified; else dev artifact.
329    if checksum_checked || signature_valid {
330        ProvenanceVerdict::Verified
331    } else if policy.allow_dev_artifacts {
332        ProvenanceVerdict::DevArtifactAllowed {
333            reason: format!(
334                "no signature/checksum material for artifact {} (release_id={}); permitted by dev-artifact policy",
335                provenance.artifact_id,
336                provenance.release_id.as_deref().unwrap_or("none")
337            ),
338        }
339    } else {
340        ProvenanceVerdict::Rejected {
341            reason_code: reason_code::UNVERIFIABLE.to_string(),
342            detail: format!(
343                "artifact {} has no verifiable provenance material and policy forbids dev artifacts",
344                provenance.artifact_id
345            ),
346        }
347    }
348}
349
350/// Append-only audit record of a single fleet binary deploy attempt.
351///
352/// Serialized into the deploy E2E log and the rollback history so operators can
353/// audit what was deployed where and which verification gate it passed. Field
354/// set covers the bead's "rollback history records ..." and "E2E logs include
355/// ..." acceptance criteria.
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357pub struct FleetDeployAuditRecord {
358    /// Schema version of this record (for migration of persisted history).
359    pub schema_version: String,
360    /// Correlates every event of one deploy run.
361    pub run_id: String,
362    /// Owning bead id (provenance for the audit row itself).
363    pub bead_id: String,
364    /// Target worker.
365    pub worker_id: String,
366    /// Remote user the binary was (or would be) installed as.
367    pub remote_user: String,
368    /// Exact remote path the binary was (or would be) installed at.
369    pub remote_path: String,
370    /// Identity of the artifact being deployed.
371    pub artifact_id: String,
372    /// Source release / build id, if any.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub release_id: Option<String>,
375    /// Target triple of the deployed artifact.
376    pub target_triple: String,
377    /// Identity of the artifact previously installed (rollback context).
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub previous_artifact_id: Option<String>,
380    /// Builder / controller identity.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub builder_identity: Option<String>,
383    /// Protocol version the artifact expects to speak.
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub expected_protocol_version: Option<u32>,
386    /// When the deploy attempt occurred (Unix epoch millis).
387    pub deployed_at_unix_ms: u64,
388    /// How long the deploy attempt took, in milliseconds. Defaults to 0 and is
389    /// set by the deploy path once the attempt completes (the verdict is
390    /// recorded before transfer, so the duration is not yet known then).
391    #[serde(default)]
392    pub duration_ms: u64,
393    /// `verified` | `dev_allowed` | `rejected` (from [`ProvenanceVerdict`]).
394    pub verification_status: String,
395    /// `none` | `rolled_back` | `rollback_failed`.
396    pub rollback_status: String,
397    /// Stable reason-code token, if any.
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub reason_code: Option<String>,
400    /// What triggered the deploy: `operator` | `remediation` | `canary_failure`
401    /// | `scheduled`.
402    pub trigger: String,
403    /// Human-readable detail.
404    pub detail: String,
405}
406
407/// Stable rollback-status tokens for [`FleetDeployAuditRecord::rollback_status`].
408pub mod rollback_status {
409    /// No rollback was needed (deploy succeeded or was never applied).
410    pub const NONE: &str = "none";
411    /// A failed deploy was rolled back to the previous artifact.
412    pub const ROLLED_BACK: &str = "rolled_back";
413    /// A rollback was attempted but did not complete.
414    pub const ROLLBACK_FAILED: &str = "rollback_failed";
415}
416
417impl FleetDeployAuditRecord {
418    /// Build an audit record from a verdict and deploy facts. `rollback_status`
419    /// defaults to `none` and `duration_ms` to 0; the deploy path overwrites
420    /// them once a rollback runs / the attempt completes.
421    #[allow(clippy::too_many_arguments)]
422    #[must_use]
423    pub fn from_verdict(
424        run_id: impl Into<String>,
425        bead_id: impl Into<String>,
426        worker_id: impl Into<String>,
427        remote_user: impl Into<String>,
428        remote_path: impl Into<String>,
429        provenance: &ArtifactProvenance,
430        previous_artifact_id: Option<String>,
431        deployed_at_unix_ms: u64,
432        verdict: &ProvenanceVerdict,
433        trigger: impl Into<String>,
434        detail: impl Into<String>,
435    ) -> Self {
436        Self {
437            schema_version: FLEET_DEPLOY_AUDIT_SCHEMA_VERSION.to_string(),
438            run_id: run_id.into(),
439            bead_id: bead_id.into(),
440            worker_id: worker_id.into(),
441            remote_user: remote_user.into(),
442            remote_path: remote_path.into(),
443            artifact_id: provenance.artifact_id.clone(),
444            release_id: provenance.release_id.clone(),
445            target_triple: provenance.target_triple.clone(),
446            previous_artifact_id,
447            builder_identity: provenance.builder_identity.clone(),
448            expected_protocol_version: provenance.expected_protocol_version,
449            deployed_at_unix_ms,
450            duration_ms: 0,
451            verification_status: verdict.verification_status().to_string(),
452            rollback_status: rollback_status::NONE.to_string(),
453            reason_code: verdict.reason_code().map(ToString::to_string),
454            trigger: trigger.into(),
455            detail: detail.into(),
456        }
457    }
458
459    /// Record that a failed deploy was reverted to the previous artifact. This
460    /// is the mutator the rollback path applies to a deploy's audit record
461    /// before re-persisting it; the live deploy/rollback wiring is part of the
462    /// `.7.4` continuation, so today only the rollback-after-canary unit tests
463    /// exercise it.
464    pub fn mark_rolled_back(&mut self) {
465        self.rollback_status = rollback_status::ROLLED_BACK.to_string();
466    }
467
468    /// Record that a rollback was attempted but did not complete — the worker
469    /// may be in a degraded state and needs operator attention. Like
470    /// [`Self::mark_rolled_back`], this is applied by the (still-pending)
471    /// deploy/rollback wiring; today only unit tests exercise it.
472    pub fn mark_rollback_failed(&mut self) {
473        self.rollback_status = rollback_status::ROLLBACK_FAILED.to_string();
474    }
475
476    /// Record how long the deploy attempt took once it completes. The verdict is
477    /// computed before transfer, so the duration is not known at construction.
478    pub fn set_duration_ms(&mut self, duration_ms: u64) {
479        self.duration_ms = duration_ms;
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    fn signed_release(triple: &str, sha: &str) -> ArtifactProvenance {
488        ArtifactProvenance {
489            artifact_id: format!("rch-wkr-v1.0.42-{triple}"),
490            release_id: Some("v1.0.42".to_string()),
491            target_triple: triple.to_string(),
492            expected_sha256: Some(sha.to_string()),
493            signature: Some(SignatureMaterial {
494                bundle_ref: "rch-wkr.sigstore.json".to_string(),
495                identity_pattern: "^https://github.com/.*/release.yml@refs/.*$".to_string(),
496            }),
497            builder_identity: Some("github-actions".to_string()),
498            expected_protocol_version: Some(1),
499        }
500    }
501
502    const SHA: &str = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
503    const LINUX: &str = "x86_64-unknown-linux-musl";
504
505    // Scenario: good signature (+ matching checksum) => Verified.
506    #[test]
507    fn good_signature_and_checksum_is_verified() {
508        let p = signed_release(LINUX, SHA);
509        let v = verify_artifact_provenance(
510            &p,
511            LINUX,
512            Some(SHA),
513            SignatureCheck::Valid,
514            &ProvenancePolicy::STRICT,
515        );
516        assert_eq!(v, ProvenanceVerdict::Verified);
517        assert!(v.may_transfer());
518        assert_eq!(v.verification_status(), "verified");
519        assert_eq!(v.reason_code(), None);
520    }
521
522    // Scenario: missing signature under STRICT policy => Rejected, fail closed.
523    #[test]
524    fn missing_signature_under_strict_policy_is_rejected() {
525        let mut p = signed_release(LINUX, SHA);
526        p.signature = None;
527        let v = verify_artifact_provenance(
528            &p,
529            LINUX,
530            Some(SHA),
531            SignatureCheck::Absent,
532            &ProvenancePolicy::STRICT,
533        );
534        assert!(!v.may_transfer());
535        assert_eq!(v.reason_code(), Some(reason_code::SIGNATURE_MISSING));
536        assert_eq!(v.verification_status(), "rejected");
537    }
538
539    // Scenario: an INVALID signature is rejected even under a lax policy.
540    #[test]
541    fn invalid_signature_is_rejected_under_any_policy() {
542        let p = signed_release(LINUX, SHA);
543        let v = verify_artifact_provenance(
544            &p,
545            LINUX,
546            Some(SHA),
547            SignatureCheck::Invalid,
548            &ProvenancePolicy::dev_friendly(),
549        );
550        assert_eq!(v.reason_code(), Some(reason_code::SIGNATURE_INVALID));
551        assert!(!v.may_transfer());
552    }
553
554    // Scenario: checksum mismatch => Rejected (takes precedence over signature).
555    #[test]
556    fn checksum_mismatch_is_rejected() {
557        let p = signed_release(LINUX, SHA);
558        let wrong = "0000000000000000000000000000000000000000000000000000000000000000";
559        let v = verify_artifact_provenance(
560            &p,
561            LINUX,
562            Some(wrong),
563            SignatureCheck::Valid,
564            &ProvenancePolicy::STRICT,
565        );
566        assert_eq!(v.reason_code(), Some(reason_code::CHECKSUM_MISMATCH));
567        assert!(!v.may_transfer());
568    }
569
570    // Scenario: wrong target triple => Rejected (highest-priority gate).
571    #[test]
572    fn wrong_target_triple_is_rejected_even_with_valid_signature() {
573        let p = signed_release(LINUX, SHA);
574        let v = verify_artifact_provenance(
575            &p,
576            "aarch64-apple-darwin",
577            Some(SHA),
578            SignatureCheck::Valid,
579            &ProvenancePolicy::STRICT,
580        );
581        assert_eq!(v.reason_code(), Some(reason_code::WRONG_TARGET_TRIPLE));
582        assert!(!v.may_transfer());
583    }
584
585    // Scenario: local dev artifact (no material) under dev-friendly policy =>
586    // DevArtifactAllowed with an explicit recorded reason.
587    #[test]
588    fn dev_artifact_is_allowed_with_explicit_reason() {
589        let p = ArtifactProvenance::dev_artifact("rch-wkr-dev", LINUX);
590        let v = verify_artifact_provenance(
591            &p,
592            LINUX,
593            None,
594            SignatureCheck::Absent,
595            &ProvenancePolicy::dev_friendly(),
596        );
597        match &v {
598            ProvenanceVerdict::DevArtifactAllowed { reason } => {
599                assert!(reason.contains("dev-artifact policy"));
600            }
601            other => panic!("expected DevArtifactAllowed, got {other:?}"),
602        }
603        assert!(v.may_transfer());
604        assert_eq!(v.verification_status(), "dev_allowed");
605        assert_eq!(v.reason_code(), Some(reason_code::DEV_ARTIFACT_ALLOWED));
606    }
607
608    // A dev artifact under a policy that forbids dev artifacts but does not hard
609    // -require material => Rejected(UNVERIFIABLE), never silently accepted.
610    #[test]
611    fn dev_artifact_without_allowance_is_unverifiable() {
612        let p = ArtifactProvenance::dev_artifact("rch-wkr-dev", LINUX);
613        let policy = ProvenancePolicy {
614            require_signature: false,
615            require_checksum: false,
616            allow_dev_artifacts: false,
617        };
618        let v = verify_artifact_provenance(&p, LINUX, None, SignatureCheck::Absent, &policy);
619        assert_eq!(v.reason_code(), Some(reason_code::UNVERIFIABLE));
620        assert!(!v.may_transfer());
621    }
622
623    // A checksum-only artifact (no signature) under require_checksum but not
624    // require_signature is Verified once the checksum matches.
625    #[test]
626    fn checksum_only_match_is_verified() {
627        let mut p = signed_release(LINUX, SHA);
628        p.signature = None;
629        let policy = ProvenancePolicy {
630            require_signature: false,
631            require_checksum: true,
632            allow_dev_artifacts: false,
633        };
634        let v = verify_artifact_provenance(&p, LINUX, Some(SHA), SignatureCheck::Absent, &policy);
635        assert_eq!(v, ProvenanceVerdict::Verified);
636    }
637
638    // require_checksum with nothing to compare => Rejected(CHECKSUM_MISSING).
639    #[test]
640    fn require_checksum_without_value_is_rejected() {
641        let p = ArtifactProvenance::dev_artifact("rch-wkr-dev", LINUX);
642        let policy = ProvenancePolicy {
643            require_signature: false,
644            require_checksum: true,
645            allow_dev_artifacts: true,
646        };
647        let v = verify_artifact_provenance(&p, LINUX, None, SignatureCheck::Absent, &policy);
648        assert_eq!(v.reason_code(), Some(reason_code::CHECKSUM_MISSING));
649    }
650
651    #[test]
652    fn checksum_comparison_is_case_insensitive() {
653        let p = signed_release(LINUX, SHA);
654        let upper = SHA.to_ascii_uppercase();
655        let v = verify_artifact_provenance(
656            &p,
657            LINUX,
658            Some(&upper),
659            SignatureCheck::Valid,
660            &ProvenancePolicy::STRICT,
661        );
662        assert_eq!(v, ProvenanceVerdict::Verified);
663    }
664
665    #[test]
666    fn default_policy_is_dev_friendly() {
667        assert_eq!(
668            ProvenancePolicy::default(),
669            ProvenancePolicy::dev_friendly()
670        );
671    }
672
673    #[test]
674    fn audit_record_from_verdict_carries_status_and_reason() {
675        let p = signed_release(LINUX, SHA);
676        let verdict = verify_artifact_provenance(
677            &p,
678            LINUX,
679            Some(SHA),
680            SignatureCheck::Valid,
681            &ProvenancePolicy::STRICT,
682        );
683        let rec = FleetDeployAuditRecord::from_verdict(
684            "run-1",
685            "bd-session-history-remediation-ocv9i.7.4",
686            "css",
687            "ubuntu",
688            "/home/ubuntu/.local/bin/rch-wkr",
689            &p,
690            Some("rch-wkr-v1.0.41-x86_64-unknown-linux-musl".to_string()),
691            1_700_000_000_000,
692            &verdict,
693            "operator",
694            "deploy verified",
695        );
696        assert_eq!(rec.schema_version, FLEET_DEPLOY_AUDIT_SCHEMA_VERSION);
697        assert_eq!(rec.verification_status, "verified");
698        assert_eq!(rec.rollback_status, rollback_status::NONE);
699        assert_eq!(rec.reason_code, None);
700        assert_eq!(rec.target_triple, LINUX);
701        // duration_ms defaults to 0 until the deploy path sets it.
702        assert_eq!(rec.duration_ms, 0);
703        assert_eq!(rec.deployed_at_unix_ms, 1_700_000_000_000);
704        assert_eq!(
705            rec.previous_artifact_id.as_deref(),
706            Some("rch-wkr-v1.0.41-x86_64-unknown-linux-musl")
707        );
708    }
709
710    #[test]
711    fn audit_record_serde_roundtrip_is_stable() {
712        let p = ArtifactProvenance::dev_artifact("rch-wkr-dev", LINUX);
713        let verdict = ProvenanceVerdict::DevArtifactAllowed {
714            reason: "dev".to_string(),
715        };
716        let rec = FleetDeployAuditRecord::from_verdict(
717            "run-2",
718            "bd-x",
719            "hz1",
720            "ubuntu",
721            "/home/ubuntu/.local/bin/rch-wkr",
722            &p,
723            None,
724            42,
725            &verdict,
726            "remediation",
727            "dev deploy",
728        );
729        let json = serde_json::to_string(&rec).unwrap();
730        let back: FleetDeployAuditRecord = serde_json::from_str(&json).unwrap();
731        assert_eq!(rec, back);
732        // Stable tagged-enum verdict serialization.
733        let vjson = serde_json::to_string(&verdict).unwrap();
734        assert!(vjson.contains("\"status\":\"dev_artifact_allowed\""));
735    }
736
737    #[test]
738    fn verdict_is_tagged_with_snake_case_status() {
739        let rejected = ProvenanceVerdict::Rejected {
740            reason_code: reason_code::CHECKSUM_MISMATCH.to_string(),
741            detail: "x".to_string(),
742        };
743        let json = serde_json::to_string(&rejected).unwrap();
744        assert!(json.contains("\"status\":\"rejected\""));
745        assert!(json.contains("provenance_checksum_mismatch"));
746
747        // The internally-tagged UNIT variant must round-trip too (a classic
748        // serde footgun for `#[serde(tag = ...)]` enums with unit variants).
749        let verified = ProvenanceVerdict::Verified;
750        let vjson = serde_json::to_string(&verified).unwrap();
751        assert_eq!(vjson, "{\"status\":\"verified\"}");
752        let back: ProvenanceVerdict = serde_json::from_str(&vjson).unwrap();
753        assert_eq!(back, ProvenanceVerdict::Verified);
754    }
755
756    #[test]
757    fn schema_version_is_pinned() {
758        assert_eq!(FLEET_DEPLOY_AUDIT_SCHEMA_VERSION, "1.0.0");
759    }
760
761    #[test]
762    fn policy_from_env_value_selects_strict_only_for_strict() {
763        assert_eq!(
764            ProvenancePolicy::from_env_value(Some("strict")),
765            ProvenancePolicy::STRICT
766        );
767        // Case-insensitive + whitespace tolerant.
768        assert_eq!(
769            ProvenancePolicy::from_env_value(Some("  STRICT \n")),
770            ProvenancePolicy::STRICT
771        );
772        // Anything else (including unset) is the dev-friendly fail-open default.
773        for v in [
774            None,
775            Some(""),
776            Some("dev"),
777            Some("1"),
778            Some("relaxed"),
779            Some("strictish"),
780        ] {
781            assert_eq!(
782                ProvenancePolicy::from_env_value(v),
783                ProvenancePolicy::dev_friendly(),
784                "value {v:?} should resolve to dev_friendly"
785            );
786        }
787    }
788
789    // Scenario: rollback after a failed canary deploy — the audit record is
790    // updated from `none` to `rolled_back` and carries the elapsed duration.
791    #[test]
792    fn rollback_after_canary_marks_record_rolled_back() {
793        let p = ArtifactProvenance::dev_artifact("rch-wkr", LINUX);
794        let verdict = ProvenanceVerdict::DevArtifactAllowed {
795            reason: "dev".to_string(),
796        };
797        let mut rec = FleetDeployAuditRecord::from_verdict(
798            "run-canary",
799            "bd-x",
800            "css",
801            "ubuntu",
802            "/home/ubuntu/.local/bin/rch-wkr",
803            &p,
804            Some("rch-wkr-prev".to_string()),
805            1,
806            &verdict,
807            "canary_failure",
808            "canary failed, reverting",
809        );
810        assert_eq!(rec.rollback_status, rollback_status::NONE);
811        rec.set_duration_ms(4_200);
812        rec.mark_rolled_back();
813        assert_eq!(rec.rollback_status, rollback_status::ROLLED_BACK);
814        assert_eq!(rec.duration_ms, 4_200);
815        // Round-trips with the mutated status.
816        let json = serde_json::to_string(&rec).unwrap();
817        let back: FleetDeployAuditRecord = serde_json::from_str(&json).unwrap();
818        assert_eq!(back, rec);
819    }
820
821    // Scenario: an interrupted deploy whose rollback also fails is flagged
822    // `rollback_failed` so operators see the worker needs attention.
823    #[test]
824    fn interrupted_deploy_with_failed_rollback_is_flagged() {
825        let p = ArtifactProvenance::dev_artifact("rch-wkr", LINUX);
826        let verdict = ProvenanceVerdict::Verified;
827        let mut rec = FleetDeployAuditRecord::from_verdict(
828            "run-interrupt",
829            "bd-x",
830            "hz1",
831            "ubuntu",
832            "/home/ubuntu/.local/bin/rch-wkr",
833            &p,
834            None,
835            1,
836            &verdict,
837            "operator",
838            "transfer interrupted mid-deploy",
839        );
840        rec.mark_rollback_failed();
841        assert_eq!(rec.rollback_status, rollback_status::ROLLBACK_FAILED);
842    }
843}