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