Skip to main content

dodot_lib/datastore/
filesystem.rs

1use std::path::{Component, Path, PathBuf};
2use std::sync::Arc;
3
4use crate::datastore::{CommandRunner, DataStore};
5use crate::fs::Fs;
6use crate::paths::Pather;
7use crate::{DodotError, Result};
8
9/// Validate that `raw` is a safe relative path to be used under `base`.
10///
11/// Defense-in-depth against path traversal: rejects absolute paths, `..`
12/// components, and anything that would escape `base`. Returns the
13/// normalised relative `PathBuf` on success.
14fn validate_safe_relative(raw: &str, base: &Path) -> Result<PathBuf> {
15    let candidate = Path::new(raw);
16    let mut cleaned = PathBuf::new();
17    for component in candidate.components() {
18        match component {
19            Component::Normal(n) => cleaned.push(n),
20            Component::CurDir => {}
21            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
22                return Err(DodotError::Other(format!(
23                    "unsafe datastore path: {} (would escape {})",
24                    raw,
25                    base.display()
26                )));
27            }
28        }
29    }
30    if cleaned.as_os_str().is_empty() {
31        return Err(DodotError::Other(format!(
32            "empty datastore path (from {raw:?})"
33        )));
34    }
35    Ok(cleaned)
36}
37
38/// [`DataStore`] implementation backed by the real filesystem.
39///
40/// State is stored as symlinks and sentinel files under the XDG data
41/// directory. The double-link architecture works as follows:
42///
43/// ```text
44/// ~/dotfiles/vim/vimrc                              (source)
45///   -> ~/.local/share/dodot/packs/vim/symlink/vimrc (data link)
46///     -> ~/.vimrc                                   (user link)
47/// ```
48pub struct FilesystemDataStore {
49    fs: Arc<dyn Fs>,
50    paths: Arc<dyn Pather>,
51    runner: Arc<dyn CommandRunner>,
52}
53
54impl FilesystemDataStore {
55    pub fn new(fs: Arc<dyn Fs>, paths: Arc<dyn Pather>, runner: Arc<dyn CommandRunner>) -> Self {
56        Self { fs, paths, runner }
57    }
58}
59
60impl DataStore for FilesystemDataStore {
61    fn create_data_link(&self, pack: &str, handler: &str, source_file: &Path) -> Result<PathBuf> {
62        let filename = source_file.file_name().ok_or_else(|| {
63            crate::DodotError::Other(format!(
64                "source file has no filename: {}",
65                source_file.display()
66            ))
67        })?;
68
69        let link_dir = self.paths.handler_data_dir(pack, handler);
70        let link_path = link_dir.join(filename);
71
72        self.fs.mkdir_all(&link_dir)?;
73
74        // Idempotent: if the link already points to the correct source, skip.
75        if self.fs.is_symlink(&link_path) {
76            if let Ok(current_target) = self.fs.readlink(&link_path) {
77                if current_target == source_file {
78                    return Ok(link_path);
79                }
80            }
81            // Wrong target — remove and re-create.
82            self.fs.remove_file(&link_path)?;
83        }
84
85        self.fs.symlink(source_file, &link_path)?;
86        Ok(link_path)
87    }
88
89    fn create_user_link(&self, datastore_path: &Path, user_path: &Path) -> Result<()> {
90        // Create parent directory
91        if let Some(parent) = user_path.parent() {
92            self.fs.mkdir_all(parent)?;
93        }
94
95        // If something already exists at user_path, handle it
96        if self.fs.is_symlink(user_path) {
97            // Existing symlink — check if it's correct
98            if let Ok(current_target) = self.fs.readlink(user_path) {
99                if current_target == datastore_path {
100                    return Ok(()); // Already correct
101                }
102            }
103            // Wrong target — remove and re-create
104            self.fs.remove_file(user_path)?;
105        } else if self.fs.exists(user_path) {
106            // Exists but is not a symlink — conflict
107            return Err(crate::DodotError::SymlinkConflict {
108                path: user_path.to_path_buf(),
109            });
110        }
111
112        self.fs.symlink(datastore_path, user_path)
113    }
114
115    fn run_and_record(
116        &self,
117        pack: &str,
118        handler: &str,
119        executable: &str,
120        arguments: &[String],
121        sentinel: &str,
122        force: bool,
123    ) -> Result<()> {
124        // Idempotent: skip if sentinel exists
125        if !force && self.has_sentinel(pack, handler, sentinel)? {
126            return Ok(());
127        }
128
129        // Provisioning scripts are consequential and can take a while; surface
130        // start/end markers on stderr so the user knows what's running and
131        // whether it succeeded. The script's own stdout/stderr still flows
132        // through the runner as before.
133        let display_name = arguments
134            .iter()
135            .rev()
136            .find_map(|arg| {
137                Path::new(arg)
138                    .file_name()
139                    .map(|n| n.to_string_lossy().into_owned())
140                    .filter(|n| n.contains('.'))
141            })
142            .unwrap_or_else(|| executable.to_string());
143        let header = format!("==== {pack} → {handler} → {display_name}");
144        let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
145        let dim = if tty { "\x1b[2m" } else { "" };
146        let green = if tty { "\x1b[32m" } else { "" };
147        let red = if tty { "\x1b[31m" } else { "" };
148        let reset = if tty { "\x1b[0m" } else { "" };
149        eprintln!("{header}  {dim}running…{reset}");
150
151        let result = self.runner.run(executable, arguments);
152        match &result {
153            Ok(_) => eprintln!("{header}  {green}OK{reset}"),
154            Err(_) => eprintln!("{header}  {red}FAILED{reset}"),
155        }
156        result?;
157
158        // Record sentinel
159        let sentinel_dir = self.paths.handler_data_dir(pack, handler);
160        self.fs.mkdir_all(&sentinel_dir)?;
161
162        let sentinel_path = sentinel_dir.join(sentinel);
163        let timestamp = std::time::SystemTime::now()
164            .duration_since(std::time::UNIX_EPOCH)
165            .unwrap_or_default()
166            .as_secs();
167        let content = format!("completed|{timestamp}");
168        self.fs.write_file(&sentinel_path, content.as_bytes())
169    }
170
171    fn has_sentinel(&self, pack: &str, handler: &str, sentinel: &str) -> Result<bool> {
172        let sentinel_path = self.paths.handler_data_dir(pack, handler).join(sentinel);
173        Ok(self.fs.exists(&sentinel_path))
174    }
175
176    fn remove_state(&self, pack: &str, handler: &str) -> Result<()> {
177        let state_dir = self.paths.handler_data_dir(pack, handler);
178        if !self.fs.exists(&state_dir) {
179            return Ok(());
180        }
181        self.fs.remove_dir_all(&state_dir)
182    }
183
184    fn has_handler_state(&self, pack: &str, handler: &str) -> Result<bool> {
185        let state_dir = self.paths.handler_data_dir(pack, handler);
186        if !self.fs.exists(&state_dir) {
187            return Ok(false);
188        }
189        let entries = self.fs.read_dir(&state_dir)?;
190        Ok(!entries.is_empty())
191    }
192
193    fn list_pack_handlers(&self, pack: &str) -> Result<Vec<String>> {
194        let pack_dir = self.paths.pack_data_dir(pack);
195        if !self.fs.exists(&pack_dir) {
196            return Ok(Vec::new());
197        }
198        let entries = self.fs.read_dir(&pack_dir)?;
199        Ok(entries
200            .into_iter()
201            .filter(|e| e.is_dir)
202            .map(|e| e.name)
203            .collect())
204    }
205
206    fn list_handler_sentinels(&self, pack: &str, handler: &str) -> Result<Vec<String>> {
207        let handler_dir = self.paths.handler_data_dir(pack, handler);
208        if !self.fs.exists(&handler_dir) {
209            return Ok(Vec::new());
210        }
211        let entries = self.fs.read_dir(&handler_dir)?;
212        Ok(entries
213            .into_iter()
214            .filter(|e| e.is_file)
215            .map(|e| e.name)
216            .collect())
217    }
218
219    fn write_rendered_file(
220        &self,
221        pack: &str,
222        handler: &str,
223        filename: &str,
224        content: &[u8],
225    ) -> Result<PathBuf> {
226        let dir = self.paths.handler_data_dir(pack, handler);
227        let relative = validate_safe_relative(filename, &dir)?;
228        let path = dir.join(&relative);
229        // Create the full parent chain (supports nested filenames like "sub/file.txt")
230        if let Some(parent) = path.parent() {
231            self.fs.mkdir_all(parent)?;
232        } else {
233            self.fs.mkdir_all(&dir)?;
234        }
235        self.fs.write_file(&path, content)?;
236        Ok(path)
237    }
238
239    fn write_rendered_dir(&self, pack: &str, handler: &str, relative: &str) -> Result<PathBuf> {
240        let dir = self.paths.handler_data_dir(pack, handler);
241        let rel = validate_safe_relative(relative, &dir)?;
242        let path = dir.join(&rel);
243        self.fs.mkdir_all(&path)?;
244        Ok(path)
245    }
246
247    fn sentinel_path(&self, pack: &str, handler: &str, sentinel: &str) -> PathBuf {
248        self.paths.handler_data_dir(pack, handler).join(sentinel)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::datastore::{CommandOutput, CommandRunner};
256    use crate::testing::TempEnvironment;
257    use std::sync::Mutex;
258
259    /// Mock command runner that records calls and can be configured to
260    /// succeed or fail.
261    struct MockCommandRunner {
262        calls: Mutex<Vec<String>>,
263        should_fail: bool,
264    }
265
266    impl MockCommandRunner {
267        fn new() -> Self {
268            Self {
269                calls: Mutex::new(Vec::new()),
270                should_fail: false,
271            }
272        }
273
274        fn failing() -> Self {
275            Self {
276                calls: Mutex::new(Vec::new()),
277                should_fail: true,
278            }
279        }
280
281        fn calls(&self) -> Vec<String> {
282            self.calls.lock().unwrap().clone()
283        }
284    }
285
286    impl CommandRunner for MockCommandRunner {
287        fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
288            let cmd_str = format!("{} {}", executable, arguments.join(" "));
289            self.calls.lock().unwrap().push(cmd_str.trim().to_string());
290            if self.should_fail {
291                Err(crate::DodotError::CommandFailed {
292                    command: cmd_str.trim().to_string(),
293                    exit_code: 1,
294                    stderr: "mock failure".to_string(),
295                })
296            } else {
297                Ok(CommandOutput {
298                    exit_code: 0,
299                    stdout: String::new(),
300                    stderr: String::new(),
301                })
302            }
303        }
304    }
305
306    fn make_datastore(env: &TempEnvironment) -> (FilesystemDataStore, Arc<MockCommandRunner>) {
307        let runner = Arc::new(MockCommandRunner::new());
308        let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner.clone());
309        (ds, runner)
310    }
311
312    // ── create_data_link ────────────────────────────────────────
313
314    #[test]
315    fn create_data_link_creates_symlink() {
316        let env = TempEnvironment::builder()
317            .pack("vim")
318            .file("vimrc", "set nocompatible")
319            .done()
320            .build();
321        let (ds, _) = make_datastore(&env);
322
323        let source = env.dotfiles_root.join("vim/vimrc");
324        let link_path = ds.create_data_link("vim", "symlink", &source).unwrap();
325
326        // Link should be in the handler data dir
327        assert_eq!(
328            link_path,
329            env.paths.handler_data_dir("vim", "symlink").join("vimrc")
330        );
331
332        // Link should point to source
333        env.assert_symlink(&link_path, &source);
334    }
335
336    #[test]
337    fn create_data_link_is_idempotent() {
338        let env = TempEnvironment::builder()
339            .pack("vim")
340            .file("vimrc", "set nocompatible")
341            .done()
342            .build();
343        let (ds, _) = make_datastore(&env);
344
345        let source = env.dotfiles_root.join("vim/vimrc");
346
347        let path1 = ds.create_data_link("vim", "symlink", &source).unwrap();
348        let path2 = ds.create_data_link("vim", "symlink", &source).unwrap();
349
350        assert_eq!(path1, path2);
351        env.assert_symlink(&path1, &source);
352    }
353
354    #[test]
355    fn create_data_link_replaces_wrong_target() {
356        let env = TempEnvironment::builder()
357            .pack("vim")
358            .file("vimrc", "v1")
359            .file("vimrc-new", "v2")
360            .done()
361            .build();
362        let (ds, _) = make_datastore(&env);
363
364        let source1 = env.dotfiles_root.join("vim/vimrc");
365        let source2 = env.dotfiles_root.join("vim/vimrc-new");
366
367        // Create initial link to source1
368        let link_dir = env.paths.handler_data_dir("vim", "symlink");
369        env.fs.mkdir_all(&link_dir).unwrap();
370        // Manually create a link named "vimrc-new" pointing to source1 (wrong target)
371        let wrong_link = link_dir.join("vimrc-new");
372        env.fs.symlink(&source1, &wrong_link).unwrap();
373
374        // Now create_data_link should fix it to point at source2
375        let link_path = ds.create_data_link("vim", "symlink", &source2).unwrap();
376        env.assert_symlink(&link_path, &source2);
377    }
378
379    // ── create_user_link ────────────────────────────────────────
380
381    #[test]
382    fn create_user_link_creates_symlink() {
383        let env = TempEnvironment::builder().build();
384        let (ds, _) = make_datastore(&env);
385
386        let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
387        let user_path = env.home.join(".vimrc");
388
389        // Create the datastore file so the symlink target exists
390        env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
391        env.fs.write_file(&datastore_path, b"link target").unwrap();
392
393        ds.create_user_link(&datastore_path, &user_path).unwrap();
394
395        env.assert_symlink(&user_path, &datastore_path);
396    }
397
398    #[test]
399    fn create_user_link_is_idempotent() {
400        let env = TempEnvironment::builder().build();
401        let (ds, _) = make_datastore(&env);
402
403        let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
404        let user_path = env.home.join(".vimrc");
405
406        env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
407        env.fs.write_file(&datastore_path, b"x").unwrap();
408
409        ds.create_user_link(&datastore_path, &user_path).unwrap();
410        ds.create_user_link(&datastore_path, &user_path).unwrap();
411
412        env.assert_symlink(&user_path, &datastore_path);
413    }
414
415    #[test]
416    fn create_user_link_conflict_with_regular_file() {
417        let env = TempEnvironment::builder().build();
418        let (ds, _) = make_datastore(&env);
419
420        let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
421        let user_path = env.home.join(".vimrc");
422
423        // Create a regular file at the user path
424        env.fs.write_file(&user_path, b"existing content").unwrap();
425
426        let err = ds
427            .create_user_link(&datastore_path, &user_path)
428            .unwrap_err();
429        assert!(
430            matches!(err, crate::DodotError::SymlinkConflict { .. }),
431            "expected SymlinkConflict, got: {err}"
432        );
433    }
434
435    #[test]
436    fn create_user_link_replaces_wrong_symlink() {
437        let env = TempEnvironment::builder().build();
438        let (ds, _) = make_datastore(&env);
439
440        let wrong_target = env.data_dir.join("wrong");
441        let correct_target = env.data_dir.join("correct");
442        let user_path = env.home.join(".vimrc");
443
444        env.fs.mkdir_all(&env.data_dir).unwrap();
445        env.fs.write_file(&wrong_target, b"wrong").unwrap();
446        env.fs.write_file(&correct_target, b"right").unwrap();
447
448        // Create wrong symlink
449        env.fs.symlink(&wrong_target, &user_path).unwrap();
450
451        // Should fix it
452        ds.create_user_link(&correct_target, &user_path).unwrap();
453        env.assert_symlink(&user_path, &correct_target);
454    }
455
456    // ── Double-link chain ───────────────────────────────────────
457
458    #[test]
459    fn full_double_link_chain() {
460        let env = TempEnvironment::builder()
461            .pack("vim")
462            .file("vimrc", "set nocompatible")
463            .done()
464            .build();
465        let (ds, _) = make_datastore(&env);
466
467        let source = env.dotfiles_root.join("vim/vimrc");
468        let user_path = env.home.join(".vimrc");
469
470        // Step 1: data link
471        let datastore_path = ds.create_data_link("vim", "symlink", &source).unwrap();
472
473        // Step 2: user link
474        ds.create_user_link(&datastore_path, &user_path).unwrap();
475
476        // Verify the full chain
477        env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
478
479        // Reading through the chain should yield the original content
480        let content = env.fs.read_to_string(&user_path).unwrap();
481        assert_eq!(content, "set nocompatible");
482    }
483
484    // ── run_and_record / has_sentinel ───────────────────────────
485
486    #[test]
487    fn run_and_record_creates_sentinel() {
488        let env = TempEnvironment::builder().build();
489        let (ds, runner) = make_datastore(&env);
490
491        assert!(!ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
492
493        ds.run_and_record(
494            "vim",
495            "install",
496            "echo",
497            &["hello".into()],
498            "install.sh-abc",
499            false,
500        )
501        .unwrap();
502
503        assert!(ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
504        assert_eq!(runner.calls(), vec!["echo hello"]);
505
506        // Sentinel file should contain "completed|..."
507        let sentinel_path = env
508            .paths
509            .handler_data_dir("vim", "install")
510            .join("install.sh-abc");
511        let content = env.fs.read_to_string(&sentinel_path).unwrap();
512        assert!(content.starts_with("completed|"), "got: {content}");
513    }
514
515    #[test]
516    fn run_and_record_is_idempotent() {
517        let env = TempEnvironment::builder().build();
518        let (ds, runner) = make_datastore(&env);
519
520        ds.run_and_record("vim", "install", "echo", &["first".into()], "s1", false)
521            .unwrap();
522        ds.run_and_record("vim", "install", "echo", &["second".into()], "s1", false)
523            .unwrap();
524
525        // Command only ran once
526        assert_eq!(runner.calls(), vec!["echo first"]);
527    }
528
529    #[test]
530    fn run_and_record_propagates_command_failure() {
531        let env = TempEnvironment::builder().build();
532        let runner = Arc::new(MockCommandRunner::failing());
533        let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner);
534
535        let err = ds
536            .run_and_record("vim", "install", "bad-cmd", &[], "s1", false)
537            .unwrap_err();
538
539        assert!(
540            matches!(err, crate::DodotError::CommandFailed { .. }),
541            "expected CommandFailed, got: {err}"
542        );
543
544        // No sentinel should be created on failure
545        assert!(!ds.has_sentinel("vim", "install", "s1").unwrap());
546    }
547
548    // ── remove_state ────────────────────────────────────────────
549
550    #[test]
551    fn remove_state_clears_handler_dir() {
552        let env = TempEnvironment::builder()
553            .pack("vim")
554            .file("vimrc", "x")
555            .done()
556            .build();
557        let (ds, _) = make_datastore(&env);
558
559        let source = env.dotfiles_root.join("vim/vimrc");
560        ds.create_data_link("vim", "symlink", &source).unwrap();
561        assert!(ds.has_handler_state("vim", "symlink").unwrap());
562
563        ds.remove_state("vim", "symlink").unwrap();
564        env.assert_no_handler_state("vim", "symlink");
565    }
566
567    #[test]
568    fn remove_state_is_noop_when_no_state() {
569        let env = TempEnvironment::builder().build();
570        let (ds, _) = make_datastore(&env);
571
572        // Should not error
573        ds.remove_state("nonexistent", "handler").unwrap();
574    }
575
576    // ── has_handler_state ───────────────────────────────────────
577
578    #[test]
579    fn has_handler_state_false_when_no_dir() {
580        let env = TempEnvironment::builder().build();
581        let (ds, _) = make_datastore(&env);
582
583        assert!(!ds.has_handler_state("vim", "symlink").unwrap());
584    }
585
586    #[test]
587    fn has_handler_state_false_when_empty_dir() {
588        let env = TempEnvironment::builder().build();
589        let (ds, _) = make_datastore(&env);
590
591        let dir = env.paths.handler_data_dir("vim", "symlink");
592        env.fs.mkdir_all(&dir).unwrap();
593
594        assert!(!ds.has_handler_state("vim", "symlink").unwrap());
595    }
596
597    #[test]
598    fn has_handler_state_true_when_entries_exist() {
599        let env = TempEnvironment::builder()
600            .pack("vim")
601            .file("vimrc", "x")
602            .done()
603            .build();
604        let (ds, _) = make_datastore(&env);
605
606        let source = env.dotfiles_root.join("vim/vimrc");
607        ds.create_data_link("vim", "symlink", &source).unwrap();
608
609        assert!(ds.has_handler_state("vim", "symlink").unwrap());
610    }
611
612    // ── list_pack_handlers ──────────────────────────────────────
613
614    #[test]
615    fn list_pack_handlers_returns_handler_dirs() {
616        let env = TempEnvironment::builder()
617            .pack("vim")
618            .file("vimrc", "x")
619            .file("aliases.sh", "y")
620            .done()
621            .build();
622        let (ds, _) = make_datastore(&env);
623
624        let source1 = env.dotfiles_root.join("vim/vimrc");
625        let source2 = env.dotfiles_root.join("vim/aliases.sh");
626        ds.create_data_link("vim", "symlink", &source1).unwrap();
627        ds.create_data_link("vim", "shell", &source2).unwrap();
628
629        let mut handlers = ds.list_pack_handlers("vim").unwrap();
630        handlers.sort();
631        assert_eq!(handlers, vec!["shell", "symlink"]);
632    }
633
634    #[test]
635    fn list_pack_handlers_empty_when_no_pack_state() {
636        let env = TempEnvironment::builder().build();
637        let (ds, _) = make_datastore(&env);
638
639        let handlers = ds.list_pack_handlers("nonexistent").unwrap();
640        assert!(handlers.is_empty());
641    }
642
643    // ── list_handler_sentinels ──────────────────────────────────
644
645    #[test]
646    fn list_handler_sentinels_returns_file_names() {
647        let env = TempEnvironment::builder().build();
648        let (ds, _) = make_datastore(&env);
649
650        ds.run_and_record(
651            "vim",
652            "install",
653            "echo",
654            &["a".into()],
655            "install.sh-aaa",
656            false,
657        )
658        .unwrap();
659        ds.run_and_record(
660            "vim",
661            "install",
662            "echo",
663            &["b".into()],
664            "install.sh-bbb",
665            false,
666        )
667        .unwrap();
668
669        let mut sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
670        sentinels.sort();
671        assert_eq!(sentinels, vec!["install.sh-aaa", "install.sh-bbb"]);
672    }
673
674    #[test]
675    fn list_handler_sentinels_empty_when_no_state() {
676        let env = TempEnvironment::builder().build();
677        let (ds, _) = make_datastore(&env);
678
679        let sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
680        assert!(sentinels.is_empty());
681    }
682
683    // ── write_rendered_file ───────────────────────────────────────
684
685    #[test]
686    fn write_rendered_file_creates_regular_file() {
687        let env = TempEnvironment::builder().build();
688        let (ds, _) = make_datastore(&env);
689
690        let path = ds
691            .write_rendered_file("app", "preprocessed", "config.toml", b"host = localhost")
692            .unwrap();
693
694        assert!(env.fs.exists(&path));
695        assert!(!env.fs.is_symlink(&path));
696        assert_eq!(env.fs.read_to_string(&path).unwrap(), "host = localhost");
697    }
698
699    #[test]
700    fn write_rendered_file_overwrites_existing() {
701        let env = TempEnvironment::builder().build();
702        let (ds, _) = make_datastore(&env);
703
704        let path1 = ds
705            .write_rendered_file("app", "preprocessed", "config.toml", b"version 1")
706            .unwrap();
707        let path2 = ds
708            .write_rendered_file("app", "preprocessed", "config.toml", b"version 2")
709            .unwrap();
710
711        assert_eq!(path1, path2);
712        assert_eq!(env.fs.read_to_string(&path1).unwrap(), "version 2");
713    }
714
715    #[test]
716    fn write_rendered_file_empty_content() {
717        let env = TempEnvironment::builder().build();
718        let (ds, _) = make_datastore(&env);
719
720        let path = ds
721            .write_rendered_file("app", "preprocessed", "empty.conf", b"")
722            .unwrap();
723
724        assert!(env.fs.exists(&path));
725        assert_eq!(env.fs.read_to_string(&path).unwrap(), "");
726    }
727
728    #[test]
729    fn write_rendered_file_binary_content() {
730        let env = TempEnvironment::builder().build();
731        let (ds, _) = make_datastore(&env);
732
733        let binary = vec![0u8, 1, 2, 255, 254, 253];
734        let path = ds
735            .write_rendered_file("app", "preprocessed", "data.bin", &binary)
736            .unwrap();
737
738        assert_eq!(env.fs.read_file(&path).unwrap(), binary);
739    }
740
741    #[test]
742    fn write_rendered_file_creates_parent_dirs() {
743        let env = TempEnvironment::builder().build();
744        let (ds, _) = make_datastore(&env);
745
746        // handler_data_dir doesn't exist yet — write_rendered_file should create it
747        let handler_dir = env.paths.handler_data_dir("newpack", "preprocessed");
748        assert!(!env.fs.exists(&handler_dir));
749
750        let path = ds
751            .write_rendered_file("newpack", "preprocessed", "file.txt", b"hello")
752            .unwrap();
753
754        assert!(env.fs.exists(&path));
755        assert_eq!(env.fs.read_to_string(&path).unwrap(), "hello");
756    }
757
758    #[test]
759    fn write_rendered_file_rejects_absolute_path() {
760        let env = TempEnvironment::builder().build();
761        let (ds, _) = make_datastore(&env);
762
763        let err = ds
764            .write_rendered_file("app", "preprocessed", "/etc/passwd", b"x")
765            .unwrap_err();
766        assert!(
767            matches!(err, crate::DodotError::Other(ref m) if m.contains("unsafe")),
768            "expected unsafe-path error, got: {err}"
769        );
770    }
771
772    #[test]
773    fn write_rendered_file_rejects_parent_dir() {
774        let env = TempEnvironment::builder().build();
775        let (ds, _) = make_datastore(&env);
776
777        let err = ds
778            .write_rendered_file("app", "preprocessed", "../escape.txt", b"x")
779            .unwrap_err();
780        assert!(
781            matches!(err, crate::DodotError::Other(ref m) if m.contains("unsafe")),
782            "expected unsafe-path error, got: {err}"
783        );
784    }
785
786    #[test]
787    fn write_rendered_dir_creates_dir() {
788        let env = TempEnvironment::builder().build();
789        let (ds, _) = make_datastore(&env);
790
791        let path = ds
792            .write_rendered_dir("app", "preprocessed", "sub/nested")
793            .unwrap();
794
795        assert!(env.fs.is_dir(&path));
796        assert!(!env.fs.is_symlink(&path));
797    }
798
799    #[test]
800    fn write_rendered_dir_is_idempotent() {
801        let env = TempEnvironment::builder().build();
802        let (ds, _) = make_datastore(&env);
803
804        let p1 = ds.write_rendered_dir("app", "preprocessed", "d").unwrap();
805        let p2 = ds.write_rendered_dir("app", "preprocessed", "d").unwrap();
806        assert_eq!(p1, p2);
807        assert!(env.fs.is_dir(&p1));
808    }
809
810    #[test]
811    fn write_rendered_dir_rejects_unsafe_paths() {
812        let env = TempEnvironment::builder().build();
813        let (ds, _) = make_datastore(&env);
814
815        assert!(ds
816            .write_rendered_dir("app", "preprocessed", "/abs")
817            .is_err());
818        assert!(ds
819            .write_rendered_dir("app", "preprocessed", "../esc")
820            .is_err());
821    }
822
823    #[test]
824    fn write_rendered_file_supports_nested_filename() {
825        // A filename containing `/` should be written to the corresponding
826        // nested directory under the handler data dir, creating any needed
827        // parents. This preserves source structure for preprocessor output.
828        let env = TempEnvironment::builder().build();
829        let (ds, _) = make_datastore(&env);
830
831        let path = ds
832            .write_rendered_file("app", "preprocessed", "sub/nested/file.txt", b"deep")
833            .unwrap();
834
835        assert!(env.fs.exists(&path));
836        assert!(!env.fs.is_symlink(&path));
837        assert_eq!(env.fs.read_to_string(&path).unwrap(), "deep");
838        assert!(
839            path.to_string_lossy().contains("sub/nested/file.txt"),
840            "path should contain nested structure: {}",
841            path.display()
842        );
843    }
844
845    // ── Object safety ───────────────────────────────────────────
846
847    #[allow(dead_code)]
848    fn assert_object_safe(_: &dyn DataStore) {}
849}