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