Skip to main content

mcpls_core/
util.rs

1//! Small helpers shared across `mcpls-core` modules.
2
3use std::borrow::Cow;
4use std::future::Future;
5use std::io::Read as _;
6use std::num::NonZeroU64;
7use std::panic::AssertUnwindSafe;
8use std::path::Path;
9use std::string::FromUtf8Error;
10use std::sync::{Mutex as StdMutex, MutexGuard, PoisonError};
11use std::time::{Duration, Instant};
12
13use futures::FutureExt as _;
14use tokio::task::JoinHandle;
15
16use crate::config::SizeLimit;
17
18/// A byte count that went over its limit.
19///
20/// The one shape of every "too large" error, so each reader reports the same
21/// two numbers and converts it without re-spelling the fields. Only
22/// [`Self::check`] and [`Self::check_limit`] build one, so `size` is always
23/// above `max`.
24///
25/// # Examples
26///
27/// ```
28/// use mcpls_core::error::{Error, SizeExceeded};
29///
30/// fn oversize(error: &Error) -> Option<&SizeExceeded> {
31///     match error {
32///         Error::FileSizeLimitExceeded(exceeded) => Some(exceeded),
33///         _ => None,
34///     }
35/// }
36/// # let _ = oversize;
37/// ```
38#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
39#[error("{size} bytes exceed the limit of {max} bytes")]
40pub struct SizeExceeded {
41    pub(crate) size: u64,
42    pub(crate) max: NonZeroU64,
43}
44
45impl SizeExceeded {
46    /// The size found, in bytes.
47    #[must_use]
48    pub const fn size(&self) -> u64 {
49        self.size
50    }
51
52    /// The limit that was exceeded, in bytes; always below [`Self::size`].
53    #[must_use]
54    pub const fn max(&self) -> NonZeroU64 {
55        self.max
56    }
57
58    /// Checks `size` against `max`.
59    ///
60    /// # Errors
61    ///
62    /// Returns the exceeded limit when `size` is over `max`.
63    pub const fn check(size: u64, max: NonZeroU64) -> Result<(), Self> {
64        if size > max.get() {
65            return Err(Self { size, max });
66        }
67        Ok(())
68    }
69
70    /// Checks `size` against `limit`, which admits everything when unlimited.
71    ///
72    /// # Errors
73    ///
74    /// Returns the exceeded limit when `size` is over `limit`.
75    pub const fn check_limit(size: u64, limit: SizeLimit) -> Result<(), Self> {
76        match limit.get() {
77            Some(max) => Self::check(size, max),
78            None => Ok(()),
79        }
80    }
81}
82
83/// Why [`check_bounded_utf8`] rejected the bytes of a bounded read.
84#[derive(thiserror::Error, Debug)]
85pub enum BoundedUtf8Error {
86    /// The bytes were longer than the limit.
87    #[error(transparent)]
88    TooLarge(#[from] SizeExceeded),
89    /// The bytes were within the limit but not valid UTF-8.
90    #[error(transparent)]
91    InvalidUtf8(#[from] FromUtf8Error),
92}
93
94/// Checks `buf`'s length against `max` *before* UTF-8-validating it, so that
95/// a multibyte character split by a bounded read's cap (see
96/// [`SizeLimit::read_cap`]) is reported as oversized rather than as invalid
97/// UTF-8.
98///
99/// Callers own the bounded read itself (sync or async filesystem I/O
100/// differs by caller) and map the error onto their own error type.
101///
102/// # Errors
103///
104/// [`BoundedUtf8Error::TooLarge`] past `max`, otherwise
105/// [`BoundedUtf8Error::InvalidUtf8`].
106pub fn check_bounded_utf8(buf: Vec<u8>, max: SizeLimit) -> Result<String, BoundedUtf8Error> {
107    SizeExceeded::check_limit(buf.len() as u64, max)?;
108    Ok(String::from_utf8(buf)?)
109}
110
111/// Why [`RegularFile::open`] refused a path.
112#[derive(thiserror::Error, Debug)]
113pub enum OpenRegularFileError {
114    /// Opening the path or reading its metadata failed.
115    #[error(transparent)]
116    Io(#[from] std::io::Error),
117    /// The path is not a regular file (a FIFO, device, directory, ...).
118    #[error("not a regular file")]
119    NotRegular,
120}
121
122/// Why [`RegularFile::read_bounded`] did not return the file's bytes.
123#[derive(thiserror::Error, Debug)]
124pub enum ReadBoundedError {
125    /// Reading the open file failed.
126    #[error(transparent)]
127    Io(std::io::Error),
128    /// The file is larger than the allowed number of bytes; the size is its
129    /// stat length, or the bytes read when the stat length understated them.
130    #[error(transparent)]
131    TooLarge(SizeExceeded),
132}
133
134/// Why [`read_regular_file_bounded`] did not return the file's bytes.
135#[derive(thiserror::Error, Debug)]
136pub enum BoundedFileError {
137    /// The path could not be opened as a regular file.
138    #[error(transparent)]
139    Open(#[from] OpenRegularFileError),
140    /// The open file could not be read within the limit.
141    #[error(transparent)]
142    Read(#[from] ReadBoundedError),
143}
144
145/// An open handle proven to refer to a regular file.
146///
147/// The type is checked on the handle itself, not on a separately stat'd path,
148/// so an atomic replace between check and open cannot swap in something else.
149/// On Unix the open uses `O_NONBLOCK`, so opening a FIFO (or a symlink to
150/// one) returns at once instead of waiting for a writer, and `O_NOCTTY` keeps
151/// a symlink to a tty from becoming the controlling terminal. On Windows the
152/// handle must report `FILE_TYPE_DISK`, which rejects device names such as
153/// `NUL`; Win32 has no non-blocking open, so the open itself can still wait on
154/// a hostile path (see #442).
155#[derive(Debug)]
156pub struct RegularFile {
157    file: std::fs::File,
158    metadata: std::fs::Metadata,
159}
160
161impl RegularFile {
162    /// Opens `path` and verifies on the open handle that it is a regular file.
163    ///
164    /// # Errors
165    ///
166    /// [`OpenRegularFileError::NotRegular`] for anything but a regular file
167    /// and [`OpenRegularFileError::Io`] for open or metadata failures.
168    pub fn open(path: &Path) -> Result<Self, OpenRegularFileError> {
169        let mut options = std::fs::OpenOptions::new();
170        options.read(true);
171        #[cfg(unix)]
172        {
173            use std::os::unix::fs::OpenOptionsExt as _;
174            options.custom_flags(libc::O_NONBLOCK | libc::O_NOCTTY);
175        }
176        let file = options.open(path)?;
177        // Must precede metadata(): GetFileInformationByHandle may fail for non-disk handles.
178        #[cfg(windows)]
179        if !winapi_util::file::typ(&file).is_ok_and(|file_type| file_type.is_disk()) {
180            return Err(OpenRegularFileError::NotRegular);
181        }
182        let metadata = file.metadata()?;
183        if !metadata.is_file() {
184            return Err(OpenRegularFileError::NotRegular);
185        }
186        Ok(Self { file, metadata })
187    }
188
189    /// Splits into the open handle and its metadata.
190    #[must_use]
191    pub fn into_parts(self) -> (std::fs::File, std::fs::Metadata) {
192        (self.file, self.metadata)
193    }
194
195    /// Reads the file if it is at most `max` bytes.
196    ///
197    /// The stat length is checked first, then the read itself is capped at
198    /// `max + 1` bytes, so a file that grows after the stat is still bounded.
199    ///
200    /// # Errors
201    ///
202    /// [`ReadBoundedError::TooLarge`] past `max` bytes and
203    /// [`ReadBoundedError::Io`] when the read fails.
204    pub fn read_bounded(self, max: NonZeroU64) -> Result<Vec<u8>, ReadBoundedError> {
205        SizeExceeded::check(self.metadata.len(), max).map_err(ReadBoundedError::TooLarge)?;
206        let mut buf = Vec::new();
207        self.file
208            .take(SizeLimit::read_cap_for(max))
209            .read_to_end(&mut buf)
210            .map_err(ReadBoundedError::Io)?;
211        SizeExceeded::check(buf.len() as u64, max).map_err(ReadBoundedError::TooLarge)?;
212        Ok(buf)
213    }
214}
215
216/// Reads the regular file at `path` if it is at most `max` bytes, without ever
217/// blocking on a special file. See [`RegularFile`] for the open semantics.
218///
219/// # Errors
220///
221/// [`BoundedFileError::Open`] when the path is not a regular file or cannot
222/// be opened and [`BoundedFileError::Read`] when it is too large or unreadable.
223pub fn read_regular_file_bounded(
224    path: &Path,
225    max: NonZeroU64,
226) -> Result<Vec<u8>, BoundedFileError> {
227    Ok(RegularFile::open(path)?.read_bounded(max)?)
228}
229
230/// Byte-length threshold for the LSP error message forwarded to the MCP
231/// caller in [`crate::error::Error::LspServerError`] (#313).
232///
233/// Deliberately much larger than [`crate::util::MAX_LOG_STRING_BYTES`]
234/// (used for this same error message in [`crate::lsp::LspClient::request`]'s own log
235/// line): a legitimate LSP error (e.g. a verbose rust-analyzer
236/// type-mismatch diagnostic reported through an error response) can run
237/// into the low kilobytes, and that detail is useful to the calling model --
238/// a log line should stay terse, but a truncated-to-200-bytes error handed
239/// to the model would cut off real content on every longer-but-honest
240/// error. Still far below #311's 256 KiB cache-entry cap: this string is
241/// echoed directly into the MCP tool result / model context, not merely
242/// cached.
243pub const MAX_ERROR_MESSAGE_CALLER_BYTES: usize = 4 * 1024;
244
245/// Marker appended to a truncated string; the returned string can be up to
246/// `max_bytes + TRUNCATION_MARKER.len()` bytes, not exactly `max_bytes`.
247pub const TRUNCATION_MARKER: &str = "... (truncated)";
248
249/// Byte-length threshold for truncating an attacker-influenceable string
250/// (an LSP server's error message, a malformed protocol line, ...) before
251/// passing it to `truncate_str`/`truncate_string` for a single log line.
252/// Shared by every call site with this purpose so they don't each pick their
253/// own value -- see [`MAX_ERROR_MESSAGE_CALLER_BYTES`] for the
254/// separate, deliberately larger budget for text forwarded to the MCP
255/// caller rather than logged.
256pub const MAX_LOG_STRING_BYTES: usize = 200;
257
258/// Truncate `s` to at most `max_bytes` bytes, cutting on the last UTF-8 char
259/// boundary at or before the limit and appending a truncation marker. The
260/// returned string can be up to `max_bytes + TRUNCATION_MARKER.len()` bytes
261/// when truncation occurs -- the marker is appended after the cut, not
262/// counted against the limit.
263///
264/// `s` is typically attacker-influenceable (forwarded from a spawned LSP
265/// server), so the cut point is found via `floor_char_boundary` rather than a
266/// raw byte index, which would panic if it fell inside a multi-byte codepoint.
267///
268/// Always allocates a fresh `String`, even when `s` is already within the
269/// limit. Prefer [`truncate_string`] when the caller already owns `s` and
270/// truncation is expected to be rare, to skip that allocation on the common
271/// path.
272pub fn truncate_str(s: &str, max_bytes: usize) -> String {
273    if s.len() <= max_bytes {
274        return s.to_string();
275    }
276    let cut = s.floor_char_boundary(max_bytes);
277    format!("{}{TRUNCATION_MARKER}", &s[..cut])
278}
279
280/// Truncate an owned `String` to at most `max_bytes` bytes in place (same
281/// cut/marker semantics as [`truncate_str`]), returning `s` unchanged and
282/// without allocating when it is already within the limit.
283///
284/// This is the common case on the hot paths that call it --
285/// `NotificationCache::store_log`/`store_message` on every
286/// `window/logMessage`/`showMessage`, and each diagnostic's `message` field
287/// on every `publishDiagnostics` -- where [`truncate_str`]'s unconditional
288/// `s.to_string()` would otherwise clone the message on every call just to
289/// hand back an equivalent copy.
290pub fn truncate_string(mut s: String, max_bytes: usize) -> String {
291    if s.len() <= max_bytes {
292        return s;
293    }
294    let cut = s.floor_char_boundary(max_bytes);
295    s.truncate(cut);
296    s.push_str(TRUNCATION_MARKER);
297    s
298}
299
300/// `text` with control and deceptive characters escaped, then bounded to
301/// `max_bytes` of escaped text, so a client- or server-controlled string can be
302/// echoed in an error. The cut falls between escaped characters, so an escape
303/// sequence is never split; [`TRUNCATION_MARKER`] follows a cut.
304pub fn escape_bounded(text: &str, max_bytes: usize) -> String {
305    let mut bounded = String::with_capacity(text.len().min(max_bytes));
306    let mut buffer = [0; 4];
307    for c in text.chars() {
308        let escaped = escape_control(c.encode_utf8(&mut buffer));
309        if bounded.len().saturating_add(escaped.len()) > max_bytes {
310            bounded.push_str(TRUNCATION_MARKER);
311            break;
312        }
313        bounded.push_str(&escaped);
314    }
315    bounded
316}
317
318/// Characters that forge or reorder text without being visible: zero-width
319/// and bidirectional marks, line/paragraph separators and bidi overrides.
320pub const fn is_deceptive_format_char(c: char) -> bool {
321    matches!(
322        c,
323        '\u{00AD}'
324            | '\u{061C}'
325            | '\u{180E}'
326            | '\u{200B}'..='\u{200F}'
327            | '\u{2028}'..='\u{202E}'
328            | '\u{2060}'..='\u{2064}'
329            | '\u{2066}'..='\u{206F}'
330            | '\u{FEFF}'
331            | '\u{FFF9}'..='\u{FFFB}'
332            | '\u{E0000}'..='\u{E007F}'
333    )
334}
335
336/// Whether `c` could forge a log line or reorder displayed text, i.e. whether
337/// [`escape_control`] rewrites it.
338///
339/// # Examples
340///
341/// ```
342/// assert!(mcpls_core::needs_control_escape('\n'));
343/// assert!(!mcpls_core::needs_control_escape('a'));
344/// ```
345#[must_use]
346pub const fn needs_control_escape(c: char) -> bool {
347    c.is_control() || is_deceptive_format_char(c)
348}
349
350/// Escape every control character, line/paragraph separator and bidi control
351/// in `s` (`\n`, `\r`, `\t` by name, the rest as `\u{..}`), borrowing `s`
352/// unchanged when it has none.
353///
354/// Applied wherever attacker-influenceable text (an LSP server's message)
355/// reaches an error `Display`, and by the `mcpls` binary to every log field,
356/// so it cannot forge log lines or reorder what an operator reads.
357///
358/// # Examples
359///
360/// ```
361/// assert_eq!(mcpls_core::escape_control("ok\nERROR forged"), "ok\\nERROR forged");
362/// assert_eq!(mcpls_core::escape_control("plain"), "plain");
363/// ```
364pub fn escape_control(s: &str) -> Cow<'_, str> {
365    if !s.chars().any(needs_control_escape) {
366        return Cow::Borrowed(s);
367    }
368    let mut escaped = String::with_capacity(s.len());
369    for c in s.chars() {
370        match c {
371            '\n' => escaped.push_str("\\n"),
372            '\r' => escaped.push_str("\\r"),
373            '\t' => escaped.push_str("\\t"),
374            c if needs_control_escape(c) => escaped.extend(c.escape_unicode()),
375            c => escaped.push(c),
376        }
377    }
378    Cow::Owned(escaped)
379}
380
381/// [`escape_control`] for an owned string: clean text is returned as is,
382/// without a copy.
383pub fn escape_control_owned(text: String) -> String {
384    match escape_control(&text) {
385        Cow::Borrowed(_) => text,
386        Cow::Owned(escaped) => escaped,
387    }
388}
389
390/// Aborts the wrapped [`JoinHandle`] when dropped, including on an unwind out
391/// of the enclosing scope -- unlike a bare `.abort()` call placed at the end
392/// of a function body, which is skipped if that scope is left early (a
393/// panic, or a future `?` added above it).
394pub struct AbortOnDrop<'a, T>(pub(crate) &'a JoinHandle<T>);
395
396impl<T> Drop for AbortOnDrop<'_, T> {
397    fn drop(&mut self) {
398        self.0.abort();
399    }
400}
401
402/// A future that panicked while being driven by [`catch_panic`].
403#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
404#[error("task panicked: {message}")]
405pub struct TaskPanicked {
406    message: String,
407}
408
409impl TaskPanicked {
410    /// Best-effort text of the panic payload.
411    #[must_use]
412    pub fn message(&self) -> &str {
413        &self.message
414    }
415
416    fn from_payload(payload: &(dyn std::any::Any + Send)) -> Self {
417        let message = payload
418            .downcast_ref::<&str>()
419            .copied()
420            .or_else(|| payload.downcast_ref::<String>().map(String::as_str))
421            .unwrap_or("non-string panic payload");
422        Self {
423            message: message.to_owned(),
424        }
425    }
426}
427
428/// Drives `fut` to completion, turning a panic inside it into
429/// [`TaskPanicked`] so supervised tasks share one containment path.
430pub async fn catch_panic<T>(fut: impl Future<Output = T>) -> Result<T, TaskPanicked> {
431    AssertUnwindSafe(fut)
432        .catch_unwind()
433        .await
434        .map_err(|payload| TaskPanicked::from_payload(payload.as_ref()))
435}
436
437/// Decides when a repeating condition may be logged at `warn` again.
438///
439/// The first occurrence is due at once, later ones only once `every` has
440/// passed since the last one that was due, so a source that repeats cannot
441/// flood the log.
442#[derive(Debug, Default)]
443pub struct WarnLimiter {
444    last: Option<Instant>,
445}
446
447impl WarnLimiter {
448    /// The period most warnings use between two lines.
449    pub const DEFAULT_PERIOD: Duration = Duration::from_mins(1);
450
451    /// A limiter whose first warning is due at once.
452    #[must_use]
453    pub const fn new() -> Self {
454        Self { last: None }
455    }
456
457    /// Whether a warning is due at `now`; when it is, `now` becomes the new
458    /// reference point.
459    pub fn due(&mut self, now: Instant, every: Duration) -> bool {
460        let due = self
461            .last
462            .is_none_or(|last| now.saturating_duration_since(last) > every);
463        if due {
464            self.last = Some(now);
465        }
466        due
467    }
468}
469
470/// Locks a `std::sync::Mutex`, recovering the guard if a previous holder
471/// panicked while holding it.
472///
473/// Every lock guarded this way protects a short, synchronous, panic-free
474/// critical section (a `HashMap`/`HashSet` lookup or insert), so poisoning
475/// can only happen if an unrelated bug already panicked; refusing to unwind
476/// the whole process a second time over stale poisoning is preferable to
477/// deadlocking future calls.
478pub fn lock_std<T>(mutex: &StdMutex<T>) -> MutexGuard<'_, T> {
479    mutex.lock().unwrap_or_else(PoisonError::into_inner)
480}
481
482#[cfg(test)]
483mod tests {
484    use std::assert_matches;
485
486    use super::*;
487
488    #[test]
489    fn warn_limiter_is_due_at_first_and_then_once_per_interval() {
490        let every = Duration::from_secs(60);
491        let start = Instant::now();
492        let mut limiter = WarnLimiter::default();
493        assert!(limiter.due(start, every));
494        assert!(!limiter.due(start + Duration::from_secs(1), every));
495        assert!(!limiter.due(start + every, every));
496        assert!(limiter.due(start + every + Duration::from_millis(1), every));
497        assert!(!limiter.due(start + every + Duration::from_secs(2), every));
498    }
499
500    #[tokio::test]
501    async fn catch_panic_passes_value_through() {
502        assert_eq!(catch_panic(async { 7 }).await, Ok(7));
503    }
504
505    #[tokio::test]
506    async fn catch_panic_reports_str_and_string_payloads() {
507        let from_str = catch_panic(async { panic!("static boom") }).await;
508        assert_eq!(from_str.unwrap_err().message(), "static boom");
509        let detail = 42;
510        let from_string = catch_panic(async { panic!("boom {detail}") }).await;
511        assert_eq!(from_string.unwrap_err().message(), "boom 42");
512    }
513
514    #[test]
515    fn lock_std_recovers_poisoned_mutex() {
516        let mutex = std::sync::Arc::new(StdMutex::new(1));
517        let poisoner = std::sync::Arc::clone(&mutex);
518        let _ = std::thread::spawn(move || {
519            let _guard = poisoner.lock().unwrap();
520            panic!("poison");
521        })
522        .join();
523        assert_eq!(*lock_std(&mutex), 1);
524    }
525
526    #[test]
527    fn read_regular_file_bounded_reads_within_limit() {
528        let dir = tempfile::tempdir().unwrap();
529        let path = dir.path().join("a.json");
530        std::fs::write(&path, b"{}").unwrap();
531        assert_eq!(read_regular_file_bounded(&path, limit(2)).unwrap(), b"{}");
532        let empty = dir.path().join("empty.json");
533        std::fs::write(&empty, b"").unwrap();
534        assert!(
535            read_regular_file_bounded(&empty, limit(2))
536                .unwrap()
537                .is_empty()
538        );
539    }
540
541    fn limit(max: u64) -> NonZeroU64 {
542        NonZeroU64::new(max).unwrap()
543    }
544
545    #[test]
546    fn regular_file_open_into_parts_returns_handle_metadata() {
547        let dir = tempfile::tempdir().unwrap();
548        let path = dir.path().join("a.txt");
549        std::fs::write(&path, b"abc").unwrap();
550        let (_handle, metadata) = RegularFile::open(&path).unwrap().into_parts();
551        assert!(metadata.is_file());
552        assert_eq!(metadata.len(), 3);
553    }
554
555    #[test]
556    fn regular_file_open_missing_path_is_io_not_found() {
557        let dir = tempfile::tempdir().unwrap();
558        assert_matches!(
559            RegularFile::open(&dir.path().join("missing")),
560            Err(OpenRegularFileError::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound
561        );
562    }
563
564    #[cfg(unix)]
565    #[test]
566    fn regular_file_open_rejects_directory_and_fifo_as_not_regular() {
567        let dir = tempfile::tempdir().unwrap();
568        assert_matches!(
569            RegularFile::open(dir.path()),
570            Err(OpenRegularFileError::NotRegular)
571        );
572        let fifo = dir.path().join("fifo");
573        crate::test_lsp::make_fifo(&fifo);
574        assert_matches!(
575            RegularFile::open(&fifo),
576            Err(OpenRegularFileError::NotRegular)
577        );
578    }
579
580    #[cfg(unix)]
581    #[test]
582    fn regular_file_open_follows_symlink_to_regular_file() {
583        let dir = tempfile::tempdir().unwrap();
584        let target = dir.path().join("target.txt");
585        std::fs::write(&target, b"abc").unwrap();
586        let link = dir.path().join("link.txt");
587        std::os::unix::fs::symlink(&target, &link).unwrap();
588        let (_handle, metadata) = RegularFile::open(&link).unwrap().into_parts();
589        assert!(metadata.is_file());
590        assert_eq!(metadata.len(), 3);
591    }
592
593    #[cfg(windows)]
594    #[test]
595    fn regular_file_open_rejects_nul_device_as_not_regular() {
596        assert_matches!(
597            RegularFile::open(Path::new("NUL")),
598            Err(OpenRegularFileError::NotRegular)
599        );
600    }
601
602    #[test]
603    fn read_regular_file_bounded_rejects_oversize() {
604        let dir = tempfile::tempdir().unwrap();
605        let path = dir.path().join("a.json");
606        std::fs::write(&path, b"{ }").unwrap();
607        assert_matches!(
608            read_regular_file_bounded(&path, limit(2)),
609            Err(BoundedFileError::Read(ReadBoundedError::TooLarge(SizeExceeded { size: 3, max }))) if max == limit(2)
610        );
611    }
612
613    #[test]
614    fn read_regular_file_bounded_rejects_directory() {
615        let dir = tempfile::tempdir().unwrap();
616        let result = read_regular_file_bounded(dir.path(), limit(10));
617        #[cfg(unix)]
618        assert_matches!(
619            result,
620            Err(BoundedFileError::Open(OpenRegularFileError::NotRegular))
621        );
622        // Windows refuses to open a directory as a file before the type check runs.
623        #[cfg(windows)]
624        assert_matches!(result, Err(BoundedFileError::Open(OpenRegularFileError::Io(ref e))) if e.kind() == std::io::ErrorKind::PermissionDenied);
625    }
626
627    #[cfg(unix)]
628    #[test]
629    fn read_regular_file_bounded_does_not_block_on_fifo_or_symlink_to_it() {
630        let dir = tempfile::tempdir().unwrap();
631        let fifo = dir.path().join("fifo");
632        crate::test_lsp::make_fifo(&fifo);
633        let link = dir.path().join("link");
634        std::os::unix::fs::symlink(&fifo, &link).unwrap();
635
636        let (tx, rx) = std::sync::mpsc::channel();
637        std::thread::spawn(move || {
638            let outcomes = [fifo, link].map(|path| read_regular_file_bounded(&path, limit(10)));
639            tx.send(outcomes).unwrap();
640        });
641        let outcomes = rx
642            .recv_timeout(std::time::Duration::from_secs(5))
643            .unwrap_or_else(|_| panic!("reading a FIFO must not block"));
644        for outcome in outcomes {
645            assert_matches!(
646                outcome,
647                Err(BoundedFileError::Open(OpenRegularFileError::NotRegular))
648            );
649        }
650    }
651
652    #[test]
653    fn escape_control_borrows_clean_text() {
654        assert_matches!(escape_control("plain ✓"), Cow::Borrowed("plain ✓"));
655    }
656
657    #[test]
658    fn escape_control_escapes_newlines_and_escape_sequences() {
659        assert_eq!(
660            escape_control("a\nb\r\tc\x1b[31m"),
661            "a\\nb\\r\\tc\\u{1b}[31m"
662        );
663    }
664
665    #[test]
666    fn escape_control_escapes_line_separators_and_bidi_controls() {
667        assert_eq!(
668            escape_control("a\u{2028}b\u{2029}c\u{202E}d\u{2066}e\u{200F}f\u{061C}g"),
669            "a\\u{2028}b\\u{2029}c\\u{202e}d\\u{2066}e\\u{200f}f\\u{61c}g"
670        );
671    }
672
673    #[test]
674    fn escape_control_escapes_forged_log_line() {
675        assert_eq!(escape_control("ok\nERROR forged"), "ok\\nERROR forged");
676    }
677
678    #[test]
679    fn truncate_with_zero_budget_keeps_only_marker() {
680        assert_eq!(truncate_str("é", 0), TRUNCATION_MARKER);
681        assert_eq!(truncate_string("abc".to_owned(), 0), TRUNCATION_MARKER);
682    }
683
684    fn size(max: u64) -> SizeLimit {
685        SizeLimit::from_static(max)
686    }
687
688    #[test]
689    fn size_exceeded_exposes_both_numbers_through_accessors() {
690        let max = NonZeroU64::new(4).unwrap();
691        let exceeded = SizeExceeded::check(9, max).unwrap_err();
692
693        assert_eq!((exceeded.size(), exceeded.max()), (9, max));
694    }
695
696    #[test]
697    fn size_exceeded_check_admits_up_to_the_limit_and_reports_both_numbers() {
698        let max = NonZeroU64::new(4).unwrap();
699        assert_eq!(SizeExceeded::check(4, max), Ok(()));
700        assert_eq!(
701            SizeExceeded::check(5, max),
702            Err(SizeExceeded { size: 5, max })
703        );
704        assert_eq!(
705            SizeExceeded::check_limit(u64::MAX, SizeLimit::UNLIMITED),
706            Ok(())
707        );
708        assert_eq!(
709            SizeExceeded::check_limit(5, size(4)),
710            Err(SizeExceeded { size: 5, max })
711        );
712        assert_eq!(
713            SizeExceeded { size: 5, max }.to_string(),
714            "5 bytes exceed the limit of 4 bytes"
715        );
716    }
717
718    #[test]
719    fn check_bounded_utf8_within_limit() {
720        assert_eq!(
721            check_bounded_utf8(b"hello".to_vec(), size(10)).unwrap(),
722            "hello"
723        );
724    }
725
726    #[test]
727    fn check_bounded_utf8_too_large() {
728        assert_matches!(
729            check_bounded_utf8(b"hello".to_vec(), size(4)),
730            Err(BoundedUtf8Error::TooLarge(SizeExceeded { size: 5, max })) if max.get() == 4
731        );
732    }
733
734    #[test]
735    fn check_bounded_utf8_unlimited() {
736        let text = check_bounded_utf8(b"a".repeat(1000), SizeLimit::UNLIMITED).unwrap();
737        assert_eq!(text.len(), 1000);
738    }
739
740    #[test]
741    fn check_bounded_utf8_invalid_utf8_within_limit() {
742        assert_matches!(
743            check_bounded_utf8(vec![0xFF, 0xFE], size(10)),
744            Err(BoundedUtf8Error::InvalidUtf8(_))
745        );
746    }
747
748    /// A multibyte character split by the bound must be reported as
749    /// oversized, not as invalid UTF-8 -- the ordering this helper exists to
750    /// preserve across both call sites.
751    #[test]
752    fn check_bounded_utf8_reports_oversized_before_invalid_utf8() {
753        let mut buf = "é".repeat(3).into_bytes();
754        buf.truncate(5);
755        assert_matches!(
756            check_bounded_utf8(buf, size(4)),
757            Err(BoundedUtf8Error::TooLarge(SizeExceeded { size: 5, .. }))
758        );
759    }
760
761    #[test]
762    fn no_truncation_at_or_below_limit() {
763        let exact = "a".repeat(10);
764        assert_eq!(truncate_str(&exact, 10), exact);
765        assert_eq!(truncate_str("", 10), "");
766    }
767
768    #[test]
769    fn truncates_just_above_limit() {
770        let message = "a".repeat(11);
771        assert_eq!(
772            truncate_str(&message, 10),
773            format!("{}... (truncated)", "a".repeat(10))
774        );
775    }
776
777    #[test]
778    fn handles_multibyte_char_boundary() {
779        // Each 'é' is 2 bytes; a raw byte-index cut at 5 would fall inside one.
780        let message = "é".repeat(10);
781        let truncated = truncate_str(&message, 5);
782        assert!(truncated.starts_with(&"é".repeat(2)));
783        assert!(truncated.ends_with("... (truncated)"));
784    }
785
786    #[test]
787    fn truncate_string_no_truncation_at_or_below_limit() {
788        let exact = "a".repeat(10);
789        assert_eq!(truncate_string(exact.clone(), 10), exact);
790        assert_eq!(truncate_string(String::new(), 10), "");
791    }
792
793    #[test]
794    fn truncate_string_truncates_just_above_limit() {
795        let message = "a".repeat(11);
796        assert_eq!(
797            truncate_string(message, 10),
798            format!("{}... (truncated)", "a".repeat(10))
799        );
800    }
801
802    #[test]
803    fn truncate_string_handles_multibyte_char_boundary() {
804        let message = "é".repeat(10);
805        let truncated = truncate_string(message, 5);
806        assert!(truncated.starts_with(&"é".repeat(2)));
807        assert!(truncated.ends_with("... (truncated)"));
808    }
809
810    #[test]
811    fn truncate_str_and_truncate_string_agree() {
812        let message = "x".repeat(500);
813        assert_eq!(truncate_str(&message, 100), truncate_string(message, 100));
814    }
815}