use std::borrow::Cow;
use std::collections::{BTreeMap, HashSet};
use std::io::Read;
use std::path::{Path, PathBuf};
use zeroize::{Zeroize, Zeroizing};
use super::block::{build_file_bytes, write_container};
use super::chunking::{chunk_ranges_with, keyed_chunk_id, CdcBounds, StreamingChunker};
use super::constants::{
CDC_MAX, CRYPT_ALGORITHM_ENCRYPTED, CRYPT_ALGORITHM_NONE, DATA_OFFSET, DEFAULT_ZSTD_LEVEL,
FLAG_PLAINTEXT_CONTENT, HEADER_SIZE, HKDF_CHUNK_ID, INCOMPRESSIBLE_PROBE_LEVEL,
INCOMPRESSIBLE_PROBE_MAX_SAMPLE, INCOMPRESSIBLE_PROBE_SAMPLE, INCOMPRESSIBLE_RATIO_PCT,
MAC_SIZE, MAGIC, MAX_BLOCK_SIZE, MAX_EXTENSION_DIR_SIZE, MAX_MANIFEST_SIZE,
MAX_PLAINTEXT_BLOCK_SIZE, MIN_PASSWORD_LEN, PACK_SMALL_FILE_THRESHOLD, PACK_TARGET,
SUPPORTED_WRAPPER_HEADER_VERSION, VERSION,
};
use super::format::{aerovz_mac_key, derive_keks, VaultHeaderV3};
use super::manifest::{
block_aad, decrypt_manifest, empty_manifest, empty_manifest_plaintext, manifest_cdc_bounds,
manifest_is_plaintext, manifest_zstd_level, next_block_index, now_iso,
parse_manifest_plaintext, AlgorithmSpec, ChunkRecordV3, ExtensionEntryV3, ManifestEntryV3,
VaultManifestV3, WrapperManifest,
};
use crate::aerocrypt::{
decrypt_with_aad, derive_base_kek, encrypt_with_aad, hkdf_expand, random_array, unwrap_key,
wrap_key, KEY_SIZE, SALT_SIZE, WRAPPED_KEY_SIZE,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VaultLane {
Encrypted,
Plaintext,
}
pub struct CreateOptionsV3 {
pub path: PathBuf,
pub password: String,
pub zstd_level: i32,
pub cdc_bounds: Option<CdcBounds>,
pub lane: VaultLane,
pub(super) error_correction: Option<super::ec::RecoveryPlacement>,
pub(super) error_correction_pct: u32,
}
impl CreateOptionsV3 {
pub fn new(path: impl Into<PathBuf>, password: impl Into<String>) -> Self {
Self {
path: path.into(),
password: password.into(),
zstd_level: DEFAULT_ZSTD_LEVEL,
cdc_bounds: None,
lane: VaultLane::Encrypted,
error_correction: None,
error_correction_pct: crate::error_correction::ERROR_CORRECTION_DEFAULT_PCT,
}
}
pub fn new_plaintext(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
password: String::new(),
zstd_level: DEFAULT_ZSTD_LEVEL,
cdc_bounds: None,
lane: VaultLane::Plaintext,
error_correction: None,
error_correction_pct: crate::error_correction::ERROR_CORRECTION_DEFAULT_PCT,
}
}
pub fn with_zstd_level(mut self, level: i32) -> Self {
self.zstd_level = level;
self
}
pub fn with_cdc_bounds(mut self, bounds: CdcBounds) -> Self {
self.cdc_bounds = Some(bounds);
self
}
}
pub struct OpenVaultV3 {
pub(super) path: PathBuf,
pub(super) header: VaultHeaderV3,
pub(super) opened_file_len: u64,
pub(super) opened_header_mac: [u8; MAC_SIZE],
pub(super) master_key: [u8; KEY_SIZE],
pub(super) mac_key: [u8; KEY_SIZE],
pub(super) manifest: VaultManifestV3,
pub(super) extensions: Vec<ExtensionEntryV3>,
pub(super) data: Vec<u8>,
pub(super) manifest_repaired_on_open: bool,
pub(super) header_repaired_on_open: bool,
pub(super) telemetry: Option<Box<dyn super::telemetry::VaultTelemetrySink + Send>>,
}
impl std::fmt::Debug for OpenVaultV3 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenVaultV3")
.field("path", &self.path)
.field("opened_file_len", &self.opened_file_len)
.field("entries", &self.manifest.entries.len())
.field("chunks", &self.manifest.chunks.len())
.field("data_len", &self.data.len())
.field("manifest_repaired_on_open", &self.manifest_repaired_on_open)
.field("header_repaired_on_open", &self.header_repaired_on_open)
.field("telemetry", &self.telemetry.is_some())
.finish_non_exhaustive()
}
}
impl Drop for OpenVaultV3 {
fn drop(&mut self) {
self.master_key.zeroize();
self.mac_key.zeroize();
}
}
impl OpenVaultV3 {
pub fn path(&self) -> &Path {
&self.path
}
pub fn set_telemetry_sink(
&mut self,
sink: Box<dyn super::telemetry::VaultTelemetrySink + Send>,
) {
self.telemetry = Some(sink);
}
pub(super) fn emit(
&mut self,
f: impl FnOnce(&mut (dyn super::telemetry::VaultTelemetrySink + Send)),
) {
if let Some(sink) = self.telemetry.as_deref_mut() {
f(sink);
}
}
}
#[derive(Debug, Clone)]
pub struct EntryInfo {
pub path: String,
pub size: u64,
pub is_dir: bool,
pub modified: String,
pub chunk_count: usize,
}
#[derive(Debug, Clone)]
pub struct VaultSummaryV3 {
pub version: u8,
pub file_count: usize,
pub chunk_count: usize,
pub dedup_chunks: usize,
pub compression_level: i32,
pub algorithms: Vec<String>,
pub entries: Vec<EntryInfo>,
}
#[derive(Debug, Clone)]
pub struct PeekInfo {
pub version: u8,
pub file_len: u64,
pub data_len: u64,
pub manifest_len: u64,
}
pub struct VaultV3;
impl VaultV3 {
pub fn create(opts: &CreateOptionsV3) -> Result<(), String> {
create_empty_vault(
&opts.path,
&opts.password,
opts.zstd_level,
opts.cdc_bounds,
opts.lane,
opts.error_correction,
opts.error_correction_pct,
)
}
pub fn create_with_error_correction(
opts: &CreateOptionsV3,
placement: super::ec::RecoveryPlacement,
pct: u32,
) -> Result<(), String> {
create_empty_vault(
&opts.path,
&opts.password,
opts.zstd_level,
opts.cdc_bounds,
opts.lane,
Some(placement),
pct,
)
}
pub fn export_parity(
vault_path: &Path,
password: &str,
out: Option<&Path>,
) -> Result<super::ec::ExportParityResult, String> {
super::ec::export_parity(vault_path, password, out)
}
pub fn strip_parity(
vault_path: &Path,
password: &str,
force: bool,
) -> Result<super::ec::StripParityResult, String> {
super::ec::strip_parity(vault_path, password, force)
}
pub fn scrub(vault: &OpenVaultV3) -> Vec<super::ec::DamagedChunk> {
super::ec::scrub_vault(vault)
}
pub fn repair(
vault: &mut OpenVaultV3,
dry_run: bool,
parity: Option<&Path>,
) -> Result<(usize, super::ec::ParitySource), String> {
super::ec::repair_vault(vault, dry_run, parity)
}
pub fn has_error_correction(path: &Path) -> Result<bool, String> {
super::ec::has_error_correction(path)
}
pub fn resolve_parity_source(
vault: &OpenVaultV3,
explicit: Option<&Path>,
) -> Result<super::ec::ParitySource, String> {
super::ec::resolve_parity_source(vault, explicit).map(|(_, source)| source)
}
pub fn recovery_status(path: &Path) -> Result<super::ec::RecoveryStatus, String> {
super::ec::recovery_status(path)
}
pub fn is_vault_v3(path: impl AsRef<Path>) -> bool {
let Ok(mut file) = std::fs::File::open(path.as_ref()) else {
return false;
};
let mut buf = [0u8; 11];
if file.read_exact(&mut buf).is_err() {
return false;
}
&buf[..10] == MAGIC && buf[10] == VERSION
}
pub fn open(path: impl Into<PathBuf>, password: &str) -> Result<OpenVaultV3, String> {
open_vault(path, password)
}
pub fn open_plaintext(path: impl Into<PathBuf>) -> Result<OpenVaultV3, String> {
let vault = open_vault(path, "")?;
if vault.header.flags & FLAG_PLAINTEXT_CONTENT == 0 {
return Err("Not a plaintext .aerovz archive (it is encrypted)".to_string());
}
Ok(vault)
}
pub fn peek(path: impl AsRef<Path>) -> Result<PeekInfo, String> {
let mut file =
std::fs::File::open(path.as_ref()).map_err(|e| format!("Open vault: {e}"))?;
let file_len = file
.metadata()
.map_err(|e| format!("Vault metadata: {e}"))?
.len();
let mut header_bytes = [0u8; HEADER_SIZE];
file.read_exact(&mut header_bytes)
.map_err(|e| format!("Read header: {e}"))?;
let header = VaultHeaderV3::from_bytes(&header_bytes)?;
Ok(PeekInfo {
version: VERSION,
file_len,
data_len: header.data_len,
manifest_len: header.manifest_len,
})
}
pub fn list(vault: &OpenVaultV3) -> Vec<EntryInfo> {
vault
.manifest
.entries
.iter()
.map(|entry| EntryInfo {
path: entry.path.clone(),
size: entry.size,
is_dir: entry.is_dir,
modified: entry.modified.clone(),
chunk_count: entry.chunks.len(),
})
.collect()
}
pub fn summary(vault: &OpenVaultV3) -> VaultSummaryV3 {
let entries = Self::list(vault);
let file_count = entries.iter().filter(|e| !e.is_dir).count();
let logical_chunks: usize = entries.iter().map(|e| e.chunk_count).sum();
let chunk_count = vault.manifest.chunks.len();
VaultSummaryV3 {
version: VERSION,
file_count,
chunk_count,
dedup_chunks: logical_chunks.saturating_sub(chunk_count),
compression_level: super::manifest::manifest_zstd_level(&vault.manifest),
algorithms: algorithm_chain(&vault.manifest),
entries,
}
}
pub fn add_files(vault: &mut OpenVaultV3, sources: &[(PathBuf, String)]) -> Result<(), String> {
append_sources_batched(vault, sources)?;
save_open_vault(vault)
}
pub fn add_files_to_dir(
vault: &mut OpenVaultV3,
sources: &[PathBuf],
target_dir: &str,
) -> Result<(), String> {
let target = target_dir.trim().trim_matches('/');
let mut mapped: Vec<(PathBuf, String)> = Vec::with_capacity(sources.len());
for source in sources {
let name = safe_entry_name(source)?;
let entry_path = if target.is_empty() {
name
} else {
let target = normalize_vault_relative_path(target)?;
join_vault_path(&target, &name)
};
mapped.push((source.clone(), entry_path));
}
if !target.is_empty() {
create_directory_in_manifest(&mut vault.manifest, target)?;
}
append_sources_batched(vault, &mapped)?;
save_open_vault(vault)
}
pub fn create_directory(vault: &mut OpenVaultV3, dir_path: &str) -> Result<bool, String> {
let created = create_directory_in_manifest(&mut vault.manifest, dir_path)?;
save_open_vault(vault)?;
Ok(created)
}
pub fn add_directory(
vault: &mut OpenVaultV3,
source_dir: &Path,
target_prefix: Option<&str>,
) -> Result<(usize, usize), String> {
add_directory_into(vault, source_dir, target_prefix)
}
pub fn delete_entry(vault: &mut OpenVaultV3, entry_name: &str) -> Result<usize, String> {
let removed = delete_entries_from_manifest(
vault,
std::slice::from_ref(&entry_name.to_string()),
false,
)?;
save_open_vault(vault)?;
Ok(removed)
}
pub fn delete_entries(
vault: &mut OpenVaultV3,
entry_names: &[String],
recursive: bool,
) -> Result<usize, String> {
let removed = delete_entries_from_manifest(vault, entry_names, recursive)?;
save_open_vault(vault)?;
Ok(removed)
}
pub fn move_entry(vault: &mut OpenVaultV3, from: &str, to: &str) -> Result<(), String> {
move_entry_in_manifest(vault, from, to)?;
save_open_vault(vault)
}
pub fn rename_entry(
vault: &mut OpenVaultV3,
current_name: &str,
new_name: &str,
) -> Result<(), String> {
let current = normalize_vault_relative_path(current_name)?;
let leaf = normalize_leaf_name(new_name)?;
let target = match path_parent(¤t) {
Some(parent) => join_vault_path(parent, &leaf),
None => leaf,
};
move_entry_in_manifest(vault, ¤t, &target)?;
save_open_vault(vault)
}
pub fn copy_entry(vault: &mut OpenVaultV3, from: &str, to: &str) -> Result<(), String> {
copy_entry_in_manifest(vault, from, to)?;
save_open_vault(vault)
}
pub fn change_password(vault: &mut OpenVaultV3, new_password: &str) -> Result<(), String> {
change_password_in_place(vault, new_password)?;
save_open_vault(vault)
}
pub fn extract_entry(
vault: &OpenVaultV3,
entry_name: &str,
dest: &Path,
) -> Result<PathBuf, String> {
extract_entry(vault, entry_name, dest)
}
pub fn extract_all(vault: &OpenVaultV3, dest: &Path) -> Result<u64, String> {
extract_all_entries(vault, dest)
}
pub fn extract_all_with_progress(
vault: &OpenVaultV3,
dest: &Path,
progress: &mut dyn FnMut(u64, u64),
) -> Result<u64, String> {
extract_all_entries_with_progress(vault, dest, progress)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EntryKindV3 {
File,
Directory,
}
fn algorithm_chain(manifest: &VaultManifestV3) -> Vec<String> {
let w = &manifest.wrappers;
let line = |name: &str, s: &AlgorithmSpec| {
format!("{name}:{} v{}", s.algorithm_id, s.algorithm_version)
};
vec![
line("packing", &w.packing),
line("chunking", &w.chunking),
line("chunk_id", &w.chunk_id),
line("compression", &w.compression),
line("crypt", &w.crypt),
line("cipher_hash", &w.cipher_hash),
]
}
fn validate_vault_path(path: &str) -> Result<(), String> {
if path.is_empty()
|| path.starts_with('/')
|| path.starts_with('\\')
|| path.contains('\0')
|| path.contains('\\')
|| path.split('/').any(|part| part == "..")
|| path.as_bytes().get(1) == Some(&b':')
{
return Err(format!("Invalid AeroVault path: {path}"));
}
Ok(())
}
fn safe_entry_name(path: &Path) -> Result<String, String> {
let name = path
.file_name()
.ok_or_else(|| format!("Invalid file name: {}", path.display()))?
.to_string_lossy()
.to_string();
validate_vault_path(&name)?;
Ok(name)
}
fn normalize_vault_relative_path(path: &str) -> Result<String, String> {
let trimmed = path.trim().trim_matches('/');
if trimmed.is_empty() {
return Err("Invalid AeroVault path: empty".to_string());
}
validate_vault_path(trimmed)?;
if trimmed
.split('/')
.any(|part| part.is_empty() || part == ".")
{
return Err(format!("Invalid AeroVault path: {trimmed}"));
}
Ok(trimmed.to_string())
}
fn normalize_leaf_name(name: &str) -> Result<String, String> {
let trimmed = name.trim();
if trimmed.is_empty()
|| trimmed.contains('/')
|| trimmed.contains('\\')
|| trimmed.contains("..")
|| trimmed.contains('\0')
{
return Err("Invalid AeroVault name".to_string());
}
Ok(trimmed.to_string())
}
fn validate_manifest_paths(manifest: &VaultManifestV3) -> Result<(), String> {
let mut seen = HashSet::new();
for entry in &manifest.entries {
let normalized = normalize_vault_relative_path(&entry.path)?;
if normalized != entry.path {
return Err(format!(
"Invalid non-canonical AeroVault path: {}",
entry.path
));
}
if !seen.insert(entry.path.as_str()) {
return Err(format!(
"Duplicate AeroVault path in manifest: {}",
entry.path
));
}
}
Ok(())
}
fn validate_extension_dir(
extensions: &[ExtensionEntryV3],
extension_payload_len: u64,
) -> Result<(), String> {
let mut seen = HashSet::new();
for ext in extensions {
if ext.critical {
return Err(format!(
"Unsupported critical AeroVault v3 extension: {}",
ext.extension_id
));
}
if !seen.insert(ext.extension_id.as_str()) {
return Err(format!(
"Duplicate AeroVault v3 extension id: {}",
ext.extension_id
));
}
let end = ext
.offset
.checked_add(ext.length)
.ok_or_else(|| format!("Extension {} payload range overflow", ext.extension_id))?;
if end > extension_payload_len {
return Err(format!(
"Extension {} payload slice [{}..{}] escapes the {extension_payload_len}-byte extension payload",
ext.extension_id, ext.offset, end
));
}
}
Ok(())
}
fn join_vault_path(parent: &str, name: &str) -> String {
if parent.is_empty() {
name.to_string()
} else {
format!("{parent}/{name}")
}
}
fn path_parent(path: &str) -> Option<&str> {
path.rsplit_once('/').map(|(parent, _)| parent)
}
fn path_basename(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
fn is_descendant_of(path: &str, parent: &str) -> bool {
path.len() > parent.len()
&& path.starts_with(parent)
&& path.as_bytes().get(parent.len()) == Some(&b'/')
}
fn entry_kind(manifest: &VaultManifestV3, path: &str) -> Option<EntryKindV3> {
if let Some(entry) = manifest.entries.iter().find(|entry| entry.path == path) {
return Some(if entry.is_dir {
EntryKindV3::Directory
} else {
EntryKindV3::File
});
}
if manifest
.entries
.iter()
.any(|entry| is_descendant_of(&entry.path, path))
{
return Some(EntryKindV3::Directory);
}
None
}
fn ensure_no_file_ancestor(manifest: &VaultManifestV3, path: &str) -> Result<(), String> {
let mut current = path;
while let Some(parent) = path_parent(current) {
if manifest
.entries
.iter()
.any(|entry| entry.path == parent && !entry.is_dir)
{
return Err(format!("Parent path is a file: {parent}"));
}
current = parent;
}
Ok(())
}
fn sort_entries(manifest: &mut VaultManifestV3) {
manifest.entries.sort_by(|a, b| a.path.cmp(&b.path));
}
fn create_directory_in_manifest(
manifest: &mut VaultManifestV3,
dir_path: &str,
) -> Result<bool, String> {
let dir_path = normalize_vault_relative_path(dir_path)?;
ensure_no_file_ancestor(manifest, &dir_path)?;
if let Some(existing) = manifest.entries.iter().find(|entry| entry.path == dir_path) {
return if existing.is_dir {
Ok(false)
} else {
Err(format!("A file already exists at: {dir_path}"))
};
}
if let Some(parent) = path_parent(&dir_path) {
create_directory_in_manifest(manifest, parent)?;
}
manifest.entries.push(ManifestEntryV3 {
path: dir_path,
size: 0,
modified: now_iso(),
is_dir: true,
chunks: Vec::new(),
pack_offset: None,
});
sort_entries(manifest);
manifest.modified = now_iso();
Ok(true)
}
fn ensure_parent_directories(manifest: &mut VaultManifestV3, path: &str) -> Result<(), String> {
if let Some(parent) = path_parent(path) {
create_directory_in_manifest(manifest, parent)?;
}
Ok(())
}
fn probe_incompressible(chunk: &[u8]) -> bool {
if chunk.is_empty() {
return false;
}
let sample = representative_probe_sample(chunk);
match zstd::stream::encode_all(sample.as_ref(), INCOMPRESSIBLE_PROBE_LEVEL) {
Ok(probed) => probed.len() as u64 * 100 >= sample.len() as u64 * INCOMPRESSIBLE_RATIO_PCT,
Err(_) => false,
}
}
fn representative_probe_sample(chunk: &[u8]) -> Cow<'_, [u8]> {
if chunk.len() <= INCOMPRESSIBLE_PROBE_MAX_SAMPLE {
return Cow::Borrowed(chunk);
}
let window_len = INCOMPRESSIBLE_PROBE_SAMPLE.min(chunk.len());
let window_count = (INCOMPRESSIBLE_PROBE_MAX_SAMPLE / window_len).max(1);
let mut sample = Vec::with_capacity(window_count * window_len);
let max_start = chunk.len() - window_len;
for idx in 0..window_count {
let start = if window_count == 1 {
0
} else {
idx * max_start / (window_count - 1)
};
sample.extend_from_slice(&chunk[start..start + window_len]);
}
Cow::Owned(sample)
}
fn ingest_chunk(
vault: &mut OpenVaultV3,
chunk: &[u8],
chunk_key: &[u8; KEY_SIZE],
level: i32,
) -> Result<String, String> {
let chunk_id = keyed_chunk_id(chunk_key, chunk);
if !vault.manifest.chunks.contains_key(&chunk_id) {
let mut stored_raw = probe_incompressible(chunk);
let mut compressed = if stored_raw {
Vec::new()
} else {
let out = zstd::stream::encode_all(chunk, level)
.map_err(|e| format!("zstd compress failed: {e}"))?;
if out.len() >= chunk.len() {
stored_raw = true;
Vec::new()
} else {
out
}
};
let payload: &[u8] = if stored_raw { chunk } else { &compressed };
let block_index = next_block_index(&vault.manifest);
let aad = block_aad(block_index, &chunk_id);
let encrypted = if manifest_is_plaintext(&vault.manifest) {
payload.to_vec()
} else {
encrypt_with_aad(&vault.master_key, payload, &aad)?
};
let (pt, cz, enc) = (
chunk.len() as u64,
payload.len() as u64,
encrypted.len() as u64,
);
compressed.zeroize();
let cipher_hash = blake3::hash(&encrypted).to_hex().to_string();
let data_offset = vault.data.len() as u64;
vault
.data
.extend_from_slice(&(encrypted.len() as u64).to_le_bytes());
vault.data.extend_from_slice(&encrypted);
vault.manifest.chunks.insert(
chunk_id.clone(),
ChunkRecordV3 {
id: chunk_id.clone(),
block_index,
data_offset,
block_len: enc,
plaintext_len: pt,
compressed_len: cz,
cipher_hash,
stored_raw,
},
);
vault.emit(|s| s.on_chunk(true, pt, cz, enc));
} else {
vault.emit(|s| s.on_chunk(false, chunk.len() as u64, 0, 0));
}
Ok(chunk_id)
}
fn append_file_at(vault: &mut OpenVaultV3, source: &Path, entry_path: &str) -> Result<(), String> {
let entry_path = normalize_vault_relative_path(entry_path)?;
if !source.is_file() {
return Err(format!("Not a regular file: {}", source.display()));
}
ensure_parent_directories(&mut vault.manifest, &entry_path)?;
if let Some(kind) = entry_kind(&vault.manifest, &entry_path) {
match kind {
EntryKindV3::Directory => {
return Err(format!(
"Destination already exists as directory: {entry_path}"
));
}
EntryKindV3::File => {
vault
.manifest
.entries
.retain(|entry| entry.path != entry_path);
}
}
}
let chunk_key = hkdf_expand::<KEY_SIZE>(&vault.master_key, HKDF_CHUNK_ID)?;
let level = manifest_zstd_level(&vault.manifest);
let bounds = manifest_cdc_bounds(&vault.manifest)?;
let mut entry_chunks = Vec::new();
let file =
std::fs::File::open(source).map_err(|e| format!("Read {}: {e}", source.display()))?;
let mut chunker = StreamingChunker::new(file, bounds);
let mut size = 0u64;
while let Some(mut chunk) = chunker
.next_chunk()
.map_err(|e| format!("Read {}: {e}", source.display()))?
{
size += chunk.len() as u64;
let chunk_id = ingest_chunk(vault, &chunk, &chunk_key, level)?;
chunk.zeroize();
entry_chunks.push(chunk_id);
}
vault.manifest.entries.push(ManifestEntryV3 {
path: entry_path,
size,
modified: now_iso(),
is_dir: false,
chunks: entry_chunks,
pack_offset: None,
});
vault.emit(|s| s.on_file(false));
sort_entries(&mut vault.manifest);
vault.manifest.modified = now_iso();
Ok(())
}
fn flush_pack(
vault: &mut OpenVaultV3,
pack: &[u8],
members: &[(String, u64, u64)],
chunk_key: &[u8; KEY_SIZE],
level: i32,
bounds: &CdcBounds,
) -> Result<(), String> {
if members.is_empty() {
return Ok(());
}
vault.emit(|s| s.on_pack());
let ranges = chunk_ranges_with(pack, bounds);
let mut chunks: Vec<(String, u64, u64)> = Vec::with_capacity(ranges.len());
for (start, end) in &ranges {
let id = ingest_chunk(vault, &pack[*start..*end], chunk_key, level)?;
chunks.push((id, *start as u64, *end as u64));
}
let (member_count, pack_len, chunk_count) = (members.len(), pack.len(), chunks.len());
vault.emit(|s| {
s.step(&format!(
"pack: {member_count} file(s), {pack_len} B -> chunk+compress+encrypt {chunk_count} chunk(s)"
))
});
for (entry_path, fstart, flen) in members {
let fstart_v = *fstart;
let flen_v = *flen;
let fend = fstart_v + flen_v;
ensure_parent_directories(&mut vault.manifest, entry_path)?;
if let Some(kind) = entry_kind(&vault.manifest, entry_path) {
match kind {
EntryKindV3::Directory => {
return Err(format!(
"Destination already exists as directory: {entry_path}"
));
}
EntryKindV3::File => {
vault.manifest.entries.retain(|e| &e.path != entry_path);
}
}
}
let (covering, pack_offset) = if flen_v == 0 {
(Vec::new(), Some(0u64))
} else {
let mut cov = Vec::new();
let mut first: Option<u64> = None;
for (id, cstart, cend) in &chunks {
if *cstart < fend && fstart_v < *cend {
if first.is_none() {
first = Some(*cstart);
}
cov.push(id.clone());
}
}
let fc = first.ok_or_else(|| format!("Packing failed to cover file: {entry_path}"))?;
(cov, Some(fstart_v - fc))
};
vault.manifest.entries.push(ManifestEntryV3 {
path: entry_path.clone(),
size: flen_v,
modified: now_iso(),
is_dir: false,
chunks: covering,
pack_offset,
});
vault.emit(|s| s.on_file(true));
}
Ok(())
}
fn append_sources_batched(
vault: &mut OpenVaultV3,
sources: &[(PathBuf, String)],
) -> Result<(), String> {
let chunk_key = hkdf_expand::<KEY_SIZE>(&vault.master_key, HKDF_CHUNK_ID)?;
let level = manifest_zstd_level(&vault.manifest);
let bounds = manifest_cdc_bounds(&vault.manifest)?;
let (cdc_min, cdc_avg, cdc_max) = (bounds.min, bounds.avg, bounds.max);
vault.emit(|s| s.set_cdc(cdc_min, cdc_avg, cdc_max));
let source_count = sources.len();
vault.emit(|s| s.step(&format!("scan: {source_count} source(s) to add")));
let mut small_meta: Vec<(PathBuf, String)> = Vec::new();
let mut large_count = 0usize;
for (source, entry_path) in sources {
let entry_path = normalize_vault_relative_path(entry_path)?;
if !source.is_file() {
return Err(format!("Not a regular file: {}", source.display()));
}
let len = std::fs::metadata(source)
.map_err(|e| format!("Stat {}: {e}", source.display()))?
.len();
if (len as usize) < PACK_SMALL_FILE_THRESHOLD {
small_meta.push((source.clone(), entry_path));
} else {
large_count += 1;
append_file_at(vault, source, &entry_path)?;
}
}
let small_count = small_meta.len();
vault.emit(|s| {
s.step(&format!(
"partition: {small_count} small (< {PACK_SMALL_FILE_THRESHOLD} B, batched) / {large_count} large (per-file)"
))
});
if !small_meta.is_empty() {
small_meta.sort_by(|a, b| a.1.cmp(&b.1));
let mut pack: Vec<u8> = Vec::new();
let mut members: Vec<(String, u64, u64)> = Vec::new();
for (source, entry_path) in &small_meta {
let mut data =
std::fs::read(source).map_err(|e| format!("Read {}: {e}", source.display()))?;
let start = pack.len() as u64;
pack.extend_from_slice(&data);
let len = data.len() as u64;
data.zeroize();
members.push((entry_path.clone(), start, len));
if pack.len() >= PACK_TARGET {
flush_pack(vault, &pack, &members, &chunk_key, level, &bounds)?;
pack.zeroize();
pack.clear();
members.clear();
}
}
if !members.is_empty() {
flush_pack(vault, &pack, &members, &chunk_key, level, &bounds)?;
pack.zeroize();
}
}
sort_entries(&mut vault.manifest);
vault.manifest.modified = now_iso();
Ok(())
}
fn compact_live_chunks(vault: &mut OpenVaultV3) -> Result<(), String> {
let live_chunk_ids: HashSet<String> = vault
.manifest
.entries
.iter()
.flat_map(|entry| entry.chunks.iter().cloned())
.collect();
if live_chunk_ids.is_empty() {
vault.manifest.chunks.clear();
vault.data.clear();
return Ok(());
}
let mut ordered_ids: Vec<(u64, String)> = vault
.manifest
.chunks
.iter()
.filter(|(id, _)| live_chunk_ids.contains(*id))
.map(|(id, record)| (record.block_index, id.clone()))
.collect();
ordered_ids.sort_by_key(|(index, _)| *index);
let mut new_data = Vec::new();
let mut new_chunks = BTreeMap::new();
for (_, chunk_id) in ordered_ids {
let mut record = vault
.manifest
.chunks
.get(&chunk_id)
.cloned()
.ok_or_else(|| format!("Missing chunk record: {chunk_id}"))?;
let len_start = record.data_offset as usize;
let len_end = len_start
.checked_add(8)
.ok_or_else(|| "Chunk length offset overflow".to_string())?;
if len_end > vault.data.len() {
return Err("Chunk length is outside data section".to_string());
}
let block_len = u64::from_le_bytes(
vault.data[len_start..len_end]
.try_into()
.expect("slice length"),
);
if block_len != record.block_len || block_len > MAX_BLOCK_SIZE {
return Err("Chunk length metadata mismatch".to_string());
}
let block_start = len_end;
let block_end = block_start
.checked_add(block_len as usize)
.ok_or_else(|| "Chunk block offset overflow".to_string())?;
if block_end > vault.data.len() {
return Err("Chunk block is outside data section".to_string());
}
record.data_offset = new_data.len() as u64;
new_data.extend_from_slice(&block_len.to_le_bytes());
new_data.extend_from_slice(&vault.data[block_start..block_end]);
new_chunks.insert(chunk_id, record);
}
vault.data = new_data;
vault.manifest.chunks = new_chunks;
Ok(())
}
fn delete_entries_from_manifest(
vault: &mut OpenVaultV3,
entry_names: &[String],
recursive: bool,
) -> Result<usize, String> {
let mut removed = 0usize;
for entry_name in entry_names {
let entry_name = normalize_vault_relative_path(entry_name)?;
let kind = entry_kind(&vault.manifest, &entry_name)
.ok_or_else(|| format!("Entry not found: {entry_name}"))?;
match kind {
EntryKindV3::File => {
let before = vault.manifest.entries.len();
vault
.manifest
.entries
.retain(|entry| entry.path != entry_name);
removed += before.saturating_sub(vault.manifest.entries.len());
}
EntryKindV3::Directory => {
let has_children = vault
.manifest
.entries
.iter()
.any(|entry| is_descendant_of(&entry.path, &entry_name));
if has_children && !recursive {
return Err(format!("Directory is not empty: {entry_name}"));
}
let before = vault.manifest.entries.len();
vault.manifest.entries.retain(|entry| {
entry.path != entry_name && !is_descendant_of(&entry.path, &entry_name)
});
removed += before.saturating_sub(vault.manifest.entries.len());
}
}
}
if removed > 0 {
compact_live_chunks(vault)?;
sort_entries(&mut vault.manifest);
vault.manifest.modified = now_iso();
}
Ok(removed)
}
fn remap_entry_path(path: &str, from: &str, to: &str) -> String {
if path == from {
to.to_string()
} else {
format!("{}/{}", to, &path[from.len() + 1..])
}
}
fn prepare_relocation(
manifest: &VaultManifestV3,
from: &str,
to: &str,
) -> Result<EntryKindV3, String> {
let from = normalize_vault_relative_path(from)?;
let to = normalize_vault_relative_path(to)?;
let kind = entry_kind(manifest, &from).ok_or_else(|| format!("Entry not found: {from}"))?;
if from == to {
return Ok(kind);
}
if kind == EntryKindV3::Directory && is_descendant_of(&to, &from) {
return Err("Cannot move a directory inside itself".to_string());
}
if entry_kind(manifest, &to).is_some() {
return Err(format!("Destination already exists: {to}"));
}
ensure_no_file_ancestor(manifest, &to)?;
Ok(kind)
}
fn move_entry_in_manifest(vault: &mut OpenVaultV3, from: &str, to: &str) -> Result<(), String> {
let from = normalize_vault_relative_path(from)?;
let to = normalize_vault_relative_path(to)?;
let _ = prepare_relocation(&vault.manifest, &from, &to)?;
if from == to {
return Ok(());
}
ensure_parent_directories(&mut vault.manifest, &to)?;
for entry in &mut vault.manifest.entries {
if entry.path == from || is_descendant_of(&entry.path, &from) {
entry.path = remap_entry_path(&entry.path, &from, &to);
entry.modified = now_iso();
}
}
sort_entries(&mut vault.manifest);
vault.manifest.modified = now_iso();
Ok(())
}
fn copy_entry_in_manifest(vault: &mut OpenVaultV3, from: &str, to: &str) -> Result<(), String> {
let from = normalize_vault_relative_path(from)?;
let to = normalize_vault_relative_path(to)?;
let _ = prepare_relocation(&vault.manifest, &from, &to)?;
if from == to {
return Ok(());
}
ensure_parent_directories(&mut vault.manifest, &to)?;
let clones: Vec<ManifestEntryV3> = vault
.manifest
.entries
.iter()
.filter(|entry| entry.path == from || is_descendant_of(&entry.path, &from))
.cloned()
.map(|mut entry| {
entry.path = remap_entry_path(&entry.path, &from, &to);
entry.modified = now_iso();
entry
})
.collect();
if clones.is_empty() {
return Err(format!("Entry not found: {from}"));
}
vault.manifest.entries.extend(clones);
sort_entries(&mut vault.manifest);
vault.manifest.modified = now_iso();
Ok(())
}
fn change_password_in_place(vault: &mut OpenVaultV3, new_password: &str) -> Result<(), String> {
if vault.header.flags & FLAG_PLAINTEXT_CONTENT != 0 {
return Err("A plaintext .aerovz archive has no password to change".to_string());
}
if new_password.len() < MIN_PASSWORD_LEN {
return Err("Password must be at least 8 characters".to_string());
}
let salt = random_array::<SALT_SIZE>();
let mut base_kek = derive_base_kek(new_password, &salt)?;
let (kek_master, kek_mac) = derive_keks(&base_kek)?;
let kek_master = Zeroizing::new(kek_master);
let kek_mac = Zeroizing::new(kek_mac);
base_kek.zeroize();
vault.header.salt = salt;
vault.header.wrapped_master_key = wrap_key(&kek_master, &vault.master_key)?;
vault.header.wrapped_mac_key = wrap_key(&kek_mac, &vault.mac_key)?;
vault.manifest.modified = now_iso();
Ok(())
}
#[cfg(windows)]
fn is_reparse_point(meta: &std::fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn is_reparse_point(meta: &std::fs::Metadata) -> bool {
meta.file_type().is_symlink()
}
fn create_contained_dirs(root: &Path, rel: &Path) -> Result<(), String> {
use std::path::Component;
let mut current = root.to_path_buf();
for comp in rel.components() {
match comp {
Component::Normal(part) => current.push(part),
Component::CurDir => continue,
_ => {
return Err(format!(
"Refusing extraction: unexpected path component in {}",
rel.display()
))
}
}
match std::fs::symlink_metadata(¤t) {
Ok(meta) => {
if is_reparse_point(&meta) {
return Err(format!(
"Refusing extraction: {} is a reparse point; it would redirect writes outside {}",
current.display(),
root.display()
));
}
if !meta.is_dir() {
return Err(format!(
"Refusing extraction: {} exists and is not a directory",
current.display()
));
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => {
std::fs::create_dir(¤t)
.map_err(|e| format!("Create output dir {}: {e}", current.display()))?;
}
Err(e) => {
return Err(format!("Resolve output dir {}: {e}", current.display()));
}
}
}
Ok(())
}
fn prepare_output_parent(root: &Path, output: &Path) -> Result<(), String> {
let parent = match output.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => return Ok(()),
};
let rel = parent.strip_prefix(root).map_err(|_| {
format!(
"Refusing extraction: {} is outside destination root {}",
parent.display(),
root.display()
)
})?;
create_contained_dirs(root, rel)?;
let canon_root = root
.canonicalize()
.map_err(|e| format!("Resolve destination {}: {e}", root.display()))?;
let canon_parent = parent
.canonicalize()
.map_err(|e| format!("Resolve output dir {}: {e}", parent.display()))?;
if !canon_parent.starts_with(&canon_root) {
return Err(format!(
"Refusing extraction: resolved {} escapes destination root {}",
canon_parent.display(),
canon_root.display()
));
}
Ok(())
}
fn extract_file_entry(
vault: &OpenVaultV3,
entry: &ManifestEntryV3,
output_path: &Path,
dest_root: &Path,
) -> Result<PathBuf, String> {
prepare_output_parent(dest_root, output_path)?;
let offset = entry.pack_offset.unwrap_or(0) as usize;
let size = entry.size as usize;
let end = offset
.checked_add(size)
.ok_or_else(|| "Entry slice range overflow".to_string())?;
let max_block_plaintext = vault
.manifest
.wrappers
.chunking
.bounds
.map(|b| b.max as u64)
.unwrap_or(CDC_MAX as u64)
.min(MAX_PLAINTEXT_BLOCK_SIZE);
let plaintext_lane = manifest_is_plaintext(&vault.manifest);
let parent = output_parent_dir(output_path);
std::fs::create_dir_all(parent).map_err(|e| format!("Create parent dir: {e}"))?;
let mut tmp = tempfile::Builder::new()
.prefix(".aerovault-v3-")
.tempfile_in(parent)
.map_err(|e| format!("Create temp file: {e}"))?;
let mut decoded_len = 0usize;
{
use std::io::Write;
let mut writer = std::io::BufWriter::new(tmp.as_file_mut());
for chunk_id in &entry.chunks {
if decoded_len >= end {
break;
}
let record = vault
.manifest
.chunks
.get(chunk_id)
.ok_or_else(|| format!("Missing chunk record: {chunk_id}"))?;
let len_start = record.data_offset as usize;
let len_end = len_start
.checked_add(8)
.ok_or_else(|| "Chunk length offset overflow".to_string())?;
if len_end > vault.data.len() {
return Err("Chunk length is outside data section".to_string());
}
let block_len = u64::from_le_bytes(
vault.data[len_start..len_end]
.try_into()
.expect("slice length"),
);
if block_len != record.block_len || block_len > MAX_BLOCK_SIZE {
return Err("Chunk length metadata mismatch".to_string());
}
if record.plaintext_len > max_block_plaintext {
return Err(format!(
"Plaintext block too large for chunk {chunk_id}: {} bytes (max {max_block_plaintext})",
record.plaintext_len
));
}
let block_start = len_end;
let block_end = block_start
.checked_add(block_len as usize)
.ok_or_else(|| "Chunk block offset overflow".to_string())?;
if block_end > vault.data.len() {
return Err("Chunk block is outside data section".to_string());
}
let encrypted = &vault.data[block_start..block_end];
let actual_hash = blake3::hash(encrypted).to_hex().to_string();
if actual_hash != record.cipher_hash {
return Err(format!("Cipher block hash mismatch for chunk {chunk_id}"));
}
let aad = block_aad(record.block_index, chunk_id);
let decrypted: Zeroizing<Vec<u8>> = Zeroizing::new(if plaintext_lane {
encrypted.to_vec()
} else {
decrypt_with_aad(&vault.master_key, encrypted, &aad)?
});
let plaintext: Zeroizing<Vec<u8>> = if record.stored_raw {
decrypted
} else {
let mut decoder = zstd::stream::read::Decoder::new(&decrypted[..])
.map_err(|e| format!("zstd decompress init failed: {e}"))?;
let mut decoded: Zeroizing<Vec<u8>> =
Zeroizing::new(Vec::with_capacity(record.plaintext_len as usize));
decoder
.by_ref()
.take(record.plaintext_len + 1)
.read_to_end(&mut decoded)
.map_err(|e| format!("zstd decompress failed: {e}"))?;
decoded
};
if plaintext.len() as u64 != record.plaintext_len {
return Err(format!("Plaintext length mismatch for chunk {chunk_id}"));
}
let block_pos = decoded_len;
let next_pos = block_pos + plaintext.len();
let w_start = offset.max(block_pos);
let w_end = end.min(next_pos);
if w_start < w_end {
writer
.write_all(&plaintext[w_start - block_pos..w_end - block_pos])
.map_err(|e| format!("Write extracted file: {e}"))?;
}
decoded_len = next_pos;
}
writer
.flush()
.map_err(|e| format!("Flush extracted file: {e}"))?;
}
if decoded_len < end {
return Err(format!(
"Entry slice [{offset}..{end}] exceeds decoded data ({decoded_len})"
));
}
tmp.as_file_mut()
.sync_all()
.map_err(|e| format!("Sync temp file: {e}"))?;
tmp.persist(output_path)
.map_err(|e| format!("Persist vault: {}", e.error))?;
fsync_parent_dir(parent);
Ok(output_path.to_path_buf())
}
pub(super) fn output_parent_dir(target: &Path) -> &Path {
target
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
pub(super) fn fsync_parent_dir(parent: &Path) {
#[cfg(unix)]
{
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
}
#[cfg(not(unix))]
{
let _ = parent;
}
}
pub(super) fn atomic_write(target: &Path, bytes: &[u8]) -> Result<(), String> {
let parent = output_parent_dir(target);
std::fs::create_dir_all(parent).map_err(|e| format!("Create parent dir: {e}"))?;
let mut tmp = tempfile::Builder::new()
.prefix(".aerovault-v3-")
.tempfile_in(parent)
.map_err(|e| format!("Create temp file: {e}"))?;
use std::io::Write;
tmp.write_all(bytes)
.map_err(|e| format!("Write temp file: {e}"))?;
tmp.as_file_mut()
.sync_all()
.map_err(|e| format!("Sync temp file: {e}"))?;
tmp.persist(target)
.map_err(|e| format!("Persist vault: {}", e.error))?;
fsync_parent_dir(parent);
Ok(())
}
pub(super) fn read_capped(
file: &mut std::fs::File,
offset: u64,
len: u64,
cap: u64,
label: &str,
) -> Result<Vec<u8>, String> {
use std::io::Seek;
if len > cap {
return Err(format!("{label} too large: {len} bytes"));
}
file.seek(std::io::SeekFrom::Start(offset))
.map_err(|e| format!("Seek {label}: {e}"))?;
let mut buf = vec![0u8; len as usize];
file.read_exact(&mut buf)
.map_err(|e| format!("Read {label}: {e}"))?;
Ok(buf)
}
fn check_wrapper(slot: &str, spec: &AlgorithmSpec, id: &str, ver: u32) -> Result<(), String> {
if spec.algorithm_id != id || spec.algorithm_version != ver {
return Err(format!(
"Unsupported AeroVault v3 {slot} algorithm: {} v{} (expected {id} v{ver})",
spec.algorithm_id, spec.algorithm_version
));
}
Ok(())
}
fn validate_supported_wrappers(w: &WrapperManifest) -> Result<(), String> {
check_wrapper("packing", &w.packing, "small-file-batching", 1)?;
check_wrapper("chunking", &w.chunking, "gear-cdc", 1)?;
check_wrapper("chunk_id", &w.chunk_id, "blake3-keyed-128", 1)?;
check_wrapper("compression", &w.compression, "zstd", 1)?;
match w.crypt.algorithm_id.as_str() {
CRYPT_ALGORITHM_ENCRYPTED | CRYPT_ALGORITHM_NONE => {
if w.crypt.algorithm_version != 1 {
return Err(format!(
"Unsupported crypt wrapper version: {}",
w.crypt.algorithm_version
));
}
}
other => return Err(format!("Unsupported crypt wrapper: {other}")),
}
check_wrapper("cipher_hash", &w.cipher_hash, "blake3-256", 1)?;
Ok(())
}
pub(super) fn open_header_bytes(
header_bytes: &[u8],
password: &str,
) -> Result<(VaultHeaderV3, [u8; KEY_SIZE], [u8; KEY_SIZE]), String> {
let header = VaultHeaderV3::from_bytes(header_bytes)?;
let mut base_kek = derive_base_kek(password, &header.salt)?;
let (kek_master, kek_mac) = derive_keks(&base_kek)?;
let kek_master = Zeroizing::new(kek_master);
let kek_mac = Zeroizing::new(kek_mac);
base_kek.zeroize();
let mac_key = unwrap_key(&kek_mac, &header.wrapped_mac_key)?;
header.verify_mac(&mac_key)?;
if header.wrapper_header_version != SUPPORTED_WRAPPER_HEADER_VERSION {
return Err(format!(
"Unsupported AeroVault v3 wrapper-header version: {} (expected {})",
header.wrapper_header_version, SUPPORTED_WRAPPER_HEADER_VERSION
));
}
let master_key = unwrap_key(&kek_master, &header.wrapped_master_key)?;
Ok((header, mac_key, master_key))
}
fn create_empty_vault(
path: &Path,
password: &str,
level: i32,
cdc_bounds: Option<CdcBounds>,
lane: VaultLane,
error_correction: Option<super::ec::RecoveryPlacement>,
error_correction_pct: u32,
) -> Result<(), String> {
let plaintext = lane == VaultLane::Plaintext;
let (salt, wrapped_master_key, wrapped_mac_key, mut master_key, mut mac_key, flags) =
if plaintext {
(
[0u8; SALT_SIZE],
[0u8; WRAPPED_KEY_SIZE],
[0u8; WRAPPED_KEY_SIZE],
[0u8; KEY_SIZE],
aerovz_mac_key()?,
FLAG_PLAINTEXT_CONTENT,
)
} else {
if password.len() < MIN_PASSWORD_LEN {
return Err("Password must be at least 8 characters".to_string());
}
let salt = random_array::<SALT_SIZE>();
let mut base_kek = derive_base_kek(password, &salt)?;
let (kek_master, kek_mac) = derive_keks(&base_kek)?;
let kek_master = Zeroizing::new(kek_master);
let kek_mac = Zeroizing::new(kek_mac);
base_kek.zeroize();
let master_key = random_array::<KEY_SIZE>();
let mac_key = random_array::<KEY_SIZE>();
let wrapped_master_key = wrap_key(&kek_master, &master_key)?;
let wrapped_mac_key = wrap_key(&kek_mac, &mac_key)?;
(
salt,
wrapped_master_key,
wrapped_mac_key,
master_key,
mac_key,
0u8,
)
};
let header = VaultHeaderV3 {
flags,
salt,
wrapped_master_key,
wrapped_mac_key,
data_offset: DATA_OFFSET,
data_len: 0,
manifest_offset: DATA_OFFSET,
manifest_len: 0,
extension_dir_offset: DATA_OFFSET,
extension_dir_len: 0,
extension_payload_offset: DATA_OFFSET,
extension_payload_len: 0,
wrapper_header_version: 1,
header_mac: [0u8; MAC_SIZE],
};
let mut manifest = if plaintext {
empty_manifest_plaintext(level)
} else {
empty_manifest(level)
};
if let Some(bounds) = cdc_bounds {
bounds.validate()?;
manifest.wrappers.chunking.bounds = Some(bounds);
}
if error_correction.is_some() {
manifest.error_correction_pct = Some(error_correction_pct.clamp(
crate::error_correction::ERROR_CORRECTION_MIN_PCT,
crate::error_correction::ERROR_CORRECTION_MAX_PCT,
));
}
let embed = error_correction.is_some_and(|p| p.embeds());
let mut extensions = if embed {
vec![super::ec::error_correction_stub_extension()]
} else {
vec![]
};
let ext_payloads = if embed {
let (p, _shards, _prot, _ov) =
crate::error_correction::compute_error_correction_shards(&[]);
if let Some(e) = extensions.first_mut() {
e.offset = 0;
e.length = p.len() as u64;
}
p
} else {
vec![]
};
let bytes = build_file_bytes(
header,
&mac_key,
&master_key,
&manifest,
&extensions,
&ext_payloads,
&[],
)?;
master_key.zeroize();
mac_key.zeroize();
atomic_write(path, &bytes)?;
if error_correction.is_some_and(|p| p.writes_sidecar()) {
super::ec::seed_empty_sidecar(path, &bytes)?;
}
Ok(())
}
pub(super) fn open_vault(path: impl Into<PathBuf>, password: &str) -> Result<OpenVaultV3, String> {
let path = path.into();
let mut file = std::fs::File::open(&path).map_err(|e| format!("Open vault: {e}"))?;
let file_len = file
.metadata()
.map_err(|e| format!("Vault metadata: {e}"))?
.len();
let mut header_bytes = [0u8; HEADER_SIZE];
file.read_exact(&mut header_bytes)
.map_err(|e| format!("Read header: {e}"))?;
let plaintext_header = VaultHeaderV3::from_bytes(&header_bytes)
.ok()
.is_some_and(|h| h.flags & FLAG_PLAINTEXT_CONTENT != 0);
let (header, mac_key, master_key, header_repaired_on_open) = if plaintext_header {
let header = VaultHeaderV3::from_bytes(&header_bytes)?;
let mac_key = aerovz_mac_key()?;
header.verify_mac(&mac_key)?;
if header.wrapper_header_version != SUPPORTED_WRAPPER_HEADER_VERSION {
return Err(format!(
"Unsupported AeroVault v3 wrapper-header version: {} (expected {})",
header.wrapper_header_version, SUPPORTED_WRAPPER_HEADER_VERSION
));
}
(header, mac_key, [0u8; KEY_SIZE], false)
} else {
match open_header_bytes(&header_bytes, password) {
Ok((h, mac, master)) => (h, mac, master, false),
Err(orig) => {
match super::ec::recover_header_from_sidecar(&path, &header_bytes, password)? {
Some((h, mac, master)) => (h, mac, master, true),
None => return Err(orig),
}
}
}
};
validate_ranges(&header, file_len)?;
let data = read_capped(
&mut file,
header.data_offset,
header.data_len,
file_len,
"data section",
)?;
let encrypted_manifest = read_capped(
&mut file,
header.manifest_offset,
header.manifest_len,
MAX_MANIFEST_SIZE,
"manifest",
)?;
let extension_json = read_capped(
&mut file,
header.extension_dir_offset,
header.extension_dir_len,
MAX_EXTENSION_DIR_SIZE,
"extension directory",
)?;
let extensions: Vec<ExtensionEntryV3> = serde_json::from_slice(&extension_json)
.map_err(|e| format!("Extension directory parse: {e}"))?;
validate_extension_dir(&extensions, header.extension_payload_len)?;
let decode_manifest = |blob: &[u8]| -> Result<VaultManifestV3, String> {
if plaintext_header {
parse_manifest_plaintext(blob)
} else {
decrypt_manifest(&master_key, blob)
}
};
let (manifest, manifest_repaired_on_open) = match decode_manifest(&encrypted_manifest) {
Ok(m) => (m, false),
Err(orig) => {
let embedded = super::ec::reconstruct_encrypted_manifest(&mut file, &header, file_len)?;
let rebuilt = match embedded {
Some(r) if r != encrypted_manifest => Some(r),
_ => super::ec::reconstruct_manifest_from_sidecar(&path, &encrypted_manifest)?,
};
match rebuilt {
Some(r) if r != encrypted_manifest => (decode_manifest(&r)?, true),
_ => return Err(orig),
}
}
};
if manifest.format != VERSION {
return Err(format!(
"Unsupported AeroVault manifest version: {}",
manifest.format
));
}
validate_supported_wrappers(&manifest.wrappers)?;
if plaintext_header != manifest_is_plaintext(&manifest) {
return Err("AeroVault v3 header/manifest encryption-lane mismatch".to_string());
}
validate_manifest_paths(&manifest)?;
let extensions: Vec<ExtensionEntryV3> = extensions
.into_iter()
.filter(|e| e.extension_id != super::constants::ERROR_CORRECTION_META_EXTENSION_ID)
.collect();
Ok(OpenVaultV3 {
path,
opened_file_len: file_len,
opened_header_mac: header.header_mac,
header,
master_key,
mac_key,
manifest,
extensions,
data,
manifest_repaired_on_open,
header_repaired_on_open,
telemetry: None,
})
}
fn validate_ranges(header: &VaultHeaderV3, file_len: u64) -> Result<(), String> {
if header.data_offset != DATA_OFFSET {
return Err("Invalid AeroVault v3 data offset".to_string());
}
let ranges = [
(header.data_offset, header.data_len, "data"),
(header.manifest_offset, header.manifest_len, "manifest"),
(
header.extension_dir_offset,
header.extension_dir_len,
"extension directory",
),
(
header.extension_payload_offset,
header.extension_payload_len,
"extension payload",
),
];
for (offset, len, label) in ranges {
let end = offset
.checked_add(len)
.ok_or_else(|| format!("{label} range overflows"))?;
if end > file_len {
return Err(format!("{label} range exceeds file size"));
}
}
Ok(())
}
fn assert_vault_generation_current(vault: &OpenVaultV3) -> Result<(), String> {
let mut file = std::fs::File::open(&vault.path).map_err(|e| format!("Open vault: {e}"))?;
let file_len = file
.metadata()
.map_err(|e| format!("Vault metadata: {e}"))?
.len();
let mut header_bytes = [0u8; HEADER_SIZE];
file.read_exact(&mut header_bytes)
.map_err(|e| format!("Read header: {e}"))?;
let header = VaultHeaderV3::from_bytes(&header_bytes)?;
if file_len != vault.opened_file_len || header.header_mac != vault.opened_header_mac {
return Err("Vault changed while this write was in progress; retry operation".to_string());
}
Ok(())
}
pub(super) fn save_open_vault(vault: &mut OpenVaultV3) -> Result<(), String> {
assert_vault_generation_current(vault)?;
let mut extensions = vault.extensions.clone();
let mut ext_payloads = vec![];
let mut ec_stats: Option<(u64, u64, f64)> = None;
if let Some(error_correction_idx) = extensions
.iter()
.position(|e| e.extension_id == super::constants::ERROR_CORRECTION_EXTENSION_ID)
{
let mut chunk_records: Vec<_> = vault.manifest.chunks.values().cloned().collect();
chunk_records.sort_by_key(|r| r.data_offset);
let blocks: Vec<&[u8]> = chunk_records
.iter()
.map(|rec| {
let start = rec.data_offset as usize;
let end = (rec.block_len as usize)
.checked_add(8)
.and_then(|full| start.checked_add(full));
match end {
Some(end) if end <= vault.data.len() => &vault.data[start..end],
_ => &[] as &[u8],
}
})
.collect();
let (k, p) = crate::error_correction::manifest_error_correction_grid(
vault.manifest.error_correction_pct,
);
let (payload, shards, protected, overhead) =
crate::error_correction::compute_error_correction_shards_grid(&blocks, k, p);
let entry = &mut extensions[error_correction_idx];
entry.offset = 0;
entry.length = payload.len() as u64;
ext_payloads = payload;
if shards > 0 || protected > 0 {
ec_stats = Some((shards, protected, overhead));
}
}
if let Some((shards, protected, overhead)) = ec_stats {
vault.emit(|s| s.set_error_correction(shards, protected, overhead));
}
{
use std::io::Write;
let parent = output_parent_dir(&vault.path);
std::fs::create_dir_all(parent).map_err(|e| format!("Create parent dir: {e}"))?;
let mut tmp = tempfile::Builder::new()
.prefix(".aerovault-v3-")
.tempfile_in(parent)
.map_err(|e| format!("Create temp file: {e}"))?;
{
let mut w = std::io::BufWriter::new(tmp.as_file_mut());
write_container(
&mut w,
vault.header.clone(),
&vault.mac_key,
&vault.master_key,
&vault.manifest,
&extensions,
&ext_payloads,
&vault.data,
)?;
w.flush().map_err(|e| format!("Flush temp file: {e}"))?;
}
tmp.as_file_mut()
.sync_all()
.map_err(|e| format!("Sync temp file: {e}"))?;
tmp.persist(&vault.path)
.map_err(|e| format!("Persist vault: {}", e.error))?;
fsync_parent_dir(parent);
}
let mut file = std::fs::File::open(&vault.path).map_err(|e| format!("Open vault: {e}"))?;
let file_len = file
.metadata()
.map_err(|e| format!("Vault metadata: {e}"))?
.len();
let mut header_bytes = [0u8; HEADER_SIZE];
file.read_exact(&mut header_bytes)
.map_err(|e| format!("Read header: {e}"))?;
let header = VaultHeaderV3::from_bytes(&header_bytes)?;
vault.opened_file_len = file_len;
vault.opened_header_mac = header.header_mac;
vault.header = header;
Ok(())
}
fn extract_entry(
vault: &OpenVaultV3,
entry_name: &str,
dest_path: &Path,
) -> Result<PathBuf, String> {
let entry_name = normalize_vault_relative_path(entry_name)?;
match entry_kind(&vault.manifest, &entry_name) {
Some(EntryKindV3::File) => {
let entry = vault
.manifest
.entries
.iter()
.find(|entry| entry.path == entry_name)
.ok_or_else(|| format!("Entry not found: {entry_name}"))?;
if dest_path.is_dir() {
let output_path = dest_path.join(&entry.path);
extract_file_entry(vault, entry, &output_path, dest_path)
} else {
let parent = dest_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent).map_err(|e| format!("Create output dir: {e}"))?;
extract_file_entry(vault, entry, dest_path, parent)
}
}
Some(EntryKindV3::Directory) => {
let (root, output_root) = if dest_path.exists() {
if !dest_path.is_dir() {
return Err(
"Destination for directory extraction must be a directory".to_string()
);
}
let basename = path_basename(&entry_name);
create_contained_dirs(dest_path, Path::new(basename))?;
(dest_path.to_path_buf(), dest_path.join(basename))
} else {
std::fs::create_dir_all(dest_path)
.map_err(|e| format!("Create output dir: {e}"))?;
(dest_path.to_path_buf(), dest_path.to_path_buf())
};
let prefix = format!("{entry_name}/");
let mut descendants: Vec<&ManifestEntryV3> = vault
.manifest
.entries
.iter()
.filter(|entry| entry.path == entry_name || entry.path.starts_with(&prefix))
.collect();
descendants.sort_by(|a, b| a.path.cmp(&b.path));
for entry in descendants {
normalize_vault_relative_path(&entry.path)?;
let rel = if entry.path == entry_name {
String::new()
} else {
entry.path[entry_name.len() + 1..].to_string()
};
if !rel.is_empty() {
normalize_vault_relative_path(&rel)?;
}
let child_output = if rel.is_empty() {
output_root.clone()
} else {
output_root.join(&rel)
};
if entry.is_dir {
let rel_from_root = child_output.strip_prefix(&root).map_err(|_| {
format!(
"Refusing extraction: {} escapes destination root {}",
child_output.display(),
root.display()
)
})?;
if !rel_from_root.as_os_str().is_empty() {
create_contained_dirs(&root, rel_from_root)?;
}
} else {
extract_file_entry(vault, entry, &child_output, &root)?;
}
}
Ok(output_root)
}
None => Err(format!("Entry not found: {entry_name}")),
}
}
fn extract_all_entries(vault: &OpenVaultV3, dest_root: &Path) -> Result<u64, String> {
extract_all_entries_with_progress(vault, dest_root, &mut |_, _| {})
}
fn extract_all_entries_with_progress(
vault: &OpenVaultV3,
dest_root: &Path,
progress: &mut dyn FnMut(u64, u64),
) -> Result<u64, String> {
std::fs::create_dir_all(dest_root).map_err(|e| format!("Create output dir: {e}"))?;
let mut entries: Vec<&ManifestEntryV3> = vault.manifest.entries.iter().collect();
entries.sort_by(|a, b| a.path.cmp(&b.path));
let total: u64 = entries.iter().filter(|e| !e.is_dir).map(|e| e.size).sum();
let mut done = 0u64;
let mut files_written = 0u64;
for entry in entries {
let rel = normalize_vault_relative_path(&entry.path)?;
let output = dest_root.join(&rel);
if entry.is_dir {
create_contained_dirs(dest_root, Path::new(&rel))?;
} else {
extract_file_entry(vault, entry, &output, dest_root)?;
files_written += 1;
done = done.saturating_add(entry.size);
progress(done, total);
}
}
Ok(files_written)
}
fn add_directory_into(
vault: &mut OpenVaultV3,
source_dir: &Path,
target_prefix: Option<&str>,
) -> Result<(usize, usize), String> {
let source = source_dir
.canonicalize()
.map_err(|e| format!("Failed to resolve directory: {e}"))?;
if !source.is_dir() {
return Err(format!("Not a directory: {}", source_dir.display()));
}
struct DirEntry {
rel_path: String,
is_dir: bool,
abs_path: PathBuf,
depth: usize,
}
let normalized_prefix = target_prefix
.map(|prefix| prefix.trim_matches('/'))
.filter(|prefix| !prefix.is_empty())
.map(normalize_vault_relative_path)
.transpose()?;
let mut all_entries: Vec<DirEntry> = Vec::new();
for entry in walkdir::WalkDir::new(&source)
.follow_links(false)
.max_depth(100)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.path() == source {
continue;
}
if all_entries.len() >= 500_000 {
return Err("Directory exceeds maximum entry limit (500000)".to_string());
}
let rel_path = entry
.path()
.strip_prefix(&source)
.map_err(|_| "Failed to compute relative path".to_string())?
.to_string_lossy()
.replace('\\', "/");
let full_rel = if let Some(prefix) = &normalized_prefix {
join_vault_path(prefix, &rel_path)
} else {
rel_path
};
let full_rel = normalize_vault_relative_path(&full_rel)?;
all_entries.push(DirEntry {
rel_path: full_rel,
is_dir: entry.file_type().is_dir(),
abs_path: entry.path().to_path_buf(),
depth: entry.depth(),
});
}
let mut dirs: Vec<&DirEntry> = all_entries.iter().filter(|entry| entry.is_dir).collect();
let files: Vec<&DirEntry> = all_entries.iter().filter(|entry| !entry.is_dir).collect();
dirs.sort_by_key(|entry| entry.depth);
let mut added_dirs = 0usize;
for dir_entry in dirs {
if create_directory_in_manifest(&mut vault.manifest, &dir_entry.rel_path)? {
added_dirs += 1;
}
}
let total_files = files.len();
let sources: Vec<(PathBuf, String)> = files
.iter()
.map(|f| (f.abs_path.clone(), f.rel_path.clone()))
.collect();
append_sources_batched(vault, &sources)?;
save_open_vault(vault)?;
Ok((total_files, added_dirs))
}
#[cfg(test)]
mod tests {
use super::*;
const PW: &str = "test-password-123";
fn vault_path() -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("av3-test-{}.aerovault", rand::random::<u64>()));
p
}
fn scratch_dir() -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("av3-scratch-{}", rand::random::<u64>()));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn create_open_wrong_password_and_full_round_trip() {
let vp = vault_path();
VaultV3::create(&CreateOptionsV3::new(&vp, PW)).unwrap();
assert!(VaultV3::is_vault_v3(&vp));
assert!(VaultV3::open(&vp, "wrong-password").is_err());
let info = VaultV3::peek(&vp).unwrap();
assert_eq!(info.version, VERSION);
let src = scratch_dir();
let small1 = src.join("a.txt");
let small2 = src.join("b.txt");
let small3 = src.join("c.txt");
std::fs::write(&small1, b"alpha contents").unwrap();
std::fs::write(&small2, b"beta contents which differ").unwrap();
std::fs::write(&small3, vec![0x5au8; 4096]).unwrap();
let large = src.join("big.bin");
let mut payload = vec![0u8; PACK_SMALL_FILE_THRESHOLD + 600_000];
let mut x = 0x9e3779b97f4a7c15u64;
for b in payload.iter_mut() {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*b = (x & 0xff) as u8;
}
std::fs::write(&large, &payload).unwrap();
let mut vault = VaultV3::open(&vp, PW).unwrap();
VaultV3::create_directory(&mut vault, "docs/sub").unwrap();
VaultV3::add_files(
&mut vault,
&[
(small1.clone(), "a.txt".to_string()),
(small2.clone(), "b.txt".to_string()),
(small3.clone(), "docs/sub/c.txt".to_string()),
(large.clone(), "big.bin".to_string()),
],
)
.unwrap();
let listed = VaultV3::list(&vault);
assert!(listed.iter().any(|e| e.path == "a.txt" && !e.is_dir));
assert!(listed.iter().any(|e| e.path == "docs/sub" && e.is_dir));
assert!(listed
.iter()
.any(|e| e.path == "big.bin" && e.size == payload.len() as u64));
let out = scratch_dir();
let written = VaultV3::extract_all(&vault, &out).unwrap();
assert_eq!(written, 4);
assert_eq!(std::fs::read(out.join("a.txt")).unwrap(), b"alpha contents");
assert_eq!(
std::fs::read(out.join("b.txt")).unwrap(),
b"beta contents which differ"
);
assert_eq!(
std::fs::read(out.join("docs/sub/c.txt")).unwrap(),
vec![0x5au8; 4096]
);
assert_eq!(std::fs::read(out.join("big.bin")).unwrap(), payload);
std::fs::remove_file(&vp).ok();
std::fs::remove_dir_all(&src).ok();
std::fs::remove_dir_all(&out).ok();
}
#[test]
fn streaming_multi_megabyte_round_trip_is_byte_identical() {
let vp = vault_path();
VaultV3::create(&CreateOptionsV3::new(&vp, PW)).unwrap();
let src = scratch_dir();
let mut random = vec![0u8; 12 * 1024 * 1024 + 7];
let mut x = 0xd1b54a32d192ed03u64;
for b in random.iter_mut() {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*b = (x & 0xff) as u8;
}
let rnd_path = src.join("random.bin");
std::fs::write(&rnd_path, &random).unwrap();
let pattern = b"AeroVault v3 streaming round-trip payload block. ";
let mut text = Vec::with_capacity(12 * 1024 * 1024);
while text.len() < 12 * 1024 * 1024 {
text.extend_from_slice(pattern);
}
let txt_path = src.join("text.bin");
std::fs::write(&txt_path, &text).unwrap();
let mut vault = VaultV3::open(&vp, PW).unwrap();
VaultV3::add_files(
&mut vault,
&[
(rnd_path.clone(), "random.bin".to_string()),
(txt_path.clone(), "text.bin".to_string()),
],
)
.unwrap();
let rnd_entry = vault
.manifest
.entries
.iter()
.find(|e| e.path == "random.bin")
.unwrap();
assert!(
rnd_entry.chunks.len() >= 3,
"12 MiB random file should split into multiple CDC chunks, got {}",
rnd_entry.chunks.len()
);
let out = scratch_dir();
VaultV3::extract_all(&vault, &out).unwrap();
assert_eq!(
std::fs::read(out.join("random.bin")).unwrap(),
random,
"incompressible multi-chunk file must round-trip byte-identically"
);
assert_eq!(
std::fs::read(out.join("text.bin")).unwrap(),
text,
"compressible multi-chunk file must round-trip byte-identically"
);
std::fs::remove_file(&vp).ok();
std::fs::remove_dir_all(&src).ok();
std::fs::remove_dir_all(&out).ok();
}
#[test]
fn streaming_packed_small_files_cross_chunk_round_trip() {
let vp = vault_path();
VaultV3::create(&CreateOptionsV3::new(&vp, PW)).unwrap();
let src = scratch_dir();
let mut files: Vec<(PathBuf, String, Vec<u8>)> = Vec::new();
for i in 0..48u64 {
let len = 100 * 1024 + (i as usize); let mut data = vec![0u8; len];
let mut x = 0xa5a5_0000_0000_0001u64.wrapping_add(i.wrapping_mul(0x9e3779b97f4a7c15));
for b in data.iter_mut() {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*b = (x & 0xff) as u8;
}
let name = format!("packed/file_{i:03}.bin");
let path = src.join(format!("file_{i:03}.bin"));
std::fs::write(&path, &data).unwrap();
files.push((path, name, data));
}
let mut vault = VaultV3::open(&vp, PW).unwrap();
let sources: Vec<(PathBuf, String)> = files
.iter()
.map(|(p, n, _)| (p.clone(), n.clone()))
.collect();
VaultV3::add_files(&mut vault, &sources).unwrap();
assert!(
vault.manifest.chunks.len() >= 2,
"packed set should chunk into >= 2 blocks, got {}",
vault.manifest.chunks.len()
);
assert!(
vault.manifest.entries.iter().any(|e| e.chunks.len() >= 2),
"at least one packed file must straddle a chunk boundary"
);
let out = scratch_dir();
VaultV3::extract_all(&vault, &out).unwrap();
for (_, name, data) in &files {
assert_eq!(
&std::fs::read(out.join(name)).unwrap(),
data,
"packed cross-chunk file {name} must round-trip byte-identically"
);
}
std::fs::remove_file(&vp).ok();
std::fs::remove_dir_all(&src).ok();
std::fs::remove_dir_all(&out).ok();
}
#[test]
fn probe_classifies_compressible_and_random() {
let text = b"the quick brown fox jumps over the lazy dog. ".repeat(4096);
assert!(!probe_incompressible(&text));
let mut noise = vec![0u8; 256 * 1024];
let mut x = 0x243f6a8885a308d3u64;
for b in noise.iter_mut() {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*b = (x & 0xff) as u8;
}
assert!(probe_incompressible(&noise));
assert!(!probe_incompressible(&[]));
}
#[test]
fn probe_does_not_let_noisy_prefix_hide_large_compressible_chunk() {
let mut chunk = b"AeroVault representative probe body. ".repeat(80_000);
let mut x = 0x9e3779b97f4a7c15u64;
for b in chunk[..INCOMPRESSIBLE_PROBE_SAMPLE].iter_mut() {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*b = (x & 0xff) as u8;
}
assert!(chunk.len() > INCOMPRESSIBLE_PROBE_MAX_SAMPLE);
assert!(
!probe_incompressible(&chunk),
"a noisy prefix must not force a large compressible chunk to raw"
);
}
#[test]
fn probe_still_stores_large_high_entropy_chunks_raw() {
let mut noise = vec![0u8; INCOMPRESSIBLE_PROBE_MAX_SAMPLE * 2];
let mut x = 0x6a09e667f3bcc909u64;
for b in noise.iter_mut() {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*b = (x & 0xff) as u8;
}
assert!(probe_incompressible(&noise));
}
#[test]
fn incompressible_chunks_store_raw_and_round_trip() {
let vp = vault_path();
VaultV3::create(&CreateOptionsV3::new(&vp, PW)).unwrap();
let src = scratch_dir();
let text = src.join("text.txt");
let text_payload = b"AeroVault incompressible-skip round trip. ".repeat(12_000);
std::fs::write(&text, &text_payload).unwrap();
let noise = src.join("noise.bin");
let mut noise_payload = vec![0u8; PACK_SMALL_FILE_THRESHOLD + 500_000];
let mut x = 0xdeadbeefcafef00du64;
for b in noise_payload.iter_mut() {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*b = (x & 0xff) as u8;
}
std::fs::write(&noise, &noise_payload).unwrap();
let mut vault = VaultV3::open(&vp, PW).unwrap();
VaultV3::add_files(
&mut vault,
&[
(text.clone(), "text.txt".to_string()),
(noise.clone(), "noise.bin".to_string()),
],
)
.unwrap();
let any_raw = vault.manifest.chunks.values().any(|c| c.stored_raw);
let any_compressed = vault.manifest.chunks.values().any(|c| !c.stored_raw);
assert!(
any_raw,
"incompressible file should store at least one raw chunk"
);
assert!(
any_compressed,
"compressible file should store at least one zstd chunk"
);
for c in vault.manifest.chunks.values().filter(|c| c.stored_raw) {
assert_eq!(c.compressed_len, c.plaintext_len);
}
let out = scratch_dir();
VaultV3::extract_all(&vault, &out).unwrap();
assert_eq!(std::fs::read(out.join("text.txt")).unwrap(), text_payload);
assert_eq!(std::fs::read(out.join("noise.bin")).unwrap(), noise_payload);
std::fs::remove_file(&vp).ok();
std::fs::remove_dir_all(&src).ok();
std::fs::remove_dir_all(&out).ok();
}
#[test]
fn plaintext_lane_round_trip_and_unencrypted_storage() {
let vp = vault_path();
VaultV3::create(&CreateOptionsV3::new_plaintext(&vp)).unwrap();
assert!(VaultV3::is_vault_v3(&vp));
let src = scratch_dir();
let small = src.join("note.txt");
std::fs::write(&small, b"plaintext archive note").unwrap();
let big = src.join("big.txt");
let big_payload = b"Aerovz plaintext compressible body. ".repeat(20_000);
std::fs::write(&big, &big_payload).unwrap();
let mut vault = VaultV3::open_plaintext(&vp).unwrap();
assert!(vault.header.flags & FLAG_PLAINTEXT_CONTENT != 0);
assert!(manifest_is_plaintext(&vault.manifest));
VaultV3::add_files(
&mut vault,
&[
(small.clone(), "note.txt".to_string()),
(big.clone(), "big.txt".to_string()),
],
)
.unwrap();
drop(vault);
let vault = VaultV3::open_plaintext(&vp).unwrap();
let out = scratch_dir();
VaultV3::extract_all(&vault, &out).unwrap();
assert_eq!(
std::fs::read(out.join("note.txt")).unwrap(),
b"plaintext archive note"
);
assert_eq!(std::fs::read(out.join("big.txt")).unwrap(), big_payload);
let raw = std::fs::read(&vp).unwrap();
let big_entry = vault
.manifest
.entries
.iter()
.find(|e| e.path == "big.txt")
.unwrap();
let first_id = big_entry.chunks.first().unwrap();
let rec = vault.manifest.chunks.get(first_id).unwrap();
let start = DATA_OFFSET as usize + rec.data_offset as usize + 8;
let block = &raw[start..start + rec.block_len as usize];
let decoded = zstd::stream::decode_all(block).unwrap();
assert!(
big_payload.starts_with(&decoded),
"plaintext-lane block must zstd-decode off disk with no key"
);
let mut vault = VaultV3::open_plaintext(&vp).unwrap();
assert!(VaultV3::change_password(&mut vault, "irrelevant-pw-123").is_err());
std::fs::remove_file(&vp).ok();
std::fs::remove_dir_all(&src).ok();
std::fs::remove_dir_all(&out).ok();
}
#[test]
fn create_options_decouple_zstd_level_from_cdc_bounds() {
let vp = vault_path();
let opts = CreateOptionsV3::new_plaintext(&vp)
.with_zstd_level(19)
.with_cdc_bounds(CdcBounds::defaults());
VaultV3::create(&opts).unwrap();
let vault = VaultV3::open_plaintext(&vp).unwrap();
let bounds = manifest_cdc_bounds(&vault.manifest).unwrap();
let defaults = CdcBounds::defaults();
assert_eq!(manifest_zstd_level(&vault.manifest), 19);
assert_eq!(bounds.min, defaults.min);
assert_eq!(bounds.avg, defaults.avg);
assert_eq!(bounds.max, defaults.max);
std::fs::remove_file(&vp).ok();
}
#[test]
fn plaintext_lane_stored_raw_composition() {
let vp = vault_path();
VaultV3::create(&CreateOptionsV3::new_plaintext(&vp)).unwrap();
let src = scratch_dir();
let noise = src.join("noise.bin");
let mut noise_payload = vec![0u8; PACK_SMALL_FILE_THRESHOLD + 400_000];
let mut x = 0x1234_5678_9abc_def0u64;
for b in noise_payload.iter_mut() {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*b = (x & 0xff) as u8;
}
std::fs::write(&noise, &noise_payload).unwrap();
let mut vault = VaultV3::open_plaintext(&vp).unwrap();
VaultV3::add_files(&mut vault, &[(noise.clone(), "noise.bin".to_string())]).unwrap();
assert!(
vault.manifest.chunks.values().any(|c| c.stored_raw),
"incompressible input should store raw chunks"
);
let out = scratch_dir();
VaultV3::extract_all(&vault, &out).unwrap();
assert_eq!(std::fs::read(out.join("noise.bin")).unwrap(), noise_payload);
std::fs::remove_file(&vp).ok();
std::fs::remove_dir_all(&src).ok();
std::fs::remove_dir_all(&out).ok();
}
#[test]
#[cfg(feature = "test-vectors")]
fn plaintext_empty_archive_is_deterministic() {
let vp = vault_path();
VaultV3::create(&CreateOptionsV3::new_plaintext(&vp)).unwrap();
let bytes = std::fs::read(&vp).unwrap();
let digest = blake3::hash(&bytes).to_hex().to_string();
let vp2 = vault_path();
VaultV3::create(&CreateOptionsV3::new_plaintext(&vp2)).unwrap();
assert_eq!(
digest,
blake3::hash(&std::fs::read(&vp2).unwrap())
.to_hex()
.to_string(),
"empty plaintext archive must be byte-deterministic"
);
std::fs::remove_file(&vp).ok();
std::fs::remove_file(&vp2).ok();
}
#[derive(Default)]
struct CountingSink {
chunks_new: u64,
chunks_dedup: u64,
files_packed: u64,
files_unpacked: u64,
packs: u64,
cdc_set: bool,
steps: Vec<String>,
plaintext: u64,
}
impl super::super::telemetry::VaultTelemetrySink
for std::sync::Arc<std::sync::Mutex<CountingSink>>
{
fn on_chunk(&mut self, is_new: bool, plaintext: u64, _c: u64, _e: u64) {
let mut g = self.lock().unwrap();
if is_new {
g.chunks_new += 1;
} else {
g.chunks_dedup += 1;
}
g.plaintext += plaintext;
}
fn on_file(&mut self, packed: bool) {
let mut g = self.lock().unwrap();
if packed {
g.files_packed += 1;
} else {
g.files_unpacked += 1;
}
}
fn on_pack(&mut self) {
self.lock().unwrap().packs += 1;
}
fn set_cdc(&mut self, _min: usize, _avg: usize, _max: usize) {
self.lock().unwrap().cdc_set = true;
}
fn step(&mut self, message: &str) {
self.lock().unwrap().steps.push(message.to_string());
}
}
#[test]
fn telemetry_sink_receives_content_pipeline_events() {
let vp = vault_path();
VaultV3::create(&CreateOptionsV3::new(&vp, PW)).unwrap();
let src = scratch_dir();
std::fs::write(src.join("s1.txt"), b"small one").unwrap();
std::fs::write(src.join("s2.txt"), b"small two differs").unwrap();
let large = src.join("big.bin");
let mut payload = vec![0u8; PACK_SMALL_FILE_THRESHOLD + 200_000];
let mut x = 0xfeed_face_dead_beefu64;
for b in payload.iter_mut() {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*b = (x & 0xff) as u8;
}
std::fs::write(&large, &payload).unwrap();
let sink = std::sync::Arc::new(std::sync::Mutex::new(CountingSink::default()));
let mut vault = VaultV3::open(&vp, PW).unwrap();
vault.set_telemetry_sink(Box::new(sink.clone()));
VaultV3::add_files(
&mut vault,
&[
(src.join("s1.txt"), "s1.txt".to_string()),
(src.join("s2.txt"), "s2.txt".to_string()),
(large.clone(), "big.bin".to_string()),
],
)
.unwrap();
drop(vault);
let g = sink.lock().unwrap();
assert!(g.cdc_set, "CDC bounds reported");
assert_eq!(g.packs, 1, "one pack for the two small files");
assert_eq!(g.files_packed, 2, "two small files via the packed path");
assert_eq!(g.files_unpacked, 1, "one large file via the per-file path");
assert!(
g.chunks_new >= 2,
"at least the pack chunk + a large-file chunk"
);
assert!(
g.steps.iter().any(|s| s.starts_with("scan:"))
&& g.steps.iter().any(|s| s.starts_with("partition:"))
&& g.steps.iter().any(|s| s.starts_with("pack:")),
"scan / partition / pack step lines present: {:?}",
g.steps
);
assert!(g.plaintext > 0);
std::fs::remove_file(&vp).ok();
std::fs::remove_dir_all(&src).ok();
}
#[test]
fn dedup_same_content_stored_once() {
let vp = vault_path();
VaultV3::create(&CreateOptionsV3::new(&vp, PW)).unwrap();
let src = scratch_dir();
let mut payload = vec![0u8; PACK_SMALL_FILE_THRESHOLD + 300_000];
let mut x = 0x1234_5678_9abc_def0u64;
for b in payload.iter_mut() {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*b = (x & 0xff) as u8;
}
let f1 = src.join("one.bin");
let f2 = src.join("two.bin");
std::fs::write(&f1, &payload).unwrap();
std::fs::write(&f2, &payload).unwrap();
let mut vault = VaultV3::open(&vp, PW).unwrap();
VaultV3::add_files(
&mut vault,
&[
(f1.clone(), "one.bin".to_string()),
(f2.clone(), "two.bin".to_string()),
],
)
.unwrap();
let entries = &vault.manifest.entries;
let total_refs: usize = entries.iter().map(|e| e.chunks.len()).sum();
let stored = vault.manifest.chunks.len();
assert!(total_refs > stored, "dedup must collapse identical content");
let c1: Vec<&String> = entries
.iter()
.find(|e| e.path == "one.bin")
.unwrap()
.chunks
.iter()
.collect();
let c2: Vec<&String> = entries
.iter()
.find(|e| e.path == "two.bin")
.unwrap()
.chunks
.iter()
.collect();
assert_eq!(c1, c2);
std::fs::remove_file(&vp).ok();
std::fs::remove_dir_all(&src).ok();
}
#[test]
fn copy_reuses_chunks_move_rename_delete() {
let vp = vault_path();
VaultV3::create(&CreateOptionsV3::new(&vp, PW)).unwrap();
let src = scratch_dir();
std::fs::write(src.join("doc.txt"), b"hello world copy").unwrap();
let mut vault = VaultV3::open(&vp, PW).unwrap();
VaultV3::create_directory(&mut vault, "src").unwrap();
VaultV3::add_files(
&mut vault,
&[(src.join("doc.txt"), "src/doc.txt".to_string())],
)
.unwrap();
let chunks_before = vault.manifest.chunks.len();
VaultV3::copy_entry(&mut vault, "src/doc.txt", "src/doc-copy.txt").unwrap();
assert_eq!(vault.manifest.chunks.len(), chunks_before);
assert!(VaultV3::list(&vault)
.iter()
.any(|e| e.path == "src/doc-copy.txt"));
VaultV3::move_entry(&mut vault, "src", "moved").unwrap();
let paths: Vec<String> = VaultV3::list(&vault).into_iter().map(|e| e.path).collect();
assert!(paths.contains(&"moved".to_string()));
assert!(paths.contains(&"moved/doc.txt".to_string()));
assert!(!paths.iter().any(|p| p.starts_with("src")));
VaultV3::rename_entry(&mut vault, "moved/doc.txt", "renamed.txt").unwrap();
assert!(VaultV3::list(&vault)
.iter()
.any(|e| e.path == "moved/renamed.txt"));
VaultV3::delete_entries(&mut vault, &["moved".to_string()], true).unwrap();
assert!(VaultV3::list(&vault).is_empty());
drop(vault);
let reopened = VaultV3::open(&vp, PW).unwrap();
assert!(VaultV3::list(&reopened).is_empty());
std::fs::remove_file(&vp).ok();
std::fs::remove_dir_all(&src).ok();
}
#[test]
fn change_password_old_fails_new_opens() {
let vp = vault_path();
VaultV3::create(&CreateOptionsV3::new(&vp, "old-password-123")).unwrap();
let src = scratch_dir();
std::fs::write(src.join("x.txt"), b"keep me").unwrap();
let mut vault = VaultV3::open(&vp, "old-password-123").unwrap();
VaultV3::add_files(&mut vault, &[(src.join("x.txt"), "x.txt".to_string())]).unwrap();
VaultV3::change_password(&mut vault, "new-password-456").unwrap();
drop(vault);
assert!(VaultV3::open(&vp, "old-password-123").is_err());
let v2 = VaultV3::open(&vp, "new-password-456").unwrap();
assert!(VaultV3::list(&v2).iter().any(|e| e.path == "x.txt"));
std::fs::remove_file(&vp).ok();
std::fs::remove_dir_all(&src).ok();
}
#[test]
fn add_directory_recursive_round_trip() {
let vp = vault_path();
VaultV3::create(&CreateOptionsV3::new(&vp, PW)).unwrap();
let tree = scratch_dir();
std::fs::create_dir_all(tree.join("nested/deep")).unwrap();
std::fs::write(tree.join("top.txt"), b"top file").unwrap();
std::fs::write(tree.join("nested/mid.txt"), b"mid file").unwrap();
std::fs::write(tree.join("nested/deep/bottom.txt"), b"bottom file").unwrap();
let mut vault = VaultV3::open(&vp, PW).unwrap();
let (files, _dirs) = VaultV3::add_directory(&mut vault, &tree, Some("imported")).unwrap();
assert_eq!(files, 3);
let out = scratch_dir();
VaultV3::extract_all(&vault, &out).unwrap();
assert_eq!(
std::fs::read(out.join("imported/top.txt")).unwrap(),
b"top file"
);
assert_eq!(
std::fs::read(out.join("imported/nested/deep/bottom.txt")).unwrap(),
b"bottom file"
);
std::fs::remove_file(&vp).ok();
std::fs::remove_dir_all(&tree).ok();
std::fs::remove_dir_all(&out).ok();
}
#[test]
fn path_normalization_rejects_traversal() {
assert!(normalize_vault_relative_path("..").is_err());
assert!(normalize_vault_relative_path("../etc/passwd").is_err());
assert!(normalize_vault_relative_path("a/../b").is_err());
assert!(normalize_vault_relative_path("a/./b").is_err());
assert!(normalize_vault_relative_path("a\\b").is_err());
assert!(normalize_vault_relative_path("C:\\x").is_err());
assert!(normalize_vault_relative_path("a/b/c").is_ok());
assert_eq!(
normalize_vault_relative_path("/etc/passwd").unwrap(),
"etc/passwd"
);
}
#[test]
fn extract_rejects_crafted_traversal_entry() {
let mut manifest = empty_manifest(DEFAULT_ZSTD_LEVEL);
manifest.entries.push(ManifestEntryV3 {
path: "../escape.txt".to_string(),
size: 0,
modified: now_iso(),
is_dir: false,
chunks: Vec::new(),
pack_offset: None,
});
let vault = OpenVaultV3 {
path: PathBuf::from("/tmp/none.aerovault"),
header: VaultHeaderV3 {
flags: 0,
salt: [0u8; SALT_SIZE],
wrapped_master_key: [0u8; crate::aerocrypt::WRAPPED_KEY_SIZE],
wrapped_mac_key: [0u8; crate::aerocrypt::WRAPPED_KEY_SIZE],
data_offset: DATA_OFFSET,
data_len: 0,
manifest_offset: DATA_OFFSET,
manifest_len: 0,
extension_dir_offset: DATA_OFFSET,
extension_dir_len: 0,
extension_payload_offset: DATA_OFFSET,
extension_payload_len: 0,
wrapper_header_version: 1,
header_mac: [0u8; MAC_SIZE],
},
opened_file_len: 0,
opened_header_mac: [0u8; MAC_SIZE],
master_key: [0u8; KEY_SIZE],
mac_key: [0u8; KEY_SIZE],
manifest,
extensions: Vec::new(),
data: Vec::new(),
manifest_repaired_on_open: false,
header_repaired_on_open: false,
telemetry: None,
};
let out = scratch_dir();
assert!(VaultV3::extract_entry(&vault, "../escape.txt", &out).is_err());
std::fs::remove_dir_all(&out).ok();
}
#[test]
fn extract_refuses_planted_reparse_point_parent() {
let vp = vault_path();
VaultV3::create(&CreateOptionsV3::new(&vp, PW)).unwrap();
let src = scratch_dir();
let secret = src.join("secret.txt");
let secret_bytes = b"TOP SECRET PLAINTEXT THAT MUST STAY CONTAINED";
std::fs::write(&secret, secret_bytes).unwrap();
let mut vault = VaultV3::open(&vp, PW).unwrap();
VaultV3::add_files(
&mut vault,
&[(secret.clone(), "sub/secret.txt".to_string())],
)
.unwrap();
let dest = scratch_dir();
let victim = scratch_dir();
let link = dest.join("sub");
let planted = plant_dir_reparse_point(&link, &victim);
if !planted {
eprintln!("skipping extract_refuses_planted_reparse_point_parent: no reparse support");
std::fs::remove_dir_all(&dest).ok();
std::fs::remove_dir_all(&victim).ok();
std::fs::remove_file(&vp).ok();
return;
}
let result = VaultV3::extract_all(&vault, &dest);
assert!(
result.is_err(),
"extract must fail closed on a planted reparse-point parent, got {result:?}"
);
assert!(
!victim.join("secret.txt").exists(),
"plaintext escaped the destination root into the victim directory"
);
std::fs::remove_dir_all(&dest).ok();
std::fs::remove_dir_all(&victim).ok();
std::fs::remove_dir_all(&src).ok();
std::fs::remove_file(&vp).ok();
}
#[test]
fn validate_extension_dir_rejects_forged_entries() {
let ok = ExtensionEntryV3 {
extension_id: "error-correction.reed-solomon".to_string(),
algorithm_id: "rs".to_string(),
algorithm_version: 1,
critical: false,
offset: 0,
length: 100,
};
assert!(validate_extension_dir(std::slice::from_ref(&ok), 100).is_ok());
let mut oob = ok.clone();
oob.length = 101;
assert!(validate_extension_dir(std::slice::from_ref(&oob), 100).is_err());
let mut overflow = ok.clone();
overflow.offset = u64::MAX;
overflow.length = 1;
assert!(validate_extension_dir(std::slice::from_ref(&overflow), 100).is_err());
assert!(validate_extension_dir(&[ok.clone(), ok.clone()], 1000).is_err());
let mut critical = ok.clone();
critical.critical = true;
assert!(validate_extension_dir(std::slice::from_ref(&critical), 100).is_err());
}
#[test]
fn extract_entry_dir_refuses_planted_output_root_junction() {
let vp = vault_path();
VaultV3::create(&CreateOptionsV3::new(&vp, PW)).unwrap();
let src = scratch_dir();
let secret = src.join("secret.txt");
std::fs::write(&secret, b"TOP SECRET PLAINTEXT THAT MUST STAY CONTAINED").unwrap();
let mut vault = VaultV3::open(&vp, PW).unwrap();
VaultV3::add_files(
&mut vault,
&[(secret.clone(), "sub/secret.txt".to_string())],
)
.unwrap();
let dest = scratch_dir(); let victim = scratch_dir();
let link = dest.join("sub"); if !plant_dir_reparse_point(&link, &victim) {
eprintln!("skipping extract_entry_dir_refuses_planted_output_root_junction: no reparse support");
std::fs::remove_dir_all(&dest).ok();
std::fs::remove_dir_all(&victim).ok();
std::fs::remove_file(&vp).ok();
return;
}
let result = VaultV3::extract_entry(&vault, "sub", &dest);
assert!(
result.is_err(),
"extract_entry must fail closed on a planted output_root junction, got {result:?}"
);
assert!(
!victim.join("secret.txt").exists(),
"plaintext escaped into the victim via extract_entry"
);
std::fs::remove_dir_all(&dest).ok();
std::fs::remove_dir_all(&victim).ok();
std::fs::remove_dir_all(&src).ok();
std::fs::remove_file(&vp).ok();
}
#[cfg(windows)]
fn plant_dir_reparse_point(link: &Path, target: &Path) -> bool {
let status = std::process::Command::new("cmd")
.args(["/c", "mklink", "/J"])
.arg(link)
.arg(target)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
matches!(status, Ok(s) if s.success()) && link.exists()
}
#[cfg(not(windows))]
fn plant_dir_reparse_point(link: &Path, target: &Path) -> bool {
std::os::unix::fs::symlink(target, link).is_ok() && link.exists()
}
}