use std::collections::BTreeMap;
use std::fmt;
use std::path::{Component, Path, PathBuf};
pub trait ResourceProvider {
fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError>;
fn read(&self, name: &str, kind: ResourceKind) -> Result<Resource, ResourceError> {
self.read_request(&ResourceRequest::new(name, kind))
}
}
impl<T> ResourceProvider for &T
where
T: ResourceProvider + ?Sized,
{
fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
(**self).read_request(request)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct InMemoryResourceProvider {
resources: BTreeMap<(String, ResourceKind), Vec<u8>>,
}
impl InMemoryResourceProvider {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_resource(
mut self,
name: impl Into<String>,
kind: ResourceKind,
bytes: impl Into<Vec<u8>>,
) -> Self {
self.insert(ResourceRequest::new(name, kind), bytes);
self
}
pub fn insert(&mut self, request: ResourceRequest, bytes: impl Into<Vec<u8>>) {
self.resources
.insert((request.canonical_name(), request.kind), bytes.into());
}
#[must_use]
pub fn len(&self) -> usize {
self.resources.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.resources.is_empty()
}
}
impl ResourceProvider for InMemoryResourceProvider {
fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
let name = request.canonical_name();
match self.resources.get(&(name, request.kind)) {
Some(bytes) => Ok(Resource::answering(request, bytes.clone())),
None => Err(ResourceError::not_found(request)),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileSystemResourceProvider {
root: PathBuf,
}
impl FileSystemResourceProvider {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
}
impl ResourceProvider for FileSystemResourceProvider {
fn read_request(&self, request: &ResourceRequest) -> Result<Resource, ResourceError> {
let name = request.canonical_name();
if name.is_empty() {
return Err(ResourceError::Invalid {
name,
message: "resource name is empty".into(),
});
}
let path = Path::new(&name);
let escapes = path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
});
if escapes {
return Err(ResourceError::Denied {
name,
message: "resource path must stay under the provider root".into(),
});
}
match std::fs::read(self.root.join(path)) {
Ok(bytes) => Ok(Resource::answering(request, bytes)),
Err(error) => Err(ResourceError::from_io(request, &error)),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ResourceRequest {
pub name: String,
pub kind: ResourceKind,
pub package: Option<String>,
}
impl ResourceRequest {
#[must_use]
pub fn new(name: impl Into<String>, kind: ResourceKind) -> Self {
Self {
name: name.into(),
kind,
package: None,
}
}
#[must_use]
pub fn asset(package: impl Into<String>, name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: ResourceKind::Asset,
package: Some(package.into()),
}
}
#[must_use]
pub fn canonical_name(&self) -> String {
match (&self.package, self.kind) {
(Some(package), ResourceKind::Asset) => format!("{package}/{}", self.name),
_ => self.name.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct Resource {
pub canonical_name: String,
pub kind: ResourceKind,
pub bytes: Vec<u8>,
}
impl Resource {
#[must_use]
pub fn answering(request: &ResourceRequest, bytes: impl Into<Vec<u8>>) -> Self {
Self {
canonical_name: request.canonical_name(),
kind: request.kind,
bytes: bytes.into(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum ResourceKind {
TexInput,
Package,
Class,
FontDefinition,
PackageSupport,
Font,
Encoding,
Map,
Config,
FormatImage,
Asset,
}
impl ResourceKind {
#[must_use]
pub fn suffixes(self) -> &'static [&'static str] {
match self {
Self::TexInput => &[".tex", ".ltx", ".def", ".sty", ".cfg", ".fd"],
Self::Package => &[".sty", ".tex", ".def", ".ltx"],
Self::Class => &[".cls"],
Self::FontDefinition => &[".fd"],
Self::PackageSupport => &[".def", ".cfg", ".ldf", ".clo", ".sty", ".tex"],
Self::Font => &[".tfm", ".otf", ".ttf"],
Self::Encoding => &[".enc"],
Self::Map => &[".map"],
Self::Config => &[".cfg", ".cnf", ".tex"],
Self::FormatImage | Self::Asset => &[],
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ResourceError {
NotFound {
name: String,
kind: ResourceKind,
},
Invalid {
name: String,
message: String,
},
Denied {
name: String,
message: String,
},
Io {
name: String,
error: std::io::ErrorKind,
message: String,
},
}
impl ResourceError {
#[must_use]
pub fn not_found(request: &ResourceRequest) -> Self {
Self::NotFound {
name: request.canonical_name(),
kind: request.kind,
}
}
#[must_use]
pub fn from_io(request: &ResourceRequest, error: &std::io::Error) -> Self {
let name = request.canonical_name();
match error.kind() {
std::io::ErrorKind::NotFound => Self::not_found(request),
std::io::ErrorKind::PermissionDenied => Self::Denied {
name,
message: error.to_string(),
},
kind => Self::Io {
name,
error: kind,
message: error.to_string(),
},
}
}
}
impl fmt::Display for ResourceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotFound { name, kind } => write!(f, "{kind:?} resource not found: {name}"),
Self::Invalid { name, message } => write!(f, "resource {name} is invalid: {message}"),
Self::Denied { name, message } => write!(f, "resource {name} was refused: {message}"),
Self::Io { name, message, .. } => {
write!(f, "resource {name} could not be read: {message}")
}
}
}
}
impl std::error::Error for ResourceError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_memory_provider_keeps_resource_kinds_separate() {
let provider =
InMemoryResourceProvider::new().with_resource("cmr10", ResourceKind::Font, b"font");
assert_eq!(
provider.read("cmr10", ResourceKind::Font).map(|r| r.bytes),
Ok(b"font".to_vec())
);
assert_eq!(
provider.read("cmr10", ResourceKind::Package),
Err(ResourceError::NotFound {
name: "cmr10".into(),
kind: ResourceKind::Package,
})
);
}
#[test]
fn asset_requests_are_keyed_by_their_package() {
let mut provider = InMemoryResourceProvider::new();
provider.insert(ResourceRequest::asset("mhchem", "arrows.dat"), b"asset");
let resource = provider
.read_request(&ResourceRequest::asset("mhchem", "arrows.dat"))
.expect("asset resolves");
assert_eq!(resource.canonical_name, "mhchem/arrows.dat");
assert!(provider
.read_request(&ResourceRequest::asset("other", "arrows.dat"))
.is_err());
}
fn scratch_dir(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("mathtex-resource-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create test root");
dir
}
#[test]
fn filesystem_provider_reads_relative_names_and_refuses_escapes() {
let root = scratch_dir("read");
std::fs::write(root.join("plain.tex"), b"\\relax").expect("write resource");
let provider = FileSystemResourceProvider::new(&root);
let resource = provider
.read("plain.tex", ResourceKind::TexInput)
.expect("relative resource loads");
assert_eq!(resource.canonical_name, "plain.tex");
assert_eq!(resource.bytes, b"\\relax");
assert!(matches!(
provider.read("../plain.tex", ResourceKind::TexInput),
Err(ResourceError::Denied { .. })
));
assert!(matches!(
provider.read("missing.tex", ResourceKind::TexInput),
Err(ResourceError::NotFound { .. })
));
std::fs::remove_dir_all(root).expect("remove test root");
}
#[test]
fn filesystem_read_failures_keep_their_kind() {
let root = scratch_dir("kind");
std::fs::create_dir_all(root.join("dir.tex")).expect("create directory");
let provider = FileSystemResourceProvider::new(&root);
let error = provider
.read("dir.tex", ResourceKind::TexInput)
.expect_err("a directory is not a file");
assert!(matches!(error, ResourceError::Io { .. }), "{error:?}");
let denied = ResourceError::from_io(
&ResourceRequest::new("x.tex", ResourceKind::TexInput),
&std::io::Error::from(std::io::ErrorKind::PermissionDenied),
);
assert!(matches!(denied, ResourceError::Denied { .. }));
std::fs::remove_dir_all(root).expect("remove test root");
}
}