1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::{Component, Path, PathBuf};
4
5pub trait ResourceProvider {
7 fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError>;
9
10 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
27pub struct InMemoryResourceProvider {
28 resources: BTreeMap<(String, ResourceKind), Vec<u8>>,
29}
30
31impl InMemoryResourceProvider {
32 #[must_use]
34 pub fn new() -> Self {
35 Self::default()
36 }
37
38 #[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 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 #[must_use]
58 pub fn len(&self) -> usize {
59 self.resources.len()
60 }
61
62 #[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#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct FileSystemResourceProvider {
82 root: PathBuf,
83}
84
85impl FileSystemResourceProvider {
86 #[must_use]
88 pub fn new(root: impl Into<PathBuf>) -> Self {
89 Self { root: root.into() }
90 }
91
92 #[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#[derive(Clone, Debug, PartialEq, Eq)]
130#[non_exhaustive]
131pub struct ResourceRequest {
132 pub name: String,
134 pub kind: ResourceKind,
136 pub package: Option<String>,
138}
139
140impl ResourceRequest {
141 #[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 #[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 #[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#[derive(Clone, Debug, PartialEq, Eq)]
173#[non_exhaustive]
174pub struct Resource {
175 pub canonical_name: String,
177 pub kind: ResourceKind,
179 pub bytes: Vec<u8>,
181}
182
183impl Resource {
184 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
197#[non_exhaustive]
198pub enum ResourceKind {
199 TexInput,
201 Package,
203 Class,
205 FontDefinition,
207 PackageSupport,
209 Font,
211 Encoding,
213 Map,
215 Config,
217 FormatImage,
219 Asset,
221}
222
223impl ResourceKind {
224 #[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#[derive(Clone, Debug, PartialEq, Eq)]
244#[non_exhaustive]
245pub enum ResourceError {
246 NotFound {
248 name: String,
250 kind: ResourceKind,
252 },
253 Invalid {
255 name: String,
257 message: String,
259 },
260 Denied {
262 name: String,
264 message: String,
266 },
267 Io {
269 name: String,
271 error: std::io::ErrorKind,
273 message: String,
275 },
276}
277
278impl ResourceError {
279 #[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 #[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 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}