Skip to main content

provenant/scanner/
collect.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use glob::Pattern;
5use std::collections::HashSet;
6use std::fs;
7use std::io;
8use std::path::{Path, PathBuf};
9use std::time::Instant;
10
11use crate::utils::file::is_path_excluded;
12
13pub struct CollectedPaths {
14    pub files: Vec<(PathBuf, fs::Metadata)>,
15    pub directories: Vec<(PathBuf, fs::Metadata)>,
16    pub excluded_count: usize,
17    pub total_file_bytes: u64,
18    pub collection_errors: Vec<(PathBuf, String)>,
19    /// Set when a configured collection limit stopped the walk before it
20    /// finished. The reason is surfaced through `collection_errors`.
21    pub limit_reached: bool,
22}
23
24/// Bounds applied while walking the filesystem.
25///
26/// Defaults are fully permissive so ordinary CLI and library scans behave
27/// exactly as before. Untrusted callers (notably `provenant serve`) opt into
28/// finite ceilings and an out-of-tree symlink guard so a hostile input tree
29/// cannot exhaust memory, wall-clock time, or leak content from outside the
30/// scan root.
31#[derive(Debug, Clone, Default)]
32pub struct CollectionLimits {
33    /// Maximum number of regular files to collect, if any.
34    pub max_file_count: Option<usize>,
35    /// Maximum cumulative size of collected files in bytes, if any.
36    pub max_total_bytes: Option<u64>,
37    /// Wall-clock deadline for the whole collection pass, if any.
38    pub deadline: Option<Instant>,
39    /// When set, file symlinks whose canonicalized target escapes this root are
40    /// skipped instead of being dereferenced and scanned.
41    pub symlink_root_guard: Option<PathBuf>,
42}
43
44impl CollectionLimits {
45    /// Permissive limits used by trusted CLI and library scans.
46    pub fn unbounded() -> Self {
47        Self::default()
48    }
49
50    /// Resolves the symlink guard once per walk so each encountered symlink only
51    /// canonicalizes its own target instead of re-canonicalizing the scan root.
52    ///
53    /// When a guard is requested but its root cannot be canonicalized, the root
54    /// path is left un-canonicalized so containment checks fail closed (no real
55    /// canonical target will be considered contained), matching the prior
56    /// per-symlink behavior.
57    fn resolved_for_walk(&self) -> Self {
58        let symlink_root_guard = self
59            .symlink_root_guard
60            .as_ref()
61            .map(|root| fs::canonicalize(root).unwrap_or_else(|_| root.clone()));
62        Self {
63            max_file_count: self.max_file_count,
64            max_total_bytes: self.max_total_bytes,
65            deadline: self.deadline,
66            symlink_root_guard,
67        }
68    }
69
70    fn deadline_exceeded(&self) -> bool {
71        self.deadline
72            .is_some_and(|deadline| Instant::now() >= deadline)
73    }
74
75    fn file_count_reached(&self, collected_files: usize) -> bool {
76        self.max_file_count
77            .is_some_and(|limit| collected_files >= limit)
78    }
79
80    fn total_bytes_exceeded(&self, collected_bytes: u64, next_file_bytes: u64) -> bool {
81        self.max_total_bytes
82            .is_some_and(|limit| collected_bytes.saturating_add(next_file_bytes) > limit)
83    }
84}
85
86/// Distinguishes a clean walk from one stopped by a configured limit.
87enum WalkBudget {
88    Continue,
89    Stop(&'static str),
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct CollectionFrontier {
94    pub path: PathBuf,
95    pub recurse: bool,
96}
97
98struct CollectionAccumulator {
99    files: Vec<(PathBuf, fs::Metadata)>,
100    directories: Vec<(PathBuf, fs::Metadata)>,
101    file_seen: HashSet<PathBuf>,
102    dir_seen: HashSet<PathBuf>,
103    excluded_count: usize,
104    total_file_bytes: u64,
105    collection_errors: Vec<(PathBuf, String)>,
106    limit_reached: bool,
107}
108
109enum TraversalMetadata {
110    File(fs::Metadata),
111    Directory {
112        metadata: fs::Metadata,
113        can_recurse: bool,
114    },
115    Other,
116}
117
118impl CollectedPaths {
119    pub fn file_count(&self) -> usize {
120        self.files.len()
121    }
122
123    pub fn directory_count(&self) -> usize {
124        self.directories.len()
125    }
126
127    pub fn scan_root(&self) -> Option<&Path> {
128        self.directories
129            .first()
130            .map(|(path, _)| path.as_path())
131            .or_else(|| {
132                self.files
133                    .first()
134                    .and_then(|(path, _)| path.parent().or(Some(path.as_path())))
135            })
136    }
137}
138
139pub fn collect_paths<P: AsRef<Path>>(
140    root: P,
141    max_depth: usize,
142    exclude_patterns: &[Pattern],
143) -> CollectedPaths {
144    collect_paths_with_limits(
145        root,
146        max_depth,
147        exclude_patterns,
148        &CollectionLimits::unbounded(),
149    )
150}
151
152pub fn collect_paths_with_limits<P: AsRef<Path>>(
153    root: P,
154    max_depth: usize,
155    exclude_patterns: &[Pattern],
156    limits: &CollectionLimits,
157) -> CollectedPaths {
158    let limits = &limits.resolved_for_walk();
159    let depth_limit = depth_limit_from_cli(max_depth);
160    let root = root.as_ref();
161
162    if is_path_excluded(root, exclude_patterns) {
163        return CollectedPaths {
164            files: Vec::new(),
165            directories: Vec::new(),
166            excluded_count: 1,
167            total_file_bytes: 0,
168            collection_errors: Vec::new(),
169            limit_reached: false,
170        };
171    }
172
173    let traversal_metadata = match classify_for_traversal(root, true, limits) {
174        Ok(traversal_metadata) => traversal_metadata,
175        Err(error) => {
176            return CollectedPaths {
177                files: Vec::new(),
178                directories: Vec::new(),
179                excluded_count: 0,
180                total_file_bytes: 0,
181                collection_errors: vec![(root.to_path_buf(), error.to_string())],
182                limit_reached: false,
183            };
184        }
185    };
186
187    match traversal_metadata {
188        TraversalMetadata::File(metadata) => CollectedPaths {
189            total_file_bytes: metadata.len(),
190            files: vec![(root.to_path_buf(), metadata)],
191            directories: Vec::new(),
192            excluded_count: 0,
193            collection_errors: Vec::new(),
194            limit_reached: false,
195        },
196        TraversalMetadata::Directory {
197            metadata,
198            can_recurse,
199        } if can_recurse => {
200            collect_all_paths(root, &metadata, depth_limit, exclude_patterns, limits)
201        }
202        TraversalMetadata::Directory { metadata, .. } => CollectedPaths {
203            files: Vec::new(),
204            directories: vec![(root.to_path_buf(), metadata)],
205            excluded_count: 0,
206            total_file_bytes: 0,
207            collection_errors: Vec::new(),
208            limit_reached: false,
209        },
210        TraversalMetadata::Other => CollectedPaths {
211            files: Vec::new(),
212            directories: Vec::new(),
213            excluded_count: 0,
214            total_file_bytes: 0,
215            collection_errors: Vec::new(),
216            limit_reached: false,
217        },
218    }
219}
220
221pub fn collect_selected_paths(
222    root: &Path,
223    selected: &[CollectionFrontier],
224    max_depth: usize,
225    exclude_patterns: &[Pattern],
226) -> CollectedPaths {
227    collect_selected_paths_with_limits(
228        root,
229        selected,
230        max_depth,
231        exclude_patterns,
232        &CollectionLimits::unbounded(),
233    )
234}
235
236pub fn collect_selected_paths_with_limits(
237    root: &Path,
238    selected: &[CollectionFrontier],
239    max_depth: usize,
240    exclude_patterns: &[Pattern],
241    limits: &CollectionLimits,
242) -> CollectedPaths {
243    let limits = &limits.resolved_for_walk();
244    let depth_limit = depth_limit_from_cli(max_depth);
245
246    if is_path_excluded(root, exclude_patterns) {
247        return CollectedPaths {
248            files: Vec::new(),
249            directories: Vec::new(),
250            excluded_count: 1,
251            total_file_bytes: 0,
252            collection_errors: Vec::new(),
253            limit_reached: false,
254        };
255    }
256
257    let root_metadata = match classify_for_traversal(root, true, limits) {
258        Ok(TraversalMetadata::Directory { metadata, .. }) => metadata,
259        Ok(TraversalMetadata::File(metadata)) => metadata,
260        Ok(TraversalMetadata::Other) => {
261            return CollectedPaths {
262                files: Vec::new(),
263                directories: Vec::new(),
264                excluded_count: 0,
265                total_file_bytes: 0,
266                collection_errors: Vec::new(),
267                limit_reached: false,
268            };
269        }
270        Err(error) => {
271            return CollectedPaths {
272                files: Vec::new(),
273                directories: Vec::new(),
274                excluded_count: 0,
275                total_file_bytes: 0,
276                collection_errors: vec![(root.to_path_buf(), error.to_string())],
277                limit_reached: false,
278            };
279        }
280    };
281
282    let mut accumulator = CollectionAccumulator {
283        files: Vec::new(),
284        directories: vec![(root.to_path_buf(), root_metadata)],
285        file_seen: HashSet::new(),
286        dir_seen: HashSet::from([root.to_path_buf()]),
287        excluded_count: 0,
288        total_file_bytes: 0,
289        collection_errors: Vec::new(),
290        limit_reached: false,
291    };
292
293    for frontier in minimize_frontier(selected) {
294        if accumulator.limit_reached {
295            break;
296        }
297        let relative_depth = frontier.path.components().count();
298        if depth_limit.is_some_and(|limit| relative_depth > limit) {
299            continue;
300        }
301
302        let absolute = root.join(&frontier.path);
303        if is_path_or_any_ancestor_excluded(root, &absolute, exclude_patterns) {
304            accumulator.excluded_count += 1;
305            continue;
306        }
307
308        let traversal_metadata = match classify_for_traversal(&absolute, false, limits) {
309            Ok(traversal_metadata) => traversal_metadata,
310            Err(error) => {
311                accumulator
312                    .collection_errors
313                    .push((absolute, error.to_string()));
314                continue;
315            }
316        };
317
318        add_ancestor_directories(root, &absolute, &mut accumulator, limits);
319
320        let collected = match traversal_metadata {
321            TraversalMetadata::File(metadata) => {
322                insert_file(&mut accumulator, absolute, metadata, limits);
323                continue;
324            }
325            TraversalMetadata::Directory {
326                metadata,
327                can_recurse,
328            } if frontier.recurse && can_recurse => {
329                let subtree_depth_limit =
330                    depth_limit.map(|limit| limit.saturating_sub(relative_depth));
331                collect_all_paths(
332                    &absolute,
333                    &metadata,
334                    subtree_depth_limit,
335                    exclude_patterns,
336                    &subtree_limits(limits, &accumulator),
337                )
338            }
339            TraversalMetadata::Directory { metadata, .. } => CollectedPaths {
340                files: Vec::new(),
341                directories: vec![(absolute, metadata)],
342                excluded_count: 0,
343                total_file_bytes: 0,
344                collection_errors: Vec::new(),
345                limit_reached: false,
346            },
347            TraversalMetadata::Other => continue,
348        };
349        merge_collected(&mut accumulator, collected, limits);
350    }
351
352    CollectedPaths {
353        files: accumulator.files,
354        directories: accumulator.directories,
355        excluded_count: accumulator.excluded_count,
356        total_file_bytes: accumulator.total_file_bytes,
357        collection_errors: accumulator.collection_errors,
358        limit_reached: accumulator.limit_reached,
359    }
360}
361
362/// Adjusts the file-count and total-bytes ceilings for a subtree walk so the
363/// nested walk accounts for what the parent walk has already collected.
364fn subtree_limits(
365    limits: &CollectionLimits,
366    accumulator: &CollectionAccumulator,
367) -> CollectionLimits {
368    CollectionLimits {
369        max_file_count: limits
370            .max_file_count
371            .map(|limit| limit.saturating_sub(accumulator.files.len())),
372        max_total_bytes: limits
373            .max_total_bytes
374            .map(|limit| limit.saturating_sub(accumulator.total_file_bytes)),
375        deadline: limits.deadline,
376        symlink_root_guard: limits.symlink_root_guard.clone(),
377    }
378}
379
380fn collect_all_paths(
381    root: &Path,
382    root_metadata: &fs::Metadata,
383    depth_limit: Option<usize>,
384    exclude_patterns: &[Pattern],
385    limits: &CollectionLimits,
386) -> CollectedPaths {
387    let mut files = Vec::new();
388    let mut directories = vec![(root.to_path_buf(), root_metadata.clone())];
389    let mut excluded_count = 0;
390    let mut total_file_bytes = 0_u64;
391    let mut collection_errors = Vec::new();
392    let mut limit_reached = false;
393
394    let mut pending_dirs: Vec<(PathBuf, Option<usize>)> = vec![(root.to_path_buf(), depth_limit)];
395
396    'walk: while let Some((dir_path, current_depth)) = pending_dirs.pop() {
397        let entries: Vec<_> = match fs::read_dir(&dir_path) {
398            Ok(entries) => entries.filter_map(Result::ok).collect(),
399            Err(e) => {
400                collection_errors.push((dir_path.clone(), e.to_string()));
401                continue;
402            }
403        };
404
405        for entry in entries {
406            let path = entry.path();
407
408            if is_path_excluded(&path, exclude_patterns) {
409                excluded_count += 1;
410                continue;
411            }
412
413            match classify_for_traversal(&path, false, limits) {
414                Ok(TraversalMetadata::File(metadata)) => {
415                    if let WalkBudget::Stop(reason) =
416                        enforce_file_budget(limits, files.len(), total_file_bytes, metadata.len())
417                    {
418                        collection_errors.push((path, reason.to_string()));
419                        limit_reached = true;
420                        break 'walk;
421                    }
422                    total_file_bytes += metadata.len();
423                    files.push((path, metadata));
424                }
425                Ok(TraversalMetadata::Directory {
426                    metadata,
427                    can_recurse,
428                }) => {
429                    directories.push((path.clone(), metadata));
430                    let should_recurse = can_recurse && current_depth.is_none_or(|d| d > 0);
431                    if should_recurse {
432                        let next_depth = current_depth.map(|d| d - 1);
433                        pending_dirs.push((path, next_depth));
434                    }
435                }
436                _ => continue,
437            }
438        }
439    }
440
441    CollectedPaths {
442        files,
443        directories,
444        excluded_count,
445        total_file_bytes,
446        collection_errors,
447        limit_reached,
448    }
449}
450
451/// Decides whether the next file would breach a configured ceiling or deadline.
452fn enforce_file_budget(
453    limits: &CollectionLimits,
454    collected_files: usize,
455    collected_bytes: u64,
456    next_file_bytes: u64,
457) -> WalkBudget {
458    if limits.deadline_exceeded() {
459        return WalkBudget::Stop("scan exceeded its overall time budget");
460    }
461    if limits.file_count_reached(collected_files) {
462        return WalkBudget::Stop("scan exceeded its maximum file count");
463    }
464    if limits.total_bytes_exceeded(collected_bytes, next_file_bytes) {
465        return WalkBudget::Stop("scan exceeded its maximum total byte size");
466    }
467    WalkBudget::Continue
468}
469
470fn classify_for_traversal(
471    path: &Path,
472    recurse_into_symlinked_directories: bool,
473    limits: &CollectionLimits,
474) -> io::Result<TraversalMetadata> {
475    let link_metadata = fs::symlink_metadata(path)?;
476    if link_metadata.file_type().is_symlink() {
477        if let Some(canonical_root) = &limits.symlink_root_guard
478            && symlink_target_escapes_root(path, canonical_root)
479        {
480            // An untrusted input tree points a symlink at content outside the
481            // scan root; refuse to dereference it so we never read or emit
482            // out-of-tree data.
483            return Ok(TraversalMetadata::Other);
484        }
485        let target_metadata = fs::metadata(path)?;
486        return Ok(classify_resolved_metadata(
487            target_metadata,
488            recurse_into_symlinked_directories,
489        ));
490    }
491
492    Ok(classify_resolved_metadata(link_metadata, true))
493}
494
495/// Returns `true` when the symlink at `path` resolves outside `canonical_root`,
496/// or when the target cannot be canonicalized (treated as an escape so we fail
497/// closed). `canonical_root` is canonicalized once per walk by the caller.
498fn symlink_target_escapes_root(path: &Path, canonical_root: &Path) -> bool {
499    match fs::canonicalize(path) {
500        Ok(canonical_target) => !canonical_target.starts_with(canonical_root),
501        Err(_) => true,
502    }
503}
504
505fn classify_resolved_metadata(metadata: fs::Metadata, can_recurse: bool) -> TraversalMetadata {
506    if metadata.is_file() {
507        TraversalMetadata::File(metadata)
508    } else if metadata.is_dir() {
509        TraversalMetadata::Directory {
510            metadata,
511            can_recurse,
512        }
513    } else {
514        TraversalMetadata::Other
515    }
516}
517
518fn depth_limit_from_cli(max_depth: usize) -> Option<usize> {
519    if max_depth == 0 {
520        None
521    } else {
522        Some(max_depth)
523    }
524}
525
526fn is_path_or_any_ancestor_excluded(
527    path_root: &Path,
528    path: &Path,
529    exclude_patterns: &[Pattern],
530) -> bool {
531    let mut current = Some(path);
532    while let Some(candidate) = current {
533        if is_path_excluded(candidate, exclude_patterns) {
534            return true;
535        }
536        if candidate == path_root {
537            break;
538        }
539        current = candidate.parent();
540    }
541    false
542}
543
544fn minimize_frontier(selected: &[CollectionFrontier]) -> Vec<CollectionFrontier> {
545    let mut ordered = selected.to_vec();
546    ordered.sort_by_key(|entry| (entry.path.components().count(), !entry.recurse));
547
548    let mut minimized = Vec::new();
549    for entry in ordered {
550        let covered = minimized.iter().any(|existing: &CollectionFrontier| {
551            existing.recurse
552                && (entry.path == existing.path || entry.path.starts_with(&existing.path))
553        });
554        if !covered {
555            minimized.push(entry);
556        }
557    }
558    minimized
559}
560
561fn add_ancestor_directories(
562    root: &Path,
563    path: &Path,
564    accumulator: &mut CollectionAccumulator,
565    limits: &CollectionLimits,
566) {
567    let mut current = path.parent();
568    while let Some(dir) = current {
569        if dir == root {
570            break;
571        }
572        if accumulator.dir_seen.insert(dir.to_path_buf()) {
573            match classify_for_traversal(dir, false, limits) {
574                Ok(TraversalMetadata::Directory { metadata, .. }) => {
575                    accumulator.directories.push((dir.to_path_buf(), metadata))
576                }
577                Ok(_) => {}
578                Err(error) => accumulator
579                    .collection_errors
580                    .push((dir.to_path_buf(), error.to_string())),
581            }
582        }
583        current = dir.parent();
584    }
585}
586
587fn insert_file(
588    accumulator: &mut CollectionAccumulator,
589    path: PathBuf,
590    metadata: fs::Metadata,
591    limits: &CollectionLimits,
592) {
593    if accumulator.limit_reached || accumulator.file_seen.contains(&path) {
594        return;
595    }
596    if let WalkBudget::Stop(reason) = enforce_file_budget(
597        limits,
598        accumulator.files.len(),
599        accumulator.total_file_bytes,
600        metadata.len(),
601    ) {
602        accumulator
603            .collection_errors
604            .push((path, reason.to_string()));
605        accumulator.limit_reached = true;
606        return;
607    }
608    accumulator.file_seen.insert(path.clone());
609    accumulator.total_file_bytes += metadata.len();
610    accumulator.files.push((path, metadata));
611}
612
613fn merge_collected(
614    accumulator: &mut CollectionAccumulator,
615    collected: CollectedPaths,
616    limits: &CollectionLimits,
617) {
618    accumulator.excluded_count += collected.excluded_count;
619    accumulator
620        .collection_errors
621        .extend(collected.collection_errors);
622
623    // Insert the subtree's already-collected files before propagating its
624    // limit flag. `insert_file` early-returns once `limit_reached` is set, so
625    // flipping the flag first would silently drop valid files the subtree
626    // gathered within its own sub-budget.
627    for (path, metadata) in collected.files {
628        insert_file(accumulator, path, metadata, limits);
629    }
630    accumulator.limit_reached |= collected.limit_reached;
631
632    for (path, metadata) in collected.directories {
633        if accumulator.dir_seen.insert(path.clone()) {
634            accumulator.directories.push((path, metadata));
635        }
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use super::{
642        CollectionFrontier, CollectionLimits, collect_paths, collect_paths_with_limits,
643        collect_selected_paths, collect_selected_paths_with_limits,
644    };
645    use std::fs;
646    use std::path::PathBuf;
647    use std::time::{Duration, Instant};
648
649    #[test]
650    fn file_scan_root_uses_parent_directory() {
651        let temp_dir = tempfile::tempdir().expect("temp dir");
652        let file_path = temp_dir.path().join("Directory.Packages.props");
653        fs::write(&file_path, "<Project />").expect("write props file");
654
655        let collected = collect_paths(&file_path, 0, &[]);
656        assert_eq!(collected.file_count(), 1);
657        assert_eq!(collected.directory_count(), 0);
658        assert_eq!(collected.scan_root(), Some(temp_dir.path()));
659    }
660
661    #[test]
662    fn collect_paths_recurses_regular_directories() {
663        let temp_dir = tempfile::tempdir().expect("temp dir");
664        let nested = temp_dir.path().join("src/bin");
665        fs::create_dir_all(&nested).expect("create nested directory");
666        fs::write(nested.join("main.rs"), "fn main() {}\n").expect("write nested file");
667
668        let collected = collect_paths(temp_dir.path(), 0, &[]);
669
670        assert!(
671            collected
672                .files
673                .iter()
674                .any(|(path, _)| path == &temp_dir.path().join("src/bin/main.rs"))
675        );
676    }
677
678    #[cfg(unix)]
679    #[test]
680    fn collect_paths_does_not_recurse_into_symlinked_directory_cycle() {
681        use std::os::unix::fs::symlink;
682
683        let temp_dir = tempfile::tempdir().expect("temp dir");
684        let root = temp_dir.path();
685        let real = root.join("real");
686        fs::create_dir_all(&real).expect("create real directory");
687        fs::write(real.join("file.txt"), "content\n").expect("write file");
688        symlink(root, real.join("back")).expect("create symlink cycle");
689
690        let collected = collect_paths(root, 0, &[]);
691
692        assert!(collected.collection_errors.is_empty());
693        assert_eq!(collected.file_count(), 1);
694        assert!(
695            collected
696                .files
697                .iter()
698                .all(|(path, _)| !path.starts_with(real.join("back")))
699        );
700    }
701
702    #[cfg(unix)]
703    #[test]
704    fn collect_paths_recurses_explicit_symlinked_scan_root() {
705        use std::os::unix::fs::symlink;
706
707        let temp_dir = tempfile::tempdir().expect("temp dir");
708        let target = temp_dir.path().join("target");
709        fs::create_dir_all(&target).expect("create target directory");
710        fs::write(target.join("inside.txt"), "content\n").expect("write file");
711        let root_link = temp_dir.path().join("root-link");
712        symlink(&target, &root_link).expect("create root symlink");
713
714        let collected = collect_paths(&root_link, 0, &[]);
715
716        assert!(collected.collection_errors.is_empty());
717        assert!(
718            collected
719                .files
720                .iter()
721                .any(|(path, _)| path == &root_link.join("inside.txt"))
722        );
723    }
724
725    #[cfg(unix)]
726    #[test]
727    fn collect_paths_keeps_symlinked_regular_files_scannable() {
728        use std::os::unix::fs::symlink;
729
730        let temp_dir = tempfile::tempdir().expect("temp dir");
731        let root = temp_dir.path();
732        let target = root.join("target.txt");
733        fs::write(&target, "content\n").expect("write target file");
734        let link = root.join("link.txt");
735        symlink(&target, &link).expect("create file symlink");
736
737        let collected = collect_paths(root, 0, &[]);
738
739        assert!(collected.collection_errors.is_empty());
740        assert!(
741            collected
742                .files
743                .iter()
744                .any(|(path, _)| path == &root.join("link.txt"))
745        );
746    }
747
748    #[cfg(unix)]
749    #[test]
750    fn collect_selected_paths_does_not_recurse_into_symlinked_directory() {
751        use std::os::unix::fs::symlink;
752
753        let temp_dir = tempfile::tempdir().expect("temp dir");
754        let root = temp_dir.path();
755        let target = root.join("target");
756        fs::create_dir_all(&target).expect("create target directory");
757        fs::write(target.join("inside.txt"), "content\n").expect("write file");
758        symlink(&target, root.join("link")).expect("create directory symlink");
759
760        let collected = collect_selected_paths(
761            root,
762            &[CollectionFrontier {
763                path: PathBuf::from("link"),
764                recurse: true,
765            }],
766            0,
767            &[],
768        );
769
770        assert!(collected.collection_errors.is_empty());
771        assert!(collected.files.is_empty());
772        assert!(
773            collected
774                .directories
775                .iter()
776                .any(|(path, _)| path == &root.join("link"))
777        );
778    }
779
780    #[cfg(unix)]
781    #[test]
782    fn symlink_root_guard_skips_file_symlink_pointing_outside_root() {
783        use std::os::unix::fs::symlink;
784
785        let outside_dir = tempfile::tempdir().expect("outside temp dir");
786        let secret = outside_dir.path().join("secret.txt");
787        fs::write(&secret, "TOP SECRET\n").expect("write out-of-tree secret");
788
789        let scan_dir = tempfile::tempdir().expect("scan temp dir");
790        let root = scan_dir.path();
791        fs::write(root.join("inside.txt"), "ordinary\n").expect("write inside file");
792        symlink(&secret, root.join("creds")).expect("create escaping symlink");
793
794        let limits = CollectionLimits {
795            symlink_root_guard: Some(root.to_path_buf()),
796            ..CollectionLimits::unbounded()
797        };
798        let collected = collect_paths_with_limits(root, 0, &[], &limits);
799
800        // The escaping symlink must never appear in the collected file set.
801        assert!(
802            collected
803                .files
804                .iter()
805                .all(|(path, _)| path != &root.join("creds"))
806        );
807        // The legitimate in-tree file is still collected.
808        assert!(
809            collected
810                .files
811                .iter()
812                .any(|(path, _)| path == &root.join("inside.txt"))
813        );
814    }
815
816    #[cfg(unix)]
817    #[test]
818    fn symlink_root_guard_keeps_in_tree_file_symlinks() {
819        use std::os::unix::fs::symlink;
820
821        let scan_dir = tempfile::tempdir().expect("scan temp dir");
822        let root = scan_dir.path();
823        let target = root.join("target.txt");
824        fs::write(&target, "content\n").expect("write target file");
825        symlink(&target, root.join("link.txt")).expect("create in-tree symlink");
826
827        let limits = CollectionLimits {
828            symlink_root_guard: Some(root.to_path_buf()),
829            ..CollectionLimits::unbounded()
830        };
831        let collected = collect_paths_with_limits(root, 0, &[], &limits);
832
833        assert!(
834            collected
835                .files
836                .iter()
837                .any(|(path, _)| path == &root.join("link.txt"))
838        );
839    }
840
841    #[test]
842    fn max_file_count_limit_stops_collection() {
843        let temp_dir = tempfile::tempdir().expect("temp dir");
844        let root = temp_dir.path();
845        for index in 0..5 {
846            fs::write(root.join(format!("file{index}.txt")), "x\n").expect("write file");
847        }
848
849        let limits = CollectionLimits {
850            max_file_count: Some(2),
851            ..CollectionLimits::unbounded()
852        };
853        let collected = collect_paths_with_limits(root, 0, &[], &limits);
854
855        assert!(collected.limit_reached);
856        assert!(collected.file_count() <= 2);
857        assert!(
858            collected
859                .collection_errors
860                .iter()
861                .any(|(_, reason)| reason.contains("maximum file count"))
862        );
863    }
864
865    #[test]
866    fn max_total_bytes_limit_stops_collection() {
867        let temp_dir = tempfile::tempdir().expect("temp dir");
868        let root = temp_dir.path();
869        for index in 0..5 {
870            fs::write(root.join(format!("file{index}.txt")), vec![b'x'; 100]).expect("write file");
871        }
872
873        let limits = CollectionLimits {
874            max_total_bytes: Some(150),
875            ..CollectionLimits::unbounded()
876        };
877        let collected = collect_paths_with_limits(root, 0, &[], &limits);
878
879        assert!(collected.limit_reached);
880        assert!(collected.total_file_bytes <= 150);
881        assert!(
882            collected
883                .collection_errors
884                .iter()
885                .any(|(_, reason)| reason.contains("maximum total byte size"))
886        );
887    }
888
889    #[test]
890    fn deadline_already_passed_stops_collection() {
891        let temp_dir = tempfile::tempdir().expect("temp dir");
892        let root = temp_dir.path();
893        fs::write(root.join("file.txt"), "x\n").expect("write file");
894
895        let limits = CollectionLimits {
896            deadline: Some(Instant::now() - Duration::from_secs(1)),
897            ..CollectionLimits::unbounded()
898        };
899        let collected = collect_paths_with_limits(root, 0, &[], &limits);
900
901        assert!(collected.limit_reached);
902        assert!(
903            collected
904                .collection_errors
905                .iter()
906                .any(|(_, reason)| reason.contains("overall time budget"))
907        );
908    }
909
910    #[test]
911    fn unbounded_limits_preserve_full_collection() {
912        let temp_dir = tempfile::tempdir().expect("temp dir");
913        let root = temp_dir.path();
914        for index in 0..5 {
915            fs::write(root.join(format!("file{index}.txt")), "x\n").expect("write file");
916        }
917
918        let collected = collect_paths_with_limits(root, 0, &[], &CollectionLimits::unbounded());
919
920        assert!(!collected.limit_reached);
921        assert_eq!(collected.file_count(), 5);
922    }
923
924    #[test]
925    fn truncated_subtree_keeps_files_collected_within_its_budget() {
926        // Regression: when a recursing subtree collects valid files and then
927        // trips the file-count limit, those already-collected files must still
928        // appear in the merged result instead of being silently dropped.
929        let temp_dir = tempfile::tempdir().expect("temp dir");
930        let root = temp_dir.path();
931
932        let first = root.join("first");
933        fs::create_dir_all(&first).expect("create first dir");
934        for index in 0..3 {
935            fs::write(first.join(format!("f{index}.txt")), "x\n").expect("write file");
936        }
937
938        let second = root.join("second");
939        fs::create_dir_all(&second).expect("create second dir");
940        for index in 0..5 {
941            fs::write(second.join(format!("s{index}.txt")), "x\n").expect("write file");
942        }
943
944        let limits = CollectionLimits {
945            max_file_count: Some(5),
946            ..CollectionLimits::unbounded()
947        };
948        let collected = collect_selected_paths_with_limits(
949            root,
950            &[
951                CollectionFrontier {
952                    path: PathBuf::from("first"),
953                    recurse: true,
954                },
955                CollectionFrontier {
956                    path: PathBuf::from("second"),
957                    recurse: true,
958                },
959            ],
960            0,
961            &[],
962            &limits,
963        );
964
965        // 3 from `first` plus 2 collected from `second` before it tripped.
966        assert!(collected.limit_reached);
967        assert_eq!(collected.file_count(), 5);
968        assert_eq!(
969            collected
970                .files
971                .iter()
972                .filter(|(path, _)| path.starts_with(&first))
973                .count(),
974            3
975        );
976        assert_eq!(
977            collected
978                .files
979                .iter()
980                .filter(|(path, _)| path.starts_with(&second))
981                .count(),
982            2
983        );
984    }
985}