1use std::collections::HashMap;
9use std::fmt;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex};
12use std::time::SystemTime;
13
14use crate::archive::MdzArchive;
15use crate::paths::find_path;
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum HostKind {
20 File,
22 Directory,
24 Archive,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
30pub enum LinkUse {
31 #[default]
33 Navigate,
34 Embed,
36}
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub enum UnresolvedReason {
41 NotFound,
43 Invalid,
46 Unsupported,
48 NoHostLocation,
50 NestedArchive,
52 Denied,
55}
56
57impl UnresolvedReason {
58 pub const fn as_str(self) -> &'static str {
60 match self {
61 UnresolvedReason::NotFound => "not-found",
62 UnresolvedReason::Invalid => "invalid",
63 UnresolvedReason::Unsupported => "unsupported",
64 UnresolvedReason::NoHostLocation => "no-host-location",
65 UnresolvedReason::NestedArchive => "nested-archive",
66 UnresolvedReason::Denied => "denied",
67 }
68 }
69}
70
71impl fmt::Display for UnresolvedReason {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.write_str(self.as_str())
74 }
75}
76
77#[derive(Clone, Debug, PartialEq, Eq)]
79pub enum LinkTarget {
80 Internal {
82 entry: String,
84 fragment: Option<String>,
86 },
87 Host {
89 path: String,
91 fragment: Option<String>,
93 },
94 Archive {
97 archive: String,
99 entry: String,
101 fragment: Option<String>,
103 },
104 Url,
106 Unresolved(UnresolvedReason),
108}
109
110impl LinkTarget {
111 pub fn kind(&self) -> &'static str {
113 match self {
114 LinkTarget::Internal { .. } => "internal",
115 LinkTarget::Host { .. } => "host",
116 LinkTarget::Archive { .. } => "archive",
117 LinkTarget::Url => "url",
118 LinkTarget::Unresolved(_) => "unresolved",
119 }
120 }
121
122 fn with_fragment(mut self, frag: &Option<String>) -> Self {
123 if let LinkTarget::Internal { fragment, .. }
124 | LinkTarget::Host { fragment, .. }
125 | LinkTarget::Archive { fragment, .. } = &mut self
126 {
127 fragment.clone_from(frag);
128 }
129 self
130 }
131}
132
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub enum LinkSource<'a> {
136 Archive {
138 archive: &'a str,
140 path: &'a str,
142 },
143 Host {
145 host: &'a str,
147 },
148}
149
150pub trait LinkHost {
157 fn kind(&self, path: &str) -> Option<HostKind>;
159 fn find_entry(&self, archive: &str, entry: &str) -> Option<String>;
162 fn entry_point(&self, archive: &str) -> Option<String>;
164
165 fn host_location(&self) -> bool {
167 true
168 }
169 fn outside_links(&self) -> bool {
171 true
172 }
173 fn links_into_archives(&self) -> bool {
175 true
176 }
177 fn trust_root(&self) -> Option<&str> {
180 Some("")
181 }
182 fn is_allowed(&self, _path: &str) -> bool {
184 false
185 }
186 fn load_outside_resources(&self) -> bool {
189 false
190 }
191}
192
193pub fn is_mdz_name(name: &str) -> bool {
195 name.to_ascii_lowercase().ends_with(".mdz")
196}
197
198fn has_scheme(link: &str) -> bool {
199 let mut chars = link.chars();
200 if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) {
201 return false;
202 }
203 for c in chars {
204 if c == ':' {
205 return true;
206 }
207 if !(c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-')) {
208 return false;
209 }
210 }
211 false
212}
213
214fn dir_segments(path: &str) -> Vec<String> {
215 let mut segs: Vec<String> = path.split('/').map(str::to_owned).collect();
216 segs.pop();
217 segs.retain(|s| !s.is_empty());
218 segs
219}
220
221fn hex(b: u8) -> Option<u8> {
222 (b as char).to_digit(16).map(|d| d as u8)
223}
224
225fn percent_decode(raw: &str) -> Option<String> {
227 if !raw.contains('%') {
228 return Some(raw.to_owned());
229 }
230 let b = raw.as_bytes();
231 let mut out = Vec::with_capacity(b.len());
232 let mut i = 0;
233 while i < b.len() {
234 if b[i] == b'%' {
235 let hi = b.get(i + 1).copied().and_then(hex)?;
236 let lo = b.get(i + 2).copied().and_then(hex)?;
237 out.push(hi * 16 + lo);
238 i += 3;
239 } else {
240 out.push(b[i]);
241 i += 1;
242 }
243 }
244 String::from_utf8(out).ok()
245}
246
247fn segments(dest: &str) -> Option<Vec<String>> {
249 if dest.starts_with('/') || dest.contains('\\') {
250 return None;
251 }
252 dest.split('/')
253 .map(|raw| {
254 let s = percent_decode(raw)?;
255 let bad = s
256 .chars()
257 .any(|c| c == '/' || c == '\\' || (c as u32) < 0x20 || c as u32 == 0x7f);
258 (!bad).then_some(s)
259 })
260 .collect()
261}
262
263fn permitted(host: &dyn LinkHost, path: &str) -> bool {
264 if host.is_allowed(path) {
265 return true;
266 }
267 match host.trust_root() {
268 None => false,
269 Some("") => true,
270 Some(root) => path == root || path.starts_with(&format!("{}/", root.trim_end_matches('/'))),
271 }
272}
273
274pub fn resolve_link(source: LinkSource<'_>, link: &str, host: &dyn LinkHost, use_: LinkUse) -> LinkTarget {
282 if has_scheme(link) {
283 return LinkTarget::Url;
284 }
285 let (dest, fragment) = match link.split_once('#') {
287 Some((d, f)) => (d, (!f.is_empty()).then(|| f.to_owned())),
288 None => (link, None),
289 };
290 let dest = dest.split_once('?').map_or(dest, |(d, _)| d);
291 let directory = dest.ends_with('/');
293
294 if dest.is_empty() {
295 return match source {
296 LinkSource::Archive { path, .. } => LinkTarget::Internal {
297 entry: path.to_owned(),
298 fragment,
299 },
300 LinkSource::Host { host: path } => LinkTarget::Host {
301 path: path.to_owned(),
302 fragment,
303 },
304 };
305 }
306 let Some(segs) = segments(dest) else {
307 return LinkTarget::Unresolved(UnresolvedReason::Invalid);
308 };
309
310 let mut i = 0;
311 let mut stack = match source {
312 LinkSource::Archive { archive, path } => {
313 let mut stack = dir_segments(path);
314 let mut left = false;
315 while i < segs.len() {
316 let s = &segs[i];
317 i += 1;
318 match s.as_str() {
319 "" | "." => {}
320 ".." => {
321 if stack.pop().is_none() {
322 left = true;
323 break;
324 }
325 }
326 _ => stack.push(s.clone()),
327 }
328 }
329 if !left {
330 if directory {
331 return LinkTarget::Unresolved(UnresolvedReason::NotFound);
332 }
333 return match host.find_entry(archive, &stack.join("/")) {
334 Some(entry) => LinkTarget::Internal { entry, fragment: None }.with_fragment(&fragment),
335 None => LinkTarget::Unresolved(UnresolvedReason::NotFound),
336 };
337 }
338 if !host.outside_links() {
339 return LinkTarget::Unresolved(UnresolvedReason::Unsupported);
340 }
341 if !host.host_location() {
342 return LinkTarget::Unresolved(UnresolvedReason::NoHostLocation);
343 }
344 if use_ == LinkUse::Embed && !host.load_outside_resources() {
345 return LinkTarget::Unresolved(UnresolvedReason::Denied);
346 }
347 dir_segments(archive)
348 }
349 LinkSource::Host { host: path } => dir_segments(path),
350 };
351
352 while i < segs.len() {
353 let s = &segs[i];
354 i += 1;
355 match s.as_str() {
356 "" | "." => continue,
357 ".." => {
358 if stack.pop().is_none() {
359 return LinkTarget::Unresolved(UnresolvedReason::NotFound);
360 }
361 continue;
362 }
363 _ => stack.push(s.clone()),
364 }
365 let host_path = stack.join("/");
366 if is_mdz_name(s) && permitted(host, &host_path) && host.kind(&host_path) == Some(HostKind::Archive) {
367 if !host.links_into_archives() {
368 return LinkTarget::Unresolved(UnresolvedReason::Unsupported);
369 }
370 if let LinkSource::Archive { archive, .. } = source {
371 if host_path != archive && use_ == LinkUse::Embed && !host.load_outside_resources() {
372 return LinkTarget::Unresolved(UnresolvedReason::Denied);
373 }
374 }
375 return inside_archive(&host_path, &segs[i..], host, &fragment, directory);
376 }
377 }
378
379 let host_path = stack.join("/");
380 if !permitted(host, &host_path) {
382 return LinkTarget::Unresolved(UnresolvedReason::Denied);
383 }
384 if directory || host.kind(&host_path) != Some(HostKind::File) {
385 return LinkTarget::Unresolved(UnresolvedReason::NotFound);
386 }
387 LinkTarget::Host {
388 path: host_path,
389 fragment,
390 }
391}
392
393fn inside_archive(
394 archive: &str,
395 segs: &[String],
396 host: &dyn LinkHost,
397 fragment: &Option<String>,
398 directory: bool,
399) -> LinkTarget {
400 let mut inner: Vec<&str> = Vec::new();
401 for (j, s) in segs.iter().enumerate() {
402 match s.as_str() {
403 "" | "." => continue,
404 ".." => {
405 if inner.pop().is_none() {
406 return LinkTarget::Unresolved(UnresolvedReason::Invalid);
407 }
408 continue;
409 }
410 _ => inner.push(s),
411 }
412 if is_mdz_name(s) && j < segs.len() - 1 {
413 return LinkTarget::Unresolved(UnresolvedReason::NestedArchive);
414 }
415 }
416 let entry = if inner.is_empty() {
417 host.entry_point(archive)
418 } else if directory {
419 None
420 } else {
421 host.find_entry(archive, &inner.join("/"))
422 };
423 match entry {
424 Some(entry) => LinkTarget::Archive {
425 archive: archive.to_owned(),
426 entry,
427 fragment: None,
428 }
429 .with_fragment(fragment),
430 None => LinkTarget::Unresolved(UnresolvedReason::NotFound),
431 }
432}
433
434#[derive(Clone, Debug)]
436pub struct HostOptions {
437 pub outside_links: bool,
439 pub links_into_archives: bool,
441 pub trust_root: Option<String>,
444 pub allowed_paths: Vec<String>,
446 pub load_outside_resources: bool,
448}
449
450impl Default for HostOptions {
451 fn default() -> Self {
452 HostOptions {
453 outside_links: true,
454 links_into_archives: true,
455 trust_root: Some(String::new()),
456 allowed_paths: Vec::new(),
457 load_outside_resources: false,
458 }
459 }
460}
461
462type ArchiveCache = HashMap<String, (Option<SystemTime>, Option<Arc<MdzArchive>>)>;
463
464#[derive(Debug)]
470pub struct FilesystemHost {
471 root: PathBuf,
472 options: HostOptions,
473 cache: Mutex<ArchiveCache>,
474}
475
476impl FilesystemHost {
477 pub fn new(root: impl Into<PathBuf>) -> Self {
479 Self::with_options(root, HostOptions::default())
480 }
481
482 pub fn with_options(root: impl Into<PathBuf>, options: HostOptions) -> Self {
484 FilesystemHost {
485 root: root.into(),
486 options,
487 cache: Mutex::new(HashMap::new()),
488 }
489 }
490
491 fn full(&self, path: &str) -> PathBuf {
492 let mut full = self.root.clone();
493 full.extend(path.split('/').filter(|s| !s.is_empty()));
494 full
495 }
496
497 fn archive(&self, path: &str) -> Option<Arc<MdzArchive>> {
498 let full = self.full(path);
499 let modified = std::fs::metadata(&full).ok()?.modified().ok();
500 let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
501 if let Some((when, archive)) = cache.get(path) {
502 if *when == modified && modified.is_some() {
503 return archive.clone();
504 }
505 }
506 let archive = MdzArchive::open_path(&full).ok().map(Arc::new);
507 cache.insert(path.to_owned(), (modified, archive.clone()));
508 archive
509 }
510}
511
512fn starts_like_zip(path: &Path) -> bool {
513 use std::io::Read;
514 let mut head = [0u8; 4];
515 std::fs::File::open(path)
516 .and_then(|mut f| f.read_exact(&mut head))
517 .is_ok_and(|_| crate::zip::looks_like_zip(&head))
518}
519
520impl LinkHost for FilesystemHost {
521 fn kind(&self, path: &str) -> Option<HostKind> {
522 let full = self.full(path);
523 let meta = std::fs::metadata(&full).ok()?;
524 if meta.is_dir() {
525 Some(HostKind::Directory)
526 } else if meta.is_file() {
527 if is_mdz_name(path) && starts_like_zip(&full) {
528 Some(HostKind::Archive)
529 } else {
530 Some(HostKind::File)
531 }
532 } else {
533 None
534 }
535 }
536
537 fn find_entry(&self, archive: &str, entry: &str) -> Option<String> {
538 let a = self.archive(archive)?;
539 find_path(a.files(), entry).map(str::to_owned)
540 }
541
542 fn entry_point(&self, archive: &str) -> Option<String> {
543 self.archive(archive)?.entry_point().ok()
544 }
545
546 fn outside_links(&self) -> bool {
547 self.options.outside_links
548 }
549 fn links_into_archives(&self) -> bool {
550 self.options.links_into_archives
551 }
552 fn trust_root(&self) -> Option<&str> {
553 self.options.trust_root.as_deref()
554 }
555 fn is_allowed(&self, path: &str) -> bool {
556 self.options.allowed_paths.iter().any(|p| p == path)
557 }
558 fn load_outside_resources(&self) -> bool {
559 self.options.load_outside_resources
560 }
561}