hayson-engine 0.22.0

Storage engine for hayson document databases — the embeddable core behind hayson-server, hayson-cli and hayson-browser
Documentation
//! Packing a database into one zip archive and unpacking it again, from outside the crate.

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();
}

/// Declares the second entry of a stored archive as deflated (method 8) in both its headers; the
/// crate under test is built without a compressor, so this is the only way to make such an archive.
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());
}