1use std::collections::BTreeMap;
14use std::fs::{self, File, OpenOptions};
15use std::io::{self, Read};
16use std::path::{Component, Path, PathBuf};
17use std::sync::Arc;
18
19use sha2::{Digest, Sha256};
20
21use crate::{ErrorCode, FileMakerError, FontAsset, FontResolver, Result};
22
23#[derive(Clone, Debug)]
25pub struct Asset {
26 pub name: String,
28 pub media_type: String,
30 pub bytes: Arc<[u8]>,
32 pub digest: [u8; 32],
34}
35
36impl Asset {
37 #[must_use]
39 pub fn new(name: impl Into<String>, media_type: impl Into<String>, bytes: Vec<u8>) -> Self {
40 let digest = Sha256::digest(&bytes).into();
41 Self {
42 name: name.into(),
43 media_type: media_type.into(),
44 bytes: bytes.into(),
45 digest,
46 }
47 }
48}
49
50pub trait AssetResolver: Send + Sync {
52 fn resolve_asset(&self, name: &str, max_bytes: usize) -> Result<Asset>;
54}
55
56pub trait TemplateResolver: Send + Sync {
58 fn resolve_template(&self, path: &str, max_bytes: usize) -> Result<Vec<u8>>;
60}
61
62#[derive(Clone, Debug)]
63struct MemoryEntry {
64 media_type: String,
65 bytes: Arc<[u8]>,
66 digest: [u8; 32],
67}
68
69#[derive(Clone, Debug, Default)]
71pub struct MemoryResolver {
72 entries: BTreeMap<String, MemoryEntry>,
73}
74
75impl MemoryResolver {
76 pub fn insert(
78 &mut self,
79 name: impl Into<String>,
80 media_type: impl Into<String>,
81 bytes: Vec<u8>,
82 ) -> Result<()> {
83 let name = name.into();
84 validate_logical_path(&name)?;
85 let digest = Sha256::digest(&bytes).into();
86 self.entries.insert(
87 name,
88 MemoryEntry {
89 media_type: media_type.into(),
90 bytes: bytes.into(),
91 digest,
92 },
93 );
94 Ok(())
95 }
96}
97
98impl AssetResolver for MemoryResolver {
99 fn resolve_asset(&self, name: &str, max_bytes: usize) -> Result<Asset> {
100 validate_logical_path(name)?;
101 let entry = self
102 .entries
103 .get(name)
104 .ok_or_else(|| asset_error("asset was not found"))?;
105 if entry.bytes.len() > max_bytes {
106 return Err(limit_error("asset bytes exceed configured limit"));
107 }
108 Ok(Asset {
109 name: name.to_owned(),
110 media_type: entry.media_type.clone(),
111 bytes: Arc::clone(&entry.bytes),
112 digest: entry.digest,
113 })
114 }
115}
116
117impl TemplateResolver for MemoryResolver {
118 fn resolve_template(&self, path: &str, max_bytes: usize) -> Result<Vec<u8>> {
119 validate_logical_path(path)?;
120 let entry = self
121 .entries
122 .get(path)
123 .ok_or_else(|| asset_error("template include was not found"))?;
124 if entry.bytes.len() > max_bytes {
125 return Err(limit_error("include bytes exceed configured limit"));
126 }
127 Ok(entry.bytes.to_vec())
128 }
129}
130
131impl FontResolver for MemoryResolver {
132 fn resolve_font(&self, name: &str, max_bytes: usize) -> Result<FontAsset> {
133 validate_logical_path(name)?;
134 let entry = self
135 .entries
136 .get(name)
137 .ok_or_else(|| asset_error("font was not found"))?;
138 if !matches!(entry.media_type.as_str(), "font/ttf" | "font/otf") {
139 return Err(asset_error("font entry has an unsupported media type"));
140 }
141 if entry.bytes.len() > max_bytes {
142 return Err(limit_error("font bytes exceed configured limit"));
143 }
144 FontAsset::new(name, entry.bytes.to_vec(), 0)
145 }
146}
147
148#[derive(Clone, Debug)]
150pub struct FileResolver {
151 root: PathBuf,
152}
153
154impl FileResolver {
155 pub fn new(root: impl AsRef<Path>) -> Result<Self> {
157 let root = fs::canonicalize(root.as_ref())
158 .map_err(|error| asset_error(format!("cannot open resolver root: {error}")))?;
159 if !root.is_dir() {
160 return Err(asset_error("resolver root is not a directory"));
161 }
162 Ok(Self { root })
163 }
164
165 fn load(&self, logical: &str, max_bytes: usize) -> Result<Vec<u8>> {
166 validate_logical_path(logical)?;
167 let candidate = fs::canonicalize(self.root.join(logical))
168 .map_err(|error| asset_error(format!("cannot resolve `{logical}`: {error}")))?;
169 if !candidate.starts_with(&self.root) {
170 return Err(sandbox_error("resolved path escapes sandbox root"));
171 }
172 let mut options = OpenOptions::new();
173 options.read(true);
174 let mut file = open_no_follow(&mut options, &candidate)
175 .map_err(|error| asset_error(format!("cannot open asset safely: {error}")))?;
176 ensure_still_sandboxed(&self.root, &candidate)?;
177 let metadata = file
178 .metadata()
179 .map_err(|error| asset_error(format!("cannot inspect asset: {error}")))?;
180 let size = usize::try_from(metadata.len())
181 .map_err(|_| limit_error("asset length exceeds platform range"))?;
182 if !metadata.is_file() || metadata_is_link(&metadata) || size > max_bytes {
183 return Err(limit_error("asset is not a bounded regular file"));
184 }
185 let mut bytes = Vec::new();
186 Read::by_ref(&mut file)
187 .take(
188 u64::try_from(max_bytes)
189 .unwrap_or(u64::MAX)
190 .saturating_add(1),
191 )
192 .read_to_end(&mut bytes)
193 .map_err(|error| asset_error(format!("cannot read asset: {error}")))?;
194 if bytes.len() > max_bytes {
195 return Err(limit_error("asset changed beyond byte limit while reading"));
196 }
197 ensure_still_sandboxed(&self.root, &candidate)?;
198 Ok(bytes)
199 }
200}
201
202fn ensure_still_sandboxed(root: &Path, candidate: &Path) -> Result<()> {
203 let current = fs::canonicalize(candidate)
204 .map_err(|error| asset_error(format!("cannot revalidate asset path: {error}")))?;
205 if current != candidate || !current.starts_with(root) {
206 return Err(sandbox_error("asset path changed outside the sandbox"));
207 }
208 Ok(())
209}
210
211#[cfg(unix)]
212fn open_no_follow(options: &mut OpenOptions, path: &Path) -> io::Result<File> {
213 use std::os::unix::fs::OpenOptionsExt;
214 options.custom_flags(libc::O_NOFOLLOW).open(path)
215}
216
217#[cfg(windows)]
218fn open_no_follow(options: &mut OpenOptions, path: &Path) -> io::Result<File> {
219 use std::os::windows::fs::OpenOptionsExt;
220 use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
221 options
222 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
223 .open(path)
224}
225
226#[cfg(all(not(unix), not(windows)))]
227fn open_no_follow(_options: &mut OpenOptions, _path: &Path) -> io::Result<File> {
228 Err(io::Error::new(
229 io::ErrorKind::Unsupported,
230 "no-follow asset opening is unavailable on this platform",
231 ))
232}
233
234#[cfg(windows)]
235fn metadata_is_link(metadata: &fs::Metadata) -> bool {
236 use std::os::windows::fs::MetadataExt;
237 use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
238
239 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
240}
241
242#[cfg(not(windows))]
243fn metadata_is_link(_metadata: &fs::Metadata) -> bool {
244 false
245}
246
247impl AssetResolver for FileResolver {
248 fn resolve_asset(&self, name: &str, max_bytes: usize) -> Result<Asset> {
249 let bytes = self.load(name, max_bytes)?;
250 Ok(Asset::new(name, media_type_for(name)?, bytes))
251 }
252}
253
254impl TemplateResolver for FileResolver {
255 fn resolve_template(&self, path: &str, max_bytes: usize) -> Result<Vec<u8>> {
256 self.load(path, max_bytes)
257 }
258}
259
260impl FontResolver for FileResolver {
261 fn resolve_font(&self, name: &str, max_bytes: usize) -> Result<FontAsset> {
262 if !matches!(media_type_for(name)?, "font/ttf" | "font/otf") {
263 return Err(asset_error("font path must end in .ttf or .otf"));
264 }
265 FontAsset::new(name, self.load(name, max_bytes)?, 0)
266 }
267}
268
269fn validate_logical_path(path: &str) -> Result<()> {
270 let path = Path::new(path);
271 if path.as_os_str().is_empty()
272 || path.is_absolute()
273 || path
274 .components()
275 .any(|part| !matches!(part, Component::Normal(_)))
276 {
277 return Err(sandbox_error(
278 "logical path must be non-empty, relative, and traversal-free",
279 ));
280 }
281 Ok(())
282}
283
284fn media_type_for(name: &str) -> Result<&'static str> {
285 match Path::new(name)
286 .extension()
287 .and_then(|value| value.to_str())
288 .map(str::to_ascii_lowercase)
289 .as_deref()
290 {
291 Some("png") => Ok("image/png"),
292 Some("jpg" | "jpeg") => Ok("image/jpeg"),
293 Some("svg") => Ok("image/svg+xml"),
294 Some("yaml" | "yml") => Ok("application/yaml"),
295 Some("ttf") => Ok("font/ttf"),
296 Some("otf") => Ok("font/otf"),
297 _ => Err(asset_error("asset extension has no declared media type")),
298 }
299}
300
301fn sandbox_error(message: impl Into<String>) -> FileMakerError {
302 FileMakerError::new(ErrorCode::AssetSandbox, message)
303}
304
305fn asset_error(message: impl Into<String>) -> FileMakerError {
306 FileMakerError::new(ErrorCode::AssetInvalid, message)
307}
308
309fn limit_error(message: impl Into<String>) -> FileMakerError {
310 FileMakerError::new(ErrorCode::LimitExceeded, message)
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 fn temporary_directory(name: &str) -> PathBuf {
318 let nonce = std::time::SystemTime::now()
319 .duration_since(std::time::UNIX_EPOCH)
320 .unwrap()
321 .as_nanos();
322 let directory = std::env::temp_dir().join(format!(
323 "appcore-filemaker-{name}-{}-{nonce}",
324 std::process::id()
325 ));
326 fs::create_dir(&directory).unwrap();
327 directory
328 }
329
330 #[test]
331 fn rejects_traversal_before_resolution() {
332 let mut resolver = MemoryResolver::default();
333 assert_eq!(
334 resolver
335 .insert("../secret", "text/plain", Vec::new())
336 .unwrap_err()
337 .code(),
338 ErrorCode::AssetSandbox
339 );
340 }
341
342 #[test]
343 fn memory_assets_share_immutable_bytes_between_resolutions() {
344 let mut resolver = MemoryResolver::default();
345 resolver
346 .insert("images/shared.png", "image/png", vec![1, 2, 3, 4])
347 .unwrap();
348 let first = resolver.resolve_asset("images/shared.png", 4).unwrap();
349 let second = resolver.resolve_asset("images/shared.png", 4).unwrap();
350 assert!(Arc::ptr_eq(&first.bytes, &second.bytes));
351 assert_eq!(first.digest, second.digest);
352 }
353
354 #[test]
355 fn font_resolution_enforces_path_media_type_and_byte_cap() {
356 let mut resolver = MemoryResolver::default();
357 resolver
358 .insert("fonts/body.ttf", "application/octet-stream", vec![0; 8])
359 .unwrap();
360 assert_eq!(
361 resolver
362 .resolve_font("fonts/body.ttf", 8)
363 .unwrap_err()
364 .code(),
365 ErrorCode::AssetInvalid
366 );
367
368 resolver
369 .insert("fonts/body.ttf", "font/ttf", vec![0; 8])
370 .unwrap();
371 assert_eq!(
372 resolver
373 .resolve_font("fonts/body.ttf", 7)
374 .unwrap_err()
375 .code(),
376 ErrorCode::LimitExceeded
377 );
378 assert_eq!(
379 resolver.resolve_font("../body.ttf", 8).unwrap_err().code(),
380 ErrorCode::AssetSandbox
381 );
382 }
383
384 #[test]
385 fn filesystem_font_resolution_reuses_the_canonical_sandbox() {
386 let resolver = FileResolver::new(".").unwrap();
387 assert_eq!(
388 resolver
389 .resolve_font("../body.ttf", usize::MAX)
390 .unwrap_err()
391 .code(),
392 ErrorCode::AssetSandbox
393 );
394 }
395
396 #[test]
397 fn filesystem_resolver_reads_only_bounded_regular_files() {
398 let directory = temporary_directory("regular-file");
399 fs::write(directory.join("image.png"), b"bounded").unwrap();
400 let resolver = FileResolver::new(&directory).unwrap();
401 assert_eq!(
402 resolver
403 .resolve_asset("image.png", 7)
404 .unwrap()
405 .bytes
406 .as_ref(),
407 b"bounded"
408 );
409 assert_eq!(
410 resolver.resolve_asset("image.png", 6).unwrap_err().code(),
411 ErrorCode::LimitExceeded
412 );
413 fs::remove_dir_all(directory).unwrap();
414 }
415
416 #[cfg(unix)]
417 #[test]
418 fn filesystem_resolver_rejects_a_symlink_escape() {
419 use std::os::unix::fs::symlink;
420
421 let root = temporary_directory("symlink-root");
422 let outside = temporary_directory("symlink-outside");
423 fs::write(outside.join("secret.png"), b"secret").unwrap();
424 symlink(outside.join("secret.png"), root.join("escape.png")).unwrap();
425
426 let resolver = FileResolver::new(&root).unwrap();
427 assert_eq!(
428 resolver.resolve_asset("escape.png", 64).unwrap_err().code(),
429 ErrorCode::AssetSandbox
430 );
431
432 fs::remove_dir_all(root).unwrap();
433 fs::remove_dir_all(outside).unwrap();
434 }
435}