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