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, Eq, 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, PartialEq, Eq, 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, Clone, PartialEq, Eq, 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/// Schema used to hand one Sandbox log worker its immutable generation data.
122pub const SANDBOX_LOG_WORKER_SCHEMA: &str = "a3s.box.sandbox-log-worker.v1";
123
124/// Configuration for the host process that projects Sandbox stdout/stderr into
125/// the configured logging driver after the launching client has detached.
126///
127/// The worker watches the exact A3S OCI owner PID identity. Once that process
128/// exits, both inherited output descriptors are closed and EOF is authoritative,
129/// so the worker can drain the final bytes without a fixed late-write delay.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct SandboxLogWorkerSpec {
132    pub schema: String,
133    pub box_id: String,
134    pub console_log: PathBuf,
135    pub log_config: LogConfig,
136    pub watched_pid: u32,
137    pub watched_pid_start_time: u64,
138    pub ready_file: PathBuf,
139}
140
141/// Schema used to hand one managed OCI init-log projector its exact route.
142pub const MANAGED_OCI_LOG_WORKER_SCHEMA: &str = "a3s.box.managed-oci-log-worker.v1";
143
144/// Platform-local OCI Runtime endpoint consumed by the detached log worker.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(tag = "kind", rename_all = "kebab-case")]
147pub enum ManagedOciLogEndpoint {
148    UnixSocket { path: PathBuf },
149    WindowsNamedPipe { name: String },
150}
151
152/// Immutable identity and product logging policy for one managed OCI init
153/// output projection.
154///
155/// The worker is a Box-owned auxiliary process. It reads raw output through
156/// the public OCI SDK, writes the conventional split console files, and feeds
157/// those files into Box's existing retention/redaction/log-driver boundary.
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct ManagedOciLogWorkerSpec {
160    pub schema: String,
161    pub box_id: String,
162    pub execution_generation: u64,
163    pub endpoint: ManagedOciLogEndpoint,
164    pub runtime_container_id: String,
165    pub runtime_generation: u64,
166    pub console_log: PathBuf,
167    pub log_config: LogConfig,
168    pub ready_file: PathBuf,
169    pub drained_file: PathBuf,
170}
171
172/// Generation-fenced readiness or drain evidence emitted by a managed OCI log
173/// worker. Linux includes a process start-time token so a stale PID cannot be
174/// mistaken for the original worker.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct ManagedOciLogWorkerMarker {
177    pub schema: String,
178    pub box_id: String,
179    pub execution_generation: u64,
180    pub runtime_container_id: String,
181    pub runtime_generation: u64,
182    pub pid: u32,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub pid_start_time: Option<u64>,
185}
186
187/// Parse a human-readable size string (e.g., "10m", "1g", "4096") into bytes.
188fn parse_size(s: &str) -> std::result::Result<u64, String> {
189    let s = s.trim().to_lowercase();
190    if let Ok(n) = s.parse::<u64>() {
191        return Ok(n);
192    }
193    let (num, mult) = if s.ends_with("gb") || s.ends_with('g') {
194        (
195            s.trim_end_matches("gb").trim_end_matches('g'),
196            1024u64 * 1024 * 1024,
197        )
198    } else if s.ends_with("mb") || s.ends_with('m') {
199        (
200            s.trim_end_matches("mb").trim_end_matches('m'),
201            1024u64 * 1024,
202        )
203    } else if s.ends_with("kb") || s.ends_with('k') {
204        (s.trim_end_matches("kb").trim_end_matches('k'), 1024u64)
205    } else if s.ends_with('b') {
206        (s.trim_end_matches('b'), 1u64)
207    } else {
208        return Err(format!("unrecognized size format: {s}"));
209    };
210    let n: u64 = num.parse().map_err(|_| format!("invalid number: {num}"))?;
211    Ok(n * mult)
212}
213
214// ===========================================================================
215// Log processor — tails the VM console (`console.log`) and produces structured
216// Docker-compatible output (`container.json`) or forwards to syslog.
217//
218// This runs in the SHIM (the box's own per-process lifetime), not the ephemeral
219// CLI: the CLI exits on `run -d` detach, which would kill an in-CLI processor
220// and truncate the logs. The shim writes `console.log` and lives exactly as
221// long as the VM, so it is the correct, daemonless home (like containerd-shim).
222// ===========================================================================
223
224use std::io::{BufRead, BufReader, Seek, Write};
225use std::path::{Path, PathBuf};
226use std::sync::atomic::{AtomicBool, Ordering};
227
228#[cfg(target_os = "windows")]
229type ConsoleFileIdentity = crate::windows_file::WindowsFileIdentity;
230#[cfg(not(target_os = "windows"))]
231type ConsoleFileIdentity = ();
232
233/// Whether the producer may still publish bytes after its apparent exit.
234///
235/// libkrun can return before its console backend's final host write becomes
236/// visible, whereas a reaped A3S OCI owner has already closed stdout and
237/// stderr. Keeping the distinction explicit avoids imposing the MicroVM's
238/// half-second settle window on every short Sandbox execution.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum ConsoleEofPolicy {
241    MayReceiveLateWrites,
242    WriterClosed,
243}
244
245/// Truncate `path` to empty if it has grown past `cap` bytes; returns whether it
246/// truncated.
247///
248/// libkrun appends the guest console to the raw `console.log`/`console.err.log`
249/// for the VM's entire lifetime, so a chatty long-running box grows them without
250/// limit — only the rotated `container.json` was ever bounded. The tail loop
251/// calls this at a clean line boundary (every line so far is already durable in
252/// `container.json`), so truncation never drops queryable log data. libkrun
253/// holds the file `O_APPEND`, so its next write resumes at offset 0 — no hole.
254fn console_truncate_if_over(
255    path: &Path,
256    cap: u64,
257    expected_identity: Option<ConsoleFileIdentity>,
258) -> bool {
259    #[cfg(not(target_os = "windows"))]
260    let _ = expected_identity;
261
262    #[cfg(target_os = "windows")]
263    let file = crate::windows_file::open_regular_file_for_write(path, expected_identity)
264        .map(|(file, _)| file);
265    #[cfg(not(target_os = "windows"))]
266    let file = std::fs::OpenOptions::new().write(true).open(path);
267
268    let Ok(file) = file else {
269        return false;
270    };
271    if file
272        .metadata()
273        .map_or(true, |metadata| metadata.len() <= cap)
274    {
275        return false;
276    }
277    if file.set_len(0).is_ok() {
278        tracing::debug!(path = %path.display(), cap, "console log exceeded cap; truncated");
279        true
280    } else {
281        false
282    }
283}
284
285/// Path to the structured JSON log file inside a box's log dir.
286pub fn json_log_path(log_dir: &Path) -> PathBuf {
287    log_dir.join("container.json")
288}
289
290/// The phase-aware filter for libkrun's C-init console preamble.
291///
292/// C-init emits a small, fixed set of diagnostics before calling `execvp`.
293/// stdout and stderr must share one instance: the `execvp(...) starting` line
294/// can arrive on either stream and permanently ends filtering for both. Once
295/// that sentinel has been observed, every subsequent line is workload output,
296/// even if it has the same text as a preamble line.
297#[derive(Debug)]
298pub struct RuntimeConsoleFilter {
299    preamble_active: AtomicBool,
300}
301
302impl RuntimeConsoleFilter {
303    pub fn new() -> Self {
304        Self {
305            preamble_active: AtomicBool::new(true),
306        }
307    }
308
309    /// Return whether `line` should be exposed as workload output.
310    ///
311    /// This method expects a complete logical line. Byte-stream callers must
312    /// retain an unterminated final fragment rather than classify it.
313    pub fn keep_line(&self, line: &str) -> bool {
314        if !self.preamble_active.load(Ordering::Acquire) {
315            return true;
316        }
317
318        match classify_runtime_console_line(line) {
319            RuntimeConsoleLineKind::Workload => true,
320            RuntimeConsoleLineKind::Preamble => {
321                // A sentinel on the companion stream may have ended the phase
322                // after our first load. Recheck so completed sentinel calls
323                // globally disable filtering.
324                !self.preamble_active.load(Ordering::Acquire)
325            }
326            RuntimeConsoleLineKind::EndPreamble => {
327                // Exactly one concurrent sentinel ends the phase and is
328                // hidden. A sentinel-shaped workload line after that is kept.
329                !self.preamble_active.swap(false, Ordering::AcqRel)
330            }
331        }
332    }
333
334    pub fn preamble_active(&self) -> bool {
335        self.preamble_active.load(Ordering::Acquire)
336    }
337}
338
339impl Default for RuntimeConsoleFilter {
340    fn default() -> Self {
341        Self::new()
342    }
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346enum RuntimeConsoleLineKind {
347    Workload,
348    Preamble,
349    EndPreamble,
350}
351
352fn classify_runtime_console_line(line: &str) -> RuntimeConsoleLineKind {
353    let line = line.trim_end_matches(['\n', '\r']);
354
355    if matches!(
356        line,
357        "init.krun: mount_filesystems ok"
358            | "init.krun: root propagation ok"
359            | "init.krun: tty/session configured"
360            | "init.krun: config parsed"
361            | "init.krun: setup_redirects ok"
362    ) {
363        return RuntimeConsoleLineKind::Preamble;
364    }
365
366    if line
367        .strip_prefix("init.krun: entered main argc=")
368        .is_some_and(is_ascii_decimal)
369    {
370        return RuntimeConsoleLineKind::Preamble;
371    }
372
373    if let Some(fields) = line.strip_prefix("init.krun: after cmdline env import KRUN_INIT=") {
374        if let Some((krun_init, fields)) = fields.split_once(" KRUN_INIT_PID1=") {
375            if let Some((krun_init_pid1, box_exec_exec)) = fields.split_once(" BOX_EXEC_EXEC=") {
376                if [krun_init, krun_init_pid1, box_exec_exec]
377                    .iter()
378                    .all(|value| !value.is_empty())
379                {
380                    return RuntimeConsoleLineKind::Preamble;
381                }
382            }
383        }
384    }
385
386    if let Some(selected) = line.strip_prefix("init.krun: selected exec=") {
387        if let Some((executable, init_pid1)) = selected.rsplit_once(" init_pid1=") {
388            if !executable.is_empty() && matches!(init_pid1, "0" | "1") {
389                return RuntimeConsoleLineKind::Preamble;
390            }
391        }
392    }
393
394    if line
395        .strip_prefix("init.krun: execvp(")
396        .and_then(|rest| rest.strip_suffix(") starting"))
397        .is_some_and(|executable| !executable.is_empty())
398    {
399        return RuntimeConsoleLineKind::EndPreamble;
400    }
401
402    RuntimeConsoleLineKind::Workload
403}
404
405fn is_ascii_decimal(value: &str) -> bool {
406    !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
407}
408
409/// True only for a line matching the known C-init preamble grammar.
410///
411/// This compatibility helper is phase-unaware. Consumers processing a stream
412/// should instead share one [`RuntimeConsoleFilter`] across stdout and stderr.
413pub fn is_runtime_console_noise(line: &str) -> bool {
414    classify_runtime_console_line(line) != RuntimeConsoleLineKind::Workload
415}
416
417/// Read the next COMPLETE line from a tailed `console.log`, returning it without
418/// the trailing newline. Polls on EOF like `tail -f` (so lines a container logs
419/// after a quiet period are not dropped), accumulating a partial line across
420/// reads. Returns `None` only when `stop` is set AND EOF is reached — i.e. the
421/// VM has exited and `console.log` is fully drained — flushing any final partial
422/// line as the last value before the subsequent `None`.
423fn tail_next_line_with_completeness<R: BufRead + Seek>(
424    reader: &mut R,
425    buf: &mut String,
426    stop: &AtomicBool,
427    on_eof: Option<&dyn Fn() -> bool>,
428    eof_policy: ConsoleEofPolicy,
429    reopen_at_eof: Option<&dyn Fn(u64) -> Option<(R, u64)>>,
430) -> Option<(String, bool)> {
431    // `krun_start_enter()` can return a few scheduler ticks before the
432    // virtio-console backend's final host write becomes visible. Treat the
433    // first stopped EOFs as provisional; otherwise a very short detached
434    // command can leave bytes in console.log after the processor has exited.
435    const STOPPED_EOF_SETTLE_MILLIS: u64 = 20;
436    let stopped_eof_settle_polls = stopped_eof_settle_polls(eof_policy);
437    let mut stopped_eof_polls = 0u8;
438    let mut refreshed_after_stop = false;
439    loop {
440        match reader.read_line(buf) {
441            Ok(0) | Err(_) => {
442                // Caught up at a clean line boundary (no partial line buffered):
443                // let the caller bound the file's growth. If it truncated, seek
444                // back to the start so reads don't sit forever past a stale EOF.
445                let mut position = reader.stream_position().ok();
446                if buf.is_empty() {
447                    if let Some(on_eof) = on_eof {
448                        if on_eof() {
449                            let _ = reader.seek(std::io::SeekFrom::Start(0));
450                            position = Some(0);
451                        }
452                    }
453                }
454
455                let stopping = stop.load(Ordering::Relaxed);
456
457                // Windows shared-filesystem producers can replace the path or
458                // append through a handle whose updates remain invisible to a
459                // reader already parked at EOF. Path metadata may be cached as
460                // well, so reopen unconditionally after each polling interval.
461                // On shutdown, perform a fresh-handle read before each
462                // late-write settle poll before declaring the source drained.
463                if let (Some(position), Some(reopen_at_eof)) = (position, reopen_at_eof) {
464                    if !(stopping && refreshed_after_stop) {
465                        if !stopping {
466                            std::thread::sleep(std::time::Duration::from_millis(100));
467                        }
468                        if let Some((mut replacement, replacement_position)) =
469                            reopen_at_eof(position)
470                        {
471                            if replacement_position != position {
472                                buf.clear();
473                            }
474                            std::mem::swap(reader, &mut replacement);
475                            refreshed_after_stop = stopping;
476                            continue;
477                        }
478                    }
479                }
480
481                if stopping {
482                    stopped_eof_polls = stopped_eof_polls.saturating_add(1);
483                    if stopped_eof_polls < stopped_eof_settle_polls {
484                        // A later poll must use another fresh Windows handle;
485                        // the producer may have replaced or appended the path.
486                        refreshed_after_stop = false;
487                        std::thread::sleep(std::time::Duration::from_millis(
488                            STOPPED_EOF_SETTLE_MILLIS,
489                        ));
490                        continue;
491                    }
492                    // The producer has stopped and the current path is drained:
493                    // flush a trailing partial line once, then finish.
494                    if buf.is_empty() {
495                        return None;
496                    }
497                    let line = std::mem::take(buf);
498                    return Some((line.trim_end_matches(['\n', '\r']).to_string(), false));
499                }
500                if reopen_at_eof.is_none() {
501                    std::thread::sleep(std::time::Duration::from_millis(100));
502                }
503                continue;
504            }
505            Ok(_) => {
506                stopped_eof_polls = 0;
507                refreshed_after_stop = false;
508            }
509        }
510        if !buf.ends_with('\n') {
511            // Partial line at EOF — keep it buffered and wait for the rest.
512            continue;
513        }
514        let line = std::mem::take(buf);
515        return Some((line.trim_end_matches(['\n', '\r']).to_string(), true));
516    }
517}
518
519#[cfg(test)]
520fn tail_next_line<R: BufRead + Seek>(
521    reader: &mut R,
522    buf: &mut String,
523    stop: &AtomicBool,
524    on_eof: Option<&dyn Fn() -> bool>,
525    eof_policy: ConsoleEofPolicy,
526    reopen_at_eof: Option<&dyn Fn(u64) -> Option<(R, u64)>>,
527) -> Option<String> {
528    tail_next_line_with_completeness(reader, buf, stop, on_eof, eof_policy, reopen_at_eof)
529        .map(|(line, _complete)| line)
530}
531
532fn stopped_eof_settle_polls(eof_policy: ConsoleEofPolicy) -> u8 {
533    const LATE_WRITE_POLLS: u8 = 25;
534    match eof_policy {
535        ConsoleEofPolicy::MayReceiveLateWrites => LATE_WRITE_POLLS,
536        ConsoleEofPolicy::WriterClosed => 1,
537    }
538}
539
540/// Run the log processor for a box, blocking until `stop` is set and the console
541/// is drained. Intended to run on a dedicated thread for the VM's lifetime; set
542/// `stop` after the VM exits, then join, to guarantee the final lines are
543/// captured (no teardown race).
544pub fn run_log_processor(
545    console_log: &Path,
546    log_dir: &Path,
547    config: &LogConfig,
548    stop: &AtomicBool,
549) {
550    run_log_processor_with_ready(console_log, log_dir, config, stop, None);
551}
552
553/// Run the processor and optionally count each console reader once it has
554/// opened its file. A VM launcher can wait for two ready readers before start,
555/// preventing a short guest from exiting before the tail threads are alive.
556pub fn run_log_processor_with_ready(
557    console_log: &Path,
558    log_dir: &Path,
559    config: &LogConfig,
560    stop: &AtomicBool,
561    ready: Option<&std::sync::atomic::AtomicUsize>,
562) {
563    run_log_processor_with_ready_and_eof_policy(
564        console_log,
565        log_dir,
566        config,
567        stop,
568        ready,
569        ConsoleEofPolicy::MayReceiveLateWrites,
570    );
571}
572
573/// Run the log processor with an explicit final-EOF policy.
574///
575/// Sandbox workers use [`ConsoleEofPolicy::WriterClosed`] only after the exact
576/// A3S OCI owner has exited. Other callers should retain the conservative
577/// default exposed by [`run_log_processor_with_ready`].
578pub fn run_log_processor_with_ready_and_eof_policy(
579    console_log: &Path,
580    log_dir: &Path,
581    config: &LogConfig,
582    stop: &AtomicBool,
583    ready: Option<&std::sync::atomic::AtomicUsize>,
584    eof_policy: ConsoleEofPolicy,
585) {
586    let stderr_log = stderr_console_path(console_log);
587    run_log_processor_streams_with_ready_and_eof_policy(
588        console_log,
589        &stderr_log,
590        log_dir,
591        config,
592        stop,
593        ready,
594        eof_policy,
595    );
596}
597
598/// Run the log processor against explicitly selected stdout and stderr files.
599///
600/// Most VMM backends write the conventional `console.log` and
601/// `console.err.log` pair, which [`run_log_processor`] discovers automatically.
602/// Backends that persist completed guest streams elsewhere can use this entry
603/// point to process exactly those files without replaying an older raw console.
604pub fn run_log_processor_streams(
605    stdout_log: &Path,
606    stderr_log: &Path,
607    log_dir: &Path,
608    config: &LogConfig,
609    stop: &AtomicBool,
610) {
611    run_log_processor_streams_with_ready_and_eof_policy(
612        stdout_log,
613        stderr_log,
614        log_dir,
615        config,
616        stop,
617        None,
618        ConsoleEofPolicy::WriterClosed,
619    );
620}
621
622/// Run the log processor against live, explicitly selected stdout and stderr
623/// files, optionally counting each reader after its source has been opened.
624///
625/// This is the explicit-stream counterpart to [`run_log_processor_with_ready`]
626/// and retains the conservative late-write settle policy for live producers.
627pub fn run_log_processor_streams_with_ready(
628    stdout_log: &Path,
629    stderr_log: &Path,
630    log_dir: &Path,
631    config: &LogConfig,
632    stop: &AtomicBool,
633    ready: Option<&std::sync::atomic::AtomicUsize>,
634) {
635    run_log_processor_streams_with_ready_and_eof_policy(
636        stdout_log,
637        stderr_log,
638        log_dir,
639        config,
640        stop,
641        ready,
642        ConsoleEofPolicy::MayReceiveLateWrites,
643    );
644}
645
646fn run_log_processor_streams_with_ready_and_eof_policy(
647    stdout_log: &Path,
648    stderr_log: &Path,
649    log_dir: &Path,
650    config: &LogConfig,
651    stop: &AtomicBool,
652    ready: Option<&std::sync::atomic::AtomicUsize>,
653    eof_policy: ConsoleEofPolicy,
654) {
655    match config.driver {
656        // `none` produces no structured output, but libkrun still writes the raw
657        // console for the VM's lifetime — drain + bound it so a chatty box with
658        // logging disabled doesn't fill the disk (same hazard as the other
659        // drivers).
660        LogDriver::None => run_discard_processor(
661            stdout_log,
662            stderr_log,
663            Some(console_cap(config.max_size(), config.max_file())),
664            stop,
665            ready,
666            eof_policy,
667        ),
668        LogDriver::JsonFile => run_json_file_processor(
669            stdout_log, stderr_log, log_dir, config, stop, ready, eof_policy,
670        ),
671        LogDriver::Syslog => {
672            run_syslog_processor(stdout_log, stderr_log, config, stop, ready, eof_policy)
673        }
674    }
675}
676
677/// Wait (bounded) for `console.log` to appear, then open it. Returns `None` if it
678/// never shows up or `stop` fires first.
679fn open_console(
680    console_log: &Path,
681    stop: &AtomicBool,
682) -> Option<(std::fs::File, ConsoleFileIdentity)> {
683    for _ in 0..300 {
684        #[cfg(target_os = "windows")]
685        match crate::windows_file::open_regular_file(console_log, None) {
686            Ok(opened) => return Some(opened),
687            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
688            Err(error) => {
689                tracing::warn!(path = %console_log.display(), %error, "Refusing unsafe Windows console source");
690                return None;
691            }
692        }
693        #[cfg(not(target_os = "windows"))]
694        match std::fs::File::open(console_log) {
695            Ok(file) => return Some((file, ())),
696            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
697            Err(_) => return None,
698        }
699        if stop.load(Ordering::Relaxed) && !console_log.exists() {
700            return None;
701        }
702        std::thread::sleep(std::time::Duration::from_millis(100));
703    }
704    None
705}
706
707#[cfg(target_os = "windows")]
708fn reopen_console(
709    console_log: &Path,
710    position: u64,
711    expected_identity: ConsoleFileIdentity,
712) -> Option<(BufReader<std::fs::File>, u64)> {
713    let (mut file, _) =
714        crate::windows_file::open_regular_file(console_log, Some(expected_identity)).ok()?;
715    let visible_len = file.seek(std::io::SeekFrom::End(0)).ok()?;
716    let replacement_position = if visible_len < position { 0 } else { position };
717    file.seek(std::io::SeekFrom::Start(replacement_position))
718        .ok()?;
719    Some((BufReader::new(file), replacement_position))
720}
721
722/// Tail console.log and write one Docker-style JSON record per container line.
723/// The stderr companion to `console.log` (libkrun's 3-fd console sends guest
724/// stderr here, stdout to `console.log`).
725pub fn stderr_console_path(console_log: &Path) -> PathBuf {
726    console_log.with_file_name("console.err.log")
727}
728
729/// Tail one console file, emitting each container line via `emit(line, stream)`.
730/// `runtime_filter` drops the strict libkrun C-init preamble. Both stream
731/// tailers share the same filter. Blocks until `stop` is set and the file is
732/// drained.
733#[derive(Clone, Copy)]
734struct TaggedTailOptions<'a> {
735    stream: &'static str,
736    runtime_filter: Option<&'a RuntimeConsoleFilter>,
737    bound: Option<u64>,
738    ready: Option<&'a std::sync::atomic::AtomicUsize>,
739    eof_policy: ConsoleEofPolicy,
740}
741
742fn run_tagged_tail(
743    file: &Path,
744    stop: &AtomicBool,
745    emit: &(dyn Fn(&str, &str) + Sync),
746    options: TaggedTailOptions<'_>,
747) {
748    let (f, identity) = match open_console(file, stop) {
749        Some(opened) => opened,
750        None => return,
751    };
752    if let Some(ready) = options.ready {
753        ready.fetch_add(1, Ordering::Release);
754    }
755    let mut reader = BufReader::new(f);
756    let mut buf = String::new();
757    // Bound the raw console file's growth at clean line boundaries (see
758    // console_truncate_if_over). None = unbounded (used by tests).
759    let truncate = options
760        .bound
761        .map(|cap| move || console_truncate_if_over(file, cap, Some(identity)));
762    let on_eof: Option<&dyn Fn() -> bool> = truncate.as_ref().map(|t| t as &dyn Fn() -> bool);
763    #[cfg(target_os = "windows")]
764    let reopen = |position| reopen_console(file, position, identity);
765    #[cfg(target_os = "windows")]
766    let reopen_at_eof = Some(&reopen as &dyn Fn(u64) -> Option<(BufReader<std::fs::File>, u64)>);
767    #[cfg(not(target_os = "windows"))]
768    let reopen_at_eof = None;
769
770    while let Some((line, complete)) = tail_next_line_with_completeness(
771        &mut reader,
772        &mut buf,
773        stop,
774        on_eof,
775        options.eof_policy,
776        reopen_at_eof,
777    ) {
778        if complete
779            && options
780                .runtime_filter
781                .is_some_and(|filter| !filter.keep_line(&line))
782        {
783            continue;
784        }
785        emit(&line, options.stream);
786    }
787}
788
789/// The raw `console.log`/`console.err.log` byte budget before the tail loop
790/// truncates it. Tied to the rotated `container.json` budget (`max_size *
791/// max_file`) so the raw console never outgrows the queryable log it feeds.
792fn console_cap(max_size: u64, max_file: u32) -> u64 {
793    max_size.saturating_mul(u64::from(max_file.max(1)))
794}
795
796/// Drain and bound the console for the `none` driver: tail both console files
797/// (advancing to clean line boundaries) and truncate when over `cap`, emitting
798/// nothing. Without this, `--log-driver none` would leave libkrun's raw
799/// `console.log`/`console.err.log` to grow without limit.
800fn run_discard_processor(
801    console_log: &Path,
802    err_log: &Path,
803    cap: Option<u64>,
804    stop: &AtomicBool,
805    ready: Option<&std::sync::atomic::AtomicUsize>,
806    eof_policy: ConsoleEofPolicy,
807) {
808    let discard = |_line: &str, _stream: &str| {};
809    let discard: &(dyn Fn(&str, &str) + Sync) = &discard;
810    std::thread::scope(|s| {
811        s.spawn(|| {
812            run_tagged_tail(
813                console_log,
814                stop,
815                discard,
816                TaggedTailOptions {
817                    stream: "stdout",
818                    runtime_filter: None,
819                    bound: cap,
820                    ready,
821                    eof_policy,
822                },
823            )
824        });
825        s.spawn(|| {
826            run_tagged_tail(
827                err_log,
828                stop,
829                discard,
830                TaggedTailOptions {
831                    stream: "stderr",
832                    runtime_filter: None,
833                    bound: cap,
834                    ready,
835                    eof_policy,
836                },
837            )
838        });
839    });
840}
841
842fn run_json_file_processor(
843    console_log: &Path,
844    err_log: &Path,
845    log_dir: &Path,
846    config: &LogConfig,
847    stop: &AtomicBool,
848    ready: Option<&std::sync::atomic::AtomicUsize>,
849    eof_policy: ConsoleEofPolicy,
850) {
851    let max_size = config.max_size();
852    let max_file = config.max_file();
853    let json_path = json_log_path(log_dir);
854    let writer = std::sync::Mutex::new(
855        match OrderedJsonWriter::new(&json_path, max_size, max_file) {
856            Ok(writer) => writer,
857            Err(_) => return,
858        },
859    );
860    // Write one tagged JSON record per line; shared by the stdout and stderr
861    // tail threads. Timestamp assignment is inside the same critical section
862    // as the append, so file order cannot invert timestamps across streams.
863    let emit = |line: &str, stream: &str| {
864        if let Ok(mut writer) = writer.lock() {
865            writer.write_entry(line, stream, chrono::Utc::now());
866        }
867    };
868    let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
869
870    let cap = Some(console_cap(max_size, max_file));
871    let runtime_filter = RuntimeConsoleFilter::new();
872    std::thread::scope(|s| {
873        s.spawn(|| {
874            run_tagged_tail(
875                console_log,
876                stop,
877                emit,
878                TaggedTailOptions {
879                    stream: "stdout",
880                    runtime_filter: Some(&runtime_filter),
881                    bound: cap,
882                    ready,
883                    eof_policy,
884                },
885            )
886        });
887        // libkrun's `init.krun:` preamble can land on EITHER stream, so filter
888        // the noise on stderr too.
889        s.spawn(|| {
890            run_tagged_tail(
891                err_log,
892                stop,
893                emit,
894                TaggedTailOptions {
895                    stream: "stderr",
896                    runtime_filter: Some(&runtime_filter),
897                    bound: cap,
898                    ready,
899                    eof_policy,
900                },
901            )
902        });
903    });
904}
905
906struct OrderedJsonWriter {
907    output: RotatingWriter,
908    last_timestamp: Option<chrono::DateTime<chrono::Utc>>,
909}
910
911impl OrderedJsonWriter {
912    fn new(path: &Path, max_size: u64, max_file: u32) -> std::io::Result<Self> {
913        Ok(Self {
914            output: RotatingWriter::new(path, max_size, max_file)?,
915            last_timestamp: None,
916        })
917    }
918
919    fn write_entry(&mut self, line: &str, stream: &str, timestamp: chrono::DateTime<chrono::Utc>) {
920        let timestamp = match &self.last_timestamp {
921            Some(previous) if previous > &timestamp => previous.to_owned(),
922            _ => timestamp,
923        };
924        let entry = LogEntry {
925            log: format!("{line}\n"),
926            stream: stream.to_string(),
927            time: timestamp.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true),
928        };
929        self.last_timestamp = Some(timestamp);
930        if let Ok(json) = serde_json::to_string(&entry) {
931            let _ = self.output.write_line(&json);
932        }
933    }
934}
935
936/// Forward both console streams (stdout + stderr) to a syslog endpoint.
937fn run_syslog_processor(
938    console_log: &Path,
939    err_log: &Path,
940    config: &LogConfig,
941    stop: &AtomicBool,
942    ready: Option<&std::sync::atomic::AtomicUsize>,
943    eof_policy: ConsoleEofPolicy,
944) {
945    use std::net::UdpSocket;
946
947    let address = config.syslog_address();
948    let _facility = config.syslog_facility();
949    let tag = config.tag().unwrap_or("a3s-box");
950    let cap = Some(console_cap(config.max_size(), config.max_file()));
951    let runtime_filter = RuntimeConsoleFilter::new();
952    let (proto, addr) = if let Some(rest) = address.strip_prefix("udp://") {
953        ("udp", rest)
954    } else if let Some(rest) = address.strip_prefix("tcp://") {
955        ("tcp", rest)
956    } else {
957        ("udp", address)
958    };
959    match proto {
960        "udp" => {
961            let socket = match UdpSocket::bind("0.0.0.0:0") {
962                Ok(s) => s,
963                Err(_) => return,
964            };
965            // RFC 3164: <priority>tag: message; daemon(3)*8 + info(6) = 30.
966            let emit = |line: &str, _stream: &str| {
967                let msg = format!("<30>{tag}: {line}");
968                let _ = socket.send_to(msg.as_bytes(), addr);
969            };
970            let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
971            std::thread::scope(|s| {
972                s.spawn(|| {
973                    run_tagged_tail(
974                        console_log,
975                        stop,
976                        emit,
977                        TaggedTailOptions {
978                            stream: "stdout",
979                            runtime_filter: Some(&runtime_filter),
980                            bound: cap,
981                            ready,
982                            eof_policy,
983                        },
984                    )
985                });
986                s.spawn(|| {
987                    run_tagged_tail(
988                        err_log,
989                        stop,
990                        emit,
991                        TaggedTailOptions {
992                            stream: "stderr",
993                            runtime_filter: Some(&runtime_filter),
994                            bound: cap,
995                            ready,
996                            eof_policy,
997                        },
998                    )
999                });
1000            });
1001        }
1002        "tcp" => {
1003            let stream = match std::net::TcpStream::connect(addr) {
1004                Ok(s) => std::sync::Mutex::new(s),
1005                Err(_) => return,
1006            };
1007            let emit = |line: &str, _stream: &str| {
1008                let msg = format!("<30>{tag}: {line}\n");
1009                if let Ok(mut s) = stream.lock() {
1010                    if s.write_all(msg.as_bytes()).is_err() {
1011                        if let Ok(news) = std::net::TcpStream::connect(addr) {
1012                            *s = news;
1013                            let _ = s.write_all(msg.as_bytes());
1014                        }
1015                    }
1016                }
1017            };
1018            let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
1019            std::thread::scope(|sc| {
1020                sc.spawn(|| {
1021                    run_tagged_tail(
1022                        console_log,
1023                        stop,
1024                        emit,
1025                        TaggedTailOptions {
1026                            stream: "stdout",
1027                            runtime_filter: Some(&runtime_filter),
1028                            bound: cap,
1029                            ready,
1030                            eof_policy,
1031                        },
1032                    )
1033                });
1034                sc.spawn(|| {
1035                    run_tagged_tail(
1036                        err_log,
1037                        stop,
1038                        emit,
1039                        TaggedTailOptions {
1040                            stream: "stderr",
1041                            runtime_filter: Some(&runtime_filter),
1042                            bound: cap,
1043                            ready,
1044                            eof_policy,
1045                        },
1046                    )
1047                });
1048            });
1049        }
1050        _ => {}
1051    }
1052}
1053
1054/// A file writer that rotates (and gzips) when the file exceeds `max_size`.
1055struct RotatingWriter {
1056    path: PathBuf,
1057    file: std::fs::File,
1058    written: u64,
1059    max_size: u64,
1060    max_file: u32,
1061}
1062
1063impl RotatingWriter {
1064    fn new(path: &Path, max_size: u64, max_file: u32) -> std::io::Result<Self> {
1065        let file = std::fs::OpenOptions::new()
1066            .create(true)
1067            .append(true)
1068            .open(path)?;
1069        let written = file.metadata()?.len();
1070        Ok(Self {
1071            path: path.to_path_buf(),
1072            file,
1073            written,
1074            max_size,
1075            max_file,
1076        })
1077    }
1078
1079    fn write_line(&mut self, line: &str) -> std::io::Result<()> {
1080        let bytes = format!("{line}\n");
1081        self.file.write_all(bytes.as_bytes())?;
1082        self.file.flush()?;
1083        self.written += bytes.len() as u64;
1084        if self.written >= self.max_size {
1085            self.rotate()?;
1086        }
1087        Ok(())
1088    }
1089
1090    fn rotate(&mut self) -> std::io::Result<()> {
1091        for i in (1..self.max_file).rev() {
1092            let from = rotated_path(&self.path, i);
1093            let to = rotated_path(&self.path, i + 1);
1094            if from.exists() {
1095                std::fs::rename(&from, &to)?;
1096            }
1097        }
1098        let oldest = rotated_path(&self.path, self.max_file);
1099        if oldest.exists() {
1100            std::fs::remove_file(&oldest)?;
1101        }
1102        let rotated = rotated_path(&self.path, 1);
1103        compress_file(&self.path, &rotated)?;
1104        std::fs::remove_file(&self.path)?;
1105        self.file = std::fs::OpenOptions::new()
1106            .create(true)
1107            .append(true)
1108            .open(&self.path)?;
1109        self.written = 0;
1110        Ok(())
1111    }
1112}
1113
1114/// Compress a file with gzip.
1115fn compress_file(src: &Path, dst: &Path) -> std::io::Result<()> {
1116    use flate2::write::GzEncoder;
1117    use flate2::Compression;
1118    use std::io::Read;
1119
1120    let mut input = std::fs::File::open(src)?;
1121    let output = std::fs::File::create(dst)?;
1122    let mut encoder = GzEncoder::new(output, Compression::fast());
1123    let mut buf = [0u8; 8192];
1124    loop {
1125        let n = input.read(&mut buf)?;
1126        if n == 0 {
1127            break;
1128        }
1129        encoder.write_all(&buf[..n])?;
1130    }
1131    encoder.finish()?;
1132    Ok(())
1133}
1134
1135/// Generate a rotated file path: container.json → container.json.1.gz
1136fn rotated_path(base: &Path, index: u32) -> PathBuf {
1137    let mut p = base.as_os_str().to_owned();
1138    p.push(format!(".{index}.gz"));
1139    PathBuf::from(p)
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144    use super::*;
1145
1146    #[test]
1147    fn test_log_driver_from_str() {
1148        assert_eq!(
1149            "json-file".parse::<LogDriver>().unwrap(),
1150            LogDriver::JsonFile
1151        );
1152        assert_eq!("syslog".parse::<LogDriver>().unwrap(), LogDriver::Syslog);
1153        assert_eq!("none".parse::<LogDriver>().unwrap(), LogDriver::None);
1154        assert!("unknown".parse::<LogDriver>().is_err());
1155    }
1156
1157    #[test]
1158    fn test_log_config_defaults() {
1159        let config = LogConfig::default();
1160        assert_eq!(config.driver, LogDriver::JsonFile);
1161        assert_eq!(config.max_size(), 10 * 1024 * 1024);
1162        assert_eq!(config.max_file(), 3);
1163    }
1164
1165    #[test]
1166    fn test_log_config_custom_options() {
1167        let mut config = LogConfig::default();
1168        config
1169            .options
1170            .insert("max-size".to_string(), "50m".to_string());
1171        config
1172            .options
1173            .insert("max-file".to_string(), "5".to_string());
1174        assert_eq!(config.max_size(), 50 * 1024 * 1024);
1175        assert_eq!(config.max_file(), 5);
1176    }
1177
1178    #[test]
1179    fn test_parse_size() {
1180        assert_eq!(parse_size("1024").unwrap(), 1024);
1181        assert_eq!(parse_size("10m").unwrap(), 10 * 1024 * 1024);
1182        assert_eq!(parse_size("1g").unwrap(), 1024 * 1024 * 1024);
1183        assert_eq!(parse_size("512k").unwrap(), 512 * 1024);
1184        assert!(parse_size("abc").is_err());
1185    }
1186
1187    #[test]
1188    fn test_log_entry_serialization() {
1189        let entry = LogEntry {
1190            log: "hello\n".to_string(),
1191            stream: "stdout".to_string(),
1192            time: "2026-02-12T06:00:00.000000000Z".to_string(),
1193        };
1194        let json = serde_json::to_string(&entry).unwrap();
1195        assert!(json.contains("\"log\":\"hello\\n\""));
1196        assert!(json.contains("\"stream\":\"stdout\""));
1197    }
1198
1199    #[test]
1200    fn sandbox_log_worker_spec_round_trips_generation_identity() {
1201        let spec = SandboxLogWorkerSpec {
1202            schema: SANDBOX_LOG_WORKER_SCHEMA.to_string(),
1203            box_id: "sandbox-id".to_string(),
1204            console_log: PathBuf::from("/tmp/sandbox-id/logs/console.log"),
1205            log_config: LogConfig::default(),
1206            watched_pid: 123,
1207            watched_pid_start_time: 456,
1208            ready_file: PathBuf::from("/tmp/sandbox-id/sandbox/log-worker.ready"),
1209        };
1210
1211        let encoded = serde_json::to_vec(&spec).unwrap();
1212        let decoded: SandboxLogWorkerSpec = serde_json::from_slice(&encoded).unwrap();
1213
1214        assert_eq!(decoded, spec);
1215    }
1216
1217    #[test]
1218    fn writer_closed_eof_skips_the_late_console_settle_window() {
1219        assert_eq!(
1220            stopped_eof_settle_polls(ConsoleEofPolicy::MayReceiveLateWrites),
1221            25
1222        );
1223        assert_eq!(stopped_eof_settle_polls(ConsoleEofPolicy::WriterClosed), 1);
1224    }
1225
1226    #[test]
1227    fn test_syslog_config_defaults() {
1228        let config = LogConfig {
1229            driver: LogDriver::Syslog,
1230            options: HashMap::new(),
1231        };
1232        assert_eq!(config.syslog_address(), "udp://localhost:514");
1233        assert_eq!(config.syslog_facility(), "daemon");
1234        assert_eq!(config.tag(), None);
1235    }
1236
1237    #[test]
1238    fn test_syslog_config_custom() {
1239        let mut options = HashMap::new();
1240        options.insert(
1241            "syslog-address".to_string(),
1242            "tcp://loghost:1514".to_string(),
1243        );
1244        options.insert("syslog-facility".to_string(), "local0".to_string());
1245        options.insert("tag".to_string(), "myapp".to_string());
1246        let config = LogConfig {
1247            driver: LogDriver::Syslog,
1248            options,
1249        };
1250        assert_eq!(config.syslog_address(), "tcp://loghost:1514");
1251        assert_eq!(config.syslog_facility(), "local0");
1252        assert_eq!(config.tag(), Some("myapp"));
1253    }
1254
1255    #[test]
1256    fn test_log_driver_display() {
1257        assert_eq!(LogDriver::JsonFile.to_string(), "json-file");
1258        assert_eq!(LogDriver::Syslog.to_string(), "syslog");
1259        assert_eq!(LogDriver::None.to_string(), "none");
1260    }
1261
1262    #[test]
1263    fn test_log_driver_serde_roundtrip() {
1264        let driver = LogDriver::Syslog;
1265        let json = serde_json::to_string(&driver).unwrap();
1266        assert_eq!(json, "\"syslog\"");
1267        let parsed: LogDriver = serde_json::from_str(&json).unwrap();
1268        assert_eq!(parsed, LogDriver::Syslog);
1269    }
1270
1271    #[test]
1272    fn test_tail_next_line_returns_complete_lines() {
1273        use std::io::Cursor;
1274        // Two complete lines (CRLF then LF) returned newline-stripped; a third
1275        // read at EOF with stop=true returns None (VM exited, console drained).
1276        let mut reader = BufReader::new(Cursor::new(b"alpha\r\nbeta\n".to_vec()));
1277        let mut buf = String::new();
1278        let stop = AtomicBool::new(true);
1279        assert_eq!(
1280            tail_next_line(
1281                &mut reader,
1282                &mut buf,
1283                &stop,
1284                None,
1285                ConsoleEofPolicy::MayReceiveLateWrites,
1286                None,
1287            ),
1288            Some("alpha".to_string())
1289        );
1290        assert_eq!(
1291            tail_next_line(
1292                &mut reader,
1293                &mut buf,
1294                &stop,
1295                None,
1296                ConsoleEofPolicy::MayReceiveLateWrites,
1297                None,
1298            ),
1299            Some("beta".to_string())
1300        );
1301        assert_eq!(
1302            tail_next_line(
1303                &mut reader,
1304                &mut buf,
1305                &stop,
1306                None,
1307                ConsoleEofPolicy::MayReceiveLateWrites,
1308                None,
1309            ),
1310            None
1311        );
1312        assert!(buf.is_empty());
1313    }
1314
1315    #[test]
1316    fn test_tail_next_line_flushes_trailing_partial_on_stop() {
1317        use std::io::Cursor;
1318        // A final line without a trailing newline is still flushed once when the
1319        // VM has exited (stop=true) — no dropped last line.
1320        let mut reader = BufReader::new(Cursor::new(b"only-partial".to_vec()));
1321        let mut buf = String::new();
1322        let stop = AtomicBool::new(true);
1323        assert_eq!(
1324            tail_next_line(
1325                &mut reader,
1326                &mut buf,
1327                &stop,
1328                None,
1329                ConsoleEofPolicy::MayReceiveLateWrites,
1330                None,
1331            ),
1332            Some("only-partial".to_string())
1333        );
1334        assert_eq!(
1335            tail_next_line(
1336                &mut reader,
1337                &mut buf,
1338                &stop,
1339                None,
1340                ConsoleEofPolicy::MayReceiveLateWrites,
1341                None,
1342            ),
1343            None
1344        );
1345    }
1346
1347    #[test]
1348    fn test_console_truncate_if_over_only_when_over_cap() {
1349        let dir = tempfile::tempdir().unwrap();
1350        let path = dir.path().join("c.log");
1351        std::fs::write(&path, b"hello").unwrap(); // 5 bytes
1352
1353        assert!(!console_truncate_if_over(&path, 10, None)); // under cap → untouched
1354        assert_eq!(std::fs::metadata(&path).unwrap().len(), 5);
1355
1356        assert!(console_truncate_if_over(&path, 4, None)); // over cap → truncated
1357        assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
1358
1359        // Missing file: false, no panic.
1360        assert!(!console_truncate_if_over(&dir.path().join("nope"), 0, None));
1361    }
1362
1363    #[test]
1364    fn test_run_tagged_tail_truncates_over_cap_and_keeps_emitting() {
1365        use std::sync::{Arc, Mutex};
1366        use std::time::Duration;
1367
1368        let dir = tempfile::tempdir().unwrap();
1369        let path = dir.path().join("console.log");
1370        // Pre-fill past a tiny cap with three complete lines.
1371        std::fs::write(&path, b"l1\nl2\nl3\n").unwrap();
1372        let cap = 4u64;
1373
1374        let collected = Arc::new(Mutex::new(Vec::<String>::new()));
1375        let stop = Arc::new(AtomicBool::new(false));
1376        let (c2, s2, p2) = (collected.clone(), stop.clone(), path.clone());
1377        let handle = std::thread::spawn(move || {
1378            let emit = move |line: &str, _stream: &str| c2.lock().unwrap().push(line.to_string());
1379            let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
1380            run_tagged_tail(
1381                &p2,
1382                &s2,
1383                emit,
1384                TaggedTailOptions {
1385                    stream: "stdout",
1386                    runtime_filter: None,
1387                    bound: Some(cap),
1388                    ready: None,
1389                    eof_policy: ConsoleEofPolicy::MayReceiveLateWrites,
1390                },
1391            );
1392        });
1393
1394        // Let the tail drain l1..l3, hit EOF, and truncate (9 bytes > cap 4).
1395        std::thread::sleep(Duration::from_millis(300));
1396        // libkrun-style O_APPEND write after the truncation.
1397        {
1398            use std::io::Write as _;
1399            let mut f = std::fs::OpenOptions::new()
1400                .append(true)
1401                .open(&path)
1402                .unwrap();
1403            f.write_all(b"l4\nl5\n").unwrap();
1404        }
1405        std::thread::sleep(Duration::from_millis(300));
1406        stop.store(true, Ordering::Relaxed);
1407        handle.join().unwrap();
1408
1409        let got = collected.lock().unwrap().clone();
1410        // No data lost across the truncation: pre- and post-truncation lines both emit.
1411        for line in ["l1", "l3", "l4", "l5"] {
1412            assert!(got.contains(&line.to_string()), "missing {line} in {got:?}");
1413        }
1414        // And the raw file stayed bounded (truncated, not left at full history).
1415        let final_len = std::fs::metadata(&path).unwrap().len();
1416        assert!(
1417            final_len <= cap + 6,
1418            "console.log unbounded: {final_len} bytes"
1419        );
1420    }
1421
1422    #[cfg(target_os = "windows")]
1423    #[test]
1424    fn test_run_tagged_tail_refuses_replaced_source_identity() {
1425        use std::sync::{Arc, Mutex};
1426        use std::time::Duration;
1427
1428        let dir = tempfile::tempdir().unwrap();
1429        let path = dir.path().join("guest-init.stdout.log");
1430        let retired = dir.path().join("guest-init.stdout.log.retired");
1431        std::fs::write(&path, b"").unwrap();
1432
1433        let collected = Arc::new(Mutex::new(Vec::<String>::new()));
1434        let stop = Arc::new(AtomicBool::new(false));
1435        let (c2, s2, p2) = (collected.clone(), stop.clone(), path.clone());
1436        let handle = std::thread::spawn(move || {
1437            let emit = move |line: &str, _stream: &str| c2.lock().unwrap().push(line.to_string());
1438            let emit: &(dyn Fn(&str, &str) + Sync) = &emit;
1439            run_tagged_tail(
1440                &p2,
1441                &s2,
1442                emit,
1443                TaggedTailOptions {
1444                    stream: "stdout",
1445                    runtime_filter: None,
1446                    bound: None,
1447                    ready: None,
1448                    eof_policy: ConsoleEofPolicy::MayReceiveLateWrites,
1449                },
1450            );
1451        });
1452
1453        // A guest may replace the path after the host tailer has pinned the
1454        // original handle. Reopening must never switch to the replacement.
1455        std::thread::sleep(Duration::from_millis(300));
1456        std::fs::rename(&path, &retired).unwrap();
1457        std::fs::write(&path, b"late-line\n").unwrap();
1458
1459        std::thread::sleep(Duration::from_millis(300));
1460
1461        stop.store(true, Ordering::Relaxed);
1462        handle.join().unwrap();
1463        let got = collected.lock().unwrap().clone();
1464        assert!(!got.iter().any(|line| line == "late-line"), "{got:?}");
1465    }
1466
1467    #[test]
1468    fn explicit_streams_with_ready_reports_both_open_readers() {
1469        use std::sync::Arc;
1470        use std::time::{Duration, Instant};
1471
1472        let dir = tempfile::tempdir().unwrap();
1473        let stdout = dir.path().join("guest.stdout.log");
1474        let stderr = dir.path().join("guest.stderr.log");
1475        std::fs::write(&stdout, b"").unwrap();
1476        std::fs::write(&stderr, b"").unwrap();
1477
1478        let stop = Arc::new(AtomicBool::new(false));
1479        let ready = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1480        let (thread_stop, thread_ready) = (Arc::clone(&stop), Arc::clone(&ready));
1481        let log_dir = dir.path().to_path_buf();
1482        let handle = std::thread::spawn(move || {
1483            run_log_processor_streams_with_ready(
1484                &stdout,
1485                &stderr,
1486                &log_dir,
1487                &LogConfig::default(),
1488                &thread_stop,
1489                Some(&thread_ready),
1490            );
1491        });
1492
1493        let deadline = Instant::now() + Duration::from_secs(2);
1494        while ready.load(Ordering::Acquire) < 2 && Instant::now() < deadline {
1495            std::thread::sleep(Duration::from_millis(10));
1496        }
1497        assert_eq!(ready.load(Ordering::Acquire), 2);
1498
1499        stop.store(true, Ordering::Release);
1500        handle.join().unwrap();
1501    }
1502
1503    #[test]
1504    fn test_stopped_tail_waits_for_delayed_final_console_write() {
1505        use std::io::Write as _;
1506
1507        let dir = tempfile::tempdir().unwrap();
1508        let path = dir.path().join("console.log");
1509        std::fs::write(&path, b"").unwrap();
1510        let writer_path = path.clone();
1511        let writer = std::thread::spawn(move || {
1512            std::thread::sleep(std::time::Duration::from_millis(30));
1513            let mut file = std::fs::OpenOptions::new()
1514                .append(true)
1515                .open(writer_path)
1516                .unwrap();
1517            file.write_all(b"late-final-line\n").unwrap();
1518            file.flush().unwrap();
1519        });
1520
1521        let file = std::fs::File::open(&path).unwrap();
1522        let mut reader = BufReader::new(file);
1523        let mut buffer = String::new();
1524        let stop = AtomicBool::new(true);
1525        assert_eq!(
1526            tail_next_line(
1527                &mut reader,
1528                &mut buffer,
1529                &stop,
1530                None,
1531                ConsoleEofPolicy::MayReceiveLateWrites,
1532                None,
1533            ),
1534            Some("late-final-line".to_string())
1535        );
1536        assert_eq!(
1537            tail_next_line(
1538                &mut reader,
1539                &mut buffer,
1540                &stop,
1541                None,
1542                ConsoleEofPolicy::MayReceiveLateWrites,
1543                None,
1544            ),
1545            None
1546        );
1547        writer.join().unwrap();
1548    }
1549
1550    #[test]
1551    fn test_none_driver_still_bounds_console() {
1552        use std::sync::Arc;
1553        use std::time::Duration;
1554
1555        let dir = tempfile::tempdir().unwrap();
1556        let console = dir.path().join("console.log");
1557        std::fs::write(&console, b"l1\nl2\nl3\n").unwrap(); // 9 bytes
1558        std::fs::write(dir.path().join("console.err.log"), b"").unwrap();
1559
1560        // none driver, tiny cap (max_size 4 * max_file 1).
1561        let mut options = HashMap::new();
1562        options.insert("max-size".to_string(), "4".to_string());
1563        options.insert("max-file".to_string(), "1".to_string());
1564        let config = LogConfig {
1565            driver: LogDriver::None,
1566            options,
1567        };
1568
1569        let stop = Arc::new(AtomicBool::new(false));
1570        let (s2, c2, d2) = (stop.clone(), console.clone(), dir.path().to_path_buf());
1571        let handle = std::thread::spawn(move || run_log_processor(&c2, &d2, &config, &s2));
1572
1573        std::thread::sleep(Duration::from_millis(300));
1574        stop.store(true, Ordering::Relaxed);
1575        handle.join().unwrap();
1576
1577        // The raw console was bounded even though `none` produces no output, and
1578        // no container.json was written.
1579        assert!(std::fs::metadata(&console).unwrap().len() <= 4);
1580        assert!(!dir.path().join("container.json").exists());
1581    }
1582
1583    #[test]
1584    fn test_is_runtime_console_noise() {
1585        assert!(is_runtime_console_noise("init.krun: mount_filesystems ok"));
1586        assert!(is_runtime_console_noise("init.krun: entered main argc=1"));
1587        assert!(is_runtime_console_noise(
1588            "init.krun: selected exec=/bin/app init_pid1=0"
1589        ));
1590        assert!(is_runtime_console_noise(
1591            "init.krun: execvp(/bin/app) starting"
1592        ));
1593        assert!(!is_runtime_console_noise("init.krun: business"));
1594        assert!(!is_runtime_console_noise(
1595            "init.krun: entered main argc=not-a-number"
1596        ));
1597        assert!(!is_runtime_console_noise(
1598            "init.krun: execvp(/bin/app) failed errno=2"
1599        ));
1600        assert!(!is_runtime_console_noise("L1"));
1601        assert!(!is_runtime_console_noise(
1602            "starting app (init.krun: ignored)"
1603        ));
1604        assert!(!is_runtime_console_noise(""));
1605    }
1606
1607    #[test]
1608    fn runtime_console_filter_shares_sentinel_phase_across_streams() {
1609        let filter = RuntimeConsoleFilter::new();
1610
1611        // Treat these calls as interleaved stdout/stderr records using the
1612        // same shared filter, as the structured log processor does.
1613        assert!(!filter.keep_line("init.krun: mount_filesystems ok"));
1614        assert!(filter.keep_line("init.krun: business"));
1615        assert!(!filter.keep_line("init.krun: execvp(/bin/app) starting"));
1616        assert!(!filter.preamble_active());
1617        assert!(filter.keep_line("init.krun: mount_filesystems ok"));
1618        assert!(filter.keep_line("init.krun: execvp(/bin/app) starting"));
1619        assert!(filter.keep_line("init.krun: execvp(/bin/app) failed errno=2"));
1620    }
1621
1622    #[test]
1623    fn test_run_json_file_processor_captures_all_lines_after_stop() {
1624        // The processor must emit a record for EVERY console line, then stop
1625        // cleanly once the VM has exited (stop=true). The original bug dropped
1626        // every line logged after the first EOF (here: BBB after a quiet line).
1627        let dir = tempfile::tempdir().unwrap();
1628        let console = dir.path().join("console.log");
1629        let stderr = dir.path().join("persisted-stderr.log");
1630        std::fs::write(
1631            &console,
1632            concat!(
1633                "init.krun: entered main argc=1\n",
1634                "init.krun: mount_filesystems ok\n",
1635                "init.krun: execvp(/bin/app) starting\n",
1636                "AAA\n",
1637                "init.krun: business\n",
1638                "BBB\n",
1639            ),
1640        )
1641        .unwrap();
1642        std::fs::write(&stderr, "ERR\n").unwrap();
1643        let stop = AtomicBool::new(true);
1644        run_log_processor_streams(&console, &stderr, dir.path(), &LogConfig::default(), &stop);
1645        let json = std::fs::read_to_string(json_log_path(dir.path())).unwrap();
1646        assert!(json.contains("\"log\":\"AAA\\n\""), "AAA missing: {json}");
1647        assert!(
1648            json.contains("\"log\":\"BBB\\n\""),
1649            "BBB (after a quiet line) missing: {json}"
1650        );
1651        assert!(
1652            json.contains("\"log\":\"ERR\\n\"") && json.contains("\"stream\":\"stderr\""),
1653            "custom stderr stream missing: {json}"
1654        );
1655        assert!(
1656            json.contains("\"log\":\"init.krun: business\\n\""),
1657            "generic init.krun workload output missing: {json}"
1658        );
1659        assert!(
1660            !json.contains("entered main"),
1661            "C-init noise leaked: {json}"
1662        );
1663        assert!(
1664            !json.contains("mount_filesystems ok"),
1665            "C-init noise leaked: {json}"
1666        );
1667        assert!(
1668            !json.contains("execvp(/bin/app) starting"),
1669            "C-init sentinel leaked: {json}"
1670        );
1671    }
1672
1673    #[test]
1674    fn test_run_json_file_processor_preserves_unterminated_prefix_line() {
1675        let dir = tempfile::tempdir().unwrap();
1676        let console = dir.path().join("console.log");
1677        let stderr = dir.path().join("console.err.log");
1678        std::fs::write(&console, "init.krun: mount_filesystems ok").unwrap();
1679        std::fs::write(&stderr, "").unwrap();
1680
1681        let stop = AtomicBool::new(true);
1682        run_log_processor_streams(&console, &stderr, dir.path(), &LogConfig::default(), &stop);
1683
1684        let json = std::fs::read_to_string(json_log_path(dir.path())).unwrap();
1685        assert!(
1686            json.contains("\"log\":\"init.krun: mount_filesystems ok\\n\""),
1687            "unterminated workload fragment was dropped: {json}"
1688        );
1689    }
1690
1691    #[test]
1692    fn ordered_json_writer_clamps_a_regressed_clock() {
1693        let dir = tempfile::tempdir().unwrap();
1694        let path = dir.path().join("container.json");
1695        let mut writer = OrderedJsonWriter::new(&path, 10 * 1024 * 1024, 3).unwrap();
1696        let newer = chrono::DateTime::parse_from_rfc3339("2026-07-19T12:00:01Z")
1697            .unwrap()
1698            .to_utc();
1699        let older = chrono::DateTime::parse_from_rfc3339("2026-07-19T12:00:00Z")
1700            .unwrap()
1701            .to_utc();
1702
1703        writer.write_entry("first", "stdout", newer);
1704        writer.write_entry("second", "stderr", older);
1705        drop(writer);
1706
1707        let entries = std::fs::read_to_string(path)
1708            .unwrap()
1709            .lines()
1710            .map(|line| serde_json::from_str::<LogEntry>(line).unwrap())
1711            .collect::<Vec<_>>();
1712        assert_eq!(entries.len(), 2);
1713        assert_eq!(entries[0].time, entries[1].time);
1714    }
1715
1716    #[test]
1717    fn test_rotating_writer_rotates_and_gzips() {
1718        let dir = tempfile::tempdir().unwrap();
1719        let path = dir.path().join("container.json");
1720        let mut w = RotatingWriter::new(&path, 20, 3).unwrap();
1721        for i in 0..10 {
1722            w.write_line(&format!("line-{i}")).unwrap();
1723        }
1724        assert!(
1725            rotated_path(&path, 1).exists(),
1726            "expected a rotated .1.gz file"
1727        );
1728    }
1729}