1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::HashSet;
6use std::sync::{Mutex, OnceLock};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum TemplateCandidateState {
11 LegacyUnverified,
12 Candidate,
13 Quarantined,
14 Approved,
15 Revoked,
16}
17
18impl TemplateCandidateState {
19 pub fn as_str(self) -> &'static str {
20 match self {
21 Self::LegacyUnverified => "legacy_unverified",
22 Self::Candidate => "candidate",
23 Self::Quarantined => "quarantined",
24 Self::Approved => "approved",
25 Self::Revoked => "revoked",
26 }
27 }
28 pub fn can_transition(self, to: Self) -> bool {
29 match self {
30 Self::LegacyUnverified => to == Self::Quarantined,
31 Self::Candidate => matches!(to, Self::Candidate | Self::Quarantined | Self::Approved),
32 Self::Approved => matches!(to, Self::Approved | Self::Quarantined | Self::Revoked),
33 Self::Quarantined => to == Self::Candidate,
34 Self::Revoked => false,
35 }
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct TemplateOutcome {
41 pub template_id: String,
42 pub run_id: String,
43 pub terminal_receipt_id: String,
44 pub receipt_digest: String,
45 pub disposition: String,
46 pub evidence_digest: String,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct OperatorReceipt {
51 pub operator_id: String,
52 pub nonce: String,
53 pub authenticated: bool,
54}
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum PromotionError {
57 Unauthorized,
58 AuthorizationReplayed,
59 NotFound,
60 InvalidTransition,
61 NotEligible,
62 ReceiptMismatch(String),
63 NonTerminal,
64}
65
66static NONCES: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
67fn nonces() -> &'static Mutex<HashSet<String>> {
68 NONCES.get_or_init(|| Mutex::new(HashSet::new()))
69}
70
71#[derive(Debug, Clone)]
72pub struct PromotionStore {
73 pub template_id: String,
74 pub spec_digest: String,
75 pub graph_id: String,
76 pub graph_version: String,
77 pub state: TemplateCandidateState,
78 pub outcomes: Vec<TemplateOutcome>,
79 pub decisions: Vec<String>,
80}
81impl PromotionStore {
82 pub fn new(template_id: &str, spec_digest: &str, graph_id: &str, graph_version: &str) -> Self {
83 Self {
84 template_id: template_id.into(),
85 spec_digest: spec_digest.into(),
86 graph_id: graph_id.into(),
87 graph_version: graph_version.into(),
88 state: TemplateCandidateState::Candidate,
89 outcomes: vec![],
90 decisions: vec![],
91 }
92 }
93 pub fn add_outcome(&mut self, o: TemplateOutcome) -> Result<(), PromotionError> {
94 if o.template_id != self.template_id {
95 return Err(PromotionError::ReceiptMismatch("template_id".into()));
96 }
97 if self.outcomes.iter().any(|x| x.run_id == o.run_id) {
98 return Ok(());
99 }
100 if o.disposition.eq_ignore_ascii_case("bad")
101 || o.disposition.eq_ignore_ascii_case("failed")
102 || o.disposition.eq_ignore_ascii_case("contradicted")
103 {
104 self.state = TemplateCandidateState::Quarantined;
105 }
106 self.outcomes.push(o);
107 Ok(())
108 }
109 pub fn eligible(&self) -> bool {
110 self.state == TemplateCandidateState::Candidate
111 && self
112 .outcomes
113 .iter()
114 .filter(|o| {
115 o.disposition.eq_ignore_ascii_case("good")
116 || o.disposition.eq_ignore_ascii_case("supported")
117 })
118 .count()
119 >= 3
120 }
121 pub fn promote_template(&mut self, receipt: OperatorReceipt) -> Result<(), PromotionError> {
122 if !receipt.authenticated || !receipt.operator_id.starts_with("operator:") {
123 return Err(PromotionError::Unauthorized);
124 }
125 let mut used = nonces().lock().unwrap();
126 if !used.insert(receipt.nonce.clone()) {
127 return Err(PromotionError::AuthorizationReplayed);
128 }
129 if !self.eligible() {
130 return Err(PromotionError::NotEligible);
131 }
132 if !self.state.can_transition(TemplateCandidateState::Approved) {
133 return Err(PromotionError::InvalidTransition);
134 }
135 self.state = TemplateCandidateState::Approved;
136 self.decisions.push(receipt.nonce);
137 Ok(())
138 }
139}
140
141pub fn verify_canonical_receipt(
142 run_id: &str,
143 receipt_digest: &str,
144 graph_id: &str,
145 graph_version: &str,
146 template_id: &str,
147 spec_digest: &str,
148 actual_run_id: &str,
149 actual_receipt_digest: &str,
150 actual_graph_id: &str,
151 actual_graph_version: &str,
152 actual_template_id: &str,
153 actual_spec_digest: &str,
154 terminal: bool,
155) -> Result<(), PromotionError> {
156 for (name, a, b) in [
157 ("run_id", run_id, actual_run_id),
158 ("receipt_digest", receipt_digest, actual_receipt_digest),
159 ("graph_id", graph_id, actual_graph_id),
160 ("graph_version", graph_version, actual_graph_version),
161 ("template_id", template_id, actual_template_id),
162 ("spec_digest", spec_digest, actual_spec_digest),
163 ] {
164 if a != b {
165 return Err(PromotionError::ReceiptMismatch(name.into()));
166 }
167 }
168 if !terminal {
169 return Err(PromotionError::NonTerminal);
170 }
171 Ok(())
172}
173pub fn evidence_digest(outcomes: &[TemplateOutcome]) -> String {
174 let mut h = Sha256::new();
175 for o in outcomes {
176 h.update(format!(
177 "{}:{}:{}:{}:{}:{}\n",
178 o.template_id,
179 o.run_id,
180 o.terminal_receipt_id,
181 o.receipt_digest,
182 o.disposition,
183 o.evidence_digest
184 ));
185 }
186 format!("{:x}", h.finalize())
187}