Skip to main content

dmc_schema/
asset.rs

1use crate::{Ctx, Schema, ValidationError};
2use serde_json::{Value, json};
3use std::path::PathBuf;
4
5#[derive(Default)]
6pub struct FileSchema {
7  pub allow_non_relative: bool,
8}
9
10impl FileSchema {
11  pub fn allow_non_relative(mut self) -> Self {
12    self.allow_non_relative = true;
13    self
14  }
15}
16
17impl Schema for FileSchema {
18  fn parse(&self, value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
19    let raw = value.as_str().ok_or_else(|| ValidationError::root("file path must be a string"))?;
20    let resolved = resolve_asset(ctx, raw, self.allow_non_relative)?;
21    let url = publish_asset(ctx, &resolved)?;
22    Ok(Value::String(url))
23  }
24}
25
26#[derive(Default)]
27pub struct ImageSchema {
28  pub absolute_root: Option<PathBuf>,
29}
30
31impl ImageSchema {
32  pub fn absolute_root(mut self, p: impl Into<PathBuf>) -> Self {
33    self.absolute_root = Some(p.into());
34    self
35  }
36}
37
38impl Schema for ImageSchema {
39  fn parse(&self, value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
40    let raw = value.as_str().ok_or_else(|| ValidationError::root("image path must be a string"))?;
41    let resolved = resolve_asset(ctx, raw, self.absolute_root.is_some())?;
42    let url = publish_asset(ctx, &resolved)?;
43    let (w, h) = image::image_dimensions(&resolved).unwrap_or((0, 0));
44    let mut out = json!({ "src": url, "width": w, "height": h });
45    if let Some((dataurl, bw, bh)) = blur_preview(&resolved) {
46      let map = out.as_object_mut().unwrap();
47      map.insert("blurDataURL".into(), Value::String(dataurl));
48      map.insert("blurWidth".into(), Value::from(bw));
49      map.insert("blurHeight".into(), Value::from(bh));
50    }
51    Ok(out)
52  }
53}
54
55fn blur_preview(path: &PathBuf) -> Option<(String, u32, u32)> {
56  use base64::Engine;
57  let img = image::open(path).ok()?;
58  let target_w: u32 = 8;
59  let aspect = img.height() as f32 / img.width() as f32;
60  let target_h = (target_w as f32 * aspect).round().max(1.0) as u32;
61  let small = img.resize_exact(target_w, target_h, image::imageops::FilterType::Lanczos3);
62  let mut buf = Vec::new();
63  small.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::WebP).ok()?;
64  let b64 = base64::engine::general_purpose::STANDARD.encode(&buf);
65  Some((format!("data:image/webp;base64,{b64}"), target_w, target_h))
66}
67
68fn resolve_asset(ctx: &Ctx, raw: &str, allow_abs: bool) -> Result<PathBuf, ValidationError> {
69  if raw.starts_with("http://") || raw.starts_with("https://") || raw.starts_with("//") {
70    return Err(ValidationError::root(format!("'{raw}' is a URL, not a local file")));
71  }
72  if raw.starts_with('/') {
73    if !allow_abs {
74      return Err(ValidationError::root(format!(
75        "'{raw}' is not relative; pass allowNonRelativePath / absoluteRoot to permit",
76      )));
77    }
78    return Ok(PathBuf::from(raw));
79  }
80  let dir = ctx.file_path.parent().unwrap_or(&ctx.root);
81  let joined = dir.join(raw);
82  // SEC-004: a relative `../` field can escape the project root. Canonicalize
83  // the joined path and assert it stays inside `ctx.root`; reject otherwise.
84  // (`canonicalize` resolves `..` and symlinks; the asset must exist anyway
85  // since `publish_asset` reads it next.)
86  let canonical =
87    joined.canonicalize().map_err(|e| ValidationError::root(format!("cannot resolve asset '{raw}': {e}")))?;
88  let root_canonical = ctx.root.canonicalize().unwrap_or_else(|_| ctx.root.clone());
89  if !canonical.starts_with(&root_canonical) {
90    return Err(ValidationError::root(format!(
91      "'{raw}' resolves outside the project root and was rejected (path traversal)",
92    )));
93  }
94  Ok(canonical)
95}
96
97fn publish_asset(ctx: &Ctx, path: &PathBuf) -> Result<String, ValidationError> {
98  let cfg = ctx.assets.as_ref().ok_or_else(|| ValidationError::root("asset pipeline not configured (engine bug?)"))?;
99  let bytes =
100    std::fs::read(path).map_err(|e| ValidationError::root(format!("cannot read asset {}: {e}", path.display())))?;
101  let hash = blake3::hash(&bytes);
102  let hash8 = &hash.to_hex().to_string()[..8];
103  let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("asset");
104  let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("bin");
105  let filename = cfg.name_template.replace("[name]", stem).replace("[hash:8]", hash8).replace("[ext]", ext);
106  let dest = cfg.assets_dir.join(&filename);
107  std::fs::create_dir_all(&cfg.assets_dir)
108    .map_err(|e| ValidationError::root(format!("cannot create assets dir: {e}")))?;
109  if !dest.exists() {
110    std::fs::write(&dest, &bytes)
111      .map_err(|e| ValidationError::root(format!("cannot write asset {}: {e}", dest.display())))?;
112  }
113  let mut url = cfg.base_url.clone();
114  if !url.ends_with('/') {
115    url.push('/');
116  }
117  url.push_str(&filename);
118  let mut map = cfg.map.lock().unwrap();
119  map.insert(path.to_string_lossy().to_string(), url.clone());
120  Ok(url)
121}
122
123#[cfg(test)]
124mod traversal_tests {
125  use super::resolve_asset;
126  use crate::Ctx;
127
128  /// SEC-004: a relative asset field using `../` must not escape the
129  /// project root, even though it is neither a URL nor an absolute path.
130  #[test]
131  fn rejects_relative_path_traversal() {
132    // Build a unique on-disk project root so `canonicalize` succeeds.
133    let root = std::env::temp_dir().join(format!("dmc-sec004-{}", std::process::id()));
134    let docs = root.join("docs");
135    std::fs::create_dir_all(&docs).unwrap();
136    std::fs::write(docs.join("page.md"), "x").unwrap();
137    // A file that exists outside the root (the traversal target).
138    let outside = std::env::temp_dir().join(format!("dmc-sec004-secret-{}", std::process::id()));
139    std::fs::write(&outside, "secret").unwrap();
140
141    let ctx = Ctx::new(docs.join("page.md"), root.clone(), String::new());
142
143    // `../<secret>` escapes the project root -> rejected.
144    let escaping = format!("../{}", outside.file_name().unwrap().to_string_lossy());
145    let err = resolve_asset(&ctx, &escaping, false);
146    assert!(err.is_err(), "path traversal `{escaping}` was not rejected");
147
148    // A sibling inside the root still resolves.
149    std::fs::write(docs.join("ok.png"), "img").unwrap();
150    assert!(resolve_asset(&ctx, "ok.png", false).is_ok());
151
152    let _ = std::fs::remove_dir_all(&root);
153    let _ = std::fs::remove_file(&outside);
154  }
155}