Skip to main content

treeboot_core/
validation.rs

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