Skip to main content

just_shield/
github_facts.rs

1//! 외부(GitHub) 조회의 격리 인터페이스.
2//!
3//! 모든 온라인 규칙은 이 trait에만 의존한다 — 테스트는 가짜 구현으로
4//! 하이재킹 상황을 오프라인 재현하고, 실제 구현은 `git ls-remote` 서브프로세스를
5//! 사용해 HTTP 클라이언트 의존성 없이 동작한다.
6
7use std::io;
8
9/// GitHub에 대한 사실 조회.
10pub trait GithubFacts {
11    /// `owner/repo`의 `git_ref`(태그/브랜치)가 현재 가리키는 커밋 SHA.
12    /// 참조가 존재하지 않으면 `Ok(None)`.
13    fn resolve_ref(&self, owner_repo: &str, git_ref: &str) -> io::Result<Option<String>>;
14
15    /// 커밋이 저장소의 정식 히스토리에서 도달 가능한가 (R5 임포스터 커밋 판정).
16    /// `Ok(None)` = 판정 불가(미지원) — 규칙은 조용히 건너뛴다.
17    fn commit_reachable(&self, _owner_repo: &str, _sha: &str) -> io::Result<Option<bool>> {
18        Ok(None)
19    }
20
21    /// 참조가 가리키는 커밋의 커미터 시각, unix epoch 초 (R10 쿨다운 판정).
22    /// `Ok(None)` = 참조 없음 또는 판정 불가 — 규칙은 조용히 건너뛴다.
23    fn ref_timestamp(&self, _owner_repo: &str, _git_ref: &str) -> io::Result<Option<i64>> {
24        Ok(None)
25    }
26
27    /// 저장소의 버전 태그 개수 (R2 교차 검증) — 유명 액션은 수십 개, 급조 짝퉁은 0~2개.
28    /// `Ok(None)` = 판정 불가(미지원/저장소 없음) — 승격하지 않는다.
29    fn ref_count(&self, _owner_repo: &str) -> io::Result<Option<usize>> {
30        Ok(None)
31    }
32}
33
34/// `git ls-remote` 기반의 실제 구현.
35pub struct GitRemote;
36
37impl GithubFacts for GitRemote {
38    fn resolve_ref(&self, owner_repo: &str, git_ref: &str) -> io::Result<Option<String>> {
39        let url = format!("https://github.com/{owner_repo}.git");
40        // 주석 태그(annotated tag)는 태그 객체 SHA와 커밋 SHA가 다르다 —
41        // `ref^{}`(peeled)가 실제 커밋이므로 함께 조회해 우선한다.
42        let peeled = format!("{git_ref}^{{}}");
43        let out = std::process::Command::new("git")
44            .args(["ls-remote", &url, git_ref, &peeled])
45            .output()?;
46        if !out.status.success() {
47            return Err(io::Error::other(format!(
48                "git ls-remote 실패 ({owner_repo}): {}",
49                String::from_utf8_lossy(&out.stderr).trim()
50            )));
51        }
52        Ok(pick_best(&String::from_utf8_lossy(&out.stdout)))
53    }
54
55    fn commit_reachable(&self, owner_repo: &str, sha: &str) -> io::Result<Option<bool>> {
56        Self::commit_reachable_impl(owner_repo, sha)
57    }
58
59    fn ref_timestamp(&self, owner_repo: &str, git_ref: &str) -> io::Result<Option<i64>> {
60        Self::ref_timestamp_impl(owner_repo, git_ref)
61    }
62
63    fn ref_count(&self, owner_repo: &str) -> io::Result<Option<usize>> {
64        let url = format!("https://github.com/{owner_repo}.git");
65        let out = std::process::Command::new("git")
66            .args(["ls-remote", "--tags", &url])
67            .output()?;
68        if !out.status.success() {
69            // 저장소 없음/접근 불가 — 승격 근거가 될 수 없으므로 판정 불가.
70            return Ok(None);
71        }
72        Ok(Some(
73            String::from_utf8_lossy(&out.stdout)
74                .lines()
75                .filter(|l| !l.trim().is_empty())
76                .count(),
77        ))
78    }
79}
80
81impl GitRemote {
82    /// 임시 저장소에 해당 객체만 fetch해 본다. 도달 불가 객체는 GitHub이 거부한다.
83    fn shallow_fetch(
84        owner_repo: &str,
85        want: &str,
86    ) -> io::Result<(bool, String, std::path::PathBuf)> {
87        let url = format!("https://github.com/{owner_repo}.git");
88        let key = want
89            .bytes()
90            .fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64));
91        let tmp =
92            std::env::temp_dir().join(format!("just-shield-fetch-{}-{key:x}", std::process::id()));
93        let _ = std::fs::remove_dir_all(&tmp);
94        std::fs::create_dir_all(&tmp)?;
95        let init = std::process::Command::new("git")
96            .args(["init", "-q"])
97            .current_dir(&tmp)
98            .output()?;
99        if !init.status.success() {
100            return Err(io::Error::other("git init 실패"));
101        }
102        let fetch = std::process::Command::new("git")
103            .args(["fetch", "--quiet", "--depth=1", &url, want])
104            .current_dir(&tmp)
105            .output()?;
106        let stderr = String::from_utf8_lossy(&fetch.stderr).to_lowercase();
107        Ok((fetch.status.success(), stderr, tmp))
108    }
109
110    /// API 조회와 달리 git 프로토콜은 포크에 숨긴 커밋(임포스터)을 내주지 않는다 —
111    /// 정식 참조에서 도달 가능한 객체만 fetch된다.
112    fn commit_reachable_impl(owner_repo: &str, sha: &str) -> io::Result<Option<bool>> {
113        let (ok, stderr, tmp) = Self::shallow_fetch(owner_repo, sha)?;
114        let _ = std::fs::remove_dir_all(&tmp);
115        if ok {
116            return Ok(Some(true));
117        }
118        // 도달 불가/미공개 객체 거부는 "임포스터" 신호, 그 외(네트워크 등)는 판정 보류.
119        if stderr.contains("not our ref") || stderr.contains("unadvertised object") {
120            return Ok(Some(false));
121        }
122        Err(io::Error::other(format!(
123            "git fetch 실패 ({owner_repo}@{sha}): {}",
124            stderr.trim()
125        )))
126    }
127
128    fn ref_timestamp_impl(owner_repo: &str, git_ref: &str) -> io::Result<Option<i64>> {
129        let (ok, stderr, tmp) = Self::shallow_fetch(owner_repo, git_ref)?;
130        if !ok {
131            let _ = std::fs::remove_dir_all(&tmp);
132            if stderr.contains("couldn't find remote ref") {
133                return Ok(None);
134            }
135            return Err(io::Error::other(format!(
136                "git fetch 실패 ({owner_repo}@{git_ref}): {}",
137                stderr.trim()
138            )));
139        }
140        let log = std::process::Command::new("git")
141            .args(["log", "-1", "--format=%ct", "FETCH_HEAD"])
142            .current_dir(&tmp)
143            .output()?;
144        let _ = std::fs::remove_dir_all(&tmp);
145        if !log.status.success() {
146            return Ok(None);
147        }
148        Ok(String::from_utf8_lossy(&log.stdout).trim().parse().ok())
149    }
150}
151
152/// ls-remote 출력에서 가장 정확한 SHA를 고른다: peeled(^{}) > tags > 그 외.
153fn pick_best(output: &str) -> Option<String> {
154    let mut tag = None;
155    let mut other = None;
156    for line in output.lines() {
157        let Some((sha, name)) = line.split_once('\t') else {
158            continue;
159        };
160        if name.ends_with("^{}") {
161            return Some(sha.to_string());
162        }
163        if name.starts_with("refs/tags/") {
164            tag.get_or_insert_with(|| sha.to_string());
165        } else {
166            other.get_or_insert_with(|| sha.to_string());
167        }
168    }
169    tag.or(other)
170}
171
172#[cfg(test)]
173mod tests {
174    use super::pick_best;
175
176    #[test]
177    fn prefers_peeled_then_tag_then_branch() {
178        let out = "aaa\trefs/heads/v4\nbbb\trefs/tags/v4\nccc\trefs/tags/v4^{}\n";
179        assert_eq!(pick_best(out).as_deref(), Some("ccc"));
180        let out = "aaa\trefs/heads/v4\nbbb\trefs/tags/v4\n";
181        assert_eq!(pick_best(out).as_deref(), Some("bbb"));
182        assert_eq!(pick_best(""), None);
183    }
184}