1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use std::fmt;
5
6use crate::config::include_with_delete_message;
7use crate::file_system::{TargetAncestorIssue, inspect_target_ancestors, matching_target_anchor};
8use crate::path_filter::{
9 PathIgnoreRules, PathIncludeRules, include_matches_any_source_path, invalid_include_pattern,
10};
11use crate::paths;
12use crate::{
13 CommandKind, CommandOperation, Config, ConfigRuntimeOptions, Error, FileOperation,
14 FileOperationKind, MetadataField, Result, SourceSpan, SymlinkMode, SyncCompare, Worktree,
15};
16
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
19pub struct ActionPlanOptions {
20 pub strict: bool,
22 pub dangerously_allow_sources_outside_root: bool,
24 pub dangerously_allow_targets_outside_worktree: bool,
26}
27
28impl From<ConfigRuntimeOptions> for ActionPlanOptions {
29 fn from(options: ConfigRuntimeOptions) -> Self {
30 Self {
31 strict: options.strict,
32 dangerously_allow_sources_outside_root: options.dangerously_allow_sources_outside_root,
33 dangerously_allow_targets_outside_worktree: options
34 .dangerously_allow_targets_outside_worktree,
35 }
36 }
37}
38
39#[derive(Debug, Clone, Copy)]
40pub(super) enum FilePlanOrigin<'a> {
41 Config(&'a Path),
42 Manual { operation: FileOperationKind },
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum PlanOrigin {
48 Manifest {
50 path: PathBuf,
52 },
53 Manual {
55 operation: FileOperationKind,
57 },
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct ActionPlan {
87 context: Worktree,
89 origin: PlanOrigin,
91 config_path: Option<PathBuf>,
93 files: Vec<PlannedFileOperation>,
95 commands: Vec<PlannedCommand>,
97 warnings: Vec<PlanWarning>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
106#[non_exhaustive]
107pub enum PlanWarning {
108 IncludeMatchedNothing {
111 operation: FileOperationKind,
113 source: PathBuf,
115 target: PathBuf,
117 },
118}
119
120impl fmt::Display for PlanWarning {
121 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122 match self {
123 Self::IncludeMatchedNothing {
124 operation,
125 source,
126 target,
127 } => write!(
128 formatter,
129 "include patterns match no source paths for {} {} -> {}",
130 operation,
131 source.display(),
132 target.display()
133 ),
134 }
135 }
136}
137
138impl ActionPlan {
139 #[must_use]
141 pub const fn context(&self) -> &Worktree {
142 &self.context
143 }
144
145 #[must_use]
147 pub fn warnings(&self) -> &[PlanWarning] {
148 &self.warnings
149 }
150
151 #[must_use]
153 pub const fn origin(&self) -> &PlanOrigin {
154 &self.origin
155 }
156
157 #[must_use]
159 pub fn config_path(&self) -> Option<&Path> {
160 self.config_path.as_deref()
161 }
162
163 #[must_use]
165 pub fn files(&self) -> &[PlannedFileOperation] {
166 &self.files
167 }
168
169 #[must_use]
171 pub fn commands(&self) -> &[PlannedCommand] {
172 &self.commands
173 }
174
175 pub fn from_manifest(
185 path: &Path,
186 manifest: &Config,
187 context: &Worktree,
188 options: ActionPlanOptions,
189 ) -> Result<Self> {
190 let worktree_path = normalize_existing(&context.worktree_path).map_err(|source| {
191 invalid_config_error(
192 path,
193 None,
194 format!("failed to resolve worktree path: {source}"),
195 )
196 })?;
197 let (files, warnings) = plan_file_operations(
198 FilePlanOrigin::Config(path),
199 &manifest.files,
200 context,
201 options,
202 )?;
203 let commands = plan_commands(path, &manifest.commands, context, worktree_path.as_path())?;
204
205 Ok(Self {
206 context: context.clone(),
207 origin: PlanOrigin::Manifest {
208 path: path.to_path_buf(),
209 },
210 config_path: Some(path.to_path_buf()),
211 files,
212 commands,
213 warnings,
214 })
215 }
216
217 pub fn from_file_operations(
226 context: &Worktree,
227 origin: PlanOrigin,
228 files: &[FileOperation],
229 options: ActionPlanOptions,
230 ) -> Result<Self> {
231 let file_origin = match &origin {
232 PlanOrigin::Manifest { path } => FilePlanOrigin::Config(path),
233 PlanOrigin::Manual { operation } => FilePlanOrigin::Manual {
234 operation: *operation,
235 },
236 };
237 let (files, warnings) = plan_file_operations(file_origin, files, context, options)?;
238 let config_path = match &origin {
239 PlanOrigin::Manifest { path } => Some(path.clone()),
240 PlanOrigin::Manual { .. } => None,
241 };
242
243 Ok(Self {
244 context: context.clone(),
245 origin,
246 config_path,
247 files,
248 commands: Vec::new(),
249 warnings,
250 })
251 }
252
253 #[cfg(test)]
254 pub(crate) fn from_parts_unchecked(
255 context: Worktree,
256 origin: PlanOrigin,
257 config_path: Option<PathBuf>,
258 files: Vec<PlannedFileOperation>,
259 commands: Vec<PlannedCommand>,
260 ) -> Self {
261 Self {
262 context,
263 origin,
264 config_path,
265 files,
266 commands,
267 warnings: Vec::new(),
268 }
269 }
270}
271
272#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct PlannedFileOperation {
282 operation: FileOperationKind,
284 source: PathBuf,
286 target: PathBuf,
288 source_path: PathBuf,
290 target_path: PathBuf,
292 required: bool,
294 compare: Option<SyncCompare>,
296 delete: Option<bool>,
298 symlinks: Option<SymlinkMode>,
300 include: Vec<String>,
302 ignore: Vec<String>,
304 ignore_metadata: Vec<MetadataField>,
306 status: PlannedFileStatus,
308 declaration: SourceSpan,
310}
311
312impl PlannedFileOperation {
313 #[must_use]
315 pub const fn operation(&self) -> FileOperationKind {
316 self.operation
317 }
318
319 #[must_use]
321 pub fn source(&self) -> &Path {
322 &self.source
323 }
324
325 #[must_use]
327 pub fn target(&self) -> &Path {
328 &self.target
329 }
330
331 #[must_use]
333 pub fn source_path(&self) -> &Path {
334 &self.source_path
335 }
336
337 #[must_use]
339 pub fn target_path(&self) -> &Path {
340 &self.target_path
341 }
342
343 #[must_use]
345 pub const fn required(&self) -> bool {
346 self.required
347 }
348
349 #[must_use]
351 pub const fn compare(&self) -> Option<SyncCompare> {
352 self.compare
353 }
354
355 #[must_use]
357 pub const fn delete(&self) -> Option<bool> {
358 self.delete
359 }
360
361 #[must_use]
363 pub const fn symlinks(&self) -> Option<SymlinkMode> {
364 self.symlinks
365 }
366
367 #[must_use]
370 pub fn include(&self) -> &[String] {
371 &self.include
372 }
373
374 #[must_use]
376 pub fn ignore(&self) -> &[String] {
377 &self.ignore
378 }
379
380 #[must_use]
382 pub fn ignore_metadata(&self) -> &[MetadataField] {
383 &self.ignore_metadata
384 }
385
386 #[must_use]
388 pub const fn status(&self) -> PlannedFileStatus {
389 self.status
390 }
391
392 #[must_use]
394 pub const fn declaration(&self) -> SourceSpan {
395 self.declaration
396 }
397
398 #[cfg(test)]
399 pub(crate) fn from_raw_parts_unchecked(parts: PlannedFileOperationParts) -> Self {
400 Self {
401 operation: parts.operation,
402 source: parts.source,
403 target: parts.target,
404 source_path: parts.source_path,
405 target_path: parts.target_path,
406 required: parts.required,
407 compare: parts.compare,
408 delete: parts.delete,
409 symlinks: parts.symlinks,
410 include: parts.include,
411 ignore: parts.ignore,
412 ignore_metadata: parts.ignore_metadata,
413 status: parts.status,
414 declaration: parts.declaration,
415 }
416 }
417
418 #[cfg(test)]
419 pub(crate) const fn with_compare(mut self, compare: Option<SyncCompare>) -> Self {
420 self.compare = compare;
421 self
422 }
423
424 #[cfg(test)]
425 pub(crate) const fn with_delete(mut self, delete: Option<bool>) -> Self {
426 self.delete = delete;
427 self
428 }
429
430 #[cfg(test)]
431 pub(crate) fn with_include(mut self, include: Vec<String>) -> Self {
432 self.include = include;
433 self
434 }
435
436 #[cfg(test)]
437 pub(crate) fn with_ignore(mut self, ignore: Vec<String>) -> Self {
438 self.ignore = ignore;
439 self
440 }
441
442 #[cfg(test)]
443 pub(crate) fn with_ignore_metadata(mut self, ignore_metadata: Vec<MetadataField>) -> Self {
444 self.ignore_metadata = ignore_metadata;
445 self
446 }
447}
448
449#[cfg(test)]
450pub(crate) struct PlannedFileOperationParts {
451 pub(crate) operation: FileOperationKind,
452 pub(crate) source: PathBuf,
453 pub(crate) target: PathBuf,
454 pub(crate) source_path: PathBuf,
455 pub(crate) target_path: PathBuf,
456 pub(crate) required: bool,
457 pub(crate) compare: Option<SyncCompare>,
458 pub(crate) delete: Option<bool>,
459 pub(crate) symlinks: Option<SymlinkMode>,
460 pub(crate) include: Vec<String>,
461 pub(crate) ignore: Vec<String>,
462 pub(crate) ignore_metadata: Vec<MetadataField>,
463 pub(crate) status: PlannedFileStatus,
464 pub(crate) declaration: SourceSpan,
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub enum PlannedFileStatus {
470 Ready,
472 SkippedMissingSource,
474}
475
476#[derive(Debug, Clone, PartialEq, Eq)]
485pub struct PlannedCommand {
486 name: Option<String>,
488 command: CommandKind,
490 cwd: Option<PathBuf>,
492 cwd_path: PathBuf,
494 env: BTreeMap<String, String>,
496 allow_failure: bool,
498 declaration: SourceSpan,
500}
501
502impl PlannedCommand {
503 #[must_use]
505 pub fn name(&self) -> Option<&str> {
506 self.name.as_deref()
507 }
508
509 #[must_use]
511 pub const fn command(&self) -> &CommandKind {
512 &self.command
513 }
514
515 #[must_use]
517 pub fn cwd(&self) -> Option<&Path> {
518 self.cwd.as_deref()
519 }
520
521 #[must_use]
523 pub fn cwd_path(&self) -> &Path {
524 &self.cwd_path
525 }
526
527 #[must_use]
529 pub const fn env(&self) -> &BTreeMap<String, String> {
530 &self.env
531 }
532
533 #[must_use]
535 pub const fn allow_failure(&self) -> bool {
536 self.allow_failure
537 }
538
539 #[must_use]
541 pub const fn declaration(&self) -> SourceSpan {
542 self.declaration
543 }
544
545 #[cfg(test)]
546 pub(crate) fn from_raw_parts_unchecked(parts: PlannedCommandParts) -> Self {
547 Self {
548 name: parts.name,
549 command: parts.command,
550 cwd: parts.cwd,
551 cwd_path: parts.cwd_path,
552 env: parts.env,
553 allow_failure: parts.allow_failure,
554 declaration: parts.declaration,
555 }
556 }
557}
558
559#[cfg(test)]
560#[derive(Clone)]
561pub(crate) struct PlannedCommandParts {
562 pub(crate) name: Option<String>,
563 pub(crate) command: CommandKind,
564 pub(crate) cwd: Option<PathBuf>,
565 pub(crate) cwd_path: PathBuf,
566 pub(crate) env: BTreeMap<String, String>,
567 pub(crate) allow_failure: bool,
568 pub(crate) declaration: SourceSpan,
569}
570
571pub(super) fn plan_file_operations(
572 origin: FilePlanOrigin<'_>,
573 files: &[FileOperation],
574 context: &Worktree,
575 options: ActionPlanOptions,
576) -> Result<(Vec<PlannedFileOperation>, Vec<PlanWarning>)> {
577 let root_path = normalize_existing(&context.root_path).map_err(|source| {
578 file_plan_error(
579 origin,
580 None,
581 format!("failed to resolve root path: {source}"),
582 )
583 })?;
584 let worktree_path = normalize_existing(&context.worktree_path).map_err(|source| {
585 file_plan_error(
586 origin,
587 None,
588 format!("failed to resolve worktree path: {source}"),
589 )
590 })?;
591
592 let target_paths = normalize_target_paths(
593 origin,
594 files,
595 context.worktree_path.as_path(),
596 worktree_path.as_path(),
597 )?;
598 validate_target_conflicts(origin, files, &target_paths)?;
599 validate_strict_sync(origin, files, options.strict)?;
600
601 build_file_operations(
602 origin,
603 files,
604 options,
605 &target_paths,
606 root_path.as_path(),
607 worktree_path.as_path(),
608 )
609}
610
611fn normalize_target_paths(
612 origin: FilePlanOrigin<'_>,
613 files: &[FileOperation],
614 worktree_path: &Path,
615 normalized_worktree_path: &Path,
616) -> Result<Vec<PathBuf>> {
617 files
618 .iter()
619 .map(|operation| {
620 let inspected_parent =
621 validate_target_parent_components(origin, operation, worktree_path)?;
622 let target_path = normalize_target_path(&operation.target_path).map_err(|source| {
623 file_plan_error(
624 origin,
625 Some(operation.declaration),
626 format!(
627 "failed to resolve target {}: {source}",
628 operation.target.display()
629 ),
630 )
631 })?;
632
633 if !inspected_parent && is_within(&target_path, normalized_worktree_path) {
634 return invalid_file_plan(
635 origin,
636 Some(operation.declaration),
637 format!(
638 "cannot create target for {}; target parent could not be inspected",
639 operation_label(operation),
640 ),
641 );
642 }
643
644 Ok(target_path)
645 })
646 .collect()
647}
648
649fn validate_target_parent_components(
650 origin: FilePlanOrigin<'_>,
651 operation: &FileOperation,
652 worktree_path: &Path,
653) -> Result<bool> {
654 let parent = operation.target_path.parent().unwrap_or(worktree_path);
655 let Some(anchor) = matching_target_anchor(parent, worktree_path) else {
656 return Ok(false);
657 };
658
659 match inspect_target_ancestors(parent, anchor.as_ref(), false) {
660 Ok(()) | Err(TargetAncestorIssue::OutsideWorktree { .. }) => Ok(true),
661 Err(TargetAncestorIssue::Symlink { path }) => invalid_file_plan(
662 origin,
663 Some(operation.declaration),
664 format!(
665 "cannot create target for {}; target parent {} is a symlink",
666 operation_label(operation),
667 path.display()
668 ),
669 ),
670 Err(TargetAncestorIssue::NotDirectory { path }) => invalid_file_plan(
671 origin,
672 Some(operation.declaration),
673 format!(
674 "cannot create target for {}; target parent {} is not a directory",
675 operation_label(operation),
676 path.display()
677 ),
678 ),
679 Err(TargetAncestorIssue::Io { path, source }) => Err(file_plan_error(
680 origin,
681 Some(operation.declaration),
682 format!(
683 "failed to inspect target parent {}: {source}",
684 path.display()
685 ),
686 )),
687 }
688}
689
690fn validate_target_conflicts(
691 origin: FilePlanOrigin<'_>,
692 files: &[FileOperation],
693 target_paths: &[PathBuf],
694) -> Result<()> {
695 validate_duplicate_targets(origin, files, target_paths)?;
696 validate_overlapping_targets(origin, files, target_paths)
697}
698
699fn validate_duplicate_targets(
700 origin: FilePlanOrigin<'_>,
701 files: &[FileOperation],
702 target_paths: &[PathBuf],
703) -> Result<()> {
704 let mut targets: BTreeMap<&Path, Vec<&FileOperation>> = BTreeMap::new();
705
706 for (operation, target_path) in files.iter().zip(target_paths) {
707 targets
708 .entry(target_path.as_path())
709 .or_default()
710 .push(operation);
711 }
712
713 let duplicates = targets
714 .into_iter()
715 .filter(|(_, operations)| operations.len() > 1)
716 .collect::<Vec<_>>();
717
718 if duplicates.is_empty() {
719 return Ok(());
720 }
721
722 let details = duplicates
723 .iter()
724 .flat_map(|(target, operations)| {
725 operations.iter().map(move |operation| {
726 format!(
727 "{}: {}",
728 target.display(),
729 operation_summary(origin, operation)
730 )
731 })
732 })
733 .collect::<Vec<_>>()
734 .join("; ");
735
736 let message = match origin {
737 FilePlanOrigin::Config(_) => format!("duplicate configured target: {details}"),
738 FilePlanOrigin::Manual { .. } => format!("duplicate target: {details}"),
739 };
740
741 Err(file_plan_error(origin, None, message))
742}
743
744fn validate_overlapping_targets(
745 origin: FilePlanOrigin<'_>,
746 files: &[FileOperation],
747 target_paths: &[PathBuf],
748) -> Result<()> {
749 let mut overlaps = Vec::new();
750
751 for (index, (operation, target_path)) in files.iter().zip(target_paths).enumerate() {
752 for (other_operation, other_target_path) in files.iter().zip(target_paths).skip(index + 1) {
753 if target_path == other_target_path {
754 continue;
755 }
756
757 let Some((ancestor_path, ancestor, descendant_path, descendant)) =
758 overlapping_targets(target_path, operation, other_target_path, other_operation)
759 else {
760 continue;
761 };
762
763 overlaps.push(format!(
764 "{} contains {}: {}; {}",
765 ancestor_path.display(),
766 descendant_path.display(),
767 operation_summary(origin, ancestor),
768 operation_summary(origin, descendant)
769 ));
770 }
771 }
772
773 if overlaps.is_empty() {
774 return Ok(());
775 }
776
777 let message = match origin {
778 FilePlanOrigin::Config(_) => {
779 format!("overlapping configured targets: {}", overlaps.join("; "))
780 }
781 FilePlanOrigin::Manual { .. } => format!("overlapping targets: {}", overlaps.join("; ")),
782 };
783
784 Err(file_plan_error(origin, None, message))
785}
786
787fn overlapping_targets<'a>(
788 target_path: &'a Path,
789 operation: &'a FileOperation,
790 other_target_path: &'a Path,
791 other_operation: &'a FileOperation,
792) -> Option<(&'a Path, &'a FileOperation, &'a Path, &'a FileOperation)> {
793 if other_target_path.starts_with(target_path) {
794 return Some((target_path, operation, other_target_path, other_operation));
795 }
796
797 if target_path.starts_with(other_target_path) {
798 return Some((other_target_path, other_operation, target_path, operation));
799 }
800
801 None
802}
803
804fn validate_strict_sync(
805 origin: FilePlanOrigin<'_>,
806 files: &[FileOperation],
807 strict: bool,
808) -> Result<()> {
809 if !strict {
810 return Ok(());
811 }
812
813 if let Some(operation) = files
814 .iter()
815 .find(|operation| operation.operation == FileOperationKind::Sync)
816 {
817 return invalid_file_plan(
818 origin,
819 Some(operation.declaration),
820 format!(
821 "strict mode cannot be used with sync file operation {}",
822 operation_summary(origin, operation)
823 ),
824 );
825 }
826
827 Ok(())
828}
829
830fn build_file_operations(
831 origin: FilePlanOrigin<'_>,
832 files: &[FileOperation],
833 options: ActionPlanOptions,
834 target_paths: &[PathBuf],
835 root_path: &Path,
836 worktree_path: &Path,
837) -> Result<(Vec<PlannedFileOperation>, Vec<PlanWarning>)> {
838 let mut planned = Vec::with_capacity(files.len());
839 let mut warnings = Vec::new();
840
841 for (operation, target_path) in files.iter().zip(target_paths) {
842 validate_target_boundary(origin, options, operation, target_path, worktree_path)?;
843
844 let source_path = normalize_maybe_existing(&operation.source_path).map_err(|source| {
845 file_plan_error(
846 origin,
847 Some(operation.declaration),
848 format!(
849 "failed to resolve source {}: {source}",
850 operation.source.display()
851 ),
852 )
853 })?;
854 validate_source_boundary(origin, options, operation, &source_path, root_path)?;
855 let ignore_rules = operation_ignore_rules(origin, operation, &source_path)?;
856 let include_rules = operation_include_rules(origin, operation, &source_path)?;
857
858 let status = match source_exists(origin, operation, source_path.as_path())? {
859 true => {
860 if matches!(
861 operation.operation,
862 FileOperationKind::Copy | FileOperationKind::Sync
863 ) {
864 validate_source_symlinks(
865 origin,
866 operation,
867 source_path.as_path(),
868 root_path,
869 ignore_rules.as_ref(),
870 include_rules.as_ref(),
871 )?;
872
873 if let Some(include) = include_rules.as_ref()
877 && std::fs::symlink_metadata(&operation.source_path)
878 .is_ok_and(|metadata| metadata.is_dir())
879 && !include_matches_any_source_path(&source_path, include)
880 {
881 warnings.push(PlanWarning::IncludeMatchedNothing {
882 operation: operation.operation,
883 source: operation.source.clone(),
884 target: operation.target.clone(),
885 });
886 }
887 }
888
889 PlannedFileStatus::Ready
890 }
891 false if operation.required => {
892 return invalid_file_plan(
893 origin,
894 Some(operation.declaration),
895 format!(
896 "required source does not exist for {}",
897 operation_summary(origin, operation)
898 ),
899 );
900 }
901 false => PlannedFileStatus::SkippedMissingSource,
902 };
903
904 planned.push(PlannedFileOperation {
905 operation: operation.operation,
906 source: operation.source.clone(),
907 target: operation.target.clone(),
908 source_path,
909 target_path: target_path.clone(),
910 required: operation.required,
911 compare: operation.compare,
912 delete: operation.delete,
913 symlinks: operation.symlinks,
914 include: operation.include.clone(),
915 ignore: operation.ignore.clone(),
916 ignore_metadata: operation.ignore_metadata.clone(),
917 status,
918 declaration: operation.declaration,
919 });
920 }
921
922 Ok((planned, warnings))
923}
924
925fn validate_target_boundary(
926 origin: FilePlanOrigin<'_>,
927 options: ActionPlanOptions,
928 operation: &FileOperation,
929 target_path: &Path,
930 worktree_path: &Path,
931) -> Result<()> {
932 if options.dangerously_allow_targets_outside_worktree {
933 return Ok(());
934 }
935
936 if !is_within(target_path, worktree_path) {
937 return invalid_file_plan(
938 origin,
939 Some(operation.declaration),
940 format!(
941 "target resolves outside worktree for {}",
942 operation_summary(origin, operation)
943 ),
944 );
945 }
946
947 Ok(())
948}
949
950fn validate_source_boundary(
951 origin: FilePlanOrigin<'_>,
952 options: ActionPlanOptions,
953 operation: &FileOperation,
954 source_path: &Path,
955 root_path: &Path,
956) -> Result<()> {
957 if options.dangerously_allow_sources_outside_root {
958 return Ok(());
959 }
960
961 if !is_within(source_path, root_path) {
962 return invalid_file_plan(
963 origin,
964 Some(operation.declaration),
965 format!(
966 "source resolves outside root for {}",
967 operation_summary(origin, operation)
968 ),
969 );
970 }
971
972 Ok(())
973}
974
975fn operation_ignore_rules(
976 origin: FilePlanOrigin<'_>,
977 operation: &FileOperation,
978 source_path: &Path,
979) -> Result<Option<PathIgnoreRules>> {
980 if !matches!(
981 operation.operation,
982 FileOperationKind::Copy | FileOperationKind::Sync
983 ) || operation.ignore.is_empty()
984 {
985 return Ok(None);
986 }
987
988 PathIgnoreRules::new(source_path, &operation.ignore)
989 .map(Some)
990 .map_err(|source| {
991 file_plan_error(
992 origin,
993 Some(operation.declaration),
994 format!(
995 "invalid ignore pattern for {}: {source}",
996 operation_summary(origin, operation)
997 ),
998 )
999 })
1000}
1001
1002fn operation_include_rules(
1003 origin: FilePlanOrigin<'_>,
1004 operation: &FileOperation,
1005 source_path: &Path,
1006) -> Result<Option<PathIncludeRules>> {
1007 if !matches!(
1008 operation.operation,
1009 FileOperationKind::Copy | FileOperationKind::Sync
1010 ) || operation.include.is_empty()
1011 {
1012 return Ok(None);
1013 }
1014
1015 if operation.operation == FileOperationKind::Sync && operation.delete == Some(true) {
1019 return invalid_file_plan(
1020 origin,
1021 Some(operation.declaration),
1022 format!(
1023 "{} for {}",
1024 include_with_delete_message(),
1025 operation_summary(origin, operation)
1026 ),
1027 );
1028 }
1029 for pattern in &operation.include {
1030 if let Some(issue) = invalid_include_pattern(pattern) {
1031 return invalid_file_plan(
1032 origin,
1033 Some(operation.declaration),
1034 format!(
1035 "{} for {}",
1036 issue.message(pattern),
1037 operation_summary(origin, operation)
1038 ),
1039 );
1040 }
1041 }
1042
1043 PathIncludeRules::new(source_path, &operation.include)
1044 .map(Some)
1045 .map_err(|source| {
1046 file_plan_error(
1047 origin,
1048 Some(operation.declaration),
1049 format!(
1050 "invalid include pattern for {}: {source}",
1051 operation_summary(origin, operation)
1052 ),
1053 )
1054 })
1055}
1056
1057fn plan_commands(
1058 path: &Path,
1059 commands: &[CommandOperation],
1060 context: &Worktree,
1061 worktree_path: &Path,
1062) -> Result<Vec<PlannedCommand>> {
1063 let mut planned = Vec::with_capacity(commands.len());
1064
1065 for command in commands {
1066 let cwd_path = command
1067 .cwd_path
1068 .as_ref()
1069 .map_or_else(
1070 || Ok(worktree_path.to_path_buf()),
1071 |cwd_path| normalize_maybe_existing(cwd_path),
1072 )
1073 .map_err(|source| {
1074 invalid_config_error(
1075 path,
1076 Some(command.declaration),
1077 format!("failed to resolve command cwd: {source}"),
1078 )
1079 })?;
1080
1081 if !is_within(&cwd_path, worktree_path) {
1082 return invalid_config(
1083 path,
1084 Some(command.declaration),
1085 "command cwd resolves outside worktree",
1086 );
1087 }
1088
1089 for key in command.env.keys() {
1090 if context.environment.contains_key(key) {
1091 return invalid_config(
1092 path,
1093 Some(command.declaration),
1094 format!("command env overrides treeboot-owned variable `{key}`"),
1095 );
1096 }
1097 }
1098
1099 planned.push(PlannedCommand {
1100 name: command.name.clone(),
1101 command: command.command.clone(),
1102 cwd: command.cwd.clone(),
1103 cwd_path,
1104 env: command.env.clone(),
1105 allow_failure: command.allow_failure,
1106 declaration: command.declaration,
1107 });
1108 }
1109
1110 Ok(planned)
1111}
1112
1113fn validate_source_symlinks(
1114 origin: FilePlanOrigin<'_>,
1115 operation: &FileOperation,
1116 source_path: &Path,
1117 root_path: &Path,
1118 ignore_rules: Option<&PathIgnoreRules>,
1119 include_rules: Option<&PathIncludeRules>,
1120) -> Result<()> {
1121 validate_source_symlink_path(
1122 origin,
1123 operation,
1124 source_root_context(source_path, ignore_rules, include_rules),
1125 source_path,
1126 root_path,
1127 false,
1128 )
1129}
1130
1131#[derive(Debug, Clone, Copy)]
1132struct SourceFilterContext<'a> {
1133 source_root: &'a Path,
1134 ignore_rules: Option<&'a PathIgnoreRules>,
1135 include_rules: Option<&'a PathIncludeRules>,
1136}
1137
1138const fn source_root_context<'a>(
1139 source_root: &'a Path,
1140 ignore_rules: Option<&'a PathIgnoreRules>,
1141 include_rules: Option<&'a PathIncludeRules>,
1142) -> SourceFilterContext<'a> {
1143 SourceFilterContext {
1144 source_root,
1145 ignore_rules,
1146 include_rules,
1147 }
1148}
1149
1150fn validate_source_symlink_path(
1151 origin: FilePlanOrigin<'_>,
1152 operation: &FileOperation,
1153 filter: SourceFilterContext<'_>,
1154 path: &Path,
1155 root_path: &Path,
1156 ancestor_included: bool,
1157) -> Result<()> {
1158 let metadata = std::fs::symlink_metadata(path).map_err(|source| {
1159 file_plan_error(
1160 origin,
1161 Some(operation.declaration),
1162 format!(
1163 "failed to inspect source {}: {source}",
1164 operation.source.display()
1165 ),
1166 )
1167 })?;
1168
1169 if metadata.file_type().is_symlink() {
1170 let target = normalize_existing(path).map_err(|source| {
1171 file_plan_error(
1172 origin,
1173 Some(operation.declaration),
1174 format!(
1175 "failed to resolve source symlink {}: {source}",
1176 path.display()
1177 ),
1178 )
1179 })?;
1180
1181 if !is_within(&target, root_path) {
1182 return invalid_file_plan(
1183 origin,
1184 Some(operation.declaration),
1185 format!(
1186 "copy or sync source contains unsafe symlink {}",
1187 path.display()
1188 ),
1189 );
1190 }
1191
1192 return Ok(());
1193 }
1194
1195 if !metadata.is_dir() {
1196 return Ok(());
1197 }
1198
1199 for entry in std::fs::read_dir(path).map_err(|source| {
1200 file_plan_error(
1201 origin,
1202 Some(operation.declaration),
1203 format!(
1204 "failed to inspect source directory {}: {source}",
1205 path.display()
1206 ),
1207 )
1208 })? {
1209 let entry = entry.map_err(|source| {
1210 file_plan_error(
1211 origin,
1212 Some(operation.declaration),
1213 format!(
1214 "failed to inspect source directory {}: {source}",
1215 path.display()
1216 ),
1217 )
1218 })?;
1219 let path = entry.path();
1220 let metadata = std::fs::symlink_metadata(&path).map_err(|source| {
1221 file_plan_error(
1222 origin,
1223 Some(operation.declaration),
1224 format!(
1225 "failed to inspect source directory {}: {source}",
1226 path.display()
1227 ),
1228 )
1229 })?;
1230
1231 let entry_included = ancestor_included || entry_matches_include(filter, &path, &metadata);
1232
1233 if ignored_source_path(filter.source_root, &path, &metadata, filter.ignore_rules) {
1234 if metadata.is_dir()
1238 && filter
1239 .ignore_rules
1240 .map(PathIgnoreRules::has_negation)
1241 .unwrap_or(false)
1242 && (entry_included || dir_may_contain_included(filter, &path))
1243 {
1244 validate_source_symlink_path(
1245 origin,
1246 operation,
1247 filter,
1248 &path,
1249 root_path,
1250 entry_included,
1251 )?;
1252 }
1253 continue;
1254 }
1255
1256 if filter.include_rules.is_some() && !entry_included {
1260 if metadata.is_dir() && dir_may_contain_included(filter, &path) {
1261 validate_source_symlink_path(origin, operation, filter, &path, root_path, false)?;
1262 }
1263 continue;
1264 }
1265
1266 validate_source_symlink_path(origin, operation, filter, &path, root_path, entry_included)?;
1267 }
1268
1269 Ok(())
1270}
1271
1272fn entry_matches_include(
1273 filter: SourceFilterContext<'_>,
1274 path: &Path,
1275 metadata: &std::fs::Metadata,
1276) -> bool {
1277 match filter.include_rules {
1278 None => true,
1279 Some(include) => path
1280 .strip_prefix(filter.source_root)
1281 .is_ok_and(|relative| include.is_included(relative, metadata.is_dir())),
1282 }
1283}
1284
1285fn dir_may_contain_included(filter: SourceFilterContext<'_>, path: &Path) -> bool {
1286 filter.include_rules.is_none_or(|include| {
1287 path.strip_prefix(filter.source_root)
1288 .is_ok_and(|relative| include.dir_may_contain_matches(relative))
1289 })
1290}
1291
1292fn ignored_source_path(
1293 source_root: &Path,
1294 path: &Path,
1295 metadata: &std::fs::Metadata,
1296 ignore_rules: Option<&PathIgnoreRules>,
1297) -> bool {
1298 ignore_rules
1299 .zip(path.strip_prefix(source_root).ok())
1300 .is_some_and(|(rules, relative)| rules.is_ignored(relative, metadata.is_dir()))
1301}
1302
1303fn source_exists(
1304 origin: FilePlanOrigin<'_>,
1305 operation: &FileOperation,
1306 source_path: &Path,
1307) -> Result<bool> {
1308 match std::fs::symlink_metadata(source_path) {
1309 Ok(_) => Ok(true),
1310 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
1311 Err(source) => Err(file_plan_error(
1312 origin,
1313 Some(operation.declaration),
1314 format!(
1315 "failed to inspect source {}: {source}",
1316 operation.source.display()
1317 ),
1318 )),
1319 }
1320}
1321
1322fn operation_summary(origin: FilePlanOrigin<'_>, operation: &FileOperation) -> String {
1323 let summary = operation_label(operation);
1324
1325 match origin {
1326 FilePlanOrigin::Config(_) => format!(
1327 "{} at line {}, column {}",
1328 summary, operation.declaration.line, operation.declaration.column
1329 ),
1330 FilePlanOrigin::Manual { .. } => summary,
1331 }
1332}
1333
1334fn operation_label(operation: &FileOperation) -> String {
1335 format!(
1336 "{} {} -> {}",
1337 operation.operation,
1338 operation.source.display(),
1339 operation.target.display()
1340 )
1341}
1342
1343fn invalid_config<T>(
1344 path: &Path,
1345 span: Option<SourceSpan>,
1346 message: impl Into<String>,
1347) -> Result<T> {
1348 Err(invalid_config_error(path, span, message))
1349}
1350
1351fn invalid_file_plan<T>(
1352 origin: FilePlanOrigin<'_>,
1353 span: Option<SourceSpan>,
1354 message: impl Into<String>,
1355) -> Result<T> {
1356 Err(file_plan_error(origin, span, message))
1357}
1358
1359fn invalid_config_error(
1360 path: &Path,
1361 span: Option<SourceSpan>,
1362 message: impl Into<String>,
1363) -> Error {
1364 let message = match span {
1365 Some(span) => format!(
1366 "{} at line {}, column {}",
1367 message.into(),
1368 span.line,
1369 span.column
1370 ),
1371 None => message.into(),
1372 };
1373
1374 Error::ConfigInvalid {
1375 path: path.to_path_buf(),
1376 message,
1377 }
1378}
1379
1380fn file_plan_error(
1381 origin: FilePlanOrigin<'_>,
1382 span: Option<SourceSpan>,
1383 message: impl Into<String>,
1384) -> Error {
1385 match origin {
1386 FilePlanOrigin::Config(path) => invalid_config_error(path, span, message),
1387 FilePlanOrigin::Manual { operation } => Error::FileOperationInvalid {
1388 operation: operation.as_str(),
1389 message: message.into(),
1390 },
1391 }
1392}
1393
1394fn normalize_existing(path: &Path) -> std::io::Result<PathBuf> {
1395 paths::canonicalize(path)
1396}
1397
1398fn normalize_maybe_existing(path: &Path) -> std::io::Result<PathBuf> {
1399 paths::normalize_maybe_existing(path)
1400}
1401
1402fn normalize_target_path(path: &Path) -> std::io::Result<PathBuf> {
1403 let Some(name) = path.file_name() else {
1404 return normalize_maybe_existing(path);
1405 };
1406
1407 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1408 let mut normalized = normalize_maybe_existing(parent)?;
1409 normalized.push(name);
1410
1411 Ok(paths::normalize_lexical(&normalized))
1412}
1413
1414fn is_within(path: &Path, boundary: &Path) -> bool {
1415 path == boundary || path.starts_with(boundary)
1416}
1417
1418#[cfg(test)]
1419mod tests {
1420 use std::collections::BTreeMap;
1421 use std::ffi::OsString;
1422 use std::time::{SystemTime, UNIX_EPOCH};
1423
1424 use super::*;
1425 use crate::test_support::{symlink_dir, symlink_file};
1426
1427 fn span() -> SourceSpan {
1428 SourceSpan {
1429 start: 0,
1430 end: 1,
1431 line: 1,
1432 column: 1,
1433 }
1434 }
1435
1436 fn temp_workspace(name: &str) -> (PathBuf, PathBuf) {
1437 let id = SystemTime::now()
1438 .duration_since(UNIX_EPOCH)
1439 .expect("clock should be after Unix epoch")
1440 .as_nanos();
1441 let base = std::env::temp_dir().join(format!("treeboot-{name}-{id}"));
1442 let root = base.join("root");
1443 let worktree = base.join("worktree");
1444
1445 std::fs::create_dir_all(&root).expect("root should be created");
1446 std::fs::create_dir_all(&worktree).expect("worktree should be created");
1447
1448 (root, worktree)
1449 }
1450
1451 fn aliased_workspace(name: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
1452 let (root, worktree) = temp_workspace(name);
1453 let base = root.parent().expect("root should have parent");
1454 let alias = base.join("alias");
1455 symlink_dir(base, &alias).expect("workspace alias should be created");
1456
1457 let alias_root = alias.join("root");
1458 let alias_worktree = alias.join("worktree");
1459
1460 (root, worktree, alias_root, alias_worktree)
1461 }
1462
1463 fn context(root_path: &Path, worktree_path: &Path) -> Worktree {
1464 Worktree {
1465 root_path: root_path.to_path_buf(),
1466 worktree_path: worktree_path.to_path_buf(),
1467 default_branch: "main".to_owned(),
1468 environment: BTreeMap::from([(
1469 "TREEBOOT_ROOT_PATH".to_owned(),
1470 OsString::from(root_path),
1471 )]),
1472 }
1473 }
1474
1475 fn empty_config() -> Config {
1476 Config {
1477 options: Default::default(),
1478 files: Vec::new(),
1479 commands: Vec::new(),
1480 }
1481 }
1482
1483 fn file_operation(
1484 operation: FileOperationKind,
1485 root: &Path,
1486 worktree: &Path,
1487 source: &str,
1488 target: &str,
1489 ) -> FileOperation {
1490 FileOperation {
1491 operation,
1492 source: PathBuf::from(source),
1493 target: PathBuf::from(target),
1494 source_path: root.join(source),
1495 target_path: worktree.join(target),
1496 required: false,
1497 compare: match operation {
1498 FileOperationKind::Sync => Some(SyncCompare::Metadata),
1499 FileOperationKind::Copy | FileOperationKind::Symlink => None,
1500 },
1501 delete: match operation {
1502 FileOperationKind::Sync => Some(false),
1503 FileOperationKind::Copy | FileOperationKind::Symlink => None,
1504 },
1505 symlinks: match operation {
1506 FileOperationKind::Copy | FileOperationKind::Sync => Some(SymlinkMode::Preserve),
1507 FileOperationKind::Symlink => None,
1508 },
1509 include: Vec::new(),
1510 ignore: Vec::new(),
1511 ignore_metadata: Vec::new(),
1512 declaration: span(),
1513 }
1514 }
1515
1516 fn plan(config: &Config, root: &Path, worktree: &Path) -> Result<ActionPlan> {
1517 ActionPlan::from_manifest(
1518 Path::new(".treeboot.toml"),
1519 config,
1520 &context(root, worktree),
1521 ActionPlanOptions::default(),
1522 )
1523 }
1524
1525 fn include_operation(
1526 operation: FileOperationKind,
1527 root: &Path,
1528 worktree: &Path,
1529 source: &str,
1530 include: &[&str],
1531 ) -> FileOperation {
1532 FileOperation {
1533 include: include.iter().map(ToString::to_string).collect(),
1534 ..file_operation(operation, root, worktree, source, source)
1535 }
1536 }
1537
1538 #[test]
1539 fn plan_should_warn_when_include_matches_no_source_paths() {
1540 let (root, worktree) = temp_workspace("include-zero-match");
1541 std::fs::create_dir_all(root.join("shared")).expect("source should be created");
1542 std::fs::write(root.join("shared/file.txt"), "data\n").expect("file should be written");
1543 let config = Config {
1544 options: Default::default(),
1545 files: vec![include_operation(
1546 FileOperationKind::Copy,
1547 &root,
1548 &worktree,
1549 "shared",
1550 &["docs/**"],
1551 )],
1552 commands: Vec::new(),
1553 };
1554
1555 let plan = plan(&config, &root, &worktree).expect("plan should build");
1556
1557 assert_eq!(plan.warnings().len(), 1);
1558 let message = plan.warnings()[0].to_string();
1559 assert!(message.contains("include patterns match no source paths"));
1560 assert!(message.contains("copy shared -> shared"));
1561 }
1562
1563 #[test]
1564 fn plan_should_not_warn_when_include_matches_before_ignore_filtering() {
1565 let (root, worktree) = temp_workspace("include-match-before-ignore");
1566 std::fs::create_dir_all(root.join("shared/docs")).expect("source should be created");
1567 std::fs::write(root.join("shared/docs/guide.md"), "guide\n")
1568 .expect("file should be written");
1569 let mut operation = include_operation(
1570 FileOperationKind::Copy,
1571 &root,
1572 &worktree,
1573 "shared",
1574 &["docs/**"],
1575 );
1576 operation.ignore = vec!["docs/**".to_owned()];
1579 let config = Config {
1580 options: Default::default(),
1581 files: vec![operation],
1582 commands: Vec::new(),
1583 };
1584
1585 let plan = plan(&config, &root, &worktree).expect("plan should build");
1586
1587 assert!(plan.warnings().is_empty());
1588 }
1589
1590 #[test]
1591 fn plan_should_not_warn_for_missing_or_file_sources_with_include() {
1592 let (root, worktree) = temp_workspace("include-no-warning-sources");
1593 std::fs::write(root.join(".env"), "TOKEN=1\n").expect("file should be written");
1594 let config = Config {
1595 options: Default::default(),
1596 files: vec![
1597 include_operation(
1598 FileOperationKind::Copy,
1599 &root,
1600 &worktree,
1601 "missing",
1602 &["docs/**"],
1603 ),
1604 include_operation(
1605 FileOperationKind::Copy,
1606 &root,
1607 &worktree,
1608 ".env",
1609 &["docs/**"],
1610 ),
1611 ],
1612 commands: Vec::new(),
1613 };
1614
1615 let plan = plan(&config, &root, &worktree).expect("plan should build");
1616
1617 assert!(plan.warnings().is_empty());
1618 }
1619
1620 #[cfg(unix)]
1621 #[test]
1622 fn plan_should_not_warn_for_symlink_directory_sources_with_include() {
1623 let (root, worktree) = temp_workspace("include-symlink-dir-source");
1624 std::fs::create_dir_all(root.join("real")).expect("real dir should be created");
1625 std::fs::write(root.join("real/file.txt"), "data\n").expect("file should be written");
1626 symlink_dir(root.join("real"), root.join("linked"))
1627 .expect("source symlink should be created");
1628 let config = Config {
1629 options: Default::default(),
1630 files: vec![include_operation(
1631 FileOperationKind::Copy,
1632 &root,
1633 &worktree,
1634 "linked",
1635 &["nomatch/**"],
1636 )],
1637 commands: Vec::new(),
1638 };
1639
1640 let plan = plan(&config, &root, &worktree).expect("plan should build");
1641
1642 assert!(plan.warnings().is_empty());
1643 }
1644
1645 #[cfg(unix)]
1646 #[test]
1647 fn plan_should_skip_unsafe_symlinks_excluded_by_include() {
1648 let (root, worktree) = temp_workspace("include-unsafe-symlink");
1649 std::fs::create_dir_all(root.join("shared/docs")).expect("source should be created");
1650 std::fs::write(root.join("shared/docs/guide.md"), "guide\n")
1651 .expect("file should be written");
1652 let outside = root.parent().expect("root should have parent").join("out");
1653 std::fs::create_dir_all(&outside).expect("outside dir should be created");
1654 symlink_dir(&outside, root.join("shared/unsafe-link"))
1655 .expect("unsafe symlink should be created");
1656
1657 let filtered = Config {
1658 options: Default::default(),
1659 files: vec![include_operation(
1660 FileOperationKind::Copy,
1661 &root,
1662 &worktree,
1663 "shared",
1664 &["docs/**"],
1665 )],
1666 commands: Vec::new(),
1667 };
1668 plan(&filtered, &root, &worktree)
1669 .expect("non-included unsafe symlink should not fail validation");
1670
1671 let unfiltered = Config {
1672 options: Default::default(),
1673 files: vec![file_operation(
1674 FileOperationKind::Copy,
1675 &root,
1676 &worktree,
1677 "shared",
1678 "shared",
1679 )],
1680 commands: Vec::new(),
1681 };
1682 let error = plan(&unfiltered, &root, &worktree)
1683 .expect_err("unfiltered unsafe symlink should fail validation");
1684 assert!(error.to_string().contains("unsafe symlink"));
1685 }
1686
1687 #[test]
1688 fn plan_should_reject_include_with_sync_delete_for_direct_file_operations() {
1689 let (root, worktree) = temp_workspace("include-delete-direct");
1690 std::fs::create_dir_all(root.join("shared")).expect("source should be created");
1691 let mut operation = include_operation(
1692 FileOperationKind::Sync,
1693 &root,
1694 &worktree,
1695 "shared",
1696 &["docs/**"],
1697 );
1698 operation.delete = Some(true);
1699
1700 let error = ActionPlan::from_file_operations(
1701 &context(&root, &worktree),
1702 PlanOrigin::Manual {
1703 operation: FileOperationKind::Sync,
1704 },
1705 &[operation],
1706 ActionPlanOptions::default(),
1707 )
1708 .expect_err("include with delete should fail");
1709
1710 assert!(
1711 error
1712 .to_string()
1713 .contains("`include` cannot be combined with `delete = true`")
1714 );
1715 }
1716
1717 #[test]
1718 fn plan_should_reject_inert_include_patterns_for_direct_file_operations() {
1719 let (root, worktree) = temp_workspace("include-inert-direct");
1720 std::fs::create_dir_all(root.join("shared")).expect("source should be created");
1721 let operation = include_operation(
1722 FileOperationKind::Copy,
1723 &root,
1724 &worktree,
1725 "shared",
1726 &["!docs"],
1727 );
1728
1729 let error = ActionPlan::from_file_operations(
1730 &context(&root, &worktree),
1731 PlanOrigin::Manual {
1732 operation: FileOperationKind::Copy,
1733 },
1734 &[operation],
1735 ActionPlanOptions::default(),
1736 )
1737 .expect_err("negated include should fail");
1738
1739 assert!(error.to_string().contains("uses `!` negation"));
1740 }
1741
1742 #[test]
1743 fn is_within_should_not_match_partial_component_prefixes() {
1744 assert!(!is_within(
1745 Path::new("/repo-worktree-other/file"),
1746 Path::new("/repo-worktree")
1747 ));
1748 }
1749
1750 #[test]
1751 fn action_plan_from_manifest_should_mark_optional_missing_sources_skipped() {
1752 let (root, worktree) = temp_workspace("missing-source");
1753 let config = Config {
1754 options: Default::default(),
1755 files: vec![FileOperation {
1756 operation: FileOperationKind::Copy,
1757 source: PathBuf::from("missing"),
1758 target: PathBuf::from("missing"),
1759 source_path: root.join("missing"),
1760 target_path: worktree.join("missing"),
1761 required: false,
1762 compare: None,
1763 delete: None,
1764 symlinks: Some(SymlinkMode::Preserve),
1765 include: Vec::new(),
1766 ignore: Vec::new(),
1767 ignore_metadata: Vec::new(),
1768 declaration: span(),
1769 }],
1770 commands: Vec::new(),
1771 };
1772
1773 let plan = ActionPlan::from_manifest(
1774 Path::new(".treeboot.toml"),
1775 &config,
1776 &context(&root, &worktree),
1777 ActionPlanOptions::default(),
1778 )
1779 .expect("optional missing source should plan");
1780
1781 assert_eq!(
1782 plan.files[0].status,
1783 PlannedFileStatus::SkippedMissingSource
1784 );
1785 }
1786
1787 #[test]
1788 fn action_plan_from_manifest_should_build_ready_file_operation() {
1789 let (root, worktree) = temp_workspace("ready-file");
1790 std::fs::write(root.join(".env"), "TOKEN=1\n").expect("source should be written");
1791 let config = Config {
1792 options: Default::default(),
1793 files: vec![file_operation(
1794 FileOperationKind::Copy,
1795 &root,
1796 &worktree,
1797 ".env",
1798 ".env",
1799 )],
1800 commands: Vec::new(),
1801 };
1802
1803 let plan = plan(&config, &root, &worktree).expect("file should plan");
1804
1805 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1806 }
1807
1808 #[test]
1809 fn action_plan_from_manifest_should_reject_overlapping_file_targets() {
1810 let (root, worktree) = temp_workspace("overlapping-targets");
1811 let mut sync = file_operation(
1812 FileOperationKind::Sync,
1813 &root,
1814 &worktree,
1815 "shared",
1816 "shared",
1817 );
1818 sync.delete = Some(true);
1819 let config = Config {
1820 options: Default::default(),
1821 files: vec![
1822 file_operation(
1823 FileOperationKind::Copy,
1824 &root,
1825 &worktree,
1826 "child",
1827 "shared/child",
1828 ),
1829 sync,
1830 ],
1831 commands: Vec::new(),
1832 };
1833
1834 let error = plan(&config, &root, &worktree).expect_err("overlapping targets should fail");
1835
1836 assert!(error.to_string().contains("overlapping configured targets"));
1837 assert!(error.to_string().contains("shared"));
1838 assert!(error.to_string().contains("shared/child"));
1839 }
1840
1841 #[test]
1842 fn action_plan_from_manual_operations_should_reject_overlapping_targets() {
1843 let (root, worktree) = temp_workspace("manual-overlapping-targets");
1844 let mut sync = file_operation(
1845 FileOperationKind::Sync,
1846 &root,
1847 &worktree,
1848 "shared",
1849 "shared",
1850 );
1851 sync.delete = Some(true);
1852 let operations = vec![
1853 sync,
1854 file_operation(
1855 FileOperationKind::Sync,
1856 &root,
1857 &worktree,
1858 "shared/nested",
1859 "shared/nested",
1860 ),
1861 ];
1862
1863 let error = ActionPlan::from_file_operations(
1864 &context(&root, &worktree),
1865 PlanOrigin::Manual {
1866 operation: FileOperationKind::Sync,
1867 },
1868 &operations,
1869 ActionPlanOptions::default(),
1870 )
1871 .expect_err("overlapping targets should fail");
1872
1873 assert!(error.to_string().contains("invalid sync file operation"));
1874 assert!(error.to_string().contains("overlapping targets"));
1875 }
1876
1877 #[test]
1878 fn action_plan_from_manifest_should_build_command_metadata() {
1879 let (root, worktree) = temp_workspace("command-metadata");
1880 let app_dir = worktree.join("app");
1881 std::fs::create_dir_all(&app_dir).expect("command cwd should be created");
1882 let config = Config {
1883 options: Default::default(),
1884 files: Vec::new(),
1885 commands: vec![CommandOperation {
1886 name: Some("Install".to_owned()),
1887 command: CommandKind::Direct {
1888 program: "npm".to_owned(),
1889 args: vec!["install".to_owned()],
1890 },
1891 cwd: Some(PathBuf::from("app")),
1892 cwd_path: Some(app_dir.clone()),
1893 env: BTreeMap::from([("NODE_ENV".to_owned(), "development".to_owned())]),
1894 allow_failure: true,
1895 declaration: span(),
1896 }],
1897 };
1898
1899 let plan = plan(&config, &root, &worktree).expect("command should plan");
1900
1901 assert_eq!(
1902 plan.commands[0].cwd_path,
1903 paths::canonicalize(&app_dir).expect("app dir should canonicalize")
1904 );
1905 assert!(plan.commands[0].allow_failure);
1906 }
1907
1908 #[test]
1909 fn action_plan_from_manifest_should_allow_explicit_boundary_escapes() {
1910 let (root, worktree) = temp_workspace("boundary-escapes");
1911 let outside_source = root
1912 .parent()
1913 .expect("root should have parent")
1914 .join("outside-source");
1915 let outside_target = worktree
1916 .parent()
1917 .expect("worktree should have parent")
1918 .join("outside-target");
1919 std::fs::write(&outside_source, "shared\n").expect("outside source should be written");
1920 let config = Config {
1921 options: Default::default(),
1922 files: vec![FileOperation {
1923 operation: FileOperationKind::Copy,
1924 source: outside_source.clone(),
1925 target: outside_target.clone(),
1926 source_path: outside_source,
1927 target_path: outside_target,
1928 required: false,
1929 compare: None,
1930 delete: None,
1931 symlinks: Some(SymlinkMode::Preserve),
1932 include: Vec::new(),
1933 ignore: Vec::new(),
1934 ignore_metadata: Vec::new(),
1935 declaration: span(),
1936 }],
1937 commands: Vec::new(),
1938 };
1939
1940 let plan = ActionPlan::from_manifest(
1941 Path::new(".treeboot.toml"),
1942 &config,
1943 &context(&root, &worktree),
1944 ActionPlanOptions {
1945 dangerously_allow_sources_outside_root: true,
1946 dangerously_allow_targets_outside_worktree: true,
1947 ..ActionPlanOptions::default()
1948 },
1949 )
1950 .expect("escaped paths should plan");
1951
1952 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1953 }
1954
1955 #[test]
1956 fn action_plan_from_manifest_should_allow_missing_target_parents_for_all_file_operations() {
1957 for operation in [
1958 FileOperationKind::Copy,
1959 FileOperationKind::Symlink,
1960 FileOperationKind::Sync,
1961 ] {
1962 let (root, worktree) = temp_workspace(&format!("missing-target-parent-{operation}"));
1963 std::fs::write(root.join("source"), "value\n").expect("source should be written");
1964 let config = Config {
1965 options: Default::default(),
1966 files: vec![file_operation(
1967 operation,
1968 &root,
1969 &worktree,
1970 "source",
1971 "nested/config/source",
1972 )],
1973 commands: Vec::new(),
1974 };
1975
1976 let plan = plan(&config, &root, &worktree)
1977 .unwrap_or_else(|error| panic!("{operation} should plan: {error}"));
1978
1979 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
1980 }
1981 }
1982
1983 #[test]
1984 fn action_plan_from_manifest_should_allow_final_symlink_target_to_root_source() {
1985 let (root, worktree) = temp_workspace("final-symlink-target-to-root");
1986 let source = root.join("config/master.key");
1987 let target = worktree.join("config/master.key");
1988 std::fs::create_dir_all(source.parent().unwrap()).expect("source parent should exist");
1989 std::fs::create_dir_all(target.parent().unwrap()).expect("target parent should exist");
1990 std::fs::write(&source, "secret\n").expect("source should be written");
1991 symlink_file(&source, &target).expect("target symlink should be created");
1992 let config = Config {
1993 options: Default::default(),
1994 files: vec![file_operation(
1995 FileOperationKind::Symlink,
1996 &root,
1997 &worktree,
1998 "config/master.key",
1999 "config/master.key",
2000 )],
2001 commands: Vec::new(),
2002 };
2003
2004 let plan = plan(&config, &root, &worktree)
2005 .expect("final target symlink to root source should plan");
2006
2007 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
2008 assert_eq!(
2009 plan.files[0].target_path,
2010 normalize_target_path(&target).expect("target should normalize")
2011 );
2012 }
2013
2014 #[test]
2015 fn action_plan_from_manifest_should_reject_target_parent_symlink_for_all_file_operations() {
2016 for operation in [
2017 FileOperationKind::Copy,
2018 FileOperationKind::Symlink,
2019 FileOperationKind::Sync,
2020 ] {
2021 let (root, worktree) = temp_workspace(&format!("target-parent-symlink-{operation}"));
2022 let linked = root.join("config");
2023 std::fs::create_dir_all(&linked).expect("linked directory should be created");
2024 std::fs::write(root.join("source"), "value\n").expect("source should be written");
2025 symlink_dir(&linked, worktree.join("config"))
2026 .expect("target parent symlink should be created");
2027 let config = Config {
2028 options: Default::default(),
2029 files: vec![file_operation(
2030 operation,
2031 &root,
2032 &worktree,
2033 "source",
2034 "config/source",
2035 )],
2036 commands: Vec::new(),
2037 };
2038
2039 let error = match plan(&config, &root, &worktree) {
2040 Ok(_) => panic!("{operation} should reject symlink parent"),
2041 Err(error) => error,
2042 };
2043 let message = error.to_string();
2044 assert!(
2045 message.contains(&format!("cannot create target for {operation}")),
2046 "{operation} error should name operation: {message}"
2047 );
2048 assert!(
2049 message.contains("target parent") && message.contains("is a symlink"),
2050 "{operation} error should describe symlink parent: {message}"
2051 );
2052 }
2053 }
2054
2055 #[test]
2056 fn action_plan_from_manifest_should_reject_target_parent_file_for_all_file_operations() {
2057 for operation in [
2058 FileOperationKind::Copy,
2059 FileOperationKind::Symlink,
2060 FileOperationKind::Sync,
2061 ] {
2062 let (root, worktree) = temp_workspace(&format!("target-parent-file-{operation}"));
2063 std::fs::write(root.join("source"), "value\n").expect("source should be written");
2064 std::fs::write(worktree.join("config"), "not a directory\n")
2065 .expect("target parent file should be written");
2066 let config = Config {
2067 options: Default::default(),
2068 files: vec![file_operation(
2069 operation,
2070 &root,
2071 &worktree,
2072 "source",
2073 "config/source",
2074 )],
2075 commands: Vec::new(),
2076 };
2077
2078 let error = match plan(&config, &root, &worktree) {
2079 Ok(_) => panic!("{operation} should reject file parent"),
2080 Err(error) => error,
2081 };
2082 let message = error.to_string();
2083 assert!(
2084 message.contains(&format!("cannot create target for {operation}")),
2085 "{operation} error should name operation: {message}"
2086 );
2087 assert!(
2088 message.contains("target parent") && message.contains("is not a directory"),
2089 "{operation} error should describe file parent: {message}"
2090 );
2091 }
2092 }
2093
2094 #[test]
2095 fn action_plan_from_manifest_should_reject_target_parent_file_with_worktree_alias() {
2096 let (_root, _worktree, alias_root, alias_worktree) =
2097 aliased_workspace("target-parent-file-alias");
2098 std::fs::write(alias_root.join("source"), "value\n").expect("source should be written");
2099 std::fs::write(alias_worktree.join("config"), "not a directory\n")
2100 .expect("target parent file should be written");
2101 let config = Config {
2102 options: Default::default(),
2103 files: vec![file_operation(
2104 FileOperationKind::Copy,
2105 &alias_root,
2106 &alias_worktree,
2107 "source",
2108 "config/source",
2109 )],
2110 commands: Vec::new(),
2111 };
2112
2113 let error = plan(&config, &alias_root, &alias_worktree)
2114 .expect_err("aliased worktree should still reject file parent");
2115
2116 assert!(error.to_string().contains("target parent"));
2117 assert!(error.to_string().contains("is not a directory"));
2118 }
2119
2120 #[test]
2121 fn action_plan_from_manifest_should_reject_target_parent_symlink_with_worktree_alias() {
2122 let (root, _worktree, alias_root, alias_worktree) =
2123 aliased_workspace("target-parent-symlink-alias");
2124 let linked = root.join("config");
2125 std::fs::create_dir_all(&linked).expect("linked directory should be created");
2126 std::fs::write(alias_root.join("source"), "value\n").expect("source should be written");
2127 symlink_dir(&linked, alias_worktree.join("config"))
2128 .expect("target parent symlink should be created");
2129 let config = Config {
2130 options: Default::default(),
2131 files: vec![file_operation(
2132 FileOperationKind::Copy,
2133 &alias_root,
2134 &alias_worktree,
2135 "source",
2136 "config/source",
2137 )],
2138 commands: Vec::new(),
2139 };
2140
2141 let error = plan(&config, &alias_root, &alias_worktree)
2142 .expect_err("aliased worktree should still reject symlink parent");
2143
2144 assert!(error.to_string().contains("target parent"));
2145 assert!(error.to_string().contains("is a symlink"));
2146 }
2147
2148 #[test]
2149 fn action_plan_from_manifest_should_reject_canonical_absolute_target_parent_symlink() {
2150 let (root, worktree, alias_root, alias_worktree) =
2151 aliased_workspace("absolute-target-parent-symlink");
2152 let linked = root.join("config");
2153 std::fs::create_dir_all(&linked).expect("linked directory should be created");
2154 std::fs::write(alias_root.join("source"), "value\n").expect("source should be written");
2155 symlink_dir(&linked, worktree.join("config"))
2156 .expect("target parent symlink should be created");
2157 let target_path = paths::canonicalize(&worktree)
2158 .expect("worktree should canonicalize")
2159 .join("config/source");
2160 let mut operation = file_operation(
2161 FileOperationKind::Copy,
2162 &alias_root,
2163 &alias_worktree,
2164 "source",
2165 "config/source",
2166 );
2167 operation.target = target_path.clone();
2168 operation.target_path = target_path;
2169 let config = Config {
2170 options: Default::default(),
2171 files: vec![operation],
2172 commands: Vec::new(),
2173 };
2174
2175 let error = plan(&config, &alias_root, &alias_worktree)
2176 .expect_err("canonical absolute target should reject symlink parent");
2177
2178 assert!(error.to_string().contains("target parent"));
2179 assert!(error.to_string().contains("is a symlink"));
2180 }
2181
2182 #[test]
2183 fn action_plan_from_manifest_should_reject_absolute_alias_target_parent_symlink() {
2184 let (root, worktree, _alias_root, alias_worktree) =
2185 aliased_workspace("absolute-alias-target-parent-symlink");
2186 let linked = worktree.join("real-config");
2187 std::fs::create_dir_all(&linked).expect("linked directory should be created");
2188 std::fs::write(root.join("source"), "value\n").expect("source should be written");
2189 symlink_dir(&linked, worktree.join("config"))
2190 .expect("target parent symlink should be created");
2191 let target_path = alias_worktree.join("config/source");
2192 let mut operation = file_operation(
2193 FileOperationKind::Copy,
2194 &root,
2195 &worktree,
2196 "source",
2197 "config/source",
2198 );
2199 operation.target = target_path.clone();
2200 operation.target_path = target_path;
2201 let config = Config {
2202 options: Default::default(),
2203 files: vec![operation],
2204 commands: Vec::new(),
2205 };
2206
2207 let error = plan(&config, &root, &worktree)
2208 .expect_err("absolute alias target should reject symlink parent");
2209
2210 assert!(error.to_string().contains("target parent"));
2211 assert!(error.to_string().contains("is a symlink"));
2212 }
2213
2214 #[test]
2215 fn action_plan_from_manifest_should_keep_input_context_for_worktree_alias() {
2216 let (_root, _worktree, alias_root, alias_worktree) = aliased_workspace("context-alias");
2217 let plan = ActionPlan::from_manifest(
2218 Path::new(".treeboot.toml"),
2219 &empty_config(),
2220 &context(&alias_root, &alias_worktree),
2221 ActionPlanOptions::default(),
2222 )
2223 .expect("empty plan should build");
2224
2225 assert_eq!(plan.context().root_path, alias_root);
2226 assert_eq!(plan.context().worktree_path, alias_worktree);
2227 }
2228
2229 #[test]
2230 fn action_plan_from_manifest_should_reject_missing_root_path() {
2231 let (_root, worktree) = temp_workspace("missing-root");
2232 let missing_root = worktree.join("missing-root");
2233 let error = ActionPlan::from_manifest(
2234 Path::new(".treeboot.toml"),
2235 &empty_config(),
2236 &context(&missing_root, &worktree),
2237 ActionPlanOptions::default(),
2238 )
2239 .expect_err("missing root should fail");
2240
2241 assert!(error.to_string().contains("failed to resolve root path"));
2242 }
2243
2244 #[test]
2245 fn action_plan_from_manifest_should_reject_missing_worktree_path() {
2246 let (root, worktree) = temp_workspace("missing-worktree");
2247 let missing_worktree = worktree.join("missing-worktree");
2248 let error = ActionPlan::from_manifest(
2249 Path::new(".treeboot.toml"),
2250 &empty_config(),
2251 &context(&root, &missing_worktree),
2252 ActionPlanOptions::default(),
2253 )
2254 .expect_err("missing worktree should fail");
2255
2256 assert!(
2257 error
2258 .to_string()
2259 .contains("failed to resolve worktree path")
2260 );
2261 }
2262
2263 #[test]
2264 fn action_plan_from_manifest_should_allow_strict_when_no_sync_exists() {
2265 let (root, worktree) = temp_workspace("strict-no-sync");
2266
2267 let plan = ActionPlan::from_manifest(
2268 Path::new(".treeboot.toml"),
2269 &empty_config(),
2270 &context(&root, &worktree),
2271 ActionPlanOptions {
2272 strict: true,
2273 ..ActionPlanOptions::default()
2274 },
2275 )
2276 .expect("strict mode should allow configs without sync");
2277
2278 assert!(plan.files.is_empty());
2279 }
2280
2281 #[test]
2282 fn action_plan_from_manifest_should_walk_source_directories() {
2283 let (root, worktree) = temp_workspace("source-directory");
2284 let source_dir = root.join("shared");
2285 std::fs::create_dir_all(&source_dir).expect("source dir should be created");
2286 std::fs::write(source_dir.join("config"), "value\n").expect("nested source should exist");
2287 let config = Config {
2288 options: Default::default(),
2289 files: vec![file_operation(
2290 FileOperationKind::Copy,
2291 &root,
2292 &worktree,
2293 "shared",
2294 "shared",
2295 )],
2296 commands: Vec::new(),
2297 };
2298
2299 let plan = plan(&config, &root, &worktree).expect("directory source should plan");
2300
2301 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
2302 }
2303
2304 #[test]
2305 fn action_plan_from_manifest_should_preserve_sync_options() {
2306 let (root, worktree) = temp_workspace("sync-options");
2307 let source_dir = root.join("shared");
2308 std::fs::create_dir_all(&source_dir).expect("source dir should be created");
2309 let mut operation = file_operation(
2310 FileOperationKind::Sync,
2311 &root,
2312 &worktree,
2313 "shared",
2314 "shared",
2315 );
2316 operation.delete = Some(true);
2317
2318 let config = Config {
2319 options: Default::default(),
2320 files: vec![operation],
2321 commands: Vec::new(),
2322 };
2323
2324 let plan = plan(&config, &root, &worktree).expect("sync should plan");
2325
2326 assert_eq!(plan.files[0].compare, Some(SyncCompare::Metadata));
2327 assert_eq!(plan.files[0].delete, Some(true));
2328 assert_eq!(plan.files[0].symlinks, Some(SymlinkMode::Preserve));
2329 }
2330
2331 #[test]
2332 fn action_plan_from_manifest_should_allow_safe_source_symlink() {
2333 let (root, worktree) = temp_workspace("safe-symlink");
2334 std::fs::write(root.join("source"), "value\n").expect("source should be written");
2335 symlink_file(root.join("source"), root.join("link"))
2336 .expect("safe source symlink should be created");
2337 let config = Config {
2338 options: Default::default(),
2339 files: vec![file_operation(
2340 FileOperationKind::Copy,
2341 &root,
2342 &worktree,
2343 "link",
2344 "link",
2345 )],
2346 commands: Vec::new(),
2347 };
2348
2349 let plan = plan(&config, &root, &worktree).expect("safe symlink should plan");
2350
2351 assert_eq!(plan.files[0].status, PlannedFileStatus::Ready);
2352 }
2353
2354 #[test]
2355 fn action_plan_from_manifest_should_reject_broken_source_symlink() {
2356 let (root, worktree) = temp_workspace("broken-symlink");
2357 symlink_file(root.join("missing"), root.join("link"))
2358 .expect("broken source symlink should be created");
2359 let config = Config {
2360 options: Default::default(),
2361 files: vec![file_operation(
2362 FileOperationKind::Copy,
2363 &root,
2364 &worktree,
2365 "link",
2366 "link",
2367 )],
2368 commands: Vec::new(),
2369 };
2370
2371 let error = plan(&config, &root, &worktree).expect_err("broken symlink should fail");
2372
2373 assert!(
2374 error
2375 .to_string()
2376 .contains("failed to resolve source symlink")
2377 );
2378 }
2379
2380 #[test]
2381 fn action_plan_from_manifest_should_default_command_cwd_to_worktree() {
2382 let (root, worktree) = temp_workspace("command-cwd");
2383 let config = Config {
2384 options: Default::default(),
2385 files: Vec::new(),
2386 commands: vec![CommandOperation {
2387 name: None,
2388 command: CommandKind::Shell {
2389 run: "pwd".to_owned(),
2390 },
2391 cwd: None,
2392 cwd_path: None,
2393 env: BTreeMap::new(),
2394 allow_failure: false,
2395 declaration: span(),
2396 }],
2397 };
2398
2399 let plan = ActionPlan::from_manifest(
2400 Path::new(".treeboot.toml"),
2401 &config,
2402 &context(&root, &worktree),
2403 ActionPlanOptions::default(),
2404 )
2405 .expect("command should plan");
2406
2407 assert_eq!(
2408 plan.commands[0].cwd_path,
2409 paths::canonicalize(&worktree).expect("worktree should canonicalize")
2410 );
2411 }
2412}