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