1use std::io::{self, Read, Seek, Write};
13use std::path::{Path, PathBuf};
14use std::time::SystemTime;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum EntryType {
23 File,
24 Directory,
25 Symlink,
26}
27
28#[derive(Debug, Clone)]
30pub struct DirEntry {
31 pub path: PathBuf,
33 pub name: String,
35 pub entry_type: EntryType,
37 pub metadata: Option<FileMetadata>,
39 pub symlink_target_is_dir: bool,
41}
42
43impl DirEntry {
44 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 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 pub fn with_metadata(mut self, metadata: FileMetadata) -> Self {
68 self.metadata = Some(metadata);
69 self
70 }
71
72 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 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 pub fn is_symlink(&self) -> bool {
86 self.entry_type == EntryType::Symlink
87 }
88}
89
90#[derive(Debug, Clone)]
96pub struct FileMetadata {
97 pub size: u64,
99 pub modified: Option<SystemTime>,
101 pub permissions: Option<FilePermissions>,
103 pub is_hidden: bool,
105 pub is_readonly: bool,
107 #[cfg(unix)]
109 pub uid: Option<u32>,
110 #[cfg(unix)]
112 pub gid: Option<u32>,
113}
114
115impl FileMetadata {
116 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 pub fn with_modified(mut self, modified: SystemTime) -> Self {
133 self.modified = Some(modified);
134 self
135 }
136
137 pub fn with_hidden(mut self, hidden: bool) -> Self {
139 self.is_hidden = hidden;
140 self
141 }
142
143 pub fn with_readonly(mut self, readonly: bool) -> Self {
145 self.is_readonly = readonly;
146 self
147 }
148
149 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#[derive(Debug, Clone)]
164pub struct FilePermissions {
165 #[cfg(unix)]
166 mode: u32,
167 #[cfg(not(unix))]
168 readonly: bool,
169}
170
171impl FilePermissions {
172 #[cfg(unix)]
174 pub fn from_mode(mode: u32) -> Self {
175 Self { mode }
176 }
177
178 #[cfg(not(unix))]
180 pub fn from_mode(mode: u32) -> Self {
181 Self {
182 readonly: mode & 0o222 == 0,
183 }
184 }
185
186 #[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 #[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 #[cfg(unix)]
216 pub fn mode(&self) -> u32 {
217 self.mode
218 }
219
220 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 #[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 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
262pub trait FileWriter: Write + Send {
268 fn sync_all(&self) -> io::Result<()>;
270}
271
272#[derive(Debug, Clone)]
278pub enum WriteOp<'a> {
279 Copy { offset: u64, len: u64 },
281 Insert { data: &'a [u8] },
283}
284
285struct 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
304pub trait FileReader: Read + Seek + Send {}
306
307struct 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#[derive(Clone, Debug)]
339pub struct FileSearchOptions {
340 pub fixed_string: bool,
342 pub case_sensitive: bool,
344 pub whole_word: bool,
346 pub max_matches: usize,
348}
349
350#[derive(Clone, Debug)]
353pub struct FileSearchCursor {
354 pub offset: usize,
356 pub running_line: usize,
358 pub done: bool,
360 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 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#[derive(Clone, Debug)]
398pub struct SearchMatch {
399 pub byte_offset: usize,
401 pub length: usize,
403 pub line: usize,
405 pub column: usize,
407 pub context: String,
409}
410
411pub trait FileSystem: Send + Sync {
425 fn read_file(&self, path: &Path) -> io::Result<Vec<u8>>;
431
432 fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>>;
434
435 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 fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()>;
449
450 fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
452
453 fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>>;
455
456 fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
458
459 fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
461
462 fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()>;
464
465 fn write_patched(&self, src_path: &Path, dst_path: &Path, ops: &[WriteOp]) -> io::Result<()> {
480 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 fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
502
503 fn copy(&self, from: &Path, to: &Path) -> io::Result<u64>;
505
506 fn remove_file(&self, path: &Path) -> io::Result<()>;
508
509 fn remove_dir(&self, path: &Path) -> io::Result<()>;
511
512 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 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 fn metadata(&self, path: &Path) -> io::Result<FileMetadata>;
544
545 fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata>;
547
548 fn exists(&self, path: &Path) -> bool {
550 self.metadata(path).is_ok()
551 }
552
553 fn metadata_if_exists(&self, path: &Path) -> Option<FileMetadata> {
555 self.metadata(path).ok()
556 }
557
558 fn is_dir(&self, path: &Path) -> io::Result<bool>;
560
561 fn is_file(&self, path: &Path) -> io::Result<bool>;
563
564 fn is_writable(&self, path: &Path) -> bool {
572 self.metadata(path).map(|m| !m.is_readonly).unwrap_or(false)
573 }
574
575 fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()>;
577
578 fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>>;
584
585 fn create_dir(&self, path: &Path) -> io::Result<()>;
587
588 fn create_dir_all(&self, path: &Path) -> io::Result<()>;
590
591 fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
597
598 fn current_uid(&self) -> u32;
604
605 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 fn temp_path_for(&self, path: &Path) -> PathBuf {
625 path.with_extension("tmp")
626 }
627
628 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 fn remote_connection_info(&self) -> Option<&str> {
655 None
656 }
657
658 fn is_remote_connected(&self) -> bool {
664 true
665 }
666
667 fn home_dir(&self) -> io::Result<PathBuf> {
672 dirs::home_dir()
673 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home directory not found"))
674 }
675
676 fn search_file(
693 &self,
694 path: &Path,
695 pattern: &str,
696 opts: &FileSearchOptions,
697 cursor: &mut FileSearchCursor,
698 ) -> io::Result<Vec<SearchMatch>>;
699
700 fn sudo_write(&self, path: &Path, data: &[u8], mode: u32, uid: u32, gid: u32)
711 -> io::Result<()>;
712
713 fn walk_files(
740 &self,
741 root: &Path,
742 skip_dirs: &[&str],
743 cancel: &std::sync::atomic::AtomicBool,
744 on_file: &mut dyn FnMut(&Path, &str) -> bool,
745 ) -> io::Result<()>;
746}
747
748pub trait FileSystemExt: FileSystem {
773 fn read_file_async(
775 &self,
776 path: &Path,
777 ) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
778 async { self.read_file(path) }
779 }
780
781 fn read_range_async(
783 &self,
784 path: &Path,
785 offset: u64,
786 len: usize,
787 ) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
788 async move { self.read_range(path, offset, len) }
789 }
790
791 fn count_line_feeds_in_range_async(
793 &self,
794 path: &Path,
795 offset: u64,
796 len: usize,
797 ) -> impl std::future::Future<Output = io::Result<usize>> + Send {
798 async move { self.count_line_feeds_in_range(path, offset, len) }
799 }
800
801 fn write_file_async(
803 &self,
804 path: &Path,
805 data: &[u8],
806 ) -> impl std::future::Future<Output = io::Result<()>> + Send {
807 async { self.write_file(path, data) }
808 }
809
810 fn metadata_async(
812 &self,
813 path: &Path,
814 ) -> impl std::future::Future<Output = io::Result<FileMetadata>> + Send {
815 async { self.metadata(path) }
816 }
817
818 fn exists_async(&self, path: &Path) -> impl std::future::Future<Output = bool> + Send {
820 async { self.exists(path) }
821 }
822
823 fn is_dir_async(
825 &self,
826 path: &Path,
827 ) -> impl std::future::Future<Output = io::Result<bool>> + Send {
828 async { self.is_dir(path) }
829 }
830
831 fn is_file_async(
833 &self,
834 path: &Path,
835 ) -> impl std::future::Future<Output = io::Result<bool>> + Send {
836 async { self.is_file(path) }
837 }
838
839 fn read_dir_async(
841 &self,
842 path: &Path,
843 ) -> impl std::future::Future<Output = io::Result<Vec<DirEntry>>> + Send {
844 async { self.read_dir(path) }
845 }
846
847 fn canonicalize_async(
849 &self,
850 path: &Path,
851 ) -> impl std::future::Future<Output = io::Result<PathBuf>> + Send {
852 async { self.canonicalize(path) }
853 }
854}
855
856impl<T: FileSystem> FileSystemExt for T {}
858
859pub fn build_search_regex(
865 pattern: &str,
866 opts: &FileSearchOptions,
867) -> io::Result<regex::bytes::Regex> {
868 let re_pattern = if opts.fixed_string {
869 regex::escape(pattern)
870 } else {
871 pattern.to_string()
872 };
873 let re_pattern = if opts.whole_word {
874 format!(r"\b{}\b", re_pattern)
875 } else {
876 re_pattern
877 };
878 let re_pattern = if opts.case_sensitive {
879 re_pattern
880 } else {
881 format!("(?i){}", re_pattern)
882 };
883 regex::bytes::Regex::new(&re_pattern)
884 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
885}
886
887pub const MAX_PROJECT_SEARCH_FILE_SIZE: u64 = 10 * 1024 * 1024;
894
895pub const BINARY_FILE_EXTENSIONS: &[&str] = &[
904 "a",
906 "class",
907 "dll",
908 "dylib",
909 "exe",
910 "jar",
911 "lib",
912 "o",
913 "obj",
914 "pdb",
915 "pyc",
916 "pyo",
917 "so",
918 "wasm",
919 "war",
920 "7z",
922 "br",
923 "bz2",
924 "gz",
925 "lz",
926 "lz4",
927 "lzma",
928 "rar",
929 "tar",
930 "tbz",
931 "tbz2",
932 "tgz",
933 "txz",
934 "xz",
935 "z",
936 "zip",
937 "zst",
938 "apk",
940 "deb",
941 "dmg",
942 "img",
943 "ipa",
944 "iso",
945 "msi",
946 "rpm",
947 "vhd",
948 "vmdk",
949 "bmp",
951 "gif",
952 "heic",
953 "heif",
954 "ico",
955 "jp2",
956 "jpe",
957 "jpeg",
958 "jpg",
959 "png",
960 "psd",
961 "raw",
962 "tif",
963 "tiff",
964 "webp",
965 "aac",
967 "aif",
968 "aiff",
969 "avi",
970 "flac",
971 "flv",
972 "m4a",
973 "m4v",
974 "mid",
975 "midi",
976 "mka",
977 "mkv",
978 "mov",
979 "mp3",
980 "mp4",
981 "mpeg",
982 "mpg",
983 "ogg",
984 "opus",
985 "wav",
986 "webm",
987 "wma",
988 "wmv",
989 "doc",
991 "docx",
992 "odp",
993 "ods",
994 "odt",
995 "pdf",
996 "ppt",
997 "pptx",
998 "rtf",
999 "xls",
1000 "xlsx",
1001 "db",
1003 "mdb",
1004 "sqlite",
1005 "sqlite3",
1006 "eot",
1008 "otf",
1009 "ttc",
1010 "ttf",
1011 "woff",
1012 "woff2",
1013 "ckpt",
1015 "h5",
1016 "hdf5",
1017 "msgpack",
1018 "npy",
1019 "npz",
1020 "onnx",
1021 "pb",
1022 "pickle",
1023 "pkl",
1024 "pt",
1025 "pth",
1026 "safetensors",
1027 "tflite",
1028 "bin",
1030 "dat",
1031 "swf",
1032];
1033
1034fn has_binary_extension(path: &Path) -> bool {
1036 let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
1037 return false;
1038 };
1039 BINARY_FILE_EXTENSIONS
1040 .iter()
1041 .any(|candidate| candidate.eq_ignore_ascii_case(ext))
1042}
1043
1044fn is_byte_searchable_encoding(enc: crate::model::encoding::Encoding) -> bool {
1052 use crate::model::encoding::Encoding;
1053 !matches!(enc, Encoding::Utf16Le | Encoding::Utf16Be)
1054}
1055
1056pub fn default_search_file(
1060 fs: &dyn FileSystem,
1061 path: &Path,
1062 pattern: &str,
1063 opts: &FileSearchOptions,
1064 cursor: &mut FileSearchCursor,
1065) -> io::Result<Vec<SearchMatch>> {
1066 if cursor.done {
1067 return Ok(vec![]);
1068 }
1069
1070 const CHUNK_SIZE: usize = 1_048_576; let overlap = pattern.len().max(256);
1072
1073 if cursor.offset == 0 && cursor.end_offset.is_none() {
1078 if has_binary_extension(path) {
1081 cursor.done = true;
1082 return Ok(vec![]);
1083 }
1084 }
1085
1086 let meta = fs.metadata(path)?;
1087 let file_size = meta.size;
1088 let file_len = file_size as usize;
1089 let effective_end = cursor.end_offset.unwrap_or(file_len).min(file_len);
1090
1091 if cursor.offset == 0 && cursor.end_offset.is_none() {
1092 if file_size == 0 {
1093 cursor.done = true;
1094 return Ok(vec![]);
1095 }
1096 if file_size > MAX_PROJECT_SEARCH_FILE_SIZE {
1100 cursor.done = true;
1101 return Ok(vec![]);
1102 }
1103 let header_len = file_len.min(8192);
1110 let header = fs.read_range(path, 0, header_len)?;
1111 let truncated = header_len < file_len;
1112 let (encoding, is_binary) =
1113 crate::model::encoding::detect_encoding_or_binary(&header, truncated);
1114 if is_binary || !is_byte_searchable_encoding(encoding) {
1115 cursor.done = true;
1116 return Ok(vec![]);
1117 }
1118 }
1119
1120 if cursor.offset >= effective_end {
1121 cursor.done = true;
1122 return Ok(vec![]);
1123 }
1124
1125 let regex = build_search_regex(pattern, opts)?;
1126
1127 let read_start = cursor.offset.saturating_sub(overlap);
1129 let read_end = (read_start + CHUNK_SIZE).min(effective_end);
1130 let chunk = fs.read_range(path, read_start as u64, read_end - read_start)?;
1131
1132 let overlap_len = cursor.offset - read_start;
1133
1134 if cursor.end_offset.is_none() && chunk[overlap_len..].contains(&0) {
1145 cursor.done = true;
1146 return Ok(vec![]);
1147 }
1148
1149 let newlines_in_overlap = chunk[..overlap_len].iter().filter(|&&b| b == b'\n').count();
1151 let mut line_at = cursor.running_line.saturating_sub(newlines_in_overlap);
1152 let mut counted_to = 0usize;
1153 let mut matches = Vec::new();
1154
1155 for m in regex.find_iter(&chunk) {
1156 if overlap_len > 0 && m.end() <= overlap_len {
1158 continue;
1159 }
1160 if matches.len() >= opts.max_matches {
1161 break;
1162 }
1163
1164 line_at += chunk[counted_to..m.start()]
1166 .iter()
1167 .filter(|&&b| b == b'\n')
1168 .count();
1169 counted_to = m.start();
1170
1171 let line_start = chunk[..m.start()]
1173 .iter()
1174 .rposition(|&b| b == b'\n')
1175 .map(|p| p + 1)
1176 .unwrap_or(0);
1177 let line_end = chunk[m.start()..]
1178 .iter()
1179 .position(|&b| b == b'\n')
1180 .map(|p| m.start() + p)
1181 .unwrap_or(chunk.len());
1182
1183 let column = m.start() - line_start + 1;
1184 let context = String::from_utf8_lossy(&chunk[line_start..line_end]).into_owned();
1185
1186 matches.push(SearchMatch {
1187 byte_offset: read_start + m.start(),
1188 length: m.end() - m.start(),
1189 line: line_at,
1190 column,
1191 context,
1192 });
1193 }
1194
1195 let new_data = &chunk[overlap_len..];
1197 cursor.running_line += new_data.iter().filter(|&&b| b == b'\n').count();
1198 cursor.offset = read_end;
1199 if read_end >= effective_end {
1200 cursor.done = true;
1201 }
1202
1203 Ok(matches)
1204}
1205
1206#[derive(Debug, Clone, Copy, Default)]
1214pub struct StdFileSystem;
1215
1216impl StdFileSystem {
1217 fn is_hidden(path: &Path) -> bool {
1219 path.file_name()
1220 .and_then(|n| n.to_str())
1221 .is_some_and(|n| n.starts_with('.'))
1222 }
1223
1224 #[cfg(unix)]
1226 pub fn current_user_groups() -> (u32, Vec<u32>) {
1227 let euid = unsafe { libc::geteuid() };
1229 let egid = unsafe { libc::getegid() };
1230 let mut groups = vec![egid];
1231
1232 let ngroups = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
1234 if ngroups > 0 {
1235 let mut sup_groups = vec![0 as libc::gid_t; ngroups as usize];
1236 let n = unsafe { libc::getgroups(ngroups, sup_groups.as_mut_ptr()) };
1237 if n > 0 {
1238 sup_groups.truncate(n as usize);
1239 for g in sup_groups {
1240 if g != egid {
1241 groups.push(g);
1242 }
1243 }
1244 }
1245 }
1246
1247 (euid, groups)
1248 }
1249
1250 #[cfg(unix)]
1257 fn kernel_writable(path: &Path) -> Option<bool> {
1258 use std::os::unix::ffi::OsStrExt;
1259 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1260 let rc = unsafe {
1263 libc::faccessat(
1264 libc::AT_FDCWD,
1265 c_path.as_ptr(),
1266 libc::W_OK,
1267 libc::AT_EACCESS,
1268 )
1269 };
1270 Some(rc == 0)
1271 }
1272
1273 fn build_metadata(path: &Path, meta: &std::fs::Metadata) -> FileMetadata {
1275 #[cfg(unix)]
1276 {
1277 use std::os::unix::fs::MetadataExt;
1278 let file_uid = meta.uid();
1279 let file_gid = meta.gid();
1280 let permissions = FilePermissions::from_std(meta.permissions());
1281 let is_readonly = match Self::kernel_writable(path) {
1285 Some(writable) => !writable,
1286 None => {
1287 let (euid, user_groups) = Self::current_user_groups();
1288 permissions.is_readonly_for_user(euid, file_uid, file_gid, &user_groups)
1289 }
1290 };
1291 FileMetadata {
1292 size: meta.len(),
1293 modified: meta.modified().ok(),
1294 permissions: Some(permissions),
1295 is_hidden: Self::is_hidden(path),
1296 is_readonly,
1297 uid: Some(file_uid),
1298 gid: Some(file_gid),
1299 }
1300 }
1301 #[cfg(not(unix))]
1302 {
1303 FileMetadata {
1304 size: meta.len(),
1305 modified: meta.modified().ok(),
1306 permissions: Some(FilePermissions::from_std(meta.permissions())),
1307 is_hidden: Self::is_hidden(path),
1308 is_readonly: meta.permissions().readonly(),
1309 }
1310 }
1311 }
1312}
1313
1314impl FileSystem for StdFileSystem {
1315 fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
1317 let data = std::fs::read(path)?;
1318 crate::services::counters::global().inc_disk_bytes_read(data.len() as u64);
1319 Ok(data)
1320 }
1321
1322 fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
1323 let mut file = std::fs::File::open(path)?;
1324 file.seek(io::SeekFrom::Start(offset))?;
1325 let mut buffer = vec![0u8; len];
1326 file.read_exact(&mut buffer)?;
1327 crate::services::counters::global().inc_disk_bytes_read(len as u64);
1328 Ok(buffer)
1329 }
1330
1331 fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()> {
1332 let original_metadata = self.metadata_if_exists(path);
1333 let temp_path = self.temp_path_for(path);
1334 {
1335 let mut file = self.create_file(&temp_path)?;
1336 file.write_all(data)?;
1337 file.sync_all()?;
1338 }
1339 if let Some(ref meta) = original_metadata {
1340 if let Some(ref perms) = meta.permissions {
1341 #[allow(clippy::let_underscore_must_use)]
1343 let _ = self.set_permissions(&temp_path, perms);
1344 }
1345 }
1346 self.rename(&temp_path, path)?;
1347 Ok(())
1348 }
1349
1350 fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1351 let file = std::fs::File::create(path)?;
1352 Ok(Box::new(StdFileWriter(file)))
1353 }
1354
1355 fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>> {
1356 let file = std::fs::File::open(path)?;
1357 Ok(Box::new(StdFileReader(file)))
1358 }
1359
1360 fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1361 let file = std::fs::OpenOptions::new()
1362 .write(true)
1363 .truncate(true)
1364 .open(path)?;
1365 Ok(Box::new(StdFileWriter(file)))
1366 }
1367
1368 fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1369 let file = std::fs::OpenOptions::new()
1370 .create(true)
1371 .append(true)
1372 .open(path)?;
1373 Ok(Box::new(StdFileWriter(file)))
1374 }
1375
1376 fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()> {
1377 let file = std::fs::OpenOptions::new().write(true).open(path)?;
1378 file.set_len(len)
1379 }
1380
1381 fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
1383 std::fs::rename(from, to)
1384 }
1385
1386 fn copy(&self, from: &Path, to: &Path) -> io::Result<u64> {
1387 std::fs::copy(from, to)
1388 }
1389
1390 fn remove_file(&self, path: &Path) -> io::Result<()> {
1391 std::fs::remove_file(path)
1392 }
1393
1394 fn remove_dir(&self, path: &Path) -> io::Result<()> {
1395 std::fs::remove_dir(path)
1396 }
1397
1398 fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1400 let meta = std::fs::metadata(path)?;
1401 Ok(Self::build_metadata(path, &meta))
1402 }
1403
1404 fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1405 let meta = std::fs::symlink_metadata(path)?;
1406 Ok(Self::build_metadata(path, &meta))
1407 }
1408
1409 fn is_dir(&self, path: &Path) -> io::Result<bool> {
1410 Ok(std::fs::metadata(path)?.is_dir())
1411 }
1412
1413 fn is_file(&self, path: &Path) -> io::Result<bool> {
1414 Ok(std::fs::metadata(path)?.is_file())
1415 }
1416
1417 fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()> {
1418 std::fs::set_permissions(path, permissions.to_std())
1419 }
1420
1421 fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
1423 let mut entries = Vec::new();
1424 for entry in std::fs::read_dir(path)? {
1425 let entry = entry?;
1426 let path = entry.path();
1427 let name = entry.file_name().to_string_lossy().into_owned();
1428 let file_type = entry.file_type()?;
1429
1430 let entry_type = if file_type.is_dir() {
1431 EntryType::Directory
1432 } else if file_type.is_symlink() {
1433 EntryType::Symlink
1434 } else {
1435 EntryType::File
1436 };
1437
1438 let mut dir_entry = DirEntry::new(path.clone(), name, entry_type);
1439
1440 if file_type.is_symlink() {
1442 dir_entry.symlink_target_is_dir = std::fs::metadata(&path)
1443 .map(|m| m.is_dir())
1444 .unwrap_or(false);
1445 }
1446
1447 entries.push(dir_entry);
1448 }
1449 Ok(entries)
1450 }
1451
1452 fn create_dir(&self, path: &Path) -> io::Result<()> {
1453 std::fs::create_dir(path)
1454 }
1455
1456 fn create_dir_all(&self, path: &Path) -> io::Result<()> {
1457 std::fs::create_dir_all(path)
1458 }
1459
1460 fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
1462 std::fs::canonicalize(path)
1463 }
1464
1465 fn current_uid(&self) -> u32 {
1467 #[cfg(all(unix, feature = "runtime"))]
1468 {
1469 unsafe { libc::getuid() }
1471 }
1472 #[cfg(not(all(unix, feature = "runtime")))]
1473 {
1474 0
1475 }
1476 }
1477
1478 fn sudo_write(
1479 &self,
1480 path: &Path,
1481 data: &[u8],
1482 mode: u32,
1483 uid: u32,
1484 gid: u32,
1485 ) -> io::Result<()> {
1486 use crate::services::process_hidden::HideWindow;
1487 use std::process::{Command, Stdio};
1488
1489 let mut child = Command::new("sudo")
1491 .args(["tee", &path.to_string_lossy()])
1492 .stdin(Stdio::piped())
1493 .stdout(Stdio::null())
1494 .stderr(Stdio::piped())
1495 .hide_window()
1496 .spawn()
1497 .map_err(|e| io::Error::other(format!("failed to spawn sudo: {}", e)))?;
1498
1499 if let Some(mut stdin) = child.stdin.take() {
1500 use std::io::Write;
1501 stdin.write_all(data)?;
1502 }
1503
1504 let output = child.wait_with_output()?;
1505 if !output.status.success() {
1506 let stderr = String::from_utf8_lossy(&output.stderr);
1507 return Err(io::Error::new(
1508 io::ErrorKind::PermissionDenied,
1509 format!("sudo tee failed: {}", stderr.trim()),
1510 ));
1511 }
1512
1513 let status = Command::new("sudo")
1515 .args(["chmod", &format!("{:o}", mode), &path.to_string_lossy()])
1516 .hide_window()
1517 .status()?;
1518 if !status.success() {
1519 return Err(io::Error::other("sudo chmod failed"));
1520 }
1521
1522 let status = Command::new("sudo")
1524 .args([
1525 "chown",
1526 &format!("{}:{}", uid, gid),
1527 &path.to_string_lossy(),
1528 ])
1529 .hide_window()
1530 .status()?;
1531 if !status.success() {
1532 return Err(io::Error::other("sudo chown failed"));
1533 }
1534
1535 Ok(())
1536 }
1537
1538 fn search_file(
1539 &self,
1540 path: &Path,
1541 pattern: &str,
1542 opts: &FileSearchOptions,
1543 cursor: &mut FileSearchCursor,
1544 ) -> io::Result<Vec<SearchMatch>> {
1545 default_search_file(self, path, pattern, opts, cursor)
1546 }
1547
1548 fn walk_files(
1549 &self,
1550 root: &Path,
1551 skip_dirs: &[&str],
1552 cancel: &std::sync::atomic::AtomicBool,
1553 on_file: &mut dyn FnMut(&Path, &str) -> bool,
1554 ) -> io::Result<()> {
1555 let mut stack = vec![root.to_path_buf()];
1556 while let Some(dir) = stack.pop() {
1557 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1558 return Ok(());
1559 }
1560
1561 let iter = match std::fs::read_dir(&dir) {
1565 Ok(it) => it,
1566 Err(_) => continue,
1567 };
1568
1569 for entry in iter {
1570 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1571 return Ok(());
1572 }
1573 let entry = match entry {
1574 Ok(e) => e,
1575 Err(_) => continue,
1576 };
1577 let name = entry.file_name();
1578 let name_str = name.to_string_lossy();
1579
1580 if name_str.starts_with('.') {
1582 continue;
1583 }
1584
1585 let ft = match entry.file_type() {
1586 Ok(ft) => ft,
1587 Err(_) => continue,
1588 };
1589 let path = entry.path();
1590
1591 if ft.is_file() {
1592 if let Ok(rel) = path.strip_prefix(root) {
1593 let rel_str = rel.to_string_lossy().replace('\\', "/");
1594 if !on_file(&path, &rel_str) {
1595 return Ok(());
1596 }
1597 }
1598 } else if ft.is_dir() && !skip_dirs.contains(&name_str.as_ref()) {
1599 stack.push(path);
1600 }
1601 }
1602 }
1603 Ok(())
1604 }
1605}
1606
1607#[derive(Debug, Clone, Copy, Default)]
1616pub struct NoopFileSystem;
1617
1618impl NoopFileSystem {
1619 fn unsupported<T>() -> io::Result<T> {
1620 Err(io::Error::new(
1621 io::ErrorKind::Unsupported,
1622 "Filesystem not available",
1623 ))
1624 }
1625}
1626
1627impl FileSystem for NoopFileSystem {
1628 fn read_file(&self, _path: &Path) -> io::Result<Vec<u8>> {
1629 Self::unsupported()
1630 }
1631
1632 fn read_range(&self, _path: &Path, _offset: u64, _len: usize) -> io::Result<Vec<u8>> {
1633 Self::unsupported()
1634 }
1635
1636 fn write_file(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
1637 Self::unsupported()
1638 }
1639
1640 fn create_file(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1641 Self::unsupported()
1642 }
1643
1644 fn open_file(&self, _path: &Path) -> io::Result<Box<dyn FileReader>> {
1645 Self::unsupported()
1646 }
1647
1648 fn open_file_for_write(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1649 Self::unsupported()
1650 }
1651
1652 fn open_file_for_append(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1653 Self::unsupported()
1654 }
1655
1656 fn set_file_length(&self, _path: &Path, _len: u64) -> io::Result<()> {
1657 Self::unsupported()
1658 }
1659
1660 fn rename(&self, _from: &Path, _to: &Path) -> io::Result<()> {
1661 Self::unsupported()
1662 }
1663
1664 fn copy(&self, _from: &Path, _to: &Path) -> io::Result<u64> {
1665 Self::unsupported()
1666 }
1667
1668 fn remove_file(&self, _path: &Path) -> io::Result<()> {
1669 Self::unsupported()
1670 }
1671
1672 fn remove_dir(&self, _path: &Path) -> io::Result<()> {
1673 Self::unsupported()
1674 }
1675
1676 fn metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1677 Self::unsupported()
1678 }
1679
1680 fn symlink_metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1681 Self::unsupported()
1682 }
1683
1684 fn is_dir(&self, _path: &Path) -> io::Result<bool> {
1685 Self::unsupported()
1686 }
1687
1688 fn is_file(&self, _path: &Path) -> io::Result<bool> {
1689 Self::unsupported()
1690 }
1691
1692 fn set_permissions(&self, _path: &Path, _permissions: &FilePermissions) -> io::Result<()> {
1693 Self::unsupported()
1694 }
1695
1696 fn read_dir(&self, _path: &Path) -> io::Result<Vec<DirEntry>> {
1697 Self::unsupported()
1698 }
1699
1700 fn create_dir(&self, _path: &Path) -> io::Result<()> {
1701 Self::unsupported()
1702 }
1703
1704 fn create_dir_all(&self, _path: &Path) -> io::Result<()> {
1705 Self::unsupported()
1706 }
1707
1708 fn canonicalize(&self, _path: &Path) -> io::Result<PathBuf> {
1709 Self::unsupported()
1710 }
1711
1712 fn current_uid(&self) -> u32 {
1713 0
1714 }
1715
1716 fn search_file(
1717 &self,
1718 _path: &Path,
1719 _pattern: &str,
1720 _opts: &FileSearchOptions,
1721 _cursor: &mut FileSearchCursor,
1722 ) -> io::Result<Vec<SearchMatch>> {
1723 Self::unsupported()
1724 }
1725
1726 fn sudo_write(
1727 &self,
1728 _path: &Path,
1729 _data: &[u8],
1730 _mode: u32,
1731 _uid: u32,
1732 _gid: u32,
1733 ) -> io::Result<()> {
1734 Self::unsupported()
1735 }
1736
1737 fn walk_files(
1738 &self,
1739 _root: &Path,
1740 _skip_dirs: &[&str],
1741 _cancel: &std::sync::atomic::AtomicBool,
1742 _on_file: &mut dyn FnMut(&Path, &str) -> bool,
1743 ) -> io::Result<()> {
1744 Self::unsupported()
1745 }
1746}
1747
1748#[cfg(test)]
1753mod tests {
1754 use super::*;
1755 use tempfile::NamedTempFile;
1756
1757 #[test]
1758 fn test_std_filesystem_read_write() {
1759 let fs = StdFileSystem;
1760 let mut temp = NamedTempFile::new().unwrap();
1761 let path = temp.path().to_path_buf();
1762
1763 std::io::Write::write_all(&mut temp, b"Hello, World!").unwrap();
1764 std::io::Write::flush(&mut temp).unwrap();
1765
1766 let content = fs.read_file(&path).unwrap();
1767 assert_eq!(content, b"Hello, World!");
1768
1769 let range = fs.read_range(&path, 7, 5).unwrap();
1770 assert_eq!(range, b"World");
1771
1772 let meta = fs.metadata(&path).unwrap();
1773 assert_eq!(meta.size, 13);
1774 }
1775
1776 #[test]
1777 fn test_noop_filesystem() {
1778 let fs = NoopFileSystem;
1779 let path = Path::new("/some/path");
1780
1781 assert!(fs.read_file(path).is_err());
1782 assert!(fs.read_range(path, 0, 10).is_err());
1783 assert!(fs.write_file(path, b"data").is_err());
1784 assert!(fs.metadata(path).is_err());
1785 assert!(fs.read_dir(path).is_err());
1786 }
1787
1788 #[test]
1789 fn test_create_and_write_file() {
1790 let fs = StdFileSystem;
1791 let temp_dir = tempfile::tempdir().unwrap();
1792 let path = temp_dir.path().join("test.txt");
1793
1794 {
1795 let mut writer = fs.create_file(&path).unwrap();
1796 writer.write_all(b"test content").unwrap();
1797 writer.sync_all().unwrap();
1798 }
1799
1800 let content = fs.read_file(&path).unwrap();
1801 assert_eq!(content, b"test content");
1802 }
1803
1804 #[test]
1805 fn test_read_dir() {
1806 let fs = StdFileSystem;
1807 let temp_dir = tempfile::tempdir().unwrap();
1808
1809 fs.create_dir(&temp_dir.path().join("subdir")).unwrap();
1811 fs.write_file(&temp_dir.path().join("file1.txt"), b"content1")
1812 .unwrap();
1813 fs.write_file(&temp_dir.path().join("file2.txt"), b"content2")
1814 .unwrap();
1815
1816 let entries = fs.read_dir(temp_dir.path()).unwrap();
1817 assert_eq!(entries.len(), 3);
1818
1819 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
1820 assert!(names.contains(&"subdir"));
1821 assert!(names.contains(&"file1.txt"));
1822 assert!(names.contains(&"file2.txt"));
1823 }
1824
1825 #[test]
1826 fn test_dir_entry_types() {
1827 let file = DirEntry::new(PathBuf::from("/file"), "file".to_string(), EntryType::File);
1828 assert!(file.is_file());
1829 assert!(!file.is_dir());
1830
1831 let dir = DirEntry::new(
1832 PathBuf::from("/dir"),
1833 "dir".to_string(),
1834 EntryType::Directory,
1835 );
1836 assert!(dir.is_dir());
1837 assert!(!dir.is_file());
1838
1839 let link_to_dir = DirEntry::new_symlink(PathBuf::from("/link"), "link".to_string(), true);
1840 assert!(link_to_dir.is_symlink());
1841 assert!(link_to_dir.is_dir());
1842 }
1843
1844 #[test]
1845 fn test_metadata_builder() {
1846 let meta = FileMetadata::default()
1847 .with_hidden(true)
1848 .with_readonly(true);
1849 assert!(meta.is_hidden);
1850 assert!(meta.is_readonly);
1851 }
1852
1853 #[test]
1854 fn test_atomic_write() {
1855 let fs = StdFileSystem;
1856 let temp_dir = tempfile::tempdir().unwrap();
1857 let path = temp_dir.path().join("atomic_test.txt");
1858
1859 fs.write_file(&path, b"initial").unwrap();
1860 assert_eq!(fs.read_file(&path).unwrap(), b"initial");
1861
1862 fs.write_file(&path, b"updated").unwrap();
1863 assert_eq!(fs.read_file(&path).unwrap(), b"updated");
1864 }
1865
1866 #[test]
1867 fn test_write_patched_default_impl() {
1868 let fs = StdFileSystem;
1870 let temp_dir = tempfile::tempdir().unwrap();
1871 let src_path = temp_dir.path().join("source.txt");
1872 let dst_path = temp_dir.path().join("dest.txt");
1873
1874 fs.write_file(&src_path, b"AAABBBCCC").unwrap();
1876
1877 let ops = vec![
1879 WriteOp::Copy { offset: 0, len: 3 }, WriteOp::Insert { data: b"XXX" }, WriteOp::Copy { offset: 6, len: 3 }, ];
1883
1884 fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1885
1886 let result = fs.read_file(&dst_path).unwrap();
1887 assert_eq!(result, b"AAAXXXCCC");
1888 }
1889
1890 #[test]
1891 fn test_write_patched_same_file() {
1892 let fs = StdFileSystem;
1894 let temp_dir = tempfile::tempdir().unwrap();
1895 let path = temp_dir.path().join("file.txt");
1896
1897 fs.write_file(&path, b"Hello World").unwrap();
1899
1900 let ops = vec![
1902 WriteOp::Copy { offset: 0, len: 6 }, WriteOp::Insert { data: b"Rust" }, ];
1905
1906 fs.write_patched(&path, &path, &ops).unwrap();
1907
1908 let result = fs.read_file(&path).unwrap();
1909 assert_eq!(result, b"Hello Rust");
1910 }
1911
1912 #[test]
1913 fn test_write_patched_insert_only() {
1914 let fs = StdFileSystem;
1916 let temp_dir = tempfile::tempdir().unwrap();
1917 let src_path = temp_dir.path().join("empty.txt");
1918 let dst_path = temp_dir.path().join("new.txt");
1919
1920 fs.write_file(&src_path, b"").unwrap();
1922
1923 let ops = vec![WriteOp::Insert {
1924 data: b"All new content",
1925 }];
1926
1927 fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1928
1929 let result = fs.read_file(&dst_path).unwrap();
1930 assert_eq!(result, b"All new content");
1931 }
1932
1933 fn make_search_opts(pattern_is_fixed: bool) -> FileSearchOptions {
1938 FileSearchOptions {
1939 fixed_string: pattern_is_fixed,
1940 case_sensitive: true,
1941 whole_word: false,
1942 max_matches: 100,
1943 }
1944 }
1945
1946 #[test]
1947 fn test_search_file_basic() {
1948 let fs = StdFileSystem;
1949 let temp_dir = tempfile::tempdir().unwrap();
1950 let path = temp_dir.path().join("test.txt");
1951 fs.write_file(&path, b"hello world\nfoo bar\nhello again\n")
1952 .unwrap();
1953
1954 let opts = make_search_opts(true);
1955 let mut cursor = FileSearchCursor::new();
1956 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1957
1958 assert!(cursor.done);
1959 assert_eq!(matches.len(), 2);
1960
1961 assert_eq!(matches[0].line, 1);
1962 assert_eq!(matches[0].column, 1);
1963 assert_eq!(matches[0].context, "hello world");
1964
1965 assert_eq!(matches[1].line, 3);
1966 assert_eq!(matches[1].column, 1);
1967 assert_eq!(matches[1].context, "hello again");
1968 }
1969
1970 #[test]
1971 fn test_search_file_no_matches() {
1972 let fs = StdFileSystem;
1973 let temp_dir = tempfile::tempdir().unwrap();
1974 let path = temp_dir.path().join("test.txt");
1975 fs.write_file(&path, b"hello world\n").unwrap();
1976
1977 let opts = make_search_opts(true);
1978 let mut cursor = FileSearchCursor::new();
1979 let matches = fs
1980 .search_file(&path, "NOTFOUND", &opts, &mut cursor)
1981 .unwrap();
1982
1983 assert!(cursor.done);
1984 assert!(matches.is_empty());
1985 }
1986
1987 #[test]
1988 fn test_search_file_case_insensitive() {
1989 let fs = StdFileSystem;
1990 let temp_dir = tempfile::tempdir().unwrap();
1991 let path = temp_dir.path().join("test.txt");
1992 fs.write_file(&path, b"Hello HELLO hello\n").unwrap();
1993
1994 let opts = FileSearchOptions {
1995 fixed_string: true,
1996 case_sensitive: false,
1997 whole_word: false,
1998 max_matches: 100,
1999 };
2000 let mut cursor = FileSearchCursor::new();
2001 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2002
2003 assert_eq!(matches.len(), 3);
2004 }
2005
2006 #[test]
2007 fn test_search_file_whole_word() {
2008 let fs = StdFileSystem;
2009 let temp_dir = tempfile::tempdir().unwrap();
2010 let path = temp_dir.path().join("test.txt");
2011 fs.write_file(&path, b"cat concatenate catalog\n").unwrap();
2012
2013 let opts = FileSearchOptions {
2014 fixed_string: true,
2015 case_sensitive: true,
2016 whole_word: true,
2017 max_matches: 100,
2018 };
2019 let mut cursor = FileSearchCursor::new();
2020 let matches = fs.search_file(&path, "cat", &opts, &mut cursor).unwrap();
2021
2022 assert_eq!(matches.len(), 1);
2023 assert_eq!(matches[0].column, 1);
2024 }
2025
2026 #[test]
2027 fn test_search_file_regex() {
2028 let fs = StdFileSystem;
2029 let temp_dir = tempfile::tempdir().unwrap();
2030 let path = temp_dir.path().join("test.txt");
2031 fs.write_file(&path, b"foo123 bar456 baz\n").unwrap();
2032
2033 let opts = FileSearchOptions {
2034 fixed_string: false,
2035 case_sensitive: true,
2036 whole_word: false,
2037 max_matches: 100,
2038 };
2039 let mut cursor = FileSearchCursor::new();
2040 let matches = fs
2041 .search_file(&path, r"[a-z]+\d+", &opts, &mut cursor)
2042 .unwrap();
2043
2044 assert_eq!(matches.len(), 2);
2045 assert_eq!(matches[0].context, "foo123 bar456 baz");
2046 }
2047
2048 #[test]
2049 fn test_search_file_binary_skipped() {
2050 let fs = StdFileSystem;
2051 let temp_dir = tempfile::tempdir().unwrap();
2052 let path = temp_dir.path().join("binary.dat");
2053 let mut data = b"hello world\n".to_vec();
2054 data.push(0); data.extend_from_slice(b"hello again\n");
2056 fs.write_file(&path, &data).unwrap();
2057
2058 let opts = make_search_opts(true);
2059 let mut cursor = FileSearchCursor::new();
2060 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2061
2062 assert!(cursor.done);
2063 assert!(matches.is_empty());
2064 }
2065
2066 #[test]
2071 fn test_search_file_binary_extension_skipped_despite_text_content() {
2072 let fs = StdFileSystem;
2073 let temp_dir = tempfile::tempdir().unwrap();
2074 let path = temp_dir.path().join("not_actually_binary.png");
2075 fs.write_file(&path, b"hello world\nhello again\n").unwrap();
2076
2077 let opts = make_search_opts(true);
2078 let mut cursor = FileSearchCursor::new();
2079 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2080
2081 assert!(cursor.done);
2082 assert!(
2083 matches.is_empty(),
2084 ".png extension should short-circuit before content scan"
2085 );
2086 }
2087
2088 #[test]
2092 fn test_search_file_binary_extension_case_insensitive() {
2093 let fs = StdFileSystem;
2094 let temp_dir = tempfile::tempdir().unwrap();
2095 for name in ["IMG.JPG", "archive.tar.gz", "weights.SafeTensors"] {
2096 let path = temp_dir.path().join(name);
2097 fs.write_file(&path, b"definitely text content here\n")
2098 .unwrap();
2099
2100 let opts = make_search_opts(true);
2101 let mut cursor = FileSearchCursor::new();
2102 let matches = fs
2103 .search_file(&path, "definitely", &opts, &mut cursor)
2104 .unwrap();
2105
2106 assert!(cursor.done, "{} should be marked done", name);
2107 assert!(
2108 matches.is_empty(),
2109 "{} matched but extension should have skipped it",
2110 name
2111 );
2112 }
2113 }
2114
2115 #[test]
2121 fn test_search_file_utf16_skipped_via_encoding_gate() {
2122 let fs = StdFileSystem;
2123 let temp_dir = tempfile::tempdir().unwrap();
2124 let path = temp_dir.path().join("utf16.txt");
2125 let mut data = vec![0xFF, 0xFE];
2127 for ch in "hello world\nhello again\n".chars() {
2128 let n = ch as u32;
2129 data.push((n & 0xFF) as u8);
2130 data.push(((n >> 8) & 0xFF) as u8);
2131 }
2132 fs.write_file(&path, &data).unwrap();
2133
2134 let opts = make_search_opts(true);
2135 let mut cursor = FileSearchCursor::new();
2136 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2137
2138 assert!(cursor.done);
2139 assert!(
2140 matches.is_empty(),
2141 "UTF-16 file must be skipped: byte-regex can't match UTF-8 patterns in it"
2142 );
2143 }
2144
2145 #[test]
2151 fn test_search_file_midstream_nul_aborts_scan() {
2152 let fs = StdFileSystem;
2153 let temp_dir = tempfile::tempdir().unwrap();
2154 let path = temp_dir.path().join("mid.dat");
2155
2156 let mut data = vec![b'a'; 9000];
2159 data.push(0);
2160 data.extend_from_slice(b"PATTERN_AFTER_NUL\n");
2161 fs.write_file(&path, &data).unwrap();
2162
2163 let opts = make_search_opts(true);
2164 let mut cursor = FileSearchCursor::new();
2165 let matches = fs
2166 .search_file(&path, "PATTERN_AFTER_NUL", &opts, &mut cursor)
2167 .unwrap();
2168
2169 assert!(cursor.done);
2170 assert!(
2171 matches.is_empty(),
2172 "mid-stream NUL should abort scan and discard pseudo-matches"
2173 );
2174 }
2175
2176 #[test]
2177 fn test_search_file_empty_file() {
2178 let fs = StdFileSystem;
2179 let temp_dir = tempfile::tempdir().unwrap();
2180 let path = temp_dir.path().join("empty.txt");
2181 fs.write_file(&path, b"").unwrap();
2182
2183 let opts = make_search_opts(true);
2184 let mut cursor = FileSearchCursor::new();
2185 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2186
2187 assert!(cursor.done);
2188 assert!(matches.is_empty());
2189 }
2190
2191 #[test]
2195 fn test_search_file_oversized_skipped() {
2196 let fs = StdFileSystem;
2197 let temp_dir = tempfile::tempdir().unwrap();
2198 let path = temp_dir.path().join("oversized.txt");
2199
2200 let mut data = vec![b'a'; (MAX_PROJECT_SEARCH_FILE_SIZE as usize) + 1024];
2204 data.extend_from_slice(b"\nUNIQUE_TAIL_MARKER\n");
2205 fs.write_file(&path, &data).unwrap();
2206
2207 let opts = make_search_opts(true);
2208 let mut cursor = FileSearchCursor::new();
2209 let matches = fs
2210 .search_file(&path, "UNIQUE_TAIL_MARKER", &opts, &mut cursor)
2211 .unwrap();
2212
2213 assert!(
2214 cursor.done,
2215 "oversized file should be marked done in one call"
2216 );
2217 assert!(matches.is_empty(), "oversized file should yield no matches");
2218 }
2219
2220 #[test]
2225 fn test_search_file_binary_control_char_skipped() {
2226 let fs = StdFileSystem;
2227 let temp_dir = tempfile::tempdir().unwrap();
2228 let path = temp_dir.path().join("ctrl.dat");
2229 let mut data = b"hello world\n".to_vec();
2232 data.push(0x1A);
2233 data.extend_from_slice(b"hello again\n");
2234 fs.write_file(&path, &data).unwrap();
2235
2236 let opts = make_search_opts(true);
2237 let mut cursor = FileSearchCursor::new();
2238 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2239
2240 assert!(cursor.done);
2241 assert!(matches.is_empty());
2242 }
2243
2244 #[test]
2245 fn test_search_file_max_matches() {
2246 let fs = StdFileSystem;
2247 let temp_dir = tempfile::tempdir().unwrap();
2248 let path = temp_dir.path().join("test.txt");
2249 fs.write_file(&path, b"aa bb aa cc aa dd aa\n").unwrap();
2250
2251 let opts = FileSearchOptions {
2252 fixed_string: true,
2253 case_sensitive: true,
2254 whole_word: false,
2255 max_matches: 2,
2256 };
2257 let mut cursor = FileSearchCursor::new();
2258 let matches = fs.search_file(&path, "aa", &opts, &mut cursor).unwrap();
2259
2260 assert_eq!(matches.len(), 2);
2261 }
2262
2263 #[test]
2264 fn test_search_file_cursor_multi_chunk() {
2265 let fs = StdFileSystem;
2266 let temp_dir = tempfile::tempdir().unwrap();
2267 let path = temp_dir.path().join("large.txt");
2268
2269 let mut content = Vec::new();
2271 for i in 0..100_000 {
2272 content.extend_from_slice(format!("line {} content here\n", i).as_bytes());
2273 }
2274 fs.write_file(&path, &content).unwrap();
2275
2276 let opts = FileSearchOptions {
2277 fixed_string: true,
2278 case_sensitive: true,
2279 whole_word: false,
2280 max_matches: 1000,
2281 };
2282 let mut cursor = FileSearchCursor::new();
2283 let mut all_matches = Vec::new();
2284
2285 while !cursor.done {
2286 let batch = fs
2287 .search_file(&path, "line 5000", &opts, &mut cursor)
2288 .unwrap();
2289 all_matches.extend(batch);
2290 }
2291
2292 assert_eq!(all_matches.len(), 11);
2295
2296 let first = &all_matches[0];
2298 assert_eq!(first.line, 5001); assert_eq!(first.column, 1);
2300 assert!(first.context.starts_with("line 5000"));
2301 }
2302
2303 #[test]
2304 fn test_search_file_cursor_no_duplicates() {
2305 let fs = StdFileSystem;
2306 let temp_dir = tempfile::tempdir().unwrap();
2307 let path = temp_dir.path().join("large.txt");
2308
2309 let mut content = Vec::new();
2311 for i in 0..100_000 {
2312 content.extend_from_slice(format!("MARKER_{:06}\n", i).as_bytes());
2313 }
2314 fs.write_file(&path, &content).unwrap();
2315
2316 let opts = FileSearchOptions {
2317 fixed_string: true,
2318 case_sensitive: true,
2319 whole_word: false,
2320 max_matches: 200_000,
2321 };
2322 let mut cursor = FileSearchCursor::new();
2323 let mut all_matches = Vec::new();
2324 let mut batches = 0;
2325
2326 while !cursor.done {
2327 let batch = fs
2328 .search_file(&path, "MARKER_", &opts, &mut cursor)
2329 .unwrap();
2330 all_matches.extend(batch);
2331 batches += 1;
2332 }
2333
2334 assert!(batches > 1, "Expected multiple batches, got {}", batches);
2336 assert_eq!(all_matches.len(), 100_000);
2338 let mut offsets: Vec<usize> = all_matches.iter().map(|m| m.byte_offset).collect();
2340 offsets.sort();
2341 offsets.dedup();
2342 assert_eq!(offsets.len(), 100_000);
2343 }
2344
2345 #[test]
2346 fn test_search_file_line_numbers_across_chunks() {
2347 let fs = StdFileSystem;
2348 let temp_dir = tempfile::tempdir().unwrap();
2349 let path = temp_dir.path().join("large.txt");
2350
2351 let mut content = Vec::new();
2353 let total_lines = 100_000;
2354 for i in 0..total_lines {
2355 if i == 99_999 {
2356 content.extend_from_slice(b"FINDME at the end\n");
2357 } else {
2358 content.extend_from_slice(format!("padding line {}\n", i).as_bytes());
2359 }
2360 }
2361 fs.write_file(&path, &content).unwrap();
2362
2363 let opts = make_search_opts(true);
2364 let mut cursor = FileSearchCursor::new();
2365 let mut all_matches = Vec::new();
2366
2367 while !cursor.done {
2368 let batch = fs.search_file(&path, "FINDME", &opts, &mut cursor).unwrap();
2369 all_matches.extend(batch);
2370 }
2371
2372 assert_eq!(all_matches.len(), 1);
2373 assert_eq!(all_matches[0].line, total_lines); assert_eq!(all_matches[0].context, "FINDME at the end");
2375 }
2376
2377 #[test]
2378 fn test_search_file_end_offset_bounds_search() {
2379 let fs = StdFileSystem;
2380 let temp_dir = tempfile::tempdir().unwrap();
2381 let path = temp_dir.path().join("bounded.txt");
2382
2383 fs.write_file(&path, b"AAA\nBBB\nCCC\nDDD\n").unwrap();
2385
2386 let opts = make_search_opts(true);
2388 let mut cursor = FileSearchCursor::for_range(0, 8, 1);
2389 let mut matches = Vec::new();
2390 while !cursor.done {
2391 matches.extend(fs.search_file(&path, "AAA", &opts, &mut cursor).unwrap());
2392 }
2393 assert_eq!(matches.len(), 1);
2394 assert_eq!(matches[0].context, "AAA");
2395 assert_eq!(matches[0].line, 1);
2396
2397 let mut cursor = FileSearchCursor::for_range(0, 8, 1);
2399 let ccc = fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap();
2400 assert!(ccc.is_empty(), "CCC should not be found in first 8 bytes");
2401
2402 let mut cursor = FileSearchCursor::for_range(8, 16, 3);
2404 let mut matches = Vec::new();
2405 while !cursor.done {
2406 matches.extend(fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap());
2407 }
2408 assert_eq!(matches.len(), 1);
2409 assert_eq!(matches[0].context, "CCC");
2410 assert_eq!(matches[0].line, 3);
2411 }
2412
2413 fn make_walk_tree() -> tempfile::TempDir {
2420 let fs = StdFileSystem;
2421 let tmp = tempfile::tempdir().unwrap();
2422 let root = tmp.path();
2423
2424 fs.write_file(&root.join("a.txt"), b"a").unwrap();
2439 fs.write_file(&root.join("b.txt"), b"b").unwrap();
2440 fs.create_dir_all(&root.join("sub/deep")).unwrap();
2441 fs.write_file(&root.join("sub/c.txt"), b"c").unwrap();
2442 fs.write_file(&root.join("sub/deep/d.txt"), b"d").unwrap();
2443 fs.create_dir_all(&root.join(".hidden_dir")).unwrap();
2444 fs.write_file(&root.join(".hidden_dir/secret.txt"), b"s")
2445 .unwrap();
2446 fs.write_file(&root.join(".hidden_file"), b"h").unwrap();
2447 fs.create_dir_all(&root.join("node_modules")).unwrap();
2448 fs.write_file(&root.join("node_modules/pkg.json"), b"{}")
2449 .unwrap();
2450 fs.create_dir_all(&root.join("target")).unwrap();
2451 fs.write_file(&root.join("target/debug.o"), b"elf").unwrap();
2452
2453 tmp
2454 }
2455
2456 #[test]
2457 fn test_walk_files_std_basic() {
2458 let tmp = make_walk_tree();
2459 let fs = StdFileSystem;
2460 let cancel = std::sync::atomic::AtomicBool::new(false);
2461 let mut found: Vec<String> = Vec::new();
2462
2463 fs.walk_files(
2464 tmp.path(),
2465 &["node_modules", "target"],
2466 &cancel,
2467 &mut |_path, rel| {
2468 found.push(rel.to_string());
2469 true
2470 },
2471 )
2472 .unwrap();
2473
2474 found.sort();
2475 assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt", "sub/deep/d.txt"]);
2476 }
2477
2478 #[test]
2479 fn test_walk_files_std_skips_hidden() {
2480 let tmp = make_walk_tree();
2481 let fs = StdFileSystem;
2482 let cancel = std::sync::atomic::AtomicBool::new(false);
2483 let mut found: Vec<String> = Vec::new();
2484
2485 fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2486 found.push(rel.to_string());
2487 true
2488 })
2489 .unwrap();
2490
2491 assert!(!found.iter().any(|f| f.contains(".hidden")));
2494 assert!(found.iter().any(|f| f.contains("node_modules")));
2495 assert!(found.iter().any(|f| f.contains("target")));
2496 }
2497
2498 #[test]
2499 fn test_walk_files_std_skip_dirs() {
2500 let tmp = make_walk_tree();
2501 let fs = StdFileSystem;
2502 let cancel = std::sync::atomic::AtomicBool::new(false);
2503 let mut found: Vec<String> = Vec::new();
2504
2505 fs.walk_files(
2506 tmp.path(),
2507 &["node_modules", "target", "deep"],
2508 &cancel,
2509 &mut |_path, rel| {
2510 found.push(rel.to_string());
2511 true
2512 },
2513 )
2514 .unwrap();
2515
2516 found.sort();
2517 assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt"]);
2519 }
2520
2521 #[test]
2522 fn test_walk_files_std_cancel() {
2523 let tmp = make_walk_tree();
2524 let fs = StdFileSystem;
2525 let cancel = std::sync::atomic::AtomicBool::new(false);
2526 let mut found: Vec<String> = Vec::new();
2527
2528 fs.walk_files(
2529 tmp.path(),
2530 &["node_modules", "target"],
2531 &cancel,
2532 &mut |_path, rel| {
2533 found.push(rel.to_string());
2534 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
2536 true
2537 },
2538 )
2539 .unwrap();
2540
2541 assert_eq!(found.len(), 1, "Should stop after cancel is set");
2542 }
2543
2544 #[test]
2545 fn test_walk_files_std_on_file_returns_false() {
2546 let tmp = make_walk_tree();
2547 let fs = StdFileSystem;
2548 let cancel = std::sync::atomic::AtomicBool::new(false);
2549 let mut count = 0usize;
2550
2551 fs.walk_files(
2552 tmp.path(),
2553 &["node_modules", "target"],
2554 &cancel,
2555 &mut |_path, _rel| {
2556 count += 1;
2557 count < 2 },
2559 )
2560 .unwrap();
2561
2562 assert_eq!(count, 2, "Should stop when on_file returns false");
2563 }
2564
2565 #[test]
2566 fn test_walk_files_std_empty_dir() {
2567 let tmp = tempfile::tempdir().unwrap();
2568 let fs = StdFileSystem;
2569 let cancel = std::sync::atomic::AtomicBool::new(false);
2570 let mut found: Vec<String> = Vec::new();
2571
2572 fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2573 found.push(rel.to_string());
2574 true
2575 })
2576 .unwrap();
2577
2578 assert!(found.is_empty());
2579 }
2580
2581 #[test]
2582 fn test_walk_files_std_nonexistent_root() {
2583 let fs = StdFileSystem;
2584 let cancel = std::sync::atomic::AtomicBool::new(false);
2585 let mut found: Vec<String> = Vec::new();
2586
2587 let result = fs.walk_files(
2589 Path::new("/nonexistent/path/that/does/not/exist"),
2590 &[],
2591 &cancel,
2592 &mut |_path, rel| {
2593 found.push(rel.to_string());
2594 true
2595 },
2596 );
2597
2598 assert!(result.is_ok());
2599 assert!(found.is_empty());
2600 }
2601
2602 #[test]
2603 fn test_walk_files_std_relative_paths_use_forward_slashes() {
2604 let tmp = make_walk_tree();
2605 let fs = StdFileSystem;
2606 let cancel = std::sync::atomic::AtomicBool::new(false);
2607 let mut found: Vec<String> = Vec::new();
2608
2609 fs.walk_files(
2610 tmp.path(),
2611 &["node_modules", "target"],
2612 &cancel,
2613 &mut |_path, rel| {
2614 found.push(rel.to_string());
2615 true
2616 },
2617 )
2618 .unwrap();
2619
2620 for path in &found {
2622 assert!(!path.contains('\\'), "Path should use / not \\: {}", path);
2623 }
2624 }
2625
2626 #[test]
2627 fn test_walk_files_noop_returns_error() {
2628 let fs = NoopFileSystem;
2629 let cancel = std::sync::atomic::AtomicBool::new(false);
2630
2631 let result = fs.walk_files(Path::new("/noop/path"), &[], &cancel, &mut |_path, _rel| {
2632 true
2633 });
2634
2635 assert!(result.is_err());
2636 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
2637 }
2638
2639 #[test]
2645 #[cfg(unix)]
2646 fn test_is_writable_matches_kernel_for_owner_writable() {
2647 use std::os::unix::ffi::OsStrExt;
2648 let fs = StdFileSystem;
2649 let temp_dir = tempfile::tempdir().unwrap();
2650 let path = temp_dir.path().join("writable.txt");
2651 fs.write_file(&path, b"x").unwrap();
2652 fs.set_permissions(&path, &FilePermissions::from_mode(0o600))
2653 .unwrap();
2654
2655 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
2656 let kernel_writable = unsafe {
2657 libc::faccessat(
2658 libc::AT_FDCWD,
2659 c_path.as_ptr(),
2660 libc::W_OK,
2661 libc::AT_EACCESS,
2662 )
2663 } == 0;
2664 assert!(
2665 kernel_writable,
2666 "owner-writable file must be writable per kernel"
2667 );
2668 assert_eq!(fs.is_writable(&path), kernel_writable);
2669 }
2670
2671 #[test]
2689 #[ignore = "requires root + setfacl; see test docstring"]
2690 #[cfg(target_os = "linux")]
2691 fn test_is_writable_respects_posix_acl() {
2692 use std::os::unix::ffi::OsStrExt;
2693 use std::process::Command;
2694
2695 if unsafe { libc::geteuid() } != 0 {
2697 panic!("test must be run as root (need to chown to a foreign uid)");
2698 }
2699 let setfacl_ok = Command::new("setfacl").arg("--version").output().is_ok();
2700 assert!(setfacl_ok, "setfacl must be installed");
2701
2702 let test_uid: libc::uid_t = 65534;
2705 let test_gid: libc::gid_t = 65534;
2706 let foreign_uid: libc::uid_t = 9999;
2710 let foreign_gid: libc::gid_t = 9999;
2711
2712 let temp_dir = tempfile::tempdir().unwrap();
2713 std::fs::set_permissions(
2715 temp_dir.path(),
2716 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o755),
2717 )
2718 .unwrap();
2719
2720 let file = temp_dir.path().join("acl_test.txt");
2721 std::fs::write(&file, b"hi").unwrap();
2722
2723 let c_file = std::ffi::CString::new(file.as_os_str().as_bytes()).unwrap();
2724 let r = unsafe { libc::chown(c_file.as_ptr(), foreign_uid, foreign_gid) };
2726 assert_eq!(r, 0, "chown failed: {}", io::Error::last_os_error());
2727 std::fs::set_permissions(
2728 &file,
2729 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
2730 )
2731 .unwrap();
2732
2733 let acl_status = Command::new("setfacl")
2734 .args(["-m", &format!("u:{test_uid}:rw")])
2735 .arg(&file)
2736 .status()
2737 .unwrap();
2738 assert!(
2739 acl_status.success(),
2740 "setfacl failed (does the filesystem support ACLs?)",
2741 );
2742
2743 let pid = unsafe { libc::fork() };
2750 if pid < 0 {
2751 panic!("fork failed: {}", io::Error::last_os_error());
2752 }
2753 if pid == 0 {
2754 if unsafe { libc::setgid(test_gid) } != 0 {
2757 unsafe { libc::_exit(2) };
2758 }
2759 if unsafe { libc::setuid(test_uid) } != 0 {
2760 unsafe { libc::_exit(3) };
2761 }
2762 let writable = StdFileSystem.is_writable(&file);
2763 unsafe { libc::_exit(if writable { 0 } else { 1 }) };
2764 }
2765
2766 let mut status: libc::c_int = 0;
2768 let r = unsafe { libc::waitpid(pid, &mut status, 0) };
2770 assert!(r > 0, "waitpid failed: {}", io::Error::last_os_error());
2771 let exited_normally = (status & 0x7f) == 0;
2772 let exit_code = (status >> 8) & 0xff;
2773 assert!(
2774 exited_normally,
2775 "child terminated abnormally; status={status}"
2776 );
2777 assert_eq!(
2778 exit_code, 0,
2779 "child reported file NOT writable (exit_code={exit_code}); ACL was ignored",
2780 );
2781 }
2782}