Skip to main content

wallfacer_core/
target.rs

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    /// Per-pack template parameter overrides (Phase G).
69    ///
70    /// `[packs.<pack_name>] key = "value"` populates the resolution
71    /// context that `parse_with_overrides` consumes when loading the
72    /// pack named `<pack_name>`. CLI `--param` flags layer on top of
73    /// these.
74    #[serde(default)]
75    pub packs: HashMap<String, HashMap<String, String>>,
76}
77
78/// `[destructive]` section of `wallfacer.toml`. By default user
79/// `patterns` are layered on top of the built-in keyword list (`delete`,
80/// `drop`, ...). Set `replace_defaults = true` to opt out of the
81/// built-ins entirely.
82///
83/// ```toml
84/// [destructive]
85/// patterns = ["^remove_.*$", "^drop_.*$"]
86/// # replace_defaults = true   # uncomment to disable built-ins
87/// ```
88#[derive(Debug, Default, Clone, Serialize, Deserialize)]
89pub struct DestructiveConfig {
90    /// Regex patterns matched against tool names. Layered on top of the
91    /// built-in keyword detector unless [`replace_defaults`] is set.
92    #[serde(default)]
93    pub patterns: Vec<String>,
94    /// When `true`, the default keyword detector is disabled and only
95    /// [`patterns`] decides which tools are destructive.
96    ///
97    /// Default `false` is additive: an operator who adds one custom
98    /// pattern still gets the protection from the built-in keywords
99    /// like `delete`, `drop`, `destroy`, ...
100    #[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    /// Maximum time, in milliseconds, that a corpus writer waits for the
109    /// shared `.wallfacer/.lock` before giving up. Phase E3 raised the
110    /// default from a hardcoded 5 s to 30 s and made it configurable so
111    /// massively-parallel CI matrices don't trip on slow filesystems.
112    #[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
125/// Default value for [`OutputConfig::lock_timeout_ms`].
126pub 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    /// Looks up an override severity for `keyword` (e.g. `"crash"`,
138    /// `"hang"`, `"protocol_error"`, ...). Returns `None` when no
139    /// override is configured or the configured value isn't a recognised
140    /// severity name. Plans use this to override
141    /// `FindingKind::default_severity()` before persisting a finding.
142    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
181/// Expands `${NAME}` placeholders against `lookup` and lets `$$` escape a
182/// literal `$`. A bare `$` followed by anything other than `$` or `{` is
183/// passed through as-is, so existing configs that legitimately contain `$`
184/// (e.g. shell-style command lines) keep working unchanged.
185///
186/// This runs *before* TOML parsing on the raw file source, which means the
187/// substitution sees both string values and key/section names. The lookup
188/// is a function so tests can avoid touching the real environment.
189fn 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                // `$$` → literal `$`.
204                out.push('$');
205                chars.next();
206            }
207            Some('{') => {
208                chars.next(); // consume `{`
209                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                // Bare `$` — leave alone.
237                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}