use std::collections::{BTreeMap, BTreeSet};
use std::io;
use std::path::Path;
pub const FILE_NAME: &str = "egress.lock";
pub struct JobSection {
pub line: usize,
pub patterns: BTreeSet<String>,
}
#[derive(Default)]
pub struct EgressLock {
pub jobs: BTreeMap<String, JobSection>,
}
impl EgressLock {
pub fn parse(content: &str) -> Result<Self, String> {
let mut jobs: BTreeMap<String, JobSection> = BTreeMap::new();
let mut current: Option<String> = None;
for (idx, raw) in content.lines().enumerate() {
let line_no = idx + 1;
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
if let Some(name) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
let name = name.trim();
if name.is_empty() {
return Err(format!("{line_no}행: 잡 이름이 빈 구획입니다"));
}
if jobs.contains_key(name) {
return Err(format!("{line_no}행: 잡 '{name}' 구획이 중복됩니다"));
}
jobs.insert(
name.to_string(),
JobSection {
line: line_no,
patterns: BTreeSet::new(),
},
);
current = Some(name.to_string());
continue;
}
let Some(job) = ¤t else {
return Err(format!(
"{line_no}행: 잡 구획(`[이름]`) 앞에 도메인이 나왔습니다"
));
};
let pattern = normalize(line);
validate_pattern(&pattern).map_err(|e| format!("{line_no}행: {e}"))?;
jobs.get_mut(job)
.expect("current는 항상 jobs에 존재")
.patterns
.insert(pattern);
}
Ok(EgressLock { jobs })
}
pub fn render(&self) -> String {
let mut out = String::from(
"# egress.lock — 잡별 허용 통신 목적지 (just-shield 층 ⓒ, ADR-0006).\n\
# 여기 적힌 잡만 대조 대상 — 미등재 목적지 관찰 = 🔴. 와일드카드는 사람이 명시적으로만.\n",
);
for (job, section) in &self.jobs {
out.push_str(&format!("\n[{job}]\n"));
for p in §ion.patterns {
out.push_str(p);
out.push('\n');
}
}
out
}
pub fn job(&self, name: &str) -> Option<&JobSection> {
self.jobs.get(name)
}
}
pub fn normalize(domain: &str) -> String {
domain.trim().trim_end_matches('.').to_ascii_lowercase()
}
fn validate_pattern(p: &str) -> Result<(), String> {
let rest = p.strip_prefix("*.").unwrap_or(p);
if rest.contains('*') {
return Err(format!(
"'{p}' — 와일드카드는 선행 라벨 1개(`*.example.com`)만 허용됩니다"
));
}
if rest.is_empty()
|| rest
.chars()
.any(|c| !(c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_'))
{
return Err(format!("'{p}' — 도메인으로 보이지 않습니다"));
}
Ok(())
}
pub fn matches(pattern: &str, domain: &str) -> bool {
let domain = normalize(domain);
if let Some(suffix) = pattern.strip_prefix("*.") {
let Some(head) = domain.strip_suffix(suffix) else {
return false;
};
let Some(label) = head.strip_suffix('.') else {
return false;
};
!label.is_empty() && !label.contains('.')
} else {
domain == pattern
}
}
pub fn load(root: &Path) -> io::Result<Option<EgressLock>> {
let path = root.join(FILE_NAME);
if !path.is_file() {
return Ok(None);
}
let content = std::fs::read_to_string(path)?;
EgressLock::parse(&content)
.map(Some)
.map_err(|e| io::Error::other(format!("egress.lock 파싱 실패 — {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_render_roundtrip_is_deterministic() {
let text = "[release]\nghcr.io\ncrates.io # 발행\n\n[build]\n*.blob.core.windows.net\n";
let lock = EgressLock::parse(text).unwrap();
let rendered = lock.render();
let reparsed = EgressLock::parse(&rendered).unwrap();
assert_eq!(rendered, reparsed.render());
assert!(rendered.find("[build]").unwrap() < rendered.find("[release]").unwrap());
assert!(lock.job("release").unwrap().patterns.contains("crates.io"));
}
#[test]
fn wildcard_matches_exactly_one_label() {
assert!(matches("*.example.com", "a.example.com"));
assert!(matches("*.example.com", "A.EXAMPLE.COM."));
assert!(!matches("*.example.com", "a.b.example.com"));
assert!(!matches("*.example.com", "example.com"));
assert!(!matches("*.example.com", "aexample.com"));
assert!(matches("ghcr.io", "GHCR.IO"));
assert!(!matches("ghcr.io", "evil-ghcr.io"));
}
#[test]
fn invalid_patterns_are_rejected() {
for bad in [
"[j]\n*.*.example.com",
"[j]\na.*.b",
"[j]\n**",
"[j]\nhas space.com",
"orphan.com",
"[]\n",
] {
assert!(EgressLock::parse(bad).is_err(), "통과되면 안 됨: {bad}");
}
}
#[test]
fn missing_job_is_none() {
let lock = EgressLock::parse("[release]\nghcr.io\n").unwrap();
assert!(lock.job("build").is_none());
assert!(lock.job("release").is_some());
}
}