1use std::fs::File;
12use std::io::{self, ErrorKind, Read, Seek, SeekFrom, Write};
13use std::os::fd::OwnedFd;
14use std::os::unix::fs::{FileExt, MetadataExt};
15use std::path::{Path, PathBuf};
16use std::time::{Duration, Instant, SystemTime};
17
18use rustix::fs::{AtFlags, Mode, OFlags};
19use rustix::io::Errno;
20
21use super::{
22 CAPPED_SOURCE, LogQuery, POD_LOG, POD_LOG_CAP, POD_LOG_CURSOR, PodLogLine, TAIL_MAX,
23 valid_cursor,
24};
25
26const POD_DIR: &str = ".pod";
28
29const CAP_RESERVE: u64 = 512;
31
32const CURSOR_EVERY: Duration = Duration::from_secs(1);
34
35fn name_in_pod(path: &str) -> Option<&str> {
38 path.strip_prefix(".pod/")
39 .filter(|name| !name.is_empty() && !name.contains('/'))
40}
41
42fn unsafe_path(path: &Path) -> io::Error {
45 io::Error::new(
46 ErrorKind::InvalidInput,
47 format!("refusing to use {}: it is not a safe path", path.display()),
48 )
49}
50
51fn open_error(path: &Path, errno: Errno) -> io::Error {
53 if errno == Errno::LOOP || errno == Errno::NOTDIR {
54 unsafe_path(path)
55 } else {
56 errno.into()
57 }
58}
59
60fn pod_dir(run_dir: &Path, create: bool) -> io::Result<OwnedFd> {
63 if create {
64 std::fs::create_dir_all(run_dir)?;
65 }
66 let run = rustix::fs::open(
67 run_dir,
68 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
69 Mode::empty(),
70 )?;
71 if create {
72 match rustix::fs::mkdirat(&run, POD_DIR, Mode::from_raw_mode(0o700)) {
73 Ok(()) => {},
74 Err(errno) if errno == Errno::EXIST => {},
75 Err(errno) => return Err(errno.into()),
76 }
77 }
78 rustix::fs::openat(
79 &run,
80 POD_DIR,
81 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
82 Mode::empty(),
83 )
84 .map_err(|errno| open_error(&run_dir.join(POD_DIR), errno))
85}
86
87fn open_in(dir: &OwnedFd, dir_path: &Path, name: &str, flags: OFlags) -> io::Result<File> {
91 let path = dir_path.join(name);
92 let fd = rustix::fs::openat(
93 dir,
94 name,
95 flags | OFlags::NOFOLLOW | OFlags::CLOEXEC,
96 Mode::from_raw_mode(0o600),
97 )
98 .map_err(|errno| open_error(&path, errno))?;
99 let file = File::from(fd);
100 let metadata = file.metadata()?;
101 if !metadata.is_file() || metadata.nlink() != 1 {
102 return Err(unsafe_path(&path));
103 }
104 if flags.intersects(OFlags::WRONLY | OFlags::RDWR) {
105 rustix::fs::fchmod(&file, Mode::from_raw_mode(0o600))?;
106 }
107 Ok(file)
108}
109
110pub fn open_kept(run_dir: &Path, path: &str) -> io::Result<Option<File>> {
120 let name = name_in_pod(path).ok_or_else(|| unsafe_path(Path::new(path)))?;
121 let dir_path = run_dir.join(POD_DIR);
122 let dir = match pod_dir(run_dir, false) {
123 Ok(dir) => dir,
124 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
125 Err(error) => return Err(error),
126 };
127 match open_in(&dir, &dir_path, name, OFlags::RDONLY) {
128 Ok(file) => Ok(Some(file)),
129 Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
130 Err(error) => Err(error),
131 }
132}
133
134pub fn read_kept(run_dir: &Path, path: &str) -> io::Result<Option<Vec<u8>>> {
141 let Some(mut file) = open_kept(run_dir, path)? else {
142 return Ok(None);
143 };
144 let mut bytes = Vec::new();
145 file.read_to_end(&mut bytes)?;
146 Ok(Some(bytes))
147}
148
149pub fn keep_file(run_dir: &Path, path: &str, bytes: &[u8]) -> io::Result<()> {
157 let name = name_in_pod(path).ok_or_else(|| unsafe_path(Path::new(path)))?;
158 let dir = pod_dir(run_dir, true)?;
159 replace_in(&dir, &run_dir.join(POD_DIR), name, bytes)
160}
161
162fn replace_in(dir: &OwnedFd, dir_path: &Path, name: &str, bytes: &[u8]) -> io::Result<()> {
164 let temporary = format!("{name}.tmp");
165 match rustix::fs::unlinkat(dir, temporary.as_str(), AtFlags::empty()) {
166 Ok(()) => {},
167 Err(errno) if errno == Errno::NOENT => {},
168 Err(errno) => return Err(errno.into()),
169 }
170 let flags = OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL;
171 let mut file = open_in(dir, dir_path, &temporary, flags)?;
172 file.write_all(bytes)?;
173 rustix::fs::renameat(dir, temporary.as_str(), dir, name)?;
174 Ok(())
175}
176
177#[must_use]
179pub fn kept_cursor(run_dir: &Path) -> Option<String> {
180 let bytes = read_kept(run_dir, POD_LOG_CURSOR).ok()??;
181 let cursor = String::from_utf8_lossy(&bytes).trim().to_string();
182 valid_cursor(&cursor).then_some(cursor)
183}
184
185#[must_use]
188pub fn parse_kept_line(text: &str) -> Option<PodLogLine> {
189 serde_json::from_str::<PodLogLine>(text)
190 .ok()
191 .map(PodLogLine::cleaned)
192}
193
194pub fn kept_lines(run_dir: &Path) -> io::Result<Vec<PodLogLine>> {
201 let Some(bytes) = read_kept(run_dir, POD_LOG)? else {
202 return Ok(Vec::new());
203 };
204 Ok(String::from_utf8_lossy(&bytes)
205 .lines()
206 .filter_map(parse_kept_line)
207 .collect())
208}
209
210pub fn read_kept_from(run_dir: &Path, offset: u64) -> io::Result<Option<(Vec<u8>, u64)>> {
217 let Some(mut file) = open_kept(run_dir, POD_LOG)? else {
218 return Ok(None);
219 };
220 let start = if file.metadata()?.len() < offset {
221 0
222 } else {
223 offset
224 };
225 file.seek(SeekFrom::Start(start))?;
226 let mut bytes = Vec::new();
227 file.read_to_end(&mut bytes)?;
228 Ok(Some((bytes, start)))
229}
230
231#[derive(Debug)]
238pub struct Capture {
239 dir: OwnedFd,
240 dir_path: PathBuf,
241 file: File,
242 size: u64,
243 cap: u64,
244 capped: bool,
245 cursor: Option<String>,
247 unsaved: bool,
249 saved_at: Option<Instant>,
250}
251
252impl Capture {
253 pub fn open(run_dir: &Path) -> io::Result<Self> {
262 let dir = pod_dir(run_dir, true)?;
263 let dir_path = run_dir.join(POD_DIR);
264 let log = name_in_pod(POD_LOG).unwrap_or("pod.log");
265 let flags = OFlags::RDWR | OFlags::APPEND | OFlags::CREATE;
266 let mut file = open_in(&dir, &dir_path, log, flags)?;
267 let mut size = file.metadata()?.len();
268 let mut last = [0_u8];
269 if size > 0 && file.read_at(&mut last, size - 1)? == 1 && last[0] != b'\n' {
270 file.write_all(b"\n")?;
271 size += 1;
272 }
273 let mut capture = Self {
274 dir,
275 dir_path,
276 file,
277 size,
278 cap: POD_LOG_CAP,
279 capped: false,
280 cursor: kept_cursor(run_dir),
281 unsaved: false,
282 saved_at: None,
283 };
284 capture.capped = capture.full(0) || capture.ends_capped();
285 Ok(capture)
286 }
287
288 #[must_use]
290 pub fn with_cap(mut self, cap: u64) -> Self {
291 self.cap = cap;
292 self.capped = self.full(0) || self.ends_capped();
293 self
294 }
295
296 #[must_use]
298 pub fn cursor(&self) -> Option<&str> {
299 self.cursor.as_deref()
300 }
301
302 #[must_use]
304 pub fn capped(&self) -> bool {
305 self.capped
306 }
307
308 #[must_use]
311 pub fn query(&self) -> LogQuery {
312 LogQuery {
313 source: None,
314 tail: self.cursor.is_none().then_some(TAIL_MAX),
315 since: None,
316 cursor: self.cursor.clone(),
317 }
318 }
319
320 fn full(&self, adding: u64) -> bool {
321 self.size + adding > self.cap.saturating_sub(CAP_RESERVE)
322 }
323
324 fn ends_capped(&self) -> bool {
326 let from = self.size.saturating_sub(1024);
327 let length = usize::try_from(self.size - from).unwrap_or(0);
328 let mut tail = vec![0_u8; length];
329 if self.file.read_exact_at(&mut tail, from).is_err() {
330 return false;
331 }
332 String::from_utf8_lossy(&tail)
333 .lines()
334 .last()
335 .and_then(|last| serde_json::from_str::<PodLogLine>(last).ok())
336 .is_some_and(|last| last.source == CAPPED_SOURCE)
337 }
338
339 pub fn take(&mut self, lines: &[PodLogLine], cursor: Option<&str>) -> io::Result<()> {
346 if self.capped {
347 return Ok(());
348 }
349 let mut text = String::new();
350 for line in lines {
351 let json = serde_json::to_string(line).map_err(io::Error::other)?;
352 let adding = u64::try_from(text.len() + json.len() + 1).unwrap_or(u64::MAX);
353 if self.full(adding) {
354 self.capped = true;
355 text.push_str(&self.marker()?);
356 break;
357 }
358 text.push_str(&json);
359 text.push('\n');
360 }
361 self.file.write_all(text.as_bytes())?;
362 self.size += u64::try_from(text.len()).unwrap_or(u64::MAX);
363 if self.capped {
364 return Ok(());
365 }
366 if let Some(cursor) = cursor
367 && self.cursor.as_deref() != Some(cursor)
368 {
369 self.cursor = Some(cursor.to_string());
370 self.unsaved = true;
371 }
372 if self.saved_at.is_none_or(|at| at.elapsed() >= CURSOR_EVERY) {
373 self.finish()?;
374 }
375 Ok(())
376 }
377
378 fn marker(&self) -> io::Result<String> {
380 let marker = PodLogLine {
381 ts: crate::runs::rfc3339(SystemTime::now()),
382 source: CAPPED_SOURCE.to_string(),
383 line: format!(
384 "the pod's log reached {} MiB: later lines are not kept",
385 self.cap / (1024 * 1024)
386 ),
387 };
388 let mut text = serde_json::to_string(&marker).map_err(io::Error::other)?;
389 text.push('\n');
390 Ok(text)
391 }
392
393 pub fn finish(&mut self) -> io::Result<()> {
399 if !self.unsaved {
400 return Ok(());
401 }
402 let Some(cursor) = self.cursor.clone() else {
403 return Ok(());
404 };
405 let name = name_in_pod(POD_LOG_CURSOR).unwrap_or("pod.log.cursor");
406 replace_in(&self.dir, &self.dir_path, name, cursor.as_bytes())?;
407 self.unsaved = false;
408 self.saved_at = Some(Instant::now());
409 Ok(())
410 }
411}
412
413impl Drop for Capture {
414 fn drop(&mut self) {
415 if let Err(error) = self.finish() {
416 tracing::debug!("cannot keep the cursor of the pod's log: {error}");
417 }
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use std::fs;
424 use std::os::unix::fs::{PermissionsExt, symlink};
425
426 use super::*;
427
428 fn line(length: usize) -> PodLogLine {
429 PodLogLine {
430 ts: "2026-06-01T12:02:03Z".into(),
431 source: "container".into(),
432 line: "x".repeat(length),
433 }
434 }
435
436 fn kept(dir: &Path) -> Result<Vec<PodLogLine>, Box<dyn std::error::Error>> {
437 Ok(kept_lines(dir)?)
438 }
439
440 #[test]
441 fn a_capture_keeps_lines_and_its_cursor_then_stops_at_its_cap()
442 -> Result<(), Box<dyn std::error::Error>> {
443 let dir = tempfile::tempdir()?;
444 let mut capture = Capture::open(dir.path())?.with_cap(CAP_RESERVE + 400);
445 assert_eq!(capture.query().tail, Some(TAIL_MAX));
446 capture.take(std::slice::from_ref(&line(100)), Some("c/1"))?;
447 assert_eq!(kept_cursor(dir.path()).as_deref(), Some("c/1"));
448 let reopened = Capture::open(dir.path())?;
449 assert_eq!(
450 reopened.query(),
451 LogQuery {
452 source: None,
453 tail: None,
454 since: None,
455 cursor: Some("c/1".into())
456 }
457 );
458 drop(reopened);
459 capture.take(&[line(100), line(100), line(100)], Some("c/4"))?;
460 assert!(capture.capped());
461 capture.take(&[line(1)], Some("c/5"))?;
462 let lines = kept(dir.path())?;
463 assert_eq!(lines.len(), 3, "{lines:?}");
464 assert_eq!(lines[2].source, CAPPED_SOURCE);
465 assert!(lines[2].line.contains("later lines are not kept"));
466 let mode = fs::metadata(dir.path().join(POD_LOG))?.permissions().mode();
467 assert_eq!(mode & 0o777, 0o600);
468 Ok(())
469 }
470
471 #[test]
474 fn a_capped_log_stays_capped_when_reopened() -> Result<(), Box<dyn std::error::Error>> {
475 let dir = tempfile::tempdir()?;
476 let cap = CAP_RESERVE + 400;
477 let mut capture = Capture::open(dir.path())?.with_cap(cap);
478 capture.take(&[line(100), line(300)], Some("c/2"))?;
479 assert!(capture.capped());
480 drop(capture);
481 let size = fs::metadata(dir.path().join(POD_LOG))?.len();
482 assert!(size < cap - CAP_RESERVE, "{size}");
483 assert!(Capture::open(dir.path())?.with_cap(cap).capped());
484 assert!(Capture::open(dir.path())?.capped());
485 Ok(())
486 }
487
488 #[test]
489 fn a_half_written_last_line_is_ended_on_open() -> Result<(), Box<dyn std::error::Error>> {
490 let dir = tempfile::tempdir()?;
491 fs::create_dir_all(dir.path().join(".pod"))?;
492 let first = serde_json::to_string(&line(3))?;
493 fs::write(
494 dir.path().join(POD_LOG),
495 format!("{first}\n{{\"ts\":\"2026"),
496 )?;
497 let mut capture = Capture::open(dir.path())?;
498 capture.take(&[line(4)], None)?;
499 let lines = kept(dir.path())?;
500 assert_eq!(lines, [line(3), line(4)]);
501 Ok(())
502 }
503
504 #[test]
505 fn the_cursor_is_written_at_most_once_a_second_then_at_the_end()
506 -> Result<(), Box<dyn std::error::Error>> {
507 let dir = tempfile::tempdir()?;
508 let mut capture = Capture::open(dir.path())?;
509 capture.take(&[line(1)], Some("c/1"))?;
510 capture.take(&[line(1)], Some("c/2"))?;
511 assert_eq!(capture.cursor(), Some("c/2"));
512 assert_eq!(kept_cursor(dir.path()).as_deref(), Some("c/1"));
513 drop(capture);
514 assert_eq!(kept_cursor(dir.path()).as_deref(), Some("c/2"));
515 Ok(())
516 }
517
518 #[test]
519 fn links_planted_in_the_run_directory_are_refused() -> Result<(), Box<dyn std::error::Error>> {
520 let root = tempfile::tempdir()?;
521 let outside = root.path().join("outside");
522 fs::write(&outside, "mine\n")?;
523 let run = root.path().join("r1");
525 fs::create_dir_all(run.join(".pod"))?;
526 symlink(&outside, run.join(POD_LOG))?;
527 let refused = Capture::open(&run).map(drop);
528 assert_eq!(refused.map_err(|e| e.kind()), Err(ErrorKind::InvalidInput));
529 assert_eq!(
530 open_kept(&run, POD_LOG).map(drop).map_err(|e| e.kind()),
531 Err(ErrorKind::InvalidInput)
532 );
533 let run = root.path().join("r2");
535 fs::create_dir_all(run.join(".pod"))?;
536 fs::hard_link(&outside, run.join(POD_LOG))?;
537 let refused = Capture::open(&run).map(drop);
538 assert_eq!(refused.map_err(|e| e.kind()), Err(ErrorKind::InvalidInput));
539 let run = root.path().join("r3");
541 let elsewhere = root.path().join("elsewhere");
542 fs::create_dir_all(&run)?;
543 fs::create_dir_all(&elsewhere)?;
544 symlink(&elsewhere, run.join(".pod"))?;
545 let refused = Capture::open(&run).map(drop);
546 assert_eq!(refused.map_err(|e| e.kind()), Err(ErrorKind::InvalidInput));
547 assert!(fs::read_dir(&elsewhere)?.next().is_none());
548 let run = root.path().join("r4");
550 fs::create_dir_all(run.join(".pod"))?;
551 symlink(&outside, run.join(".pod/pod.log.cursor.tmp"))?;
552 let mut capture = Capture::open(&run)?;
553 capture.take(&[line(1)], Some("c/1"))?;
554 assert_eq!(kept_cursor(&run).as_deref(), Some("c/1"));
555 symlink(&outside, run.join(".pod/watchdog.log.tmp"))?;
557 keep_file(&run, super::super::super::WATCHDOG_LOG, b"watchdog\n")?;
558 assert_eq!(fs::read(run.join(".pod/watchdog.log"))?, b"watchdog\n");
559 assert_eq!(fs::read_to_string(&outside)?, "mine\n");
560 Ok(())
561 }
562}