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}