Skip to main content

mathtex_engine/
resource.rs

1use alloc::collections::BTreeMap;
2use alloc::string::String;
3use alloc::vec::Vec;
4#[cfg(feature = "std")]
5use std::path::{Component, Path, PathBuf};
6
7use mathtex_ir::ByteSpan;
8
9/// Resolves TeX inputs, packages, fonts, encodings, maps, and config assets.
10pub trait ResourceProvider {
11    /// Resolve an arbitrary resource described by `request`.
12    fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError>;
13
14    /// Resolve `name` as a resource of the given `kind`.
15    fn read(&self, name: &str, kind: ResourceKind) -> Result<Resource, ResourceError> {
16        self.read_request(&ResourceRequest::new(name, kind))
17    }
18
19    /// Resolve a TeX input file by name.
20    fn read_tex_input(&self, name: &str) -> Result<Resource, ResourceError> {
21        self.read(name, ResourceKind::TexInput)
22    }
23
24    /// Resolve a LaTeX package file by name.
25    fn read_package(&self, name: &str) -> Result<Resource, ResourceError> {
26        self.read(name, ResourceKind::Package)
27    }
28
29    /// Resolve a LaTeX class file by name.
30    fn read_class(&self, name: &str) -> Result<Resource, ResourceError> {
31        self.read(name, ResourceKind::Class)
32    }
33
34    /// Resolve a font definition file by name.
35    fn read_font_definition(&self, name: &str) -> Result<Resource, ResourceError> {
36        self.read(name, ResourceKind::FontDefinition)
37    }
38
39    /// Resolve a package support file by name.
40    fn read_package_support(&self, name: &str) -> Result<Resource, ResourceError> {
41        self.read(name, ResourceKind::PackageSupport)
42    }
43
44    /// Resolve a font program or collection by name.
45    fn read_font(&self, name: &str) -> Result<Resource, ResourceError> {
46        self.read(name, ResourceKind::Font)
47    }
48
49    /// Resolve a font encoding file by name.
50    fn read_encoding(&self, name: &str) -> Result<Resource, ResourceError> {
51        self.read(name, ResourceKind::Encoding)
52    }
53
54    /// Resolve a font map file by name.
55    fn read_map(&self, name: &str) -> Result<Resource, ResourceError> {
56        self.read(name, ResourceKind::Map)
57    }
58
59    /// Resolve a configuration file by name.
60    fn read_config(&self, name: &str) -> Result<Resource, ResourceError> {
61        self.read(name, ResourceKind::Config)
62    }
63
64    /// Resolve a precompiled format image by name.
65    fn read_format_image(&self, name: &str) -> Result<Resource, ResourceError> {
66        self.read(name, ResourceKind::FormatImage)
67    }
68
69    /// Resolve a package-owned asset by package and asset name.
70    fn read_asset(&self, package: &str, name: &str) -> Result<Resource, ResourceError> {
71        self.read_request(&ResourceRequest::asset(package, name))
72    }
73}
74
75impl<T> ResourceProvider for &T
76where
77    T: ResourceProvider,
78{
79    fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
80        (*self).read_request(request)
81    }
82}
83
84/// Resource provider backed by a caller supplied resolver function.
85#[derive(Clone, Debug)]
86pub struct ResolverResourceProvider<F> {
87    resolver: F,
88}
89
90impl<F> ResolverResourceProvider<F> {
91    /// Create a provider that delegates to the given resolver function.
92    #[must_use]
93    pub fn new(resolver: F) -> Self {
94        Self { resolver }
95    }
96
97    /// Return a reference to the underlying resolver function.
98    #[must_use]
99    pub fn resolver(&self) -> &F {
100        &self.resolver
101    }
102}
103
104impl<F> ResourceProvider for ResolverResourceProvider<F>
105where
106    F: Fn(&ResourceRequest) -> Result<Resource, ResourceError>,
107{
108    fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
109        (self.resolver)(request)
110    }
111}
112
113/// Resource provider that holds resources in a keyed in-memory map.
114#[derive(Clone, Debug, Default, PartialEq, Eq)]
115pub struct InMemoryResourceProvider {
116    resources: BTreeMap<ResourceKey, Resource>,
117}
118
119impl InMemoryResourceProvider {
120    /// Create an empty in-memory provider.
121    #[must_use]
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    /// Add a resource by name, kind, and bytes, returning the updated provider.
127    #[must_use]
128    pub fn with_resource(
129        mut self,
130        name: impl Into<String>,
131        kind: ResourceKind,
132        bytes: impl Into<Vec<u8>>,
133    ) -> Self {
134        self.insert(name, kind, bytes);
135        self
136    }
137
138    /// Insert a resource by name, kind, and bytes.
139    pub fn insert(
140        &mut self,
141        name: impl Into<String>,
142        kind: ResourceKind,
143        bytes: impl Into<Vec<u8>>,
144    ) {
145        self.insert_request(ResourceRequest::new(name, kind), bytes);
146    }
147
148    /// Insert a resource from a prepared request and bytes.
149    pub fn insert_request(&mut self, request: ResourceRequest, bytes: impl Into<Vec<u8>>) {
150        let key = ResourceKey::from_request(&request);
151        let resource = Resource {
152            canonical_name: request.canonical_name(),
153            kind: request.kind,
154            bytes: bytes.into(),
155        };
156        self.resources.insert(key, resource);
157    }
158
159    /// Return the number of resources stored in this provider.
160    #[must_use]
161    pub fn len(&self) -> usize {
162        self.resources.len()
163    }
164
165    /// Return true if this provider contains no resources.
166    #[must_use]
167    pub fn is_empty(&self) -> bool {
168        self.resources.is_empty()
169    }
170}
171
172impl ResourceProvider for InMemoryResourceProvider {
173    fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
174        let key = ResourceKey::from_request(request);
175
176        self.resources
177            .get(&key)
178            .cloned()
179            .ok_or_else(|| ResourceError::NotFound {
180                name: request.canonical_name(),
181                kind: request.kind,
182            })
183    }
184}
185
186/// Resource provider that checks an override provider before falling back to the base.
187#[derive(Clone, Debug, PartialEq, Eq)]
188pub struct OverlayResourceProvider<Overrides, Base> {
189    overrides: Overrides,
190    base: Base,
191}
192
193impl<Overrides, Base> OverlayResourceProvider<Overrides, Base> {
194    /// Create a provider that checks `overrides` before `base`.
195    #[must_use]
196    pub fn new(overrides: Overrides, base: Base) -> Self {
197        Self { overrides, base }
198    }
199
200    /// Return a reference to the override provider.
201    #[must_use]
202    pub fn overrides(&self) -> &Overrides {
203        &self.overrides
204    }
205
206    /// Return a reference to the base provider.
207    #[must_use]
208    pub fn base(&self) -> &Base {
209        &self.base
210    }
211}
212
213impl<Overrides, Base> ResourceProvider for OverlayResourceProvider<Overrides, Base>
214where
215    Overrides: ResourceProvider,
216    Base: ResourceProvider,
217{
218    fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
219        match self.overrides.read_request(request) {
220            Ok(resource) => Ok(resource),
221            Err(ResourceError::NotFound { .. }) => self.base.read_request(request),
222            Err(error) => Err(error),
223        }
224    }
225}
226
227/// Named in memory resource universe suitable for embedded TeX/package bundles.
228#[derive(Clone, Debug, PartialEq, Eq)]
229pub struct ResourceBundle {
230    id: String,
231    resources: InMemoryResourceProvider,
232}
233
234impl ResourceBundle {
235    /// Create an empty bundle with the given identifier.
236    #[must_use]
237    pub fn new(id: impl Into<String>) -> Self {
238        Self {
239            id: id.into(),
240            resources: InMemoryResourceProvider::new(),
241        }
242    }
243
244    /// Return the identifier of this bundle.
245    #[must_use]
246    pub fn id(&self) -> &str {
247        &self.id
248    }
249
250    /// Add a resource by name, kind, and bytes, returning the updated bundle.
251    #[must_use]
252    pub fn with_resource(
253        mut self,
254        name: impl Into<String>,
255        kind: ResourceKind,
256        bytes: impl Into<Vec<u8>>,
257    ) -> Self {
258        self.resources.insert(name, kind, bytes);
259        self
260    }
261
262    /// Add a resource from a prepared request and bytes, returning the updated bundle.
263    #[must_use]
264    pub fn with_request(mut self, request: ResourceRequest, bytes: impl Into<Vec<u8>>) -> Self {
265        self.resources.insert_request(request, bytes);
266        self
267    }
268}
269
270impl ResourceProvider for ResourceBundle {
271    fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
272        self.resources.read_request(request)
273    }
274}
275
276/// Filesystem backed provider; gated on the `std` feature to keep the engine `no_std` compatible.
277#[cfg(feature = "std")]
278#[derive(Clone, Debug, PartialEq, Eq)]
279pub struct FileSystemResourceProvider {
280    root: PathBuf,
281}
282
283#[cfg(feature = "std")]
284impl FileSystemResourceProvider {
285    /// Create a provider that resolves resources relative to `root`.
286    #[must_use]
287    pub fn new(root: impl Into<PathBuf>) -> Self {
288        Self { root: root.into() }
289    }
290
291    /// Return the root directory this provider resolves against.
292    #[must_use]
293    pub fn root(&self) -> &Path {
294        &self.root
295    }
296
297    fn validate_name(name: &str, kind: ResourceKind) -> Result<&Path, ResourceError> {
298        let path = Path::new(name);
299        let invalid_component = path.components().any(|component| {
300            matches!(
301                component,
302                Component::ParentDir | Component::RootDir | Component::Prefix(_)
303            )
304        });
305
306        if path.is_absolute() || invalid_component {
307            return Err(ResourceError::Denied {
308                name: name.to_string(),
309                message: "resource path must be relative to the provider root".to_string(),
310            });
311        }
312
313        if name.is_empty() {
314            return Err(ResourceError::Invalid {
315                name: name.to_string(),
316                message: "resource name cannot be empty".to_string(),
317            });
318        }
319
320        let _ = kind;
321        Ok(path)
322    }
323}
324
325#[cfg(feature = "std")]
326impl ResourceProvider for FileSystemResourceProvider {
327    fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
328        let name = request.canonical_name();
329        let relative_path = Self::validate_name(&name, request.kind)?;
330        let path = self.root.join(relative_path);
331        let bytes = std::fs::read(&path).map_err(|error| ResourceError::NotFound {
332            name: format!("{} ({})", name, error),
333            kind: request.kind,
334        })?;
335
336        Ok(Resource {
337            canonical_name: path.to_string_lossy().into_owned(),
338            kind: request.kind,
339            bytes,
340        })
341    }
342}
343
344#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
345struct ResourceKey {
346    name: String,
347    kind: ResourceKind,
348}
349
350impl ResourceKey {
351    fn from_request(request: &ResourceRequest) -> Self {
352        Self {
353            name: request.canonical_name(),
354            kind: request.kind,
355        }
356    }
357}
358
359/// A request to resolve a named resource of a given kind.
360#[derive(Clone, Debug, PartialEq, Eq)]
361pub struct ResourceRequest {
362    /// Base name of the resource being requested.
363    pub name: String,
364    /// Kind of resource being requested.
365    pub kind: ResourceKind,
366    /// Owner package; set only for `ResourceKind::Asset` requests.
367    pub package: Option<String>,
368    /// Source location that triggered this request, if available.
369    pub source: Option<ResourceRequestSource>,
370}
371
372/// Source location that triggered a resource request.
373#[derive(Clone, Debug, PartialEq, Eq)]
374pub struct ResourceRequestSource {
375    /// Name of the source file that issued the request.
376    pub name: String,
377    /// Byte span within the source file that triggered the request.
378    pub span: ByteSpan,
379}
380
381impl ResourceRequest {
382    /// Create a basic request for a named resource of the given kind.
383    #[must_use]
384    pub fn new(name: impl Into<String>, kind: ResourceKind) -> Self {
385        Self {
386            name: name.into(),
387            kind,
388            package: None,
389            source: None,
390        }
391    }
392
393    /// Create a `ResourceKind::Asset` request owned by a package.
394    #[must_use]
395    pub fn asset(package: impl Into<String>, name: impl Into<String>) -> Self {
396        Self {
397            name: name.into(),
398            kind: ResourceKind::Asset,
399            package: Some(package.into()),
400            source: None,
401        }
402    }
403
404    /// Attach a source location to this request.
405    #[must_use]
406    pub fn with_source(mut self, name: impl Into<String>, span: ByteSpan) -> Self {
407        self.source = Some(ResourceRequestSource {
408            name: name.into(),
409            span,
410        });
411        self
412    }
413
414    /// Canonical request name used by simple providers.
415    #[must_use]
416    pub fn canonical_name(&self) -> String {
417        match (&self.package, self.kind) {
418            (Some(package), ResourceKind::Asset) => {
419                let mut name = String::with_capacity(package.len() + 1 + self.name.len());
420                name.push_str(package);
421                name.push('/');
422                name.push_str(&self.name);
423                name
424            }
425            _ => self.name.clone(),
426        }
427    }
428}
429
430/// A resolved resource consisting of its canonical name, kind, and raw bytes.
431#[derive(Clone, Debug, PartialEq, Eq)]
432pub struct Resource {
433    /// Canonical path or name under which this resource was resolved.
434    pub canonical_name: String,
435    /// Kind of this resource.
436    pub kind: ResourceKind,
437    /// Raw bytes of the resource content.
438    pub bytes: Vec<u8>,
439}
440
441impl Resource {
442    /// Create a resource from its canonical name, kind, and bytes.
443    #[must_use]
444    pub fn new(
445        canonical_name: impl Into<String>,
446        kind: ResourceKind,
447        bytes: impl Into<Vec<u8>>,
448    ) -> Self {
449        Self {
450            canonical_name: canonical_name.into(),
451            kind,
452            bytes: bytes.into(),
453        }
454    }
455
456    /// Build a resource from an existing request and the resolved bytes.
457    #[must_use]
458    pub fn from_request(request: &ResourceRequest, bytes: impl Into<Vec<u8>>) -> Self {
459        Self::new(request.canonical_name(), request.kind, bytes)
460    }
461}
462
463/// Discriminant that classifies the kind of a resource request.
464#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
465#[non_exhaustive]
466pub enum ResourceKind {
467    /// TeX input file requested by `\input`.
468    TexInput,
469    /// LaTeX package requested by `\usepackage`.
470    Package,
471    /// LaTeX class requested by `\documentclass`.
472    Class,
473    /// TeX font definition file such as `.fd`.
474    FontDefinition,
475    /// Package support input such as `.clo`, `.def`, `.ldf`, or `.cfg`.
476    PackageSupport,
477    /// Font program or collection.
478    Font,
479    /// Font encoding vector file.
480    Encoding,
481    /// Font map file.
482    Map,
483    /// Engine configuration file such as `texmf.cnf`.
484    Config,
485    /// Precompiled format image.
486    FormatImage,
487    /// Other package owned asset.
488    Asset,
489}
490
491/// Error returned when a resource request cannot be fulfilled.
492#[derive(Clone, Debug, PartialEq, Eq)]
493#[non_exhaustive]
494pub enum ResourceError {
495    /// The requested resource does not exist in the configured universe.
496    NotFound {
497        /// Name of the resource that was not found.
498        name: String,
499        /// Kind of resource that was requested.
500        kind: ResourceKind,
501    },
502    /// The resource exists but is not valid for the requested purpose.
503    Invalid {
504        /// Name of the invalid resource.
505        name: String,
506        /// Description of why the resource is invalid.
507        message: String,
508    },
509    /// The resolver policy denied the request.
510    Denied {
511        /// Name of the denied resource.
512        name: String,
513        /// Reason the request was denied.
514        message: String,
515    },
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn in_memory_provider_loads_package_without_filesystem() {
524        let provider = InMemoryResourceProvider::new().with_resource(
525            "amsmath.sty",
526            ResourceKind::Package,
527            br"\ProvidesPackage{amsmath}".to_vec(),
528        );
529
530        let resource = provider
531            .read("amsmath.sty", ResourceKind::Package)
532            .expect("package should resolve");
533
534        assert_eq!(resource.canonical_name, "amsmath.sty");
535        assert_eq!(resource.kind, ResourceKind::Package);
536        assert_eq!(resource.bytes, br"\ProvidesPackage{amsmath}".to_vec());
537    }
538
539    #[test]
540    fn in_memory_provider_keeps_resource_kinds_separate() {
541        let provider =
542            InMemoryResourceProvider::new().with_resource("cmr10", ResourceKind::Font, b"font");
543
544        let error = provider
545            .read("cmr10", ResourceKind::Package)
546            .expect_err("font must not satisfy package lookup");
547
548        assert_eq!(
549            error,
550            ResourceError::NotFound {
551                name: "cmr10".to_string(),
552                kind: ResourceKind::Package,
553            }
554        );
555    }
556
557    #[test]
558    fn provider_convenience_methods_cover_tex_resource_kinds() {
559        let provider = InMemoryResourceProvider::new()
560            .with_resource("plain.tex", ResourceKind::TexInput, b"tex")
561            .with_resource("amsmath.sty", ResourceKind::Package, b"package")
562            .with_resource("article.cls", ResourceKind::Class, b"class")
563            .with_resource("ot1cmr.fd", ResourceKind::FontDefinition, b"fd")
564            .with_resource("size10.clo", ResourceKind::PackageSupport, b"support")
565            .with_resource("latinmodern-math.otf", ResourceKind::Font, b"font")
566            .with_resource("t1.enc", ResourceKind::Encoding, b"encoding")
567            .with_resource("pdftex.map", ResourceKind::Map, b"map")
568            .with_resource("texmf.cnf", ResourceKind::Config, b"config")
569            .with_resource("latex.fmt", ResourceKind::FormatImage, b"format");
570
571        assert_eq!(
572            provider.read_tex_input("plain.tex").expect("tex").bytes,
573            b"tex"
574        );
575        assert_eq!(
576            provider.read_package("amsmath.sty").expect("package").bytes,
577            b"package"
578        );
579        assert_eq!(
580            provider.read_class("article.cls").expect("class").bytes,
581            b"class"
582        );
583        assert_eq!(
584            provider
585                .read_font_definition("ot1cmr.fd")
586                .expect("font definition")
587                .bytes,
588            b"fd"
589        );
590        assert_eq!(
591            provider
592                .read_package_support("size10.clo")
593                .expect("package support")
594                .bytes,
595            b"support"
596        );
597        assert_eq!(
598            provider
599                .read_font("latinmodern-math.otf")
600                .expect("font")
601                .bytes,
602            b"font"
603        );
604        assert_eq!(
605            provider.read_encoding("t1.enc").expect("encoding").bytes,
606            b"encoding"
607        );
608        assert_eq!(provider.read_map("pdftex.map").expect("map").bytes, b"map");
609        assert_eq!(
610            provider.read_config("texmf.cnf").expect("config").bytes,
611            b"config"
612        );
613        assert_eq!(
614            provider
615                .read_format_image("latex.fmt")
616                .expect("format")
617                .bytes,
618            b"format"
619        );
620    }
621
622    #[test]
623    fn typed_asset_requests_include_package_owner() {
624        let mut provider = InMemoryResourceProvider::new();
625        provider.insert_request(ResourceRequest::asset("mhchem", "arrows.dat"), b"asset");
626
627        let resource = provider
628            .read_asset("mhchem", "arrows.dat")
629            .expect("asset should resolve");
630
631        assert_eq!(resource.kind, ResourceKind::Asset);
632        assert_eq!(resource.canonical_name, "mhchem/arrows.dat");
633        assert_eq!(resource.bytes, b"asset");
634    }
635
636    #[test]
637    fn overlay_provider_prefers_overrides_before_base_bundle() {
638        let base = ResourceBundle::new("latex-base")
639            .with_resource("article.cls", ResourceKind::TexInput, b"base")
640            .with_resource("amsmath.sty", ResourceKind::Package, b"base-ams");
641        let overrides = InMemoryResourceProvider::new().with_resource(
642            "amsmath.sty",
643            ResourceKind::Package,
644            b"override-ams",
645        );
646        let provider = OverlayResourceProvider::new(overrides, base);
647
648        let package = provider
649            .read_package("amsmath.sty")
650            .expect("override package should resolve");
651        let class = provider
652            .read_tex_input("article.cls")
653            .expect("base input should resolve");
654
655        assert_eq!(package.bytes, b"override-ams");
656        assert_eq!(class.bytes, b"base");
657    }
658
659    #[test]
660    fn overlay_provider_preserves_denied_errors_from_overrides() {
661        #[derive(Clone, Debug, PartialEq, Eq)]
662        struct DenyProvider;
663
664        impl ResourceProvider for DenyProvider {
665            fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
666                Err(ResourceError::Denied {
667                    name: request.canonical_name(),
668                    message: "denied by policy".to_string(),
669                })
670            }
671        }
672
673        let base = InMemoryResourceProvider::new().with_resource(
674            "plain.tex",
675            ResourceKind::TexInput,
676            b"base",
677        );
678        let provider = OverlayResourceProvider::new(DenyProvider, base);
679
680        let error = provider
681            .read_tex_input("plain.tex")
682            .expect_err("override denial must not fall through");
683
684        assert_eq!(
685            error,
686            ResourceError::Denied {
687                name: "plain.tex".to_string(),
688                message: "denied by policy".to_string(),
689            }
690        );
691    }
692
693    #[test]
694    fn resource_bundle_resolves_package_owned_assets() {
695        let bundle = ResourceBundle::new("chemistry")
696            .with_request(ResourceRequest::asset("mhchem", "arrows.dat"), b"asset");
697
698        let asset = bundle
699            .read_asset("mhchem", "arrows.dat")
700            .expect("bundle asset should resolve");
701
702        assert_eq!(bundle.id(), "chemistry");
703        assert_eq!(asset.canonical_name, "mhchem/arrows.dat");
704        assert_eq!(asset.bytes, b"asset");
705    }
706
707    #[test]
708    fn resolver_provider_delegates_typed_requests_to_host_resolver() {
709        let provider = ResolverResourceProvider::new(|request: &ResourceRequest| {
710            if request.kind == ResourceKind::Package && request.name == "amsmath.sty" {
711                Ok(Resource::from_request(request, b"package"))
712            } else if request.kind == ResourceKind::Asset
713                && request.package.as_deref() == Some("mhchem")
714                && request.name == "arrows.dat"
715            {
716                Ok(Resource::from_request(request, b"asset"))
717            } else {
718                Err(ResourceError::NotFound {
719                    name: request.canonical_name(),
720                    kind: request.kind,
721                })
722            }
723        });
724
725        let package = provider
726            .read_package("amsmath.sty")
727            .expect("package should resolve through resolver");
728        let asset = provider
729            .read_asset("mhchem", "arrows.dat")
730            .expect("asset should resolve through resolver");
731        let error = provider
732            .read_tex_input("missing.tex")
733            .expect_err("missing input should propagate resolver error");
734
735        assert_eq!(package.canonical_name, "amsmath.sty");
736        assert_eq!(package.bytes, b"package");
737        assert_eq!(asset.canonical_name, "mhchem/arrows.dat");
738        assert_eq!(asset.bytes, b"asset");
739        assert_eq!(
740            error,
741            ResourceError::NotFound {
742                name: "missing.tex".to_string(),
743                kind: ResourceKind::TexInput,
744            }
745        );
746    }
747
748    #[cfg(feature = "std")]
749    #[test]
750    fn filesystem_provider_loads_relative_resource() {
751        let root =
752            std::env::temp_dir().join(format!("mathtex-resource-test-{}", std::process::id()));
753        std::fs::create_dir_all(&root).expect("create test root");
754        let path = root.join("plain.tex");
755        std::fs::write(&path, b"\\relax").expect("write resource");
756
757        let provider = FileSystemResourceProvider::new(&root);
758        let resource = provider
759            .read("plain.tex", ResourceKind::TexInput)
760            .expect("relative resource should load");
761
762        assert_eq!(resource.kind, ResourceKind::TexInput);
763        assert_eq!(resource.bytes, b"\\relax");
764
765        std::fs::remove_file(path).expect("remove resource");
766        std::fs::remove_dir(root).expect("remove test root");
767    }
768
769    #[cfg(feature = "std")]
770    #[test]
771    fn filesystem_provider_rejects_parent_directory_escape() {
772        let provider = FileSystemResourceProvider::new(std::env::temp_dir());
773        let error = provider
774            .read("../plain.tex", ResourceKind::TexInput)
775            .expect_err("parent path must be denied");
776
777        assert_eq!(
778            error,
779            ResourceError::Denied {
780                name: "../plain.tex".to_string(),
781                message: "resource path must be relative to the provider root".to_string(),
782            }
783        );
784    }
785}