aedb 0.2.1

Embedded Rust storage engine with transactional commits, WAL durability, and snapshot-consistent reads
Documentation
use crate::backup::sha256_file_hex;
use crate::error::AedbError;
use crate::manifest::schema::{Manifest, SegmentMeta};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::fs;
use std::io::Write;
use std::path::Path;
use tempfile::NamedTempFile;

pub fn write_manifest_atomic(manifest: &Manifest, dir: &Path) -> Result<(), AedbError> {
    write_manifest_atomic_signed(manifest, dir, None)
}

pub fn write_manifest_atomic_signed(
    manifest: &Manifest,
    dir: &Path,
    signing_key: Option<&[u8]>,
) -> Result<(), AedbError> {
    fs::create_dir_all(dir)?;
    let primary = dir.join("manifest.json");
    let prev = dir.join("manifest.json.prev");
    let sig = dir.join("manifest.hmac");
    let sig_prev = dir.join("manifest.hmac.prev");
    let bytes =
        serde_json::to_vec_pretty(manifest).map_err(|e| AedbError::Encode(e.to_string()))?;
    let signature = signing_key.map(|key| hmac_hex(key, &bytes)).transpose()?;

    if primary.exists() {
        let data = fs::read(&primary)?;
        fs::write(&prev, data)?;
        fsync_file(&prev)?;
    }

    let mut tmp = NamedTempFile::new_in(dir)?;
    tmp.write_all(&bytes)?;
    tmp.flush()?;
    tmp.as_file().sync_all()?;
    if let Some(signature) = signature {
        if sig.exists() {
            let data = fs::read(&sig)?;
            fs::write(&sig_prev, data)?;
            fsync_file(&sig_prev)?;
        }
        let mut sig_tmp = NamedTempFile::new_in(dir)?;
        sig_tmp.write_all(signature.as_bytes())?;
        sig_tmp.flush()?;
        sig_tmp.as_file().sync_all()?;
        sig_tmp.persist(&sig).map_err(|e| AedbError::Io(e.error))?;
        tmp.persist(&primary).map_err(|e| AedbError::Io(e.error))?;
        fsync_file(&sig)?;
    } else {
        tmp.persist(&primary).map_err(|e| AedbError::Io(e.error))?;
        let _ = fs::remove_file(&sig);
        let _ = fs::remove_file(&sig_prev);
    }
    fsync_file(&primary)?;
    fsync_dir(dir)?;
    Ok(())
}

pub fn load_manifest(dir: &Path) -> Result<Manifest, AedbError> {
    load_manifest_signed_mode(dir, None, false)
}

pub fn load_manifest_signed(dir: &Path, signing_key: Option<&[u8]>) -> Result<Manifest, AedbError> {
    load_manifest_signed_mode(dir, signing_key, false)
}

pub fn load_manifest_signed_mode(
    dir: &Path,
    signing_key: Option<&[u8]>,
    strict_recovery: bool,
) -> Result<Manifest, AedbError> {
    let primary = dir.join("manifest.json");
    if let Ok(m) = try_read_manifest_signed(dir, &primary, signing_key) {
        return Ok(m);
    }

    let prev = dir.join("manifest.json.prev");
    if let Ok(m) = try_read_manifest_signed(dir, &prev, signing_key) {
        return Ok(m);
    }

    if signing_key.is_some() || strict_recovery {
        return Err(AedbError::Unavailable {
            message: "manifest unavailable and reconstruction disabled".into(),
        });
    }

    reconstruct_manifest(dir)
}

fn try_read_manifest_signed(
    dir: &Path,
    path: &Path,
    signing_key: Option<&[u8]>,
) -> Result<Manifest, AedbError> {
    let bytes = fs::read(path)?;
    if let Some(key) = signing_key {
        verify_manifest_hmac(dir, path, key, &bytes)?;
    }
    serde_json::from_slice(&bytes).map_err(|e| AedbError::Decode(e.to_string()))
}

fn verify_manifest_hmac(
    dir: &Path,
    path: &Path,
    key: &[u8],
    bytes: &[u8],
) -> Result<(), AedbError> {
    let primary = dir.join("manifest.json");
    let sig_path = if path == primary {
        dir.join("manifest.hmac")
    } else {
        dir.join("manifest.hmac.prev")
    };
    let expected = fs::read_to_string(&sig_path).map_err(|_| AedbError::IntegrityError {
        message: "manifest hmac missing".into(),
    })?;
    let expected_bytes = decode_hex(expected.trim())?;
    let mut mac = Hmac::<Sha256>::new_from_slice(key).map_err(|e| AedbError::InvalidConfig {
        message: format!("invalid hmac key: {e}"),
    })?;
    mac.update(bytes);
    mac.verify_slice(&expected_bytes)
        .map_err(|_| AedbError::IntegrityError {
            message: "manifest hmac mismatch".into(),
        })
}

