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 [`Self::replace_defaults`] is
92    /// set.
93    #[serde(default)]
94    pub patterns: Vec<String>,
95    /// When `true`, the default keyword detector is disabled and only
96    /// [`Self::patterns`] decides which tools are destructive.
97    ///
98    /// Default `false` is additive: an operator who adds one custom
99    /// pattern still gets the protection from the built-in keywords
100    /// like `delete`, `drop`, `destroy`, ...
101    #[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    /// Maximum time, in milliseconds, that a corpus writer waits for the
110    /// shared `.wallfacer/.lock` before giving up. Phase E3 raised the
111    /// default from a hardcoded 5 s to 30 s and made it configurable so
112    /// massively-parallel CI matrices don't trip on slow filesystems.
113    #[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
126/// Default value for [`OutputConfig::lock_timeout_ms`].
127pub 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    /// Looks up an override severity for `keyword` (e.g. `"crash"`,
139    /// `"hang"`, `"protocol_error"`, ...). Returns `None` when no
140    /// override is configured or the configured value isn't a recognised
141    /// severity name. Plans use this to override
142    /// `FindingKind::default_severity()` before persisting a finding.
143    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
182/// Expands `${NAME}` placeholders against `lookup` and lets `$$` escape a
183/// literal `$`. A bare `$` followed by anything other than `$` or `{` is
184/// passed through as-is, so existing configs that legitimately contain `$`
185/// (e.g. shell-style command lines) keep working unchanged.
186///
187/// This runs *before* TOML parsing on the raw file source, which means the
188/// substitution sees both string values and key/section names. The lookup
189/// is a function so tests can avoid touching the real environment.
190fn 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                // `$$` → literal `$`.
205                out.push('$');
206                chars.next();
207            }
208            Some('{') => {
209                chars.next(); // consume `{`
210                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                // Bare `$` — leave alone.
238                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}