use std::path::{Path, PathBuf};
pub struct UsesEntry {
pub line: usize,
pub value: String,
}
pub struct WorkflowDoc {
pub on_text: String,
pub workflow_permissions: Option<(usize, String)>,
pub jobs: Vec<Job>,
}
pub struct Job {
pub name: String,
pub line: usize,
pub permissions: Option<(usize, String)>,
pub uses_secrets: bool,
pub steps: Vec<Step>,
}
pub struct Step {
pub line: usize,
pub uses: Option<String>,
pub text: String,
}
pub fn find_workflows(root: &Path) -> std::io::Result<Vec<PathBuf>> {
let dir = root.join(".github").join("workflows");
let mut out = Vec::new();
if !dir.is_dir() {
return Ok(out);
}
for entry in std::fs::read_dir(&dir)? {
let path = entry?.path();
if !path.is_file() {
continue;
}
let is_yaml = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml"));
if is_yaml {
out.push(path);
}
}
out.sort();
Ok(out)
}
pub fn extract_uses_entries(content: &str) -> Vec<UsesEntry> {
content
.lines()
.enumerate()
.filter_map(|(idx, line)| {
extract_uses_value(line).map(|value| UsesEntry {
line: idx + 1,
value,
})
})
.collect()
}
#[derive(Clone, Copy)]
struct Line<'a> {
no: usize,
indent: usize,
text: &'a str,
}
pub fn parse_workflow(content: &str) -> WorkflowDoc {
let lines: Vec<Line> = content
.lines()
.enumerate()
.filter_map(|(i, raw)| {
let text = raw.trim_start();
if text.is_empty() || text.starts_with('#') {
return None;
}
Some(Line {
no: i + 1,
indent: raw.len() - text.len(),
text,
})
})
.collect();
let mut on_text = String::new();
let mut workflow_permissions = None;
let mut jobs = Vec::new();
let mut i = 0;
while i < lines.len() {
let l = lines[i];
if l.indent != 0 {
i += 1;
continue;
}
if l.text.strip_prefix("on:").is_some() {
let (text, next) = collect_block_text(&lines, i, "on:");
on_text = text;
i = next;
} else if l.text.strip_prefix("permissions:").is_some() {
let (text, next) = collect_block_text(&lines, i, "permissions:");
workflow_permissions = Some((l.no, text));
i = next;
} else if l.text.starts_with("jobs:") {
let block_end = block_end(&lines, i + 1, 0);
jobs = parse_jobs(&lines[i + 1..block_end]);
i = block_end;
} else {
i += 1;
}
}
WorkflowDoc {
on_text,
workflow_permissions,
jobs,
}
}
fn collect_block_text(lines: &[Line], start: usize, key: &str) -> (String, usize) {
let base_indent = lines[start].indent;
let mut text = lines[start].text[key.len()..].trim().to_string();
let mut j = start + 1;
while j < lines.len() && lines[j].indent > base_indent {
if !text.is_empty() {
text.push(' ');
}
text.push_str(lines[j].text);
j += 1;
}
(text, j)
}
fn block_end(lines: &[Line], from: usize, parent_indent: usize) -> usize {
let mut k = from;
while k < lines.len() && lines[k].indent > parent_indent {
k += 1;
}
k
}
fn parse_jobs(lines: &[Line]) -> Vec<Job> {
let Some(job_indent) = lines.first().map(|l| l.indent) else {
return Vec::new();
};
let mut jobs = Vec::new();
let mut i = 0;
while i < lines.len() {
let l = lines[i];
if l.indent == job_indent && l.text.ends_with(':') {
let end = block_end(lines, i + 1, job_indent);
jobs.push(parse_job(
l.text.trim_end_matches(':').to_string(),
l.no,
&lines[i + 1..end],
));
i = end;
} else {
i += 1;
}
}
jobs
}
fn parse_job(name: String, line: usize, lines: &[Line]) -> Job {
let uses_secrets = lines.iter().any(|l| {
(l.text.contains("${{") && l.text.contains("secrets.")) || l.text.starts_with("secrets:")
});
let child_indent = lines.iter().map(|l| l.indent).min().unwrap_or(0);
let mut permissions = None;
let mut steps = Vec::new();
let mut i = 0;
while i < lines.len() {
let l = lines[i];
if l.indent == child_indent && l.text.strip_prefix("permissions:").is_some() {
let (text, next) = collect_block_text(lines, i, "permissions:");
permissions = Some((l.no, text));
i = next;
} else if l.indent == child_indent && l.text.starts_with("steps:") {
let end = block_end(lines, i + 1, child_indent);
steps = parse_steps(&lines[i + 1..end]);
i = end;
} else {
i += 1;
}
}
Job {
name,
line,
permissions,
uses_secrets,
steps,
}
}
fn parse_steps(lines: &[Line]) -> Vec<Step> {
let Some(item_indent) = lines
.iter()
.filter(|l| l.text.starts_with('-'))
.map(|l| l.indent)
.min()
else {
return Vec::new();
};
let mut steps = Vec::new();
let mut i = 0;
while i < lines.len() {
let l = lines[i];
if l.indent == item_indent && l.text.starts_with('-') {
let mut end = i + 1;
while end < lines.len()
&& !(lines[end].indent == item_indent && lines[end].text.starts_with('-'))
&& lines[end].indent >= item_indent
{
end += 1;
}
let block = &lines[i..end];
let uses = block.iter().find_map(|b| extract_uses_value(b.text));
let text = block.iter().map(|b| b.text).collect::<Vec<_>>().join("\n");
steps.push(Step {
line: l.no,
uses,
text,
});
i = end;
} else {
i += 1;
}
}
steps
}
pub fn extract_image_refs(content: &str) -> Vec<UsesEntry> {
content
.lines()
.enumerate()
.filter_map(|(idx, line)| {
let t = line.trim_start();
if t.starts_with('#') {
return None;
}
let rest = match t.strip_prefix('-') {
Some(r) => r.trim_start(),
None => t,
};
let rest = rest
.strip_prefix("image:")
.or_else(|| rest.strip_prefix("container:"))?;
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
return None;
}
if rest.contains("${{") {
return None;
}
let value = scalar_value(rest.trim_start())?;
Some(UsesEntry {
line: idx + 1,
value,
})
})
.collect()
}
pub fn extract_uses_value(line: &str) -> Option<String> {
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
return None;
}
let rest = match trimmed.strip_prefix('-') {
Some(r) => r.trim_start(),
None => trimmed,
};
let rest = rest.strip_prefix("uses:")?;
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
return None;
}
scalar_value(rest.trim_start())
}
fn scalar_value(rest: &str) -> Option<String> {
if rest.is_empty() {
return None;
}
let value = if let Some(q) = rest.strip_prefix('"') {
q.split('"').next()?
} else if let Some(q) = rest.strip_prefix('\'') {
q.split('\'').next()?
} else {
rest.split(|c: char| c.is_whitespace() || c == '#').next()?
};
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
#[cfg(test)]
mod tests {
use super::{extract_uses_value, parse_workflow};
#[test]
fn extracts_plain_and_list_item() {
assert_eq!(
extract_uses_value(" - uses: actions/checkout@v4"),
Some("actions/checkout@v4".to_string())
);
assert_eq!(
extract_uses_value(" uses: owner/repo@main"),
Some("owner/repo@main".to_string())
);
}
#[test]
fn extracts_quoted_values() {
assert_eq!(
extract_uses_value(r#" - uses: "owner/repo@v1""#),
Some("owner/repo@v1".to_string())
);
assert_eq!(
extract_uses_value(" - uses: 'owner/repo@v1'"),
Some("owner/repo@v1".to_string())
);
}
#[test]
fn drops_trailing_comment() {
assert_eq!(
extract_uses_value(" - uses: owner/repo@abc123 # v2"),
Some("owner/repo@abc123".to_string())
);
}
#[test]
fn ignores_commented_lines_and_non_uses() {
assert_eq!(extract_uses_value(" # uses: owner/repo@v1"), None);
assert_eq!(extract_uses_value(" - run: echo uses: nothing"), None);
assert_eq!(extract_uses_value(" uses:foo"), None);
}
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";
#[test]
fn parses_triggers_permissions_jobs_steps() {
let doc = parse_workflow(SAMPLE);
assert!(doc.on_text.contains("pull_request_target"));
assert!(
doc.workflow_permissions
.as_ref()
.is_some_and(|(_, v)| v.contains("write-all"))
);
assert_eq!(doc.jobs.len(), 2);
let build = &doc.jobs[0];
assert_eq!(build.name, "build");
assert!(build.permissions.is_none());
assert!(!build.uses_secrets);
assert_eq!(build.steps.len(), 2);
assert_eq!(build.steps[0].uses.as_deref(), Some("actions/checkout@v4"));
assert!(
build.steps[0]
.text
.contains("github.event.pull_request.head")
);
let deploy = &doc.jobs[1];
assert!(deploy.permissions.is_some());
assert!(deploy.uses_secrets);
assert_eq!(deploy.steps[0].uses.as_deref(), Some("evil/tool@v1"));
}
}