1#[cfg(all(test, target_os = "linux"))]
7use std::cell::RefCell;
8#[cfg(target_os = "linux")]
9use std::ffi::{CStr, CString};
10#[cfg(target_os = "linux")]
11use std::io;
12#[cfg(target_os = "linux")]
13use std::mem::{self, MaybeUninit};
14#[cfg(target_os = "linux")]
15use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
16#[cfg(target_os = "linux")]
17use std::os::unix::ffi::OsStrExt;
18#[cfg(target_os = "linux")]
19use std::path::{Path, PathBuf};
20
21#[cfg(target_os = "linux")]
22use sweepx_model::{DecimalU128, NativeName, ReasonCode};
23#[cfg(target_os = "linux")]
24use sweepx_platform::{
25 BoundaryKind, BoundaryRecord, BoundedRegularFileReadError, BoundedRegularFileReadRequest,
26 EntryIdentity, EntryKind, ErrorRecord, FilesystemIdentity, HardLinkKey, MountIdentity,
27 OpenedDirectory, PresentRegularFileRead, RegularFileChangeStamp, RegularFileIdentityMismatch,
28 RegularFileMountMismatch, RegularFileObservation, RegularFileReadExpectation,
29 error_kind_for_io, fingerprint_for, known_count, known_u128, reason_for_io,
30};
31use sweepx_platform::{
32 CancellationToken, DirectoryEntryBatch, DirectoryEntryRecord, DirectoryHandleAdmission,
33 DirectoryReadLimits, EntryMetadata, PlatformError, PlatformScanner, RootAdmission, ScanRoot,
34 WalkEntry,
35};
36
37#[cfg(all(test, target_os = "linux"))]
40mod trash_qualification;
41
42#[derive(Debug, Default, Clone)]
43pub struct LinuxPlatformScanner;
44
45impl LinuxPlatformScanner {
46 pub fn new() -> Self {
47 Self
48 }
49}
50
51#[cfg(not(target_os = "linux"))]
56#[derive(Debug)]
57pub struct LinuxUnavailableDirectory;
58
59#[cfg(target_os = "linux")]
65#[derive(Debug)]
66pub struct LinuxDirectoryHandle {
67 fd: OwnedFd,
68 display_path: PathBuf,
69 cursor: DirectoryCursor,
70}
71
72#[cfg(target_os = "linux")]
73#[derive(Debug)]
74enum DirectoryCursor {
75 NotStarted,
76 Active {
77 stream: DirectoryStream,
78 pending: Option<DirectoryEntryRecord>,
79 },
80}
81
82#[cfg(target_os = "linux")]
83#[derive(Debug)]
84struct DirectoryStream(*mut libc::DIR);
85
86#[cfg(target_os = "linux")]
90unsafe impl Send for DirectoryStream {}
91
92#[cfg(target_os = "linux")]
93impl Drop for DirectoryStream {
94 fn drop(&mut self) {
95 unsafe {
98 libc::closedir(self.0);
99 }
100 }
101}
102
103#[cfg(target_os = "linux")]
104impl LinuxPlatformScanner {
105 const REGULAR_FILE_READ_CHUNK_BYTES: usize = 8192;
106
107 fn ensure_not_cancelled(cancel: &CancellationToken) -> Result<(), PlatformError> {
108 if cancel.is_cancelled() {
109 return Err(PlatformError::Cancelled);
110 }
111 Ok(())
112 }
113
114 fn ensure_regular_file_read_not_cancelled(
115 cancel: &CancellationToken,
116 ) -> Result<(), BoundedRegularFileReadError> {
117 if cancel.is_cancelled() {
118 return Err(BoundedRegularFileReadError::Cancelled);
119 }
120 Ok(())
121 }
122
123 fn native_name(path: &Path) -> NativeName {
124 NativeName::unix(
125 path.file_name()
126 .unwrap_or(path.as_os_str())
127 .as_bytes()
128 .to_vec(),
129 )
130 }
131
132 fn path_c_string(path: &Path) -> Result<CString, io::Error> {
133 CString::new(path.as_os_str().as_bytes()).map_err(|_| {
134 io::Error::new(io::ErrorKind::InvalidInput, "path contains an interior NUL")
135 })
136 }
137
138 fn child_name_c_string(name: &NativeName) -> Result<CString, io::Error> {
139 let NativeName::UnixBytes(bytes) = name else {
140 return Err(io::Error::new(
141 io::ErrorKind::InvalidInput,
142 "linux child name is not encoded as Unix bytes",
143 ));
144 };
145 if bytes.is_empty()
146 || bytes == b"."
147 || bytes == b".."
148 || bytes.contains(&b'/')
149 || bytes.contains(&0)
150 {
151 return Err(io::Error::new(
152 io::ErrorKind::InvalidInput,
153 "child token is not a single safe basename",
154 ));
155 }
156 CString::new(bytes.as_slice()).map_err(|_| {
157 io::Error::new(
158 io::ErrorKind::InvalidInput,
159 "child name contains an interior NUL",
160 )
161 })
162 }
163
164 fn open_root(path: &Path) -> Result<OwnedFd, io::Error> {
165 let path = Self::path_c_string(path)?;
166 let mut how: libc::open_how = unsafe { mem::zeroed() };
169 how.flags =
170 u64::try_from(libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
171 .expect("open flags fit u64");
172 how.resolve = libc::RESOLVE_NO_SYMLINKS | libc::RESOLVE_NO_MAGICLINKS;
173 let raw = unsafe {
175 libc::syscall(
176 libc::SYS_openat2,
177 libc::AT_FDCWD,
178 path.as_ptr(),
179 &how,
180 mem::size_of::<libc::open_how>(),
181 ) as libc::c_int
182 };
183 Self::owned_fd(raw)
184 }
185
186 fn pin_child(parent: &OwnedFd, name: &CStr) -> Result<OwnedFd, io::Error> {
187 let mut how: libc::open_how = unsafe { mem::zeroed() };
194 how.flags = u64::try_from(libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC)
195 .expect("open flags fit u64");
196 how.resolve = libc::RESOLVE_BENEATH | libc::RESOLVE_NO_MAGICLINKS;
197 let raw = unsafe {
200 libc::syscall(
201 libc::SYS_openat2,
202 parent.as_raw_fd(),
203 name.as_ptr(),
204 &how,
205 mem::size_of::<libc::open_how>(),
206 ) as libc::c_int
207 };
208 Self::owned_fd(raw)
209 }
210
211 fn open_child_directory(parent: &OwnedFd, name: &CStr) -> Result<OwnedFd, io::Error> {
212 let mut how: libc::open_how = unsafe { mem::zeroed() };
215 how.flags =
216 u64::try_from(libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
217 .expect("open flags fit u64");
218 how.resolve = libc::RESOLVE_BENEATH
219 | libc::RESOLVE_NO_SYMLINKS
220 | libc::RESOLVE_NO_MAGICLINKS
221 | libc::RESOLVE_NO_XDEV;
222 let raw = unsafe {
225 libc::syscall(
226 libc::SYS_openat2,
227 parent.as_raw_fd(),
228 name.as_ptr(),
229 &how,
230 mem::size_of::<libc::open_how>(),
231 ) as libc::c_int
232 };
233 Self::owned_fd(raw)
234 }
235
236 fn open_child_regular_file(parent: &OwnedFd, name: &CStr) -> Result<OwnedFd, io::Error> {
237 let mut how: libc::open_how = unsafe { mem::zeroed() };
240 how.flags =
241 u64::try_from(libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
242 .expect("open flags fit u64");
243 how.resolve = libc::RESOLVE_BENEATH
244 | libc::RESOLVE_NO_SYMLINKS
245 | libc::RESOLVE_NO_MAGICLINKS
246 | libc::RESOLVE_NO_XDEV;
247 let raw = unsafe {
250 libc::syscall(
251 libc::SYS_openat2,
252 parent.as_raw_fd(),
253 name.as_ptr(),
254 &how,
255 mem::size_of::<libc::open_how>(),
256 ) as libc::c_int
257 };
258 Self::owned_fd(raw)
259 }
260
261 fn open_matching_child_directory(
262 parent: &OwnedFd,
263 name: &CStr,
264 pinned_stat: &libc::stat,
265 pinned_mount_id: u64,
266 ) -> Result<OwnedFd, io::Error> {
267 let directory = Self::open_child_directory(parent, name)?;
268 let directory_stat = Self::fstat(&directory)?;
269 let directory_mount_id = Self::mount_id_for_fd(&directory)?;
270 if !Self::same_object(pinned_stat, &directory_stat) || pinned_mount_id != directory_mount_id
271 {
272 return Err(io::Error::other(
273 "directory entry changed between pinning and enumerable open",
274 ));
275 }
276 Ok(directory)
277 }
278
279 fn owned_fd(raw: libc::c_int) -> Result<OwnedFd, io::Error> {
280 if raw < 0 {
281 return Err(io::Error::last_os_error());
282 }
283 Ok(unsafe { OwnedFd::from_raw_fd(raw) })
285 }
286
287 fn duplicate_fd(fd: &OwnedFd) -> Result<OwnedFd, io::Error> {
288 let raw = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) };
290 Self::owned_fd(raw)
291 }
292
293 fn fstat(fd: &OwnedFd) -> Result<libc::stat, io::Error> {
294 let mut output = MaybeUninit::<libc::stat>::uninit();
295 if unsafe { libc::fstat(fd.as_raw_fd(), output.as_mut_ptr()) } != 0 {
297 return Err(io::Error::last_os_error());
298 }
299 Ok(unsafe { output.assume_init() })
301 }
302
303 fn statx_for_fd_with_mask(fd: &OwnedFd, mask: libc::c_uint) -> Result<libc::statx, io::Error> {
304 let mut output = MaybeUninit::<libc::statx>::zeroed();
305 if unsafe {
309 libc::statx(
310 fd.as_raw_fd(),
311 c"".as_ptr(),
312 libc::AT_EMPTY_PATH | libc::AT_SYMLINK_NOFOLLOW,
313 mask,
314 output.as_mut_ptr(),
315 )
316 } != 0
317 {
318 return Err(io::Error::last_os_error());
319 }
320 let output = unsafe { output.assume_init() };
322 if output.stx_mask & mask != mask {
323 return Err(io::Error::new(
324 io::ErrorKind::Unsupported,
325 format!(
326 "statx omitted required regular-file fields: mask=0x{:x}",
327 output.stx_mask
328 ),
329 ));
330 }
331 Ok(output)
332 }
333
334 fn statx_for_fd(fd: &OwnedFd) -> Result<libc::statx, io::Error> {
335 Self::statx_for_fd_with_mask(fd, libc::STATX_BASIC_STATS | libc::STATX_MNT_ID)
336 }
337
338 fn mount_id_for_fd(fd: &OwnedFd) -> Result<u64, io::Error> {
339 Ok(Self::statx_for_fd_with_mask(fd, libc::STATX_MNT_ID)?.stx_mnt_id)
340 }
341
342 fn kind_from_mode(mode: libc::mode_t) -> EntryKind {
343 match mode & libc::S_IFMT {
344 libc::S_IFDIR => EntryKind::Directory,
345 libc::S_IFREG => EntryKind::File,
346 libc::S_IFLNK => EntryKind::Symlink,
347 _ => EntryKind::Other,
348 }
349 }
350
351 fn metadata_to_entry(
352 path: &Path,
353 file_name: NativeName,
354 stat: &libc::stat,
355 mount_id: u64,
356 ) -> EntryMetadata {
357 let kind = Self::kind_from_mode(stat.st_mode);
358 let logical_bytes = if kind == EntryKind::File {
359 known_u128(stat.st_size.max(0) as u128)
360 } else {
361 known_u128(0)
362 };
363 let allocated_bytes = if kind == EntryKind::File {
364 known_u128(stat.st_blocks.max(0) as u128 * 512)
365 } else {
366 known_u128(0)
367 };
368 let device = stat.st_dev;
369 let inode = stat.st_ino;
370 let identity = EntryIdentity::from_unix(device, inode);
371 let filesystem_identity = Some(FilesystemIdentity { device });
372 let hard_link_key = (kind == EntryKind::File).then(|| HardLinkKey::from(identity.clone()));
373 let fingerprint = fingerprint_for(Some(&identity), &kind, &logical_bytes);
374
375 EntryMetadata {
376 path: path.to_path_buf(),
377 file_name,
378 kind,
379 logical_bytes,
380 allocated_bytes,
381 hard_link_count: known_count(stat.st_nlink as u128),
382 fingerprint,
383 identity: Some(identity),
384 filesystem_identity,
385 mount_identity: Some(MountIdentity { value: mount_id }),
386 hard_link_key,
387 }
388 }
389
390 fn error_entry(path: &Path, error: io::Error) -> WalkEntry<LinuxDirectoryHandle> {
391 WalkEntry::Error(ErrorRecord {
392 path: path.to_path_buf(),
393 kind: error_kind_for_io(&error),
394 reason: reason_for_io(&error),
395 detail: error.to_string(),
396 })
397 }
398
399 fn same_object(left: &libc::stat, right: &libc::stat) -> bool {
400 left.st_dev == right.st_dev
401 && left.st_ino == right.st_ino
402 && Self::kind_from_mode(left.st_mode) == Self::kind_from_mode(right.st_mode)
403 }
404
405 fn regular_file_change_stamp(statx: &libc::statx) -> RegularFileChangeStamp {
406 let mut bytes = Vec::with_capacity(
407 mem::size_of_val(&statx.stx_mtime.tv_sec)
408 + mem::size_of_val(&statx.stx_mtime.tv_nsec)
409 + mem::size_of_val(&statx.stx_ctime.tv_sec)
410 + mem::size_of_val(&statx.stx_ctime.tv_nsec),
411 );
412 bytes.extend_from_slice(&statx.stx_mtime.tv_sec.to_le_bytes());
413 bytes.extend_from_slice(&statx.stx_mtime.tv_nsec.to_le_bytes());
414 bytes.extend_from_slice(&statx.stx_ctime.tv_sec.to_le_bytes());
415 bytes.extend_from_slice(&statx.stx_ctime.tv_nsec.to_le_bytes());
416 RegularFileChangeStamp::new(bytes)
417 }
418
419 fn observe_regular_file_fd(fd: &OwnedFd) -> Result<RegularFileObservation, io::Error> {
420 let statx = Self::statx_for_fd(fd)?;
421 let device = libc::makedev(statx.stx_dev_major, statx.stx_dev_minor) as u64;
422 Ok(RegularFileObservation {
423 kind: Self::kind_from_mode(statx.stx_mode.into()),
424 identity: EntryIdentity::from_unix(device, statx.stx_ino),
425 filesystem_identity: FilesystemIdentity { device },
426 mount_identity: MountIdentity {
427 value: statx.stx_mnt_id,
428 },
429 logical_bytes: DecimalU128::new(u128::from(statx.stx_size)),
430 change_stamp: Self::regular_file_change_stamp(&statx),
431 })
432 }
433
434 fn classify_pinned_regular_file(
435 pinned: &OwnedFd,
436 ) -> Result<RegularFileObservation, BoundedRegularFileReadError> {
437 let observed = Self::observe_regular_file_fd(pinned).map_err(|error| {
438 if error.kind() == io::ErrorKind::Unsupported {
439 BoundedRegularFileReadError::Unsupported(error.to_string())
440 } else {
441 BoundedRegularFileReadError::io(error)
442 }
443 })?;
444 match observed.kind {
445 EntryKind::File => Ok(observed),
446 EntryKind::Symlink | EntryKind::ReparsePoint => {
447 Err(BoundedRegularFileReadError::SymlinkOrReparse {
448 observed_kind: observed.kind,
449 })
450 }
451 _ => Err(BoundedRegularFileReadError::NotRegular {
452 observed_kind: observed.kind,
453 }),
454 }
455 }
456
457 fn map_regular_file_open_error(error: io::Error) -> BoundedRegularFileReadError {
458 match error.raw_os_error() {
459 Some(libc::ENOENT) => BoundedRegularFileReadError::NotFound,
460 Some(libc::ELOOP) => BoundedRegularFileReadError::SymlinkOrReparse {
461 observed_kind: EntryKind::Symlink,
462 },
463 Some(libc::ENOSYS) => BoundedRegularFileReadError::Unsupported(
464 "openat2 is required for safe Linux bounded regular-file reads".to_string(),
465 ),
466 _ => BoundedRegularFileReadError::io(error),
467 }
468 }
469
470 fn map_regular_file_read_error(error: io::Error) -> BoundedRegularFileReadError {
471 match error.raw_os_error() {
472 Some(libc::ENODEV) | Some(libc::ENXIO) => {
473 BoundedRegularFileReadError::ProviderOrOffline(error.to_string())
474 }
475 Some(libc::EOPNOTSUPP) => BoundedRegularFileReadError::Unsupported(error.to_string()),
476 _ => BoundedRegularFileReadError::io(error),
477 }
478 }
479
480 fn validate_regular_file_unchanged(
481 observed_before: &RegularFileObservation,
482 observed_after: &RegularFileObservation,
483 ) -> Result<(), BoundedRegularFileReadError> {
484 if observed_before.mount_identity != observed_after.mount_identity {
485 return Err(BoundedRegularFileReadError::MountMismatch(Box::new(
486 RegularFileMountMismatch {
487 expected: None,
488 observed_mount_identity: observed_after.mount_identity.clone(),
489 },
490 )));
491 }
492 if observed_before.identity != observed_after.identity
493 || observed_before.filesystem_identity != observed_after.filesystem_identity
494 {
495 return Err(BoundedRegularFileReadError::IdentityMismatch(Box::new(
496 RegularFileIdentityMismatch {
497 expected: None,
498 observed_identity: observed_after.identity.clone(),
499 observed_filesystem_identity: observed_after.filesystem_identity.clone(),
500 },
501 )));
502 }
503 if observed_before.logical_bytes != observed_after.logical_bytes
504 || observed_before.change_stamp != observed_after.change_stamp
505 {
506 return Err(BoundedRegularFileReadError::ChangedDuringRead(Box::new(
507 sweepx_platform::RegularFileObservationMismatch {
508 observed_before: observed_before.clone(),
509 observed_after: observed_after.clone(),
510 },
511 )));
512 }
513 Ok(())
514 }
515
516 fn compare_expected_regular_file(
517 request: &BoundedRegularFileReadRequest,
518 observed_before: &RegularFileObservation,
519 ) -> Result<(), BoundedRegularFileReadError> {
520 let RegularFileReadExpectation::PreviouslyObserved(expected) = request.expectation() else {
521 return Ok(());
522 };
523 if expected.mount_identity() != &observed_before.mount_identity {
524 return Err(BoundedRegularFileReadError::MountMismatch(Box::new(
525 RegularFileMountMismatch {
526 expected: Some(expected.clone()),
527 observed_mount_identity: observed_before.mount_identity.clone(),
528 },
529 )));
530 }
531 if expected.identity() != &observed_before.identity
532 || expected.filesystem_identity() != &observed_before.filesystem_identity
533 {
534 return Err(BoundedRegularFileReadError::IdentityMismatch(Box::new(
535 RegularFileIdentityMismatch {
536 expected: Some(expected.clone()),
537 observed_identity: observed_before.identity.clone(),
538 observed_filesystem_identity: observed_before.filesystem_identity.clone(),
539 },
540 )));
541 }
542 Ok(())
543 }
544
545 fn compare_pinned_and_opened_regular_file(
546 pinned: &RegularFileObservation,
547 observed_before: &RegularFileObservation,
548 ) -> Result<(), BoundedRegularFileReadError> {
549 if pinned.mount_identity != observed_before.mount_identity {
550 return Err(BoundedRegularFileReadError::MountMismatch(Box::new(
551 RegularFileMountMismatch {
552 expected: None,
553 observed_mount_identity: observed_before.mount_identity.clone(),
554 },
555 )));
556 }
557 if pinned.identity != observed_before.identity
558 || pinned.filesystem_identity != observed_before.filesystem_identity
559 {
560 return Err(BoundedRegularFileReadError::IdentityMismatch(Box::new(
561 RegularFileIdentityMismatch {
562 expected: None,
563 observed_identity: observed_before.identity.clone(),
564 observed_filesystem_identity: observed_before.filesystem_identity.clone(),
565 },
566 )));
567 }
568 Ok(())
569 }
570
571 fn observed_limit_exceeded(
572 request: &BoundedRegularFileReadRequest,
573 observed_logical_bytes: DecimalU128,
574 ) -> BoundedRegularFileReadError {
575 BoundedRegularFileReadError::LimitExceeded {
576 max_bytes: request.max_bytes(),
577 observed_logical_bytes,
578 }
579 }
580
581 #[cfg(all(test, target_os = "linux"))]
582 fn run_regular_file_read_test_hook(
583 event: RegularFileReadTestHookEvent,
584 cancel: &CancellationToken,
585 ) {
586 let hook = REGULAR_FILE_READ_TEST_HOOK.with(|slot| {
587 let mut slot = slot.borrow_mut();
588 match slot.as_ref() {
589 Some(installed) if installed.event == event => slot.take(),
590 _ => None,
591 }
592 });
593 match hook.map(|hook| hook.action) {
594 Some(RegularFileReadTestHookAction::Cancel) => cancel.cancel(),
595 Some(RegularFileReadTestHookAction::RewriteBytes { path, bytes }) => {
596 std::fs::write(path, bytes).unwrap();
597 }
598 None => {}
599 }
600 }
601}
602
603#[cfg(target_os = "linux")]
604impl PlatformScanner for LinuxPlatformScanner {
605 type DirectoryHandle = LinuxDirectoryHandle;
606
607 fn platform_name(&self) -> &'static str {
608 "linux"
609 }
610
611 fn admit_root(
612 &self,
613 root: &ScanRoot,
614 cancel: &CancellationToken,
615 ) -> Result<RootAdmission<Self::DirectoryHandle>, PlatformError> {
616 Self::ensure_not_cancelled(cancel)?;
617 if !root.path().is_absolute() {
618 return Err(PlatformError::RootRejected(format!(
619 "root is not absolute: {}",
620 root.path().display()
621 )));
622 }
623 let root_locator = root
624 .native_absolute_path()
625 .map_err(|error| PlatformError::RootRejected(error.to_string()))?;
626
627 let fd = Self::open_root(root.path()).map_err(|error| {
628 if matches!(
629 error.raw_os_error(),
630 Some(libc::ELOOP) | Some(libc::ENOTDIR)
631 ) {
632 PlatformError::RootRejected(format!(
633 "root or an intermediate component is not a real directory: {}",
634 root.path().display()
635 ))
636 } else if error.raw_os_error() == Some(libc::ENOSYS) {
637 PlatformError::Unsupported(
638 "openat2 is required for safe Linux root admission".to_string(),
639 )
640 } else {
641 PlatformError::io(root.path(), error)
642 }
643 })?;
644 Self::ensure_not_cancelled(cancel)?;
645 let stat = Self::fstat(&fd).map_err(|error| PlatformError::io(root.path(), error))?;
646 if Self::kind_from_mode(stat.st_mode) != EntryKind::Directory {
647 return Err(PlatformError::RootRejected(format!(
648 "root is not a directory: {}",
649 root.path().display()
650 )));
651 }
652 let mount_id = Self::mount_id_for_fd(&fd).map_err(|error| {
653 PlatformError::Unsupported(format!(
654 "statx mount identity unavailable for {}: {error}",
655 root.path().display()
656 ))
657 })?;
658 let metadata =
659 Self::metadata_to_entry(root.path(), Self::native_name(root.path()), &stat, mount_id);
660 Ok(RootAdmission::new(
661 root.clone(),
662 metadata,
663 LinuxDirectoryHandle {
664 fd,
665 display_path: root.path().to_path_buf(),
666 cursor: DirectoryCursor::NotStarted,
667 },
668 root_locator,
669 ))
670 }
671
672 fn enumerate_children(
673 &self,
674 directory: &mut Self::DirectoryHandle,
675 cancel: &CancellationToken,
676 limits: DirectoryReadLimits,
677 ) -> Result<DirectoryEntryBatch, PlatformError> {
678 Self::ensure_not_cancelled(cancel)?;
679 if limits.max_batch_entries == 0 || limits.max_batch_bytes == 0 {
680 return Err(PlatformError::ResourceLimit(format!(
681 "directory batch limits must be nonzero at {}",
682 directory.display_path.display()
683 )));
684 }
685 if matches!(directory.cursor, DirectoryCursor::NotStarted) {
686 let duplicate = Self::duplicate_fd(&directory.fd)
687 .map_err(|error| PlatformError::io(&directory.display_path, error))?;
688 let raw = duplicate.as_raw_fd();
689 let stream = unsafe { libc::fdopendir(raw) };
692 if stream.is_null() {
693 return Err(PlatformError::io(
694 &directory.display_path,
695 io::Error::last_os_error(),
696 ));
697 }
698 mem::forget(duplicate);
699 directory.cursor = DirectoryCursor::Active {
700 stream: DirectoryStream(stream),
701 pending: None,
702 };
703 }
704 let DirectoryCursor::Active { stream, pending } = &mut directory.cursor else {
705 unreachable!("nonterminal directory cursor must be active");
706 };
707 let mut entries = Vec::new();
708 let mut retained_bytes = 0usize;
709
710 loop {
711 Self::ensure_not_cancelled(cancel)?;
712 let child = if let Some(child) = pending.take() {
713 child
714 } else {
715 loop {
716 unsafe {
719 *libc::__errno_location() = 0;
720 }
721 let raw_entry = unsafe { libc::readdir(stream.0) };
723 if raw_entry.is_null() {
724 let error = io::Error::last_os_error();
725 if error.raw_os_error() == Some(0) {
726 return Ok(DirectoryEntryBatch::complete(entries));
727 }
728 return Err(PlatformError::io(&directory.display_path, error));
729 }
730 let name = unsafe { CStr::from_ptr((*raw_entry).d_name.as_ptr()) }.to_bytes();
732 if name == b"." || name == b".." {
733 continue;
734 }
735 break DirectoryEntryRecord::from_parent_and_name(
736 &directory.display_path,
737 NativeName::unix(name.to_vec()),
738 )
739 .map_err(|error| PlatformError::InvalidDirectoryEntry {
740 parent: directory.display_path.clone(),
741 detail: error.to_string(),
742 })?;
743 }
744 };
745 let record_bytes = child.estimated_retained_bytes().ok_or_else(|| {
746 PlatformError::ResourceLimit(format!(
747 "directory byte accounting overflow at {}",
748 directory.display_path.display()
749 ))
750 })?;
751 let next_bytes = retained_bytes.checked_add(record_bytes).ok_or_else(|| {
752 PlatformError::ResourceLimit(format!(
753 "directory byte accounting overflow at {}",
754 directory.display_path.display()
755 ))
756 })?;
757 if entries.is_empty() && record_bytes > limits.max_batch_bytes {
758 *pending = Some(child);
759 return Err(PlatformError::ResourceLimit(format!(
760 "single directory entry exceeds the retained-byte cap at {}",
761 directory.display_path.display()
762 )));
763 }
764 if entries.len() >= limits.max_batch_entries || next_bytes > limits.max_batch_bytes {
765 *pending = Some(child);
766 return Ok(DirectoryEntryBatch::continued(entries));
767 }
768 retained_bytes = next_bytes;
769 entries.push(child);
770 }
771 }
772
773 fn inspect_child(
774 &self,
775 parent: &Self::DirectoryHandle,
776 child: &DirectoryEntryRecord,
777 cancel: &CancellationToken,
778 ) -> Result<WalkEntry<Self::DirectoryHandle>, PlatformError> {
779 self.inspect_child_with_directory_admission(
780 parent,
781 child,
782 cancel,
783 DirectoryHandleAdmission::Allow,
784 )
785 }
786
787 fn inspect_child_with_directory_admission(
788 &self,
789 parent: &Self::DirectoryHandle,
790 child: &DirectoryEntryRecord,
791 cancel: &CancellationToken,
792 directory_admission: DirectoryHandleAdmission,
793 ) -> Result<WalkEntry<Self::DirectoryHandle>, PlatformError> {
794 child
795 .validate_for_parent(&parent.display_path)
796 .map_err(|error| PlatformError::InvalidDirectoryEntry {
797 parent: parent.display_path.clone(),
798 detail: error.to_string(),
799 })?;
800 Self::ensure_not_cancelled(cancel)?;
801 let name = match Self::child_name_c_string(&child.file_name) {
802 Ok(name) => name,
803 Err(error) => return Ok(Self::error_entry(&child.path, error)),
804 };
805 let path = child.path.clone();
806 let pinned = match Self::pin_child(&parent.fd, &name) {
807 Ok(fd) => fd,
808 Err(error) => return Ok(Self::error_entry(&path, error)),
809 };
810 Self::ensure_not_cancelled(cancel)?;
811 let pinned_stat = match Self::fstat(&pinned) {
812 Ok(stat) => stat,
813 Err(error) => return Ok(Self::error_entry(&path, error)),
814 };
815 let pinned_mount_id = match Self::mount_id_for_fd(&pinned) {
816 Ok(value) => value,
817 Err(error) => return Ok(Self::error_entry(&path, error)),
818 };
819
820 if Self::kind_from_mode(pinned_stat.st_mode) == EntryKind::Directory {
821 if directory_admission == DirectoryHandleAdmission::Deny {
822 return Ok(WalkEntry::Boundary(BoundaryRecord {
823 path,
824 kind: BoundaryKind::ResourceLimit,
825 reason: ReasonCode::ResourceLimit,
826 detail: "frontier limit exceeded".to_string(),
827 }));
828 }
829 let directory_fd = match Self::open_matching_child_directory(
830 &parent.fd,
831 &name,
832 &pinned_stat,
833 pinned_mount_id,
834 ) {
835 Ok(fd) => fd,
836 Err(error) if error.raw_os_error() == Some(libc::EXDEV) => {
837 return Ok(WalkEntry::Boundary(BoundaryRecord {
838 path,
839 kind: BoundaryKind::Mount,
840 reason: ReasonCode::UnsupportedFilesystem,
841 detail: format!(
842 "entry crosses a mount boundary (mount id {})",
843 pinned_mount_id
844 ),
845 }));
846 }
847 Err(error) => return Ok(Self::error_entry(&path, error)),
848 };
849 let metadata = Self::metadata_to_entry(
850 &path,
851 child.file_name.clone(),
852 &pinned_stat,
853 pinned_mount_id,
854 );
855 return Ok(WalkEntry::Directory(OpenedDirectory {
856 metadata,
857 handle: LinuxDirectoryHandle {
858 fd: directory_fd,
859 display_path: path,
860 cursor: DirectoryCursor::NotStarted,
861 },
862 }));
863 }
864
865 let metadata = Self::metadata_to_entry(
866 &path,
867 child.file_name.clone(),
868 &pinned_stat,
869 pinned_mount_id,
870 );
871 match metadata.kind {
872 EntryKind::Directory => unreachable!("directories returned above"),
873 EntryKind::File => Ok(WalkEntry::File(metadata)),
874 EntryKind::Symlink => Ok(WalkEntry::Link(metadata)),
875 EntryKind::ReparsePoint => Ok(WalkEntry::Boundary(BoundaryRecord {
876 path,
877 kind: BoundaryKind::ReparsePoint,
878 reason: ReasonCode::UnsupportedFilesystem,
879 detail: "directory reparse points are unsupported on linux backend".to_string(),
880 })),
881 EntryKind::Other => Ok(WalkEntry::Boundary(BoundaryRecord {
882 path,
883 kind: BoundaryKind::OtherFilesystem,
884 reason: ReasonCode::UnsupportedFilesystem,
885 detail: "special filesystem entry rejected".to_string(),
886 })),
887 }
888 }
889
890 fn is_same_mount(
891 &self,
892 root: &EntryMetadata,
893 entry: &EntryMetadata,
894 ) -> Result<bool, PlatformError> {
895 match (&root.mount_identity, &entry.mount_identity) {
896 (Some(left), Some(right)) => Ok(left == right),
897 _ => Err(PlatformError::Unsupported(
898 "mount identity unavailable".to_string(),
899 )),
900 }
901 }
902
903 fn read_regular_file_relative(
904 &self,
905 parent: &Self::DirectoryHandle,
906 request: &BoundedRegularFileReadRequest,
907 cancel: &CancellationToken,
908 ) -> Result<PresentRegularFileRead, BoundedRegularFileReadError> {
909 Self::ensure_regular_file_read_not_cancelled(cancel)?;
910 let name = Self::child_name_c_string(request.child_name()).map_err(|error| {
911 if error.kind() == io::ErrorKind::InvalidInput {
912 BoundedRegularFileReadError::UnsafeName
913 } else {
914 BoundedRegularFileReadError::io(error)
915 }
916 })?;
917 let pinned =
918 Self::pin_child(&parent.fd, &name).map_err(Self::map_regular_file_open_error)?;
919 Self::ensure_regular_file_read_not_cancelled(cancel)?;
920 let pinned_observation = Self::classify_pinned_regular_file(&pinned)?;
921 let read_fd = Self::open_child_regular_file(&parent.fd, &name)
922 .map_err(Self::map_regular_file_open_error)?;
923 let observed_before = Self::observe_regular_file_fd(&read_fd).map_err(|error| {
924 if error.kind() == io::ErrorKind::Unsupported {
925 BoundedRegularFileReadError::Unsupported(error.to_string())
926 } else {
927 BoundedRegularFileReadError::io(error)
928 }
929 })?;
930 Self::compare_pinned_and_opened_regular_file(&pinned_observation, &observed_before)?;
931 Self::compare_expected_regular_file(request, &observed_before)?;
932 Self::ensure_regular_file_read_not_cancelled(cancel)?;
933
934 let mut bytes = Vec::new();
935 let hard_cap = request.max_bytes().saturating_add(1);
936 let mut buffer = [0u8; Self::REGULAR_FILE_READ_CHUNK_BYTES];
937 let mut overflow_lower_bound = (observed_before.logical_bytes
938 > DecimalU128::new(request.max_bytes() as u128))
939 .then_some(observed_before.logical_bytes);
940 loop {
941 if overflow_lower_bound.is_some() {
942 break;
943 }
944 Self::ensure_regular_file_read_not_cancelled(cancel)?;
945 let remaining = hard_cap.saturating_sub(bytes.len());
946 if remaining == 0 {
947 overflow_lower_bound = Some(DecimalU128::new(
948 (request.max_bytes() as u128).saturating_add(1),
949 ));
950 break;
951 }
952 let chunk_len = remaining.min(buffer.len());
953 let read =
956 unsafe { libc::read(read_fd.as_raw_fd(), buffer.as_mut_ptr().cast(), chunk_len) };
957 if read < 0 {
958 let error = io::Error::last_os_error();
959 if error.raw_os_error() == Some(libc::EINTR) {
960 Self::ensure_regular_file_read_not_cancelled(cancel)?;
961 continue;
962 }
963 return Err(Self::map_regular_file_read_error(error));
964 }
965 let read = read as usize;
966 if read == 0 {
967 break;
968 }
969 bytes.extend_from_slice(&buffer[..read]);
970 #[cfg(test)]
971 Self::run_regular_file_read_test_hook(
972 RegularFileReadTestHookEvent::AfterReadChunk,
973 cancel,
974 );
975 if bytes.len() > request.max_bytes() {
976 overflow_lower_bound = Some(
977 overflow_lower_bound
978 .unwrap_or(DecimalU128::ZERO)
979 .max(DecimalU128::new(bytes.len() as u128)),
980 );
981 break;
982 }
983 }
984
985 #[cfg(test)]
986 Self::run_regular_file_read_test_hook(
987 RegularFileReadTestHookEvent::BeforeObservedAfter,
988 cancel,
989 );
990 let observed_after = Self::observe_regular_file_fd(&read_fd).map_err(|error| {
991 if error.kind() == io::ErrorKind::Unsupported {
992 BoundedRegularFileReadError::Unsupported(error.to_string())
993 } else {
994 BoundedRegularFileReadError::io(error)
995 }
996 })?;
997 #[cfg(test)]
998 Self::run_regular_file_read_test_hook(
999 RegularFileReadTestHookEvent::AfterObservedAfter,
1000 cancel,
1001 );
1002 Self::ensure_regular_file_read_not_cancelled(cancel)?;
1003 Self::validate_regular_file_unchanged(&observed_before, &observed_after)?;
1004 if let Some(lower_bound) = overflow_lower_bound {
1005 let observed_logical_bytes = if observed_after.logical_bytes > lower_bound {
1006 observed_after.logical_bytes
1007 } else {
1008 lower_bound
1009 };
1010 return Err(Self::observed_limit_exceeded(
1011 request,
1012 observed_logical_bytes,
1013 ));
1014 }
1015 Ok(PresentRegularFileRead {
1016 bytes,
1017 observed_before,
1018 observed_after,
1019 })
1020 }
1021}
1022
1023#[cfg(not(target_os = "linux"))]
1028impl PlatformScanner for LinuxPlatformScanner {
1029 type DirectoryHandle = LinuxUnavailableDirectory;
1030
1031 fn platform_name(&self) -> &'static str {
1032 "linux"
1033 }
1034
1035 fn admit_root(
1036 &self,
1037 _root: &ScanRoot,
1038 _cancel: &CancellationToken,
1039 ) -> Result<RootAdmission<Self::DirectoryHandle>, PlatformError> {
1040 Err(PlatformError::Unsupported(
1041 "Linux scanner backend is unavailable on this host".to_string(),
1042 ))
1043 }
1044
1045 fn enumerate_children(
1046 &self,
1047 _directory: &mut Self::DirectoryHandle,
1048 _cancel: &CancellationToken,
1049 _limits: DirectoryReadLimits,
1050 ) -> Result<DirectoryEntryBatch, PlatformError> {
1051 Err(PlatformError::Unsupported(
1052 "Linux scanner backend is unavailable on this host".to_string(),
1053 ))
1054 }
1055
1056 fn inspect_child(
1057 &self,
1058 _parent: &Self::DirectoryHandle,
1059 _child: &DirectoryEntryRecord,
1060 _cancel: &CancellationToken,
1061 ) -> Result<WalkEntry<Self::DirectoryHandle>, PlatformError> {
1062 Err(PlatformError::Unsupported(
1063 "Linux scanner backend is unavailable on this host".to_string(),
1064 ))
1065 }
1066
1067 fn inspect_child_with_directory_admission(
1068 &self,
1069 parent: &Self::DirectoryHandle,
1070 child: &DirectoryEntryRecord,
1071 cancel: &CancellationToken,
1072 _directory_admission: DirectoryHandleAdmission,
1073 ) -> Result<WalkEntry<Self::DirectoryHandle>, PlatformError> {
1074 self.inspect_child(parent, child, cancel)
1075 }
1076
1077 fn is_same_mount(
1078 &self,
1079 _root: &EntryMetadata,
1080 _entry: &EntryMetadata,
1081 ) -> Result<bool, PlatformError> {
1082 Err(PlatformError::Unsupported(
1083 "Linux scanner backend is unavailable on this host".to_string(),
1084 ))
1085 }
1086}
1087
1088#[cfg(all(test, target_os = "linux"))]
1089#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1090enum RegularFileReadTestHookEvent {
1091 AfterReadChunk,
1092 BeforeObservedAfter,
1093 AfterObservedAfter,
1094}
1095
1096#[cfg(all(test, target_os = "linux"))]
1097#[derive(Debug)]
1098enum RegularFileReadTestHookAction {
1099 Cancel,
1100 RewriteBytes { path: PathBuf, bytes: Vec<u8> },
1101}
1102
1103#[cfg(all(test, target_os = "linux"))]
1104#[derive(Debug)]
1105struct RegularFileReadTestHook {
1106 event: RegularFileReadTestHookEvent,
1107 action: RegularFileReadTestHookAction,
1108}
1109
1110#[cfg(all(test, target_os = "linux"))]
1111thread_local! {
1112 static REGULAR_FILE_READ_TEST_HOOK: RefCell<Option<RegularFileReadTestHook>> = const { RefCell::new(None) };
1113}
1114
1115#[cfg(all(test, target_os = "linux"))]
1116mod tests {
1117 use std::ffi::OsStr;
1118 use std::fs;
1119 use std::os::unix::ffi::OsStrExt;
1120 use std::os::unix::fs::FileTypeExt;
1121 use std::os::unix::fs::MetadataExt;
1122
1123 use super::*;
1124 use sweepx_platform::{BoundedRegularFileReadError, read_bound_regular_file};
1125 use tempfile::TempDir;
1126
1127 fn limits(max_entries: usize) -> DirectoryReadLimits {
1128 DirectoryReadLimits {
1129 max_batch_entries: max_entries,
1130 max_batch_bytes: 1024 * 1024,
1131 }
1132 }
1133
1134 fn find_child(
1135 scanner: &LinuxPlatformScanner,
1136 admission: &mut RootAdmission<LinuxDirectoryHandle>,
1137 name: &[u8],
1138 ) -> DirectoryEntryRecord {
1139 scanner
1140 .enumerate_children(
1141 &mut admission.directory,
1142 &CancellationToken::new(),
1143 limits(64),
1144 )
1145 .unwrap()
1146 .entries
1147 .into_iter()
1148 .find(|entry| entry.file_name == NativeName::unix(name.to_vec()))
1149 .unwrap()
1150 }
1151
1152 fn install_regular_file_read_test_hook(hook: RegularFileReadTestHook) -> impl Drop {
1153 REGULAR_FILE_READ_TEST_HOOK.with(|slot| {
1154 let mut slot = slot.borrow_mut();
1155 assert!(
1156 slot.is_none(),
1157 "regular-file read test hook already installed"
1158 );
1159 *slot = Some(hook);
1160 });
1161 struct HookReset;
1162 impl Drop for HookReset {
1163 fn drop(&mut self) {
1164 REGULAR_FILE_READ_TEST_HOOK.with(|slot| {
1165 *slot.borrow_mut() = None;
1166 });
1167 }
1168 }
1169 HookReset
1170 }
1171
1172 fn live_read_request(name: &[u8], max_bytes: usize) -> BoundedRegularFileReadRequest {
1173 BoundedRegularFileReadRequest::establish_live(NativeName::unix(name.to_vec()), max_bytes)
1174 .unwrap()
1175 }
1176
1177 #[test]
1178 fn reject_root_symlink() {
1179 let temp = TempDir::new().unwrap();
1180 let target = temp.path().join("target");
1181 fs::create_dir(&target).unwrap();
1182 let link = temp.path().join("link");
1183 std::os::unix::fs::symlink(&target, &link).unwrap();
1184
1185 let scanner = LinuxPlatformScanner::new();
1186 let root = ScanRoot::new(link).unwrap();
1187 let error = scanner
1188 .admit_root(&root, &CancellationToken::new())
1189 .unwrap_err();
1190 assert!(matches!(error, PlatformError::RootRejected(_)));
1191 }
1192
1193 #[test]
1194 fn symlink_child_is_reported_without_following() {
1195 let temp = TempDir::new().unwrap();
1196 fs::write(temp.path().join("target"), b"payload").unwrap();
1197 std::os::unix::fs::symlink("target", temp.path().join("link")).unwrap();
1198
1199 let scanner = LinuxPlatformScanner::new();
1200 let mut admission = scanner
1201 .admit_root(
1202 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1203 &CancellationToken::new(),
1204 )
1205 .unwrap();
1206 let child = find_child(&scanner, &mut admission, b"link");
1207 let entry = scanner
1208 .inspect_child(&admission.directory, &child, &CancellationToken::new())
1209 .unwrap();
1210
1211 assert!(matches!(entry, WalkEntry::Link(_)));
1212 }
1213
1214 #[test]
1215 fn denied_directory_admission_classifies_without_opening_a_retained_handle() {
1216 let temp = TempDir::new().unwrap();
1217 fs::create_dir(temp.path().join("directory")).unwrap();
1218 fs::write(temp.path().join("file"), b"payload").unwrap();
1219 std::os::unix::fs::symlink("file", temp.path().join("link")).unwrap();
1220
1221 let scanner = LinuxPlatformScanner::new();
1222 let mut admission = scanner
1223 .admit_root(
1224 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1225 &CancellationToken::new(),
1226 )
1227 .unwrap();
1228 let children = scanner
1229 .enumerate_children(
1230 &mut admission.directory,
1231 &CancellationToken::new(),
1232 limits(64),
1233 )
1234 .unwrap()
1235 .entries;
1236
1237 for child in children {
1238 let name = child.file_name.clone();
1239 let entry = scanner
1240 .inspect_child_with_directory_admission(
1241 &admission.directory,
1242 &child,
1243 &CancellationToken::new(),
1244 DirectoryHandleAdmission::Deny,
1245 )
1246 .unwrap();
1247 if name == NativeName::unix(b"directory".to_vec()) {
1248 assert!(matches!(
1249 entry,
1250 WalkEntry::Boundary(BoundaryRecord {
1251 kind: BoundaryKind::ResourceLimit,
1252 ..
1253 })
1254 ));
1255 } else if name == NativeName::unix(b"file".to_vec()) {
1256 assert!(matches!(entry, WalkEntry::File(_)));
1257 } else if name == NativeName::unix(b"link".to_vec()) {
1258 assert!(matches!(entry, WalkEntry::Link(_)));
1259 }
1260 }
1261 }
1262
1263 #[test]
1264 fn direct_child_inspection_rejects_forged_parent_binding() {
1265 let temp = TempDir::new().unwrap();
1266 fs::write(temp.path().join("safe"), b"safe").unwrap();
1267 let scanner = LinuxPlatformScanner::new();
1268 let admission = scanner
1269 .admit_root(
1270 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1271 &CancellationToken::new(),
1272 )
1273 .unwrap();
1274 let forged = DirectoryEntryRecord {
1275 path: temp.path().join("different"),
1276 file_name: NativeName::unix(b"safe".to_vec()),
1277 };
1278
1279 let error = scanner
1280 .inspect_child(&admission.directory, &forged, &CancellationToken::new())
1281 .unwrap_err();
1282
1283 assert!(matches!(error, PlatformError::InvalidDirectoryEntry { .. }));
1284 }
1285
1286 #[test]
1287 fn pinned_file_identity_and_mount_survive_name_replacement() {
1288 let temp = TempDir::new().unwrap();
1289 let child_path = temp.path().join("child");
1290 let displaced_path = temp.path().join("displaced");
1291 fs::write(&child_path, b"original").unwrap();
1292
1293 let scanner = LinuxPlatformScanner::new();
1294 let admission = scanner
1295 .admit_root(
1296 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1297 &CancellationToken::new(),
1298 )
1299 .unwrap();
1300 let name = CString::new("child").unwrap();
1301 let pinned = LinuxPlatformScanner::pin_child(&admission.directory.fd, &name).unwrap();
1302 let before = LinuxPlatformScanner::fstat(&pinned).unwrap();
1303 let before_mount = LinuxPlatformScanner::mount_id_for_fd(&pinned).unwrap();
1304
1305 fs::rename(&child_path, &displaced_path).unwrap();
1306 fs::write(&child_path, b"replacement-is-different").unwrap();
1307
1308 let after = LinuxPlatformScanner::fstat(&pinned).unwrap();
1309 let after_mount = LinuxPlatformScanner::mount_id_for_fd(&pinned).unwrap();
1310 let replacement = fs::metadata(&child_path).unwrap();
1311
1312 assert!(LinuxPlatformScanner::same_object(&before, &after));
1313 assert_eq!(before_mount, after_mount);
1314 assert_eq!(after.st_size, 8);
1315 assert_ne!(after.st_ino, replacement.ino());
1316 }
1317
1318 #[test]
1319 fn directory_reopen_rejects_replacement_after_pin() {
1320 let temp = TempDir::new().unwrap();
1321 let child_path = temp.path().join("child");
1322 let displaced_path = temp.path().join("displaced");
1323 fs::create_dir(&child_path).unwrap();
1324
1325 let scanner = LinuxPlatformScanner::new();
1326 let admission = scanner
1327 .admit_root(
1328 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1329 &CancellationToken::new(),
1330 )
1331 .unwrap();
1332 let name = CString::new("child").unwrap();
1333 let pinned = LinuxPlatformScanner::pin_child(&admission.directory.fd, &name).unwrap();
1334 let pinned_stat = LinuxPlatformScanner::fstat(&pinned).unwrap();
1335 let pinned_mount = LinuxPlatformScanner::mount_id_for_fd(&pinned).unwrap();
1336
1337 fs::rename(&child_path, &displaced_path).unwrap();
1338 fs::create_dir(&child_path).unwrap();
1339
1340 let error = LinuxPlatformScanner::open_matching_child_directory(
1341 &admission.directory.fd,
1342 &name,
1343 &pinned_stat,
1344 pinned_mount,
1345 )
1346 .unwrap_err();
1347
1348 assert!(error.to_string().contains("changed between pinning"));
1349 }
1350
1351 #[test]
1352 fn nested_mount_is_a_boundary_before_enumerable_handle_admission() {
1353 if !Path::new("/proc").is_dir() {
1354 return;
1355 }
1356 let scanner = LinuxPlatformScanner::new();
1357 let admission = scanner
1358 .admit_root(
1359 &ScanRoot::new(PathBuf::from("/")).unwrap(),
1360 &CancellationToken::new(),
1361 )
1362 .unwrap();
1363 let name = CString::new("proc").unwrap();
1364 let pinned = LinuxPlatformScanner::pin_child(&admission.directory.fd, &name).unwrap();
1365 let root_mount = admission.metadata.mount_identity.as_ref().unwrap().value;
1366 let proc_mount = LinuxPlatformScanner::mount_id_for_fd(&pinned).unwrap();
1367 if root_mount == proc_mount {
1368 return;
1369 }
1370 let child = DirectoryEntryRecord::from_parent_and_name(
1371 Path::new("/"),
1372 NativeName::unix(b"proc".to_vec()),
1373 )
1374 .unwrap();
1375
1376 let entry = scanner
1377 .inspect_child(&admission.directory, &child, &CancellationToken::new())
1378 .unwrap();
1379
1380 assert!(matches!(
1381 entry,
1382 WalkEntry::Boundary(BoundaryRecord {
1383 kind: BoundaryKind::Mount,
1384 ..
1385 })
1386 ));
1387 }
1388
1389 #[test]
1390 fn admitted_descriptor_survives_parent_path_swap() {
1391 let temp = TempDir::new().unwrap();
1392 let root = temp.path().join("root");
1393 let moved = temp.path().join("moved");
1394 fs::create_dir_all(root.join("child")).unwrap();
1395 fs::write(root.join("child/safe"), b"safe").unwrap();
1396
1397 let scanner = LinuxPlatformScanner::new();
1398 let mut admission = scanner
1399 .admit_root(
1400 &ScanRoot::new(root.clone()).unwrap(),
1401 &CancellationToken::new(),
1402 )
1403 .unwrap();
1404 fs::rename(&root, &moved).unwrap();
1405 fs::create_dir_all(root.join("child")).unwrap();
1406 fs::write(root.join("child/evil"), b"evil").unwrap();
1407
1408 let child = find_child(&scanner, &mut admission, b"child");
1409 let WalkEntry::Directory(mut opened) = scanner
1410 .inspect_child(&admission.directory, &child, &CancellationToken::new())
1411 .unwrap()
1412 else {
1413 panic!("expected descriptor-relative child directory");
1414 };
1415 let names: Vec<_> = scanner
1416 .enumerate_children(&mut opened.handle, &CancellationToken::new(), limits(64))
1417 .unwrap()
1418 .entries
1419 .into_iter()
1420 .map(|entry| entry.file_name)
1421 .collect();
1422
1423 assert!(names.contains(&NativeName::unix(b"safe".to_vec())));
1424 assert!(!names.contains(&NativeName::unix(b"evil".to_vec())));
1425 }
1426
1427 #[test]
1428 fn admit_root_honors_cancellation() {
1429 let temp = TempDir::new().unwrap();
1430 let cancel = CancellationToken::new();
1431 cancel.cancel();
1432 let error = LinuxPlatformScanner::new()
1433 .admit_root(&ScanRoot::new(temp.path().to_path_buf()).unwrap(), &cancel)
1434 .unwrap_err();
1435 assert!(matches!(error, PlatformError::Cancelled));
1436 }
1437
1438 #[test]
1439 fn file_metadata_reports_hard_link_identity() {
1440 let temp = TempDir::new().unwrap();
1441 fs::write(temp.path().join("file"), b"hello world").unwrap();
1442 fs::hard_link(temp.path().join("file"), temp.path().join("second")).unwrap();
1443
1444 let scanner = LinuxPlatformScanner::new();
1445 let mut admission = scanner
1446 .admit_root(
1447 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1448 &CancellationToken::new(),
1449 )
1450 .unwrap();
1451 let children = scanner
1452 .enumerate_children(
1453 &mut admission.directory,
1454 &CancellationToken::new(),
1455 limits(64),
1456 )
1457 .unwrap();
1458 let inspect = |name: &[u8]| {
1459 let child = children
1460 .entries
1461 .iter()
1462 .find(|entry| entry.file_name == NativeName::unix(name.to_vec()))
1463 .unwrap();
1464 let WalkEntry::File(metadata) = scanner
1465 .inspect_child(&admission.directory, child, &CancellationToken::new())
1466 .unwrap()
1467 else {
1468 panic!("expected file");
1469 };
1470 metadata
1471 };
1472 let first = inspect(b"file");
1473 let second = inspect(b"second");
1474 assert_eq!(first.hard_link_key, second.hard_link_key);
1475 assert_eq!(first.allocated_bytes, second.allocated_bytes);
1476 }
1477
1478 #[test]
1479 fn cancellation_stops_enumeration() {
1480 let temp = TempDir::new().unwrap();
1481 fs::create_dir(temp.path().join("child")).unwrap();
1482 let scanner = LinuxPlatformScanner::new();
1483 let mut admission = scanner
1484 .admit_root(
1485 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1486 &CancellationToken::new(),
1487 )
1488 .unwrap();
1489 let cancel = CancellationToken::new();
1490 cancel.cancel();
1491 let error = scanner
1492 .enumerate_children(&mut admission.directory, &cancel, limits(16))
1493 .unwrap_err();
1494 assert!(matches!(error, PlatformError::Cancelled));
1495 }
1496
1497 #[test]
1498 fn enumeration_continues_at_entry_cap_and_enforces_unfit_byte_cap() {
1499 let temp = TempDir::new().unwrap();
1500 fs::write(temp.path().join("a"), b"a").unwrap();
1501 fs::write(temp.path().join("b"), b"b").unwrap();
1502 let scanner = LinuxPlatformScanner::new();
1503
1504 let mut entry_limited = scanner
1505 .admit_root(
1506 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1507 &CancellationToken::new(),
1508 )
1509 .unwrap();
1510 let first = scanner
1511 .enumerate_children(
1512 &mut entry_limited.directory,
1513 &CancellationToken::new(),
1514 limits(1),
1515 )
1516 .unwrap();
1517 assert_eq!(first.entries.len(), 1);
1518 assert!(!first.end_of_directory);
1519 let second = scanner
1520 .enumerate_children(
1521 &mut entry_limited.directory,
1522 &CancellationToken::new(),
1523 limits(1),
1524 )
1525 .unwrap();
1526 assert_eq!(second.entries.len(), 1);
1527 assert!(second.end_of_directory);
1528
1529 let mut byte_limited = scanner
1530 .admit_root(
1531 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1532 &CancellationToken::new(),
1533 )
1534 .unwrap();
1535 assert!(matches!(
1536 scanner.enumerate_children(
1537 &mut byte_limited.directory,
1538 &CancellationToken::new(),
1539 DirectoryReadLimits {
1540 max_batch_entries: 16,
1541 max_batch_bytes: 1,
1542 },
1543 ),
1544 Err(PlatformError::ResourceLimit(_))
1545 ));
1546 }
1547
1548 #[test]
1549 fn multi_batch_continuation_matches_single_batch_without_loss_or_duplicates() {
1550 let temp = TempDir::new().unwrap();
1551 for name in ["alpha", "beta", "gamma", "delta", "epsilon"] {
1552 fs::write(temp.path().join(name), name.as_bytes()).unwrap();
1553 }
1554 let scanner = LinuxPlatformScanner::new();
1555 let root = ScanRoot::new(temp.path().to_path_buf()).unwrap();
1556
1557 let mut single = scanner
1558 .admit_root(&root, &CancellationToken::new())
1559 .unwrap();
1560 let single = scanner
1561 .enumerate_children(&mut single.directory, &CancellationToken::new(), limits(64))
1562 .unwrap();
1563 assert!(single.end_of_directory);
1564
1565 let mut paged = scanner
1566 .admit_root(&root, &CancellationToken::new())
1567 .unwrap();
1568 let mut paged_entries = Vec::new();
1569 let mut batch_count = 0;
1570 loop {
1571 let batch = scanner
1572 .enumerate_children(&mut paged.directory, &CancellationToken::new(), limits(2))
1573 .unwrap();
1574 batch_count += 1;
1575 assert!(!batch.entries.is_empty() || batch.end_of_directory);
1576 paged_entries.extend(batch.entries);
1577 if batch.end_of_directory {
1578 break;
1579 }
1580 }
1581
1582 assert!(batch_count > 1);
1583 assert_eq!(paged_entries, single.entries);
1584 }
1585
1586 #[test]
1587 fn root_mount_identity_comes_from_statx() {
1588 let temp = TempDir::new().unwrap();
1589 let admission = LinuxPlatformScanner::new()
1590 .admit_root(
1591 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1592 &CancellationToken::new(),
1593 )
1594 .unwrap();
1595 assert!(admission.metadata.mount_identity.is_some());
1596 }
1597
1598 #[test]
1599 fn bounded_regular_file_read_succeeds_for_regular_file() {
1600 let temp = TempDir::new().unwrap();
1601 fs::write(temp.path().join("file"), b"payload").unwrap();
1602 let scanner = LinuxPlatformScanner::new();
1603 let admission = scanner
1604 .admit_root(
1605 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1606 &CancellationToken::new(),
1607 )
1608 .unwrap();
1609
1610 let read = read_bound_regular_file(
1611 &scanner,
1612 &admission.directory,
1613 &live_read_request(b"file", 7),
1614 &CancellationToken::new(),
1615 )
1616 .unwrap();
1617
1618 assert_eq!(read.bytes, b"payload");
1619 assert_eq!(read.observed_before.kind, EntryKind::File);
1620 assert_eq!(read.observed_before, read.observed_after);
1621 }
1622
1623 #[test]
1624 fn bounded_regular_file_read_enforces_exact_and_overflow_limits() {
1625 let temp = TempDir::new().unwrap();
1626 fs::write(temp.path().join("file"), b"payload").unwrap();
1627 let scanner = LinuxPlatformScanner::new();
1628 let admission = scanner
1629 .admit_root(
1630 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1631 &CancellationToken::new(),
1632 )
1633 .unwrap();
1634
1635 let exact = read_bound_regular_file(
1636 &scanner,
1637 &admission.directory,
1638 &live_read_request(b"file", 7),
1639 &CancellationToken::new(),
1640 )
1641 .unwrap();
1642 assert_eq!(exact.bytes, b"payload");
1643
1644 let overflow = read_bound_regular_file(
1645 &scanner,
1646 &admission.directory,
1647 &live_read_request(b"file", 6),
1648 &CancellationToken::new(),
1649 )
1650 .unwrap_err();
1651 assert!(matches!(
1652 overflow,
1653 BoundedRegularFileReadError::LimitExceeded {
1654 max_bytes: 6,
1655 observed_logical_bytes,
1656 } if observed_logical_bytes == DecimalU128::new(7)
1657 ));
1658 }
1659
1660 #[test]
1661 fn bounded_regular_file_read_reports_missing_precisely() {
1662 let temp = TempDir::new().unwrap();
1663 let scanner = LinuxPlatformScanner::new();
1664 let admission = scanner
1665 .admit_root(
1666 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1667 &CancellationToken::new(),
1668 )
1669 .unwrap();
1670
1671 let error = read_bound_regular_file(
1672 &scanner,
1673 &admission.directory,
1674 &live_read_request(b"missing", 16),
1675 &CancellationToken::new(),
1676 )
1677 .unwrap_err();
1678
1679 assert_eq!(error, BoundedRegularFileReadError::NotFound);
1680 assert!(error.is_verified_absent());
1681 }
1682
1683 #[test]
1684 fn bounded_regular_file_read_rejects_symlink_without_following() {
1685 let temp = TempDir::new().unwrap();
1686 fs::write(temp.path().join("target"), b"payload").unwrap();
1687 std::os::unix::fs::symlink("target", temp.path().join("link")).unwrap();
1688 let scanner = LinuxPlatformScanner::new();
1689 let admission = scanner
1690 .admit_root(
1691 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1692 &CancellationToken::new(),
1693 )
1694 .unwrap();
1695
1696 let error = read_bound_regular_file(
1697 &scanner,
1698 &admission.directory,
1699 &live_read_request(b"link", 16),
1700 &CancellationToken::new(),
1701 )
1702 .unwrap_err();
1703
1704 assert!(matches!(
1705 error,
1706 BoundedRegularFileReadError::SymlinkOrReparse {
1707 observed_kind: EntryKind::Symlink
1708 }
1709 ));
1710 }
1711
1712 #[test]
1713 fn bounded_regular_file_read_rejects_directory_and_fifo_promptly() {
1714 let temp = TempDir::new().unwrap();
1715 fs::create_dir(temp.path().join("directory")).unwrap();
1716 let fifo = temp.path().join("fifo");
1717 let fifo_cstr = CString::new(fifo.as_os_str().as_bytes()).unwrap();
1718 let result = unsafe { libc::mkfifo(fifo_cstr.as_ptr(), 0o600) };
1720 assert_eq!(result, 0, "mkfifo failed: {}", io::Error::last_os_error());
1721 assert!(fs::symlink_metadata(&fifo).unwrap().file_type().is_fifo());
1722
1723 let scanner = LinuxPlatformScanner::new();
1724 let admission = scanner
1725 .admit_root(
1726 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1727 &CancellationToken::new(),
1728 )
1729 .unwrap();
1730
1731 let directory_error = read_bound_regular_file(
1732 &scanner,
1733 &admission.directory,
1734 &live_read_request(b"directory", 16),
1735 &CancellationToken::new(),
1736 )
1737 .unwrap_err();
1738 assert!(matches!(
1739 directory_error,
1740 BoundedRegularFileReadError::NotRegular {
1741 observed_kind: EntryKind::Directory
1742 }
1743 ));
1744
1745 let fifo_error = read_bound_regular_file(
1746 &scanner,
1747 &admission.directory,
1748 &live_read_request(b"fifo", 16),
1749 &CancellationToken::new(),
1750 )
1751 .unwrap_err();
1752 assert!(matches!(
1753 fifo_error,
1754 BoundedRegularFileReadError::NotRegular {
1755 observed_kind: EntryKind::Other
1756 }
1757 ));
1758 }
1759
1760 #[test]
1761 fn bounded_regular_file_read_supports_non_utf8_basenames() {
1762 let temp = TempDir::new().unwrap();
1763 let raw_name = b"bad-\xff-name";
1764 let path = temp.path().join(OsStr::from_bytes(raw_name));
1765 fs::write(&path, b"payload").unwrap();
1766 let scanner = LinuxPlatformScanner::new();
1767 let admission = scanner
1768 .admit_root(
1769 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1770 &CancellationToken::new(),
1771 )
1772 .unwrap();
1773
1774 let read = read_bound_regular_file(
1775 &scanner,
1776 &admission.directory,
1777 &live_read_request(raw_name, 16),
1778 &CancellationToken::new(),
1779 )
1780 .unwrap();
1781
1782 assert_eq!(read.bytes, b"payload");
1783 }
1784
1785 #[test]
1786 fn bounded_regular_file_read_checks_expected_binding_before_reading() {
1787 let temp = TempDir::new().unwrap();
1788 fs::write(temp.path().join("file"), b"payload").unwrap();
1789 let scanner = LinuxPlatformScanner::new();
1790 let admission = scanner
1791 .admit_root(
1792 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1793 &CancellationToken::new(),
1794 )
1795 .unwrap();
1796
1797 let live = read_bound_regular_file(
1798 &scanner,
1799 &admission.directory,
1800 &live_read_request(b"file", 16),
1801 &CancellationToken::new(),
1802 )
1803 .unwrap();
1804 let request = BoundedRegularFileReadRequest::previously_observed(
1805 NativeName::unix(b"file".to_vec()),
1806 EntryIdentity::from_unix(
1807 live.observed_before.identity.device(),
1808 u64::try_from(live.observed_before.identity.inode()).unwrap() + 1,
1809 ),
1810 live.observed_before.filesystem_identity.clone(),
1811 live.observed_before.mount_identity.clone(),
1812 16,
1813 )
1814 .unwrap();
1815
1816 let error = read_bound_regular_file(
1817 &scanner,
1818 &admission.directory,
1819 &request,
1820 &CancellationToken::new(),
1821 )
1822 .unwrap_err();
1823
1824 assert!(matches!(
1825 error,
1826 BoundedRegularFileReadError::IdentityMismatch(mismatch)
1827 if mismatch.expected.is_some()
1828 ));
1829 }
1830
1831 #[test]
1832 fn bounded_regular_file_read_uses_retained_parent_not_display_path() {
1833 let temp = TempDir::new().unwrap();
1834 let root = temp.path().join("root");
1835 let moved = temp.path().join("moved");
1836 fs::create_dir(&root).unwrap();
1837 fs::write(root.join("safe"), b"safe").unwrap();
1838
1839 let scanner = LinuxPlatformScanner::new();
1840 let admission = scanner
1841 .admit_root(
1842 &ScanRoot::new(root.clone()).unwrap(),
1843 &CancellationToken::new(),
1844 )
1845 .unwrap();
1846 fs::rename(&root, &moved).unwrap();
1847 fs::create_dir(&root).unwrap();
1848 fs::write(root.join("safe"), b"evil").unwrap();
1849
1850 let read = read_bound_regular_file(
1851 &scanner,
1852 &admission.directory,
1853 &live_read_request(b"safe", 16),
1854 &CancellationToken::new(),
1855 )
1856 .unwrap();
1857
1858 assert_eq!(read.bytes, b"safe");
1859 }
1860
1861 #[test]
1862 fn bounded_regular_file_read_honors_cancellation_before_open() {
1863 let temp = TempDir::new().unwrap();
1864 fs::write(temp.path().join("file"), b"payload").unwrap();
1865 let scanner = LinuxPlatformScanner::new();
1866 let admission = scanner
1867 .admit_root(
1868 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1869 &CancellationToken::new(),
1870 )
1871 .unwrap();
1872 let cancel = CancellationToken::new();
1873 cancel.cancel();
1874
1875 let error = read_bound_regular_file(
1876 &scanner,
1877 &admission.directory,
1878 &live_read_request(b"file", 16),
1879 &cancel,
1880 )
1881 .unwrap_err();
1882
1883 assert_eq!(error, BoundedRegularFileReadError::Cancelled);
1884 }
1885
1886 #[test]
1887 fn bounded_regular_file_read_honors_cancellation_between_reads() {
1888 let temp = TempDir::new().unwrap();
1889 fs::write(
1890 temp.path().join("file"),
1891 vec![b'x'; LinuxPlatformScanner::REGULAR_FILE_READ_CHUNK_BYTES * 2],
1892 )
1893 .unwrap();
1894 let scanner = LinuxPlatformScanner::new();
1895 let admission = scanner
1896 .admit_root(
1897 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1898 &CancellationToken::new(),
1899 )
1900 .unwrap();
1901 let cancel = CancellationToken::new();
1902 let _hook_reset = install_regular_file_read_test_hook(RegularFileReadTestHook {
1903 event: RegularFileReadTestHookEvent::AfterReadChunk,
1904 action: RegularFileReadTestHookAction::Cancel,
1905 });
1906
1907 let error = read_bound_regular_file(
1908 &scanner,
1909 &admission.directory,
1910 &live_read_request(
1911 b"file",
1912 LinuxPlatformScanner::REGULAR_FILE_READ_CHUNK_BYTES * 3,
1913 ),
1914 &cancel,
1915 )
1916 .unwrap_err();
1917
1918 assert_eq!(error, BoundedRegularFileReadError::Cancelled);
1919 }
1920
1921 #[test]
1922 fn bounded_regular_file_read_honors_cancellation_after_metadata() {
1923 let temp = TempDir::new().unwrap();
1924 fs::write(temp.path().join("file"), b"payload").unwrap();
1925 let scanner = LinuxPlatformScanner::new();
1926 let admission = scanner
1927 .admit_root(
1928 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1929 &CancellationToken::new(),
1930 )
1931 .unwrap();
1932 let cancel = CancellationToken::new();
1933 let _hook_reset = install_regular_file_read_test_hook(RegularFileReadTestHook {
1934 event: RegularFileReadTestHookEvent::AfterObservedAfter,
1935 action: RegularFileReadTestHookAction::Cancel,
1936 });
1937
1938 let error = read_bound_regular_file(
1939 &scanner,
1940 &admission.directory,
1941 &live_read_request(b"file", 16),
1942 &cancel,
1943 )
1944 .unwrap_err();
1945
1946 assert_eq!(error, BoundedRegularFileReadError::Cancelled);
1947 }
1948
1949 #[test]
1950 fn bounded_regular_file_read_detects_changed_during_read_with_test_seam() {
1951 let temp = TempDir::new().unwrap();
1952 let path = temp.path().join("file");
1953 fs::write(&path, b"payload").unwrap();
1954 let scanner = LinuxPlatformScanner::new();
1955 let admission = scanner
1956 .admit_root(
1957 &ScanRoot::new(temp.path().to_path_buf()).unwrap(),
1958 &CancellationToken::new(),
1959 )
1960 .unwrap();
1961 let _hook_reset = install_regular_file_read_test_hook(RegularFileReadTestHook {
1962 event: RegularFileReadTestHookEvent::BeforeObservedAfter,
1963 action: RegularFileReadTestHookAction::RewriteBytes {
1964 path,
1965 bytes: b"changed-size".to_vec(),
1966 },
1967 });
1968
1969 let error = read_bound_regular_file(
1970 &scanner,
1971 &admission.directory,
1972 &live_read_request(b"file", 16),
1973 &CancellationToken::new(),
1974 )
1975 .unwrap_err();
1976
1977 assert!(matches!(
1978 error,
1979 BoundedRegularFileReadError::ChangedDuringRead(_)
1980 ));
1981 }
1982}