Skip to main content

provenant/
workflow.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use serde_json::{Map as JsonMap, Value as JsonValue};
5use std::path::{Path, PathBuf};
6
7use crate::app::request::{InputMode, ScanRequest};
8use crate::app::scan_pipeline::execute_request;
9use crate::license_detection::DEFAULT_LICENSEDB_URL_TEMPLATE;
10use crate::progress::ProgressMode;
11use crate::scanner::MemoryMode;
12use crate::{Output, ProcessMode};
13
14#[derive(Debug, thiserror::Error)]
15pub enum WorkflowError {
16    #[error("{0}")]
17    InvalidOptions(String),
18    #[error(transparent)]
19    Pipeline(#[from] anyhow::Error),
20}
21
22/// Selects how the workflow facade sources license rules.
23#[derive(Debug, Clone)]
24pub enum LicenseSource {
25    /// Skip license detection entirely.
26    Disabled,
27    /// Use the embedded Provenant license dataset.
28    Embedded,
29    /// Load a custom dataset from a directory containing the expected rules and licenses layout.
30    Directory(PathBuf),
31}
32
33/// High-level configuration for in-process scans through [`scan_path`] and [`scan_paths`].
34///
35/// Defaults stay intentionally conservative: progress is quiet, no scan dimensions are enabled,
36/// input headers are omitted, and ambient `PROVENANT_CACHE` is ignored unless you set
37/// [`ScanOptions::cache_dir`].
38#[derive(Debug, Clone)]
39pub struct ScanOptions {
40    pub progress_mode: ProgressMode,
41    pub process_mode: ProcessMode,
42    pub timeout_seconds: f64,
43    pub max_depth: usize,
44    pub max_in_memory: MemoryMode,
45    pub collect_info: bool,
46    pub detect_license: LicenseSource,
47    pub detect_packages: bool,
48    pub detect_system_packages: bool,
49    pub detect_packages_in_compiled: bool,
50    pub package_only: bool,
51    pub no_assemble: bool,
52    pub detect_copyrights: bool,
53    pub detect_emails: bool,
54    pub detect_urls: bool,
55    pub detect_generated: bool,
56    pub max_emails: usize,
57    pub max_urls: usize,
58    pub include: Vec<String>,
59    pub exclude: Vec<String>,
60    pub include_input_header: bool,
61    pub cache_dir: Option<PathBuf>,
62    pub cache_clear: bool,
63    pub incremental: bool,
64    /// Opt-in trust-mtime mode for warm incremental scans.
65    ///
66    /// When `true`, a cached entry is reused on a size + mtime fingerprint match
67    /// without re-reading and re-hashing the file. Default `false` keeps the
68    /// paranoid full-hash behavior so default scans stay byte-identical.
69    pub cache_trust_mtime: bool,
70    pub reindex: bool,
71    pub no_license_index_cache: bool,
72    pub license_text: bool,
73    pub license_text_diagnostics: bool,
74    pub license_diagnostics: bool,
75    pub unknown_licenses: bool,
76    pub no_sequence_matching: bool,
77    pub license_score: u8,
78    pub filter_clues: bool,
79    pub ignore_author_patterns: Vec<String>,
80    pub ignore_copyright_holder_patterns: Vec<String>,
81    pub only_findings: bool,
82    pub mark_source: bool,
83    pub classify: bool,
84    pub summary: bool,
85    pub license_clarity_score: bool,
86    pub license_references: bool,
87    pub license_url_template: String,
88    pub license_policy: Option<PathBuf>,
89    pub tallies: bool,
90    pub tallies_key_files: bool,
91    pub tallies_with_details: bool,
92    pub facets: Vec<String>,
93    pub tallies_by_facet: bool,
94    pub strip_root: bool,
95    pub full_root: bool,
96    pub header_options: JsonMap<String, JsonValue>,
97    /// Maximum number of files to collect before the walk stops, if any.
98    ///
99    /// Untrusted callers (such as `provenant serve`) set finite ceilings to
100    /// bound denial-of-service exposure; trusted scans leave these unset.
101    pub max_files: Option<usize>,
102    /// Maximum cumulative size of collected files in bytes, if any.
103    pub max_total_bytes: Option<u64>,
104    /// Overall wall-clock budget for the collection pass in seconds, if any.
105    pub scan_deadline_seconds: Option<f64>,
106    /// When `true`, file symlinks whose target escapes the scan root are skipped
107    /// instead of being dereferenced and scanned.
108    pub restrict_out_of_tree_symlinks: bool,
109}
110
111impl Default for ScanOptions {
112    fn default() -> Self {
113        Self {
114            progress_mode: ProgressMode::Quiet,
115            process_mode: ProcessMode::default(),
116            timeout_seconds: 120.0,
117            max_depth: 0,
118            max_in_memory: MemoryMode::Limit(10_000),
119            collect_info: false,
120            detect_license: LicenseSource::Disabled,
121            detect_packages: false,
122            detect_system_packages: false,
123            detect_packages_in_compiled: false,
124            package_only: false,
125            no_assemble: false,
126            detect_copyrights: false,
127            detect_emails: false,
128            detect_urls: false,
129            detect_generated: false,
130            max_emails: 50,
131            max_urls: 50,
132            include: Vec::new(),
133            exclude: Vec::new(),
134            include_input_header: false,
135            cache_dir: None,
136            cache_clear: false,
137            incremental: false,
138            cache_trust_mtime: false,
139            reindex: false,
140            no_license_index_cache: false,
141            license_text: false,
142            license_text_diagnostics: false,
143            license_diagnostics: false,
144            unknown_licenses: false,
145            no_sequence_matching: false,
146            license_score: 0,
147            filter_clues: false,
148            ignore_author_patterns: Vec::new(),
149            ignore_copyright_holder_patterns: Vec::new(),
150            only_findings: false,
151            mark_source: false,
152            classify: false,
153            summary: false,
154            license_clarity_score: false,
155            license_references: false,
156            license_url_template: DEFAULT_LICENSEDB_URL_TEMPLATE.to_string(),
157            license_policy: None,
158            tallies: false,
159            tallies_key_files: false,
160            tallies_with_details: false,
161            facets: Vec::new(),
162            tallies_by_facet: false,
163            strip_root: false,
164            full_root: false,
165            header_options: JsonMap::new(),
166            max_files: None,
167            max_total_bytes: None,
168            scan_deadline_seconds: None,
169            restrict_out_of_tree_symlinks: false,
170        }
171    }
172}
173
174/// Scan a single native filesystem input through the supported high-level workflow facade.
175///
176/// ```
177/// use provenant::workflow::{scan_path, ScanOptions};
178/// use std::fs;
179/// use tempfile::tempdir;
180///
181/// let root = tempdir()?;
182/// let root = root.path();
183/// fs::write(root.join("README.txt"), "hello from doctest\n")?;
184///
185/// let output = scan_path(&root, &ScanOptions::default())?;
186/// assert!(output.files.iter().any(|file| file.path.ends_with("README.txt")));
187/// assert_eq!(output.headers.len(), 1);
188/// assert!(!output.headers[0].options.contains_key("input"));
189/// # Ok::<(), Box<dyn std::error::Error>>(())
190/// ```
191pub fn scan_path(path: impl AsRef<Path>, options: &ScanOptions) -> Result<Output, WorkflowError> {
192    scan_paths([path.as_ref()], options)
193}
194
195/// Scan multiple native filesystem inputs in one in-process workflow run.
196///
197/// Absolute paths are supported as long as they can be resolved through a shared scan root by the
198/// internal pipeline.
199///
200/// ```
201/// use provenant::workflow::{scan_paths, ScanOptions};
202/// use std::fs;
203/// use tempfile::tempdir;
204///
205/// let root = tempdir()?;
206/// let root = root.path();
207/// let left = root.join("left");
208/// let right = root.join("right");
209/// fs::create_dir_all(&left)?;
210/// fs::create_dir_all(&right)?;
211/// fs::write(left.join("one.txt"), "left\n")?;
212/// fs::write(right.join("two.txt"), "right\n")?;
213///
214/// let output = scan_paths([left.as_path(), right.as_path()], &ScanOptions::default())?;
215/// let paths: Vec<_> = output.files.iter().map(|file| file.path.as_str()).collect();
216/// assert!(paths.iter().any(|path| path.ends_with("one.txt")));
217/// assert!(paths.iter().any(|path| path.ends_with("two.txt")));
218/// # Ok::<(), Box<dyn std::error::Error>>(())
219/// ```
220pub fn scan_paths<'a>(
221    paths: impl IntoIterator<Item = &'a Path>,
222    options: &ScanOptions,
223) -> Result<Output, WorkflowError> {
224    let input_paths: Vec<String> = paths
225        .into_iter()
226        .map(|path| path.to_string_lossy().to_string())
227        .collect();
228
229    if input_paths.is_empty() {
230        return Err(WorkflowError::InvalidOptions(
231            "At least one input path is required".to_string(),
232        ));
233    }
234
235    let request = request_for_native_paths(input_paths, options);
236    validate_workflow_request(&request)?;
237
238    execute_request(&request)
239        .map(|executed| executed.output)
240        .map_err(WorkflowError::Pipeline)
241}
242
243fn request_for_native_paths(input_paths: Vec<String>, options: &ScanOptions) -> ScanRequest {
244    let mut header_options = options.header_options.clone();
245    if options.include_input_header {
246        header_options.insert(
247            "input".to_string(),
248            JsonValue::Array(input_paths.iter().cloned().map(JsonValue::String).collect()),
249        );
250    }
251
252    let (license, license_dataset_path) = match &options.detect_license {
253        LicenseSource::Disabled => (false, None),
254        LicenseSource::Embedded => (true, None),
255        LicenseSource::Directory(path) => (true, Some(path.to_string_lossy().to_string())),
256    };
257
258    ScanRequest {
259        input_paths,
260        input_mode: InputMode::Native,
261        output_targets: Vec::new(),
262        output_header_options: header_options,
263        progress_mode: options.progress_mode,
264        process_mode: options.process_mode,
265        timeout_seconds: options.timeout_seconds,
266        quiet: matches!(options.progress_mode, ProgressMode::Quiet),
267        verbose: matches!(options.progress_mode, ProgressMode::Verbose),
268        strip_root: options.strip_root,
269        full_root: options.full_root,
270        include: options.include.clone(),
271        exclude: options.exclude.clone(),
272        paths_files: Vec::new(),
273        respect_process_cache_env: false,
274        cache_dir: options
275            .cache_dir
276            .as_ref()
277            .map(|path| path.to_string_lossy().to_string()),
278        cache_clear: options.cache_clear,
279        incremental: options.incremental,
280        cache_trust_mtime: options.cache_trust_mtime,
281        max_depth: options.max_depth,
282        max_in_memory: options.max_in_memory,
283        info: options.collect_info,
284        package: options.detect_packages,
285        system_package: options.detect_system_packages,
286        package_in_compiled: options.detect_packages_in_compiled,
287        package_only: options.package_only,
288        no_assemble: options.no_assemble,
289        license_dataset_path,
290        reindex: options.reindex,
291        no_license_index_cache: options.no_license_index_cache,
292        license_text: options.license_text,
293        license_text_diagnostics: options.license_text_diagnostics,
294        license_diagnostics: options.license_diagnostics,
295        unknown_licenses: options.unknown_licenses,
296        no_sequence_matching: options.no_sequence_matching,
297        license_score: options.license_score,
298        license_url_template: options.license_url_template.clone(),
299        filter_clues: options.filter_clues,
300        ignore_author: options.ignore_author_patterns.clone(),
301        ignore_copyright_holder: options.ignore_copyright_holder_patterns.clone(),
302        only_findings: options.only_findings,
303        mark_source: options.mark_source,
304        classify: options.classify,
305        summary: options.summary,
306        license_clarity_score: options.license_clarity_score,
307        license_references: options.license_references,
308        license_policy: options
309            .license_policy
310            .as_ref()
311            .map(|path| path.to_string_lossy().to_string()),
312        fail_on: None,
313        tallies: options.tallies,
314        tallies_key_files: options.tallies_key_files,
315        tallies_with_details: options.tallies_with_details,
316        facet: options.facets.clone(),
317        tallies_by_facet: options.tallies_by_facet,
318        generated: options.detect_generated,
319        license,
320        copyright: options.detect_copyrights,
321        email: options.detect_emails,
322        max_email: options.max_emails,
323        url: options.detect_urls,
324        max_url: options.max_urls,
325        scan_bounds: crate::app::request::ScanBounds {
326            max_files: options.max_files,
327            max_total_bytes: options.max_total_bytes,
328            deadline_seconds: options.scan_deadline_seconds,
329            restrict_out_of_tree_symlinks: options.restrict_out_of_tree_symlinks,
330        },
331    }
332}
333
334fn validate_workflow_request(request: &ScanRequest) -> Result<(), WorkflowError> {
335    let license_enabled = request.license;
336
337    if request.strip_root && request.full_root {
338        return Err(WorkflowError::InvalidOptions(
339            "strip_root and full_root are mutually exclusive".to_string(),
340        ));
341    }
342
343    if request.license_text && !license_enabled {
344        return Err(WorkflowError::InvalidOptions(
345            "license_text requires detect_license".to_string(),
346        ));
347    }
348
349    if request.license_text_diagnostics && !request.license_text {
350        return Err(WorkflowError::InvalidOptions(
351            "license_text_diagnostics requires license_text".to_string(),
352        ));
353    }
354
355    if request.license_diagnostics && !license_enabled {
356        return Err(WorkflowError::InvalidOptions(
357            "license_diagnostics requires detect_license".to_string(),
358        ));
359    }
360
361    if request.unknown_licenses && !license_enabled {
362        return Err(WorkflowError::InvalidOptions(
363            "unknown_licenses requires detect_license".to_string(),
364        ));
365    }
366
367    if request.license_references && !license_enabled {
368        return Err(WorkflowError::InvalidOptions(
369            "license_references requires detect_license".to_string(),
370        ));
371    }
372
373    if request.license_url_template != DEFAULT_LICENSEDB_URL_TEMPLATE && !license_enabled {
374        return Err(WorkflowError::InvalidOptions(
375            "license_url_template requires detect_license".to_string(),
376        ));
377    }
378
379    if request.package_only && license_enabled {
380        return Err(WorkflowError::InvalidOptions(
381            "package_only cannot be combined with detect_license".to_string(),
382        ));
383    }
384
385    if request.package_only && request.summary {
386        return Err(WorkflowError::InvalidOptions(
387            "package_only cannot be combined with summary".to_string(),
388        ));
389    }
390
391    if request.package_only && request.package {
392        return Err(WorkflowError::InvalidOptions(
393            "package_only cannot be combined with detect_packages".to_string(),
394        ));
395    }
396
397    if request.package_only && request.system_package {
398        return Err(WorkflowError::InvalidOptions(
399            "package_only cannot be combined with detect_system_packages".to_string(),
400        ));
401    }
402
403    if request.summary && !request.classify {
404        return Err(WorkflowError::InvalidOptions(
405            "summary requires classify".to_string(),
406        ));
407    }
408
409    if request.license_clarity_score && !request.classify {
410        return Err(WorkflowError::InvalidOptions(
411            "license_clarity_score requires classify".to_string(),
412        ));
413    }
414
415    if request.tallies_key_files && !(request.tallies && request.classify) {
416        return Err(WorkflowError::InvalidOptions(
417            "tallies_key_files requires tallies and classify".to_string(),
418        ));
419    }
420
421    if request.tallies_by_facet && request.facet.is_empty() {
422        return Err(WorkflowError::InvalidOptions(
423            "tallies_by_facet requires at least one facet definition".to_string(),
424        ));
425    }
426
427    if request.tallies_by_facet && !request.tallies {
428        return Err(WorkflowError::InvalidOptions(
429            "tallies_by_facet requires tallies".to_string(),
430        ));
431    }
432
433    if request.mark_source && !request.info {
434        return Err(WorkflowError::InvalidOptions(
435            "mark_source requires collect_info".to_string(),
436        ));
437    }
438
439    if request.license_score > 100 {
440        return Err(WorkflowError::InvalidOptions(
441            "license_score must be between 0 and 100".to_string(),
442        ));
443    }
444
445    Ok(())
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use std::fs;
452
453    #[test]
454    fn scan_path_requires_at_least_one_input() {
455        let result = scan_paths(std::iter::empty::<&Path>(), &ScanOptions::default());
456        assert!(result.is_err());
457    }
458
459    #[test]
460    fn workflow_request_populates_input_header() {
461        let options = ScanOptions {
462            include_input_header: true,
463            ..ScanOptions::default()
464        };
465        let request = request_for_native_paths(vec!["src".to_string()], &options);
466        assert!(request.output_header_options.contains_key("input"));
467    }
468
469    #[test]
470    fn workflow_validation_rejects_license_dependent_flags_without_license() {
471        let options = ScanOptions {
472            license_references: true,
473            ..ScanOptions::default()
474        };
475
476        let request = request_for_native_paths(vec!["src".to_string()], &options);
477        let error = validate_workflow_request(&request).expect_err("validation should fail");
478        assert!(matches!(error, WorkflowError::InvalidOptions(_)));
479        assert!(
480            error
481                .to_string()
482                .contains("license_references requires detect_license")
483        );
484    }
485
486    #[test]
487    fn workflow_validation_rejects_package_only_with_regular_package_modes() {
488        let options = ScanOptions {
489            package_only: true,
490            detect_packages: true,
491            ..ScanOptions::default()
492        };
493
494        let request = request_for_native_paths(vec!["src".to_string()], &options);
495        let error = validate_workflow_request(&request).expect_err("validation should fail");
496        assert!(matches!(error, WorkflowError::InvalidOptions(_)));
497        assert!(
498            error
499                .to_string()
500                .contains("package_only cannot be combined with detect_packages")
501        );
502    }
503
504    #[test]
505    fn workflow_validation_rejects_classify_dependent_flags_without_classify() {
506        let options = ScanOptions {
507            summary: true,
508            ..ScanOptions::default()
509        };
510
511        let request = request_for_native_paths(vec!["src".to_string()], &options);
512        let error = validate_workflow_request(&request).expect_err("validation should fail");
513        assert!(matches!(error, WorkflowError::InvalidOptions(_)));
514        assert!(error.to_string().contains("summary requires classify"));
515    }
516
517    #[test]
518    fn scan_path_runs_a_basic_in_process_scan() {
519        let temp_dir = tempfile::TempDir::new().expect("create temp dir");
520        fs::write(
521            temp_dir.path().join("README.txt"),
522            "hello from workflow facade\n",
523        )
524        .expect("write fixture file");
525
526        let options = ScanOptions {
527            collect_info: true,
528            include_input_header: true,
529            ..ScanOptions::default()
530        };
531
532        let output = scan_path(temp_dir.path(), &options).expect("workflow scan should succeed");
533
534        assert_eq!(output.headers.len(), 1);
535        assert!(!output.files.is_empty());
536        assert!(output.headers[0].options.contains_key("input"));
537    }
538
539    #[test]
540    fn scan_paths_supports_multiple_absolute_inputs() {
541        let temp_dir = tempfile::TempDir::new().expect("create temp dir");
542        let left = temp_dir.path().join("left");
543        let right = temp_dir.path().join("right");
544        fs::create_dir_all(&left).expect("create left dir");
545        fs::create_dir_all(&right).expect("create right dir");
546        fs::write(left.join("one.txt"), "left\n").expect("write left fixture");
547        fs::write(right.join("two.txt"), "right\n").expect("write right fixture");
548
549        let output = scan_paths([left.as_path(), right.as_path()], &ScanOptions::default())
550            .expect("workflow scan should succeed for multiple absolute inputs");
551
552        assert!(
553            output
554                .files
555                .iter()
556                .any(|file| file.path.ends_with("one.txt"))
557        );
558        assert!(
559            output
560                .files
561                .iter()
562                .any(|file| file.path.ends_with("two.txt"))
563        );
564    }
565}