Skip to main content

opys_bundle/
lib.rs

1//! The bundle: a manifest and the blobs it names, as one file.
2//!
3//! It is a zip, and deliberately nothing more — `unzip -l` reads it:
4//!
5//! ```text
6//! opys.json        the head: the bundle's format, and its options
7//! manifest.json    the manifest, whole
8//! blobs/<sha256>   one entry per blob
9//! ```
10//!
11//! The head is about the bundle and the manifest is about the installation,
12//! and the two are kept apart for that reason. The head is the first entry
13//! and is stored uncompressed, so what a bundle is can be read with one seek,
14//! or off the front of the file by something that never parses a zip at all.
15//! It says the `format` and the `options` a player may set; whatever else is
16//! worth knowing about a bundle without decoding megabytes of manifest goes
17//! there.
18//!
19//! `manifest.json` is a [`Manifest`] as `opys-core` reads and writes it, and
20//! nothing else: the container adds no spelling of its own.
21
22use std::fs::File;
23use std::io::{self, Read, Seek, Write};
24
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27use zip::write::SimpleFileOptions;
28use zip::{CompressionMethod, DateTime, ZipArchive, ZipWriter};
29
30use opys_core::Manifest;
31
32mod blob;
33mod directory;
34mod localized;
35mod option;
36
37pub use blob::{blob_id, blob_id_of, BlobSource, Blobs};
38pub use directory::{directory_from_back, Back, Directory, Entry, EntryDecoder};
39pub use localized::{is_language_tag, Localized};
40pub use option::{Choice, Label, OptionDef, Options};
41
42/// The format this reader and writer speak. A bundle that says anything else
43/// is refused before another byte of it is interpreted.
44pub const BUNDLE_FORMAT: u32 = 1;
45
46const HEAD_ENTRY: &str = "opys.json";
47const MANIFEST_ENTRY: &str = "manifest.json";
48
49fn blob_entry(id: &str) -> String {
50    format!("blobs/{id}")
51}
52
53#[derive(Debug, thiserror::Error)]
54pub enum BundleError {
55    #[error("not a bundle: {0}")]
56    Zip(#[from] zip::result::ZipError),
57    #[error(transparent)]
58    Io(#[from] io::Error),
59    #[error("not a bundle: it has no `{0}`")]
60    MissingEntry(&'static str),
61    #[error("`{entry}` does not parse: {source}")]
62    Json {
63        entry: &'static str,
64        #[source]
65        source: serde_json::Error,
66    },
67    #[error(
68        "bundle format {found} is not one this reader knows (it reads format {BUNDLE_FORMAT})"
69    )]
70    Format { found: u64 },
71    #[error("the manifest names blob {0}, and nothing holds it")]
72    MissingBlob(String),
73    #[error("blob {id} does not hold what its name says: its bytes hash to {found}")]
74    BlobMismatch { id: String, found: String },
75    #[error("cannot read blob {id} from {path}: {source}")]
76    BlobFile {
77        id: String,
78        path: String,
79        #[source]
80        source: io::Error,
81    },
82}
83
84/// What a bundle says about itself, apart from the installation it carries.
85///
86/// A head that has been read is in [`BUNDLE_FORMAT`], since any other is
87/// refused; the field is kept because a head is also what gets written.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(from = "HeadWire", into = "HeadWire")]
90pub struct Head {
91    pub format: u32,
92    /// What whoever launches the bundle may choose: the variables and
93    /// features it leaves to them, and how to ask for each.
94    pub options: Options,
95}
96
97impl Head {
98    /// A head in the format this crate writes.
99    pub fn new(options: Options) -> Self {
100        Head {
101            format: BUNDLE_FORMAT,
102            options,
103        }
104    }
105}
106
107impl Default for Head {
108    fn default() -> Self {
109        Head::new(Options::default())
110    }
111}
112
113/// Not `deny_unknown_fields`: what a head says about its bundle may grow
114/// without a new format, since the format decides only whether the manifest
115/// beside it can be read.
116#[derive(Serialize, Deserialize)]
117pub(crate) struct HeadWire {
118    format: u32,
119    #[serde(default, skip_serializing_if = "Options::is_empty")]
120    options: Options,
121}
122
123impl From<HeadWire> for Head {
124    fn from(raw: HeadWire) -> Self {
125        Head {
126            format: raw.format,
127            options: raw.options,
128        }
129    }
130}
131
132impl From<Head> for HeadWire {
133    fn from(head: Head) -> Self {
134        HeadWire {
135            format: head.format,
136            options: head.options,
137        }
138    }
139}
140
141/// Only the format, read first and as widely as a number goes: another
142/// format may spell the rest of the head in a way this reader would misreport
143/// as a parse error.
144///
145/// A reader reads one format. Any other number is refused, an older one as
146/// much as a newer: there is no migration and no lenient reading, because a
147/// half-understood manifest is one that installs or deletes the wrong files.
148/// Whoever holds an old bundle rebuilds it.
149#[derive(Deserialize)]
150struct FormatProbe {
151    format: u64,
152}
153
154fn parse_head(bytes: &[u8]) -> Result<Head, BundleError> {
155    let json = |source| BundleError::Json {
156        entry: HEAD_ENTRY,
157        source,
158    };
159    let probe: FormatProbe = serde_json::from_slice(bytes).map_err(json)?;
160    if probe.format != u64::from(BUNDLE_FORMAT) {
161        return Err(BundleError::Format {
162            found: probe.format,
163        });
164    }
165    serde_json::from_slice(bytes).map_err(json)
166}
167
168fn parse_manifest(bytes: &[u8]) -> Result<Manifest, BundleError> {
169    serde_json::from_slice(bytes).map_err(|source| BundleError::Json {
170        entry: MANIFEST_ENTRY,
171        source,
172    })
173}
174
175fn read_entry<R: Read + Seek>(
176    archive: &mut ZipArchive<R>,
177    name: &'static str,
178) -> Result<Vec<u8>, BundleError> {
179    let mut entry = match archive.by_name(name) {
180        Ok(entry) => entry,
181        Err(zip::result::ZipError::FileNotFound) => return Err(BundleError::MissingEntry(name)),
182        Err(other) => return Err(other.into()),
183    };
184    let mut bytes = Vec::with_capacity(entry.size() as usize);
185    entry.read_to_end(&mut bytes)?;
186    Ok(bytes)
187}
188
189/// Read a bundle's head and nothing else. Costs the zip's directory and a few
190/// bytes, whatever the bundle weighs.
191pub fn read_bundle_head<R: Read + Seek>(reader: R) -> Result<Head, BundleError> {
192    let mut archive = ZipArchive::new(reader)?;
193    parse_head(&read_entry(&mut archive, HEAD_ENTRY)?)
194}
195
196/// What the first bytes of a bundle give of its head.
197#[derive(Debug, Clone, PartialEq)]
198pub enum Front {
199    Head(Head),
200    /// The head ends past what was given: it takes this many bytes, counted
201    /// from the start of the file.
202    Needs(u64),
203}
204
205const LOCAL_HEADER: [u8; 4] = *b"PK\x03\x04";
206const LOCAL_HEADER_LEN: usize = 30;
207/// Set when an entry's sizes follow its data instead of preceding it.
208const SIZES_AFTER_DATA: u16 = 1 << 3;
209
210fn not_a_bundle(why: &'static str) -> BundleError {
211    BundleError::Zip(zip::result::ZipError::InvalidArchive(why))
212}
213
214/// Read a bundle's head off the front of the file, for a reader that has
215/// only that: a bundle behind a URL, asked for its first bytes.
216///
217/// A zip is read from its directory, which is at the end. The head is the
218/// first entry and stored, so its bytes are also right behind the first
219/// local header, and this reads that header by hand. It trusts what
220/// [`write_bundle`] wrote and nothing more general: any other first entry is
221/// not a bundle.
222pub fn head_from_front(front: &[u8]) -> Result<Front, BundleError> {
223    let Some(header) = front.get(..LOCAL_HEADER_LEN) else {
224        return Ok(Front::Needs(LOCAL_HEADER_LEN as u64));
225    };
226    if header[..4] != LOCAL_HEADER {
227        return Err(not_a_bundle("it does not begin with a zip entry"));
228    }
229    let short = |at: usize| u16::from_le_bytes([header[at], header[at + 1]]);
230    let long = |at: usize| {
231        u32::from_le_bytes([header[at], header[at + 1], header[at + 2], header[at + 3]])
232    };
233    let (flags, method, size) = (short(6), short(8), u64::from(long(22)));
234    let (name_len, extra_len) = (usize::from(short(26)), usize::from(short(28)));
235
236    let name_end = LOCAL_HEADER_LEN + name_len;
237    let Some(name) = front.get(LOCAL_HEADER_LEN..name_end) else {
238        return Ok(Front::Needs(name_end as u64));
239    };
240    if name != HEAD_ENTRY.as_bytes() {
241        return Err(BundleError::MissingEntry(HEAD_ENTRY));
242    }
243    if method != 0 || flags & SIZES_AFTER_DATA != 0 {
244        return Err(not_a_bundle("its head is not stored"));
245    }
246    let start = name_end + extra_len;
247    let end = start as u64 + size;
248    match usize::try_from(end)
249        .ok()
250        .and_then(|end| front.get(start..end))
251    {
252        Some(head) => parse_head(head).map(Front::Head),
253        None => Ok(Front::Needs(end)),
254    }
255}
256
257/// An open bundle: its manifest, decoded, and its blobs, still in the file.
258pub struct Bundle<R> {
259    manifest: Manifest,
260    archive: ZipArchive<R>,
261}
262
263/// Open a bundle and decode its manifest. A manifest that names a blob the
264/// bundle does not hold is refused here, not halfway through an install.
265pub fn open_bundle<R: Read + Seek>(reader: R) -> Result<Bundle<R>, BundleError> {
266    let mut archive = ZipArchive::new(reader)?;
267    // The head first: a bundle in another format is refused before its
268    // manifest is looked at.
269    parse_head(&read_entry(&mut archive, HEAD_ENTRY)?)?;
270    let manifest = parse_manifest(&read_entry(&mut archive, MANIFEST_ENTRY)?)?;
271    for id in manifest.blob_ids() {
272        if archive.index_for_name(&blob_entry(id)).is_none() {
273            return Err(BundleError::MissingBlob(id.to_owned()));
274        }
275    }
276    Ok(Bundle { manifest, archive })
277}
278
279impl<R: Read + Seek> Bundle<R> {
280    pub fn manifest(&self) -> &Manifest {
281        &self.manifest
282    }
283
284    pub fn into_manifest(self) -> Manifest {
285        self.manifest
286    }
287
288    /// Write the blob `id` to `out`, returning how many bytes that was.
289    pub fn copy_blob(&mut self, id: &str, out: &mut impl Write) -> Result<u64, BundleError> {
290        let mut entry = match self.archive.by_name(&blob_entry(id)) {
291            Ok(entry) => entry,
292            Err(zip::result::ZipError::FileNotFound) => {
293                return Err(BundleError::MissingBlob(id.to_owned()))
294            }
295            Err(other) => return Err(other.into()),
296        };
297        Ok(io::copy(&mut entry, out)?)
298    }
299}
300
301/// Hashes what passes through it, so a blob is checked against its name by
302/// the same read that writes it.
303struct Hashing<W> {
304    inner: W,
305    hasher: Sha256,
306}
307
308impl<W: Write> Write for Hashing<W> {
309    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
310        let written = self.inner.write(buf)?;
311        self.hasher.update(&buf[..written]);
312        Ok(written)
313    }
314
315    fn flush(&mut self) -> io::Result<()> {
316        self.inner.flush()
317    }
318}
319
320/// Every entry is written with the same fixed timestamp and mode, and blobs go
321/// in id order, so two builds of the same manifest are the same bytes.
322fn entry_options(method: CompressionMethod, size: u64) -> SimpleFileOptions {
323    SimpleFileOptions::default()
324        .compression_method(method)
325        .last_modified_time(DateTime::default())
326        .unix_permissions(0o644)
327        .large_file(size > u64::from(u32::MAX))
328}
329
330fn write_json<W: Write + Seek>(
331    zip: &mut ZipWriter<W>,
332    entry: &'static str,
333    method: CompressionMethod,
334    bytes: &[u8],
335) -> Result<(), BundleError> {
336    zip.start_file(entry, entry_options(method, bytes.len() as u64))?;
337    zip.write_all(bytes)?;
338    Ok(())
339}
340
341/// Write `manifest` and the blobs it names as a bundle, under `head`.
342///
343/// A head in another format is refused: this writes the one it reads.
344///
345/// Only blobs the manifest names are written — `blobs` may hold more, since an
346/// artifact a later plugin replaced leaves its blob behind. Each is hashed as
347/// it is copied and refused if it is not what its name says, which is the one
348/// moment a blob table can be caught lying.
349pub fn write_bundle<W: Write + Seek>(
350    writer: W,
351    head: &Head,
352    manifest: &Manifest,
353    blobs: &Blobs,
354) -> Result<(), BundleError> {
355    if head.format != BUNDLE_FORMAT {
356        return Err(BundleError::Format {
357            found: u64::from(head.format),
358        });
359    }
360    let json = |entry| move |source| BundleError::Json { entry, source };
361    let mut zip = ZipWriter::new(writer);
362
363    // Both are written to be read by a person: a bundle is debugged with
364    // `unzip -p`. The manifest is deflated, so its indentation costs about
365    // one percent of the entry.
366    let head = serde_json::to_vec_pretty(head).map_err(json(HEAD_ENTRY))?;
367    write_json(&mut zip, HEAD_ENTRY, CompressionMethod::Stored, &head)?;
368    let body = serde_json::to_vec_pretty(manifest).map_err(json(MANIFEST_ENTRY))?;
369    write_json(&mut zip, MANIFEST_ENTRY, CompressionMethod::Deflated, &body)?;
370
371    for id in manifest.blob_ids() {
372        let source = blobs
373            .get(id)
374            .ok_or_else(|| BundleError::MissingBlob(id.to_owned()))?;
375        let size = match source {
376            BlobSource::Bytes(bytes) => bytes.len() as u64,
377            BlobSource::File(path) => std::fs::metadata(path)
378                .map_err(|source| BundleError::BlobFile {
379                    id: id.to_owned(),
380                    path: path.display().to_string(),
381                    source,
382                })?
383                .len(),
384        };
385        zip.start_file(
386            blob_entry(id),
387            entry_options(CompressionMethod::Deflated, size),
388        )?;
389        let mut hashing = Hashing {
390            inner: &mut zip,
391            hasher: Sha256::new(),
392        };
393        match source {
394            BlobSource::Bytes(bytes) => hashing.write_all(bytes)?,
395            BlobSource::File(path) => {
396                let mut file = File::open(path).map_err(|source| BundleError::BlobFile {
397                    id: id.to_owned(),
398                    path: path.display().to_string(),
399                    source,
400                })?;
401                io::copy(&mut file, &mut hashing)?;
402            }
403        }
404        let found = hex::encode(hashing.hasher.finalize());
405        if found != id {
406            return Err(BundleError::BlobMismatch {
407                id: id.to_owned(),
408                found,
409            });
410        }
411    }
412
413    zip.finish()?;
414    Ok(())
415}