1use std::io::{Cursor, Read as _, Write as _};
15
16use crate::container::{ASSETS, LOCK, MANIFEST};
17use crate::revs::REVS;
18use crate::storage::{Entry, Storage};
19
20const WITHIN: [&str; 2] = [REVS, ASSETS];
23
24pub async fn pack<S: Storage>(storage: &S) -> std::io::Result<Vec<u8>> {
29 let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
30 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
45pub 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
84fn 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
103async 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
129pub 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 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 #[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 #[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 #[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(¬hing, &archive)).expect_err("not a diagram");
283 assert_eq!(refusal.kind(), std::io::ErrorKind::InvalidData);
284 }
285
286 #[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 #[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}