Skip to main content

just_shield/
egress_lockfile.rs

1//! egress.lock — 잡별 허용 통신 목적지의 박제 (ADR-0006, shield.lock의 자매).
2//!
3//! 상태는 도구가 아니라 저장소의 이 파일에 산다. 잠금은 잡 단위 선택제 —
4//! 여기 적힌 잡만 대조 대상이고, 안 적힌 잡은 관찰 보고만 받는다.
5//! 와일드카드는 사람이 명시적으로만 쓴다 — 도구는 절대 자동 생성하지 않는다.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::io;
9use std::path::Path;
10
11pub const FILE_NAME: &str = "egress.lock";
12
13/// 락의 잡 구획 하나 — `[이름]` 헤더의 행 번호와 허용 패턴들.
14pub struct JobSection {
15    pub line: usize,
16    pub patterns: BTreeSet<String>,
17}
18
19/// 박제본. 키는 잡 이름.
20#[derive(Default)]
21pub struct EgressLock {
22    pub jobs: BTreeMap<String, JobSection>,
23}
24
25impl EgressLock {
26    /// 엄격 파싱 — 정책 파일이므로 모르는 줄은 조용히 넘기지 않고 오류를 낸다.
27    pub fn parse(content: &str) -> Result<Self, String> {
28        let mut jobs: BTreeMap<String, JobSection> = BTreeMap::new();
29        let mut current: Option<String> = None;
30        for (idx, raw) in content.lines().enumerate() {
31            let line_no = idx + 1;
32            // 행 끝 주석 제거 후 정리.
33            let line = raw.split('#').next().unwrap_or("").trim();
34            if line.is_empty() {
35                continue;
36            }
37            if let Some(name) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
38                let name = name.trim();
39                if name.is_empty() {
40                    return Err(format!("{line_no}행: 잡 이름이 빈 구획입니다"));
41                }
42                if jobs.contains_key(name) {
43                    return Err(format!("{line_no}행: 잡 '{name}' 구획이 중복됩니다"));
44                }
45                jobs.insert(
46                    name.to_string(),
47                    JobSection {
48                        line: line_no,
49                        patterns: BTreeSet::new(),
50                    },
51                );
52                current = Some(name.to_string());
53                continue;
54            }
55            let Some(job) = &current else {
56                return Err(format!(
57                    "{line_no}행: 잡 구획(`[이름]`) 앞에 도메인이 나왔습니다"
58                ));
59            };
60            let pattern = normalize(line);
61            validate_pattern(&pattern).map_err(|e| format!("{line_no}행: {e}"))?;
62            jobs.get_mut(job)
63                .expect("current는 항상 jobs에 존재")
64                .patterns
65                .insert(pattern);
66        }
67        Ok(EgressLock { jobs })
68    }
69
70    /// BTreeMap/BTreeSet이라 같은 입력이면 항상 같은 바이트가 나온다.
71    pub fn render(&self) -> String {
72        let mut out = String::from(
73            "# egress.lock — 잡별 허용 통신 목적지 (just-shield 층 ⓒ, ADR-0006).\n\
74             # 여기 적힌 잡만 대조 대상 — 미등재 목적지 관찰 = 🔴. 와일드카드는 사람이 명시적으로만.\n",
75        );
76        for (job, section) in &self.jobs {
77            out.push_str(&format!("\n[{job}]\n"));
78            for p in &section.patterns {
79                out.push_str(p);
80                out.push('\n');
81            }
82        }
83        out
84    }
85
86    pub fn job(&self, name: &str) -> Option<&JobSection> {
87        self.jobs.get(name)
88    }
89}
90
91/// 도메인 정규화 — 소문자, 끝점 제거.
92pub fn normalize(domain: &str) -> String {
93    domain.trim().trim_end_matches('.').to_ascii_lowercase()
94}
95
96/// 패턴 검증. 허용: 일반 도메인 또는 선행 라벨 1개 와일드카드(`*.example.com`).
97fn validate_pattern(p: &str) -> Result<(), String> {
98    let rest = p.strip_prefix("*.").unwrap_or(p);
99    if rest.contains('*') {
100        return Err(format!(
101            "'{p}' — 와일드카드는 선행 라벨 1개(`*.example.com`)만 허용됩니다"
102        ));
103    }
104    if rest.is_empty()
105        || rest
106            .chars()
107            .any(|c| !(c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_'))
108    {
109        return Err(format!("'{p}' — 도메인으로 보이지 않습니다"));
110    }
111    Ok(())
112}
113
114/// 관찰된 도메인이 패턴과 맞는가. 와일드카드는 정확히 한 라벨만 대신한다 —
115/// `*.example.com`은 `a.example.com`과 맞고 `a.b.example.com`·`example.com`과는 안 맞는다.
116pub fn matches(pattern: &str, domain: &str) -> bool {
117    let domain = normalize(domain);
118    if let Some(suffix) = pattern.strip_prefix("*.") {
119        let Some(head) = domain.strip_suffix(suffix) else {
120            return false;
121        };
122        let Some(label) = head.strip_suffix('.') else {
123            return false;
124        };
125        !label.is_empty() && !label.contains('.')
126    } else {
127        domain == pattern
128    }
129}
130
131/// `<root>/egress.lock`을 읽는다. 없으면 `Ok(None)` — 오류가 아니다(잠금은 선택제).
132/// 파싱 실패는 오류다 — 정책 파일이 깨진 채 침묵하면 안 된다.
133pub fn load(root: &Path) -> io::Result<Option<EgressLock>> {
134    let path = root.join(FILE_NAME);
135    if !path.is_file() {
136        return Ok(None);
137    }
138    let content = std::fs::read_to_string(path)?;
139    EgressLock::parse(&content)
140        .map(Some)
141        .map_err(|e| io::Error::other(format!("egress.lock 파싱 실패 — {e}")))
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn parse_render_roundtrip_is_deterministic() {
150        let text = "[release]\nghcr.io\ncrates.io # 발행\n\n[build]\n*.blob.core.windows.net\n";
151        let lock = EgressLock::parse(text).unwrap();
152        let rendered = lock.render();
153        let reparsed = EgressLock::parse(&rendered).unwrap();
154        assert_eq!(rendered, reparsed.render());
155        // 정렬: build가 release보다 먼저.
156        assert!(rendered.find("[build]").unwrap() < rendered.find("[release]").unwrap());
157        // 행 끝 주석은 패턴에 포함되지 않는다.
158        assert!(lock.job("release").unwrap().patterns.contains("crates.io"));
159    }
160
161    #[test]
162    fn wildcard_matches_exactly_one_label() {
163        assert!(matches("*.example.com", "a.example.com"));
164        assert!(matches("*.example.com", "A.EXAMPLE.COM."));
165        assert!(!matches("*.example.com", "a.b.example.com"));
166        assert!(!matches("*.example.com", "example.com"));
167        assert!(!matches("*.example.com", "aexample.com"));
168        assert!(matches("ghcr.io", "GHCR.IO"));
169        assert!(!matches("ghcr.io", "evil-ghcr.io"));
170    }
171
172    #[test]
173    fn invalid_patterns_are_rejected() {
174        for bad in [
175            "[j]\n*.*.example.com",
176            "[j]\na.*.b",
177            "[j]\n**",
178            "[j]\nhas space.com",
179            "orphan.com",
180            "[]\n",
181        ] {
182            assert!(EgressLock::parse(bad).is_err(), "통과되면 안 됨: {bad}");
183        }
184    }
185
186    #[test]
187    fn missing_job_is_none() {
188        let lock = EgressLock::parse("[release]\nghcr.io\n").unwrap();
189        assert!(lock.job("build").is_none());
190        assert!(lock.job("release").is_some());
191    }
192}