1use chrono::{DateTime, Utc};
9use clap::ValueEnum;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13pub type SchemaVersion = u32;
14pub const SCHEMA_VERSION: SchemaVersion = 1;
15
16#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "lowercase")]
18pub enum OutputFormat {
19 Text,
20 Json,
21 Markdown,
22}
23
24#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize, PartialEq, Eq, Hash)]
25#[serde(rename_all = "lowercase")]
26pub enum RunnerOs {
27 Linux,
28 Windows,
29 Macos,
30}
31
32impl RunnerOs {
33 pub fn gha_name(self) -> &'static str {
34 match self {
35 Self::Linux => "Linux",
36 Self::Windows => "Windows",
37 Self::Macos => "macOS",
38 }
39 }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct ToolInfo {
44 pub name: String,
45 pub version: String,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
49#[serde(rename_all = "lowercase")]
50pub enum CheckStatus {
51 Pass,
52 Warn,
53 Fail,
54 Skip,
55}
56
57impl CheckStatus {
58 pub fn symbol(self) -> &'static str {
59 match self {
60 Self::Pass => "✓",
61 Self::Warn => "!",
62 Self::Fail => "✗",
63 Self::Skip => "-",
64 }
65 }
66
67 pub fn word(self) -> &'static str {
68 match self {
69 Self::Pass => "pass",
70 Self::Warn => "warn",
71 Self::Fail => "fail",
72 Self::Skip => "skip",
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
86#[serde(rename_all = "lowercase")]
87pub enum Compatibility {
88 Exact,
89 Compatible,
90 Simulated,
91 Unsupported,
92}
93
94impl Compatibility {
95 pub fn worse(self, other: Self) -> Self {
97 use Compatibility::*;
98 match (self, other) {
99 (Unsupported, _) | (_, Unsupported) => Unsupported,
100 (Simulated, _) | (_, Simulated) => Simulated,
101 (Compatible, _) | (_, Compatible) => Compatible,
102 _ => Exact,
103 }
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
108#[serde(rename_all = "kebab-case")]
109pub enum NetworkModel {
110 CiForgeManaged,
112 DockerDefault,
114 UnsupportedCustom,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
119#[serde(rename_all = "kebab-case")]
120pub enum SubjectKind {
121 JobContainer,
122 DockerAction,
123 DockerProbe,
124}
125
126#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
127pub struct ReceiptSummary {
128 pub passed: usize,
129 pub warnings: usize,
130 pub failed: usize,
131 pub skipped: usize,
132}
133
134impl ReceiptSummary {
135 pub fn from_checks(checks: &[Check]) -> Self {
136 let mut summary = Self::default();
137 for check in checks {
138 summary.tally(check.status);
139 }
140 summary
141 }
142
143 pub fn tally(&mut self, status: CheckStatus) {
144 match status {
145 CheckStatus::Pass => self.passed += 1,
146 CheckStatus::Warn => self.warnings += 1,
147 CheckStatus::Fail => self.failed += 1,
148 CheckStatus::Skip => self.skipped += 1,
149 }
150 }
151
152 pub fn add(&mut self, other: &Self) {
153 self.passed += other.passed;
154 self.warnings += other.warnings;
155 self.failed += other.failed;
156 self.skipped += other.skipped;
157 }
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161pub struct Check {
162 pub id: String,
163 pub status: CheckStatus,
164 pub message: String,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub location: Option<String>,
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub details: Option<Value>,
169}
170
171impl Check {
172 pub fn new(id: impl Into<String>, status: CheckStatus, message: impl Into<String>) -> Self {
173 Self {
174 id: id.into(),
175 status,
176 message: message.into(),
177 location: None,
178 details: None,
179 }
180 }
181
182 pub fn pass(id: impl Into<String>, message: impl Into<String>) -> Self {
183 Self::new(id, CheckStatus::Pass, message)
184 }
185
186 pub fn warn(id: impl Into<String>, message: impl Into<String>) -> Self {
187 Self::new(id, CheckStatus::Warn, message)
188 }
189
190 pub fn fail(id: impl Into<String>, message: impl Into<String>) -> Self {
191 Self::new(id, CheckStatus::Fail, message)
192 }
193
194 pub fn skip(id: impl Into<String>, message: impl Into<String>) -> Self {
195 Self::new(id, CheckStatus::Skip, message)
196 }
197
198 pub fn at(mut self, location: impl Into<String>) -> Self {
199 self.location = Some(location.into());
200 self
201 }
202
203 pub fn with_details(mut self, details: Value) -> Self {
204 self.details = Some(details);
205 self
206 }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212pub struct Subject {
213 pub kind: SubjectKind,
214 #[serde(skip_serializing_if = "Option::is_none")]
215 pub job_id: Option<String>,
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub step_id: Option<String>,
218 #[serde(skip_serializing_if = "Option::is_none")]
219 pub action_ref: Option<String>,
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub image: Option<String>,
222 #[serde(skip_serializing_if = "Option::is_none")]
223 pub dockerfile: Option<String>,
224 #[serde(skip_serializing_if = "Option::is_none")]
225 pub runner_os: Option<RunnerOs>,
226 pub classification: Compatibility,
227 pub network_model: NetworkModel,
228 pub requires_docker: bool,
229 pub requires_build: bool,
230 pub requires_pull: bool,
231 #[serde(default, skip_serializing_if = "Vec::is_empty")]
232 pub credentials_redacted: Vec<String>,
233 #[serde(default, skip_serializing_if = "Vec::is_empty")]
234 pub env_redacted: Vec<String>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub probe: Option<ProbeReport>,
237 pub summary: ReceiptSummary,
238 pub checks: Vec<Check>,
239}
240
241impl Subject {
242 pub fn new(kind: SubjectKind) -> Self {
243 Self {
244 kind,
245 job_id: None,
246 step_id: None,
247 action_ref: None,
248 image: None,
249 dockerfile: None,
250 runner_os: None,
251 classification: Compatibility::Exact,
252 network_model: NetworkModel::DockerDefault,
253 requires_docker: false,
254 requires_build: false,
255 requires_pull: false,
256 credentials_redacted: Vec::new(),
257 env_redacted: Vec::new(),
258 probe: None,
259 summary: ReceiptSummary::default(),
260 checks: Vec::new(),
261 }
262 }
263
264 pub fn push(&mut self, check: Check) {
265 self.summary.tally(check.status);
266 self.checks.push(check);
267 }
268
269 pub fn finalize(&mut self) {
273 self.summary = ReceiptSummary::from_checks(&self.checks);
274 if self.summary.failed > 0 {
275 self.classification = Compatibility::Unsupported;
276 } else if self.classification == Compatibility::Exact && self.summary.warnings > 0 {
277 self.classification = Compatibility::Compatible;
278 }
279 }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
284pub struct ProbeReport {
285 pub docker_cli_available: bool,
286 #[serde(skip_serializing_if = "Option::is_none")]
287 pub docker_bin: Option<String>,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub inspect: Option<ProbeStep>,
290 #[serde(default, skip_serializing_if = "Vec::is_empty")]
291 pub tools: Vec<ProbeStep>,
292 #[serde(default, skip_serializing_if = "Vec::is_empty")]
293 pub commands: Vec<ProbeStep>,
294}
295
296impl ProbeReport {
297 pub fn new() -> Self {
298 Self {
299 docker_cli_available: false,
300 docker_bin: None,
301 inspect: None,
302 tools: Vec::new(),
303 commands: Vec::new(),
304 }
305 }
306}
307
308impl Default for ProbeReport {
309 fn default() -> Self {
310 Self::new()
311 }
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
317pub struct ProbeStep {
318 pub kind: ProbeStepKind,
319 pub command: String,
320 pub success: bool,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 pub exit_code: Option<i32>,
323 pub elapsed_ms: u128,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 pub stdout: Option<String>,
326 #[serde(skip_serializing_if = "Option::is_none")]
327 pub stderr: Option<String>,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub spawn_error: Option<String>,
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
333#[serde(rename_all = "kebab-case")]
334pub enum ProbeStepKind {
335 Inspect,
336 Tool,
337 Command,
338 Pull,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
343pub struct ContainerProofReceipt {
344 pub schema_version: SchemaVersion,
345 pub tool: ToolInfo,
346 pub checked_at: DateTime<Utc>,
347 pub mode: String,
348 pub compatibility: Compatibility,
349 pub summary: ReceiptSummary,
350 #[serde(default, skip_serializing_if = "Vec::is_empty")]
351 pub subjects: Vec<Subject>,
352 #[serde(default, skip_serializing_if = "Vec::is_empty")]
353 pub checks: Vec<Check>,
354}
355
356impl ContainerProofReceipt {
357 pub fn build(
361 mode: impl Into<String>,
362 mut subjects: Vec<Subject>,
363 receipt_checks: Vec<Check>,
364 ) -> Self {
365 for subject in &mut subjects {
366 subject.finalize();
367 }
368
369 let mut summary = ReceiptSummary::from_checks(&receipt_checks);
370 for subject in &subjects {
371 summary.add(&subject.summary);
372 }
373
374 let compatibility = subjects
375 .iter()
376 .map(|subject| subject.classification)
377 .fold(Compatibility::Exact, Compatibility::worse);
378
379 let compatibility = if subjects.is_empty() && summary.failed == 0 {
380 Compatibility::Exact
382 } else {
383 compatibility
384 };
385
386 Self {
387 schema_version: SCHEMA_VERSION,
388 tool: ToolInfo {
389 name: crate::TOOL_NAME.to_owned(),
390 version: crate::TOOL_VERSION.to_owned(),
391 },
392 checked_at: Utc::now(),
393 mode: mode.into(),
394 compatibility,
395 summary,
396 subjects,
397 checks: receipt_checks,
398 }
399 }
400
401 pub fn is_success(&self, strict: bool) -> bool {
404 self.summary.failed == 0 && (!strict || self.summary.warnings == 0)
405 }
406}
407
408pub fn is_sensitive_key(key: &str) -> bool {
410 let upper = key.to_ascii_uppercase();
411 [
412 "PASSWORD",
413 "PASS",
414 "SECRET",
415 "TOKEN",
416 "CREDENTIAL",
417 "API_KEY",
418 "ACCESS_KEY",
419 "PRIVATE_KEY",
420 ]
421 .iter()
422 .any(|needle| upper.contains(needle))
423 || (upper.contains("KEY") && !upper.starts_with("KEYWORD"))
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
431 fn compatibility_worse_orders_correctly() {
432 use Compatibility::*;
433 assert_eq!(Exact.worse(Compatible), Compatible);
434 assert_eq!(Compatible.worse(Simulated), Simulated);
435 assert_eq!(Simulated.worse(Unsupported), Unsupported);
436 assert_eq!(Unsupported.worse(Exact), Unsupported);
437 }
438
439 #[test]
440 fn subject_finalize_promotes_failures_to_unsupported() {
441 let mut subject = Subject::new(SubjectKind::JobContainer);
442 subject.classification = Compatibility::Exact;
443 subject.push(Check::fail("x", "broken"));
444 subject.finalize();
445 assert_eq!(subject.classification, Compatibility::Unsupported);
446 assert_eq!(subject.summary.failed, 1);
447 }
448
449 #[test]
450 fn subject_finalize_promotes_warnings_to_compatible() {
451 let mut subject = Subject::new(SubjectKind::JobContainer);
452 subject.classification = Compatibility::Exact;
453 subject.push(Check::warn("x", "iffy"));
454 subject.finalize();
455 assert_eq!(subject.classification, Compatibility::Compatible);
456 assert_eq!(subject.summary.warnings, 1);
457 }
458
459 #[test]
460 fn subject_finalize_keeps_simulated_with_warnings() {
461 let mut subject = Subject::new(SubjectKind::DockerAction);
462 subject.classification = Compatibility::Simulated;
463 subject.push(Check::warn("x", "iffy"));
464 subject.finalize();
465 assert_eq!(subject.classification, Compatibility::Simulated);
466 }
467
468 #[test]
469 fn receipt_build_rolls_up_compatibility() {
470 let mut subject = Subject::new(SubjectKind::JobContainer);
471 subject.classification = Compatibility::Simulated;
472 let receipt = ContainerProofReceipt::build("plan-job", vec![subject], Vec::new());
473 assert_eq!(receipt.compatibility, Compatibility::Simulated);
474 }
475
476 #[test]
477 fn is_sensitive_key_catches_common_shapes() {
478 assert!(is_sensitive_key("DATABASE_PASSWORD"));
479 assert!(is_sensitive_key("github_token"));
480 assert!(is_sensitive_key("API_SECRET"));
481 assert!(is_sensitive_key("MY_PRIVATE_KEY"));
482 assert!(!is_sensitive_key("NODE_ENV"));
483 assert!(!is_sensitive_key("KEYWORD"));
484 }
485}