Skip to main content

just_shield/
uses_ref.rs

1//! `uses:` 참조 문자열의 분해.
2//!
3//! 참조가 가변(태그/브랜치)인지 불변(40자리 커밋 SHA)인지는 문법적 사실이며,
4//! R1은 이 사실만으로 판정한다 (ADR-0002).
5
6/// `uses:` 값이 가리키는 대상의 종류.
7pub enum UsesRef {
8    /// 이 저장소 안의 로컬 액션 (`./...`) — 퍼스트파티, 섭취 검증 대상 아님.
9    Local,
10    /// 컨테이너 이미지 참조 (`docker://...`) — R4(이미지 다이제스트)의 영역, R1 대상 아님.
11    DockerImage,
12    /// 다른 저장소의 액션.
13    Repository {
14        owner_repo: String,
15        git_ref: Option<RefKind>,
16    },
17}
18
19/// 저장소 액션 참조의 종류.
20pub enum RefKind {
21    /// 40자리 16진수 커밋 SHA — 불변 참조.
22    CommitSha(String),
23    /// 태그/브랜치 — 공격자가 옮겨 꽂을 수 있는 가변 참조.
24    Mutable(String),
25}
26
27/// `uses:` 값을 분해한다.
28pub fn parse(value: &str) -> UsesRef {
29    if value.starts_with("./") || value.starts_with(".\\") {
30        return UsesRef::Local;
31    }
32    if value.starts_with("docker://") {
33        return UsesRef::DockerImage;
34    }
35    match value.split_once('@') {
36        None => UsesRef::Repository {
37            owner_repo: value.to_string(),
38            git_ref: None,
39        },
40        Some((path, r)) => {
41            let kind = if is_commit_sha(r) {
42                RefKind::CommitSha(r.to_string())
43            } else {
44                RefKind::Mutable(r.to_string())
45            };
46            UsesRef::Repository {
47                owner_repo: path.to_string(),
48                git_ref: Some(kind),
49            }
50        }
51    }
52}
53
54fn is_commit_sha(s: &str) -> bool {
55    s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit())
56}
57
58/// `owner/repo/subpath`에서 저장소 부분(`owner/repo`)만 — 태그는 저장소에 속한다.
59pub fn repo_root(owner_repo: &str) -> &str {
60    match owner_repo.match_indices('/').nth(1) {
61        Some((idx, _)) => &owner_repo[..idx],
62        None => owner_repo,
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::{RefKind, UsesRef, parse};
69
70    #[test]
71    fn classifies_local_and_docker() {
72        assert!(matches!(parse("./.github/actions/x"), UsesRef::Local));
73        assert!(matches!(
74            parse("docker://alpine:3.19"),
75            UsesRef::DockerImage
76        ));
77    }
78
79    #[test]
80    fn full_sha_is_immutable() {
81        let r = parse("owner/repo@0123456789abcdef0123456789abcdef01234567");
82        assert!(matches!(
83            r,
84            UsesRef::Repository {
85                git_ref: Some(RefKind::CommitSha(_)),
86                ..
87            }
88        ));
89    }
90
91    #[test]
92    fn tag_branch_and_short_sha_are_mutable() {
93        for v in ["owner/repo@v4", "owner/repo@main", "owner/repo@abc1234"] {
94            assert!(matches!(
95                parse(v),
96                UsesRef::Repository {
97                    git_ref: Some(RefKind::Mutable(_)),
98                    ..
99                }
100            ));
101        }
102    }
103
104    #[test]
105    fn missing_ref_is_detected() {
106        assert!(matches!(
107            parse("owner/repo"),
108            UsesRef::Repository { git_ref: None, .. }
109        ));
110    }
111}