use crate::compat::{format, String, Vec};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ResourceId(String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResourceIdError {
Empty,
EscapesRoot,
}
impl core::fmt::Display for ResourceIdError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Empty => f.write_str("a resource id cannot be empty"),
Self::EscapesRoot => f.write_str("a resource id cannot contain a `..` segment"),
}
}
}
impl ResourceId {
pub fn parse(raw: &str) -> Result<Self, ResourceIdError> {
let mut normalized = String::new();
let mut first = true;
for segment in raw.split(['/', '\\']) {
if segment.is_empty() || segment == "." {
continue;
}
if segment == ".." {
return Err(ResourceIdError::EscapesRoot);
}
if !first {
normalized.push('/');
}
normalized.push_str(segment);
first = false;
}
if normalized.is_empty() {
return Err(ResourceIdError::Empty);
}
Ok(Self(normalized))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn file_name(&self) -> &str {
self.0.rsplit('/').next().unwrap_or(&self.0)
}
pub fn parent(&self) -> &str {
match self.0.rfind('/') {
Some(index) => &self.0[..index],
None => "",
}
}
}
impl core::fmt::Display for ResourceId {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolveError {
NotFound {
id: ResourceId,
},
Ambiguous {
id: ResourceId,
claimants: usize,
},
Failed {
id: ResourceId,
reason: String,
},
}
impl core::fmt::Display for ResolveError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::NotFound { id } => write!(f, "no source resolves the resource `{id}`"),
Self::Ambiguous { id, claimants } => write!(
f,
"{claimants} sources claim the resource `{id}`, so its identity is ambiguous"
),
Self::Failed { id, reason } => write!(f, "the source for `{id}` failed: {reason}"),
}
}
}
pub trait ResourceSource {
fn name(&self) -> &str;
fn contains(&self, id: &ResourceId) -> bool;
fn get(&self, id: &ResourceId) -> Option<Vec<u8>>;
}
#[derive(Debug, Clone, Default)]
pub struct EmbeddedSource {
name: String,
entries: crate::compat::HashMap<String, Vec<u8>>,
}
impl EmbeddedSource {
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into(), entries: crate::compat::HashMap::new() }
}
pub fn insert(&mut self, id: ResourceId, bytes: Vec<u8>) {
self.entries.insert(id.as_str().into(), bytes);
}
pub fn ids(&self) -> Vec<ResourceId> {
let mut ids: Vec<ResourceId> =
self.entries.keys().filter_map(|key| ResourceId::parse(key).ok()).collect();
ids.sort();
ids
}
}
impl ResourceSource for EmbeddedSource {
fn name(&self) -> &str {
&self.name
}
fn contains(&self, id: &ResourceId) -> bool {
self.entries.contains_key(id.as_str())
}
fn get(&self, id: &ResourceId) -> Option<Vec<u8>> {
self.entries.get(id.as_str()).cloned()
}
}
#[derive(Default)]
pub struct ResourceResolver {
sources: Vec<crate::compat::Box<dyn ResourceSource>>,
}
impl core::fmt::Debug for ResourceResolver {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("ResourceResolver")
.field("sources", &self.sources.iter().map(|s| s.name()).collect::<Vec<_>>())
.finish()
}
}
impl ResourceResolver {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, source: crate::compat::Box<dyn ResourceSource>) {
self.sources.push(source);
}
pub fn source_count(&self) -> usize {
self.sources.len()
}
pub fn resolve(&self, id: &ResourceId) -> Result<Vec<u8>, ResolveError> {
let claimants: Vec<&crate::compat::Box<dyn ResourceSource>> =
self.sources.iter().filter(|source| source.contains(id)).collect();
match claimants.len() {
0 => Err(ResolveError::NotFound { id: id.clone() }),
1 => claimants[0].get(id).ok_or_else(|| ResolveError::Failed {
id: id.clone(),
reason: format!(
"source `{}` claimed the id but returned no bytes",
claimants[0].name()
),
}),
claimants => Err(ResolveError::Ambiguous { id: id.clone(), claimants }),
}
}
pub fn origins(&self, id: &ResourceId) -> Vec<&str> {
self.sources
.iter()
.filter(|source| source.contains(id))
.map(|source| source.name())
.collect()
}
pub fn resolve_preferring(&self, id: &ResourceId) -> Result<Vec<u8>, ResolveError> {
for source in &self.sources {
if source.contains(id) {
return source.get(id).ok_or_else(|| ResolveError::Failed {
id: id.clone(),
reason: format!(
"source `{}` claimed the id but returned no bytes",
source.name()
),
});
}
}
Err(ResolveError::NotFound { id: id.clone() })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn source_with(name: &str, entries: &[(&str, &[u8])]) -> EmbeddedSource {
let mut source = EmbeddedSource::new(name);
for (id, bytes) in entries {
source.insert(ResourceId::parse(id).expect("a valid id"), bytes.to_vec());
}
source
}
#[test]
fn equivalent_spellings_normalise_to_one_id() {
let canonical = ResourceId::parse("icons/save.png").expect("valid");
for spelling in
["./icons/save.png", "icons\\save.png", "icons//save.png", "/icons/save.png"]
{
assert_eq!(
ResourceId::parse(spelling).expect("valid"),
canonical,
"`{spelling}` must normalise to the same id"
);
}
}
#[test]
fn an_escaping_name_is_rejected() {
assert_eq!(ResourceId::parse("../secret"), Err(ResourceIdError::EscapesRoot));
assert_eq!(ResourceId::parse("a/../../b"), Err(ResourceIdError::EscapesRoot));
assert_eq!(ResourceId::parse(""), Err(ResourceIdError::Empty));
assert_eq!(ResourceId::parse("./."), Err(ResourceIdError::Empty));
}
#[test]
fn an_id_splits_into_a_name_and_a_parent() {
let id = ResourceId::parse("audio/ui/click.wav").expect("valid");
assert_eq!(id.file_name(), "click.wav");
assert_eq!(id.parent(), "audio/ui");
let top = ResourceId::parse("icon.png").expect("valid");
assert_eq!(top.file_name(), "icon.png");
assert_eq!(top.parent(), "");
}
#[test]
fn one_source_resolves_its_bytes() {
let mut resolver = ResourceResolver::new();
resolver.add(crate::compat::Box::new(source_with("embedded", &[("a/b.png", b"\x89PNG")])));
let id = ResourceId::parse("a/b.png").expect("valid");
assert_eq!(resolver.resolve(&id).expect("resolves"), b"\x89PNG".to_vec());
}
#[test]
fn a_missing_id_is_not_found() {
let mut resolver = ResourceResolver::new();
resolver.add(crate::compat::Box::new(source_with("embedded", &[("a.png", b"x")])));
let id = ResourceId::parse("missing.png").expect("valid");
assert_eq!(resolver.resolve(&id), Err(ResolveError::NotFound { id }));
}
#[test]
fn a_duplicated_id_is_ambiguous() {
let mut resolver = ResourceResolver::new();
resolver.add(crate::compat::Box::new(source_with("first", &[("logo.png", b"first")])));
resolver.add(crate::compat::Box::new(source_with("second", &[("logo.png", b"second")])));
let id = ResourceId::parse("logo.png").expect("valid");
assert_eq!(
resolver.resolve(&id),
Err(ResolveError::Ambiguous { id, claimants: 2 }),
"a duplicated id must not resolve to an arbitrary claimant"
);
}
#[test]
fn origins_lists_every_claimant() {
let mut resolver = ResourceResolver::new();
resolver.add(crate::compat::Box::new(source_with("first", &[("logo.png", b"a")])));
resolver.add(crate::compat::Box::new(source_with("second", &[("logo.png", b"b")])));
let id = ResourceId::parse("logo.png").expect("valid");
assert_eq!(resolver.origins(&id), vec!["first", "second"]);
}
#[test]
fn the_shadowing_form_resolves_the_first_claimant() {
let mut resolver = ResourceResolver::new();
resolver.add(crate::compat::Box::new(source_with("override", &[("theme.css", b"new")])));
resolver.add(crate::compat::Box::new(source_with("default", &[("theme.css", b"old")])));
let id = ResourceId::parse("theme.css").expect("valid");
assert_eq!(resolver.resolve_preferring(&id).expect("resolves"), b"new".to_vec());
}
#[test]
fn an_id_resolves_independently_of_where_the_bytes_live() {
let id = ResourceId::parse("icons/save.png").expect("valid");
let mut runtime = ResourceResolver::new();
runtime.add(crate::compat::Box::new(source_with("files", &[("icons/save.png", b"disk")])));
let mut package = ResourceResolver::new();
package
.add(crate::compat::Box::new(source_with("embedded", &[("icons/save.png", b"bytes")])));
assert!(runtime.resolve(&id).is_ok());
assert!(package.resolve(&id).is_ok());
assert_eq!(runtime.origins(&id), vec!["files"]);
assert_eq!(package.origins(&id), vec!["embedded"]);
}
#[test]
fn an_embedded_source_lists_its_ids_sorted() {
let source = source_with("t", &[("b.png", b"b"), ("a.png", b"a"), ("c.png", b"c")]);
let ids: Vec<String> = source.ids().iter().map(|id| id.as_str().into()).collect();
assert_eq!(ids, vec!["a.png", "b.png", "c.png"]);
}
}