Skip to main content

blockworx/storage/
archive.rs

1//! A container as a single `.zip`, for getting one in or out in one piece.
2//!
3//! This is how a container travels: between machines, between the native app
4//! and the browser, and — on Firefox and Safari, which have no directory picker
5//! — it is the *only* way a document leaves the browser at all.
6//!
7//! There are two scopes, and which one is the default matters. A container holds
8//! every earlier draft, everything that was deleted, and every asset that was
9//! ever placed, since history may still point at them. That is what you want
10//! from a backup and emphatically not what you want to hand to someone.
11
12use std::io::{Cursor, Read as _, Write as _};
13
14use super::container::{ASSETS, Container, ROOT};
15use super::history;
16use super::{Durability, Storage};
17use crate::schema::model as schema;
18
19/// What to include when writing a container out.
20#[derive(Clone, Copy, PartialEq, Eq, Debug)]
21pub enum Scope {
22    /// Everything: the history, and every asset whether the current document
23    /// references it or not. For backups, moving a document between machines,
24    /// and native↔web exchange.
25    Archive,
26    /// The current document and only the assets it actually places. For sharing,
27    /// where the history is nobody else's business.
28    Document,
29}
30
31/// Write `container` out as a zip.
32pub fn to_zip(container: &Container<impl Storage>, scope: Scope) -> miette::Result<Vec<u8>> {
33    let storage = container.storage();
34    let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
35    let options: zip::write::FileOptions<'_, ()> =
36        zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
37
38    let add = |zip: &mut zip::ZipWriter<Cursor<Vec<u8>>>, path: &str| -> miette::Result<()> {
39        let bytes = storage
40            .read(path)
41            .map_err(|e| miette::miette!("reading {path}: {e}"))?;
42        zip.start_file(path, options)
43            .map_err(|e| miette::miette!("adding {path}: {e}"))?;
44        zip.write_all(&bytes)
45            .map_err(|e| miette::miette!("writing {path}: {e}"))?;
46        Ok(())
47    };
48
49    if storage.exists(ROOT) {
50        add(&mut zip, ROOT)?;
51    }
52    for name in wanted_assets(container, scope)? {
53        add(&mut zip, &format!("{ASSETS}/{name}"))?;
54    }
55    if scope == Scope::Archive {
56        let mut names = storage
57            .list(history::DIR)
58            .map_err(|e| miette::miette!("listing {}: {e}", history::DIR))?;
59        // Sorted so an archive of the same container is byte-stable rather than
60        // depending on the order the filesystem happened to return.
61        names.sort();
62        for name in names {
63            add(&mut zip, &format!("{}/{name}", history::DIR))?;
64        }
65    }
66
67    let cursor = zip
68        .finish()
69        .map_err(|e| miette::miette!("finishing the archive: {e}"))?;
70    Ok(cursor.into_inner())
71}
72
73/// The asset file names to include: all of them for an archive, and for a
74/// document only the ones its placements name.
75fn wanted_assets(container: &Container<impl Storage>, scope: Scope) -> miette::Result<Vec<String>> {
76    let storage = container.storage();
77    let mut names = storage
78        .list(ASSETS)
79        .map_err(|e| miette::miette!("listing {ASSETS}: {e}"))?;
80    names.sort();
81    if scope == Scope::Archive {
82        return Ok(names);
83    }
84    let Ok(bytes) = storage.read(ROOT) else {
85        return Ok(Vec::new());
86    };
87    let src = String::from_utf8_lossy(&bytes);
88    let model = schema::Document::parse_kdl(&src, ROOT)?;
89    let referenced = model.referenced_assets();
90    Ok(names
91        .into_iter()
92        .filter(|name| referenced.iter().any(|id| id == name))
93        .collect())
94}
95
96/// Read a zip into `container`.
97///
98/// Entries are written the way the container writes them itself, so a zip made
99/// elsewhere lands as an ordinary container rather than as a special case.
100/// Paths that would escape the container are refused by the storage, which is
101/// what keeps a hostile archive from writing outside it.
102pub fn from_zip(bytes: &[u8], container: &Container<impl Storage>) -> miette::Result<()> {
103    let mut zip = zip::ZipArchive::new(Cursor::new(bytes))
104        .map_err(|e| miette::miette!("reading the archive: {e}"))?;
105    for i in 0..zip.len() {
106        let mut entry = zip
107            .by_index(i)
108            .map_err(|e| miette::miette!("reading archive entry {i}: {e}"))?;
109        if entry.is_dir() {
110            continue;
111        }
112        // `enclosed_name` is None for anything that would escape — an absolute
113        // path, or one climbing out with `..`.
114        let Some(path) = entry.enclosed_name() else {
115            return Err(miette::miette!(
116                "the archive holds an entry that would write outside the container: {}",
117                entry.name()
118            ));
119        };
120        let path = path.to_string_lossy().replace('\\', "/");
121        let mut contents = Vec::new();
122        entry
123            .read_to_end(&mut contents)
124            .map_err(|e| miette::miette!("reading {path} from the archive: {e}"))?;
125        container
126            .storage()
127            .write(&path, &contents, Durability::Relaxed)
128            .map_err(|e| miette::miette!("writing {path}: {e}"))?;
129    }
130    Ok(())
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::document::{Document, Image, ImageData};
137    use crate::storage::atomic::tests::TempDir;
138    use crate::storage::fs::FsStorage;
139    use crate::store::IdMapExt as _;
140
141    fn with_image(marker: &str) -> Document {
142        let mut doc = Document::default();
143        let top = doc.top_id;
144        doc.blocks
145            .get_mut(&top)
146            .expect("the top block")
147            .images
148            .insert_value(Image::new(
149                ImageData::Svg(format!("<svg viewBox=\"0 0 1 1\"><!--{marker}--></svg>")),
150                egui::Rect::ZERO,
151            ));
152        doc
153    }
154
155    /// A container holding one live image, one asset nothing references any
156    /// more, and two history entries.
157    fn populated(name: &str) -> (TempDir, Container<FsStorage>) {
158        let dir = TempDir::new(name);
159        let c = Container::new(FsStorage::new(dir.path()), name);
160        // An earlier image, then a different one: the first asset stays on disk
161        // unreferenced, which is the container's whole point.
162        c.save(&with_image("dropped")).unwrap();
163        c.save_and_record(
164            &with_image("kept"),
165            0,
166            &history::Entry {
167                ts: 1,
168                command: Some("delete".to_string()),
169                changed: vec!["b1".to_string()],
170            },
171        )
172        .unwrap();
173        c.save_and_record(
174            &with_image("kept"),
175            1,
176            &history::Entry {
177                ts: 2,
178                command: None,
179                changed: vec!["b1".to_string()],
180            },
181        )
182        .unwrap();
183        (dir, c)
184    }
185
186    fn names_in(zip: &[u8]) -> Vec<String> {
187        let mut archive = zip::ZipArchive::new(Cursor::new(zip)).unwrap();
188        let mut names: Vec<String> = (0..archive.len())
189            .map(|i| archive.by_index(i).unwrap().name().to_string())
190            .collect();
191        names.sort();
192        names
193    }
194
195    #[test]
196    fn an_archive_carries_the_history_and_every_asset() {
197        let (_dir, c) = populated("archive-full");
198        let names = names_in(&to_zip(&c, Scope::Archive).unwrap());
199
200        assert!(names.contains(&ROOT.to_string()));
201        assert_eq!(
202            names.iter().filter(|n| n.starts_with("assets/")).count(),
203            2,
204            "the unreferenced asset belongs in a backup: {names:?}"
205        );
206        assert_eq!(
207            names.iter().filter(|n| n.starts_with("history/")).count(),
208            4,
209            "two entries, payload and sidecar each: {names:?}"
210        );
211    }
212
213    /// The distinction that matters: a container carries every earlier draft
214    /// and everything deleted, so sharing one leaks the lot.
215    #[test]
216    fn a_document_export_carries_no_history_and_no_orphaned_assets() {
217        let (_dir, c) = populated("archive-document");
218        let names = names_in(&to_zip(&c, Scope::Document).unwrap());
219
220        assert!(names.contains(&ROOT.to_string()));
221        assert!(
222            !names.iter().any(|n| n.starts_with("history/")),
223            "history leaked into a shared document: {names:?}"
224        );
225        assert_eq!(
226            names.iter().filter(|n| n.starts_with("assets/")).count(),
227            1,
228            "only the placed image belongs: {names:?}"
229        );
230    }
231
232    #[test]
233    fn a_container_round_trips_through_a_zip() {
234        let (_dir, source) = populated("archive-roundtrip");
235        let zipped = to_zip(&source, Scope::Archive).unwrap();
236
237        let dest_dir = TempDir::new("archive-roundtrip-dest");
238        let dest = Container::new(FsStorage::new(dest_dir.path()), "dest");
239        from_zip(&zipped, &dest).unwrap();
240
241        assert_eq!(dest.load().unwrap(), source.load().unwrap());
242        assert_eq!(
243            history::records(dest.storage()).unwrap(),
244            history::records(source.storage()).unwrap(),
245        );
246        assert_eq!(
247            names_in(&to_zip(&dest, Scope::Archive).unwrap()),
248            names_in(&zipped)
249        );
250    }
251
252    /// A shared document is a working container in its own right, just without
253    /// the history — opening one must not need anything that was stripped.
254    #[test]
255    fn a_document_export_opens_as_a_container() {
256        let (_dir, source) = populated("archive-document-opens");
257        let zipped = to_zip(&source, Scope::Document).unwrap();
258
259        let dest_dir = TempDir::new("archive-document-opens-dest");
260        let dest = Container::new(FsStorage::new(dest_dir.path()), "dest");
261        from_zip(&zipped, &dest).unwrap();
262
263        assert_eq!(dest.load().unwrap(), source.load().unwrap());
264        assert!(history::records(dest.storage()).unwrap().is_empty());
265    }
266
267    /// An archive is untrusted input: an entry naming a path outside the
268    /// container must not be written there.
269    #[test]
270    fn an_archive_cannot_write_outside_the_container() {
271        let dir = TempDir::new("archive-escape");
272        let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
273        let options: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default();
274        zip.start_file("../escaped.kdl", options).unwrap();
275        zip.write_all(b"gotcha").unwrap();
276        let bytes = zip.finish().unwrap().into_inner();
277
278        let c = Container::new(FsStorage::new(dir.join("inner")), "inner");
279        assert!(from_zip(&bytes, &c).is_err());
280        assert!(
281            !dir.join("escaped.kdl").exists(),
282            "the archive wrote outside the container"
283        );
284    }
285
286    #[test]
287    fn an_empty_container_archives_to_an_empty_zip() {
288        let dir = TempDir::new("archive-empty");
289        let c = Container::new(FsStorage::new(dir.path()), "empty");
290        assert!(names_in(&to_zip(&c, Scope::Archive).unwrap()).is_empty());
291    }
292}