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