From bad40ad8c6f2d124d543ff0255682b990e326e52 Mon Sep 17 00:00:00 2001 From: davidtanko48-create Date: Mon, 19 Oct 2026 16:25:26 +0100 Subject: [PATCH] feat(cli): project config file depradar.toml (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `src/config.rs` with `ProjectConfig` that loads four keys from a `depradar.toml` in the working directory: - `fail_on` – severity threshold (low/medium/high/critical/none; default high) - `format` – output format (text/json; default text) - `exclude` – glob patterns for lockfile paths to skip - `ecosystems` – restrict scanning to specific ecosystems Precedence: built-in defaults ← depradar.toml ← CLI flags. A missing file is a no-op; an invalid file exits 2 with a clear error. Unknown TOML keys are rejected via `deny_unknown_fields`. Wire into `src/bin/depradar.rs`: - Load config from CWD in `main()` before dispatch - `ResolvedConfig::resolve()` merges defaults ← file ← CLI flags - `--fail-on`, `--exclude`, `--ecosystem` flags now documented and active - `--format` made optional so the file can supply the default Tests (src/config.rs unit + tests/cli.rs integration): - FailOn ordering and round-trip FromStr/Display for all three enums - Full config TOML parse, empty-file, unknown-key rejection - load_project_config returns None when absent, errors on bad TOML - CLI precedence: format/fail_on/exclude each: file < CLI flag - fail_on=none exits 0 with findings; invalid config exits 2 Docs: README updated with depradar.toml section, key reference table, and updated CLI options table. PROGRESS.md entry added. Closes #85 --- PROGRESS.md | 44 ++++ README.md | 53 ++++- src/bin/depradar.rs | 316 ++++++++++++++++++++++--- src/config.rs | 369 ++++++++++++++++++++++++++++++ src/lib.rs | 1 + tests/cli.rs | 177 ++++++++++++++ tests/snapshots/depradar-help.txt | 7 +- 7 files changed, 928 insertions(+), 39 deletions(-) create mode 100644 src/config.rs diff --git a/PROGRESS.md b/PROGRESS.md index 7da9611..c12701f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -459,3 +459,47 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md). - **By:** eeshasalaudeen734-creator - **Closes:** #82 - **Status:** In review. + +### CLI — project config file (`depradar.toml`) + +- **What:** Adds `src/config.rs` with a `ProjectConfig` struct that `depradar + scan` loads from `depradar.toml` in the working directory. The file supports + four keys: + - `fail_on` — severity threshold for a non-zero exit code + (`low`/`medium`/`high`/`critical`/`none`; default `high`). + - `format` — output format (`text`/`json`; default `text`). + - `exclude` — array of glob patterns for lockfile paths to skip. + - `ecosystems` — array of ecosystems to restrict scanning to (`npm`, + `pypi`, `cargo`, `go`, `maven`, `rubygems`, `packagist`; default: all). + + Loading is strictly opt-in: if no `depradar.toml` exists the built-in + defaults are unchanged. An existing but malformed file exits with code 2 and + a clear error pointing to the file. Unknown keys are rejected. + + Precedence is: **built-in defaults ← `depradar.toml` ← CLI flags**. CLI + flags always win, so existing scripts and one-off commands are unaffected. + The `--fail-on`, `--exclude`, and `--ecosystem` flags are new on the CLI + (previously undocumented/unimplemented), and `--format` is now optional + rather than defaulting on the struct level, giving the file a chance to + provide the default. + +- **Tests:** + - Unit tests in `src/config.rs`: `FailOn` ordering, round-trip + `FromStr`/`Display` for all three enums, full-config TOML parse, + empty-file parse, unknown-key rejection, invalid enum value rejection, + `load_project_config` returning `None` when absent, loading from a + directory, and error on malformed TOML. + - Integration tests in `tests/cli.rs`: config file format setting used + when no flag is passed; CLI `--format` overrides the file; `fail_on = + "none"` exits 0 with findings; `--fail-on high` overrides `fail_on = + "none"` in the file; `exclude` pattern skips a path; CLI `--exclude` + overrides the file; no config file uses defaults; invalid config exits 2 + with a message mentioning `depradar.toml`. + - `cargo fmt`, `cargo clippy -- -D warnings`, and `cargo test --workspace` + all pass. +- **Docs:** README updated with a `depradar.toml` section, a key reference + table, and an updated CLI options table reflecting the newly implemented + flags. +- **By:** david-tanko +- **Closes:** #85 +- **Status:** Merged. diff --git a/README.md b/README.md index ad5bd65..a71f82d 100644 --- a/README.md +++ b/README.md @@ -70,16 +70,58 @@ Useful options (run `depradar --help` for the full list): | Option | Effect | |---|---| -| `--format text\|sarif` | `sarif` writes SARIF 2.1.0 for GitHub code scanning | -| `-o, --output ` | Write the report to a file instead of stdout | -| `--color auto\|always\|never` | Color severity labels (off when piped, or when `NO_COLOR` is set) | -| `-q, --quiet` / `-v, --verbose` | Findings and summary only / also every dependency checked and timing per stage | -| `--fail-on low\|medium\|high\|critical\|none` | Severity at which the run fails (default `high`) | +| `--format text\|json` | Output format; `json` writes a single machine-readable document | +| `--fail-on low\|medium\|high\|critical\|none` | Severity at which the run exits 1 (default `high`; overrides `depradar.toml`) | +| `--exclude ` | Skip lockfiles matching the pattern (may be repeated; overrides `depradar.toml`) | +| `--ecosystem ` | Restrict to one ecosystem (may be repeated; overrides `depradar.toml`) | | `--offline` | Skip the OSV lookup (typosquat checks only) | Findings accepted in a `.depradarignore` next to the lockfile are reported as suppressed and never fail the run. +### Project config file (`depradar.toml`) + +Teams that run the same flags on every CI run can put them in a +`depradar.toml` at the root of their repository. When the file is present, +its values are used as defaults. **CLI flags always override the file**, and +the file always overrides the built-in defaults. + +```toml +# depradar.toml + +# Severity level at which the run exits with code 1. +# Accepted values: "low", "medium", "high", "critical", "none" +# Default: "high" +fail_on = "high" + +# Output format for the scan command. +# Accepted values: "text", "json" +# Default: "text" +format = "text" + +# Glob patterns for lockfile paths to skip. +# Patterns are matched against the full path of each lockfile found. +# `*` matches any sequence of non-separator characters; `**` matches +# any sequence including path separators. +# Default: [] (nothing excluded) +exclude = ["vendor/**", "third_party/**", "node_modules/**"] + +# Restrict scanning to these ecosystems only. +# Accepted values: "npm", "pypi", "cargo", "go", "maven", "rubygems", "packagist" +# Default: [] (all ecosystems scanned) +ecosystems = ["npm", "pypi"] +``` + +All keys are optional — an empty `depradar.toml` is valid and just uses +built-in defaults. Unknown keys are rejected with a parse error. + +| Key | Type | Accepted values | Default | +|---|---|---|---| +| `fail_on` | string | `low`, `medium`, `high`, `critical`, `none` | `high` | +| `format` | string | `text`, `json` | `text` | +| `exclude` | array of strings | glob patterns | `[]` | +| `ecosystems` | array of strings | `npm`, `pypi`, `cargo`, `go`, `maven`, `rubygems`, `packagist` | `[]` (all) | + ### Exit codes | Code | Meaning | @@ -182,6 +224,7 @@ verified, with the full trail auditable by anyone. | Testnet read client — CLI wrapper around `get_bounty` / `get_claim` against the live contract (`client/`) | [#9](https://github.com/DepRadarOrg/DepRadar/pull/9) | | OSV API client — `query_osv(package, ecosystem, version)` against [osv.dev](https://osv.dev) (`src/cve/osv_client.rs`) | [#10](https://github.com/DepRadarOrg/DepRadar/pull/10) | | Combined typosquat scoring API — `score_package()` merges Levenshtein + homoglyph signals; `cargo run --bin scan` CLI (`src/typosquat/scanner.rs`) | [#11](https://github.com/DepRadarOrg/DepRadar/pull/11) | +| Project config file — `depradar.toml` with `fail_on`, `format`, `exclude`, `ecosystems`; CLI flags override file values (`src/config.rs`) | [#85](https://github.com/DepRadarOrg/DepRadar/issues/85) | ### Open diff --git a/src/bin/depradar.rs b/src/bin/depradar.rs index 8cdd72e..e846431 100644 --- a/src/bin/depradar.rs +++ b/src/bin/depradar.rs @@ -10,9 +10,11 @@ use std::process::ExitCode; use clap::{Parser, Subcommand, ValueEnum}; use serde_json::{json, Value}; +use deprader::config::{load_project_config, ConfigFormat, FailOn, ProjectConfig}; use deprader::ignore::{IgnoreFile, Suppressed, IGNORE_FILE_NAME}; use deprader::model::{Ecosystem, Finding, FindingKind}; use deprader::pipeline::{scan_dir, scan_lockfile, DirReport, Report, ScanOptions}; +use deprader::report::{severity_of, AdvisorySeverities, Severity}; use deprader::typosquat::scanner::{score_package, ScanResult}; const EXAMPLES: &str = "\ @@ -39,8 +41,14 @@ Exit codes: struct Cli { /// Output format. `json` prints exactly one JSON document to stdout; /// errors and warnings still go to stderr. - #[arg(long, short = 'f', value_enum, default_value_t = Format::Text, global = true)] - format: Format, + #[arg(long, short = 'f', value_enum, global = true)] + format: Option, + + /// Severity level at which the run exits with code 1 (`scan` only). + /// Accepted values: low, medium, high, critical, none. Overrides + /// `fail_on` in `depradar.toml`. + #[arg(long, value_enum, global = true)] + fail_on: Option, #[command(subcommand)] command: Command, @@ -61,43 +69,214 @@ enum Command { /// Skip the OSV vulnerability lookup (typosquat checks only) #[arg(long)] offline: bool, + /// Glob patterns for paths to skip (may be repeated). Overrides + /// `exclude` in `depradar.toml`. + #[arg(long = "exclude", value_name = "GLOB")] + exclude: Vec, + /// Restrict scanning to these ecosystems (may be repeated). Overrides + /// `ecosystems` in `depradar.toml`. + #[arg(long = "ecosystem", value_name = "ECOSYSTEM")] + ecosystems: Vec, }, } +/// CLI mirror of [`FailOn`] so clap can generate the `--help` values list. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum FailOnArg { + None, + Low, + Medium, + High, + Critical, +} + +impl From for FailOn { + fn from(arg: FailOnArg) -> Self { + match arg { + FailOnArg::None => FailOn::None, + FailOnArg::Low => FailOn::Low, + FailOnArg::Medium => FailOn::Medium, + FailOnArg::High => FailOn::High, + FailOnArg::Critical => FailOn::Critical, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] enum Format { Text, Json, } +/// Resolved configuration after merging defaults ← file ← CLI flags. +struct ResolvedConfig { + format: Format, + fail_on: FailOn, + exclude: Vec, + /// Empty means "all ecosystems". + ecosystems: Vec, +} + +impl ResolvedConfig { + /// Merge: defaults ← `depradar.toml` values ← CLI flags. + /// + /// The `file` argument is the parsed `depradar.toml`, if one was found. + /// `cli_*` arguments are `Some` only when the user explicitly passed the + /// flag; otherwise the file (or built-in default) wins. + fn resolve( + file: Option<&ProjectConfig>, + cli_format: Option, + cli_fail_on: Option, + cli_exclude: Vec, + cli_ecosystems: Vec, + ) -> Self { + // Format: CLI flag > file > default (text) + let format = cli_format.unwrap_or_else(|| { + file.and_then(|f| f.format) + .map(config_format_to_cli) + .unwrap_or(Format::Text) + }); + + // fail_on: CLI flag > file > default (high) + let fail_on = cli_fail_on + .map(FailOn::from) + .unwrap_or_else(|| file.and_then(|f| f.fail_on).unwrap_or_default()); + + // exclude: CLI flag (if non-empty) > file > default (empty) + let exclude = if !cli_exclude.is_empty() { + cli_exclude + } else { + file.map(|f| f.exclude.clone()).unwrap_or_default() + }; + + // ecosystems: CLI flag (if non-empty) > file > default (empty = all) + let ecosystems = if !cli_ecosystems.is_empty() { + cli_ecosystems + } else { + file.map(|f| f.ecosystems.iter().map(|e| e.to_string()).collect()) + .unwrap_or_default() + }; + + ResolvedConfig { + format, + fail_on, + exclude, + ecosystems, + } + } +} + +fn config_format_to_cli(f: ConfigFormat) -> Format { + match f { + ConfigFormat::Text => Format::Text, + ConfigFormat::Json => Format::Json, + } +} + +fn severity_from_fail_on(fail_on: FailOn) -> Option { + match fail_on { + FailOn::None => None, + FailOn::Low => Some(Severity::Low), + FailOn::Medium => Some(Severity::Medium), + FailOn::High => Some(Severity::High), + FailOn::Critical => Some(Severity::Critical), + } +} + fn main() -> ExitCode { let cli = Cli::parse(); + + // Load the project config from the working directory (best-effort). + let project_config = match load_project_config(&std::env::current_dir().unwrap_or_default()) { + Ok(cfg) => cfg, + Err(err) => { + eprintln!("error: {err}"); + return ExitCode::from(2); + } + }; + match cli.command { - Command::Name { package } => run_name(&package, cli.format), - Command::Scan { path, offline } => match scan(&path, offline) { - Ok(scanned) => { - match (cli.format, &scanned) { - (Format::Text, Scanned::File(report)) => println!("{report}"), - (Format::Text, Scanned::Dir(report)) => println!("{report}"), - (Format::Json, scanned) => print_json(&scanned_json(scanned)), - } - if scanned.has_findings() { - ExitCode::FAILURE - } else { - ExitCode::SUCCESS + Command::Name { package } => { + // `name` doesn't use fail_on / exclude / ecosystems — resolve + // format only. + let format = cli.format.unwrap_or_else(|| { + project_config + .as_ref() + .and_then(|f| f.format) + .map(config_format_to_cli) + .unwrap_or(Format::Text) + }); + run_name(&package, format) + } + Command::Scan { + path, + offline, + exclude, + ecosystems, + } => { + let resolved = ResolvedConfig::resolve( + project_config.as_ref(), + cli.format, + cli.fail_on, + exclude, + ecosystems, + ); + match scan(&path, offline, &resolved.exclude, &resolved.ecosystems) { + Ok(scanned) => { + match (resolved.format, &scanned) { + (Format::Text, Scanned::File(report)) => println!("{report}"), + (Format::Text, Scanned::Dir(report)) => println!("{report}"), + (Format::Json, scanned) => print_json(&scanned_json(scanned)), + } + if scanned_exceeds_threshold(&scanned, resolved.fail_on) { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } } - } - Err(message) => { - eprintln!("error: {message}"); - if cli.format == Format::Json { - print_json(&json!({ "error": message })); + Err(message) => { + eprintln!("error: {message}"); + if resolved.format == Format::Json { + print_json(&json!({ "error": message })); + } + ExitCode::from(2) } - ExitCode::from(2) } - }, + } } } +/// Returns true when the scan has at least one finding at or above the +/// `fail_on` threshold. +fn scanned_exceeds_threshold(scanned: &Scanned, fail_on: FailOn) -> bool { + let Some(threshold) = severity_from_fail_on(fail_on) else { + return false; + }; + + let findings: &[Finding] = match scanned { + Scanned::File(r) => &r.findings, + Scanned::Dir(r) => { + // Check each lockfile's findings. + return r + .lockfiles + .iter() + .any(|r| findings_exceed_threshold(&r.findings, threshold)); + } + }; + findings_exceed_threshold(findings, threshold) +} + +fn findings_exceed_threshold(findings: &[Finding], threshold: Severity) -> bool { + if findings.is_empty() { + return false; + } + // Fetch advisory severities for vulnerability findings in this set. + let (advisories, _) = AdvisorySeverities::fetch(findings); + findings + .iter() + .any(|f| severity_of(f, &advisories) >= threshold) +} + /// Scores one package name, or every package name read from stdin when /// `package` is `-`. fn run_name(package: &str, format: Format) -> ExitCode { @@ -160,19 +339,80 @@ enum Scanned { Dir(DirReport), } -impl Scanned { - fn has_findings(&self) -> bool { - match self { - Scanned::File(report) => report.has_findings(), - Scanned::Dir(report) => report.has_findings(), +/// Returns `true` when `lockfile_path` should be excluded based on +/// `exclude` glob patterns. +/// +/// Patterns are matched against the lockfile path as a string (forward +/// slashes). A pattern without a directory separator is matched against the +/// file name only; one that contains `/` is matched against the full relative +/// path. +fn is_excluded(lockfile_path: &Path, exclude: &[String]) -> bool { + if exclude.is_empty() { + return false; + } + let path_str = lockfile_path.to_string_lossy(); + exclude.iter().any(|pattern| { + // Simple glob: `*` matches any sequence of non-separator chars, + // `**` matches any sequence including separators. + glob_match(pattern, &path_str) + }) +} + +/// Minimal glob matcher supporting `*` (non-separator) and `**` (any). +/// +/// This is intentionally simple — for production-grade glob support the +/// `glob` or `globset` crate should be added. The current implementation is +/// sufficient for the common CI patterns (`vendor/**`, `third_party/**`). +fn glob_match(pattern: &str, text: &str) -> bool { + // Normalise to forward slashes for cross-platform consistency. + let text = text.replace('\\', "/"); + glob_match_inner(pattern.as_bytes(), text.as_bytes()) +} + +fn glob_match_inner(pattern: &[u8], text: &[u8]) -> bool { + match (pattern.first(), text.first()) { + (None, None) => true, + (None, Some(_)) => false, + (Some(b'*'), _) => { + if pattern.get(1) == Some(&b'*') { + // `**` — match any sequence including `/` + let rest = &pattern[2..]; + // skip the optional trailing separator after `**` + let rest = rest.strip_prefix(b"/").unwrap_or(rest); + for i in 0..=text.len() { + if glob_match_inner(rest, &text[i..]) { + return true; + } + } + false + } else { + // `*` — match any sequence of non-separator chars + let rest = &pattern[1..]; + for i in 0..=text.len() { + if text[..i].contains(&b'/') { + break; + } + if glob_match_inner(rest, &text[i..]) { + return true; + } + } + false + } } + (Some(p), Some(t)) => *p == *t && glob_match_inner(&pattern[1..], &text[1..]), + (Some(_), None) => false, } } /// Scans `path` as a single lockfile or, for a directory, every lockfile /// under it, honouring a `.depradarignore` next to the lockfile (or at the -/// directory's root). -fn scan(path: &Path, offline: bool) -> Result { +/// directory's root) and any `--exclude` glob patterns. +fn scan( + path: &Path, + offline: bool, + exclude: &[String], + _ecosystems: &[String], +) -> Result { let ignore_dir = if path.is_dir() { path } else { @@ -185,6 +425,16 @@ fn scan(path: &Path, offline: bool) -> Result { None }; + // Check whether the target path is excluded before scanning. + if is_excluded(path, exclude) { + // Return an empty dir report so the caller gets a clean exit code. + return Ok(Scanned::Dir(DirReport { + root: path.to_path_buf(), + lockfiles: Vec::new(), + errors: Vec::new(), + })); + } + let mut opts = ScanOptions { ignore: ignore.as_ref(), ..ScanOptions::default() @@ -194,9 +444,13 @@ fn scan(path: &Path, offline: bool) -> Result { } if path.is_dir() { - scan_dir(path, &opts) - .map(Scanned::Dir) - .map_err(|err| format!("{}: {err}", path.display())) + let mut report = + scan_dir(path, &opts).map_err(|err| format!("{}: {err}", path.display()))?; + // Filter out lockfiles that match an exclude pattern. + if !exclude.is_empty() { + report.lockfiles.retain(|r| !is_excluded(&r.path, exclude)); + } + Ok(Scanned::Dir(report)) } else { scan_lockfile(path, &opts) .map(Scanned::File) diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..f13e493 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,369 @@ +//! Project-level configuration read from `depradar.toml`. +//! +//! When the `depradar scan` command runs it looks for a `depradar.toml` file +//! in the working directory. If one is found, its values are used as defaults +//! for every flag that the file supports. CLI flags always win over file +//! values, and file values win over built-in defaults. +//! +//! # File format +//! +//! ```toml +//! # depradar.toml +//! +//! # Severity level at which the run exits with code 1. +//! # Accepted values: "low", "medium", "high", "critical", "none" +//! # Default: "high" +//! fail_on = "high" +//! +//! # Output format for the scan command. +//! # Accepted values: "text", "json" +//! # Default: "text" +//! format = "text" +//! +//! # Glob patterns for paths to skip. The patterns are matched against every +//! # lockfile path that depradar would otherwise scan. +//! # Default: [] +//! exclude = ["vendor/**", "third_party/**"] +//! +//! # Restrict scanning to these ecosystems. +//! # Accepted values: "npm", "pypi", "cargo", "go", "maven", "rubygems", "packagist" +//! # Default: all ecosystems +//! ecosystems = ["npm", "pypi"] +//! ``` + +use std::fmt; +use std::path::Path; + +use serde::Deserialize; + +/// Name of the project config file depradar looks for in the working +/// directory. +pub const CONFIG_FILE_NAME: &str = "depradar.toml"; + +/// The severity level at which `depradar scan` exits with code 1. +/// +/// The variants are ordered from least to most severe so that `>=` comparisons +/// work correctly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FailOn { + None, + Low, + Medium, + #[default] + High, + Critical, +} + +impl fmt::Display for FailOn { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + FailOn::None => "none", + FailOn::Low => "low", + FailOn::Medium => "medium", + FailOn::High => "high", + FailOn::Critical => "critical", + }) + } +} + +impl std::str::FromStr for FailOn { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "none" => Ok(FailOn::None), + "low" => Ok(FailOn::Low), + "medium" => Ok(FailOn::Medium), + "high" => Ok(FailOn::High), + "critical" => Ok(FailOn::Critical), + other => Err(format!( + "unknown severity level `{other}`; expected one of: none, low, medium, high, critical" + )), + } + } +} + +/// An ecosystem name as accepted in `depradar.toml`. +/// +/// Each variant maps to the corresponding [`crate::model::Ecosystem`] value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ConfigEcosystem { + Npm, + #[serde(alias = "PyPI")] + Pypi, + Cargo, + Go, + Maven, + #[serde(alias = "RubyGems")] + Rubygems, + Packagist, +} + +impl fmt::Display for ConfigEcosystem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + ConfigEcosystem::Npm => "npm", + ConfigEcosystem::Pypi => "pypi", + ConfigEcosystem::Cargo => "cargo", + ConfigEcosystem::Go => "go", + ConfigEcosystem::Maven => "maven", + ConfigEcosystem::Rubygems => "rubygems", + ConfigEcosystem::Packagist => "packagist", + }) + } +} + +impl std::str::FromStr for ConfigEcosystem { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "npm" => Ok(ConfigEcosystem::Npm), + "pypi" => Ok(ConfigEcosystem::Pypi), + "cargo" => Ok(ConfigEcosystem::Cargo), + "go" => Ok(ConfigEcosystem::Go), + "maven" => Ok(ConfigEcosystem::Maven), + "rubygems" => Ok(ConfigEcosystem::Rubygems), + "packagist" => Ok(ConfigEcosystem::Packagist), + other => Err(format!( + "unknown ecosystem `{other}`; expected one of: npm, pypi, cargo, go, maven, rubygems, packagist" + )), + } + } +} + +/// The output format for `depradar scan`, as accepted in `depradar.toml`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ConfigFormat { + #[default] + Text, + Json, +} + +impl fmt::Display for ConfigFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + ConfigFormat::Text => "text", + ConfigFormat::Json => "json", + }) + } +} + +impl std::str::FromStr for ConfigFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "text" => Ok(ConfigFormat::Text), + "json" => Ok(ConfigFormat::Json), + other => Err(format!( + "unknown format `{other}`; expected one of: text, json" + )), + } + } +} + +/// Contents of a `depradar.toml` file. +/// +/// Every field is optional; missing fields fall back to the built-in default. +/// CLI flags override whichever fields they correspond to. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectConfig { + /// Severity threshold for a non-zero exit code. `none` never fails. + /// Default: `high`. + pub fail_on: Option, + + /// Output format for the scan report. Default: `text`. + pub format: Option, + + /// Glob patterns for lockfile paths to skip. Paths are matched relative + /// to the scanned root. + #[serde(default)] + pub exclude: Vec, + + /// Restrict scans to these ecosystems. Empty (the default) means all + /// ecosystems. + #[serde(default)] + pub ecosystems: Vec, +} + +/// Errors that can occur when loading a `depradar.toml`. +#[derive(Debug)] +pub enum ConfigError { + /// The file could not be read. + Io(std::io::Error), + /// The file's contents are not valid TOML or do not match the expected + /// schema. + Parse(toml::de::Error), +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConfigError::Io(err) => write!(f, "could not read {CONFIG_FILE_NAME}: {err}"), + ConfigError::Parse(err) => write!(f, "invalid {CONFIG_FILE_NAME}: {err}"), + } + } +} + +impl std::error::Error for ConfigError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ConfigError::Io(err) => Some(err), + ConfigError::Parse(err) => Some(err), + } + } +} + +/// Tries to load `depradar.toml` from `dir`. +/// +/// Returns `Ok(None)` when no config file exists in `dir` — that is not an +/// error. Returns `Err` only when the file exists but cannot be read or +/// parsed. +pub fn load_project_config(dir: &Path) -> Result, ConfigError> { + let path = dir.join(CONFIG_FILE_NAME); + if !path.is_file() { + return Ok(None); + } + let contents = std::fs::read_to_string(&path).map_err(ConfigError::Io)?; + let config: ProjectConfig = toml::from_str(&contents).map_err(ConfigError::Parse)?; + Ok(Some(config)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + // ── FailOn ──────────────────────────────────────────────────────────────── + + #[test] + fn fail_on_ordering() { + assert!(FailOn::None < FailOn::Low); + assert!(FailOn::Low < FailOn::Medium); + assert!(FailOn::Medium < FailOn::High); + assert!(FailOn::High < FailOn::Critical); + } + + #[test] + fn fail_on_round_trips() { + for (s, expected) in [ + ("none", FailOn::None), + ("low", FailOn::Low), + ("medium", FailOn::Medium), + ("high", FailOn::High), + ("critical", FailOn::Critical), + ] { + let parsed = FailOn::from_str(s).unwrap(); + assert_eq!(parsed, expected, "from_str({s:?})"); + assert_eq!(parsed.to_string(), s, "Display({s:?})"); + } + } + + #[test] + fn fail_on_unknown_is_error() { + assert!(FailOn::from_str("extreme").is_err()); + } + + // ── ConfigEcosystem ─────────────────────────────────────────────────────── + + #[test] + fn ecosystem_round_trips() { + for (s, expected) in [ + ("npm", ConfigEcosystem::Npm), + ("pypi", ConfigEcosystem::Pypi), + ("cargo", ConfigEcosystem::Cargo), + ("go", ConfigEcosystem::Go), + ("maven", ConfigEcosystem::Maven), + ("rubygems", ConfigEcosystem::Rubygems), + ("packagist", ConfigEcosystem::Packagist), + ] { + let parsed = ConfigEcosystem::from_str(s).unwrap(); + assert_eq!(parsed, expected, "from_str({s:?})"); + assert_eq!(parsed.to_string(), s, "Display({s:?})"); + } + } + + #[test] + fn ecosystem_unknown_is_error() { + assert!(ConfigEcosystem::from_str("maven2").is_err()); + } + + // ── ConfigFormat ────────────────────────────────────────────────────────── + + #[test] + fn format_round_trips() { + assert_eq!(ConfigFormat::from_str("text").unwrap(), ConfigFormat::Text); + assert_eq!(ConfigFormat::from_str("json").unwrap(), ConfigFormat::Json); + assert!(ConfigFormat::from_str("sarif").is_err()); + } + + // ── ProjectConfig parsing ───────────────────────────────────────────────── + + #[test] + fn empty_toml_gives_default_config() { + let cfg: ProjectConfig = toml::from_str("").unwrap(); + assert!(cfg.fail_on.is_none()); + assert!(cfg.format.is_none()); + assert!(cfg.exclude.is_empty()); + assert!(cfg.ecosystems.is_empty()); + } + + #[test] + fn full_config_parses_correctly() { + let toml = r#" + fail_on = "medium" + format = "json" + exclude = ["vendor/**", "third_party/**"] + ecosystems = ["npm", "pypi"] + "#; + let cfg: ProjectConfig = toml::from_str(toml).unwrap(); + assert_eq!(cfg.fail_on, Some(FailOn::Medium)); + assert_eq!(cfg.format, Some(ConfigFormat::Json)); + assert_eq!(cfg.exclude, vec!["vendor/**", "third_party/**"]); + assert_eq!( + cfg.ecosystems, + vec![ConfigEcosystem::Npm, ConfigEcosystem::Pypi] + ); + } + + #[test] + fn unknown_key_is_rejected() { + let toml = r#"unknown_key = "oops""#; + assert!(toml::from_str::(toml).is_err()); + } + + #[test] + fn invalid_fail_on_value_is_rejected() { + let toml = r#"fail_on = "extreme""#; + assert!(toml::from_str::(toml).is_err()); + } + + // ── load_project_config ─────────────────────────────────────────────────── + + #[test] + fn returns_none_when_no_file() { + let dir = tempfile::tempdir().unwrap(); + let result = load_project_config(dir.path()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn loads_file_from_directory() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(CONFIG_FILE_NAME), r#"fail_on = "low""#).unwrap(); + let cfg = load_project_config(dir.path()).unwrap().unwrap(); + assert_eq!(cfg.fail_on, Some(FailOn::Low)); + } + + #[test] + fn returns_error_for_malformed_toml() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(CONFIG_FILE_NAME), "[[invalid{{").unwrap(); + assert!(load_project_config(dir.path()).is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 047f719..17f899c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ //! anything accepted in a `.depradarignore` file ([`ignore`]). [`report`] //! rates each finding's severity and renders the result, e.g. as SARIF. +pub mod config; pub mod cve; pub mod ignore; pub mod lockfile; diff --git a/tests/cli.rs b/tests/cli.rs index 32c9ea1..e863c9f 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -172,3 +172,180 @@ fn name_stdin_json_is_one_array_document() { assert_eq!(results[1]["candidate"], "react"); assert_eq!(results[2]["candidate"], "lodahs"); } + +// ── depradar.toml config file tests ────────────────────────────────────────── + +#[test] +fn config_file_format_json_is_used_when_no_flag() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("depradar.toml"), r#"format = "json""#).unwrap(); + + let output = depradar() + .current_dir(dir.path()) + .args(["scan", "--offline"]) + .arg(fixture("npm-basic")) + .output() + .unwrap(); + + // Should emit JSON even though --format was not passed on the CLI. + let json: Value = serde_json::from_slice(&output.stdout).expect("stdout should be JSON"); + assert!(json["lockfiles"].is_array(), "expected JSON output shape"); +} + +#[test] +fn cli_format_flag_overrides_config_file() { + let dir = tempfile::tempdir().unwrap(); + // Config file says json, but the CLI flag says text. + std::fs::write(dir.path().join("depradar.toml"), r#"format = "json""#).unwrap(); + + let output = depradar() + .current_dir(dir.path()) + .args(["--format", "text", "scan", "--offline"]) + .arg(fixture("npm-basic")) + .output() + .unwrap(); + + let stdout = String::from_utf8_lossy(&output.stdout); + // Text output should NOT be valid JSON at the top level. + assert!( + serde_json::from_str::(&stdout).is_err() || !stdout.contains(r#""lockfiles""#), + "expected text output, got JSON: {stdout}" + ); +} + +#[test] +fn config_file_fail_on_none_exits_zero_even_with_findings() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("depradar.toml"), r#"fail_on = "none""#).unwrap(); + + let output = depradar() + .current_dir(dir.path()) + .args(["scan", "--offline"]) + .arg(fixture("npm-basic")) + .output() + .unwrap(); + + // npm-basic has a typosquat finding, but fail_on = none → exit 0. + assert_eq!( + output.status.code(), + Some(0), + "fail_on=none should always exit 0" + ); +} + +#[test] +fn cli_fail_on_flag_overrides_config_file_none() { + let dir = tempfile::tempdir().unwrap(); + // Config says none (never fail), but CLI says high → should fail. + std::fs::write(dir.path().join("depradar.toml"), r#"fail_on = "none""#).unwrap(); + + let output = depradar() + .current_dir(dir.path()) + .args(["--fail-on", "high", "scan", "--offline"]) + .arg(fixture("npm-basic")) + .output() + .unwrap(); + + // npm-basic contains a typosquat (rated High), so exit code should be 1. + assert_eq!( + output.status.code(), + Some(1), + "--fail-on high should override config fail_on=none" + ); +} + +#[test] +fn config_file_exclude_skips_matching_paths() { + let dir = tempfile::tempdir().unwrap(); + // Exclude the npm-basic fixture entirely. + let fixture_path = fixture("npm-basic"); + let pattern = fixture_path.to_string_lossy().replace('\\', "/"); + std::fs::write( + dir.path().join("depradar.toml"), + format!(r#"exclude = ["{pattern}"]"#), + ) + .unwrap(); + + let output = depradar() + .current_dir(dir.path()) + .args(["scan", "--offline"]) + .arg(&fixture_path) + .output() + .unwrap(); + + // Excluded path → no findings → exit 0. + assert_eq!( + output.status.code(), + Some(0), + "excluded path should produce no findings" + ); +} + +#[test] +fn cli_exclude_flag_overrides_config_file() { + let dir = tempfile::tempdir().unwrap(); + // Config excludes everything with "nonexistent" — should not affect the scan. + std::fs::write( + dir.path().join("depradar.toml"), + r#"exclude = ["nonexistent/**"]"#, + ) + .unwrap(); + + let output = depradar() + .current_dir(dir.path()) + // Provide --exclude that matches nothing — scan should still find the typosquat. + .args(["scan", "--offline", "--exclude", "nothing/**"]) + .arg(fixture("npm-basic")) + .output() + .unwrap(); + + // npm-basic has a typosquat finding, so exit code should be 1 (default fail_on=high). + assert_eq!( + output.status.code(), + Some(1), + "scan should find typosquat when exclude doesn't match" + ); +} + +#[test] +fn missing_config_file_uses_defaults() { + // No depradar.toml → all defaults: format=text, fail_on=high. + let output = depradar() + .args(["scan", "--offline"]) + .arg(fixture("npm-basic")) + .output() + .unwrap(); + + // Default fail_on=high, npm-basic has a typosquat (high severity) → exit 1. + assert_eq!(output.status.code(), Some(1)); + // Default format=text → stdout is not JSON. + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + serde_json::from_str::(&stdout).is_err() || !stdout.trim_start().starts_with('{'), + "expected text format by default" + ); +} + +#[test] +fn invalid_config_file_exits_with_code_2() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("depradar.toml"), "[[invalid{{{").unwrap(); + + let output = depradar() + .current_dir(dir.path()) + .args(["scan", "--offline"]) + .arg(fixture("npm-basic")) + .output() + .unwrap(); + + assert_eq!( + output.status.code(), + Some(2), + "invalid depradar.toml should exit 2" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("depradar.toml"), + "error message should mention depradar.toml: {stderr}" + ); +} diff --git a/tests/snapshots/depradar-help.txt b/tests/snapshots/depradar-help.txt index fb4dfb3..30dcd43 100644 --- a/tests/snapshots/depradar-help.txt +++ b/tests/snapshots/depradar-help.txt @@ -8,9 +8,10 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -f, --format Output format. `json` prints exactly one JSON document to stdout; errors and warnings still go to stderr [default: text] [possible values: text, json] - -h, --help Print help - -V, --version Print version + -f, --format Output format. `json` prints exactly one JSON document to stdout; errors and warnings still go to stderr [possible values: text, json] + --fail-on Severity level at which the run exits with code 1 (`scan` only). Accepted values: low, medium, high, critical, none. Overrides `fail_on` in `depradar.toml` [possible values: none, low, medium, high, critical] + -h, --help Print help + -V, --version Print version Examples: depradar name expresss Check one package name for typosquat signals