1use std::collections::BTreeMap;
2use std::path::{Component, Path, PathBuf};
3
4use crate::{
5 CommandKind, CommandOperation, Config, ConfigRuntimeOptions, Error, FileOperation,
6 FileOperationKind, Result, SourceSpan, SymlinkMode, SyncCompare, Worktree,
7};
8
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
11pub struct ActionPlanOptions {
12 pub strict: bool,
14 pub dangerously_allow_sources_outside_root: bool,
16 pub dangerously_allow_targets_outside_worktree: bool,
18}
19
20impl From<ConfigRuntimeOptions> for ActionPlanOptions {
21 fn from(options: ConfigRuntimeOptions) -> Self {
22 Self {
23 strict: options.strict,
24 dangerously_allow_sources_outside_root: options.dangerously_allow_sources_outside_root,
25 dangerously_allow_targets_outside_worktree: options
26 .dangerously_allow_targets_outside_worktree,
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy)]
32pub(super) enum FilePlanOrigin<'a> {
33 Config(&'a Path),
34 Manual { operation: FileOperationKind },
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum PlanOrigin {
40 Manifest {
42 path: PathBuf,
44 },
45 Manual {
47 operation: FileOperationKind,
49 },
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ActionPlan {
55 pub context: Worktree,
57 pub origin: PlanOrigin,
59 pub config_path: Option<PathBuf>,
61 pub files: Vec<PlannedFileOperation>,
63 pub commands: Vec<PlannedCommand>,
65}
66
67impl ActionPlan {
68 pub fn from_manifest(
78 path: &Path,
79 manifest: &Config,
80 context: &Worktree,
81 options: ActionPlanOptions,
82 ) -> Result<Self> {
83 let worktree_path = normalize_existing(&context.worktree_path).map_err(|source| {
84 invalid_config_error(
85 path,
86 None,
87 format!("failed to resolve worktree path: {source}"),
88 )
89 })?;
90 let files = plan_file_operations(
91 FilePlanOrigin::Config(path),
92 &manifest.files,
93 context,
94 options,
95 )?;
96 let commands = plan_commands(path, &manifest.commands, context, worktree_path.as_path())?;
97
98 Ok(Self {
99 context: context.clone(),
100 origin: PlanOrigin::Manifest {
101 path: path.to_path_buf(),
102 },
103 config_path: Some(path.to_path_buf()),
104 files,
105 commands,
106 })
107 }
108
109 pub fn from_file_operations(
118 context: &Worktree,
119 origin: PlanOrigin,
120 files: &[FileOperation],
121 options: ActionPlanOptions,
122 ) -> Result<Self> {
123 let file_origin = match &origin {
124 PlanOrigin::Manifest { path } => FilePlanOrigin::Config(path),
125 PlanOrigin::Manual { operation } => FilePlanOrigin::Manual {
126 operation: *operation,
127 },
128 };
129 let files = plan_file_operations(file_origin, files, context, options)?;
130 let config_path = match &origin {
131 PlanOrigin::Manifest { path } => Some(path.clone()),
132 PlanOrigin::Manual { .. } => None,
133 };
134
135 Ok(Self {
136 context: context.clone(),
137 origin,
138 config_path,
139 files,
140 commands: Vec::new(),
141 })
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct PlannedFileOperation {
148 pub operation: FileOperationKind,
150 pub source: PathBuf,
152 pub target: PathBuf,
154 pub source_path: PathBuf,
156 pub target_path: PathBuf,
158 pub required: bool,
160 pub compare: Option<SyncCompare>,
162 pub delete: Option<bool>,
164 pub symlinks: Option<SymlinkMode>,
166 pub status: PlannedFileStatus,
168 pub declaration: SourceSpan,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum PlannedFileStatus {
175 Ready,
177 SkippedMissingSource,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct PlannedCommand {
184 pub name: Option<String>,
186 pub command: CommandKind,
188 pub cwd: Option<PathBuf>,
190 pub cwd_path: PathBuf,
192 pub env: BTreeMap<String, String>,
194 pub allow_failure: bool,
196 pub declaration: SourceSpan,
198}
199
200pub(super) fn plan_file_operations(
201 origin: FilePlanOrigin<'_>,
202 files: &[FileOperation],
203 context: &Worktree,
204 options: ActionPlanOptions,
205) -> Result<Vec<PlannedFileOperation>> {
206 let root_path = normalize_existing(&context.root_path).map_err(|source| {
207 file_plan_error(
208 origin,
209 None,
210 format!("failed to resolve root path: {source}"),
211 )
212 })?;
213 let worktree_path = normalize_existing(&context.worktree_path).map_err(|source| {
214 file_plan_error(
215 origin,
216 None,
217 format!("failed to resolve worktree path: {source}"),
218 )
219 })?;
220
221 let target_paths = normalize_target_paths(origin, files)?;
222 validate_target_conflicts(origin, files, &target_paths)?;
223 validate_strict_sync(origin, files, options.strict)?;
224
225 build_file_operations(
226 origin,
227 files,
228 options,
229 &target_paths,
230 root_path.as_path(),
231 worktree_path.as_path(),
232 )
233}
234
235fn normalize_target_paths(
236 origin: FilePlanOrigin<'_>,
237 files: &[FileOperation],
238) -> Result<Vec<PathBuf>> {
239 files
240 .iter()
241 .map(|operation| {
242 normalize_maybe_existing(&operation.target_path).map_err(|source| {
243 file_plan_error(
244 origin,
245 Some(operation.declaration),
246 format!(
247 "failed to resolve target {}: {source}",
248 operation.target.display()
249 ),
250 )
251 })
252 })
253 .collect()
254}
255
256fn validate_target_conflicts(
257 origin: FilePlanOrigin<'_>,
258 files: &[FileOperation],
259 target_paths: &[PathBuf],
260) -> Result<()> {
261 validate_duplicate_targets(origin, files, target_paths)?;
262 validate_overlapping_targets(origin, files, target_paths)
263}
264
265fn validate_duplicate_targets(
266 origin: FilePlanOrigin<'_>,
267 files: &[FileOperation],
268 target_paths: &[PathBuf],
269) -> Result<()> {
270 let mut targets: BTreeMap<&Path, Vec<&FileOperation>> = BTreeMap::new();
271
272 for (operation, target_path) in files.iter().zip(target_paths) {
273 targets
274 .entry(target_path.as_path())
275 .or_default()
276 .push(operation);
277 }
278
279 let duplicates = targets
280 .into_iter()
281 .filter(|(_, operations)| operations.len() > 1)
282 .collect::<Vec<_>>();
283
284 if duplicates.is_empty() {
285 return Ok(());
286 }
287
288 let details = duplicates
289 .iter()
290 .flat_map(|(target, operations)| {
291 operations.iter().map(move |operation| {
292 format!(
293 "{}: {}",
294 target.display(),
295 operation_summary(origin, operation)
296 )
297 })
298 })
299 .collect::<Vec<_>>()
300 .join("; ");
301
302 let message = match origin {
303 FilePlanOrigin::Config(_) => format!("duplicate configured target: {details}"),
304 FilePlanOrigin::Manual { .. } => format!("duplicate target: {details}"),
305 };
306
307 Err(file_plan_error(origin, None, message))
308}
309
310fn validate_overlapping_targets(
311 origin: FilePlanOrigin<'_>,
312 files: &[FileOperation],
313 target_paths: &[PathBuf],
314) -> Result<()> {
315 let mut overlaps = Vec::new();
316
317 for (index, (operation, target_path)) in files.iter().zip(target_paths).enumerate() {
318 for (other_operation, other_target_path) in files.iter().zip(target_paths).skip(index + 1) {
319 if target_path == other_target_path {
320 continue;
321 }
322
323 let Some((ancestor_path, ancestor, descendant_path, descendant)) =
324 overlapping_targets(target_path, operation, other_target_path, other_operation)
325 else {
326 continue;
327 };
328
329 overlaps.push(format!(
330 "{} contains {}: {}; {}",
331 ancestor_path.display(),
332 descendant_path.display(),
333 operation_summary(origin, ancestor),
334 operation_summary(origin, descendant)
335 ));
336 }
337 }
338
339 if overlaps.is_empty() {
340 return Ok(());
341 }
342
343 let message = match origin {
344 FilePlanOrigin::Config(_) => {
345 format!("overlapping configured targets: {}", overlaps.join("; "))
346 }
347 FilePlanOrigin::Manual { .. } => format!("overlapping targets: {}", overlaps.join("; ")),
348 };
349
350 Err(file_plan_error(origin, None, message))
351}
352
353fn overlapping_targets<'a>(
354 target_path: &'a Path,
355 operation: &'a FileOperation,
356 other_target_path: &'a Path,
357 other_operation: &'a FileOperation,
358) -> Option<(&'a Path, &'a FileOperation, &'a Path, &'a FileOperation)> {
359 if other_target_path.starts_with(target_path) {
360 return Some((target_path, operation, other_target_path, other_operation));
361 }
362
363 if target_path.starts_with(other_target_path) {
364 return Some((other_target_path, other_operation, target_path, operation));
365 }
366
367 None
368}
369
370fn validate_strict_sync(
371 origin: FilePlanOrigin<'_>,
372 files: &[FileOperation],
373 strict: bool,
374) -> Result<()> {
375 if !strict {
376 return Ok(());
377 }
378
379 if let Some(operation) = files
380 .iter()
381 .find(|operation| operation.operation == FileOperationKind::Sync)
382 {
383 return invalid_file_plan(
384 origin,
385 Some(operation.declaration),
386 format!(
387 "`--strict` cannot be used with sync file operation {}",
388 operation_summary(origin, operation)
389 ),
390 );
391 }
392
393 Ok(())
394}
395
396fn build_file_operations(
397 origin: FilePlanOrigin<'_>,
398 files: &[FileOperation],
399 options: ActionPlanOptions,
400 target_paths: &[PathBuf],
401 root_path: &Path,
402 worktree_path: &Path,
403) -> Result<Vec<PlannedFileOperation>> {
404 let mut planned = Vec::with_capacity(files.len());
405
406 for (operation, target_path) in files.iter().zip(target_paths) {
407 validate_target_boundary(origin, options, operation, target_path, worktree_path)?;
408
409 let source_path = normalize_maybe_existing(&operation.source_path).map_err(|source| {
410 file_plan_error(
411 origin,
412 Some(operation.declaration),
413 format!(
414 "failed to resolve source {}: {source}",
415 operation.source.display()
416 ),
417 )
418 })?;
419 validate_source_boundary(origin, options, operation, &source_path, root_path)?;
420
421 let status = match source_exists(origin, operation, source_path.as_path())? {
422 true => {
423 if matches!(
424 operation.operation,
425 FileOperationKind::Copy | FileOperationKind::Sync
426 ) {
427 validate_source_symlinks(origin, operation, source_path.as_path(), root_path)?;
428 }
429
430 PlannedFileStatus::Ready
431 }
432 false if operation.required => {
433 return invalid_file_plan(
434 origin,
435 Some(operation.declaration),
436 format!(
437 "required source does not exist for {}",
438 operation_summary(origin, operation)
439 ),
440 );
441 }
442 false => PlannedFileStatus::SkippedMissingSource,
443 };
444
445 planned.push(PlannedFileOperation {
446 operation: operation.operation,
447 source: operation.source.clone(),
448 target: operation.target.clone(),
449 source_path,
450 target_path: target_path.clone(),
451 required: operation.required,
452 compare: operation.compare,
453 delete: operation.delete,
454 symlinks: operation.symlinks,
455 status,
456 declaration: operation.declaration,
457 });
458 }
459
460 Ok(planned)
461}
462
463fn validate_target_boundary(
464 origin: FilePlanOrigin<'_>,
465 options: ActionPlanOptions,
466 operation: &FileOperation,
467 target_path: &Path,
468 worktree_path: &Path,
469) -> Result<()> {
470 if options.dangerously_allow_targets_outside_worktree {
471 return Ok(());
472 }
473
474 if !is_within(target_path, worktree_path) {
475 return invalid_file_plan(
476 origin,
477 Some(operation.declaration),
478 format!(
479 "target resolves outside worktree for {}",
480 operation_summary(origin, operation)
481 ),
482 );
483 }
484
485 Ok(())
486}
487
488fn validate_source_boundary(
489 origin: FilePlanOrigin<'_>,
490 options: ActionPlanOptions,
491 operation: &FileOperation,
492 source_path: &Path,
493 root_path: &Path,
494) -> Result<()> {
495 if options.dangerously_allow_sources_outside_root {
496 return Ok(());
497 }
498
499 if !is_within(source_path, root_path) {
500 return invalid_file_plan(
501 origin,
502 Some(operation.declaration),
503 format!(
504 "source resolves outside root for {}",
505 operation_summary(origin, operation)
506 ),
507 );
508 }
509
510 Ok(())
511}
512
513fn plan_commands(
514 path: &Path,
515 commands: &[CommandOperation],
516 context: &Worktree,
517 worktree_path: &Path,
518) -> Result<Vec<PlannedCommand>> {
519 let mut planned = Vec::with_capacity(commands.len());
520
521 for command in commands {
522 let cwd_path = command
523 .cwd_path
524 .as_ref()
525 .map_or_else(
526 || Ok(worktree_path.to_path_buf()),
527 |cwd_path| normalize_maybe_existing(cwd_path),
528 )
529 .map_err(|source| {
530 invalid_config_error(
531 path,
532 Some(command.declaration),
533 format!("failed to resolve command cwd: {source}"),
534 )
535 })?;
536
537 if !is_within(&cwd_path, worktree_path) {
538 return invalid_config(
539 path,
540 Some(command.declaration),
541 "command cwd resolves outside worktree",
542 );
543 }
544
545 for key in command.env.keys() {
546 if context.environment.contains_key(key) {
547 return invalid_config(
548 path,
549 Some(command.declaration),
550 format!("command env overrides treeboot-owned variable `{key}`"),
551 );
552 }
553 }
554
555 planned.push(PlannedCommand {
556 name: command.name.clone(),
557 command: command.command.clone(),
558 cwd: command.cwd.clone(),
559 cwd_path,
560 env: command.env.clone(),
561 allow_failure: command.allow_failure,
562 declaration: command.declaration,
563 });
564 }
565
566 Ok(planned)
567}
568
569fn validate_source_symlinks(
570 origin: FilePlanOrigin<'_>,
571 operation: &FileOperation,
572 source_path: &Path,
573 root_path: &Path,
574) -> Result<()> {
575 validate_source_symlink_path(origin, operation, source_path, root_path)
576}
577
578fn validate_source_symlink_path(
579 origin: FilePlanOrigin<'_>,
580 operation: &FileOperation,
581 path: &Path,
582 root_path: &Path,
583) -> Result<()> {
584 let metadata = std::fs::symlink_metadata(path).map_err(|source| {
585 file_plan_error(
586 origin,
587 Some(operation.declaration),
588 format!(
589 "failed to inspect source {}: {source}",
590 operation.source.display()
591 ),
592 )
593 })?;
594
595 if metadata.file_type().is_symlink() {
596 let target = normalize_existing(path).map_err(|source| {
597 file_plan_error(
598 origin,
599 Some(operation.declaration),
600 format!(
601 "failed to resolve source symlink {}: {source}",
602 path.display()
603 ),
604 )
605 })?;
606
607 if !is_within(&target, root_path) {
608 return invalid_file_plan(
609 origin,
610 Some(operation.declaration),
611 format!(
612 "copy or sync source contains unsafe symlink {}",
613 path.display()
614 ),
615 );
616 }
617
618 return Ok(());
619 }
620
621 if !metadata.is_dir() {
622 return Ok(());
623 }
624
625 for entry in std::fs::read_dir(path).map_err(|source| {
626 file_plan_error(
627 origin,
628 Some(operation.declaration),
629 format!(
630 "failed to inspect source directory {}: {source}",
631 path.display()
632 ),
633 )
634 })? {
635 let entry = entry.map_err(|source| {
636 file_plan_error(
637 origin,
638 Some(operation.declaration),
639 format!(
640 "failed to inspect source directory {}: {source}",
641 path.display()
642 ),
643 )
644 })?;
645 validate_source_symlink_path(origin, operation, &entry.path(), root_path)?;
646 }
647
648 Ok(())
649}
650
651fn source_exists(
652 origin: FilePlanOrigin<'_>,
653 operation: &FileOperation,
654 source_path: &Path,
655) -> Result<bool> {
656 match std::fs::symlink_metadata(source_path) {
657 Ok(_) => Ok(true),
658 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
659 Err(source) => Err(file_plan_error(
660 origin,
661 Some(operation.declaration),
662 format!(
663 "failed to inspect source {}: {source}",
664 operation.source.display()
665 ),
666 )),
667 }
668}
669
670fn operation_summary(origin: FilePlanOrigin<'_>, operation: &FileOperation) -> String {
671 let summary = format!(
672 "{} {} -> {}",
673 operation.operation,
674 operation.source.display(),
675 operation.target.display()
676 );
677
678 match origin {
679 FilePlanOrigin::Config(_) => format!(
680 "{} at line {}, column {}",
681 summary, operation.declaration.line, operation.declaration.column
682 ),
683 FilePlanOrigin::Manual { .. } => summary,
684 }
685}
686
687fn invalid_config<T>(
688 path: &Path,
689 span: Option<SourceSpan>,
690 message: impl Into<String>,
691) -> Result<T> {
692 Err(invalid_config_error(path, span, message))
693}
694
695fn invalid_file_plan<T>(
696 origin: FilePlanOrigin<'_>,
697 span: Option<SourceSpan>,
698 message: impl Into<String>,
699) -> Result<T> {
700 Err(file_plan_error(origin, span, message))
701}
702
703fn invalid_config_error(
704 path: &Path,
705 span: Option<SourceSpan>,
706 message: impl Into<String>,
707) -> Error {
708 let message = match span {
709 Some(span) => format!(
710 "{} at line {}, column {}",
711 message.into(),
712 span.line,
713 span.column
714 ),
715 None => message.into(),
716 };
717
718 Error::ConfigInvalid {
719 path: path.to_path_buf(),
720 message,
721 }
722}
723
724fn file_plan_error(
725 origin: FilePlanOrigin<'_>,
726 span: Option<SourceSpan>,
727 message: impl Into<String>,
728) -> Error {
729 match origin {
730 FilePlanOrigin::Config(path) => invalid_config_error(path, span, message),
731 FilePlanOrigin::Manual { operation } => Error::FileOperationInvalid {
732 operation: operation.as_str(),
733 message: message.into(),
734 },
735 }
736}
737
738fn normalize_existing(path: &Path) -> std::io::Result<PathBuf> {
739 std::fs::canonicalize(path)
740}
741
742fn normalize_maybe_existing(path: &Path) -> std::io::Result<PathBuf> {
743 match normalize_existing(path) {
744 Ok(path) => return Ok(path),
745 Err(source) if source.kind() != std::io::ErrorKind::NotFound => {
746 return Err(source);
747 }
748 Err(_) => {}
749 }
750
751 let mut missing = Vec::new();
752 let mut ancestor = path;
753
754 while !ancestor.exists() {
755 if let Some(name) = ancestor.file_name() {
756 missing.push(name.to_owned());
757 }
758
759 let Some(parent) = ancestor.parent() else {
760 break;
761 };
762 ancestor = parent;
763 }
764
765 let mut normalized = if ancestor.exists() {
766 normalize_existing(ancestor)?
767 } else {
768 PathBuf::new()
769 };
770
771 for component in missing.iter().rev() {
772 normalized.push(component);
773 }
774
775 Ok(normalize_lexical(&normalized))
776}
777
778fn normalize_lexical(path: &Path) -> PathBuf {
779 let mut normalized = PathBuf::new();
780
781 for component in path.components() {
782 match component {
783 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
784 Component::RootDir => normalized.push(component.as_os_str()),
785 Component::CurDir => {}
786 Component::ParentDir => {
787 if !normalized.pop() && !normalized.has_root() {
788 normalized.push(component.as_os_str());
789 }
790 }
791 Component::Normal(part) => normalized.push(part),
792 }
793 }
794
795 normalized
796}
797
798fn is_within(path: &Path, boundary: &Path) -> bool {
799 path == boundary || path.starts_with(boundary)
800}
801
802#[cfg(test)]
803mod tests {
804 use std::collections::BTreeMap;
805 use std::ffi::OsString;
806 use std::time::{SystemTime, UNIX_EPOCH};
807
808 use super::*;
809
810 fn span() -> SourceSpan {
811 SourceSpan {
812 start: 0,
813 end: 1,
814 line: 1,
815 column: 1,
816 }
817 }
818
819 fn temp_workspace(name: &str) -> (PathBuf, PathBuf) {
820 let id = SystemTime::now()
821 .duration_since(UNIX_EPOCH)
822 .expect("clock should be after Unix epoch")
823 .as_nanos();
824 let base = std::env::temp_dir().join(format!("treeboot-{name}-{id}"));
825 let root = base.join("root");
826 let worktree = base.join("worktree");
827
828 std::fs::create_dir_all(&root).expect("root should be created");
829 std::fs::create_dir_all(&worktree).expect("worktree should be created");
830
831 (root, worktree)
832 }
833
834 fn context(root_path: &Path, worktree_path: &Path) -> Worktree {
835 Worktree {
836 root_path: root_path.to_path_buf(),
837 worktree_path: worktree_path.to_path_buf(),
838 default_branch: "main".to_owned(),
839 environment: BTreeMap::from([(
840 "TREEBOOT_ROOT_PATH".to_owned(),
841 OsString::from(root_path),
842 )]),
843 }
844 }
845
846 fn empty_config() -> Config {
847 Config {
848 options: Default::default(),
849 files: Vec::new(),
850 commands: Vec::new(),
851 }
852 }
853
854 fn file_operation(
855 operation: FileOperationKind,
856 root: &Path,
857 worktree: &Path,
858 source: &str,
859 target: &str,
860 ) -> FileOperation {
861 FileOperation {
862 operation,
863 source: PathBuf::from(source),
864 target: PathBuf::from(target),
865 source_path: root.join(source),
866 target_path: worktree.join(target),
867 required: false,
868 compare: match operation {
869 FileOperationKind::Sync => Some(SyncCompare::Metadata),
870 FileOperationKind::Copy | FileOperationKind::Symlink => None,
871 },
872 delete: match operation {
873 FileOperationKind::Sync => Some(false),
874 FileOperationKind::Copy | FileOperationKind::Symlink => None,
875 },
876 symlinks: match operation {
877 FileOperationKind::Copy | FileOperationKind::Sync => Some(SymlinkMode::Preserve),
878 FileOperationKind::Symlink => None,
879 },
880 declaration: span(),
881 }
882 }
883
884 fn plan(config: &Config, root: &Path, worktree: &Path) -> Result<ActionPlan> {
885 ActionPlan::from_manifest(
886 Path::new(".treeboot.toml"),
887 config,
888 &context(root, worktree),
889 ActionPlanOptions::default(),
890 )
891 }
892
893 #[test]
894 fn normalize_lexical_should_resolve_parent_components() {
895 assert_eq!(
896 normalize_lexical(Path::new("/repo/worktree/../outside")),
897 PathBuf::from("/repo/outside")
898 );
899 }
900
901 #[test]
902 fn is_within_should_not_match_partial_component_prefixes() {
903 assert!(!is_within(
904 Path::new("/repo-worktree-other/file"),
905 Path::new("/repo-worktree")
906 ));
907 }
908
909 #[test]
910 fn action_plan_from_manifest_should_mark_optional_missing_sources_skipped() {
911 let (root, worktree) = temp_workspace("missing-source");
912 let config = Config {
913 options: Default::default(),
914 files: vec![FileOperation {
915 operation: FileOperationKind::Copy,
916 source: PathBuf::from("missing"),
917 target: PathBuf::from("missing"),
918 source_path: root.join("missing"),
919 target_path: worktree.join("missing"),
920 required: false,
921 compare: None,
922 delete: None,
923 symlinks: Some(SymlinkMode::Preserve),
924 declaration: span(),
925 }],
926 commands: Vec::new(),
927 };
928
929 let plan = ActionPlan::from_manifest(
930 Path::new(".treeboot.toml"),
931 &config,
932 &context(&root, &worktree),
933 ActionPlanOptions::default(),
934 )
935 .expect("optional missing source should plan");
936
937 assert_eq!(
938 plan.files[0].status,
939 PlannedFileStatus::SkippedMissingSource
940 );
941 }
942
943 #[test]
944 fn action_plan_from_manifest_should_build_ready_file_operation() {
945 let (root, worktree) = temp_workspace("ready-file");
946 std::fs::write(root.join(".env"), "TOKEN=1\n").expect("source should be written");
947 let config = Config {
948 options: Default::default(),
949 files: vec![file_operation(
950 FileOperationKind::Copy,
951 &root,
952 &worktree,
953 ".env",
954 ".env",
955 )],
956 commands: Vec::new(),
957 };
958
959 let plan = plan(&config, &root, &worktree).expect("file should plan");
960
961 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
962 }
963
964 #[test]
965 fn action_plan_from_manifest_should_reject_overlapping_file_targets() {
966 let (root, worktree) = temp_workspace("overlapping-targets");
967 let mut sync = file_operation(
968 FileOperationKind::Sync,
969 &root,
970 &worktree,
971 "shared",
972 "shared",
973 );
974 sync.delete = Some(true);
975 let config = Config {
976 options: Default::default(),
977 files: vec![
978 file_operation(
979 FileOperationKind::Copy,
980 &root,
981 &worktree,
982 "child",
983 "shared/child",
984 ),
985 sync,
986 ],
987 commands: Vec::new(),
988 };
989
990 let error = plan(&config, &root, &worktree).expect_err("overlapping targets should fail");
991
992 assert!(error.to_string().contains("overlapping configured targets"));
993 assert!(error.to_string().contains("shared"));
994 assert!(error.to_string().contains("shared/child"));
995 }
996
997 #[test]
998 fn action_plan_from_manual_operations_should_reject_overlapping_targets() {
999 let (root, worktree) = temp_workspace("manual-overlapping-targets");
1000 let mut sync = file_operation(
1001 FileOperationKind::Sync,
1002 &root,
1003 &worktree,
1004 "shared",
1005 "shared",
1006 );
1007 sync.delete = Some(true);
1008 let operations = vec![
1009 sync,
1010 file_operation(
1011 FileOperationKind::Sync,
1012 &root,
1013 &worktree,
1014 "shared/nested",
1015 "shared/nested",
1016 ),
1017 ];
1018
1019 let error = ActionPlan::from_file_operations(
1020 &context(&root, &worktree),
1021 PlanOrigin::Manual {
1022 operation: FileOperationKind::Sync,
1023 },
1024 &operations,
1025 ActionPlanOptions::default(),
1026 )
1027 .expect_err("overlapping targets should fail");
1028
1029 assert!(error.to_string().contains("invalid sync file operation"));
1030 assert!(error.to_string().contains("overlapping targets"));
1031 }
1032
1033 #[test]
1034 fn action_plan_from_manifest_should_build_command_metadata() {
1035 let (root, worktree) = temp_workspace("command-metadata");
1036 let app_dir = worktree.join("app");
1037 std::fs::create_dir_all(&app_dir).expect("command cwd should be created");
1038 let config = Config {
1039 options: Default::default(),
1040 files: Vec::new(),
1041 commands: vec![CommandOperation {
1042 name: Some("Install".to_owned()),
1043 command: CommandKind::Direct {
1044 program: "npm".to_owned(),
1045 args: vec!["install".to_owned()],
1046 },
1047 cwd: Some(PathBuf::from("app")),
1048 cwd_path: Some(app_dir.clone()),
1049 env: BTreeMap::from([("NODE_ENV".to_owned(), "development".to_owned())]),
1050 allow_failure: true,
1051 declaration: span(),
1052 }],
1053 };
1054
1055 let plan = plan(&config, &root, &worktree).expect("command should plan");
1056
1057 assert_eq!(
1058 plan.commands[0].cwd_path,
1059 std::fs::canonicalize(app_dir).expect("app dir should canonicalize")
1060 );
1061 assert!(plan.commands[0].allow_failure);
1062 }
1063
1064 #[test]
1065 fn action_plan_from_manifest_should_allow_explicit_boundary_escapes() {
1066 let (root, worktree) = temp_workspace("boundary-escapes");
1067 let outside_source = root
1068 .parent()
1069 .expect("root should have parent")
1070 .join("outside-source");
1071 let outside_target = worktree
1072 .parent()
1073 .expect("worktree should have parent")
1074 .join("outside-target");
1075 std::fs::write(&outside_source, "shared\n").expect("outside source should be written");
1076 let config = Config {
1077 options: Default::default(),
1078 files: vec![FileOperation {
1079 operation: FileOperationKind::Copy,
1080 source: outside_source.clone(),
1081 target: outside_target.clone(),
1082 source_path: outside_source,
1083 target_path: outside_target,
1084 required: false,
1085 compare: None,
1086 delete: None,
1087 symlinks: Some(SymlinkMode::Preserve),
1088 declaration: span(),
1089 }],
1090 commands: Vec::new(),
1091 };
1092
1093 let plan = ActionPlan::from_manifest(
1094 Path::new(".treeboot.toml"),
1095 &config,
1096 &context(&root, &worktree),
1097 ActionPlanOptions {
1098 dangerously_allow_sources_outside_root: true,
1099 dangerously_allow_targets_outside_worktree: true,
1100 ..ActionPlanOptions::default()
1101 },
1102 )
1103 .expect("escaped paths should plan");
1104
1105 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1106 }
1107
1108 #[test]
1109 fn action_plan_from_manifest_should_reject_missing_root_path() {
1110 let (_root, worktree) = temp_workspace("missing-root");
1111 let missing_root = worktree.join("missing-root");
1112 let error = ActionPlan::from_manifest(
1113 Path::new(".treeboot.toml"),
1114 &empty_config(),
1115 &context(&missing_root, &worktree),
1116 ActionPlanOptions::default(),
1117 )
1118 .expect_err("missing root should fail");
1119
1120 assert!(error.to_string().contains("failed to resolve root path"));
1121 }
1122
1123 #[test]
1124 fn action_plan_from_manifest_should_reject_missing_worktree_path() {
1125 let (root, worktree) = temp_workspace("missing-worktree");
1126 let missing_worktree = worktree.join("missing-worktree");
1127 let error = ActionPlan::from_manifest(
1128 Path::new(".treeboot.toml"),
1129 &empty_config(),
1130 &context(&root, &missing_worktree),
1131 ActionPlanOptions::default(),
1132 )
1133 .expect_err("missing worktree should fail");
1134
1135 assert!(
1136 error
1137 .to_string()
1138 .contains("failed to resolve worktree path")
1139 );
1140 }
1141
1142 #[test]
1143 fn action_plan_from_manifest_should_allow_strict_when_no_sync_exists() {
1144 let (root, worktree) = temp_workspace("strict-no-sync");
1145
1146 let plan = ActionPlan::from_manifest(
1147 Path::new(".treeboot.toml"),
1148 &empty_config(),
1149 &context(&root, &worktree),
1150 ActionPlanOptions {
1151 strict: true,
1152 ..ActionPlanOptions::default()
1153 },
1154 )
1155 .expect("strict mode should allow configs without sync");
1156
1157 assert!(plan.files.is_empty());
1158 }
1159
1160 #[test]
1161 fn action_plan_from_manifest_should_walk_source_directories() {
1162 let (root, worktree) = temp_workspace("source-directory");
1163 let source_dir = root.join("shared");
1164 std::fs::create_dir_all(&source_dir).expect("source dir should be created");
1165 std::fs::write(source_dir.join("config"), "value\n").expect("nested source should exist");
1166 let config = Config {
1167 options: Default::default(),
1168 files: vec![file_operation(
1169 FileOperationKind::Copy,
1170 &root,
1171 &worktree,
1172 "shared",
1173 "shared",
1174 )],
1175 commands: Vec::new(),
1176 };
1177
1178 let plan = plan(&config, &root, &worktree).expect("directory source should plan");
1179
1180 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1181 }
1182
1183 #[test]
1184 fn action_plan_from_manifest_should_preserve_sync_options() {
1185 let (root, worktree) = temp_workspace("sync-options");
1186 let source_dir = root.join("shared");
1187 std::fs::create_dir_all(&source_dir).expect("source dir should be created");
1188 let mut operation = file_operation(
1189 FileOperationKind::Sync,
1190 &root,
1191 &worktree,
1192 "shared",
1193 "shared",
1194 );
1195 operation.delete = Some(true);
1196
1197 let config = Config {
1198 options: Default::default(),
1199 files: vec![operation],
1200 commands: Vec::new(),
1201 };
1202
1203 let plan = plan(&config, &root, &worktree).expect("sync should plan");
1204
1205 assert_eq!(plan.files[0].compare, Some(SyncCompare::Metadata));
1206 assert_eq!(plan.files[0].delete, Some(true));
1207 assert_eq!(plan.files[0].symlinks, Some(SymlinkMode::Preserve));
1208 }
1209
1210 #[cfg(unix)]
1211 #[test]
1212 fn action_plan_from_manifest_should_allow_safe_source_symlink() {
1213 let (root, worktree) = temp_workspace("safe-symlink");
1214 std::fs::write(root.join("source"), "value\n").expect("source should be written");
1215 std::os::unix::fs::symlink(root.join("source"), root.join("link"))
1216 .expect("safe source symlink should be created");
1217 let config = Config {
1218 options: Default::default(),
1219 files: vec![file_operation(
1220 FileOperationKind::Copy,
1221 &root,
1222 &worktree,
1223 "link",
1224 "link",
1225 )],
1226 commands: Vec::new(),
1227 };
1228
1229 let plan = plan(&config, &root, &worktree).expect("safe symlink should plan");
1230
1231 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1232 }
1233
1234 #[cfg(unix)]
1235 #[test]
1236 fn action_plan_from_manifest_should_reject_broken_source_symlink() {
1237 let (root, worktree) = temp_workspace("broken-symlink");
1238 std::os::unix::fs::symlink(root.join("missing"), root.join("link"))
1239 .expect("broken source symlink should be created");
1240 let config = Config {
1241 options: Default::default(),
1242 files: vec![file_operation(
1243 FileOperationKind::Copy,
1244 &root,
1245 &worktree,
1246 "link",
1247 "link",
1248 )],
1249 commands: Vec::new(),
1250 };
1251
1252 let error = plan(&config, &root, &worktree).expect_err("broken symlink should fail");
1253
1254 assert!(
1255 error
1256 .to_string()
1257 .contains("failed to resolve source symlink")
1258 );
1259 }
1260
1261 #[test]
1262 fn action_plan_from_manifest_should_default_command_cwd_to_worktree() {
1263 let (root, worktree) = temp_workspace("command-cwd");
1264 let config = Config {
1265 options: Default::default(),
1266 files: Vec::new(),
1267 commands: vec![CommandOperation {
1268 name: None,
1269 command: CommandKind::Shell {
1270 run: "pwd".to_owned(),
1271 },
1272 cwd: None,
1273 cwd_path: None,
1274 env: BTreeMap::new(),
1275 allow_failure: false,
1276 declaration: span(),
1277 }],
1278 };
1279
1280 let plan = ActionPlan::from_manifest(
1281 Path::new(".treeboot.toml"),
1282 &config,
1283 &context(&root, &worktree),
1284 ActionPlanOptions::default(),
1285 )
1286 .expect("command should plan");
1287
1288 assert_eq!(
1289 plan.commands[0].cwd_path,
1290 std::fs::canonicalize(worktree).expect("worktree should canonicalize")
1291 );
1292 }
1293}