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/// Renders a single string with Tera — used for dynamic file paths.
29pub fn render_string(s: &str, ctx: &tera::Context) -> Result<String> {
30  tera::Tera::one_off(s, ctx, false).map_err(Into::into)
31}
32
33/// Normalises `root.join(relative)` without touching the filesystem by
34/// resolving `.` and `..` components lexically.
35fn normalize_join(root: &Path, relative: &str) -> PathBuf {
36  let joined = root.join(relative);
37  let mut out = PathBuf::new();
38  for component in joined.components() {
39    match component {
40      Component::ParentDir => {
41        out.pop();
42      }
43      Component::CurDir => {}
44      c => out.push(c),
45    }
46  }
47  out
48}
49
50/// Normalises `root` itself lexically (no filesystem I/O).
51fn normalize_path(path: &Path) -> PathBuf {
52  let mut out = PathBuf::new();
53  for component in path.components() {
54    match component {
55      Component::ParentDir => {
56        out.pop();
57      }
58      Component::CurDir => {}
59      c => out.push(c),
60    }
61  }
62  out
63}
64
65/// Joins `root` with `relative`, normalises the result lexically, then
66/// verifies the resulting path starts with `root`.  Returns the normalised
67/// path or an error if the path would escape `root`.
68///
69/// This prevents path-traversal attacks in addon manifests (e.g. `../../etc/passwd`).
70pub(super) fn safe_join(root: &Path, relative: &str, label: &str) -> Result<PathBuf> {
71  let norm_root = normalize_path(root);
72  let path = normalize_join(root, relative);
73  if !path.starts_with(&norm_root) {
74    return Err(anyhow::anyhow!(
75      "Path traversal blocked: {} '{}' would escape the root directory",
76      label,
77      relative
78    ));
79  }
80  Ok(path)
81}
82
83pub(super) fn resolve_target(
84  target: &crate::addons::manifest::Target,
85  project_root: &Path,
86) -> Result<Vec<PathBuf>> {
87  use crate::addons::manifest::Target;
88  match target {
89    Target::File { file } => {
90      let path = safe_join(project_root, file, "target file")?;
91      Ok(vec![path])
92    }
93    Target::Glob { glob } => {
94      // Validate the pattern itself doesn't traverse outside root
95      safe_join(project_root, glob, "glob pattern")?;
96      let pattern = project_root.join(glob).to_string_lossy().to_string();
97      let canonical_root = project_root
98        .canonicalize()
99        .with_context(|| format!("Cannot resolve project root '{}'", project_root.display()))?;
100      let paths = glob::glob(&pattern)?
101        .filter_map(|e| e.ok())
102        // Filter out any results that escape root via symlinks
103        .filter(|p| {
104          p.canonicalize()
105            .map(|cp| cp.starts_with(&canonical_root))
106            .unwrap_or(false)
107        })
108        .collect();
109      Ok(paths)
110    }
111  }
112}