Skip to main content

dodot_lib/datastore/
mod.rs

1//! State management for dodot.
2//!
3//! The [`DataStore`] trait defines dodot's 8-method storage API.
4//! [`FilesystemDataStore`] implements it using symlinks and sentinel
5//! files on a real (or test) filesystem via the [`Fs`](crate::fs::Fs) trait.
6
7mod filesystem;
8
9pub use filesystem::FilesystemDataStore;
10
11use std::path::{Path, PathBuf};
12
13use crate::Result;
14
15/// Dodot's storage interface.
16///
17/// State is represented entirely by symlinks and sentinel files in the
18/// filesystem — no database, no lock files. The 8 methods break into
19/// three groups:
20///
21/// **Mutations** — modify state:
22/// - [`create_data_link`](DataStore::create_data_link)
23/// - [`create_user_link`](DataStore::create_user_link)
24/// - [`run_and_record`](DataStore::run_and_record)
25/// - [`remove_state`](DataStore::remove_state)
26///
27/// **Queries** — read state:
28/// - [`has_sentinel`](DataStore::has_sentinel)
29/// - [`has_handler_state`](DataStore::has_handler_state)
30/// - [`list_pack_handlers`](DataStore::list_pack_handlers)
31/// - [`list_handler_sentinels`](DataStore::list_handler_sentinels)
32pub trait DataStore: Send + Sync {
33    /// Creates an intermediate symlink in the datastore:
34    /// `handler_data_dir(pack, handler) / filename -> source_file`
35    ///
36    /// Returns the absolute path of the created datastore link.
37    /// Idempotent: if the link exists and already points to the correct
38    /// source, this is a no-op.
39    fn create_data_link(&self, pack: &str, handler: &str, source_file: &Path) -> Result<PathBuf>;
40
41    /// Creates a user-visible symlink:
42    /// `user_path -> datastore_path`
43    ///
44    /// This is the second leg of the double-link architecture.
45    /// Creates parent directories as needed.
46    fn create_user_link(&self, datastore_path: &Path, user_path: &Path) -> Result<()>;
47
48    /// Executes `command` via shell and records a sentinel on success.
49    ///
50    /// Idempotent: if the sentinel already exists, the command is not
51    /// re-run. The sentinel file stores `completed|{timestamp}`.
52    ///
53    /// **Edge case**: if the command succeeds but the sentinel write
54    /// fails, a subsequent call will re-run the command. This is by
55    /// design — re-running is safer than falsely marking as complete.
56    /// Install scripts should be idempotent to handle this.
57    fn run_and_record(
58        &self,
59        pack: &str,
60        handler: &str,
61        executable: &str,
62        arguments: &[String],
63        sentinel: &str,
64        force: bool,
65    ) -> Result<()>;
66
67    /// Checks whether a sentinel exists for this pack/handler.
68    fn has_sentinel(&self, pack: &str, handler: &str, sentinel: &str) -> Result<bool>;
69
70    /// Removes all state for a pack/handler pair.
71    ///
72    /// Deletes the handler data directory and everything in it.
73    fn remove_state(&self, pack: &str, handler: &str) -> Result<()>;
74
75    /// Checks if any state exists for a pack/handler pair.
76    fn has_handler_state(&self, pack: &str, handler: &str) -> Result<bool>;
77
78    /// Lists handler names that have state for a pack.
79    fn list_pack_handlers(&self, pack: &str) -> Result<Vec<String>>;
80
81    /// Lists sentinel file names for a pack/handler.
82    fn list_handler_sentinels(&self, pack: &str, handler: &str) -> Result<Vec<String>>;
83
84    /// Writes a regular file (not a symlink) into the datastore.
85    ///
86    /// Used for preprocessor-expanded files where the datastore holds
87    /// rendered content rather than a symlink to the source.
88    /// Returns the absolute path of the written file.
89    /// Idempotent: overwrites if the file already exists.
90    ///
91    /// `filename` must be a safe relative path — no absolute paths, no
92    /// `..` components. Callers (typically the preprocessing pipeline)
93    /// are expected to validate before calling. Implementations should
94    /// also reject unsafe paths as defense-in-depth.
95    fn write_rendered_file(
96        &self,
97        pack: &str,
98        handler: &str,
99        filename: &str,
100        content: &[u8],
101    ) -> Result<PathBuf>;
102
103    /// Creates a directory (mkdir -p) inside the datastore and returns
104    /// its absolute path. Used for preprocessor-expanded directory
105    /// entries (e.g. directory markers from tar archives).
106    ///
107    /// Same path-safety constraints as [`write_rendered_file`].
108    fn write_rendered_dir(&self, pack: &str, handler: &str, relative: &str) -> Result<PathBuf>;
109
110    /// Returns the absolute path where a sentinel file would be stored.
111    fn sentinel_path(&self, pack: &str, handler: &str, sentinel: &str) -> std::path::PathBuf;
112}
113
114/// Abstraction over process execution.
115///
116/// [`FilesystemDataStore`] uses this to run commands in
117/// [`run_and_record`](DataStore::run_and_record). Tests can provide a
118/// mock that records calls without spawning processes.
119pub trait CommandRunner: Send + Sync {
120    fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput>;
121}
122
123/// Output from a command execution.
124#[derive(Debug, Clone)]
125pub struct CommandOutput {
126    pub exit_code: i32,
127    pub stdout: String,
128    pub stderr: String,
129}
130
131/// [`CommandRunner`] that spawns a real shell process.
132///
133/// `verbose` controls whether the script's raw stdout/stderr is streamed
134/// through to the user's terminal. Regardless of the flag, lines matching
135/// the `# status:` convention on stdout are always surfaced as live progress
136/// markers, and captured output is returned via [`CommandOutput`] for
137/// callers that want it.
138pub struct ShellCommandRunner {
139    verbose: bool,
140}
141
142impl ShellCommandRunner {
143    pub fn new(verbose: bool) -> Self {
144        Self { verbose }
145    }
146}
147
148pub(crate) fn format_command_for_display(executable: &str, arguments: &[String]) -> String {
149    if arguments.is_empty() {
150        return executable.to_string();
151    }
152
153    let args = arguments
154        .iter()
155        .map(|arg| {
156            if arg.is_empty()
157                || arg.chars().any(char::is_whitespace)
158                || arg.contains('"')
159                || arg.contains('\'')
160            {
161                format!("{arg:?}")
162            } else {
163                arg.clone()
164            }
165        })
166        .collect::<Vec<_>>()
167        .join(" ");
168    format!("{executable} {args}")
169}
170
171/// Strip the `# status:` prefix from a script line, returning the
172/// trimmed message if present.
173///
174/// Matches `#status:`, `# status:`, and any leading whitespace before
175/// the `#`. Designed to be tool-agnostic — a script using this convention
176/// is still valid and meaningful when run manually outside dodot.
177pub(crate) fn parse_status_line(line: &str) -> Option<&str> {
178    let s = line.trim_start();
179    let rest = s.strip_prefix('#')?;
180    let rest = rest.trim_start();
181    let msg = rest.strip_prefix("status:")?;
182    Some(msg.trim())
183}
184
185impl CommandRunner for ShellCommandRunner {
186    fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
187        use std::io::{BufRead, BufReader, IsTerminal, Write};
188        use std::process::{Command, Stdio};
189        use std::sync::{Arc, Mutex};
190        use std::thread;
191
192        let mut child = Command::new(executable)
193            .args(arguments)
194            .stdout(Stdio::piped())
195            .stderr(Stdio::piped())
196            .spawn()
197            .map_err(|e| crate::DodotError::CommandFailed {
198                command: format_command_for_display(executable, arguments),
199                exit_code: -1,
200                stderr: e.to_string(),
201            })?;
202
203        let stdout_pipe = child
204            .stdout
205            .take()
206            .expect("piped stdout missing after spawn");
207        let stderr_pipe = child
208            .stderr
209            .take()
210            .expect("piped stderr missing after spawn");
211
212        // ANSI dim only if the user's stdout is a TTY — keeps colour
213        // codes out of pipes/log files.
214        let tty = std::io::stdout().is_terminal();
215        let dim = if tty { "\x1b[2m" } else { "" };
216        let reset = if tty { "\x1b[0m" } else { "" };
217        let arrow = if tty { "→" } else { "->" };
218
219        let verbose = self.verbose;
220        let stderr_buf = Arc::new(Mutex::new(String::new()));
221
222        // Read raw bytes (not `BufRead::lines()`) so non-UTF-8 output
223        // doesn't stop draining mid-stream — a stalled drain would
224        // deadlock the child once the pipe buffer fills. Decode each
225        // line lossily for display/capture; binary garbage becomes U+FFFD
226        // rather than aborting the read.
227        fn pop_eol(buf: &mut Vec<u8>) {
228            if buf.last() == Some(&b'\n') {
229                buf.pop();
230            }
231            if buf.last() == Some(&b'\r') {
232                buf.pop();
233            }
234        }
235
236        // Drain stderr in a worker thread to avoid pipe-buffer deadlock
237        // (a chatty stderr can block the child if no one's reading).
238        let stderr_thread = {
239            let buf = stderr_buf.clone();
240            thread::spawn(move || {
241                let mut reader = BufReader::new(stderr_pipe);
242                let host_stderr = std::io::stderr();
243                let mut bytes = Vec::new();
244                loop {
245                    bytes.clear();
246                    match reader.read_until(b'\n', &mut bytes) {
247                        Ok(0) | Err(_) => break,
248                        Ok(_) => {
249                            pop_eol(&mut bytes);
250                            let line = String::from_utf8_lossy(&bytes);
251                            {
252                                let mut guard = buf.lock().expect("stderr buf poisoned");
253                                guard.push_str(&line);
254                                guard.push('\n');
255                            }
256                            if verbose {
257                                let mut h = host_stderr.lock();
258                                let _ = writeln!(h, "{line}");
259                            }
260                        }
261                    }
262                }
263            })
264        };
265
266        // Read stdout on the main thread: capture, scan for `# status:`,
267        // optionally passthrough.
268        let mut stdout_buf = String::new();
269        {
270            let mut reader = BufReader::new(stdout_pipe);
271            let host_stdout = std::io::stdout();
272            let mut bytes = Vec::new();
273            loop {
274                bytes.clear();
275                match reader.read_until(b'\n', &mut bytes) {
276                    Ok(0) | Err(_) => break,
277                    Ok(_) => {
278                        pop_eol(&mut bytes);
279                        let line = String::from_utf8_lossy(&bytes);
280                        stdout_buf.push_str(&line);
281                        stdout_buf.push('\n');
282
283                        if let Some(msg) = parse_status_line(&line) {
284                            let mut h = host_stdout.lock();
285                            let _ = writeln!(h, "{dim}{arrow}{reset} {msg}");
286                        }
287                        if verbose {
288                            let mut h = host_stdout.lock();
289                            let _ = writeln!(h, "{line}");
290                        }
291                    }
292                }
293            }
294        }
295
296        let _ = stderr_thread.join();
297        let stderr_text = stderr_buf.lock().expect("stderr buf poisoned").clone();
298
299        let status = child.wait().map_err(|e| crate::DodotError::CommandFailed {
300            command: format_command_for_display(executable, arguments),
301            exit_code: -1,
302            stderr: e.to_string(),
303        })?;
304        let exit_code = status.code().unwrap_or(-1);
305
306        if !status.success() {
307            // When not verbose, the user hasn't seen any of the script's
308            // stderr — surface it now so a failure is debuggable.
309            if !verbose && !stderr_text.is_empty() {
310                let host_stderr = std::io::stderr();
311                let mut h = host_stderr.lock();
312                let _ = h.write_all(stderr_text.as_bytes());
313                if !stderr_text.ends_with('\n') {
314                    let _ = writeln!(h);
315                }
316            }
317            return Err(crate::DodotError::CommandFailed {
318                command: format_command_for_display(executable, arguments),
319                exit_code,
320                stderr: stderr_text,
321            });
322        }
323
324        Ok(CommandOutput {
325            exit_code,
326            stdout: stdout_buf,
327            stderr: stderr_text,
328        })
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn parse_status_line_matches_no_space() {
338        assert_eq!(parse_status_line("#status: building"), Some("building"));
339    }
340
341    #[test]
342    fn parse_status_line_matches_one_space() {
343        assert_eq!(
344            parse_status_line("# status: downloading installer"),
345            Some("downloading installer")
346        );
347    }
348
349    #[test]
350    fn parse_status_line_matches_extra_whitespace() {
351        assert_eq!(
352            parse_status_line("   #   status:   compiling   "),
353            Some("compiling")
354        );
355    }
356
357    #[test]
358    fn parse_status_line_rejects_plain_comment() {
359        assert_eq!(parse_status_line("# just a comment"), None);
360    }
361
362    #[test]
363    fn parse_status_line_rejects_non_comment() {
364        assert_eq!(parse_status_line("echo status: foo"), None);
365    }
366
367    #[test]
368    fn parse_status_line_rejects_shebang() {
369        // `#!/usr/bin/env bash` doesn't start the magic word — ignored.
370        assert_eq!(parse_status_line("#!/bin/bash"), None);
371    }
372
373    #[test]
374    fn parse_status_line_returns_empty_message() {
375        // Empty status: still matches (script chose to print a blank progress).
376        assert_eq!(parse_status_line("# status:"), Some(""));
377    }
378
379    #[test]
380    fn shell_runner_streams_and_captures_real_script() {
381        // Smoke-test the real spawn/streaming path. We assert on the
382        // captured CommandOutput; live host-stdout assertions would
383        // require redirecting process-wide stdout and aren't worth the
384        // complexity here.
385        let runner = ShellCommandRunner::new(false);
386        let script = "echo starting; \
387            echo '# status: phase one'; \
388            echo middle; \
389            echo '# status: phase two'; \
390            echo done";
391        let out = runner
392            .run("bash", &["-c".into(), script.into()])
393            .expect("script should succeed");
394        assert!(out.stdout.contains("starting"));
395        assert!(out.stdout.contains("# status: phase one"));
396        assert!(out.stdout.contains("middle"));
397        assert!(out.stdout.contains("# status: phase two"));
398        assert!(out.stdout.contains("done"));
399        assert_eq!(out.exit_code, 0);
400    }
401
402    #[test]
403    fn shell_runner_returns_error_on_nonzero_exit() {
404        let runner = ShellCommandRunner::new(false);
405        let result = runner.run("bash", &["-c".into(), "exit 7".into()]);
406        match result {
407            Err(crate::DodotError::CommandFailed { exit_code, .. }) => {
408                assert_eq!(exit_code, 7);
409            }
410            other => panic!("expected CommandFailed, got {other:?}"),
411        }
412    }
413
414    #[test]
415    fn shell_runner_captures_stderr_in_command_output() {
416        let runner = ShellCommandRunner::new(false);
417        let out = runner
418            .run("bash", &["-c".into(), "echo hello >&2; echo world".into()])
419            .expect("script should succeed");
420        assert!(out.stderr.contains("hello"));
421        assert!(out.stdout.contains("world"));
422    }
423}