1use std::collections::BTreeMap;
2use std::path::{Component, Path, PathBuf};
3
4use crate::file_system::{TargetAncestorIssue, inspect_target_ancestors, matching_target_anchor};
5use crate::ignore_rules::PathIgnoreRules;
6use crate::{
7 CommandKind, CommandOperation, Config, ConfigRuntimeOptions, Error, FileOperation,
8 FileOperationKind, MetadataField, Result, SourceSpan, SymlinkMode, SyncCompare, Worktree,
9};
10
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13pub struct ActionPlanOptions {
14 pub strict: bool,
16 pub dangerously_allow_sources_outside_root: bool,
18 pub dangerously_allow_targets_outside_worktree: bool,
20}
21
22impl From<ConfigRuntimeOptions> for ActionPlanOptions {
23 fn from(options: ConfigRuntimeOptions) -> Self {
24 Self {
25 strict: options.strict,
26 dangerously_allow_sources_outside_root: options.dangerously_allow_sources_outside_root,
27 dangerously_allow_targets_outside_worktree: options
28 .dangerously_allow_targets_outside_worktree,
29 }
30 }
31}
32
33#[derive(Debug, Clone, Copy)]
34pub(super) enum FilePlanOrigin<'a> {
35 Config(&'a Path),
36 Manual { operation: FileOperationKind },
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum PlanOrigin {
42 Manifest {
44 path: PathBuf,
46 },
47 Manual {
49 operation: FileOperationKind,
51 },
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ActionPlan {
81 context: Worktree,
83 origin: PlanOrigin,
85 config_path: Option<PathBuf>,
87 files: Vec<PlannedFileOperation>,
89 commands: Vec<PlannedCommand>,
91}
92
93impl ActionPlan {
94 #[must_use]
96 pub const fn context(&self) -> &Worktree {
97 &self.context
98 }
99
100 #[must_use]
102 pub const fn origin(&self) -> &PlanOrigin {
103 &self.origin
104 }
105
106 #[must_use]
108 pub fn config_path(&self) -> Option<&Path> {
109 self.config_path.as_deref()
110 }
111
112 #[must_use]
114 pub fn files(&self) -> &[PlannedFileOperation] {
115 &self.files
116 }
117
118 #[must_use]
120 pub fn commands(&self) -> &[PlannedCommand] {
121 &self.commands
122 }
123
124 pub fn from_manifest(
134 path: &Path,
135 manifest: &Config,
136 context: &Worktree,
137 options: ActionPlanOptions,
138 ) -> Result<Self> {
139 let worktree_path = normalize_existing(&context.worktree_path).map_err(|source| {
140 invalid_config_error(
141 path,
142 None,
143 format!("failed to resolve worktree path: {source}"),
144 )
145 })?;
146 let files = plan_file_operations(
147 FilePlanOrigin::Config(path),
148 &manifest.files,
149 context,
150 options,
151 )?;
152 let commands = plan_commands(path, &manifest.commands, context, worktree_path.as_path())?;
153
154 Ok(Self {
155 context: context.clone(),
156 origin: PlanOrigin::Manifest {
157 path: path.to_path_buf(),
158 },
159 config_path: Some(path.to_path_buf()),
160 files,
161 commands,
162 })
163 }
164
165 pub fn from_file_operations(
174 context: &Worktree,
175 origin: PlanOrigin,
176 files: &[FileOperation],
177 options: ActionPlanOptions,
178 ) -> Result<Self> {
179 let file_origin = match &origin {
180 PlanOrigin::Manifest { path } => FilePlanOrigin::Config(path),
181 PlanOrigin::Manual { operation } => FilePlanOrigin::Manual {
182 operation: *operation,
183 },
184 };
185 let files = plan_file_operations(file_origin, files, context, options)?;
186 let config_path = match &origin {
187 PlanOrigin::Manifest { path } => Some(path.clone()),
188 PlanOrigin::Manual { .. } => None,
189 };
190
191 Ok(Self {
192 context: context.clone(),
193 origin,
194 config_path,
195 files,
196 commands: Vec::new(),
197 })
198 }
199
200 #[cfg(test)]
201 pub(crate) fn from_parts_unchecked(
202 context: Worktree,
203 origin: PlanOrigin,
204 config_path: Option<PathBuf>,
205 files: Vec<PlannedFileOperation>,
206 commands: Vec<PlannedCommand>,
207 ) -> Self {
208 Self {
209 context,
210 origin,
211 config_path,
212 files,
213 commands,
214 }
215 }
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct PlannedFileOperation {
228 operation: FileOperationKind,
230 source: PathBuf,
232 target: PathBuf,
234 source_path: PathBuf,
236 target_path: PathBuf,
238 required: bool,
240 compare: Option<SyncCompare>,
242 delete: Option<bool>,
244 symlinks: Option<SymlinkMode>,
246 ignore: Vec<String>,
248 ignore_metadata: Vec<MetadataField>,
250 status: PlannedFileStatus,
252 declaration: SourceSpan,
254}
255
256impl PlannedFileOperation {
257 #[must_use]
259 pub const fn operation(&self) -> FileOperationKind {
260 self.operation
261 }
262
263 #[must_use]
265 pub fn source(&self) -> &Path {
266 &self.source
267 }
268
269 #[must_use]
271 pub fn target(&self) -> &Path {
272 &self.target
273 }
274
275 #[must_use]
277 pub fn source_path(&self) -> &Path {
278 &self.source_path
279 }
280
281 #[must_use]
283 pub fn target_path(&self) -> &Path {
284 &self.target_path
285 }
286
287 #[must_use]
289 pub const fn required(&self) -> bool {
290 self.required
291 }
292
293 #[must_use]
295 pub const fn compare(&self) -> Option<SyncCompare> {
296 self.compare
297 }
298
299 #[must_use]
301 pub const fn delete(&self) -> Option<bool> {
302 self.delete
303 }
304
305 #[must_use]
307 pub const fn symlinks(&self) -> Option<SymlinkMode> {
308 self.symlinks
309 }
310
311 #[must_use]
313 pub fn ignore(&self) -> &[String] {
314 &self.ignore
315 }
316
317 #[must_use]
319 pub fn ignore_metadata(&self) -> &[MetadataField] {
320 &self.ignore_metadata
321 }
322
323 #[must_use]
325 pub const fn status(&self) -> PlannedFileStatus {
326 self.status
327 }
328
329 #[must_use]
331 pub const fn declaration(&self) -> SourceSpan {
332 self.declaration
333 }
334
335 #[cfg(test)]
336 pub(crate) fn from_raw_parts_unchecked(parts: PlannedFileOperationParts) -> Self {
337 Self {
338 operation: parts.operation,
339 source: parts.source,
340 target: parts.target,
341 source_path: parts.source_path,
342 target_path: parts.target_path,
343 required: parts.required,
344 compare: parts.compare,
345 delete: parts.delete,
346 symlinks: parts.symlinks,
347 ignore: parts.ignore,
348 ignore_metadata: parts.ignore_metadata,
349 status: parts.status,
350 declaration: parts.declaration,
351 }
352 }
353
354 #[cfg(test)]
355 pub(crate) const fn with_compare(mut self, compare: Option<SyncCompare>) -> Self {
356 self.compare = compare;
357 self
358 }
359
360 #[cfg(test)]
361 pub(crate) const fn with_delete(mut self, delete: Option<bool>) -> Self {
362 self.delete = delete;
363 self
364 }
365
366 #[cfg(test)]
367 pub(crate) fn with_ignore(mut self, ignore: Vec<String>) -> Self {
368 self.ignore = ignore;
369 self
370 }
371
372 #[cfg(test)]
373 pub(crate) fn with_ignore_metadata(mut self, ignore_metadata: Vec<MetadataField>) -> Self {
374 self.ignore_metadata = ignore_metadata;
375 self
376 }
377}
378
379#[cfg(test)]
380pub(crate) struct PlannedFileOperationParts {
381 pub(crate) operation: FileOperationKind,
382 pub(crate) source: PathBuf,
383 pub(crate) target: PathBuf,
384 pub(crate) source_path: PathBuf,
385 pub(crate) target_path: PathBuf,
386 pub(crate) required: bool,
387 pub(crate) compare: Option<SyncCompare>,
388 pub(crate) delete: Option<bool>,
389 pub(crate) symlinks: Option<SymlinkMode>,
390 pub(crate) ignore: Vec<String>,
391 pub(crate) ignore_metadata: Vec<MetadataField>,
392 pub(crate) status: PlannedFileStatus,
393 pub(crate) declaration: SourceSpan,
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
398pub enum PlannedFileStatus {
399 Ready,
401 SkippedMissingSource,
403}
404
405#[derive(Debug, Clone, PartialEq, Eq)]
414pub struct PlannedCommand {
415 name: Option<String>,
417 command: CommandKind,
419 cwd: Option<PathBuf>,
421 cwd_path: PathBuf,
423 env: BTreeMap<String, String>,
425 allow_failure: bool,
427 declaration: SourceSpan,
429}
430
431impl PlannedCommand {
432 #[must_use]
434 pub fn name(&self) -> Option<&str> {
435 self.name.as_deref()
436 }
437
438 #[must_use]
440 pub const fn command(&self) -> &CommandKind {
441 &self.command
442 }
443
444 #[must_use]
446 pub fn cwd(&self) -> Option<&Path> {
447 self.cwd.as_deref()
448 }
449
450 #[must_use]
452 pub fn cwd_path(&self) -> &Path {
453 &self.cwd_path
454 }
455
456 #[must_use]
458 pub const fn env(&self) -> &BTreeMap<String, String> {
459 &self.env
460 }
461
462 #[must_use]
464 pub const fn allow_failure(&self) -> bool {
465 self.allow_failure
466 }
467
468 #[must_use]
470 pub const fn declaration(&self) -> SourceSpan {
471 self.declaration
472 }
473
474 #[cfg(test)]
475 pub(crate) fn from_raw_parts_unchecked(parts: PlannedCommandParts) -> Self {
476 Self {
477 name: parts.name,
478 command: parts.command,
479 cwd: parts.cwd,
480 cwd_path: parts.cwd_path,
481 env: parts.env,
482 allow_failure: parts.allow_failure,
483 declaration: parts.declaration,
484 }
485 }
486}
487
488#[cfg(test)]
489#[derive(Clone)]
490pub(crate) struct PlannedCommandParts {
491 pub(crate) name: Option<String>,
492 pub(crate) command: CommandKind,
493 pub(crate) cwd: Option<PathBuf>,
494 pub(crate) cwd_path: PathBuf,
495 pub(crate) env: BTreeMap<String, String>,
496 pub(crate) allow_failure: bool,
497 pub(crate) declaration: SourceSpan,
498}
499
500pub(super) fn plan_file_operations(
501 origin: FilePlanOrigin<'_>,
502 files: &[FileOperation],
503 context: &Worktree,
504 options: ActionPlanOptions,
505) -> Result<Vec<PlannedFileOperation>> {
506 let root_path = normalize_existing(&context.root_path).map_err(|source| {
507 file_plan_error(
508 origin,
509 None,
510 format!("failed to resolve root path: {source}"),
511 )
512 })?;
513 let worktree_path = normalize_existing(&context.worktree_path).map_err(|source| {
514 file_plan_error(
515 origin,
516 None,
517 format!("failed to resolve worktree path: {source}"),
518 )
519 })?;
520
521 let target_paths = normalize_target_paths(
522 origin,
523 files,
524 context.worktree_path.as_path(),
525 worktree_path.as_path(),
526 )?;
527 validate_target_conflicts(origin, files, &target_paths)?;
528 validate_strict_sync(origin, files, options.strict)?;
529
530 build_file_operations(
531 origin,
532 files,
533 options,
534 &target_paths,
535 root_path.as_path(),
536 worktree_path.as_path(),
537 )
538}
539
540fn normalize_target_paths(
541 origin: FilePlanOrigin<'_>,
542 files: &[FileOperation],
543 worktree_path: &Path,
544 normalized_worktree_path: &Path,
545) -> Result<Vec<PathBuf>> {
546 files
547 .iter()
548 .map(|operation| {
549 let inspected_parent =
550 validate_target_parent_components(origin, operation, worktree_path)?;
551 let target_path = normalize_target_path(&operation.target_path).map_err(|source| {
552 file_plan_error(
553 origin,
554 Some(operation.declaration),
555 format!(
556 "failed to resolve target {}: {source}",
557 operation.target.display()
558 ),
559 )
560 })?;
561
562 if !inspected_parent && is_within(&target_path, normalized_worktree_path) {
563 return invalid_file_plan(
564 origin,
565 Some(operation.declaration),
566 format!(
567 "cannot create target for {}; target parent could not be inspected",
568 operation_label(operation),
569 ),
570 );
571 }
572
573 Ok(target_path)
574 })
575 .collect()
576}
577
578fn validate_target_parent_components(
579 origin: FilePlanOrigin<'_>,
580 operation: &FileOperation,
581 worktree_path: &Path,
582) -> Result<bool> {
583 let parent = operation.target_path.parent().unwrap_or(worktree_path);
584 let Some(anchor) = matching_target_anchor(parent, worktree_path) else {
585 return Ok(false);
586 };
587
588 match inspect_target_ancestors(parent, anchor.as_ref(), false) {
589 Ok(()) | Err(TargetAncestorIssue::OutsideWorktree { .. }) => Ok(true),
590 Err(TargetAncestorIssue::Symlink { path }) => invalid_file_plan(
591 origin,
592 Some(operation.declaration),
593 format!(
594 "cannot create target for {}; target parent {} is a symlink",
595 operation_label(operation),
596 path.display()
597 ),
598 ),
599 Err(TargetAncestorIssue::NotDirectory { path }) => invalid_file_plan(
600 origin,
601 Some(operation.declaration),
602 format!(
603 "cannot create target for {}; target parent {} is not a directory",
604 operation_label(operation),
605 path.display()
606 ),
607 ),
608 Err(TargetAncestorIssue::Io { path, source }) => Err(file_plan_error(
609 origin,
610 Some(operation.declaration),
611 format!(
612 "failed to inspect target parent {}: {source}",
613 path.display()
614 ),
615 )),
616 }
617}
618
619fn validate_target_conflicts(
620 origin: FilePlanOrigin<'_>,
621 files: &[FileOperation],
622 target_paths: &[PathBuf],
623) -> Result<()> {
624 validate_duplicate_targets(origin, files, target_paths)?;
625 validate_overlapping_targets(origin, files, target_paths)
626}
627
628fn validate_duplicate_targets(
629 origin: FilePlanOrigin<'_>,
630 files: &[FileOperation],
631 target_paths: &[PathBuf],
632) -> Result<()> {
633 let mut targets: BTreeMap<&Path, Vec<&FileOperation>> = BTreeMap::new();
634
635 for (operation, target_path) in files.iter().zip(target_paths) {
636 targets
637 .entry(target_path.as_path())
638 .or_default()
639 .push(operation);
640 }
641
642 let duplicates = targets
643 .into_iter()
644 .filter(|(_, operations)| operations.len() > 1)
645 .collect::<Vec<_>>();
646
647 if duplicates.is_empty() {
648 return Ok(());
649 }
650
651 let details = duplicates
652 .iter()
653 .flat_map(|(target, operations)| {
654 operations.iter().map(move |operation| {
655 format!(
656 "{}: {}",
657 target.display(),
658 operation_summary(origin, operation)
659 )
660 })
661 })
662 .collect::<Vec<_>>()
663 .join("; ");
664
665 let message = match origin {
666 FilePlanOrigin::Config(_) => format!("duplicate configured target: {details}"),
667 FilePlanOrigin::Manual { .. } => format!("duplicate target: {details}"),
668 };
669
670 Err(file_plan_error(origin, None, message))
671}
672
673fn validate_overlapping_targets(
674 origin: FilePlanOrigin<'_>,
675 files: &[FileOperation],
676 target_paths: &[PathBuf],
677) -> Result<()> {
678 let mut overlaps = Vec::new();
679
680 for (index, (operation, target_path)) in files.iter().zip(target_paths).enumerate() {
681 for (other_operation, other_target_path) in files.iter().zip(target_paths).skip(index + 1) {
682 if target_path == other_target_path {
683 continue;
684 }
685
686 let Some((ancestor_path, ancestor, descendant_path, descendant)) =
687 overlapping_targets(target_path, operation, other_target_path, other_operation)
688 else {
689 continue;
690 };
691
692 overlaps.push(format!(
693 "{} contains {}: {}; {}",
694 ancestor_path.display(),
695 descendant_path.display(),
696 operation_summary(origin, ancestor),
697 operation_summary(origin, descendant)
698 ));
699 }
700 }
701
702 if overlaps.is_empty() {
703 return Ok(());
704 }
705
706 let message = match origin {
707 FilePlanOrigin::Config(_) => {
708 format!("overlapping configured targets: {}", overlaps.join("; "))
709 }
710 FilePlanOrigin::Manual { .. } => format!("overlapping targets: {}", overlaps.join("; ")),
711 };
712
713 Err(file_plan_error(origin, None, message))
714}
715
716fn overlapping_targets<'a>(
717 target_path: &'a Path,
718 operation: &'a FileOperation,
719 other_target_path: &'a Path,
720 other_operation: &'a FileOperation,
721) -> Option<(&'a Path, &'a FileOperation, &'a Path, &'a FileOperation)> {
722 if other_target_path.starts_with(target_path) {
723 return Some((target_path, operation, other_target_path, other_operation));
724 }
725
726 if target_path.starts_with(other_target_path) {
727 return Some((other_target_path, other_operation, target_path, operation));
728 }
729
730 None
731}
732
733fn validate_strict_sync(
734 origin: FilePlanOrigin<'_>,
735 files: &[FileOperation],
736 strict: bool,
737) -> Result<()> {
738 if !strict {
739 return Ok(());
740 }
741
742 if let Some(operation) = files
743 .iter()
744 .find(|operation| operation.operation == FileOperationKind::Sync)
745 {
746 return invalid_file_plan(
747 origin,
748 Some(operation.declaration),
749 format!(
750 "strict mode cannot be used with sync file operation {}",
751 operation_summary(origin, operation)
752 ),
753 );
754 }
755
756 Ok(())
757}
758
759fn build_file_operations(
760 origin: FilePlanOrigin<'_>,
761 files: &[FileOperation],
762 options: ActionPlanOptions,
763 target_paths: &[PathBuf],
764 root_path: &Path,
765 worktree_path: &Path,
766) -> Result<Vec<PlannedFileOperation>> {
767 let mut planned = Vec::with_capacity(files.len());
768
769 for (operation, target_path) in files.iter().zip(target_paths) {
770 validate_target_boundary(origin, options, operation, target_path, worktree_path)?;
771
772 let source_path = normalize_maybe_existing(&operation.source_path).map_err(|source| {
773 file_plan_error(
774 origin,
775 Some(operation.declaration),
776 format!(
777 "failed to resolve source {}: {source}",
778 operation.source.display()
779 ),
780 )
781 })?;
782 validate_source_boundary(origin, options, operation, &source_path, root_path)?;
783 let ignore_rules = operation_ignore_rules(origin, operation, &source_path)?;
784
785 let status = match source_exists(origin, operation, source_path.as_path())? {
786 true => {
787 if matches!(
788 operation.operation,
789 FileOperationKind::Copy | FileOperationKind::Sync
790 ) {
791 validate_source_symlinks(
792 origin,
793 operation,
794 source_path.as_path(),
795 root_path,
796 ignore_rules.as_ref(),
797 )?;
798 }
799
800 PlannedFileStatus::Ready
801 }
802 false if operation.required => {
803 return invalid_file_plan(
804 origin,
805 Some(operation.declaration),
806 format!(
807 "required source does not exist for {}",
808 operation_summary(origin, operation)
809 ),
810 );
811 }
812 false => PlannedFileStatus::SkippedMissingSource,
813 };
814
815 planned.push(PlannedFileOperation {
816 operation: operation.operation,
817 source: operation.source.clone(),
818 target: operation.target.clone(),
819 source_path,
820 target_path: target_path.clone(),
821 required: operation.required,
822 compare: operation.compare,
823 delete: operation.delete,
824 symlinks: operation.symlinks,
825 ignore: operation.ignore.clone(),
826 ignore_metadata: operation.ignore_metadata.clone(),
827 status,
828 declaration: operation.declaration,
829 });
830 }
831
832 Ok(planned)
833}
834
835fn validate_target_boundary(
836 origin: FilePlanOrigin<'_>,
837 options: ActionPlanOptions,
838 operation: &FileOperation,
839 target_path: &Path,
840 worktree_path: &Path,
841) -> Result<()> {
842 if options.dangerously_allow_targets_outside_worktree {
843 return Ok(());
844 }
845
846 if !is_within(target_path, worktree_path) {
847 return invalid_file_plan(
848 origin,
849 Some(operation.declaration),
850 format!(
851 "target resolves outside worktree for {}",
852 operation_summary(origin, operation)
853 ),
854 );
855 }
856
857 Ok(())
858}
859
860fn validate_source_boundary(
861 origin: FilePlanOrigin<'_>,
862 options: ActionPlanOptions,
863 operation: &FileOperation,
864 source_path: &Path,
865 root_path: &Path,
866) -> Result<()> {
867 if options.dangerously_allow_sources_outside_root {
868 return Ok(());
869 }
870
871 if !is_within(source_path, root_path) {
872 return invalid_file_plan(
873 origin,
874 Some(operation.declaration),
875 format!(
876 "source resolves outside root for {}",
877 operation_summary(origin, operation)
878 ),
879 );
880 }
881
882 Ok(())
883}
884
885fn operation_ignore_rules(
886 origin: FilePlanOrigin<'_>,
887 operation: &FileOperation,
888 source_path: &Path,
889) -> Result<Option<PathIgnoreRules>> {
890 if !matches!(
891 operation.operation,
892 FileOperationKind::Copy | FileOperationKind::Sync
893 ) || operation.ignore.is_empty()
894 {
895 return Ok(None);
896 }
897
898 PathIgnoreRules::new(source_path, &operation.ignore)
899 .map(Some)
900 .map_err(|source| {
901 file_plan_error(
902 origin,
903 Some(operation.declaration),
904 format!(
905 "invalid ignore pattern for {}: {source}",
906 operation_summary(origin, operation)
907 ),
908 )
909 })
910}
911
912fn plan_commands(
913 path: &Path,
914 commands: &[CommandOperation],
915 context: &Worktree,
916 worktree_path: &Path,
917) -> Result<Vec<PlannedCommand>> {
918 let mut planned = Vec::with_capacity(commands.len());
919
920 for command in commands {
921 let cwd_path = command
922 .cwd_path
923 .as_ref()
924 .map_or_else(
925 || Ok(worktree_path.to_path_buf()),
926 |cwd_path| normalize_maybe_existing(cwd_path),
927 )
928 .map_err(|source| {
929 invalid_config_error(
930 path,
931 Some(command.declaration),
932 format!("failed to resolve command cwd: {source}"),
933 )
934 })?;
935
936 if !is_within(&cwd_path, worktree_path) {
937 return invalid_config(
938 path,
939 Some(command.declaration),
940 "command cwd resolves outside worktree",
941 );
942 }
943
944 for key in command.env.keys() {
945 if context.environment.contains_key(key) {
946 return invalid_config(
947 path,
948 Some(command.declaration),
949 format!("command env overrides treeboot-owned variable `{key}`"),
950 );
951 }
952 }
953
954 planned.push(PlannedCommand {
955 name: command.name.clone(),
956 command: command.command.clone(),
957 cwd: command.cwd.clone(),
958 cwd_path,
959 env: command.env.clone(),
960 allow_failure: command.allow_failure,
961 declaration: command.declaration,
962 });
963 }
964
965 Ok(planned)
966}
967
968fn validate_source_symlinks(
969 origin: FilePlanOrigin<'_>,
970 operation: &FileOperation,
971 source_path: &Path,
972 root_path: &Path,
973 ignore_rules: Option<&PathIgnoreRules>,
974) -> Result<()> {
975 validate_source_symlink_path(
976 origin,
977 operation,
978 source_path,
979 source_path,
980 root_path,
981 ignore_rules,
982 )
983}
984
985fn validate_source_symlink_path(
986 origin: FilePlanOrigin<'_>,
987 operation: &FileOperation,
988 source_root: &Path,
989 path: &Path,
990 root_path: &Path,
991 ignore_rules: Option<&PathIgnoreRules>,
992) -> Result<()> {
993 let metadata = std::fs::symlink_metadata(path).map_err(|source| {
994 file_plan_error(
995 origin,
996 Some(operation.declaration),
997 format!(
998 "failed to inspect source {}: {source}",
999 operation.source.display()
1000 ),
1001 )
1002 })?;
1003
1004 if metadata.file_type().is_symlink() {
1005 let target = normalize_existing(path).map_err(|source| {
1006 file_plan_error(
1007 origin,
1008 Some(operation.declaration),
1009 format!(
1010 "failed to resolve source symlink {}: {source}",
1011 path.display()
1012 ),
1013 )
1014 })?;
1015
1016 if !is_within(&target, root_path) {
1017 return invalid_file_plan(
1018 origin,
1019 Some(operation.declaration),
1020 format!(
1021 "copy or sync source contains unsafe symlink {}",
1022 path.display()
1023 ),
1024 );
1025 }
1026
1027 return Ok(());
1028 }
1029
1030 if !metadata.is_dir() {
1031 return Ok(());
1032 }
1033
1034 for entry in std::fs::read_dir(path).map_err(|source| {
1035 file_plan_error(
1036 origin,
1037 Some(operation.declaration),
1038 format!(
1039 "failed to inspect source directory {}: {source}",
1040 path.display()
1041 ),
1042 )
1043 })? {
1044 let entry = entry.map_err(|source| {
1045 file_plan_error(
1046 origin,
1047 Some(operation.declaration),
1048 format!(
1049 "failed to inspect source directory {}: {source}",
1050 path.display()
1051 ),
1052 )
1053 })?;
1054 let path = entry.path();
1055 let metadata = std::fs::symlink_metadata(&path).map_err(|source| {
1056 file_plan_error(
1057 origin,
1058 Some(operation.declaration),
1059 format!(
1060 "failed to inspect source directory {}: {source}",
1061 path.display()
1062 ),
1063 )
1064 })?;
1065
1066 if ignored_source_path(source_root, &path, &metadata, ignore_rules) {
1067 if metadata.is_dir()
1068 && ignore_rules
1069 .map(PathIgnoreRules::has_negation)
1070 .unwrap_or(false)
1071 {
1072 validate_source_symlink_path(
1073 origin,
1074 operation,
1075 source_root,
1076 &path,
1077 root_path,
1078 ignore_rules,
1079 )?;
1080 }
1081 continue;
1082 }
1083
1084 validate_source_symlink_path(
1085 origin,
1086 operation,
1087 source_root,
1088 &path,
1089 root_path,
1090 ignore_rules,
1091 )?;
1092 }
1093
1094 Ok(())
1095}
1096
1097fn ignored_source_path(
1098 source_root: &Path,
1099 path: &Path,
1100 metadata: &std::fs::Metadata,
1101 ignore_rules: Option<&PathIgnoreRules>,
1102) -> bool {
1103 ignore_rules
1104 .zip(path.strip_prefix(source_root).ok())
1105 .is_some_and(|(rules, relative)| rules.is_ignored(relative, metadata.is_dir()))
1106}
1107
1108fn source_exists(
1109 origin: FilePlanOrigin<'_>,
1110 operation: &FileOperation,
1111 source_path: &Path,
1112) -> Result<bool> {
1113 match std::fs::symlink_metadata(source_path) {
1114 Ok(_) => Ok(true),
1115 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
1116 Err(source) => Err(file_plan_error(
1117 origin,
1118 Some(operation.declaration),
1119 format!(
1120 "failed to inspect source {}: {source}",
1121 operation.source.display()
1122 ),
1123 )),
1124 }
1125}
1126
1127fn operation_summary(origin: FilePlanOrigin<'_>, operation: &FileOperation) -> String {
1128 let summary = operation_label(operation);
1129
1130 match origin {
1131 FilePlanOrigin::Config(_) => format!(
1132 "{} at line {}, column {}",
1133 summary, operation.declaration.line, operation.declaration.column
1134 ),
1135 FilePlanOrigin::Manual { .. } => summary,
1136 }
1137}
1138
1139fn operation_label(operation: &FileOperation) -> String {
1140 format!(
1141 "{} {} -> {}",
1142 operation.operation,
1143 operation.source.display(),
1144 operation.target.display()
1145 )
1146}
1147
1148fn invalid_config<T>(
1149 path: &Path,
1150 span: Option<SourceSpan>,
1151 message: impl Into<String>,
1152) -> Result<T> {
1153 Err(invalid_config_error(path, span, message))
1154}
1155
1156fn invalid_file_plan<T>(
1157 origin: FilePlanOrigin<'_>,
1158 span: Option<SourceSpan>,
1159 message: impl Into<String>,
1160) -> Result<T> {
1161 Err(file_plan_error(origin, span, message))
1162}
1163
1164fn invalid_config_error(
1165 path: &Path,
1166 span: Option<SourceSpan>,
1167 message: impl Into<String>,
1168) -> Error {
1169 let message = match span {
1170 Some(span) => format!(
1171 "{} at line {}, column {}",
1172 message.into(),
1173 span.line,
1174 span.column
1175 ),
1176 None => message.into(),
1177 };
1178
1179 Error::ConfigInvalid {
1180 path: path.to_path_buf(),
1181 message,
1182 }
1183}
1184
1185fn file_plan_error(
1186 origin: FilePlanOrigin<'_>,
1187 span: Option<SourceSpan>,
1188 message: impl Into<String>,
1189) -> Error {
1190 match origin {
1191 FilePlanOrigin::Config(path) => invalid_config_error(path, span, message),
1192 FilePlanOrigin::Manual { operation } => Error::FileOperationInvalid {
1193 operation: operation.as_str(),
1194 message: message.into(),
1195 },
1196 }
1197}
1198
1199fn normalize_existing(path: &Path) -> std::io::Result<PathBuf> {
1200 std::fs::canonicalize(path)
1201}
1202
1203fn normalize_maybe_existing(path: &Path) -> std::io::Result<PathBuf> {
1204 match normalize_existing(path) {
1205 Ok(path) => return Ok(path),
1206 Err(source) if source.kind() != std::io::ErrorKind::NotFound => {
1207 return Err(source);
1208 }
1209 Err(_) => {}
1210 }
1211
1212 let mut missing = Vec::new();
1213 let mut ancestor = path;
1214
1215 while !ancestor.exists() {
1216 if let Some(name) = ancestor.file_name() {
1217 missing.push(name.to_owned());
1218 }
1219
1220 let Some(parent) = ancestor.parent() else {
1221 break;
1222 };
1223 ancestor = parent;
1224 }
1225
1226 let mut normalized = if ancestor.exists() {
1227 normalize_existing(ancestor)?
1228 } else {
1229 PathBuf::new()
1230 };
1231
1232 for component in missing.iter().rev() {
1233 normalized.push(component);
1234 }
1235
1236 Ok(normalize_lexical(&normalized))
1237}
1238
1239fn normalize_target_path(path: &Path) -> std::io::Result<PathBuf> {
1240 let Some(name) = path.file_name() else {
1241 return normalize_maybe_existing(path);
1242 };
1243
1244 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1245 let mut normalized = normalize_maybe_existing(parent)?;
1246 normalized.push(name);
1247
1248 Ok(normalize_lexical(&normalized))
1249}
1250
1251fn normalize_lexical(path: &Path) -> PathBuf {
1252 let mut normalized = PathBuf::new();
1253
1254 for component in path.components() {
1255 match component {
1256 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
1257 Component::RootDir => normalized.push(component.as_os_str()),
1258 Component::CurDir => {}
1259 Component::ParentDir => {
1260 if !normalized.pop() && !normalized.has_root() {
1261 normalized.push(component.as_os_str());
1262 }
1263 }
1264 Component::Normal(part) => normalized.push(part),
1265 }
1266 }
1267
1268 normalized
1269}
1270
1271fn is_within(path: &Path, boundary: &Path) -> bool {
1272 path == boundary || path.starts_with(boundary)
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277 use std::collections::BTreeMap;
1278 use std::ffi::OsString;
1279 use std::time::{SystemTime, UNIX_EPOCH};
1280
1281 use super::*;
1282
1283 fn span() -> SourceSpan {
1284 SourceSpan {
1285 start: 0,
1286 end: 1,
1287 line: 1,
1288 column: 1,
1289 }
1290 }
1291
1292 fn temp_workspace(name: &str) -> (PathBuf, PathBuf) {
1293 let id = SystemTime::now()
1294 .duration_since(UNIX_EPOCH)
1295 .expect("clock should be after Unix epoch")
1296 .as_nanos();
1297 let base = std::env::temp_dir().join(format!("treeboot-{name}-{id}"));
1298 let root = base.join("root");
1299 let worktree = base.join("worktree");
1300
1301 std::fs::create_dir_all(&root).expect("root should be created");
1302 std::fs::create_dir_all(&worktree).expect("worktree should be created");
1303
1304 (root, worktree)
1305 }
1306
1307 #[cfg(unix)]
1308 fn aliased_workspace(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
1309 let (root, worktree) = temp_workspace(name);
1310 let base = root.parent().expect("root should have parent");
1311 let alias = base.join("alias");
1312 std::os::unix::fs::symlink(base, &alias).expect("workspace alias should be created");
1313
1314 let alias_root = alias.join("root");
1315 let alias_worktree = alias.join("worktree");
1316
1317 (root, worktree, alias_root, alias_worktree)
1318 }
1319
1320 fn context(root_path: &Path, worktree_path: &Path) -> Worktree {
1321 Worktree {
1322 root_path: root_path.to_path_buf(),
1323 worktree_path: worktree_path.to_path_buf(),
1324 default_branch: "main".to_owned(),
1325 environment: BTreeMap::from([(
1326 "TREEBOOT_ROOT_PATH".to_owned(),
1327 OsString::from(root_path),
1328 )]),
1329 }
1330 }
1331
1332 fn empty_config() -> Config {
1333 Config {
1334 options: Default::default(),
1335 files: Vec::new(),
1336 commands: Vec::new(),
1337 }
1338 }
1339
1340 fn file_operation(
1341 operation: FileOperationKind,
1342 root: &Path,
1343 worktree: &Path,
1344 source: &str,
1345 target: &str,
1346 ) -> FileOperation {
1347 FileOperation {
1348 operation,
1349 source: PathBuf::from(source),
1350 target: PathBuf::from(target),
1351 source_path: root.join(source),
1352 target_path: worktree.join(target),
1353 required: false,
1354 compare: match operation {
1355 FileOperationKind::Sync => Some(SyncCompare::Metadata),
1356 FileOperationKind::Copy | FileOperationKind::Symlink => None,
1357 },
1358 delete: match operation {
1359 FileOperationKind::Sync => Some(false),
1360 FileOperationKind::Copy | FileOperationKind::Symlink => None,
1361 },
1362 symlinks: match operation {
1363 FileOperationKind::Copy | FileOperationKind::Sync => Some(SymlinkMode::Preserve),
1364 FileOperationKind::Symlink => None,
1365 },
1366 ignore: Vec::new(),
1367 ignore_metadata: Vec::new(),
1368 declaration: span(),
1369 }
1370 }
1371
1372 fn plan(config: &Config, root: &Path, worktree: &Path) -> Result<ActionPlan> {
1373 ActionPlan::from_manifest(
1374 Path::new(".treeboot.toml"),
1375 config,
1376 &context(root, worktree),
1377 ActionPlanOptions::default(),
1378 )
1379 }
1380
1381 #[test]
1382 fn normalize_lexical_should_resolve_parent_components() {
1383 assert_eq!(
1384 normalize_lexical(Path::new("/repo/worktree/../outside")),
1385 PathBuf::from("/repo/outside")
1386 );
1387 }
1388
1389 #[test]
1390 fn is_within_should_not_match_partial_component_prefixes() {
1391 assert!(!is_within(
1392 Path::new("/repo-worktree-other/file"),
1393 Path::new("/repo-worktree")
1394 ));
1395 }
1396
1397 #[test]
1398 fn action_plan_from_manifest_should_mark_optional_missing_sources_skipped() {
1399 let (root, worktree) = temp_workspace("missing-source");
1400 let config = Config {
1401 options: Default::default(),
1402 files: vec![FileOperation {
1403 operation: FileOperationKind::Copy,
1404 source: PathBuf::from("missing"),
1405 target: PathBuf::from("missing"),
1406 source_path: root.join("missing"),
1407 target_path: worktree.join("missing"),
1408 required: false,
1409 compare: None,
1410 delete: None,
1411 symlinks: Some(SymlinkMode::Preserve),
1412 ignore: Vec::new(),
1413 ignore_metadata: Vec::new(),
1414 declaration: span(),
1415 }],
1416 commands: Vec::new(),
1417 };
1418
1419 let plan = ActionPlan::from_manifest(
1420 Path::new(".treeboot.toml"),
1421 &config,
1422 &context(&root, &worktree),
1423 ActionPlanOptions::default(),
1424 )
1425 .expect("optional missing source should plan");
1426
1427 assert_eq!(
1428 plan.files[0].status,
1429 PlannedFileStatus::SkippedMissingSource
1430 );
1431 }
1432
1433 #[test]
1434 fn action_plan_from_manifest_should_build_ready_file_operation() {
1435 let (root, worktree) = temp_workspace("ready-file");
1436 std::fs::write(root.join(".env"), "TOKEN=1\n").expect("source should be written");
1437 let config = Config {
1438 options: Default::default(),
1439 files: vec![file_operation(
1440 FileOperationKind::Copy,
1441 &root,
1442 &worktree,
1443 ".env",
1444 ".env",
1445 )],
1446 commands: Vec::new(),
1447 };
1448
1449 let plan = plan(&config, &root, &worktree).expect("file should plan");
1450
1451 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1452 }
1453
1454 #[test]
1455 fn action_plan_from_manifest_should_reject_overlapping_file_targets() {
1456 let (root, worktree) = temp_workspace("overlapping-targets");
1457 let mut sync = file_operation(
1458 FileOperationKind::Sync,
1459 &root,
1460 &worktree,
1461 "shared",
1462 "shared",
1463 );
1464 sync.delete = Some(true);
1465 let config = Config {
1466 options: Default::default(),
1467 files: vec![
1468 file_operation(
1469 FileOperationKind::Copy,
1470 &root,
1471 &worktree,
1472 "child",
1473 "shared/child",
1474 ),
1475 sync,
1476 ],
1477 commands: Vec::new(),
1478 };
1479
1480 let error = plan(&config, &root, &worktree).expect_err("overlapping targets should fail");
1481
1482 assert!(error.to_string().contains("overlapping configured targets"));
1483 assert!(error.to_string().contains("shared"));
1484 assert!(error.to_string().contains("shared/child"));
1485 }
1486
1487 #[test]
1488 fn action_plan_from_manual_operations_should_reject_overlapping_targets() {
1489 let (root, worktree) = temp_workspace("manual-overlapping-targets");
1490 let mut sync = file_operation(
1491 FileOperationKind::Sync,
1492 &root,
1493 &worktree,
1494 "shared",
1495 "shared",
1496 );
1497 sync.delete = Some(true);
1498 let operations = vec![
1499 sync,
1500 file_operation(
1501 FileOperationKind::Sync,
1502 &root,
1503 &worktree,
1504 "shared/nested",
1505 "shared/nested",
1506 ),
1507 ];
1508
1509 let error = ActionPlan::from_file_operations(
1510 &context(&root, &worktree),
1511 PlanOrigin::Manual {
1512 operation: FileOperationKind::Sync,
1513 },
1514 &operations,
1515 ActionPlanOptions::default(),
1516 )
1517 .expect_err("overlapping targets should fail");
1518
1519 assert!(error.to_string().contains("invalid sync file operation"));
1520 assert!(error.to_string().contains("overlapping targets"));
1521 }
1522
1523 #[test]
1524 fn action_plan_from_manifest_should_build_command_metadata() {
1525 let (root, worktree) = temp_workspace("command-metadata");
1526 let app_dir = worktree.join("app");
1527 std::fs::create_dir_all(&app_dir).expect("command cwd should be created");
1528 let config = Config {
1529 options: Default::default(),
1530 files: Vec::new(),
1531 commands: vec![CommandOperation {
1532 name: Some("Install".to_owned()),
1533 command: CommandKind::Direct {
1534 program: "npm".to_owned(),
1535 args: vec!["install".to_owned()],
1536 },
1537 cwd: Some(PathBuf::from("app")),
1538 cwd_path: Some(app_dir.clone()),
1539 env: BTreeMap::from([("NODE_ENV".to_owned(), "development".to_owned())]),
1540 allow_failure: true,
1541 declaration: span(),
1542 }],
1543 };
1544
1545 let plan = plan(&config, &root, &worktree).expect("command should plan");
1546
1547 assert_eq!(
1548 plan.commands[0].cwd_path,
1549 std::fs::canonicalize(app_dir).expect("app dir should canonicalize")
1550 );
1551 assert!(plan.commands[0].allow_failure);
1552 }
1553
1554 #[test]
1555 fn action_plan_from_manifest_should_allow_explicit_boundary_escapes() {
1556 let (root, worktree) = temp_workspace("boundary-escapes");
1557 let outside_source = root
1558 .parent()
1559 .expect("root should have parent")
1560 .join("outside-source");
1561 let outside_target = worktree
1562 .parent()
1563 .expect("worktree should have parent")
1564 .join("outside-target");
1565 std::fs::write(&outside_source, "shared\n").expect("outside source should be written");
1566 let config = Config {
1567 options: Default::default(),
1568 files: vec![FileOperation {
1569 operation: FileOperationKind::Copy,
1570 source: outside_source.clone(),
1571 target: outside_target.clone(),
1572 source_path: outside_source,
1573 target_path: outside_target,
1574 required: false,
1575 compare: None,
1576 delete: None,
1577 symlinks: Some(SymlinkMode::Preserve),
1578 ignore: Vec::new(),
1579 ignore_metadata: Vec::new(),
1580 declaration: span(),
1581 }],
1582 commands: Vec::new(),
1583 };
1584
1585 let plan = ActionPlan::from_manifest(
1586 Path::new(".treeboot.toml"),
1587 &config,
1588 &context(&root, &worktree),
1589 ActionPlanOptions {
1590 dangerously_allow_sources_outside_root: true,
1591 dangerously_allow_targets_outside_worktree: true,
1592 ..ActionPlanOptions::default()
1593 },
1594 )
1595 .expect("escaped paths should plan");
1596
1597 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1598 }
1599
1600 #[test]
1601 fn action_plan_from_manifest_should_allow_missing_target_parents_for_all_file_operations() {
1602 for operation in [
1603 FileOperationKind::Copy,
1604 FileOperationKind::Symlink,
1605 FileOperationKind::Sync,
1606 ] {
1607 let (root, worktree) = temp_workspace(&format!("missing-target-parent-{operation}"));
1608 std::fs::write(root.join("source"), "value\n").expect("source should be written");
1609 let config = Config {
1610 options: Default::default(),
1611 files: vec![file_operation(
1612 operation,
1613 &root,
1614 &worktree,
1615 "source",
1616 "nested/config/source",
1617 )],
1618 commands: Vec::new(),
1619 };
1620
1621 let plan = plan(&config, &root, &worktree)
1622 .unwrap_or_else(|error| panic!("{operation} should plan: {error}"));
1623
1624 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1625 }
1626 }
1627
1628 #[cfg(unix)]
1629 #[test]
1630 fn action_plan_from_manifest_should_allow_final_symlink_target_to_root_source() {
1631 let (root, worktree) = temp_workspace("final-symlink-target-to-root");
1632 let source = root.join("config/master.key");
1633 let target = worktree.join("config/master.key");
1634 std::fs::create_dir_all(source.parent().unwrap()).expect("source parent should exist");
1635 std::fs::create_dir_all(target.parent().unwrap()).expect("target parent should exist");
1636 std::fs::write(&source, "secret\n").expect("source should be written");
1637 std::os::unix::fs::symlink(&source, &target).expect("target symlink should be created");
1638 let config = Config {
1639 options: Default::default(),
1640 files: vec![file_operation(
1641 FileOperationKind::Symlink,
1642 &root,
1643 &worktree,
1644 "config/master.key",
1645 "config/master.key",
1646 )],
1647 commands: Vec::new(),
1648 };
1649
1650 let plan = plan(&config, &root, &worktree)
1651 .expect("final target symlink to root source should plan");
1652
1653 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1654 assert_eq!(
1655 plan.files[0].target_path,
1656 normalize_target_path(&target).expect("target should normalize")
1657 );
1658 }
1659
1660 #[cfg(unix)]
1661 #[test]
1662 fn action_plan_from_manifest_should_reject_target_parent_symlink_for_all_file_operations() {
1663 for operation in [
1664 FileOperationKind::Copy,
1665 FileOperationKind::Symlink,
1666 FileOperationKind::Sync,
1667 ] {
1668 let (root, worktree) = temp_workspace(&format!("target-parent-symlink-{operation}"));
1669 let linked = root.join("config");
1670 std::fs::create_dir_all(&linked).expect("linked directory should be created");
1671 std::fs::write(root.join("source"), "value\n").expect("source should be written");
1672 std::os::unix::fs::symlink(&linked, worktree.join("config"))
1673 .expect("target parent symlink should be created");
1674 let config = Config {
1675 options: Default::default(),
1676 files: vec![file_operation(
1677 operation,
1678 &root,
1679 &worktree,
1680 "source",
1681 "config/source",
1682 )],
1683 commands: Vec::new(),
1684 };
1685
1686 let error = match plan(&config, &root, &worktree) {
1687 Ok(_) => panic!("{operation} should reject symlink parent"),
1688 Err(error) => error,
1689 };
1690 let message = error.to_string();
1691 assert!(
1692 message.contains(&format!("cannot create target for {operation}")),
1693 "{operation} error should name operation: {message}"
1694 );
1695 assert!(
1696 message.contains("target parent") && message.contains("is a symlink"),
1697 "{operation} error should describe symlink parent: {message}"
1698 );
1699 }
1700 }
1701
1702 #[test]
1703 fn action_plan_from_manifest_should_reject_target_parent_file_for_all_file_operations() {
1704 for operation in [
1705 FileOperationKind::Copy,
1706 FileOperationKind::Symlink,
1707 FileOperationKind::Sync,
1708 ] {
1709 let (root, worktree) = temp_workspace(&format!("target-parent-file-{operation}"));
1710 std::fs::write(root.join("source"), "value\n").expect("source should be written");
1711 std::fs::write(worktree.join("config"), "not a directory\n")
1712 .expect("target parent file should be written");
1713 let config = Config {
1714 options: Default::default(),
1715 files: vec![file_operation(
1716 operation,
1717 &root,
1718 &worktree,
1719 "source",
1720 "config/source",
1721 )],
1722 commands: Vec::new(),
1723 };
1724
1725 let error = match plan(&config, &root, &worktree) {
1726 Ok(_) => panic!("{operation} should reject file parent"),
1727 Err(error) => error,
1728 };
1729 let message = error.to_string();
1730 assert!(
1731 message.contains(&format!("cannot create target for {operation}")),
1732 "{operation} error should name operation: {message}"
1733 );
1734 assert!(
1735 message.contains("target parent") && message.contains("is not a directory"),
1736 "{operation} error should describe file parent: {message}"
1737 );
1738 }
1739 }
1740
1741 #[cfg(unix)]
1742 #[test]
1743 fn action_plan_from_manifest_should_reject_target_parent_file_with_worktree_alias() {
1744 let (_root, _worktree, alias_root, alias_worktree) =
1745 aliased_workspace("target-parent-file-alias");
1746 std::fs::write(alias_root.join("source"), "value\n").expect("source should be written");
1747 std::fs::write(alias_worktree.join("config"), "not a directory\n")
1748 .expect("target parent file should be written");
1749 let config = Config {
1750 options: Default::default(),
1751 files: vec![file_operation(
1752 FileOperationKind::Copy,
1753 &alias_root,
1754 &alias_worktree,
1755 "source",
1756 "config/source",
1757 )],
1758 commands: Vec::new(),
1759 };
1760
1761 let error = plan(&config, &alias_root, &alias_worktree)
1762 .expect_err("aliased worktree should still reject file parent");
1763
1764 assert!(error.to_string().contains("target parent"));
1765 assert!(error.to_string().contains("is not a directory"));
1766 }
1767
1768 #[cfg(unix)]
1769 #[test]
1770 fn action_plan_from_manifest_should_reject_target_parent_symlink_with_worktree_alias() {
1771 let (root, _worktree, alias_root, alias_worktree) =
1772 aliased_workspace("target-parent-symlink-alias");
1773 let linked = root.join("config");
1774 std::fs::create_dir_all(&linked).expect("linked directory should be created");
1775 std::fs::write(alias_root.join("source"), "value\n").expect("source should be written");
1776 std::os::unix::fs::symlink(&linked, alias_worktree.join("config"))
1777 .expect("target parent symlink should be created");
1778 let config = Config {
1779 options: Default::default(),
1780 files: vec![file_operation(
1781 FileOperationKind::Copy,
1782 &alias_root,
1783 &alias_worktree,
1784 "source",
1785 "config/source",
1786 )],
1787 commands: Vec::new(),
1788 };
1789
1790 let error = plan(&config, &alias_root, &alias_worktree)
1791 .expect_err("aliased worktree should still reject symlink parent");
1792
1793 assert!(error.to_string().contains("target parent"));
1794 assert!(error.to_string().contains("is a symlink"));
1795 }
1796
1797 #[cfg(unix)]
1798 #[test]
1799 fn action_plan_from_manifest_should_reject_canonical_absolute_target_parent_symlink() {
1800 let (root, worktree, alias_root, alias_worktree) =
1801 aliased_workspace("absolute-target-parent-symlink");
1802 let linked = root.join("config");
1803 std::fs::create_dir_all(&linked).expect("linked directory should be created");
1804 std::fs::write(alias_root.join("source"), "value\n").expect("source should be written");
1805 std::os::unix::fs::symlink(&linked, worktree.join("config"))
1806 .expect("target parent symlink should be created");
1807 let target_path = std::fs::canonicalize(&worktree)
1808 .expect("worktree should canonicalize")
1809 .join("config/source");
1810 let mut operation = file_operation(
1811 FileOperationKind::Copy,
1812 &alias_root,
1813 &alias_worktree,
1814 "source",
1815 "config/source",
1816 );
1817 operation.target = target_path.clone();
1818 operation.target_path = target_path;
1819 let config = Config {
1820 options: Default::default(),
1821 files: vec![operation],
1822 commands: Vec::new(),
1823 };
1824
1825 let error = plan(&config, &alias_root, &alias_worktree)
1826 .expect_err("canonical absolute target should reject symlink parent");
1827
1828 assert!(error.to_string().contains("target parent"));
1829 assert!(error.to_string().contains("is a symlink"));
1830 }
1831
1832 #[cfg(unix)]
1833 #[test]
1834 fn action_plan_from_manifest_should_reject_absolute_alias_target_parent_symlink() {
1835 let (root, worktree, _alias_root, alias_worktree) =
1836 aliased_workspace("absolute-alias-target-parent-symlink");
1837 let linked = worktree.join("real-config");
1838 std::fs::create_dir_all(&linked).expect("linked directory should be created");
1839 std::fs::write(root.join("source"), "value\n").expect("source should be written");
1840 std::os::unix::fs::symlink(&linked, worktree.join("config"))
1841 .expect("target parent symlink should be created");
1842 let target_path = alias_worktree.join("config/source");
1843 let mut operation = file_operation(
1844 FileOperationKind::Copy,
1845 &root,
1846 &worktree,
1847 "source",
1848 "config/source",
1849 );
1850 operation.target = target_path.clone();
1851 operation.target_path = target_path;
1852 let config = Config {
1853 options: Default::default(),
1854 files: vec![operation],
1855 commands: Vec::new(),
1856 };
1857
1858 let error = plan(&config, &root, &worktree)
1859 .expect_err("absolute alias target should reject symlink parent");
1860
1861 assert!(error.to_string().contains("target parent"));
1862 assert!(error.to_string().contains("is a symlink"));
1863 }
1864
1865 #[cfg(unix)]
1866 #[test]
1867 fn action_plan_from_manifest_should_keep_input_context_for_worktree_alias() {
1868 let (_root, _worktree, alias_root, alias_worktree) = aliased_workspace("context-alias");
1869 let plan = ActionPlan::from_manifest(
1870 Path::new(".treeboot.toml"),
1871 &empty_config(),
1872 &context(&alias_root, &alias_worktree),
1873 ActionPlanOptions::default(),
1874 )
1875 .expect("empty plan should build");
1876
1877 assert_eq!(plan.context().root_path, alias_root);
1878 assert_eq!(plan.context().worktree_path, alias_worktree);
1879 }
1880
1881 #[test]
1882 fn action_plan_from_manifest_should_reject_missing_root_path() {
1883 let (_root, worktree) = temp_workspace("missing-root");
1884 let missing_root = worktree.join("missing-root");
1885 let error = ActionPlan::from_manifest(
1886 Path::new(".treeboot.toml"),
1887 &empty_config(),
1888 &context(&missing_root, &worktree),
1889 ActionPlanOptions::default(),
1890 )
1891 .expect_err("missing root should fail");
1892
1893 assert!(error.to_string().contains("failed to resolve root path"));
1894 }
1895
1896 #[test]
1897 fn action_plan_from_manifest_should_reject_missing_worktree_path() {
1898 let (root, worktree) = temp_workspace("missing-worktree");
1899 let missing_worktree = worktree.join("missing-worktree");
1900 let error = ActionPlan::from_manifest(
1901 Path::new(".treeboot.toml"),
1902 &empty_config(),
1903 &context(&root, &missing_worktree),
1904 ActionPlanOptions::default(),
1905 )
1906 .expect_err("missing worktree should fail");
1907
1908 assert!(
1909 error
1910 .to_string()
1911 .contains("failed to resolve worktree path")
1912 );
1913 }
1914
1915 #[test]
1916 fn action_plan_from_manifest_should_allow_strict_when_no_sync_exists() {
1917 let (root, worktree) = temp_workspace("strict-no-sync");
1918
1919 let plan = ActionPlan::from_manifest(
1920 Path::new(".treeboot.toml"),
1921 &empty_config(),
1922 &context(&root, &worktree),
1923 ActionPlanOptions {
1924 strict: true,
1925 ..ActionPlanOptions::default()
1926 },
1927 )
1928 .expect("strict mode should allow configs without sync");
1929
1930 assert!(plan.files.is_empty());
1931 }
1932
1933 #[test]
1934 fn action_plan_from_manifest_should_walk_source_directories() {
1935 let (root, worktree) = temp_workspace("source-directory");
1936 let source_dir = root.join("shared");
1937 std::fs::create_dir_all(&source_dir).expect("source dir should be created");
1938 std::fs::write(source_dir.join("config"), "value\n").expect("nested source should exist");
1939 let config = Config {
1940 options: Default::default(),
1941 files: vec![file_operation(
1942 FileOperationKind::Copy,
1943 &root,
1944 &worktree,
1945 "shared",
1946 "shared",
1947 )],
1948 commands: Vec::new(),
1949 };
1950
1951 let plan = plan(&config, &root, &worktree).expect("directory source should plan");
1952
1953 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1954 }
1955
1956 #[test]
1957 fn action_plan_from_manifest_should_preserve_sync_options() {
1958 let (root, worktree) = temp_workspace("sync-options");
1959 let source_dir = root.join("shared");
1960 std::fs::create_dir_all(&source_dir).expect("source dir should be created");
1961 let mut operation = file_operation(
1962 FileOperationKind::Sync,
1963 &root,
1964 &worktree,
1965 "shared",
1966 "shared",
1967 );
1968 operation.delete = Some(true);
1969
1970 let config = Config {
1971 options: Default::default(),
1972 files: vec![operation],
1973 commands: Vec::new(),
1974 };
1975
1976 let plan = plan(&config, &root, &worktree).expect("sync should plan");
1977
1978 assert_eq!(plan.files[0].compare, Some(SyncCompare::Metadata));
1979 assert_eq!(plan.files[0].delete, Some(true));
1980 assert_eq!(plan.files[0].symlinks, Some(SymlinkMode::Preserve));
1981 }
1982
1983 #[cfg(unix)]
1984 #[test]
1985 fn action_plan_from_manifest_should_allow_safe_source_symlink() {
1986 let (root, worktree) = temp_workspace("safe-symlink");
1987 std::fs::write(root.join("source"), "value\n").expect("source should be written");
1988 std::os::unix::fs::symlink(root.join("source"), root.join("link"))
1989 .expect("safe source symlink should be created");
1990 let config = Config {
1991 options: Default::default(),
1992 files: vec![file_operation(
1993 FileOperationKind::Copy,
1994 &root,
1995 &worktree,
1996 "link",
1997 "link",
1998 )],
1999 commands: Vec::new(),
2000 };
2001
2002 let plan = plan(&config, &root, &worktree).expect("safe symlink should plan");
2003
2004 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
2005 }
2006
2007 #[cfg(unix)]
2008 #[test]
2009 fn action_plan_from_manifest_should_reject_broken_source_symlink() {
2010 let (root, worktree) = temp_workspace("broken-symlink");
2011 std::os::unix::fs::symlink(root.join("missing"), root.join("link"))
2012 .expect("broken source symlink should be created");
2013 let config = Config {
2014 options: Default::default(),
2015 files: vec![file_operation(
2016 FileOperationKind::Copy,
2017 &root,
2018 &worktree,
2019 "link",
2020 "link",
2021 )],
2022 commands: Vec::new(),
2023 };
2024
2025 let error = plan(&config, &root, &worktree).expect_err("broken symlink should fail");
2026
2027 assert!(
2028 error
2029 .to_string()
2030 .contains("failed to resolve source symlink")
2031 );
2032 }
2033
2034 #[test]
2035 fn action_plan_from_manifest_should_default_command_cwd_to_worktree() {
2036 let (root, worktree) = temp_workspace("command-cwd");
2037 let config = Config {
2038 options: Default::default(),
2039 files: Vec::new(),
2040 commands: vec![CommandOperation {
2041 name: None,
2042 command: CommandKind::Shell {
2043 run: "pwd".to_owned(),
2044 },
2045 cwd: None,
2046 cwd_path: None,
2047 env: BTreeMap::new(),
2048 allow_failure: false,
2049 declaration: span(),
2050 }],
2051 };
2052
2053 let plan = ActionPlan::from_manifest(
2054 Path::new(".treeboot.toml"),
2055 &config,
2056 &context(&root, &worktree),
2057 ActionPlanOptions::default(),
2058 )
2059 .expect("command should plan");
2060
2061 assert_eq!(
2062 plan.commands[0].cwd_path,
2063 std::fs::canonicalize(worktree).expect("worktree should canonicalize")
2064 );
2065 }
2066}