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)]
94 pub patterns: Vec<String>,
95 #[serde(default)]
102 pub replace_defaults: bool,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct OutputConfig {
107 #[serde(default = "default_corpus_dir")]
108 pub corpus_dir: PathBuf,
109 #[serde(default = "default_lock_timeout_ms")]
114 pub lock_timeout_ms: u64,
115}
116
117impl Default for OutputConfig {
118 fn default() -> Self {
119 Self {
120 corpus_dir: default_corpus_dir(),
121 lock_timeout_ms: default_lock_timeout_ms(),
122 }
123 }
124}
125
126pub fn default_lock_timeout_ms() -> u64 {
128 30_000
129}
130
131#[derive(Debug, Default, Clone, Serialize, Deserialize)]
132pub struct SeverityConfig {
133 #[serde(flatten)]
134 pub overrides: HashMap<String, String>,
135}
136
137impl SeverityConfig {
138 pub fn resolve(&self, keyword: &str) -> Option<crate::finding::Severity> {
144 let raw = self.overrides.get(keyword)?;
145 match raw.to_ascii_lowercase().as_str() {
146 "low" => Some(crate::finding::Severity::Low),
147 "medium" => Some(crate::finding::Severity::Medium),
148 "high" => Some(crate::finding::Severity::High),
149 "critical" => Some(crate::finding::Severity::Critical),
150 _ => None,
151 }
152 }
153}
154
155#[derive(Debug, Default, Clone, Serialize, Deserialize)]
156pub struct AllowDestructiveConfig {
157 #[serde(default)]
158 pub tools: Vec<String>,
159}
160
161impl Config {
162 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
163 let path = path.as_ref();
164 let source = fs::read_to_string(path).map_err(|source| TargetError::Read {
165 path: path.to_path_buf(),
166 source,
167 })?;
168 let expanded = expand_env(&source, path, &|name| env::var(name).ok())?;
169 toml::from_str(&expanded).map_err(|source| TargetError::Parse {
170 path: path.to_path_buf(),
171 source: Box::new(source),
172 })
173 }
174
175 pub fn load_from_lookup(explicit: Option<&Path>) -> Result<(PathBuf, Self)> {
176 let path = find_config(explicit)?;
177 let config = Self::load(&path)?;
178 Ok((path, config))
179 }
180}
181
182fn expand_env(
191 source: &str,
192 path: &Path,
193 lookup: &dyn Fn(&str) -> Option<String>,
194) -> Result<String> {
195 let mut out = String::with_capacity(source.len());
196 let mut chars = source.char_indices().peekable();
197 while let Some((idx, ch)) = chars.next() {
198 if ch != '$' {
199 out.push(ch);
200 continue;
201 }
202 match chars.peek().map(|(_, next)| *next) {
203 Some('$') => {
204 out.push('$');
206 chars.next();
207 }
208 Some('{') => {
209 chars.next(); let mut name = String::new();
211 let mut closed = false;
212 for (_, c) in chars.by_ref() {
213 if c == '}' {
214 closed = true;
215 break;
216 }
217 name.push(c);
218 }
219 if !closed || name.is_empty() {
220 let snippet = source[idx..(idx + 8).min(source.len())].to_string();
221 return Err(TargetError::MalformedPlaceholder {
222 path: path.to_path_buf(),
223 snippet,
224 });
225 }
226 match lookup(&name) {
227 Some(value) => out.push_str(&value),
228 None => {
229 return Err(TargetError::MissingEnv {
230 path: path.to_path_buf(),
231 name,
232 });
233 }
234 }
235 }
236 _ => {
237 out.push('$');
239 }
240 }
241 }
242 Ok(out)
243}
244
245impl Target {
246 pub fn transport_name(&self) -> &'static str {
247 match self.transport {
248 Transport::Stdio { .. } => "stdio",
249 Transport::Http { .. } => "http",
250 }
251 }
252}
253
254pub fn default_timeout_ms() -> u64 {
255 5000
256}
257
258pub fn default_corpus_dir() -> PathBuf {
259 PathBuf::from(".wallfacer/corpus")
260}
261
262pub fn find_config(explicit: Option<&Path>) -> Result<PathBuf> {
263 if let Some(path) = explicit {
264 return Ok(path.to_path_buf());
265 }
266
267 let cwd = env::current_dir().map_err(|source| TargetError::Read {
268 path: PathBuf::from("."),
269 source,
270 })?;
271
272 let direct = cwd.join("wallfacer.toml");
273 if direct.is_file() {
274 return Ok(direct);
275 }
276
277 let mut current = cwd.as_path();
278 loop {
279 let candidate = current.join("wallfacer.toml");
280 if candidate.is_file() {
281 return Ok(candidate);
282 }
283
284 if current.join(".git").is_dir() {
285 break;
286 }
287
288 match current.parent() {
289 Some(parent) => current = parent,
290 None => break,
291 }
292 }
293
294 Err(TargetError::NotFound)
295}
296
297#[cfg(test)]
298#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
299mod tests {
300 use super::*;
301 use std::collections::HashMap;
302
303 fn lookup<'a>(
304 map: &'a HashMap<&'static str, &'static str>,
305 ) -> impl Fn(&str) -> Option<String> + 'a {
306 move |name: &str| map.get(name).map(|v| (*v).to_string())
307 }
308
309 #[test]
310 fn expands_braced_placeholder() {
311 let env = HashMap::from([("WALLFACER_BEARER", "abc123")]);
312 let out = expand_env(
313 r#"Authorization = "Bearer ${WALLFACER_BEARER}""#,
314 Path::new("/x"),
315 &lookup(&env),
316 )
317 .unwrap();
318 assert_eq!(out, r#"Authorization = "Bearer abc123""#);
319 }
320
321 #[test]
322 fn double_dollar_escapes_to_literal() {
323 let env = HashMap::new();
324 let out = expand_env("price = \"$$50\"", Path::new("/x"), &lookup(&env)).unwrap();
325 assert_eq!(out, "price = \"$50\"");
326 }
327
328 #[test]
329 fn bare_dollar_passes_through() {
330 let env = HashMap::new();
331 let out = expand_env(r#"command = "echo $HOME""#, Path::new("/x"), &lookup(&env)).unwrap();
332 assert_eq!(out, r#"command = "echo $HOME""#);
333 }
334
335 #[test]
336 fn missing_env_var_surfaces_error() {
337 let env = HashMap::new();
338 let err = expand_env(
339 r#"Authorization = "Bearer ${WALLFACER_BEARER}""#,
340 Path::new("/x"),
341 &lookup(&env),
342 )
343 .unwrap_err();
344 match err {
345 TargetError::MissingEnv { name, .. } => assert_eq!(name, "WALLFACER_BEARER"),
346 other => panic!("unexpected: {other:?}"),
347 }
348 }
349
350 #[test]
351 fn malformed_placeholder_is_rejected() {
352 let env = HashMap::new();
353 let err = expand_env(r#"x = "${unterminated"#, Path::new("/x"), &lookup(&env)).unwrap_err();
354 assert!(matches!(err, TargetError::MalformedPlaceholder { .. }));
355 let err = expand_env(r#"x = "${}""#, Path::new("/x"), &lookup(&env)).unwrap_err();
356 assert!(matches!(err, TargetError::MalformedPlaceholder { .. }));
357 }
358}