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 let re_pattern = if !opts.fixed_string && pattern.contains('\n') {
885 format!("(?s){}", re_pattern)
886 } else {
887 re_pattern
888 };
889 regex::bytes::Regex::new(&re_pattern)
890 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
891}
892
893pub const MAX_PROJECT_SEARCH_FILE_SIZE: u64 = 10 * 1024 * 1024;
900
901pub const BINARY_FILE_EXTENSIONS: &[&str] = &[
910 "a",
912 "class",
913 "dll",
914 "dylib",
915 "exe",
916 "jar",
917 "lib",
918 "o",
919 "obj",
920 "pdb",
921 "pyc",
922 "pyo",
923 "so",
924 "wasm",
925 "war",
926 "7z",
928 "br",
929 "bz2",
930 "gz",
931 "lz",
932 "lz4",
933 "lzma",
934 "rar",
935 "tar",
936 "tbz",
937 "tbz2",
938 "tgz",
939 "txz",
940 "xz",
941 "z",
942 "zip",
943 "zst",
944 "apk",
946 "deb",
947 "dmg",
948 "img",
949 "ipa",
950 "iso",
951 "msi",
952 "rpm",
953 "vhd",
954 "vmdk",
955 "bmp",
957 "gif",
958 "heic",
959 "heif",
960 "ico",
961 "jp2",
962 "jpe",
963 "jpeg",
964 "jpg",
965 "png",
966 "psd",
967 "raw",
968 "tif",
969 "tiff",
970 "webp",
971 "aac",
973 "aif",
974 "aiff",
975 "avi",
976 "flac",
977 "flv",
978 "m4a",
979 "m4v",
980 "mid",
981 "midi",
982 "mka",
983 "mkv",
984 "mov",
985 "mp3",
986 "mp4",
987 "mpeg",
988 "mpg",
989 "ogg",
990 "opus",
991 "wav",
992 "webm",
993 "wma",
994 "wmv",
995 "doc",
997 "docx",
998 "odp",
999 "ods",
1000 "odt",
1001 "pdf",
1002 "ppt",
1003 "pptx",
1004 "rtf",
1005 "xls",
1006 "xlsx",
1007 "db",
1009 "mdb",
1010 "sqlite",
1011 "sqlite3",
1012 "eot",
1014 "otf",
1015 "ttc",
1016 "ttf",
1017 "woff",
1018 "woff2",
1019 "ckpt",
1021 "h5",
1022 "hdf5",
1023 "msgpack",
1024 "npy",
1025 "npz",
1026 "onnx",
1027 "pb",
1028 "pickle",
1029 "pkl",
1030 "pt",
1031 "pth",
1032 "safetensors",
1033 "tflite",
1034 "bin",
1036 "dat",
1037 "swf",
1038];
1039
1040fn has_binary_extension(path: &Path) -> bool {
1042 let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
1043 return false;
1044 };
1045 BINARY_FILE_EXTENSIONS
1046 .iter()
1047 .any(|candidate| candidate.eq_ignore_ascii_case(ext))
1048}
1049
1050fn is_byte_searchable_encoding(enc: crate::model::encoding::Encoding) -> bool {
1058 use crate::model::encoding::Encoding;
1059 !matches!(enc, Encoding::Utf16Le | Encoding::Utf16Be)
1060}
1061
1062pub fn default_search_file(
1066 fs: &dyn FileSystem,
1067 path: &Path,
1068 pattern: &str,
1069 opts: &FileSearchOptions,
1070 cursor: &mut FileSearchCursor,
1071) -> io::Result<Vec<SearchMatch>> {
1072 if cursor.done {
1073 return Ok(vec![]);
1074 }
1075
1076 const CHUNK_SIZE: usize = 1_048_576; let overlap = pattern.len().max(256);
1078
1079 if cursor.offset == 0 && cursor.end_offset.is_none() {
1084 if has_binary_extension(path) {
1087 cursor.done = true;
1088 return Ok(vec![]);
1089 }
1090 }
1091
1092 let meta = fs.metadata(path)?;
1093 let file_size = meta.size;
1094 let file_len = file_size as usize;
1095 let effective_end = cursor.end_offset.unwrap_or(file_len).min(file_len);
1096
1097 if cursor.offset == 0 && cursor.end_offset.is_none() {
1098 if file_size == 0 {
1099 cursor.done = true;
1100 return Ok(vec![]);
1101 }
1102 if file_size > MAX_PROJECT_SEARCH_FILE_SIZE {
1106 cursor.done = true;
1107 return Ok(vec![]);
1108 }
1109 let header_len = file_len.min(8192);
1116 let header = fs.read_range(path, 0, header_len)?;
1117 let truncated = header_len < file_len;
1118 let (encoding, is_binary) =
1119 crate::model::encoding::detect_encoding_or_binary(&header, truncated);
1120 if is_binary || !is_byte_searchable_encoding(encoding) {
1121 cursor.done = true;
1122 return Ok(vec![]);
1123 }
1124 }
1125
1126 if cursor.offset >= effective_end {
1127 cursor.done = true;
1128 return Ok(vec![]);
1129 }
1130
1131 let regex = build_search_regex(pattern, opts)?;
1132
1133 let read_start = cursor.offset.saturating_sub(overlap);
1135 let read_end = (read_start + CHUNK_SIZE).min(effective_end);
1136 let chunk = fs.read_range(path, read_start as u64, read_end - read_start)?;
1137
1138 let overlap_len = cursor.offset - read_start;
1139
1140 if cursor.end_offset.is_none() && chunk[overlap_len..].contains(&0) {
1151 cursor.done = true;
1152 return Ok(vec![]);
1153 }
1154
1155 let newlines_in_overlap = chunk[..overlap_len].iter().filter(|&&b| b == b'\n').count();
1157 let mut line_at = cursor.running_line.saturating_sub(newlines_in_overlap);
1158 let mut counted_to = 0usize;
1159 let mut matches = Vec::new();
1160
1161 for m in regex.find_iter(&chunk) {
1162 if overlap_len > 0 && m.end() <= overlap_len {
1164 continue;
1165 }
1166 if matches.len() >= opts.max_matches {
1167 break;
1168 }
1169
1170 line_at += chunk[counted_to..m.start()]
1172 .iter()
1173 .filter(|&&b| b == b'\n')
1174 .count();
1175 counted_to = m.start();
1176
1177 let line_start = chunk[..m.start()]
1179 .iter()
1180 .rposition(|&b| b == b'\n')
1181 .map(|p| p + 1)
1182 .unwrap_or(0);
1183 let line_end = chunk[m.start()..]
1184 .iter()
1185 .position(|&b| b == b'\n')
1186 .map(|p| m.start() + p)
1187 .unwrap_or(chunk.len());
1188
1189 let column = m.start() - line_start + 1;
1190 let context = String::from_utf8_lossy(&chunk[line_start..line_end]).into_owned();
1191
1192 matches.push(SearchMatch {
1193 byte_offset: read_start + m.start(),
1194 length: m.end() - m.start(),
1195 line: line_at,
1196 column,
1197 context,
1198 });
1199 }
1200
1201 let new_data = &chunk[overlap_len..];
1203 cursor.running_line += new_data.iter().filter(|&&b| b == b'\n').count();
1204 cursor.offset = read_end;
1205 if read_end >= effective_end {
1206 cursor.done = true;
1207 }
1208
1209 Ok(matches)
1210}
1211
1212#[derive(Debug, Clone, Copy, Default)]
1220pub struct StdFileSystem;
1221
1222impl StdFileSystem {
1223 fn is_hidden(path: &Path) -> bool {
1225 path.file_name()
1226 .and_then(|n| n.to_str())
1227 .is_some_and(|n| n.starts_with('.'))
1228 }
1229
1230 #[cfg(unix)]
1232 pub fn current_user_groups() -> (u32, Vec<u32>) {
1233 let euid = unsafe { libc::geteuid() };
1235 let egid = unsafe { libc::getegid() };
1236 let mut groups = vec![egid];
1237
1238 let ngroups = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
1240 if ngroups > 0 {
1241 let mut sup_groups = vec![0 as libc::gid_t; ngroups as usize];
1242 let n = unsafe { libc::getgroups(ngroups, sup_groups.as_mut_ptr()) };
1243 if n > 0 {
1244 sup_groups.truncate(n as usize);
1245 for g in sup_groups {
1246 if g != egid {
1247 groups.push(g);
1248 }
1249 }
1250 }
1251 }
1252
1253 (euid, groups)
1254 }
1255
1256 #[cfg(unix)]
1263 fn kernel_writable(path: &Path) -> Option<bool> {
1264 use std::os::unix::ffi::OsStrExt;
1265 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1266 let rc = unsafe {
1269 libc::faccessat(
1270 libc::AT_FDCWD,
1271 c_path.as_ptr(),
1272 libc::W_OK,
1273 libc::AT_EACCESS,
1274 )
1275 };
1276 Some(rc == 0)
1277 }
1278
1279 fn build_metadata(path: &Path, meta: &std::fs::Metadata) -> FileMetadata {
1281 #[cfg(unix)]
1282 {
1283 use std::os::unix::fs::MetadataExt;
1284 let file_uid = meta.uid();
1285 let file_gid = meta.gid();
1286 let permissions = FilePermissions::from_std(meta.permissions());
1287 let is_readonly = match Self::kernel_writable(path) {
1291 Some(writable) => !writable,
1292 None => {
1293 let (euid, user_groups) = Self::current_user_groups();
1294 permissions.is_readonly_for_user(euid, file_uid, file_gid, &user_groups)
1295 }
1296 };
1297 FileMetadata {
1298 size: meta.len(),
1299 modified: meta.modified().ok(),
1300 permissions: Some(permissions),
1301 is_hidden: Self::is_hidden(path),
1302 is_readonly,
1303 uid: Some(file_uid),
1304 gid: Some(file_gid),
1305 }
1306 }
1307 #[cfg(not(unix))]
1308 {
1309 FileMetadata {
1310 size: meta.len(),
1311 modified: meta.modified().ok(),
1312 permissions: Some(FilePermissions::from_std(meta.permissions())),
1313 is_hidden: Self::is_hidden(path),
1314 is_readonly: meta.permissions().readonly(),
1315 }
1316 }
1317 }
1318}
1319
1320impl FileSystem for StdFileSystem {
1321 fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
1323 let data = std::fs::read(path)?;
1324 crate::services::counters::global().inc_disk_bytes_read(data.len() as u64);
1325 Ok(data)
1326 }
1327
1328 fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
1329 let mut file = std::fs::File::open(path)?;
1330 file.seek(io::SeekFrom::Start(offset))?;
1331 let mut buffer = vec![0u8; len];
1332 file.read_exact(&mut buffer)?;
1333 crate::services::counters::global().inc_disk_bytes_read(len as u64);
1334 Ok(buffer)
1335 }
1336
1337 fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()> {
1338 let original_metadata = self.metadata_if_exists(path);
1339 let temp_path = self.temp_path_for(path);
1340 {
1341 let mut file = self.create_file(&temp_path)?;
1342 file.write_all(data)?;
1343 file.sync_all()?;
1344 }
1345 if let Some(ref meta) = original_metadata {
1346 if let Some(ref perms) = meta.permissions {
1347 #[allow(clippy::let_underscore_must_use)]
1349 let _ = self.set_permissions(&temp_path, perms);
1350 }
1351 }
1352 self.rename(&temp_path, path)?;
1353 Ok(())
1354 }
1355
1356 fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1357 let file = std::fs::File::create(path)?;
1358 Ok(Box::new(StdFileWriter(file)))
1359 }
1360
1361 fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>> {
1362 let file = std::fs::File::open(path)?;
1363 Ok(Box::new(StdFileReader(file)))
1364 }
1365
1366 fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1367 let file = std::fs::OpenOptions::new()
1368 .write(true)
1369 .truncate(true)
1370 .open(path)?;
1371 Ok(Box::new(StdFileWriter(file)))
1372 }
1373
1374 fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1375 let file = std::fs::OpenOptions::new()
1376 .create(true)
1377 .append(true)
1378 .open(path)?;
1379 Ok(Box::new(StdFileWriter(file)))
1380 }
1381
1382 fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()> {
1383 let file = std::fs::OpenOptions::new().write(true).open(path)?;
1384 file.set_len(len)
1385 }
1386
1387 fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
1389 std::fs::rename(from, to)
1390 }
1391
1392 fn copy(&self, from: &Path, to: &Path) -> io::Result<u64> {
1393 std::fs::copy(from, to)
1394 }
1395
1396 fn remove_file(&self, path: &Path) -> io::Result<()> {
1397 std::fs::remove_file(path)
1398 }
1399
1400 fn remove_dir(&self, path: &Path) -> io::Result<()> {
1401 std::fs::remove_dir(path)
1402 }
1403
1404 fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1406 let meta = std::fs::metadata(path)?;
1407 Ok(Self::build_metadata(path, &meta))
1408 }
1409
1410 fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1411 let meta = std::fs::symlink_metadata(path)?;
1412 Ok(Self::build_metadata(path, &meta))
1413 }
1414
1415 fn is_dir(&self, path: &Path) -> io::Result<bool> {
1416 Ok(std::fs::metadata(path)?.is_dir())
1417 }
1418
1419 fn is_file(&self, path: &Path) -> io::Result<bool> {
1420 Ok(std::fs::metadata(path)?.is_file())
1421 }
1422
1423 fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()> {
1424 std::fs::set_permissions(path, permissions.to_std())
1425 }
1426
1427 fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
1429 let mut entries = Vec::new();
1430 for entry in std::fs::read_dir(path)? {
1431 let entry = entry?;
1432 let path = entry.path();
1433 let name = entry.file_name().to_string_lossy().into_owned();
1434 let file_type = entry.file_type()?;
1435
1436 let entry_type = if file_type.is_dir() {
1437 EntryType::Directory
1438 } else if file_type.is_symlink() {
1439 EntryType::Symlink
1440 } else {
1441 EntryType::File
1442 };
1443
1444 let mut dir_entry = DirEntry::new(path.clone(), name, entry_type);
1445
1446 if file_type.is_symlink() {
1448 dir_entry.symlink_target_is_dir = std::fs::metadata(&path)
1449 .map(|m| m.is_dir())
1450 .unwrap_or(false);
1451 }
1452
1453 entries.push(dir_entry);
1454 }
1455 Ok(entries)
1456 }
1457
1458 fn create_dir(&self, path: &Path) -> io::Result<()> {
1459 std::fs::create_dir(path)
1460 }
1461
1462 fn create_dir_all(&self, path: &Path) -> io::Result<()> {
1463 std::fs::create_dir_all(path)
1464 }
1465
1466 fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
1468 std::fs::canonicalize(path)
1469 }
1470
1471 fn current_uid(&self) -> u32 {
1473 #[cfg(all(unix, feature = "runtime"))]
1474 {
1475 unsafe { libc::getuid() }
1477 }
1478 #[cfg(not(all(unix, feature = "runtime")))]
1479 {
1480 0
1481 }
1482 }
1483
1484 fn sudo_write(
1485 &self,
1486 path: &Path,
1487 data: &[u8],
1488 mode: u32,
1489 uid: u32,
1490 gid: u32,
1491 ) -> io::Result<()> {
1492 use crate::services::process_hidden::HideWindow;
1493 use std::process::{Command, Stdio};
1494
1495 let mut child = Command::new("sudo")
1497 .args(["tee", &path.to_string_lossy()])
1498 .stdin(Stdio::piped())
1499 .stdout(Stdio::null())
1500 .stderr(Stdio::piped())
1501 .hide_window()
1502 .spawn()
1503 .map_err(|e| io::Error::other(format!("failed to spawn sudo: {}", e)))?;
1504
1505 if let Some(mut stdin) = child.stdin.take() {
1506 use std::io::Write;
1507 stdin.write_all(data)?;
1508 }
1509
1510 let output = child.wait_with_output()?;
1511 if !output.status.success() {
1512 let stderr = String::from_utf8_lossy(&output.stderr);
1513 return Err(io::Error::new(
1514 io::ErrorKind::PermissionDenied,
1515 format!("sudo tee failed: {}", stderr.trim()),
1516 ));
1517 }
1518
1519 let status = Command::new("sudo")
1521 .args(["chmod", &format!("{:o}", mode), &path.to_string_lossy()])
1522 .hide_window()
1523 .status()?;
1524 if !status.success() {
1525 return Err(io::Error::other("sudo chmod failed"));
1526 }
1527
1528 let status = Command::new("sudo")
1530 .args([
1531 "chown",
1532 &format!("{}:{}", uid, gid),
1533 &path.to_string_lossy(),
1534 ])
1535 .hide_window()
1536 .status()?;
1537 if !status.success() {
1538 return Err(io::Error::other("sudo chown failed"));
1539 }
1540
1541 Ok(())
1542 }
1543
1544 fn search_file(
1545 &self,
1546 path: &Path,
1547 pattern: &str,
1548 opts: &FileSearchOptions,
1549 cursor: &mut FileSearchCursor,
1550 ) -> io::Result<Vec<SearchMatch>> {
1551 default_search_file(self, path, pattern, opts, cursor)
1552 }
1553
1554 fn walk_files(
1555 &self,
1556 root: &Path,
1557 skip_dirs: &[&str],
1558 cancel: &std::sync::atomic::AtomicBool,
1559 on_file: &mut dyn FnMut(&Path, &str) -> bool,
1560 ) -> io::Result<()> {
1561 let mut stack = vec![root.to_path_buf()];
1562 while let Some(dir) = stack.pop() {
1563 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1564 return Ok(());
1565 }
1566
1567 let iter = match std::fs::read_dir(&dir) {
1571 Ok(it) => it,
1572 Err(_) => continue,
1573 };
1574
1575 for entry in iter {
1576 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1577 return Ok(());
1578 }
1579 let entry = match entry {
1580 Ok(e) => e,
1581 Err(_) => continue,
1582 };
1583 let name = entry.file_name();
1584 let name_str = name.to_string_lossy();
1585
1586 if name_str.starts_with('.') {
1588 continue;
1589 }
1590
1591 let ft = match entry.file_type() {
1592 Ok(ft) => ft,
1593 Err(_) => continue,
1594 };
1595 let path = entry.path();
1596
1597 if ft.is_file() {
1598 if let Ok(rel) = path.strip_prefix(root) {
1599 let rel_str = rel.to_string_lossy().replace('\\', "/");
1600 if !on_file(&path, &rel_str) {
1601 return Ok(());
1602 }
1603 }
1604 } else if ft.is_dir() && !skip_dirs.contains(&name_str.as_ref()) {
1605 stack.push(path);
1606 }
1607 }
1608 }
1609 Ok(())
1610 }
1611}
1612
1613#[derive(Debug, Clone, Copy, Default)]
1622pub struct NoopFileSystem;
1623
1624impl NoopFileSystem {
1625 fn unsupported<T>() -> io::Result<T> {
1626 Err(io::Error::new(
1627 io::ErrorKind::Unsupported,
1628 "Filesystem not available",
1629 ))
1630 }
1631}
1632
1633impl FileSystem for NoopFileSystem {
1634 fn read_file(&self, _path: &Path) -> io::Result<Vec<u8>> {
1635 Self::unsupported()
1636 }
1637
1638 fn read_range(&self, _path: &Path, _offset: u64, _len: usize) -> io::Result<Vec<u8>> {
1639 Self::unsupported()
1640 }
1641
1642 fn write_file(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
1643 Self::unsupported()
1644 }
1645
1646 fn create_file(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1647 Self::unsupported()
1648 }
1649
1650 fn open_file(&self, _path: &Path) -> io::Result<Box<dyn FileReader>> {
1651 Self::unsupported()
1652 }
1653
1654 fn open_file_for_write(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1655 Self::unsupported()
1656 }
1657
1658 fn open_file_for_append(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1659 Self::unsupported()
1660 }
1661
1662 fn set_file_length(&self, _path: &Path, _len: u64) -> io::Result<()> {
1663 Self::unsupported()
1664 }
1665
1666 fn rename(&self, _from: &Path, _to: &Path) -> io::Result<()> {
1667 Self::unsupported()
1668 }
1669
1670 fn copy(&self, _from: &Path, _to: &Path) -> io::Result<u64> {
1671 Self::unsupported()
1672 }
1673
1674 fn remove_file(&self, _path: &Path) -> io::Result<()> {
1675 Self::unsupported()
1676 }
1677
1678 fn remove_dir(&self, _path: &Path) -> io::Result<()> {
1679 Self::unsupported()
1680 }
1681
1682 fn metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1683 Self::unsupported()
1684 }
1685
1686 fn symlink_metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1687 Self::unsupported()
1688 }
1689
1690 fn is_dir(&self, _path: &Path) -> io::Result<bool> {
1691 Self::unsupported()
1692 }
1693
1694 fn is_file(&self, _path: &Path) -> io::Result<bool> {
1695 Self::unsupported()
1696 }
1697
1698 fn set_permissions(&self, _path: &Path, _permissions: &FilePermissions) -> io::Result<()> {
1699 Self::unsupported()
1700 }
1701
1702 fn read_dir(&self, _path: &Path) -> io::Result<Vec<DirEntry>> {
1703 Self::unsupported()
1704 }
1705
1706 fn create_dir(&self, _path: &Path) -> io::Result<()> {
1707 Self::unsupported()
1708 }
1709
1710 fn create_dir_all(&self, _path: &Path) -> io::Result<()> {
1711 Self::unsupported()
1712 }
1713
1714 fn canonicalize(&self, _path: &Path) -> io::Result<PathBuf> {
1715 Self::unsupported()
1716 }
1717
1718 fn current_uid(&self) -> u32 {
1719 0
1720 }
1721
1722 fn search_file(
1723 &self,
1724 _path: &Path,
1725 _pattern: &str,
1726 _opts: &FileSearchOptions,
1727 _cursor: &mut FileSearchCursor,
1728 ) -> io::Result<Vec<SearchMatch>> {
1729 Self::unsupported()
1730 }
1731
1732 fn sudo_write(
1733 &self,
1734 _path: &Path,
1735 _data: &[u8],
1736 _mode: u32,
1737 _uid: u32,
1738 _gid: u32,
1739 ) -> io::Result<()> {
1740 Self::unsupported()
1741 }
1742
1743 fn walk_files(
1744 &self,
1745 _root: &Path,
1746 _skip_dirs: &[&str],
1747 _cancel: &std::sync::atomic::AtomicBool,
1748 _on_file: &mut dyn FnMut(&Path, &str) -> bool,
1749 ) -> io::Result<()> {
1750 Self::unsupported()
1751 }
1752}
1753
1754#[cfg(test)]
1759mod tests {
1760 use super::*;
1761 use tempfile::NamedTempFile;
1762
1763 #[test]
1764 fn test_std_filesystem_read_write() {
1765 let fs = StdFileSystem;
1766 let mut temp = NamedTempFile::new().unwrap();
1767 let path = temp.path().to_path_buf();
1768
1769 std::io::Write::write_all(&mut temp, b"Hello, World!").unwrap();
1770 std::io::Write::flush(&mut temp).unwrap();
1771
1772 let content = fs.read_file(&path).unwrap();
1773 assert_eq!(content, b"Hello, World!");
1774
1775 let range = fs.read_range(&path, 7, 5).unwrap();
1776 assert_eq!(range, b"World");
1777
1778 let meta = fs.metadata(&path).unwrap();
1779 assert_eq!(meta.size, 13);
1780 }
1781
1782 #[test]
1783 fn test_noop_filesystem() {
1784 let fs = NoopFileSystem;
1785 let path = Path::new("/some/path");
1786
1787 assert!(fs.read_file(path).is_err());
1788 assert!(fs.read_range(path, 0, 10).is_err());
1789 assert!(fs.write_file(path, b"data").is_err());
1790 assert!(fs.metadata(path).is_err());
1791 assert!(fs.read_dir(path).is_err());
1792 }
1793
1794 #[test]
1795 fn test_create_and_write_file() {
1796 let fs = StdFileSystem;
1797 let temp_dir = tempfile::tempdir().unwrap();
1798 let path = temp_dir.path().join("test.txt");
1799
1800 {
1801 let mut writer = fs.create_file(&path).unwrap();
1802 writer.write_all(b"test content").unwrap();
1803 writer.sync_all().unwrap();
1804 }
1805
1806 let content = fs.read_file(&path).unwrap();
1807 assert_eq!(content, b"test content");
1808 }
1809
1810 #[test]
1811 fn test_read_dir() {
1812 let fs = StdFileSystem;
1813 let temp_dir = tempfile::tempdir().unwrap();
1814
1815 fs.create_dir(&temp_dir.path().join("subdir")).unwrap();
1817 fs.write_file(&temp_dir.path().join("file1.txt"), b"content1")
1818 .unwrap();
1819 fs.write_file(&temp_dir.path().join("file2.txt"), b"content2")
1820 .unwrap();
1821
1822 let entries = fs.read_dir(temp_dir.path()).unwrap();
1823 assert_eq!(entries.len(), 3);
1824
1825 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
1826 assert!(names.contains(&"subdir"));
1827 assert!(names.contains(&"file1.txt"));
1828 assert!(names.contains(&"file2.txt"));
1829 }
1830
1831 #[test]
1832 fn test_dir_entry_types() {
1833 let file = DirEntry::new(PathBuf::from("/file"), "file".to_string(), EntryType::File);
1834 assert!(file.is_file());
1835 assert!(!file.is_dir());
1836
1837 let dir = DirEntry::new(
1838 PathBuf::from("/dir"),
1839 "dir".to_string(),
1840 EntryType::Directory,
1841 );
1842 assert!(dir.is_dir());
1843 assert!(!dir.is_file());
1844
1845 let link_to_dir = DirEntry::new_symlink(PathBuf::from("/link"), "link".to_string(), true);
1846 assert!(link_to_dir.is_symlink());
1847 assert!(link_to_dir.is_dir());
1848 }
1849
1850 #[test]
1851 fn test_metadata_builder() {
1852 let meta = FileMetadata::default()
1853 .with_hidden(true)
1854 .with_readonly(true);
1855 assert!(meta.is_hidden);
1856 assert!(meta.is_readonly);
1857 }
1858
1859 #[test]
1860 fn test_atomic_write() {
1861 let fs = StdFileSystem;
1862 let temp_dir = tempfile::tempdir().unwrap();
1863 let path = temp_dir.path().join("atomic_test.txt");
1864
1865 fs.write_file(&path, b"initial").unwrap();
1866 assert_eq!(fs.read_file(&path).unwrap(), b"initial");
1867
1868 fs.write_file(&path, b"updated").unwrap();
1869 assert_eq!(fs.read_file(&path).unwrap(), b"updated");
1870 }
1871
1872 #[test]
1873 fn test_write_patched_default_impl() {
1874 let fs = StdFileSystem;
1876 let temp_dir = tempfile::tempdir().unwrap();
1877 let src_path = temp_dir.path().join("source.txt");
1878 let dst_path = temp_dir.path().join("dest.txt");
1879
1880 fs.write_file(&src_path, b"AAABBBCCC").unwrap();
1882
1883 let ops = vec![
1885 WriteOp::Copy { offset: 0, len: 3 }, WriteOp::Insert { data: b"XXX" }, WriteOp::Copy { offset: 6, len: 3 }, ];
1889
1890 fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1891
1892 let result = fs.read_file(&dst_path).unwrap();
1893 assert_eq!(result, b"AAAXXXCCC");
1894 }
1895
1896 #[test]
1897 fn test_write_patched_same_file() {
1898 let fs = StdFileSystem;
1900 let temp_dir = tempfile::tempdir().unwrap();
1901 let path = temp_dir.path().join("file.txt");
1902
1903 fs.write_file(&path, b"Hello World").unwrap();
1905
1906 let ops = vec![
1908 WriteOp::Copy { offset: 0, len: 6 }, WriteOp::Insert { data: b"Rust" }, ];
1911
1912 fs.write_patched(&path, &path, &ops).unwrap();
1913
1914 let result = fs.read_file(&path).unwrap();
1915 assert_eq!(result, b"Hello Rust");
1916 }
1917
1918 #[test]
1919 fn test_write_patched_insert_only() {
1920 let fs = StdFileSystem;
1922 let temp_dir = tempfile::tempdir().unwrap();
1923 let src_path = temp_dir.path().join("empty.txt");
1924 let dst_path = temp_dir.path().join("new.txt");
1925
1926 fs.write_file(&src_path, b"").unwrap();
1928
1929 let ops = vec![WriteOp::Insert {
1930 data: b"All new content",
1931 }];
1932
1933 fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1934
1935 let result = fs.read_file(&dst_path).unwrap();
1936 assert_eq!(result, b"All new content");
1937 }
1938
1939 fn make_search_opts(pattern_is_fixed: bool) -> FileSearchOptions {
1944 FileSearchOptions {
1945 fixed_string: pattern_is_fixed,
1946 case_sensitive: true,
1947 whole_word: false,
1948 max_matches: 100,
1949 }
1950 }
1951
1952 #[test]
1953 fn test_search_file_basic() {
1954 let fs = StdFileSystem;
1955 let temp_dir = tempfile::tempdir().unwrap();
1956 let path = temp_dir.path().join("test.txt");
1957 fs.write_file(&path, b"hello world\nfoo bar\nhello again\n")
1958 .unwrap();
1959
1960 let opts = make_search_opts(true);
1961 let mut cursor = FileSearchCursor::new();
1962 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1963
1964 assert!(cursor.done);
1965 assert_eq!(matches.len(), 2);
1966
1967 assert_eq!(matches[0].line, 1);
1968 assert_eq!(matches[0].column, 1);
1969 assert_eq!(matches[0].context, "hello world");
1970
1971 assert_eq!(matches[1].line, 3);
1972 assert_eq!(matches[1].column, 1);
1973 assert_eq!(matches[1].context, "hello again");
1974 }
1975
1976 #[test]
1977 fn test_search_file_no_matches() {
1978 let fs = StdFileSystem;
1979 let temp_dir = tempfile::tempdir().unwrap();
1980 let path = temp_dir.path().join("test.txt");
1981 fs.write_file(&path, b"hello world\n").unwrap();
1982
1983 let opts = make_search_opts(true);
1984 let mut cursor = FileSearchCursor::new();
1985 let matches = fs
1986 .search_file(&path, "NOTFOUND", &opts, &mut cursor)
1987 .unwrap();
1988
1989 assert!(cursor.done);
1990 assert!(matches.is_empty());
1991 }
1992
1993 #[test]
1994 fn test_search_file_case_insensitive() {
1995 let fs = StdFileSystem;
1996 let temp_dir = tempfile::tempdir().unwrap();
1997 let path = temp_dir.path().join("test.txt");
1998 fs.write_file(&path, b"Hello HELLO hello\n").unwrap();
1999
2000 let opts = FileSearchOptions {
2001 fixed_string: true,
2002 case_sensitive: false,
2003 whole_word: false,
2004 max_matches: 100,
2005 };
2006 let mut cursor = FileSearchCursor::new();
2007 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2008
2009 assert_eq!(matches.len(), 3);
2010 }
2011
2012 #[test]
2013 fn test_search_file_whole_word() {
2014 let fs = StdFileSystem;
2015 let temp_dir = tempfile::tempdir().unwrap();
2016 let path = temp_dir.path().join("test.txt");
2017 fs.write_file(&path, b"cat concatenate catalog\n").unwrap();
2018
2019 let opts = FileSearchOptions {
2020 fixed_string: true,
2021 case_sensitive: true,
2022 whole_word: true,
2023 max_matches: 100,
2024 };
2025 let mut cursor = FileSearchCursor::new();
2026 let matches = fs.search_file(&path, "cat", &opts, &mut cursor).unwrap();
2027
2028 assert_eq!(matches.len(), 1);
2029 assert_eq!(matches[0].column, 1);
2030 }
2031
2032 #[test]
2033 fn test_search_file_regex() {
2034 let fs = StdFileSystem;
2035 let temp_dir = tempfile::tempdir().unwrap();
2036 let path = temp_dir.path().join("test.txt");
2037 fs.write_file(&path, b"foo123 bar456 baz\n").unwrap();
2038
2039 let opts = FileSearchOptions {
2040 fixed_string: false,
2041 case_sensitive: true,
2042 whole_word: false,
2043 max_matches: 100,
2044 };
2045 let mut cursor = FileSearchCursor::new();
2046 let matches = fs
2047 .search_file(&path, r"[a-z]+\d+", &opts, &mut cursor)
2048 .unwrap();
2049
2050 assert_eq!(matches.len(), 2);
2051 assert_eq!(matches[0].context, "foo123 bar456 baz");
2052 }
2053
2054 #[test]
2055 fn test_search_file_binary_skipped() {
2056 let fs = StdFileSystem;
2057 let temp_dir = tempfile::tempdir().unwrap();
2058 let path = temp_dir.path().join("binary.dat");
2059 let mut data = b"hello world\n".to_vec();
2060 data.push(0); data.extend_from_slice(b"hello again\n");
2062 fs.write_file(&path, &data).unwrap();
2063
2064 let opts = make_search_opts(true);
2065 let mut cursor = FileSearchCursor::new();
2066 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2067
2068 assert!(cursor.done);
2069 assert!(matches.is_empty());
2070 }
2071
2072 #[test]
2077 fn test_search_file_binary_extension_skipped_despite_text_content() {
2078 let fs = StdFileSystem;
2079 let temp_dir = tempfile::tempdir().unwrap();
2080 let path = temp_dir.path().join("not_actually_binary.png");
2081 fs.write_file(&path, b"hello world\nhello again\n").unwrap();
2082
2083 let opts = make_search_opts(true);
2084 let mut cursor = FileSearchCursor::new();
2085 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2086
2087 assert!(cursor.done);
2088 assert!(
2089 matches.is_empty(),
2090 ".png extension should short-circuit before content scan"
2091 );
2092 }
2093
2094 #[test]
2098 fn test_search_file_binary_extension_case_insensitive() {
2099 let fs = StdFileSystem;
2100 let temp_dir = tempfile::tempdir().unwrap();
2101 for name in ["IMG.JPG", "archive.tar.gz", "weights.SafeTensors"] {
2102 let path = temp_dir.path().join(name);
2103 fs.write_file(&path, b"definitely text content here\n")
2104 .unwrap();
2105
2106 let opts = make_search_opts(true);
2107 let mut cursor = FileSearchCursor::new();
2108 let matches = fs
2109 .search_file(&path, "definitely", &opts, &mut cursor)
2110 .unwrap();
2111
2112 assert!(cursor.done, "{} should be marked done", name);
2113 assert!(
2114 matches.is_empty(),
2115 "{} matched but extension should have skipped it",
2116 name
2117 );
2118 }
2119 }
2120
2121 #[test]
2127 fn test_search_file_utf16_skipped_via_encoding_gate() {
2128 let fs = StdFileSystem;
2129 let temp_dir = tempfile::tempdir().unwrap();
2130 let path = temp_dir.path().join("utf16.txt");
2131 let mut data = vec![0xFF, 0xFE];
2133 for ch in "hello world\nhello again\n".chars() {
2134 let n = ch as u32;
2135 data.push((n & 0xFF) as u8);
2136 data.push(((n >> 8) & 0xFF) as u8);
2137 }
2138 fs.write_file(&path, &data).unwrap();
2139
2140 let opts = make_search_opts(true);
2141 let mut cursor = FileSearchCursor::new();
2142 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2143
2144 assert!(cursor.done);
2145 assert!(
2146 matches.is_empty(),
2147 "UTF-16 file must be skipped: byte-regex can't match UTF-8 patterns in it"
2148 );
2149 }
2150
2151 #[test]
2157 fn test_search_file_midstream_nul_aborts_scan() {
2158 let fs = StdFileSystem;
2159 let temp_dir = tempfile::tempdir().unwrap();
2160 let path = temp_dir.path().join("mid.dat");
2161
2162 let mut data = vec![b'a'; 9000];
2165 data.push(0);
2166 data.extend_from_slice(b"PATTERN_AFTER_NUL\n");
2167 fs.write_file(&path, &data).unwrap();
2168
2169 let opts = make_search_opts(true);
2170 let mut cursor = FileSearchCursor::new();
2171 let matches = fs
2172 .search_file(&path, "PATTERN_AFTER_NUL", &opts, &mut cursor)
2173 .unwrap();
2174
2175 assert!(cursor.done);
2176 assert!(
2177 matches.is_empty(),
2178 "mid-stream NUL should abort scan and discard pseudo-matches"
2179 );
2180 }
2181
2182 #[test]
2183 fn test_multiline_regex_dotall_implicit() {
2184 let opts = FileSearchOptions {
2186 fixed_string: false,
2187 case_sensitive: true,
2188 whole_word: false,
2189 max_matches: 100,
2190 };
2191 let re = build_search_regex("foo\n.+bar", &opts).unwrap();
2192 assert!(re.is_match(b"foo\nXXXXbar"));
2193 let opts_lit = FileSearchOptions {
2195 fixed_string: true,
2196 ..opts
2197 };
2198 let re_lit = build_search_regex("foo\nbar", &opts_lit).unwrap();
2199 assert!(re_lit.is_match(b"foo\nbar"));
2200 }
2201
2202 #[test]
2203 fn test_search_file_empty_file() {
2204 let fs = StdFileSystem;
2205 let temp_dir = tempfile::tempdir().unwrap();
2206 let path = temp_dir.path().join("empty.txt");
2207 fs.write_file(&path, b"").unwrap();
2208
2209 let opts = make_search_opts(true);
2210 let mut cursor = FileSearchCursor::new();
2211 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2212
2213 assert!(cursor.done);
2214 assert!(matches.is_empty());
2215 }
2216
2217 #[test]
2221 fn test_search_file_oversized_skipped() {
2222 let fs = StdFileSystem;
2223 let temp_dir = tempfile::tempdir().unwrap();
2224 let path = temp_dir.path().join("oversized.txt");
2225
2226 let mut data = vec![b'a'; (MAX_PROJECT_SEARCH_FILE_SIZE as usize) + 1024];
2230 data.extend_from_slice(b"\nUNIQUE_TAIL_MARKER\n");
2231 fs.write_file(&path, &data).unwrap();
2232
2233 let opts = make_search_opts(true);
2234 let mut cursor = FileSearchCursor::new();
2235 let matches = fs
2236 .search_file(&path, "UNIQUE_TAIL_MARKER", &opts, &mut cursor)
2237 .unwrap();
2238
2239 assert!(
2240 cursor.done,
2241 "oversized file should be marked done in one call"
2242 );
2243 assert!(matches.is_empty(), "oversized file should yield no matches");
2244 }
2245
2246 #[test]
2251 fn test_search_file_binary_control_char_skipped() {
2252 let fs = StdFileSystem;
2253 let temp_dir = tempfile::tempdir().unwrap();
2254 let path = temp_dir.path().join("ctrl.dat");
2255 let mut data = b"hello world\n".to_vec();
2258 data.push(0x1A);
2259 data.extend_from_slice(b"hello again\n");
2260 fs.write_file(&path, &data).unwrap();
2261
2262 let opts = make_search_opts(true);
2263 let mut cursor = FileSearchCursor::new();
2264 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2265
2266 assert!(cursor.done);
2267 assert!(matches.is_empty());
2268 }
2269
2270 #[test]
2271 fn test_search_file_max_matches() {
2272 let fs = StdFileSystem;
2273 let temp_dir = tempfile::tempdir().unwrap();
2274 let path = temp_dir.path().join("test.txt");
2275 fs.write_file(&path, b"aa bb aa cc aa dd aa\n").unwrap();
2276
2277 let opts = FileSearchOptions {
2278 fixed_string: true,
2279 case_sensitive: true,
2280 whole_word: false,
2281 max_matches: 2,
2282 };
2283 let mut cursor = FileSearchCursor::new();
2284 let matches = fs.search_file(&path, "aa", &opts, &mut cursor).unwrap();
2285
2286 assert_eq!(matches.len(), 2);
2287 }
2288
2289 #[test]
2290 fn test_search_file_cursor_multi_chunk() {
2291 let fs = StdFileSystem;
2292 let temp_dir = tempfile::tempdir().unwrap();
2293 let path = temp_dir.path().join("large.txt");
2294
2295 let mut content = Vec::new();
2297 for i in 0..100_000 {
2298 content.extend_from_slice(format!("line {} content here\n", i).as_bytes());
2299 }
2300 fs.write_file(&path, &content).unwrap();
2301
2302 let opts = FileSearchOptions {
2303 fixed_string: true,
2304 case_sensitive: true,
2305 whole_word: false,
2306 max_matches: 1000,
2307 };
2308 let mut cursor = FileSearchCursor::new();
2309 let mut all_matches = Vec::new();
2310
2311 while !cursor.done {
2312 let batch = fs
2313 .search_file(&path, "line 5000", &opts, &mut cursor)
2314 .unwrap();
2315 all_matches.extend(batch);
2316 }
2317
2318 assert_eq!(all_matches.len(), 11);
2321
2322 let first = &all_matches[0];
2324 assert_eq!(first.line, 5001); assert_eq!(first.column, 1);
2326 assert!(first.context.starts_with("line 5000"));
2327 }
2328
2329 #[test]
2330 fn test_search_file_cursor_no_duplicates() {
2331 let fs = StdFileSystem;
2332 let temp_dir = tempfile::tempdir().unwrap();
2333 let path = temp_dir.path().join("large.txt");
2334
2335 let mut content = Vec::new();
2337 for i in 0..100_000 {
2338 content.extend_from_slice(format!("MARKER_{:06}\n", i).as_bytes());
2339 }
2340 fs.write_file(&path, &content).unwrap();
2341
2342 let opts = FileSearchOptions {
2343 fixed_string: true,
2344 case_sensitive: true,
2345 whole_word: false,
2346 max_matches: 200_000,
2347 };
2348 let mut cursor = FileSearchCursor::new();
2349 let mut all_matches = Vec::new();
2350 let mut batches = 0;
2351
2352 while !cursor.done {
2353 let batch = fs
2354 .search_file(&path, "MARKER_", &opts, &mut cursor)
2355 .unwrap();
2356 all_matches.extend(batch);
2357 batches += 1;
2358 }
2359
2360 assert!(batches > 1, "Expected multiple batches, got {}", batches);
2362 assert_eq!(all_matches.len(), 100_000);
2364 let mut offsets: Vec<usize> = all_matches.iter().map(|m| m.byte_offset).collect();
2366 offsets.sort();
2367 offsets.dedup();
2368 assert_eq!(offsets.len(), 100_000);
2369 }
2370
2371 #[test]
2372 fn test_search_file_line_numbers_across_chunks() {
2373 let fs = StdFileSystem;
2374 let temp_dir = tempfile::tempdir().unwrap();
2375 let path = temp_dir.path().join("large.txt");
2376
2377 let mut content = Vec::new();
2379 let total_lines = 100_000;
2380 for i in 0..total_lines {
2381 if i == 99_999 {
2382 content.extend_from_slice(b"FINDME at the end\n");
2383 } else {
2384 content.extend_from_slice(format!("padding line {}\n", i).as_bytes());
2385 }
2386 }
2387 fs.write_file(&path, &content).unwrap();
2388
2389 let opts = make_search_opts(true);
2390 let mut cursor = FileSearchCursor::new();
2391 let mut all_matches = Vec::new();
2392
2393 while !cursor.done {
2394 let batch = fs.search_file(&path, "FINDME", &opts, &mut cursor).unwrap();
2395 all_matches.extend(batch);
2396 }
2397
2398 assert_eq!(all_matches.len(), 1);
2399 assert_eq!(all_matches[0].line, total_lines); assert_eq!(all_matches[0].context, "FINDME at the end");
2401 }
2402
2403 #[test]
2404 fn test_search_file_end_offset_bounds_search() {
2405 let fs = StdFileSystem;
2406 let temp_dir = tempfile::tempdir().unwrap();
2407 let path = temp_dir.path().join("bounded.txt");
2408
2409 fs.write_file(&path, b"AAA\nBBB\nCCC\nDDD\n").unwrap();
2411
2412 let opts = make_search_opts(true);
2414 let mut cursor = FileSearchCursor::for_range(0, 8, 1);
2415 let mut matches = Vec::new();
2416 while !cursor.done {
2417 matches.extend(fs.search_file(&path, "AAA", &opts, &mut cursor).unwrap());
2418 }
2419 assert_eq!(matches.len(), 1);
2420 assert_eq!(matches[0].context, "AAA");
2421 assert_eq!(matches[0].line, 1);
2422
2423 let mut cursor = FileSearchCursor::for_range(0, 8, 1);
2425 let ccc = fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap();
2426 assert!(ccc.is_empty(), "CCC should not be found in first 8 bytes");
2427
2428 let mut cursor = FileSearchCursor::for_range(8, 16, 3);
2430 let mut matches = Vec::new();
2431 while !cursor.done {
2432 matches.extend(fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap());
2433 }
2434 assert_eq!(matches.len(), 1);
2435 assert_eq!(matches[0].context, "CCC");
2436 assert_eq!(matches[0].line, 3);
2437 }
2438
2439 fn make_walk_tree() -> tempfile::TempDir {
2446 let fs = StdFileSystem;
2447 let tmp = tempfile::tempdir().unwrap();
2448 let root = tmp.path();
2449
2450 fs.write_file(&root.join("a.txt"), b"a").unwrap();
2465 fs.write_file(&root.join("b.txt"), b"b").unwrap();
2466 fs.create_dir_all(&root.join("sub/deep")).unwrap();
2467 fs.write_file(&root.join("sub/c.txt"), b"c").unwrap();
2468 fs.write_file(&root.join("sub/deep/d.txt"), b"d").unwrap();
2469 fs.create_dir_all(&root.join(".hidden_dir")).unwrap();
2470 fs.write_file(&root.join(".hidden_dir/secret.txt"), b"s")
2471 .unwrap();
2472 fs.write_file(&root.join(".hidden_file"), b"h").unwrap();
2473 fs.create_dir_all(&root.join("node_modules")).unwrap();
2474 fs.write_file(&root.join("node_modules/pkg.json"), b"{}")
2475 .unwrap();
2476 fs.create_dir_all(&root.join("target")).unwrap();
2477 fs.write_file(&root.join("target/debug.o"), b"elf").unwrap();
2478
2479 tmp
2480 }
2481
2482 #[test]
2483 fn test_walk_files_std_basic() {
2484 let tmp = make_walk_tree();
2485 let fs = StdFileSystem;
2486 let cancel = std::sync::atomic::AtomicBool::new(false);
2487 let mut found: Vec<String> = Vec::new();
2488
2489 fs.walk_files(
2490 tmp.path(),
2491 &["node_modules", "target"],
2492 &cancel,
2493 &mut |_path, rel| {
2494 found.push(rel.to_string());
2495 true
2496 },
2497 )
2498 .unwrap();
2499
2500 found.sort();
2501 assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt", "sub/deep/d.txt"]);
2502 }
2503
2504 #[test]
2505 fn test_walk_files_std_skips_hidden() {
2506 let tmp = make_walk_tree();
2507 let fs = StdFileSystem;
2508 let cancel = std::sync::atomic::AtomicBool::new(false);
2509 let mut found: Vec<String> = Vec::new();
2510
2511 fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2512 found.push(rel.to_string());
2513 true
2514 })
2515 .unwrap();
2516
2517 assert!(!found.iter().any(|f| f.contains(".hidden")));
2520 assert!(found.iter().any(|f| f.contains("node_modules")));
2521 assert!(found.iter().any(|f| f.contains("target")));
2522 }
2523
2524 #[test]
2525 fn test_walk_files_std_skip_dirs() {
2526 let tmp = make_walk_tree();
2527 let fs = StdFileSystem;
2528 let cancel = std::sync::atomic::AtomicBool::new(false);
2529 let mut found: Vec<String> = Vec::new();
2530
2531 fs.walk_files(
2532 tmp.path(),
2533 &["node_modules", "target", "deep"],
2534 &cancel,
2535 &mut |_path, rel| {
2536 found.push(rel.to_string());
2537 true
2538 },
2539 )
2540 .unwrap();
2541
2542 found.sort();
2543 assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt"]);
2545 }
2546
2547 #[test]
2548 fn test_walk_files_std_cancel() {
2549 let tmp = make_walk_tree();
2550 let fs = StdFileSystem;
2551 let cancel = std::sync::atomic::AtomicBool::new(false);
2552 let mut found: Vec<String> = Vec::new();
2553
2554 fs.walk_files(
2555 tmp.path(),
2556 &["node_modules", "target"],
2557 &cancel,
2558 &mut |_path, rel| {
2559 found.push(rel.to_string());
2560 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
2562 true
2563 },
2564 )
2565 .unwrap();
2566
2567 assert_eq!(found.len(), 1, "Should stop after cancel is set");
2568 }
2569
2570 #[test]
2571 fn test_walk_files_std_on_file_returns_false() {
2572 let tmp = make_walk_tree();
2573 let fs = StdFileSystem;
2574 let cancel = std::sync::atomic::AtomicBool::new(false);
2575 let mut count = 0usize;
2576
2577 fs.walk_files(
2578 tmp.path(),
2579 &["node_modules", "target"],
2580 &cancel,
2581 &mut |_path, _rel| {
2582 count += 1;
2583 count < 2 },
2585 )
2586 .unwrap();
2587
2588 assert_eq!(count, 2, "Should stop when on_file returns false");
2589 }
2590
2591 #[test]
2592 fn test_walk_files_std_empty_dir() {
2593 let tmp = tempfile::tempdir().unwrap();
2594 let fs = StdFileSystem;
2595 let cancel = std::sync::atomic::AtomicBool::new(false);
2596 let mut found: Vec<String> = Vec::new();
2597
2598 fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2599 found.push(rel.to_string());
2600 true
2601 })
2602 .unwrap();
2603
2604 assert!(found.is_empty());
2605 }
2606
2607 #[test]
2608 fn test_walk_files_std_nonexistent_root() {
2609 let fs = StdFileSystem;
2610 let cancel = std::sync::atomic::AtomicBool::new(false);
2611 let mut found: Vec<String> = Vec::new();
2612
2613 let result = fs.walk_files(
2615 Path::new("/nonexistent/path/that/does/not/exist"),
2616 &[],
2617 &cancel,
2618 &mut |_path, rel| {
2619 found.push(rel.to_string());
2620 true
2621 },
2622 );
2623
2624 assert!(result.is_ok());
2625 assert!(found.is_empty());
2626 }
2627
2628 #[test]
2629 fn test_walk_files_std_relative_paths_use_forward_slashes() {
2630 let tmp = make_walk_tree();
2631 let fs = StdFileSystem;
2632 let cancel = std::sync::atomic::AtomicBool::new(false);
2633 let mut found: Vec<String> = Vec::new();
2634
2635 fs.walk_files(
2636 tmp.path(),
2637 &["node_modules", "target"],
2638 &cancel,
2639 &mut |_path, rel| {
2640 found.push(rel.to_string());
2641 true
2642 },
2643 )
2644 .unwrap();
2645
2646 for path in &found {
2648 assert!(!path.contains('\\'), "Path should use / not \\: {}", path);
2649 }
2650 }
2651
2652 #[test]
2653 fn test_walk_files_noop_returns_error() {
2654 let fs = NoopFileSystem;
2655 let cancel = std::sync::atomic::AtomicBool::new(false);
2656
2657 let result = fs.walk_files(Path::new("/noop/path"), &[], &cancel, &mut |_path, _rel| {
2658 true
2659 });
2660
2661 assert!(result.is_err());
2662 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
2663 }
2664
2665 #[test]
2671 #[cfg(unix)]
2672 fn test_is_writable_matches_kernel_for_owner_writable() {
2673 use std::os::unix::ffi::OsStrExt;
2674 let fs = StdFileSystem;
2675 let temp_dir = tempfile::tempdir().unwrap();
2676 let path = temp_dir.path().join("writable.txt");
2677 fs.write_file(&path, b"x").unwrap();
2678 fs.set_permissions(&path, &FilePermissions::from_mode(0o600))
2679 .unwrap();
2680
2681 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
2682 let kernel_writable = unsafe {
2683 libc::faccessat(
2684 libc::AT_FDCWD,
2685 c_path.as_ptr(),
2686 libc::W_OK,
2687 libc::AT_EACCESS,
2688 )
2689 } == 0;
2690 assert!(
2691 kernel_writable,
2692 "owner-writable file must be writable per kernel"
2693 );
2694 assert_eq!(fs.is_writable(&path), kernel_writable);
2695 }
2696
2697 #[test]
2715 #[ignore = "requires root + setfacl; see test docstring"]
2716 #[cfg(target_os = "linux")]
2717 fn test_is_writable_respects_posix_acl() {
2718 use std::os::unix::ffi::OsStrExt;
2719 use std::process::Command;
2720
2721 if unsafe { libc::geteuid() } != 0 {
2723 panic!("test must be run as root (need to chown to a foreign uid)");
2724 }
2725 let setfacl_ok = Command::new("setfacl").arg("--version").output().is_ok();
2726 assert!(setfacl_ok, "setfacl must be installed");
2727
2728 let test_uid: libc::uid_t = 65534;
2731 let test_gid: libc::gid_t = 65534;
2732 let foreign_uid: libc::uid_t = 9999;
2736 let foreign_gid: libc::gid_t = 9999;
2737
2738 let temp_dir = tempfile::tempdir().unwrap();
2739 std::fs::set_permissions(
2741 temp_dir.path(),
2742 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o755),
2743 )
2744 .unwrap();
2745
2746 let file = temp_dir.path().join("acl_test.txt");
2747 std::fs::write(&file, b"hi").unwrap();
2748
2749 let c_file = std::ffi::CString::new(file.as_os_str().as_bytes()).unwrap();
2750 let r = unsafe { libc::chown(c_file.as_ptr(), foreign_uid, foreign_gid) };
2752 assert_eq!(r, 0, "chown failed: {}", io::Error::last_os_error());
2753 std::fs::set_permissions(
2754 &file,
2755 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
2756 )
2757 .unwrap();
2758
2759 let acl_status = Command::new("setfacl")
2760 .args(["-m", &format!("u:{test_uid}:rw")])
2761 .arg(&file)
2762 .status()
2763 .unwrap();
2764 assert!(
2765 acl_status.success(),
2766 "setfacl failed (does the filesystem support ACLs?)",
2767 );
2768
2769 let pid = unsafe { libc::fork() };
2776 if pid < 0 {
2777 panic!("fork failed: {}", io::Error::last_os_error());
2778 }
2779 if pid == 0 {
2780 if unsafe { libc::setgid(test_gid) } != 0 {
2783 unsafe { libc::_exit(2) };
2784 }
2785 if unsafe { libc::setuid(test_uid) } != 0 {
2786 unsafe { libc::_exit(3) };
2787 }
2788 let writable = StdFileSystem.is_writable(&file);
2789 unsafe { libc::_exit(if writable { 0 } else { 1 }) };
2790 }
2791
2792 let mut status: libc::c_int = 0;
2794 let r = unsafe { libc::waitpid(pid, &mut status, 0) };
2796 assert!(r > 0, "waitpid failed: {}", io::Error::last_os_error());
2797 let exited_normally = (status & 0x7f) == 0;
2798 let exit_code = (status >> 8) & 0xff;
2799 assert!(
2800 exited_normally,
2801 "child terminated abnormally; status={status}"
2802 );
2803 assert_eq!(
2804 exit_code, 0,
2805 "child reported file NOT writable (exit_code={exit_code}); ACL was ignored",
2806 );
2807 }
2808}