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.
132pub struct ShellCommandRunner;
133
134pub(crate) fn format_command_for_display(executable: &str, arguments: &[String]) -> String {
135    if arguments.is_empty() {
136        return executable.to_string();
137    }
138
139    let args = arguments
140        .iter()
141        .map(|arg| {
142            if arg.is_empty()
143                || arg.chars().any(char::is_whitespace)
144                || arg.contains('"')
145                || arg.contains('\'')
146            {
147                format!("{arg:?}")
148            } else {
149                arg.clone()
150            }
151        })
152        .collect::<Vec<_>>()
153        .join(" ");
154    format!("{executable} {args}")
155}
156
157impl CommandRunner for ShellCommandRunner {
158    fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
159        let output = std::process::Command::new(executable)
160            .args(arguments)
161            .output()
162            .map_err(|e| crate::DodotError::CommandFailed {
163                command: format_command_for_display(executable, arguments),
164                exit_code: -1,
165                stderr: e.to_string(),
166            })?;
167
168        let exit_code = output.status.code().unwrap_or(-1);
169        let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
170        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
171
172        if !output.status.success() {
173            return Err(crate::DodotError::CommandFailed {
174                command: format_command_for_display(executable, arguments),
175                exit_code,
176                stderr,
177            });
178        }
179
180        Ok(CommandOutput {
181            exit_code,
182            stdout,
183            stderr,
184        })
185    }
186}