Skip to main content

a3s_box_core/
log.rs

1//! Logging driver types and configuration.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Logging driver type.
7#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
8#[serde(rename_all = "kebab-case")]
9pub enum LogDriver {
10    /// Docker-compatible JSON lines format (default).
11    #[default]
12    JsonFile,
13    /// Forward logs to a syslog endpoint.
14    ///
15    /// Options:
16    /// - `syslog-address`: UDP/TCP address (e.g., "udp://localhost:514")
17    /// - `syslog-facility`: Syslog facility (default: "daemon")
18    /// - `tag`: Log tag template (default: box name)
19    Syslog,
20    /// Disable logging entirely.
21    None,
22}
23
24impl std::fmt::Display for LogDriver {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            Self::JsonFile => write!(f, "json-file"),
28            Self::Syslog => write!(f, "syslog"),
29            Self::None => write!(f, "none"),
30        }
31    }
32}
33
34impl std::str::FromStr for LogDriver {
35    type Err = String;
36
37    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
38        match s {
39            "json-file" => Ok(Self::JsonFile),
40            "syslog" => Ok(Self::Syslog),
41            "none" => Ok(Self::None),
42            _ => Err(format!(
43                "unknown log driver: '{}' (supported: json-file, syslog, none)",
44                s
45            )),
46        }
47    }
48}
49
50/// Logging configuration for a box.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct LogConfig {
53    pub driver: LogDriver,
54    #[serde(default)]
55    pub options: HashMap<String, String>,
56}
57
58impl Default for LogConfig {
59    fn default() -> Self {
60        Self {
61            driver: LogDriver::JsonFile,
62            options: HashMap::new(),
63        }
64    }
65}
66
67impl LogConfig {
68    /// Maximum log file size in bytes before rotation.
69    /// Default: 10 MiB. Set via `max-size` option (e.g., "10m", "1g").
70    pub fn max_size(&self) -> u64 {
71        self.options
72            .get("max-size")
73            .and_then(|s| parse_size(s).ok())
74            .unwrap_or(10 * 1024 * 1024)
75    }
76
77    /// Maximum number of rotated log files to keep.
78    /// Default: 3. Set via `max-file` option.
79    pub fn max_file(&self) -> u32 {
80        self.options
81            .get("max-file")
82            .and_then(|s| s.parse().ok())
83            .unwrap_or(3)
84    }
85
86    /// Syslog address (e.g., "udp://localhost:514").
87    /// Only relevant when driver is `Syslog`.
88    pub fn syslog_address(&self) -> &str {
89        self.options
90            .get("syslog-address")
91            .map(|s| s.as_str())
92            .unwrap_or("udp://localhost:514")
93    }
94
95    /// Syslog facility (e.g., "daemon", "local0").
96    /// Only relevant when driver is `Syslog`.
97    pub fn syslog_facility(&self) -> &str {
98        self.options
99            .get("syslog-facility")
100            .map(|s| s.as_str())
101            .unwrap_or("daemon")
102    }
103
104    /// Log tag (used by syslog driver as the program name).
105    pub fn tag(&self) -> Option<&str> {
106        self.options.get("tag").map(|s| s.as_str())
107    }
108}
109
110/// A single structured log entry (Docker-compatible JSON format).
111#[derive(Debug, Serialize, Deserialize)]
112pub struct LogEntry {
113    /// The log message (including trailing newline).
114    pub log: String,
115    /// The output stream: "stdout" or "stderr".
116    pub stream: String,
117    /// RFC 3339 timestamp with nanosecond precision.
118    pub time: String,
119}
120
121/// Parse a human-readable size string (e.g., "10m", "1g", "4096") into bytes.
122fn parse_size(s: &str) -> std::result::Result<u64, String> {
123    let s = s.trim().to_lowercase();
124    if let Ok(n) = s.parse::<u64>() {
125        return Ok(n);
126    }
127    let (num, mult) = if s.ends_with("gb") || s.ends_with('g') {
128        (
129            s.trim_end_matches("gb").trim_end_matches('g'),
130            1024u64 * 1024 * 1024,
131        )
132    } else if s.ends_with("mb") || s.ends_with('m') {
133        (
134            s.trim_end_matches("mb").trim_end_matches('m'),
135            1024u64 * 1024,
136        )
137    } else if s.ends_with("kb") || s.ends_with('k') {
138        (s.trim_end_matches("kb").trim_end_matches('k'), 1024u64)
139    } else if s.ends_with('b') {
140        (s.trim_end_matches('b'), 1u64)
141    } else {
142        return Err(format!("unrecognized size format: {s}"));
143    };
144    let n: u64 = num.parse().map_err(|_| format!("invalid number: {num}"))?;
145    Ok(n * mult)
146}
147
148// ===========================================================================
149// Log processor — tails the VM console (`console.log`) and produces structured
150// Docker-compatible output (`container.json`) or forwards to syslog.
151//
152// This runs in the SHIM (the box's own per-process lifetime), not the ephemeral
153// CLI: the CLI exits on `run -d` detach, which would kill an in-CLI processor
154// and truncate the logs. The shim writes `console.log` and lives exactly as
155// long as the VM, so it is the correct, daemonless home (like containerd-shim).
156// ===========================================================================
157
158use std::io::{BufRead, BufReader, Seek, Write};
159use std::path::{Path, PathBuf};
160use std::sync::atomic::{AtomicBool, Ordering};
161
162/// Truncate `path` to empty if it has grown past `cap` bytes; returns whether it
163/// truncated.
164///
165/// libkrun appends the guest console to the raw `console.log`/`console.err.log`
166/// for the VM's entire lifetime, so a chatty long-running box grows them without
167/// limit — only the rotated `container.json` was ever bounded. The tail loop
168/// calls this at a clean line boundary (every line so far is already durable in
169/// `container.json`), so truncation never drops queryable log data. libkrun
170/// holds the file `O_APPEND`, so its next write resumes at offset 0 — no hole.
171fn console_truncate_if_over(path: &Path, cap: u64) -> bool {
172    let Ok(meta) = std::fs::metadata(path) else {
173        return false;
174    };
175    if meta.len() <= cap {
176        return false;
177    }
178    match std::fs::OpenOptions::new().write(true).open(path) {
179        Ok(f) if f.set_len(0).is_ok() => {
180            tracing::debug!(path = %path.display(), cap, "console log exceeded cap; truncated");
181            true
182        }
183        _ => false,
184    }
185}
186
187/// Path to the structured JSON log file inside a box's log dir.
188pub fn json_log_path(log_dir: &Path) -> PathBuf {
189    log_dir.join("container.json")
190}
191
192/// True for console lines that are VM/runtime boot internals, not container
193/// output — libkrun's C-init preamble (`init.krun: ...`), printed before
194/// `/sbin/init` (guest-init) takes over. guest-init's own tracing goes to
195/// `/dev/kmsg`, so this is the only remaining non-container source on the
196/// console.
197pub fn is_runtime_console_noise(line: &str) -> bool {
198    line.starts_with("init.krun:")
199}
200
201/// Read the next COMPLETE line from a tailed `console.log`, returning it without
202/// the trailing newline. Polls on EOF like `tail -f` (so lines a container logs
203/// after a quiet period are not dropped), accumulating a partial line across
204/// reads. Returns `None` only when `stop` is set AND EOF is reached — i.e. the
205/// VM has exited and `console.log` is fully drained — flushing any final partial
206/// line as the last value before the subsequent `None`.
207fn tail_next_line(
208    reader: &mut (impl BufRead + Seek),
209    buf: &mut String,
210    stop: &AtomicBool,
211    on_eof: Option<&dyn Fn() -> bool>,
212) -> Option<String> {
213    loop {
214        match reader.read_line(buf) {
215            Ok(0) | Err(_) => {
216                if stop.load(Ordering::Relaxed) {
217                    // VM exited and we are at EOF: flush a trailing partial line
218                    // (no newline) once, then signal completion.
219                    if buf.is_empty() {
220                        return None;
221                    }
222                    let line = std::mem::take(buf);
223                    return Some(line.trim_end_matches(['\n', '\r']).to_string());
224                }
225                // Caught up at a clean line boundary (no partial line buffered):
226                // let the caller bound the file's growth. If it truncated, seek
227                // back to the start so reads don't sit forever past a stale EOF.
228                if buf.is_empty() {
229                    if let Some(on_eof) = on_eof {
230                        if on_eof() {
231                            let _ = reader.seek(std::io::SeekFrom::Start(0));
232                        }
233                    }
234                }
235                std::thread::sleep(std::time::Duration::from_millis(100));
236                continue;
237            }
238            Ok(_) => {}
239        }
240        if !buf.ends_with('\n') {
241            // Partial line at EOF — keep it buffered and wait for the rest.
242            continue;
243        }
244        let line = std::mem::take(buf);
245        return Some(line.trim_end_matches(['\n', '\r']).to_string());
246    }
247}
248
249/// Run the log processor for a box, blocking until `stop` is set and the console
250/// is drained. Intended to run on a dedicated thread for the VM's lifetime; set
251/// `stop` after the VM exits, then join, to guarantee the final lines are
252/// captured (no teardown race).
253pub fn run_log_processor(
254    console_log: &Path,
255    log_dir: &Path,
256    config: &LogConfig,
257    stop: &AtomicBool,
258) {
259    match config.driver {
260        // `none` produces no structured output, but libkrun still writes the raw
261        // console for the VM's lifetime — drain + bound it so a chatty box with
262        // logging disabled doesn't fill the disk (same hazard as the other
263        // drivers).
264        LogDriver::None => run_discard_processor(
265            console_log,
266            Some(console_cap(config.max_size(), config.max_file())),
267            stop,
268        ),
269        LogDriver::JsonFile => run_json_file_processor(
270            console_log,
271            log_dir,
272            config.max_size(),
273            config.max_file(),
274            stop,
275        ),
276        LogDriver::Syslog => run_syslog_processor(
277            console_log,
278            config.syslog_address(),
279            config.syslog_facility(),
280            config.tag().unwrap_or("a3s-box"),
281            Some(console_cap(config.max_size(), config.max_file())),
282            stop,
283        ),
284    }
285}
286
287/// Wait (bounded) for `console.log` to appear, then open it. Returns `None` if it
288/// never shows up or `stop` fires first.
289fn open_console(console_log: &Path, stop: &AtomicBool) -> Option<std::fs::File> {
290    for _ in 0..300 {
291        if console_log.exists() {
292            break;
293        }
294        if stop.load(Ordering::Relaxed) && !console_log.exists() {
295            return None;
296        }
297        std::thread::sleep(std::time::Duration::from_millis(100));
298    }
299    std::fs::File::open(console_log).ok()
300}
301
302/// Tail console.log and write one Docker-style JSON record per container line.
303/// The stderr companion to `console.log` (libkrun's 3-fd console sends guest
304/// stderr here, stdout to `console.log`).
305pub fn stderr_console_path(console_log: &Path) -> PathBuf {
306    console_log.with_file_name("console.err.log")
307}
308
309/// Tail one console file, emitting each container line via `emit(line, stream)`.
310/// `filter_noise` drops libkrun's `init.krun:` preamble (only on the stdout
311/// console). Blocks until `stop` is set and the file is drained.
312fn run_tagged_tail(
313    file: &Path,
314    stream: &str,
315    filter_noise: bool,
316    bound: Option<u64>,
317    stop: &AtomicBool,
318    emit: &(dyn Fn(&str, &str) + Sync),
319) {
320    let f = match open_console(file, stop) {
321        Some(f) => f,
322        None => return,
323    };
324    let mut reader = BufReader::new(f);
325    let mut buf = String::new();
326    // Bound the raw console file's growth at clean line boundaries (see
327    // console_truncate_if_over). None = unbounded (used by tests).
328    let truncate = bound.map(|cap| move || console_truncate_if_over(file, cap));
329    let on_eof: Option<&dyn Fn() -> bool> = truncate.as_ref().map(|t| t as &dyn Fn() -> bool);
330    while let Some(line) = tail_next_line(&mut reader, &mut buf, stop, on_eof) {
331        if filter_noise && is_runtime_console_noise(&line) {
332            continue;
333        }
334        emit(&line, stream);
335    }
336}
337
338/// The raw `console.log`/`console.err.log` byte budget before the tail loop
339/// truncates it. Tied to the rotated `container.json` budget (`max_size *
340/// max_file`) so the raw console never outgrows the queryable log it feeds.
341fn console_cap(max_size: u64, max_file: u32) -> u64 {
342    max_size.saturating_mul(u64::from(max_file.max(1)))
343}
344
345/// Drain and bound the console for the `none` driver: tail both console files
346/// (advancing to clean line boundaries) and truncate when over `cap`, emitting
347/// nothing. Without this, `--log-driver none` would leave libkrun's raw
348/// `console.log`/`console.err.log` to grow without limit.
349fn run_discard_processor(console_log: &Path, cap: Option<u64>, stop: &AtomicBool) {
350    let err_log = stderr_console_path(console_log);
351    let discard = |_line: &str, _stream: &str| {};
352    let discard: &(dyn Fn(&str, &str) + Sync) = &discard;
353    std::thread::scope(|s| {
354        s.spawn(|| run_tagged_tail(console_log, "stdout", false, cap, stop, discard));
355        s.spawn(|| run_tagged_tail(&err_log, "stderr", false, cap, stop, discard));
356    });
357}
358
359fn run_json_file_processor(
360    console_log: &Path,
361    log_dir: &Path,
362    max_size: u64,
363    max_file: u32,
364    stop: &AtomicBool,
365) {
366    let json_path = json_log_path(log_dir);
367    let writer = std::sync::Mutex::new(match RotatingWriter::new(&json_path, max_size, max_file) {
368        Ok(w) => w,
369        Err(_) => return,
370    });
371    let err_log = stderr_console_path(console_log);
372
373    // Write one tagged JSON record per line; shared by the stdout and stderr
374    // tail threads (the Mutex serializes their interleave into container.json).
375    let emit = |line: &str, stream: &str| {
376        let entry = LogEntry {
377            log: format!("{line}\n"),
378            stream: stream.to_string(),
379            time: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Nanos, true),
380        };
381        if let Ok(json) = serde_json::to_string(&entry) {
382            if let Ok(mut w) = writer.lock() {
383                let _ = w.write_line(&json);
384            }
385        }
386    };
387    let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
388
389    let cap = Some(console_cap(max_size, max_file));
390    std::thread::scope(|s| {
391        s.spawn(|| run_tagged_tail(console_log, "stdout", true, cap, stop, emit));
392        // libkrun's `init.krun:` preamble can land on EITHER stream, so filter
393        // the noise on stderr too.
394        s.spawn(|| run_tagged_tail(&err_log, "stderr", true, cap, stop, emit));
395    });
396}
397
398/// Forward both console streams (stdout + stderr) to a syslog endpoint.
399fn run_syslog_processor(
400    console_log: &Path,
401    address: &str,
402    _facility: &str,
403    tag: &str,
404    cap: Option<u64>,
405    stop: &AtomicBool,
406) {
407    use std::net::UdpSocket;
408
409    let (proto, addr) = if let Some(rest) = address.strip_prefix("udp://") {
410        ("udp", rest)
411    } else if let Some(rest) = address.strip_prefix("tcp://") {
412        ("tcp", rest)
413    } else {
414        ("udp", address)
415    };
416    let err_log = stderr_console_path(console_log);
417
418    match proto {
419        "udp" => {
420            let socket = match UdpSocket::bind("0.0.0.0:0") {
421                Ok(s) => s,
422                Err(_) => return,
423            };
424            // RFC 3164: <priority>tag: message; daemon(3)*8 + info(6) = 30.
425            let emit = |line: &str, _stream: &str| {
426                let msg = format!("<30>{tag}: {line}");
427                let _ = socket.send_to(msg.as_bytes(), addr);
428            };
429            let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
430            std::thread::scope(|s| {
431                s.spawn(|| run_tagged_tail(console_log, "stdout", true, cap, stop, emit));
432                s.spawn(|| run_tagged_tail(&err_log, "stderr", true, cap, stop, emit));
433            });
434        }
435        "tcp" => {
436            let stream = match std::net::TcpStream::connect(addr) {
437                Ok(s) => std::sync::Mutex::new(s),
438                Err(_) => return,
439            };
440            let emit = |line: &str, _stream: &str| {
441                let msg = format!("<30>{tag}: {line}\n");
442                if let Ok(mut s) = stream.lock() {
443                    if s.write_all(msg.as_bytes()).is_err() {
444                        if let Ok(news) = std::net::TcpStream::connect(addr) {
445                            *s = news;
446                            let _ = s.write_all(msg.as_bytes());
447                        }
448                    }
449                }
450            };
451            let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
452            std::thread::scope(|sc| {
453                sc.spawn(|| run_tagged_tail(console_log, "stdout", true, cap, stop, emit));
454                sc.spawn(|| run_tagged_tail(&err_log, "stderr", true, cap, stop, emit));
455            });
456        }
457        _ => {}
458    }
459}
460
461/// A file writer that rotates (and gzips) when the file exceeds `max_size`.
462struct RotatingWriter {
463    path: PathBuf,
464    file: std::fs::File,
465    written: u64,
466    max_size: u64,
467    max_file: u32,
468}
469
470impl RotatingWriter {
471    fn new(path: &Path, max_size: u64, max_file: u32) -> std::io::Result<Self> {
472        let file = std::fs::OpenOptions::new()
473            .create(true)
474            .append(true)
475            .open(path)?;
476        let written = file.metadata()?.len();
477        Ok(Self {
478            path: path.to_path_buf(),
479            file,
480            written,
481            max_size,
482            max_file,
483        })
484    }
485
486    fn write_line(&mut self, line: &str) -> std::io::Result<()> {
487        let bytes = format!("{line}\n");
488        self.file.write_all(bytes.as_bytes())?;
489        self.file.flush()?;
490        self.written += bytes.len() as u64;
491        if self.written >= self.max_size {
492            self.rotate()?;
493        }
494        Ok(())
495    }
496
497    fn rotate(&mut self) -> std::io::Result<()> {
498        for i in (1..self.max_file).rev() {
499            let from = rotated_path(&self.path, i);
500            let to = rotated_path(&self.path, i + 1);
501            if from.exists() {
502                std::fs::rename(&from, &to)?;
503            }
504        }
505        let oldest = rotated_path(&self.path, self.max_file);
506        if oldest.exists() {
507            std::fs::remove_file(&oldest)?;
508        }
509        let rotated = rotated_path(&self.path, 1);
510        compress_file(&self.path, &rotated)?;
511        std::fs::remove_file(&self.path)?;
512        self.file = std::fs::OpenOptions::new()
513            .create(true)
514            .append(true)
515            .open(&self.path)?;
516        self.written = 0;
517        Ok(())
518    }
519}
520
521/// Compress a file with gzip.
522fn compress_file(src: &Path, dst: &Path) -> std::io::Result<()> {
523    use flate2::write::GzEncoder;
524    use flate2::Compression;
525    use std::io::Read;
526
527    let mut input = std::fs::File::open(src)?;
528    let output = std::fs::File::create(dst)?;
529    let mut encoder = GzEncoder::new(output, Compression::fast());
530    let mut buf = [0u8; 8192];
531    loop {
532        let n = input.read(&mut buf)?;
533        if n == 0 {
534            break;
535        }
536        encoder.write_all(&buf[..n])?;
537    }
538    encoder.finish()?;
539    Ok(())
540}
541
542/// Generate a rotated file path: container.json → container.json.1.gz
543fn rotated_path(base: &Path, index: u32) -> PathBuf {
544    let mut p = base.as_os_str().to_owned();
545    p.push(format!(".{index}.gz"));
546    PathBuf::from(p)
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn test_log_driver_from_str() {
555        assert_eq!(
556            "json-file".parse::<LogDriver>().unwrap(),
557            LogDriver::JsonFile
558        );
559        assert_eq!("syslog".parse::<LogDriver>().unwrap(), LogDriver::Syslog);
560        assert_eq!("none".parse::<LogDriver>().unwrap(), LogDriver::None);
561        assert!("unknown".parse::<LogDriver>().is_err());
562    }
563
564    #[test]
565    fn test_log_config_defaults() {
566        let config = LogConfig::default();
567        assert_eq!(config.driver, LogDriver::JsonFile);
568        assert_eq!(config.max_size(), 10 * 1024 * 1024);
569        assert_eq!(config.max_file(), 3);
570    }
571
572    #[test]
573    fn test_log_config_custom_options() {
574        let mut config = LogConfig::default();
575        config
576            .options
577            .insert("max-size".to_string(), "50m".to_string());
578        config
579            .options
580            .insert("max-file".to_string(), "5".to_string());
581        assert_eq!(config.max_size(), 50 * 1024 * 1024);
582        assert_eq!(config.max_file(), 5);
583    }
584
585    #[test]
586    fn test_parse_size() {
587        assert_eq!(parse_size("1024").unwrap(), 1024);
588        assert_eq!(parse_size("10m").unwrap(), 10 * 1024 * 1024);
589        assert_eq!(parse_size("1g").unwrap(), 1024 * 1024 * 1024);
590        assert_eq!(parse_size("512k").unwrap(), 512 * 1024);
591        assert!(parse_size("abc").is_err());
592    }
593
594    #[test]
595    fn test_log_entry_serialization() {
596        let entry = LogEntry {
597            log: "hello\n".to_string(),
598            stream: "stdout".to_string(),
599            time: "2026-02-12T06:00:00.000000000Z".to_string(),
600        };
601        let json = serde_json::to_string(&entry).unwrap();
602        assert!(json.contains("\"log\":\"hello\\n\""));
603        assert!(json.contains("\"stream\":\"stdout\""));
604    }
605
606    #[test]
607    fn test_syslog_config_defaults() {
608        let config = LogConfig {
609            driver: LogDriver::Syslog,
610            options: HashMap::new(),
611        };
612        assert_eq!(config.syslog_address(), "udp://localhost:514");
613        assert_eq!(config.syslog_facility(), "daemon");
614        assert_eq!(config.tag(), None);
615    }
616
617    #[test]
618    fn test_syslog_config_custom() {
619        let mut options = HashMap::new();
620        options.insert(
621            "syslog-address".to_string(),
622            "tcp://loghost:1514".to_string(),
623        );
624        options.insert("syslog-facility".to_string(), "local0".to_string());
625        options.insert("tag".to_string(), "myapp".to_string());
626        let config = LogConfig {
627            driver: LogDriver::Syslog,
628            options,
629        };
630        assert_eq!(config.syslog_address(), "tcp://loghost:1514");
631        assert_eq!(config.syslog_facility(), "local0");
632        assert_eq!(config.tag(), Some("myapp"));
633    }
634
635    #[test]
636    fn test_log_driver_display() {
637        assert_eq!(LogDriver::JsonFile.to_string(), "json-file");
638        assert_eq!(LogDriver::Syslog.to_string(), "syslog");
639        assert_eq!(LogDriver::None.to_string(), "none");
640    }
641
642    #[test]
643    fn test_log_driver_serde_roundtrip() {
644        let driver = LogDriver::Syslog;
645        let json = serde_json::to_string(&driver).unwrap();
646        assert_eq!(json, "\"syslog\"");
647        let parsed: LogDriver = serde_json::from_str(&json).unwrap();
648        assert_eq!(parsed, LogDriver::Syslog);
649    }
650
651    #[test]
652    fn test_tail_next_line_returns_complete_lines() {
653        use std::io::Cursor;
654        // Two complete lines (CRLF then LF) returned newline-stripped; a third
655        // read at EOF with stop=true returns None (VM exited, console drained).
656        let mut reader = BufReader::new(Cursor::new(b"alpha\r\nbeta\n".to_vec()));
657        let mut buf = String::new();
658        let stop = AtomicBool::new(true);
659        assert_eq!(
660            tail_next_line(&mut reader, &mut buf, &stop, None),
661            Some("alpha".to_string())
662        );
663        assert_eq!(
664            tail_next_line(&mut reader, &mut buf, &stop, None),
665            Some("beta".to_string())
666        );
667        assert_eq!(tail_next_line(&mut reader, &mut buf, &stop, None), None);
668        assert!(buf.is_empty());
669    }
670
671    #[test]
672    fn test_tail_next_line_flushes_trailing_partial_on_stop() {
673        use std::io::Cursor;
674        // A final line without a trailing newline is still flushed once when the
675        // VM has exited (stop=true) — no dropped last line.
676        let mut reader = BufReader::new(Cursor::new(b"only-partial".to_vec()));
677        let mut buf = String::new();
678        let stop = AtomicBool::new(true);
679        assert_eq!(
680            tail_next_line(&mut reader, &mut buf, &stop, None),
681            Some("only-partial".to_string())
682        );
683        assert_eq!(tail_next_line(&mut reader, &mut buf, &stop, None), None);
684    }
685
686    #[test]
687    fn test_console_truncate_if_over_only_when_over_cap() {
688        let dir = tempfile::tempdir().unwrap();
689        let path = dir.path().join("c.log");
690        std::fs::write(&path, b"hello").unwrap(); // 5 bytes
691
692        assert!(!console_truncate_if_over(&path, 10)); // under cap → untouched
693        assert_eq!(std::fs::metadata(&path).unwrap().len(), 5);
694
695        assert!(console_truncate_if_over(&path, 4)); // over cap → truncated
696        assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
697
698        // Missing file: false, no panic.
699        assert!(!console_truncate_if_over(&dir.path().join("nope"), 0));
700    }
701
702    #[test]
703    fn test_run_tagged_tail_truncates_over_cap_and_keeps_emitting() {
704        use std::sync::{Arc, Mutex};
705        use std::time::Duration;
706
707        let dir = tempfile::tempdir().unwrap();
708        let path = dir.path().join("console.log");
709        // Pre-fill past a tiny cap with three complete lines.
710        std::fs::write(&path, b"l1\nl2\nl3\n").unwrap();
711        let cap = 4u64;
712
713        let collected = Arc::new(Mutex::new(Vec::<String>::new()));
714        let stop = Arc::new(AtomicBool::new(false));
715        let (c2, s2, p2) = (collected.clone(), stop.clone(), path.clone());
716        let handle = std::thread::spawn(move || {
717            let emit = move |line: &str, _stream: &str| c2.lock().unwrap().push(line.to_string());
718            let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
719            run_tagged_tail(&p2, "stdout", false, Some(cap), &s2, emit);
720        });
721
722        // Let the tail drain l1..l3, hit EOF, and truncate (9 bytes > cap 4).
723        std::thread::sleep(Duration::from_millis(300));
724        // libkrun-style O_APPEND write after the truncation.
725        {
726            use std::io::Write as _;
727            let mut f = std::fs::OpenOptions::new()
728                .append(true)
729                .open(&path)
730                .unwrap();
731            f.write_all(b"l4\nl5\n").unwrap();
732        }
733        std::thread::sleep(Duration::from_millis(300));
734        stop.store(true, Ordering::Relaxed);
735        handle.join().unwrap();
736
737        let got = collected.lock().unwrap().clone();
738        // No data lost across the truncation: pre- and post-truncation lines both emit.
739        for line in ["l1", "l3", "l4", "l5"] {
740            assert!(got.contains(&line.to_string()), "missing {line} in {got:?}");
741        }
742        // And the raw file stayed bounded (truncated, not left at full history).
743        let final_len = std::fs::metadata(&path).unwrap().len();
744        assert!(
745            final_len <= cap + 6,
746            "console.log unbounded: {final_len} bytes"
747        );
748    }
749
750    #[test]
751    fn test_none_driver_still_bounds_console() {
752        use std::sync::Arc;
753        use std::time::Duration;
754
755        let dir = tempfile::tempdir().unwrap();
756        let console = dir.path().join("console.log");
757        std::fs::write(&console, b"l1\nl2\nl3\n").unwrap(); // 9 bytes
758        std::fs::write(dir.path().join("console.err.log"), b"").unwrap();
759
760        // none driver, tiny cap (max_size 4 * max_file 1).
761        let mut options = HashMap::new();
762        options.insert("max-size".to_string(), "4".to_string());
763        options.insert("max-file".to_string(), "1".to_string());
764        let config = LogConfig {
765            driver: LogDriver::None,
766            options,
767        };
768
769        let stop = Arc::new(AtomicBool::new(false));
770        let (s2, c2, d2) = (stop.clone(), console.clone(), dir.path().to_path_buf());
771        let handle = std::thread::spawn(move || run_log_processor(&c2, &d2, &config, &s2));
772
773        std::thread::sleep(Duration::from_millis(300));
774        stop.store(true, Ordering::Relaxed);
775        handle.join().unwrap();
776
777        // The raw console was bounded even though `none` produces no output, and
778        // no container.json was written.
779        assert!(std::fs::metadata(&console).unwrap().len() <= 4);
780        assert!(!dir.path().join("container.json").exists());
781    }
782
783    #[test]
784    fn test_is_runtime_console_noise() {
785        assert!(is_runtime_console_noise("init.krun: mount_filesystems ok"));
786        assert!(!is_runtime_console_noise("L1"));
787        assert!(!is_runtime_console_noise(
788            "starting app (init.krun: ignored)"
789        ));
790        assert!(!is_runtime_console_noise(""));
791    }
792
793    #[test]
794    fn test_run_json_file_processor_captures_all_lines_after_stop() {
795        // The processor must emit a record for EVERY console line, then stop
796        // cleanly once the VM has exited (stop=true). The original bug dropped
797        // every line logged after the first EOF (here: BBB after a quiet line).
798        let dir = tempfile::tempdir().unwrap();
799        let console = dir.path().join("console.log");
800        std::fs::write(&console, "AAA\ninit.krun: noise\nBBB\n").unwrap();
801        let stop = AtomicBool::new(true);
802        run_json_file_processor(&console, dir.path(), 10 * 1024 * 1024, 3, &stop);
803        let json = std::fs::read_to_string(json_log_path(dir.path())).unwrap();
804        assert!(json.contains("\"log\":\"AAA\\n\""), "AAA missing: {json}");
805        assert!(
806            json.contains("\"log\":\"BBB\\n\""),
807            "BBB (after a quiet line) missing: {json}"
808        );
809        assert!(
810            !json.contains("init.krun"),
811            "runtime noise must be filtered: {json}"
812        );
813    }
814
815    #[test]
816    fn test_rotating_writer_rotates_and_gzips() {
817        let dir = tempfile::tempdir().unwrap();
818        let path = dir.path().join("container.json");
819        let mut w = RotatingWriter::new(&path, 20, 3).unwrap();
820        for i in 0..10 {
821            w.write_line(&format!("line-{i}")).unwrap();
822        }
823        assert!(
824            rotated_path(&path, 1).exists(),
825            "expected a rotated .1.gz file"
826        );
827    }
828}