Skip to main content

dodot_lib/shell/
validate.rs

1//! Pre-flight syntax check for shell-sourced files.
2//!
3//! Runs `bash -n` / `zsh -n` against each deployed shell source so that
4//! syntax errors in `aliases.sh`, `profile.zsh` etc. surface at
5//! `dodot up` time instead of silently breaking the user's next shell
6//! startup. The interpreter's stderr (which carries `file: line N:
7//! error_message`) is preserved verbatim into a sidecar file under the
8//! handler datastore so `dodot status` can show it later (3c).
9//!
10//! This module does not invoke the staged file. It only parses it.
11//! `bash -n` / `zsh -n` are syntax-only — no commands run, no side
12//! effects on the user's environment.
13//!
14//! Sidecar layout: `<data_dir>/packs/<pack>/shell/.errors/<filename>.err`
15//! - Written on a fresh syntax failure.
16//! - Removed on a fresh syntax success (so a fix clears the prior error).
17//! - Untouched when the interpreter is missing (we have no info either way).
18//!
19//! Init-script generation already filters non-symlinks, so the
20//! `.errors` subdirectory does not end up sourced at shell startup.
21
22use std::collections::BTreeSet;
23use std::path::{Path, PathBuf};
24
25use crate::fs::Fs;
26use crate::paths::Pather;
27use crate::Result;
28
29/// Run a syntax-only check on a shell file.
30///
31/// Implementations must not run the file or alter the environment;
32/// they invoke the interpreter in parse-only mode and return what it
33/// found.
34pub trait SyntaxChecker: Send + Sync {
35    fn check(&self, interpreter: &str, file: &Path) -> SyntaxCheckResult;
36}
37
38#[derive(Debug, Clone)]
39pub enum SyntaxCheckResult {
40    /// Interpreter parsed the file with no errors.
41    Ok,
42    /// Interpreter reported syntax errors. `stderr` carries the
43    /// raw output (with line/column information from the shell).
44    SyntaxError { stderr: String },
45    /// The interpreter binary was not found on PATH.
46    InterpreterMissing,
47}
48
49/// Production [`SyntaxChecker`] that spawns real subprocesses.
50pub struct SystemSyntaxChecker;
51
52/// [`SyntaxChecker`] that always returns [`SyntaxCheckResult::Ok`].
53/// Used in tests to keep the validation pass deterministic and
54/// hermetic (no real `bash`/`zsh` invocations).
55pub struct NoopSyntaxChecker;
56
57impl SyntaxChecker for NoopSyntaxChecker {
58    fn check(&self, _interpreter: &str, _file: &Path) -> SyntaxCheckResult {
59        SyntaxCheckResult::Ok
60    }
61}
62
63impl SyntaxChecker for SystemSyntaxChecker {
64    fn check(&self, interpreter: &str, file: &Path) -> SyntaxCheckResult {
65        match std::process::Command::new(interpreter)
66            .arg("-n")
67            .arg(file)
68            .output()
69        {
70            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
71                SyntaxCheckResult::InterpreterMissing
72            }
73            Err(e) => SyntaxCheckResult::SyntaxError {
74                stderr: format!("dodot: failed to spawn {interpreter}: {e}\n"),
75            },
76            Ok(output) if output.status.success() => SyntaxCheckResult::Ok,
77            Ok(output) => SyntaxCheckResult::SyntaxError {
78                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
79            },
80        }
81    }
82}
83
84/// Pick the interpreter to validate `file` with based on its extension.
85/// Returns `None` for files we don't know how to syntax-check (the
86/// caller should skip those).
87fn interpreter_for(file: &Path) -> Option<&'static str> {
88    match file.extension().and_then(|e| e.to_str()) {
89        Some("zsh") => Some("zsh"),
90        Some("bash") => Some("bash"),
91        // bash is the most permissive POSIX-compatible parser. A user
92        // whose interactive shell is dash/sh and who relies on POSIX
93        // strictness will need to verify themselves; bash -n still
94        // catches the bulk of real-world syntax errors in `.sh` files.
95        Some("sh") => Some("bash"),
96        _ => None,
97    }
98}
99
100/// Summary of one validation pass over the deployed shell sources.
101#[derive(Debug, Default)]
102pub struct ShellValidationReport {
103    /// Total files inspected (matched a known shell extension).
104    pub checked: usize,
105    /// Per-failure detail for callers that want to render or log them.
106    pub failures: Vec<ShellValidationFailure>,
107    /// Interpreters we tried to spawn but couldn't find. Each entry is
108    /// recorded once even if many files needed it.
109    pub missing_interpreters: BTreeSet<String>,
110}
111
112/// One file that failed pre-flight syntax check.
113#[derive(Debug, Clone)]
114pub struct ShellValidationFailure {
115    pub pack: String,
116    pub source: PathBuf,
117    pub stderr: String,
118}
119
120/// Subdirectory (under each pack's shell handler dir) where sidecar
121/// `.err` files live. Public so 3c (`status`) can read it back.
122pub const ERRORS_SUBDIR: &str = ".errors";
123
124/// Path of the sidecar error file for one source.
125pub fn error_sidecar_path(paths: &dyn Pather, pack: &str, source_filename: &str) -> PathBuf {
126    paths
127        .handler_data_dir(pack, "shell")
128        .join(ERRORS_SUBDIR)
129        .join(format!("{source_filename}.err"))
130}
131
132/// Iterate every deployed shell source, run a syntax check, and update
133/// the per-file sidecar files. Idempotent across runs: a previously
134/// failing file that's been fixed gets its sidecar removed.
135pub fn validate_shell_sources(
136    fs: &dyn Fs,
137    paths: &dyn Pather,
138    checker: &dyn SyntaxChecker,
139) -> Result<ShellValidationReport> {
140    let mut report = ShellValidationReport::default();
141
142    let packs_dir = paths.data_dir().join("packs");
143    if !fs.exists(&packs_dir) {
144        return Ok(report);
145    }
146
147    for pack_entry in fs.read_dir(&packs_dir)? {
148        if !pack_entry.is_dir {
149            continue;
150        }
151        let pack_name = &pack_entry.name;
152        let shell_dir = paths.handler_data_dir(pack_name, "shell");
153        if !fs.is_dir(&shell_dir) {
154            continue;
155        }
156        let errors_dir = shell_dir.join(ERRORS_SUBDIR);
157
158        let entries = match fs.read_dir(&shell_dir) {
159            Ok(e) => e,
160            Err(_) => continue,
161        };
162
163        for entry in entries {
164            if !entry.is_symlink {
165                continue;
166            }
167            let source = match fs.readlink(&entry.path) {
168                Ok(p) => p,
169                Err(_) => continue,
170            };
171            let interpreter = match interpreter_for(&source) {
172                Some(i) => i,
173                None => continue,
174            };
175
176            let filename = source
177                .file_name()
178                .map(|s| s.to_string_lossy().into_owned())
179                .unwrap_or_default();
180            let err_path = errors_dir.join(format!("{filename}.err"));
181
182            report.checked += 1;
183            match checker.check(interpreter, &source) {
184                SyntaxCheckResult::Ok => {
185                    // Clear any stale sidecar from a previous failure.
186                    if fs.exists(&err_path) {
187                        let _ = fs.remove_file(&err_path);
188                    }
189                }
190                SyntaxCheckResult::SyntaxError { stderr } => {
191                    fs.mkdir_all(&errors_dir)?;
192                    fs.write_file(&err_path, stderr.as_bytes())?;
193                    report.failures.push(ShellValidationFailure {
194                        pack: pack_name.clone(),
195                        source: source.clone(),
196                        stderr,
197                    });
198                }
199                SyntaxCheckResult::InterpreterMissing => {
200                    report.missing_interpreters.insert(interpreter.to_string());
201                }
202            }
203        }
204    }
205
206    Ok(report)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::datastore::{CommandOutput, CommandRunner, DataStore, FilesystemDataStore};
213    use crate::testing::TempEnvironment;
214    use std::collections::HashMap;
215    use std::sync::{Arc, Mutex};
216
217    struct NoopRunner;
218    impl CommandRunner for NoopRunner {
219        fn run(&self, _: &str, _: &[String]) -> Result<CommandOutput> {
220            Ok(CommandOutput {
221                exit_code: 0,
222                stdout: String::new(),
223                stderr: String::new(),
224            })
225        }
226    }
227
228    fn make_datastore(env: &TempEnvironment) -> FilesystemDataStore {
229        FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), Arc::new(NoopRunner))
230    }
231
232    /// Test checker: returns canned results keyed by source filename
233    /// (basename), so tests can target individual files without caring
234    /// about absolute paths.
235    #[derive(Default)]
236    struct CannedChecker {
237        results: Mutex<HashMap<String, SyntaxCheckResult>>,
238        calls: Mutex<Vec<(String, PathBuf)>>,
239    }
240    impl CannedChecker {
241        fn set(&self, filename: &str, result: SyntaxCheckResult) {
242            self.results
243                .lock()
244                .unwrap()
245                .insert(filename.to_string(), result);
246        }
247        fn calls(&self) -> Vec<(String, PathBuf)> {
248            self.calls.lock().unwrap().clone()
249        }
250    }
251    impl SyntaxChecker for CannedChecker {
252        fn check(&self, interpreter: &str, file: &Path) -> SyntaxCheckResult {
253            let basename = file
254                .file_name()
255                .map(|s| s.to_string_lossy().into_owned())
256                .unwrap_or_default();
257            self.calls
258                .lock()
259                .unwrap()
260                .push((interpreter.to_string(), file.to_path_buf()));
261            self.results
262                .lock()
263                .unwrap()
264                .get(&basename)
265                .cloned()
266                .unwrap_or(SyntaxCheckResult::Ok)
267        }
268    }
269
270    #[test]
271    fn interpreter_picked_per_extension() {
272        assert_eq!(interpreter_for(Path::new("a.sh")), Some("bash"));
273        assert_eq!(interpreter_for(Path::new("a.bash")), Some("bash"));
274        assert_eq!(interpreter_for(Path::new("a.zsh")), Some("zsh"));
275        assert_eq!(interpreter_for(Path::new("a.fish")), None);
276        assert_eq!(interpreter_for(Path::new("Makefile")), None);
277    }
278
279    #[test]
280    fn validates_each_deployed_shell_file() {
281        let env = TempEnvironment::builder()
282            .pack("vim")
283            .file("aliases.sh", "alias vi=vim")
284            .file("env.zsh", "export FOO=bar")
285            .done()
286            .build();
287        let ds = make_datastore(&env);
288        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
289            .unwrap();
290        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/env.zsh"))
291            .unwrap();
292
293        let checker = CannedChecker::default();
294        let report = validate_shell_sources(env.fs.as_ref(), env.paths.as_ref(), &checker).unwrap();
295
296        assert_eq!(report.checked, 2);
297        assert!(report.failures.is_empty());
298        assert!(report.missing_interpreters.is_empty());
299
300        let calls = checker.calls();
301        let interpreters: Vec<&String> = calls.iter().map(|(i, _)| i).collect();
302        assert!(interpreters.contains(&&"bash".to_string()));
303        assert!(interpreters.contains(&&"zsh".to_string()));
304    }
305
306    #[test]
307    fn syntax_failure_writes_sidecar_with_stderr() {
308        let env = TempEnvironment::builder()
309            .pack("vim")
310            .file("aliases.sh", "if [ x = y\nfi\n")
311            .done()
312            .build();
313        let ds = make_datastore(&env);
314        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
315            .unwrap();
316
317        let checker = CannedChecker::default();
318        checker.set(
319            "aliases.sh",
320            SyntaxCheckResult::SyntaxError {
321                stderr: "aliases.sh: line 2: syntax error near `fi'\n".into(),
322            },
323        );
324
325        let report = validate_shell_sources(env.fs.as_ref(), env.paths.as_ref(), &checker).unwrap();
326
327        assert_eq!(report.checked, 1);
328        assert_eq!(report.failures.len(), 1);
329        assert_eq!(report.failures[0].pack, "vim");
330
331        let sidecar = error_sidecar_path(env.paths.as_ref(), "vim", "aliases.sh");
332        assert!(env.fs.exists(&sidecar));
333        let body = env.fs.read_to_string(&sidecar).unwrap();
334        assert!(body.contains("syntax error near"), "sidecar:\n{body}");
335    }
336
337    #[test]
338    fn fixed_syntax_clears_stale_sidecar() {
339        let env = TempEnvironment::builder()
340            .pack("vim")
341            .file("aliases.sh", "alias vi=vim")
342            .done()
343            .build();
344        let ds = make_datastore(&env);
345        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
346            .unwrap();
347
348        // First run: failure → sidecar written.
349        let bad = CannedChecker::default();
350        bad.set(
351            "aliases.sh",
352            SyntaxCheckResult::SyntaxError {
353                stderr: "aliases.sh: line 1: oops\n".into(),
354            },
355        );
356        validate_shell_sources(env.fs.as_ref(), env.paths.as_ref(), &bad).unwrap();
357        let sidecar = error_sidecar_path(env.paths.as_ref(), "vim", "aliases.sh");
358        assert!(env.fs.exists(&sidecar));
359
360        // Second run: success → sidecar removed.
361        let good = CannedChecker::default();
362        let report = validate_shell_sources(env.fs.as_ref(), env.paths.as_ref(), &good).unwrap();
363        assert_eq!(report.checked, 1);
364        assert!(report.failures.is_empty());
365        assert!(!env.fs.exists(&sidecar));
366    }
367
368    #[test]
369    fn missing_interpreter_recorded_and_sidecar_left_alone() {
370        // If we previously had a sidecar (e.g., from a prior failure)
371        // and the interpreter goes missing, we don't clobber the
372        // sidecar — we have no fresh info, so we leave the old verdict.
373        let env = TempEnvironment::builder()
374            .pack("vim")
375            .file("aliases.zsh", "alias vi=vim")
376            .done()
377            .build();
378        let ds = make_datastore(&env);
379        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.zsh"))
380            .unwrap();
381
382        // Prime a stale sidecar.
383        let sidecar = error_sidecar_path(env.paths.as_ref(), "vim", "aliases.zsh");
384        env.fs.mkdir_all(sidecar.parent().unwrap()).unwrap();
385        env.fs.write_file(&sidecar, b"old failure\n").unwrap();
386
387        let checker = CannedChecker::default();
388        checker.set("aliases.zsh", SyntaxCheckResult::InterpreterMissing);
389
390        let report = validate_shell_sources(env.fs.as_ref(), env.paths.as_ref(), &checker).unwrap();
391
392        assert_eq!(report.checked, 1);
393        assert!(report.failures.is_empty());
394        assert!(report.missing_interpreters.contains("zsh"));
395        // Sidecar untouched.
396        assert!(env.fs.exists(&sidecar));
397        assert_eq!(env.fs.read_to_string(&sidecar).unwrap(), "old failure\n");
398    }
399
400    #[test]
401    fn unknown_extensions_are_skipped() {
402        // A file the symlink handler caught (because it didn't match
403        // shell mappings) ends up under handler dir "symlink", not
404        // "shell" — so this test really only covers the case where
405        // someone manually places a non-shell-extension file under the
406        // shell handler. We still avoid running bash on a `.fish` file.
407        let env = TempEnvironment::builder()
408            .pack("vim")
409            .file("config.fish", "set -x FOO bar")
410            .done()
411            .build();
412        let ds = make_datastore(&env);
413        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/config.fish"))
414            .unwrap();
415
416        let checker = CannedChecker::default();
417        let report = validate_shell_sources(env.fs.as_ref(), env.paths.as_ref(), &checker).unwrap();
418
419        assert_eq!(report.checked, 0);
420        assert!(checker.calls().is_empty());
421    }
422
423    #[test]
424    fn empty_datastore_is_ok() {
425        let env = TempEnvironment::builder().build();
426        let checker = CannedChecker::default();
427        let report = validate_shell_sources(env.fs.as_ref(), env.paths.as_ref(), &checker).unwrap();
428        assert_eq!(report.checked, 0);
429    }
430}