Skip to main content

oxide_cli/addons/steps/
mod.rs

1use std::path::{Component, Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5pub mod append;
6pub mod copy;
7pub mod create;
8pub mod delete;
9pub mod inject;
10pub mod move_step;
11pub mod rename;
12pub mod replace;
13
14pub enum Rollback {
15  DeleteCreatedFile { path: PathBuf },
16  RestoreFile { path: PathBuf, original: Vec<u8> },
17  RenameFile { from: PathBuf, to: PathBuf },
18}
19
20/// Renders content lines with Tera one_off — substitutes {{ var }} from user inputs.
21pub fn render_lines(lines: &[String], ctx: &tera::Context) -> Result<Vec<String>> {
22  lines
23    .iter()
24    .map(|line| tera::Tera::one_off(line, ctx, false).map_err(Into::into))
25    .collect()
26}
27
28/// Normalises `root.join(relative)` without touching the filesystem by
29/// resolving `.` and `..` components lexically.
30fn normalize_join(root: &Path, relative: &str) -> PathBuf {
31  let joined = root.join(relative);
32  let mut out = PathBuf::new();
33  for component in joined.components() {
34    match component {
35      Component::ParentDir => {
36        out.pop();
37      }
38      Component::CurDir => {}
39      c => out.push(c),
40    }
41  }
42  out
43}
44
45/// Normalises `root` itself lexically (no filesystem I/O).
46fn normalize_path(path: &Path) -> PathBuf {
47  let mut out = PathBuf::new();
48  for component in path.components() {
49    match component {
50      Component::ParentDir => {
51        out.pop();
52      }
53      Component::CurDir => {}
54      c => out.push(c),
55    }
56  }
57  out
58}
59
60/// Joins `root` with `relative`, normalises the result lexically, then
61/// verifies the resulting path starts with `root`.  Returns the normalised
62/// path or an error if the path would escape `root`.
63///
64/// This prevents path-traversal attacks in addon manifests (e.g. `../../etc/passwd`).
65pub(super) fn safe_join(root: &Path, relative: &str, label: &str) -> Result<PathBuf> {
66  let norm_root = normalize_path(root);
67  let path = normalize_join(root, relative);
68  if !path.starts_with(&norm_root) {
69    return Err(anyhow::anyhow!(
70      "Path traversal blocked: {} '{}' would escape the root directory",
71      label,
72      relative
73    ));
74  }
75  Ok(path)
76}
77
78pub(super) fn resolve_target(
79  target: &crate::addons::manifest::Target,
80  project_root: &Path,
81) -> Result<Vec<PathBuf>> {
82  use crate::addons::manifest::Target;
83  match target {
84    Target::File { file } => {
85      let path = safe_join(project_root, file, "target file")?;
86      Ok(vec![path])
87    }
88    Target::Glob { glob } => {
89      // Validate the pattern itself doesn't traverse outside root
90      safe_join(project_root, glob, "glob pattern")?;
91      let pattern = project_root.join(glob).to_string_lossy().to_string();
92      let canonical_root = project_root
93        .canonicalize()
94        .with_context(|| format!("Cannot resolve project root '{}'", project_root.display()))?;
95      let paths = glob::glob(&pattern)?
96        .filter_map(|e| e.ok())
97        // Filter out any results that escape root via symlinks
98        .filter(|p| {
99          p.canonicalize()
100            .map(|cp| cp.starts_with(&canonical_root))
101            .unwrap_or(false)
102        })
103        .collect();
104      Ok(paths)
105    }
106  }
107}