gha_container_proof/
action.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum DockerImage {
104 DockerUri(String),
106 Dockerfile(Utf8PathBuf),
108 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 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}