Skip to main content

treeboot_core/
validation.rs

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