fn hmac_hex(key: &[u8], bytes: &[u8]) -> Result<String, AedbError> {
    let mut mac = Hmac::<Sha256>::new_from_slice(key).map_err(|e| AedbError::InvalidConfig {
        message: format!("invalid hmac key: {e}"),
    })?;
    mac.update(bytes);
    Ok(bytes_hex(&mac.finalize().into_bytes()))
}

fn bytes_hex(bytes: &[u8]) -> String {
    let mut out = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        out.push_str(&format!("{b:02x}"));
    }
    out
}

fn decode_hex(input: &str) -> Result<Vec<u8>, AedbError> {
    if !input.len().is_multiple_of(2) {
        return Err(AedbError::IntegrityError {
            message: "manifest hmac must be hex".into(),
        });
    }
    let mut out = Vec::with_capacity(input.len() / 2);
    for pair in input.as_bytes().chunks_exact(2) {
        let hi = hex_nibble(pair[0]).ok_or_else(|| AedbError::IntegrityError {
            message: "manifest hmac must be hex".into(),
        })?;
        let lo = hex_nibble(pair[1]).ok_or_else(|| AedbError::IntegrityError {
            message: "manifest hmac must be hex".into(),
        })?;
        out.push((hi << 4) | lo);
    }
    Ok(out)
}

fn hex_nibble(ch: u8) -> Option<u8> {
    match ch {
        b'0'..=b'9' => Some(ch - b'0'),
        b'a'..=b'f' => Some(ch - b'a' + 10),
        b'A'..=b'F' => Some(ch - b'A' + 10),
        _ => None,
    }
}

fn reconstruct_manifest(dir: &Path) -> Result<Manifest, AedbError> {
    let mut manifest = Manifest::default();

    let mut checkpoints = Vec::new();
    let mut segments = Vec::new();
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let name = entry.file_name().to_string_lossy().to_string();
        if let Some(seq) = parse_checkpoint_seq(&name) {
            checkpoints.push((seq, name.clone()));
        }
        if let Some(seq) = parse_segment_seq(&name) {
            segments.push((seq, name.clone()));
        }
    }

    checkpoints.sort_by_key(|(seq, _)| *seq);
    segments.sort_by_key(|(seq, _)| *seq);

    if let Some((seq, _)) = checkpoints.last() {
        manifest.visible_seq = *seq;
        manifest.durable_seq = *seq;
    }
    manifest.checkpoints = checkpoints
        .iter()
        .map(
            |(seq, filename)| crate::checkpoint::writer::CheckpointMeta {
                filename: filename.clone(),
                seq: *seq,
                sha256_hex: sha256_file_hex(&dir.join(filename)).unwrap_or_default(),
                created_at_micros: 0,
                key_id: None,
            },
        )
        .collect();
    if let Some((seq, _)) = segments.last() {
        manifest.active_segment_seq = *seq;
    }
    manifest.segments = segments
        .into_iter()
        .map(|(segment_seq, filename)| {
            let path = dir.join(&filename);
            let sha256_hex = sha256_file_hex(&path).unwrap_or_default();
            let size_bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
            SegmentMeta {
                filename,
                segment_seq,
                sha256_hex,
                size_bytes,
            }
        })
        .collect();
    Ok(manifest)
}

fn parse_checkpoint_seq(name: &str) -> Option<u64> {
    if !name.starts_with("checkpoint_") || !name.ends_with(".aedb.zst") {
        return None;
    }
    let middle = name
        .trim_start_matches("checkpoint_")
        .trim_end_matches(".aedb.zst");
    middle.parse::<u64>().ok()
}

fn parse_segment_seq(name: &str) -> Option<u64> {
    if !name.starts_with("segment_") || !name.ends_with(".aedbwal") {
        return None;
    }
    let middle = name
        .trim_start_matches("segment_")
        .trim_end_matches(".aedbwal");
    middle.parse::<u64>().ok()
}

fn fsync_file(path: &Path) -> Result<(), AedbError> {
    let file = fs::OpenOptions::new().read(true).open(path)?;
    file.sync_all()?;
    Ok(())
}

