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