1use std::collections::HashMap;
4use std::fmt;
5use std::path::{Path, PathBuf};
6
7use crate::entrypoint::resolve_entry_point;
8use crate::error::{ErrorCode, MdzError, Result, err};
9use crate::links::{FilesystemHost, HostKind, LinkHost, LinkSource, LinkTarget, LinkUse, resolve_link};
10use crate::manifest::{Manifest, Mode, manifest_mode, parse_manifest, validate_manifest};
11use crate::paths::{is_markdown, nfc, strip_dot_slash, validate_entry_set, validate_path};
12use crate::zip::{self, METHOD_DEFLATE, METHOD_STORED, ZipEntry};
13
14pub const MANIFEST_NAME: &str = "manifest.json";
16
17#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct Limits {
24 pub max_entries: u64,
26 pub max_total_uncompressed: u64,
28 pub max_entry_uncompressed: u64,
30 pub max_compression_ratio: f64,
32 pub ratio_applies_above: u64,
34}
35
36impl Limits {
37 pub const DEFAULT: Limits = Limits {
39 max_entries: 100_000,
40 max_total_uncompressed: 4 * 1024 * 1024 * 1024,
41 max_entry_uncompressed: 1024 * 1024 * 1024,
42 max_compression_ratio: 1000.0,
43 ratio_applies_above: 1024 * 1024,
44 };
45}
46
47impl Default for Limits {
48 fn default() -> Self {
49 Limits::DEFAULT
50 }
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
55pub enum Level {
56 Error,
58 Warning,
60 Info,
62}
63
64impl fmt::Display for Level {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 f.write_str(match self {
67 Level::Error => "error",
68 Level::Warning => "warning",
69 Level::Info => "info",
70 })
71 }
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct Diagnostic {
77 pub level: Level,
79 pub code: &'static str,
82 pub message: String,
84}
85
86impl Diagnostic {
87 fn new(level: Level, code: &'static str, message: impl Into<String>) -> Self {
88 Diagnostic {
89 level,
90 code,
91 message: message.into(),
92 }
93 }
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct EntryInfo {
99 pub name: String,
101 pub compression_method: u16,
103 pub size: u64,
105 pub compressed_size: u64,
107 pub crc32: u32,
109 pub dos_date_time: (u16, u16),
111 pub unix_mode: Option<u32>,
113}
114
115impl From<&ZipEntry> for EntryInfo {
116 fn from(e: &ZipEntry) -> Self {
117 let mode = e.external_attributes >> 16;
118 EntryInfo {
119 name: e.name.clone(),
120 compression_method: e.method,
121 size: e.size,
122 compressed_size: e.compressed_size,
123 crc32: e.crc32,
124 dos_date_time: (e.dos_date, e.dos_time),
125 unix_mode: (mode != 0).then_some(mode),
126 }
127 }
128}
129
130#[derive(Clone)]
135pub struct MdzArchive {
136 data: Vec<u8>,
137 path: Option<PathBuf>,
138 limits: Limits,
139 pub(crate) files: Vec<ZipEntry>,
140 pub(crate) directories: Vec<ZipEntry>,
141 pub(crate) special: Vec<ZipEntry>,
142 file_index: HashMap<String, usize>,
143 manifest: Option<Manifest>,
144 mode: Mode,
145 ignored_fields: Vec<&'static str>,
146 warnings: Vec<Diagnostic>,
147}
148
149impl fmt::Debug for MdzArchive {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 f.debug_struct("MdzArchive")
152 .field("path", &self.path)
153 .field("files", &self.files().collect::<Vec<_>>())
154 .field("mode", &self.mode)
155 .finish_non_exhaustive()
156 }
157}
158
159impl MdzArchive {
160 pub fn open(data: impl Into<Vec<u8>>) -> Result<Self> {
178 Self::open_with_limits(data, Limits::DEFAULT)
179 }
180
181 pub fn open_with_limits(data: impl Into<Vec<u8>>, limits: Limits) -> Result<Self> {
183 Self::from_bytes(data.into(), None, limits)
184 }
185
186 pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
188 Self::open_path_with_limits(path, Limits::DEFAULT)
189 }
190
191 pub fn open_path_with_limits(path: impl AsRef<Path>, limits: Limits) -> Result<Self> {
193 let path = path.as_ref();
194 let data = std::fs::read(path)
195 .map_err(|e| MdzError::new(ErrorCode::Io, format!("can't read '{}': {e}", path.display())))?;
196 Self::from_bytes(data, Some(path.to_path_buf()), limits)
197 }
198
199 fn from_bytes(data: Vec<u8>, path: Option<PathBuf>, limits: Limits) -> Result<Self> {
200 let mut entries = zip::read_directory(&data, limits.max_entries)?;
201 for e in &entries {
202 validate_path(&e.name)?;
203 }
204 validate_entry_set(entries.iter().map(|e| e.name.as_str()))?;
206
207 let mut warnings = Vec::new();
208 let mut total: u64 = 0;
209 for e in &entries {
210 if zip::is_encrypted(e.flags) {
211 return err(ErrorCode::ZipEncrypted, format!("entry '{}' is encrypted", e.name));
212 }
213 if e.name.ends_with('/') {
214 continue;
215 }
216 if e.method != METHOD_STORED && e.method != METHOD_DEFLATE {
217 return err(
218 ErrorCode::ZipInvalid,
219 format!(
220 "entry '{}' uses compression method {}; MDZip allows 0 (stored) and 8 (DEFLATE)",
221 e.name, e.method
222 ),
223 );
224 }
225 check_declared_size(e, &limits)?;
226 if e.is_symlink() {
227 warnings.push(Diagnostic::new(
228 Level::Warning,
229 ErrorCode::PathInvalid.as_str(),
230 format!(
231 "symbolic link '{}' ignored; an update keeps it unless asked to drop it (Sections 4, 13.1)",
232 e.name
233 ),
234 ));
235 continue;
236 }
237 total = total.saturating_add(e.size);
238 if total > limits.max_total_uncompressed {
239 return err(
240 ErrorCode::LimitExceeded,
241 format!(
242 "total uncompressed size exceeds {} bytes",
243 limits.max_total_uncompressed
244 ),
245 );
246 }
247 }
248 zip::locate_data(&data, &mut entries)?;
249
250 let mut archive = MdzArchive {
251 data,
252 path,
253 limits,
254 files: Vec::new(),
255 directories: Vec::new(),
256 special: Vec::new(),
257 file_index: HashMap::new(),
258 manifest: None,
259 mode: Mode::Document,
260 ignored_fields: Vec::new(),
261 warnings,
262 };
263 for e in entries {
264 if e.name.ends_with('/') {
265 archive.directories.push(e);
266 } else if e.is_symlink() {
267 archive.special.push(e);
268 } else {
269 archive.file_index.insert(nfc(&e.name), archive.files.len());
270 archive.files.push(e);
271 }
272 }
273
274 if archive.find(MANIFEST_NAME).is_some() {
275 let parsed = parse_manifest(&archive.read_bytes(MANIFEST_NAME)?)?;
276 for member in &parsed.duplicate_members {
277 archive.warnings.push(Diagnostic::new(
278 Level::Warning,
279 "WARN_DUPLICATE_MEMBER",
280 format!("manifest.json repeats member '{member}'; the last one is used (Section 6.4)"),
281 ));
282 }
283 archive.ignored_fields = validate_manifest(&parsed.manifest, false)?;
285 for name in &archive.ignored_fields {
286 archive.warnings.push(Diagnostic::new(
287 Level::Warning,
288 "WARN_FIELD_IGNORED",
289 format!(
290 "manifest field '{name}' has the wrong type or an invalid value and is ignored (Section 6.4)"
291 ),
292 ));
293 }
294 archive.mode = manifest_mode(Some(&parsed.manifest))?;
295 archive.manifest = Some(parsed.manifest);
296 }
297 Ok(archive)
298 }
299
300 pub fn as_bytes(&self) -> &[u8] {
304 &self.data
305 }
306
307 pub fn into_bytes(self) -> Vec<u8> {
309 self.data
310 }
311
312 pub fn path(&self) -> Option<&Path> {
314 self.path.as_deref()
315 }
316
317 pub fn limits(&self) -> &Limits {
319 &self.limits
320 }
321
322 pub fn files(&self) -> impl ExactSizeIterator<Item = &str> + '_ {
327 self.files.iter().map(|e| e.name.as_str())
328 }
329
330 pub fn directories(&self) -> impl ExactSizeIterator<Item = &str> + '_ {
334 self.directories.iter().map(|e| e.name.as_str())
335 }
336
337 pub fn special_files(&self) -> impl ExactSizeIterator<Item = &str> + '_ {
339 self.special.iter().map(|e| e.name.as_str())
340 }
341
342 pub fn documents(&self) -> Vec<&str> {
344 let mut docs: Vec<&str> = self.files().filter(|p| is_markdown(p)).collect();
345 docs.sort_unstable();
346 docs
347 }
348
349 pub fn find(&self, path: &str) -> Option<&str> {
351 self.file_index.get(&nfc(path)).map(|&i| self.files[i].name.as_str())
352 }
353
354 pub fn contains(&self, path: &str) -> bool {
356 self.find(path).is_some()
357 }
358
359 pub fn find_directory(&self, path: &str) -> Option<&str> {
362 let wanted = nfc(&format!("{}/", path.trim_end_matches('/')));
363 self.directories
364 .iter()
365 .find(|e| nfc(&e.name) == wanted)
366 .map(|e| e.name.as_str())
367 }
368
369 pub(crate) fn entry(&self, path: &str) -> Option<&ZipEntry> {
370 self.file_index.get(&nfc(path)).map(|&i| &self.files[i])
371 }
372
373 pub fn entry_info(&self, path: &str) -> Option<EntryInfo> {
375 self.entry(path).map(EntryInfo::from)
376 }
377
378 pub fn read_bytes(&self, path: &str) -> Result<Vec<u8>> {
383 let e = self
384 .entry(path)
385 .ok_or_else(|| MdzError::new(ErrorCode::NotFound, format!("'{path}' is not in the archive")))?;
386 self.read_entry(e)
387 }
388
389 pub(crate) fn read_entry(&self, e: &ZipEntry) -> Result<Vec<u8>> {
390 zip::read_entry(&self.data, e, self.limits.max_entry_uncompressed)
391 }
392
393 pub fn read_text(&self, path: &str) -> Result<String> {
397 let bytes = self.read_bytes(path)?;
398 let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").map(<[u8]>::to_vec).unwrap_or(bytes);
399 String::from_utf8(bytes)
400 .map_err(|e| MdzError::new(ErrorCode::TextInvalid, format!("'{path}' is not valid UTF-8: {e}")))
401 }
402
403 pub fn entry_point(&self) -> Result<String> {
410 resolve_entry_point(self.files(), self.manifest.as_ref())
411 }
412
413 pub fn manifest(&self) -> Option<&Manifest> {
415 self.manifest.as_ref()
416 }
417
418 pub fn mode(&self) -> Mode {
420 self.mode
421 }
422
423 pub fn title(&self) -> Option<&str> {
425 self.manifest.as_ref()?.get("title")?.as_str().filter(|s| !s.is_empty())
426 }
427
428 pub fn ignored_fields(&self) -> &[&'static str] {
431 &self.ignored_fields
432 }
433
434 pub fn warnings(&self) -> &[Diagnostic] {
437 &self.warnings
438 }
439
440 pub fn resolve_link(&self, document: &str, link: &str, use_: LinkUse) -> LinkTarget {
450 let host = DefaultHost::new(self);
451 let archive = host.name.clone();
452 resolve_link(
453 LinkSource::Archive {
454 archive: &archive,
455 path: document,
456 },
457 link,
458 &host,
459 use_,
460 )
461 }
462
463 pub fn validate(&self) -> Vec<Diagnostic> {
471 let mut found = self.warnings.clone();
472 if let Err(e) = self.entry_point() {
473 found.push(Diagnostic::new(Level::Error, e.code().as_str(), e.message()));
474 }
475 match &self.manifest {
476 None => found.push(Diagnostic::new(
477 Level::Info,
478 "INFO_NO_MANIFEST",
479 "no manifest.json; valid, but a conforming producer would write one (Section 14.1)",
480 )),
481 Some(m) => {
482 if m.get("spec").and_then(|s| s.get("version")).is_none() {
483 found.push(Diagnostic::new(
484 Level::Warning,
485 ErrorCode::ManifestInvalid.as_str(),
486 "manifest has no spec.version (required in producer-written manifests)",
487 ));
488 }
489 if !m.contains_key("entryPoint") {
490 found.push(Diagnostic::new(
491 Level::Warning,
492 ErrorCode::ManifestInvalid.as_str(),
493 "manifest has no entryPoint (required in producer-written manifests)",
494 ));
495 }
496 if let Some(cover) = m.get("cover").and_then(|c| c.as_str()) {
497 if !self.ignored_fields.contains(&"cover") && self.find(strip_dot_slash(cover)).is_none() {
498 found.push(Diagnostic::new(
499 Level::Warning,
500 "WARN_COVER_MISSING",
501 format!("cover '{cover}' is not in the archive"),
502 ));
503 }
504 }
505 }
506 }
507 found
508 }
509}
510
511fn check_declared_size(e: &ZipEntry, limits: &Limits) -> Result<()> {
512 if e.size > limits.max_entry_uncompressed {
513 return err(
514 ErrorCode::LimitExceeded,
515 format!(
516 "'{}' is {} bytes (limit {})",
517 e.name, e.size, limits.max_entry_uncompressed
518 ),
519 );
520 }
521 if e.size > limits.ratio_applies_above {
522 if e.compressed_size == 0 {
523 return err(
524 ErrorCode::LimitExceeded,
525 format!("'{}' declares data but stores none", e.name),
526 );
527 }
528 let ratio = e.size as f64 / e.compressed_size as f64;
529 if ratio > limits.max_compression_ratio {
530 return err(
531 ErrorCode::LimitExceeded,
532 format!(
533 "'{}' has compression ratio {ratio:.0}:1 (limit {:.0}:1)",
534 e.name, limits.max_compression_ratio
535 ),
536 );
537 }
538 }
539 Ok(())
540}
541
542struct DefaultHost<'a> {
545 archive: &'a MdzArchive,
546 name: String,
547 fs: Option<FilesystemHost>,
548}
549
550impl<'a> DefaultHost<'a> {
551 fn new(archive: &'a MdzArchive) -> Self {
552 let name = archive
553 .path
554 .as_deref()
555 .and_then(Path::file_name)
556 .map(|n| n.to_string_lossy().into_owned())
557 .unwrap_or_else(|| "archive.mdz".into());
558 let fs = archive.path.as_deref().map(|p| {
559 let dir = std::path::absolute(p)
560 .ok()
561 .and_then(|a| a.parent().map(Path::to_path_buf))
562 .unwrap_or_default();
563 FilesystemHost::new(dir)
564 });
565 DefaultHost { archive, name, fs }
566 }
567}
568
569impl LinkHost for DefaultHost<'_> {
570 fn kind(&self, path: &str) -> Option<HostKind> {
571 self.fs.as_ref()?.kind(path)
572 }
573 fn find_entry(&self, archive: &str, entry: &str) -> Option<String> {
574 if archive == self.name {
575 return self.archive.find(entry).map(str::to_owned);
576 }
577 self.fs.as_ref()?.find_entry(archive, entry)
578 }
579 fn entry_point(&self, archive: &str) -> Option<String> {
580 if archive == self.name {
581 return self.archive.entry_point().ok();
582 }
583 self.fs.as_ref()?.entry_point(archive)
584 }
585 fn host_location(&self) -> bool {
586 self.fs.is_some()
587 }
588}