use crate::error::AedbError;
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::{Component, Path, PathBuf};
use tempfile::NamedTempFile;
use uuid::Uuid;
pub const BACKUP_MANIFEST_FILE: &str = "backup_manifest.json";
pub const BACKUP_MANIFEST_HMAC_FILE: &str = "backup_manifest.hmac";
const BACKUP_ARCHIVE_MAGIC: &[u8; 8] = b"AEDBARC1";
const BACKUP_ARCHIVE_FLAG_ENCRYPTED: u8 = 0x01;
const BACKUP_ARCHIVE_ENTRY_FILE: u8 = 0x01;
const BACKUP_ARCHIVE_ENTRY_CHUNKED_FILE: u8 = 0x02;
const BACKUP_ARCHIVE_ENTRY_END: u8 = 0xFF;
const MAX_BACKUP_ARCHIVE_PATH_BYTES: u32 = 4_096;
const MAX_BACKUP_ARCHIVE_PAYLOAD_BYTES: u64 = 2 * 1024 * 1024 * 1024;
const BACKUP_ARCHIVE_CHUNK_BYTES: usize = 4 * 1024 * 1024;
const BACKUP_ARCHIVE_CHUNKED_FILE_THRESHOLD_BYTES: u64 = 8 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BackupManifest {
pub backup_id: String,
pub backup_type: String,
#[serde(default)]
pub parent_backup_id: Option<String>,
#[serde(default)]
pub from_seq: Option<u64>,
pub created_at_micros: u64,
pub aedb_version: String,
pub checkpoint_seq: u64,
pub wal_head_seq: u64,
pub checkpoint_file: String,
pub wal_segments: Vec<String>,
pub file_sha256: BTreeMap<String, String>,
}
pub fn write_backup_manifest(
dir: &Path,
manifest: &BackupManifest,
signing_key: Option<&[u8]>,
) -> Result<(), AedbError> {
fs::create_dir_all(dir)?;
let bytes =
serde_json::to_vec_pretty(manifest).map_err(|e| AedbError::Encode(e.to_string()))?;
write_file_atomic_synced(dir, BACKUP_MANIFEST_FILE, &bytes)?;
if let Some(key) = signing_key {
let sig = hmac_hex(key, &bytes)?;
write_file_atomic_synced(dir, BACKUP_MANIFEST_HMAC_FILE, sig.as_bytes())?;
} else {
let _ = fs::remove_file(dir.join(BACKUP_MANIFEST_HMAC_FILE));
sync_dir(dir)?;
}
Ok(())
}
pub fn load_backup_manifest(
dir: &Path,
signing_key: Option<&[u8]>,
) -> Result<BackupManifest, AedbError> {
let bytes = fs::read(dir.join(BACKUP_MANIFEST_FILE))?;
if let Some(key) = signing_key {
let expected_hex = fs::read_to_string(dir.join(BACKUP_MANIFEST_HMAC_FILE))
.map_err(|_| AedbError::Validation("backup manifest hmac missing".into()))?;
verify_hmac_hex(key, &bytes, expected_hex.trim())?;
}
let manifest: BackupManifest =
serde_json::from_slice(&bytes).map_err(|e| AedbError::Decode(e.to_string()))?;
validate_backup_manifest(&manifest)?;
Ok(manifest)
}
pub fn verify_backup_files(dir: &Path, manifest: &BackupManifest) -> Result<(), AedbError> {
for (rel, expected) in &manifest.file_sha256 {
if !is_valid_sha256_hex(expected) {
return Err(AedbError::Validation(format!(
"invalid sha256 entry in backup manifest for path: {rel}"
)));
}
let resolved = resolve_backup_path(dir, rel)?;
let actual = sha256_file_hex(&resolved)?;
if &actual != expected {
return Err(AedbError::Validation(format!(
"backup file checksum mismatch: {rel}"
)));
}
}
Ok(())
}
pub fn write_backup_archive(
dir: &Path,
archive_path: &Path,
encryption_key: Option<&[u8; 32]>,
) -> Result<(), AedbError> {
if !dir.exists() {
return Err(AedbError::Validation(
"backup source directory not found".into(),
));
}
if let Some(parent) = archive_path.parent() {
fs::create_dir_all(parent)?;
}
let mut rel_files = collect_relative_files(dir)?;
rel_files.sort();
let archive_flags = if encryption_key.is_some() {
BACKUP_ARCHIVE_FLAG_ENCRYPTED
} else {
0
};
let salt = *Uuid::new_v4().as_bytes();
let archive_file = fs::File::create(archive_path)?;
let mut writer = BufWriter::new(archive_file);
writer.write_all(BACKUP_ARCHIVE_MAGIC)?;
writer.write_all(&[archive_flags])?;
writer.write_all(&salt)?;
for (entry_index, rel) in rel_files.iter().enumerate() {
let resolved = resolve_backup_path(dir, rel)?;
let file_len = resolved.metadata()?.len();
if file_len >= BACKUP_ARCHIVE_CHUNKED_FILE_THRESHOLD_BYTES {
write_chunked_archive_file(
&mut writer,
rel,
&resolved,
file_len,
&salt,
entry_index as u64,
encryption_key,
)?;
} else {
write_legacy_archive_file(
&mut writer,
rel,
&resolved,
&salt,
entry_index as u64,
encryption_key,
)?;
}
}
write_u8(&mut writer, BACKUP_ARCHIVE_ENTRY_END)?;
writer.flush()?;
writer.get_ref().sync_all()?;
if let Some(parent) = archive_path.parent() {
sync_dir(parent)?;
}
Ok(())
}
pub fn extract_backup_archive(
archive_path: &Path,
dir: &Path,
encryption_key: Option<&[u8; 32]>,
) -> Result<(), AedbError> {
if dir.exists() && fs::read_dir(dir)?.next().is_some() {
return Err(AedbError::Validation(
"archive extract target directory must be empty".into(),
));
}
fs::create_dir_all(dir)?;
let mut reader = BufReader::new(fs::File::open(archive_path)?);
let mut magic = [0u8; 8];
reader.read_exact(&mut magic)?;
if &magic != BACKUP_ARCHIVE_MAGIC {
return Err(AedbError::Validation("invalid backup archive magic".into()));
}
let mut flag_buf = [0u8; 1];
reader.read_exact(&mut flag_buf)?;
let encrypted = (flag_buf[0] & BACKUP_ARCHIVE_FLAG_ENCRYPTED) != 0;
if encrypted && encryption_key.is_none() {
return Err(AedbError::Validation(
"backup archive requires checkpoint key".into(),
));
}
let mut salt = [0u8; 16];
reader.read_exact(&mut salt)?;
let mut entry_index = 0u64;
loop {
let entry = read_u8(&mut reader)?;
if entry == BACKUP_ARCHIVE_ENTRY_END {
break;
}
let rel = read_archive_relative_path(&mut reader)?;
match entry {
BACKUP_ARCHIVE_ENTRY_FILE => {
extract_legacy_archive_file(
&mut reader,
dir,
&rel,
&salt,
entry_index,
encrypted,
encryption_key,
)?;
}
BACKUP_ARCHIVE_ENTRY_CHUNKED_FILE => {
extract_chunked_archive_file(
&mut reader,
dir,
&rel,
&salt,
entry_index,
encrypted,
encryption_key,
)?;
}
_ => return Err(AedbError::Validation("invalid backup archive entry".into())),
}
entry_index = entry_index.saturating_add(1);
}
sync_dir(dir)?;
Ok(())
}
pub fn sha256_file_hex(path: &Path) -> Result<String, AedbError> {
let file = fs::File::open(path)?;
let mut reader = BufReader::new(file);
let mut hasher = Sha256::new();
let mut buf = [0u8; 16 * 1024];
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex_string(hasher.finalize().as_slice()))
}
fn hmac_hex(key: &[u8], bytes: &[u8]) -> Result<String, AedbError> {
type HmacSha256 = Hmac<Sha256>;
let mut mac = <HmacSha256 as Mac>::new_from_slice(key)
.map_err(|e| AedbError::Validation(format!("invalid hmac key: {e}")))?;
mac.update(bytes);
Ok(hex_string(&mac.finalize().into_bytes()))
}
fn write_file_atomic_synced(dir: &Path, filename: &str, bytes: &[u8]) -> Result<(), AedbError> {
let final_path = dir.join(filename);
let mut tmp = NamedTempFile::new_in(dir)?;
tmp.write_all(bytes)?;
tmp.flush()?;
tmp.as_file().sync_all()?;
tmp.persist(&final_path)
.map_err(|e| AedbError::Io(e.error))?;
fs::File::open(&final_path)?.sync_all()?;
sync_dir(dir)?;
Ok(())
}
fn write_output_file_synced(path: &Path, bytes: &[u8]) -> Result<(), AedbError> {
let mut file = fs::File::create(path)?;
file.write_all(bytes)?;
file.flush()?;
file.sync_all()?;
if let Some(parent) = path.parent() {
sync_dir(parent)?;
}
Ok(())
}
fn write_legacy_archive_file<W: Write>(
writer: &mut W,
rel: &str,
resolved: &Path,
salt: &[u8; 16],
entry_index: u64,
encryption_key: Option<&[u8; 32]>,
) -> Result<(), AedbError> {
validate_archive_path_len(rel)?;
let raw = fs::read(resolved)?;
let compressed = zstd::stream::encode_all(raw.as_slice(), 3)
.map_err(|e| AedbError::Encode(e.to_string()))?;
let payload = if let Some(key) = encryption_key {
let nonce = derive_archive_nonce(salt, entry_index, rel);
encrypt_archive_payload(&compressed, key, &nonce)?
} else {
compressed
};
validate_archive_payload_len(payload.len() as u64)?;
write_u8(writer, BACKUP_ARCHIVE_ENTRY_FILE)?;
write_u32(writer, rel.len() as u32)?;
writer.write_all(rel.as_bytes())?;
write_u64(writer, payload.len() as u64)?;
writer.write_all(&payload)?;
Ok(())
}
fn write_chunked_archive_file<W: Write>(
writer: &mut W,
rel: &str,
resolved: &Path,
file_len: u64,
salt: &[u8; 16],
entry_index: u64,
encryption_key: Option<&[u8; 32]>,
) -> Result<(), AedbError> {
validate_archive_path_len(rel)?;
write_u8(writer, BACKUP_ARCHIVE_ENTRY_CHUNKED_FILE)?;
write_u32(writer, rel.len() as u32)?;
writer.write_all(rel.as_bytes())?;
write_u64(writer, file_len)?;
let mut reader = BufReader::new(fs::File::open(resolved)?);
let mut buf = vec![0u8; BACKUP_ARCHIVE_CHUNK_BYTES];
let mut chunk_index = 0u64;
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
let raw = &buf[..n];
let compressed =
zstd::stream::encode_all(raw, 3).map_err(|e| AedbError::Encode(e.to_string()))?;
let payload = if let Some(key) = encryption_key {
let nonce = derive_archive_chunk_nonce(salt, entry_index, chunk_index, rel);
encrypt_archive_payload(&compressed, key, &nonce)?
} else {
compressed
};
validate_archive_payload_len(payload.len() as u64)?;
write_u32(writer, n as u32)?;
write_u64(writer, payload.len() as u64)?;
writer.write_all(&payload)?;
chunk_index = chunk_index.saturating_add(1);
}
Ok(())
}
fn read_archive_relative_path<R: Read>(reader: &mut R) -> Result<String, AedbError> {
let path_len_u32 = read_u32(reader)?;
if path_len_u32 == 0 {
return Err(AedbError::Validation(
"backup archive path must not be empty".into(),
));
}
if path_len_u32 > MAX_BACKUP_ARCHIVE_PATH_BYTES {
return Err(AedbError::Validation(
"backup archive path exceeds max length".into(),
));
}
let path_len = usize::try_from(path_len_u32)
.map_err(|_| AedbError::Validation("backup archive path exceeds platform limits".into()))?;
let mut path_bytes = vec![0u8; path_len];
reader.read_exact(&mut path_bytes)?;
let rel = String::from_utf8(path_bytes)
.map_err(|_| AedbError::Validation("backup archive path is not utf-8".into()))?;
validate_safe_relative_path(&rel, "backup archive path")?;
Ok(rel)
}
fn extract_legacy_archive_file<R: Read>(
reader: &mut R,
dir: &Path,
rel: &str,
salt: &[u8; 16],
entry_index: u64,
encrypted: bool,
encryption_key: Option<&[u8; 32]>,
) -> Result<(), AedbError> {
let payload = read_archive_payload(reader)?;
let compressed = if encrypted {
let Some(key) = encryption_key else {
return Err(AedbError::Validation(
"backup archive missing encryption key".into(),
));
};
let expected_nonce = derive_archive_nonce(salt, entry_index, rel);
decrypt_archive_payload(&payload, key, &expected_nonce)?
} else {
payload
};
let bytes = zstd::stream::decode_all(compressed.as_slice())
.map_err(|e| AedbError::Decode(e.to_string()))?;
let out = resolve_backup_output_path(dir, rel)?;
write_output_file_synced(&out, &bytes)?;
Ok(())
}
fn extract_chunked_archive_file<R: Read>(
reader: &mut R,
dir: &Path,
rel: &str,
salt: &[u8; 16],
entry_index: u64,
encrypted: bool,
encryption_key: Option<&[u8; 32]>,
) -> Result<(), AedbError> {
let file_len = read_u64(reader)?;
let out = resolve_backup_output_path(dir, rel)?;
let mut file = BufWriter::new(fs::File::create(&out)?);
let mut remaining = file_len;
let mut chunk_index = 0u64;
while remaining > 0 {
let raw_len_u32 = read_u32(reader)?;
if raw_len_u32 == 0 {
return Err(AedbError::Validation(
"chunked backup archive contains empty chunk".into(),
));
}
let raw_len = usize::try_from(raw_len_u32).map_err(|_| {
AedbError::Validation("backup archive chunk exceeds platform limits".into())
})?;
if raw_len > BACKUP_ARCHIVE_CHUNK_BYTES || raw_len as u64 > remaining {
return Err(AedbError::Validation(
"chunked backup archive chunk length is invalid".into(),
));
}
let payload = read_archive_payload(reader)?;
let compressed = if encrypted {
let Some(key) = encryption_key else {
return Err(AedbError::Validation(
"backup archive missing encryption key".into(),
));
};
let expected_nonce = derive_archive_chunk_nonce(salt, entry_index, chunk_index, rel);
decrypt_archive_payload(&payload, key, &expected_nonce)?
} else {
payload
};
let bytes = decode_archive_chunk(&compressed, raw_len)?;
file.write_all(&bytes)?;
remaining -= raw_len as u64;
chunk_index = chunk_index.saturating_add(1);
}
file.flush()?;
file.get_ref().sync_all()?;
if let Some(parent) = out.parent() {
sync_dir(parent)?;
}
Ok(())
}
fn read_archive_payload<R: Read>(reader: &mut R) -> Result<Vec<u8>, AedbError> {
let payload_len_u64 = read_u64(reader)?;
validate_archive_payload_len(payload_len_u64)?;
let payload_len = usize::try_from(payload_len_u64).map_err(|_| {
AedbError::Validation("backup archive payload exceeds platform limits".into())
})?;
let mut payload = vec![0u8; payload_len];
reader.read_exact(&mut payload)?;
Ok(payload)
}
fn decode_archive_chunk(compressed: &[u8], expected_len: usize) -> Result<Vec<u8>, AedbError> {
let decoder =
zstd::stream::Decoder::new(compressed).map_err(|e| AedbError::Decode(e.to_string()))?;
let mut limited = decoder.take(expected_len as u64 + 1);
let mut bytes = Vec::with_capacity(expected_len);
limited
.read_to_end(&mut bytes)
.map_err(|e| AedbError::Decode(e.to_string()))?;
if bytes.len() != expected_len {
return Err(AedbError::Decode(
"chunked backup archive decoded length mismatch".into(),
));
}
Ok(bytes)
}
fn validate_archive_path_len(rel: &str) -> Result<(), AedbError> {
if rel.len() > MAX_BACKUP_ARCHIVE_PATH_BYTES as usize {
return Err(AedbError::Validation(
"backup archive path exceeds max length".into(),
));
}
Ok(())
}
fn validate_archive_payload_len(payload_len: u64) -> Result<(), AedbError> {
if payload_len > MAX_BACKUP_ARCHIVE_PAYLOAD_BYTES {
return Err(AedbError::Validation(
"backup archive payload exceeds max size".into(),
));
}
Ok(())
}
fn sync_dir(dir: &Path) -> Result<(), AedbError> {
fs::File::open(dir)?.sync_all()?;
Ok(())
}
fn verify_hmac_hex(key: &[u8], bytes: &[u8], expected_hex: &str) -> Result<(), AedbError> {
let expected = decode_hex(expected_hex)?;
type HmacSha256 = Hmac<Sha256>;
let mut mac = <HmacSha256 as Mac>::new_from_slice(key)
.map_err(|e| AedbError::Validation(format!("invalid hmac key: {e}")))?;
mac.update(bytes);
mac.verify_slice(&expected)
.map_err(|_| AedbError::Validation("backup manifest hmac mismatch".into()))
}
fn decode_hex(input: &str) -> Result<Vec<u8>, AedbError> {
let trimmed = input.trim();
if !trimmed.len().is_multiple_of(2) {
return Err(AedbError::Validation(
"backup manifest hmac must be hex".into(),
));
}
let mut out = Vec::with_capacity(trimmed.len() / 2);
for pair in trimmed.as_bytes().chunks_exact(2) {
let hi = hex_nibble(pair[0])
.ok_or_else(|| AedbError::Validation("backup manifest hmac must be hex".into()))?;
let lo = hex_nibble(pair[1])
.ok_or_else(|| AedbError::Validation("backup 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 is_valid_sha256_hex(value: &str) -> bool {
value.len() == 64 && value.as_bytes().iter().all(|b| b.is_ascii_hexdigit())
}
fn validate_backup_manifest(manifest: &BackupManifest) -> Result<(), AedbError> {
validate_safe_relative_path(&manifest.checkpoint_file, "checkpoint_file")?;
for seg in &manifest.wal_segments {
validate_safe_relative_path(seg, "wal_segments[]")?;
}
for rel in manifest.file_sha256.keys() {
validate_safe_relative_path(rel, "file_sha256 key")?;
}
if manifest.backup_type == "full"
&& !manifest.file_sha256.contains_key(&manifest.checkpoint_file)
{
return Err(AedbError::Validation(
"backup manifest missing checkpoint checksum".into(),
));
}
for seg in &manifest.wal_segments {
let rel = format!("wal_tail/{seg}");
if !manifest.file_sha256.contains_key(&rel) {
return Err(AedbError::Validation(format!(
"backup manifest missing checksum for wal segment: {seg}"
)));
}
}
Ok(())
}
fn validate_safe_relative_path(path: &str, field: &str) -> Result<(), AedbError> {
if path.is_empty() {
return Err(AedbError::Validation(format!(
"{field} cannot be empty in backup manifest"
)));
}
if path.contains('\\') {
return Err(AedbError::Validation(format!(
"{field} must not contain backslashes"
)));
}
let candidate = Path::new(path);
if candidate.is_absolute() {
return Err(AedbError::Validation(format!(
"{field} must be a relative path"
)));
}
for component in candidate.components() {
match component {
Component::Normal(_) => {}
Component::CurDir
| Component::ParentDir
| Component::RootDir
| Component::Prefix(_) => {
return Err(AedbError::Validation(format!(
"{field} contains disallowed path component"
)));
}
}
}
Ok(())
}
pub(crate) fn resolve_backup_path(dir: &Path, rel: &str) -> Result<std::path::PathBuf, AedbError> {
validate_safe_relative_path(rel, "backup path")?;
let base = fs::canonicalize(dir)?;
let candidate = dir.join(rel);
let canonical = fs::canonicalize(&candidate)?;
if !canonical.starts_with(&base) {
return Err(AedbError::Validation(
"backup path escapes backup directory".into(),
));
}
Ok(canonical)
}
fn collect_relative_files(dir: &Path) -> Result<Vec<String>, AedbError> {
fn walk(base: &Path, cur: &Path, out: &mut Vec<String>) -> Result<(), AedbError> {
for entry in fs::read_dir(cur)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
walk(base, &path, out)?;
} else if file_type.is_file() {
let rel = path
.strip_prefix(base)
.map_err(|e| AedbError::Validation(format!("invalid backup file path: {e}")))?;
let rel = rel.to_string_lossy().replace('\\', "/");
validate_safe_relative_path(&rel, "backup archive path")?;
out.push(rel);
}
}
Ok(())
}
let mut out = Vec::new();
walk(dir, dir, &mut out)?;
Ok(out)
}
fn resolve_backup_output_path(dir: &Path, rel: &str) -> Result<std::path::PathBuf, AedbError> {
validate_safe_relative_path(rel, "backup archive path")?;
let base = fs::canonicalize(dir)?;
let out = dir.join(rel);
if !out.starts_with(dir) {
return Err(AedbError::Validation(
"backup path escapes backup directory".into(),
));
}
let parent = out.parent().ok_or_else(|| {
AedbError::Validation("backup archive output path must have parent".into())
})?;
ensure_no_symlink_components(
dir,
parent
.strip_prefix(dir)
.map_err(|_| AedbError::Validation("backup path escapes backup directory".into()))?,
)?;
fs::create_dir_all(parent)?;
let canonical_parent = fs::canonicalize(parent)?;
if !canonical_parent.starts_with(&base) {
return Err(AedbError::Validation(
"backup path escapes backup directory".into(),
));
}
Ok(out)
}
fn ensure_no_symlink_components(base: &Path, rel_parent: &Path) -> Result<(), AedbError> {
let mut current = PathBuf::from(base);
for component in rel_parent.components() {
let Component::Normal(part) = component else {
return Err(AedbError::Validation(
"backup archive path contains disallowed path component".into(),
));
};
current.push(part);
if let Ok(metadata) = fs::symlink_metadata(¤t)
&& metadata.file_type().is_symlink()
{
return Err(AedbError::Validation(
"backup archive output path traverses symlink".into(),
));
}
}
Ok(())
}
fn derive_archive_nonce(salt: &[u8; 16], index: u64, rel: &str) -> [u8; 12] {
let mut input = Vec::with_capacity(16 + 8 + rel.len());
input.extend_from_slice(salt);
input.extend_from_slice(&index.to_le_bytes());
input.extend_from_slice(rel.as_bytes());
let hash = blake3::hash(&input);
let mut nonce = [0u8; 12];
nonce.copy_from_slice(&hash.as_bytes()[..12]);
nonce
}
fn derive_archive_chunk_nonce(
salt: &[u8; 16],
index: u64,
chunk_index: u64,
rel: &str,
) -> [u8; 12] {
let mut input = Vec::with_capacity(16 + 8 + 8 + rel.len());
input.extend_from_slice(salt);
input.extend_from_slice(&index.to_le_bytes());
input.extend_from_slice(&chunk_index.to_le_bytes());
input.extend_from_slice(rel.as_bytes());
let hash = blake3::hash(&input);
let mut nonce = [0u8; 12];
nonce.copy_from_slice(&hash.as_bytes()[..12]);
nonce
}
fn encrypt_archive_payload(
payload: &[u8],
key: &[u8; 32],
nonce: &[u8; 12],
) -> Result<Vec<u8>, AedbError> {
let cipher = Aes256Gcm::new_from_slice(key)
.map_err(|e| AedbError::Validation(format!("invalid encryption key: {e}")))?;
let ciphertext = cipher
.encrypt(Nonce::from_slice(nonce), payload)
.map_err(|e| AedbError::Validation(format!("backup archive encryption failed: {e}")))?;
let mut out = Vec::with_capacity(12 + ciphertext.len());
out.extend_from_slice(nonce);
out.extend_from_slice(&ciphertext);
Ok(out)
}
fn decrypt_archive_payload(
payload: &[u8],
key: &[u8; 32],
expected_nonce: &[u8; 12],
) -> Result<Vec<u8>, AedbError> {
if payload.len() < 12 {
return Err(AedbError::Decode(
"encrypted backup archive payload too small".into(),
));
}
let nonce = &payload[..12];
if nonce != expected_nonce {
return Err(AedbError::Validation(
"backup archive nonce mismatch".into(),
));
}
let cipher = Aes256Gcm::new_from_slice(key)
.map_err(|e| AedbError::Validation(format!("invalid encryption key: {e}")))?;
cipher
.decrypt(Nonce::from_slice(nonce), &payload[12..])
.map_err(|e| AedbError::Validation(format!("backup archive decryption failed: {e}")))
}
fn write_u8<W: Write>(writer: &mut W, value: u8) -> Result<(), AedbError> {
writer.write_all(&[value])?;
Ok(())
}
fn write_u32<W: Write>(writer: &mut W, value: u32) -> Result<(), AedbError> {
writer.write_all(&value.to_le_bytes())?;
Ok(())
}
fn write_u64<W: Write>(writer: &mut W, value: u64) -> Result<(), AedbError> {
writer.write_all(&value.to_le_bytes())?;
Ok(())
}
fn read_u8<R: Read>(reader: &mut R) -> Result<u8, AedbError> {
let mut buf = [0u8; 1];
reader.read_exact(&mut buf)?;
Ok(buf[0])
}
fn read_u32<R: Read>(reader: &mut R) -> Result<u32, AedbError> {
let mut buf = [0u8; 4];
reader.read_exact(&mut buf)?;
Ok(u32::from_le_bytes(buf))
}
fn read_u64<R: Read>(reader: &mut R) -> Result<u64, AedbError> {
let mut buf = [0u8; 8];
reader.read_exact(&mut buf)?;
Ok(u64::from_le_bytes(buf))
}
fn hex_string(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push_str(&format!("{b:02x}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_manifest() -> BackupManifest {
BackupManifest {
backup_id: "bk_1".into(),
backup_type: "full".into(),
parent_backup_id: None,
from_seq: None,
created_at_micros: 1,
aedb_version: "0.1.0".into(),
checkpoint_seq: 1,
wal_head_seq: 2,
checkpoint_file: "checkpoint_1.aedbcp".into(),
wal_segments: vec!["segment_2.aedbwal".into()],
file_sha256: BTreeMap::from([
("checkpoint_1.aedbcp".into(), "a".repeat(64)),
("wal_tail/segment_2.aedbwal".into(), "b".repeat(64)),
]),
}
}
#[test]
fn backup_manifest_rejects_unsafe_paths() {
let mut manifest = sample_manifest();
manifest.wal_segments = vec!["../segment_2.aedbwal".into()];
let err = validate_backup_manifest(&manifest).expect_err("must reject parent path");
assert!(matches!(err, AedbError::Validation(_)));
}
#[test]
fn backup_manifest_requires_wal_checksums() {
let mut manifest = sample_manifest();
manifest.file_sha256.remove("wal_tail/segment_2.aedbwal");
let err = validate_backup_manifest(&manifest).expect_err("must require checksum");
assert!(matches!(err, AedbError::Validation(_)));
}
#[test]
fn backup_manifest_rejects_invalid_checksum_hex() {
let mut manifest = sample_manifest();
manifest
.file_sha256
.insert("checkpoint_1.aedbcp".into(), "not_hex".into());
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("checkpoint_1.aedbcp"), b"x").expect("write");
std::fs::create_dir_all(dir.path().join("wal_tail")).expect("wal dir");
std::fs::write(dir.path().join("wal_tail/segment_2.aedbwal"), b"x").expect("write wal");
let err = verify_backup_files(dir.path(), &manifest).expect_err("must reject checksum");
assert!(matches!(err, AedbError::Validation(_)));
}
#[test]
fn backup_archive_roundtrip_plain_and_encrypted() {
let src = tempfile::tempdir().expect("src");
let dst_plain = tempfile::tempdir().expect("dst plain");
let dst_enc = tempfile::tempdir().expect("dst enc");
let archive_plain = src.path().join("backup_plain.aedbarc");
let archive_enc = src.path().join("backup_enc.aedbarc");
std::fs::create_dir_all(src.path().join("wal_tail")).expect("wal dir");
std::fs::create_dir_all(src.path().join("pages")).expect("pages dir");
std::fs::write(src.path().join("backup_manifest.json"), b"{\"x\":1}").expect("manifest");
std::fs::write(src.path().join("wal_tail/segment_1.aedbwal"), b"segment").expect("wal");
std::fs::write(src.path().join("pages/rows.aedbpg"), b"page-data")
.expect("page store file");
write_backup_archive(src.path(), &archive_plain, None).expect("write plain");
extract_backup_archive(&archive_plain, dst_plain.path(), None).expect("extract plain");
assert_eq!(
std::fs::read(dst_plain.path().join("backup_manifest.json")).expect("read manifest"),
b"{\"x\":1}".to_vec()
);
let key = [9u8; 32];
write_backup_archive(src.path(), &archive_enc, Some(&key)).expect("write enc");
extract_backup_archive(&archive_enc, dst_enc.path(), Some(&key)).expect("extract enc");
assert_eq!(
std::fs::read(dst_enc.path().join("wal_tail/segment_1.aedbwal")).expect("read wal"),
b"segment".to_vec()
);
assert_eq!(
std::fs::read(dst_enc.path().join("pages/rows.aedbpg")).expect("read page store file"),
b"page-data".to_vec()
);
let wrong = [7u8; 32];
let err = extract_backup_archive(
&archive_enc,
tempfile::tempdir().expect("tmp").path(),
Some(&wrong),
)
.expect_err("wrong key must fail");
assert!(format!("{err}").contains("decryption failed"));
}
#[test]
fn backup_archive_streams_large_page_file_in_encrypted_chunks() {
let src = tempfile::tempdir().expect("src");
let dst = tempfile::tempdir().expect("dst");
let archive = src.path().join("backup_large_page.aedbarc");
let page_rel = "pages/rows.aedbpg";
let page_path = src.path().join(page_rel);
std::fs::create_dir_all(page_path.parent().expect("page parent")).expect("pages dir");
std::fs::write(src.path().join("backup_manifest.json"), b"{\"x\":1}").expect("manifest");
let large_len =
BACKUP_ARCHIVE_CHUNKED_FILE_THRESHOLD_BYTES as usize + BACKUP_ARCHIVE_CHUNK_BYTES + 17;
let mut large_page = Vec::with_capacity(large_len);
for i in 0..large_len {
large_page.push(((i.wrapping_mul(31) ^ (i >> 7)) & 0xff) as u8);
}
std::fs::write(&page_path, &large_page).expect("large page file");
let key = [11u8; 32];
write_backup_archive(src.path(), &archive, Some(&key)).expect("write archive");
let archive_bytes = std::fs::read(&archive).expect("read archive");
assert!(
archive_bytes.contains(&BACKUP_ARCHIVE_ENTRY_CHUNKED_FILE),
"large page file should use chunked archive entry"
);
extract_backup_archive(&archive, dst.path(), Some(&key)).expect("extract archive");
assert_eq!(
sha256_file_hex(&page_path).expect("source hash"),
sha256_file_hex(&dst.path().join(page_rel)).expect("restored hash")
);
assert_eq!(
std::fs::read(dst.path().join("backup_manifest.json")).expect("manifest"),
b"{\"x\":1}".to_vec()
);
let wrong = [12u8; 32];
let err = extract_backup_archive(
&archive,
tempfile::tempdir().expect("bad dst").path(),
Some(&wrong),
)
.expect_err("wrong key must fail");
assert!(format!("{err}").contains("decryption failed"));
}
#[test]
fn backup_archive_rejects_oversized_path_and_payload_lengths() {
let archive_path = tempfile::NamedTempFile::new().expect("archive");
let out_dir = tempfile::tempdir().expect("out");
let mut bytes = Vec::new();
bytes.extend_from_slice(BACKUP_ARCHIVE_MAGIC);
bytes.push(0);
bytes.extend_from_slice(&[0u8; 16]);
bytes.push(BACKUP_ARCHIVE_ENTRY_FILE);
bytes.extend_from_slice(&(MAX_BACKUP_ARCHIVE_PATH_BYTES + 1).to_le_bytes());
std::fs::write(archive_path.path(), &bytes).expect("write malformed archive");
let err = extract_backup_archive(archive_path.path(), out_dir.path(), None)
.expect_err("oversized path must be rejected");
assert!(format!("{err}").contains("path exceeds max length"));
let archive_path = tempfile::NamedTempFile::new().expect("archive");
let out_dir = tempfile::tempdir().expect("out");
let mut bytes = Vec::new();
bytes.extend_from_slice(BACKUP_ARCHIVE_MAGIC);
bytes.push(0);
bytes.extend_from_slice(&[0u8; 16]);
bytes.push(BACKUP_ARCHIVE_ENTRY_FILE);
bytes.extend_from_slice(&(8u32).to_le_bytes());
bytes.extend_from_slice(b"file.bin");
bytes.extend_from_slice(&(MAX_BACKUP_ARCHIVE_PAYLOAD_BYTES + 1).to_le_bytes());
std::fs::write(archive_path.path(), &bytes).expect("write malformed archive");
let err = extract_backup_archive(archive_path.path(), out_dir.path(), None)
.expect_err("oversized payload must be rejected");
assert!(format!("{err}").contains("payload exceeds max size"));
}
#[cfg(unix)]
#[test]
fn backup_manifest_rejects_symlink_escape() {
let mut manifest = sample_manifest();
let dir = tempfile::tempdir().expect("tempdir");
let outside = tempfile::tempdir().expect("outside");
let outside_file = outside.path().join("outside.bin");
std::fs::write(&outside_file, b"outside").expect("write outside");
std::os::unix::fs::symlink(&outside_file, dir.path().join("checkpoint_1.aedbcp"))
.expect("symlink checkpoint");
std::fs::create_dir_all(dir.path().join("wal_tail")).expect("wal dir");
std::fs::write(dir.path().join("wal_tail/segment_2.aedbwal"), b"x").expect("write wal");
manifest.file_sha256.insert(
"checkpoint_1.aedbcp".into(),
sha256_file_hex(&outside_file).expect("hash"),
);
manifest.file_sha256.insert(
"wal_tail/segment_2.aedbwal".into(),
sha256_file_hex(&dir.path().join("wal_tail/segment_2.aedbwal")).expect("hash wal"),
);
let err = verify_backup_files(dir.path(), &manifest).expect_err("must reject escape");
assert!(matches!(err, AedbError::Validation(_)));
}
#[cfg(unix)]
#[test]
fn backup_output_path_rejects_symlinked_parent() {
let dir = tempfile::tempdir().expect("tempdir");
let outside = tempfile::tempdir().expect("outside");
std::os::unix::fs::symlink(outside.path(), dir.path().join("wal_tail"))
.expect("symlink parent");
let err = resolve_backup_output_path(dir.path(), "wal_tail/segment_1.aedbwal")
.expect_err("must reject symlinked output parent");
assert!(matches!(err, AedbError::Validation(_)));
}
}