1use 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#[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
61pub 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
93pub 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
115fn 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
135fn 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
146fn stamped() -> SimpleFileOptions {
150 SimpleFileOptions::default()
151 .compression_method(CompressionMethod::Deflated)
152 .last_modified_time(DateTime::default())
153}
154
155fn 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
177fn 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 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 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 #[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 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 #[test]
306 fn the_bundle_carries_no_lock() {
307 let dir = fixture::dir("bundle-no-lock");
308 let from = dir.join("engine.bwx");
309 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 #[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 #[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 #[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 #[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 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}