just_shield/
egress_lockfile.rs1use std::collections::{BTreeMap, BTreeSet};
8use std::io;
9use std::path::Path;
10
11pub const FILE_NAME: &str = "egress.lock";
12
13pub struct JobSection {
15 pub line: usize,
16 pub patterns: BTreeSet<String>,
17}
18
19#[derive(Default)]
21pub struct EgressLock {
22 pub jobs: BTreeMap<String, JobSection>,
23}
24
25impl EgressLock {
26 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 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) = ¤t 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 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 §ion.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
91pub fn normalize(domain: &str) -> String {
93 domain.trim().trim_end_matches('.').to_ascii_lowercase()
94}
95
96fn 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
114pub 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
131pub 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 assert!(rendered.find("[build]").unwrap() < rendered.find("[release]").unwrap());
157 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}