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(crate) fn extract_header_block(content: &str) -> Vec<String> {
47 let mut out = Vec::new();
48 let mut iter = content.lines().peekable();
49 if matches!(iter.peek(), Some(l) if l.starts_with("#!")) {
50 iter.next();
51 }
52 while matches!(iter.peek(), Some(l) if l.trim().is_empty()) {
53 iter.next();
54 }
55 for line in iter {
56 let t = line.trim_start();
57 if !t.starts_with('#') || t.starts_with("#!") {
58 break;
59 }
60 let stripped = t.trim_start_matches('#').trim_start().to_string();
61 out.push(stripped);
62 }
63 out
64}
65
66pub struct FilesystemDataStore {
77 fs: Arc<dyn Fs>,
78 paths: Arc<dyn Pather>,
79 runner: Arc<dyn CommandRunner>,
80}
81
82impl FilesystemDataStore {
83 pub fn new(fs: Arc<dyn Fs>, paths: Arc<dyn Pather>, runner: Arc<dyn CommandRunner>) -> Self {
84 Self { fs, paths, runner }
85 }
86}
87
88impl DataStore for FilesystemDataStore {
89 fn create_data_link(&self, pack: &str, handler: &str, source_file: &Path) -> Result<PathBuf> {
90 let filename = source_file.file_name().ok_or_else(|| {
91 crate::DodotError::Other(format!(
92 "source file has no filename: {}",
93 source_file.display()
94 ))
95 })?;
96
97 let link_dir = self.paths.handler_data_dir(pack, handler);
98 let link_path = link_dir.join(filename);
99
100 self.fs.mkdir_all(&link_dir)?;
101
102 if self.fs.is_symlink(&link_path) {
104 if let Ok(current_target) = self.fs.readlink(&link_path) {
105 if current_target == source_file {
106 return Ok(link_path);
107 }
108 }
109 self.fs.remove_file(&link_path)?;
111 }
112
113 self.fs.symlink(source_file, &link_path)?;
114 Ok(link_path)
115 }
116
117 fn create_user_link(&self, datastore_path: &Path, user_path: &Path) -> Result<()> {
118 if let Some(parent) = user_path.parent() {
120 self.fs.mkdir_all(parent)?;
121 }
122
123 if self.fs.is_symlink(user_path) {
125 if let Ok(current_target) = self.fs.readlink(user_path) {
127 if current_target == datastore_path {
128 return Ok(()); }
130 }
131 self.fs.remove_file(user_path)?;
133 } else if self.fs.exists(user_path) {
134 return Err(crate::DodotError::SymlinkConflict {
136 path: user_path.to_path_buf(),
137 });
138 }
139
140 self.fs.symlink(datastore_path, user_path)
141 }
142
143 fn run_and_record(
144 &self,
145 pack: &str,
146 handler: &str,
147 executable: &str,
148 arguments: &[String],
149 sentinel: &str,
150 force: bool,
151 ) -> Result<()> {
152 if !force && self.has_sentinel(pack, handler, sentinel)? {
154 return Ok(());
155 }
156
157 let script_path = arguments.last().cloned();
169 let display_name = script_path
170 .as_deref()
171 .and_then(|p| Path::new(p).file_name())
172 .map(|n| n.to_string_lossy().into_owned())
173 .unwrap_or_else(|| executable.to_string());
174 let header = format!("==== {pack} → {handler} → {display_name}");
175 let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
176 let dim = if tty { "\x1b[2m" } else { "" };
177 let green = if tty { "\x1b[32m" } else { "" };
178 let red = if tty { "\x1b[31m" } else { "" };
179 let reset = if tty { "\x1b[0m" } else { "" };
180 eprintln!("{header} {dim}running…{reset}");
181
182 if let Some(path_str) = script_path.as_deref() {
186 if let Ok(content) = self.fs.read_to_string(Path::new(path_str)) {
187 let lines = extract_header_block(&content);
188 if !lines.is_empty() {
189 let stdout = std::io::stdout();
190 let mut h = stdout.lock();
191 use std::io::Write;
192 for line in lines {
193 let _ = writeln!(h, "{dim} {line}{reset}");
194 }
195 }
196 }
197 }
198
199 let result = self.runner.run(executable, arguments);
200 match &result {
201 Ok(_) => eprintln!("{header} {green}OK{reset}"),
202 Err(_) => eprintln!("{header} {red}FAILED{reset}"),
203 }
204 result?;
205
206 let sentinel_dir = self.paths.handler_data_dir(pack, handler);
208 self.fs.mkdir_all(&sentinel_dir)?;
209
210 let sentinel_path = sentinel_dir.join(sentinel);
211 let timestamp = std::time::SystemTime::now()
212 .duration_since(std::time::UNIX_EPOCH)
213 .unwrap_or_default()
214 .as_secs();
215 let content = format!("completed|{timestamp}");
216 self.fs.write_file(&sentinel_path, content.as_bytes())
217 }
218
219 fn has_sentinel(&self, pack: &str, handler: &str, sentinel: &str) -> Result<bool> {
220 let sentinel_path = self.paths.handler_data_dir(pack, handler).join(sentinel);
221 Ok(self.fs.exists(&sentinel_path))
222 }
223
224 fn remove_state(&self, pack: &str, handler: &str) -> Result<()> {
225 let state_dir = self.paths.handler_data_dir(pack, handler);
226 if !self.fs.exists(&state_dir) {
227 return Ok(());
228 }
229 self.fs.remove_dir_all(&state_dir)
230 }
231
232 fn has_handler_state(&self, pack: &str, handler: &str) -> Result<bool> {
233 let state_dir = self.paths.handler_data_dir(pack, handler);
234 if !self.fs.exists(&state_dir) {
235 return Ok(false);
236 }
237 let entries = self.fs.read_dir(&state_dir)?;
238 Ok(!entries.is_empty())
239 }
240
241 fn list_pack_handlers(&self, pack: &str) -> Result<Vec<String>> {
242 let pack_dir = self.paths.pack_data_dir(pack);
243 if !self.fs.exists(&pack_dir) {
244 return Ok(Vec::new());
245 }
246 let entries = self.fs.read_dir(&pack_dir)?;
247 Ok(entries
248 .into_iter()
249 .filter(|e| e.is_dir)
250 .map(|e| e.name)
251 .collect())
252 }
253
254 fn list_handler_sentinels(&self, pack: &str, handler: &str) -> Result<Vec<String>> {
255 let handler_dir = self.paths.handler_data_dir(pack, handler);
256 if !self.fs.exists(&handler_dir) {
257 return Ok(Vec::new());
258 }
259 let entries = self.fs.read_dir(&handler_dir)?;
260 Ok(entries
261 .into_iter()
262 .filter(|e| e.is_file)
263 .map(|e| e.name)
264 .collect())
265 }
266
267 fn write_rendered_file(
268 &self,
269 pack: &str,
270 handler: &str,
271 filename: &str,
272 content: &[u8],
273 ) -> Result<PathBuf> {
274 let dir = self.paths.handler_data_dir(pack, handler);
275 let relative = validate_safe_relative(filename, &dir)?;
276 let path = dir.join(&relative);
277 if let Some(parent) = path.parent() {
279 self.fs.mkdir_all(parent)?;
280 } else {
281 self.fs.mkdir_all(&dir)?;
282 }
283 self.fs.write_file(&path, content)?;
284 Ok(path)
285 }
286
287 fn write_rendered_file_with_mode(
288 &self,
289 pack: &str,
290 handler: &str,
291 filename: &str,
292 content: &[u8],
293 mode: u32,
294 ) -> Result<PathBuf> {
295 let dir = self.paths.handler_data_dir(pack, handler);
296 let relative = validate_safe_relative(filename, &dir)?;
297 let path = dir.join(&relative);
298 if let Some(parent) = path.parent() {
299 self.fs.mkdir_all(parent)?;
300 } else {
301 self.fs.mkdir_all(&dir)?;
302 }
303 self.fs.write_file_with_mode(&path, content, mode)?;
306 Ok(path)
307 }
308
309 fn write_rendered_dir(&self, pack: &str, handler: &str, relative: &str) -> Result<PathBuf> {
310 let dir = self.paths.handler_data_dir(pack, handler);
311 let rel = validate_safe_relative(relative, &dir)?;
312 let path = dir.join(&rel);
313 self.fs.mkdir_all(&path)?;
314 Ok(path)
315 }
316
317 fn sentinel_path(&self, pack: &str, handler: &str, sentinel: &str) -> PathBuf {
318 self.paths.handler_data_dir(pack, handler).join(sentinel)
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use crate::datastore::{CommandOutput, CommandRunner};
326 use crate::testing::TempEnvironment;
327 use std::sync::Mutex;
328
329 #[test]
330 fn extract_header_block_skips_shebang() {
331 let content = "#!/bin/bash\n# Install nvm\n# Requires curl\necho hi\n";
332 assert_eq!(
333 extract_header_block(content),
334 vec!["Install nvm".to_string(), "Requires curl".to_string()]
335 );
336 }
337
338 #[test]
339 fn extract_header_block_no_shebang() {
340 let content = "# header line\necho hi\n";
341 assert_eq!(extract_header_block(content), vec!["header line"]);
342 }
343
344 #[test]
345 fn extract_header_block_blanks_between_shebang_and_comments() {
346 let content = "#!/bin/bash\n\n\n# first\n# second\nstuff\n";
347 assert_eq!(
348 extract_header_block(content),
349 vec!["first".to_string(), "second".to_string()]
350 );
351 }
352
353 #[test]
354 fn extract_header_block_stops_at_first_non_comment() {
355 let content = "# a\n# b\necho mid\n# late\n";
356 assert_eq!(
357 extract_header_block(content),
358 vec!["a".to_string(), "b".to_string()]
359 );
360 }
361
362 #[test]
363 fn extract_header_block_strips_extra_hashes_and_spaces() {
364 let content = "## Section\n# spaced\n";
367 assert_eq!(
368 extract_header_block(content),
369 vec!["Section".to_string(), "spaced".to_string()]
370 );
371 }
372
373 #[test]
374 fn extract_header_block_empty_input() {
375 assert!(extract_header_block("").is_empty());
376 }
377
378 #[test]
379 fn extract_header_block_no_comments() {
380 assert!(extract_header_block("#!/bin/bash\necho hi\n").is_empty());
381 }
382
383 struct MockCommandRunner {
386 calls: Mutex<Vec<String>>,
387 should_fail: bool,
388 }
389
390 impl MockCommandRunner {
391 fn new() -> Self {
392 Self {
393 calls: Mutex::new(Vec::new()),
394 should_fail: false,
395 }
396 }
397
398 fn failing() -> Self {
399 Self {
400 calls: Mutex::new(Vec::new()),
401 should_fail: true,
402 }
403 }
404
405 fn calls(&self) -> Vec<String> {
406 self.calls.lock().unwrap().clone()
407 }
408 }
409
410 impl CommandRunner for MockCommandRunner {
411 fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
412 let cmd_str = format!("{} {}", executable, arguments.join(" "));
413 self.calls.lock().unwrap().push(cmd_str.trim().to_string());
414 if self.should_fail {
415 Err(crate::DodotError::CommandFailed {
416 command: cmd_str.trim().to_string(),
417 exit_code: 1,
418 stderr: "mock failure".to_string(),
419 })
420 } else {
421 Ok(CommandOutput {
422 exit_code: 0,
423 stdout: String::new(),
424 stderr: String::new(),
425 })
426 }
427 }
428 }
429
430 fn make_datastore(env: &TempEnvironment) -> (FilesystemDataStore, Arc<MockCommandRunner>) {
431 let runner = Arc::new(MockCommandRunner::new());
432 let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner.clone());
433 (ds, runner)
434 }
435
436 #[test]
439 fn create_data_link_creates_symlink() {
440 let env = TempEnvironment::builder()
441 .pack("vim")
442 .file("vimrc", "set nocompatible")
443 .done()
444 .build();
445 let (ds, _) = make_datastore(&env);
446
447 let source = env.dotfiles_root.join("vim/vimrc");
448 let link_path = ds.create_data_link("vim", "symlink", &source).unwrap();
449
450 assert_eq!(
452 link_path,
453 env.paths.handler_data_dir("vim", "symlink").join("vimrc")
454 );
455
456 env.assert_symlink(&link_path, &source);
458 }
459
460 #[test]
461 fn create_data_link_is_idempotent() {
462 let env = TempEnvironment::builder()
463 .pack("vim")
464 .file("vimrc", "set nocompatible")
465 .done()
466 .build();
467 let (ds, _) = make_datastore(&env);
468
469 let source = env.dotfiles_root.join("vim/vimrc");
470
471 let path1 = ds.create_data_link("vim", "symlink", &source).unwrap();
472 let path2 = ds.create_data_link("vim", "symlink", &source).unwrap();
473
474 assert_eq!(path1, path2);
475 env.assert_symlink(&path1, &source);
476 }
477
478 #[test]
479 fn create_data_link_replaces_wrong_target() {
480 let env = TempEnvironment::builder()
481 .pack("vim")
482 .file("vimrc", "v1")
483 .file("vimrc-new", "v2")
484 .done()
485 .build();
486 let (ds, _) = make_datastore(&env);
487
488 let source1 = env.dotfiles_root.join("vim/vimrc");
489 let source2 = env.dotfiles_root.join("vim/vimrc-new");
490
491 let link_dir = env.paths.handler_data_dir("vim", "symlink");
493 env.fs.mkdir_all(&link_dir).unwrap();
494 let wrong_link = link_dir.join("vimrc-new");
496 env.fs.symlink(&source1, &wrong_link).unwrap();
497
498 let link_path = ds.create_data_link("vim", "symlink", &source2).unwrap();
500 env.assert_symlink(&link_path, &source2);
501 }
502
503 #[test]
506 fn create_user_link_creates_symlink() {
507 let env = TempEnvironment::builder().build();
508 let (ds, _) = make_datastore(&env);
509
510 let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
511 let user_path = env.home.join(".vimrc");
512
513 env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
515 env.fs.write_file(&datastore_path, b"link target").unwrap();
516
517 ds.create_user_link(&datastore_path, &user_path).unwrap();
518
519 env.assert_symlink(&user_path, &datastore_path);
520 }
521
522 #[test]
523 fn create_user_link_is_idempotent() {
524 let env = TempEnvironment::builder().build();
525 let (ds, _) = make_datastore(&env);
526
527 let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
528 let user_path = env.home.join(".vimrc");
529
530 env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
531 env.fs.write_file(&datastore_path, b"x").unwrap();
532
533 ds.create_user_link(&datastore_path, &user_path).unwrap();
534 ds.create_user_link(&datastore_path, &user_path).unwrap();
535
536 env.assert_symlink(&user_path, &datastore_path);
537 }
538
539 #[test]
540 fn create_user_link_conflict_with_regular_file() {
541 let env = TempEnvironment::builder().build();
542 let (ds, _) = make_datastore(&env);
543
544 let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
545 let user_path = env.home.join(".vimrc");
546
547 env.fs.write_file(&user_path, b"existing content").unwrap();
549
550 let err = ds
551 .create_user_link(&datastore_path, &user_path)
552 .unwrap_err();
553 assert!(
554 matches!(err, crate::DodotError::SymlinkConflict { .. }),
555 "expected SymlinkConflict, got: {err}"
556 );
557 }
558
559 #[test]
560 fn create_user_link_replaces_wrong_symlink() {
561 let env = TempEnvironment::builder().build();
562 let (ds, _) = make_datastore(&env);
563
564 let wrong_target = env.data_dir.join("wrong");
565 let correct_target = env.data_dir.join("correct");
566 let user_path = env.home.join(".vimrc");
567
568 env.fs.mkdir_all(&env.data_dir).unwrap();
569 env.fs.write_file(&wrong_target, b"wrong").unwrap();
570 env.fs.write_file(&correct_target, b"right").unwrap();
571
572 env.fs.symlink(&wrong_target, &user_path).unwrap();
574
575 ds.create_user_link(&correct_target, &user_path).unwrap();
577 env.assert_symlink(&user_path, &correct_target);
578 }
579
580 #[test]
583 fn full_double_link_chain() {
584 let env = TempEnvironment::builder()
585 .pack("vim")
586 .file("vimrc", "set nocompatible")
587 .done()
588 .build();
589 let (ds, _) = make_datastore(&env);
590
591 let source = env.dotfiles_root.join("vim/vimrc");
592 let user_path = env.home.join(".vimrc");
593
594 let datastore_path = ds.create_data_link("vim", "symlink", &source).unwrap();
596
597 ds.create_user_link(&datastore_path, &user_path).unwrap();
599
600 env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
602
603 let content = env.fs.read_to_string(&user_path).unwrap();
605 assert_eq!(content, "set nocompatible");
606 }
607
608 #[test]
611 fn run_and_record_creates_sentinel() {
612 let env = TempEnvironment::builder().build();
613 let (ds, runner) = make_datastore(&env);
614
615 assert!(!ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
616
617 ds.run_and_record(
618 "vim",
619 "install",
620 "echo",
621 &["hello".into()],
622 "install.sh-abc",
623 false,
624 )
625 .unwrap();
626
627 assert!(ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
628 assert_eq!(runner.calls(), vec!["echo hello"]);
629
630 let sentinel_path = env
632 .paths
633 .handler_data_dir("vim", "install")
634 .join("install.sh-abc");
635 let content = env.fs.read_to_string(&sentinel_path).unwrap();
636 assert!(content.starts_with("completed|"), "got: {content}");
637 }
638
639 #[test]
640 fn run_and_record_is_idempotent() {
641 let env = TempEnvironment::builder().build();
642 let (ds, runner) = make_datastore(&env);
643
644 ds.run_and_record("vim", "install", "echo", &["first".into()], "s1", false)
645 .unwrap();
646 ds.run_and_record("vim", "install", "echo", &["second".into()], "s1", false)
647 .unwrap();
648
649 assert_eq!(runner.calls(), vec!["echo first"]);
651 }
652
653 #[test]
654 fn run_and_record_propagates_command_failure() {
655 let env = TempEnvironment::builder().build();
656 let runner = Arc::new(MockCommandRunner::failing());
657 let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner);
658
659 let err = ds
660 .run_and_record("vim", "install", "bad-cmd", &[], "s1", false)
661 .unwrap_err();
662
663 assert!(
664 matches!(err, crate::DodotError::CommandFailed { .. }),
665 "expected CommandFailed, got: {err}"
666 );
667
668 assert!(!ds.has_sentinel("vim", "install", "s1").unwrap());
670 }
671
672 #[test]
675 fn remove_state_clears_handler_dir() {
676 let env = TempEnvironment::builder()
677 .pack("vim")
678 .file("vimrc", "x")
679 .done()
680 .build();
681 let (ds, _) = make_datastore(&env);
682
683 let source = env.dotfiles_root.join("vim/vimrc");
684 ds.create_data_link("vim", "symlink", &source).unwrap();
685 assert!(ds.has_handler_state("vim", "symlink").unwrap());
686
687 ds.remove_state("vim", "symlink").unwrap();
688 env.assert_no_handler_state("vim", "symlink");
689 }
690
691 #[test]
692 fn remove_state_is_noop_when_no_state() {
693 let env = TempEnvironment::builder().build();
694 let (ds, _) = make_datastore(&env);
695
696 ds.remove_state("nonexistent", "handler").unwrap();
698 }
699
700 #[test]
703 fn has_handler_state_false_when_no_dir() {
704 let env = TempEnvironment::builder().build();
705 let (ds, _) = make_datastore(&env);
706
707 assert!(!ds.has_handler_state("vim", "symlink").unwrap());
708 }
709
710 #[test]
711 fn has_handler_state_false_when_empty_dir() {
712 let env = TempEnvironment::builder().build();
713 let (ds, _) = make_datastore(&env);
714
715 let dir = env.paths.handler_data_dir("vim", "symlink");
716 env.fs.mkdir_all(&dir).unwrap();
717
718 assert!(!ds.has_handler_state("vim", "symlink").unwrap());
719 }
720
721 #[test]
722 fn has_handler_state_true_when_entries_exist() {
723 let env = TempEnvironment::builder()
724 .pack("vim")
725 .file("vimrc", "x")
726 .done()
727 .build();
728 let (ds, _) = make_datastore(&env);
729
730 let source = env.dotfiles_root.join("vim/vimrc");
731 ds.create_data_link("vim", "symlink", &source).unwrap();
732
733 assert!(ds.has_handler_state("vim", "symlink").unwrap());
734 }
735
736 #[test]
739 fn list_pack_handlers_returns_handler_dirs() {
740 let env = TempEnvironment::builder()
741 .pack("vim")
742 .file("vimrc", "x")
743 .file("aliases.sh", "y")
744 .done()
745 .build();
746 let (ds, _) = make_datastore(&env);
747
748 let source1 = env.dotfiles_root.join("vim/vimrc");
749 let source2 = env.dotfiles_root.join("vim/aliases.sh");
750 ds.create_data_link("vim", "symlink", &source1).unwrap();
751 ds.create_data_link("vim", "shell", &source2).unwrap();
752
753 let mut handlers = ds.list_pack_handlers("vim").unwrap();
754 handlers.sort();
755 assert_eq!(handlers, vec!["shell", "symlink"]);
756 }
757
758 #[test]
759 fn list_pack_handlers_empty_when_no_pack_state() {
760 let env = TempEnvironment::builder().build();
761 let (ds, _) = make_datastore(&env);
762
763 let handlers = ds.list_pack_handlers("nonexistent").unwrap();
764 assert!(handlers.is_empty());
765 }
766
767 #[test]
770 fn list_handler_sentinels_returns_file_names() {
771 let env = TempEnvironment::builder().build();
772 let (ds, _) = make_datastore(&env);
773
774 ds.run_and_record(
775 "vim",
776 "install",
777 "echo",
778 &["a".into()],
779 "install.sh-aaa",
780 false,
781 )
782 .unwrap();
783 ds.run_and_record(
784 "vim",
785 "install",
786 "echo",
787 &["b".into()],
788 "install.sh-bbb",
789 false,
790 )
791 .unwrap();
792
793 let mut sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
794 sentinels.sort();
795 assert_eq!(sentinels, vec!["install.sh-aaa", "install.sh-bbb"]);
796 }
797
798 #[test]
799 fn list_handler_sentinels_empty_when_no_state() {
800 let env = TempEnvironment::builder().build();
801 let (ds, _) = make_datastore(&env);
802
803 let sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
804 assert!(sentinels.is_empty());
805 }
806
807 #[test]
810 fn write_rendered_file_creates_regular_file() {
811 let env = TempEnvironment::builder().build();
812 let (ds, _) = make_datastore(&env);
813
814 let path = ds
815 .write_rendered_file("app", "preprocessed", "config.toml", b"host = localhost")
816 .unwrap();
817
818 assert!(env.fs.exists(&path));
819 assert!(!env.fs.is_symlink(&path));
820 assert_eq!(env.fs.read_to_string(&path).unwrap(), "host = localhost");
821 }
822
823 #[test]
824 fn write_rendered_file_overwrites_existing() {
825 let env = TempEnvironment::builder().build();
826 let (ds, _) = make_datastore(&env);
827
828 let path1 = ds
829 .write_rendered_file("app", "preprocessed", "config.toml", b"version 1")
830 .unwrap();
831 let path2 = ds
832 .write_rendered_file("app", "preprocessed", "config.toml", b"version 2")
833 .unwrap();
834
835 assert_eq!(path1, path2);
836 assert_eq!(env.fs.read_to_string(&path1).unwrap(), "version 2");
837 }
838
839 #[test]
840 fn write_rendered_file_empty_content() {
841 let env = TempEnvironment::builder().build();
842 let (ds, _) = make_datastore(&env);
843
844 let path = ds
845 .write_rendered_file("app", "preprocessed", "empty.conf", b"")
846 .unwrap();
847
848 assert!(env.fs.exists(&path));
849 assert_eq!(env.fs.read_to_string(&path).unwrap(), "");
850 }
851
852 #[test]
853 fn write_rendered_file_binary_content() {
854 let env = TempEnvironment::builder().build();
855 let (ds, _) = make_datastore(&env);
856
857 let binary = vec![0u8, 1, 2, 255, 254, 253];
858 let path = ds
859 .write_rendered_file("app", "preprocessed", "data.bin", &binary)
860 .unwrap();
861
862 assert_eq!(env.fs.read_file(&path).unwrap(), binary);
863 }
864
865 #[test]
866 fn write_rendered_file_creates_parent_dirs() {
867 let env = TempEnvironment::builder().build();
868 let (ds, _) = make_datastore(&env);
869
870 let handler_dir = env.paths.handler_data_dir("newpack", "preprocessed");
872 assert!(!env.fs.exists(&handler_dir));
873
874 let path = ds
875 .write_rendered_file("newpack", "preprocessed", "file.txt", b"hello")
876 .unwrap();
877
878 assert!(env.fs.exists(&path));
879 assert_eq!(env.fs.read_to_string(&path).unwrap(), "hello");
880 }
881
882 #[test]
883 fn write_rendered_file_rejects_absolute_path() {
884 let env = TempEnvironment::builder().build();
885 let (ds, _) = make_datastore(&env);
886
887 let err = ds
888 .write_rendered_file("app", "preprocessed", "/etc/passwd", b"x")
889 .unwrap_err();
890 assert!(
891 matches!(err, crate::DodotError::Other(ref m) if m.contains("unsafe")),
892 "expected unsafe-path error, got: {err}"
893 );
894 }
895
896 #[test]
897 fn write_rendered_file_rejects_parent_dir() {
898 let env = TempEnvironment::builder().build();
899 let (ds, _) = make_datastore(&env);
900
901 let err = ds
902 .write_rendered_file("app", "preprocessed", "../escape.txt", b"x")
903 .unwrap_err();
904 assert!(
905 matches!(err, crate::DodotError::Other(ref m) if m.contains("unsafe")),
906 "expected unsafe-path error, got: {err}"
907 );
908 }
909
910 #[test]
911 fn write_rendered_dir_creates_dir() {
912 let env = TempEnvironment::builder().build();
913 let (ds, _) = make_datastore(&env);
914
915 let path = ds
916 .write_rendered_dir("app", "preprocessed", "sub/nested")
917 .unwrap();
918
919 assert!(env.fs.is_dir(&path));
920 assert!(!env.fs.is_symlink(&path));
921 }
922
923 #[test]
924 fn write_rendered_dir_is_idempotent() {
925 let env = TempEnvironment::builder().build();
926 let (ds, _) = make_datastore(&env);
927
928 let p1 = ds.write_rendered_dir("app", "preprocessed", "d").unwrap();
929 let p2 = ds.write_rendered_dir("app", "preprocessed", "d").unwrap();
930 assert_eq!(p1, p2);
931 assert!(env.fs.is_dir(&p1));
932 }
933
934 #[test]
935 fn write_rendered_dir_rejects_unsafe_paths() {
936 let env = TempEnvironment::builder().build();
937 let (ds, _) = make_datastore(&env);
938
939 assert!(ds
940 .write_rendered_dir("app", "preprocessed", "/abs")
941 .is_err());
942 assert!(ds
943 .write_rendered_dir("app", "preprocessed", "../esc")
944 .is_err());
945 }
946
947 #[test]
948 fn write_rendered_file_supports_nested_filename() {
949 let env = TempEnvironment::builder().build();
953 let (ds, _) = make_datastore(&env);
954
955 let path = ds
956 .write_rendered_file("app", "preprocessed", "sub/nested/file.txt", b"deep")
957 .unwrap();
958
959 assert!(env.fs.exists(&path));
960 assert!(!env.fs.is_symlink(&path));
961 assert_eq!(env.fs.read_to_string(&path).unwrap(), "deep");
962 assert!(
963 path.to_string_lossy().contains("sub/nested/file.txt"),
964 "path should contain nested structure: {}",
965 path.display()
966 );
967 }
968
969 #[allow(dead_code)]
972 fn assert_object_safe(_: &dyn DataStore) {}
973}