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 remote_channel_id(&self) -> Option<u64> {
671 None
672 }
673
674 fn remote_reconnect_notify(&self) -> Option<std::sync::Arc<tokio::sync::Notify>> {
679 None
680 }
681
682 fn home_dir(&self) -> io::Result<PathBuf> {
687 dirs::home_dir()
688 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home directory not found"))
689 }
690
691 fn search_file(
708 &self,
709 path: &Path,
710 pattern: &str,
711 opts: &FileSearchOptions,
712 cursor: &mut FileSearchCursor,
713 ) -> io::Result<Vec<SearchMatch>>;
714
715 fn sudo_write(&self, path: &Path, data: &[u8], mode: u32, uid: u32, gid: u32)
726 -> io::Result<()>;
727
728 fn walk_files(
755 &self,
756 root: &Path,
757 skip_dirs: &[&str],
758 cancel: &std::sync::atomic::AtomicBool,
759 on_file: &mut dyn FnMut(&Path, &str) -> bool,
760 ) -> io::Result<()>;
761}
762
763pub trait FileSystemExt: FileSystem {
788 fn read_file_async(
790 &self,
791 path: &Path,
792 ) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
793 async { self.read_file(path) }
794 }
795
796 fn read_range_async(
798 &self,
799 path: &Path,
800 offset: u64,
801 len: usize,
802 ) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
803 async move { self.read_range(path, offset, len) }
804 }
805
806 fn count_line_feeds_in_range_async(
808 &self,
809 path: &Path,
810 offset: u64,
811 len: usize,
812 ) -> impl std::future::Future<Output = io::Result<usize>> + Send {
813 async move { self.count_line_feeds_in_range(path, offset, len) }
814 }
815
816 fn write_file_async(
818 &self,
819 path: &Path,
820 data: &[u8],
821 ) -> impl std::future::Future<Output = io::Result<()>> + Send {
822 async { self.write_file(path, data) }
823 }
824
825 fn metadata_async(
827 &self,
828 path: &Path,
829 ) -> impl std::future::Future<Output = io::Result<FileMetadata>> + Send {
830 async { self.metadata(path) }
831 }
832
833 fn exists_async(&self, path: &Path) -> impl std::future::Future<Output = bool> + Send {
835 async { self.exists(path) }
836 }
837
838 fn is_dir_async(
840 &self,
841 path: &Path,
842 ) -> impl std::future::Future<Output = io::Result<bool>> + Send {
843 async { self.is_dir(path) }
844 }
845
846 fn is_file_async(
848 &self,
849 path: &Path,
850 ) -> impl std::future::Future<Output = io::Result<bool>> + Send {
851 async { self.is_file(path) }
852 }
853
854 fn read_dir_async(
856 &self,
857 path: &Path,
858 ) -> impl std::future::Future<Output = io::Result<Vec<DirEntry>>> + Send {
859 async { self.read_dir(path) }
860 }
861
862 fn canonicalize_async(
864 &self,
865 path: &Path,
866 ) -> impl std::future::Future<Output = io::Result<PathBuf>> + Send {
867 async { self.canonicalize(path) }
868 }
869}
870
871impl<T: FileSystem> FileSystemExt for T {}
873
874pub fn build_search_regex(
880 pattern: &str,
881 opts: &FileSearchOptions,
882) -> io::Result<regex::bytes::Regex> {
883 let re_pattern = if opts.fixed_string {
884 regex::escape(pattern)
885 } else {
886 pattern.to_string()
887 };
888 let re_pattern = if opts.whole_word {
889 format!(r"\b{}\b", re_pattern)
890 } else {
891 re_pattern
892 };
893 let re_pattern = if opts.case_sensitive {
894 re_pattern
895 } else {
896 format!("(?i){}", re_pattern)
897 };
898 let re_pattern = if !opts.fixed_string && pattern.contains('\n') {
900 format!("(?s){}", re_pattern)
901 } else {
902 re_pattern
903 };
904 regex::bytes::Regex::new(&re_pattern)
905 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
906}
907
908pub const MAX_PROJECT_SEARCH_FILE_SIZE: u64 = 10 * 1024 * 1024;
915
916pub const BINARY_FILE_EXTENSIONS: &[&str] = &[
925 "a",
927 "class",
928 "dll",
929 "dylib",
930 "exe",
931 "jar",
932 "lib",
933 "o",
934 "obj",
935 "pdb",
936 "pyc",
937 "pyo",
938 "so",
939 "wasm",
940 "war",
941 "7z",
943 "br",
944 "bz2",
945 "gz",
946 "lz",
947 "lz4",
948 "lzma",
949 "rar",
950 "tar",
951 "tbz",
952 "tbz2",
953 "tgz",
954 "txz",
955 "xz",
956 "z",
957 "zip",
958 "zst",
959 "apk",
961 "deb",
962 "dmg",
963 "img",
964 "ipa",
965 "iso",
966 "msi",
967 "rpm",
968 "vhd",
969 "vmdk",
970 "bmp",
972 "gif",
973 "heic",
974 "heif",
975 "ico",
976 "jp2",
977 "jpe",
978 "jpeg",
979 "jpg",
980 "png",
981 "psd",
982 "raw",
983 "tif",
984 "tiff",
985 "webp",
986 "aac",
988 "aif",
989 "aiff",
990 "avi",
991 "flac",
992 "flv",
993 "m4a",
994 "m4v",
995 "mid",
996 "midi",
997 "mka",
998 "mkv",
999 "mov",
1000 "mp3",
1001 "mp4",
1002 "mpeg",
1003 "mpg",
1004 "ogg",
1005 "opus",
1006 "wav",
1007 "webm",
1008 "wma",
1009 "wmv",
1010 "doc",
1012 "docx",
1013 "odp",
1014 "ods",
1015 "odt",
1016 "pdf",
1017 "ppt",
1018 "pptx",
1019 "rtf",
1020 "xls",
1021 "xlsx",
1022 "db",
1024 "mdb",
1025 "sqlite",
1026 "sqlite3",
1027 "eot",
1029 "otf",
1030 "ttc",
1031 "ttf",
1032 "woff",
1033 "woff2",
1034 "ckpt",
1036 "h5",
1037 "hdf5",
1038 "msgpack",
1039 "npy",
1040 "npz",
1041 "onnx",
1042 "pb",
1043 "pickle",
1044 "pkl",
1045 "pt",
1046 "pth",
1047 "safetensors",
1048 "tflite",
1049 "bin",
1051 "dat",
1052 "swf",
1053];
1054
1055fn has_binary_extension(path: &Path) -> bool {
1057 let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
1058 return false;
1059 };
1060 BINARY_FILE_EXTENSIONS
1061 .iter()
1062 .any(|candidate| candidate.eq_ignore_ascii_case(ext))
1063}
1064
1065fn is_byte_searchable_encoding(enc: crate::model::encoding::Encoding) -> bool {
1073 use crate::model::encoding::Encoding;
1074 !matches!(enc, Encoding::Utf16Le | Encoding::Utf16Be)
1075}
1076
1077pub fn default_search_file(
1081 fs: &dyn FileSystem,
1082 path: &Path,
1083 pattern: &str,
1084 opts: &FileSearchOptions,
1085 cursor: &mut FileSearchCursor,
1086) -> io::Result<Vec<SearchMatch>> {
1087 if cursor.done {
1088 return Ok(vec![]);
1089 }
1090
1091 const CHUNK_SIZE: usize = 1_048_576; let overlap = pattern.len().max(256);
1093
1094 if cursor.offset == 0 && cursor.end_offset.is_none() {
1099 if has_binary_extension(path) {
1102 cursor.done = true;
1103 return Ok(vec![]);
1104 }
1105 }
1106
1107 let meta = fs.metadata(path)?;
1108 let file_size = meta.size;
1109 let file_len = file_size as usize;
1110 let effective_end = cursor.end_offset.unwrap_or(file_len).min(file_len);
1111
1112 if cursor.offset == 0 && cursor.end_offset.is_none() {
1113 if file_size == 0 {
1114 cursor.done = true;
1115 return Ok(vec![]);
1116 }
1117 if file_size > MAX_PROJECT_SEARCH_FILE_SIZE {
1121 cursor.done = true;
1122 return Ok(vec![]);
1123 }
1124 let header_len = file_len.min(8192);
1131 let header = fs.read_range(path, 0, header_len)?;
1132 let truncated = header_len < file_len;
1133 let (encoding, is_binary) =
1134 crate::model::encoding::detect_encoding_or_binary(&header, truncated);
1135 if is_binary || !is_byte_searchable_encoding(encoding) {
1136 cursor.done = true;
1137 return Ok(vec![]);
1138 }
1139 }
1140
1141 if cursor.offset >= effective_end {
1142 cursor.done = true;
1143 return Ok(vec![]);
1144 }
1145
1146 let regex = build_search_regex(pattern, opts)?;
1147
1148 let read_start = cursor.offset.saturating_sub(overlap);
1150 let read_end = (read_start + CHUNK_SIZE).min(effective_end);
1151 let chunk = fs.read_range(path, read_start as u64, read_end - read_start)?;
1152
1153 let overlap_len = cursor.offset - read_start;
1154
1155 if cursor.end_offset.is_none() && chunk[overlap_len..].contains(&0) {
1166 cursor.done = true;
1167 return Ok(vec![]);
1168 }
1169
1170 let newlines_in_overlap = chunk[..overlap_len].iter().filter(|&&b| b == b'\n').count();
1172 let mut line_at = cursor.running_line.saturating_sub(newlines_in_overlap);
1173 let mut counted_to = 0usize;
1174 let mut matches = Vec::new();
1175
1176 for m in regex.find_iter(&chunk) {
1177 if overlap_len > 0 && m.end() <= overlap_len {
1179 continue;
1180 }
1181 if matches.len() >= opts.max_matches {
1182 break;
1183 }
1184
1185 line_at += chunk[counted_to..m.start()]
1187 .iter()
1188 .filter(|&&b| b == b'\n')
1189 .count();
1190 counted_to = m.start();
1191
1192 let line_start = chunk[..m.start()]
1194 .iter()
1195 .rposition(|&b| b == b'\n')
1196 .map(|p| p + 1)
1197 .unwrap_or(0);
1198 let line_end = chunk[m.start()..]
1199 .iter()
1200 .position(|&b| b == b'\n')
1201 .map(|p| m.start() + p)
1202 .unwrap_or(chunk.len());
1203
1204 let column = m.start() - line_start + 1;
1205 let context = String::from_utf8_lossy(&chunk[line_start..line_end]).into_owned();
1206
1207 matches.push(SearchMatch {
1208 byte_offset: read_start + m.start(),
1209 length: m.end() - m.start(),
1210 line: line_at,
1211 column,
1212 context,
1213 });
1214 }
1215
1216 let new_data = &chunk[overlap_len..];
1218 cursor.running_line += new_data.iter().filter(|&&b| b == b'\n').count();
1219 cursor.offset = read_end;
1220 if read_end >= effective_end {
1221 cursor.done = true;
1222 }
1223
1224 Ok(matches)
1225}
1226
1227#[derive(Debug, Clone, Copy, Default)]
1235pub struct StdFileSystem;
1236
1237impl StdFileSystem {
1238 fn is_hidden(path: &Path) -> bool {
1240 path.file_name()
1241 .and_then(|n| n.to_str())
1242 .is_some_and(|n| n.starts_with('.'))
1243 }
1244
1245 #[cfg(unix)]
1247 pub fn current_user_groups() -> (u32, Vec<u32>) {
1248 let euid = unsafe { libc::geteuid() };
1250 let egid = unsafe { libc::getegid() };
1251 let mut groups = vec![egid];
1252
1253 let ngroups = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
1255 if ngroups > 0 {
1256 let mut sup_groups = vec![0 as libc::gid_t; ngroups as usize];
1257 let n = unsafe { libc::getgroups(ngroups, sup_groups.as_mut_ptr()) };
1258 if n > 0 {
1259 sup_groups.truncate(n as usize);
1260 for g in sup_groups {
1261 if g != egid {
1262 groups.push(g);
1263 }
1264 }
1265 }
1266 }
1267
1268 (euid, groups)
1269 }
1270
1271 #[cfg(unix)]
1278 fn kernel_writable(path: &Path) -> Option<bool> {
1279 use std::os::unix::ffi::OsStrExt;
1280 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1281 let rc = unsafe {
1284 libc::faccessat(
1285 libc::AT_FDCWD,
1286 c_path.as_ptr(),
1287 libc::W_OK,
1288 libc::AT_EACCESS,
1289 )
1290 };
1291 Some(rc == 0)
1292 }
1293
1294 fn build_metadata(path: &Path, meta: &std::fs::Metadata) -> FileMetadata {
1296 #[cfg(unix)]
1297 {
1298 use std::os::unix::fs::MetadataExt;
1299 let file_uid = meta.uid();
1300 let file_gid = meta.gid();
1301 let permissions = FilePermissions::from_std(meta.permissions());
1302 let is_readonly = match Self::kernel_writable(path) {
1306 Some(writable) => !writable,
1307 None => {
1308 let (euid, user_groups) = Self::current_user_groups();
1309 permissions.is_readonly_for_user(euid, file_uid, file_gid, &user_groups)
1310 }
1311 };
1312 FileMetadata {
1313 size: meta.len(),
1314 modified: meta.modified().ok(),
1315 permissions: Some(permissions),
1316 is_hidden: Self::is_hidden(path),
1317 is_readonly,
1318 uid: Some(file_uid),
1319 gid: Some(file_gid),
1320 }
1321 }
1322 #[cfg(not(unix))]
1323 {
1324 FileMetadata {
1325 size: meta.len(),
1326 modified: meta.modified().ok(),
1327 permissions: Some(FilePermissions::from_std(meta.permissions())),
1328 is_hidden: Self::is_hidden(path),
1329 is_readonly: meta.permissions().readonly(),
1330 }
1331 }
1332 }
1333}
1334
1335impl FileSystem for StdFileSystem {
1336 fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
1338 let data = std::fs::read(path)?;
1339 crate::services::counters::global().inc_disk_bytes_read(data.len() as u64);
1340 Ok(data)
1341 }
1342
1343 fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
1344 let mut file = std::fs::File::open(path)?;
1345 file.seek(io::SeekFrom::Start(offset))?;
1346 let mut buffer = vec![0u8; len];
1347 file.read_exact(&mut buffer)?;
1348 crate::services::counters::global().inc_disk_bytes_read(len as u64);
1349 Ok(buffer)
1350 }
1351
1352 fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()> {
1353 let original_metadata = self.metadata_if_exists(path);
1354 let temp_path = self.temp_path_for(path);
1355 {
1356 let mut file = self.create_file(&temp_path)?;
1357 file.write_all(data)?;
1358 file.sync_all()?;
1359 }
1360 if let Some(ref meta) = original_metadata {
1361 if let Some(ref perms) = meta.permissions {
1362 #[allow(clippy::let_underscore_must_use)]
1364 let _ = self.set_permissions(&temp_path, perms);
1365 }
1366 }
1367 self.rename(&temp_path, path)?;
1368 Ok(())
1369 }
1370
1371 fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1372 let file = std::fs::File::create(path)?;
1373 Ok(Box::new(StdFileWriter(file)))
1374 }
1375
1376 fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>> {
1377 let file = std::fs::File::open(path)?;
1378 Ok(Box::new(StdFileReader(file)))
1379 }
1380
1381 fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1382 let file = std::fs::OpenOptions::new()
1383 .write(true)
1384 .truncate(true)
1385 .open(path)?;
1386 Ok(Box::new(StdFileWriter(file)))
1387 }
1388
1389 fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1390 let file = std::fs::OpenOptions::new()
1391 .create(true)
1392 .append(true)
1393 .open(path)?;
1394 Ok(Box::new(StdFileWriter(file)))
1395 }
1396
1397 fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()> {
1398 let file = std::fs::OpenOptions::new().write(true).open(path)?;
1399 file.set_len(len)
1400 }
1401
1402 fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
1404 std::fs::rename(from, to)
1405 }
1406
1407 fn copy(&self, from: &Path, to: &Path) -> io::Result<u64> {
1408 std::fs::copy(from, to)
1409 }
1410
1411 fn remove_file(&self, path: &Path) -> io::Result<()> {
1412 std::fs::remove_file(path)
1413 }
1414
1415 fn remove_dir(&self, path: &Path) -> io::Result<()> {
1416 std::fs::remove_dir(path)
1417 }
1418
1419 fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1421 let meta = std::fs::metadata(path)?;
1422 Ok(Self::build_metadata(path, &meta))
1423 }
1424
1425 fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1426 let meta = std::fs::symlink_metadata(path)?;
1427 Ok(Self::build_metadata(path, &meta))
1428 }
1429
1430 fn is_dir(&self, path: &Path) -> io::Result<bool> {
1431 Ok(std::fs::metadata(path)?.is_dir())
1432 }
1433
1434 fn is_file(&self, path: &Path) -> io::Result<bool> {
1435 Ok(std::fs::metadata(path)?.is_file())
1436 }
1437
1438 fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()> {
1439 std::fs::set_permissions(path, permissions.to_std())
1440 }
1441
1442 fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
1444 let mut entries = Vec::new();
1445 for entry in std::fs::read_dir(path)? {
1446 let entry = entry?;
1447 let path = entry.path();
1448 let name = entry.file_name().to_string_lossy().into_owned();
1449 let file_type = entry.file_type()?;
1450
1451 let entry_type = if file_type.is_dir() {
1452 EntryType::Directory
1453 } else if file_type.is_symlink() {
1454 EntryType::Symlink
1455 } else {
1456 EntryType::File
1457 };
1458
1459 let mut dir_entry = DirEntry::new(path.clone(), name, entry_type);
1460
1461 if file_type.is_symlink() {
1463 dir_entry.symlink_target_is_dir = std::fs::metadata(&path)
1464 .map(|m| m.is_dir())
1465 .unwrap_or(false);
1466 }
1467
1468 entries.push(dir_entry);
1469 }
1470 Ok(entries)
1471 }
1472
1473 fn create_dir(&self, path: &Path) -> io::Result<()> {
1474 std::fs::create_dir(path)
1475 }
1476
1477 fn create_dir_all(&self, path: &Path) -> io::Result<()> {
1478 std::fs::create_dir_all(path)
1479 }
1480
1481 fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
1483 std::fs::canonicalize(path)
1484 }
1485
1486 fn current_uid(&self) -> u32 {
1488 #[cfg(all(unix, feature = "runtime"))]
1489 {
1490 unsafe { libc::getuid() }
1492 }
1493 #[cfg(not(all(unix, feature = "runtime")))]
1494 {
1495 0
1496 }
1497 }
1498
1499 fn sudo_write(
1500 &self,
1501 path: &Path,
1502 data: &[u8],
1503 mode: u32,
1504 uid: u32,
1505 gid: u32,
1506 ) -> io::Result<()> {
1507 use crate::services::process_hidden::HideWindow;
1508 use std::process::{Command, Stdio};
1509
1510 let mut child = Command::new("sudo")
1512 .args(["tee", &path.to_string_lossy()])
1513 .stdin(Stdio::piped())
1514 .stdout(Stdio::null())
1515 .stderr(Stdio::piped())
1516 .hide_window()
1517 .spawn()
1518 .map_err(|e| io::Error::other(format!("failed to spawn sudo: {}", e)))?;
1519
1520 if let Some(mut stdin) = child.stdin.take() {
1521 use std::io::Write;
1522 stdin.write_all(data)?;
1523 }
1524
1525 let output = child.wait_with_output()?;
1526 if !output.status.success() {
1527 let stderr = String::from_utf8_lossy(&output.stderr);
1528 return Err(io::Error::new(
1529 io::ErrorKind::PermissionDenied,
1530 format!("sudo tee failed: {}", stderr.trim()),
1531 ));
1532 }
1533
1534 let status = Command::new("sudo")
1536 .args(["chmod", &format!("{:o}", mode), &path.to_string_lossy()])
1537 .hide_window()
1538 .status()?;
1539 if !status.success() {
1540 return Err(io::Error::other("sudo chmod failed"));
1541 }
1542
1543 let status = Command::new("sudo")
1545 .args([
1546 "chown",
1547 &format!("{}:{}", uid, gid),
1548 &path.to_string_lossy(),
1549 ])
1550 .hide_window()
1551 .status()?;
1552 if !status.success() {
1553 return Err(io::Error::other("sudo chown failed"));
1554 }
1555
1556 Ok(())
1557 }
1558
1559 fn search_file(
1560 &self,
1561 path: &Path,
1562 pattern: &str,
1563 opts: &FileSearchOptions,
1564 cursor: &mut FileSearchCursor,
1565 ) -> io::Result<Vec<SearchMatch>> {
1566 default_search_file(self, path, pattern, opts, cursor)
1567 }
1568
1569 fn walk_files(
1570 &self,
1571 root: &Path,
1572 skip_dirs: &[&str],
1573 cancel: &std::sync::atomic::AtomicBool,
1574 on_file: &mut dyn FnMut(&Path, &str) -> bool,
1575 ) -> io::Result<()> {
1576 let mut stack = vec![root.to_path_buf()];
1577 while let Some(dir) = stack.pop() {
1578 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1579 return Ok(());
1580 }
1581
1582 let iter = match std::fs::read_dir(&dir) {
1586 Ok(it) => it,
1587 Err(_) => continue,
1588 };
1589
1590 for entry in iter {
1591 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1592 return Ok(());
1593 }
1594 let entry = match entry {
1595 Ok(e) => e,
1596 Err(_) => continue,
1597 };
1598 let name = entry.file_name();
1599 let name_str = name.to_string_lossy();
1600
1601 if name_str.starts_with('.') {
1603 continue;
1604 }
1605
1606 let ft = match entry.file_type() {
1607 Ok(ft) => ft,
1608 Err(_) => continue,
1609 };
1610 let path = entry.path();
1611
1612 if ft.is_file() {
1613 if let Ok(rel) = path.strip_prefix(root) {
1614 let rel_str = rel.to_string_lossy().replace('\\', "/");
1615 if !on_file(&path, &rel_str) {
1616 return Ok(());
1617 }
1618 }
1619 } else if ft.is_dir() && !skip_dirs.contains(&name_str.as_ref()) {
1620 stack.push(path);
1621 }
1622 }
1623 }
1624 Ok(())
1625 }
1626}
1627
1628#[derive(Debug, Clone, Copy, Default)]
1637pub struct NoopFileSystem;
1638
1639impl NoopFileSystem {
1640 fn unsupported<T>() -> io::Result<T> {
1641 Err(io::Error::new(
1642 io::ErrorKind::Unsupported,
1643 "Filesystem not available",
1644 ))
1645 }
1646}
1647
1648impl FileSystem for NoopFileSystem {
1649 fn read_file(&self, _path: &Path) -> io::Result<Vec<u8>> {
1650 Self::unsupported()
1651 }
1652
1653 fn read_range(&self, _path: &Path, _offset: u64, _len: usize) -> io::Result<Vec<u8>> {
1654 Self::unsupported()
1655 }
1656
1657 fn write_file(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
1658 Self::unsupported()
1659 }
1660
1661 fn create_file(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1662 Self::unsupported()
1663 }
1664
1665 fn open_file(&self, _path: &Path) -> io::Result<Box<dyn FileReader>> {
1666 Self::unsupported()
1667 }
1668
1669 fn open_file_for_write(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1670 Self::unsupported()
1671 }
1672
1673 fn open_file_for_append(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1674 Self::unsupported()
1675 }
1676
1677 fn set_file_length(&self, _path: &Path, _len: u64) -> io::Result<()> {
1678 Self::unsupported()
1679 }
1680
1681 fn rename(&self, _from: &Path, _to: &Path) -> io::Result<()> {
1682 Self::unsupported()
1683 }
1684
1685 fn copy(&self, _from: &Path, _to: &Path) -> io::Result<u64> {
1686 Self::unsupported()
1687 }
1688
1689 fn remove_file(&self, _path: &Path) -> io::Result<()> {
1690 Self::unsupported()
1691 }
1692
1693 fn remove_dir(&self, _path: &Path) -> io::Result<()> {
1694 Self::unsupported()
1695 }
1696
1697 fn metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1698 Self::unsupported()
1699 }
1700
1701 fn symlink_metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1702 Self::unsupported()
1703 }
1704
1705 fn is_dir(&self, _path: &Path) -> io::Result<bool> {
1706 Self::unsupported()
1707 }
1708
1709 fn is_file(&self, _path: &Path) -> io::Result<bool> {
1710 Self::unsupported()
1711 }
1712
1713 fn set_permissions(&self, _path: &Path, _permissions: &FilePermissions) -> io::Result<()> {
1714 Self::unsupported()
1715 }
1716
1717 fn read_dir(&self, _path: &Path) -> io::Result<Vec<DirEntry>> {
1718 Self::unsupported()
1719 }
1720
1721 fn create_dir(&self, _path: &Path) -> io::Result<()> {
1722 Self::unsupported()
1723 }
1724
1725 fn create_dir_all(&self, _path: &Path) -> io::Result<()> {
1726 Self::unsupported()
1727 }
1728
1729 fn canonicalize(&self, _path: &Path) -> io::Result<PathBuf> {
1730 Self::unsupported()
1731 }
1732
1733 fn current_uid(&self) -> u32 {
1734 0
1735 }
1736
1737 fn search_file(
1738 &self,
1739 _path: &Path,
1740 _pattern: &str,
1741 _opts: &FileSearchOptions,
1742 _cursor: &mut FileSearchCursor,
1743 ) -> io::Result<Vec<SearchMatch>> {
1744 Self::unsupported()
1745 }
1746
1747 fn sudo_write(
1748 &self,
1749 _path: &Path,
1750 _data: &[u8],
1751 _mode: u32,
1752 _uid: u32,
1753 _gid: u32,
1754 ) -> io::Result<()> {
1755 Self::unsupported()
1756 }
1757
1758 fn walk_files(
1759 &self,
1760 _root: &Path,
1761 _skip_dirs: &[&str],
1762 _cancel: &std::sync::atomic::AtomicBool,
1763 _on_file: &mut dyn FnMut(&Path, &str) -> bool,
1764 ) -> io::Result<()> {
1765 Self::unsupported()
1766 }
1767}
1768
1769#[cfg(test)]
1774mod tests {
1775 use super::*;
1776 use tempfile::NamedTempFile;
1777
1778 #[test]
1779 fn test_std_filesystem_read_write() {
1780 let fs = StdFileSystem;
1781 let mut temp = NamedTempFile::new().unwrap();
1782 let path = temp.path().to_path_buf();
1783
1784 std::io::Write::write_all(&mut temp, b"Hello, World!").unwrap();
1785 std::io::Write::flush(&mut temp).unwrap();
1786
1787 let content = fs.read_file(&path).unwrap();
1788 assert_eq!(content, b"Hello, World!");
1789
1790 let range = fs.read_range(&path, 7, 5).unwrap();
1791 assert_eq!(range, b"World");
1792
1793 let meta = fs.metadata(&path).unwrap();
1794 assert_eq!(meta.size, 13);
1795 }
1796
1797 #[test]
1798 fn test_noop_filesystem() {
1799 let fs = NoopFileSystem;
1800 let path = Path::new("/some/path");
1801
1802 assert!(fs.read_file(path).is_err());
1803 assert!(fs.read_range(path, 0, 10).is_err());
1804 assert!(fs.write_file(path, b"data").is_err());
1805 assert!(fs.metadata(path).is_err());
1806 assert!(fs.read_dir(path).is_err());
1807 }
1808
1809 #[test]
1810 fn test_create_and_write_file() {
1811 let fs = StdFileSystem;
1812 let temp_dir = tempfile::tempdir().unwrap();
1813 let path = temp_dir.path().join("test.txt");
1814
1815 {
1816 let mut writer = fs.create_file(&path).unwrap();
1817 writer.write_all(b"test content").unwrap();
1818 writer.sync_all().unwrap();
1819 }
1820
1821 let content = fs.read_file(&path).unwrap();
1822 assert_eq!(content, b"test content");
1823 }
1824
1825 #[test]
1826 fn test_read_dir() {
1827 let fs = StdFileSystem;
1828 let temp_dir = tempfile::tempdir().unwrap();
1829
1830 fs.create_dir(&temp_dir.path().join("subdir")).unwrap();
1832 fs.write_file(&temp_dir.path().join("file1.txt"), b"content1")
1833 .unwrap();
1834 fs.write_file(&temp_dir.path().join("file2.txt"), b"content2")
1835 .unwrap();
1836
1837 let entries = fs.read_dir(temp_dir.path()).unwrap();
1838 assert_eq!(entries.len(), 3);
1839
1840 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
1841 assert!(names.contains(&"subdir"));
1842 assert!(names.contains(&"file1.txt"));
1843 assert!(names.contains(&"file2.txt"));
1844 }
1845
1846 #[test]
1847 fn test_dir_entry_types() {
1848 let file = DirEntry::new(PathBuf::from("/file"), "file".to_string(), EntryType::File);
1849 assert!(file.is_file());
1850 assert!(!file.is_dir());
1851
1852 let dir = DirEntry::new(
1853 PathBuf::from("/dir"),
1854 "dir".to_string(),
1855 EntryType::Directory,
1856 );
1857 assert!(dir.is_dir());
1858 assert!(!dir.is_file());
1859
1860 let link_to_dir = DirEntry::new_symlink(PathBuf::from("/link"), "link".to_string(), true);
1861 assert!(link_to_dir.is_symlink());
1862 assert!(link_to_dir.is_dir());
1863 }
1864
1865 #[test]
1866 fn test_metadata_builder() {
1867 let meta = FileMetadata::default()
1868 .with_hidden(true)
1869 .with_readonly(true);
1870 assert!(meta.is_hidden);
1871 assert!(meta.is_readonly);
1872 }
1873
1874 #[test]
1875 fn test_atomic_write() {
1876 let fs = StdFileSystem;
1877 let temp_dir = tempfile::tempdir().unwrap();
1878 let path = temp_dir.path().join("atomic_test.txt");
1879
1880 fs.write_file(&path, b"initial").unwrap();
1881 assert_eq!(fs.read_file(&path).unwrap(), b"initial");
1882
1883 fs.write_file(&path, b"updated").unwrap();
1884 assert_eq!(fs.read_file(&path).unwrap(), b"updated");
1885 }
1886
1887 #[test]
1888 fn test_write_patched_default_impl() {
1889 let fs = StdFileSystem;
1891 let temp_dir = tempfile::tempdir().unwrap();
1892 let src_path = temp_dir.path().join("source.txt");
1893 let dst_path = temp_dir.path().join("dest.txt");
1894
1895 fs.write_file(&src_path, b"AAABBBCCC").unwrap();
1897
1898 let ops = vec![
1900 WriteOp::Copy { offset: 0, len: 3 }, WriteOp::Insert { data: b"XXX" }, WriteOp::Copy { offset: 6, len: 3 }, ];
1904
1905 fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1906
1907 let result = fs.read_file(&dst_path).unwrap();
1908 assert_eq!(result, b"AAAXXXCCC");
1909 }
1910
1911 #[test]
1912 fn test_write_patched_same_file() {
1913 let fs = StdFileSystem;
1915 let temp_dir = tempfile::tempdir().unwrap();
1916 let path = temp_dir.path().join("file.txt");
1917
1918 fs.write_file(&path, b"Hello World").unwrap();
1920
1921 let ops = vec![
1923 WriteOp::Copy { offset: 0, len: 6 }, WriteOp::Insert { data: b"Rust" }, ];
1926
1927 fs.write_patched(&path, &path, &ops).unwrap();
1928
1929 let result = fs.read_file(&path).unwrap();
1930 assert_eq!(result, b"Hello Rust");
1931 }
1932
1933 #[test]
1934 fn test_write_patched_insert_only() {
1935 let fs = StdFileSystem;
1937 let temp_dir = tempfile::tempdir().unwrap();
1938 let src_path = temp_dir.path().join("empty.txt");
1939 let dst_path = temp_dir.path().join("new.txt");
1940
1941 fs.write_file(&src_path, b"").unwrap();
1943
1944 let ops = vec![WriteOp::Insert {
1945 data: b"All new content",
1946 }];
1947
1948 fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1949
1950 let result = fs.read_file(&dst_path).unwrap();
1951 assert_eq!(result, b"All new content");
1952 }
1953
1954 fn make_search_opts(pattern_is_fixed: bool) -> FileSearchOptions {
1959 FileSearchOptions {
1960 fixed_string: pattern_is_fixed,
1961 case_sensitive: true,
1962 whole_word: false,
1963 max_matches: 100,
1964 }
1965 }
1966
1967 #[test]
1968 fn test_search_file_basic() {
1969 let fs = StdFileSystem;
1970 let temp_dir = tempfile::tempdir().unwrap();
1971 let path = temp_dir.path().join("test.txt");
1972 fs.write_file(&path, b"hello world\nfoo bar\nhello again\n")
1973 .unwrap();
1974
1975 let opts = make_search_opts(true);
1976 let mut cursor = FileSearchCursor::new();
1977 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1978
1979 assert!(cursor.done);
1980 assert_eq!(matches.len(), 2);
1981
1982 assert_eq!(matches[0].line, 1);
1983 assert_eq!(matches[0].column, 1);
1984 assert_eq!(matches[0].context, "hello world");
1985
1986 assert_eq!(matches[1].line, 3);
1987 assert_eq!(matches[1].column, 1);
1988 assert_eq!(matches[1].context, "hello again");
1989 }
1990
1991 #[test]
1992 fn test_search_file_no_matches() {
1993 let fs = StdFileSystem;
1994 let temp_dir = tempfile::tempdir().unwrap();
1995 let path = temp_dir.path().join("test.txt");
1996 fs.write_file(&path, b"hello world\n").unwrap();
1997
1998 let opts = make_search_opts(true);
1999 let mut cursor = FileSearchCursor::new();
2000 let matches = fs
2001 .search_file(&path, "NOTFOUND", &opts, &mut cursor)
2002 .unwrap();
2003
2004 assert!(cursor.done);
2005 assert!(matches.is_empty());
2006 }
2007
2008 #[test]
2009 fn test_search_file_case_insensitive() {
2010 let fs = StdFileSystem;
2011 let temp_dir = tempfile::tempdir().unwrap();
2012 let path = temp_dir.path().join("test.txt");
2013 fs.write_file(&path, b"Hello HELLO hello\n").unwrap();
2014
2015 let opts = FileSearchOptions {
2016 fixed_string: true,
2017 case_sensitive: false,
2018 whole_word: false,
2019 max_matches: 100,
2020 };
2021 let mut cursor = FileSearchCursor::new();
2022 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2023
2024 assert_eq!(matches.len(), 3);
2025 }
2026
2027 #[test]
2028 fn test_search_file_whole_word() {
2029 let fs = StdFileSystem;
2030 let temp_dir = tempfile::tempdir().unwrap();
2031 let path = temp_dir.path().join("test.txt");
2032 fs.write_file(&path, b"cat concatenate catalog\n").unwrap();
2033
2034 let opts = FileSearchOptions {
2035 fixed_string: true,
2036 case_sensitive: true,
2037 whole_word: true,
2038 max_matches: 100,
2039 };
2040 let mut cursor = FileSearchCursor::new();
2041 let matches = fs.search_file(&path, "cat", &opts, &mut cursor).unwrap();
2042
2043 assert_eq!(matches.len(), 1);
2044 assert_eq!(matches[0].column, 1);
2045 }
2046
2047 #[test]
2048 fn test_search_file_regex() {
2049 let fs = StdFileSystem;
2050 let temp_dir = tempfile::tempdir().unwrap();
2051 let path = temp_dir.path().join("test.txt");
2052 fs.write_file(&path, b"foo123 bar456 baz\n").unwrap();
2053
2054 let opts = FileSearchOptions {
2055 fixed_string: false,
2056 case_sensitive: true,
2057 whole_word: false,
2058 max_matches: 100,
2059 };
2060 let mut cursor = FileSearchCursor::new();
2061 let matches = fs
2062 .search_file(&path, r"[a-z]+\d+", &opts, &mut cursor)
2063 .unwrap();
2064
2065 assert_eq!(matches.len(), 2);
2066 assert_eq!(matches[0].context, "foo123 bar456 baz");
2067 }
2068
2069 #[test]
2070 fn test_search_file_binary_skipped() {
2071 let fs = StdFileSystem;
2072 let temp_dir = tempfile::tempdir().unwrap();
2073 let path = temp_dir.path().join("binary.dat");
2074 let mut data = b"hello world\n".to_vec();
2075 data.push(0); data.extend_from_slice(b"hello again\n");
2077 fs.write_file(&path, &data).unwrap();
2078
2079 let opts = make_search_opts(true);
2080 let mut cursor = FileSearchCursor::new();
2081 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2082
2083 assert!(cursor.done);
2084 assert!(matches.is_empty());
2085 }
2086
2087 #[test]
2092 fn test_search_file_binary_extension_skipped_despite_text_content() {
2093 let fs = StdFileSystem;
2094 let temp_dir = tempfile::tempdir().unwrap();
2095 let path = temp_dir.path().join("not_actually_binary.png");
2096 fs.write_file(&path, b"hello world\nhello again\n").unwrap();
2097
2098 let opts = make_search_opts(true);
2099 let mut cursor = FileSearchCursor::new();
2100 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2101
2102 assert!(cursor.done);
2103 assert!(
2104 matches.is_empty(),
2105 ".png extension should short-circuit before content scan"
2106 );
2107 }
2108
2109 #[test]
2113 fn test_search_file_binary_extension_case_insensitive() {
2114 let fs = StdFileSystem;
2115 let temp_dir = tempfile::tempdir().unwrap();
2116 for name in ["IMG.JPG", "archive.tar.gz", "weights.SafeTensors"] {
2117 let path = temp_dir.path().join(name);
2118 fs.write_file(&path, b"definitely text content here\n")
2119 .unwrap();
2120
2121 let opts = make_search_opts(true);
2122 let mut cursor = FileSearchCursor::new();
2123 let matches = fs
2124 .search_file(&path, "definitely", &opts, &mut cursor)
2125 .unwrap();
2126
2127 assert!(cursor.done, "{} should be marked done", name);
2128 assert!(
2129 matches.is_empty(),
2130 "{} matched but extension should have skipped it",
2131 name
2132 );
2133 }
2134 }
2135
2136 #[test]
2142 fn test_search_file_utf16_skipped_via_encoding_gate() {
2143 let fs = StdFileSystem;
2144 let temp_dir = tempfile::tempdir().unwrap();
2145 let path = temp_dir.path().join("utf16.txt");
2146 let mut data = vec![0xFF, 0xFE];
2148 for ch in "hello world\nhello again\n".chars() {
2149 let n = ch as u32;
2150 data.push((n & 0xFF) as u8);
2151 data.push(((n >> 8) & 0xFF) as u8);
2152 }
2153 fs.write_file(&path, &data).unwrap();
2154
2155 let opts = make_search_opts(true);
2156 let mut cursor = FileSearchCursor::new();
2157 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2158
2159 assert!(cursor.done);
2160 assert!(
2161 matches.is_empty(),
2162 "UTF-16 file must be skipped: byte-regex can't match UTF-8 patterns in it"
2163 );
2164 }
2165
2166 #[test]
2172 fn test_search_file_midstream_nul_aborts_scan() {
2173 let fs = StdFileSystem;
2174 let temp_dir = tempfile::tempdir().unwrap();
2175 let path = temp_dir.path().join("mid.dat");
2176
2177 let mut data = vec![b'a'; 9000];
2180 data.push(0);
2181 data.extend_from_slice(b"PATTERN_AFTER_NUL\n");
2182 fs.write_file(&path, &data).unwrap();
2183
2184 let opts = make_search_opts(true);
2185 let mut cursor = FileSearchCursor::new();
2186 let matches = fs
2187 .search_file(&path, "PATTERN_AFTER_NUL", &opts, &mut cursor)
2188 .unwrap();
2189
2190 assert!(cursor.done);
2191 assert!(
2192 matches.is_empty(),
2193 "mid-stream NUL should abort scan and discard pseudo-matches"
2194 );
2195 }
2196
2197 #[test]
2198 fn test_multiline_regex_dotall_implicit() {
2199 let opts = FileSearchOptions {
2201 fixed_string: false,
2202 case_sensitive: true,
2203 whole_word: false,
2204 max_matches: 100,
2205 };
2206 let re = build_search_regex("foo\n.+bar", &opts).unwrap();
2207 assert!(re.is_match(b"foo\nXXXXbar"));
2208 let opts_lit = FileSearchOptions {
2210 fixed_string: true,
2211 ..opts
2212 };
2213 let re_lit = build_search_regex("foo\nbar", &opts_lit).unwrap();
2214 assert!(re_lit.is_match(b"foo\nbar"));
2215 }
2216
2217 #[test]
2218 fn test_search_file_empty_file() {
2219 let fs = StdFileSystem;
2220 let temp_dir = tempfile::tempdir().unwrap();
2221 let path = temp_dir.path().join("empty.txt");
2222 fs.write_file(&path, b"").unwrap();
2223
2224 let opts = make_search_opts(true);
2225 let mut cursor = FileSearchCursor::new();
2226 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2227
2228 assert!(cursor.done);
2229 assert!(matches.is_empty());
2230 }
2231
2232 #[test]
2236 fn test_search_file_oversized_skipped() {
2237 let fs = StdFileSystem;
2238 let temp_dir = tempfile::tempdir().unwrap();
2239 let path = temp_dir.path().join("oversized.txt");
2240
2241 let mut data = vec![b'a'; (MAX_PROJECT_SEARCH_FILE_SIZE as usize) + 1024];
2245 data.extend_from_slice(b"\nUNIQUE_TAIL_MARKER\n");
2246 fs.write_file(&path, &data).unwrap();
2247
2248 let opts = make_search_opts(true);
2249 let mut cursor = FileSearchCursor::new();
2250 let matches = fs
2251 .search_file(&path, "UNIQUE_TAIL_MARKER", &opts, &mut cursor)
2252 .unwrap();
2253
2254 assert!(
2255 cursor.done,
2256 "oversized file should be marked done in one call"
2257 );
2258 assert!(matches.is_empty(), "oversized file should yield no matches");
2259 }
2260
2261 #[test]
2266 fn test_search_file_binary_control_char_skipped() {
2267 let fs = StdFileSystem;
2268 let temp_dir = tempfile::tempdir().unwrap();
2269 let path = temp_dir.path().join("ctrl.dat");
2270 let mut data = b"hello world\n".to_vec();
2273 data.push(0x1A);
2274 data.extend_from_slice(b"hello again\n");
2275 fs.write_file(&path, &data).unwrap();
2276
2277 let opts = make_search_opts(true);
2278 let mut cursor = FileSearchCursor::new();
2279 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
2280
2281 assert!(cursor.done);
2282 assert!(matches.is_empty());
2283 }
2284
2285 #[test]
2286 fn test_search_file_max_matches() {
2287 let fs = StdFileSystem;
2288 let temp_dir = tempfile::tempdir().unwrap();
2289 let path = temp_dir.path().join("test.txt");
2290 fs.write_file(&path, b"aa bb aa cc aa dd aa\n").unwrap();
2291
2292 let opts = FileSearchOptions {
2293 fixed_string: true,
2294 case_sensitive: true,
2295 whole_word: false,
2296 max_matches: 2,
2297 };
2298 let mut cursor = FileSearchCursor::new();
2299 let matches = fs.search_file(&path, "aa", &opts, &mut cursor).unwrap();
2300
2301 assert_eq!(matches.len(), 2);
2302 }
2303
2304 #[test]
2305 fn test_search_file_cursor_multi_chunk() {
2306 let fs = StdFileSystem;
2307 let temp_dir = tempfile::tempdir().unwrap();
2308 let path = temp_dir.path().join("large.txt");
2309
2310 let mut content = Vec::new();
2312 for i in 0..100_000 {
2313 content.extend_from_slice(format!("line {} content here\n", i).as_bytes());
2314 }
2315 fs.write_file(&path, &content).unwrap();
2316
2317 let opts = FileSearchOptions {
2318 fixed_string: true,
2319 case_sensitive: true,
2320 whole_word: false,
2321 max_matches: 1000,
2322 };
2323 let mut cursor = FileSearchCursor::new();
2324 let mut all_matches = Vec::new();
2325
2326 while !cursor.done {
2327 let batch = fs
2328 .search_file(&path, "line 5000", &opts, &mut cursor)
2329 .unwrap();
2330 all_matches.extend(batch);
2331 }
2332
2333 assert_eq!(all_matches.len(), 11);
2336
2337 let first = &all_matches[0];
2339 assert_eq!(first.line, 5001); assert_eq!(first.column, 1);
2341 assert!(first.context.starts_with("line 5000"));
2342 }
2343
2344 #[test]
2345 fn test_search_file_cursor_no_duplicates() {
2346 let fs = StdFileSystem;
2347 let temp_dir = tempfile::tempdir().unwrap();
2348 let path = temp_dir.path().join("large.txt");
2349
2350 let mut content = Vec::new();
2352 for i in 0..100_000 {
2353 content.extend_from_slice(format!("MARKER_{:06}\n", i).as_bytes());
2354 }
2355 fs.write_file(&path, &content).unwrap();
2356
2357 let opts = FileSearchOptions {
2358 fixed_string: true,
2359 case_sensitive: true,
2360 whole_word: false,
2361 max_matches: 200_000,
2362 };
2363 let mut cursor = FileSearchCursor::new();
2364 let mut all_matches = Vec::new();
2365 let mut batches = 0;
2366
2367 while !cursor.done {
2368 let batch = fs
2369 .search_file(&path, "MARKER_", &opts, &mut cursor)
2370 .unwrap();
2371 all_matches.extend(batch);
2372 batches += 1;
2373 }
2374
2375 assert!(batches > 1, "Expected multiple batches, got {}", batches);
2377 assert_eq!(all_matches.len(), 100_000);
2379 let mut offsets: Vec<usize> = all_matches.iter().map(|m| m.byte_offset).collect();
2381 offsets.sort();
2382 offsets.dedup();
2383 assert_eq!(offsets.len(), 100_000);
2384 }
2385
2386 #[test]
2387 fn test_search_file_line_numbers_across_chunks() {
2388 let fs = StdFileSystem;
2389 let temp_dir = tempfile::tempdir().unwrap();
2390 let path = temp_dir.path().join("large.txt");
2391
2392 let mut content = Vec::new();
2394 let total_lines = 100_000;
2395 for i in 0..total_lines {
2396 if i == 99_999 {
2397 content.extend_from_slice(b"FINDME at the end\n");
2398 } else {
2399 content.extend_from_slice(format!("padding line {}\n", i).as_bytes());
2400 }
2401 }
2402 fs.write_file(&path, &content).unwrap();
2403
2404 let opts = make_search_opts(true);
2405 let mut cursor = FileSearchCursor::new();
2406 let mut all_matches = Vec::new();
2407
2408 while !cursor.done {
2409 let batch = fs.search_file(&path, "FINDME", &opts, &mut cursor).unwrap();
2410 all_matches.extend(batch);
2411 }
2412
2413 assert_eq!(all_matches.len(), 1);
2414 assert_eq!(all_matches[0].line, total_lines); assert_eq!(all_matches[0].context, "FINDME at the end");
2416 }
2417
2418 #[test]
2419 fn test_search_file_end_offset_bounds_search() {
2420 let fs = StdFileSystem;
2421 let temp_dir = tempfile::tempdir().unwrap();
2422 let path = temp_dir.path().join("bounded.txt");
2423
2424 fs.write_file(&path, b"AAA\nBBB\nCCC\nDDD\n").unwrap();
2426
2427 let opts = make_search_opts(true);
2429 let mut cursor = FileSearchCursor::for_range(0, 8, 1);
2430 let mut matches = Vec::new();
2431 while !cursor.done {
2432 matches.extend(fs.search_file(&path, "AAA", &opts, &mut cursor).unwrap());
2433 }
2434 assert_eq!(matches.len(), 1);
2435 assert_eq!(matches[0].context, "AAA");
2436 assert_eq!(matches[0].line, 1);
2437
2438 let mut cursor = FileSearchCursor::for_range(0, 8, 1);
2440 let ccc = fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap();
2441 assert!(ccc.is_empty(), "CCC should not be found in first 8 bytes");
2442
2443 let mut cursor = FileSearchCursor::for_range(8, 16, 3);
2445 let mut matches = Vec::new();
2446 while !cursor.done {
2447 matches.extend(fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap());
2448 }
2449 assert_eq!(matches.len(), 1);
2450 assert_eq!(matches[0].context, "CCC");
2451 assert_eq!(matches[0].line, 3);
2452 }
2453
2454 fn make_walk_tree() -> tempfile::TempDir {
2461 let fs = StdFileSystem;
2462 let tmp = tempfile::tempdir().unwrap();
2463 let root = tmp.path();
2464
2465 fs.write_file(&root.join("a.txt"), b"a").unwrap();
2480 fs.write_file(&root.join("b.txt"), b"b").unwrap();
2481 fs.create_dir_all(&root.join("sub/deep")).unwrap();
2482 fs.write_file(&root.join("sub/c.txt"), b"c").unwrap();
2483 fs.write_file(&root.join("sub/deep/d.txt"), b"d").unwrap();
2484 fs.create_dir_all(&root.join(".hidden_dir")).unwrap();
2485 fs.write_file(&root.join(".hidden_dir/secret.txt"), b"s")
2486 .unwrap();
2487 fs.write_file(&root.join(".hidden_file"), b"h").unwrap();
2488 fs.create_dir_all(&root.join("node_modules")).unwrap();
2489 fs.write_file(&root.join("node_modules/pkg.json"), b"{}")
2490 .unwrap();
2491 fs.create_dir_all(&root.join("target")).unwrap();
2492 fs.write_file(&root.join("target/debug.o"), b"elf").unwrap();
2493
2494 tmp
2495 }
2496
2497 #[test]
2498 fn test_walk_files_std_basic() {
2499 let tmp = make_walk_tree();
2500 let fs = StdFileSystem;
2501 let cancel = std::sync::atomic::AtomicBool::new(false);
2502 let mut found: Vec<String> = Vec::new();
2503
2504 fs.walk_files(
2505 tmp.path(),
2506 &["node_modules", "target"],
2507 &cancel,
2508 &mut |_path, rel| {
2509 found.push(rel.to_string());
2510 true
2511 },
2512 )
2513 .unwrap();
2514
2515 found.sort();
2516 assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt", "sub/deep/d.txt"]);
2517 }
2518
2519 #[test]
2520 fn test_walk_files_std_skips_hidden() {
2521 let tmp = make_walk_tree();
2522 let fs = StdFileSystem;
2523 let cancel = std::sync::atomic::AtomicBool::new(false);
2524 let mut found: Vec<String> = Vec::new();
2525
2526 fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2527 found.push(rel.to_string());
2528 true
2529 })
2530 .unwrap();
2531
2532 assert!(!found.iter().any(|f| f.contains(".hidden")));
2535 assert!(found.iter().any(|f| f.contains("node_modules")));
2536 assert!(found.iter().any(|f| f.contains("target")));
2537 }
2538
2539 #[test]
2540 fn test_walk_files_std_skip_dirs() {
2541 let tmp = make_walk_tree();
2542 let fs = StdFileSystem;
2543 let cancel = std::sync::atomic::AtomicBool::new(false);
2544 let mut found: Vec<String> = Vec::new();
2545
2546 fs.walk_files(
2547 tmp.path(),
2548 &["node_modules", "target", "deep"],
2549 &cancel,
2550 &mut |_path, rel| {
2551 found.push(rel.to_string());
2552 true
2553 },
2554 )
2555 .unwrap();
2556
2557 found.sort();
2558 assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt"]);
2560 }
2561
2562 #[test]
2563 fn test_walk_files_std_cancel() {
2564 let tmp = make_walk_tree();
2565 let fs = StdFileSystem;
2566 let cancel = std::sync::atomic::AtomicBool::new(false);
2567 let mut found: Vec<String> = Vec::new();
2568
2569 fs.walk_files(
2570 tmp.path(),
2571 &["node_modules", "target"],
2572 &cancel,
2573 &mut |_path, rel| {
2574 found.push(rel.to_string());
2575 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
2577 true
2578 },
2579 )
2580 .unwrap();
2581
2582 assert_eq!(found.len(), 1, "Should stop after cancel is set");
2583 }
2584
2585 #[test]
2586 fn test_walk_files_std_on_file_returns_false() {
2587 let tmp = make_walk_tree();
2588 let fs = StdFileSystem;
2589 let cancel = std::sync::atomic::AtomicBool::new(false);
2590 let mut count = 0usize;
2591
2592 fs.walk_files(
2593 tmp.path(),
2594 &["node_modules", "target"],
2595 &cancel,
2596 &mut |_path, _rel| {
2597 count += 1;
2598 count < 2 },
2600 )
2601 .unwrap();
2602
2603 assert_eq!(count, 2, "Should stop when on_file returns false");
2604 }
2605
2606 #[test]
2607 fn test_walk_files_std_empty_dir() {
2608 let tmp = tempfile::tempdir().unwrap();
2609 let fs = StdFileSystem;
2610 let cancel = std::sync::atomic::AtomicBool::new(false);
2611 let mut found: Vec<String> = Vec::new();
2612
2613 fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2614 found.push(rel.to_string());
2615 true
2616 })
2617 .unwrap();
2618
2619 assert!(found.is_empty());
2620 }
2621
2622 #[test]
2623 fn test_walk_files_std_nonexistent_root() {
2624 let fs = StdFileSystem;
2625 let cancel = std::sync::atomic::AtomicBool::new(false);
2626 let mut found: Vec<String> = Vec::new();
2627
2628 let result = fs.walk_files(
2630 Path::new("/nonexistent/path/that/does/not/exist"),
2631 &[],
2632 &cancel,
2633 &mut |_path, rel| {
2634 found.push(rel.to_string());
2635 true
2636 },
2637 );
2638
2639 assert!(result.is_ok());
2640 assert!(found.is_empty());
2641 }
2642
2643 #[test]
2644 fn test_walk_files_std_relative_paths_use_forward_slashes() {
2645 let tmp = make_walk_tree();
2646 let fs = StdFileSystem;
2647 let cancel = std::sync::atomic::AtomicBool::new(false);
2648 let mut found: Vec<String> = Vec::new();
2649
2650 fs.walk_files(
2651 tmp.path(),
2652 &["node_modules", "target"],
2653 &cancel,
2654 &mut |_path, rel| {
2655 found.push(rel.to_string());
2656 true
2657 },
2658 )
2659 .unwrap();
2660
2661 for path in &found {
2663 assert!(!path.contains('\\'), "Path should use / not \\: {}", path);
2664 }
2665 }
2666
2667 #[test]
2668 fn test_walk_files_noop_returns_error() {
2669 let fs = NoopFileSystem;
2670 let cancel = std::sync::atomic::AtomicBool::new(false);
2671
2672 let result = fs.walk_files(Path::new("/noop/path"), &[], &cancel, &mut |_path, _rel| {
2673 true
2674 });
2675
2676 assert!(result.is_err());
2677 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
2678 }
2679
2680 #[test]
2686 #[cfg(unix)]
2687 fn test_is_writable_matches_kernel_for_owner_writable() {
2688 use std::os::unix::ffi::OsStrExt;
2689 let fs = StdFileSystem;
2690 let temp_dir = tempfile::tempdir().unwrap();
2691 let path = temp_dir.path().join("writable.txt");
2692 fs.write_file(&path, b"x").unwrap();
2693 fs.set_permissions(&path, &FilePermissions::from_mode(0o600))
2694 .unwrap();
2695
2696 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
2697 let kernel_writable = unsafe {
2698 libc::faccessat(
2699 libc::AT_FDCWD,
2700 c_path.as_ptr(),
2701 libc::W_OK,
2702 libc::AT_EACCESS,
2703 )
2704 } == 0;
2705 assert!(
2706 kernel_writable,
2707 "owner-writable file must be writable per kernel"
2708 );
2709 assert_eq!(fs.is_writable(&path), kernel_writable);
2710 }
2711
2712 #[test]
2730 #[ignore = "requires root + setfacl; see test docstring"]
2731 #[cfg(target_os = "linux")]
2732 fn test_is_writable_respects_posix_acl() {
2733 use std::os::unix::ffi::OsStrExt;
2734 use std::process::Command;
2735
2736 if unsafe { libc::geteuid() } != 0 {
2738 panic!("test must be run as root (need to chown to a foreign uid)");
2739 }
2740 let setfacl_ok = Command::new("setfacl").arg("--version").output().is_ok();
2741 assert!(setfacl_ok, "setfacl must be installed");
2742
2743 let test_uid: libc::uid_t = 65534;
2746 let test_gid: libc::gid_t = 65534;
2747 let foreign_uid: libc::uid_t = 9999;
2751 let foreign_gid: libc::gid_t = 9999;
2752
2753 let temp_dir = tempfile::tempdir().unwrap();
2754 std::fs::set_permissions(
2756 temp_dir.path(),
2757 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o755),
2758 )
2759 .unwrap();
2760
2761 let file = temp_dir.path().join("acl_test.txt");
2762 std::fs::write(&file, b"hi").unwrap();
2763
2764 let c_file = std::ffi::CString::new(file.as_os_str().as_bytes()).unwrap();
2765 let r = unsafe { libc::chown(c_file.as_ptr(), foreign_uid, foreign_gid) };
2767 assert_eq!(r, 0, "chown failed: {}", io::Error::last_os_error());
2768 std::fs::set_permissions(
2769 &file,
2770 <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
2771 )
2772 .unwrap();
2773
2774 let acl_status = Command::new("setfacl")
2775 .args(["-m", &format!("u:{test_uid}:rw")])
2776 .arg(&file)
2777 .status()
2778 .unwrap();
2779 assert!(
2780 acl_status.success(),
2781 "setfacl failed (does the filesystem support ACLs?)",
2782 );
2783
2784 let pid = unsafe { libc::fork() };
2791 if pid < 0 {
2792 panic!("fork failed: {}", io::Error::last_os_error());
2793 }
2794 if pid == 0 {
2795 if unsafe { libc::setgid(test_gid) } != 0 {
2798 unsafe { libc::_exit(2) };
2799 }
2800 if unsafe { libc::setuid(test_uid) } != 0 {
2801 unsafe { libc::_exit(3) };
2802 }
2803 let writable = StdFileSystem.is_writable(&file);
2804 unsafe { libc::_exit(if writable { 0 } else { 1 }) };
2805 }
2806
2807 let mut status: libc::c_int = 0;
2809 let r = unsafe { libc::waitpid(pid, &mut status, 0) };
2811 assert!(r > 0, "waitpid failed: {}", io::Error::last_os_error());
2812 let exited_normally = (status & 0x7f) == 0;
2813 let exit_code = (status >> 8) & 0xff;
2814 assert!(
2815 exited_normally,
2816 "child terminated abnormally; status={status}"
2817 );
2818 assert_eq!(
2819 exit_code, 0,
2820 "child reported file NOT writable (exit_code={exit_code}); ACL was ignored",
2821 );
2822 }
2823}