Skip to main content

ferridriver_script/
console.rs

1//! Captured `console.*` output with size limits.
2//!
3//! The engine installs a `console` global inside every script context. Each
4//! call (`console.log`, `.info`, `.warn`, `.error`, `.debug`) pushes an entry
5//! into a shared `ConsoleCapture`. Output is bounded by three limits:
6//!
7//! - max entries (count-based),
8//! - max total bytes (sum of message lengths),
9//! - max per-entry bytes (individual `message` truncation).
10//!
11//! When a limit is hit, a single `system`-level entry is appended noting
12//! truncation and no further entries are recorded.
13
14use std::sync::Mutex;
15use std::time::Instant;
16
17use crate::result::{ConsoleEntry, ConsoleLevel};
18
19/// Thread-safe capture buffer.
20///
21/// Shared between the JS context (via `Arc<ConsoleCapture>`) and the engine,
22/// which drains the buffer into the final `ScriptResult` after the script
23/// completes.
24pub struct ConsoleCapture {
25  max_entries: usize,
26  max_total_bytes: usize,
27  max_entry_bytes: usize,
28  started: Instant,
29  inner: Mutex<ConsoleInner>,
30}
31
32struct ConsoleInner {
33  entries: Vec<ConsoleEntry>,
34  total_bytes: usize,
35  truncated: bool,
36}
37
38impl ConsoleCapture {
39  #[must_use]
40  pub fn new(max_entries: usize, max_total_bytes: usize, max_entry_bytes: usize) -> Self {
41    Self {
42      max_entries,
43      max_total_bytes,
44      max_entry_bytes,
45      started: Instant::now(),
46      inner: Mutex::new(ConsoleInner {
47        entries: Vec::new(),
48        total_bytes: 0,
49        truncated: false,
50      }),
51    }
52  }
53
54  /// Record one entry.
55  ///
56  /// `message` is clamped to `max_entry_bytes`, and the entry is only
57  /// appended if both the count and total-byte budgets still allow it.
58  /// Once any budget is exceeded, a single `system` entry is appended
59  /// noting truncation and all further calls are silently dropped.
60  pub fn push(&self, level: ConsoleLevel, message: impl Into<String>) {
61    let mut message = message.into();
62    if message.len() > self.max_entry_bytes {
63      message.truncate(self.max_entry_bytes);
64      message.push('…');
65    }
66
67    let Ok(mut inner) = self.inner.lock() else {
68      return;
69    };
70
71    if inner.truncated {
72      return;
73    }
74
75    let would_exceed_count = inner.entries.len() >= self.max_entries;
76    let would_exceed_bytes = inner.total_bytes.saturating_add(message.len()) > self.max_total_bytes;
77
78    if would_exceed_count || would_exceed_bytes {
79      inner.entries.push(ConsoleEntry {
80        level: ConsoleLevel::System,
81        message: "console capture truncated: limits exceeded".to_string(),
82        ts_ms: self.started.elapsed().as_millis() as u64,
83      });
84      inner.truncated = true;
85      return;
86    }
87
88    inner.total_bytes = inner.total_bytes.saturating_add(message.len());
89    inner.entries.push(ConsoleEntry {
90      level,
91      message,
92      ts_ms: self.started.elapsed().as_millis() as u64,
93    });
94  }
95
96  /// Drain the captured entries.
97  ///
98  /// Returns `Vec::new()` if the mutex is poisoned — we prefer silent data
99  /// loss over panicking because the engine has no recovery path.
100  #[must_use]
101  pub fn drain(&self) -> Vec<ConsoleEntry> {
102    self
103      .inner
104      .lock()
105      .map(|mut inner| std::mem::take(&mut inner.entries))
106      .unwrap_or_default()
107  }
108
109  /// Milliseconds since capture was created; used for `ts_ms` in entries.
110  #[must_use]
111  pub fn elapsed_ms(&self) -> u64 {
112    self.started.elapsed().as_millis() as u64
113  }
114}
115
116/// Strip ANSI escape sequences from a captured message so malicious page
117/// content (or legitimate page `console.log` bridged through) cannot poison
118/// logs with terminal control codes.
119#[must_use]
120pub fn strip_ansi(input: &str) -> String {
121  let mut out = String::with_capacity(input.len());
122  let mut chars = input.chars().peekable();
123  while let Some(c) = chars.next() {
124    if c == '\x1b' && chars.peek() == Some(&'[') {
125      chars.next();
126      for nc in chars.by_ref() {
127        if ('@'..='~').contains(&nc) {
128          break;
129        }
130      }
131    } else {
132      out.push(c);
133    }
134  }
135  out
136}
137
138#[cfg(test)]
139mod tests {
140  use super::*;
141
142  #[test]
143  fn strip_ansi_removes_color_codes() {
144    assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red");
145    assert_eq!(strip_ansi("\x1b[1;34mbold blue\x1b[0m"), "bold blue");
146    assert_eq!(strip_ansi("plain"), "plain");
147  }
148
149  #[test]
150  fn capture_respects_entry_limit() {
151    let cap = ConsoleCapture::new(3, 10_000, 1000);
152    for i in 0..5 {
153      cap.push(ConsoleLevel::Log, format!("line {i}"));
154    }
155    let entries = cap.drain();
156    // 3 real + 1 truncation system entry
157    assert_eq!(entries.len(), 4);
158    assert_eq!(entries[3].level, ConsoleLevel::System);
159  }
160
161  #[test]
162  fn capture_respects_byte_limit() {
163    let cap = ConsoleCapture::new(100, 20, 100);
164    cap.push(ConsoleLevel::Log, "a".repeat(15));
165    cap.push(ConsoleLevel::Log, "b".repeat(15));
166    let entries = cap.drain();
167    // First fits (15 <= 20), second would exceed (15+15=30 > 20) so truncation fires.
168    assert_eq!(entries.len(), 2);
169    assert_eq!(entries[1].level, ConsoleLevel::System);
170  }
171
172  #[test]
173  fn capture_truncates_long_entry() {
174    let cap = ConsoleCapture::new(10, 10_000, 5);
175    cap.push(ConsoleLevel::Log, "abcdefgh");
176    let entries = cap.drain();
177    assert_eq!(entries.len(), 1);
178    assert!(entries[0].message.starts_with("abcde"));
179    assert!(entries[0].message.ends_with('…'));
180  }
181}