1use sha2::{Digest, Sha256};
4
5use crate::{
6 errors::{Diagnostic, McdError, Result},
7 manifest::AssetManifestEntry,
8 package::{McdPackage, validate_internal_path},
9};
10
11#[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
20pub 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#[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#[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}