fn fsync_dir(path: &Path) -> Result<(), AedbError> {
    let dir = fs::File::open(path)?;
    dir.sync_all()?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{
        load_manifest, load_manifest_signed, load_manifest_signed_mode, write_manifest_atomic,
        write_manifest_atomic_signed,
    };
    use crate::manifest::schema::Manifest;
    use tempfile::tempdir;

    #[test]
    fn manifest_roundtrip_and_prev_fallback() {
        let dir = tempdir().expect("temp");
        let m1 = Manifest {
            durable_seq: 1,
            visible_seq: 1,
            active_segment_seq: 1,
            checkpoints: vec![],
            segments: vec![],
        };
        write_manifest_atomic(&m1, dir.path()).expect("write 1");

        let m2 = Manifest {
            durable_seq: 2,
            visible_seq: 2,
            active_segment_seq: 2,
            checkpoints: vec![],
            segments: vec![],
        };
        write_manifest_atomic(&m2, dir.path()).expect("write 2");

        let loaded = load_manifest(dir.path()).expect("load primary");
        assert_eq!(loaded, m2);

        std::fs::write(dir.path().join("manifest.json"), b"{broken").expect("corrupt primary");
        let fallback = load_manifest(dir.path()).expect("fallback");
        assert_eq!(fallback, m1);
    }

    #[test]
    fn manifest_hmac_sign_and_verify() {
        let dir = tempdir().expect("temp");
        let m = Manifest {
            durable_seq: 3,
            visible_seq: 3,
            active_segment_seq: 3,
            checkpoints: vec![],
            segments: vec![],
        };
        let key = b"super-secret-key";
        write_manifest_atomic_signed(&m, dir.path(), Some(key)).expect("write signed");
        let loaded = load_manifest_signed(dir.path(), Some(key)).expect("verify");
        assert_eq!(loaded, m);
        std::fs::write(dir.path().join("manifest.hmac"), "bad").expect("corrupt sig");
        std::fs::write(dir.path().join("manifest.hmac.prev"), "bad").expect("corrupt sig prev");
        assert!(load_manifest_signed(dir.path(), Some(key)).is_err());
    }

    #[test]
    fn signed_manifest_loads_previous_copy_when_primary_hmac_is_newer_than_manifest() {
        let dir = tempdir().expect("temp");
        let key = b"super-secret-key";
        let m2 = Manifest {
            durable_seq: 2,
            visible_seq: 2,
            active_segment_seq: 2,
            checkpoints: vec![],
            segments: vec![],
        };
        let m3 = Manifest {
            durable_seq: 3,
            visible_seq: 3,
            active_segment_seq: 3,
            checkpoints: vec![],
            segments: vec![],
        };
        write_manifest_atomic_signed(&m2, dir.path(), Some(key)).expect("write signed");
        let m2_bytes = serde_json::to_vec_pretty(&m2).expect("serialize m2");
        let m3_bytes = serde_json::to_vec_pretty(&m3).expect("serialize m3");

        std::fs::write(dir.path().join("manifest.json.prev"), &m2_bytes).expect("write prev");
        std::fs::write(
            dir.path().join("manifest.hmac.prev"),
            super::hmac_hex(key, &m2_bytes).expect("sign prev"),
        )
        .expect("write prev sig");
        std::fs::write(
            dir.path().join("manifest.hmac"),
            super::hmac_hex(key, &m3_bytes).expect("sign newer primary"),
        )
        .expect("write mismatched primary sig");

        let loaded = load_manifest_signed(dir.path(), Some(key)).expect("fallback to prev");
        assert_eq!(loaded, m2);
    }

    #[test]
    fn strict_recovery_rejects_reconstruction_without_manifest() {
        let dir = tempdir().expect("temp");
        std::fs::write(
            dir.path().join("segment_0000000000000001.aedbwal"),
            b"placeholder",
        )
        .expect("segment");
        let err = load_manifest_signed_mode(dir.path(), None, true).expect_err("strict fail");
        assert!(format!("{err}").contains("reconstruction disabled"));
    }

    #[test]
    fn reconstruction_collects_all_checkpoints() {
        let dir = tempdir().expect("temp");
        std::fs::write(
            dir.path().join("checkpoint_0000000000000002.aedb.zst"),
            b"x",
        )
        .expect("cp2");
        std::fs::write(
            dir.path().join("checkpoint_0000000000000005.aedb.zst"),
            b"y",
        )
        .expect("cp5");
        let m = load_manifest(dir.path()).expect("reconstruct");
        assert_eq!(m.checkpoints.len(), 2);
        assert_eq!(m.checkpoints[0].seq, 2);
        assert_eq!(m.checkpoints[1].seq, 5);
        assert_eq!(m.visible_seq, 5);
        assert_eq!(m.durable_seq, 5);
    }
}