1use std::{
2 collections::HashMap,
3 env, fs, io,
4 path::{Path, PathBuf},
5};
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10#[derive(Debug, Error)]
11pub enum TargetError {
12 #[error("config file not found; run `wallfacer init` or pass `--config <path>`")]
13 NotFound,
14 #[error("failed to read config {path}: {source}")]
15 Read { path: PathBuf, source: io::Error },
16 #[error("failed to parse config {path}: {source}")]
17 Parse {
18 path: PathBuf,
19 source: Box<toml::de::Error>,
20 },
21 #[error(
22 "config {path} references env var `{name}` that is not set; \
23 export it before running, or escape `$` as `$$` to keep the literal"
24 )]
25 MissingEnv { path: PathBuf, name: String },
26 #[error("config {path} contains malformed `${{...}}` placeholder near `{snippet}`")]
27 MalformedPlaceholder { path: PathBuf, snippet: String },
28}
29
30pub type Result<T> = std::result::Result<T, TargetError>;
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(tag = "kind", rename_all = "lowercase")]
34pub enum Transport {
35 Stdio {
36 command: String,
37 #[serde(default)]
38 args: Vec<String>,
39 #[serde(default)]
40 env: HashMap<String, String>,
41 },
42 Http {
43 url: String,
44 #[serde(default)]
45 headers: HashMap<String, String>,
46 },
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct Target {
51 #[serde(flatten)]
52 pub transport: Transport,
53 #[serde(default = "default_timeout_ms")]
54 pub timeout_ms: u64,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Config {
59 pub target: Target,
60 #[serde(default)]
61 pub output: OutputConfig,
62 #[serde(default)]
63 pub severity: SeverityConfig,
64 #[serde(default)]
65 pub allow_destructive: AllowDestructiveConfig,
66 #[serde(default)]
67 pub destructive: DestructiveConfig,
68 #[serde(default)]
75 pub packs: HashMap<String, HashMap<String, String>>,
76}
77
78#[derive(Debug, Default, Clone, Serialize, Deserialize)]
89pub struct DestructiveConfig {
90 #[serde(default)]
93 pub patterns: Vec<String>,
94 #[serde(default)]
101 pub replace_defaults: bool,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct OutputConfig {
106 #[serde(default = "default_corpus_dir")]
107 pub corpus_dir: PathBuf,
108 #[serde(default = "default_lock_timeout_ms")]
113 pub lock_timeout_ms: u64,
114}
115
116impl Default for OutputConfig {
117 fn default() -> Self {
118 Self {
119 corpus_dir: default_corpus_dir(),
120 lock_timeout_ms: default_lock_timeout_ms(),
121 }
122 }
123}
124
125pub fn default_lock_timeout_ms() -> u64 {
127 30_000
128}
129
130#[derive(Debug, Default, Clone, Serialize, Deserialize)]
131pub struct SeverityConfig {
132 #[serde(flatten)]
133 pub overrides: HashMap<String, String>,
134}
135
136impl SeverityConfig {
137 pub fn resolve(&self, keyword: &str) -> Option<crate::finding::Severity> {
143 let raw = self.overrides.get(keyword)?;
144 match raw.to_ascii_lowercase().as_str() {
145 "low" => Some(crate::finding::Severity::Low),
146 "medium" => Some(crate::finding::Severity::Medium),
147 "high" => Some(crate::finding::Severity::High),
148 "critical" => Some(crate::finding::Severity::Critical),
149 _ => None,
150 }
151 }
152}
153
154#[derive(Debug, Default, Clone, Serialize, Deserialize)]
155pub struct AllowDestructiveConfig {
156 #[serde(default)]
157 pub tools: Vec<String>,
158}
159
160impl Config {
161 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
162 let path = path.as_ref();
163 let source = fs::read_to_string(path).map_err(|source| TargetError::Read {
164 path: path.to_path_buf(),
165 source,
166 })?;
167 let expanded = expand_env(&source, path, &|name| env::var(name).ok())?;
168 toml::from_str(&expanded).map_err(|source| TargetError::Parse {
169 path: path.to_path_buf(),
170 source: Box::new(source),
171 })
172 }
173
174 pub fn load_from_lookup(explicit: Option<&Path>) -> Result<(PathBuf, Self)> {
175 let path = find_config(explicit)?;
176 let config = Self::load(&path)?;
177 Ok((path, config))
178 }
179}
180
181fn expand_env(
190 source: &str,
191 path: &Path,
192 lookup: &dyn Fn(&str) -> Option<String>,
193) -> Result<String> {
194 let mut out = String::with_capacity(source.len());
195 let mut chars = source.char_indices().peekable();
196 while let Some((idx, ch)) = chars.next() {
197 if ch != '$' {
198 out.push(ch);
199 continue;
200 }
201 match chars.peek().map(|(_, next)| *next) {
202 Some('$') => {
203 out.push('$');
205 chars.next();
206 }
207 Some('{') => {
208 chars.next(); let mut name = String::new();
210 let mut closed = false;
211 for (_, c) in chars.by_ref() {
212 if c == '}' {
213 closed = true;
214 break;
215 }
216 name.push(c);
217 }
218 if !closed || name.is_empty() {
219 let snippet = source[idx..(idx + 8).min(source.len())].to_string();
220 return Err(TargetError::MalformedPlaceholder {
221 path: path.to_path_buf(),
222 snippet,
223 });
224 }
225 match lookup(&name) {
226 Some(value) => out.push_str(&value),
227 None => {
228 return Err(TargetError::MissingEnv {
229 path: path.to_path_buf(),
230 name,
231 });
232 }
233 }
234 }
235 _ => {
236 out.push('$');
238 }
239 }
240 }
241 Ok(out)
242}
243
244impl Target {
245 pub fn transport_name(&self) -> &'static str {
246 match self.transport {
247 Transport::Stdio { .. } => "stdio",
248 Transport::Http { .. } => "http",
249 }
250 }
251}
252
253pub fn default_timeout_ms() -> u64 {
254 5000
255}
256
257pub fn default_corpus_dir() -> PathBuf {
258 PathBuf::from(".wallfacer/corpus")
259}
260
261pub fn find_config(explicit: Option<&Path>) -> Result<PathBuf> {
262 if let Some(path) = explicit {
263 return Ok(path.to_path_buf());
264 }
265
266 let cwd = env::current_dir().map_err(|source| TargetError::Read {
267 path: PathBuf::from("."),
268 source,
269 })?;
270
271 let direct = cwd.join("wallfacer.toml");
272 if direct.is_file() {
273 return Ok(direct);
274 }
275
276 let mut current = cwd.as_path();
277 loop {
278 let candidate = current.join("wallfacer.toml");
279 if candidate.is_file() {
280 return Ok(candidate);
281 }
282
283 if current.join(".git").is_dir() {
284 break;
285 }
286
287 match current.parent() {
288 Some(parent) => current = parent,
289 None => break,
290 }
291 }
292
293 Err(TargetError::NotFound)
294}
295
296#[cfg(test)]
297#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
298mod tests {
299 use super::*;
300 use std::collections::HashMap;
301
302 fn lookup<'a>(
303 map: &'a HashMap<&'static str, &'static str>,
304 ) -> impl Fn(&str) -> Option<String> + 'a {
305 move |name: &str| map.get(name).map(|v| (*v).to_string())
306 }
307
308 #[test]
309 fn expands_braced_placeholder() {
310 let env = HashMap::from([("WALLFACER_BEARER", "abc123")]);
311 let out = expand_env(
312 r#"Authorization = "Bearer ${WALLFACER_BEARER}""#,
313 Path::new("/x"),
314 &lookup(&env),
315 )
316 .unwrap();
317 assert_eq!(out, r#"Authorization = "Bearer abc123""#);
318 }
319
320 #[test]
321 fn double_dollar_escapes_to_literal() {
322 let env = HashMap::new();
323 let out = expand_env("price = \"$$50\"", Path::new("/x"), &lookup(&env)).unwrap();
324 assert_eq!(out, "price = \"$50\"");
325 }
326
327 #[test]
328 fn bare_dollar_passes_through() {
329 let env = HashMap::new();
330 let out = expand_env(r#"command = "echo $HOME""#, Path::new("/x"), &lookup(&env)).unwrap();
331 assert_eq!(out, r#"command = "echo $HOME""#);
332 }
333
334 #[test]
335 fn missing_env_var_surfaces_error() {
336 let env = HashMap::new();
337 let err = expand_env(
338 r#"Authorization = "Bearer ${WALLFACER_BEARER}""#,
339 Path::new("/x"),
340 &lookup(&env),
341 )
342 .unwrap_err();
343 match err {
344 TargetError::MissingEnv { name, .. } => assert_eq!(name, "WALLFACER_BEARER"),
345 other => panic!("unexpected: {other:?}"),
346 }
347 }
348
349 #[test]
350 fn malformed_placeholder_is_rejected() {
351 let env = HashMap::new();
352 let err = expand_env(r#"x = "${unterminated"#, Path::new("/x"), &lookup(&env)).unwrap_err();
353 assert!(matches!(err, TargetError::MalformedPlaceholder { .. }));
354 let err = expand_env(r#"x = "${}""#, Path::new("/x"), &lookup(&env)).unwrap_err();
355 assert!(matches!(err, TargetError::MalformedPlaceholder { .. }));
356 }
357}