tests.rsannotatedtests.rssource431 lines · 18.4 KB · raw
1use std::io::Write;
2
3use zip::write::SimpleFileOptions;
4use zip::{CompressionMethod, ZipWriter};
5
6use super::*;
7
8const DB: &str = crate::DATABASE;

A scratch directory under the system temp dir, removed on drop.

11struct Scratch(PathBuf);
12impl Scratch {
13    fn new(tag: &str) -> Self {
14        let p = std::env::temp_dir().join(format!("whiskers-backup-{tag}-{}-{}", std::process::id(), now_ms()));
15        fs::create_dir_all(&p).unwrap();
16        Self(p)
17    }
18    fn join(&self, s: &str) -> PathBuf {
19        self.0.join(s)
20    }
21}
22impl Drop for Scratch {
23    fn drop(&mut self) {
24        let _ = fs::remove_dir_all(&self.0);
25    }
26}

A device's data directory as the engine leaves it: a database with a memory, a chat, a log line and two pictures, the device's id, and files that are not state.

30fn filled(dir: &Path) {
31    use whiskers_core::{ChatState, ChatStore, ChatTurn, Entry, Event, Log, Memory, NewFact, PictureId, Speaker};
32    use whiskers_store::{SqliteChat, SqliteLog, SqliteMemory, SqlitePictures, Store};
33    fs::create_dir_all(dir).unwrap();
34    let store = Store::open(&dir.join(crate::DATABASE)).unwrap();
35    let pictures = SqlitePictures::new(store.clone());
36    pictures.store(&PictureId("0000000000000001-0000.jpg".into()), &[0xff, 0xd8, 1, 2, 3]).unwrap();
37    pictures.store(&PictureId("0000000000000002-0000.png".into()), &vec![7u8; 100_000]).unwrap();
38    SqliteMemory::open(store.clone(), "old-device")
39        .unwrap()
40        .add(NewFact { text: "Biscuit is her bunny".into(), pictures: vec![PictureId("0000000000000001-0000.jpg".into())], ..NewFact::default() }, 5)
41        .unwrap();
42    SqliteChat::new(store.clone())
43        .save(&ChatState { summary: "s".into(), turns: vec![ChatTurn { speaker: Speaker::Child, text: "one".into() }], last_active_ms: 3, version: 2, scrubbed: vec![] })
44        .unwrap();
45    SqliteLog::new(store.clone(), "old-device").append(&Entry::new(1, Event::Greeted { text: "hi".into() })).unwrap();
46    fs::write(dir.join("device.id"), b"old-device").unwrap();
47    fs::create_dir_all(dir.join("picture-cache")).unwrap();
48    fs::write(dir.join("picture-cache/0000000000000001-0000.jpg"), [0xff, 0xd8, 1, 2, 3]).unwrap();
49    fs::write(dir.join("whiskers.db.damaged-1"), b"junk").unwrap();
50}

What a data directory's database holds, to compare two of them without caring how the file is laid out.

53fn content(dir: &Path) -> (Vec<String>, Vec<(String, Vec<u8>)>, usize, String) {
54    use whiskers_core::{ChatStore, Memory};
55    use whiskers_store::{SqliteChat, SqliteLog, SqliteMemory, SqlitePictures, Store};
56    let store = Store::open(&dir.join(crate::DATABASE)).unwrap();
57    let facts: Vec<String> = SqliteMemory::open(store.clone(), "x").unwrap().facts().into_iter().map(|f| f.text).collect();
58    let conn = rusqlite::Connection::open(dir.join(crate::DATABASE)).unwrap();
59    let names: Vec<String> = conn.prepare("SELECT id FROM picture ORDER BY id").unwrap().query_map([], |r| r.get(0)).unwrap().collect::<Result<_, _>>().unwrap();
60    let p = SqlitePictures::new(store.clone());
61    let pictures = names.into_iter().map(|n| (n.clone(), p.read(&whiskers_core::PictureId(n)).unwrap())).collect();
62    let log = SqliteLog::new(store.clone(), "old-device").own_count().unwrap();
63    (facts, pictures, log, SqliteChat::new(store).load().unwrap().summary)
64}

Writes a zip by hand: manifest as the first entry, then entries.

