Skip to main content

just_shield/
fix.rs

1//! `fix` — 가변 참조를 커밋 SHA로 자동 교체 (탈출구 ③).
2//!
3//! 이 도구가 사용자 파일을 고쳐 쓰는 유일한 지점이므로 가장 보수적으로 동작한다:
4//! YAML을 재직렬화하지 않고 해당 행의 참조 부분 문자열만 치환해 주석·서식·키 순서를
5//! 바이트 단위로 보존하며, 각 행의 원래 줄바꿈(CRLF/LF)도 그대로 유지한다.
6//! 해석에 실패한 참조는 절대 건드리지 않고 사유를 보고한다.
7
8use crate::github_facts::GithubFacts;
9use crate::trust::{Trust, TrustContext};
10use crate::uses_ref::{self, RefKind, UsesRef};
11use crate::{config, trust, workflow};
12use std::collections::{BTreeMap, HashMap};
13use std::io;
14use std::path::Path;
15
16/// 교체 한 건.
17pub struct FixChange {
18    pub file: String,
19    pub line: usize,
20    pub from: String,
21    pub to: String,
22}
23
24/// `fix` 실행 결과.
25pub struct FixOutcome {
26    pub changes: Vec<FixChange>,
27    /// 해석 실패로 건드리지 않은 참조와 사유.
28    pub skipped: Vec<(String, String)>,
29    /// false면 dry-run — 파일은 변경되지 않았다.
30    pub applied: bool,
31}
32
33/// 모든 워크플로의 가변 참조를 현재 SHA로 핀 고정한다.
34/// 이미 SHA인 참조·로컬 액션·docker 이미지·퍼스트파티는 건드리지 않는다 → 멱등.
35pub fn fix(root: &Path, facts: &dyn GithubFacts, dry_run: bool) -> io::Result<FixOutcome> {
36    let ctx = TrustContext::new(
37        trust::detect_repo_owner(root),
38        config::load(root)?.trusted_owners,
39    );
40    // 같은 (저장소, 태그)는 한 번만 해석한다.
41    let mut cache: HashMap<(String, String), Option<String>> = HashMap::new();
42    let mut skipped: BTreeMap<String, String> = BTreeMap::new();
43    let mut changes = Vec::new();
44
45    for wf in workflow::find_workflows(root)? {
46        let content = std::fs::read_to_string(&wf)?;
47        let rel = wf.strip_prefix(root).unwrap_or(&wf).display().to_string();
48        let mut new_content = String::with_capacity(content.len() + 64);
49        let mut changed = false;
50
51        for (idx, segment) in content.split_inclusive('\n').enumerate() {
52            let (body, ending) = split_ending(segment);
53            match try_fix_line(body, &ctx, facts, &mut cache, &mut skipped) {
54                Some((new_body, from, to)) => {
55                    changes.push(FixChange {
56                        file: rel.clone(),
57                        line: idx + 1,
58                        from,
59                        to,
60                    });
61                    new_content.push_str(&new_body);
62                    changed = true;
63                }
64                None => new_content.push_str(body),
65            }
66            new_content.push_str(ending);
67        }
68
69        if changed && !dry_run {
70            std::fs::write(&wf, new_content)?;
71        }
72    }
73
74    Ok(FixOutcome {
75        changes,
76        skipped: skipped.into_iter().collect(),
77        applied: !dry_run,
78    })
79}
80
81/// 행 본문과 줄바꿈 문자를 분리한다 — 원래의 CRLF/LF를 보존하기 위해.
82fn split_ending(segment: &str) -> (&str, &str) {
83    if let Some(body) = segment.strip_suffix("\r\n") {
84        (body, "\r\n")
85    } else if let Some(body) = segment.strip_suffix('\n') {
86        (body, "\n")
87    } else {
88        (segment, "")
89    }
90}
91
92/// 교체 대상 행이면 (새 행, 이전 참조, 새 참조)를 반환한다.
93fn try_fix_line(
94    line: &str,
95    ctx: &TrustContext,
96    facts: &dyn GithubFacts,
97    cache: &mut HashMap<(String, String), Option<String>>,
98    skipped: &mut BTreeMap<String, String>,
99) -> Option<(String, String, String)> {
100    let value = workflow::extract_uses_value(line)?;
101    let UsesRef::Repository {
102        owner_repo,
103        git_ref: Some(RefKind::Mutable(git_ref)),
104    } = uses_ref::parse(&value)
105    else {
106        return None;
107    };
108    if ctx.classify(&owner_repo) == Trust::FirstParty {
109        return None;
110    }
111    let repo = uses_ref::repo_root(&owner_repo).to_string();
112    let key = (repo.clone(), git_ref.clone());
113    let sha = match cache.get(&key) {
114        Some(cached) => cached.clone(),
115        None => {
116            let resolved = match facts.resolve_ref(&repo, &git_ref) {
117                Ok(Some(sha)) => Some(sha),
118                Ok(None) => {
119                    skipped.insert(
120                        format!("{repo}@{git_ref}"),
121                        "참조를 찾을 수 없음 — 변경하지 않음".to_string(),
122                    );
123                    None
124                }
125                Err(e) => {
126                    skipped.insert(
127                        format!("{repo}@{git_ref}"),
128                        format!("해석 실패 — 변경하지 않음: {e}"),
129                    );
130                    None
131                }
132            };
133            cache.insert(key, resolved.clone());
134            resolved
135        }
136    }?;
137
138    let new_value = format!("{owner_repo}@{sha}");
139    let pos = line.find(&value)?;
140    let rest = &line[pos + value.len()..];
141    let mut new_line = String::with_capacity(line.len() + 48);
142    new_line.push_str(&line[..pos]);
143    new_line.push_str(&new_value);
144    new_line.push_str(rest);
145    // 사람이 읽을 버전 주석 — 이미 행 끝 주석이 있으면 보존하고 덧붙이지 않는다.
146    if !rest.contains('#') {
147        new_line.push_str(&format!(" # {git_ref}"));
148    }
149    Some((new_line, value, new_value))
150}