Skip to main content

fleetreach_cli/
config.rs

1//! `fleet.toml` parsing and validation.
2//!
3//! Trust boundary (§3): every table uses `deny_unknown_fields`, every repo path
4//! is validated to exist and be a directory **before** any scanning, and every
5//! `ignore` requires a non-empty `reason`. A bad config is a hard error
6//! (exit `2`) surfaced up front, never a mid-run surprise.
7
8use std::path::{Path, PathBuf};
9
10use fleetreach_core::{Ecosystem, RepoId};
11use fleetreach_report::VexScope;
12use serde::Deserialize;
13
14/// Default depth bound for `glob = true` lockfile discovery (§6).
15pub const DEFAULT_GLOB_MAX_DEPTH: usize = 3;
16
17/// A configuration error. All are fatal (exit `2`).
18#[derive(Debug, thiserror::Error)]
19pub enum ConfigError {
20    #[error("failed to read config `{path}`: {message}")]
21    Read { path: String, message: String },
22    #[error("failed to parse config `{path}`: {message}")]
23    Parse { path: String, message: String },
24    #[error("repo `{repo}`: path `{path}` does not exist")]
25    PathMissing { repo: String, path: String },
26    #[error("repo `{repo}`: path `{path}` is not a directory")]
27    PathNotDir { repo: String, path: String },
28    #[error("repo id `{0}` is declared more than once")]
29    DuplicateRepoId(String),
30    #[error("ignore `{0}` must have a non-empty `reason`")]
31    EmptyIgnoreReason(String),
32    #[error("settings.vex.scope `{0}` is not one of `runtime`, `build`")]
33    InvalidVexScope(String),
34    #[error("vex_assertion `{0}` must have a non-empty `reason`")]
35    EmptyAssertionReason(String),
36    #[error("vex_assertion `{0}` (a not_affected statement) must have a non-empty `approved_by`")]
37    EmptyAssertionApprover(String),
38    #[error("vex_assertion `{id}`: justification `{justification}` is not a VEX WG label")]
39    InvalidVexJustification { id: String, justification: String },
40}
41
42/// The five CISA VEX Working Group `not_affected` justification labels; a
43/// `vex_assertion.justification`, when present, must be one of these (§5, §6).
44pub const VEX_JUSTIFICATIONS: [&str; 5] = [
45    "component_not_present",
46    "vulnerable_code_not_present",
47    "vulnerable_code_not_in_execute_path",
48    "vulnerable_code_cannot_be_controlled_by_adversary",
49    "inline_mitigations_already_exist",
50];
51
52// ---- Raw, untrusted shapes (deny_unknown_fields everywhere) ----
53
54#[derive(Debug, Deserialize)]
55#[serde(deny_unknown_fields)]
56struct RawConfig {
57    #[serde(default)]
58    fleet: FleetTable,
59    #[serde(default)]
60    repo: Vec<RawRepo>,
61    #[serde(default)]
62    settings: SettingsTable,
63}
64
65/// The `[fleet]` table carries no fields yet; `deny_unknown_fields` rejects
66/// stray keys so typos surface instead of being silently ignored.
67#[derive(Debug, Default, Deserialize)]
68#[serde(deny_unknown_fields)]
69struct FleetTable {}
70
71#[derive(Debug, Deserialize)]
72#[serde(deny_unknown_fields)]
73struct RawRepo {
74    id: String,
75    path: String,
76    #[serde(default)]
77    glob: bool,
78    glob_max_depth: Option<usize>,
79    /// Optional explicit product `@id` for `-f vex` (§4.3 step 1). An IRI/PURL.
80    vex_product_id: Option<String>,
81    /// Optional ecosystem override (`cargo`/`rust`/`go`/`npm`/`pypi`/`rubygems`/`packagist`/
82    /// `nuget`/`julia`/`swift`/`hex`/`maven`/`githubactions`).
83    /// Absent = auto-detect from the repo's manifests at scan time.
84    ecosystem: Option<RawEcosystem>,
85}
86
87/// The accepted `ecosystem = "..."` strings, mapped onto [`Ecosystem`]. `rust` is
88/// an alias for `cargo` since that is what users call the ecosystem; `python` is an
89/// alias for `pypi`; `ruby` is an alias for `rubygems`; `composer` and `php` are aliases
90/// for `packagist`; `dotnet` is an alias for `nuget`; `elixir` aliases `hex`; `actions` and
91/// `gha` alias `githubactions`.
92#[derive(Debug, Clone, Copy, Deserialize)]
93#[serde(rename_all = "lowercase")]
94enum RawEcosystem {
95    Cargo,
96    Rust,
97    Go,
98    Npm,
99    Pypi,
100    Python,
101    Rubygems,
102    Ruby,
103    Packagist,
104    Composer,
105    Php,
106    Nuget,
107    Dotnet,
108    Julia,
109    Swift,
110    Hex,
111    Elixir,
112    Githubactions,
113    Actions,
114    Gha,
115    Maven,
116    Gradle,
117    Java,
118}
119
120impl From<RawEcosystem> for Ecosystem {
121    fn from(raw: RawEcosystem) -> Self {
122        match raw {
123            RawEcosystem::Cargo | RawEcosystem::Rust => Ecosystem::Cargo,
124            RawEcosystem::Go => Ecosystem::Go,
125            RawEcosystem::Npm => Ecosystem::Npm,
126            RawEcosystem::Pypi | RawEcosystem::Python => Ecosystem::Pypi,
127            RawEcosystem::Rubygems | RawEcosystem::Ruby => Ecosystem::RubyGems,
128            RawEcosystem::Packagist | RawEcosystem::Composer | RawEcosystem::Php => {
129                Ecosystem::Packagist
130            }
131            RawEcosystem::Nuget | RawEcosystem::Dotnet => Ecosystem::NuGet,
132            RawEcosystem::Julia => Ecosystem::Julia,
133            RawEcosystem::Swift => Ecosystem::Swift,
134            RawEcosystem::Hex | RawEcosystem::Elixir => Ecosystem::Hex,
135            RawEcosystem::Githubactions | RawEcosystem::Actions | RawEcosystem::Gha => {
136                Ecosystem::GitHubActions
137            }
138            RawEcosystem::Maven | RawEcosystem::Gradle | RawEcosystem::Java => Ecosystem::Maven,
139        }
140    }
141}
142
143#[derive(Debug, Default, Deserialize)]
144#[serde(deny_unknown_fields)]
145struct SettingsTable {
146    #[serde(default)]
147    ignore: Vec<RawIgnore>,
148    #[serde(default)]
149    vex: RawVex,
150    #[serde(default)]
151    vex_assertion: Vec<RawVexAssertion>,
152}
153
154#[derive(Debug, Deserialize)]
155#[serde(deny_unknown_fields)]
156struct RawIgnore {
157    id: String,
158    reason: String,
159}
160
161/// A `[[settings.vex_assertion]]` entry (§6): a richer `ignore` with an optional
162/// justification label and a required approver.
163#[derive(Debug, Deserialize)]
164#[serde(deny_unknown_fields)]
165struct RawVexAssertion {
166    id: String,
167    /// Omit = fleet-wide; else scope the assertion to one repo id.
168    repo: Option<String>,
169    /// One of [`VEX_JUSTIFICATIONS`]; falls back to a free-text `impact_statement`.
170    justification: Option<String>,
171    reason: String,
172    approved_by: String,
173}
174
175/// The `[settings.vex]` block (§12); all fields optional, the rest from `--vex-*`.
176#[derive(Debug, Default, Deserialize)]
177#[serde(deny_unknown_fields)]
178struct RawVex {
179    author: Option<String>,
180    role: Option<String>,
181    scope: Option<String>,
182    product_id_base: Option<String>,
183}
184
185// ---- Validated, trusted shapes ----
186
187#[derive(Debug, Clone)]
188pub struct Config {
189    pub repos: Vec<Repo>,
190    pub ignores: Vec<Ignore>,
191    pub vex: VexConfig,
192    pub vex_assertions: Vec<VexAssertion>,
193}
194
195#[derive(Debug, Clone)]
196pub struct Repo {
197    pub id: RepoId,
198    /// Resolved against the config file's directory; validated to be a directory.
199    pub path: PathBuf,
200    pub glob: bool,
201    pub glob_max_depth: usize,
202    /// Explicit product `@id` for `-f vex` (§4.3 step 1).
203    pub vex_product_id: Option<String>,
204    /// Explicit ecosystem override; `None` = auto-detect from manifests.
205    pub ecosystem: Option<Ecosystem>,
206}
207
208#[derive(Debug, Clone)]
209pub struct Ignore {
210    pub id: String,
211    pub reason: String,
212}
213
214/// Validated `[settings.vex]` (§12); resolved against `--vex-*` flags at `-f vex`.
215#[derive(Debug, Clone, Default)]
216pub struct VexConfig {
217    pub author: Option<String>,
218    pub role: Option<String>,
219    pub scope: Option<VexScope>,
220    pub product_id_base: Option<String>,
221}
222
223/// A validated `[[settings.vex_assertion]]` (§6, §7.2): `approved_by` + `reason`
224/// non-empty and `justification` a known label, all enforced at parse (fail-closed).
225#[derive(Debug, Clone)]
226pub struct VexAssertion {
227    pub id: String,
228    /// `None` = fleet-wide; else scoped to this repo id.
229    pub repo: Option<RepoId>,
230    pub justification: Option<String>,
231    pub reason: String,
232    pub approved_by: String,
233}
234
235impl Config {
236    /// Read and validate a `fleet.toml` from disk. Repo paths are resolved
237    /// relative to the config file's directory.
238    pub fn load(path: &Path) -> Result<Self, ConfigError> {
239        let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Read {
240            path: path.display().to_string(),
241            message: e.to_string(),
242        })?;
243        let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
244        Self::from_str(&text, base_dir, &path.display().to_string())
245    }
246
247    /// Parse and validate config text with an explicit base directory for
248    /// resolving repo paths. Split out from [`Config::load`] for testing.
249    pub fn from_str(text: &str, base_dir: &Path, label: &str) -> Result<Self, ConfigError> {
250        let raw: RawConfig = toml::from_str(text).map_err(|e| ConfigError::Parse {
251            path: label.to_string(),
252            message: e.to_string(),
253        })?;
254        let FleetTable {} = raw.fleet; // touch the field so it is not dead
255
256        let mut repos = Vec::with_capacity(raw.repo.len());
257        let mut seen = std::collections::BTreeSet::new();
258        for r in raw.repo {
259            if !seen.insert(r.id.clone()) {
260                return Err(ConfigError::DuplicateRepoId(r.id));
261            }
262            let resolved = base_dir.join(&r.path);
263            if !resolved.exists() {
264                return Err(ConfigError::PathMissing {
265                    repo: r.id,
266                    path: resolved.display().to_string(),
267                });
268            }
269            if !resolved.is_dir() {
270                return Err(ConfigError::PathNotDir {
271                    repo: r.id,
272                    path: resolved.display().to_string(),
273                });
274            }
275            repos.push(Repo {
276                id: RepoId(r.id),
277                path: resolved,
278                glob: r.glob,
279                glob_max_depth: r.glob_max_depth.unwrap_or(DEFAULT_GLOB_MAX_DEPTH),
280                vex_product_id: r.vex_product_id,
281                ecosystem: r.ecosystem.map(Ecosystem::from),
282            });
283        }
284
285        let mut ignores = Vec::with_capacity(raw.settings.ignore.len());
286        for ig in raw.settings.ignore {
287            if ig.reason.trim().is_empty() {
288                return Err(ConfigError::EmptyIgnoreReason(ig.id));
289            }
290            ignores.push(Ignore {
291                id: ig.id,
292                reason: ig.reason,
293            });
294        }
295
296        let scope = match raw.settings.vex.scope {
297            Some(s) => Some(VexScope::parse(&s).ok_or(ConfigError::InvalidVexScope(s))?),
298            None => None,
299        };
300        let vex = VexConfig {
301            author: raw.settings.vex.author,
302            role: raw.settings.vex.role,
303            scope,
304            product_id_base: raw.settings.vex.product_id_base,
305        };
306
307        let mut vex_assertions = Vec::with_capacity(raw.settings.vex_assertion.len());
308        for a in raw.settings.vex_assertion {
309            if a.reason.trim().is_empty() {
310                return Err(ConfigError::EmptyAssertionReason(a.id));
311            }
312            // A human `not_affected` must name an approver (§7) — fail closed.
313            if a.approved_by.trim().is_empty() {
314                return Err(ConfigError::EmptyAssertionApprover(a.id));
315            }
316            if let Some(j) = &a.justification {
317                if !VEX_JUSTIFICATIONS.contains(&j.as_str()) {
318                    return Err(ConfigError::InvalidVexJustification {
319                        id: a.id,
320                        justification: j.clone(),
321                    });
322                }
323            }
324            vex_assertions.push(VexAssertion {
325                id: a.id,
326                repo: a.repo.map(RepoId),
327                justification: a.justification,
328                reason: a.reason,
329                approved_by: a.approved_by,
330            });
331        }
332
333        Ok(Config {
334            repos,
335            ignores,
336            vex,
337            vex_assertions,
338        })
339    }
340}