Skip to main content

treeboot_core/
validation.rs

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