use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use crate::archive::MdzArchive;
use crate::paths::find_path;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HostKind {
File,
Directory,
Archive,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum LinkUse {
#[default]
Navigate,
Embed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UnresolvedReason {
NotFound,
Invalid,
Unsupported,
NoHostLocation,
NestedArchive,
Denied,
}
impl UnresolvedReason {
pub const fn as_str(self) -> &'static str {
match self {
UnresolvedReason::NotFound => "not-found",
UnresolvedReason::Invalid => "invalid",
UnresolvedReason::Unsupported => "unsupported",
UnresolvedReason::NoHostLocation => "no-host-location",
UnresolvedReason::NestedArchive => "nested-archive",
UnresolvedReason::Denied => "denied",
}
}
}
impl fmt::Display for UnresolvedReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LinkTarget {
Internal {
entry: String,
fragment: Option<String>,
},
Host {
path: String,
fragment: Option<String>,
},
Archive {
archive: String,
entry: String,
fragment: Option<String>,
},
Url,
Unresolved(UnresolvedReason),
}
impl LinkTarget {
pub fn kind(&self) -> &'static str {
match self {
LinkTarget::Internal { .. } => "internal",
LinkTarget::Host { .. } => "host",
LinkTarget::Archive { .. } => "archive",
LinkTarget::Url => "url",
LinkTarget::Unresolved(_) => "unresolved",
}
}
fn with_fragment(mut self, frag: &Option<String>) -> Self {
if let LinkTarget::Internal { fragment, .. }
| LinkTarget::Host { fragment, .. }
| LinkTarget::Archive { fragment, .. } = &mut self
{
fragment.clone_from(frag);
}
self
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinkSource<'a> {
Archive {
archive: &'a str,
path: &'a str,
},
Host {
host: &'a str,
},
}
pub trait LinkHost {
fn kind(&self, path: &str) -> Option<HostKind>;
fn find_entry(&self, archive: &str, entry: &str) -> Option<String>;
fn entry_point(&self, archive: &str) -> Option<String>;
fn host_location(&self) -> bool {
true
}
fn outside_links(&self) -> bool {
true
}
fn links_into_archives(&self) -> bool {
true
}
fn trust_root(&self) -> Option<&str> {
Some("")
}
fn is_allowed(&self, _path: &str) -> bool {
false
}
fn load_outside_resources(&self) -> bool {
false
}
}
pub fn is_mdz_name(name: &str) -> bool {
name.to_ascii_lowercase().ends_with(".mdz")
}
fn has_scheme(link: &str) -> bool {
let mut chars = link.chars();
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) {
return false;
}
for c in chars {
if c == ':' {
return true;
}
if !(c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-')) {
return false;
}
}
false
}
fn dir_segments(path: &str) -> Vec<String> {
let mut segs: Vec<String> = path.split('/').map(str::to_owned).collect();
segs.pop();
segs.retain(|s| !s.is_empty());
segs
}
fn hex(b: u8) -> Option<u8> {
(b as char).to_digit(16).map(|d| d as u8)
}
fn percent_decode(raw: &str) -> Option<String> {
if !raw.contains('%') {
return Some(raw.to_owned());
}
let b = raw.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' {
let hi = b.get(i + 1).copied().and_then(hex)?;
let lo = b.get(i + 2).copied().and_then(hex)?;
out.push(hi * 16 + lo);
i += 3;
} else {
out.push(b[i]);
i += 1;
}
}
String::from_utf8(out).ok()
}
fn segments(dest: &str) -> Option<Vec<String>> {
if dest.starts_with('/') || dest.contains('\\') {
return None;
}
dest.split('/')
.map(|raw| {
let s = percent_decode(raw)?;
let bad = s
.chars()
.any(|c| c == '/' || c == '\\' || (c as u32) < 0x20 || c as u32 == 0x7f);
(!bad).then_some(s)
})
.collect()
}
fn permitted(host: &dyn LinkHost, path: &str) -> bool {
if host.is_allowed(path) {
return true;
}
match host.trust_root() {
None => false,
Some("") => true,
Some(root) => path == root || path.starts_with(&format!("{}/", root.trim_end_matches('/'))),
}
}
pub fn resolve_link(source: LinkSource<'_>, link: &str, host: &dyn LinkHost, use_: LinkUse) -> LinkTarget {
if has_scheme(link) {
return LinkTarget::Url;
}
let (dest, fragment) = match link.split_once('#') {
Some((d, f)) => (d, (!f.is_empty()).then(|| f.to_owned())),
None => (link, None),
};
let dest = dest.split_once('?').map_or(dest, |(d, _)| d);
let directory = dest.ends_with('/');
if dest.is_empty() {
return match source {
LinkSource::Archive { path, .. } => LinkTarget::Internal {
entry: path.to_owned(),
fragment,
},
LinkSource::Host { host: path } => LinkTarget::Host {
path: path.to_owned(),
fragment,
},
};
}
let Some(segs) = segments(dest) else {
return LinkTarget::Unresolved(UnresolvedReason::Invalid);
};
let mut i = 0;
let mut stack = match source {
LinkSource::Archive { archive, path } => {
let mut stack = dir_segments(path);
let mut left = false;
while i < segs.len() {
let s = &segs[i];
i += 1;
match s.as_str() {
"" | "." => {}
".." => {
if stack.pop().is_none() {
left = true;
break;
}
}
_ => stack.push(s.clone()),
}
}
if !left {
if directory {
return LinkTarget::Unresolved(UnresolvedReason::NotFound);
}
return match host.find_entry(archive, &stack.join("/")) {
Some(entry) => LinkTarget::Internal { entry, fragment: None }.with_fragment(&fragment),
None => LinkTarget::Unresolved(UnresolvedReason::NotFound),
};
}
if !host.outside_links() {
return LinkTarget::Unresolved(UnresolvedReason::Unsupported);
}
if !host.host_location() {
return LinkTarget::Unresolved(UnresolvedReason::NoHostLocation);
}
if use_ == LinkUse::Embed && !host.load_outside_resources() {
return LinkTarget::Unresolved(UnresolvedReason::Denied);
}
dir_segments(archive)
}
LinkSource::Host { host: path } => dir_segments(path),
};
while i < segs.len() {
let s = &segs[i];
i += 1;
match s.as_str() {
"" | "." => continue,
".." => {
if stack.pop().is_none() {
return LinkTarget::Unresolved(UnresolvedReason::NotFound);
}
continue;
}
_ => stack.push(s.clone()),
}
let host_path = stack.join("/");
if is_mdz_name(s) && permitted(host, &host_path) && host.kind(&host_path) == Some(HostKind::Archive) {
if !host.links_into_archives() {
return LinkTarget::Unresolved(UnresolvedReason::Unsupported);
}
if let LinkSource::Archive { archive, .. } = source {
if host_path != archive && use_ == LinkUse::Embed && !host.load_outside_resources() {
return LinkTarget::Unresolved(UnresolvedReason::Denied);
}
}
return inside_archive(&host_path, &segs[i..], host, &fragment, directory);
}
}
let host_path = stack.join("/");
if !permitted(host, &host_path) {
return LinkTarget::Unresolved(UnresolvedReason::Denied);
}
if directory || host.kind(&host_path) != Some(HostKind::File) {
return LinkTarget::Unresolved(UnresolvedReason::NotFound);
}
LinkTarget::Host {
path: host_path,
fragment,
}
}
fn inside_archive(
archive: &str,
segs: &[String],
host: &dyn LinkHost,
fragment: &Option<String>,
directory: bool,
) -> LinkTarget {
let mut inner: Vec<&str> = Vec::new();
for (j, s) in segs.iter().enumerate() {
match s.as_str() {
"" | "." => continue,
".." => {
if inner.pop().is_none() {
return LinkTarget::Unresolved(UnresolvedReason::Invalid);
}
continue;
}
_ => inner.push(s),
}
if is_mdz_name(s) && j < segs.len() - 1 {
return LinkTarget::Unresolved(UnresolvedReason::NestedArchive);
}
}
let entry = if inner.is_empty() {
host.entry_point(archive)
} else if directory {
None
} else {
host.find_entry(archive, &inner.join("/"))
};
match entry {
Some(entry) => LinkTarget::Archive {
archive: archive.to_owned(),
entry,
fragment: None,
}
.with_fragment(fragment),
None => LinkTarget::Unresolved(UnresolvedReason::NotFound),
}
}
#[derive(Clone, Debug)]
pub struct HostOptions {
pub outside_links: bool,
pub links_into_archives: bool,
pub trust_root: Option<String>,
pub allowed_paths: Vec<String>,
pub load_outside_resources: bool,
}
impl Default for HostOptions {
fn default() -> Self {
HostOptions {
outside_links: true,
links_into_archives: true,
trust_root: Some(String::new()),
allowed_paths: Vec::new(),
load_outside_resources: false,
}
}
}
type ArchiveCache = HashMap<String, (Option<SystemTime>, Option<Arc<MdzArchive>>)>;
#[derive(Debug)]
pub struct FilesystemHost {
root: PathBuf,
options: HostOptions,
cache: Mutex<ArchiveCache>,
}
impl FilesystemHost {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self::with_options(root, HostOptions::default())
}
pub fn with_options(root: impl Into<PathBuf>, options: HostOptions) -> Self {
FilesystemHost {
root: root.into(),
options,
cache: Mutex::new(HashMap::new()),
}
}
fn full(&self, path: &str) -> PathBuf {
let mut full = self.root.clone();
full.extend(path.split('/').filter(|s| !s.is_empty()));
full
}
fn archive(&self, path: &str) -> Option<Arc<MdzArchive>> {
let full = self.full(path);
let modified = std::fs::metadata(&full).ok()?.modified().ok();
let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some((when, archive)) = cache.get(path) {
if *when == modified && modified.is_some() {
return archive.clone();
}
}
let archive = MdzArchive::open_path(&full).ok().map(Arc::new);
cache.insert(path.to_owned(), (modified, archive.clone()));
archive
}
}
fn starts_like_zip(path: &Path) -> bool {
use std::io::Read;
let mut head = [0u8; 4];
std::fs::File::open(path)
.and_then(|mut f| f.read_exact(&mut head))
.is_ok_and(|_| crate::zip::looks_like_zip(&head))
}
impl LinkHost for FilesystemHost {
fn kind(&self, path: &str) -> Option<HostKind> {
let full = self.full(path);
let meta = std::fs::metadata(&full).ok()?;
if meta.is_dir() {
Some(HostKind::Directory)
} else if meta.is_file() {
if is_mdz_name(path) && starts_like_zip(&full) {
Some(HostKind::Archive)
} else {
Some(HostKind::File)
}
} else {
None
}
}
fn find_entry(&self, archive: &str, entry: &str) -> Option<String> {
let a = self.archive(archive)?;
find_path(a.files(), entry).map(str::to_owned)
}
fn entry_point(&self, archive: &str) -> Option<String> {
self.archive(archive)?.entry_point().ok()
}
fn outside_links(&self) -> bool {
self.options.outside_links
}
fn links_into_archives(&self) -> bool {
self.options.links_into_archives
}
fn trust_root(&self) -> Option<&str> {
self.options.trust_root.as_deref()
}
fn is_allowed(&self, path: &str) -> bool {
self.options.allowed_paths.iter().any(|p| p == path)
}
fn load_outside_resources(&self) -> bool {
self.options.load_outside_resources
}
}