Skip to main content

appcore_filemaker/
asset.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: asset.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded asset contracts and behavior for this crate.
12
13use 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/// Explicit resolved asset bytes and metadata.
24#[derive(Clone, Debug)]
25pub struct Asset {
26    /// Stable logical name.
27    pub name: String,
28    /// Declared media type.
29    pub media_type: String,
30    /// Immutable bytes.
31    pub bytes: Arc<[u8]>,
32    /// SHA-256 digest.
33    pub digest: [u8; 32],
34}
35
36impl Asset {
37    /// Creates an asset and computes its digest.
38    #[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
50/// Read-only explicit asset resolver.
51pub trait AssetResolver: Send + Sync {
52    /// Resolves exact logical name with a caller-supplied byte cap.
53    fn resolve_asset(&self, name: &str, max_bytes: usize) -> Result<Asset>;
54}
55
56/// Read-only explicit template include resolver.
57pub trait TemplateResolver: Send + Sync {
58    /// Resolves exact logical include path with a caller-supplied byte cap.
59    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/// In-memory resolver useful for embedded assets and deterministic tests.
70#[derive(Clone, Debug, Default)]
71pub struct MemoryResolver {
72    entries: BTreeMap<String, MemoryEntry>,
73}
74
75impl MemoryResolver {
76    /// Inserts or replaces one explicitly named entry.
77    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/// Canonical-root filesystem resolver rejecting traversal and escaping symlinks.
149#[derive(Clone, Debug)]
150pub struct FileResolver {
151    root: PathBuf,
152}
153
154impl FileResolver {
155    /// Opens a canonical sandbox root.
156    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}