Skip to main content

mcd_core/
assets.rs

1//! Package asset validation helpers.
2
3use sha2::{Digest, Sha256};
4
5use crate::{
6    errors::{Diagnostic, McdError, Result},
7    manifest::AssetManifestEntry,
8    package::{McdPackage, validate_internal_path},
9};
10
11/// Return true when an asset path is allowed by the default asset directory or manifest entries.
12#[must_use]
13pub fn asset_path_allowed(path: &str, declared_assets: &[AssetManifestEntry]) -> bool {
14    path.starts_with("assets/")
15        || declared_assets
16            .iter()
17            .any(|asset| path_allowed_by(path, asset))
18}
19
20/// Validate asset path, existence, media type, hash, and SVG safety.
21pub fn validate_image_asset(
22    package: &McdPackage,
23    path: &str,
24    declared_media_type: &str,
25    hash: Option<&str>,
26    declared_assets: &[AssetManifestEntry],
27    source: &str,
28) -> Result<()> {
29    validate_internal_path(path).map_err(|_| {
30        McdError::from_diagnostic(
31            Diagnostic::error(
32                "asset.path.invalid",
33                format!("Image asset path '{path}' is not a safe package path."),
34            )
35            .with_source(source.to_owned()),
36        )
37    })?;
38
39    if !asset_path_allowed(path, declared_assets) {
40        return Err(asset_error(
41            "asset.path.disallowed",
42            format!("Image asset '{path}' must be inside assets/ or a declared asset path."),
43            source,
44        ));
45    }
46
47    let bytes = package.read(path).map_err(|_| {
48        McdError::from_diagnostic(
49            Diagnostic::error(
50                "asset.missing",
51                format!("Referenced image asset '{path}' is missing."),
52            )
53            .with_source(path.to_owned()),
54        )
55    })?;
56
57    let detected = detect_media_type(path);
58    if detected != declared_media_type {
59        return Err(asset_error(
60            "asset.media_type.mismatch",
61            format!(
62                "Asset '{}' declares media type '{}', but path detection found '{}'.",
63                path, declared_media_type, detected
64            ),
65            source,
66        ));
67    }
68
69    if !declared_media_type.starts_with("image/") {
70        return Err(asset_error(
71            "asset.media_type.unsupported",
72            format!("Asset '{path}' must declare an image media type."),
73            source,
74        ));
75    }
76
77    if let Some(hash) = hash {
78        validate_sha256_hash(bytes, hash, source)?;
79    }
80
81    if declared_media_type == "image/svg+xml" {
82        validate_svg_safety(bytes, path)?;
83    }
84
85    Ok(())
86}
87
88/// Detect media type from the asset path.
89#[must_use]
90pub fn detect_media_type(path: &str) -> String {
91    if path.to_ascii_lowercase().ends_with(".svg") {
92        "image/svg+xml".to_owned()
93    } else {
94        mime_guess::from_path(path).first().map_or_else(
95            || "application/octet-stream".to_owned(),
96            |mime| mime.to_string(),
97        )
98    }
99}
100
101/// Compute a stable `sha256:<hex>` hash string.
102#[must_use]
103pub fn sha256_hash(bytes: &[u8]) -> String {
104    let digest = Sha256::digest(bytes);
105    let hex = digest
106        .iter()
107        .map(|byte| format!("{byte:02x}"))
108        .collect::<String>();
109    format!("sha256:{hex}")
110}
111
112fn validate_sha256_hash(bytes: &[u8], declared_hash: &str, source: &str) -> Result<()> {
113    if !declared_hash.starts_with("sha256:") || declared_hash.len() != "sha256:".len() + 64 {
114        return Err(asset_error(
115            "asset.hash.invalid",
116            "Image asset hash must use sha256:<64 lowercase hex characters>.",
117            source,
118        ));
119    }
120    if !declared_hash["sha256:".len()..]
121        .chars()
122        .all(|character| character.is_ascii_hexdigit())
123    {
124        return Err(asset_error(
125            "asset.hash.invalid",
126            "Image asset hash must use hexadecimal characters.",
127            source,
128        ));
129    }
130
131    let actual = sha256_hash(bytes);
132    if actual != declared_hash {
133        return Err(asset_error(
134            "asset.hash.mismatch",
135            "Image asset hash does not match package bytes.",
136            source,
137        ));
138    }
139
140    Ok(())
141}
142
143fn validate_svg_safety(bytes: &[u8], path: &str) -> Result<()> {
144    let text = std::str::from_utf8(bytes).map_err(|_| {
145        McdError::from_diagnostic(
146            Diagnostic::error("asset.svg.utf8", "SVG asset is not valid UTF-8.")
147                .with_source(path.to_owned()),
148        )
149    })?;
150    let doc = roxmltree::Document::parse(text).map_err(|err| {
151        McdError::from_diagnostic(
152            Diagnostic::error(
153                "asset.svg.invalid",
154                format!("SVG asset is not valid XML: {err}."),
155            )
156            .with_source(path.to_owned()),
157        )
158    })?;
159
160    for node in doc.descendants().filter(roxmltree::Node::is_element) {
161        let tag_name = node.tag_name().name();
162        if matches!(
163            tag_name,
164            "script" | "foreignObject" | "animate" | "animateMotion" | "animateTransform" | "set"
165        ) {
166            return Err(asset_error(
167                "security.svg.active_content",
168                format!("SVG asset contains disallowed <{tag_name}> content."),
169                path,
170            ));
171        }
172
173        for attribute in node.attributes() {
174            let name = attribute.name();
175            let value = attribute.value().trim();
176            if name == "xmlns" || name.starts_with("xmlns:") {
177                continue;
178            }
179            if name.starts_with("on") {
180                return Err(asset_error(
181                    "security.svg.event_handler",
182                    format!("SVG asset contains disallowed event handler attribute '{name}'."),
183                    path,
184                ));
185            }
186            if is_external_reference(value) {
187                return Err(asset_error(
188                    "security.svg.external_reference",
189                    format!("SVG asset contains disallowed external reference '{value}'."),
190                    path,
191                ));
192            }
193        }
194    }
195
196    Ok(())
197}
198
199fn is_external_reference(value: &str) -> bool {
200    let lower = value.to_ascii_lowercase();
201    lower.contains("http://")
202        || lower.contains("https://")
203        || lower.contains("//")
204        || lower.contains("javascript:")
205        || lower.contains("data:")
206}
207
208fn path_allowed_by(path: &str, asset: &AssetManifestEntry) -> bool {
209    let declared = asset.path.trim_end_matches('/');
210    path == declared || path.starts_with(&format!("{declared}/"))
211}
212
213fn asset_error(code: impl Into<String>, message: impl Into<String>, source: &str) -> McdError {
214    McdError::from_diagnostic(Diagnostic::error(code, message).with_source(source.to_owned()))
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn detects_svg_media_type() {
223        assert_eq!(detect_media_type("assets/process.svg"), "image/svg+xml");
224    }
225
226    #[test]
227    fn hashes_asset_bytes() {
228        assert_eq!(
229            sha256_hash(b"abc"),
230            "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
231        );
232    }
233}