Skip to main content

just_shield/
workflow.rs

1//! 워크플로 파일 발견과 구조 추출.
2//!
3//! 의존 크레이트 0개를 유지하기 위해 워크플로 YAML의 관용적 형태(블록 매핑 +
4//! 들여쓰기)만 해석하는 전용 파서를 쓴다. R1은 `uses:` 행 추출로, R6~R8은
5//! `parse_workflow`의 구조(트리거/권한/잡/스텝)로 판정한다.
6
7use std::path::{Path, PathBuf};
8
9/// 워크플로 파일에서 발견된 `uses:` 한 건.
10pub struct UsesEntry {
11    /// 1부터 시작하는 행 번호.
12    pub line: usize,
13    /// 따옴표·주석이 제거된 참조 값 (예: `actions/checkout@v4`).
14    pub value: String,
15}
16
17/// R6~R8 판정에 필요한 워크플로 구조.
18pub struct WorkflowDoc {
19    /// `on:` 값 전체를 이어붙인 텍스트 — 트리거 토큰 검사용.
20    pub on_text: String,
21    /// 워크플로 수준 `permissions:` (행 번호, 값 텍스트). 없으면 None.
22    pub workflow_permissions: Option<(usize, String)>,
23    pub jobs: Vec<Job>,
24}
25
26/// 잡 하나의 구조.
27pub struct Job {
28    pub name: String,
29    pub line: usize,
30    /// 잡 수준 `permissions:` (행 번호, 값 텍스트). 없으면 None.
31    pub permissions: Option<(usize, String)>,
32    /// 잡 블록 어딘가에서 `${{ secrets.* }}` 또는 `secrets:`를 참조하는가.
33    pub uses_secrets: bool,
34    pub steps: Vec<Step>,
35}
36
37/// 스텝 하나.
38pub struct Step {
39    pub line: usize,
40    pub uses: Option<String>,
41    /// 스텝 블록 원문(트림된 행들의 결합) — `ref:` 패턴 검사용.
42    pub text: String,
43}
44
45/// `<root>/.github/workflows`의 `*.yml`/`*.yaml` 파일 목록 (정렬됨).
46pub 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
69/// 파일 내용에서 모든 `uses:` 참조를 행 번호와 함께 추출한다.
70pub 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/// 의미 있는 행 하나 (빈 행·주석 제외).
84#[derive(Clone, Copy)]
85struct Line<'a> {
86    no: usize,
87    indent: usize,
88    text: &'a str,
89}
90
91/// 워크플로의 구조(트리거/권한/잡/스텝)를 추출한다.
92pub 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
144/// `lines[start]`의 키 인라인 값 + 더 깊은 들여쓰기의 자식 행들을 한 문자열로 모은다.
145/// 반환: (모은 텍스트, 다음으로 처리할 인덱스).
146fn 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
160/// `lines[from..]`에서 indent가 `parent_indent` 이하로 돌아오는 첫 인덱스.
161fn 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
262/// 컨테이너 이미지 참조(`image:` 값, 인라인 `container:` 값)를 행 번호와 함께 추출한다.
263/// `${{ ... }}` 표현식은 값을 알 수 없으므로 판정 대상에서 제외한다 (추측 금지).
264pub 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
295/// 한 행에서 `uses:` 값을 추출한다. 주석 행과 `uses:`가 아닌 행은 None.
296pub fn extract_uses_value(line: &str) -> Option<String> {
297    let trimmed = line.trim_start();
298    // 주석 처리된 행 (`# uses: ...`)은 실행되지 않으므로 검사 대상이 아니다.
299    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    // YAML 블록 매핑에서 키 뒤에는 공백이 와야 한다 — `uses:foo`는 키가 아니라 스칼라.
308    if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
309        return None;
310    }
311    scalar_value(rest.trim_start())
312}
313
314/// 따옴표·행 끝 주석을 처리해 스칼라 값만 꺼낸다.
315fn 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        // 따옴표 없는 값: 공백 또는 행 끝 주석(#) 앞까지.
325        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}