Skip to main content

blockworx_store/
bundle.rs

1//! The share bundle: a container as one file.
2//!
3//! The user: *"I suspect that the application should allow for opening a .zip
4//! file of a .bwx, since that is something that can be sent via e-mail or
5//! dropped on a thumb drive. Directories don't cross OS boundaries all that
6//! well."*
7//!
8//! A container is a directory and stays one — a commit is an append and an
9//! fsync, and the single-writer claim is a file another process can see,
10//! neither of which a zip has anywhere to put. So the bundle is the
11//! *transfer* form and only that: [`pack`] writes one, [`unpack`] lays it back
12//! out as a directory, and opening a bundle is always unpack-then-open. There
13//! is no working-in-place path here to keep in step with the directory one.
14//!
15//! What goes in is the container's own bytes, verbatim and in sorted order:
16//! the rev files — which *are* the diagram, so re-serializing would be
17//! the projection beside it, the rev files, the assets, and the git template. The names given
18//! rewriting them. Names given to revs are `tag` rows in the manifest
19//! rather than a sidecar, so they travel without being asked to. The lock
20//! does not travel: it is one machine's
21//! claim on one directory, and a copy of it is a claim about nothing.
22//!
23//! Whole history, always. The ledger's without-history variant would have to
24//! decide which rev the bundle is cut at and re-stamp the projection, which is
25//! Save-as's question ([`super::prefix`]) rather than this module's; it is
26//! deferred until somebody asks for it.
27
28use std::ffi::OsStr;
29use std::io::{Read, Seek, Write as _};
30use std::path::{Path, PathBuf};
31
32use zip::write::SimpleFileOptions;
33use zip::{CompressionMethod, DateTime, ZipArchive, ZipWriter};
34
35use super::container::{ASSETS, LOCK, MANIFEST};
36use super::revs::REVS;
37
38/// Why a bundle could not be written or read back.
39#[derive(Debug, thiserror::Error)]
40pub enum BundleFailure {
41    #[error("{0} is not a diagram to share")]
42    NotADiagram(PathBuf),
43    #[error("a diagram at {0} has no name to share it under")]
44    Nameless(PathBuf),
45    #[error("it is not a zip file: {0}")]
46    NotAZip(zip::result::ZipError),
47    #[error("it holds no diagram \u{2014} a shared diagram is a zip of a .bwx folder")]
48    NoDiagram,
49    #[error("it holds {0} diagrams, and a shared one is a single diagram")]
50    ManyDiagrams(usize),
51    #[error("it would write {0} outside the diagram")]
52    Escapes(String),
53    #[error("{0} already holds a diagram")]
54    Occupied(PathBuf),
55    #[error("{0}")]
56    Read(std::io::Error),
57    #[error("{0}")]
58    Write(std::io::Error),
59}
60
61/// Write the container at `root` into a bundle at `to`.
62///
63/// The archive holds one top-level directory, named as the container is, so
64/// unpacking it with any tool on any platform yields the `.bwx` folder rather
65/// than its contents loose in whatever directory the user was standing in.
66///
67/// # Errors
68/// [`BundleFailure`]: `root` is no container, its files could not be read, or
69/// the archive could not be written.
70pub fn pack(root: &Path, to: &Path) -> Result<(), BundleFailure> {
71    if !root.join(MANIFEST).is_file() {
72        return Err(BundleFailure::NotADiagram(root.to_path_buf()));
73    }
74    let held = root
75        .file_name()
76        .and_then(OsStr::to_str)
77        .ok_or_else(|| BundleFailure::Nameless(root.to_path_buf()))?;
78    let mut archive = ZipWriter::new(std::io::Cursor::new(Vec::new()));
79    for at in contents(root).map_err(BundleFailure::Read)? {
80        let bytes = std::fs::read(root.join(&at)).map_err(BundleFailure::Read)?;
81        archive
82            .start_file(entry_name(held, &at), stamped())
83            .map_err(BundleFailure::NotAZip)?;
84        archive.write_all(&bytes).map_err(BundleFailure::Write)?;
85    }
86    let bytes = archive
87        .finish()
88        .map_err(BundleFailure::NotAZip)?
89        .into_inner();
90    crate::atomic::write_atomically(to, &bytes).map_err(BundleFailure::Write)
91}
92
93/// Lay the bundle at `from` out as a container at `to`, which must not exist.
94///
95/// A failure part-way leaves nothing behind: half a container is worse than no
96/// container, since the half would still look openable.
97///
98/// # Errors
99/// [`BundleFailure`]: `to` is taken, `from` is not a zip, it holds no diagram
100/// or more than one, an entry names a path outside the diagram, or the files
101/// could not be written.
102pub fn unpack(from: &Path, to: &Path) -> Result<(), BundleFailure> {
103    if to.exists() {
104        return Err(BundleFailure::Occupied(to.to_path_buf()));
105    }
106    let file = std::fs::File::open(from).map_err(BundleFailure::Read)?;
107    let mut archive =
108        ZipArchive::new(std::io::BufReader::new(file)).map_err(BundleFailure::NotAZip)?;
109    let held = diagram_in(&archive)?;
110    lay_out(&mut archive, &held, to).inspect_err(|_| {
111        let _ = std::fs::remove_dir_all(to);
112    })
113}
114
115/// Every file under `root`, relative to it and sorted, so the same container
116/// packs to comparable archives. The lock is left out.
117fn contents(root: &Path) -> std::io::Result<Vec<PathBuf>> {
118    let mut found = Vec::new();
119    let mut walk = vec![PathBuf::new()];
120    while let Some(at) = walk.pop() {
121        for entry in std::fs::read_dir(root.join(&at))? {
122            let entry = entry?;
123            let here = at.join(entry.file_name());
124            if entry.file_type()?.is_dir() {
125                walk.push(here);
126            } else if here != Path::new(LOCK) {
127                found.push(here);
128            }
129        }
130    }
131    found.sort();
132    Ok(found)
133}
134
135/// A zip entry always spells its separator `/`, whatever this platform spells
136/// a path with.
137fn entry_name(held: &str, at: &Path) -> String {
138    let mut name = held.to_owned();
139    for part in at.components() {
140        name.push('/');
141        name.push_str(&part.as_os_str().to_string_lossy());
142    }
143    name
144}
145
146/// One timestamp for every entry, so two packs of the same container differ
147/// in no byte. The archive says nothing about when it was made; the manifest
148/// already says when everything in it happened.
149fn stamped() -> SimpleFileOptions {
150    SimpleFileOptions::default()
151        .compression_method(CompressionMethod::Deflated)
152        .last_modified_time(DateTime::default())
153}
154
155/// Which directory inside the archive is the diagram — the one holding a
156/// manifest, since that is what makes a directory one. Refusing two of them is the
157/// point: an archive of a folder of diagrams is not a shared diagram, and
158/// picking one of them for the user would be picking for them.
159fn diagram_in<R: Read + Seek>(archive: &ZipArchive<R>) -> Result<PathBuf, BundleFailure> {
160    let mut roots: Vec<PathBuf> = archive
161        .file_names()
162        .filter_map(|name| {
163            let path = Path::new(name);
164            (path.file_name() == Some(OsStr::new(MANIFEST)))
165                .then(|| path.parent().unwrap_or(Path::new("")).to_path_buf())
166        })
167        .collect();
168    roots.sort();
169    roots.dedup();
170    match roots.len() {
171        0 => Err(BundleFailure::NoDiagram),
172        1 => Ok(roots.swap_remove(0)),
173        many => Err(BundleFailure::ManyDiagrams(many)),
174    }
175}
176
177/// Write the entries under `held` into `to`. Anything the archiver put beside
178/// the diagram — a `__MACOSX` sidecar, a readme somebody zipped along — is
179/// left in the archive rather than dropped into the container, where a file
180/// nothing wrote would keep it from ever being discarded as pristine.
181fn lay_out<R: Read + Seek>(
182    archive: &mut ZipArchive<R>,
183    held: &Path,
184    to: &Path,
185) -> Result<(), BundleFailure> {
186    std::fs::create_dir_all(to.join(ASSETS)).map_err(BundleFailure::Write)?;
187    std::fs::create_dir_all(to.join(REVS)).map_err(BundleFailure::Write)?;
188    for nth in 0..archive.len() {
189        let mut entry = archive.by_index(nth).map_err(BundleFailure::NotAZip)?;
190        // `enclosed_name` is the crate's own zip-slip guard: `None` for an
191        // absolute path, a `..` component, or a drive letter.
192        let Some(inside) = entry.enclosed_name() else {
193            return Err(BundleFailure::Escapes(entry.name().to_owned()));
194        };
195        let Ok(at) = inside.strip_prefix(held) else {
196            continue;
197        };
198        if entry.is_dir() || at.as_os_str().is_empty() || at == Path::new(LOCK) {
199            continue;
200        }
201        let target = to.join(at);
202        if let Some(dir) = target.parent() {
203            std::fs::create_dir_all(dir).map_err(BundleFailure::Write)?;
204        }
205        let mut file = std::fs::File::create(&target).map_err(BundleFailure::Write)?;
206        std::io::copy(&mut entry, &mut file).map_err(BundleFailure::Write)?;
207    }
208    Ok(())
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::container::{GITATTRIBUTES, PROJECTION};
215    use crate::fixture;
216    use crate::handle::Store;
217    use crate::record::Identity;
218    use blockworx_doc::fixtures::rev;
219
220    /// A container with three edits, a named rev and a piece of artwork —
221    /// everything a bundle has to carry.
222    fn source(root: &Path) -> Vec<u8> {
223        let mut store = Store::create(root, fixture::clock()).expect("the container");
224        let author = Identity::new("ada");
225        for commit in fixture::edits(2) {
226            store.submit_edit(commit, &author).expect("the edit lands");
227        }
228        let asset = fixture::svg(1);
229        store
230            .submit_edit(
231                fixture::commit("Added an icon", fixture::icon(1, &asset)),
232                &author,
233            )
234            .expect("the icon lands");
235        store
236            .tag(
237                rev(2),
238                "worth keeping",
239                crate::tags::Tagging::Added,
240                &author,
241            )
242            .expect("the tag");
243        store.save_projection().expect("the projection");
244        drop(store);
245        asset.bytes().to_vec()
246    }
247
248    /// The round trip the whole feature is: a container shared to one file,
249    /// opened somewhere else, is the same diagram — the same manifest
250    /// *bytes*, with its revs, its names and its artwork, and it
251    /// verifies rather than opening read-only over a broken chain.
252    #[test]
253    fn a_shared_diagram_opens_elsewhere_as_the_diagram_it_was() {
254        let dir = fixture::dir("bundle-round-trip");
255        let from = dir.join("engine.bwx");
256        let artwork = source(&from);
257        let bundle = dir.join("engine.bwx.zip");
258        pack(&from, &bundle).expect("the bundle is written");
259
260        let to = dir.join("elsewhere").join("engine.bwx");
261        unpack(&bundle, &to).expect("the bundle unpacks");
262
263        assert_eq!(
264            std::fs::read(from.join(MANIFEST)).expect("the source manifest"),
265            std::fs::read(to.join(MANIFEST)).expect("the unpacked manifest"),
266            "the unpacked manifest is not the bytes that were shared",
267        );
268        // Before it is opened: opening a container backfills the revs it
269        // is missing, which would hide a bundle that carried none.
270        for at in crate::revs::through(rev(3)) {
271            assert_eq!(
272                std::fs::read(crate::revs::path(&from, at)).expect("the shared rev"),
273                std::fs::read(crate::revs::path(&to, at)).expect("the unpacked rev"),
274                "rev {} did not come over in the bundle",
275                at.get(),
276            );
277        }
278        let opened = Store::open(&to, fixture::clock()).expect("the unpacked container opens");
279        assert!(
280            opened.read_only_reason().is_none(),
281            "the unpacked diagram did not verify: {:?}",
282            opened.read_only_reason(),
283        );
284        assert_eq!(opened.repo().rev(), rev(3), "the head did not come over");
285        assert_eq!(
286            opened.tags().of(rev(2)),
287            ["worth keeping"],
288            "a rev's name did not come over",
289        );
290        assert_eq!(
291            opened.projection(),
292            crate::projection::Freshness::Fresh,
293            "the projection beside the unpacked revs does not stamp its own head",
294        );
295        let carried: Vec<Vec<u8>> = std::fs::read_dir(to.join(ASSETS))
296            .expect("the unpacked assets")
297            .map(|entry| std::fs::read(entry.expect("an entry").path()).expect("the payload"))
298            .collect();
299        assert_eq!(carried, vec![artwork], "the artwork did not come over");
300    }
301
302    /// The lock is one machine's claim on one directory. Packed into an
303    /// archive it would arrive as a claim by a process that is not running,
304    /// and the diagram would open read-only on the far side.
305    #[test]
306    fn the_bundle_carries_no_lock() {
307        let dir = fixture::dir("bundle-no-lock");
308        let from = dir.join("engine.bwx");
309        // Held open, so the lock is on disk while the bundle is written —
310        // which is the case that matters: Share is pressed from inside the
311        // session that holds it.
312        let mut store = Store::create(&from, fixture::clock()).expect("the container");
313        store
314            .submit_edit(fixture::edits(1)[0].clone(), &Identity::new("ada"))
315            .expect("the edit lands");
316        assert!(
317            from.join(LOCK).is_file(),
318            "precondition: the open session holds the lock",
319        );
320
321        let bundle = dir.join("engine.bwx.zip");
322        pack(&from, &bundle).expect("the bundle is written");
323        let names = names_in(&bundle);
324        assert!(
325            !names.iter().any(|name| name.ends_with(LOCK)),
326            "the bundle carries a lock: {names:?}",
327        );
328        assert_eq!(
329            names,
330            vec![
331                format!("engine.bwx/{GITATTRIBUTES}"),
332                format!("engine.bwx/{MANIFEST}"),
333                format!("engine.bwx/{REVS}/000001.json.zst"),
334            ],
335            "the bundle is not the container's own files under its own name",
336        );
337
338        drop(store);
339        let to = dir.join("unpacked.bwx");
340        unpack(&bundle, &to).expect("the bundle unpacks");
341        assert!(
342            !to.join(LOCK).exists(),
343            "the unpacked diagram arrived locked"
344        );
345    }
346
347    /// Sorted paths, one timestamp: the same container shared twice is the
348    /// same archive, so two bundles can be compared byte for byte.
349    #[test]
350    fn the_same_diagram_shares_to_the_same_bytes() {
351        let dir = fixture::dir("bundle-deterministic");
352        let from = dir.join("engine.bwx");
353        source(&from);
354        let (once, twice) = (dir.join("once.zip"), dir.join("twice.zip"));
355        pack(&from, &once).expect("the first bundle");
356        pack(&from, &twice).expect("the second bundle");
357        assert_eq!(
358            std::fs::read(&once).expect("the first"),
359            std::fs::read(&twice).expect("the second"),
360            "two shares of one diagram are not the same archive",
361        );
362        assert!(
363            names_in(&once).windows(2).all(|pair| pair[0] < pair[1]),
364            "the entries are not in sorted order: {:?}",
365            names_in(&once),
366        );
367    }
368
369    /// A zip that is not a shared diagram is refused, and the refusal says
370    /// which of the things that can be wrong with it was.
371    #[test]
372    fn a_zip_with_no_diagram_in_it_is_refused_by_name() {
373        let dir = fixture::dir("bundle-not-a-diagram");
374        let bundle = dir.join("holiday.zip");
375        write_zip(&bundle, &[("photos/beach.jpg", b"not a diagram")]);
376        let refusal =
377            unpack(&bundle, &dir.join("out.bwx")).expect_err("a zip of photos is refused");
378        assert!(
379            matches!(refusal, BundleFailure::NoDiagram),
380            "the wrong refusal: {refusal}",
381        );
382        assert!(
383            refusal.to_string().contains(".bwx"),
384            "the refusal does not say what a shared diagram is: {refusal}",
385        );
386        assert!(
387            !dir.join("out.bwx").exists(),
388            "a refused unpack left a container behind",
389        );
390
391        let two = dir.join("both.zip");
392        write_zip(
393            &two,
394            &[
395                ("one.bwx/manifest.jsonl", b""),
396                ("two.bwx/manifest.jsonl", b""),
397            ],
398        );
399        assert!(
400            matches!(
401                unpack(&two, &dir.join("out.bwx")),
402                Err(BundleFailure::ManyDiagrams(2)),
403            ),
404            "a zip of two diagrams was taken for one",
405        );
406
407        let corrupt = dir.join("corrupt.zip");
408        std::fs::write(&corrupt, b"PK\x03\x04 and then nothing").expect("the corrupt file");
409        assert!(matches!(
410            unpack(&corrupt, &dir.join("out.bwx")),
411            Err(BundleFailure::NotAZip(_)),
412        ));
413    }
414
415    /// An entry naming a path outside the diagram is refused rather than
416    /// skipped: an archive reaching for the filesystem around it is not a
417    /// bundle we finish reading.
418    #[test]
419    fn an_entry_reaching_outside_the_diagram_is_refused() {
420        let dir = fixture::dir("bundle-escape");
421        let bundle = dir.join("nasty.zip");
422        write_zip(
423            &bundle,
424            &[
425                ("engine.bwx/manifest.jsonl", b""),
426                ("engine.bwx/../../taken.txt", b"gotcha"),
427            ],
428        );
429        let out = dir.join("out.bwx");
430        assert!(matches!(
431            unpack(&bundle, &out),
432            Err(BundleFailure::Escapes(_)),
433        ));
434        assert!(!out.exists(), "a refused unpack left a container behind");
435        assert!(
436            !dir.path().join("taken.txt").exists(),
437            "the archive wrote outside the diagram",
438        );
439    }
440
441    /// Nothing is ever overwritten to make room for an unpack. The chrome
442    /// asks the user where instead ([`crate::file::unpacks_to`]); this is the
443    /// same refusal at the seam under it.
444    #[test]
445    fn unpacking_onto_something_that_exists_is_refused() {
446        let dir = fixture::dir("bundle-occupied");
447        let from = dir.join("engine.bwx");
448        source(&from);
449        let bundle = dir.join("engine.bwx.zip");
450        pack(&from, &bundle).expect("the bundle");
451
452        let standing = std::fs::read(from.join(MANIFEST)).expect("the manifest that is in the way");
453        let refusal = unpack(&bundle, &from).expect_err("unpacking over a diagram is refused");
454        assert!(matches!(refusal, BundleFailure::Occupied(_)));
455        assert_eq!(
456            std::fs::read(from.join(MANIFEST)).expect("the manifest that was in the way"),
457            standing,
458            "the diagram in the way was written over",
459        );
460        assert!(
461            from.join(PROJECTION).is_file(),
462            "the diagram in the way lost a file",
463        );
464    }
465
466    #[test]
467    fn a_directory_that_is_not_a_diagram_is_not_shareable() {
468        let dir = fixture::dir("bundle-source");
469        let plain = dir.join("just-a-folder");
470        std::fs::create_dir_all(&plain).expect("the directory");
471        assert!(matches!(
472            pack(&plain, &dir.join("out.zip")),
473            Err(BundleFailure::NotADiagram(_)),
474        ));
475    }
476
477    fn names_in(bundle: &Path) -> Vec<String> {
478        let file = std::fs::File::open(bundle).expect("the bundle");
479        let archive = ZipArchive::new(std::io::BufReader::new(file)).expect("a zip");
480        archive.file_names().map(str::to_owned).collect()
481    }
482
483    /// A zip written by hand, so a test can put things in one that `pack`
484    /// never would.
485    fn write_zip(at: &Path, entries: &[(&str, &[u8])]) {
486        let file = std::fs::File::create(at).expect("the file");
487        let mut archive = ZipWriter::new(std::io::BufWriter::new(file));
488        for (name, bytes) in entries {
489            archive
490                .start_file(*name, stamped())
491                .expect("the entry starts");
492            archive.write_all(bytes).expect("the entry is written");
493        }
494        archive.finish().expect("the archive closes");
495    }
496}