use std::collections::HashSet;
use std::path::Path;
use regex::Regex;
use serde_yaml::Value;
use super::io_helpers::{hash_value, mtime_info, relativize};
use super::types::{EnvReadSite, EnvSource, EnvSourceKind, ValuePresence};
pub fn parse_workflow_file(
path: &Path,
root: &Path,
env_rank: u8,
secret_rank: u8,
) -> Vec<(String, EnvSource)> {
let raw = match std::fs::read_to_string(path) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let yaml: Value = match serde_yaml::from_str(&raw) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let rel = relativize(path, root);
let (mtime, age) = mtime_info(path);
let mtime_str = mtime.unwrap_or_default();
let mut out = Vec::new();
let mut secret_names: HashSet<String> = HashSet::new();
if let Some(env) = yaml.get("env").and_then(Value::as_mapping) {
push_env_mapping(env, &rel, &mtime_str, age, env_rank, &mut out);
}
if let Some(jobs) = yaml.get("jobs").and_then(Value::as_mapping) {
for (_id, job_value) in jobs {
let Some(job) = job_value.as_mapping() else {
continue;
};
if let Some(env) = job
.get(Value::String("env".into()))
.and_then(Value::as_mapping)
{
push_env_mapping(env, &rel, &mtime_str, age, env_rank, &mut out);
}
if let Some(steps) = job
.get(Value::String("steps".into()))
.and_then(Value::as_sequence)
{
for step in steps {
if let Some(env) = step.get("env").and_then(Value::as_mapping) {
push_env_mapping(env, &rel, &mtime_str, age, env_rank, &mut out);
}
}
}
}
}
let secret_re =
Regex::new(r"\$\{\{\s*secrets\.([A-Z_][A-Z0-9_]*)\s*\}\}").expect("static regex compiles");
walk_strings(&yaml, &mut |s| {
for cap in secret_re.captures_iter(s) {
if let Some(name) = cap.get(1) {
secret_names.insert(name.as_str().to_string());
}
}
});
for name in secret_names {
out.push((
name,
EnvSource {
kind: EnvSourceKind::GitHubActionsSecret,
path: rel.clone(),
line: None,
mtime: mtime_str.clone(),
mtime_age_days: age,
git_age_days: None,
value_present: ValuePresence::EnvFrom {
reference: "github_secret".into(),
},
precedence_rank: secret_rank,
},
));
}
out
}
fn push_env_mapping(
env: &serde_yaml::Mapping,
rel: &str,
mtime: &str,
age: Option<u32>,
rank: u8,
out: &mut Vec<(String, EnvSource)>,
) {
for (k, v) in env {
let Some(name) = k.as_str() else { continue };
let presence = match v {
Value::String(s) if s.is_empty() => ValuePresence::Empty,
Value::String(s) if s.contains("${{") => ValuePresence::EnvFrom {
reference: s.to_string(),
},
Value::String(s) => ValuePresence::Plain {
value_hash: hash_value(s),
},
Value::Null => ValuePresence::Empty,
Value::Bool(b) => ValuePresence::Plain {
value_hash: hash_value(&b.to_string()),
},
Value::Number(n) => ValuePresence::Plain {
value_hash: hash_value(&n.to_string()),
},
_ => ValuePresence::EnvFrom {
reference: "complex".into(),
},
};
out.push((
name.to_string(),
EnvSource {
kind: EnvSourceKind::GitHubActionsEnv,
path: rel.to_string(),
line: None,
mtime: mtime.to_string(),
mtime_age_days: age,
git_age_days: None,
value_present: presence,
precedence_rank: rank,
},
));
}
}
fn walk_strings<F: FnMut(&str)>(value: &Value, f: &mut F) {
match value {
Value::String(s) => f(s),
Value::Mapping(m) => {
for (_, v) in m {
walk_strings(v, f);
}
}
Value::Sequence(seq) => {
for entry in seq {
walk_strings(entry, f);
}
}
_ => {}
}
}
pub fn parse_workflow_shell_reads(path: &Path, root: &Path) -> Vec<(String, EnvReadSite)> {
let raw = match std::fs::read_to_string(path) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let yaml: Value = match serde_yaml::from_str(&raw) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let rel = relativize(path, root);
let mut out: Vec<(String, EnvReadSite)> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let shell_var = Regex::new(r"\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)")
.expect("static regex compiles");
let Some(jobs) = yaml.get("jobs").and_then(Value::as_mapping) else {
return out;
};
let mut assigned: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for_each_run_block(jobs, &mut |run| {
let cleaned = strip_gha_expressions(run);
assigned.extend(crate::semantic::shell::collect_assigned_shell_vars(
&cleaned,
));
});
for (_id, job_value) in jobs {
let Some(job) = job_value.as_mapping() else {
continue;
};
let Some(steps) = job
.get(Value::String("steps".into()))
.and_then(Value::as_sequence)
else {
continue;
};
for step in steps {
let Some(run) = step.get("run").and_then(Value::as_str) else {
continue;
};
let cleaned = strip_gha_expressions(run);
for cap in shell_var.captures_iter(&cleaned) {
let name = cap
.get(1)
.or_else(|| cap.get(2))
.map(|m| m.as_str().to_string());
if let Some(name) = name {
if assigned.contains(&name)
|| crate::semantic::shell::is_shell_runtime_var(&name)
{
continue;
}
let key = format!("{name}::{rel}");
if seen.insert(key) {
out.push((
name,
EnvReadSite {
file: rel.clone(),
line: None,
symbol: None,
required_for: vec!["github_actions_workflow".into()],
},
));
}
}
}
}
}
out
}
fn for_each_run_block<F: FnMut(&str)>(jobs: &serde_yaml::Mapping, f: &mut F) {
for (_id, job_value) in jobs {
let Some(job) = job_value.as_mapping() else {
continue;
};
let Some(steps) = job
.get(Value::String("steps".into()))
.and_then(Value::as_sequence)
else {
continue;
};
for step in steps {
if let Some(run) = step.get("run").and_then(Value::as_str) {
f(run);
}
}
}
}
fn strip_gha_expressions(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let bytes = src.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 2 < bytes.len() && &bytes[i..i + 3] == b"${{" {
let mut j = i + 3;
while j + 1 < bytes.len() && &bytes[j..j + 2] != b"}}" {
j += 1;
}
i = if j + 1 < bytes.len() {
j + 2
} else {
bytes.len()
};
out.push(' ');
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn parses_top_level_and_step_env() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join(".github/workflows");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("ci.yml");
fs::write(
&path,
"
name: CI
on: [push]
env:
CI_LEVEL: production
jobs:
build:
env:
JOB_TOKEN: '${{ secrets.JOB_TOKEN }}'
steps:
- name: build
env:
STEP_FLAG: '1'
",
)
.unwrap();
let out = parse_workflow_file(&path, tmp.path(), 15, 20);
let names: Vec<&str> = out.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"CI_LEVEL"));
assert!(names.contains(&"JOB_TOKEN"));
assert!(names.contains(&"STEP_FLAG"));
}
#[test]
fn shell_reads_include_dollar_and_brace_forms_skip_gha_expr() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join(".github/workflows");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("ci.yml");
fs::write(
&path,
"
name: CI
on: [push]
env:
HEALTH_THRESHOLD: 50
jobs:
gate:
runs-on: ubuntu-latest
steps:
- run: |
if [ \"$HEALTH\" -lt \"$HEALTH_THRESHOLD\" ]; then
echo \"below ${THRESHOLD_LABEL}\"
echo \"${{ env.UNRELATED }}\"
fi
",
)
.unwrap();
let reads = parse_workflow_shell_reads(&path, tmp.path());
let names: HashSet<&str> = reads.iter().map(|(n, _)| n.as_str()).collect();
assert!(
names.contains("HEALTH"),
"bare `$HEALTH` should be picked up: {names:?}"
);
assert!(
names.contains("HEALTH_THRESHOLD"),
"bare `$HEALTH_THRESHOLD` should be picked up: {names:?}"
);
assert!(
names.contains("THRESHOLD_LABEL"),
"bracketed `${{THRESHOLD_LABEL}}` should be picked up: {names:?}"
);
assert!(
!names.contains("UNRELATED"),
"GHA template expression `${{ env.UNRELATED }}` must NOT count as a shell read: {names:?}"
);
let count_threshold = reads
.iter()
.filter(|(n, _)| n == "HEALTH_THRESHOLD")
.count();
assert_eq!(
count_threshold, 1,
"duplicate references within one workflow should dedupe"
);
}
#[test]
fn shell_reads_skip_run_block_assignments_and_runner_builtins() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join(".github/workflows");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("ci.yml");
fs::write(
&path,
"
name: CI
on: [push]
jobs:
vars:
runs-on: ubuntu-latest
steps:
- run: |
BASE_REF=\"main\"
echo \"ref=$BASE_REF\" >> \"$GITHUB_ENV\"
echo \"out=$BASE_REF\" >> \"$GITHUB_OUTPUT\"
echo \"on $RUNNER_OS ci=$CI\"
echo \"genuine read: $DEPLOY_TOKEN\"
",
)
.unwrap();
let reads = parse_workflow_shell_reads(&path, tmp.path());
let names: HashSet<&str> = reads.iter().map(|(n, _)| n.as_str()).collect();
for skipped in ["BASE_REF", "GITHUB_ENV", "GITHUB_OUTPUT", "RUNNER_OS", "CI"] {
assert!(
!names.contains(skipped),
"`{skipped}` must not be a shell read: {names:?}"
);
}
assert!(
names.contains("DEPLOY_TOKEN"),
"unassigned `$DEPLOY_TOKEN` must remain a read: {names:?}"
);
}
#[test]
fn extracts_secrets_references() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join(".github/workflows");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("deploy.yml");
fs::write(
&path,
"
name: Deploy
on: [push]
jobs:
ship:
runs-on: ubuntu-latest
steps:
- run: deploy
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET: ${{ secrets.AWS_SECRET }}
",
)
.unwrap();
let out = parse_workflow_file(&path, tmp.path(), 15, 20);
let secret_names: Vec<&str> = out
.iter()
.filter(|(_, s)| matches!(s.kind, EnvSourceKind::GitHubActionsSecret))
.map(|(n, _)| n.as_str())
.collect();
assert!(secret_names.contains(&"AWS_KEY_ID"));
assert!(secret_names.contains(&"AWS_SECRET"));
}
}