use std::fs;
use std::io::Write;
use std::path::Path;
use hayson_engine::database::{
pack_database, read_pack_entry, unpack_database, Database, PackLimits, PackOptions, UnpackOptions, BACKUP_MANIFEST,
};
use serde_json::json;
use tempfile::TempDir;
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipWriter};
fn seeded(root: &Path) {
let mut database = Database::open(root).unwrap();
database
.create_table(
"docs",
json!({
"schemaVersion": 1,
"table": "docs",
"primaryKey": "id",
"fields": { "id": { "type": "string" }, "cover": { "type": "image" } },
}),
)
.unwrap();
database
.table_mut("docs")
.unwrap()
.put(
&json!({ "id": "d1", "cover": { "content": "Ynl0ZXM=", "virtualPath": "/a.png" } }),
false,
)
.unwrap();
database.close().unwrap();
}
fn count(root: &Path) -> usize {
let database = Database::open(root).unwrap();
let keys = database.table("docs").unwrap().keys().count();
database.close().unwrap();
keys
}
fn archive_with(path: &Path, entries: &[(&str, &[u8], CompressionMethod)]) {
let mut writer = ZipWriter::new(fs::File::create(path).unwrap());
for (name, bytes, method) in entries {
writer
.start_file(*name, SimpleFileOptions::default().compression_method(*method))
.unwrap();
writer.write_all(bytes).unwrap();
}
writer.finish().unwrap();
}
fn mark_second_entry_deflated(path: &Path) {
let mut bytes = fs::read(path).unwrap();
let nth = |bytes: &[u8], signature: &[u8; 4], n: usize| {
(0..bytes.len() - 4)
.filter(|i| &bytes[*i..*i + 4] == signature)
.nth(n)
.unwrap()
};
let local = nth(&bytes, b"PK\x03\x04", 1);
bytes[local + 8] = 8;
let central = nth(&bytes, b"PK\x01\x02", 1);
bytes[central + 10] = 8;
fs::write(path, bytes).unwrap();
}
fn manifest_for(files: &[(&str, u64)]) -> Vec<u8> {
let listed: Vec<_> = files.iter().map(|(p, b)| json!({ "path": p, "bytes": b })).collect();
serde_json::to_vec(&json!({ "format": 1, "metadataFile": "hayson.json", "files": listed })).unwrap()
}
#[test]
fn a_pack_unpacks_into_an_empty_directory_with_data_images_and_the_callers_entries() {
let source = TempDir::new().unwrap();
seeded(source.path());
let holder = TempDir::new().unwrap();
let pack = holder.path().join("project.gly");
let options = PackOptions {
extra: vec![("gly.json".to_string(), b"{\"v\":1}".to_vec())],
..PackOptions::default()
};
let made = pack_database(source.path(), &pack, &options).unwrap();
assert_eq!(made.files, 3);
assert!(!holder.path().join("project.gly.partial").exists());
assert_eq!(
read_pack_entry(&pack, "gly.json", &PackLimits::default()).unwrap(),
Some(b"{\"v\":1}".to_vec())
);
assert_eq!(read_pack_entry(&pack, "nope", &PackLimits::default()).unwrap(), None);
assert!(read_pack_entry(&pack, BACKUP_MANIFEST, &PackLimits::default())
.unwrap()
.is_some());
let target = TempDir::new().unwrap();
let restored = unpack_database(&pack, target.path(), &UnpackOptions::default()).unwrap();
assert_eq!(restored.files, 3);
assert_eq!(count(target.path()), 1);
assert!(
!target.path().join("gly.json").exists(),
"extras are not database files"
);
let database = Database::open(target.path()).unwrap();
let docs = database.table("docs").unwrap();
let image = &docs.list_images().unwrap()[0];
assert_eq!(docs.read_image(&image.physical_path).unwrap(), Some(b"bytes".to_vec()));
database.close().unwrap();
}
#[test]
fn packing_refuses_an_existing_destination_and_a_clashing_extra() {
let source = TempDir::new().unwrap();
seeded(source.path());
let holder = TempDir::new().unwrap();
let pack = holder.path().join("p.gly");
fs::write(&pack, b"x").unwrap();
assert!(pack_database(source.path(), &pack, &PackOptions::default()).is_err());
let fresh = holder.path().join("q.gly");
for name in [BACKUP_MANIFEST, "hayson.json", "../x", "con"] {
let options = PackOptions {
extra: vec![(name.to_string(), b"x".to_vec())],
..PackOptions::default()
};
assert!(pack_database(source.path(), &fresh, &options).is_err(), "{name}");
assert!(!fresh.exists());
}
}
#[test]
fn unpacking_over_a_database_replaces_it_and_a_bad_pack_changes_nothing() {
let root = TempDir::new().unwrap();
seeded(root.path());
let holder = TempDir::new().unwrap();
let pack = holder.path().join("p.gly");
pack_database(root.path(), &pack, &PackOptions::default()).unwrap();
let mut database = Database::open(root.path()).unwrap();
database
.table_mut("docs")
.unwrap()
.put(&json!({ "id": "d2" }), false)
.unwrap();
database.close().unwrap();
assert_eq!(count(root.path()), 2);
let broken = holder.path().join("broken.gly");
archive_with(
&broken,
&[
(
BACKUP_MANIFEST,
&manifest_for(&[("hayson.json", 2), ("docs.jsonl", 8)]),
CompressionMethod::Stored,
),
("hayson.json", b"{}", CompressionMethod::Stored),
("docs.jsonl", b"{broken\n", CompressionMethod::Stored),
],
);
assert!(unpack_database(&broken, root.path(), &UnpackOptions::default()).is_err());
assert_eq!(count(root.path()), 2, "a refused pack leaves the database as it was");
assert!(!root.path().join(".restore-new").exists());
unpack_database(&pack, root.path(), &UnpackOptions::default()).unwrap();
assert_eq!(count(root.path()), 1);
}
#[test]
fn hostile_archives_are_refused_before_anything_is_written() {
let holder = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let ok_manifest = manifest_for(&[("hayson.json", 2)]);
let stored = CompressionMethod::Stored;
let cases: Vec<(&str, Vec<(&str, &[u8], CompressionMethod)>)> = vec![
(
"traversal",
vec![
(BACKUP_MANIFEST, &ok_manifest, stored),
("../hayson.json", b"{}", stored),
],
),
(
"absolute",
vec![(BACKUP_MANIFEST, &ok_manifest, stored), ("/hayson.json", b"{}", stored)],
),
(
"backslash",
vec![
(BACKUP_MANIFEST, &ok_manifest, stored),
("a\\hayson.json", b"{}", stored),
],
),
(
"control",
vec![(BACKUP_MANIFEST, &ok_manifest, stored), ("a\u{1}b", b"{}", stored)],
),
(
"device",
vec![(BACKUP_MANIFEST, &ok_manifest, stored), ("aux.json", b"{}", stored)],
),
(
"duplicate-ignoring-case",
vec![
(BACKUP_MANIFEST, &ok_manifest, stored),
("hayson.json", b"{}", stored),
("HAYSON.json", b"{}", stored),
],
),
(
"compressed",
vec![
(BACKUP_MANIFEST, &ok_manifest, stored),
("hayson.json", b"{}{}{}{}{}{}{}{}", stored),
],
),
("no-manifest", vec![("hayson.json", b"{}", stored)]),
(
"size-mismatch",
vec![(BACKUP_MANIFEST, &ok_manifest, stored), ("hayson.json", b"{} ", stored)],
),
("listed-but-absent", vec![(BACKUP_MANIFEST, &ok_manifest, stored)]),
];
for (label, entries) in cases {
let path = holder.path().join(format!("{label}.gly"));
archive_with(&path, &entries);
if label == "compressed" {
mark_second_entry_deflated(&path);
}
let outcome = unpack_database(&path, target.path(), &UnpackOptions::default());
assert!(outcome.is_err(), "{label} should be refused");
assert_eq!(
fs::read_dir(target.path()).unwrap().count(),
0,
"{label} wrote something"
);
}
assert!(!target.path().parent().unwrap().join("hayson.json").exists());
}
#[test]
fn symlink_entries_and_limits_are_refused() {
let holder = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let ok_manifest = manifest_for(&[("hayson.json", 2)]);
let link = holder.path().join("link.gly");
let mut writer = ZipWriter::new(fs::File::create(&link).unwrap());
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
writer.start_file(BACKUP_MANIFEST, options).unwrap();
writer.write_all(&ok_manifest).unwrap();
writer.add_symlink("hayson.json", "/etc/passwd", options).unwrap();
writer.finish().unwrap();
assert!(unpack_database(&link, target.path(), &UnpackOptions::default()).is_err());
let plain = holder.path().join("plain.gly");
archive_with(
&plain,
&[
(BACKUP_MANIFEST, &ok_manifest, CompressionMethod::Stored),
("hayson.json", b"{}", CompressionMethod::Stored),
],
);
for limits in [
PackLimits {
max_entries: 1,
..PackLimits::default()
},
PackLimits {
max_entry_bytes: 1,
..PackLimits::default()
},
PackLimits {
max_total_bytes: 1,
..PackLimits::default()
},
PackLimits {
max_archive_bytes: 10,
..PackLimits::default()
},
] {
let outcome = unpack_database(
&plain,
target.path(),
&UnpackOptions {
limits,
..UnpackOptions::default()
},
);
assert!(outcome.is_err());
}
assert!(unpack_database(
&holder.path().join("missing.gly"),
target.path(),
&UnpackOptions::default()
)
.is_err());
fs::write(holder.path().join("text.gly"), b"not a zip").unwrap();
assert!(unpack_database(
&holder.path().join("text.gly"),
target.path(),
&UnpackOptions::default()
)
.is_err());
}
#[test]
fn packing_enforces_the_limits_so_a_pack_is_always_readable() {
let source = TempDir::new().unwrap();
seeded(source.path());
let holder = TempDir::new().unwrap();
let pack = holder.path().join("p.gly");
let options = PackOptions {
limits: PackLimits {
max_entry_bytes: 4,
..PackLimits::default()
},
..PackOptions::default()
};
assert!(pack_database(source.path(), &pack, &options).is_err());
assert!(!pack.exists());
assert!(!holder.path().join("p.gly.partial").exists());
}