obj-db 1.1.2

Embedded document database. Stable file format, full ACID, single-file portability.
Documentation
//! Phase 4 (issue #9): contract test for the no-encryption-feature
//! refusal path.
//!
//! Constructs a synthetic 4 KiB page-0 header with
//! `format_minor = 2` and `feature_flags = 0x0000_0002` (the
//! encryption bit), writes it as a fresh `.obj` file, and asserts
//! that a `Db::open_with` against a default-features build returns
//! `Error::FormatFeatureUnsupported { feature: "encryption" }`
//! BEFORE any page-level operation. This is the user-facing
//! contract for "this build can't open encrypted files".
//!
//! The test deliberately does NOT carry the `#![cfg(feature =
//! "encryption")]` gate — it MUST compile and pass under the
//! default-features build so a regression in the refusal path
//! surfaces in baseline CI.

use std::fs::File;
use std::io::Write;
use tempfile::TempDir;

use obj::Db;
#[cfg(not(feature = "encryption"))]
use obj::Error;

const PAGE_SIZE: usize = 4096;
const HEADER_CRC_OFFSET: usize = PAGE_SIZE - 4;

/// Synthesise a minimum-viable page-0 header that opens to an
/// encryption-capable file: magic `OBJF`, `format_major = 0`,
/// `format_minor = 2`, `page_size = 4096`,
/// `feature_flags = 0x02` (encryption bit), `page_count = 1`,
/// a non-zero `kdf_salt` at offset 72..104, everything else zero.
/// The header CRC is recomputed last so `decode_header` accepts it;
/// the open path then trips the feature-refusal check.
fn synth_encryption_capable_header() -> [u8; PAGE_SIZE] {
    let mut buf = [0u8; PAGE_SIZE];
    // magic OBJF
    buf[0..4].copy_from_slice(b"OBJF");
    // format_major = 0
    buf[4..6].copy_from_slice(&0u16.to_le_bytes());
    // format_minor = 2
    buf[6..8].copy_from_slice(&2u16.to_le_bytes());
    // page_size = 4096
    buf[8..10].copy_from_slice(
        &u16::try_from(PAGE_SIZE)
            .expect("PAGE_SIZE fits in u16")
            .to_le_bytes(),
    );
    // feature_flags = 0x02 (encryption bit 1) at offset 10..14
    buf[10..14].copy_from_slice(&0b10u32.to_le_bytes());
    // bytes 14..16 reserved zero
    // page_count = 1 at offset 16..24
    buf[16..24].copy_from_slice(&1u64.to_le_bytes());
    // root_catalog, freelist_head, wal_salt, file_uuid all zero
    // (covered by the zero-init above).
    // kdf_salt at offset 72..104 — synthesise some non-zero bytes
    // so the header looks like a real encrypted file even though
    // the build can't actually use it.
    for (i, b) in buf[72..104].iter_mut().enumerate() {
        *b = u8::try_from(i & 0xFF).expect("masked");
    }

    // CRC32C of bytes [0..HEADER_CRC_OFFSET] at the last 4 bytes.
    let crc = obj_core::pager::checksum::crc32c(&buf[..HEADER_CRC_OFFSET]);
    buf[HEADER_CRC_OFFSET..HEADER_CRC_OFFSET + 4].copy_from_slice(&crc.to_le_bytes());
    buf
}

#[cfg(not(feature = "encryption"))]
#[test]
fn open_refuses_format_minor_two_without_encryption_feature() {
    let dir = TempDir::new().expect("tmp");
    let path = dir.path().join("encryption_capable.obj");

    // Write the synthetic page-0 to disk.
    let header = synth_encryption_capable_header();
    {
        let mut f = File::create(&path).expect("create");
        f.write_all(&header).expect("write header");
        f.sync_all().expect("sync");
    }

    // Default-features build: must refuse with the feature-gate error.
    let err = Db::open(&path).expect_err("default build must refuse");

    // The refusal MUST be `FormatFeatureUnsupported { feature:
    // "encryption" }` specifically — any other error means we
    // either reached a code path the open-time guard was supposed
    // to short-circuit, or the wrong gate fired.
    match err {
        Error::FormatFeatureUnsupported { feature } => {
            assert_eq!(feature, "encryption");
        }
        other => panic!(
            "expected Error::FormatFeatureUnsupported {{ feature: \"encryption\" }}; \
             got {other:?}",
        ),
    }
}

/// Compile-time test: with the `encryption` feature on, the
/// `Error::FormatFeatureUnsupported` variant still exists. The
/// behavioral side of the refusal contract has nothing to test
/// in this build configuration (encrypted files open
/// successfully when the right key is supplied), but compiling
/// this no-op against the variant catches accidental removal
/// under `--all-features`.
#[cfg(feature = "encryption")]
#[test]
fn format_feature_unsupported_variant_exists_under_encryption() {
    // Type-level confirmation that the variant is reachable. We
    // don't need to do anything with the value — its mere
    // existence is the load-bearing check.
    let err = obj::Error::FormatFeatureUnsupported {
        feature: "encryption",
    };
    assert!(matches!(
        err,
        obj::Error::FormatFeatureUnsupported { feature } if feature == "encryption"
    ));
    // Also confirm Db::open is reachable against the synthetic
    // header (with the feature on, the open returns
    // EncryptionKeyRequired since no key was provided).
    let dir = TempDir::new().expect("tmp");
    let path = dir.path().join("e.obj");
    let header = synth_encryption_capable_header();
    {
        let mut f = File::create(&path).expect("create");
        f.write_all(&header).expect("write header");
        f.sync_all().expect("sync");
    }
    let err = Db::open(&path).expect_err("missing key must fail");
    assert!(matches!(err, obj::Error::EncryptionKeyRequired));
}