dpm-store 0.1.0

SQLite persistence and append-only operation log for DPM
Documentation
use super::*;

fn tamper(path: &Path, sql: &str) {
    Connection::open(path)
        .expect("raw connection")
        .execute_batch(sql)
        .expect("tamper");
}

fn backup_of(dir: &Path, name: &str, operations: u64) -> PathBuf {
    let backup = dir.join(name);
    store_with_history(&dir.join(format!("live-{name}")), operations)
        .backup_blocking(&backup)
        .expect("backup");
    backup
}

fn listing(dir: &Path) -> Vec<String> {
    let mut names: Vec<String> = std::fs::read_dir(dir)
        .expect("listing")
        .map(|entry| {
            entry
                .expect("entry")
                .file_name()
                .to_string_lossy()
                .into_owned()
        })
        .collect();
    names.sort();
    names
}

#[test]
fn planted_triggers_views_and_indexes_are_refused_before_any_write() {
    let dir = tempfile::tempdir().expect("directory");
    for (name, ddl) in [
        (
            "trigger.sqlite",
            "CREATE TRIGGER wipe AFTER INSERT ON operations BEGIN \
             DELETE FROM operations WHERE sequence < NEW.sequence; END",
        ),
        (
            "view.sqlite",
            "CREATE VIEW recent AS SELECT * FROM operations",
        ),
        (
            "index.sqlite",
            "CREATE INDEX by_base ON operations(base_revision)",
        ),
    ] {
        let backup = backup_of(dir.path(), name, 2);
        tamper(&backup, ddl);
        let error = verify_store_blocking(&backup).expect_err("planted schema object");
        assert!(error.is_corruption(), "{name}: {error}");
        let before = std::fs::read(&backup).expect("bytes");
        assert!(
            SqliteStore::open_existing_blocking(&backup).is_err(),
            "{name}"
        );
        assert!(SqliteStore::open_blocking(&backup).is_err(), "{name}");
        assert_eq!(std::fs::read(&backup).expect("bytes"), before, "{name}");
        let target = dir.path().join(format!("restored-{name}"));
        assert!(restore_store_blocking(&backup, &target).is_err(), "{name}");
        assert!(!target.exists());
    }
}

#[test]
fn damaged_json_is_corruption_naming_the_file_and_record() {
    let dir = tempfile::tempdir().expect("directory");
    let command = backup_of(dir.path(), "command.sqlite", 3);
    tamper(
        &command,
        "UPDATE operations SET command_json = '{' WHERE sequence = 2",
    );
    let error = verify_store_blocking(&command).expect_err("damaged command");
    assert!(error.is_corruption(), "{error}");
    let message = error.to_string();
    assert!(
        message.contains("command.sqlite") && message.contains("sequence 2"),
        "{message}"
    );
    let snapshot = backup_of(dir.path(), "snapshot.sqlite", 1);
    tamper(
        &snapshot,
        "UPDATE plan_state SET snapshot_json = 'not json'",
    );
    for error in [
        verify_store_blocking(&snapshot).expect_err("damaged snapshot"),
        SqliteStore::open_existing_blocking(&snapshot)
            .err()
            .expect("refused before read-write open"),
    ] {
        assert!(error.is_corruption(), "{error}");
        let message = error.to_string();
        assert!(
            message.contains("snapshot.sqlite") && message.contains("snapshot snapshot_json"),
            "{message}"
        );
    }
}

#[test]
fn losing_the_start_of_history_fails_verification() {
    let dir = tempfile::tempdir().expect("directory");
    let backup = backup_of(dir.path(), "head.sqlite", 3);
    tamper(&backup, "DELETE FROM operations WHERE sequence = 1");
    let error = verify_store_blocking(&backup).expect_err("missing first operation");
    assert!(error.is_corruption(), "{error}");
    let emptied = backup_of(dir.path(), "emptied.sqlite", 2);
    tamper(&emptied, "DELETE FROM operations");
    let error = verify_store_blocking(&emptied).expect_err("missing every operation");
    assert!(error.is_corruption(), "{error}");
}

#[test]
fn side_file_names_are_refused_as_backup_and_restore_targets() {
    let dir = tempfile::tempdir().expect("directory");
    let store = store_with_history(&dir.path().join("live.sqlite"), 1);
    let backup = dir.path().join("backup.sqlite");
    store.backup_blocking(&backup).expect("backup");
    for name in ["live.sqlite-journal", "x.sqlite-WAL", "y-shm"] {
        let target = dir.path().join(name);
        assert!(store.backup_blocking(&target).is_err(), "backup to {name}");
        assert!(
            restore_store_blocking(&backup, &target).is_err(),
            "restore to {name}"
        );
        assert!(!target.exists(), "{name}");
    }
}

