planarian_backend_netwatch/
contract.rs1use crate::model::{Endpoint, Footprint, FootprintView, Proto, WatchState};
4use planarian_schema::{Scope, UnitId, UnitKind};
5use std::io::{self, Write};
6use std::path::Path;
7
8pub const DIR: &str = "/run/planarian/footprint";
10pub const CLOCK: &str = "CLOCK_BOOTTIME";
14pub const FRESH_SECS: u64 = 30;
16pub const AHEAD_SECS: u64 = 5;
18pub const TICK_SECS: u64 = 5;
20
21const WATCH: &str = ".watch";
22const STATE: &str = ".state";
25const UNATTRIBUTED: &str = ".unattributed";
26pub fn hold_may_release(view: &FootprintView, unit: &UnitId, invocation: Option<&str>, now: u64) -> bool {
34 let name = file_name(unit);
35 let Some(f) = view.units.get(&name) else { return false };
36 invocation.is_some()
37 && f.invocation.as_deref() == invocation
38 && f.ended
39 && !f.unknown
40 && !view.protecting_now(&name, now)
41}
42
43pub fn file_name(u: &UnitId) -> String {
46 debug_assert!(u.kind == UnitKind::Service && u.scope == Scope::System, "{u}");
47 u.name.trim_end_matches(".service").to_string()
48}
49
50fn proto(p: Proto) -> &'static str {
51 match p {
52 Proto::Tcp => "tcp",
53 Proto::Udp => "udp",
54 }
55}
56
57pub(crate) fn render(f: &Footprint) -> String {
58 let protecting = if f.unknown {
59 "unknown"
60 } else if f.protecting {
61 "yes"
62 } else {
63 "no"
64 };
65 let mut s = format!("protecting {protecting}\nat {}\n", f.at);
66 if let Some(i) = &f.invocation {
67 s.push_str(&format!("invocation {i}\n"));
68 }
69 if f.ended {
70 s.push_str("ended\n");
71 }
72 for i in &f.ifaces {
73 s.push_str(&format!("iface {i}\n"));
74 }
75 for e in &f.endpoints {
76 s.push_str(&format!("endpoint {} {} {}\n", proto(e.proto), e.addr, e.port));
77 }
78 if f.unattributed {
79 s.push_str("unattributed\n");
80 }
81 s
82}
83
84fn put(dir: &Path, name: &str, body: &str) -> io::Result<()> {
86 let tmp = dir.join(format!(".{name}.tmp"));
87 let mut f = std::fs::File::create(&tmp)?;
88 f.write_all(body.as_bytes())?;
89 f.sync_all()?;
90 std::fs::rename(&tmp, dir.join(name))
91}
92
93pub fn write(dir: &Path, view: &FootprintView) -> io::Result<()> {
97 std::fs::create_dir_all(dir)?;
98 for (name, f) in &view.units {
99 put(dir, name, &render(f))?;
100 }
101 for e in std::fs::read_dir(dir)? {
102 let name = e?.file_name().to_string_lossy().into_owned();
103 if !name.starts_with('.') && !view.units.contains_key(&name) {
104 std::fs::remove_file(dir.join(&name))?;
105 }
106 }
107 if !view.unattributed.is_empty() {
108 let why: String = view.unattributed.iter().map(|r| format!("{}\n", one_line(r))).collect();
109 put(dir, UNATTRIBUTED, &why)?;
110 } else {
111 match std::fs::remove_file(dir.join(UNATTRIBUTED)) {
112 Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e),
113 _ => {}
114 }
115 }
116 let watch = match &view.watch {
118 WatchState::Attached { at } => format!("attached {at}\n"),
119 WatchState::Failed { reason } => format!("failed {}\n", one_line(reason)),
120 WatchState::Unknown => "unknown\n".to_string(),
121 };
122 put(dir, WATCH, &watch)
123}
124
125fn one_line(s: &str) -> String {
126 let s: String = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
127 if s.trim().is_empty() { "unknown".into() } else { s.trim().to_string() }
128}
129
130pub(crate) fn parse_footprint(text: &str) -> Option<Footprint> {
131 let mut f = Footprint::default();
132 let mut at = None;
133 for line in text.lines() {
134 let w: Vec<&str> = line.split_whitespace().collect();
135 match w.as_slice() {
136 ["at", t] => at = Some(t.parse().ok()?),
137 ["invocation", i] if i.len() == 32 && i.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f')) => {
138 f.invocation = Some((*i).to_string());
139 }
140 ["protecting", "yes"] => f.protecting = true,
141 ["protecting", "no"] => f.protecting = false,
142 ["protecting", "unknown"] => {
144 f.protecting = true;
145 f.unknown = true;
146 }
147 ["ended"] => f.ended = true,
148 ["iface", i] => {
149 f.ifaces.insert((*i).to_string());
150 }
151 ["endpoint", p, a, port] => {
152 let proto = match *p {
153 "tcp" => Proto::Tcp,
154 "udp" => Proto::Udp,
155 _ => return None,
156 };
157 f.endpoints.insert(Endpoint { proto, addr: a.parse().ok()?, port: port.parse().ok()? });
158 }
159 ["unattributed"] => f.unattributed = true,
160 [] => {}
161 _ => return None,
163 }
164 }
165 f.at = at?;
166 Some(f)
167}
168
169pub fn read(dir: &Path) -> FootprintView {
173 let mut view = FootprintView::default();
174 let Ok(entries) = std::fs::read_dir(dir) else { return view };
175 view.watch = match std::fs::read_to_string(dir.join(WATCH)) {
176 Ok(s) => match s.split_whitespace().collect::<Vec<_>>().as_slice() {
177 ["attached", t] => t.parse().map(|at| WatchState::Attached { at }).unwrap_or(WatchState::Unknown),
178 ["failed", why @ ..] => WatchState::Failed { reason: why.join(" ") },
179 _ => WatchState::Unknown,
180 },
181 Err(_) => WatchState::Unknown,
182 };
183 view.unattributed = match std::fs::read_to_string(dir.join(UNATTRIBUTED)) {
184 Ok(s) => {
186 let why: Vec<String> = s.lines().filter(|l| !l.trim().is_empty()).map(str::to_string).collect();
187 if why.is_empty() { vec!["unattributed".into()] } else { why }
188 }
189 Err(e) if e.kind() == io::ErrorKind::NotFound => vec![],
190 Err(e) => vec![format!("unreadable: {e}")],
192 };
193 for e in entries.flatten() {
194 let name = e.file_name().to_string_lossy().into_owned();
195 if name.starts_with('.') {
196 continue;
197 }
198 if let Some(f) = std::fs::read_to_string(e.path()).ok().and_then(|t| parse_footprint(&t)) {
199 view.units.insert(name, f);
200 }
201 }
202 view
203}
204
205pub fn write_state(dir: &Path, claims: &crate::Claims) -> io::Result<()> {
207 let json = serde_json::to_string(claims).map_err(io::Error::other)?;
208 put(dir, STATE, &json)
209}
210
211pub fn read_claims(dir: &Path) -> crate::Claims {
214 std::fs::read_to_string(dir.join(STATE))
215 .ok()
216 .and_then(|s| serde_json::from_str(&s).ok())
217 .unwrap_or_else(|| crate::Tracker::claims_from_files(&read(dir)))
218}