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