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