Skip to main content

deputy_core/
state.rs

1use crate::error::{Error, Result};
2use serde::{Deserialize, Serialize};
3
4/// The lifecycle state of a single dependency artifact as it moves through the pipeline.
5/// The allowed transitions encode `docs/PIPELINE.md` §7 and are enforced by
6/// [`ArtifactState::transition`]. Every edge is, additionally, mID-gated at the API layer.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum ArtifactState {
9    /// Found in a source's resolved dependency graph.
10    Discovered,
11    /// Downloaded and integrity-verified into the dirty store.
12    Acquired,
13    /// Language analytics + critical-point-of-failure scoring complete.
14    Analyzed,
15    /// Scanners have run and produced a verdict.
16    Scanned,
17    /// Clean verdict; promoted into the prod store with a signed receipt.
18    Promoted,
19    /// Scan found issues; held out of prod pending review / re-scan.
20    Quarantined,
21    /// Materialized back into source and cleared by the deploy gate.
22    Deployed,
23}
24
25impl ArtifactState {
26    pub const fn name(self) -> &'static str {
27        match self {
28            ArtifactState::Discovered => "Discovered",
29            ArtifactState::Acquired => "Acquired",
30            ArtifactState::Analyzed => "Analyzed",
31            ArtifactState::Scanned => "Scanned",
32            ArtifactState::Promoted => "Promoted",
33            ArtifactState::Quarantined => "Quarantined",
34            ArtifactState::Deployed => "Deployed",
35        }
36    }
37
38    /// Whether `self -> next` is a permitted edge in the pipeline state machine.
39    pub const fn can_transition_to(self, next: ArtifactState) -> bool {
40        use ArtifactState::*;
41        matches!(
42            (self, next),
43            (Discovered, Acquired)
44                | (Acquired, Analyzed)
45                | (Analyzed, Scanned)
46                | (Scanned, Promoted)
47                | (Scanned, Quarantined)
48                | (Quarantined, Scanned)
49                | (Promoted, Deployed)
50        )
51    }
52
53    /// Advance to `next`, or return [`Error::IllegalTransition`] if the edge is not allowed.
54    pub fn transition(self, next: ArtifactState) -> Result<ArtifactState> {
55        if self.can_transition_to(next) {
56            Ok(next)
57        } else {
58            Err(Error::IllegalTransition {
59                from: self.name(),
60                to: next.name(),
61            })
62        }
63    }
64}
65
66/// Severity of a scanner finding.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
68pub enum Severity {
69    Low,
70    Medium,
71    High,
72    Critical,
73}
74
75/// A single issue raised by a scanner against an artifact.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct Finding {
78    pub id: String,
79    pub severity: Severity,
80    pub summary: String,
81}
82
83/// The outcome of scanning an artifact. Only [`ScanVerdict::Clean`] is promotable
84/// (`docs/PIPELINE.md` §5).
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub enum ScanVerdict {
87    Clean,
88    Findings(Vec<Finding>),
89}
90
91impl ScanVerdict {
92    pub fn is_clean(&self) -> bool {
93        matches!(self, ScanVerdict::Clean)
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::ArtifactState::*;
100    use super::*;
101
102    #[test]
103    fn happy_path_is_permitted() {
104        let path = [Discovered, Acquired, Analyzed, Scanned, Promoted, Deployed];
105        for pair in path.windows(2) {
106            assert!(
107                pair[0].can_transition_to(pair[1]),
108                "{:?} -> {:?}",
109                pair[0],
110                pair[1]
111            );
112        }
113    }
114
115    #[test]
116    fn quarantine_and_rescan_loop_is_permitted() {
117        assert!(Scanned.can_transition_to(Quarantined));
118        assert!(Quarantined.can_transition_to(Scanned));
119    }
120
121    #[test]
122    fn skipping_states_is_rejected() {
123        let err = Discovered.transition(Promoted).unwrap_err();
124        assert_eq!(
125            err,
126            Error::IllegalTransition {
127                from: "Discovered",
128                to: "Promoted"
129            }
130        );
131    }
132
133    #[test]
134    fn quarantined_cannot_deploy() {
135        assert!(!Quarantined.can_transition_to(Deployed));
136        assert!(!Quarantined.can_transition_to(Promoted));
137    }
138
139    #[test]
140    fn only_clean_verdict_is_clean() {
141        assert!(ScanVerdict::Clean.is_clean());
142        assert!(!ScanVerdict::Findings(vec![Finding {
143            id: "RUSTSEC-0000-0000".into(),
144            severity: Severity::High,
145            summary: "example".into(),
146        }])
147        .is_clean());
148    }
149}