Skip to main content

gha_container_proof/
action.rs

1//! Parser for local action manifests (`action.yml` / `action.yaml`).
2//!
3//! `gha-container-proof` only classifies actions whose `runs.using` is
4//! `docker`; other action shapes are still parsed enough to report
5//! `action.using.unsupported`.
6
7use anyhow::{Context, Result};
8use camino::{Utf8Path, Utf8PathBuf};
9use serde_yaml::{Mapping, Value as YamlValue};
10use std::fs;
11
12#[derive(Debug, Clone, Default)]
13pub struct ActionManifest {
14    pub source_path: Utf8PathBuf,
15    pub using: Option<String>,
16    pub image: Option<String>,
17    pub entrypoint: Option<String>,
18    pub pre_entrypoint: Option<String>,
19    pub post_entrypoint: Option<String>,
20    pub args: Vec<String>,
21    pub env: Vec<(String, String)>,
22}
23
24impl ActionManifest {
25    pub fn read(action_path: &Utf8Path) -> Result<Self> {
26        let manifest_path = if action_path.is_file() {
27            action_path.to_owned()
28        } else {
29            let yml = action_path.join("action.yml");
30            if yml.exists() {
31                yml
32            } else {
33                action_path.join("action.yaml")
34            }
35        };
36
37        let raw = fs::read_to_string(&manifest_path)
38            .with_context(|| format!("reading action manifest {manifest_path}"))?;
39        let yaml: YamlValue = serde_yaml::from_str(&raw)
40            .with_context(|| format!("parsing action manifest {manifest_path}"))?;
41
42        let mut manifest = ActionManifest {
43            source_path: manifest_path,
44            ..ActionManifest::default()
45        };
46
47        if let Some(runs) = yaml.get("runs").and_then(YamlValue::as_mapping) {
48            manifest.using = string_value(runs, "using");
49            manifest.image = string_value(runs, "image");
50            manifest.entrypoint = string_value(runs, "entrypoint");
51            manifest.pre_entrypoint = string_value(runs, "pre-entrypoint");
52            manifest.post_entrypoint = string_value(runs, "post-entrypoint");
53            manifest.args = string_list(runs, "args");
54            manifest.env = string_map(runs, "env");
55        }
56
57        Ok(manifest)
58    }
59}
60
61fn string_value(mapping: &Mapping, key: &str) -> Option<String> {
62    mapping
63        .get(YamlValue::String(key.to_owned()))
64        .and_then(yaml_string)
65}
66
67fn string_list(mapping: &Mapping, key: &str) -> Vec<String> {
68    let Some(value) = mapping.get(YamlValue::String(key.to_owned())) else {
69        return Vec::new();
70    };
71    match value {
72        YamlValue::Sequence(items) => items.iter().filter_map(yaml_string).collect(),
73        other => yaml_string(other).into_iter().collect(),
74    }
75}
76
77fn string_map(mapping: &Mapping, key: &str) -> Vec<(String, String)> {
78    let Some(map) = mapping
79        .get(YamlValue::String(key.to_owned()))
80        .and_then(YamlValue::as_mapping)
81    else {
82        return Vec::new();
83    };
84    map.iter()
85        .filter_map(|(k, v)| match (yaml_string(k), yaml_string(v)) {
86            (Some(k), Some(v)) => Some((k, v)),
87            _ => None,
88        })
89        .collect()
90}
91
92fn yaml_string(value: &YamlValue) -> Option<String> {
93    match value {
94        YamlValue::String(value) => Some(value.clone()),
95        YamlValue::Number(value) => Some(value.to_string()),
96        YamlValue::Bool(value) => Some(value.to_string()),
97        _ => None,
98    }
99}
100
101/// Recognized shapes of `runs.image` for a Docker action.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum DockerImage {
104    /// `docker://image[:tag]` reference.
105    DockerUri(String),
106    /// A Dockerfile path (literal `Dockerfile` or a relative `.Dockerfile`).
107    Dockerfile(Utf8PathBuf),
108    /// `runs.image` was missing or empty.
109    Missing,
110}
111
112pub fn classify_image(image: Option<&str>, action_dir: Option<&Utf8Path>) -> DockerImage {
113    let Some(raw) = image.map(str::trim) else {
114        return DockerImage::Missing;
115    };
116    if raw.is_empty() {
117        return DockerImage::Missing;
118    }
119    if let Some(uri) = raw.strip_prefix("docker://") {
120        return DockerImage::DockerUri(uri.to_owned());
121    }
122    if raw.eq_ignore_ascii_case("dockerfile")
123        || raw.to_ascii_lowercase().ends_with(".dockerfile")
124        || raw.to_ascii_lowercase().ends_with("dockerfile")
125    {
126        let path = if let Some(dir) = action_dir {
127            dir.join(raw)
128        } else {
129            Utf8PathBuf::from(raw)
130        };
131        return DockerImage::Dockerfile(path);
132    }
133    // Treat anything else as a Docker URI without the explicit scheme.
134    DockerImage::DockerUri(raw.to_owned())
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use tempfile::tempdir;
141
142    #[test]
143    fn reads_minimal_docker_action() {
144        let dir = tempdir().unwrap();
145        let action = dir.path().join("action.yml");
146        std::fs::write(
147            &action,
148            r#"name: x
149description: y
150runs:
151  using: docker
152  image: Dockerfile
153  entrypoint: /entrypoint.sh
154  pre-entrypoint: /pre.sh
155  post-entrypoint: /post.sh
156  args:
157    - build
158"#,
159        )
160        .unwrap();
161        let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap();
162        let manifest = ActionManifest::read(&path).unwrap();
163        assert_eq!(manifest.using.as_deref(), Some("docker"));
164        assert_eq!(manifest.image.as_deref(), Some("Dockerfile"));
165        assert_eq!(manifest.entrypoint.as_deref(), Some("/entrypoint.sh"));
166        assert_eq!(manifest.pre_entrypoint.as_deref(), Some("/pre.sh"));
167        assert_eq!(manifest.post_entrypoint.as_deref(), Some("/post.sh"));
168        assert_eq!(manifest.args, vec!["build"]);
169    }
170
171    #[test]
172    fn missing_manifest_errors() {
173        let dir = tempdir().unwrap();
174        let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap();
175        assert!(ActionManifest::read(&path).is_err());
176    }
177
178    #[test]
179    fn classify_image_handles_docker_uri() {
180        assert_eq!(
181            classify_image(Some("docker://alpine:3"), None),
182            DockerImage::DockerUri("alpine:3".to_owned())
183        );
184    }
185
186    #[test]
187    fn classify_image_handles_dockerfile() {
188        let DockerImage::Dockerfile(path) = classify_image(Some("Dockerfile"), None) else {
189            panic!("expected Dockerfile classification");
190        };
191        assert_eq!(path.as_str(), "Dockerfile");
192    }
193
194    #[test]
195    fn classify_image_handles_missing() {
196        assert_eq!(classify_image(None, None), DockerImage::Missing);
197        assert_eq!(classify_image(Some(""), None), DockerImage::Missing);
198    }
199
200    #[test]
201    fn classify_image_treats_bare_string_as_docker_uri() {
202        assert_eq!(
203            classify_image(Some("ubuntu:22.04"), None),
204            DockerImage::DockerUri("ubuntu:22.04".to_owned())
205        );
206    }
207}