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.
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}