1use std::path::{Path, PathBuf};
8
9pub struct UsesEntry {
11 pub line: usize,
13 pub value: String,
15}
16
17pub struct WorkflowDoc {
19 pub on_text: String,
21 pub workflow_permissions: Option<(usize, String)>,
23 pub jobs: Vec<Job>,
24}
25
26pub struct Job {
28 pub name: String,
29 pub line: usize,
30 pub permissions: Option<(usize, String)>,
32 pub uses_secrets: bool,
34 pub steps: Vec<Step>,
35}
36
37pub struct Step {
39 pub line: usize,
40 pub uses: Option<String>,
41 pub text: String,
43}
44
45pub fn find_workflows(root: &Path) -> std::io::Result<Vec<PathBuf>> {
47 let dir = root.join(".github").join("workflows");
48 let mut out = Vec::new();
49 if !dir.is_dir() {
50 return Ok(out);
51 }
52 for entry in std::fs::read_dir(&dir)? {
53 let path = entry?.path();
54 if !path.is_file() {
55 continue;
56 }
57 let is_yaml = path
58 .extension()
59 .and_then(|e| e.to_str())
60 .is_some_and(|ext| ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml"));
61 if is_yaml {
62 out.push(path);
63 }
64 }
65 out.sort();
66 Ok(out)
67}
68
69pub fn extract_uses_entries(content: &str) -> Vec<UsesEntry> {
71 content
72 .lines()
73 .enumerate()
74 .filter_map(|(idx, line)| {
75 extract_uses_value(line).map(|value| UsesEntry {
76 line: idx + 1,
77 value,
78 })
79 })
80 .collect()
81}
82
83#[derive(Clone, Copy)]
85struct Line<'a> {
86 no: usize,
87 indent: usize,
88 text: &'a str,
89}
90
91pub fn parse_workflow(content: &str) -> WorkflowDoc {
93 let lines: Vec<Line> = content
94 .lines()
95 .enumerate()
96 .filter_map(|(i, raw)| {
97 let text = raw.trim_start();
98 if text.is_empty() || text.starts_with('#') {
99 return None;
100 }
101 Some(Line {
102 no: i + 1,
103 indent: raw.len() - text.len(),
104 text,
105 })
106 })
107 .collect();
108
109 let mut on_text = String::new();
110 let mut workflow_permissions = None;
111 let mut jobs = Vec::new();
112
113 let mut i = 0;
114 while i < lines.len() {
115 let l = lines[i];
116 if l.indent != 0 {
117 i += 1;
118 continue;
119 }
120 if l.text.strip_prefix("on:").is_some() {
121 let (text, next) = collect_block_text(&lines, i, "on:");
122 on_text = text;
123 i = next;
124 } else if l.text.strip_prefix("permissions:").is_some() {
125 let (text, next) = collect_block_text(&lines, i, "permissions:");
126 workflow_permissions = Some((l.no, text));
127 i = next;
128 } else if l.text.starts_with("jobs:") {
129 let block_end = block_end(&lines, i + 1, 0);
130 jobs = parse_jobs(&lines[i + 1..block_end]);
131 i = block_end;
132 } else {
133 i += 1;
134 }
135 }
136
137 WorkflowDoc {
138 on_text,
139 workflow_permissions,
140 jobs,
141 }
142}
143
144fn collect_block_text(lines: &[Line], start: usize, key: &str) -> (String, usize) {
147 let base_indent = lines[start].indent;
148 let mut text = lines[start].text[key.len()..].trim().to_string();
149 let mut j = start + 1;
150 while j < lines.len() && lines[j].indent > base_indent {
151 if !text.is_empty() {
152 text.push(' ');
153 }
154 text.push_str(lines[j].text);
155 j += 1;
156 }
157 (text, j)
158}
159
160fn block_end(lines: &[Line], from: usize, parent_indent: usize) -> usize {
162 let mut k = from;
163 while k < lines.len() && lines[k].indent > parent_indent {
164 k += 1;
165 }
166 k
167}
168
169fn parse_jobs(lines: &[Line]) -> Vec<Job> {
170 let Some(job_indent) = lines.first().map(|l| l.indent) else {
171 return Vec::new();
172 };
173 let mut jobs = Vec::new();
174 let mut i = 0;
175 while i < lines.len() {
176 let l = lines[i];
177 if l.indent == job_indent && l.text.ends_with(':') {
178 let end = block_end(lines, i + 1, job_indent);
179 jobs.push(parse_job(
180 l.text.trim_end_matches(':').to_string(),
181 l.no,
182 &lines[i + 1..end],
183 ));
184 i = end;
185 } else {
186 i += 1;
187 }
188 }
189 jobs
190}
191
192fn parse_job(name: String, line: usize, lines: &[Line]) -> Job {
193 let uses_secrets = lines.iter().any(|l| {
194 (l.text.contains("${{") && l.text.contains("secrets.")) || l.text.starts_with("secrets:")
195 });
196 let child_indent = lines.iter().map(|l| l.indent).min().unwrap_or(0);
197 let mut permissions = None;
198 let mut steps = Vec::new();
199
200 let mut i = 0;
201 while i < lines.len() {
202 let l = lines[i];
203 if l.indent == child_indent && l.text.strip_prefix("permissions:").is_some() {
204 let (text, next) = collect_block_text(lines, i, "permissions:");
205 permissions = Some((l.no, text));
206 i = next;
207 } else if l.indent == child_indent && l.text.starts_with("steps:") {
208 let end = block_end(lines, i + 1, child_indent);
209 steps = parse_steps(&lines[i + 1..end]);
210 i = end;
211 } else {
212 i += 1;
213 }
214 }
215
216 Job {
217 name,
218 line,
219 permissions,
220 uses_secrets,
221 steps,
222 }
223}
224
225fn parse_steps(lines: &[Line]) -> Vec<Step> {
226 let Some(item_indent) = lines
227 .iter()
228 .filter(|l| l.text.starts_with('-'))
229 .map(|l| l.indent)
230 .min()
231 else {
232 return Vec::new();
233 };
234 let mut steps = Vec::new();
235 let mut i = 0;
236 while i < lines.len() {
237 let l = lines[i];
238 if l.indent == item_indent && l.text.starts_with('-') {
239 let mut end = i + 1;
240 while end < lines.len()
241 && !(lines[end].indent == item_indent && lines[end].text.starts_with('-'))
242 && lines[end].indent >= item_indent
243 {
244 end += 1;
245 }
246 let block = &lines[i..end];
247 let uses = block.iter().find_map(|b| extract_uses_value(b.text));
248 let text = block.iter().map(|b| b.text).collect::<Vec<_>>().join("\n");
249 steps.push(Step {
250 line: l.no,
251 uses,
252 text,
253 });
254 i = end;
255 } else {
256 i += 1;
257 }
258 }
259 steps
260}
261
262pub fn extract_image_refs(content: &str) -> Vec<UsesEntry> {
265 content
266 .lines()
267 .enumerate()
268 .filter_map(|(idx, line)| {
269 let t = line.trim_start();
270 if t.starts_with('#') {
271 return None;
272 }
273 let rest = match t.strip_prefix('-') {
274 Some(r) => r.trim_start(),
275 None => t,
276 };
277 let rest = rest
278 .strip_prefix("image:")
279 .or_else(|| rest.strip_prefix("container:"))?;
280 if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
281 return None;
282 }
283 if rest.contains("${{") {
284 return None;
285 }
286 let value = scalar_value(rest.trim_start())?;
287 Some(UsesEntry {
288 line: idx + 1,
289 value,
290 })
291 })
292 .collect()
293}
294
295pub fn extract_uses_value(line: &str) -> Option<String> {
297 let trimmed = line.trim_start();
298 if trimmed.starts_with('#') {
300 return None;
301 }
302 let rest = match trimmed.strip_prefix('-') {
303 Some(r) => r.trim_start(),
304 None => trimmed,
305 };
306 let rest = rest.strip_prefix("uses:")?;
307 if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
309 return None;
310 }
311 scalar_value(rest.trim_start())
312}
313
314fn scalar_value(rest: &str) -> Option<String> {
316 if rest.is_empty() {
317 return None;
318 }
319 let value = if let Some(q) = rest.strip_prefix('"') {
320 q.split('"').next()?
321 } else if let Some(q) = rest.strip_prefix('\'') {
322 q.split('\'').next()?
323 } else {
324 rest.split(|c: char| c.is_whitespace() || c == '#').next()?
326 };
327 if value.is_empty() {
328 None
329 } else {
330 Some(value.to_string())
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::{extract_uses_value, parse_workflow};
337
338 #[test]
339 fn extracts_plain_and_list_item() {
340 assert_eq!(
341 extract_uses_value(" - uses: actions/checkout@v4"),
342 Some("actions/checkout@v4".to_string())
343 );
344 assert_eq!(
345 extract_uses_value(" uses: owner/repo@main"),
346 Some("owner/repo@main".to_string())
347 );
348 }
349
350 #[test]
351 fn extracts_quoted_values() {
352 assert_eq!(
353 extract_uses_value(r#" - uses: "owner/repo@v1""#),
354 Some("owner/repo@v1".to_string())
355 );
356 assert_eq!(
357 extract_uses_value(" - uses: 'owner/repo@v1'"),
358 Some("owner/repo@v1".to_string())
359 );
360 }
361
362 #[test]
363 fn drops_trailing_comment() {
364 assert_eq!(
365 extract_uses_value(" - uses: owner/repo@abc123 # v2"),
366 Some("owner/repo@abc123".to_string())
367 );
368 }
369
370 #[test]
371 fn ignores_commented_lines_and_non_uses() {
372 assert_eq!(extract_uses_value(" # uses: owner/repo@v1"), None);
373 assert_eq!(extract_uses_value(" - run: echo uses: nothing"), None);
374 assert_eq!(extract_uses_value(" uses:foo"), None);
375 }
376
377 const SAMPLE: &str = "name: CI\non: pull_request_target\npermissions: write-all\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n with:\n ref: ${{ github.event.pull_request.head.sha }}\n - run: cargo test\n deploy:\n permissions:\n contents: read\n steps:\n - uses: evil/tool@v1\n env:\n T: ${{ secrets.TOKEN }}\n";
378
379 #[test]
380 fn parses_triggers_permissions_jobs_steps() {
381 let doc = parse_workflow(SAMPLE);
382 assert!(doc.on_text.contains("pull_request_target"));
383 assert!(
384 doc.workflow_permissions
385 .as_ref()
386 .is_some_and(|(_, v)| v.contains("write-all"))
387 );
388 assert_eq!(doc.jobs.len(), 2);
389
390 let build = &doc.jobs[0];
391 assert_eq!(build.name, "build");
392 assert!(build.permissions.is_none());
393 assert!(!build.uses_secrets);
394 assert_eq!(build.steps.len(), 2);
395 assert_eq!(build.steps[0].uses.as_deref(), Some("actions/checkout@v4"));
396 assert!(
397 build.steps[0]
398 .text
399 .contains("github.event.pull_request.head")
400 );
401
402 let deploy = &doc.jobs[1];
403 assert!(deploy.permissions.is_some());
404 assert!(deploy.uses_secrets);
405 assert_eq!(deploy.steps[0].uses.as_deref(), Some("evil/tool@v1"));
406 }
407}