hayson-engine 0.27.0

Storage engine for hayson document databases — the embeddable core behind hayson-server, hayson-cli and hayson-browser
Documentation
//! The image blob API from outside the crate: read, list, usage, delete, and the refusal to let
//! different bytes take over a content-hash file name.

use std::fs;

use hayson_engine::database::Database;
use serde_json::json;
use tempfile::TempDir;

const BYTES: &str = "Ynl0ZXM="; // "bytes"
const OTHER: &str = "b3RoZXI="; // "other"

fn docs() -> (TempDir, Database) {
    let dir = TempDir::new().unwrap();
    let mut database = Database::open(dir.path()).unwrap();
    database
        .create_table(
            "docs",
            json!({
                "schemaVersion": 1,
                "table": "docs",
                "primaryKey": "id",
                "fields": { "id": { "type": "string" }, "cover": { "type": "image" } },
            }),
        )
        .unwrap();
    (dir, database)
}

fn put(database: &mut Database, id: &str, content: &str, path: &str) -> Result<(), String> {
    database
        .table_mut("docs")
        .unwrap()
        .put(
            &json!({ "id": id, "cover": { "content": content, "virtualPath": path } }),
            false,
        )
        .map(|_| ())
        .map_err(|error| error.to_string())
}

#[test]
fn reads_back_the_bytes_and_refuses_a_path_outside_the_image_directory() {
    let (_dir, mut database) = docs();
    put(&mut database, "d1", BYTES, "/a.png").unwrap();
    let table = database.table("docs").unwrap();
    let name = table.list_images().unwrap()[0].physical_path.clone();

    assert_eq!(table.read_image(&name).unwrap(), Some(b"bytes".to_vec()));
    assert_eq!(table.read_image("0123456789abcdef.bin").unwrap(), None);
    assert!(table.read_image("../hayson.json").is_err());
    assert!(table.read_image("/etc/passwd").is_err());
}

#[test]
fn lists_who_references_an_image_and_counts_orphans() {
    let (_dir, mut database) = docs();
    put(&mut database, "d1", BYTES, "/a.png").unwrap();
    put(&mut database, "d2", BYTES, "/b.png").unwrap();
    put(&mut database, "d3", OTHER, "/c.png").unwrap();
    database.table_mut("docs").unwrap().delete("d3", false).unwrap();

    let table = database.table("docs").unwrap();
    let images = table.list_images().unwrap();
    assert_eq!(images.len(), 2);
    let shared = images.iter().find(|image| image.referenced_by.len() == 2).unwrap();
    assert_eq!(shared.bytes, 5);
    let orphan = images.iter().find(|image| image.referenced_by.is_empty()).unwrap();

    let usage = table.image_usage().unwrap();
    assert_eq!((usage.count, usage.bytes), (2, 10));
    assert_eq!((usage.orphan_count, usage.orphan_bytes), (1, orphan.bytes));
}

#[test]
fn deletes_an_orphan_but_not_a_referenced_image() {
    let (_dir, mut database) = docs();
    put(&mut database, "d1", BYTES, "/a.png").unwrap();
    put(&mut database, "d2", OTHER, "/b.png").unwrap();
    database.table_mut("docs").unwrap().delete("d2", false).unwrap();

    let table = database.table("docs").unwrap();
    let images = table.list_images().unwrap();
    let referenced = images.iter().find(|i| !i.referenced_by.is_empty()).unwrap();
    let orphan = images.iter().find(|i| i.referenced_by.is_empty()).unwrap();

    assert!(table.delete_image(&referenced.physical_path).is_err());
    assert!(table.delete_image(&orphan.physical_path).unwrap());
    assert!(!table.delete_image(&orphan.physical_path).unwrap());
    assert_eq!(table.list_images().unwrap().len(), 1);
}

#[test]
fn a_missing_file_is_listed_for_its_referencing_record() {
    let (_dir, mut database) = docs();
    put(&mut database, "d1", BYTES, "/a.png").unwrap();
    let table = database.table("docs").unwrap();
    let name = table.list_images().unwrap()[0].physical_path.clone();
    fs::remove_file(table.image_dir().join(&name)).unwrap();

    let images = table.list_images().unwrap();
    assert_eq!(images.len(), 1);
    assert!(images[0].is_missing());
    assert_eq!(table.image_usage().unwrap().count, 0);
}

#[test]
fn different_bytes_cannot_take_over_an_existing_content_hash_file() {
    let (_dir, mut database) = docs();
    put(&mut database, "d1", BYTES, "/a.png").unwrap();
    let table = database.table("docs").unwrap();
    let name = table.list_images().unwrap()[0].physical_path.clone();
    // Simulates a colliding image: the file under this hash holds other bytes than the write.
    fs::write(table.image_dir().join(&name), b"tampered").unwrap();

    let refused = put(&mut database, "d2", BYTES, "/b.png").unwrap_err();
    assert!(refused.contains("differ"), "{refused}");
    let table = database.table("docs").unwrap();
    assert!(!table.has("d2"));
    assert_eq!(table.read_image(&name).unwrap(), Some(b"tampered".to_vec()));
}

#[test]
fn an_image_over_the_opened_limit_is_refused_and_one_at_the_limit_is_kept() {
    let dir = TempDir::new().unwrap();
    {
        let mut database = Database::open(dir.path()).unwrap();
        database
            .create_table(
                "docs",
                json!({
                    "schemaVersion": 1,
                    "table": "docs",
                    "primaryKey": "id",
                    "fields": { "id": { "type": "string" }, "cover": { "type": "image" } },
                }),
            )
            .unwrap();
        database.close().unwrap();
    }
    let mut database = Database::open_with(
        dir.path(),
        hayson_engine::database::OpenOptions {
            max_image_bytes: Some(5),
            ..Default::default()
        },
    )
    .unwrap();

    put(&mut database, "d1", BYTES, "/a.png").unwrap(); // exactly 5 bytes
    let refused = put(&mut database, "d2", "Ynl0ZXMh", "/b.png").unwrap_err(); // 6 bytes
    assert!(refused.contains("6 bytes, over the 5 byte limit"), "{refused}");
    assert!(!database.table("docs").unwrap().has("d2"));
    assert_eq!(database.table("docs").unwrap().list_images().unwrap().len(), 1);
}