#[test]
fn a_refused_initialization_leaves_an_existing_store_byte_for_byte_unchanged() {
    let dir = tempfile::tempdir().expect("directory");
    let live = dir.path().join("live.sqlite");
    drop(store_with_history(&live, 2));
    let retired = dir.path().join("baseline.sqlite");
    std::fs::copy(backup_of(dir.path(), "source.sqlite", 2), &retired).expect("copy");
    tamper(&retired, "PRAGMA user_version = 2");
    for path in [&live, &retired] {
        let before = std::fs::read(path).expect("bytes");
        let refused = SqliteStore::open_blocking(path)
            .and_then(|mut store| store.initialize_blocking(&fixture()));
        assert!(
            matches!(
                refused,
                Err(StoreError::AlreadyInitialized(_) | StoreError::RetiredSchemaVersion { .. })
            ),
            "{path:?}: {refused:?}"
        );
        assert!(std::fs::read(path).expect("bytes") == before, "{path:?}");
    }
    assert_eq!(
        listing(dir.path())
            .into_iter()
            .filter(|name| name.starts_with("live") || name.starts_with("baseline"))
            .collect::<Vec<_>>(),
        ["baseline.sqlite", "live-source.sqlite", "live.sqlite"]
    );
}

#[cfg(unix)]
#[test]
fn verification_creates_no_files_and_works_in_a_read_only_directory() {
    use std::os::unix::fs::PermissionsExt;
    let dir = tempfile::tempdir().expect("directory");
    let sealed = dir.path().join("sealed");
    std::fs::create_dir(&sealed).expect("directory");
    let live = sealed.join("state.sqlite");
    let archive = sealed.join("backup.sqlite");
    let store = store_with_history(&live, 2);
    store.backup_blocking(&archive).expect("backup");
    drop(store);
    assert_eq!(journal_mode(&live), [[Value::Text("wal".into())]]);
    assert_eq!(listing(&sealed), ["backup.sqlite", "state.sqlite"]);
    let restored = dir.path().join("restored.sqlite");
    restore_store_blocking(&archive, &restored).expect("restore");
    assert_eq!(
        listing(&sealed),
        ["backup.sqlite", "state.sqlite"],
        "restore source"
    );
    assert_eq!(
        listing(dir.path()),
        ["restored.sqlite", "sealed"],
        "no stray side files next to the restored store"
    );
    std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o555)).expect("seal");
    let result = verify_store_blocking(&live);
    let after = listing(&sealed);
    std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).expect("unseal");
    assert_eq!(
        result
            .expect("verify in read-only directory")
            .operation_count,
        2
    );
    assert_eq!(after, ["backup.sqlite", "state.sqlite"]);
}

#[test]
fn reports_name_canonical_absolute_paths() {
    let dir = tempfile::tempdir().expect("directory");
    std::fs::create_dir(dir.path().join("nested")).expect("directory");
    let store = store_with_history(&dir.path().join("live.sqlite"), 1);
    let indirect = dir.path().join("nested/../backup.sqlite");
    let canonical = |path: &Path| std::fs::canonicalize(path).expect("canonical");
    let report = store.backup_blocking(&indirect).expect("backup");
    assert_eq!(report.path, canonical(&indirect));
    let restored = dir.path().join("nested/./restored.sqlite");
    let report = restore_store_blocking(&indirect, &restored).expect("restore");
    assert_eq!(report.path, canonical(&restored));
    let report = verify_store_blocking(&restored).expect("verify");
    assert_eq!(report.path, canonical(&restored));
}

#[test]
fn a_trigger_planted_after_open_is_refused_inside_the_write_transaction() {
    let dir = tempfile::tempdir().expect("directory");
    let live = dir.path().join("live.sqlite");
    let mut store = store_with_history(&live, 2);
    let mut plan = store.load_blocking().expect("load").expect("plan");
    tamper(
        &live,
        "CREATE TRIGGER wipe AFTER INSERT ON operations BEGIN \
         DELETE FROM operations WHERE sequence < NEW.sequence; END",
    );
    let command = next_command(&plan, 2);
    let operation = apply_command(
        &mut plan,
        ActorId::agent("owner"),
        command,
        Utc::now(),
        dpm_model::OperationId::new(),
    )
    .expect("command");
    let refused = store
        .persist_blocking(&plan, &operation, None)
        .expect_err("refused write");
    assert!(
        matches!(refused, StoreError::UnrecognizedSchema { .. }),
        "{refused}"
    );
    assert_eq!(
        rows(&live, "SELECT COUNT(*) FROM operations"),
        [[Value::Integer(2)]]
    );
}