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    regex::bytes::Regex::new(&re_pattern)
884        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
885}
886
887/// Maximum per-file size for unbounded project-wide search.  Files larger
888/// than this are treated as binary and skipped: searching multi-gigabyte
889/// archives, model weights, or audio files would otherwise lock up the
890/// editor for minutes (issue #1342).  Source files virtually never exceed
891/// this limit; users wanting to search huge text logs should open them
892/// directly so hybrid buffer search applies.
893pub const MAX_PROJECT_SEARCH_FILE_SIZE: u64 = 10 * 1024 * 1024;
894
895/// Filename extensions that always represent binary content for project
896/// search.  Files matching this list are skipped before any I/O — no
897/// `stat`, no header read — which both avoids waste on the obvious cases
898/// and removes the dependency on heuristic content detection for formats
899/// whose first 8 KB can occasionally look text-like.
900///
901/// Kept as a sorted ASCII-lowercase list and matched case-insensitively
902/// via `eq_ignore_ascii_case` (no allocation per file).
903pub const BINARY_FILE_EXTENSIONS: &[&str] = &[
904    // Compiled binaries / object code / libraries
905    "a",
906    "class",
907    "dll",
908    "dylib",
909    "exe",
910    "jar",
911    "lib",
912    "o",
913    "obj",
914    "pdb",
915    "pyc",
916    "pyo",
917    "so",
918    "wasm",
919    "war",
920    // Archives / compressed
921    "7z",
922    "br",
923    "bz2",
924    "gz",
925    "lz",
926    "lz4",
927    "lzma",
928    "rar",
929    "tar",
930    "tbz",
931    "tbz2",
932    "tgz",
933    "txz",
934    "xz",
935    "z",
936    "zip",
937    "zst",
938    // Disk images / installers / packages
939    "apk",
940    "deb",
941    "dmg",
942    "img",
943    "ipa",
944    "iso",
945    "msi",
946    "rpm",
947    "vhd",
948    "vmdk",
949    // Images
950    "bmp",
951    "gif",
952    "heic",
953    "heif",
954    "ico",
955    "jp2",
956    "jpe",
957    "jpeg",
958    "jpg",
959    "png",
960    "psd",
961    "raw",
962    "tif",
963    "tiff",
964    "webp",
965    // Audio / video
966    "aac",
967    "aif",
968    "aiff",
969    "avi",
970    "flac",
971    "flv",
972    "m4a",
973    "m4v",
974    "mid",
975    "midi",
976    "mka",
977    "mkv",
978    "mov",
979    "mp3",
980    "mp4",
981    "mpeg",
982    "mpg",
983    "ogg",
984    "opus",
985    "wav",
986    "webm",
987    "wma",
988    "wmv",
989    // Office / compound documents
990    "doc",
991    "docx",
992    "odp",
993    "ods",
994    "odt",
995    "pdf",
996    "ppt",
997    "pptx",
998    "rtf",
999    "xls",
1000    "xlsx",
1001    // Databases
1002    "db",
1003    "mdb",
1004    "sqlite",
1005    "sqlite3",
1006    // Fonts
1007    "eot",
1008    "otf",
1009    "ttc",
1010    "ttf",
1011    "woff",
1012    "woff2",
1013    // ML / scientific data
1014    "ckpt",
1015    "h5",
1016    "hdf5",
1017    "msgpack",
1018    "npy",
1019    "npz",
1020    "onnx",
1021    "pb",
1022    "pickle",
1023    "pkl",
1024    "pt",
1025    "pth",
1026    "safetensors",
1027    "tflite",
1028    // Generic binary
1029    "bin",
1030    "dat",
1031    "swf",
1032];
1033
1034/// True if `path`'s extension matches a known binary format.
1035fn has_binary_extension(path: &Path) -> bool {
1036    let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
1037        return false;
1038    };
1039    BINARY_FILE_EXTENSIONS
1040        .iter()
1041        .any(|candidate| candidate.eq_ignore_ascii_case(ext))
1042}
1043
1044/// True if a UTF-8/byte-level regex can meaningfully match content of
1045/// this encoding.  UTF-16/UTF-32 carry interleaved NUL bytes for ASCII
1046/// characters, so byte-regex search would never find a UTF-8 pattern in
1047/// them; correct support requires transcoding the chunk first, which we
1048/// don't do yet.  All single-byte ASCII-superset encodings (UTF-8,
1049/// Latin-1, Windows-12xx) and CJK encodings (lead+trail bytes ≥ 0x40)
1050/// match a UTF-8 pattern in their ASCII portion just fine.
1051fn is_byte_searchable_encoding(enc: crate::model::encoding::Encoding) -> bool {
1052    use crate::model::encoding::Encoding;
1053    !matches!(enc, Encoding::Utf16Le | Encoding::Utf16Be)
1054}
1055
1056/// Default implementation of `FileSystem::search_file` that works for any
1057/// filesystem backend.  Reads one chunk via `read_range`, scans with the
1058/// given regex, and returns matches with line/column/context.
1059pub fn default_search_file(
1060    fs: &dyn FileSystem,
1061    path: &Path,
1062    pattern: &str,
1063    opts: &FileSearchOptions,
1064    cursor: &mut FileSearchCursor,
1065) -> io::Result<Vec<SearchMatch>> {
1066    if cursor.done {
1067        return Ok(vec![]);
1068    }
1069
1070    const CHUNK_SIZE: usize = 1_048_576; // 1 MB
1071    let overlap = pattern.len().max(256);
1072
1073    // Pre-flight checks for unbounded scans.  Bounded scans (hybrid
1074    // buffer search) intentionally bypass all of these — they run only
1075    // when the user has explicitly opened the file as text, and we trust
1076    // the editor's load-time decision.
1077    if cursor.offset == 0 && cursor.end_offset.is_none() {
1078        // Extension fast-path: skip known-binary formats with no I/O at
1079        // all (no stat, no header read).
1080        if has_binary_extension(path) {
1081            cursor.done = true;
1082            return Ok(vec![]);
1083        }
1084    }
1085
1086    let meta = fs.metadata(path)?;
1087    let file_size = meta.size;
1088    let file_len = file_size as usize;
1089    let effective_end = cursor.end_offset.unwrap_or(file_len).min(file_len);
1090
1091    if cursor.offset == 0 && cursor.end_offset.is_none() {
1092        if file_size == 0 {
1093            cursor.done = true;
1094            return Ok(vec![]);
1095        }
1096        // Skip files that exceed the project-search size cap.  Multi-gigabyte
1097        // archives, model weights, and audio files would otherwise lock up
1098        // the editor (issue #1342).
1099        if file_size > MAX_PROJECT_SEARCH_FILE_SIZE {
1100            cursor.done = true;
1101            return Ok(vec![]);
1102        }
1103        // Delegate the header sniff to the same encoding detector the
1104        // buffer loader uses, so search and editor agree on what's text.
1105        // The detector handles BOMs, UTF-16 statistical detection, and
1106        // the full set of "non-text control char" indicators.  We then
1107        // additionally reject encodings byte-regex can't meaningfully
1108        // match (UTF-16/32) until we add transcoding.
1109        let header_len = file_len.min(8192);
1110        let header = fs.read_range(path, 0, header_len)?;
1111        let truncated = header_len < file_len;
1112        let (encoding, is_binary) =
1113            crate::model::encoding::detect_encoding_or_binary(&header, truncated);
1114        if is_binary || !is_byte_searchable_encoding(encoding) {
1115            cursor.done = true;
1116            return Ok(vec![]);
1117        }
1118    }
1119
1120    if cursor.offset >= effective_end {
1121        cursor.done = true;
1122        return Ok(vec![]);
1123    }
1124
1125    let regex = build_search_regex(pattern, opts)?;
1126
1127    // Read chunk with overlap from previous
1128    let read_start = cursor.offset.saturating_sub(overlap);
1129    let read_end = (read_start + CHUNK_SIZE).min(effective_end);
1130    let chunk = fs.read_range(path, read_start as u64, read_end - read_start)?;
1131
1132    let overlap_len = cursor.offset - read_start;
1133
1134    // Mid-stream binary detection.  The 8 KB header check at the top of
1135    // the function only sees the start of the file, but plenty of formats
1136    // are text in their first few KB and binary thereafter (self-extracting
1137    // installers, mbox files with attachments, log files with embedded
1138    // crash dumps).  A NUL byte in any subsequent chunk is the strongest
1139    // indicator the file isn't text we should be searching, so bail out
1140    // and discard whatever pseudo-matches the regex would have found.
1141    // Bounded scans skip this — they're searching a delimited region of
1142    // an already-loaded text buffer where NULs may legitimately appear
1143    // mid-stream (e.g., cursor sentinels in piece-tree leaves).
1144    if cursor.end_offset.is_none() && chunk[overlap_len..].contains(&0) {
1145        cursor.done = true;
1146        return Ok(vec![]);
1147    }
1148
1149    // Incremental line counting (same algorithm as search_scan_next_chunk)
1150    let newlines_in_overlap = chunk[..overlap_len].iter().filter(|&&b| b == b'\n').count();
1151    let mut line_at = cursor.running_line.saturating_sub(newlines_in_overlap);
1152    let mut counted_to = 0usize;
1153    let mut matches = Vec::new();
1154
1155    for m in regex.find_iter(&chunk) {
1156        // Skip matches in overlap region (already reported in previous batch)
1157        if overlap_len > 0 && m.end() <= overlap_len {
1158            continue;
1159        }
1160        if matches.len() >= opts.max_matches {
1161            break;
1162        }
1163
1164        // Count newlines from last position to this match
1165        line_at += chunk[counted_to..m.start()]
1166            .iter()
1167            .filter(|&&b| b == b'\n')
1168            .count();
1169        counted_to = m.start();
1170
1171        // Find line boundaries for context
1172        let line_start = chunk[..m.start()]
1173            .iter()
1174            .rposition(|&b| b == b'\n')
1175            .map(|p| p + 1)
1176            .unwrap_or(0);
1177        let line_end = chunk[m.start()..]
1178            .iter()
1179            .position(|&b| b == b'\n')
1180            .map(|p| m.start() + p)
1181            .unwrap_or(chunk.len());
1182
1183        let column = m.start() - line_start + 1;
1184        let context = String::from_utf8_lossy(&chunk[line_start..line_end]).into_owned();
1185
1186        matches.push(SearchMatch {
1187            byte_offset: read_start + m.start(),
1188            length: m.end() - m.start(),
1189            line: line_at,
1190            column,
1191            context,
1192        });
1193    }
1194
1195    // Advance cursor
1196    let new_data = &chunk[overlap_len..];
1197    cursor.running_line += new_data.iter().filter(|&&b| b == b'\n').count();
1198    cursor.offset = read_end;
1199    if read_end >= effective_end {
1200        cursor.done = true;
1201    }
1202
1203    Ok(matches)
1204}
1205
1206// ============================================================================
1207// StdFileSystem Implementation
1208// ============================================================================
1209
1210/// Standard filesystem implementation using `std::fs`
1211///
1212/// This is the default implementation for native builds.
1213#[derive(Debug, Clone, Copy, Default)]
1214pub struct StdFileSystem;
1215
1216impl StdFileSystem {
1217    /// Check if a file is hidden (platform-specific)
1218    fn is_hidden(path: &Path) -> bool {
1219        path.file_name()
1220            .and_then(|n| n.to_str())
1221            .is_some_and(|n| n.starts_with('.'))
1222    }
1223
1224    /// Get the current user's effective UID and all group IDs (primary + supplementary).
1225    #[cfg(unix)]
1226    pub fn current_user_groups() -> (u32, Vec<u32>) {
1227        // SAFETY: these libc calls are always safe and have no failure modes
1228        let euid = unsafe { libc::geteuid() };
1229        let egid = unsafe { libc::getegid() };
1230        let mut groups = vec![egid];
1231
1232        // Get supplementary groups
1233        let ngroups = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
1234        if ngroups > 0 {
1235            let mut sup_groups = vec![0 as libc::gid_t; ngroups as usize];
1236            let n = unsafe { libc::getgroups(ngroups, sup_groups.as_mut_ptr()) };
1237            if n > 0 {
1238                sup_groups.truncate(n as usize);
1239                for g in sup_groups {
1240                    if g != egid {
1241                        groups.push(g);
1242                    }
1243                }
1244            }
1245        }
1246
1247        (euid, groups)
1248    }
1249
1250    /// Ask the kernel whether the effective user can write to `path`.
1251    ///
1252    /// Uses `faccessat(AT_FDCWD, path, W_OK, AT_EACCESS)`, which respects POSIX
1253    /// ACLs, capabilities, and read-only mounts — all of which a manual mode-bit
1254    /// check would miss. Returns `None` if the path can't be encoded as a
1255    /// C string; callers should fall back to mode-bit checks in that case.
1256    #[cfg(unix)]
1257    fn kernel_writable(path: &Path) -> Option<bool> {
1258        use std::os::unix::ffi::OsStrExt;
1259        let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1260        // SAFETY: c_path is a valid NUL-terminated C string for the lifetime of
1261        // this call; AT_FDCWD, W_OK and AT_EACCESS are well-defined constants.
1262        let rc = unsafe {
1263            libc::faccessat(
1264                libc::AT_FDCWD,
1265                c_path.as_ptr(),
1266                libc::W_OK,
1267                libc::AT_EACCESS,
1268            )
1269        };
1270        Some(rc == 0)
1271    }
1272
1273    /// Build FileMetadata from std::fs::Metadata
1274    fn build_metadata(path: &Path, meta: &std::fs::Metadata) -> FileMetadata {
1275        #[cfg(unix)]
1276        {
1277            use std::os::unix::fs::MetadataExt;
1278            let file_uid = meta.uid();
1279            let file_gid = meta.gid();
1280            let permissions = FilePermissions::from_std(meta.permissions());
1281            // Prefer the kernel's view (respects POSIX ACLs, capabilities,
1282            // read-only mounts); fall back to mode bits if the syscall can't
1283            // be issued for this path.
1284            let is_readonly = match Self::kernel_writable(path) {
1285                Some(writable) => !writable,
1286                None => {
1287                    let (euid, user_groups) = Self::current_user_groups();
1288                    permissions.is_readonly_for_user(euid, file_uid, file_gid, &user_groups)
1289                }
1290            };
1291            FileMetadata {
1292                size: meta.len(),
1293                modified: meta.modified().ok(),
1294                permissions: Some(permissions),
1295                is_hidden: Self::is_hidden(path),
1296                is_readonly,
1297                uid: Some(file_uid),
1298                gid: Some(file_gid),
1299            }
1300        }
1301        #[cfg(not(unix))]
1302        {
1303            FileMetadata {
1304                size: meta.len(),
1305                modified: meta.modified().ok(),
1306                permissions: Some(FilePermissions::from_std(meta.permissions())),
1307                is_hidden: Self::is_hidden(path),
1308                is_readonly: meta.permissions().readonly(),
1309            }
1310        }
1311    }
1312}
1313
1314impl FileSystem for StdFileSystem {
1315    // File Content Operations
1316    fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
1317        let data = std::fs::read(path)?;
1318        crate::services::counters::global().inc_disk_bytes_read(data.len() as u64);
1319        Ok(data)
1320    }
1321
1322    fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
1323        let mut file = std::fs::File::open(path)?;
1324        file.seek(io::SeekFrom::Start(offset))?;
1325        let mut buffer = vec![0u8; len];
1326        file.read_exact(&mut buffer)?;
1327        crate::services::counters::global().inc_disk_bytes_read(len as u64);
1328        Ok(buffer)
1329    }
1330
1331    fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()> {
1332        let original_metadata = self.metadata_if_exists(path);
1333        let temp_path = self.temp_path_for(path);
1334        {
1335            let mut file = self.create_file(&temp_path)?;
1336            file.write_all(data)?;
1337            file.sync_all()?;
1338        }
1339        if let Some(ref meta) = original_metadata {
1340            if let Some(ref perms) = meta.permissions {
1341                // Best-effort permission restore; rename will proceed regardless
1342                #[allow(clippy::let_underscore_must_use)]
1343                let _ = self.set_permissions(&temp_path, perms);
1344            }
1345        }
1346        self.rename(&temp_path, path)?;
1347        Ok(())
1348    }
1349
1350    fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1351        let file = std::fs::File::create(path)?;
1352        Ok(Box::new(StdFileWriter(file)))
1353    }
1354
1355    fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>> {
1356        let file = std::fs::File::open(path)?;
1357        Ok(Box::new(StdFileReader(file)))
1358    }
1359
1360    fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1361        let file = std::fs::OpenOptions::new()
1362            .write(true)
1363            .truncate(true)
1364            .open(path)?;
1365        Ok(Box::new(StdFileWriter(file)))
1366    }
1367
1368    fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1369        let file = std::fs::OpenOptions::new()
1370            .create(true)
1371            .append(true)
1372            .open(path)?;
1373        Ok(Box::new(StdFileWriter(file)))
1374    }
1375
1376    fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()> {
1377        let file = std::fs::OpenOptions::new().write(true).open(path)?;
1378        file.set_len(len)
1379    }
1380
1381    // File Operations
1382    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
1383        std::fs::rename(from, to)
1384    }
1385
1386    fn copy(&self, from: &Path, to: &Path) -> io::Result<u64> {
1387        std::fs::copy(from, to)
1388    }
1389
1390    fn remove_file(&self, path: &Path) -> io::Result<()> {
1391        std::fs::remove_file(path)
1392    }
1393
1394    fn remove_dir(&self, path: &Path) -> io::Result<()> {
1395        std::fs::remove_dir(path)
1396    }
1397
1398    // Metadata Operations
1399    fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1400        let meta = std::fs::metadata(path)?;
1401        Ok(Self::build_metadata(path, &meta))
1402    }
1403
1404    fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1405        let meta = std::fs::symlink_metadata(path)?;
1406        Ok(Self::build_metadata(path, &meta))
1407    }
1408
1409    fn is_dir(&self, path: &Path) -> io::Result<bool> {
1410        Ok(std::fs::metadata(path)?.is_dir())
1411    }
1412
1413    fn is_file(&self, path: &Path) -> io::Result<bool> {
1414        Ok(std::fs::metadata(path)?.is_file())
1415    }
1416
1417    fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()> {
1418        std::fs::set_permissions(path, permissions.to_std())
1419    }
1420
1421    // Directory Operations
1422    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
1423        let mut entries = Vec::new();
1424        for entry in std::fs::read_dir(path)? {
1425            let entry = entry?;
1426            let path = entry.path();
1427            let name = entry.file_name().to_string_lossy().into_owned();
1428            let file_type = entry.file_type()?;
1429
1430            let entry_type = if file_type.is_dir() {
1431                EntryType::Directory
1432            } else if file_type.is_symlink() {
1433                EntryType::Symlink
1434            } else {
1435                EntryType::File
1436            };
1437
1438            let mut dir_entry = DirEntry::new(path.clone(), name, entry_type);
1439
1440            // For symlinks, check if target is a directory
1441            if file_type.is_symlink() {
1442                dir_entry.symlink_target_is_dir = std::fs::metadata(&path)
1443                    .map(|m| m.is_dir())
1444                    .unwrap_or(false);
1445            }
1446
1447            entries.push(dir_entry);
1448        }
1449        Ok(entries)
1450    }
1451
1452    fn create_dir(&self, path: &Path) -> io::Result<()> {
1453        std::fs::create_dir(path)
1454    }
1455
1456    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
1457        std::fs::create_dir_all(path)
1458    }
1459
1460    // Path Operations
1461    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
1462        std::fs::canonicalize(path)
1463    }
1464
1465    // Utility
1466    fn current_uid(&self) -> u32 {
1467        #[cfg(all(unix, feature = "runtime"))]
1468        {
1469            // SAFETY: getuid() is a simple syscall with no arguments
1470            unsafe { libc::getuid() }
1471        }
1472        #[cfg(not(all(unix, feature = "runtime")))]
1473        {
1474            0
1475        }
1476    }
1477
1478    fn sudo_write(
1479        &self,
1480        path: &Path,
1481        data: &[u8],
1482        mode: u32,
1483        uid: u32,
1484        gid: u32,
1485    ) -> io::Result<()> {
1486        use crate::services::process_hidden::HideWindow;
1487        use std::process::{Command, Stdio};
1488
1489        // Write data via sudo tee
1490        let mut child = Command::new("sudo")
1491            .args(["tee", &path.to_string_lossy()])
1492            .stdin(Stdio::piped())
1493            .stdout(Stdio::null())
1494            .stderr(Stdio::piped())
1495            .hide_window()
1496            .spawn()
1497            .map_err(|e| io::Error::other(format!("failed to spawn sudo: {}", e)))?;
1498
1499        if let Some(mut stdin) = child.stdin.take() {
1500            use std::io::Write;
1501            stdin.write_all(data)?;
1502        }
1503
1504        let output = child.wait_with_output()?;
1505        if !output.status.success() {
1506            let stderr = String::from_utf8_lossy(&output.stderr);
1507            return Err(io::Error::new(
1508                io::ErrorKind::PermissionDenied,
1509                format!("sudo tee failed: {}", stderr.trim()),
1510            ));
1511        }
1512
1513        // Set permissions via sudo chmod
1514        let status = Command::new("sudo")
1515            .args(["chmod", &format!("{:o}", mode), &path.to_string_lossy()])
1516            .hide_window()
1517            .status()?;
1518        if !status.success() {
1519            return Err(io::Error::other("sudo chmod failed"));
1520        }
1521
1522        // Set ownership via sudo chown
1523        let status = Command::new("sudo")
1524            .args([
1525                "chown",
1526                &format!("{}:{}", uid, gid),
1527                &path.to_string_lossy(),
1528            ])
1529            .hide_window()
1530            .status()?;
1531        if !status.success() {
1532            return Err(io::Error::other("sudo chown failed"));
1533        }
1534
1535        Ok(())
1536    }
1537
1538    fn search_file(
1539        &self,
1540        path: &Path,
1541        pattern: &str,
1542        opts: &FileSearchOptions,
1543        cursor: &mut FileSearchCursor,
1544    ) -> io::Result<Vec<SearchMatch>> {
1545        default_search_file(self, path, pattern, opts, cursor)
1546    }
1547
1548    fn walk_files(
1549        &self,
1550        root: &Path,
1551        skip_dirs: &[&str],
1552        cancel: &std::sync::atomic::AtomicBool,
1553        on_file: &mut dyn FnMut(&Path, &str) -> bool,
1554    ) -> io::Result<()> {
1555        let mut stack = vec![root.to_path_buf()];
1556        while let Some(dir) = stack.pop() {
1557            if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1558                return Ok(());
1559            }
1560
1561            // Use std::fs::read_dir iterator directly — NOT self.read_dir()
1562            // which collects into a Vec.  This keeps memory O(1) per directory
1563            // even for directories with millions of entries.
1564            let iter = match std::fs::read_dir(&dir) {
1565                Ok(it) => it,
1566                Err(_) => continue,
1567            };
1568
1569            for entry in iter {
1570                if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1571                    return Ok(());
1572                }
1573                let entry = match entry {
1574                    Ok(e) => e,
1575                    Err(_) => continue,
1576                };
1577                let name = entry.file_name();
1578                let name_str = name.to_string_lossy();
1579
1580                // Skip hidden entries
1581                if name_str.starts_with('.') {
1582                    continue;
1583                }
1584
1585                let ft = match entry.file_type() {
1586                    Ok(ft) => ft,
1587                    Err(_) => continue,
1588                };
1589                let path = entry.path();
1590
1591                if ft.is_file() {
1592                    if let Ok(rel) = path.strip_prefix(root) {
1593                        let rel_str = rel.to_string_lossy().replace('\\', "/");
1594                        if !on_file(&path, &rel_str) {
1595                            return Ok(());
1596                        }
1597                    }
1598                } else if ft.is_dir() && !skip_dirs.contains(&name_str.as_ref()) {
1599                    stack.push(path);
1600                }
1601            }
1602        }
1603        Ok(())
1604    }
1605}
1606
1607// ============================================================================
1608// NoopFileSystem Implementation
1609// ============================================================================
1610
1611/// No-op filesystem that returns errors for all operations
1612///
1613/// Used as a placeholder or in WASM builds where a VirtualFileSystem
1614/// should be used instead.
1615#[derive(Debug, Clone, Copy, Default)]
1616pub struct NoopFileSystem;
1617
1618impl NoopFileSystem {
1619    fn unsupported<T>() -> io::Result<T> {
1620        Err(io::Error::new(
1621            io::ErrorKind::Unsupported,
1622            "Filesystem not available",
1623        ))
1624    }
1625}
1626
1627impl FileSystem for NoopFileSystem {
1628    fn read_file(&self, _path: &Path) -> io::Result<Vec<u8>> {
1629        Self::unsupported()
1630    }
1631
1632    fn read_range(&self, _path: &Path, _offset: u64, _len: usize) -> io::Result<Vec<u8>> {
1633        Self::unsupported()
1634    }
1635
1636    fn write_file(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
1637        Self::unsupported()
1638    }
1639
1640    fn create_file(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1641        Self::unsupported()
1642    }
1643
1644    fn open_file(&self, _path: &Path) -> io::Result<Box<dyn FileReader>> {
1645        Self::unsupported()
1646    }
1647
1648    fn open_file_for_write(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1649        Self::unsupported()
1650    }
1651
1652    fn open_file_for_append(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1653        Self::unsupported()
1654    }
1655
1656    fn set_file_length(&self, _path: &Path, _len: u64) -> io::Result<()> {
1657        Self::unsupported()
1658    }
1659
1660    fn rename(&self, _from: &Path, _to: &Path) -> io::Result<()> {
1661        Self::unsupported()
1662    }
1663
1664    fn copy(&self, _from: &Path, _to: &Path) -> io::Result<u64> {
1665        Self::unsupported()
1666    }
1667
1668    fn remove_file(&self, _path: &Path) -> io::Result<()> {
1669        Self::unsupported()
1670    }
1671
1672    fn remove_dir(&self, _path: &Path) -> io::Result<()> {
1673        Self::unsupported()
1674    }
1675
1676    fn metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1677        Self::unsupported()
1678    }
1679
1680    fn symlink_metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1681        Self::unsupported()
1682    }
1683
1684    fn is_dir(&self, _path: &Path) -> io::Result<bool> {
1685        Self::unsupported()
1686    }
1687
1688    fn is_file(&self, _path: &Path) -> io::Result<bool> {
1689        Self::unsupported()
1690    }
1691
1692    fn set_permissions(&self, _path: &Path, _permissions: &FilePermissions) -> io::Result<()> {
1693        Self::unsupported()
1694    }
1695
1696    fn read_dir(&self, _path: &Path) -> io::Result<Vec<DirEntry>> {
1697        Self::unsupported()
1698    }
1699
1700    fn create_dir(&self, _path: &Path) -> io::Result<()> {
1701        Self::unsupported()
1702    }
1703
1704    fn create_dir_all(&self, _path: &Path) -> io::Result<()> {
1705        Self::unsupported()
1706    }
1707
1708    fn canonicalize(&self, _path: &Path) -> io::Result<PathBuf> {
1709        Self::unsupported()
1710    }
1711
1712    fn current_uid(&self) -> u32 {
1713        0
1714    }
1715
1716    fn search_file(
1717        &self,
1718        _path: &Path,
1719        _pattern: &str,
1720        _opts: &FileSearchOptions,
1721        _cursor: &mut FileSearchCursor,
1722    ) -> io::Result<Vec<SearchMatch>> {
1723        Self::unsupported()
1724    }
1725
1726    fn sudo_write(
1727        &self,
1728        _path: &Path,
1729        _data: &[u8],
1730        _mode: u32,
1731        _uid: u32,
1732        _gid: u32,
1733    ) -> io::Result<()> {
1734        Self::unsupported()
1735    }
1736
1737    fn walk_files(
1738        &self,
1739        _root: &Path,
1740        _skip_dirs: &[&str],
1741        _cancel: &std::sync::atomic::AtomicBool,
1742        _on_file: &mut dyn FnMut(&Path, &str) -> bool,
1743    ) -> io::Result<()> {
1744        Self::unsupported()
1745    }
1746}
1747
1748// ============================================================================
1749// Tests
1750// ============================================================================
1751
1752#[cfg(test)]
1753mod tests {
1754    use super::*;
1755    use tempfile::NamedTempFile;
1756
1757    #[test]
1758    fn test_std_filesystem_read_write() {
1759        let fs = StdFileSystem;
1760        let mut temp = NamedTempFile::new().unwrap();
1761        let path = temp.path().to_path_buf();
1762
1763        std::io::Write::write_all(&mut temp, b"Hello, World!").unwrap();
1764        std::io::Write::flush(&mut temp).unwrap();
1765
1766        let content = fs.read_file(&path).unwrap();
1767        assert_eq!(content, b"Hello, World!");
1768
1769        let range = fs.read_range(&path, 7, 5).unwrap();
1770        assert_eq!(range, b"World");
1771
1772        let meta = fs.metadata(&path).unwrap();
1773        assert_eq!(meta.size, 13);
1774    }
1775
1776    #[test]
1777    fn test_noop_filesystem() {
1778        let fs = NoopFileSystem;
1779        let path = Path::new("/some/path");
1780
1781        assert!(fs.read_file(path).is_err());
1782        assert!(fs.read_range(path, 0, 10).is_err());
1783        assert!(fs.write_file(path, b"data").is_err());
1784        assert!(fs.metadata(path).is_err());
1785        assert!(fs.read_dir(path).is_err());
1786    }
1787
1788    #[test]
1789    fn test_create_and_write_file() {
1790        let fs = StdFileSystem;
1791        let temp_dir = tempfile::tempdir().unwrap();
1792        let path = temp_dir.path().join("test.txt");
1793
1794        {
1795            let mut writer = fs.create_file(&path).unwrap();
1796            writer.write_all(b"test content").unwrap();
1797            writer.sync_all().unwrap();
1798        }
1799
1800        let content = fs.read_file(&path).unwrap();
1801        assert_eq!(content, b"test content");
1802    }
1803
1804    #[test]
1805    fn test_read_dir() {
1806        let fs = StdFileSystem;
1807        let temp_dir = tempfile::tempdir().unwrap();
1808
1809        // Create some files and directories
1810        fs.create_dir(&temp_dir.path().join("subdir")).unwrap();
1811        fs.write_file(&temp_dir.path().join("file1.txt"), b"content1")
1812            .unwrap();
1813        fs.write_file(&temp_dir.path().join("file2.txt"), b"content2")
1814            .unwrap();
1815
1816        let entries = fs.read_dir(temp_dir.path()).unwrap();
1817        assert_eq!(entries.len(), 3);
1818
1819        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
1820        assert!(names.contains(&"subdir"));
1821        assert!(names.contains(&"file1.txt"));
1822        assert!(names.contains(&"file2.txt"));
1823    }
1824
1825    #[test]
1826    fn test_dir_entry_types() {
1827        let file = DirEntry::new(PathBuf::from("/file"), "file".to_string(), EntryType::File);
1828        assert!(file.is_file());
1829        assert!(!file.is_dir());
1830
1831        let dir = DirEntry::new(
1832            PathBuf::from("/dir"),
1833            "dir".to_string(),
1834            EntryType::Directory,
1835        );
1836        assert!(dir.is_dir());
1837        assert!(!dir.is_file());
1838
1839        let link_to_dir = DirEntry::new_symlink(PathBuf::from("/link"), "link".to_string(), true);
1840        assert!(link_to_dir.is_symlink());
1841        assert!(link_to_dir.is_dir());
1842    }
1843
1844    #[test]
1845    fn test_metadata_builder() {
1846        let meta = FileMetadata::default()
1847            .with_hidden(true)
1848            .with_readonly(true);
1849        assert!(meta.is_hidden);
1850        assert!(meta.is_readonly);
1851    }
1852
1853    #[test]
1854    fn test_atomic_write() {
1855        let fs = StdFileSystem;
1856        let temp_dir = tempfile::tempdir().unwrap();
1857        let path = temp_dir.path().join("atomic_test.txt");
1858
1859        fs.write_file(&path, b"initial").unwrap();
1860        assert_eq!(fs.read_file(&path).unwrap(), b"initial");
1861
1862        fs.write_file(&path, b"updated").unwrap();
1863        assert_eq!(fs.read_file(&path).unwrap(), b"updated");
1864    }
1865
1866    #[test]
1867    fn test_write_patched_default_impl() {
1868        // Test that the default write_patched implementation works correctly
1869        let fs = StdFileSystem;
1870        let temp_dir = tempfile::tempdir().unwrap();
1871        let src_path = temp_dir.path().join("source.txt");
1872        let dst_path = temp_dir.path().join("dest.txt");
1873
1874        // Create source file with known content
1875        fs.write_file(&src_path, b"AAABBBCCC").unwrap();
1876
1877        // Apply patch: copy first 3 bytes, insert "XXX", copy last 3 bytes
1878        let ops = vec![
1879            WriteOp::Copy { offset: 0, len: 3 }, // "AAA"
1880            WriteOp::Insert { data: b"XXX" },    // "XXX"
1881            WriteOp::Copy { offset: 6, len: 3 }, // "CCC"
1882        ];
1883
1884        fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1885
1886        let result = fs.read_file(&dst_path).unwrap();
1887        assert_eq!(result, b"AAAXXXCCC");
1888    }
1889
1890    #[test]
1891    fn test_write_patched_same_file() {
1892        // Test patching a file in-place (src == dst)
1893        let fs = StdFileSystem;
1894        let temp_dir = tempfile::tempdir().unwrap();
1895        let path = temp_dir.path().join("file.txt");
1896
1897        // Create file
1898        fs.write_file(&path, b"Hello World").unwrap();
1899
1900        // Replace "World" with "Rust"
1901        let ops = vec![
1902            WriteOp::Copy { offset: 0, len: 6 }, // "Hello "
1903            WriteOp::Insert { data: b"Rust" },   // "Rust"
1904        ];
1905
1906        fs.write_patched(&path, &path, &ops).unwrap();
1907
1908        let result = fs.read_file(&path).unwrap();
1909        assert_eq!(result, b"Hello Rust");
1910    }
1911
1912    #[test]
1913    fn test_write_patched_insert_only() {
1914        // Test a patch with only inserts (new file)
1915        let fs = StdFileSystem;
1916        let temp_dir = tempfile::tempdir().unwrap();
1917        let src_path = temp_dir.path().join("empty.txt");
1918        let dst_path = temp_dir.path().join("new.txt");
1919
1920        // Create empty source (won't be read from)
1921        fs.write_file(&src_path, b"").unwrap();
1922
1923        let ops = vec![WriteOp::Insert {
1924            data: b"All new content",
1925        }];
1926
1927        fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1928
1929        let result = fs.read_file(&dst_path).unwrap();
1930        assert_eq!(result, b"All new content");
1931    }
1932
1933    // ====================================================================
1934    // search_file tests
1935    // ====================================================================
1936
1937    fn make_search_opts(pattern_is_fixed: bool) -> FileSearchOptions {
1938        FileSearchOptions {
1939            fixed_string: pattern_is_fixed,
1940            case_sensitive: true,
1941            whole_word: false,
1942            max_matches: 100,
1943        }
1944    }
1945
1946    #[test]
1947    fn test_search_file_basic() {
1948        let fs = StdFileSystem;
1949        let temp_dir = tempfile::tempdir().unwrap();
1950        let path = temp_dir.path().join("test.txt");
1951        fs.write_file(&path, b"hello world\nfoo bar\nhello again\n")
1952            .unwrap();
1953
1954        let opts = make_search_opts(true);
1955        let mut cursor = FileSearchCursor::new();
1956        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1957
1958        assert!(cursor.done);
1959        assert_eq!(matches.len(), 2);
1960
1961        assert_eq!(matches[0].line, 1);
1962        assert_eq!(matches[0].column, 1);
1963        assert_eq!(matches[0].context, "hello world");
1964
1965        assert_eq!(matches[1].line, 3);
1966        assert_eq!(matches[1].column, 1);
1967        assert_eq!(matches[1].context, "hello again");
1968    }
1969
1970    #[test]
1971    fn test_search_file_no_matches() {
1972        let fs = StdFileSystem;
1973        let temp_dir = tempfile::tempdir().unwrap();
1974        let path = temp_dir.path().join("test.txt");
1975        fs.write_file(&path, b"hello world\n").unwrap();
1976
1977        let opts = make_search_opts(true);
1978        let mut cursor = FileSearchCursor::new();
1979        let matches = fs
1980            .search_file(&path, "NOTFOUND", &opts, &mut cursor)
1981            .unwrap();
1982
1983        assert!(cursor.done);
1984        assert!(matches.is_empty());
1985    }
1986
1987    #[test]
1988    fn test_search_file_case_insensitive() {
1989        let fs = StdFileSystem;
1990        let temp_dir = tempfile::tempdir().unwrap();
1991        let path = temp_dir.path().join("test.txt");
1992        fs.write_file(&path, b"Hello HELLO hello\n").unwrap();
1993
1994        let opts = FileSearchOptions {
1995            fixed_string: true,
1996            case_sensitive: false,
1997            whole_word: false,
1998            max_matches: 100,
1999        };
2000        let mut cursor = FileSearchCursor::new();
2001        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2002
2003        assert_eq!(matches.len(), 3);
2004    }
2005
2006    #[test]
2007    fn test_search_file_whole_word() {
2008        let fs = StdFileSystem;
2009        let temp_dir = tempfile::tempdir().unwrap();
2010        let path = temp_dir.path().join("test.txt");
2011        fs.write_file(&path, b"cat concatenate catalog\n").unwrap();
2012
2013        let opts = FileSearchOptions {
2014            fixed_string: true,
2015            case_sensitive: true,
2016            whole_word: true,
2017            max_matches: 100,
2018        };
2019        let mut cursor = FileSearchCursor::new();
2020        let matches = fs.search_file(&path, "cat", &opts, &mut cursor).unwrap();
2021
2022        assert_eq!(matches.len(), 1);
2023        assert_eq!(matches[0].column, 1);
2024    }
2025
2026    #[test]
2027    fn test_search_file_regex() {
2028        let fs = StdFileSystem;
2029        let temp_dir = tempfile::tempdir().unwrap();
2030        let path = temp_dir.path().join("test.txt");
2031        fs.write_file(&path, b"foo123 bar456 baz\n").unwrap();
2032
2033        let opts = FileSearchOptions {
2034            fixed_string: false,
2035            case_sensitive: true,
2036            whole_word: false,
2037            max_matches: 100,
2038        };
2039        let mut cursor = FileSearchCursor::new();
2040        let matches = fs
2041            .search_file(&path, r"[a-z]+\d+", &opts, &mut cursor)
2042            .unwrap();
2043
2044        assert_eq!(matches.len(), 2);
2045        assert_eq!(matches[0].context, "foo123 bar456 baz");
2046    }
2047
2048    #[test]
2049    fn test_search_file_binary_skipped() {
2050        let fs = StdFileSystem;
2051        let temp_dir = tempfile::tempdir().unwrap();
2052        let path = temp_dir.path().join("binary.dat");
2053        let mut data = b"hello world\n".to_vec();
2054        data.push(0); // null byte makes it binary
2055        data.extend_from_slice(b"hello again\n");
2056        fs.write_file(&path, &data).unwrap();
2057
2058        let opts = make_search_opts(true);
2059        let mut cursor = FileSearchCursor::new();
2060        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2061
2062        assert!(cursor.done);
2063        assert!(matches.is_empty());
2064    }
2065
2066    /// Issue #1342 follow-up (A — extension fast-path): a file with a
2067    /// known-binary extension is skipped without any I/O even when its
2068    /// content happens to be valid UTF-8.  Previously the content
2069    /// heuristic was the only gate, so an ASCII `.png` would be scanned.
2070    #[test]
2071    fn test_search_file_binary_extension_skipped_despite_text_content() {
2072        let fs = StdFileSystem;
2073        let temp_dir = tempfile::tempdir().unwrap();
2074        let path = temp_dir.path().join("not_actually_binary.png");
2075        fs.write_file(&path, b"hello world\nhello again\n").unwrap();
2076
2077        let opts = make_search_opts(true);
2078        let mut cursor = FileSearchCursor::new();
2079        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2080
2081        assert!(cursor.done);
2082        assert!(
2083            matches.is_empty(),
2084            ".png extension should short-circuit before content scan"
2085        );
2086    }
2087
2088    /// Issue #1342 follow-up (A — extension fast-path): the check is
2089    /// case-insensitive (Windows/macOS users frequently see uppercase
2090    /// extensions) and respects multi-segment names like `archive.tar.gz`.
2091    #[test]
2092    fn test_search_file_binary_extension_case_insensitive() {
2093        let fs = StdFileSystem;
2094        let temp_dir = tempfile::tempdir().unwrap();
2095        for name in ["IMG.JPG", "archive.tar.gz", "weights.SafeTensors"] {
2096            let path = temp_dir.path().join(name);
2097            fs.write_file(&path, b"definitely text content here\n")
2098                .unwrap();
2099
2100            let opts = make_search_opts(true);
2101            let mut cursor = FileSearchCursor::new();
2102            let matches = fs
2103                .search_file(&path, "definitely", &opts, &mut cursor)
2104                .unwrap();
2105
2106            assert!(cursor.done, "{} should be marked done", name);
2107            assert!(
2108                matches.is_empty(),
2109                "{} matched but extension should have skipped it",
2110                name
2111            );
2112        }
2113    }
2114
2115    /// Issue #1342 follow-up (B — encoding-aware sniff): UTF-16 with BOM
2116    /// is recognised as text by the editor, but byte-regex search would
2117    /// never match a UTF-8 pattern in interleaved-NUL content.  The
2118    /// sniff must skip it explicitly rather than wasting I/O on regex
2119    /// passes that find nothing.
2120    #[test]
2121    fn test_search_file_utf16_skipped_via_encoding_gate() {
2122        let fs = StdFileSystem;
2123        let temp_dir = tempfile::tempdir().unwrap();
2124        let path = temp_dir.path().join("utf16.txt");
2125        // UTF-16 LE BOM + "hello"
2126        let mut data = vec![0xFF, 0xFE];
2127        for ch in "hello world\nhello again\n".chars() {
2128            let n = ch as u32;
2129            data.push((n & 0xFF) as u8);
2130            data.push(((n >> 8) & 0xFF) as u8);
2131        }
2132        fs.write_file(&path, &data).unwrap();
2133
2134        let opts = make_search_opts(true);
2135        let mut cursor = FileSearchCursor::new();
2136        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2137
2138        assert!(cursor.done);
2139        assert!(
2140            matches.is_empty(),
2141            "UTF-16 file must be skipped: byte-regex can't match UTF-8 patterns in it"
2142        );
2143    }
2144
2145    /// Issue #1342 follow-up (C — mid-stream NUL check): a file whose
2146    /// first 8 KB looks textual but turns binary later (self-extracting
2147    /// installers, mbox + attachment, log + crash dump) used to be
2148    /// regex-scanned end-to-end.  Once a NUL appears in any subsequent
2149    /// chunk, search bails out.
2150    #[test]
2151    fn test_search_file_midstream_nul_aborts_scan() {
2152        let fs = StdFileSystem;
2153        let temp_dir = tempfile::tempdir().unwrap();
2154        let path = temp_dir.path().join("mid.dat");
2155
2156        // 9000 bytes of text — past the 8 KB header window — then a NUL,
2157        // then a marker the regex would otherwise find.
2158        let mut data = vec![b'a'; 9000];
2159        data.push(0);
2160        data.extend_from_slice(b"PATTERN_AFTER_NUL\n");
2161        fs.write_file(&path, &data).unwrap();
2162
2163        let opts = make_search_opts(true);
2164        let mut cursor = FileSearchCursor::new();
2165        let matches = fs
2166            .search_file(&path, "PATTERN_AFTER_NUL", &opts, &mut cursor)
2167            .unwrap();
2168
2169        assert!(cursor.done);
2170        assert!(
2171            matches.is_empty(),
2172            "mid-stream NUL should abort scan and discard pseudo-matches"
2173        );
2174    }
2175
2176    #[test]
2177    fn test_search_file_empty_file() {
2178        let fs = StdFileSystem;
2179        let temp_dir = tempfile::tempdir().unwrap();
2180        let path = temp_dir.path().join("empty.txt");
2181        fs.write_file(&path, b"").unwrap();
2182
2183        let opts = make_search_opts(true);
2184        let mut cursor = FileSearchCursor::new();
2185        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2186
2187        assert!(cursor.done);
2188        assert!(matches.is_empty());
2189    }
2190
2191    /// Issue #1342: Search/Replace across project locks up on huge files
2192    /// (multi-GB archives, model weights, audio).  Unbounded scans cap the
2193    /// per-file size; anything larger is treated as binary and skipped.
2194    #[test]
2195    fn test_search_file_oversized_skipped() {
2196        let fs = StdFileSystem;
2197        let temp_dir = tempfile::tempdir().unwrap();
2198        let path = temp_dir.path().join("oversized.txt");
2199
2200        // File slightly larger than the project-search cap, with the search
2201        // pattern only at the very end.  Without the size guard, the scanner
2202        // would chew through the whole file and return a match.
2203        let mut data = vec![b'a'; (MAX_PROJECT_SEARCH_FILE_SIZE as usize) + 1024];
2204        data.extend_from_slice(b"\nUNIQUE_TAIL_MARKER\n");
2205        fs.write_file(&path, &data).unwrap();
2206
2207        let opts = make_search_opts(true);
2208        let mut cursor = FileSearchCursor::new();
2209        let matches = fs
2210            .search_file(&path, "UNIQUE_TAIL_MARKER", &opts, &mut cursor)
2211            .unwrap();
2212
2213        assert!(
2214            cursor.done,
2215            "oversized file should be marked done in one call"
2216        );
2217        assert!(matches.is_empty(), "oversized file should yield no matches");
2218    }
2219
2220    /// Issue #1342: Some binary formats (zip-based archives like .pth, ELF
2221    /// tail headers, etc.) have non-null control bytes in their first 8 KB
2222    /// even when null bytes happen to appear later.  The unbounded scan
2223    /// should reject those just like the null-byte case.
2224    #[test]
2225    fn test_search_file_binary_control_char_skipped() {
2226        let fs = StdFileSystem;
2227        let temp_dir = tempfile::tempdir().unwrap();
2228        let path = temp_dir.path().join("ctrl.dat");
2229        // No null bytes, but a SUB control char (0x1A) — same byte that
2230        // identifies PNG headers and many other binary formats.
2231        let mut data = b"hello world\n".to_vec();
2232        data.push(0x1A);
2233        data.extend_from_slice(b"hello again\n");
2234        fs.write_file(&path, &data).unwrap();
2235
2236        let opts = make_search_opts(true);
2237        let mut cursor = FileSearchCursor::new();
2238        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2239
2240        assert!(cursor.done);
2241        assert!(matches.is_empty());
2242    }
2243
2244    #[test]
2245    fn test_search_file_max_matches() {
2246        let fs = StdFileSystem;
2247        let temp_dir = tempfile::tempdir().unwrap();
2248        let path = temp_dir.path().join("test.txt");
2249        fs.write_file(&path, b"aa bb aa cc aa dd aa\n").unwrap();
2250
2251        let opts = FileSearchOptions {
2252            fixed_string: true,
2253            case_sensitive: true,
2254            whole_word: false,
2255            max_matches: 2,
2256        };
2257        let mut cursor = FileSearchCursor::new();
2258        let matches = fs.search_file(&path, "aa", &opts, &mut cursor).unwrap();
2259
2260        assert_eq!(matches.len(), 2);
2261    }
2262
2263    #[test]
2264    fn test_search_file_cursor_multi_chunk() {
2265        let fs = StdFileSystem;
2266        let temp_dir = tempfile::tempdir().unwrap();
2267        let path = temp_dir.path().join("large.txt");
2268
2269        // Create a file larger than 1MB chunk size to test cursor continuation
2270        let mut content = Vec::new();
2271        for i in 0..100_000 {
2272            content.extend_from_slice(format!("line {} content here\n", i).as_bytes());
2273        }
2274        fs.write_file(&path, &content).unwrap();
2275
2276        let opts = FileSearchOptions {
2277            fixed_string: true,
2278            case_sensitive: true,
2279            whole_word: false,
2280            max_matches: 1000,
2281        };
2282        let mut cursor = FileSearchCursor::new();
2283        let mut all_matches = Vec::new();
2284
2285        while !cursor.done {
2286            let batch = fs
2287                .search_file(&path, "line 5000", &opts, &mut cursor)
2288                .unwrap();
2289            all_matches.extend(batch);
2290        }
2291
2292        // "line 5000" matches: "line 5000 ", "line 50000 "..  "line 50009 "
2293        // = 11 matches (5000, 50000-50009)
2294        assert_eq!(all_matches.len(), 11);
2295
2296        // Verify line numbers are correct
2297        let first = &all_matches[0];
2298        assert_eq!(first.line, 5001); // 0-indexed lines, 1-based line numbers
2299        assert_eq!(first.column, 1);
2300        assert!(first.context.starts_with("line 5000"));
2301    }
2302
2303    #[test]
2304    fn test_search_file_cursor_no_duplicates() {
2305        let fs = StdFileSystem;
2306        let temp_dir = tempfile::tempdir().unwrap();
2307        let path = temp_dir.path().join("large.txt");
2308
2309        // Create file with matches near chunk boundaries
2310        let mut content = Vec::new();
2311        for i in 0..100_000 {
2312            content.extend_from_slice(format!("MARKER_{:06}\n", i).as_bytes());
2313        }
2314        fs.write_file(&path, &content).unwrap();
2315
2316        let opts = FileSearchOptions {
2317            fixed_string: true,
2318            case_sensitive: true,
2319            whole_word: false,
2320            max_matches: 200_000,
2321        };
2322        let mut cursor = FileSearchCursor::new();
2323        let mut all_matches = Vec::new();
2324        let mut batches = 0;
2325
2326        while !cursor.done {
2327            let batch = fs
2328                .search_file(&path, "MARKER_", &opts, &mut cursor)
2329                .unwrap();
2330            all_matches.extend(batch);
2331            batches += 1;
2332        }
2333
2334        // Must have multiple batches (file > 1MB)
2335        assert!(batches > 1, "Expected multiple batches, got {}", batches);
2336        // Exactly one match per line, no duplicates
2337        assert_eq!(all_matches.len(), 100_000);
2338        // Check no duplicate byte offsets
2339        let mut offsets: Vec<usize> = all_matches.iter().map(|m| m.byte_offset).collect();
2340        offsets.sort();
2341        offsets.dedup();
2342        assert_eq!(offsets.len(), 100_000);
2343    }
2344
2345    #[test]
2346    fn test_search_file_line_numbers_across_chunks() {
2347        let fs = StdFileSystem;
2348        let temp_dir = tempfile::tempdir().unwrap();
2349        let path = temp_dir.path().join("large.txt");
2350
2351        // Create file where we know exact line numbers
2352        let mut content = Vec::new();
2353        let total_lines = 100_000;
2354        for i in 0..total_lines {
2355            if i == 99_999 {
2356                content.extend_from_slice(b"FINDME at the end\n");
2357            } else {
2358                content.extend_from_slice(format!("padding line {}\n", i).as_bytes());
2359            }
2360        }
2361        fs.write_file(&path, &content).unwrap();
2362
2363        let opts = make_search_opts(true);
2364        let mut cursor = FileSearchCursor::new();
2365        let mut all_matches = Vec::new();
2366
2367        while !cursor.done {
2368            let batch = fs.search_file(&path, "FINDME", &opts, &mut cursor).unwrap();
2369            all_matches.extend(batch);
2370        }
2371
2372        assert_eq!(all_matches.len(), 1);
2373        assert_eq!(all_matches[0].line, total_lines); // last line
2374        assert_eq!(all_matches[0].context, "FINDME at the end");
2375    }
2376
2377    #[test]
2378    fn test_search_file_end_offset_bounds_search() {
2379        let fs = StdFileSystem;
2380        let temp_dir = tempfile::tempdir().unwrap();
2381        let path = temp_dir.path().join("bounded.txt");
2382
2383        // "AAA\nBBB\nCCC\nDDD\n" — each line is 4 bytes
2384        fs.write_file(&path, b"AAA\nBBB\nCCC\nDDD\n").unwrap();
2385
2386        // Search only the first 8 bytes ("AAA\nBBB\n") — should find AAA and BBB
2387        let opts = make_search_opts(true);
2388        let mut cursor = FileSearchCursor::for_range(0, 8, 1);
2389        let mut matches = Vec::new();
2390        while !cursor.done {
2391            matches.extend(fs.search_file(&path, "AAA", &opts, &mut cursor).unwrap());
2392        }
2393        assert_eq!(matches.len(), 1);
2394        assert_eq!(matches[0].context, "AAA");
2395        assert_eq!(matches[0].line, 1);
2396
2397        // CCC is at byte 8, outside the first 8 bytes
2398        let mut cursor = FileSearchCursor::for_range(0, 8, 1);
2399        let ccc = fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap();
2400        assert!(ccc.is_empty(), "CCC should not be found in first 8 bytes");
2401
2402        // Search bytes 8..16 ("CCC\nDDD\n") — should find CCC
2403        let mut cursor = FileSearchCursor::for_range(8, 16, 3);
2404        let mut matches = Vec::new();
2405        while !cursor.done {
2406            matches.extend(fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap());
2407        }
2408        assert_eq!(matches.len(), 1);
2409        assert_eq!(matches[0].context, "CCC");
2410        assert_eq!(matches[0].line, 3);
2411    }
2412
2413    // ====================================================================
2414    // walk_files tests
2415    // ====================================================================
2416
2417    /// Helper: create a directory tree for walk_files tests.
2418    /// Returns the tempdir (must be kept alive for the duration of the test).
2419    fn make_walk_tree() -> tempfile::TempDir {
2420        let fs = StdFileSystem;
2421        let tmp = tempfile::tempdir().unwrap();
2422        let root = tmp.path();
2423
2424        // root/
2425        //   a.txt
2426        //   b.txt
2427        //   sub/
2428        //     c.txt
2429        //     deep/
2430        //       d.txt
2431        //   .hidden_dir/
2432        //     secret.txt
2433        //   .hidden_file
2434        //   node_modules/
2435        //     pkg.json
2436        //   target/
2437        //     debug.o
2438        fs.write_file(&root.join("a.txt"), b"a").unwrap();
2439        fs.write_file(&root.join("b.txt"), b"b").unwrap();
2440        fs.create_dir_all(&root.join("sub/deep")).unwrap();
2441        fs.write_file(&root.join("sub/c.txt"), b"c").unwrap();
2442        fs.write_file(&root.join("sub/deep/d.txt"), b"d").unwrap();
2443        fs.create_dir_all(&root.join(".hidden_dir")).unwrap();
2444        fs.write_file(&root.join(".hidden_dir/secret.txt"), b"s")
2445            .unwrap();
2446        fs.write_file(&root.join(".hidden_file"), b"h").unwrap();
2447        fs.create_dir_all(&root.join("node_modules")).unwrap();
2448        fs.write_file(&root.join("node_modules/pkg.json"), b"{}")
2449            .unwrap();
2450        fs.create_dir_all(&root.join("target")).unwrap();
2451        fs.write_file(&root.join("target/debug.o"), b"elf").unwrap();
2452
2453        tmp
2454    }
2455
2456    #[test]
2457    fn test_walk_files_std_basic() {
2458        let tmp = make_walk_tree();
2459        let fs = StdFileSystem;
2460        let cancel = std::sync::atomic::AtomicBool::new(false);
2461        let mut found: Vec<String> = Vec::new();
2462
2463        fs.walk_files(
2464            tmp.path(),
2465            &["node_modules", "target"],
2466            &cancel,
2467            &mut |_path, rel| {
2468                found.push(rel.to_string());
2469                true
2470            },
2471        )
2472        .unwrap();
2473
2474        found.sort();
2475        assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt", "sub/deep/d.txt"]);
2476    }
2477
2478    #[test]
2479    fn test_walk_files_std_skips_hidden() {
2480        let tmp = make_walk_tree();
2481        let fs = StdFileSystem;
2482        let cancel = std::sync::atomic::AtomicBool::new(false);
2483        let mut found: Vec<String> = Vec::new();
2484
2485        fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2486            found.push(rel.to_string());
2487            true
2488        })
2489        .unwrap();
2490
2491        // Hidden files/dirs should be excluded, but node_modules and target
2492        // are NOT skipped (empty skip list)
2493        assert!(!found.iter().any(|f| f.contains(".hidden")));
2494        assert!(found.iter().any(|f| f.contains("node_modules")));
2495        assert!(found.iter().any(|f| f.contains("target")));
2496    }
2497
2498    #[test]
2499    fn test_walk_files_std_skip_dirs() {
2500        let tmp = make_walk_tree();
2501        let fs = StdFileSystem;
2502        let cancel = std::sync::atomic::AtomicBool::new(false);
2503        let mut found: Vec<String> = Vec::new();
2504
2505        fs.walk_files(
2506            tmp.path(),
2507            &["node_modules", "target", "deep"],
2508            &cancel,
2509            &mut |_path, rel| {
2510                found.push(rel.to_string());
2511                true
2512            },
2513        )
2514        .unwrap();
2515
2516        found.sort();
2517        // "deep" dir is also skipped, so d.txt should not appear
2518        assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt"]);
2519    }
2520
2521    #[test]
2522    fn test_walk_files_std_cancel() {
2523        let tmp = make_walk_tree();
2524        let fs = StdFileSystem;
2525        let cancel = std::sync::atomic::AtomicBool::new(false);
2526        let mut found: Vec<String> = Vec::new();
2527
2528        fs.walk_files(
2529            tmp.path(),
2530            &["node_modules", "target"],
2531            &cancel,
2532            &mut |_path, rel| {
2533                found.push(rel.to_string());
2534                // Cancel after finding the first file
2535                cancel.store(true, std::sync::atomic::Ordering::Relaxed);
2536                true
2537            },
2538        )
2539        .unwrap();
2540
2541        assert_eq!(found.len(), 1, "Should stop after cancel is set");
2542    }
2543
2544    #[test]
2545    fn test_walk_files_std_on_file_returns_false() {
2546        let tmp = make_walk_tree();
2547        let fs = StdFileSystem;
2548        let cancel = std::sync::atomic::AtomicBool::new(false);
2549        let mut count = 0usize;
2550
2551        fs.walk_files(
2552            tmp.path(),
2553            &["node_modules", "target"],
2554            &cancel,
2555            &mut |_path, _rel| {
2556                count += 1;
2557                count < 2 // stop after 2 files
2558            },
2559        )
2560        .unwrap();
2561
2562        assert_eq!(count, 2, "Should stop when on_file returns false");
2563    }
2564
2565    #[test]
2566    fn test_walk_files_std_empty_dir() {
2567        let tmp = tempfile::tempdir().unwrap();
2568        let fs = StdFileSystem;
2569        let cancel = std::sync::atomic::AtomicBool::new(false);
2570        let mut found: Vec<String> = Vec::new();
2571
2572        fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2573            found.push(rel.to_string());
2574            true
2575        })
2576        .unwrap();
2577
2578        assert!(found.is_empty());
2579    }
2580
2581    #[test]
2582    fn test_walk_files_std_nonexistent_root() {
2583        let fs = StdFileSystem;
2584        let cancel = std::sync::atomic::AtomicBool::new(false);
2585        let mut found: Vec<String> = Vec::new();
2586
2587        // Non-existent root should not panic, just return Ok with no files
2588        let result = fs.walk_files(
2589            Path::new("/nonexistent/path/that/does/not/exist"),
2590            &[],
2591            &cancel,
2592            &mut |_path, rel| {
2593                found.push(rel.to_string());
2594                true
2595            },
2596        );
2597
2598        assert!(result.is_ok());
2599        assert!(found.is_empty());
2600    }
2601
2602    #[test]
2603    fn test_walk_files_std_relative_paths_use_forward_slashes() {
2604        let tmp = make_walk_tree();
2605        let fs = StdFileSystem;
2606        let cancel = std::sync::atomic::AtomicBool::new(false);
2607        let mut found: Vec<String> = Vec::new();
2608
2609        fs.walk_files(
2610            tmp.path(),
2611            &["node_modules", "target"],
2612            &cancel,
2613            &mut |_path, rel| {
2614                found.push(rel.to_string());
2615                true
2616            },
2617        )
2618        .unwrap();
2619
2620        // All paths should use forward slashes (even on Windows)
2621        for path in &found {
2622            assert!(!path.contains('\\'), "Path should use / not \\: {}", path);
2623        }
2624    }
2625
2626    #[test]
2627    fn test_walk_files_noop_returns_error() {
2628        let fs = NoopFileSystem;
2629        let cancel = std::sync::atomic::AtomicBool::new(false);
2630
2631        let result = fs.walk_files(Path::new("/noop/path"), &[], &cancel, &mut |_path, _rel| {
2632            true
2633        });
2634
2635        assert!(result.is_err());
2636        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
2637    }
2638
2639    /// `is_writable()` must agree with the kernel's `faccessat(W_OK, AT_EACCESS)`
2640    /// on a regular, writable file owned by the current user. This pins down the
2641    /// contract that we delegate writability to the kernel — which is what makes
2642    /// the fix for #1765 (POSIX ACLs ignored) correct: the kernel honours ACLs,
2643    /// capabilities, and read-only mounts, while a manual mode-bit walk does not.
2644    #[test]
2645    #[cfg(unix)]
2646    fn test_is_writable_matches_kernel_for_owner_writable() {
2647        use std::os::unix::ffi::OsStrExt;
2648        let fs = StdFileSystem;
2649        let temp_dir = tempfile::tempdir().unwrap();
2650        let path = temp_dir.path().join("writable.txt");
2651        fs.write_file(&path, b"x").unwrap();
2652        fs.set_permissions(&path, &FilePermissions::from_mode(0o600))
2653            .unwrap();
2654
2655        let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
2656        let kernel_writable = unsafe {
2657            libc::faccessat(
2658                libc::AT_FDCWD,
2659                c_path.as_ptr(),
2660                libc::W_OK,
2661                libc::AT_EACCESS,
2662            )
2663        } == 0;
2664        assert!(
2665            kernel_writable,
2666            "owner-writable file must be writable per kernel"
2667        );
2668        assert_eq!(fs.is_writable(&path), kernel_writable);
2669    }
2670
2671    /// Regression test for #1765: a POSIX ACL granting write access to the
2672    /// effective user must be honoured by `is_writable`, even when the inode's
2673    /// "other" mode bits would say the file is read-only.
2674    ///
2675    /// The setup needs three things that aren't available in vanilla CI:
2676    ///   * `setfacl` (acl userspace)
2677    ///   * the ability to chown the test file to a foreign uid (root)
2678    ///   * a non-root uid the test child can switch to (we use `nobody`, 65534)
2679    ///
2680    /// On Linux test runners that have all three, this exercises the exact
2681    /// scenario from the bug report: a file owned by uid 999, mode 0o600,
2682    /// with a named-user ACL granting our user rw — fixed code reports the
2683    /// file writable, the previous mode-bits-only code reported it read-only.
2684    ///
2685    /// Run manually with:
2686    ///   sudo cargo test -p fresh-editor --features runtime \
2687    ///       test_is_writable_respects_posix_acl -- --ignored --nocapture
2688    #[test]
2689    #[ignore = "requires root + setfacl; see test docstring"]
2690    #[cfg(target_os = "linux")]
2691    fn test_is_writable_respects_posix_acl() {
2692        use std::os::unix::ffi::OsStrExt;
2693        use std::process::Command;
2694
2695        // SAFETY: geteuid is always safe.
2696        if unsafe { libc::geteuid() } != 0 {
2697            panic!("test must be run as root (need to chown to a foreign uid)");
2698        }
2699        let setfacl_ok = Command::new("setfacl").arg("--version").output().is_ok();
2700        assert!(setfacl_ok, "setfacl must be installed");
2701
2702        // The non-root uid we drop to in the child. 65534 is the conventional
2703        // "nobody" uid on Linux.
2704        let test_uid: libc::uid_t = 65534;
2705        let test_gid: libc::gid_t = 65534;
2706        // A different "foreign" uid for the file's owner so the test user
2707        // is neither owner nor a group member — i.e. matches against the
2708        // "other" mode bits, which are 0 here.
2709        let foreign_uid: libc::uid_t = 9999;
2710        let foreign_gid: libc::gid_t = 9999;
2711
2712        let temp_dir = tempfile::tempdir().unwrap();
2713        // Ensure the child can traverse into the test dir.
2714        std::fs::set_permissions(
2715            temp_dir.path(),
2716            <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o755),
2717        )
2718        .unwrap();
2719
2720        let file = temp_dir.path().join("acl_test.txt");
2721        std::fs::write(&file, b"hi").unwrap();
2722
2723        let c_file = std::ffi::CString::new(file.as_os_str().as_bytes()).unwrap();
2724        // SAFETY: c_file is a NUL-terminated path; the uids/gids are valid.
2725        let r = unsafe { libc::chown(c_file.as_ptr(), foreign_uid, foreign_gid) };
2726        assert_eq!(r, 0, "chown failed: {}", io::Error::last_os_error());
2727        std::fs::set_permissions(
2728            &file,
2729            <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
2730        )
2731        .unwrap();
2732
2733        let acl_status = Command::new("setfacl")
2734            .args(["-m", &format!("u:{test_uid}:rw")])
2735            .arg(&file)
2736            .status()
2737            .unwrap();
2738        assert!(
2739            acl_status.success(),
2740            "setfacl failed (does the filesystem support ACLs?)",
2741        );
2742
2743        // Fork + setuid in the child. Keep the child's work to bare syscalls
2744        // and exit via _exit (skipping atexit handlers) to stay safe in a
2745        // multi-threaded test runner.
2746        // SAFETY: fork is allowed, but the child must avoid touching shared
2747        // mutable state. We only call setgid/setuid + a single metadata read,
2748        // then _exit.
2749        let pid = unsafe { libc::fork() };
2750        if pid < 0 {
2751            panic!("fork failed: {}", io::Error::last_os_error());
2752        }
2753        if pid == 0 {
2754            // Child
2755            // SAFETY: setgid/setuid are async-signal-safe.
2756            if unsafe { libc::setgid(test_gid) } != 0 {
2757                unsafe { libc::_exit(2) };
2758            }
2759            if unsafe { libc::setuid(test_uid) } != 0 {
2760                unsafe { libc::_exit(3) };
2761            }
2762            let writable = StdFileSystem.is_writable(&file);
2763            unsafe { libc::_exit(if writable { 0 } else { 1 }) };
2764        }
2765
2766        // Parent
2767        let mut status: libc::c_int = 0;
2768        // SAFETY: pid is a valid child; status is a writable c_int.
2769        let r = unsafe { libc::waitpid(pid, &mut status, 0) };
2770        assert!(r > 0, "waitpid failed: {}", io::Error::last_os_error());
2771        let exited_normally = (status & 0x7f) == 0;
2772        let exit_code = (status >> 8) & 0xff;
2773        assert!(
2774            exited_normally,
2775            "child terminated abnormally; status={status}"
2776        );
2777        assert_eq!(
2778            exit_code, 0,
2779            "child reported file NOT writable (exit_code={exit_code}); ACL was ignored",
2780        );
2781    }
2782}