Skip to main content

gha_command_proof/
lib.rs

1//! GitHub Actions workflow command and environment-file verifier.
2//!
3//! `gha-command-proof` parses the command channel that actions write to
4//! stdout/stderr, plus the file command protocol exposed through
5//! `GITHUB_ENV`, `GITHUB_OUTPUT`, `GITHUB_STATE`, `GITHUB_PATH`, and
6//! `GITHUB_STEP_SUMMARY`.
7
8mod command;
9mod envfile;
10mod receipt;
11mod render;
12
13use std::collections::BTreeMap;
14
15use chrono::Utc;
16
17pub use command::{
18    CommandRecord, CommandSyntax, LogAnalysis, ParsedCommand, analyze_command_stream, escape_data,
19    escape_legacy, escape_property, parse_command_line, unescape_data, unescape_legacy,
20    unescape_property,
21};
22pub use envfile::{EnvFileAnalysis, EnvFileKind, EnvFileRecord, analyze_env_file};
23pub use receipt::{Check, CheckStatus, Location, Receipt, Summary, ToolReceipt};
24pub use render::{OutputFormat, render_receipt};
25
26/// Options shared by command-stream and environment-file analysis.
27#[derive(Clone, Debug, Default)]
28pub struct ProofOptions {
29    /// Treat warnings as failed checks in the rendered summary and process exit.
30    pub strict: bool,
31}
32
33/// Inputs for a single GitHub Actions step proof.
34#[derive(Clone, Debug, Default)]
35pub struct StepInput {
36    /// Optional stdout/stderr command stream.
37    pub log: Option<NamedText>,
38    /// Optional `GITHUB_ENV` file contents.
39    pub github_env: Option<NamedText>,
40    /// Optional `GITHUB_OUTPUT` file contents.
41    pub github_output: Option<NamedText>,
42    /// Optional `GITHUB_STATE` file contents.
43    pub github_state: Option<NamedText>,
44    /// Optional `GITHUB_PATH` file contents.
45    pub github_path: Option<NamedText>,
46    /// Optional `GITHUB_STEP_SUMMARY` file contents.
47    pub github_step_summary: Option<NamedText>,
48}
49
50/// Text with a stable source label used in receipts.
51#[derive(Clone, Debug)]
52pub struct NamedText {
53    pub source: String,
54    pub text: String,
55}
56
57impl NamedText {
58    pub fn new(source: impl Into<String>, text: impl Into<String>) -> Self {
59        Self {
60            source: source.into(),
61            text: text.into(),
62        }
63    }
64}
65
66/// Analyze one command stream.
67pub fn prove_log(input: NamedText, options: &ProofOptions) -> Receipt {
68    let analysis = analyze_command_stream(&input.text, Some(input.source));
69    receipt_from_parts(
70        "log",
71        options,
72        analysis.checks,
73        analysis.commands,
74        Vec::new(),
75    )
76}
77
78/// Analyze one environment file.
79pub fn prove_env_file(kind: EnvFileKind, input: NamedText, options: &ProofOptions) -> Receipt {
80    let analysis = analyze_env_file(kind, &input.text, Some(input.source), &[]);
81    receipt_from_parts(
82        "env-file",
83        options,
84        analysis.checks.clone(),
85        Vec::new(),
86        vec![analysis],
87    )
88}
89
90/// Analyze a whole step boundary: command stream first, then all supplied file commands.
91pub fn prove_step(input: StepInput, options: &ProofOptions) -> Receipt {
92    let mut checks = Vec::new();
93    let mut commands = Vec::new();
94    let mut files = Vec::new();
95    let mut masks = Vec::new();
96
97    if let Some(log) = input.log {
98        let analysis = analyze_command_stream(&log.text, Some(log.source));
99        masks = analysis.mask_values;
100        checks.extend(analysis.checks);
101        commands.extend(analysis.commands);
102    } else {
103        checks.push(Check::skip(
104            "step.log",
105            "no command stream supplied for this step",
106            None,
107        ));
108    }
109
110    for (kind, text) in [
111        (EnvFileKind::Env, input.github_env),
112        (EnvFileKind::Output, input.github_output),
113        (EnvFileKind::State, input.github_state),
114        (EnvFileKind::Path, input.github_path),
115        (EnvFileKind::StepSummary, input.github_step_summary),
116    ] {
117        if let Some(text) = text {
118            let analysis = analyze_env_file(kind, &text.text, Some(text.source), &masks);
119            checks.extend(analysis.checks.clone());
120            files.push(analysis);
121        }
122    }
123
124    if files.is_empty() {
125        checks.push(Check::skip(
126            "step.env_files",
127            "no environment files supplied for this step",
128            None,
129        ));
130    }
131
132    receipt_from_parts("step", options, checks, commands, files)
133}
134
135fn receipt_from_parts(
136    mode: impl Into<String>,
137    options: &ProofOptions,
138    checks: Vec<Check>,
139    commands: Vec<CommandRecord>,
140    env_files: Vec<EnvFileAnalysis>,
141) -> Receipt {
142    let mut summary = Summary::from_checks(&checks);
143    if options.strict && summary.warned > 0 {
144        summary.failed += summary.warned;
145        summary.warned = 0;
146    }
147
148    Receipt {
149        schema_version: 1,
150        tool: ToolReceipt {
151            name: "gha-command-proof",
152            version: env!("CARGO_PKG_VERSION"),
153        },
154        checked_at: Utc::now(),
155        mode: mode.into(),
156        summary,
157        checks,
158        commands,
159        env_files,
160        metadata: BTreeMap::new(),
161    }
162}