blockworx/storage/
archive.rs1use 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
21pub enum Scope {
22 Archive,
26 Document,
29}
30
31pub 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 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
73fn 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
96pub 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 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 fn populated(name: &str) -> (TempDir, Container<FsStorage>) {
158 let dir = TempDir::new(name);
159 let c = Container::new(FsStorage::new(dir.path()), name);
160 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 #[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 #[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 #[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}