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/// An open bundle: its manifest, decoded, and its blobs, still in the file.
165pub struct Bundle<R> {
166    manifest: Manifest,
167    archive: ZipArchive<R>,
168}
169
170/// Open a bundle and decode its manifest. A manifest that names a blob the
171/// bundle does not hold is refused here, not halfway through an install.
172pub fn open_bundle<R: Read + Seek>(reader: R) -> Result<Bundle<R>, BundleError> {
173    let mut archive = ZipArchive::new(reader)?;
174    // The head first: a bundle in another format is refused before its
175    // manifest is looked at.
176    parse_head(&read_entry(&mut archive, HEAD_ENTRY)?)?;
177    let manifest = parse_manifest(&read_entry(&mut archive, MANIFEST_ENTRY)?)?;
178    for id in manifest.blob_ids() {
179        if archive.index_for_name(&blob_entry(id)).is_none() {
180            return Err(BundleError::MissingBlob(id.to_owned()));
181        }
182    }
183    Ok(Bundle { manifest, archive })
184}
185
186impl<R: Read + Seek> Bundle<R> {
187    pub fn manifest(&self) -> &Manifest {
188        &self.manifest
189    }
190
191    pub fn into_manifest(self) -> Manifest {
192        self.manifest
193    }
194
195    /// Write the blob `id` to `out`, returning how many bytes that was.
196    pub fn copy_blob(&mut self, id: &str, out: &mut impl Write) -> Result<u64, BundleError> {
197        let mut entry = match self.archive.by_name(&blob_entry(id)) {
198            Ok(entry) => entry,
199            Err(zip::result::ZipError::FileNotFound) => {
200                return Err(BundleError::MissingBlob(id.to_owned()))
201            }
202            Err(other) => return Err(other.into()),
203        };
204        Ok(io::copy(&mut entry, out)?)
205    }
206}
207
208/// Hashes what passes through it, so a blob is checked against its name by
209/// the same read that writes it.
210struct Hashing<W> {
211    inner: W,
212    hasher: Sha256,
213}
214
215impl<W: Write> Write for Hashing<W> {
216    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
217        let written = self.inner.write(buf)?;
218        self.hasher.update(&buf[..written]);
219        Ok(written)
220    }
221
222    fn flush(&mut self) -> io::Result<()> {
223        self.inner.flush()
224    }
225}
226
227/// Every entry is written with the same fixed timestamp and mode, and blobs go
228/// in id order, so two builds of the same manifest are the same bytes.
229fn entry_options(method: CompressionMethod, size: u64) -> SimpleFileOptions {
230    SimpleFileOptions::default()
231        .compression_method(method)
232        .last_modified_time(DateTime::default())
233        .unix_permissions(0o644)
234        .large_file(size > u64::from(u32::MAX))
235}
236
237fn write_json<W: Write + Seek>(
238    zip: &mut ZipWriter<W>,
239    entry: &'static str,
240    method: CompressionMethod,
241    bytes: &[u8],
242) -> Result<(), BundleError> {
243    zip.start_file(entry, entry_options(method, bytes.len() as u64))?;
244    zip.write_all(bytes)?;
245    Ok(())
246}
247
248/// Write `manifest` and the blobs it names as a bundle, under `head`.
249///
250/// A head in another format is refused: this writes the one it reads.
251///
252/// Only blobs the manifest names are written — `blobs` may hold more, since an
253/// artifact a later plugin replaced leaves its blob behind. Each is hashed as
254/// it is copied and refused if it is not what its name says, which is the one
255/// moment a blob table can be caught lying.
256pub fn write_bundle<W: Write + Seek>(
257    writer: W,
258    head: &Head,
259    manifest: &Manifest,
260    blobs: &Blobs,
261) -> Result<(), BundleError> {
262    if head.format != BUNDLE_FORMAT {
263        return Err(BundleError::Format {
264            found: u64::from(head.format),
265        });
266    }
267    let json = |entry| move |source| BundleError::Json { entry, source };
268    let mut zip = ZipWriter::new(writer);
269
270    let head = serde_json::to_vec_pretty(head).map_err(json(HEAD_ENTRY))?;
271    write_json(&mut zip, HEAD_ENTRY, CompressionMethod::Stored, &head)?;
272    let body = serde_json::to_vec(manifest).map_err(json(MANIFEST_ENTRY))?;
273    write_json(&mut zip, MANIFEST_ENTRY, CompressionMethod::Deflated, &body)?;
274
275    for id in manifest.blob_ids() {
276        let source = blobs
277            .get(id)
278            .ok_or_else(|| BundleError::MissingBlob(id.to_owned()))?;
279        let size = match source {
280            BlobSource::Bytes(bytes) => bytes.len() as u64,
281            BlobSource::File(path) => std::fs::metadata(path)
282                .map_err(|source| BundleError::BlobFile {
283                    id: id.to_owned(),
284                    path: path.display().to_string(),
285                    source,
286                })?
287                .len(),
288        };
289        zip.start_file(
290            blob_entry(id),
291            entry_options(CompressionMethod::Deflated, size),
292        )?;
293        let mut hashing = Hashing {
294            inner: &mut zip,
295            hasher: Sha256::new(),
296        };
297        match source {
298            BlobSource::Bytes(bytes) => hashing.write_all(bytes)?,
299            BlobSource::File(path) => {
300                let mut file = File::open(path).map_err(|source| BundleError::BlobFile {
301                    id: id.to_owned(),
302                    path: path.display().to_string(),
303                    source,
304                })?;
305                io::copy(&mut file, &mut hashing)?;
306            }
307        }
308        let found = hex::encode(hashing.hasher.finalize());
309        if found != id {
310            return Err(BundleError::BlobMismatch {
311                id: id.to_owned(),
312                found,
313            });
314        }
315    }
316
317    zip.finish()?;
318    Ok(())
319}