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        // Run the command
130        self.runner.run(executable, arguments)?;
131
132        // Record sentinel
133        let sentinel_dir = self.paths.handler_data_dir(pack, handler);
134        self.fs.mkdir_all(&sentinel_dir)?;
135
136        let sentinel_path = sentinel_dir.join(sentinel);
137        let timestamp = std::time::SystemTime::now()
138            .duration_since(std::time::UNIX_EPOCH)
139            .unwrap_or_default()
140            .as_secs();
141        let content = format!("completed|{timestamp}");
142        self.fs.write_file(&sentinel_path, content.as_bytes())
143    }
144
145    fn has_sentinel(&self, pack: &str, handler: &str, sentinel: &str) -> Result<bool> {
146        let sentinel_path = self.paths.handler_data_dir(pack, handler).join(sentinel);
147        Ok(self.fs.exists(&sentinel_path))
148    }
149
150    fn remove_state(&self, pack: &str, handler: &str) -> Result<()> {
151        let state_dir = self.paths.handler_data_dir(pack, handler);
152        if !self.fs.exists(&state_dir) {
153            return Ok(());
154        }
155        self.fs.remove_dir_all(&state_dir)
156    }
157
158    fn has_handler_state(&self, pack: &str, handler: &str) -> Result<bool> {
159        let state_dir = self.paths.handler_data_dir(pack, handler);
160        if !self.fs.exists(&state_dir) {
161            return Ok(false);
162        }
163        let entries = self.fs.read_dir(&state_dir)?;
164        Ok(!entries.is_empty())
165    }
166
167    fn list_pack_handlers(&self, pack: &str) -> Result<Vec<String>> {
168        let pack_dir = self.paths.pack_data_dir(pack);
169        if !self.fs.exists(&pack_dir) {
170            return Ok(Vec::new());
171        }
172        let entries = self.fs.read_dir(&pack_dir)?;
173        Ok(entries
174            .into_iter()
175            .filter(|e| e.is_dir)
176            .map(|e| e.name)
177            .collect())
178    }
179
180    fn list_handler_sentinels(&self, pack: &str, handler: &str) -> Result<Vec<String>> {
181        let handler_dir = self.paths.handler_data_dir(pack, handler);
182        if !self.fs.exists(&handler_dir) {
183            return Ok(Vec::new());
184        }
185        let entries = self.fs.read_dir(&handler_dir)?;
186        Ok(entries
187            .into_iter()
188            .filter(|e| e.is_file)
189            .map(|e| e.name)
190            .collect())
191    }
192
193    fn write_rendered_file(
194        &self,
195        pack: &str,
196        handler: &str,
197        filename: &str,
198        content: &[u8],
199    ) -> Result<PathBuf> {
200        let dir = self.paths.handler_data_dir(pack, handler);
201        let relative = validate_safe_relative(filename, &dir)?;
202        let path = dir.join(&relative);
203        // Create the full parent chain (supports nested filenames like "sub/file.txt")
204        if let Some(parent) = path.parent() {
205            self.fs.mkdir_all(parent)?;
206        } else {
207            self.fs.mkdir_all(&dir)?;
208        }
209        self.fs.write_file(&path, content)?;
210        Ok(path)
211    }
212
213    fn write_rendered_dir(&self, pack: &str, handler: &str, relative: &str) -> Result<PathBuf> {
214        let dir = self.paths.handler_data_dir(pack, handler);
215        let rel = validate_safe_relative(relative, &dir)?;
216        let path = dir.join(&rel);
217        self.fs.mkdir_all(&path)?;
218        Ok(path)
219    }
220
221    fn sentinel_path(&self, pack: &str, handler: &str, sentinel: &str) -> PathBuf {
222        self.paths.handler_data_dir(pack, handler).join(sentinel)
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::datastore::{CommandOutput, CommandRunner};
230    use crate::testing::TempEnvironment;
231    use std::sync::Mutex;
232
233    /// Mock command runner that records calls and can be configured to
234    /// succeed or fail.
235    struct MockCommandRunner {
236        calls: Mutex<Vec<String>>,
237        should_fail: bool,
238    }
239
240    impl MockCommandRunner {
241        fn new() -> Self {
242            Self {
243                calls: Mutex::new(Vec::new()),
244                should_fail: false,
245            }
246        }
247
248        fn failing() -> Self {
249            Self {
250                calls: Mutex::new(Vec::new()),
251                should_fail: true,
252            }
253        }
254
255        fn calls(&self) -> Vec<String> {
256            self.calls.lock().unwrap().clone()
257        }
258    }
259
260    impl CommandRunner for MockCommandRunner {
261        fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
262            let cmd_str = format!("{} {}", executable, arguments.join(" "));
263            self.calls.lock().unwrap().push(cmd_str.trim().to_string());
264            if self.should_fail {
265                Err(crate::DodotError::CommandFailed {
266                    command: cmd_str.trim().to_string(),
267                    exit_code: 1,
268                    stderr: "mock failure".to_string(),
269                })
270            } else {
271                Ok(CommandOutput {
272                    exit_code: 0,
273                    stdout: String::new(),
274                    stderr: String::new(),
275                })
276            }
277        }
278    }
279
280    fn make_datastore(env: &TempEnvironment) -> (FilesystemDataStore, Arc<MockCommandRunner>) {
281        let runner = Arc::new(MockCommandRunner::new());
282        let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner.clone());
283        (ds, runner)
284    }
285
286    // ── create_data_link ────────────────────────────────────────
287
288    #[test]
289    fn create_data_link_creates_symlink() {
290        let env = TempEnvironment::builder()
291            .pack("vim")
292            .file("vimrc", "set nocompatible")
293            .done()
294            .build();
295        let (ds, _) = make_datastore(&env);
296
297        let source = env.dotfiles_root.join("vim/vimrc");
298        let link_path = ds.create_data_link("vim", "symlink", &source).unwrap();
299
300        // Link should be in the handler data dir
301        assert_eq!(
302            link_path,
303            env.paths.handler_data_dir("vim", "symlink").join("vimrc")
304        );
305
306        // Link should point to source
307        env.assert_symlink(&link_path, &source);
308    }
309
310    #[test]
311    fn create_data_link_is_idempotent() {
312        let env = TempEnvironment::builder()
313            .pack("vim")
314            .file("vimrc", "set nocompatible")
315            .done()
316            .build();
317        let (ds, _) = make_datastore(&env);
318
319        let source = env.dotfiles_root.join("vim/vimrc");
320
321        let path1 = ds.create_data_link("vim", "symlink", &source).unwrap();
322        let path2 = ds.create_data_link("vim", "symlink", &source).unwrap();
323
324        assert_eq!(path1, path2);
325        env.assert_symlink(&path1, &source);
326    }
327
328    #[test]
329    fn create_data_link_replaces_wrong_target() {
330        let env = TempEnvironment::builder()
331            .pack("vim")
332            .file("vimrc", "v1")
333            .file("vimrc-new", "v2")
334            .done()
335            .build();
336        let (ds, _) = make_datastore(&env);
337
338        let source1 = env.dotfiles_root.join("vim/vimrc");
339        let source2 = env.dotfiles_root.join("vim/vimrc-new");
340
341        // Create initial link to source1
342        let link_dir = env.paths.handler_data_dir("vim", "symlink");
343        env.fs.mkdir_all(&link_dir).unwrap();
344        // Manually create a link named "vimrc-new" pointing to source1 (wrong target)
345        let wrong_link = link_dir.join("vimrc-new");
346        env.fs.symlink(&source1, &wrong_link).unwrap();
347
348        // Now create_data_link should fix it to point at source2
349        let link_path = ds.create_data_link("vim", "symlink", &source2).unwrap();
350        env.assert_symlink(&link_path, &source2);
351    }
352
353    // ── create_user_link ────────────────────────────────────────
354
355    #[test]
356    fn create_user_link_creates_symlink() {
357        let env = TempEnvironment::builder().build();
358        let (ds, _) = make_datastore(&env);
359
360        let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
361        let user_path = env.home.join(".vimrc");
362
363        // Create the datastore file so the symlink target exists
364        env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
365        env.fs.write_file(&datastore_path, b"link target").unwrap();
366
367        ds.create_user_link(&datastore_path, &user_path).unwrap();
368
369        env.assert_symlink(&user_path, &datastore_path);
370    }
371
372    #[test]
373    fn create_user_link_is_idempotent() {
374        let env = TempEnvironment::builder().build();
375        let (ds, _) = make_datastore(&env);
376
377        let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
378        let user_path = env.home.join(".vimrc");
379
380        env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
381        env.fs.write_file(&datastore_path, b"x").unwrap();
382
383        ds.create_user_link(&datastore_path, &user_path).unwrap();
384        ds.create_user_link(&datastore_path, &user_path).unwrap();
385
386        env.assert_symlink(&user_path, &datastore_path);
387    }
388
389    #[test]
390    fn create_user_link_conflict_with_regular_file() {
391        let env = TempEnvironment::builder().build();
392        let (ds, _) = make_datastore(&env);
393
394        let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
395        let user_path = env.home.join(".vimrc");
396
397        // Create a regular file at the user path
398        env.fs.write_file(&user_path, b"existing content").unwrap();
399
400        let err = ds
401            .create_user_link(&datastore_path, &user_path)
402            .unwrap_err();
403        assert!(
404            matches!(err, crate::DodotError::SymlinkConflict { .. }),
405            "expected SymlinkConflict, got: {err}"
406        );
407    }
408
409    #[test]
410    fn create_user_link_replaces_wrong_symlink() {
411        let env = TempEnvironment::builder().build();
412        let (ds, _) = make_datastore(&env);
413
414        let wrong_target = env.data_dir.join("wrong");
415        let correct_target = env.data_dir.join("correct");
416        let user_path = env.home.join(".vimrc");
417
418        env.fs.mkdir_all(&env.data_dir).unwrap();
419        env.fs.write_file(&wrong_target, b"wrong").unwrap();
420        env.fs.write_file(&correct_target, b"right").unwrap();
421
422        // Create wrong symlink
423        env.fs.symlink(&wrong_target, &user_path).unwrap();
424
425        // Should fix it
426        ds.create_user_link(&correct_target, &user_path).unwrap();
427        env.assert_symlink(&user_path, &correct_target);
428    }
429
430    // ── Double-link chain ───────────────────────────────────────
431
432    #[test]
433    fn full_double_link_chain() {
434        let env = TempEnvironment::builder()
435            .pack("vim")
436            .file("vimrc", "set nocompatible")
437            .done()
438            .build();
439        let (ds, _) = make_datastore(&env);
440
441        let source = env.dotfiles_root.join("vim/vimrc");
442        let user_path = env.home.join(".vimrc");
443
444        // Step 1: data link
445        let datastore_path = ds.create_data_link("vim", "symlink", &source).unwrap();
446
447        // Step 2: user link
448        ds.create_user_link(&datastore_path, &user_path).unwrap();
449
450        // Verify the full chain
451        env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
452
453        // Reading through the chain should yield the original content
454        let content = env.fs.read_to_string(&user_path).unwrap();
455        assert_eq!(content, "set nocompatible");
456    }
457
458    // ── run_and_record / has_sentinel ───────────────────────────
459
460    #[test]
461    fn run_and_record_creates_sentinel() {
462        let env = TempEnvironment::builder().build();
463        let (ds, runner) = make_datastore(&env);
464
465        assert!(!ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
466
467        ds.run_and_record(
468            "vim",
469            "install",
470            "echo",
471            &["hello".into()],
472            "install.sh-abc",
473            false,
474        )
475        .unwrap();
476
477        assert!(ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
478        assert_eq!(runner.calls(), vec!["echo hello"]);
479
480        // Sentinel file should contain "completed|..."
481        let sentinel_path = env
482            .paths
483            .handler_data_dir("vim", "install")
484            .join("install.sh-abc");
485        let content = env.fs.read_to_string(&sentinel_path).unwrap();
486        assert!(content.starts_with("completed|"), "got: {content}");
487    }
488
489    #[test]
490    fn run_and_record_is_idempotent() {
491        let env = TempEnvironment::builder().build();
492        let (ds, runner) = make_datastore(&env);
493
494        ds.run_and_record("vim", "install", "echo", &["first".into()], "s1", false)
495            .unwrap();
496        ds.run_and_record("vim", "install", "echo", &["second".into()], "s1", false)
497            .unwrap();
498
499        // Command only ran once
500        assert_eq!(runner.calls(), vec!["echo first"]);
501    }
502
503    #[test]
504    fn run_and_record_propagates_command_failure() {
505        let env = TempEnvironment::builder().build();
506        let runner = Arc::new(MockCommandRunner::failing());
507        let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner);
508
509        let err = ds
510            .run_and_record("vim", "install", "bad-cmd", &[], "s1", false)
511            .unwrap_err();
512
513        assert!(
514            matches!(err, crate::DodotError::CommandFailed { .. }),
515            "expected CommandFailed, got: {err}"
516        );
517
518        // No sentinel should be created on failure
519        assert!(!ds.has_sentinel("vim", "install", "s1").unwrap());
520    }
521
522    // ── remove_state ────────────────────────────────────────────
523
524    #[test]
525    fn remove_state_clears_handler_dir() {
526        let env = TempEnvironment::builder()
527            .pack("vim")
528            .file("vimrc", "x")
529            .done()
530            .build();
531        let (ds, _) = make_datastore(&env);
532
533        let source = env.dotfiles_root.join("vim/vimrc");
534        ds.create_data_link("vim", "symlink", &source).unwrap();
535        assert!(ds.has_handler_state("vim", "symlink").unwrap());
536
537        ds.remove_state("vim", "symlink").unwrap();
538        env.assert_no_handler_state("vim", "symlink");
539    }
540
541    #[test]
542    fn remove_state_is_noop_when_no_state() {
543        let env = TempEnvironment::builder().build();
544        let (ds, _) = make_datastore(&env);
545
546        // Should not error
547        ds.remove_state("nonexistent", "handler").unwrap();
548    }
549
550    // ── has_handler_state ───────────────────────────────────────
551
552    #[test]
553    fn has_handler_state_false_when_no_dir() {
554        let env = TempEnvironment::builder().build();
555        let (ds, _) = make_datastore(&env);
556
557        assert!(!ds.has_handler_state("vim", "symlink").unwrap());
558    }
559
560    #[test]
561    fn has_handler_state_false_when_empty_dir() {
562        let env = TempEnvironment::builder().build();
563        let (ds, _) = make_datastore(&env);
564
565        let dir = env.paths.handler_data_dir("vim", "symlink");
566        env.fs.mkdir_all(&dir).unwrap();
567
568        assert!(!ds.has_handler_state("vim", "symlink").unwrap());
569    }
570
571    #[test]
572    fn has_handler_state_true_when_entries_exist() {
573        let env = TempEnvironment::builder()
574            .pack("vim")
575            .file("vimrc", "x")
576            .done()
577            .build();
578        let (ds, _) = make_datastore(&env);
579
580        let source = env.dotfiles_root.join("vim/vimrc");
581        ds.create_data_link("vim", "symlink", &source).unwrap();
582
583        assert!(ds.has_handler_state("vim", "symlink").unwrap());
584    }
585
586    // ── list_pack_handlers ──────────────────────────────────────
587
588    #[test]
589    fn list_pack_handlers_returns_handler_dirs() {
590        let env = TempEnvironment::builder()
591            .pack("vim")
592            .file("vimrc", "x")
593            .file("aliases.sh", "y")
594            .done()
595            .build();
596        let (ds, _) = make_datastore(&env);
597
598        let source1 = env.dotfiles_root.join("vim/vimrc");
599        let source2 = env.dotfiles_root.join("vim/aliases.sh");
600        ds.create_data_link("vim", "symlink", &source1).unwrap();
601        ds.create_data_link("vim", "shell", &source2).unwrap();
602
603        let mut handlers = ds.list_pack_handlers("vim").unwrap();
604        handlers.sort();
605        assert_eq!(handlers, vec!["shell", "symlink"]);
606    }
607
608    #[test]
609    fn list_pack_handlers_empty_when_no_pack_state() {
610        let env = TempEnvironment::builder().build();
611        let (ds, _) = make_datastore(&env);
612
613        let handlers = ds.list_pack_handlers("nonexistent").unwrap();
614        assert!(handlers.is_empty());
615    }
616
617    // ── list_handler_sentinels ──────────────────────────────────
618
619    #[test]
620    fn list_handler_sentinels_returns_file_names() {
621        let env = TempEnvironment::builder().build();
622        let (ds, _) = make_datastore(&env);
623
624        ds.run_and_record(
625            "vim",
626            "install",
627            "echo",
628            &["a".into()],
629            "install.sh-aaa",
630            false,
631        )
632        .unwrap();
633        ds.run_and_record(
634            "vim",
635            "install",
636            "echo",
637            &["b".into()],
638            "install.sh-bbb",
639            false,
640        )
641        .unwrap();
642
643        let mut sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
644        sentinels.sort();
645        assert_eq!(sentinels, vec!["install.sh-aaa", "install.sh-bbb"]);
646    }
647
648    #[test]
649    fn list_handler_sentinels_empty_when_no_state() {
650        let env = TempEnvironment::builder().build();
651        let (ds, _) = make_datastore(&env);
652
653        let sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
654        assert!(sentinels.is_empty());
655    }
656
657    // ── write_rendered_file ───────────────────────────────────────
658
659    #[test]
660    fn write_rendered_file_creates_regular_file() {
661        let env = TempEnvironment::builder().build();
662        let (ds, _) = make_datastore(&env);
663
664        let path = ds
665            .write_rendered_file("app", "preprocessed", "config.toml", b"host = localhost")
666            .unwrap();
667
668        assert!(env.fs.exists(&path));
669        assert!(!env.fs.is_symlink(&path));
670        assert_eq!(env.fs.read_to_string(&path).unwrap(), "host = localhost");
671    }
672
673    #[test]
674    fn write_rendered_file_overwrites_existing() {
675        let env = TempEnvironment::builder().build();
676        let (ds, _) = make_datastore(&env);
677
678        let path1 = ds
679            .write_rendered_file("app", "preprocessed", "config.toml", b"version 1")
680            .unwrap();
681        let path2 = ds
682            .write_rendered_file("app", "preprocessed", "config.toml", b"version 2")
683            .unwrap();
684
685        assert_eq!(path1, path2);
686        assert_eq!(env.fs.read_to_string(&path1).unwrap(), "version 2");
687    }
688
689    #[test]
690    fn write_rendered_file_empty_content() {
691        let env = TempEnvironment::builder().build();
692        let (ds, _) = make_datastore(&env);
693
694        let path = ds
695            .write_rendered_file("app", "preprocessed", "empty.conf", b"")
696            .unwrap();
697
698        assert!(env.fs.exists(&path));
699        assert_eq!(env.fs.read_to_string(&path).unwrap(), "");
700    }
701
702    #[test]
703    fn write_rendered_file_binary_content() {
704        let env = TempEnvironment::builder().build();
705        let (ds, _) = make_datastore(&env);
706
707        let binary = vec![0u8, 1, 2, 255, 254, 253];
708        let path = ds
709            .write_rendered_file("app", "preprocessed", "data.bin", &binary)
710            .unwrap();
711
712        assert_eq!(env.fs.read_file(&path).unwrap(), binary);
713    }
714
715    #[test]
716    fn write_rendered_file_creates_parent_dirs() {
717        let env = TempEnvironment::builder().build();
718        let (ds, _) = make_datastore(&env);
719
720        // handler_data_dir doesn't exist yet — write_rendered_file should create it
721        let handler_dir = env.paths.handler_data_dir("newpack", "preprocessed");
722        assert!(!env.fs.exists(&handler_dir));
723
724        let path = ds
725            .write_rendered_file("newpack", "preprocessed", "file.txt", b"hello")
726            .unwrap();
727
728        assert!(env.fs.exists(&path));
729        assert_eq!(env.fs.read_to_string(&path).unwrap(), "hello");
730    }
731
732    #[test]
733    fn write_rendered_file_rejects_absolute_path() {
734        let env = TempEnvironment::builder().build();
735        let (ds, _) = make_datastore(&env);
736
737        let err = ds
738            .write_rendered_file("app", "preprocessed", "/etc/passwd", b"x")
739            .unwrap_err();
740        assert!(
741            matches!(err, crate::DodotError::Other(ref m) if m.contains("unsafe")),
742            "expected unsafe-path error, got: {err}"
743        );
744    }
745
746    #[test]
747    fn write_rendered_file_rejects_parent_dir() {
748        let env = TempEnvironment::builder().build();
749        let (ds, _) = make_datastore(&env);
750
751        let err = ds
752            .write_rendered_file("app", "preprocessed", "../escape.txt", b"x")
753            .unwrap_err();
754        assert!(
755            matches!(err, crate::DodotError::Other(ref m) if m.contains("unsafe")),
756            "expected unsafe-path error, got: {err}"
757        );
758    }
759
760    #[test]
761    fn write_rendered_dir_creates_dir() {
762        let env = TempEnvironment::builder().build();
763        let (ds, _) = make_datastore(&env);
764
765        let path = ds
766            .write_rendered_dir("app", "preprocessed", "sub/nested")
767            .unwrap();
768
769        assert!(env.fs.is_dir(&path));
770        assert!(!env.fs.is_symlink(&path));
771    }
772
773    #[test]
774    fn write_rendered_dir_is_idempotent() {
775        let env = TempEnvironment::builder().build();
776        let (ds, _) = make_datastore(&env);
777
778        let p1 = ds.write_rendered_dir("app", "preprocessed", "d").unwrap();
779        let p2 = ds.write_rendered_dir("app", "preprocessed", "d").unwrap();
780        assert_eq!(p1, p2);
781        assert!(env.fs.is_dir(&p1));
782    }
783
784    #[test]
785    fn write_rendered_dir_rejects_unsafe_paths() {
786        let env = TempEnvironment::builder().build();
787        let (ds, _) = make_datastore(&env);
788
789        assert!(ds
790            .write_rendered_dir("app", "preprocessed", "/abs")
791            .is_err());
792        assert!(ds
793            .write_rendered_dir("app", "preprocessed", "../esc")
794            .is_err());
795    }
796
797    #[test]
798    fn write_rendered_file_supports_nested_filename() {
799        // A filename containing `/` should be written to the corresponding
800        // nested directory under the handler data dir, creating any needed
801        // parents. This preserves source structure for preprocessor output.
802        let env = TempEnvironment::builder().build();
803        let (ds, _) = make_datastore(&env);
804
805        let path = ds
806            .write_rendered_file("app", "preprocessed", "sub/nested/file.txt", b"deep")
807            .unwrap();
808
809        assert!(env.fs.exists(&path));
810        assert!(!env.fs.is_symlink(&path));
811        assert_eq!(env.fs.read_to_string(&path).unwrap(), "deep");
812        assert!(
813            path.to_string_lossy().contains("sub/nested/file.txt"),
814            "path should contain nested structure: {}",
815            path.display()
816        );
817    }
818
819    // ── Object safety ───────────────────────────────────────────
820
821    #[allow(dead_code)]
822    fn assert_object_safe(_: &dyn DataStore) {}
823}