1use std::path::{Path, PathBuf};
9
10use fleetreach_core::{Ecosystem, RepoId};
11use fleetreach_report::VexScope;
12use serde::Deserialize;
13
14pub const DEFAULT_GLOB_MAX_DEPTH: usize = 3;
16
17#[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
42pub 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#[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#[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 vex_product_id: Option<String>,
81 ecosystem: Option<RawEcosystem>,
85}
86
87#[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#[derive(Debug, Deserialize)]
164#[serde(deny_unknown_fields)]
165struct RawVexAssertion {
166 id: String,
167 repo: Option<String>,
169 justification: Option<String>,
171 reason: String,
172 approved_by: String,
173}
174
175#[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#[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 pub path: PathBuf,
200 pub glob: bool,
201 pub glob_max_depth: usize,
202 pub vex_product_id: Option<String>,
204 pub ecosystem: Option<Ecosystem>,
206}
207
208#[derive(Debug, Clone)]
209pub struct Ignore {
210 pub id: String,
211 pub reason: String,
212}
213
214#[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#[derive(Debug, Clone)]
226pub struct VexAssertion {
227 pub id: String,
228 pub repo: Option<RepoId>,
230 pub justification: Option<String>,
231 pub reason: String,
232 pub approved_by: String,
233}
234
235impl Config {
236 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 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; 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 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}