Skip to main content

rich_micro/
package.rs

1//! `.richmicro` packages and packs (#580), read-only.
2//!
3//! A **package** is one asset: a zip archive or a directory holding a
4//! `manifest.json` (`schema_version: 1`), a static image, and optionally an
5//! animation (GIF, APNG or WebP) and a frame sequence:
6//!
7//! ```json
8//! {
9//!   "schema_version": 1,
10//!   "name": "status/success",
11//!   "alt": "green check mark",
12//!   "size": "2x1",
13//!   "fallback": { "emoji": "✅", "text": "OK" },
14//!   "static": "static.png",
15//!   "animation": "animation.gif",
16//!   "aliases": ["ok"],
17//!   "version": "1.0.0",
18//!   "license": "CC0-1.0",
19//!   "author": "rs-rich contributors"
20//! }
21//! ```
22//!
23//! A **pack** is a directory or zip archive with a `pack.json` listing its
24//! packages (folders or, in a pack directory, `.richmicro` archives):
25//!
26//! ```json
27//! { "schema_version": 1, "name": "status", "version": "1.0.0",
28//!   "packages": ["success", "warning.richmicro"] }
29//! ```
30//!
31//! Everything is untrusted input. Hard [`Limits`] bound the archive, every
32//! file, the manifest, the entry count, pixel dimensions, frame counts and
33//! the bytes an asset takes decoded; images are checked by their headers,
34//! never decoded here. Paths must stay inside the package: absolute paths,
35//! `..`, backslashes, and symbolic links (in an archive, any; in a
36//! directory, any that leads out of it) are refused.
37
38use std::cell::Cell;
39use std::fs::File;
40use std::io::Read;
41use std::path::{Path, PathBuf};
42
43use serde_json::{Map, Value};
44
45use crate::error::MicroError;
46use crate::image::sniff;
47use crate::model::{
48    AssetKind, CellSize, ImageFormat, ImageRef, Layer, MicroAsset, Origin, PackageLocation,
49    Variants,
50};
51use crate::name::check_name;
52
53/// The manifest's file name.
54pub const MANIFEST: &str = "manifest.json";
55/// A pack's index file name.
56pub const PACK_INDEX: &str = "pack.json";
57/// The only manifest and pack schema this release reads.
58pub const SCHEMA_VERSION: u64 = 1;
59
60/// Hard limits on untrusted packages. The defaults suit emoji-sized assets
61/// with generous headroom; anything beyond them is refused, not truncated.
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct Limits {
64    /// An archive's size on disk.
65    pub max_archive_bytes: u64,
66    /// Any one file, uncompressed.
67    pub max_file_bytes: u64,
68    /// `manifest.json` and `pack.json`.
69    pub max_manifest_bytes: u64,
70    /// Entries in one archive.
71    pub max_entries: usize,
72    /// Bytes read, uncompressed, from one archive in total.
73    pub max_total_bytes: u64,
74    /// Width or height of any image, in pixels.
75    pub max_dimension: u32,
76    /// Frames in one asset: its animation's plus its frame sequence's.
77    pub max_frames: u32,
78    /// What one asset takes decoded (RGBA, every frame at canvas size).
79    pub max_decoded_bytes: u64,
80    /// Packages in one pack.
81    pub max_packages: usize,
82}
83
84impl Default for Limits {
85    fn default() -> Self {
86        Limits {
87            max_archive_bytes: 8 << 20,
88            max_file_bytes: 2 << 20,
89            max_manifest_bytes: 64 << 10,
90            max_entries: 512,
91            max_total_bytes: 16 << 20,
92            max_dimension: 256,
93            max_frames: 240,
94            max_decoded_bytes: 32 << 20,
95            max_packages: 1024,
96        }
97    }
98}
99
100/// A loaded pack: its assets, and the packages in it that failed, each with
101/// its reason. One bad package does not reject the pack.
102#[derive(Clone, Debug)]
103pub struct Pack {
104    pub name: String,
105    pub version: Option<String>,
106    pub assets: Vec<MicroAsset>,
107    /// `(package path within the pack, why it was rejected)`.
108    pub rejected: Vec<(String, MicroError)>,
109}
110
111/// What a path held: one package or a pack.
112#[derive(Clone, Debug)]
113pub enum Loaded {
114    Package(Box<MicroAsset>),
115    Pack(Pack),
116}
117
118/// Check a path from a manifest or an archive: relative, `/`-separated, no
119/// empty, `.` or `..` segments, no backslashes, drive letters, NULs or other
120/// control characters.
121/// Returns it normalised (without a trailing `/`).
122pub fn safe_relative(path: &str) -> Result<String, MicroError> {
123    let unsafe_path = |why: &str| MicroError::UnsafePath(format!("unsafe path {path:?}: {why}"));
124    if path.is_empty() {
125        return Err(unsafe_path("empty"));
126    }
127    if path.starts_with('/') {
128        return Err(unsafe_path("absolute"));
129    }
130    if path.contains('\\') || path.contains('\0') || path.contains(':') {
131        return Err(unsafe_path("backslashes, colons and NULs are not allowed"));
132    }
133    // Paths are quoted in messages and `rich micro show`: no C0 or C1
134    // control or DEL may reach the terminal through one.
135    if path.chars().any(char::is_control) {
136        return Err(unsafe_path("control characters are not allowed"));
137    }
138    let trimmed = path.strip_suffix('/').unwrap_or(path);
139    for segment in trimmed.split('/') {
140        match segment {
141            "" | "." => return Err(unsafe_path("empty or `.` segment")),
142            ".." => return Err(unsafe_path("`..` leads out of the package")),
143            _ => {}
144        }
145    }
146    Ok(trimmed.to_string())
147}
148
149fn io_error(path: &Path, error: impl std::fmt::Display) -> MicroError {
150    MicroError::Io(format!("{}: {error}", path.display()))
151}
152
153/// A FIFO, socket or device where a package or archive was expected.
154fn not_a_file(path: &Path) -> MicroError {
155    MicroError::Io(format!(
156        "{} is not a regular file or a directory",
157        path.display()
158    ))
159}
160
161/// Read at most `limit` bytes; more is an error, whatever a header claimed.
162fn read_bounded(mut reader: impl Read, limit: u64, what: &str) -> Result<Vec<u8>, MicroError> {
163    let mut out = Vec::new();
164    reader
165        .by_ref()
166        .take(limit + 1)
167        .read_to_end(&mut out)
168        .map_err(|e| MicroError::Io(format!("{what}: {e}")))?;
169    if out.len() as u64 > limit {
170        return Err(MicroError::Limit(format!(
171            "{what} is larger than {limit} bytes"
172        )));
173    }
174    Ok(out)
175}
176
177/// Where files are read from: a directory, or a checked zip archive.
178enum Source {
179    Directory {
180        root: PathBuf,
181        canonical: PathBuf,
182    },
183    Archive {
184        path: PathBuf,
185        archive: Box<zip::ZipArchive<File>>,
186        read_total: Cell<u64>,
187    },
188    /// The compiled-in built-in set ([`crate::builtin`]), under `root`.
189    Embedded {
190        root: String,
191    },
192}
193
194impl Source {
195    fn directory(root: &Path) -> Result<Self, MicroError> {
196        let canonical = root.canonicalize().map_err(|e| io_error(root, e))?;
197        Ok(Source::Directory {
198            root: root.to_path_buf(),
199            canonical,
200        })
201    }
202
203    /// Open an archive and check every entry before reading any: the size,
204    /// the count, each name, and no links, encryption or duplicates.
205    fn archive(path: &Path, limits: &Limits) -> Result<Self, MicroError> {
206        let metadata = std::fs::metadata(path).map_err(|e| io_error(path, e))?;
207        if !metadata.is_file() {
208            return Err(not_a_file(path));
209        }
210        if metadata.len() > limits.max_archive_bytes {
211            return Err(MicroError::Limit(format!(
212                "{} is larger than {} bytes",
213                path.display(),
214                limits.max_archive_bytes
215            )));
216        }
217        let file = File::open(path).map_err(|e| io_error(path, e))?;
218        // What was opened, in case the path changed since it was checked.
219        if !file.metadata().is_ok_and(|m| m.is_file()) {
220            return Err(not_a_file(path));
221        }
222        let mut archive = zip::ZipArchive::new(file).map_err(|e| io_error(path, e))?;
223        if archive.len() > limits.max_entries {
224            return Err(MicroError::Limit(format!(
225                "{} has more than {} entries",
226                path.display(),
227                limits.max_entries
228            )));
229        }
230        let mut seen = std::collections::HashSet::new();
231        for index in 0..archive.len() {
232            let entry = archive.by_index_raw(index).map_err(|e| io_error(path, e))?;
233            let name = entry.name().map_err(|e| io_error(path, e))?.into_owned();
234            let normal = safe_relative(&name)
235                .map_err(|e| MicroError::UnsafePath(format!("{}: {e}", path.display())))?;
236            if entry.is_symlink() {
237                return Err(MicroError::UnsafePath(format!(
238                    "{}: {name:?} is a symbolic link",
239                    path.display()
240                )));
241            }
242            if entry.encrypted() {
243                return Err(MicroError::Manifest(format!(
244                    "{}: {name:?} is encrypted",
245                    path.display()
246                )));
247            }
248            if !entry.is_dir() && entry.size() > limits.max_file_bytes {
249                return Err(MicroError::Limit(format!(
250                    "{}: {name:?} is larger than {} bytes",
251                    path.display(),
252                    limits.max_file_bytes
253                )));
254            }
255            if !seen.insert(normal) {
256                return Err(MicroError::Manifest(format!(
257                    "{}: {name:?} appears twice",
258                    path.display()
259                )));
260            }
261        }
262        Ok(Source::Archive {
263            path: path.to_path_buf(),
264            archive: Box::new(archive),
265            read_total: Cell::new(0),
266        })
267    }
268
269    fn display(&self, relative: &str) -> String {
270        match self {
271            Source::Directory { root, .. } => root.join(relative).display().to_string(),
272            Source::Archive { path, .. } => format!("{}!{relative}", path.display()),
273            Source::Embedded { root } => format!("builtin:{}", join(root, relative)),
274        }
275    }
276
277    fn exists(&mut self, relative: &str) -> bool {
278        match self {
279            Source::Directory { root, .. } => root.join(relative).is_file(),
280            Source::Archive { archive, .. } => archive.index_for_name(relative).is_some(),
281            Source::Embedded { root } => crate::builtin::file(&join(root, relative)).is_some(),
282        }
283    }
284
285    fn is_dir(&mut self, relative: &str) -> bool {
286        match self {
287            Source::Directory { root, .. } => root.join(relative).is_dir(),
288            Source::Archive { archive, .. } => {
289                let prefix = format!("{relative}/");
290                archive
291                    .file_names()
292                    .any(|name| name.is_ok_and(|name| name.starts_with(&prefix)))
293            }
294            Source::Embedded { root } => crate::builtin::is_dir(&join(root, relative)),
295        }
296    }
297
298    /// Read `relative` (already [`safe_relative`]), at most `limit` bytes.
299    fn read(&mut self, relative: &str, limit: u64, limits: &Limits) -> Result<Vec<u8>, MicroError> {
300        let shown = self.display(relative);
301        match self {
302            Source::Directory { root, canonical } => {
303                let path = root.join(relative);
304                let resolved = path.canonicalize().map_err(|e| io_error(&path, e))?;
305                if !resolved.starts_with(&*canonical) {
306                    return Err(MicroError::UnsafePath(format!(
307                        "{shown} links out of the package"
308                    )));
309                }
310                let metadata = std::fs::metadata(&resolved).map_err(|e| io_error(&path, e))?;
311                if !metadata.is_file() {
312                    return Err(MicroError::Io(format!("{shown} is not a file")));
313                }
314                if metadata.len() > limit {
315                    return Err(MicroError::Limit(format!(
316                        "{shown} is larger than {limit} bytes"
317                    )));
318                }
319                let file = File::open(&resolved).map_err(|e| io_error(&path, e))?;
320                read_bounded(file, limit, &shown)
321            }
322            Source::Archive {
323                archive,
324                read_total,
325                ..
326            } => {
327                let entry = archive
328                    .by_name(relative)
329                    .map_err(|e| MicroError::Io(format!("{shown}: {e}")))?;
330                if !entry.is_file() {
331                    return Err(MicroError::Io(format!("{shown} is not a file")));
332                }
333                let left = limits.max_total_bytes.saturating_sub(read_total.get());
334                let bytes = read_bounded(entry, limit.min(left), &shown).map_err(|e| {
335                    if limit > left {
336                        MicroError::Limit(format!(
337                            "{shown}: the archive holds more than {} bytes uncompressed",
338                            limits.max_total_bytes
339                        ))
340                    } else {
341                        e
342                    }
343                })?;
344                read_total.set(read_total.get() + bytes.len() as u64);
345                Ok(bytes)
346            }
347            Source::Embedded { root } => {
348                let bytes = crate::builtin::file(&join(root, relative))
349                    .ok_or_else(|| MicroError::Io(format!("{shown}: no such file")))?;
350                if bytes.len() as u64 > limit {
351                    return Err(MicroError::Limit(format!(
352                        "{shown} is larger than {limit} bytes"
353                    )));
354                }
355                Ok(bytes.to_vec())
356            }
357        }
358    }
359
360    fn location(&self, prefix: &str) -> PackageLocation {
361        match self {
362            Source::Directory { root, .. } => PackageLocation::Directory(if prefix.is_empty() {
363                root.clone()
364            } else {
365                root.join(prefix)
366            }),
367            Source::Archive { path, .. } => PackageLocation::Archive {
368                path: path.clone(),
369                prefix: prefix.to_string(),
370            },
371            Source::Embedded { root } => PackageLocation::BuiltIn(join(root, prefix)),
372        }
373    }
374}
375
376fn join(prefix: &str, relative: &str) -> String {
377    if prefix.is_empty() {
378        relative.to_string()
379    } else {
380        format!("{prefix}/{relative}")
381    }
382}
383
384/// Read one file of a loaded package, under `limits`. Used by
385/// [`MicroAsset::read_variant`].
386pub(crate) fn read_file(
387    location: &PackageLocation,
388    relative: &str,
389    limits: &Limits,
390) -> Result<Vec<u8>, MicroError> {
391    let relative = safe_relative(relative)?;
392    match location {
393        PackageLocation::Directory(root) => {
394            Source::directory(root)?.read(&relative, limits.max_file_bytes, limits)
395        }
396        PackageLocation::Archive { path, prefix } => Source::archive(path, limits)?.read(
397            &join(prefix, &relative),
398            limits.max_file_bytes,
399            limits,
400        ),
401        PackageLocation::BuiltIn(prefix) => Source::Embedded {
402            root: prefix.clone(),
403        }
404        .read(&relative, limits.max_file_bytes, limits),
405    }
406}
407
408/// Load the compiled-in pack at `root` (`status`) into `layer`.
409pub(crate) fn load_embedded_pack(
410    root: &str,
411    layer: Layer,
412    limits: &Limits,
413) -> Result<Pack, MicroError> {
414    let mut source = Source::Embedded {
415        root: root.to_string(),
416    };
417    read_pack(&mut source, None, layer, limits)
418}
419
420fn parse_json(bytes: &[u8], what: &str) -> Result<Map<String, Value>, MicroError> {
421    match serde_json::from_slice::<Value>(bytes) {
422        Ok(Value::Object(map)) => Ok(map),
423        Ok(_) => Err(MicroError::Manifest(format!("{what}: not a JSON object"))),
424        Err(e) => Err(MicroError::Manifest(format!("{what}: invalid JSON: {e}"))),
425    }
426}
427
428fn check_schema(map: &Map<String, Value>, what: &str) -> Result<(), MicroError> {
429    match map.get("schema_version") {
430        Some(Value::Number(n)) if n.as_u64() == Some(SCHEMA_VERSION) => Ok(()),
431        Some(other) => Err(MicroError::Manifest(format!(
432            "{what}: unsupported schema_version {other}; this release reads {SCHEMA_VERSION}"
433        ))),
434        None => Err(MicroError::Manifest(format!(
435            "{what}: missing schema_version"
436        ))),
437    }
438}
439
440fn check_keys(map: &Map<String, Value>, allowed: &[&str], what: &str) -> Result<(), MicroError> {
441    match map.keys().find(|key| !allowed.contains(&key.as_str())) {
442        Some(key) => Err(MicroError::Manifest(format!(
443            "{what}: unknown field {key:?}"
444        ))),
445        None => Ok(()),
446    }
447}
448
449fn string<'a>(
450    map: &'a Map<String, Value>,
451    key: &str,
452    what: &str,
453) -> Result<Option<&'a str>, MicroError> {
454    match map.get(key) {
455        None | Some(Value::Null) => Ok(None),
456        Some(Value::String(s)) => Ok(Some(s)),
457        Some(_) => Err(MicroError::Manifest(format!(
458            "{what}: {key:?} must be a string"
459        ))),
460    }
461}
462
463/// [`string`] for free text shown to people (`version`, `license`,
464/// `author`, `description`): no C0 or C1 control or DEL, which a terminal
465/// would run rather than show.
466fn plain<'a>(
467    map: &'a Map<String, Value>,
468    key: &str,
469    what: &str,
470) -> Result<Option<&'a str>, MicroError> {
471    let value = string(map, key, what)?;
472    if value.is_some_and(|value| value.chars().any(char::is_control)) {
473        return Err(MicroError::Manifest(format!(
474            "{what}: {key:?} holds control characters"
475        )));
476    }
477    Ok(value)
478}
479
480fn required<'a>(map: &'a Map<String, Value>, key: &str, what: &str) -> Result<&'a str, MicroError> {
481    string(map, key, what)?.ok_or_else(|| MicroError::Manifest(format!("{what}: missing {key:?}")))
482}
483
484fn strings(map: &Map<String, Value>, key: &str, what: &str) -> Result<Vec<String>, MicroError> {
485    match map.get(key) {
486        None | Some(Value::Null) => Ok(Vec::new()),
487        Some(Value::Array(items)) => items
488            .iter()
489            .map(|item| match item {
490                Value::String(s) => Ok(s.clone()),
491                _ => Err(MicroError::Manifest(format!(
492                    "{what}: {key:?} must be a list of strings"
493                ))),
494            })
495            .collect(),
496        Some(_) => Err(MicroError::Manifest(format!(
497            "{what}: {key:?} must be a list of strings"
498        ))),
499    }
500}
501
502fn size(map: &Map<String, Value>, what: &str) -> Result<CellSize, MicroError> {
503    let in_manifest = |e: MicroError| MicroError::InvalidSize(format!("{what}: {e}"));
504    match map.get("size") {
505        None | Some(Value::Null) => Ok(CellSize::default()),
506        Some(Value::String(s)) => s.parse().map_err(in_manifest),
507        Some(Value::Object(size)) => {
508            let get = |key: &str| match size.get(key) {
509                Some(Value::Number(n)) => n
510                    .as_u64()
511                    .and_then(|n| u16::try_from(n).ok())
512                    .ok_or_else(|| {
513                        MicroError::InvalidSize(format!("{what}: size.{key} is out of range"))
514                    }),
515                None if key == "rows" => Ok(1),
516                _ => Err(MicroError::InvalidSize(format!(
517                    "{what}: size.{key} must be a number"
518                ))),
519            };
520            CellSize::new(get("cols")?, get("rows")?).map_err(in_manifest)
521        }
522        Some(_) => Err(MicroError::InvalidSize(format!(
523            "{what}: size must be \"COLSxROWS\" or {{\"cols\": …, \"rows\": …}}"
524        ))),
525    }
526}
527
528const MANIFEST_KEYS: &[&str] = &[
529    "schema_version",
530    "name",
531    "kind",
532    "size",
533    "alt",
534    "fallback",
535    "static",
536    "animation",
537    "frames",
538    "aliases",
539    "version",
540    "license",
541    "author",
542];
543
544/// Read the package at `prefix` in `source`.
545fn read_package(
546    source: &mut Source,
547    prefix: &str,
548    origin: Origin,
549    limits: &Limits,
550) -> Result<MicroAsset, MicroError> {
551    let manifest_path = join(prefix, MANIFEST);
552    let what = source.display(&manifest_path);
553    let bytes = source.read(&manifest_path, limits.max_manifest_bytes, limits)?;
554    let map = parse_json(&bytes, &what)?;
555    check_schema(&map, &what)?;
556    check_keys(&map, MANIFEST_KEYS, &what)?;
557
558    let name = required(&map, "name", &what)?;
559    let alt = required(&map, "alt", &what)?;
560    let in_manifest = |e: MicroError| match e {
561        MicroError::InvalidName(_) => e,
562        other => MicroError::Manifest(format!("{what}: {other}")),
563    };
564    let mut asset = MicroAsset::new(name, alt)
565        .map_err(in_manifest)?
566        .with_size(size(&map, &what)?)
567        .map_err(in_manifest)?;
568    match map.get("fallback") {
569        None | Some(Value::Null) => {}
570        Some(Value::Object(fallback)) => {
571            check_keys(fallback, &["emoji", "text"], &format!("{what}: fallback"))?;
572            if let Some(emoji) = string(fallback, "emoji", &what)? {
573                asset = asset.with_emoji(emoji).map_err(in_manifest)?;
574            }
575            if let Some(text) = string(fallback, "text", &what)? {
576                asset = asset.with_text(text).map_err(in_manifest)?;
577            }
578        }
579        Some(_) => {
580            return Err(MicroError::Manifest(format!(
581                "{what}: fallback must be an object with \"emoji\" and/or \"text\""
582            )));
583        }
584    }
585    for alias in strings(&map, "aliases", &what)? {
586        asset = asset.with_alias(&alias).map_err(in_manifest)?;
587    }
588    if let Some(version) = plain(&map, "version", &what)? {
589        asset = asset.with_version(version);
590    }
591    if let Some(license) = plain(&map, "license", &what)? {
592        asset = asset.with_license(license);
593    }
594    if let Some(author) = plain(&map, "author", &what)? {
595        asset = asset.with_author(author);
596    }
597
598    // Images: header-checked against the limits, never decoded.
599    let mut load = |relative: &str, animation: bool| -> Result<ImageRef, MicroError> {
600        let relative = safe_relative(relative)?;
601        let full = join(prefix, &relative);
602        let bytes = source.read(&full, limits.max_file_bytes, limits)?;
603        let shown = source.display(&full);
604        let info = sniff(&bytes, limits.max_frames)
605            .map_err(|e| MicroError::Image(format!("{shown}: {e}")))?;
606        if info.width > limits.max_dimension || info.height > limits.max_dimension {
607            return Err(MicroError::Limit(format!(
608                "{shown} is {}x{} pixels; the limit is {} on each side",
609                info.width, info.height, limits.max_dimension
610            )));
611        }
612        if info.frames > limits.max_frames {
613            return Err(MicroError::Limit(format!(
614                "{shown} has more than {} frames",
615                limits.max_frames
616            )));
617        }
618        if !animation && info.frames > 1 {
619            return Err(MicroError::Image(format!(
620                "{shown} is animated; the static image and frames must be still images"
621            )));
622        }
623        if animation && info.format == ImageFormat::Png {
624            return Err(MicroError::Image(format!(
625                "{shown} is a still PNG; an animation is a GIF, APNG or WebP"
626            )));
627        }
628        Ok(ImageRef {
629            path: relative,
630            info,
631            bytes: bytes.len() as u64,
632        })
633    };
634    let static_path = required(&map, "static", &what)?;
635    let static_image = load(static_path, false)?;
636    let animation = string(&map, "animation", &what)?
637        .map(|path| load(path, true))
638        .transpose()?;
639    let frame_paths = strings(&map, "frames", &what)?;
640    if frame_paths.len() as u64 > limits.max_frames as u64 {
641        return Err(MicroError::Limit(format!(
642            "{what}: more than {} frames",
643            limits.max_frames
644        )));
645    }
646    let frames = frame_paths
647        .iter()
648        .map(|path| load(path, false))
649        .collect::<Result<Vec<_>, _>>()?;
650    let variants = Variants {
651        static_image: Some(static_image),
652        animation,
653        frames,
654    };
655    let total_frames: u64 = variants
656        .iter()
657        .map(|image| image.info.frames as u64)
658        .sum::<u64>()
659        - 1;
660    if total_frames > limits.max_frames as u64 {
661        return Err(MicroError::Limit(format!(
662            "{what}: more than {} frames in all",
663            limits.max_frames
664        )));
665    }
666    let decoded: u64 = variants
667        .iter()
668        .map(|image| image.info.decoded_bytes())
669        .sum();
670    if decoded > limits.max_decoded_bytes {
671        return Err(MicroError::Limit(format!(
672            "{what}: its images take {decoded} bytes decoded; the limit is {}",
673            limits.max_decoded_bytes
674        )));
675    }
676
677    let kind = match string(&map, "kind", &what)? {
678        Some(kind) => kind.parse().map_err(in_manifest)?,
679        None if variants.animation.is_some() || !variants.frames.is_empty() => AssetKind::Animated,
680        None => AssetKind::Static,
681    };
682    let asset = asset
683        .with_kind(kind)
684        .with_variants(variants)
685        .with_origin(Origin {
686            location: Some(source.location(prefix)),
687            ..origin
688        });
689    asset.validate().map_err(in_manifest)?;
690    Ok(asset)
691}
692
693const PACK_KEYS: &[&str] = &[
694    "schema_version",
695    "name",
696    "version",
697    "description",
698    "license",
699    "author",
700    "packages",
701];
702
703/// Read the pack whose `pack.json` is at `source`'s root.
704fn read_pack(
705    source: &mut Source,
706    pack_dir: Option<&Path>,
707    layer: Layer,
708    limits: &Limits,
709) -> Result<Pack, MicroError> {
710    let what = source.display(PACK_INDEX);
711    let bytes = source.read(PACK_INDEX, limits.max_manifest_bytes, limits)?;
712    let map = parse_json(&bytes, &what)?;
713    check_schema(&map, &what)?;
714    check_keys(&map, PACK_KEYS, &what)?;
715    let name = required(&map, "name", &what)?;
716    check_name(name)?;
717    for key in ["description", "license", "author"] {
718        plain(&map, key, &what)?;
719    }
720    let packages = strings(&map, "packages", &what)?;
721    if packages.len() > limits.max_packages {
722        return Err(MicroError::Limit(format!(
723            "{what}: more than {} packages",
724            limits.max_packages
725        )));
726    }
727    let mut pack = Pack {
728        name: name.to_string(),
729        version: plain(&map, "version", &what)?.map(str::to_string),
730        assets: Vec::new(),
731        rejected: Vec::new(),
732    };
733    for package in packages {
734        let origin = Origin {
735            layer,
736            pack: Some(pack.name.clone()),
737            location: None,
738        };
739        let result = safe_relative(&package).and_then(|relative| {
740            if source.is_dir(&relative) {
741                return read_package(source, &relative, origin, limits);
742            }
743            // A `.richmicro` archive inside a pack directory. (Inside a pack
744            // archive, packages are folders: archives do not nest.)
745            match (pack_dir, source.exists(&relative)) {
746                (Some(dir), true) => {
747                    let path = dir.join(&relative);
748                    // `read` checks it does not link out of the pack.
749                    let canonical_dir = dir.canonicalize().map_err(|e| io_error(dir, e))?;
750                    let resolved = path.canonicalize().map_err(|e| io_error(&path, e))?;
751                    if !resolved.starts_with(&canonical_dir) {
752                        return Err(MicroError::UnsafePath(format!(
753                            "{} links out of the pack",
754                            path.display()
755                        )));
756                    }
757                    let mut inner = Source::archive(&path, limits)?;
758                    read_package(&mut inner, "", origin, limits)
759                }
760                _ => Err(MicroError::Io(format!(
761                    "{}: no such package",
762                    source.display(&relative)
763                ))),
764            }
765        });
766        match result {
767            Ok(asset) => pack.assets.push(asset),
768            Err(error) => pack.rejected.push((package, error)),
769        }
770    }
771    Ok(pack)
772}
773
774/// Load a package from a directory or a zip archive.
775pub fn load_package(path: &Path, layer: Layer, limits: &Limits) -> Result<MicroAsset, MicroError> {
776    match load(path, layer, limits)? {
777        Loaded::Package(asset) => Ok(*asset),
778        Loaded::Pack(_) => Err(MicroError::Manifest(format!(
779            "{} is a pack, not a package",
780            path.display()
781        ))),
782    }
783}
784
785/// Load a pack from a directory or a zip archive.
786pub fn load_pack(path: &Path, layer: Layer, limits: &Limits) -> Result<Pack, MicroError> {
787    match load(path, layer, limits)? {
788        Loaded::Pack(pack) => Ok(pack),
789        Loaded::Package(_) => Err(MicroError::Manifest(format!(
790            "{} is a package, not a pack",
791            path.display()
792        ))),
793    }
794}
795
796/// Load whatever `path` holds: a package (`manifest.json`) or a pack
797/// (`pack.json`), as a directory or a zip archive.
798pub fn load(path: &Path, layer: Layer, limits: &Limits) -> Result<Loaded, MicroError> {
799    let metadata = std::fs::symlink_metadata(path).map_err(|e| io_error(path, e))?;
800    let metadata = if metadata.file_type().is_symlink() {
801        std::fs::metadata(path).map_err(|e| io_error(path, e))?
802    } else {
803        metadata
804    };
805    let is_dir = metadata.is_dir();
806    // Only a regular file is opened as an archive: opening a FIFO blocks
807    // until something writes to it, and a device or socket is no archive.
808    if !is_dir && !metadata.is_file() {
809        return Err(not_a_file(path));
810    }
811    let mut source = if is_dir {
812        Source::directory(path)?
813    } else {
814        Source::archive(path, limits)?
815    };
816    let origin = Origin {
817        layer,
818        pack: None,
819        location: None,
820    };
821    if source.exists(MANIFEST) {
822        read_package(&mut source, "", origin, limits).map(|asset| Loaded::Package(Box::new(asset)))
823    } else if source.exists(PACK_INDEX) {
824        read_pack(&mut source, is_dir.then_some(path), layer, limits).map(Loaded::Pack)
825    } else {
826        Err(MicroError::Manifest(format!(
827            "{}: no {MANIFEST} or {PACK_INDEX}",
828            path.display()
829        )))
830    }
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    #[test]
838    fn relative_paths() {
839        assert_eq!(safe_relative("a/b.png").unwrap(), "a/b.png");
840        assert_eq!(safe_relative("frames/").unwrap(), "frames");
841        for bad in [
842            "",
843            "/etc/passwd",
844            "../x",
845            "a/../../x",
846            "a/./b",
847            "a//b",
848            "C:/x",
849            "a\\b",
850            "a\0",
851            "a\u{1b}]0;title\u{7}",
852            "a\u{7f}",
853            "a\u{9b}2J",
854            "a\u{85}b",
855            "a\tb",
856        ] {
857            assert!(
858                matches!(safe_relative(bad), Err(MicroError::UnsafePath(_))),
859                "{bad:?}"
860            );
861        }
862    }
863}