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_dir(&self, pack: &str, handler: &str, relative: &str) -> Result<PathBuf> {
288 let dir = self.paths.handler_data_dir(pack, handler);
289 let rel = validate_safe_relative(relative, &dir)?;
290 let path = dir.join(&rel);
291 self.fs.mkdir_all(&path)?;
292 Ok(path)
293 }
294
295 fn sentinel_path(&self, pack: &str, handler: &str, sentinel: &str) -> PathBuf {
296 self.paths.handler_data_dir(pack, handler).join(sentinel)
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::datastore::{CommandOutput, CommandRunner};
304 use crate::testing::TempEnvironment;
305 use std::sync::Mutex;
306
307 #[test]
308 fn extract_header_block_skips_shebang() {
309 let content = "#!/bin/bash\n# Install nvm\n# Requires curl\necho hi\n";
310 assert_eq!(
311 extract_header_block(content),
312 vec!["Install nvm".to_string(), "Requires curl".to_string()]
313 );
314 }
315
316 #[test]
317 fn extract_header_block_no_shebang() {
318 let content = "# header line\necho hi\n";
319 assert_eq!(extract_header_block(content), vec!["header line"]);
320 }
321
322 #[test]
323 fn extract_header_block_blanks_between_shebang_and_comments() {
324 let content = "#!/bin/bash\n\n\n# first\n# second\nstuff\n";
325 assert_eq!(
326 extract_header_block(content),
327 vec!["first".to_string(), "second".to_string()]
328 );
329 }
330
331 #[test]
332 fn extract_header_block_stops_at_first_non_comment() {
333 let content = "# a\n# b\necho mid\n# late\n";
334 assert_eq!(
335 extract_header_block(content),
336 vec!["a".to_string(), "b".to_string()]
337 );
338 }
339
340 #[test]
341 fn extract_header_block_strips_extra_hashes_and_spaces() {
342 let content = "## Section\n# spaced\n";
345 assert_eq!(
346 extract_header_block(content),
347 vec!["Section".to_string(), "spaced".to_string()]
348 );
349 }
350
351 #[test]
352 fn extract_header_block_empty_input() {
353 assert!(extract_header_block("").is_empty());
354 }
355
356 #[test]
357 fn extract_header_block_no_comments() {
358 assert!(extract_header_block("#!/bin/bash\necho hi\n").is_empty());
359 }
360
361 struct MockCommandRunner {
364 calls: Mutex<Vec<String>>,
365 should_fail: bool,
366 }
367
368 impl MockCommandRunner {
369 fn new() -> Self {
370 Self {
371 calls: Mutex::new(Vec::new()),
372 should_fail: false,
373 }
374 }
375
376 fn failing() -> Self {
377 Self {
378 calls: Mutex::new(Vec::new()),
379 should_fail: true,
380 }
381 }
382
383 fn calls(&self) -> Vec<String> {
384 self.calls.lock().unwrap().clone()
385 }
386 }
387
388 impl CommandRunner for MockCommandRunner {
389 fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
390 let cmd_str = format!("{} {}", executable, arguments.join(" "));
391 self.calls.lock().unwrap().push(cmd_str.trim().to_string());
392 if self.should_fail {
393 Err(crate::DodotError::CommandFailed {
394 command: cmd_str.trim().to_string(),
395 exit_code: 1,
396 stderr: "mock failure".to_string(),
397 })
398 } else {
399 Ok(CommandOutput {
400 exit_code: 0,
401 stdout: String::new(),
402 stderr: String::new(),
403 })
404 }
405 }
406 }
407
408 fn make_datastore(env: &TempEnvironment) -> (FilesystemDataStore, Arc<MockCommandRunner>) {
409 let runner = Arc::new(MockCommandRunner::new());
410 let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner.clone());
411 (ds, runner)
412 }
413
414 #[test]
417 fn create_data_link_creates_symlink() {
418 let env = TempEnvironment::builder()
419 .pack("vim")
420 .file("vimrc", "set nocompatible")
421 .done()
422 .build();
423 let (ds, _) = make_datastore(&env);
424
425 let source = env.dotfiles_root.join("vim/vimrc");
426 let link_path = ds.create_data_link("vim", "symlink", &source).unwrap();
427
428 assert_eq!(
430 link_path,
431 env.paths.handler_data_dir("vim", "symlink").join("vimrc")
432 );
433
434 env.assert_symlink(&link_path, &source);
436 }
437
438 #[test]
439 fn create_data_link_is_idempotent() {
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
449 let path1 = ds.create_data_link("vim", "symlink", &source).unwrap();
450 let path2 = ds.create_data_link("vim", "symlink", &source).unwrap();
451
452 assert_eq!(path1, path2);
453 env.assert_symlink(&path1, &source);
454 }
455
456 #[test]
457 fn create_data_link_replaces_wrong_target() {
458 let env = TempEnvironment::builder()
459 .pack("vim")
460 .file("vimrc", "v1")
461 .file("vimrc-new", "v2")
462 .done()
463 .build();
464 let (ds, _) = make_datastore(&env);
465
466 let source1 = env.dotfiles_root.join("vim/vimrc");
467 let source2 = env.dotfiles_root.join("vim/vimrc-new");
468
469 let link_dir = env.paths.handler_data_dir("vim", "symlink");
471 env.fs.mkdir_all(&link_dir).unwrap();
472 let wrong_link = link_dir.join("vimrc-new");
474 env.fs.symlink(&source1, &wrong_link).unwrap();
475
476 let link_path = ds.create_data_link("vim", "symlink", &source2).unwrap();
478 env.assert_symlink(&link_path, &source2);
479 }
480
481 #[test]
484 fn create_user_link_creates_symlink() {
485 let env = TempEnvironment::builder().build();
486 let (ds, _) = make_datastore(&env);
487
488 let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
489 let user_path = env.home.join(".vimrc");
490
491 env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
493 env.fs.write_file(&datastore_path, b"link target").unwrap();
494
495 ds.create_user_link(&datastore_path, &user_path).unwrap();
496
497 env.assert_symlink(&user_path, &datastore_path);
498 }
499
500 #[test]
501 fn create_user_link_is_idempotent() {
502 let env = TempEnvironment::builder().build();
503 let (ds, _) = make_datastore(&env);
504
505 let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
506 let user_path = env.home.join(".vimrc");
507
508 env.fs.mkdir_all(datastore_path.parent().unwrap()).unwrap();
509 env.fs.write_file(&datastore_path, b"x").unwrap();
510
511 ds.create_user_link(&datastore_path, &user_path).unwrap();
512 ds.create_user_link(&datastore_path, &user_path).unwrap();
513
514 env.assert_symlink(&user_path, &datastore_path);
515 }
516
517 #[test]
518 fn create_user_link_conflict_with_regular_file() {
519 let env = TempEnvironment::builder().build();
520 let (ds, _) = make_datastore(&env);
521
522 let datastore_path = env.data_dir.join("packs/vim/symlink/vimrc");
523 let user_path = env.home.join(".vimrc");
524
525 env.fs.write_file(&user_path, b"existing content").unwrap();
527
528 let err = ds
529 .create_user_link(&datastore_path, &user_path)
530 .unwrap_err();
531 assert!(
532 matches!(err, crate::DodotError::SymlinkConflict { .. }),
533 "expected SymlinkConflict, got: {err}"
534 );
535 }
536
537 #[test]
538 fn create_user_link_replaces_wrong_symlink() {
539 let env = TempEnvironment::builder().build();
540 let (ds, _) = make_datastore(&env);
541
542 let wrong_target = env.data_dir.join("wrong");
543 let correct_target = env.data_dir.join("correct");
544 let user_path = env.home.join(".vimrc");
545
546 env.fs.mkdir_all(&env.data_dir).unwrap();
547 env.fs.write_file(&wrong_target, b"wrong").unwrap();
548 env.fs.write_file(&correct_target, b"right").unwrap();
549
550 env.fs.symlink(&wrong_target, &user_path).unwrap();
552
553 ds.create_user_link(&correct_target, &user_path).unwrap();
555 env.assert_symlink(&user_path, &correct_target);
556 }
557
558 #[test]
561 fn full_double_link_chain() {
562 let env = TempEnvironment::builder()
563 .pack("vim")
564 .file("vimrc", "set nocompatible")
565 .done()
566 .build();
567 let (ds, _) = make_datastore(&env);
568
569 let source = env.dotfiles_root.join("vim/vimrc");
570 let user_path = env.home.join(".vimrc");
571
572 let datastore_path = ds.create_data_link("vim", "symlink", &source).unwrap();
574
575 ds.create_user_link(&datastore_path, &user_path).unwrap();
577
578 env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
580
581 let content = env.fs.read_to_string(&user_path).unwrap();
583 assert_eq!(content, "set nocompatible");
584 }
585
586 #[test]
589 fn run_and_record_creates_sentinel() {
590 let env = TempEnvironment::builder().build();
591 let (ds, runner) = make_datastore(&env);
592
593 assert!(!ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
594
595 ds.run_and_record(
596 "vim",
597 "install",
598 "echo",
599 &["hello".into()],
600 "install.sh-abc",
601 false,
602 )
603 .unwrap();
604
605 assert!(ds.has_sentinel("vim", "install", "install.sh-abc").unwrap());
606 assert_eq!(runner.calls(), vec!["echo hello"]);
607
608 let sentinel_path = env
610 .paths
611 .handler_data_dir("vim", "install")
612 .join("install.sh-abc");
613 let content = env.fs.read_to_string(&sentinel_path).unwrap();
614 assert!(content.starts_with("completed|"), "got: {content}");
615 }
616
617 #[test]
618 fn run_and_record_is_idempotent() {
619 let env = TempEnvironment::builder().build();
620 let (ds, runner) = make_datastore(&env);
621
622 ds.run_and_record("vim", "install", "echo", &["first".into()], "s1", false)
623 .unwrap();
624 ds.run_and_record("vim", "install", "echo", &["second".into()], "s1", false)
625 .unwrap();
626
627 assert_eq!(runner.calls(), vec!["echo first"]);
629 }
630
631 #[test]
632 fn run_and_record_propagates_command_failure() {
633 let env = TempEnvironment::builder().build();
634 let runner = Arc::new(MockCommandRunner::failing());
635 let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner);
636
637 let err = ds
638 .run_and_record("vim", "install", "bad-cmd", &[], "s1", false)
639 .unwrap_err();
640
641 assert!(
642 matches!(err, crate::DodotError::CommandFailed { .. }),
643 "expected CommandFailed, got: {err}"
644 );
645
646 assert!(!ds.has_sentinel("vim", "install", "s1").unwrap());
648 }
649
650 #[test]
653 fn remove_state_clears_handler_dir() {
654 let env = TempEnvironment::builder()
655 .pack("vim")
656 .file("vimrc", "x")
657 .done()
658 .build();
659 let (ds, _) = make_datastore(&env);
660
661 let source = env.dotfiles_root.join("vim/vimrc");
662 ds.create_data_link("vim", "symlink", &source).unwrap();
663 assert!(ds.has_handler_state("vim", "symlink").unwrap());
664
665 ds.remove_state("vim", "symlink").unwrap();
666 env.assert_no_handler_state("vim", "symlink");
667 }
668
669 #[test]
670 fn remove_state_is_noop_when_no_state() {
671 let env = TempEnvironment::builder().build();
672 let (ds, _) = make_datastore(&env);
673
674 ds.remove_state("nonexistent", "handler").unwrap();
676 }
677
678 #[test]
681 fn has_handler_state_false_when_no_dir() {
682 let env = TempEnvironment::builder().build();
683 let (ds, _) = make_datastore(&env);
684
685 assert!(!ds.has_handler_state("vim", "symlink").unwrap());
686 }
687
688 #[test]
689 fn has_handler_state_false_when_empty_dir() {
690 let env = TempEnvironment::builder().build();
691 let (ds, _) = make_datastore(&env);
692
693 let dir = env.paths.handler_data_dir("vim", "symlink");
694 env.fs.mkdir_all(&dir).unwrap();
695
696 assert!(!ds.has_handler_state("vim", "symlink").unwrap());
697 }
698
699 #[test]
700 fn has_handler_state_true_when_entries_exist() {
701 let env = TempEnvironment::builder()
702 .pack("vim")
703 .file("vimrc", "x")
704 .done()
705 .build();
706 let (ds, _) = make_datastore(&env);
707
708 let source = env.dotfiles_root.join("vim/vimrc");
709 ds.create_data_link("vim", "symlink", &source).unwrap();
710
711 assert!(ds.has_handler_state("vim", "symlink").unwrap());
712 }
713
714 #[test]
717 fn list_pack_handlers_returns_handler_dirs() {
718 let env = TempEnvironment::builder()
719 .pack("vim")
720 .file("vimrc", "x")
721 .file("aliases.sh", "y")
722 .done()
723 .build();
724 let (ds, _) = make_datastore(&env);
725
726 let source1 = env.dotfiles_root.join("vim/vimrc");
727 let source2 = env.dotfiles_root.join("vim/aliases.sh");
728 ds.create_data_link("vim", "symlink", &source1).unwrap();
729 ds.create_data_link("vim", "shell", &source2).unwrap();
730
731 let mut handlers = ds.list_pack_handlers("vim").unwrap();
732 handlers.sort();
733 assert_eq!(handlers, vec!["shell", "symlink"]);
734 }
735
736 #[test]
737 fn list_pack_handlers_empty_when_no_pack_state() {
738 let env = TempEnvironment::builder().build();
739 let (ds, _) = make_datastore(&env);
740
741 let handlers = ds.list_pack_handlers("nonexistent").unwrap();
742 assert!(handlers.is_empty());
743 }
744
745 #[test]
748 fn list_handler_sentinels_returns_file_names() {
749 let env = TempEnvironment::builder().build();
750 let (ds, _) = make_datastore(&env);
751
752 ds.run_and_record(
753 "vim",
754 "install",
755 "echo",
756 &["a".into()],
757 "install.sh-aaa",
758 false,
759 )
760 .unwrap();
761 ds.run_and_record(
762 "vim",
763 "install",
764 "echo",
765 &["b".into()],
766 "install.sh-bbb",
767 false,
768 )
769 .unwrap();
770
771 let mut sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
772 sentinels.sort();
773 assert_eq!(sentinels, vec!["install.sh-aaa", "install.sh-bbb"]);
774 }
775
776 #[test]
777 fn list_handler_sentinels_empty_when_no_state() {
778 let env = TempEnvironment::builder().build();
779 let (ds, _) = make_datastore(&env);
780
781 let sentinels = ds.list_handler_sentinels("vim", "install").unwrap();
782 assert!(sentinels.is_empty());
783 }
784
785 #[test]
788 fn write_rendered_file_creates_regular_file() {
789 let env = TempEnvironment::builder().build();
790 let (ds, _) = make_datastore(&env);
791
792 let path = ds
793 .write_rendered_file("app", "preprocessed", "config.toml", b"host = localhost")
794 .unwrap();
795
796 assert!(env.fs.exists(&path));
797 assert!(!env.fs.is_symlink(&path));
798 assert_eq!(env.fs.read_to_string(&path).unwrap(), "host = localhost");
799 }
800
801 #[test]
802 fn write_rendered_file_overwrites_existing() {
803 let env = TempEnvironment::builder().build();
804 let (ds, _) = make_datastore(&env);
805
806 let path1 = ds
807 .write_rendered_file("app", "preprocessed", "config.toml", b"version 1")
808 .unwrap();
809 let path2 = ds
810 .write_rendered_file("app", "preprocessed", "config.toml", b"version 2")
811 .unwrap();
812
813 assert_eq!(path1, path2);
814 assert_eq!(env.fs.read_to_string(&path1).unwrap(), "version 2");
815 }
816
817 #[test]
818 fn write_rendered_file_empty_content() {
819 let env = TempEnvironment::builder().build();
820 let (ds, _) = make_datastore(&env);
821
822 let path = ds
823 .write_rendered_file("app", "preprocessed", "empty.conf", b"")
824 .unwrap();
825
826 assert!(env.fs.exists(&path));
827 assert_eq!(env.fs.read_to_string(&path).unwrap(), "");
828 }
829
830 #[test]
831 fn write_rendered_file_binary_content() {
832 let env = TempEnvironment::builder().build();
833 let (ds, _) = make_datastore(&env);
834
835 let binary = vec![0u8, 1, 2, 255, 254, 253];
836 let path = ds
837 .write_rendered_file("app", "preprocessed", "data.bin", &binary)
838 .unwrap();
839
840 assert_eq!(env.fs.read_file(&path).unwrap(), binary);
841 }
842
843 #[test]
844 fn write_rendered_file_creates_parent_dirs() {
845 let env = TempEnvironment::builder().build();
846 let (ds, _) = make_datastore(&env);
847
848 let handler_dir = env.paths.handler_data_dir("newpack", "preprocessed");
850 assert!(!env.fs.exists(&handler_dir));
851
852 let path = ds
853 .write_rendered_file("newpack", "preprocessed", "file.txt", b"hello")
854 .unwrap();
855
856 assert!(env.fs.exists(&path));
857 assert_eq!(env.fs.read_to_string(&path).unwrap(), "hello");
858 }
859
860 #[test]
861 fn write_rendered_file_rejects_absolute_path() {
862 let env = TempEnvironment::builder().build();
863 let (ds, _) = make_datastore(&env);
864
865 let err = ds
866 .write_rendered_file("app", "preprocessed", "/etc/passwd", b"x")
867 .unwrap_err();
868 assert!(
869 matches!(err, crate::DodotError::Other(ref m) if m.contains("unsafe")),
870 "expected unsafe-path error, got: {err}"
871 );
872 }
873
874 #[test]
875 fn write_rendered_file_rejects_parent_dir() {
876 let env = TempEnvironment::builder().build();
877 let (ds, _) = make_datastore(&env);
878
879 let err = ds
880 .write_rendered_file("app", "preprocessed", "../escape.txt", b"x")
881 .unwrap_err();
882 assert!(
883 matches!(err, crate::DodotError::Other(ref m) if m.contains("unsafe")),
884 "expected unsafe-path error, got: {err}"
885 );
886 }
887
888 #[test]
889 fn write_rendered_dir_creates_dir() {
890 let env = TempEnvironment::builder().build();
891 let (ds, _) = make_datastore(&env);
892
893 let path = ds
894 .write_rendered_dir("app", "preprocessed", "sub/nested")
895 .unwrap();
896
897 assert!(env.fs.is_dir(&path));
898 assert!(!env.fs.is_symlink(&path));
899 }
900
901 #[test]
902 fn write_rendered_dir_is_idempotent() {
903 let env = TempEnvironment::builder().build();
904 let (ds, _) = make_datastore(&env);
905
906 let p1 = ds.write_rendered_dir("app", "preprocessed", "d").unwrap();
907 let p2 = ds.write_rendered_dir("app", "preprocessed", "d").unwrap();
908 assert_eq!(p1, p2);
909 assert!(env.fs.is_dir(&p1));
910 }
911
912 #[test]
913 fn write_rendered_dir_rejects_unsafe_paths() {
914 let env = TempEnvironment::builder().build();
915 let (ds, _) = make_datastore(&env);
916
917 assert!(ds
918 .write_rendered_dir("app", "preprocessed", "/abs")
919 .is_err());
920 assert!(ds
921 .write_rendered_dir("app", "preprocessed", "../esc")
922 .is_err());
923 }
924
925 #[test]
926 fn write_rendered_file_supports_nested_filename() {
927 let env = TempEnvironment::builder().build();
931 let (ds, _) = make_datastore(&env);
932
933 let path = ds
934 .write_rendered_file("app", "preprocessed", "sub/nested/file.txt", b"deep")
935 .unwrap();
936
937 assert!(env.fs.exists(&path));
938 assert!(!env.fs.is_symlink(&path));
939 assert_eq!(env.fs.read_to_string(&path).unwrap(), "deep");
940 assert!(
941 path.to_string_lossy().contains("sub/nested/file.txt"),
942 "path should contain nested structure: {}",
943 path.display()
944 );
945 }
946
947 #[allow(dead_code)]
950 fn assert_object_safe(_: &dyn DataStore) {}
951}