67fn hand_zip(path: &Path, manifest: &str, entries: &[(&str, &[u8])]) {
68    let mut z = ZipWriter::new(File::create(path).unwrap());
69    let o = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
70    z.start_file("manifest.json", o).unwrap();
71    z.write_all(manifest.as_bytes()).unwrap();
72    for (n, b) in entries {
73        z.start_file(*n, o).unwrap();
74        z.write_all(b).unwrap();
75    }
76    z.finish().unwrap();
77}
79fn manifest(format: u32, entries: &[(&str, u64)]) -> String {
80    let list: Vec<String> = entries.iter().map(|(n, s)| format!(r#"{{"name":{n:?},"size":{s}}}"#)).collect();
81    format!(r#"{{"format":{format},"created_at_ms":1,"app_version":"t","entries":[{}]}}"#, list.join(","))
82}
83
84fn untouched(dir: &Path) {
85    assert_eq!(content(dir).0, ["Biscuit is her bunny"]);
86    assert_eq!(fs::read(dir.join("device.id")).unwrap(), b"old-device");
87    assert_eq!(fs::read_dir(dir.parent().unwrap()).unwrap().filter(|e| e.as_ref().unwrap().file_name().to_string_lossy().contains(".restoring-")).count(), 0, "staging removed");
88}
89
90#[test]
91fn a_round_trip_brings_back_the_whole_database_but_not_the_device_id() {
92    let s = Scratch::new("round");
93    let data = s.join("whiskers");
94    filled(&data);
95    let zip = s.join("b.zip");
96    let made = backup_to(&data, &zip, Some("192.0.2.10:47900")).unwrap();
97    assert_eq!((made.files, made.pictures), (1, 2));
98    assert_eq!(made.bytes, fs::metadata(&zip).unwrap().len());
99    assert!(!s.join("b.zip.part").exists() && !s.join("b.zip.db-copy").exists(), "nothing is left beside the backup");
100
101    // A different device: some other data, restored over.
102    let other = s.join("elsewhere/whiskers");
103    fs::create_dir_all(&other).unwrap();
104    fs::write(other.join("whiskers.db"), b"something else").unwrap();
105    fs::write(other.join("device.id"), b"new-device").unwrap();
106    let got = restore_from(&other, &zip).unwrap();
107    assert_eq!((got.files, got.pictures), (1, 2));
108    // The core's own form: the default port is not shown.
109    assert_eq!(got.service_address.as_deref(), Some("192.0.2.10"));
110    assert_eq!(got.created_at_ms, made.created_at_ms);
111
112    assert_eq!(content(&other), content(&data), "every memory, picture, log line and the chat, read back from the restored database");
113    let names: BTreeSet<String> = fs::read_dir(&other).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
114    assert_eq!(names, BTreeSet::from([DB.to_owned()]), "the database alone: no device id, no cache, no leftovers of the other device's data");
115    // Nothing is left beside the data directory.
116    let names: Vec<_> = fs::read_dir(other.parent().unwrap()).unwrap().map(|e| e.unwrap().file_name()).collect();
117    assert_eq!(names.len(), 1);
118}
119
120#[test]
121fn a_backup_taken_while_the_app_has_the_database_open_is_consistent() {
122    let s = Scratch::new("live");
123    let data = s.join("whiskers");
124    filled(&data);
125    // The engine's own handle stays open and keeps writing while the backup is made.
126    let store = whiskers_store::Store::open(&data.join(DB)).unwrap();
127    let mut log = whiskers_store::SqliteLog::new(store.clone(), "old-device");
128    for n in 0..50 {
129        whiskers_core::Log::append(&mut log, &whiskers_core::Entry::new(n, whiskers_core::Event::Greeted { text: "hi".into() })).unwrap();
130    }
131    backup_to(&data, &s.join("b.zip"), None).unwrap();
132    whiskers_core::Log::append(&mut log, &whiskers_core::Entry::new(99, whiskers_core::Event::Greeted { text: "later".into() })).unwrap();
133    let other = s.join("other");
134    restore_from(&other, &s.join("b.zip")).unwrap();
135    assert_eq!(content(&other).2, 51, "the log as it was when the backup began, with no half of a later line");
136}
137
138#[test]
139fn the_zip_starts_with_a_manifest_that_lists_every_entry() {
140    let s = Scratch::new("manifest");
141    filled(&s.join("d"));
142    backup_to(&s.join("d"), &s.join("b.zip"), None).unwrap();
143    let mut z = ZipArchive::new(File::open(s.join("b.zip")).unwrap()).unwrap();
144    assert_eq!(z.by_index(0).unwrap().name(), "manifest.json");
145    let m = read_manifest(&mut z).unwrap();
146    assert_eq!(m.format, FORMAT_VERSION);
147    assert_eq!(m.entries.len(), 1);
148    assert!(m.entries.iter().all(|e| allowed(&e.name)));
149    assert!(m.entries.iter().all(|e| e.name != SERVICE_ADDRESS_ENTRY));
150    assert_eq!(z.len(), 2);
151}
152
153#[test]
154fn restoring_a_backup_without_an_address_offers_none() {
155    let s = Scratch::new("noaddr");
156    filled(&s.join("d"));
157    backup_to(&s.join("d"), &s.join("b.zip"), Some("   ")).unwrap();
158    assert_eq!(restore_from(&s.join("e"), &s.join("b.zip")).unwrap().service_address, None);
159}
160
161#[test]
162fn an_empty_data_directory_is_nothing_to_back_up() {
163    let s = Scratch::new("empty");
164    fs::create_dir_all(s.join("d")).unwrap();
165    fs::write(s.join("d/device.id"), b"x").unwrap();
166    assert_eq!(backup_to(&s.join("d"), &s.join("b.zip"), None), Err(BackupError::NothingToBackUp));
167    assert_eq!(backup_to(&s.join("missing"), &s.join("b.zip"), None), Err(BackupError::NothingToBackUp));
168    assert!(!s.join("b.zip").exists());
169}
170
171#[test]
172fn an_unwritable_destination_is_a_plain_error_and_leaves_nothing() {
173    let s = Scratch::new("nowrite");
174    filled(&s.join("d"));
175    assert_eq!(backup_to(&s.join("d"), &s.join("no-such-dir/b.zip"), None), Err(BackupError::CannotWrite));
176}
177
178#[test]
179fn a_first_restore_with_no_data_yet_works() {
180    let s = Scratch::new("fresh");
181    filled(&s.join("d"));
182    backup_to(&s.join("d"), &s.join("b.zip"), None).unwrap();
183    restore_from(&s.join("new"), &s.join("b.zip")).unwrap();
184    assert!(s.join("new").join(DB).is_file());
185}
186
187#[test]
188fn path_traversal_and_other_unlisted_names_are_refused_and_the_data_is_untouched() {
189    let hostile = [
190        "../whiskers.db",
191        "pictures/../../escape",
192        "/etc/passwd",
193        "pictures\\x.jpg",
194        "C:/x",
195        "pictures/.hidden",
196        "pictures/a/b.jpg",
197        "pictures/0000000000000001-0000.jpg",
198        "device.id",
199        "chat.json",
200        "whiskers.db-wal",
201        "pictures/",
202        "pictures/a..b",
203        "pictures/%2e%2e",
204        "",
205    ];
206    for name in hostile {
207        let s = Scratch::new("hostile");
208        let data = s.join("whiskers");
209        filled(&data);
210        let zip = s.join("h.zip");
211        // Honest manifest that names it, and the entry present.
212        hand_zip(&zip, &manifest(FORMAT_VERSION, &[(name, 1)]), &[(name, b"x")]);
213        assert_eq!(restore_from(&data, &zip), Err(BackupError::Unsafe), "{name:?}");
214        untouched(&data);
215        // A manifest that does not name it, with the entry present.
216        hand_zip(&zip, &manifest(FORMAT_VERSION, &[(DB, 1)]), &[(DB, b"x"), (name, b"x")]);
217        assert!(restore_from(&data, &zip).is_err(), "{name:?}");
218        untouched(&data);
219    }
220}
221
222#[test]
223fn a_link_entry_is_refused() {
224    let s = Scratch::new("link");
225    let data = s.join("whiskers");
226    filled(&data);
227    let zip = s.join("l.zip");
228    let mut z = ZipWriter::new(File::create(&zip).unwrap());
229    let o = SimpleFileOptions::default();
230    z.start_file("manifest.json", o).unwrap();
231    z.write_all(manifest(FORMAT_VERSION, &[(DB, 2)]).as_bytes()).unwrap();
232    z.add_symlink(DB, "/x", o).unwrap();
233    z.finish().unwrap();
234    assert_eq!(restore_from(&data, &zip), Err(BackupError::Unsafe));
235    untouched(&data);
236}
237
238#[test]
239fn a_future_or_unknown_format_is_refused() {
240    let s = Scratch::new("version");
241    let data = s.join("whiskers");
242    filled(&data);
243    let zip = s.join("v.zip");
244    // Format 1 (the JSON files) is older than this build reads, and it has no reader: it says so.
245    for (v, want) in [(FORMAT_VERSION + 1, BackupError::TooNew), (900, BackupError::TooNew), (0, BackupError::NotABackup), (1, BackupError::UnknownVersion)] {
246        hand_zip(&zip, &manifest(v, &[(DB, 1)]), &[(DB, b"x")]);
247        assert_eq!(restore_from(&data, &zip), Err(want), "format {v}");
248        untouched(&data);
249    }
250    // A newer format whose other fields changed shape still reads as "newer", not "damaged".
251    hand_zip(&zip, &format!(r#"{{"format":{},"entries":"different now"}}"#, FORMAT_VERSION + 1), &[]);
252    assert_eq!(restore_from(&data, &zip), Err(BackupError::TooNew));
253}
254
255#[test]
256fn a_file_that_is_not_a_backup_is_refused() {
257    let s = Scratch::new("notzip");
258    let data = s.join("whiskers");
259    filled(&data);
260    fs::write(s.join("t.zip"), b"this is not a zip file at all").unwrap();
261    assert_eq!(restore_from(&data, &s.join("t.zip")), Err(BackupError::NotABackup));
262    assert_eq!(restore_from(&data, &s.join("missing.zip")), Err(BackupError::NotABackup));
263    // A zip without a manifest first.
264    let z = s.join("n.zip");
265    let mut w = ZipWriter::new(File::create(&z).unwrap());
266    w.start_file(DB, SimpleFileOptions::default()).unwrap();
267    w.write_all(b"x").unwrap();
268    w.finish().unwrap();
269    assert_eq!(restore_from(&data, &z), Err(BackupError::NotABackup));
270    // A manifest that is not JSON.
271    hand_zip(&s.join("m.zip"), "not json", &[]);
272    assert_eq!(restore_from(&data, &s.join("m.zip")), Err(BackupError::NotABackup));
273    untouched(&data);
274}
275
276#[test]
277fn a_truncated_or_corrupted_zip_leaves_the_current_data_alone() {
278    let s = Scratch::new("corrupt");
279    let data = s.join("whiskers");
280    filled(&data);
281    let src = s.join("src");
282    filled(&src);
283    backup_to(&src, &s.join("good.zip"), None).unwrap();
284    let bytes = fs::read(s.join("good.zip")).unwrap();
285
286    // Cut short at several places: the central directory is at the end, so all of these fail to open.
287    for cut in [10, bytes.len() / 3, bytes.len() / 2, bytes.len() - 5] {
288        fs::write(s.join("cut.zip"), &bytes[..cut]).unwrap();
289        assert_eq!(restore_from(&data, &s.join("cut.zip")), Err(BackupError::NotABackup), "cut at {cut}");
290        untouched(&data);
291    }
292    // A flipped byte inside the database: the entry's checksum fails mid-unpack, after the file was started.
293    let mut flipped = bytes.clone();
294    let at = flipped.windows(4).position(|w| w == [7, 7, 7, 7]).unwrap_or(flipped.len() / 2);
295    let at = if at + 1 >= flipped.len() { flipped.len() / 2 } else { at + 1 };
296    flipped[at] ^= 0xff;
297    fs::write(s.join("flip.zip"), &flipped).unwrap();
298    assert!(restore_from(&data, &s.join("flip.zip")).is_err());
299    untouched(&data);
300}
301
302#[test]
303fn an_entry_whose_size_differs_from_the_manifest_is_refused() {
304    let s = Scratch::new("size");
305    let data = s.join("whiskers");
306    filled(&data);
307    let z = s.join("s.zip");
308    hand_zip(&z, &manifest(FORMAT_VERSION, &[(DB, 2)]), &[(DB, b"much longer than two")]);
309    assert_eq!(restore_from(&data, &z), Err(BackupError::Mismatch));
310    untouched(&data);
311}
312
313#[test]
314fn missing_extra_and_duplicate_entries_are_refused() {
315    let s = Scratch::new("set");
316    let data = s.join("whiskers");
317    filled(&data);
318    let z = s.join("m.zip");
319    // Listed but absent.
320    hand_zip(&z, &manifest(FORMAT_VERSION, &[(DB, 1), (SERVICE_ADDRESS_ENTRY, 1)]), &[(DB, b"x")]);
321    assert_eq!(restore_from(&data, &z), Err(BackupError::Mismatch));
322    // Present but not listed.
323    hand_zip(&z, &manifest(FORMAT_VERSION, &[(DB, 1)]), &[(DB, b"x"), (SERVICE_ADDRESS_ENTRY, b"x")]);
324    assert_eq!(restore_from(&data, &z), Err(BackupError::Mismatch));
325    // Listed twice (the zip format itself cannot be made to hold one name twice).
326    hand_zip(&z, &manifest(FORMAT_VERSION, &[(DB, 1), (DB, 1)]), &[(DB, b"x")]);
327    assert_eq!(restore_from(&data, &z), Err(BackupError::Mismatch));
328    untouched(&data);
329}
330
331#[test]
332fn a_manifest_claiming_an_absurd_size_is_refused_before_anything_is_written() {
333    let s = Scratch::new("big");
334    let data = s.join("whiskers");
335    filled(&data);
336    let z = s.join("b.zip");
337    hand_zip(&z, &manifest(FORMAT_VERSION, &[(DB, u64::MAX)]), &[(DB, b"x")]);
338    assert_eq!(restore_from(&data, &z), Err(BackupError::TooLarge));
339    untouched(&data);
340}

Fails the n-th rename, and counts them.

343struct FailNth(std::cell::Cell<u32>, u32);
344impl Rename for FailNth {
345    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
346        self.0.set(self.0.get() + 1);
347        if self.0.get() == self.1 {
348            return Err(io::Error::other("injected"));
349        }
350        fs::rename(from, to)
351    }
352}
354#[test]
355fn a_failure_while_swapping_in_puts_the_old_data_back() {
356    for failing in [1, 2] {
357        let s = Scratch::new("swap");
358        let data = s.join("whiskers");
359        filled(&data);
360        let src = s.join("src");
361        filled(&src);
362        backup_to(&src, &s.join("b.zip"), None).unwrap();
363
364        let ops = FailNth(Default::default(), failing);
365        assert_eq!(restore_with(&data, &s.join("b.zip"), &ops), Err(BackupError::CannotInstall), "rename {failing} failing");
366        untouched(&data);
367        // The old data is where it was, whole, and nothing is left beside it.
368        assert!(data.join(DB).is_file() && data.join("device.id").is_file());
369        let beside: Vec<_> = fs::read_dir(&s.0).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
370        assert!(beside.iter().all(|n| !n.contains("replaced") && !n.contains("restoring")), "{beside:?}");
371    }
372}
373
374#[test]
375fn errors_are_plain_sentences_that_name_no_file() {
376    for e in [
377        BackupError::NothingToBackUp,
378        BackupError::CannotRead,
379        BackupError::CannotWrite,
380        BackupError::NotABackup,
381        BackupError::TooNew,
382        BackupError::UnknownVersion,
383        BackupError::Unsafe,
384        BackupError::Mismatch,
385        BackupError::TooLarge,
386        BackupError::CannotInstall,
387    ] {
388        let t = e.to_string();
389        assert!(t.ends_with('.') && t.len() > 20, "{t}");
390        for bad in ["json", "zip", "Exception", "io::", "os error", "/"] {
391            assert!(!t.contains(bad), "{t} mentions {bad}");
392        }
393    }
394}
395
396#[test]
397fn an_address_the_core_does_not_accept_is_not_offered() {
398    let s = Scratch::new("badaddr");
399    let z = s.join("a.zip");
400    filled(&s.join("src"));
401    let real = {
402        backup_to(&s.join("src"), &s.join("real.zip"), None).unwrap();
403        let mut a = ZipArchive::new(File::open(s.join("real.zip")).unwrap()).unwrap();
404        let mut bytes = Vec::new();
405        a.by_name(DB).unwrap().read_to_end(&mut bytes).unwrap();
406        bytes
407    };
408    hand_zip(&z, &manifest(FORMAT_VERSION, &[(DB, real.len() as u64), (SERVICE_ADDRESS_ENTRY, 3)]), &[(DB, &real), (SERVICE_ADDRESS_ENTRY, b"a b")]);
409    assert_eq!(restore_from(&s.join("d"), &z).unwrap().service_address, None);
410}
411
412#[test]
413fn a_file_that_is_not_a_database_is_refused_and_one_from_a_newer_whiskers_says_so() {
414    let s = Scratch::new("notdb");
415    let data = s.join("whiskers");
416    filled(&data);
417    let z = s.join("n.zip");
418    hand_zip(&z, &manifest(FORMAT_VERSION, &[(DB, 40)]), &[(DB, &[b'x'; 40])]);
419    assert_eq!(restore_from(&data, &z), Err(BackupError::NotABackup), "a zip that holds no database");
420    untouched(&data);
421    // A real database that a newer build wrote.
422    let src = s.join("src");
423    filled(&src);
424    let conn = rusqlite::Connection::open(src.join(DB)).unwrap();
425    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); PRAGMA user_version = 999").unwrap();
426    drop(conn);
427    let bytes = fs::read(src.join(DB)).unwrap();
428    hand_zip(&z, &manifest(FORMAT_VERSION, &[(DB, bytes.len() as u64)]), &[(DB, &bytes)]);
429    assert_eq!(restore_from(&data, &z), Err(BackupError::TooNew));
430    untouched(&data);
431}