Skip to main content

weavatrix_scan/
scanner.rs

1use crate::config::{EvidenceMode, ScanOptions};
2use crate::content::inspect_files;
3use crate::error::{Error, Result};
4use crate::ignore::{RepositoryMatch, RepositoryMatcher};
5use crate::path::normalized_relative_path;
6use crate::report::{ScanReport, ScannedFile, SkipKind};
7use crate::scan_finalize::finalize_report;
8use crate::scan_limits::{ScanRuntime, apply_total_bytes_limit};
9use crate::scan_match::skip_match;
10use crate::walker::{ErrorPolicy, WalkEntry, WalkError, WalkOperation, WalkSkipReason, Walker};
11use std::fs;
12use std::path::{Path, PathBuf};
13
14pub struct Scanner {
15    root: PathBuf,
16    options: ScanOptions,
17}
18
19impl Scanner {
20    #[must_use]
21    pub fn new(root: impl Into<PathBuf>) -> Self {
22        Self {
23            root: root.into(),
24            options: ScanOptions::default(),
25        }
26    }
27
28    #[must_use]
29    pub fn options(mut self, options: ScanOptions) -> Self {
30        self.options = options;
31        self
32    }
33
34    /// Scans the configured repository root.
35    ///
36    /// # Errors
37    ///
38    /// Returns an error when the root cannot be canonicalized/read, or when a
39    /// local error occurs under `ErrorPolicy::Abort`.
40    pub fn scan(self) -> Result<ScanReport> {
41        scan_repository_with_options(&self.root, &self.options)
42    }
43}
44
45/// Scans a repository with default options.
46///
47/// # Errors
48///
49/// Returns an error when the root cannot be canonicalized/read, or when a local
50/// error occurs under `ErrorPolicy::Abort`.
51pub fn scan_repository(root: impl AsRef<Path>) -> Result<ScanReport> {
52    scan_repository_with_options(root.as_ref(), &ScanOptions::default())
53}
54
55fn scan_repository_with_options(root: &Path, options: &ScanOptions) -> Result<ScanReport> {
56    let canonical = root
57        .canonicalize()
58        .map_err(|source| Error::io(root, source))?;
59    if !canonical.is_dir() {
60        return Err(Error::InvalidRoot(canonical));
61    }
62
63    let mut report = ScanReport::new(
64        canonical.clone(),
65        options.evidence == EvidenceMode::Complete,
66    );
67    let mut walker = Walker::with_options(&canonical, options.walk_options())
68        .map_err(walker_error_into_scan_error)?;
69    let mut matcher = RepositoryMatcher::with_options(&canonical, options)?;
70    let mut runtime = ScanRuntime::new();
71    loop {
72        if let Some(reason) = runtime.before_next(options) {
73            report.terminate(reason);
74            break;
75        }
76        let Some(item) = walker.next() else {
77            break;
78        };
79        runtime.record_entry();
80        match item {
81            Ok(entry) => {
82                process_entry(&entry, options, &mut report, &mut matcher, &mut walker)?;
83            }
84            Err(error) => record_walk_error(error, &canonical, options, &mut report)?,
85        }
86    }
87    report.ignore_sources = matcher.sources().to_vec();
88    report.portable = matcher.portable();
89    if !matcher.warnings().is_empty() {
90        report.complete = false;
91        report.warnings.extend_from_slice(matcher.warnings());
92    }
93    if report.termination.is_none()
94        && let Some(reason) = runtime.external_termination(options)
95    {
96        report.terminate(reason);
97    }
98    apply_total_bytes_limit(&mut report, options);
99    let inspected = inspect_files(std::mem::take(&mut report.files), options, runtime.started)?;
100    report.files = inspected.files;
101    report.skipped.extend(inspected.skipped);
102    if let Some(reason) = inspected.termination {
103        report.terminate(reason);
104    }
105    if !inspected.warnings.is_empty() {
106        report.complete = false;
107        report.warnings.extend(inspected.warnings);
108    }
109    finalize_report(&mut report);
110    Ok(report)
111}
112
113fn process_entry(
114    entry: &WalkEntry,
115    options: &ScanOptions,
116    report: &mut ScanReport,
117    matcher: &mut RepositoryMatcher,
118    walker: &mut Walker,
119) -> Result<()> {
120    let relative_path = entry.relative_path();
121    let relative = normalized_relative_path(relative_path);
122    if entry.depth() == 0 {
123        if let Some(reason) = entry.skip_reason() {
124            report.skip(".".to_owned(), skip_kind(reason), None);
125            return Ok(());
126        }
127        matcher.prepare_directory(entry.path())?;
128        return Ok(());
129    }
130    if entry.depth() < options.effective_min_depth() && !entry.is_dir() {
131        return Ok(());
132    }
133    if entry.is_symlink() && !options.walk.follow_links {
134        report.skip(relative, SkipKind::Symlink, None);
135        return Ok(());
136    }
137    if let Some(reason) = entry.skip_reason() {
138        report.skip(relative, skip_kind(reason), None);
139        return Ok(());
140    }
141
142    if entry.is_dir() {
143        let parent = entry.path().parent().unwrap_or(entry.path());
144        let decision = matcher.matched_prepared(&relative, parent, entry.path(), true);
145        if skip_match(report, relative.clone(), decision) {
146            walker.skip_current_dir();
147            return Ok(());
148        }
149        if decision != RepositoryMatch::OverrideInclude
150            && options.should_skip_directory(entry.file_name())
151        {
152            walker.skip_current_dir();
153            report.skip(relative, SkipKind::StandardDirectory, None);
154            return Ok(());
155        }
156        matcher.prepare_directory(entry.path())?;
157        return Ok(());
158    }
159    if !entry.is_file() {
160        return Ok(());
161    }
162    let parent = entry.path().parent().unwrap_or(entry.path());
163    let decision = matcher.matched_prepared(&relative, parent, entry.path(), false);
164    if skip_match(report, relative.clone(), decision) {
165        return Ok(());
166    }
167    process_file(
168        entry,
169        relative,
170        decision == RepositoryMatch::OverrideInclude,
171        options,
172        report,
173    )
174}
175
176fn process_file(
177    entry: &WalkEntry,
178    relative: String,
179    override_include: bool,
180    options: &ScanOptions,
181    report: &mut ScanReport,
182) -> Result<()> {
183    let path = entry.path();
184    if !override_include && !options.accepts_extension(path) {
185        report.skip(relative, SkipKind::Extension, None);
186        return Ok(());
187    }
188    let bytes = match entry.bytes() {
189        Some(bytes) => bytes,
190        None => match fs::metadata(path) {
191            Ok(metadata) => metadata.len(),
192            Err(source) => {
193                return record_local_io_error(
194                    path,
195                    relative,
196                    WalkOperation::ReadMetadata,
197                    source,
198                    options,
199                    report,
200                );
201            }
202        },
203    };
204    if bytes > options.max_file_bytes {
205        report.skip(
206            relative,
207            SkipKind::Oversized,
208            Some(format!("{bytes} bytes")),
209        );
210        return Ok(());
211    }
212    report.files.push(ScannedFile {
213        absolute: path.to_path_buf(),
214        relative,
215        bytes,
216        content_hash: None,
217    });
218    Ok(())
219}
220
221fn record_walk_error(
222    error: WalkError,
223    root: &Path,
224    options: &ScanOptions,
225    report: &mut ScanReport,
226) -> Result<()> {
227    if options.walk.error_policy == ErrorPolicy::Abort {
228        return Err(walker_error_into_scan_error(error));
229    }
230    let relative = error.path().strip_prefix(root).map_or_else(
231        |_| normalized_relative_path(error.path()),
232        normalized_relative_path,
233    );
234    let relative = if relative.is_empty() {
235        ".".to_owned()
236    } else {
237        relative
238    };
239    let message = format!(
240        "{}: {}",
241        operation_label(error.operation()),
242        error.io_error()
243    );
244    report.skip(relative.clone(), SkipKind::IoError, Some(message.clone()));
245    report.warn(Some(relative), message);
246    Ok(())
247}
248
249fn record_local_io_error(
250    path: &Path,
251    relative: String,
252    operation: WalkOperation,
253    source: std::io::Error,
254    options: &ScanOptions,
255    report: &mut ScanReport,
256) -> Result<()> {
257    if options.walk.error_policy == ErrorPolicy::Abort {
258        return Err(Error::io(path, source));
259    }
260    let message = format!("{}: {source}", operation_label(operation));
261    report.skip(relative.clone(), SkipKind::IoError, Some(message.clone()));
262    report.warn(Some(relative), message);
263    Ok(())
264}
265
266const fn skip_kind(reason: WalkSkipReason) -> SkipKind {
267    match reason {
268        WalkSkipReason::MaxDepth => SkipKind::MaxDepth,
269        WalkSkipReason::FileSystemBoundary => SkipKind::FileSystemBoundary,
270        WalkSkipReason::PathEscape => SkipKind::PathEscape,
271        WalkSkipReason::SymlinkLoop => SkipKind::SymlinkLoop,
272    }
273}
274
275const fn operation_label(operation: WalkOperation) -> &'static str {
276    match operation {
277        WalkOperation::Canonicalize => "canonicalize",
278        WalkOperation::ReadDirectory => "read directory",
279        WalkOperation::ReadEntry => "read entry",
280        WalkOperation::ReadMetadata => "read metadata",
281    }
282}
283
284fn walker_error_into_scan_error(error: WalkError) -> Error {
285    let (path, source) = error.into_parts();
286    Error::io(path, source)
287}