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
9fn 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
38pub 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 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 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 if let Some(parent) = user_path.parent() {
92 self.fs.mkdir_all(parent)?;
93 }
94
95 if self.fs.is_symlink(user_path) {
97 if let Ok(current_target) = self.fs.readlink(user_path) {
99 if current_target == datastore_path {
100 return Ok(()); }
102 }
103 self.fs.remove_file(user_path)?;
105 } else if self.fs.exists(user_path) {
106 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 if !force && self.has_sentinel(pack, handler, sentinel)? {
126 return Ok(());
127 }
128
129 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 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 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 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 #[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 assert_eq!(
328 link_path,
329 env.paths.handler_data_dir("vim", "symlink").join("vimrc")
330 );
331
332 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 let link_dir = env.paths.handler_data_dir("vim", "symlink");
369 env.fs.mkdir_all(&link_dir).unwrap();
370 let wrong_link = link_dir.join("vimrc-new");
372 env.fs.symlink(&source1, &wrong_link).unwrap();
373
374 let link_path = ds.create_data_link("vim", "symlink", &source2).unwrap();
376 env.assert_symlink(&link_path, &source2);
377 }
378
379 #[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 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 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 env.fs.symlink(&wrong_target, &user_path).unwrap();
450
451 ds.create_user_link(&correct_target, &user_path).unwrap();
453 env.assert_symlink(&user_path, &correct_target);
454 }
455
456 #[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 let datastore_path = ds.create_data_link("vim", "symlink", &source).unwrap();
472
473 ds.create_user_link(&datastore_path, &user_path).unwrap();
475
476 env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
478
479 let content = env.fs.read_to_string(&user_path).unwrap();
481 assert_eq!(content, "set nocompatible");
482 }
483
484 #[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 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 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 assert!(!ds.has_sentinel("vim", "install", "s1").unwrap());
546 }
547
548 #[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 ds.remove_state("nonexistent", "handler").unwrap();
574 }
575
576 #[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 #[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 #[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 #[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 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 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 #[allow(dead_code)]
848 fn assert_object_safe(_: &dyn DataStore) {}
849}