use crate::format::StoreFormat;
use citum_schema::{Locale, Style, StyleRegistry};
use serde::de::DeserializeOwned;
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::fs;
use std::io::Cursor;
#[cfg(feature = "http")]
use std::io::Read;
#[cfg(feature = "http")]
use std::net::IpAddr;
use std::path::{Path, PathBuf};
#[cfg(feature = "http")]
use std::time::{Duration, SystemTime};
#[cfg(feature = "http")]
use sha2::{Digest, Sha256};
#[cfg(feature = "http")]
use tempfile;
pub use citum_resolver_api::{ResolutionError, ResolverError, StyleResolver};
fn parse_style_bytes(bytes: &[u8], format: StoreFormat) -> Result<Style, ResolverError> {
let doc_format = match format {
StoreFormat::Yaml => citum_schema::StyleDocumentFormat::Yaml,
StoreFormat::Json => citum_schema::StyleDocumentFormat::Json,
StoreFormat::Cbor => citum_schema::StyleDocumentFormat::Cbor,
};
Style::from_document_bytes(bytes, doc_format).map_err(|e| match e {
citum_schema::StyleDocumentError::Yaml(err) => ResolverError::YamlError(err.to_string()),
citum_schema::StyleDocumentError::Json(err) => ResolverError::JsonError(err),
citum_schema::StyleDocumentError::Cbor(err) => ResolverError::CborError(err),
citum_schema::StyleDocumentError::Validation(err) => {
ResolverError::InvalidStyle(err.into())
}
})
}
fn parse_generic_bytes<T: DeserializeOwned>(
bytes: &[u8],
format: StoreFormat,
) -> Result<T, ResolverError> {
match format {
StoreFormat::Yaml => {
serde_yaml::from_slice(bytes).map_err(|e| ResolverError::YamlError(e.to_string()))
}
StoreFormat::Json => serde_json::from_slice(bytes).map_err(ResolverError::JsonError),
StoreFormat::Cbor => ciborium::de::from_reader(Cursor::new(bytes))
.map_err(|e| ResolverError::CborError(e.to_string())),
}
}
pub struct FileResolver;
impl StyleResolver for FileResolver {
type Style = Style;
type Locale = Locale;
fn resolve_style(&self, uri: &str) -> Result<Style, ResolverError> {
let path = if let Some(path_str) = uri.strip_prefix("file://") {
PathBuf::from(path_str)
} else {
PathBuf::from(uri)
};
if path.is_file() {
let content = fs::read(&path)?;
let format = StoreFormat::detect(&path).unwrap_or(StoreFormat::Yaml);
parse_style_bytes(&content, format)
} else {
Err(ResolverError::StyleNotFound(Cow::Owned(uri.to_string())))
}
}
fn resolve_locale(&self, _id: &str) -> Result<Locale, ResolverError> {
Err(ResolverError::LocaleNotFound(Cow::Borrowed(
"file resolver only resolves styles",
)))
}
}
pub struct FileLocaleResolver {
base_dir: PathBuf,
}
impl FileLocaleResolver {
#[must_use]
pub fn new(base_dir: PathBuf) -> Self {
Self { base_dir }
}
}
impl StyleResolver for FileLocaleResolver {
type Style = Style;
type Locale = Locale;
fn resolve_style(&self, uri: &str) -> Result<Style, ResolverError> {
Err(ResolverError::StyleNotFound(Cow::Owned(uri.to_string())))
}
fn resolve_locale(&self, id: &str) -> Result<Locale, ResolverError> {
for ext in StoreFormat::all_extensions() {
let path = self.base_dir.join(format!("{id}.{ext}"));
if !path.is_file() {
continue;
}
let bytes = fs::read(&path)?;
let format = StoreFormat::detect(&path).unwrap_or(StoreFormat::Yaml);
return match format {
StoreFormat::Yaml => Locale::from_yaml_str(&String::from_utf8_lossy(&bytes))
.map_err(|e| ResolverError::YamlError(ToString::to_string(&e))),
StoreFormat::Json => {
serde_json::from_slice(&bytes).map_err(ResolverError::JsonError)
}
StoreFormat::Cbor => ciborium::de::from_reader(Cursor::new(&bytes))
.map_err(|e| ResolverError::CborError(e.to_string())),
};
}
Err(ResolverError::LocaleNotFound(Cow::Owned(id.to_string())))
}
}
pub struct StoreResolver {
data_dir: PathBuf,
format: StoreFormat,
}
impl StyleResolver for StoreResolver {
type Style = Style;
type Locale = Locale;
fn resolve_style(&self, uri: &str) -> Result<Style, ResolverError> {
StoreResolver::resolve_style(self, uri)
}
fn resolve_locale(&self, id: &str) -> Result<Locale, ResolverError> {
StoreResolver::resolve_locale(self, id)
}
}
pub struct EmbeddedResolver;
impl StyleResolver for EmbeddedResolver {
type Style = Style;
type Locale = Locale;
fn resolve_style(&self, uri: &str) -> Result<Style, ResolverError> {
citum_schema::embedded::get_embedded_style(uri)
.ok_or_else(|| ResolverError::StyleNotFound(Cow::Owned(uri.to_string())))?
.map_err(|e| ResolverError::YamlError(ToString::to_string(&e)))
}
fn resolve_locale(&self, id: &str) -> Result<Locale, ResolverError> {
citum_schema::embedded::get_locale(id)
.ok_or_else(|| ResolverError::LocaleNotFound(Cow::Owned(id.to_string())))
}
}
pub struct RegistryResolver {
registry: StyleRegistry,
base_dir: Option<PathBuf>,
#[cfg(feature = "http")]
http: Option<HttpResolver>,
#[cfg(feature = "http")]
git: Option<GitResolver>,
}
impl RegistryResolver {
#[must_use]
pub fn new(registry: StyleRegistry) -> Self {
Self {
registry,
base_dir: None,
#[cfg(feature = "http")]
http: None,
#[cfg(feature = "http")]
git: None,
}
}
#[must_use]
pub fn with_base_dir(mut self, base_dir: PathBuf) -> Self {
self.base_dir = Some(base_dir);
self
}
#[cfg(feature = "http")]
#[must_use]
pub fn with_http(mut self, http: HttpResolver) -> Self {
self.http = Some(http);
self
}
#[cfg(feature = "http")]
#[must_use]
pub fn with_git(mut self, git: GitResolver) -> Self {
self.git = Some(git);
self
}
}
impl StyleResolver for RegistryResolver {
type Style = Style;
type Locale = Locale;
fn resolve_style(&self, uri: &str) -> Result<Style, ResolverError> {
let entry = self
.registry
.resolve(uri)
.ok_or_else(|| ResolverError::StyleNotFound(Cow::Owned(uri.to_string())))?;
if let Some(builtin) = &entry.builtin {
return EmbeddedResolver.resolve_style(builtin);
}
if let Some(path) = &entry.path {
let path = self
.base_dir
.as_ref()
.map_or_else(|| path.clone(), |base| base.join(path));
let path_str = path
.to_str()
.ok_or_else(|| ResolverError::StyleNotFound(Cow::Owned(uri.to_string())))?;
return FileResolver.resolve_style(path_str);
}
#[cfg(feature = "http")]
if let Some(url) = &entry.url {
if url.starts_with("git+") && GitResolver::parse_git_uri(url).is_none() {
return Err(ResolverError::Denied {
uri: url.clone(),
reason: "only git+https:// URLs with safe relative file paths are allowed"
.to_string(),
});
}
if let Some((_, _)) = GitResolver::parse_git_uri(url)
&& let Some(git) = &self.git
{
return git.resolve_style(url);
}
if let Some(http) = &self.http {
return http.resolve_style(url);
}
}
Err(ResolverError::StyleNotFound(Cow::Owned(uri.to_string())))
}
fn resolve_locale(&self, id: &str) -> Result<Locale, ResolverError> {
Err(ResolverError::LocaleNotFound(Cow::Owned(id.to_string())))
}
}
#[cfg(feature = "http")]
pub struct GitResolver {
cache_dir: PathBuf,
policy: RemoteFetchPolicy,
}
#[cfg(feature = "http")]
pub struct HttpResolver {
cache_dir: PathBuf,
client: std::sync::OnceLock<Result<reqwest::blocking::Client, String>>,
policy: RemoteFetchPolicy,
}
#[cfg(feature = "http")]
const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
#[cfg(feature = "http")]
const HTTP_CACHE_MAX_AGE: Duration = Duration::from_hours(24);
#[cfg(feature = "http")]
pub const DEFAULT_REMOTE_FETCH_MAX_BYTES: u64 = 2 * 1024 * 1024;
#[cfg(feature = "http")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteFetchPolicy {
pub allow_http: bool,
pub allow_redirects: bool,
pub max_bytes: u64,
pub allowed_hosts: Vec<String>,
}
#[cfg(feature = "http")]
impl Default for RemoteFetchPolicy {
fn default() -> Self {
Self {
allow_http: false,
allow_redirects: false,
max_bytes: DEFAULT_REMOTE_FETCH_MAX_BYTES,
allowed_hosts: Vec::new(),
}
}
}
#[cfg(feature = "http")]
impl RemoteFetchPolicy {
#[must_use]
pub fn localhost_for_tests() -> Self {
Self {
allow_http: true,
allow_redirects: false,
max_bytes: DEFAULT_REMOTE_FETCH_MAX_BYTES,
allowed_hosts: vec!["127.0.0.1".to_string(), "localhost".to_string()],
}
}
pub fn validate_url(&self, uri: &str) -> Result<reqwest::Url, ResolverError> {
let url = reqwest::Url::parse(uri).map_err(|err| ResolverError::Denied {
uri: uri.to_string(),
reason: format!("invalid URL: {err}"),
})?;
match url.scheme() {
"https" => {}
"http" if self.allow_http => {}
"http" => {
return Err(ResolverError::Denied {
uri: uri.to_string(),
reason: "plaintext HTTP is disabled by default".to_string(),
});
}
scheme => {
return Err(ResolverError::Denied {
uri: uri.to_string(),
reason: format!("unsupported URL scheme '{scheme}'"),
});
}
}
if !url.username().is_empty() || url.password().is_some() {
return Err(ResolverError::Denied {
uri: uri.to_string(),
reason: "credentials in remote style URLs are not allowed".to_string(),
});
}
if url.fragment().is_some() {
return Err(ResolverError::Denied {
uri: uri.to_string(),
reason: "fragments in remote style URLs are not allowed".to_string(),
});
}
let Some(host) = url.host_str() else {
return Err(ResolverError::Denied {
uri: uri.to_string(),
reason: "remote URL has no host".to_string(),
});
};
let host_is_allowlisted =
!self.allowed_hosts.is_empty() && self.allowed_hosts.iter().any(|h| h == host);
if !host_is_allowlisted && host.parse::<IpAddr>().is_ok_and(is_denied_ip_literal) {
return Err(ResolverError::Denied {
uri: uri.to_string(),
reason: format!("IP literal host '{host}' is not allowed by default"),
});
}
if !self.allowed_hosts.is_empty() && !host_is_allowlisted {
return Err(ResolverError::Denied {
uri: uri.to_string(),
reason: format!("host '{host}' not in resolver allowlist"),
});
}
Ok(url)
}
}
#[cfg(feature = "http")]
fn is_denied_ip_literal(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => {
ip.is_private()
|| ip.is_loopback()
|| ip.is_link_local()
|| ip.is_unspecified()
|| ip.is_broadcast()
}
IpAddr::V6(ip) => {
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_unique_local()
|| ip.is_unicast_link_local()
}
}
}
#[cfg(feature = "http")]
impl HttpResolver {
#[must_use]
pub fn new(cache_dir: PathBuf) -> Self {
Self {
cache_dir,
client: std::sync::OnceLock::new(),
policy: RemoteFetchPolicy::default(),
}
}
fn client(&self) -> Result<&reqwest::blocking::Client, ResolverError> {
self.client
.get_or_init(|| {
let mut builder = reqwest::blocking::Client::builder().timeout(HTTP_TIMEOUT);
if !self.policy.allow_redirects {
builder = builder.redirect(reqwest::redirect::Policy::none());
}
builder
.build()
.map_err(|err| format!("failed to build HTTP client: {err}"))
})
.as_ref()
.map_err(|err| ResolverError::HttpError(err.clone()))
}
#[must_use]
pub fn with_policy(mut self, policy: RemoteFetchPolicy) -> Self {
self.policy = policy;
self.client = std::sync::OnceLock::new();
self
}
#[must_use]
pub fn policy(&self) -> &RemoteFetchPolicy {
&self.policy
}
fn read_limited_body(
&self,
uri: &str,
mut response: reqwest::blocking::Response,
) -> Result<Vec<u8>, ResolverError> {
let mut reader = response
.by_ref()
.take(self.policy.max_bytes.saturating_add(1));
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes)?;
if bytes.len() as u64 > self.policy.max_bytes {
return Err(ResolverError::Denied {
uri: uri.to_string(),
reason: format!(
"remote response exceeds {} byte limit",
self.policy.max_bytes
),
});
}
Ok(bytes)
}
fn fetch_validated_bytes(&self, uri: &str) -> Result<Vec<u8>, ResolverError> {
let url = self.policy.validate_url(uri)?;
let response =
self.client()?
.get(url)
.send()
.map_err(|err| ResolverError::NetworkError {
uri: uri.to_string(),
reason: err.to_string(),
})?;
if response.status().is_redirection() && !self.policy.allow_redirects {
return Err(ResolverError::Denied {
uri: uri.to_string(),
reason: format!("redirect response {} is not allowed", response.status()),
});
}
if !response.status().is_success() {
return Err(ResolverError::HttpError(format!(
"failed to fetch {uri}: status {}",
response.status()
)));
}
self.read_limited_body(uri, response)
}
#[must_use]
pub fn from_platform_cache_dir() -> Option<Self> {
crate::platform_cache_dir().map(Self::new)
}
#[must_use]
pub fn with_allowed_hosts(mut self, hosts: Vec<String>) -> Self {
self.policy.allowed_hosts = hosts;
self.client = std::sync::OnceLock::new();
self
}
pub fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
self.fetch_validated_bytes(url)
.map_err(|err| Box::new(err) as Box<dyn std::error::Error>)
}
fn cache_path(&self, uri: &str) -> PathBuf {
let mut hasher = Sha256::new();
hasher.update(uri.as_bytes());
let hash = hasher.finalize();
let mut hex = String::with_capacity(hash.len() * 2);
for byte in hash {
hex.push(hex_digit(byte >> 4));
hex.push(hex_digit(byte & 0x0f));
}
self.cache_dir
.join("styles")
.join("http")
.join(format!("{hex}.yaml"))
}
fn parse_style(uri: &str, bytes: &[u8]) -> Result<Style, ResolverError> {
Style::from_yaml_bytes(bytes).map_err(|err| {
ResolverError::YamlError(format!("failed to parse style fetched from {uri}: {err}"))
})
}
fn cache_is_fresh(path: &Path) -> bool {
path.metadata()
.and_then(|metadata| metadata.modified())
.and_then(|modified| {
SystemTime::now()
.duration_since(modified)
.map_err(std::io::Error::other)
})
.is_ok_and(|age| age < HTTP_CACHE_MAX_AGE)
}
fn write_cache(path: &Path, bytes: &[u8]) -> Result<(), ResolverError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut tmp = tempfile::NamedTempFile::new_in(
path.parent().unwrap_or_else(|| std::path::Path::new(".")),
)?;
std::io::Write::write_all(&mut tmp, bytes)?;
tmp.persist(path).map_err(|e| ResolverError::Io(e.error))?;
Ok(())
}
}
#[cfg(feature = "http")]
fn hex_digit(nibble: u8) -> char {
match nibble {
0..=9 => char::from(b'0' + nibble),
10..=15 => char::from(b'a' + (nibble - 10)),
_ => '?',
}
}
#[cfg(feature = "http")]
impl StyleResolver for HttpResolver {
type Style = Style;
type Locale = Locale;
fn resolve_style(&self, uri: &str) -> Result<Style, ResolverError> {
if !uri.starts_with("http://") && !uri.starts_with("https://") {
return Err(ResolverError::StyleNotFound(Cow::Owned(uri.to_string())));
}
let url = self.policy.validate_url(uri)?;
let cache_path = self.cache_path(uri);
if cache_path.is_file() && Self::cache_is_fresh(&cache_path) {
let bytes = fs::read(&cache_path)?;
return Self::parse_style(uri, &bytes);
}
let fetch_result = self.client()?.get(url).send();
match fetch_result {
Ok(response) => {
if response.status().is_redirection() && !self.policy.allow_redirects {
if cache_path.is_file() {
let bytes = fs::read(&cache_path)?;
return Self::parse_style(uri, &bytes);
}
return Err(ResolverError::Denied {
uri: uri.to_string(),
reason: format!("redirect response {} is not allowed", response.status()),
});
}
if response.status() == reqwest::StatusCode::NOT_FOUND {
if cache_path.is_file() {
let bytes = fs::read(&cache_path)?;
return Self::parse_style(uri, &bytes);
}
return Err(ResolverError::StyleNotFound(Cow::Owned(uri.to_string())));
}
if !response.status().is_success() {
if cache_path.is_file() {
let bytes = fs::read(&cache_path)?;
return Self::parse_style(uri, &bytes);
}
return Err(ResolverError::HttpError(format!(
"failed to fetch {uri}: HTTP {}",
response.status()
)));
}
match self.read_limited_body(uri, response) {
Ok(bytes) => {
let style = Self::parse_style(uri, &bytes)?;
Self::write_cache(&cache_path, &bytes)?;
Ok(style)
}
Err(err) => {
if matches!(err, ResolverError::Denied { .. }) {
return Err(err);
}
if cache_path.is_file() {
let bytes = fs::read(&cache_path)?;
Self::parse_style(uri, &bytes)
} else {
Err(ResolverError::NetworkError {
uri: uri.to_string(),
reason: err.to_string(),
})
}
}
}
}
Err(err) => {
if cache_path.is_file() {
let bytes = fs::read(&cache_path)?;
Self::parse_style(uri, &bytes)
} else {
Err(ResolverError::NetworkError {
uri: uri.to_string(),
reason: err.to_string(),
})
}
}
}
}
fn resolve_locale(&self, id: &str) -> Result<Locale, ResolverError> {
Err(ResolverError::LocaleNotFound(Cow::Owned(id.to_string())))
}
}
#[cfg(feature = "http")]
impl GitResolver {
#[must_use]
pub fn new(cache_dir: PathBuf) -> Self {
Self {
cache_dir,
policy: RemoteFetchPolicy::default(),
}
}
#[must_use]
pub fn with_policy(mut self, policy: RemoteFetchPolicy) -> Self {
self.policy = policy;
self
}
#[must_use]
pub fn from_platform_cache_dir() -> Option<Self> {
crate::platform_cache_dir().map(Self::new)
}
fn cache_path(&self, uri: &str) -> PathBuf {
let mut hasher = Sha256::new();
hasher.update(uri.as_bytes());
let hash = hasher.finalize();
let mut hex = String::with_capacity(hash.len() * 2);
for byte in hash {
hex.push(hex_digit(byte >> 4));
hex.push(hex_digit(byte & 0x0f));
}
self.cache_dir
.join("styles")
.join("git")
.join(format!("{hex}.yaml"))
}
fn parse_style(uri: &str, bytes: &[u8]) -> Result<Style, ResolverError> {
Style::from_yaml_bytes(bytes).map_err(|err| {
ResolverError::YamlError(format!("failed to parse style fetched from {uri}: {err}"))
})
}
fn cache_is_fresh(path: &Path) -> bool {
path.metadata()
.and_then(|metadata| metadata.modified())
.and_then(|modified| {
SystemTime::now()
.duration_since(modified)
.map_err(std::io::Error::other)
})
.is_ok_and(|age| age < HTTP_CACHE_MAX_AGE)
}
fn write_cache(path: &Path, bytes: &[u8]) -> Result<(), ResolverError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut tmp = tempfile::NamedTempFile::new_in(
path.parent().unwrap_or_else(|| std::path::Path::new(".")),
)?;
std::io::Write::write_all(&mut tmp, bytes)?;
tmp.persist(path).map_err(|e| ResolverError::Io(e.error))?;
Ok(())
}
pub fn parse_git_uri(uri: &str) -> Option<(String, String)> {
if !uri.starts_with("git+https://") {
return None;
}
let rest = uri.strip_prefix("git+")?;
let (repo_part, file_path) = rest.split_once('#')?;
if !is_safe_git_style_path(file_path) {
return None;
}
Some((repo_part.to_string(), file_path.to_string()))
}
}
#[cfg(feature = "http")]
fn is_safe_git_style_path(file_path: &str) -> bool {
let path = Path::new(file_path);
!file_path.is_empty()
&& !path.is_absolute()
&& !path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
}
#[cfg(feature = "http")]
impl StyleResolver for GitResolver {
type Style = Style;
type Locale = Locale;
fn resolve_style(&self, uri: &str) -> Result<Style, ResolverError> {
let (repo_url, file_path) = Self::parse_git_uri(uri).ok_or_else(|| {
if uri.starts_with("git+") {
ResolverError::Denied {
uri: uri.to_string(),
reason: "only git+https:// URLs with safe relative file paths are allowed"
.to_string(),
}
} else {
ResolverError::StyleNotFound(Cow::Owned(uri.to_string()))
}
})?;
self.policy.validate_url(&repo_url)?;
let cache_path = self.cache_path(uri);
if cache_path.is_file() && Self::cache_is_fresh(&cache_path) {
let bytes = fs::read(&cache_path)?;
return Self::parse_style(uri, &bytes);
}
let tmpdir = tempfile::TempDir::new().map_err(ResolverError::Io)?;
let mut prepare = gix::prepare_clone(repo_url.clone(), tmpdir.path()).map_err(|e| {
if cache_path.is_file() {
return ResolverError::Io(std::io::Error::other("gix fail"));
}
ResolverError::NetworkError {
uri: uri.to_string(),
reason: format!("git clone failed to prepare: {e}"),
}
})?;
if let Some(depth) = std::num::NonZeroU32::new(1) {
prepare = prepare.with_shallow(gix::remote::fetch::Shallow::DepthAtRemote(depth));
}
let (repo, _) = prepare
.fetch_only(
gix::progress::Discard,
&std::sync::atomic::AtomicBool::new(false),
)
.map_err(|e| {
if cache_path.is_file() {
return ResolverError::Io(std::io::Error::other("gix fetch fail"));
}
ResolverError::NetworkError {
uri: uri.to_string(),
reason: format!("git fetch failed: {e}"),
}
})?;
let bytes_result = (|| -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let head = repo
.head()?
.id()
.ok_or("repository has no HEAD")?
.object()?
.into_commit();
let tree = head.tree()?;
let entry = tree
.lookup_entry_by_path(&file_path)?
.ok_or("file not found in repo")?;
let blob = entry.object()?;
Ok(blob.data.clone())
})();
match bytes_result {
Ok(bytes) => {
let style = Self::parse_style(uri, &bytes)?;
Self::write_cache(&cache_path, &bytes)?;
Ok(style)
}
Err(_) => {
if cache_path.is_file() {
let bytes = fs::read(&cache_path)?;
Self::parse_style(uri, &bytes)
} else {
Err(ResolverError::StyleNotFound(Cow::Owned(uri.to_string())))
}
}
}
}
fn resolve_locale(&self, id: &str) -> Result<Locale, ResolverError> {
Err(ResolverError::LocaleNotFound(Cow::Owned(id.to_string())))
}
}
#[cfg(feature = "http")]
pub const DEFAULT_CID_GATEWAY: &str = "https://dweb.link/ipfs/";
#[cfg(feature = "http")]
pub struct CidResolver {
gateway: String,
http: HttpResolver,
}
#[cfg(feature = "http")]
impl CidResolver {
#[must_use]
pub fn new(gateway: String, http: HttpResolver) -> Self {
let gateway = if gateway.ends_with('/') {
gateway
} else {
format!("{gateway}/")
};
Self { gateway, http }
}
#[must_use]
pub fn from_platform_cache_dir() -> Option<Self> {
HttpResolver::from_platform_cache_dir()
.map(|http| Self::new(DEFAULT_CID_GATEWAY.to_string(), http))
}
fn resolve_cid_uri(&self, uri: &str) -> Result<Style, ResolverError> {
let cid_str = crate::cid::strip_cid_scheme(uri);
let canonical = crate::cid::canonicalize_cid(cid_str)?;
let gateway_url = format!("{}{canonical}", self.gateway);
let bytes =
self.http
.fetch_bytes(&gateway_url)
.map_err(|err| ResolverError::NetworkError {
uri: uri.to_string(),
reason: err.to_string(),
})?;
crate::cid::verify_cid(uri, &canonical, &bytes)?;
Style::from_yaml_bytes(&bytes).map_err(|err| {
ResolverError::YamlError(format!("failed to parse CID-resolved style {uri}: {err}"))
})
}
}
#[cfg(feature = "http")]
impl StyleResolver for CidResolver {
type Style = Style;
type Locale = Locale;
fn resolve_style(&self, uri: &str) -> Result<Style, ResolverError> {
if !crate::cid::is_cid_uri(uri) {
return Err(ResolverError::StyleNotFound(Cow::Owned(uri.to_string())));
}
self.resolve_cid_uri(uri)
}
fn resolve_locale(&self, id: &str) -> Result<Locale, ResolverError> {
Err(ResolverError::LocaleNotFound(Cow::Owned(id.to_string())))
}
}
#[cfg(feature = "http")]
pub struct VerifyingResolver<R: StyleResolver> {
inner: R,
expected: Option<String>,
}
#[cfg(feature = "http")]
impl<R: StyleResolver> VerifyingResolver<R> {
#[must_use]
pub fn new(inner: R, expected_cid: Option<String>) -> Self {
Self {
inner,
expected: expected_cid,
}
}
pub fn into_inner(self) -> R {
self.inner
}
}
#[cfg(feature = "http")]
impl<R: StyleResolver<Style = Style, Locale = Locale>> StyleResolver for VerifyingResolver<R> {
type Style = Style;
type Locale = Locale;
fn resolve_style(&self, uri: &str) -> Result<Style, ResolverError> {
let style = self.inner.resolve_style(uri)?;
if let Some(ref pin) = self.expected {
let bytes = serde_yaml::to_string(&style).map_err(|err| {
ResolverError::YamlError(format!("re-serialize for pin check: {err}"))
})?;
crate::cid::verify_cid(uri, pin, bytes.as_bytes())?;
}
Ok(style)
}
fn resolve_locale(&self, id: &str) -> Result<Locale, ResolverError> {
self.inner.resolve_locale(id)
}
}
#[cfg(feature = "http")]
pub fn fetch_and_verify_bytes(
http: &HttpResolver,
cid_resolver: &CidResolver,
uri: &str,
expected_cid: &str,
) -> Result<Vec<u8>, ResolverError> {
let bytes = if crate::cid::is_cid_uri(uri) {
let canonical = crate::cid::canonicalize_cid(crate::cid::strip_cid_scheme(uri))?;
let gateway_url = format!("{}{canonical}", cid_resolver.gateway);
http.fetch_bytes(&gateway_url)
.map_err(|err| ResolverError::NetworkError {
uri: uri.to_string(),
reason: err.to_string(),
})?
} else if matches!(
reqwest::Url::parse(uri).as_ref().map(reqwest::Url::scheme),
Ok("https" | "http")
) {
http.fetch_bytes(uri)
.map_err(|err| ResolverError::NetworkError {
uri: uri.to_string(),
reason: err.to_string(),
})?
} else {
return Err(ResolverError::Denied {
uri: uri.to_string(),
reason: "extends-pin only supports cid: and policy-approved HTTP(S) URIs".to_string(),
});
};
crate::cid::verify_cid(uri, expected_cid, &bytes)?;
Ok(bytes)
}
pub struct ChainResolver {
resolvers: Vec<Box<dyn StyleResolver<Style = Style, Locale = Locale>>>,
}
impl ChainResolver {
#[must_use]
pub fn new(resolvers: Vec<Box<dyn StyleResolver<Style = Style, Locale = Locale>>>) -> Self {
ChainResolver { resolvers }
}
}
impl StyleResolver for ChainResolver {
type Style = Style;
type Locale = Locale;
fn resolve_style(&self, uri: &str) -> Result<Style, ResolverError> {
for resolver in &self.resolvers {
match resolver.resolve_style(uri) {
Ok(style) => return Ok(style),
Err(ResolverError::StyleNotFound(_)) => {}
Err(err) => return Err(err),
}
}
Err(ResolverError::StyleNotFound(Cow::Owned(uri.to_string())))
}
fn resolve_locale(&self, id: &str) -> Result<Locale, ResolverError> {
for resolver in &self.resolvers {
match resolver.resolve_locale(id) {
Ok(locale) => return Ok(locale),
Err(ResolverError::LocaleNotFound(_)) => {}
Err(err) => return Err(err),
}
}
Err(ResolverError::LocaleNotFound(Cow::Owned(id.to_string())))
}
}
impl StoreResolver {
#[must_use]
pub fn new(data_dir: PathBuf, format: StoreFormat) -> Self {
StoreResolver { data_dir, format }
}
pub fn resolve_style(&self, id: &str) -> Result<Style, ResolverError> {
self.resolve_item(id, "styles", parse_style_bytes)
}
pub fn resolve_locale(&self, id: &str) -> Result<Locale, ResolverError> {
self.resolve_item(id, "locales", parse_generic_bytes)
}
pub fn list_styles(&self) -> Result<Vec<String>, ResolverError> {
self.list_items("styles")
}
pub fn list_locales(&self) -> Result<Vec<String>, ResolverError> {
self.list_items("locales")
}
pub fn install_style(&self, source: &Path) -> Result<String, ResolverError> {
self.install_item(source, "styles")
}
pub fn install_locale(&self, source: &Path) -> Result<String, ResolverError> {
self.install_item(source, "locales")
}
pub fn remove_style(&self, id: &str) -> Result<(), ResolverError> {
self.remove_item(id, "styles")
}
pub fn remove_locale(&self, id: &str) -> Result<(), ResolverError> {
self.remove_item(id, "locales")
}
fn resolve_item<T>(
&self,
id: &str,
category: &str,
parse: impl Fn(&[u8], StoreFormat) -> Result<T, ResolverError>,
) -> Result<T, ResolverError> {
let items_dir = self.data_dir.join(category);
if !items_dir.exists() {
return Err(match category {
"styles" => ResolverError::StyleNotFound(id.to_string().into()),
_ => ResolverError::LocaleNotFound(id.to_string().into()),
});
}
let path = items_dir.join(format!("{}.{}", id, self.format.extension()));
if path.is_file() {
return self.load_item_at(&path, &parse);
}
for ext in StoreFormat::all_extensions() {
let path = items_dir.join(format!("{id}.{ext}"));
if path.is_file() {
return self.load_item_at(&path, &parse);
}
}
Err(match category {
"styles" => ResolverError::StyleNotFound(id.to_string().into()),
_ => ResolverError::LocaleNotFound(id.to_string().into()),
})
}
fn load_item_at<T>(
&self,
path: &Path,
parse: &impl Fn(&[u8], StoreFormat) -> Result<T, ResolverError>,
) -> Result<T, ResolverError> {
let content = fs::read(path)?;
let format = StoreFormat::detect(path).unwrap_or(self.format);
parse(&content, format)
}
fn list_items(&self, category: &str) -> Result<Vec<String>, ResolverError> {
let items_dir = self.data_dir.join(category);
if !items_dir.exists() {
return Ok(Vec::new());
}
let mut names = BTreeSet::new();
for entry in fs::read_dir(items_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file()
&& StoreFormat::detect(&path).is_some()
&& let Some(name) = path.file_stem().and_then(|s| s.to_str())
{
names.insert(name.to_string());
}
}
Ok(names.into_iter().collect())
}
fn install_item(&self, source: &Path, category: &str) -> Result<String, ResolverError> {
let name = source
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| ResolverError::InvalidStyle("no filename".into()))?;
let category_dir = self.data_dir.join(category);
fs::create_dir_all(&category_dir)?;
let source_format = StoreFormat::detect(source).unwrap_or(self.format);
let dest_path = category_dir.join(format!("{}.{}", name, source_format.extension()));
fs::copy(source, dest_path)?;
Ok(name.to_string())
}
fn remove_item(&self, id: &str, category: &str) -> Result<(), ResolverError> {
let items_dir = self.data_dir.join(category);
if !items_dir.exists() {
return Err(match category {
"styles" => ResolverError::StyleNotFound(id.to_string().into()),
_ => ResolverError::LocaleNotFound(id.to_string().into()),
});
}
let mut found = false;
for ext in StoreFormat::all_extensions() {
let path = items_dir.join(format!("{id}.{ext}"));
if path.exists() {
fs::remove_file(path)?;
found = true;
}
}
if !found {
return Err(match category {
"styles" => ResolverError::StyleNotFound(id.to_string().into()),
_ => ResolverError::LocaleNotFound(id.to_string().into()),
});
}
Ok(())
}
}