Skip to main content

fresh/model/
filesystem.rs

1//! Unified filesystem abstraction for platform-independent file and directory operations
2//!
3//! This module provides a single trait for all filesystem operations, allowing the editor
4//! to work with different backends:
5//! - `StdFileSystem`: Native filesystem using `std::fs`
6//! - `VirtualFileSystem`: In-memory filesystem for WASM/browser (to be implemented)
7//! - Custom implementations for remote agents, network filesystems, etc.
8//!
9//! The trait is synchronous. For async UI operations (like the file explorer),
10//! callers should use `spawn_blocking` or similar patterns.
11
12use std::io::{self, Read, Seek, Write};
13use std::path::{Path, PathBuf};
14use std::time::SystemTime;
15
16// ============================================================================
17// Directory Entry Types
18// ============================================================================
19
20/// Type of filesystem entry
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum EntryType {
23    File,
24    Directory,
25    Symlink,
26}
27
28/// A directory entry returned by `read_dir`
29#[derive(Debug, Clone)]
30pub struct DirEntry {
31    /// Full path to the entry
32    pub path: PathBuf,
33    /// File/directory name (last component of path)
34    pub name: String,
35    /// Type of entry
36    pub entry_type: EntryType,
37    /// Optional metadata (can be populated lazily)
38    pub metadata: Option<FileMetadata>,
39    /// For symlinks, whether the target is a directory
40    pub symlink_target_is_dir: bool,
41}
42
43impl DirEntry {
44    /// Create a new directory entry
45    pub fn new(path: PathBuf, name: String, entry_type: EntryType) -> Self {
46        Self {
47            path,
48            name,
49            entry_type,
50            metadata: None,
51            symlink_target_is_dir: false,
52        }
53    }
54
55    /// Create a symlink entry with target info
56    pub fn new_symlink(path: PathBuf, name: String, target_is_dir: bool) -> Self {
57        Self {
58            path,
59            name,
60            entry_type: EntryType::Symlink,
61            metadata: None,
62            symlink_target_is_dir: target_is_dir,
63        }
64    }
65
66    /// Add metadata to this entry
67    pub fn with_metadata(mut self, metadata: FileMetadata) -> Self {
68        self.metadata = Some(metadata);
69        self
70    }
71
72    /// Returns true if this entry is a directory OR a symlink pointing to a directory
73    pub fn is_dir(&self) -> bool {
74        self.entry_type == EntryType::Directory
75            || (self.entry_type == EntryType::Symlink && self.symlink_target_is_dir)
76    }
77
78    /// Returns true if this is a regular file (or symlink to file)
79    pub fn is_file(&self) -> bool {
80        self.entry_type == EntryType::File
81            || (self.entry_type == EntryType::Symlink && !self.symlink_target_is_dir)
82    }
83
84    /// Returns true if this is a symlink
85    pub fn is_symlink(&self) -> bool {
86        self.entry_type == EntryType::Symlink
87    }
88}
89
90// ============================================================================
91// Metadata Types
92// ============================================================================
93
94/// Metadata about a file or directory
95#[derive(Debug, Clone)]
96pub struct FileMetadata {
97    /// Size in bytes (0 for directories)
98    pub size: u64,
99    /// Last modification time
100    pub modified: Option<SystemTime>,
101    /// File permissions (opaque, platform-specific)
102    pub permissions: Option<FilePermissions>,
103    /// Whether this is a hidden file (starts with . on Unix, hidden attribute on Windows)
104    pub is_hidden: bool,
105    /// Whether the file is read-only
106    pub is_readonly: bool,
107    /// File owner UID (Unix only)
108    #[cfg(unix)]
109    pub uid: Option<u32>,
110    /// File owner GID (Unix only)
111    #[cfg(unix)]
112    pub gid: Option<u32>,
113}
114
115impl FileMetadata {
116    /// Create minimal metadata with just size
117    pub fn new(size: u64) -> Self {
118        Self {
119            size,
120            modified: None,
121            permissions: None,
122            is_hidden: false,
123            is_readonly: false,
124            #[cfg(unix)]
125            uid: None,
126            #[cfg(unix)]
127            gid: None,
128        }
129    }
130
131    /// Builder: set modified time
132    pub fn with_modified(mut self, modified: SystemTime) -> Self {
133        self.modified = Some(modified);
134        self
135    }
136
137    /// Builder: set hidden flag
138    pub fn with_hidden(mut self, hidden: bool) -> Self {
139        self.is_hidden = hidden;
140        self
141    }
142
143    /// Builder: set readonly flag
144    pub fn with_readonly(mut self, readonly: bool) -> Self {
145        self.is_readonly = readonly;
146        self
147    }
148
149    /// Builder: set permissions
150    pub fn with_permissions(mut self, permissions: FilePermissions) -> Self {
151        self.permissions = Some(permissions);
152        self
153    }
154}
155
156impl Default for FileMetadata {
157    fn default() -> Self {
158        Self::new(0)
159    }
160}
161
162/// Opaque file permissions wrapper
163#[derive(Debug, Clone)]
164pub struct FilePermissions {
165    #[cfg(unix)]
166    mode: u32,
167    #[cfg(not(unix))]
168    readonly: bool,
169}
170
171impl FilePermissions {
172    /// Create from raw Unix mode bits
173    #[cfg(unix)]
174    pub fn from_mode(mode: u32) -> Self {
175        Self { mode }
176    }
177
178    /// Create from raw mode (non-Unix: treated as readonly if no write bits)
179    #[cfg(not(unix))]
180    pub fn from_mode(mode: u32) -> Self {
181        Self {
182            readonly: mode & 0o222 == 0,
183        }
184    }
185
186    /// Create from std::fs::Permissions
187    #[cfg(unix)]
188    pub fn from_std(perms: std::fs::Permissions) -> Self {
189        use std::os::unix::fs::PermissionsExt;
190        Self { mode: perms.mode() }
191    }
192
193    #[cfg(not(unix))]
194    pub fn from_std(perms: std::fs::Permissions) -> Self {
195        Self {
196            readonly: perms.readonly(),
197        }
198    }
199
200    /// Convert to std::fs::Permissions
201    #[cfg(unix)]
202    pub fn to_std(&self) -> std::fs::Permissions {
203        use std::os::unix::fs::PermissionsExt;
204        std::fs::Permissions::from_mode(self.mode)
205    }
206
207    #[cfg(not(unix))]
208    pub fn to_std(&self) -> std::fs::Permissions {
209        let mut perms = std::fs::Permissions::from(std::fs::metadata(".").unwrap().permissions());
210        perms.set_readonly(self.readonly);
211        perms
212    }
213
214    /// Get the Unix mode (if available)
215    #[cfg(unix)]
216    pub fn mode(&self) -> u32 {
217        self.mode
218    }
219
220    /// Check if no write bits are set at all (any user).
221    ///
222    /// NOTE: On Unix, this only checks whether the mode has zero write bits.
223    /// It does NOT check whether the *current user* can write. For that,
224    /// use [`is_readonly_for_user`] with the appropriate uid/gid.
225    pub fn is_readonly(&self) -> bool {
226        #[cfg(unix)]
227        {
228            self.mode & 0o222 == 0
229        }
230        #[cfg(not(unix))]
231        {
232            self.readonly
233        }
234    }
235
236    /// Check if the file is read-only for a specific user identified by
237    /// `user_uid` and a set of group IDs the user belongs to.
238    ///
239    /// On non-Unix platforms, falls back to the simple readonly flag.
240    #[cfg(unix)]
241    pub fn is_readonly_for_user(
242        &self,
243        user_uid: u32,
244        file_uid: u32,
245        file_gid: u32,
246        user_groups: &[u32],
247    ) -> bool {
248        // root can write to anything
249        if user_uid == 0 {
250            return false;
251        }
252        if user_uid == file_uid {
253            return self.mode & 0o200 == 0;
254        }
255        if user_groups.contains(&file_gid) {
256            return self.mode & 0o020 == 0;
257        }
258        self.mode & 0o002 == 0
259    }
260}
261
262// ============================================================================
263// File Handle Traits
264// ============================================================================
265
266/// A writable file handle
267pub trait FileWriter: Write + Send {
268    /// Sync all data to disk
269    fn sync_all(&self) -> io::Result<()>;
270}
271
272// ============================================================================
273// Patch Operations for Efficient Remote Saves
274// ============================================================================
275
276/// An operation in a patched write - either copy from source or insert new data
277#[derive(Debug, Clone)]
278pub enum WriteOp<'a> {
279    /// Copy bytes from the source file at the given offset
280    Copy { offset: u64, len: u64 },
281    /// Insert new data
282    Insert { data: &'a [u8] },
283}
284
285/// Wrapper around std::fs::File that implements FileWriter
286struct StdFileWriter(std::fs::File);
287
288impl Write for StdFileWriter {
289    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
290        self.0.write(buf)
291    }
292
293    fn flush(&mut self) -> io::Result<()> {
294        self.0.flush()
295    }
296}
297
298impl FileWriter for StdFileWriter {
299    fn sync_all(&self) -> io::Result<()> {
300        self.0.sync_all()
301    }
302}
303
304/// A readable and seekable file handle
305pub trait FileReader: Read + Seek + Send {}
306
307/// Wrapper around std::fs::File that implements FileReader
308struct StdFileReader(std::fs::File);
309
310impl Read for StdFileReader {
311    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
312        self.0.read(buf)
313    }
314}
315
316impl Seek for StdFileReader {
317    fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
318        self.0.seek(pos)
319    }
320}
321
322impl FileReader for StdFileReader {}
323
324// ============================================================================
325// File Search Types
326// ============================================================================
327//
328// Used by `FileSystem::search_file` for project-wide search.  The search
329// runs where the data lives: `StdFileSystem` scans locally, `RemoteFileSystem`
330// delegates to the remote agent so only matches cross the network.
331//
332// For searching an already-open buffer's piece tree (in-editor Ctrl+F and
333// dirty buffers in project search), see `TextBuffer::search_scan_*` in
334// `buffer.rs` which uses the same `SearchMatch` type but reads from the
335// in-memory piece tree rather than from disk.
336
337/// Options for searching within a file via `FileSystem::search_file`.
338#[derive(Clone, Debug)]
339pub struct FileSearchOptions {
340    /// If true, treat pattern as a literal string (regex-escape it).
341    pub fixed_string: bool,
342    /// If true, match case-sensitively.
343    pub case_sensitive: bool,
344    /// If true, match whole words only (wrap with `\b`).
345    pub whole_word: bool,
346    /// Maximum number of matches to return per batch.
347    pub max_matches: usize,
348}
349
350/// Cursor for incremental `search_file` calls.  Each call searches one
351/// chunk and advances the cursor; the caller loops until `done`.
352#[derive(Clone, Debug)]
353pub struct FileSearchCursor {
354    /// Byte offset to resume searching from.
355    pub offset: usize,
356    /// 1-based line number at `offset` (tracks newlines across calls).
357    pub running_line: usize,
358    /// Set to true when the entire file has been searched.
359    pub done: bool,
360    /// Optional upper bound — stop searching at this byte offset instead
361    /// of EOF.  Used by hybrid search to restrict `search_file` to a
362    /// specific file range (e.g. an unloaded piece-tree region).
363    pub end_offset: Option<usize>,
364}
365
366impl Default for FileSearchCursor {
367    fn default() -> Self {
368        Self {
369            offset: 0,
370            running_line: 1,
371            done: false,
372            end_offset: None,
373        }
374    }
375}
376
377impl FileSearchCursor {
378    pub fn new() -> Self {
379        Self::default()
380    }
381
382    /// Create a cursor bounded to a specific file range.
383    pub fn for_range(offset: usize, end_offset: usize, running_line: usize) -> Self {
384        Self {
385            offset,
386            running_line,
387            done: false,
388            end_offset: Some(end_offset),
389        }
390    }
391}
392
393/// A single search match with position and context.
394///
395/// Shared between `FileSystem::search_file` (project grep on disk) and
396/// `TextBuffer::search_scan_*` (in-editor search on piece tree).
397#[derive(Clone, Debug)]
398pub struct SearchMatch {
399    /// Byte offset of the match in the file/buffer.
400    pub byte_offset: usize,
401    /// Length of the match in bytes.
402    pub length: usize,
403    /// 1-based line number.
404    pub line: usize,
405    /// 1-based byte column within the line.
406    pub column: usize,
407    /// Content of the line containing the match (no trailing newline).
408    pub context: String,
409}
410
411// ============================================================================
412// FileSystem Trait
413// ============================================================================
414
415/// Unified trait for all filesystem operations
416///
417/// This trait provides both file content I/O and directory operations.
418/// Implementations can be:
419/// - `StdFileSystem`: Native filesystem using `std::fs`
420/// - `VirtualFileSystem`: In-memory for WASM/browser
421/// - Custom backends for remote agents, network filesystems, etc.
422///
423/// All methods are synchronous. For async UI operations, use `spawn_blocking`.
424pub trait FileSystem: Send + Sync {
425    // ========================================================================
426    // File Content Operations
427    // ========================================================================
428
429    /// Read entire file into memory
430    fn read_file(&self, path: &Path) -> io::Result<Vec<u8>>;
431
432    /// Read a range of bytes from a file (for lazy loading large files)
433    fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>>;
434
435    /// Count `\n` bytes in a file range without returning the data.
436    ///
437    /// Used by the line-feed scanner to count newlines in unloaded chunks.
438    /// Remote filesystem implementations can override this to count on the
439    /// server side, avoiding the transfer of chunk bytes over the network.
440    ///
441    /// The default implementation reads the range and counts locally.
442    fn count_line_feeds_in_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<usize> {
443        let data = self.read_range(path, offset, len)?;
444        Ok(data.iter().filter(|&&b| b == b'\n').count())
445    }
446
447    /// Write data to file atomically (temp file + rename)
448    fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()>;
449
450    /// Create a file for writing, returns a writer handle
451    fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
452
453    /// Open a file for reading, returns a reader handle
454    fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>>;
455
456    /// Open a file for writing in-place (truncating, preserves ownership on Unix)
457    fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
458
459    /// Open a file for appending (creates if doesn't exist)
460    fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
461
462    /// Set file length (truncate or extend with zeros)
463    fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()>;
464
465    /// Write a file using a patch recipe (optimized for remote filesystems).
466    ///
467    /// This allows saving edited files by specifying which parts to copy from
468    /// the original and which parts are new content. For remote filesystems,
469    /// this avoids transferring unchanged portions over the network.
470    ///
471    /// # Arguments
472    /// * `src_path` - The original file to read from (for Copy operations)
473    /// * `dst_path` - The destination file (often same as src_path)
474    /// * `ops` - The sequence of operations to build the new file
475    ///
476    /// The default implementation flattens all operations into memory and
477    /// calls `write_file`. Remote implementations can override this to send
478    /// the recipe and let the remote host do the reconstruction.
479    fn write_patched(&self, src_path: &Path, dst_path: &Path, ops: &[WriteOp]) -> io::Result<()> {
480        // Default implementation: flatten to buffer and write
481        let mut buffer = Vec::new();
482        for op in ops {
483            match op {
484                WriteOp::Copy { offset, len } => {
485                    let data = self.read_range(src_path, *offset, *len as usize)?;
486                    buffer.extend_from_slice(&data);
487                }
488                WriteOp::Insert { data } => {
489                    buffer.extend_from_slice(data);
490                }
491            }
492        }
493        self.write_file(dst_path, &buffer)
494    }
495
496    // ========================================================================
497    // File Operations
498    // ========================================================================
499
500    /// Rename/move a file or directory atomically
501    fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
502
503    /// Copy a file (fallback when rename fails across filesystems)
504    fn copy(&self, from: &Path, to: &Path) -> io::Result<u64>;
505
506    /// Remove a file
507    fn remove_file(&self, path: &Path) -> io::Result<()>;
508
509    /// Remove an empty directory
510    fn remove_dir(&self, path: &Path) -> io::Result<()>;
511
512    /// Recursively remove a directory and all its contents
513    fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
514        for entry in self.read_dir(path)? {
515            if entry.is_dir() {
516                self.remove_dir_all(&entry.path)?;
517            } else {
518                self.remove_file(&entry.path)?;
519            }
520        }
521        self.remove_dir(path)
522    }
523
524    /// Recursively copy a directory and all its contents to dst
525    fn copy_dir_all(&self, src: &Path, dst: &Path) -> io::Result<()> {
526        self.create_dir_all(dst)?;
527        for entry in self.read_dir(src)? {
528            let dst_child = dst.join(&entry.name);
529            if entry.is_dir() {
530                self.copy_dir_all(&entry.path, &dst_child)?;
531            } else {
532                self.copy(&entry.path, &dst_child)?;
533            }
534        }
535        Ok(())
536    }
537
538    // ========================================================================
539    // Metadata Operations
540    // ========================================================================
541
542    /// Get file/directory metadata
543    fn metadata(&self, path: &Path) -> io::Result<FileMetadata>;
544
545    /// Get symlink metadata (doesn't follow symlinks)
546    fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata>;
547
548    /// Check if path exists
549    fn exists(&self, path: &Path) -> bool {
550        self.metadata(path).is_ok()
551    }
552
553    /// Check if path exists, returns metadata if it does
554    fn metadata_if_exists(&self, path: &Path) -> Option<FileMetadata> {
555        self.metadata(path).ok()
556    }
557
558    /// Check if path is a directory
559    fn is_dir(&self, path: &Path) -> io::Result<bool>;
560
561    /// Check if path is a file
562    fn is_file(&self, path: &Path) -> io::Result<bool>;
563
564    /// Check if the current user has write permission to the given path.
565    ///
566    /// On Unix, this considers file ownership, group membership (including
567    /// supplementary groups), and the relevant permission bits. On other
568    /// platforms it falls back to the standard readonly check.
569    ///
570    /// Returns `false` if the path doesn't exist or metadata can't be read.
571    fn is_writable(&self, path: &Path) -> bool {
572        self.metadata(path).map(|m| !m.is_readonly).unwrap_or(false)
573    }
574
575    /// Set file permissions
576    fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()>;
577
578    // ========================================================================
579    // Directory Operations
580    // ========================================================================
581
582    /// List entries in a directory (non-recursive)
583    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>>;
584
585    /// Create a directory
586    fn create_dir(&self, path: &Path) -> io::Result<()>;
587
588    /// Create a directory and all parent directories
589    fn create_dir_all(&self, path: &Path) -> io::Result<()>;
590
591    // ========================================================================
592    // Path Operations
593    // ========================================================================
594
595    /// Get canonical (absolute, normalized) path
596    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
597
598    // ========================================================================
599    // Utility Methods
600    // ========================================================================
601
602    /// Get the current user's UID (Unix only, returns 0 on other platforms)
603    fn current_uid(&self) -> u32;
604
605    /// Check if the current user is the owner of the file
606    fn is_owner(&self, path: &Path) -> bool {
607        #[cfg(unix)]
608        {
609            if let Ok(meta) = self.metadata(path) {
610                if let Some(uid) = meta.uid {
611                    return uid == self.current_uid();
612                }
613            }
614            true
615        }
616        #[cfg(not(unix))]
617        {
618            let _ = path;
619            true
620        }
621    }
622
623    /// Get a temporary file path for atomic writes
624    fn temp_path_for(&self, path: &Path) -> PathBuf {
625        path.with_extension("tmp")
626    }
627
628    /// Get a unique temporary file path (using timestamp and PID)
629    fn unique_temp_path(&self, dest_path: &Path) -> PathBuf {
630        let temp_dir = std::env::temp_dir();
631        let file_name = dest_path
632            .file_name()
633            .unwrap_or_else(|| std::ffi::OsStr::new("fresh-save"));
634        let timestamp = std::time::SystemTime::now()
635            .duration_since(std::time::UNIX_EPOCH)
636            .map(|d| d.as_nanos())
637            .unwrap_or(0);
638        temp_dir.join(format!(
639            "{}-{}-{}.tmp",
640            file_name.to_string_lossy(),
641            std::process::id(),
642            timestamp
643        ))
644    }
645
646    // ========================================================================
647    // Remote Connection Info
648    // ========================================================================
649
650    /// Get remote connection info if this is a remote filesystem
651    ///
652    /// Returns `Some("user@host")` for remote filesystems, `None` for local.
653    /// Used to display remote connection status in the UI.
654    fn remote_connection_info(&self) -> Option<&str> {
655        None
656    }
657
658    /// Check if a remote filesystem is currently connected.
659    ///
660    /// Returns `true` for local filesystems (always "connected") and for
661    /// remote filesystems with a healthy connection. Returns `false` when
662    /// the remote connection has been lost (e.g., timeout, SSH disconnect).
663    fn is_remote_connected(&self) -> bool {
664        true
665    }
666
667    /// Get the home directory for this filesystem
668    ///
669    /// For local filesystems, returns the local home directory.
670    /// For remote filesystems, returns the remote home directory.
671    fn home_dir(&self) -> io::Result<PathBuf> {
672        dirs::home_dir()
673            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home directory not found"))
674    }
675
676    // ========================================================================
677    // Search Operations
678    // ========================================================================
679
680    /// Search a file on disk for a pattern, returning one batch of matches.
681    ///
682    /// Call repeatedly with the same cursor until `cursor.done` is true.
683    /// Each call searches one chunk; the cursor tracks position and line
684    /// numbers across calls.
685    ///
686    /// The search runs where the data lives: `StdFileSystem` reads and
687    /// scans locally; `RemoteFileSystem` sends a stateless RPC to the
688    /// remote agent.  Only matches cross the network.
689    ///
690    /// For searching an already-open buffer with unsaved edits, use
691    /// `TextBuffer::search_scan_all` which reads from the piece tree.
692    fn search_file(
693        &self,
694        path: &Path,
695        pattern: &str,
696        opts: &FileSearchOptions,
697        cursor: &mut FileSearchCursor,
698    ) -> io::Result<Vec<SearchMatch>>;
699
700    /// Write file using sudo (for root-owned files).
701    ///
702    /// This writes the file with elevated privileges, preserving the specified
703    /// permissions and ownership. Used when normal write fails due to permissions.
704    ///
705    /// - `path`: Destination file path
706    /// - `data`: File contents to write
707    /// - `mode`: File permissions (e.g., 0o644)
708    /// - `uid`: Owner user ID
709    /// - `gid`: Owner group ID
710    fn sudo_write(&self, path: &Path, data: &[u8], mode: u32, uid: u32, gid: u32)
711        -> io::Result<()>;
712
713    // ========================================================================
714    // Directory Walking
715    // ========================================================================
716
717    /// Recursively walk a directory tree, invoking `on_file` for each file.
718    ///
719    /// Skips hidden entries (dot-prefixed names) and directories whose
720    /// basename appears in `skip_dirs`.  The walk stops early when:
721    /// - `on_file` returns `false` (caller reached its limit), or
722    /// - `cancel` is set to `true` (e.g. user closed the dialog).
723    ///
724    /// `on_file` receives `(absolute_path, path_relative_to_root)`.
725    ///
726    /// `skip_dirs` entries are **basenames** matched at every depth
727    /// (e.g. `"node_modules"` skips every `node_modules` directory in the
728    /// tree).
729    ///
730    /// // TODO: support .gitignore-style glob patterns in addition to
731    /// // basename matching, so callers can express richer ignore rules
732    /// // (e.g. `build/`, `*.o`, `vendor/**`).
733    ///
734    /// Each implementation must walk the filesystem it owns.  Local
735    /// implementations should iterate `std::fs::read_dir` lazily (not
736    /// collect into a Vec) so memory stays O(tree depth).  Remote
737    /// implementations should walk server-side and stream results back
738    /// via the channel, avoiding per-directory round-trips.
739    fn walk_files(
740        &self,
741        root: &Path,
742        skip_dirs: &[&str],
743        cancel: &std::sync::atomic::AtomicBool,
744        on_file: &mut dyn FnMut(&Path, &str) -> bool,
745    ) -> io::Result<()>;
746}
747
748// ============================================================================
749// FileSystemExt - Async Extension Trait
750// ============================================================================
751
752/// Async extension trait for FileSystem
753///
754/// This trait provides async versions of FileSystem methods using native
755/// Rust async fn (no async_trait crate needed). Default implementations
756/// simply call the sync methods, which works for local filesystem operations.
757///
758/// For truly async backends (network FS, remote agents), implementations
759/// can override these methods with actual async implementations.
760///
761/// Note: This trait is NOT object-safe due to async fn. Use generics
762/// (`impl FileSystem` or `F: FileSystem`) instead of `dyn FileSystem`
763/// when async methods are needed.
764///
765/// # Example
766///
767/// ```ignore
768/// async fn list_files<F: FileSystem>(fs: &F, path: &Path) -> io::Result<Vec<DirEntry>> {
769///     fs.read_dir_async(path).await
770/// }
771/// ```
772pub trait FileSystemExt: FileSystem {
773    /// Async version of read_file
774    fn read_file_async(
775        &self,
776        path: &Path,
777    ) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
778        async { self.read_file(path) }
779    }
780
781    /// Async version of read_range
782    fn read_range_async(
783        &self,
784        path: &Path,
785        offset: u64,
786        len: usize,
787    ) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
788        async move { self.read_range(path, offset, len) }
789    }
790
791    /// Async version of count_line_feeds_in_range
792    fn count_line_feeds_in_range_async(
793        &self,
794        path: &Path,
795        offset: u64,
796        len: usize,
797    ) -> impl std::future::Future<Output = io::Result<usize>> + Send {
798        async move { self.count_line_feeds_in_range(path, offset, len) }
799    }
800
801    /// Async version of write_file
802    fn write_file_async(
803        &self,
804        path: &Path,
805        data: &[u8],
806    ) -> impl std::future::Future<Output = io::Result<()>> + Send {
807        async { self.write_file(path, data) }
808    }
809
810    /// Async version of metadata
811    fn metadata_async(
812        &self,
813        path: &Path,
814    ) -> impl std::future::Future<Output = io::Result<FileMetadata>> + Send {
815        async { self.metadata(path) }
816    }
817
818    /// Async version of exists
819    fn exists_async(&self, path: &Path) -> impl std::future::Future<Output = bool> + Send {
820        async { self.exists(path) }
821    }
822
823    /// Async version of is_dir
824    fn is_dir_async(
825        &self,
826        path: &Path,
827    ) -> impl std::future::Future<Output = io::Result<bool>> + Send {
828        async { self.is_dir(path) }
829    }
830
831    /// Async version of is_file
832    fn is_file_async(
833        &self,
834        path: &Path,
835    ) -> impl std::future::Future<Output = io::Result<bool>> + Send {
836        async { self.is_file(path) }
837    }
838
839    /// Async version of read_dir
840    fn read_dir_async(
841        &self,
842        path: &Path,
843    ) -> impl std::future::Future<Output = io::Result<Vec<DirEntry>>> + Send {
844        async { self.read_dir(path) }
845    }
846
847    /// Async version of canonicalize
848    fn canonicalize_async(
849        &self,
850        path: &Path,
851    ) -> impl std::future::Future<Output = io::Result<PathBuf>> + Send {
852        async { self.canonicalize(path) }
853    }
854}
855
856/// Blanket implementation: all FileSystem types automatically get async methods
857impl<T: FileSystem> FileSystemExt for T {}
858
859// ============================================================================
860// Default search_file implementation
861// ============================================================================
862
863/// Build a `regex::bytes::Regex` from a user-facing pattern and search options.
864pub fn build_search_regex(
865    pattern: &str,
866    opts: &FileSearchOptions,
867) -> io::Result<regex::bytes::Regex> {
868    let re_pattern = if opts.fixed_string {
869        regex::escape(pattern)
870    } else {
871        pattern.to_string()
872    };
873    let re_pattern = if opts.whole_word {
874        format!(r"\b{}\b", re_pattern)
875    } else {
876        re_pattern
877    };
878    let re_pattern = if opts.case_sensitive {
879        re_pattern
880    } else {
881        format!("(?i){}", re_pattern)
882    };
883    // Multi-line regex patterns get (?s) so `.` matches newlines.
884    let re_pattern = if !opts.fixed_string && pattern.contains('\n') {
885        format!("(?s){}", re_pattern)
886    } else {
887        re_pattern
888    };
889    regex::bytes::Regex::new(&re_pattern)
890        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
891}
892
893/// Maximum per-file size for unbounded project-wide search.  Files larger
894/// than this are treated as binary and skipped: searching multi-gigabyte
895/// archives, model weights, or audio files would otherwise lock up the
896/// editor for minutes (issue #1342).  Source files virtually never exceed
897/// this limit; users wanting to search huge text logs should open them
898/// directly so hybrid buffer search applies.
899pub const MAX_PROJECT_SEARCH_FILE_SIZE: u64 = 10 * 1024 * 1024;
900
901/// Filename extensions that always represent binary content for project
902/// search.  Files matching this list are skipped before any I/O — no
903/// `stat`, no header read — which both avoids waste on the obvious cases
904/// and removes the dependency on heuristic content detection for formats
905/// whose first 8 KB can occasionally look text-like.
906///
907/// Kept as a sorted ASCII-lowercase list and matched case-insensitively
908/// via `eq_ignore_ascii_case` (no allocation per file).
909pub const BINARY_FILE_EXTENSIONS: &[&str] = &[
910    // Compiled binaries / object code / libraries
911    "a",
912    "class",
913    "dll",
914    "dylib",
915    "exe",
916    "jar",
917    "lib",
918    "o",
919    "obj",
920    "pdb",
921    "pyc",
922    "pyo",
923    "so",
924    "wasm",
925    "war",
926    // Archives / compressed
927    "7z",
928    "br",
929    "bz2",
930    "gz",
931    "lz",
932    "lz4",
933    "lzma",
934    "rar",
935    "tar",
936    "tbz",
937    "tbz2",
938    "tgz",
939    "txz",
940    "xz",
941    "z",
942    "zip",
943    "zst",
944    // Disk images / installers / packages
945    "apk",
946    "deb",
947    "dmg",
948    "img",
949    "ipa",
950    "iso",
951    "msi",
952    "rpm",
953    "vhd",
954    "vmdk",
955    // Images
956    "bmp",
957    "gif",
958    "heic",
959    "heif",
960    "ico",
961    "jp2",
962    "jpe",
963    "jpeg",
964    "jpg",
965    "png",
966    "psd",
967    "raw",
968    "tif",
969    "tiff",
970    "webp",
971    // Audio / video
972    "aac",
973    "aif",
974    "aiff",
975    "avi",
976    "flac",
977    "flv",
978    "m4a",
979    "m4v",
980    "mid",
981    "midi",
982    "mka",
983    "mkv",
984    "mov",
985    "mp3",
986    "mp4",
987    "mpeg",
988    "mpg",
989    "ogg",
990    "opus",
991    "wav",
992    "webm",
993    "wma",
994    "wmv",
995    // Office / compound documents
996    "doc",
997    "docx",
998    "odp",
999    "ods",
1000    "odt",
1001    "pdf",
1002    "ppt",
1003    "pptx",
1004    "rtf",
1005    "xls",
1006    "xlsx",
1007    // Databases
1008    "db",
1009    "mdb",
1010    "sqlite",
1011    "sqlite3",
1012    // Fonts
1013    "eot",
1014    "otf",
1015    "ttc",
1016    "ttf",
1017    "woff",
1018    "woff2",
1019    // ML / scientific data
1020    "ckpt",
1021    "h5",
1022    "hdf5",
1023    "msgpack",
1024    "npy",
1025    "npz",
1026    "onnx",
1027    "pb",
1028    "pickle",
1029    "pkl",
1030    "pt",
1031    "pth",
1032    "safetensors",
1033    "tflite",
1034    // Generic binary
1035    "bin",
1036    "dat",
1037    "swf",
1038];
1039
1040/// True if `path`'s extension matches a known binary format.
1041fn has_binary_extension(path: &Path) -> bool {
1042    let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
1043        return false;
1044    };
1045    BINARY_FILE_EXTENSIONS
1046        .iter()
1047        .any(|candidate| candidate.eq_ignore_ascii_case(ext))
1048}
1049
1050/// True if a UTF-8/byte-level regex can meaningfully match content of
1051/// this encoding.  UTF-16/UTF-32 carry interleaved NUL bytes for ASCII
1052/// characters, so byte-regex search would never find a UTF-8 pattern in
1053/// them; correct support requires transcoding the chunk first, which we
1054/// don't do yet.  All single-byte ASCII-superset encodings (UTF-8,
1055/// Latin-1, Windows-12xx) and CJK encodings (lead+trail bytes ≥ 0x40)
1056/// match a UTF-8 pattern in their ASCII portion just fine.
1057fn is_byte_searchable_encoding(enc: crate::model::encoding::Encoding) -> bool {
1058    use crate::model::encoding::Encoding;
1059    !matches!(enc, Encoding::Utf16Le | Encoding::Utf16Be)
1060}
1061
1062/// Default implementation of `FileSystem::search_file` that works for any
1063/// filesystem backend.  Reads one chunk via `read_range`, scans with the
1064/// given regex, and returns matches with line/column/context.
1065pub fn default_search_file(
1066    fs: &dyn FileSystem,
1067    path: &Path,
1068    pattern: &str,
1069    opts: &FileSearchOptions,
1070    cursor: &mut FileSearchCursor,
1071) -> io::Result<Vec<SearchMatch>> {
1072    if cursor.done {
1073        return Ok(vec![]);
1074    }
1075
1076    const CHUNK_SIZE: usize = 1_048_576; // 1 MB
1077    let overlap = pattern.len().max(256);
1078
1079    // Pre-flight checks for unbounded scans.  Bounded scans (hybrid
1080    // buffer search) intentionally bypass all of these — they run only
1081    // when the user has explicitly opened the file as text, and we trust
1082    // the editor's load-time decision.
1083    if cursor.offset == 0 && cursor.end_offset.is_none() {
1084        // Extension fast-path: skip known-binary formats with no I/O at
1085        // all (no stat, no header read).
1086        if has_binary_extension(path) {
1087            cursor.done = true;
1088            return Ok(vec![]);
1089        }
1090    }
1091
1092    let meta = fs.metadata(path)?;
1093    let file_size = meta.size;
1094    let file_len = file_size as usize;
1095    let effective_end = cursor.end_offset.unwrap_or(file_len).min(file_len);
1096
1097    if cursor.offset == 0 && cursor.end_offset.is_none() {
1098        if file_size == 0 {
1099            cursor.done = true;
1100            return Ok(vec![]);
1101        }
1102        // Skip files that exceed the project-search size cap.  Multi-gigabyte
1103        // archives, model weights, and audio files would otherwise lock up
1104        // the editor (issue #1342).
1105        if file_size > MAX_PROJECT_SEARCH_FILE_SIZE {
1106            cursor.done = true;
1107            return Ok(vec![]);
1108        }
1109        // Delegate the header sniff to the same encoding detector the
1110        // buffer loader uses, so search and editor agree on what's text.
1111        // The detector handles BOMs, UTF-16 statistical detection, and
1112        // the full set of "non-text control char" indicators.  We then
1113        // additionally reject encodings byte-regex can't meaningfully
1114        // match (UTF-16/32) until we add transcoding.
1115        let header_len = file_len.min(8192);
1116        let header = fs.read_range(path, 0, header_len)?;
1117        let truncated = header_len < file_len;
1118        let (encoding, is_binary) =
1119            crate::model::encoding::detect_encoding_or_binary(&header, truncated);
1120        if is_binary || !is_byte_searchable_encoding(encoding) {
1121            cursor.done = true;
1122            return Ok(vec![]);
1123        }
1124    }
1125
1126    if cursor.offset >= effective_end {
1127        cursor.done = true;
1128        return Ok(vec![]);
1129    }
1130
1131    let regex = build_search_regex(pattern, opts)?;
1132
1133    // Read chunk with overlap from previous
1134    let read_start = cursor.offset.saturating_sub(overlap);
1135    let read_end = (read_start + CHUNK_SIZE).min(effective_end);
1136    let chunk = fs.read_range(path, read_start as u64, read_end - read_start)?;
1137
1138    let overlap_len = cursor.offset - read_start;
1139
1140    // Mid-stream binary detection.  The 8 KB header check at the top of
1141    // the function only sees the start of the file, but plenty of formats
1142    // are text in their first few KB and binary thereafter (self-extracting
1143    // installers, mbox files with attachments, log files with embedded
1144    // crash dumps).  A NUL byte in any subsequent chunk is the strongest
1145    // indicator the file isn't text we should be searching, so bail out
1146    // and discard whatever pseudo-matches the regex would have found.
1147    // Bounded scans skip this — they're searching a delimited region of
1148    // an already-loaded text buffer where NULs may legitimately appear
1149    // mid-stream (e.g., cursor sentinels in piece-tree leaves).
1150    if cursor.end_offset.is_none() && chunk[overlap_len..].contains(&0) {
1151        cursor.done = true;
1152        return Ok(vec![]);
1153    }
1154
1155    // Incremental line counting (same algorithm as search_scan_next_chunk)
1156    let newlines_in_overlap = chunk[..overlap_len].iter().filter(|&&b| b == b'\n').count();
1157    let mut line_at = cursor.running_line.saturating_sub(newlines_in_overlap);
1158    let mut counted_to = 0usize;
1159    let mut matches = Vec::new();
1160
1161    for m in regex.find_iter(&chunk) {
1162        // Skip matches in overlap region (already reported in previous batch)
1163        if overlap_len > 0 && m.end() <= overlap_len {
1164            continue;
1165        }
1166        if matches.len() >= opts.max_matches {
1167            break;
1168        }
1169
1170        // Count newlines from last position to this match
1171        line_at += chunk[counted_to..m.start()]
1172            .iter()
1173            .filter(|&&b| b == b'\n')
1174            .count();
1175        counted_to = m.start();
1176
1177        // Find line boundaries for context
1178        let line_start = chunk[..m.start()]
1179            .iter()
1180            .rposition(|&b| b == b'\n')
1181            .map(|p| p + 1)
1182            .unwrap_or(0);
1183        let line_end = chunk[m.start()..]
1184            .iter()
1185            .position(|&b| b == b'\n')
1186            .map(|p| m.start() + p)
1187            .unwrap_or(chunk.len());
1188
1189        let column = m.start() - line_start + 1;
1190        let context = String::from_utf8_lossy(&chunk[line_start..line_end]).into_owned();
1191
1192        matches.push(SearchMatch {
1193            byte_offset: read_start + m.start(),
1194            length: m.end() - m.start(),
1195            line: line_at,
1196            column,
1197            context,
1198        });
1199    }
1200
1201    // Advance cursor
1202    let new_data = &chunk[overlap_len..];
1203    cursor.running_line += new_data.iter().filter(|&&b| b == b'\n').count();
1204    cursor.offset = read_end;
1205    if read_end >= effective_end {
1206        cursor.done = true;
1207    }
1208
1209    Ok(matches)
1210}
1211
1212// ============================================================================
1213// StdFileSystem Implementation
1214// ============================================================================
1215
1216/// Standard filesystem implementation using `std::fs`
1217///
1218/// This is the default implementation for native builds.
1219#[derive(Debug, Clone, Copy, Default)]
1220pub struct StdFileSystem;
1221
1222impl StdFileSystem {
1223    /// Check if a file is hidden (platform-specific)
1224    fn is_hidden(path: &Path) -> bool {
1225        path.file_name()
1226            .and_then(|n| n.to_str())
1227            .is_some_and(|n| n.starts_with('.'))
1228    }
1229
1230    /// Get the current user's effective UID and all group IDs (primary + supplementary).
1231    #[cfg(unix)]
1232    pub fn current_user_groups() -> (u32, Vec<u32>) {
1233        // SAFETY: these libc calls are always safe and have no failure modes
1234        let euid = unsafe { libc::geteuid() };
1235        let egid = unsafe { libc::getegid() };
1236        let mut groups = vec![egid];
1237
1238        // Get supplementary groups
1239        let ngroups = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
1240        if ngroups > 0 {
1241            let mut sup_groups = vec![0 as libc::gid_t; ngroups as usize];
1242            let n = unsafe { libc::getgroups(ngroups, sup_groups.as_mut_ptr()) };
1243            if n > 0 {
1244                sup_groups.truncate(n as usize);
1245                for g in sup_groups {
1246                    if g != egid {
1247                        groups.push(g);
1248                    }
1249                }
1250            }
1251        }
1252
1253        (euid, groups)
1254    }
1255
1256    /// Ask the kernel whether the effective user can write to `path`.
1257    ///
1258    /// Uses `faccessat(AT_FDCWD, path, W_OK, AT_EACCESS)`, which respects POSIX
1259    /// ACLs, capabilities, and read-only mounts — all of which a manual mode-bit
1260    /// check would miss. Returns `None` if the path can't be encoded as a
1261    /// C string; callers should fall back to mode-bit checks in that case.
1262    #[cfg(unix)]
1263    fn kernel_writable(path: &Path) -> Option<bool> {
1264        use std::os::unix::ffi::OsStrExt;
1265        let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1266        // SAFETY: c_path is a valid NUL-terminated C string for the lifetime of
1267        // this call; AT_FDCWD, W_OK and AT_EACCESS are well-defined constants.
1268        let rc = unsafe {
1269            libc::faccessat(
1270                libc::AT_FDCWD,
1271                c_path.as_ptr(),
1272                libc::W_OK,
1273                libc::AT_EACCESS,
1274            )
1275        };
1276        Some(rc == 0)
1277    }
1278
1279    /// Build FileMetadata from std::fs::Metadata
1280    fn build_metadata(path: &Path, meta: &std::fs::Metadata) -> FileMetadata {
1281        #[cfg(unix)]
1282        {
1283            use std::os::unix::fs::MetadataExt;
1284            let file_uid = meta.uid();
1285            let file_gid = meta.gid();
1286            let permissions = FilePermissions::from_std(meta.permissions());
1287            // Prefer the kernel's view (respects POSIX ACLs, capabilities,
1288            // read-only mounts); fall back to mode bits if the syscall can't
1289            // be issued for this path.
1290            let is_readonly = match Self::kernel_writable(path) {
1291                Some(writable) => !writable,
1292                None => {
1293                    let (euid, user_groups) = Self::current_user_groups();
1294                    permissions.is_readonly_for_user(euid, file_uid, file_gid, &user_groups)
1295                }
1296            };
1297            FileMetadata {
1298                size: meta.len(),
1299                modified: meta.modified().ok(),
1300                permissions: Some(permissions),
1301                is_hidden: Self::is_hidden(path),
1302                is_readonly,
1303                uid: Some(file_uid),
1304                gid: Some(file_gid),
1305            }
1306        }
1307        #[cfg(not(unix))]
1308        {
1309            FileMetadata {
1310                size: meta.len(),
1311                modified: meta.modified().ok(),
1312                permissions: Some(FilePermissions::from_std(meta.permissions())),
1313                is_hidden: Self::is_hidden(path),
1314                is_readonly: meta.permissions().readonly(),
1315            }
1316        }
1317    }
1318}
1319
1320impl FileSystem for StdFileSystem {
1321    // File Content Operations
1322    fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
1323        let data = std::fs::read(path)?;
1324        crate::services::counters::global().inc_disk_bytes_read(data.len() as u64);
1325        Ok(data)
1326    }
1327
1328    fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
1329        let mut file = std::fs::File::open(path)?;
1330        file.seek(io::SeekFrom::Start(offset))?;
1331        let mut buffer = vec![0u8; len];
1332        file.read_exact(&mut buffer)?;
1333        crate::services::counters::global().inc_disk_bytes_read(len as u64);
1334        Ok(buffer)
1335    }
1336
1337    fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()> {
1338        let original_metadata = self.metadata_if_exists(path);
1339        let temp_path = self.temp_path_for(path);
1340        {
1341            let mut file = self.create_file(&temp_path)?;
1342            file.write_all(data)?;
1343            file.sync_all()?;
1344        }
1345        if let Some(ref meta) = original_metadata {
1346            if let Some(ref perms) = meta.permissions {
1347                // Best-effort permission restore; rename will proceed regardless
1348                #[allow(clippy::let_underscore_must_use)]
1349                let _ = self.set_permissions(&temp_path, perms);
1350            }
1351        }
1352        self.rename(&temp_path, path)?;
1353        Ok(())
1354    }
1355
1356    fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1357        let file = std::fs::File::create(path)?;
1358        Ok(Box::new(StdFileWriter(file)))
1359    }
1360
1361    fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>> {
1362        let file = std::fs::File::open(path)?;
1363        Ok(Box::new(StdFileReader(file)))
1364    }
1365
1366    fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1367        let file = std::fs::OpenOptions::new()
1368            .write(true)
1369            .truncate(true)
1370            .open(path)?;
1371        Ok(Box::new(StdFileWriter(file)))
1372    }
1373
1374    fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1375        let file = std::fs::OpenOptions::new()
1376            .create(true)
1377            .append(true)
1378            .open(path)?;
1379        Ok(Box::new(StdFileWriter(file)))
1380    }
1381
1382    fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()> {
1383        let file = std::fs::OpenOptions::new().write(true).open(path)?;
1384        file.set_len(len)
1385    }
1386
1387    // File Operations
1388    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
1389        std::fs::rename(from, to)
1390    }
1391
1392    fn copy(&self, from: &Path, to: &Path) -> io::Result<u64> {
1393        std::fs::copy(from, to)
1394    }
1395
1396    fn remove_file(&self, path: &Path) -> io::Result<()> {
1397        std::fs::remove_file(path)
1398    }
1399
1400    fn remove_dir(&self, path: &Path) -> io::Result<()> {
1401        std::fs::remove_dir(path)
1402    }
1403
1404    // Metadata Operations
1405    fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1406        let meta = std::fs::metadata(path)?;
1407        Ok(Self::build_metadata(path, &meta))
1408    }
1409
1410    fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1411        let meta = std::fs::symlink_metadata(path)?;
1412        Ok(Self::build_metadata(path, &meta))
1413    }
1414
1415    fn is_dir(&self, path: &Path) -> io::Result<bool> {
1416        Ok(std::fs::metadata(path)?.is_dir())
1417    }
1418
1419    fn is_file(&self, path: &Path) -> io::Result<bool> {
1420        Ok(std::fs::metadata(path)?.is_file())
1421    }
1422
1423    fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()> {
1424        std::fs::set_permissions(path, permissions.to_std())
1425    }
1426
1427    // Directory Operations
1428    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
1429        let mut entries = Vec::new();
1430        for entry in std::fs::read_dir(path)? {
1431            let entry = entry?;
1432            let path = entry.path();
1433            let name = entry.file_name().to_string_lossy().into_owned();
1434            let file_type = entry.file_type()?;
1435
1436            let entry_type = if file_type.is_dir() {
1437                EntryType::Directory
1438            } else if file_type.is_symlink() {
1439                EntryType::Symlink
1440            } else {
1441                EntryType::File
1442            };
1443
1444            let mut dir_entry = DirEntry::new(path.clone(), name, entry_type);
1445
1446            // For symlinks, check if target is a directory
1447            if file_type.is_symlink() {
1448                dir_entry.symlink_target_is_dir = std::fs::metadata(&path)
1449                    .map(|m| m.is_dir())
1450                    .unwrap_or(false);
1451            }
1452
1453            entries.push(dir_entry);
1454        }
1455        Ok(entries)
1456    }
1457
1458    fn create_dir(&self, path: &Path) -> io::Result<()> {
1459        std::fs::create_dir(path)
1460    }
1461
1462    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
1463        std::fs::create_dir_all(path)
1464    }
1465
1466    // Path Operations
1467    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
1468        std::fs::canonicalize(path)
1469    }
1470
1471    // Utility
1472    fn current_uid(&self) -> u32 {
1473        #[cfg(all(unix, feature = "runtime"))]
1474        {
1475            // SAFETY: getuid() is a simple syscall with no arguments
1476            unsafe { libc::getuid() }
1477        }
1478        #[cfg(not(all(unix, feature = "runtime")))]
1479        {
1480            0
1481        }
1482    }
1483
1484    fn sudo_write(
1485        &self,
1486        path: &Path,
1487        data: &[u8],
1488        mode: u32,
1489        uid: u32,
1490        gid: u32,
1491    ) -> io::Result<()> {
1492        use crate::services::process_hidden::HideWindow;
1493        use std::process::{Command, Stdio};
1494
1495        // Write data via sudo tee
1496        let mut child = Command::new("sudo")
1497            .args(["tee", &path.to_string_lossy()])
1498            .stdin(Stdio::piped())
1499            .stdout(Stdio::null())
1500            .stderr(Stdio::piped())
1501            .hide_window()
1502            .spawn()
1503            .map_err(|e| io::Error::other(format!("failed to spawn sudo: {}", e)))?;
1504
1505        if let Some(mut stdin) = child.stdin.take() {
1506            use std::io::Write;
1507            stdin.write_all(data)?;
1508        }
1509
1510        let output = child.wait_with_output()?;
1511        if !output.status.success() {
1512            let stderr = String::from_utf8_lossy(&output.stderr);
1513            return Err(io::Error::new(
1514                io::ErrorKind::PermissionDenied,
1515                format!("sudo tee failed: {}", stderr.trim()),
1516            ));
1517        }
1518
1519        // Set permissions via sudo chmod
1520        let status = Command::new("sudo")
1521            .args(["chmod", &format!("{:o}", mode), &path.to_string_lossy()])
1522            .hide_window()
1523            .status()?;
1524        if !status.success() {
1525            return Err(io::Error::other("sudo chmod failed"));
1526        }
1527
1528        // Set ownership via sudo chown
1529        let status = Command::new("sudo")
1530            .args([
1531                "chown",
1532                &format!("{}:{}", uid, gid),
1533                &path.to_string_lossy(),
1534            ])
1535            .hide_window()
1536            .status()?;
1537        if !status.success() {
1538            return Err(io::Error::other("sudo chown failed"));
1539        }
1540
1541        Ok(())
1542    }
1543
1544    fn search_file(
1545        &self,
1546        path: &Path,
1547        pattern: &str,
1548        opts: &FileSearchOptions,
1549        cursor: &mut FileSearchCursor,
1550    ) -> io::Result<Vec<SearchMatch>> {
1551        default_search_file(self, path, pattern, opts, cursor)
1552    }
1553
1554    fn walk_files(
1555        &self,
1556        root: &Path,
1557        skip_dirs: &[&str],
1558        cancel: &std::sync::atomic::AtomicBool,
1559        on_file: &mut dyn FnMut(&Path, &str) -> bool,
1560    ) -> io::Result<()> {
1561        let mut stack = vec![root.to_path_buf()];
1562        while let Some(dir) = stack.pop() {
1563            if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1564                return Ok(());
1565            }
1566
1567            // Use std::fs::read_dir iterator directly — NOT self.read_dir()
1568            // which collects into a Vec.  This keeps memory O(1) per directory
1569            // even for directories with millions of entries.
1570            let iter = match std::fs::read_dir(&dir) {
1571                Ok(it) => it,
1572                Err(_) => continue,
1573            };
1574
1575            for entry in iter {
1576                if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1577                    return Ok(());
1578                }
1579                let entry = match entry {
1580                    Ok(e) => e,
1581                    Err(_) => continue,
1582                };
1583                let name = entry.file_name();
1584                let name_str = name.to_string_lossy();
1585
1586                // Skip hidden entries
1587                if name_str.starts_with('.') {
1588                    continue;
1589                }
1590
1591                let ft = match entry.file_type() {
1592                    Ok(ft) => ft,
1593                    Err(_) => continue,
1594                };
1595                let path = entry.path();
1596
1597                if ft.is_file() {
1598                    if let Ok(rel) = path.strip_prefix(root) {
1599                        let rel_str = rel.to_string_lossy().replace('\\', "/");
1600                        if !on_file(&path, &rel_str) {
1601                            return Ok(());
1602                        }
1603                    }
1604                } else if ft.is_dir() && !skip_dirs.contains(&name_str.as_ref()) {
1605                    stack.push(path);
1606                }
1607            }
1608        }
1609        Ok(())
1610    }
1611}
1612
1613// ============================================================================
1614// NoopFileSystem Implementation
1615// ============================================================================
1616
1617/// No-op filesystem that returns errors for all operations
1618///
1619/// Used as a placeholder or in WASM builds where a VirtualFileSystem
1620/// should be used instead.
1621#[derive(Debug, Clone, Copy, Default)]
1622pub struct NoopFileSystem;
1623
1624impl NoopFileSystem {
1625    fn unsupported<T>() -> io::Result<T> {
1626        Err(io::Error::new(
1627            io::ErrorKind::Unsupported,
1628            "Filesystem not available",
1629        ))
1630    }
1631}
1632
1633impl FileSystem for NoopFileSystem {
1634    fn read_file(&self, _path: &Path) -> io::Result<Vec<u8>> {
1635        Self::unsupported()
1636    }
1637
1638    fn read_range(&self, _path: &Path, _offset: u64, _len: usize) -> io::Result<Vec<u8>> {
1639        Self::unsupported()
1640    }
1641
1642    fn write_file(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
1643        Self::unsupported()
1644    }
1645
1646    fn create_file(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1647        Self::unsupported()
1648    }
1649
1650    fn open_file(&self, _path: &Path) -> io::Result<Box<dyn FileReader>> {
1651        Self::unsupported()
1652    }
1653
1654    fn open_file_for_write(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1655        Self::unsupported()
1656    }
1657
1658    fn open_file_for_append(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1659        Self::unsupported()
1660    }
1661
1662    fn set_file_length(&self, _path: &Path, _len: u64) -> io::Result<()> {
1663        Self::unsupported()
1664    }
1665
1666    fn rename(&self, _from: &Path, _to: &Path) -> io::Result<()> {
1667        Self::unsupported()
1668    }
1669
1670    fn copy(&self, _from: &Path, _to: &Path) -> io::Result<u64> {
1671        Self::unsupported()
1672    }
1673
1674    fn remove_file(&self, _path: &Path) -> io::Result<()> {
1675        Self::unsupported()
1676    }
1677
1678    fn remove_dir(&self, _path: &Path) -> io::Result<()> {
1679        Self::unsupported()
1680    }
1681
1682    fn metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1683        Self::unsupported()
1684    }
1685
1686    fn symlink_metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1687        Self::unsupported()
1688    }
1689
1690    fn is_dir(&self, _path: &Path) -> io::Result<bool> {
1691        Self::unsupported()
1692    }
1693
1694    fn is_file(&self, _path: &Path) -> io::Result<bool> {
1695        Self::unsupported()
1696    }
1697
1698    fn set_permissions(&self, _path: &Path, _permissions: &FilePermissions) -> io::Result<()> {
1699        Self::unsupported()
1700    }
1701
1702    fn read_dir(&self, _path: &Path) -> io::Result<Vec<DirEntry>> {
1703        Self::unsupported()
1704    }
1705
1706    fn create_dir(&self, _path: &Path) -> io::Result<()> {
1707        Self::unsupported()
1708    }
1709
1710    fn create_dir_all(&self, _path: &Path) -> io::Result<()> {
1711        Self::unsupported()
1712    }
1713
1714    fn canonicalize(&self, _path: &Path) -> io::Result<PathBuf> {
1715        Self::unsupported()
1716    }
1717
1718    fn current_uid(&self) -> u32 {
1719        0
1720    }
1721
1722    fn search_file(
1723        &self,
1724        _path: &Path,
1725        _pattern: &str,
1726        _opts: &FileSearchOptions,
1727        _cursor: &mut FileSearchCursor,
1728    ) -> io::Result<Vec<SearchMatch>> {
1729        Self::unsupported()
1730    }
1731
1732    fn sudo_write(
1733        &self,
1734        _path: &Path,
1735        _data: &[u8],
1736        _mode: u32,
1737        _uid: u32,
1738        _gid: u32,
1739    ) -> io::Result<()> {
1740        Self::unsupported()
1741    }
1742
1743    fn walk_files(
1744        &self,
1745        _root: &Path,
1746        _skip_dirs: &[&str],
1747        _cancel: &std::sync::atomic::AtomicBool,
1748        _on_file: &mut dyn FnMut(&Path, &str) -> bool,
1749    ) -> io::Result<()> {
1750        Self::unsupported()
1751    }
1752}
1753
1754// ============================================================================
1755// Tests
1756// ============================================================================
1757
1758#[cfg(test)]
1759mod tests {
1760    use super::*;
1761    use tempfile::NamedTempFile;
1762
1763    #[test]
1764    fn test_std_filesystem_read_write() {
1765        let fs = StdFileSystem;
1766        let mut temp = NamedTempFile::new().unwrap();
1767        let path = temp.path().to_path_buf();
1768
1769        std::io::Write::write_all(&mut temp, b"Hello, World!").unwrap();
1770        std::io::Write::flush(&mut temp).unwrap();
1771
1772        let content = fs.read_file(&path).unwrap();
1773        assert_eq!(content, b"Hello, World!");
1774
1775        let range = fs.read_range(&path, 7, 5).unwrap();
1776        assert_eq!(range, b"World");
1777
1778        let meta = fs.metadata(&path).unwrap();
1779        assert_eq!(meta.size, 13);
1780    }
1781
1782    #[test]
1783    fn test_noop_filesystem() {
1784        let fs = NoopFileSystem;
1785        let path = Path::new("/some/path");
1786
1787        assert!(fs.read_file(path).is_err());
1788        assert!(fs.read_range(path, 0, 10).is_err());
1789        assert!(fs.write_file(path, b"data").is_err());
1790        assert!(fs.metadata(path).is_err());
1791        assert!(fs.read_dir(path).is_err());
1792    }
1793
1794    #[test]
1795    fn test_create_and_write_file() {
1796        let fs = StdFileSystem;
1797        let temp_dir = tempfile::tempdir().unwrap();
1798        let path = temp_dir.path().join("test.txt");
1799
1800        {
1801            let mut writer = fs.create_file(&path).unwrap();
1802            writer.write_all(b"test content").unwrap();
1803            writer.sync_all().unwrap();
1804        }
1805
1806        let content = fs.read_file(&path).unwrap();
1807        assert_eq!(content, b"test content");
1808    }
1809
1810    #[test]
1811    fn test_read_dir() {
1812        let fs = StdFileSystem;
1813        let temp_dir = tempfile::tempdir().unwrap();
1814
1815        // Create some files and directories
1816        fs.create_dir(&temp_dir.path().join("subdir")).unwrap();
1817        fs.write_file(&temp_dir.path().join("file1.txt"), b"content1")
1818            .unwrap();
1819        fs.write_file(&temp_dir.path().join("file2.txt"), b"content2")
1820            .unwrap();
1821
1822        let entries = fs.read_dir(temp_dir.path()).unwrap();
1823        assert_eq!(entries.len(), 3);
1824
1825        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
1826        assert!(names.contains(&"subdir"));
1827        assert!(names.contains(&"file1.txt"));
1828        assert!(names.contains(&"file2.txt"));
1829    }
1830
1831    #[test]
1832    fn test_dir_entry_types() {
1833        let file = DirEntry::new(PathBuf::from("/file"), "file".to_string(), EntryType::File);
1834        assert!(file.is_file());
1835        assert!(!file.is_dir());
1836
1837        let dir = DirEntry::new(
1838            PathBuf::from("/dir"),
1839            "dir".to_string(),
1840            EntryType::Directory,
1841        );
1842        assert!(dir.is_dir());
1843        assert!(!dir.is_file());
1844
1845        let link_to_dir = DirEntry::new_symlink(PathBuf::from("/link"), "link".to_string(), true);
1846        assert!(link_to_dir.is_symlink());
1847        assert!(link_to_dir.is_dir());
1848    }
1849
1850    #[test]
1851    fn test_metadata_builder() {
1852        let meta = FileMetadata::default()
1853            .with_hidden(true)
1854            .with_readonly(true);
1855        assert!(meta.is_hidden);
1856        assert!(meta.is_readonly);
1857    }
1858
1859    #[test]
1860    fn test_atomic_write() {
1861        let fs = StdFileSystem;
1862        let temp_dir = tempfile::tempdir().unwrap();
1863        let path = temp_dir.path().join("atomic_test.txt");
1864
1865        fs.write_file(&path, b"initial").unwrap();
1866        assert_eq!(fs.read_file(&path).unwrap(), b"initial");
1867
1868        fs.write_file(&path, b"updated").unwrap();
1869        assert_eq!(fs.read_file(&path).unwrap(), b"updated");
1870    }
1871
1872    #[test]
1873    fn test_write_patched_default_impl() {
1874        // Test that the default write_patched implementation works correctly
1875        let fs = StdFileSystem;
1876        let temp_dir = tempfile::tempdir().unwrap();
1877        let src_path = temp_dir.path().join("source.txt");
1878        let dst_path = temp_dir.path().join("dest.txt");
1879
1880        // Create source file with known content
1881        fs.write_file(&src_path, b"AAABBBCCC").unwrap();
1882
1883        // Apply patch: copy first 3 bytes, insert "XXX", copy last 3 bytes
1884        let ops = vec![
1885            WriteOp::Copy { offset: 0, len: 3 }, // "AAA"
1886            WriteOp::Insert { data: b"XXX" },    // "XXX"
1887            WriteOp::Copy { offset: 6, len: 3 }, // "CCC"
1888        ];
1889
1890        fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1891
1892        let result = fs.read_file(&dst_path).unwrap();
1893        assert_eq!(result, b"AAAXXXCCC");
1894    }
1895
1896    #[test]
1897    fn test_write_patched_same_file() {
1898        // Test patching a file in-place (src == dst)
1899        let fs = StdFileSystem;
1900        let temp_dir = tempfile::tempdir().unwrap();
1901        let path = temp_dir.path().join("file.txt");
1902
1903        // Create file
1904        fs.write_file(&path, b"Hello World").unwrap();
1905
1906        // Replace "World" with "Rust"
1907        let ops = vec![
1908            WriteOp::Copy { offset: 0, len: 6 }, // "Hello "
1909            WriteOp::Insert { data: b"Rust" },   // "Rust"
1910        ];
1911
1912        fs.write_patched(&path, &path, &ops).unwrap();
1913
1914        let result = fs.read_file(&path).unwrap();
1915        assert_eq!(result, b"Hello Rust");
1916    }
1917
1918    #[test]
1919    fn test_write_patched_insert_only() {
1920        // Test a patch with only inserts (new file)
1921        let fs = StdFileSystem;
1922        let temp_dir = tempfile::tempdir().unwrap();
1923        let src_path = temp_dir.path().join("empty.txt");
1924        let dst_path = temp_dir.path().join("new.txt");
1925
1926        // Create empty source (won't be read from)
1927        fs.write_file(&src_path, b"").unwrap();
1928
1929        let ops = vec![WriteOp::Insert {
1930            data: b"All new content",
1931        }];
1932
1933        fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1934
1935        let result = fs.read_file(&dst_path).unwrap();
1936        assert_eq!(result, b"All new content");
1937    }
1938
1939    // ====================================================================
1940    // search_file tests
1941    // ====================================================================
1942
1943    fn make_search_opts(pattern_is_fixed: bool) -> FileSearchOptions {
1944        FileSearchOptions {
1945            fixed_string: pattern_is_fixed,
1946            case_sensitive: true,
1947            whole_word: false,
1948            max_matches: 100,
1949        }
1950    }
1951
1952    #[test]
1953    fn test_search_file_basic() {
1954        let fs = StdFileSystem;
1955        let temp_dir = tempfile::tempdir().unwrap();
1956        let path = temp_dir.path().join("test.txt");
1957        fs.write_file(&path, b"hello world\nfoo bar\nhello again\n")
1958            .unwrap();
1959
1960        let opts = make_search_opts(true);
1961        let mut cursor = FileSearchCursor::new();
1962        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1963
1964        assert!(cursor.done);
1965        assert_eq!(matches.len(), 2);
1966
1967        assert_eq!(matches[0].line, 1);
1968        assert_eq!(matches[0].column, 1);
1969        assert_eq!(matches[0].context, "hello world");
1970
1971        assert_eq!(matches[1].line, 3);
1972        assert_eq!(matches[1].column, 1);
1973        assert_eq!(matches[1].context, "hello again");
1974    }
1975
1976    #[test]
1977    fn test_search_file_no_matches() {
1978        let fs = StdFileSystem;
1979        let temp_dir = tempfile::tempdir().unwrap();
1980        let path = temp_dir.path().join("test.txt");
1981        fs.write_file(&path, b"hello world\n").unwrap();
1982
1983        let opts = make_search_opts(true);
1984        let mut cursor = FileSearchCursor::new();
1985        let matches = fs
1986            .search_file(&path, "NOTFOUND", &opts, &mut cursor)
1987            .unwrap();
1988
1989        assert!(cursor.done);
1990        assert!(matches.is_empty());
1991    }
1992
1993    #[test]
1994    fn test_search_file_case_insensitive() {
1995        let fs = StdFileSystem;
1996        let temp_dir = tempfile::tempdir().unwrap();
1997        let path = temp_dir.path().join("test.txt");
1998        fs.write_file(&path, b"Hello HELLO hello\n").unwrap();
1999
2000        let opts = FileSearchOptions {
2001            fixed_string: true,
2002            case_sensitive: false,
2003            whole_word: false,
2004            max_matches: 100,
2005        };
2006        let mut cursor = FileSearchCursor::new();
2007        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2008
2009        assert_eq!(matches.len(), 3);
2010    }
2011
2012    #[test]
2013    fn test_search_file_whole_word() {
2014        let fs = StdFileSystem;
2015        let temp_dir = tempfile::tempdir().unwrap();
2016        let path = temp_dir.path().join("test.txt");
2017        fs.write_file(&path, b"cat concatenate catalog\n").unwrap();
2018
2019        let opts = FileSearchOptions {
2020            fixed_string: true,
2021            case_sensitive: true,
2022            whole_word: true,
2023            max_matches: 100,
2024        };
2025        let mut cursor = FileSearchCursor::new();
2026        let matches = fs.search_file(&path, "cat", &opts, &mut cursor).unwrap();
2027
2028        assert_eq!(matches.len(), 1);
2029        assert_eq!(matches[0].column, 1);
2030    }
2031
2032    #[test]
2033    fn test_search_file_regex() {
2034        let fs = StdFileSystem;
2035        let temp_dir = tempfile::tempdir().unwrap();
2036        let path = temp_dir.path().join("test.txt");
2037        fs.write_file(&path, b"foo123 bar456 baz\n").unwrap();
2038
2039        let opts = FileSearchOptions {
2040            fixed_string: false,
2041            case_sensitive: true,
2042            whole_word: false,
2043            max_matches: 100,
2044        };
2045        let mut cursor = FileSearchCursor::new();
2046        let matches = fs
2047            .search_file(&path, r"[a-z]+\d+", &opts, &mut cursor)
2048            .unwrap();
2049
2050        assert_eq!(matches.len(), 2);
2051        assert_eq!(matches[0].context, "foo123 bar456 baz");
2052    }
2053
2054    #[test]
2055    fn test_search_file_binary_skipped() {
2056        let fs = StdFileSystem;
2057        let temp_dir = tempfile::tempdir().unwrap();
2058        let path = temp_dir.path().join("binary.dat");
2059        let mut data = b"hello world\n".to_vec();
2060        data.push(0); // null byte makes it binary
2061        data.extend_from_slice(b"hello again\n");
2062        fs.write_file(&path, &data).unwrap();
2063
2064        let opts = make_search_opts(true);
2065        let mut cursor = FileSearchCursor::new();
2066        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2067
2068        assert!(cursor.done);
2069        assert!(matches.is_empty());
2070    }
2071
2072    /// Issue #1342 follow-up (A — extension fast-path): a file with a
2073    /// known-binary extension is skipped without any I/O even when its
2074    /// content happens to be valid UTF-8.  Previously the content
2075    /// heuristic was the only gate, so an ASCII `.png` would be scanned.
2076    #[test]
2077    fn test_search_file_binary_extension_skipped_despite_text_content() {
2078        let fs = StdFileSystem;
2079        let temp_dir = tempfile::tempdir().unwrap();
2080        let path = temp_dir.path().join("not_actually_binary.png");
2081        fs.write_file(&path, b"hello world\nhello again\n").unwrap();
2082
2083        let opts = make_search_opts(true);
2084        let mut cursor = FileSearchCursor::new();
2085        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2086
2087        assert!(cursor.done);
2088        assert!(
2089            matches.is_empty(),
2090            ".png extension should short-circuit before content scan"
2091        );
2092    }
2093
2094    /// Issue #1342 follow-up (A — extension fast-path): the check is
2095    /// case-insensitive (Windows/macOS users frequently see uppercase
2096    /// extensions) and respects multi-segment names like `archive.tar.gz`.
2097    #[test]
2098    fn test_search_file_binary_extension_case_insensitive() {
2099        let fs = StdFileSystem;
2100        let temp_dir = tempfile::tempdir().unwrap();
2101        for name in ["IMG.JPG", "archive.tar.gz", "weights.SafeTensors"] {
2102            let path = temp_dir.path().join(name);
2103            fs.write_file(&path, b"definitely text content here\n")
2104                .unwrap();
2105
2106            let opts = make_search_opts(true);
2107            let mut cursor = FileSearchCursor::new();
2108            let matches = fs
2109                .search_file(&path, "definitely", &opts, &mut cursor)
2110                .unwrap();
2111
2112            assert!(cursor.done, "{} should be marked done", name);
2113            assert!(
2114                matches.is_empty(),
2115                "{} matched but extension should have skipped it",
2116                name
2117            );
2118        }
2119    }
2120
2121    /// Issue #1342 follow-up (B — encoding-aware sniff): UTF-16 with BOM
2122    /// is recognised as text by the editor, but byte-regex search would
2123    /// never match a UTF-8 pattern in interleaved-NUL content.  The
2124    /// sniff must skip it explicitly rather than wasting I/O on regex
2125    /// passes that find nothing.
2126    #[test]
2127    fn test_search_file_utf16_skipped_via_encoding_gate() {
2128        let fs = StdFileSystem;
2129        let temp_dir = tempfile::tempdir().unwrap();
2130        let path = temp_dir.path().join("utf16.txt");
2131        // UTF-16 LE BOM + "hello"
2132        let mut data = vec![0xFF, 0xFE];
2133        for ch in "hello world\nhello again\n".chars() {
2134            let n = ch as u32;
2135            data.push((n & 0xFF) as u8);
2136            data.push(((n >> 8) & 0xFF) as u8);
2137        }
2138        fs.write_file(&path, &data).unwrap();
2139
2140        let opts = make_search_opts(true);
2141        let mut cursor = FileSearchCursor::new();
2142        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2143
2144        assert!(cursor.done);
2145        assert!(
2146            matches.is_empty(),
2147            "UTF-16 file must be skipped: byte-regex can't match UTF-8 patterns in it"
2148        );
2149    }
2150
2151    /// Issue #1342 follow-up (C — mid-stream NUL check): a file whose
2152    /// first 8 KB looks textual but turns binary later (self-extracting
2153    /// installers, mbox + attachment, log + crash dump) used to be
2154    /// regex-scanned end-to-end.  Once a NUL appears in any subsequent
2155    /// chunk, search bails out.
2156    #[test]
2157    fn test_search_file_midstream_nul_aborts_scan() {
2158        let fs = StdFileSystem;
2159        let temp_dir = tempfile::tempdir().unwrap();
2160        let path = temp_dir.path().join("mid.dat");
2161
2162        // 9000 bytes of text — past the 8 KB header window — then a NUL,
2163        // then a marker the regex would otherwise find.
2164        let mut data = vec![b'a'; 9000];
2165        data.push(0);
2166        data.extend_from_slice(b"PATTERN_AFTER_NUL\n");
2167        fs.write_file(&path, &data).unwrap();
2168
2169        let opts = make_search_opts(true);
2170        let mut cursor = FileSearchCursor::new();
2171        let matches = fs
2172            .search_file(&path, "PATTERN_AFTER_NUL", &opts, &mut cursor)
2173            .unwrap();
2174
2175        assert!(cursor.done);
2176        assert!(
2177            matches.is_empty(),
2178            "mid-stream NUL should abort scan and discard pseudo-matches"
2179        );
2180    }
2181
2182    #[test]
2183    fn test_multiline_regex_dotall_implicit() {
2184        // Pattern with a literal newline + a `.+` should span the line break.
2185        let opts = FileSearchOptions {
2186            fixed_string: false,
2187            case_sensitive: true,
2188            whole_word: false,
2189            max_matches: 100,
2190        };
2191        let re = build_search_regex("foo\n.+bar", &opts).unwrap();
2192        assert!(re.is_match(b"foo\nXXXXbar"));
2193        // Sanity: literal mode also matches the embedded newline.
2194        let opts_lit = FileSearchOptions {
2195            fixed_string: true,
2196            ..opts
2197        };
2198        let re_lit = build_search_regex("foo\nbar", &opts_lit).unwrap();
2199        assert!(re_lit.is_match(b"foo\nbar"));
2200    }
2201
2202    #[test]
2203    fn test_search_file_empty_file() {
2204        let fs = StdFileSystem;
2205        let temp_dir = tempfile::tempdir().unwrap();
2206        let path = temp_dir.path().join("empty.txt");
2207        fs.write_file(&path, b"").unwrap();
2208
2209        let opts = make_search_opts(true);
2210        let mut cursor = FileSearchCursor::new();
2211        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2212
2213        assert!(cursor.done);
2214        assert!(matches.is_empty());
2215    }
2216
2217    /// Issue #1342: Search/Replace across project locks up on huge files
2218    /// (multi-GB archives, model weights, audio).  Unbounded scans cap the
2219    /// per-file size; anything larger is treated as binary and skipped.
2220    #[test]
2221    fn test_search_file_oversized_skipped() {
2222        let fs = StdFileSystem;
2223        let temp_dir = tempfile::tempdir().unwrap();
2224        let path = temp_dir.path().join("oversized.txt");
2225
2226        // File slightly larger than the project-search cap, with the search
2227        // pattern only at the very end.  Without the size guard, the scanner
2228        // would chew through the whole file and return a match.
2229        let mut data = vec![b'a'; (MAX_PROJECT_SEARCH_FILE_SIZE as usize) + 1024];
2230        data.extend_from_slice(b"\nUNIQUE_TAIL_MARKER\n");
2231        fs.write_file(&path, &data).unwrap();
2232
2233        let opts = make_search_opts(true);
2234        let mut cursor = FileSearchCursor::new();
2235        let matches = fs
2236            .search_file(&path, "UNIQUE_TAIL_MARKER", &opts, &mut cursor)
2237            .unwrap();
2238
2239        assert!(
2240            cursor.done,
2241            "oversized file should be marked done in one call"
2242        );
2243        assert!(matches.is_empty(), "oversized file should yield no matches");
2244    }
2245
2246    /// Issue #1342: Some binary formats (zip-based archives like .pth, ELF
2247    /// tail headers, etc.) have non-null control bytes in their first 8 KB
2248    /// even when null bytes happen to appear later.  The unbounded scan
2249    /// should reject those just like the null-byte case.
2250    #[test]
2251    fn test_search_file_binary_control_char_skipped() {
2252        let fs = StdFileSystem;
2253        let temp_dir = tempfile::tempdir().unwrap();
2254        let path = temp_dir.path().join("ctrl.dat");
2255        // No null bytes, but a SUB control char (0x1A) — same byte that
2256        // identifies PNG headers and many other binary formats.
2257        let mut data = b"hello world\n".to_vec();
2258        data.push(0x1A);
2259        data.extend_from_slice(b"hello again\n");
2260        fs.write_file(&path, &data).unwrap();
2261
2262        let opts = make_search_opts(true);
2263        let mut cursor = FileSearchCursor::new();
2264        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2265
2266        assert!(cursor.done);
2267        assert!(matches.is_empty());
2268    }
2269
2270    #[test]
2271    fn test_search_file_max_matches() {
2272        let fs = StdFileSystem;
2273        let temp_dir = tempfile::tempdir().unwrap();
2274        let path = temp_dir.path().join("test.txt");
2275        fs.write_file(&path, b"aa bb aa cc aa dd aa\n").unwrap();
2276
2277        let opts = FileSearchOptions {
2278            fixed_string: true,
2279            case_sensitive: true,
2280            whole_word: false,
2281            max_matches: 2,
2282        };
2283        let mut cursor = FileSearchCursor::new();
2284        let matches = fs.search_file(&path, "aa", &opts, &mut cursor).unwrap();
2285
2286        assert_eq!(matches.len(), 2);
2287    }
2288
2289    #[test]
2290    fn test_search_file_cursor_multi_chunk() {
2291        let fs = StdFileSystem;
2292        let temp_dir = tempfile::tempdir().unwrap();
2293        let path = temp_dir.path().join("large.txt");
2294
2295        // Create a file larger than 1MB chunk size to test cursor continuation
2296        let mut content = Vec::new();
2297        for i in 0..100_000 {
2298            content.extend_from_slice(format!("line {} content here\n", i).as_bytes());
2299        }
2300        fs.write_file(&path, &content).unwrap();
2301
2302        let opts = FileSearchOptions {
2303            fixed_string: true,
2304            case_sensitive: true,
2305            whole_word: false,
2306            max_matches: 1000,
2307        };
2308        let mut cursor = FileSearchCursor::new();
2309        let mut all_matches = Vec::new();
2310
2311        while !cursor.done {
2312            let batch = fs
2313                .search_file(&path, "line 5000", &opts, &mut cursor)
2314                .unwrap();
2315            all_matches.extend(batch);
2316        }
2317
2318        // "line 5000" matches: "line 5000 ", "line 50000 "..  "line 50009 "
2319        // = 11 matches (5000, 50000-50009)
2320        assert_eq!(all_matches.len(), 11);
2321
2322        // Verify line numbers are correct
2323        let first = &all_matches[0];
2324        assert_eq!(first.line, 5001); // 0-indexed lines, 1-based line numbers
2325        assert_eq!(first.column, 1);
2326        assert!(first.context.starts_with("line 5000"));
2327    }
2328
2329    #[test]
2330    fn test_search_file_cursor_no_duplicates() {
2331        let fs = StdFileSystem;
2332        let temp_dir = tempfile::tempdir().unwrap();
2333        let path = temp_dir.path().join("large.txt");
2334
2335        // Create file with matches near chunk boundaries
2336        let mut content = Vec::new();
2337        for i in 0..100_000 {
2338            content.extend_from_slice(format!("MARKER_{:06}\n", i).as_bytes());
2339        }
2340        fs.write_file(&path, &content).unwrap();
2341
2342        let opts = FileSearchOptions {
2343            fixed_string: true,
2344            case_sensitive: true,
2345            whole_word: false,
2346            max_matches: 200_000,
2347        };
2348        let mut cursor = FileSearchCursor::new();
2349        let mut all_matches = Vec::new();
2350        let mut batches = 0;
2351
2352        while !cursor.done {
2353            let batch = fs
2354                .search_file(&path, "MARKER_", &opts, &mut cursor)
2355                .unwrap();
2356            all_matches.extend(batch);
2357            batches += 1;
2358        }
2359
2360        // Must have multiple batches (file > 1MB)
2361        assert!(batches > 1, "Expected multiple batches, got {}", batches);
2362        // Exactly one match per line, no duplicates
2363        assert_eq!(all_matches.len(), 100_000);
2364        // Check no duplicate byte offsets
2365        let mut offsets: Vec<usize> = all_matches.iter().map(|m| m.byte_offset).collect();
2366        offsets.sort();
2367        offsets.dedup();
2368        assert_eq!(offsets.len(), 100_000);
2369    }
2370
2371    #[test]
2372    fn test_search_file_line_numbers_across_chunks() {
2373        let fs = StdFileSystem;
2374        let temp_dir = tempfile::tempdir().unwrap();
2375        let path = temp_dir.path().join("large.txt");
2376
2377        // Create file where we know exact line numbers
2378        let mut content = Vec::new();
2379        let total_lines = 100_000;
2380        for i in 0..total_lines {
2381            if i == 99_999 {
2382                content.extend_from_slice(b"FINDME at the end\n");
2383            } else {
2384                content.extend_from_slice(format!("padding line {}\n", i).as_bytes());
2385            }
2386        }
2387        fs.write_file(&path, &content).unwrap();
2388
2389        let opts = make_search_opts(true);
2390        let mut cursor = FileSearchCursor::new();
2391        let mut all_matches = Vec::new();
2392
2393        while !cursor.done {
2394            let batch = fs.search_file(&path, "FINDME", &opts, &mut cursor).unwrap();
2395            all_matches.extend(batch);
2396        }
2397
2398        assert_eq!(all_matches.len(), 1);
2399        assert_eq!(all_matches[0].line, total_lines); // last line
2400        assert_eq!(all_matches[0].context, "FINDME at the end");
2401    }
2402
2403    #[test]
2404    fn test_search_file_end_offset_bounds_search() {
2405        let fs = StdFileSystem;
2406        let temp_dir = tempfile::tempdir().unwrap();
2407        let path = temp_dir.path().join("bounded.txt");
2408
2409        // "AAA\nBBB\nCCC\nDDD\n" — each line is 4 bytes
2410        fs.write_file(&path, b"AAA\nBBB\nCCC\nDDD\n").unwrap();
2411
2412        // Search only the first 8 bytes ("AAA\nBBB\n") — should find AAA and BBB
2413        let opts = make_search_opts(true);
2414        let mut cursor = FileSearchCursor::for_range(0, 8, 1);
2415        let mut matches = Vec::new();
2416        while !cursor.done {
2417            matches.extend(fs.search_file(&path, "AAA", &opts, &mut cursor).unwrap());
2418        }
2419        assert_eq!(matches.len(), 1);
2420        assert_eq!(matches[0].context, "AAA");
2421        assert_eq!(matches[0].line, 1);
2422
2423        // CCC is at byte 8, outside the first 8 bytes
2424        let mut cursor = FileSearchCursor::for_range(0, 8, 1);
2425        let ccc = fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap();
2426        assert!(ccc.is_empty(), "CCC should not be found in first 8 bytes");
2427
2428        // Search bytes 8..16 ("CCC\nDDD\n") — should find CCC
2429        let mut cursor = FileSearchCursor::for_range(8, 16, 3);
2430        let mut matches = Vec::new();
2431        while !cursor.done {
2432            matches.extend(fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap());
2433        }
2434        assert_eq!(matches.len(), 1);
2435        assert_eq!(matches[0].context, "CCC");
2436        assert_eq!(matches[0].line, 3);
2437    }
2438
2439    // ====================================================================
2440    // walk_files tests
2441    // ====================================================================
2442
2443    /// Helper: create a directory tree for walk_files tests.
2444    /// Returns the tempdir (must be kept alive for the duration of the test).
2445    fn make_walk_tree() -> tempfile::TempDir {
2446        let fs = StdFileSystem;
2447        let tmp = tempfile::tempdir().unwrap();
2448        let root = tmp.path();
2449
2450        // root/
2451        //   a.txt
2452        //   b.txt
2453        //   sub/
2454        //     c.txt
2455        //     deep/
2456        //       d.txt
2457        //   .hidden_dir/
2458        //     secret.txt
2459        //   .hidden_file
2460        //   node_modules/
2461        //     pkg.json
2462        //   target/
2463        //     debug.o
2464        fs.write_file(&root.join("a.txt"), b"a").unwrap();
2465        fs.write_file(&root.join("b.txt"), b"b").unwrap();
2466        fs.create_dir_all(&root.join("sub/deep")).unwrap();
2467        fs.write_file(&root.join("sub/c.txt"), b"c").unwrap();
2468        fs.write_file(&root.join("sub/deep/d.txt"), b"d").unwrap();
2469        fs.create_dir_all(&root.join(".hidden_dir")).unwrap();
2470        fs.write_file(&root.join(".hidden_dir/secret.txt"), b"s")
2471            .unwrap();
2472        fs.write_file(&root.join(".hidden_file"), b"h").unwrap();
2473        fs.create_dir_all(&root.join("node_modules")).unwrap();
2474        fs.write_file(&root.join("node_modules/pkg.json"), b"{}")
2475            .unwrap();
2476        fs.create_dir_all(&root.join("target")).unwrap();
2477        fs.write_file(&root.join("target/debug.o"), b"elf").unwrap();
2478
2479        tmp
2480    }
2481
2482    #[test]
2483    fn test_walk_files_std_basic() {
2484        let tmp = make_walk_tree();
2485        let fs = StdFileSystem;
2486        let cancel = std::sync::atomic::AtomicBool::new(false);
2487        let mut found: Vec<String> = Vec::new();
2488
2489        fs.walk_files(
2490            tmp.path(),
2491            &["node_modules", "target"],
2492            &cancel,
2493            &mut |_path, rel| {
2494                found.push(rel.to_string());
2495                true
2496            },
2497        )
2498        .unwrap();
2499
2500        found.sort();
2501        assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt", "sub/deep/d.txt"]);
2502    }
2503
2504    #[test]
2505    fn test_walk_files_std_skips_hidden() {
2506        let tmp = make_walk_tree();
2507        let fs = StdFileSystem;
2508        let cancel = std::sync::atomic::AtomicBool::new(false);
2509        let mut found: Vec<String> = Vec::new();
2510
2511        fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2512            found.push(rel.to_string());
2513            true
2514        })
2515        .unwrap();
2516
2517        // Hidden files/dirs should be excluded, but node_modules and target
2518        // are NOT skipped (empty skip list)
2519        assert!(!found.iter().any(|f| f.contains(".hidden")));
2520        assert!(found.iter().any(|f| f.contains("node_modules")));
2521        assert!(found.iter().any(|f| f.contains("target")));
2522    }
2523
2524    #[test]
2525    fn test_walk_files_std_skip_dirs() {
2526        let tmp = make_walk_tree();
2527        let fs = StdFileSystem;
2528        let cancel = std::sync::atomic::AtomicBool::new(false);
2529        let mut found: Vec<String> = Vec::new();
2530
2531        fs.walk_files(
2532            tmp.path(),
2533            &["node_modules", "target", "deep"],
2534            &cancel,
2535            &mut |_path, rel| {
2536                found.push(rel.to_string());
2537                true
2538            },
2539        )
2540        .unwrap();
2541
2542        found.sort();
2543        // "deep" dir is also skipped, so d.txt should not appear
2544        assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt"]);
2545    }
2546
2547    #[test]
2548    fn test_walk_files_std_cancel() {
2549        let tmp = make_walk_tree();
2550        let fs = StdFileSystem;
2551        let cancel = std::sync::atomic::AtomicBool::new(false);
2552        let mut found: Vec<String> = Vec::new();
2553
2554        fs.walk_files(
2555            tmp.path(),
2556            &["node_modules", "target"],
2557            &cancel,
2558            &mut |_path, rel| {
2559                found.push(rel.to_string());
2560                // Cancel after finding the first file
2561                cancel.store(true, std::sync::atomic::Ordering::Relaxed);
2562                true
2563            },
2564        )
2565        .unwrap();
2566
2567        assert_eq!(found.len(), 1, "Should stop after cancel is set");
2568    }
2569
2570    #[test]
2571    fn test_walk_files_std_on_file_returns_false() {
2572        let tmp = make_walk_tree();
2573        let fs = StdFileSystem;
2574        let cancel = std::sync::atomic::AtomicBool::new(false);
2575        let mut count = 0usize;
2576
2577        fs.walk_files(
2578            tmp.path(),
2579            &["node_modules", "target"],
2580            &cancel,
2581            &mut |_path, _rel| {
2582                count += 1;
2583                count < 2 // stop after 2 files
2584            },
2585        )
2586        .unwrap();
2587
2588        assert_eq!(count, 2, "Should stop when on_file returns false");
2589    }
2590
2591    #[test]
2592    fn test_walk_files_std_empty_dir() {
2593        let tmp = tempfile::tempdir().unwrap();
2594        let fs = StdFileSystem;
2595        let cancel = std::sync::atomic::AtomicBool::new(false);
2596        let mut found: Vec<String> = Vec::new();
2597
2598        fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2599            found.push(rel.to_string());
2600            true
2601        })
2602        .unwrap();
2603
2604        assert!(found.is_empty());
2605    }
2606
2607    #[test]
2608    fn test_walk_files_std_nonexistent_root() {
2609        let fs = StdFileSystem;
2610        let cancel = std::sync::atomic::AtomicBool::new(false);
2611        let mut found: Vec<String> = Vec::new();
2612
2613        // Non-existent root should not panic, just return Ok with no files
2614        let result = fs.walk_files(
2615            Path::new("/nonexistent/path/that/does/not/exist"),
2616            &[],
2617            &cancel,
2618            &mut |_path, rel| {
2619                found.push(rel.to_string());
2620                true
2621            },
2622        );
2623
2624        assert!(result.is_ok());
2625        assert!(found.is_empty());
2626    }
2627
2628    #[test]
2629    fn test_walk_files_std_relative_paths_use_forward_slashes() {
2630        let tmp = make_walk_tree();
2631        let fs = StdFileSystem;
2632        let cancel = std::sync::atomic::AtomicBool::new(false);
2633        let mut found: Vec<String> = Vec::new();
2634
2635        fs.walk_files(
2636            tmp.path(),
2637            &["node_modules", "target"],
2638            &cancel,
2639            &mut |_path, rel| {
2640                found.push(rel.to_string());
2641                true
2642            },
2643        )
2644        .unwrap();
2645
2646        // All paths should use forward slashes (even on Windows)
2647        for path in &found {
2648            assert!(!path.contains('\\'), "Path should use / not \\: {}", path);
2649        }
2650    }
2651
2652    #[test]
2653    fn test_walk_files_noop_returns_error() {
2654        let fs = NoopFileSystem;
2655        let cancel = std::sync::atomic::AtomicBool::new(false);
2656
2657        let result = fs.walk_files(Path::new("/noop/path"), &[], &cancel, &mut |_path, _rel| {
2658            true
2659        });
2660
2661        assert!(result.is_err());
2662        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
2663    }
2664
2665    /// `is_writable()` must agree with the kernel's `faccessat(W_OK, AT_EACCESS)`
2666    /// on a regular, writable file owned by the current user. This pins down the
2667    /// contract that we delegate writability to the kernel — which is what makes
2668    /// the fix for #1765 (POSIX ACLs ignored) correct: the kernel honours ACLs,
2669    /// capabilities, and read-only mounts, while a manual mode-bit walk does not.
2670    #[test]
2671    #[cfg(unix)]
2672    fn test_is_writable_matches_kernel_for_owner_writable() {
2673        use std::os::unix::ffi::OsStrExt;
2674        let fs = StdFileSystem;
2675        let temp_dir = tempfile::tempdir().unwrap();
2676        let path = temp_dir.path().join("writable.txt");
2677        fs.write_file(&path, b"x").unwrap();
2678        fs.set_permissions(&path, &FilePermissions::from_mode(0o600))
2679            .unwrap();
2680
2681        let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
2682        let kernel_writable = unsafe {
2683            libc::faccessat(
2684                libc::AT_FDCWD,
2685                c_path.as_ptr(),
2686                libc::W_OK,
2687                libc::AT_EACCESS,
2688            )
2689        } == 0;
2690        assert!(
2691            kernel_writable,
2692            "owner-writable file must be writable per kernel"
2693        );
2694        assert_eq!(fs.is_writable(&path), kernel_writable);
2695    }
2696
2697    /// Regression test for #1765: a POSIX ACL granting write access to the
2698    /// effective user must be honoured by `is_writable`, even when the inode's
2699    /// "other" mode bits would say the file is read-only.
2700    ///
2701    /// The setup needs three things that aren't available in vanilla CI:
2702    ///   * `setfacl` (acl userspace)
2703    ///   * the ability to chown the test file to a foreign uid (root)
2704    ///   * a non-root uid the test child can switch to (we use `nobody`, 65534)
2705    ///
2706    /// On Linux test runners that have all three, this exercises the exact
2707    /// scenario from the bug report: a file owned by uid 999, mode 0o600,
2708    /// with a named-user ACL granting our user rw — fixed code reports the
2709    /// file writable, the previous mode-bits-only code reported it read-only.
2710    ///
2711    /// Run manually with:
2712    ///   sudo cargo test -p fresh-editor --features runtime \
2713    ///       test_is_writable_respects_posix_acl -- --ignored --nocapture
2714    #[test]
2715    #[ignore = "requires root + setfacl; see test docstring"]
2716    #[cfg(target_os = "linux")]
2717    fn test_is_writable_respects_posix_acl() {
2718        use std::os::unix::ffi::OsStrExt;
2719        use std::process::Command;
2720
2721        // SAFETY: geteuid is always safe.
2722        if unsafe { libc::geteuid() } != 0 {
2723            panic!("test must be run as root (need to chown to a foreign uid)");
2724        }
2725        let setfacl_ok = Command::new("setfacl").arg("--version").output().is_ok();
2726        assert!(setfacl_ok, "setfacl must be installed");
2727
2728        // The non-root uid we drop to in the child. 65534 is the conventional
2729        // "nobody" uid on Linux.
2730        let test_uid: libc::uid_t = 65534;
2731        let test_gid: libc::gid_t = 65534;
2732        // A different "foreign" uid for the file's owner so the test user
2733        // is neither owner nor a group member — i.e. matches against the
2734        // "other" mode bits, which are 0 here.
2735        let foreign_uid: libc::uid_t = 9999;
2736        let foreign_gid: libc::gid_t = 9999;
2737
2738        let temp_dir = tempfile::tempdir().unwrap();
2739        // Ensure the child can traverse into the test dir.
2740        std::fs::set_permissions(
2741            temp_dir.path(),
2742            <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o755),
2743        )
2744        .unwrap();
2745
2746        let file = temp_dir.path().join("acl_test.txt");
2747        std::fs::write(&file, b"hi").unwrap();
2748
2749        let c_file = std::ffi::CString::new(file.as_os_str().as_bytes()).unwrap();
2750        // SAFETY: c_file is a NUL-terminated path; the uids/gids are valid.
2751        let r = unsafe { libc::chown(c_file.as_ptr(), foreign_uid, foreign_gid) };
2752        assert_eq!(r, 0, "chown failed: {}", io::Error::last_os_error());
2753        std::fs::set_permissions(
2754            &file,
2755            <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
2756        )
2757        .unwrap();
2758
2759        let acl_status = Command::new("setfacl")
2760            .args(["-m", &format!("u:{test_uid}:rw")])
2761            .arg(&file)
2762            .status()
2763            .unwrap();
2764        assert!(
2765            acl_status.success(),
2766            "setfacl failed (does the filesystem support ACLs?)",
2767        );
2768
2769        // Fork + setuid in the child. Keep the child's work to bare syscalls
2770        // and exit via _exit (skipping atexit handlers) to stay safe in a
2771        // multi-threaded test runner.
2772        // SAFETY: fork is allowed, but the child must avoid touching shared
2773        // mutable state. We only call setgid/setuid + a single metadata read,
2774        // then _exit.
2775        let pid = unsafe { libc::fork() };
2776        if pid < 0 {
2777            panic!("fork failed: {}", io::Error::last_os_error());
2778        }
2779        if pid == 0 {
2780            // Child
2781            // SAFETY: setgid/setuid are async-signal-safe.
2782            if unsafe { libc::setgid(test_gid) } != 0 {
2783                unsafe { libc::_exit(2) };
2784            }
2785            if unsafe { libc::setuid(test_uid) } != 0 {
2786                unsafe { libc::_exit(3) };
2787            }
2788            let writable = StdFileSystem.is_writable(&file);
2789            unsafe { libc::_exit(if writable { 0 } else { 1 }) };
2790        }
2791
2792        // Parent
2793        let mut status: libc::c_int = 0;
2794        // SAFETY: pid is a valid child; status is a writable c_int.
2795        let r = unsafe { libc::waitpid(pid, &mut status, 0) };
2796        assert!(r > 0, "waitpid failed: {}", io::Error::last_os_error());
2797        let exited_normally = (status & 0x7f) == 0;
2798        let exit_code = (status >> 8) & 0xff;
2799        assert!(
2800            exited_normally,
2801            "child terminated abnormally; status={status}"
2802        );
2803        assert_eq!(
2804            exit_code, 0,
2805            "child reported file NOT writable (exit_code={exit_code}); ACL was ignored",
2806        );
2807    }
2808}