Skip to main content

blockworx_store/
transfer.rs

1//! A container as one file: the `.bwx.zip` a document travels in.
2//!
3//! A `.bwx` is a directory, which a host that has one hands over as it
4//! stands. A browser has none to hand over, so a container leaves the
5//! origin — and comes back into it — packed, and this is the one place that
6//! packing is written. Over a [`Storage`], so the two ends are the same
7//! bytes whether a desktop or a tab wrote them.
8//!
9//! The lock does not travel: it says who is writing a container *here*, and
10//! a copy someone else is opening is not that container. Everything else in
11//! the layout does, which is what makes an unpacked archive a container the
12//! ordinary open verifies rather than a shape that only looks like one.
13
14use std::io::{Cursor, Read as _, Write as _};
15
16use crate::container::{ASSETS, LOCK, MANIFEST};
17use crate::revs::REVS;
18use crate::storage::{Entry, Storage};
19
20/// The directories a container files entries in, which is the whole of its
21/// nesting: `pack` walks these and nothing deeper.
22const WITHIN: [&str; 2] = [REVS, ASSETS];
23
24/// Everything in `storage` as a zip, entry names being the container's own.
25///
26/// # Errors
27/// The read that did not work, or a zip that would not be written.
28pub async fn pack<S: Storage>(storage: &S) -> std::io::Result<Vec<u8>> {
29    let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
30    // Stored, not deflated: a rev is already gzip and a payload is already
31    // PNG, so the second pass costs the tab a second compression of the
32    // same bytes and saves the transfer almost nothing.
33    let how =
34        zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
35    for at in entries(storage).await? {
36        let Some(bytes) = storage.read(&at).await? else {
37            continue;
38        };
39        archive.start_file(at.as_str(), how).map_err(refused)?;
40        archive.write_all(&bytes)?;
41    }
42    Ok(archive.finish().map_err(refused)?.into_inner())
43}
44
45/// Lay the container `archive` holds down in `storage`.
46///
47/// # Errors
48/// [`std::io::ErrorKind::InvalidData`] for an archive that is not a
49/// container — one holding no manifest, or naming an entry outside the
50/// layout — and the write that did not land.
51pub async fn unpack<S: Storage>(storage: &S, archive: &[u8]) -> std::io::Result<()> {
52    let mut read = zip::ZipArchive::new(Cursor::new(archive)).map_err(refused)?;
53    let mut held: Vec<(Entry, Vec<u8>)> = Vec::new();
54    for at in 0..read.len() {
55        let mut file = read.by_index(at).map_err(refused)?;
56        if file.is_dir() {
57            continue;
58        }
59        let Some(at) = named(file.name()) else {
60            return Err(std::io::Error::new(
61                std::io::ErrorKind::InvalidData,
62                format!("{} is not part of a diagram", file.name()),
63            ));
64        };
65        let mut bytes = Vec::with_capacity(file.size() as usize);
66        file.read_to_end(&mut bytes)?;
67        held.push((at, bytes));
68    }
69    if !held.iter().any(|(at, _)| *at == MANIFEST) {
70        return Err(std::io::Error::new(
71            std::io::ErrorKind::InvalidData,
72            format!("this archive holds no {MANIFEST}, so it holds no diagram"),
73        ));
74    }
75    for dir in WITHIN {
76        storage.create_dir(&Entry::fixed(dir)).await?;
77    }
78    for (at, bytes) in held {
79        storage.write(&at, &bytes).await?;
80    }
81    Ok(())
82}
83
84/// The entry `name` spells, or `None` for a name no container ever wrote:
85/// one that climbs out of the container, or sits under a directory the
86/// layout does not have.
87fn named(name: &str) -> Option<Entry> {
88    let mut path = name.split('/');
89    match (path.next()?, path.next(), path.next()) {
90        (first, None, _) if first == LOCK.as_str() || !is_a_name(first) => None,
91        (first, None, _) => Some(Entry::named(first)),
92        (dir, Some(entry), None) if WITHIN.contains(&dir) && is_a_name(entry) => {
93            Some(Entry::under(dir, entry))
94        }
95        _ => None,
96    }
97}
98
99fn is_a_name(entry: &str) -> bool {
100    !entry.is_empty() && entry != "." && entry != ".."
101}
102
103/// What a container holds, in the order it was laid out: the files beside
104/// the manifest, then what each directory of it holds.
105async fn entries<S: Storage>(storage: &S) -> std::io::Result<Vec<Entry>> {
106    let mut found = Vec::new();
107    for name in storage.list(&Entry::ROOT).await? {
108        if WITHIN.contains(&name.as_str()) || name == LOCK.as_str() {
109            continue;
110        }
111        found.push(Entry::named(&name));
112    }
113    found.sort();
114    for dir in WITHIN {
115        let mut within = storage.list(&Entry::fixed(dir)).await.unwrap_or_default();
116        within.sort();
117        found.extend(within.iter().map(|name| Entry::under(dir, name)));
118    }
119    Ok(found)
120}
121
122fn refused(why: zip::result::ZipError) -> std::io::Error {
123    match why {
124        zip::result::ZipError::Io(error) => error,
125        other => std::io::Error::new(std::io::ErrorKind::InvalidData, other.to_string()),
126    }
127}
128
129/// The document an archive holds.
130///
131/// Laid down in memory and opened exactly as the library opens one, so what
132/// an archive *reads* as and what it *opens* as cannot come apart. Reading
133/// one file off the top would be cheaper, and was how this started: the
134/// container used to carry a pretty-printed copy of its head beside the
135/// revs. That copy was allowed to fall behind, so an archive packed between
136/// an edit and the save after it read as a drawing missing its most recent
137/// edits — and the file is gone now
138/// (`docs/retire-projection-playbook.md`).
139///
140/// # Errors
141/// [`std::io::ErrorKind::InvalidData`] for an archive that is not a
142/// container, and for a container that will not open.
143pub fn document_in(archive: &[u8]) -> std::io::Result<blockworx_doc::document::Document> {
144    let storage = crate::storage::Memory::new("embedded.bwx");
145    crate::storage::ready_now(unpack(&storage, archive))?;
146    let store = crate::handle::Store::reading(crate::storage::Any::new(storage))
147        .map_err(|why| std::io::Error::new(std::io::ErrorKind::InvalidData, why.to_string()))?;
148    Ok(store.document().clone())
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::fixture::{self, block_on};
155    use crate::handle::Store;
156    use crate::storage::{Any, Memory, Name};
157
158    /// A container with rows, revs and a payload in it — everything the
159    /// layout has places for.
160    fn packed() -> (Memory, Vec<u8>) {
161        let bytes = Memory::new("carried.bwx");
162        let mut store =
163            Store::create(Any::new(bytes.clone()), fixture::clock()).expect("the container");
164        let asset = fixture::svg(1);
165        for commit in fixture::edits(2) {
166            store
167                .submit_edit(commit, &fixture::author())
168                .expect("an edit");
169        }
170        store
171            .submit_edit(
172                fixture::commit("Added an icon", fixture::icon(1, &asset)),
173                &fixture::author(),
174            )
175            .expect("the icon");
176        assert!(
177            block_on(bytes.exists(&LOCK)).expect("the read"),
178            "precondition: what is packed is a container this session holds",
179        );
180        let archive = block_on(pack(&bytes)).expect("the archive");
181        (bytes, archive)
182    }
183
184    /// Artwork lives once, in `assets/<hash>`, and everything else keeps
185    /// only the hash — so reading a document out of an archive has to put
186    /// the payloads back, not just find the references.
187    #[test]
188    fn the_document_an_archive_holds_comes_back_with_its_artwork_attached() {
189        let (was, archive) = packed();
190        let stood = Store::open(Any::new(was), fixture::clock()).expect("the original");
191        assert!(
192            stood.document().assets().next().is_some(),
193            "precondition: the packed document holds a payload, so reading it back \
194             proves the bytes travel and not only the hash",
195        );
196
197        let read = document_in(&archive).expect("the archive holds a diagram");
198        assert_eq!(&read, stood.document());
199        assert_eq!(read.assets().count(), stood.document().assets().count());
200    }
201
202    #[test]
203    fn what_is_not_an_archive_at_all_is_refused_rather_than_read() {
204        let refused = document_in(b"not a zip").expect_err("there is no archive here");
205        assert_eq!(refused.kind(), std::io::ErrorKind::InvalidData);
206    }
207
208    /// An archive read is an archive opened: one path, so the two cannot
209    /// come apart. They did while this read `document.json` — a view, which
210    /// a container packed between an edit and the save after it carried
211    /// behind its own head.
212    #[test]
213    fn what_an_archive_reads_as_is_what_it_opens_as() {
214        let bytes = Memory::new("drifting.bwx");
215        let mut store =
216            Store::create(Any::new(bytes.clone()), fixture::clock()).expect("the container");
217        for commit in fixture::edits(2) {
218            store
219                .submit_edit(commit, &fixture::author())
220                .expect("the edit lands");
221        }
222        let archive = block_on(pack(&bytes)).expect("the archive");
223
224        let read = document_in(&archive).expect("the archive holds a diagram");
225        let laid = Memory::new("opened.bwx");
226        block_on(unpack(&laid, &archive)).expect("the archive lays down");
227        let opened = Store::open(Any::new(laid), fixture::clock()).expect("a container");
228
229        assert_eq!(
230            opened.document().rev().get(),
231            2,
232            "precondition: two edits stand"
233        );
234        assert_eq!(
235            &read,
236            opened.document(),
237            "reading an archive and opening it must come to the same drawing",
238        );
239    }
240
241    #[test]
242    fn a_container_packed_and_unpacked_is_the_container_it_was() {
243        let (was, archive) = packed();
244        let now = Memory::new("arrived.bwx");
245        block_on(unpack(&now, &archive)).expect("the archive lays down");
246
247        let opened = Store::open(Any::new(now.clone()), fixture::clock()).expect("a container");
248        let stood = Store::open(Any::new(was.clone()), fixture::clock()).expect("the original");
249        assert_eq!(opened.rows().len(), stood.rows().len());
250        assert_eq!(opened.document(), stood.document());
251        assert!(
252            opened.read_only_reason().is_none(),
253            "an unpacked container is one this session may write",
254        );
255        assert_eq!(
256            block_on(super::entries(&was)).expect("the entries"),
257            block_on(super::entries(&now)).expect("the entries"),
258            "the two containers hold the same entries",
259        );
260    }
261
262    /// The lock is about who is writing a container here, so a copy of it
263    /// arrives unheld.
264    #[test]
265    fn the_lock_does_not_travel() {
266        let (_, archive) = packed();
267        let now = Memory::new("arrived.bwx");
268        block_on(unpack(&now, &archive)).expect("the archive lays down");
269        assert!(!block_on(now.exists(&LOCK)).expect("the read"));
270    }
271
272    #[test]
273    fn an_archive_that_is_not_a_container_is_refused() {
274        let nothing = Memory::new("refused.bwx");
275        let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
276        archive
277            .start_file("notes.txt", zip::write::SimpleFileOptions::default())
278            .expect("a file");
279        archive.write_all(b"hello").expect("the bytes");
280        let archive = archive.finish().expect("the archive").into_inner();
281
282        let refusal = block_on(unpack(&nothing, &archive)).expect_err("not a diagram");
283        assert_eq!(refusal.kind(), std::io::ErrorKind::InvalidData);
284    }
285
286    /// A name that climbs out of the container is refused rather than
287    /// written somewhere it was never meant to reach.
288    #[test]
289    fn an_entry_outside_the_layout_is_refused() {
290        for climbing in [
291            "../escaped",
292            "..",
293            "",
294            "revs/../../escaped",
295            "revs/..",
296            "elsewhere/rev.json.gz",
297        ] {
298            assert_eq!(named(climbing), None, "{climbing} was taken for an entry");
299        }
300        assert_eq!(named(LOCK.as_str()), None, "the lock is not carried");
301        assert_eq!(named(MANIFEST.as_str()), Some(MANIFEST));
302        assert_eq!(
303            named("revs/000001.json.gz"),
304            Some(Entry::under(REVS, "000001.json.gz")),
305        );
306    }
307
308    /// The whole point of the form: a container leaves one storage and
309    /// opens in another under a name of its own.
310    #[test]
311    fn a_container_travels_between_two_storages_under_a_new_name() {
312        let (_, archive) = packed();
313        let arrived = Memory::new("renamed.bwx");
314        block_on(unpack(&arrived, &archive)).expect("the archive lays down");
315        let store = Store::open(Any::new(arrived), fixture::clock()).expect("a container");
316        assert_eq!(store.name(), Name::of_document("renamed").expect("a name"));
317    }
318}