host_identity/sources/
lxc.rs1use std::io::{BufRead, BufReader, Read};
30use std::path::{Path, PathBuf};
31
32use crate::error::Error;
33use crate::source::{Probe, Source, SourceKind};
34use crate::sources::util::{MAX_ID_FILE_BYTES, normalize, read_capped};
35
36const DEFAULT_CGROUP_PATH: &str = "/proc/self/cgroup";
37const DEFAULT_MOUNTINFO_PATH: &str = "/proc/self/mountinfo";
38const DEFAULT_MACHINE_ID_PATH: &str = "/etc/machine-id";
39
40const MAX_MOUNTINFO_BYTES: u64 = 2 * 1024 * 1024;
44
45#[derive(Debug, Clone)]
47pub struct LxcId {
48 cgroup: PathBuf,
49 mountinfo: PathBuf,
50 machine_id: PathBuf,
51}
52
53impl LxcId {
54 #[must_use]
56 pub fn new() -> Self {
57 Self {
58 cgroup: PathBuf::from(DEFAULT_CGROUP_PATH),
59 mountinfo: PathBuf::from(DEFAULT_MOUNTINFO_PATH),
60 machine_id: PathBuf::from(DEFAULT_MACHINE_ID_PATH),
61 }
62 }
63
64 #[must_use]
67 pub fn with_cgroup(mut self, path: impl Into<PathBuf>) -> Self {
68 self.cgroup = path.into();
69 self
70 }
71
72 #[must_use]
74 pub fn with_mountinfo(mut self, path: impl Into<PathBuf>) -> Self {
75 self.mountinfo = path.into();
76 self
77 }
78
79 #[must_use]
81 pub fn with_machine_id(mut self, path: impl Into<PathBuf>) -> Self {
82 self.machine_id = path.into();
83 self
84 }
85}
86
87impl Default for LxcId {
88 fn default() -> Self {
89 Self::new()
90 }
91}
92
93impl Source for LxcId {
94 fn kind(&self) -> SourceKind {
95 SourceKind::Lxc
96 }
97
98 fn probe(&self) -> Result<Option<Probe>, Error> {
99 let Some(machine_id) = read_machine_id(&self.machine_id) else {
100 return Ok(None);
101 };
102 let Some(name) = scan_file(&self.cgroup, MAX_ID_FILE_BYTES)
103 .or_else(|| scan_file(&self.mountinfo, MAX_MOUNTINFO_BYTES))
104 else {
105 return Ok(None);
106 };
107 let raw = format!("lxc:{machine_id}:{name}");
108 Ok(Some(Probe::new(SourceKind::Lxc, raw)))
109 }
110}
111
112fn read_machine_id(path: &Path) -> Option<String> {
117 let content = read_capped(path).ok()?;
118 normalize(&content).map(str::to_owned)
119}
120
121fn scan_file(path: &Path, cap: u64) -> Option<String> {
122 let file = match std::fs::File::open(path) {
123 Ok(f) => f,
124 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
125 Err(err) => {
126 log::debug!("host-identity: lxc: reading {}: {err}", path.display());
127 return None;
128 }
129 };
130 BufReader::new(file.take(cap))
131 .lines()
132 .map_while(Result::ok)
133 .find_map(|line| {
134 line.split_ascii_whitespace()
135 .find_map(extract_name_from_word)
136 })
137}
138
139fn extract_name_from_word(word: &str) -> Option<String> {
140 word.split(':').find_map(match_lxc_marker)
141}
142
143fn match_lxc_marker(segment: &str) -> Option<String> {
144 [LXC_PAYLOAD, LXC_MONITOR]
149 .iter()
150 .find_map(|&m| extract_name(&segment[segment.find(m)? + m.len()..]))
151 .or_else(|| segment.strip_prefix(LXC_LEGACY).and_then(extract_name))
152}
153
154const LXC_PAYLOAD: &str = "/lxc.payload.";
155const LXC_MONITOR: &str = "/lxc.monitor.";
156const LXC_LEGACY: &str = "/lxc/";
157
158fn extract_name(rest: &str) -> Option<String> {
159 let end = rest
160 .bytes()
161 .position(|b| !is_name_byte(b))
162 .unwrap_or(rest.len());
163 let candidate = &rest[..end];
164 let candidate = candidate
165 .strip_suffix(".scope")
166 .or_else(|| candidate.strip_suffix(".service"))
167 .unwrap_or(candidate);
168 (!candidate.is_empty()).then(|| candidate.to_owned())
169}
170
171fn is_name_byte(b: u8) -> bool {
172 b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use std::io::Write;
179 use tempfile::NamedTempFile;
180
181 fn probe_with(cgroup: &str, mountinfo: &str, machine_id: &str) -> Option<Probe> {
182 let mut cg = NamedTempFile::new().unwrap();
183 cg.write_all(cgroup.as_bytes()).unwrap();
184 let mut mi = NamedTempFile::new().unwrap();
185 mi.write_all(mountinfo.as_bytes()).unwrap();
186 let mut id = NamedTempFile::new().unwrap();
187 id.write_all(machine_id.as_bytes()).unwrap();
188 LxcId::new()
189 .with_cgroup(cg.path())
190 .with_mountinfo(mi.path())
191 .with_machine_id(id.path())
192 .probe()
193 .unwrap()
194 }
195
196 #[test]
197 fn payload_in_cgroup_v2_matches() {
198 let probe = probe_with("0::/lxc.payload.demo\n", "", "abc123\n").unwrap();
199 assert_eq!(probe.kind(), SourceKind::Lxc);
200 assert_eq!(probe.value(), "lxc:abc123:demo");
201 }
202
203 #[test]
204 fn payload_in_cgroup_v1_matches() {
205 let cg = "4:memory:/lxc.payload.demo/init.scope\n";
206 let probe = probe_with(cg, "", "abc123\n").unwrap();
207 assert_eq!(probe.value(), "lxc:abc123:demo");
208 }
209
210 #[test]
211 fn monitor_matches() {
212 let cg = "0::/lxc.monitor.demo\n";
213 let probe = probe_with(cg, "", "abc123\n").unwrap();
214 assert_eq!(probe.value(), "lxc:abc123:demo");
215 }
216
217 #[test]
218 fn legacy_slash_lxc_matches() {
219 let cg = "11:name=systemd:/lxc/demo\n";
220 let probe = probe_with(cg, "", "abc123\n").unwrap();
221 assert_eq!(probe.value(), "lxc:abc123:demo");
222 }
223
224 #[test]
225 fn scope_suffix_is_stripped() {
226 assert_eq!(extract_name("demo.scope"), Some("demo".to_owned()));
227 }
228
229 #[test]
230 fn service_suffix_is_stripped() {
231 assert_eq!(extract_name("demo.service"), Some("demo".to_owned()));
232 }
233
234 #[test]
235 fn name_with_dots_is_preserved() {
236 assert_eq!(
239 match_lxc_marker("/lxc.payload.foo.bar/init.scope"),
240 Some("foo.bar".to_owned())
241 );
242 }
243
244 #[test]
245 fn name_with_hyphen_is_preserved() {
246 assert_eq!(
247 match_lxc_marker("/lxc.payload.my-container"),
248 Some("my-container".to_owned())
249 );
250 }
251
252 #[test]
253 fn name_with_underscore_is_preserved() {
254 assert_eq!(
255 match_lxc_marker("/lxc.payload.my_ct"),
256 Some("my_ct".to_owned())
257 );
258 }
259
260 #[test]
261 fn empty_name_is_rejected() {
262 assert_eq!(match_lxc_marker("/lxc.payload./leftover"), None);
264 }
265
266 #[test]
267 fn legacy_substring_in_share_path_rejected() {
268 assert_eq!(match_lxc_marker("/usr/share/lxc/templates/download"), None);
271 }
272
273 #[test]
274 fn lxc_templates_hyphenated_path_rejected() {
275 assert_eq!(match_lxc_marker("/var/lib/lxc-templates/download"), None);
278 }
279
280 #[test]
281 fn payload_substring_in_deeper_path_still_matches() {
282 assert_eq!(
286 match_lxc_marker("/sys/fs/cgroup/lxc.payload.demo/memory"),
287 Some("demo".to_owned())
288 );
289 }
290
291 #[test]
292 fn name_stops_at_slash() {
293 assert_eq!(match_lxc_marker("/lxc.payload.a/b"), Some("a".to_owned()),);
296 }
297
298 #[test]
299 fn name_stops_at_whitespace() {
300 assert_eq!(
301 match_lxc_marker("/lxc.payload.bad name"),
302 Some("bad".to_owned()),
303 );
304 }
305
306 #[test]
307 fn machine_id_missing_yields_none() {
308 let mut cg = NamedTempFile::new().unwrap();
309 cg.write_all(b"0::/lxc.payload.demo\n").unwrap();
310 let mi = NamedTempFile::new().unwrap();
311 let missing = cg.path().with_extension("definitely-not-there");
313 let probe = LxcId::new()
314 .with_cgroup(cg.path())
315 .with_mountinfo(mi.path())
316 .with_machine_id(missing)
317 .probe()
318 .unwrap();
319 assert!(probe.is_none());
320 }
321
322 #[test]
323 fn machine_id_sentinel_yields_none() {
324 let probe = probe_with("0::/lxc.payload.demo\n", "", "uninitialized\n");
325 assert!(probe.is_none());
326 }
327
328 #[test]
329 fn machine_id_empty_yields_none() {
330 let probe = probe_with("0::/lxc.payload.demo\n", "", " \n");
331 assert!(probe.is_none());
332 }
333
334 #[test]
335 fn no_markers_yields_none() {
336 let probe = probe_with("0::/user.slice/user-1000.slice\n", "", "abc123\n");
337 assert!(probe.is_none());
338 }
339
340 #[test]
341 fn cgroup_wins_over_mountinfo() {
342 let probe = probe_with(
345 "0::/lxc.payload.from-cgroup\n",
346 "1 2 0:0 / /host rw - overlay overlay rw,lowerdir=/lxc.payload.from-mountinfo/rootfs\n",
347 "abc123\n",
348 )
349 .unwrap();
350 assert_eq!(probe.value(), "lxc:abc123:from-cgroup");
351 }
352
353 #[test]
354 fn mountinfo_fallback_when_cgroup_missing() {
355 let probe = probe_with(
356 "0::/\n",
357 "1 2 0:0 / /host rw - overlay overlay rw,lowerdir=/lxc.payload.demo/rootfs\n",
358 "abc123\n",
359 )
360 .unwrap();
361 assert_eq!(probe.value(), "lxc:abc123:demo");
362 }
363
364 #[test]
365 fn composed_value_is_stable_across_calls() {
366 let mut cg = NamedTempFile::new().unwrap();
367 cg.write_all(b"0::/lxc.payload.demo\n").unwrap();
368 let mi = NamedTempFile::new().unwrap();
369 let mut id = NamedTempFile::new().unwrap();
370 id.write_all(b"abc123\n").unwrap();
371 let src = LxcId::new()
372 .with_cgroup(cg.path())
373 .with_mountinfo(mi.path())
374 .with_machine_id(id.path());
375 let a = src.probe().unwrap().unwrap();
376 let b = src.probe().unwrap().unwrap();
377 assert_eq!(a.value(), b.value());
378 assert_eq!(a.value(), "lxc:abc123:demo");
379 }
380
381 #[test]
382 fn mountinfo_past_cap_is_ignored() {
383 let mut mi = NamedTempFile::new().unwrap();
388 let padding_line = "1 2 0:0 / /pad rw - overlay overlay rw,lowerdir=/var/lib/foo/bar\n";
390 let cap = usize::try_from(MAX_MOUNTINFO_BYTES).unwrap();
391 let mut written = 0usize;
392 while written <= cap {
393 mi.write_all(padding_line.as_bytes()).unwrap();
394 written += padding_line.len();
395 }
396 mi.write_all(
398 b"9 9 0:0 / /late rw - overlay overlay rw,lowerdir=/lxc.payload.hidden/rootfs\n",
399 )
400 .unwrap();
401 mi.flush().unwrap();
402 let cg = NamedTempFile::new().unwrap();
403 let mut id = NamedTempFile::new().unwrap();
404 id.write_all(b"abc123\n").unwrap();
405 let probe = LxcId::new()
406 .with_cgroup(cg.path())
407 .with_mountinfo(mi.path())
408 .with_machine_id(id.path())
409 .probe()
410 .unwrap();
411 assert!(probe.is_none(), "marker past cap must not match");
412 }
413}