Skip to main content

mathtex_engine/
resource.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::{Component, Path, PathBuf};
4
5/// Resolves the TeX inputs, packages, fonts and other files a format build reads.
6pub trait ResourceProvider {
7    /// Resolves the file `request` names, by its exact name, the engine tries suffixed names itself.
8    fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError>;
9
10    /// Resolves `name` as a resource of the given `kind`.
11    fn read(&self, name: &str, kind: ResourceKind) -> Result<Resource, ResourceError> {
12        self.read_request(&ResourceRequest::new(name, kind))
13    }
14}
15
16impl<T> ResourceProvider for &T
17where
18    T: ResourceProvider + ?Sized,
19{
20    fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
21        (**self).read_request(request)
22    }
23}
24
25/// Resource provider over resources held in memory, keyed by canonical name and kind.
26#[derive(Clone, Debug, Default, PartialEq, Eq)]
27pub struct InMemoryResourceProvider {
28    resources: BTreeMap<(String, ResourceKind), Vec<u8>>,
29}
30
31impl InMemoryResourceProvider {
32    /// Creates an empty provider.
33    #[must_use]
34    pub fn new() -> Self {
35        Self::default()
36    }
37
38    /// Adds a resource and returns the provider.
39    #[must_use]
40    pub fn with_resource(
41        mut self,
42        name: impl Into<String>,
43        kind: ResourceKind,
44        bytes: impl Into<Vec<u8>>,
45    ) -> Self {
46        self.insert(ResourceRequest::new(name, kind), bytes);
47        self
48    }
49
50    /// Adds or replaces the resource that answers `request`.
51    pub fn insert(&mut self, request: ResourceRequest, bytes: impl Into<Vec<u8>>) {
52        self.resources
53            .insert((request.canonical_name(), request.kind), bytes.into());
54    }
55
56    /// Number of resources held.
57    #[must_use]
58    pub fn len(&self) -> usize {
59        self.resources.len()
60    }
61
62    /// Whether the provider holds no resources.
63    #[must_use]
64    pub fn is_empty(&self) -> bool {
65        self.resources.is_empty()
66    }
67}
68
69impl ResourceProvider for InMemoryResourceProvider {
70    fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
71        let name = request.canonical_name();
72        match self.resources.get(&(name, request.kind)) {
73            Some(bytes) => Ok(Resource::answering(request, bytes.clone())),
74            None => Err(ResourceError::not_found(request)),
75        }
76    }
77}
78
79/// Resource provider over a directory, a request's canonical name is a path relative to the root.
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct FileSystemResourceProvider {
82    root: PathBuf,
83}
84
85impl FileSystemResourceProvider {
86    /// Creates a provider that resolves names under `root`.
87    #[must_use]
88    pub fn new(root: impl Into<PathBuf>) -> Self {
89        Self { root: root.into() }
90    }
91
92    /// The directory names resolve under.
93    #[must_use]
94    pub fn root(&self) -> &Path {
95        &self.root
96    }
97}
98
99impl ResourceProvider for FileSystemResourceProvider {
100    fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
101        let name = request.canonical_name();
102        if name.is_empty() {
103            return Err(ResourceError::Invalid {
104                name,
105                message: "resource name is empty".into(),
106            });
107        }
108        let path = Path::new(&name);
109        let escapes = path.components().any(|component| {
110            matches!(
111                component,
112                Component::ParentDir | Component::RootDir | Component::Prefix(_)
113            )
114        });
115        if escapes {
116            return Err(ResourceError::Denied {
117                name,
118                message: "resource path must stay under the provider root".into(),
119            });
120        }
121        match std::fs::read(self.root.join(path)) {
122            Ok(bytes) => Ok(Resource::answering(request, bytes)),
123            Err(error) => Err(ResourceError::from_io(request, &error)),
124        }
125    }
126}
127
128/// A request for a named resource of a given kind.
129#[derive(Clone, Debug, PartialEq, Eq)]
130#[non_exhaustive]
131pub struct ResourceRequest {
132    /// File name as TeX asked for it, without the owning package.
133    pub name: String,
134    /// Kind of file TeX asked for.
135    pub kind: ResourceKind,
136    /// Owning package of an [`ResourceKind::Asset`] request.
137    pub package: Option<String>,
138}
139
140impl ResourceRequest {
141    /// A request for `name` of the given kind.
142    #[must_use]
143    pub fn new(name: impl Into<String>, kind: ResourceKind) -> Self {
144        Self {
145            name: name.into(),
146            kind,
147            package: None,
148        }
149    }
150
151    /// A request for an asset file owned by `package`.
152    #[must_use]
153    pub fn asset(package: impl Into<String>, name: impl Into<String>) -> Self {
154        Self {
155            name: name.into(),
156            kind: ResourceKind::Asset,
157            package: Some(package.into()),
158        }
159    }
160
161    /// The name providers key resources by, `package/name` for an asset and the file name otherwise.
162    #[must_use]
163    pub fn canonical_name(&self) -> String {
164        match (&self.package, self.kind) {
165            (Some(package), ResourceKind::Asset) => format!("{package}/{}", self.name),
166            _ => self.name.clone(),
167        }
168    }
169}
170
171/// A resolved resource.
172#[derive(Clone, Debug, PartialEq, Eq)]
173#[non_exhaustive]
174pub struct Resource {
175    /// Canonical name of the request this resource answers, see [`ResourceRequest::canonical_name`].
176    pub canonical_name: String,
177    /// Kind of the request this resource answers.
178    pub kind: ResourceKind,
179    /// File contents.
180    pub bytes: Vec<u8>,
181}
182
183impl Resource {
184    /// The resource that answers `request` with `bytes`.
185    #[must_use]
186    pub fn answering(request: &ResourceRequest, bytes: impl Into<Vec<u8>>) -> Self {
187        Self {
188            canonical_name: request.canonical_name(),
189            kind: request.kind,
190            bytes: bytes.into(),
191        }
192    }
193}
194
195/// Kind of file a request asks for, which picks the suffixes the engine tries and the search path.
196#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
197#[non_exhaustive]
198pub enum ResourceKind {
199    /// TeX input file requested by `\input`.
200    TexInput,
201    /// LaTeX package.
202    Package,
203    /// LaTeX document class.
204    Class,
205    /// Font definition file such as `.fd`.
206    FontDefinition,
207    /// Package support file such as `.clo`, `.def`, `.ldf` or `.cfg`.
208    PackageSupport,
209    /// Font metric file or font program.
210    Font,
211    /// Font encoding vector.
212    Encoding,
213    /// Font map.
214    Map,
215    /// Engine configuration file.
216    Config,
217    /// Precompiled format image.
218    FormatImage,
219    /// Other file owned by a package.
220    Asset,
221}
222
223impl ResourceKind {
224    /// Suffixes tried in order after the bare name when a request has no extension.
225    #[must_use]
226    pub fn suffixes(self) -> &'static [&'static str] {
227        match self {
228            Self::TexInput => &[".tex", ".ltx", ".def", ".sty", ".cfg", ".fd"],
229            Self::Package => &[".sty", ".tex", ".def", ".ltx"],
230            Self::Class => &[".cls"],
231            Self::FontDefinition => &[".fd"],
232            Self::PackageSupport => &[".def", ".cfg", ".ldf", ".clo", ".sty", ".tex"],
233            Self::Font => &[".tfm", ".otf", ".ttf"],
234            Self::Encoding => &[".enc"],
235            Self::Map => &[".map"],
236            Self::Config => &[".cfg", ".cnf", ".tex"],
237            Self::FormatImage | Self::Asset => &[],
238        }
239    }
240}
241
242/// Why a resource could not be read.
243#[derive(Clone, Debug, PartialEq, Eq)]
244#[non_exhaustive]
245pub enum ResourceError {
246    /// No resource answers the request.
247    NotFound {
248        /// Canonical name of the request.
249        name: String,
250        /// Kind of the request.
251        kind: ResourceKind,
252    },
253    /// The resource exists but cannot serve the request.
254    Invalid {
255        /// Canonical name of the request.
256        name: String,
257        /// Why it cannot serve.
258        message: String,
259    },
260    /// The provider's policy or the file system's permissions refuse the request.
261    Denied {
262        /// Canonical name of the request.
263        name: String,
264        /// Why it was refused.
265        message: String,
266    },
267    /// Reading the resource failed for another reason.
268    Io {
269        /// Canonical name of the request.
270        name: String,
271        /// The failure's kind.
272        error: std::io::ErrorKind,
273        /// The failure's message.
274        message: String,
275    },
276}
277
278impl ResourceError {
279    /// The error for a request no resource answers.
280    #[must_use]
281    pub fn not_found(request: &ResourceRequest) -> Self {
282        Self::NotFound {
283            name: request.canonical_name(),
284            kind: request.kind,
285        }
286    }
287
288    /// The error for a failed read, keeping the kind of the I/O failure.
289    #[must_use]
290    pub fn from_io(request: &ResourceRequest, error: &std::io::Error) -> Self {
291        let name = request.canonical_name();
292        match error.kind() {
293            std::io::ErrorKind::NotFound => Self::not_found(request),
294            std::io::ErrorKind::PermissionDenied => Self::Denied {
295                name,
296                message: error.to_string(),
297            },
298            kind => Self::Io {
299                name,
300                error: kind,
301                message: error.to_string(),
302            },
303        }
304    }
305}
306
307impl fmt::Display for ResourceError {
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        match self {
310            Self::NotFound { name, kind } => write!(f, "{kind:?} resource not found: {name}"),
311            Self::Invalid { name, message } => write!(f, "resource {name} is invalid: {message}"),
312            Self::Denied { name, message } => write!(f, "resource {name} was refused: {message}"),
313            Self::Io { name, message, .. } => {
314                write!(f, "resource {name} could not be read: {message}")
315            }
316        }
317    }
318}
319
320impl std::error::Error for ResourceError {}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn in_memory_provider_keeps_resource_kinds_separate() {
328        let provider =
329            InMemoryResourceProvider::new().with_resource("cmr10", ResourceKind::Font, b"font");
330
331        assert_eq!(
332            provider.read("cmr10", ResourceKind::Font).map(|r| r.bytes),
333            Ok(b"font".to_vec())
334        );
335        assert_eq!(
336            provider.read("cmr10", ResourceKind::Package),
337            Err(ResourceError::NotFound {
338                name: "cmr10".into(),
339                kind: ResourceKind::Package,
340            })
341        );
342    }
343
344    #[test]
345    fn asset_requests_are_keyed_by_their_package() {
346        let mut provider = InMemoryResourceProvider::new();
347        provider.insert(ResourceRequest::asset("mhchem", "arrows.dat"), b"asset");
348
349        let resource = provider
350            .read_request(&ResourceRequest::asset("mhchem", "arrows.dat"))
351            .expect("asset resolves");
352        assert_eq!(resource.canonical_name, "mhchem/arrows.dat");
353        assert!(provider
354            .read_request(&ResourceRequest::asset("other", "arrows.dat"))
355            .is_err());
356    }
357
358    fn scratch_dir(name: &str) -> PathBuf {
359        let dir =
360            std::env::temp_dir().join(format!("mathtex-resource-{name}-{}", std::process::id()));
361        std::fs::create_dir_all(&dir).expect("create test root");
362        dir
363    }
364
365    #[test]
366    fn filesystem_provider_reads_relative_names_and_refuses_escapes() {
367        let root = scratch_dir("read");
368        std::fs::write(root.join("plain.tex"), b"\\relax").expect("write resource");
369        let provider = FileSystemResourceProvider::new(&root);
370
371        let resource = provider
372            .read("plain.tex", ResourceKind::TexInput)
373            .expect("relative resource loads");
374        assert_eq!(resource.canonical_name, "plain.tex");
375        assert_eq!(resource.bytes, b"\\relax");
376        assert!(matches!(
377            provider.read("../plain.tex", ResourceKind::TexInput),
378            Err(ResourceError::Denied { .. })
379        ));
380        assert!(matches!(
381            provider.read("missing.tex", ResourceKind::TexInput),
382            Err(ResourceError::NotFound { .. })
383        ));
384        std::fs::remove_dir_all(root).expect("remove test root");
385    }
386
387    #[test]
388    fn filesystem_read_failures_keep_their_kind() {
389        let root = scratch_dir("kind");
390        std::fs::create_dir_all(root.join("dir.tex")).expect("create directory");
391        let provider = FileSystemResourceProvider::new(&root);
392
393        // Reading a directory fails with an I/O error that is neither missing nor refused.
394        let error = provider
395            .read("dir.tex", ResourceKind::TexInput)
396            .expect_err("a directory is not a file");
397        assert!(matches!(error, ResourceError::Io { .. }), "{error:?}");
398        let denied = ResourceError::from_io(
399            &ResourceRequest::new("x.tex", ResourceKind::TexInput),
400            &std::io::Error::from(std::io::ErrorKind::PermissionDenied),
401        );
402        assert!(matches!(denied, ResourceError::Denied { .. }));
403        std::fs::remove_dir_all(root).expect("remove test root");
404    }
405}