use std::fs::{File, OpenOptions};
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use rand::RngCore;
use secrecy::{ExposeSecret, SecretString, SecretVec};
use zeroize::Zeroize;
use crate::constants::*;
use crate::crypto;
use crate::error::{CryptoError, Error, FormatError};
use crate::format::*;
const MIN_CHUNK_SIZE: u32 = 4 * 1024;
const MAX_CHUNK_SIZE: u32 = 16 * 1024 * 1024;
pub struct CreateOptions {
path: PathBuf,
password: SecretString,
mode: EncryptionMode,
chunk_size: u32,
version: u8,
}
impl CreateOptions {
pub fn new(path: impl Into<PathBuf>, password: impl Into<String>) -> Self {
Self {
path: path.into(),
password: SecretString::from(password.into()),
mode: EncryptionMode::Standard,
chunk_size: DEFAULT_CHUNK_SIZE,
version: VERSION,
}
}
pub fn with_version(mut self, version: u8) -> Self {
self.version = version;
self
}
pub fn with_mode(mut self, mode: EncryptionMode) -> Self {
self.mode = mode;
self
}
pub fn with_chunk_size(mut self, size: u32) -> Self {
self.chunk_size = size;
self
}
}
#[derive(Debug, Clone)]
pub struct EntryInfo {
pub name: String,
pub size: u64,
pub is_dir: bool,
pub modified: String,
}
pub struct Vault {
path: PathBuf,
header: VaultHeader,
master_key: SecretVec<u8>,
mac_key: SecretVec<u8>,
}
impl Vault {
pub fn create(opts: CreateOptions) -> crate::Result<Self> {
if opts.password.expose_secret().len() < MIN_PASSWORD_LENGTH {
return Err(crate::Error::PasswordPolicy(format!(
"password must be at least {MIN_PASSWORD_LENGTH} characters"
)));
}
if opts.chunk_size < MIN_CHUNK_SIZE || opts.chunk_size > MAX_CHUNK_SIZE {
return Err(FormatError::InvalidChunkSize(opts.chunk_size).into());
}
if opts.version != VERSION && opts.version != LEGACY_VERSION {
return Err(FormatError::UnsupportedVersion(opts.version).into());
}
let mut salt = [0u8; SALT_SIZE];
rand::rngs::OsRng.fill_bytes(&mut salt);
let base_kek = crypto::derive_key(&opts.password, &salt)?;
let (kek_master, kek_mac) = crypto::derive_kek_pair(base_kek.expose_secret());
let mut master_key_raw = [0u8; KEY_SIZE];
let mut mac_key_raw = [0u8; KEY_SIZE];
rand::rngs::OsRng.fill_bytes(&mut master_key_raw);
rand::rngs::OsRng.fill_bytes(&mut mac_key_raw);
let wrapped_master = crypto::wrap_key(&kek_master, &master_key_raw)?;
let wrapped_mac = crypto::wrap_key(&kek_mac, &mac_key_raw)?;
let flags = HeaderFlags {
cascade_mode: opts.mode == EncryptionMode::Cascade,
};
let mut header = VaultHeader {
magic: *MAGIC,
version: opts.version,
flags,
salt,
wrapped_master_key: wrapped_master,
wrapped_mac_key: wrapped_mac,
chunk_size: opts.chunk_size,
reserved: [0u8; 320],
header_mac: [0u8; MAC_SIZE],
};
header.header_mac = header.compute_mac(&mac_key_raw);
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let manifest = VaultManifest {
created: now.clone(),
modified: now,
description: None,
entries: Vec::new(),
};
let manifest_json =
serde_json::to_string(&manifest).map_err(|e| crate::Error::Manifest(e.to_string()))?;
let encrypted_manifest = crypto::encrypt_filename(&master_key_raw, &manifest_json)?;
let manifest_bytes = encrypted_manifest.as_bytes();
let tmp_path = format!("{}.create.tmp", opts.path.display());
let file = File::create(&tmp_path)?;
let mut writer = BufWriter::new(file);
writer.write_all(&header.to_bytes())?;
writer.write_all(&(manifest_bytes.len() as u32).to_le_bytes())?;
writer.write_all(manifest_bytes)?;
writer.flush()?;
writer.get_ref().sync_all()?;
drop(writer);
atomic_rename(&tmp_path, &opts.path)?;
let master_key = SecretVec::new(master_key_raw.to_vec());
let mac_key = SecretVec::new(mac_key_raw.to_vec());
master_key_raw.zeroize();
mac_key_raw.zeroize();
Ok(Self {
path: opts.path,
header,
master_key,
mac_key,
})
}
pub fn open(path: impl Into<PathBuf>, password: impl Into<String>) -> crate::Result<Self> {
let path = path.into();
let pwd = SecretString::from(password.into());
let file = File::open(&path)?;
let mut reader = BufReader::new(file);
let mut header_buf = [0u8; HEADER_SIZE];
reader.read_exact(&mut header_buf)?;
let header = VaultHeader::from_bytes(&header_buf)?;
let base_kek = crypto::derive_key(&pwd, &header.salt)?;
let (kek_master, kek_mac) = crypto::derive_kek_pair(base_kek.expose_secret());
let mac_key = crypto::unwrap_key(&kek_mac, &header.wrapped_mac_key)?;
header.verify_mac(mac_key.expose_secret())?;
let master_key = crypto::unwrap_key(&kek_master, &header.wrapped_master_key)?;
Ok(Self {
path,
header,
master_key,
mac_key,
})
}
pub fn is_vault(path: impl AsRef<Path>) -> bool {
let Ok(file) = File::open(path.as_ref()) else {
return false;
};
let mut buf = [0u8; 11];
let mut reader = BufReader::new(file);
if reader.read_exact(&mut buf).is_err() {
return false;
}
&buf[..10] == MAGIC && (buf[10] == VERSION || buf[10] == LEGACY_VERSION)
}
pub fn peek(path: impl AsRef<Path>) -> crate::Result<PeekInfo> {
let file = File::open(path.as_ref())?;
let mut reader = BufReader::new(file);
let mut header_buf = [0u8; HEADER_SIZE];
reader.read_exact(&mut header_buf)?;
let header = VaultHeader::from_bytes(&header_buf)?;
let mode = if header.flags.cascade_mode {
EncryptionMode::Cascade
} else {
EncryptionMode::Standard
};
Ok(PeekInfo {
version: header.version,
mode,
chunk_size: header.chunk_size,
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn mode(&self) -> EncryptionMode {
if self.header.flags.cascade_mode {
EncryptionMode::Cascade
} else {
EncryptionMode::Standard
}
}
pub fn chunk_size(&self) -> u32 {
self.header.chunk_size
}
pub fn security_info(&self) -> SecurityInfo {
SecurityInfo {
version: self.header.version,
mode: self.mode(),
chunk_size: self.header.chunk_size,
argon2_m_cost_kib: ARGON2_M_COST,
argon2_t_cost: ARGON2_T_COST,
argon2_p_cost: ARGON2_P_COST,
}
}
pub fn list(&self) -> crate::Result<Vec<EntryInfo>> {
let manifest = self.read_manifest()?;
let mut entries = Vec::with_capacity(manifest.entries.len());
for entry in &manifest.entries {
let name =
crypto::decrypt_filename(self.master_key.expose_secret(), &entry.encrypted_name)?;
entries.push(EntryInfo {
name,
size: entry.size,
is_dir: entry.is_dir,
modified: entry.modified.clone(),
});
}
Ok(entries)
}
pub fn add_files(&self, file_paths: &[impl AsRef<Path>]) -> crate::Result<u32> {
self.add_files_to_dir(file_paths, "")
}
pub fn add_files_to_dir(
&self,
file_paths: &[impl AsRef<Path>],
target_dir: &str,
) -> crate::Result<u32> {
let target_dir = target_dir.trim().trim_matches('/');
if target_dir.contains("..") {
return Err(crate::Error::InvalidPath(
"directory path cannot contain '..'".into(),
));
}
let cascade_mode = self.header.flags.cascade_mode;
let chunk_size = self.header.chunk_size as usize;
let bind_chunks = self.header.version >= VERSION;
let file = File::open(&self.path)?;
let mut reader = BufReader::new(file);
let mut header_buf = [0u8; HEADER_SIZE];
reader.read_exact(&mut header_buf)?;
let (_manifest_len, manifest_encrypted) = crypto::read_manifest_bounded(&mut reader)?;
let manifest_str =
std::str::from_utf8(&manifest_encrypted).map_err(|_| CryptoError::ManifestEncoding)?;
let manifest_json =
crypto::decrypt_filename(self.master_key.expose_secret(), manifest_str)?;
let mut manifest: VaultManifest = serde_json::from_str(&manifest_json)
.map_err(|e| crate::Error::Manifest(e.to_string()))?;
if !target_dir.is_empty() {
let target_encrypted =
crypto::encrypt_filename(self.master_key.expose_secret(), target_dir)?;
let dir_exists = manifest
.entries
.iter()
.any(|e| e.encrypted_name == target_encrypted && e.is_dir);
if !dir_exists {
return Err(crate::Error::EntryNotFound(format!(
"target directory '{target_dir}'"
)));
}
}
let mut existing_data = Vec::new();
reader.read_to_end(&mut existing_data)?;
let chacha_key = if cascade_mode {
crypto::derive_chacha_key(self.master_key.expose_secret())
} else {
zeroize::Zeroizing::new([0u8; KEY_SIZE])
};
let mut data_offset = existing_data.len() as u64;
let mut new_data = Vec::new();
let mut added_count = 0u32;
for file_path in file_paths {
let file_path = file_path.as_ref();
let source = File::open(file_path)?;
let metadata = source.metadata()?;
let filename = file_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| crate::Error::InvalidPath(format!("{}", file_path.display())))?;
let vault_name = if target_dir.is_empty() {
filename.to_string()
} else {
format!("{target_dir}/{filename}")
};
let encrypted_name =
crypto::encrypt_filename(self.master_key.expose_secret(), &vault_name)?;
if manifest
.entries
.iter()
.any(|e| e.encrypted_name == encrypted_name)
{
continue;
}
let mut source_reader = BufReader::new(source);
let mut chunk_count = 0u32;
let chunk_total = if metadata.len() == 0 {
0
} else {
metadata
.len()
.div_ceil(chunk_size as u64)
.try_into()
.map_err(|_| crate::Error::Manifest("chunk count overflow".into()))?
};
let file_id = if bind_chunks {
let mut id = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut id);
Some(id)
} else {
None
};
let entry_offset = data_offset;
loop {
let mut chunk = vec![0u8; chunk_size];
let bytes_read = source_reader.read(&mut chunk)?;
if bytes_read == 0 {
break;
}
chunk.truncate(bytes_read);
let encrypted_chunk = if cascade_mode {
crypto::encrypt_chunk_cascade_bound(
self.master_key.expose_secret(),
&chacha_key,
&chunk,
chunk_count,
file_id.as_ref(),
chunk_total,
)?
} else {
crypto::encrypt_chunk_bound(
self.master_key.expose_secret(),
&chunk,
chunk_count,
file_id.as_ref(),
chunk_total,
)?
};
let chunk_len = encrypted_chunk.len() as u32;
new_data.extend_from_slice(&chunk_len.to_le_bytes());
new_data.extend_from_slice(&encrypted_chunk);
data_offset += 4 + encrypted_chunk.len() as u64;
chunk_count = chunk_count
.checked_add(1)
.ok_or_else(|| crate::Error::Manifest("chunk count overflow".into()))?;
chunk.zeroize();
}
if bind_chunks && chunk_count != chunk_total {
return Err(crate::Error::Manifest(format!(
"source changed size during add (bound {chunk_total} chunks, streamed {chunk_count}); aborting to avoid an unrecoverable entry"
)));
}
let modified = metadata
.modified()
.map(|t| {
let datetime: chrono::DateTime<chrono::Utc> = t.into();
datetime.format("%Y-%m-%dT%H:%M:%SZ").to_string()
})
.unwrap_or_else(|_| chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string());
manifest.entries.push(ManifestEntry {
encrypted_name,
name: String::new(),
size: metadata.len(),
offset: entry_offset,
chunk_count,
file_id,
is_dir: false,
modified,
});
added_count += 1;
}
if added_count > 0 {
manifest.modified = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
self.write_vault_atomic(&header_buf, &manifest, &existing_data, &new_data)?;
}
Ok(added_count)
}
pub fn create_directory(&self, dir_name: &str) -> crate::Result<u32> {
let dir_name = dir_name.trim().trim_matches('/');
if dir_name.is_empty() {
return Err(crate::Error::InvalidPath(
"directory name cannot be empty".into(),
));
}
if dir_name.contains("..") {
return Err(crate::Error::InvalidPath(
"directory name cannot contain '..'".into(),
));
}
if dir_name.len() > 4096 {
return Err(crate::Error::InvalidPath("directory name too long".into()));
}
let file = File::open(&self.path)?;
let mut reader = BufReader::new(file);
let mut header_buf = [0u8; HEADER_SIZE];
reader.read_exact(&mut header_buf)?;
let (_manifest_len, manifest_encrypted) = crypto::read_manifest_bounded(&mut reader)?;
let manifest_str =
std::str::from_utf8(&manifest_encrypted).map_err(|_| CryptoError::ManifestEncoding)?;
let manifest_json =
crypto::decrypt_filename(self.master_key.expose_secret(), manifest_str)?;
let mut manifest: VaultManifest = serde_json::from_str(&manifest_json)
.map_err(|e| crate::Error::Manifest(e.to_string()))?;
let mut existing_data = Vec::new();
reader.read_to_end(&mut existing_data)?;
let mut dirs_to_create = Vec::new();
let parts: Vec<&str> = dir_name.split('/').collect();
for i in 1..=parts.len() {
let partial = parts[..i].join("/");
let encrypted = crypto::encrypt_filename(self.master_key.expose_secret(), &partial)?;
if !manifest
.entries
.iter()
.any(|e| e.encrypted_name == encrypted)
{
dirs_to_create.push((partial, encrypted));
}
}
if dirs_to_create.is_empty() {
return Ok(0);
}
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
for (_, encrypted_name) in &dirs_to_create {
manifest.entries.push(ManifestEntry {
encrypted_name: encrypted_name.clone(),
name: String::new(),
size: 0,
offset: 0,
chunk_count: 0,
file_id: None,
is_dir: true,
modified: now.clone(),
});
}
manifest.modified = now;
let created = dirs_to_create.len() as u32;
self.write_vault_atomic(&header_buf, &manifest, &existing_data, &[])?;
Ok(created)
}
pub fn extract(
&self,
entry_name: &str,
output_dir: impl AsRef<Path>,
) -> crate::Result<PathBuf> {
let output_dir = output_dir.as_ref();
validate_entry_name(entry_name)?;
let manifest = self.read_manifest()?;
let entry = manifest
.entries
.iter()
.find(|e| {
crypto::decrypt_filename(self.master_key.expose_secret(), &e.encrypted_name)
.map(|name| name == entry_name)
.unwrap_or(false)
})
.ok_or_else(|| crate::Error::EntryNotFound(entry_name.to_string()))?;
if entry.is_dir {
let dir_path = output_dir.join(entry_name);
validate_output_path(&dir_path, output_dir)?;
std::fs::create_dir_all(&dir_path)?;
return Ok(dir_path);
}
let cascade_mode = self.header.flags.cascade_mode;
let chacha_key = if cascade_mode {
crypto::derive_chacha_key(self.master_key.expose_secret())
} else {
zeroize::Zeroizing::new([0u8; KEY_SIZE])
};
let file = File::open(&self.path)?;
let mut reader = BufReader::new(file);
let mut skip_buf = [0u8; HEADER_SIZE];
reader.read_exact(&mut skip_buf)?;
let (manifest_len, _manifest_data) = crypto::read_manifest_bounded(&mut reader)?;
let data_start = HEADER_SIZE as u64 + 4 + manifest_len as u64;
let vault_len = std::fs::metadata(&self.path)?.len();
let mut skipped = 0u64;
while skipped < entry.offset {
let to_skip = std::cmp::min(entry.offset - skipped, 8192) as usize;
let mut skip = vec![0u8; to_skip];
reader.read_exact(&mut skip)?;
skipped += to_skip as u64;
}
let filename = Path::new(entry_name)
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| {
crate::Error::InvalidPath(format!("invalid entry name: {entry_name}"))
})?;
let dest_path = output_dir.join(filename);
validate_output_path(&dest_path, output_dir)?;
if let Some(parent) = dest_path.parent() {
std::fs::create_dir_all(parent)?;
}
let out_file = open_new_output_file(&dest_path, output_dir)?;
let mut writer = BufWriter::new(out_file);
let mut entry_bytes_consumed = 0u64;
for chunk_idx in 0..entry.chunk_count {
let mut len_buf = [0u8; 4];
reader.read_exact(&mut len_buf)?;
let chunk_len = u32::from_le_bytes(len_buf) as usize;
let bytes_remaining =
vault_len.saturating_sub(data_start + entry.offset + entry_bytes_consumed + 4);
validate_encrypted_chunk_len(
chunk_len,
bytes_remaining,
self.header.chunk_size,
cascade_mode,
)?;
entry_bytes_consumed += 4 + chunk_len as u64;
let mut encrypted_chunk = vec![0u8; chunk_len];
reader.read_exact(&mut encrypted_chunk)?;
let mut plaintext = if cascade_mode {
crypto::decrypt_chunk_cascade_bound(
self.master_key.expose_secret(),
&chacha_key,
&encrypted_chunk,
chunk_idx,
entry.file_id.as_ref(),
entry.chunk_count,
)?
} else {
crypto::decrypt_chunk_bound(
self.master_key.expose_secret(),
&encrypted_chunk,
chunk_idx,
entry.file_id.as_ref(),
entry.chunk_count,
)?
};
writer.write_all(&plaintext)?;
plaintext.zeroize();
encrypted_chunk.zeroize();
}
writer.flush()?;
Ok(dest_path)
}
pub fn extract_all(&self, output_dir: impl AsRef<Path>) -> crate::Result<u32> {
let entries = self.list()?;
let mut count = 0u32;
for entry in &entries {
self.extract(&entry.name, output_dir.as_ref())?;
count += 1;
}
Ok(count)
}
pub fn delete_entry(&self, entry_name: &str) -> crate::Result<()> {
let file = File::open(&self.path)?;
let mut reader = BufReader::new(file);
let mut header_buf = [0u8; HEADER_SIZE];
reader.read_exact(&mut header_buf)?;
let (_manifest_len, manifest_encrypted) = crypto::read_manifest_bounded(&mut reader)?;
let manifest_str =
std::str::from_utf8(&manifest_encrypted).map_err(|_| CryptoError::ManifestEncoding)?;
let manifest_json =
crypto::decrypt_filename(self.master_key.expose_secret(), manifest_str)?;
let mut manifest: VaultManifest = serde_json::from_str(&manifest_json)
.map_err(|e| crate::Error::Manifest(e.to_string()))?;
let mut data_section = Vec::new();
reader.read_to_end(&mut data_section)?;
let mut found = false;
manifest.entries.retain(|entry| {
match crypto::decrypt_filename(self.master_key.expose_secret(), &entry.encrypted_name) {
Ok(name) if name == entry_name => {
found = true;
false
}
_ => true,
}
});
if !found {
return Err(crate::Error::EntryNotFound(entry_name.to_string()));
}
manifest.modified = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
self.write_vault_atomic(&header_buf, &manifest, &data_section, &[])?;
Ok(())
}
pub fn delete_entries(&self, names: &[&str], recursive: bool) -> crate::Result<u32> {
let file = File::open(&self.path)?;
let mut reader = BufReader::new(file);
let mut header_buf = [0u8; HEADER_SIZE];
reader.read_exact(&mut header_buf)?;
let (_manifest_len, manifest_encrypted) = crypto::read_manifest_bounded(&mut reader)?;
let manifest_str =
std::str::from_utf8(&manifest_encrypted).map_err(|_| CryptoError::ManifestEncoding)?;
let manifest_json =
crypto::decrypt_filename(self.master_key.expose_secret(), manifest_str)?;
let mut manifest: VaultManifest = serde_json::from_str(&manifest_json)
.map_err(|e| crate::Error::Manifest(e.to_string()))?;
let mut data_section = Vec::new();
reader.read_to_end(&mut data_section)?;
let original_count = manifest.entries.len();
manifest.entries.retain(|entry| {
let decrypted =
crypto::decrypt_filename(self.master_key.expose_secret(), &entry.encrypted_name);
match decrypted {
Ok(name) => {
if names.contains(&name.as_str()) {
return false;
}
if recursive {
for target in names {
if name.starts_with(&format!("{target}/")) {
return false;
}
}
}
true
}
Err(_) => true,
}
});
let removed = (original_count - manifest.entries.len()) as u32;
if removed > 0 {
manifest.modified = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
self.write_vault_atomic(&header_buf, &manifest, &data_section, &[])?;
}
Ok(removed)
}
pub fn move_entry(&self, from: &str, to: &str) -> crate::Result<()> {
let from = from.trim().trim_matches('/');
let to = to.trim().trim_matches('/');
if from.is_empty() || to.is_empty() {
return Err(crate::Error::InvalidPath(
"source and destination cannot be empty".into(),
));
}
validate_entry_name(from)?;
validate_entry_name(to)?;
if from == to {
return Ok(());
}
if to.starts_with(&format!("{from}/")) {
return Err(crate::Error::InvalidPath(
"cannot move an entry into its own subtree".into(),
));
}
if from.len() > 4096 || to.len() > 4096 {
return Err(crate::Error::InvalidPath("path too long".into()));
}
let file = File::open(&self.path)?;
let mut reader = BufReader::new(file);
let mut header_buf = [0u8; HEADER_SIZE];
reader.read_exact(&mut header_buf)?;
let (_manifest_len, manifest_encrypted) = crypto::read_manifest_bounded(&mut reader)?;
let manifest_str =
std::str::from_utf8(&manifest_encrypted).map_err(|_| CryptoError::ManifestEncoding)?;
let manifest_json =
crypto::decrypt_filename(self.master_key.expose_secret(), manifest_str)?;
let mut manifest: VaultManifest = serde_json::from_str(&manifest_json)
.map_err(|e| crate::Error::Manifest(e.to_string()))?;
let mut data_section = Vec::new();
reader.read_to_end(&mut data_section)?;
let mut names: Vec<String> = Vec::with_capacity(manifest.entries.len());
for entry in &manifest.entries {
names.push(crypto::decrypt_filename(
self.master_key.expose_secret(),
&entry.encrypted_name,
)?);
}
let src_index = names
.iter()
.position(|n| n == from)
.ok_or_else(|| crate::Error::EntryNotFound(from.to_string()))?;
let src_is_dir = manifest.entries[src_index].is_dir;
let mut renames: Vec<(usize, String)> = Vec::new();
for (idx, name) in names.iter().enumerate() {
if src_is_dir {
if name == from {
renames.push((idx, to.to_string()));
} else if name.starts_with(&format!("{from}/")) {
renames.push((idx, format!("{to}{}", &name[from.len()..])));
}
} else if name == from {
renames.push((idx, to.to_string()));
}
}
if renames.is_empty() {
return Err(crate::Error::EntryNotFound(from.to_string()));
}
let mut final_map: std::collections::HashMap<String, bool> =
std::collections::HashMap::new();
for (idx, old_name) in names.iter().enumerate() {
let resolved_name = renames
.iter()
.find(|(rename_idx, _)| *rename_idx == idx)
.map(|(_, new_name)| new_name.as_str())
.unwrap_or(old_name.as_str());
if final_map
.insert(resolved_name.to_string(), manifest.entries[idx].is_dir)
.is_some()
{
return Err(crate::Error::Manifest(format!(
"target entry already exists: {resolved_name}"
)));
}
}
for name in final_map.keys() {
if let Some((parent, _)) = name.rsplit_once('/') {
if parent.is_empty() {
continue;
}
let parent_is_dir = final_map.get(parent).copied().unwrap_or(false);
if !parent_is_dir {
return Err(crate::Error::EntryNotFound(format!(
"parent directory '{parent}'"
)));
}
}
}
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
for (idx, new_name) in &renames {
manifest.entries[*idx].encrypted_name =
crypto::encrypt_filename(self.master_key.expose_secret(), new_name)?;
manifest.entries[*idx].modified = now.clone();
}
manifest.modified = now;
self.write_vault_atomic(&header_buf, &manifest, &data_section, &[])?;
Ok(())
}
pub fn rename_entry(&self, current_name: &str, new_name: &str) -> crate::Result<()> {
let current_name = current_name.trim().trim_matches('/');
let new_name = new_name.trim().trim_matches('/');
if new_name.is_empty() {
return Err(crate::Error::InvalidPath("new name cannot be empty".into()));
}
if new_name.contains('/') || new_name.contains('\\') || new_name.contains('\0') {
return Err(crate::Error::InvalidPath(
"new name must be a single path segment".into(),
));
}
if new_name == "." || new_name == ".." {
return Err(crate::Error::InvalidPath("invalid new name".into()));
}
let target = if let Some((parent, _)) = current_name.rsplit_once('/') {
format!("{parent}/{new_name}")
} else {
new_name.to_string()
};
self.move_entry(current_name, &target)
}
pub fn copy_entry(&self, from: &str, to: &str) -> crate::Result<()> {
let from = from.trim().trim_matches('/');
let to = to.trim().trim_matches('/');
if from.is_empty() || to.is_empty() {
return Err(crate::Error::InvalidPath(
"source and destination cannot be empty".into(),
));
}
validate_entry_name(from)?;
validate_entry_name(to)?;
if from == to {
return Err(crate::Error::Manifest("target entry already exists".into()));
}
if to.starts_with(&format!("{from}/")) {
return Err(crate::Error::InvalidPath(
"cannot copy an entry into its own subtree".into(),
));
}
if from.len() > 4096 || to.len() > 4096 {
return Err(crate::Error::InvalidPath("path too long".into()));
}
let file = File::open(&self.path)?;
let mut reader = BufReader::new(file);
let mut header_buf = [0u8; HEADER_SIZE];
reader.read_exact(&mut header_buf)?;
let (_manifest_len, manifest_encrypted) = crypto::read_manifest_bounded(&mut reader)?;
let manifest_str =
std::str::from_utf8(&manifest_encrypted).map_err(|_| CryptoError::ManifestEncoding)?;
let manifest_json =
crypto::decrypt_filename(self.master_key.expose_secret(), manifest_str)?;
let mut manifest: VaultManifest = serde_json::from_str(&manifest_json)
.map_err(|e| crate::Error::Manifest(e.to_string()))?;
let mut data_section = Vec::new();
reader.read_to_end(&mut data_section)?;
let mut names: Vec<String> = Vec::with_capacity(manifest.entries.len());
for entry in &manifest.entries {
names.push(crypto::decrypt_filename(
self.master_key.expose_secret(),
&entry.encrypted_name,
)?);
}
let src_index = names
.iter()
.position(|n| n == from)
.ok_or_else(|| crate::Error::EntryNotFound(from.to_string()))?;
let src_is_dir = manifest.entries[src_index].is_dir;
let mut copies: Vec<(usize, String)> = Vec::new();
for (idx, name) in names.iter().enumerate() {
if src_is_dir {
if name == from {
copies.push((idx, to.to_string()));
} else if name.starts_with(&format!("{from}/")) {
copies.push((idx, format!("{to}{}", &name[from.len()..])));
}
} else if name == from {
copies.push((idx, to.to_string()));
}
}
if copies.is_empty() {
return Err(crate::Error::EntryNotFound(from.to_string()));
}
let mut final_map: std::collections::HashMap<String, bool> =
std::collections::HashMap::new();
for (idx, name) in names.iter().enumerate() {
final_map.insert(name.to_string(), manifest.entries[idx].is_dir);
}
for (src_idx, copied_name) in &copies {
if final_map
.insert(copied_name.clone(), manifest.entries[*src_idx].is_dir)
.is_some()
{
return Err(crate::Error::Manifest(format!(
"target entry already exists: {copied_name}"
)));
}
}
for name in final_map.keys() {
if let Some((parent, _)) = name.rsplit_once('/') {
if parent.is_empty() {
continue;
}
let parent_is_dir = final_map.get(parent).copied().unwrap_or(false);
if !parent_is_dir {
return Err(crate::Error::EntryNotFound(format!(
"parent directory '{parent}'"
)));
}
}
}
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let mut new_entries: Vec<ManifestEntry> = Vec::with_capacity(copies.len());
for (src_idx, copied_name) in copies {
let mut cloned = manifest.entries[src_idx].clone();
cloned.encrypted_name =
crypto::encrypt_filename(self.master_key.expose_secret(), &copied_name)?;
cloned.modified = now.clone();
new_entries.push(cloned);
}
manifest.entries.extend(new_entries);
manifest.modified = now;
self.write_vault_atomic(&header_buf, &manifest, &data_section, &[])?;
Ok(())
}
pub fn compact(&self) -> crate::Result<CompactResult> {
use std::io::Seek;
let original_size = std::fs::metadata(&self.path)?.len();
let file = File::open(&self.path)?;
let mut reader = BufReader::new(file);
let mut header_buf = [0u8; HEADER_SIZE];
reader.read_exact(&mut header_buf)?;
let (manifest_len, manifest_encrypted) = crypto::read_manifest_bounded(&mut reader)?;
let manifest_str =
std::str::from_utf8(&manifest_encrypted).map_err(|_| CryptoError::ManifestEncoding)?;
let manifest_json =
crypto::decrypt_filename(self.master_key.expose_secret(), manifest_str)?;
let mut manifest: VaultManifest = serde_json::from_str(&manifest_json)
.map_err(|e| crate::Error::Manifest(e.to_string()))?;
let data_start = HEADER_SIZE as u64 + 4 + manifest_len as u64;
let cascade_mode = self.header.flags.cascade_mode;
let file_count = manifest.entries.len();
drop(reader);
let chacha_key = if cascade_mode {
crypto::derive_chacha_key(self.master_key.expose_secret())
} else {
zeroize::Zeroizing::new([0u8; KEY_SIZE])
};
let orig_file = File::open(&self.path)?;
let mut orig_reader = BufReader::new(orig_file);
let mut compacted_data: Vec<u8> = Vec::new();
let mut new_data_offset: u64 = 0;
for entry in &mut manifest.entries {
if entry.is_dir || entry.chunk_count == 0 {
entry.offset = 0;
continue;
}
let entry_new_offset = new_data_offset;
orig_reader.seek(std::io::SeekFrom::Start(data_start + entry.offset))?;
let mut entry_bytes_consumed = 0u64;
for chunk_idx in 0..entry.chunk_count {
let mut len_buf = [0u8; 4];
orig_reader.read_exact(&mut len_buf)?;
let encrypted_chunk_len = u32::from_le_bytes(len_buf) as usize;
let bytes_remaining = original_size
.saturating_sub(data_start + entry.offset + entry_bytes_consumed + 4);
validate_encrypted_chunk_len(
encrypted_chunk_len,
bytes_remaining,
self.header.chunk_size,
cascade_mode,
)?;
entry_bytes_consumed += 4 + encrypted_chunk_len as u64;
let mut encrypted_chunk = vec![0u8; encrypted_chunk_len];
orig_reader.read_exact(&mut encrypted_chunk)?;
let mut plaintext = if cascade_mode {
crypto::decrypt_chunk_cascade_bound(
self.master_key.expose_secret(),
&chacha_key,
&encrypted_chunk,
chunk_idx,
entry.file_id.as_ref(),
entry.chunk_count,
)?
} else {
crypto::decrypt_chunk_bound(
self.master_key.expose_secret(),
&encrypted_chunk,
chunk_idx,
entry.file_id.as_ref(),
entry.chunk_count,
)?
};
encrypted_chunk.zeroize();
let new_encrypted = if cascade_mode {
crypto::encrypt_chunk_cascade_bound(
self.master_key.expose_secret(),
&chacha_key,
&plaintext,
chunk_idx,
entry.file_id.as_ref(),
entry.chunk_count,
)?
} else {
crypto::encrypt_chunk_bound(
self.master_key.expose_secret(),
&plaintext,
chunk_idx,
entry.file_id.as_ref(),
entry.chunk_count,
)?
};
plaintext.zeroize();
let new_chunk_len = new_encrypted.len() as u32;
compacted_data.extend_from_slice(&new_chunk_len.to_le_bytes());
compacted_data.extend_from_slice(&new_encrypted);
new_data_offset += 4 + new_encrypted.len() as u64;
}
entry.offset = entry_new_offset;
}
drop(orig_reader);
manifest.modified = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let manifest_json =
serde_json::to_string(&manifest).map_err(|e| crate::Error::Manifest(e.to_string()))?;
let encrypted_manifest =
crypto::encrypt_filename(self.master_key.expose_secret(), &manifest_json)?;
let manifest_bytes = encrypted_manifest.as_bytes();
let tmp_path = format!("{}.compact.tmp", self.path.display());
let tmp_file = File::create(&tmp_path)?;
let mut writer = BufWriter::new(tmp_file);
writer.write_all(&header_buf)?;
writer.write_all(&(manifest_bytes.len() as u32).to_le_bytes())?;
writer.write_all(manifest_bytes)?;
writer.write_all(&compacted_data)?;
writer.flush()?;
writer.get_ref().sync_all()?;
drop(writer);
atomic_rename(&tmp_path, &self.path)?;
let compacted_size = std::fs::metadata(&self.path)?.len();
Ok(CompactResult {
original_size,
compacted_size,
saved_bytes: original_size.saturating_sub(compacted_size),
file_count,
})
}
pub fn change_password(&mut self, new_password: impl Into<String>) -> crate::Result<()> {
let new_pwd = SecretString::from(new_password.into());
if new_pwd.expose_secret().len() < MIN_PASSWORD_LENGTH {
return Err(crate::Error::PasswordPolicy(format!(
"password must be at least {MIN_PASSWORD_LENGTH} characters"
)));
}
let mut vault_data = std::fs::read(&self.path)?;
if vault_data.len() < HEADER_SIZE {
return Err(FormatError::TooSmall {
actual: vault_data.len(),
expected: HEADER_SIZE,
}
.into());
}
let mut new_salt = [0u8; SALT_SIZE];
rand::rngs::OsRng.fill_bytes(&mut new_salt);
let new_base_kek = crypto::derive_key(&new_pwd, &new_salt)?;
let (new_kek_master, new_kek_mac) = crypto::derive_kek_pair(new_base_kek.expose_secret());
let new_wrapped_master =
crypto::wrap_key(&new_kek_master, self.master_key.expose_secret())?;
let new_wrapped_mac = crypto::wrap_key(&new_kek_mac, self.mac_key.expose_secret())?;
let mut new_header = VaultHeader {
magic: *MAGIC,
version: VERSION,
flags: self.header.flags,
salt: new_salt,
wrapped_master_key: new_wrapped_master,
wrapped_mac_key: new_wrapped_mac,
chunk_size: self.header.chunk_size,
reserved: [0u8; 320],
header_mac: [0u8; MAC_SIZE],
};
new_header.header_mac = new_header.compute_mac(self.mac_key.expose_secret());
let header_bytes = new_header.to_bytes();
vault_data[..HEADER_SIZE].copy_from_slice(&header_bytes);
atomic_write(&self.path, &vault_data, "chpw")?;
self.header = new_header;
vault_data.zeroize();
Ok(())
}
fn read_manifest(&self) -> crate::Result<VaultManifest> {
let file = File::open(&self.path)?;
let mut reader = BufReader::new(file);
let mut header_buf = [0u8; HEADER_SIZE];
reader.read_exact(&mut header_buf)?;
let (_manifest_len, manifest_encrypted) = crypto::read_manifest_bounded(&mut reader)?;
let manifest_str =
std::str::from_utf8(&manifest_encrypted).map_err(|_| CryptoError::ManifestEncoding)?;
let manifest_json =
crypto::decrypt_filename(self.master_key.expose_secret(), manifest_str)?;
serde_json::from_str(&manifest_json).map_err(|e| crate::Error::Manifest(e.to_string()))
}
fn write_vault_atomic(
&self,
header_buf: &[u8; HEADER_SIZE],
manifest: &VaultManifest,
existing_data: &[u8],
new_data: &[u8],
) -> crate::Result<()> {
let manifest_json =
serde_json::to_string(manifest).map_err(|e| crate::Error::Manifest(e.to_string()))?;
let encrypted_manifest =
crypto::encrypt_filename(self.master_key.expose_secret(), &manifest_json)?;
let manifest_bytes = encrypted_manifest.as_bytes();
let tmp_path = format!("{}.tmp", self.path.display());
let file = File::create(&tmp_path)?;
let mut writer = BufWriter::new(file);
writer.write_all(header_buf)?;
writer.write_all(&(manifest_bytes.len() as u32).to_le_bytes())?;
writer.write_all(manifest_bytes)?;
writer.write_all(existing_data)?;
writer.write_all(new_data)?;
writer.flush()?;
writer.get_ref().sync_all()?;
drop(writer);
atomic_rename(&tmp_path, &self.path)?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct PeekInfo {
pub version: u8,
pub mode: EncryptionMode,
pub chunk_size: u32,
}
#[derive(Debug, Clone)]
pub struct CompactResult {
pub original_size: u64,
pub compacted_size: u64,
pub saved_bytes: u64,
pub file_count: usize,
}
#[derive(Debug, Clone)]
pub struct SecurityInfo {
pub version: u8,
pub mode: EncryptionMode,
pub chunk_size: u32,
pub argon2_m_cost_kib: u32,
pub argon2_t_cost: u32,
pub argon2_p_cost: u32,
}
impl std::fmt::Display for SecurityInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mode_str = match self.mode {
EncryptionMode::Standard => "AES-256-GCM-SIV",
EncryptionMode::Cascade => "AES-256-GCM-SIV + ChaCha20-Poly1305",
};
write!(
f,
"Version: {}\n\
Encryption: {}\n\
Chunk size: {} bytes\n\
KDF: Argon2id ({} MiB, t={}, p={})\n\
Key wrapping: AES-256-KW (RFC 3394)\n\
Filename encryption: AES-256-SIV\n\
Header integrity: HMAC-SHA512",
self.version,
mode_str,
self.chunk_size,
self.argon2_m_cost_kib / 1024,
self.argon2_t_cost,
self.argon2_p_cost,
)
}
}
fn validate_entry_name(name: &str) -> crate::Result<()> {
if name.contains("..") {
return Err(crate::Error::InvalidPath(
"entry name contains '..' (path traversal)".into(),
));
}
if name.starts_with('/') || name.starts_with('\\') {
return Err(crate::Error::InvalidPath(
"entry name is an absolute path".into(),
));
}
if name.contains('\0') {
return Err(crate::Error::InvalidPath(
"entry name contains null byte".into(),
));
}
Ok(())
}
fn validate_output_path(path: &Path, output_dir: &Path) -> crate::Result<()> {
let canonical_dir = output_dir
.canonicalize()
.unwrap_or_else(|_| output_dir.to_path_buf());
let canonical_path = if let Some(parent) = path.parent() {
let canon_parent = parent
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf());
if let Some(filename) = path.file_name() {
canon_parent.join(filename)
} else {
canon_parent
}
} else {
path.to_path_buf()
};
if !canonical_path.starts_with(&canonical_dir) {
return Err(crate::Error::InvalidPath(format!(
"output path escapes target directory: {}",
path.display()
)));
}
Ok(())
}
fn validate_encrypted_chunk_len(
chunk_len: usize,
bytes_remaining: u64,
header_chunk_size: u32,
cascade_mode: bool,
) -> crate::Result<()> {
let aead_overhead = NONCE_SIZE + TAG_SIZE;
let max_len =
header_chunk_size as usize + aead_overhead + if cascade_mode { aead_overhead } else { 0 };
if chunk_len > max_len {
return Err(Error::Format(FormatError::InvalidChunkSize(
chunk_len as u32,
)));
}
if chunk_len as u64 > bytes_remaining {
return Err(Error::Format(FormatError::ManifestTruncated));
}
Ok(())
}
fn open_new_output_file(path: &Path, output_dir: &Path) -> crate::Result<File> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
validate_output_path(path, output_dir)?;
let canonical_dir = output_dir.canonicalize()?;
let canonical_parent = path
.parent()
.ok_or_else(|| Error::InvalidPath(format!("missing parent for {}", path.display())))?
.canonicalize()?;
if !canonical_parent.starts_with(&canonical_dir) {
return Err(Error::InvalidPath(format!(
"output path escapes target directory: {}",
path.display()
)));
}
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
options.custom_flags(libc::O_NOFOLLOW);
let file = options.open(path)?;
let fd_meta = file.metadata()?;
let path_meta = std::fs::symlink_metadata(path)?;
if !path_meta.file_type().is_file()
|| fd_meta.dev() != path_meta.dev()
|| fd_meta.ino() != path_meta.ino()
{
return Err(Error::InvalidPath(format!(
"output path is not the newly-created regular file: {}",
path.display()
)));
}
let canonical_path = path.canonicalize()?;
if !canonical_path.starts_with(&canonical_dir) {
return Err(Error::InvalidPath(format!(
"output path escapes target directory: {}",
path.display()
)));
}
Ok(file)
}
#[cfg(not(unix))]
{
let file = options.open(path)?;
let canonical_path = path.canonicalize()?;
if !canonical_path.starts_with(&canonical_dir) {
return Err(Error::InvalidPath(format!(
"output path escapes target directory: {}",
path.display()
)));
}
Ok(file)
}
}
#[cfg(unix)]
fn fsync_parent_dir(path: &Path) -> crate::Result<()> {
if let Some(parent) = path.parent() {
let parent = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
let dir = File::open(parent)?;
dir.sync_all()?;
}
Ok(())
}
#[cfg(not(unix))]
fn fsync_parent_dir(_path: &Path) -> crate::Result<()> {
Ok(())
}
fn atomic_rename(tmp_path: &str, final_path: &Path) -> crate::Result<()> {
#[cfg(windows)]
if final_path.exists() {
std::fs::remove_file(final_path)?;
}
std::fs::rename(tmp_path, final_path)?;
fsync_parent_dir(final_path)?;
Ok(())
}
fn atomic_write(path: &Path, data: &[u8], suffix: &str) -> crate::Result<()> {
let tmp_path = format!("{}.{suffix}.tmp", path.display());
let file = File::create(&tmp_path)?;
let mut writer = BufWriter::new(file);
writer.write_all(data)?;
writer.flush()?;
writer.get_ref().sync_all()?;
drop(writer);
#[cfg(windows)]
if path.exists() {
std::fs::remove_file(path)?;
}
std::fs::rename(&tmp_path, path)?;
fsync_parent_dir(path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn temp_vault_path() -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"aerovault-test-{}.aerovault",
rand::random::<u64>()
));
path
}
#[test]
fn test_create_and_open() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "test-password-123");
let _vault = Vault::create(opts).unwrap();
assert!(Vault::is_vault(&path));
let vault = Vault::open(&path, "test-password-123").unwrap();
let entries = vault.list().unwrap();
assert!(entries.is_empty());
std::fs::remove_file(&path).ok();
}
#[test]
fn test_wrong_password() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "correct-password");
let _vault = Vault::create(opts).unwrap();
let result = Vault::open(&path, "wrong-password");
assert!(result.is_err());
std::fs::remove_file(&path).ok();
}
#[test]
fn test_password_too_short() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "short");
let result = Vault::create(opts);
assert!(result.is_err());
std::fs::remove_file(&path).ok();
}
#[test]
fn test_chunk_size_validation() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "test-password-123").with_chunk_size(1); assert!(Vault::create(opts).is_err());
let opts = CreateOptions::new(&path, "test-password-123").with_chunk_size(32 * 1024 * 1024); assert!(Vault::create(opts).is_err());
std::fs::remove_file(&path).ok();
}
#[test]
fn test_add_and_extract() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "test-password-123");
let vault = Vault::create(opts).unwrap();
let test_file = std::env::temp_dir().join("aerovault-test-input.txt");
let mut f = File::create(&test_file).unwrap();
f.write_all(b"Hello from AeroVault!").unwrap();
let added = vault.add_files(&[&test_file]).unwrap();
assert_eq!(added, 1);
let entries = vault.list().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "aerovault-test-input.txt");
assert_eq!(entries[0].size, 21);
assert!(!entries[0].is_dir);
let out_dir = std::env::temp_dir().join("aerovault-test-output");
std::fs::create_dir_all(&out_dir).ok();
let extracted = vault.extract("aerovault-test-input.txt", &out_dir).unwrap();
let content = std::fs::read_to_string(&extracted).unwrap();
assert_eq!(content, "Hello from AeroVault!");
std::fs::remove_file(&path).ok();
std::fs::remove_file(&test_file).ok();
std::fs::remove_dir_all(&out_dir).ok();
}
#[test]
fn test_legacy_v2_roundtrip_and_version_dispatch() {
let v2_path = temp_vault_path();
let v3_path = temp_vault_path();
let input = std::env::temp_dir().join(format!(
"aerovault-legacy-input-{}.bin",
v2_path.file_stem().unwrap().to_string_lossy()
));
let payload: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
std::fs::write(&input, &payload).unwrap();
let entry = input.file_name().unwrap().to_string_lossy().to_string();
let v2 = Vault::create(
CreateOptions::new(&v2_path, "legacy-password-123").with_version(LEGACY_VERSION),
)
.unwrap();
v2.add_files(&[&input]).unwrap();
assert_eq!(Vault::peek(&v2_path).unwrap().version, LEGACY_VERSION);
let v2r = Vault::open(&v2_path, "legacy-password-123").unwrap();
let out2 = std::env::temp_dir().join(format!("av-legacy-out-{}", std::process::id()));
std::fs::create_dir_all(&out2).ok();
let ex2 = v2r.extract(&entry, &out2).unwrap();
assert_eq!(
std::fs::read(&ex2).unwrap(),
payload,
"v2 legacy round-trip"
);
let v3 = Vault::create(CreateOptions::new(&v3_path, "modern-password-123")).unwrap();
v3.add_files(&[&input]).unwrap();
assert_eq!(Vault::peek(&v3_path).unwrap().version, VERSION);
let v3r = Vault::open(&v3_path, "modern-password-123").unwrap();
let out3 = std::env::temp_dir().join(format!("av-v3-out-{}", std::process::id()));
std::fs::create_dir_all(&out3).ok();
let ex3 = v3r.extract(&entry, &out3).unwrap();
assert_eq!(std::fs::read(&ex3).unwrap(), payload, "v3 round-trip");
assert!(Vault::create(
CreateOptions::new(temp_vault_path(), "x-password-123").with_version(9)
)
.is_err());
std::fs::remove_file(&v2_path).ok();
std::fs::remove_file(&v3_path).ok();
std::fs::remove_file(&input).ok();
std::fs::remove_dir_all(&out2).ok();
std::fs::remove_dir_all(&out3).ok();
}
#[test]
fn test_cascade_mode() {
let path = temp_vault_path();
let opts =
CreateOptions::new(&path, "test-password-123").with_mode(EncryptionMode::Cascade);
let vault = Vault::create(opts).unwrap();
assert_eq!(vault.mode(), EncryptionMode::Cascade);
let test_file = std::env::temp_dir().join("aerovault-cascade-input.txt");
let mut f = File::create(&test_file).unwrap();
f.write_all(b"Cascade mode test").unwrap();
vault.add_files(&[&test_file]).unwrap();
let vault2 = Vault::open(&path, "test-password-123").unwrap();
let out_dir = std::env::temp_dir().join("aerovault-cascade-output");
std::fs::create_dir_all(&out_dir).ok();
let extracted = vault2
.extract("aerovault-cascade-input.txt", &out_dir)
.unwrap();
let content = std::fs::read_to_string(&extracted).unwrap();
assert_eq!(content, "Cascade mode test");
std::fs::remove_file(&path).ok();
std::fs::remove_file(&test_file).ok();
std::fs::remove_dir_all(&out_dir).ok();
}
#[test]
fn test_create_directory() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "test-password-123");
let vault = Vault::create(opts).unwrap();
let created = vault.create_directory("docs/notes").unwrap();
assert_eq!(created, 2);
let created = vault.create_directory("docs/notes").unwrap();
assert_eq!(created, 0);
let entries = vault.list().unwrap();
assert_eq!(entries.len(), 2);
assert!(entries.iter().all(|e| e.is_dir));
std::fs::remove_file(&path).ok();
}
#[test]
fn test_delete_entry() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "test-password-123");
let vault = Vault::create(opts).unwrap();
let test_file = std::env::temp_dir().join("aerovault-delete-input.txt");
let mut f = File::create(&test_file).unwrap();
f.write_all(b"to be deleted").unwrap();
vault.add_files(&[&test_file]).unwrap();
assert_eq!(vault.list().unwrap().len(), 1);
vault.delete_entry("aerovault-delete-input.txt").unwrap();
assert_eq!(vault.list().unwrap().len(), 0);
assert!(vault.delete_entry("aerovault-delete-input.txt").is_err());
std::fs::remove_file(&path).ok();
std::fs::remove_file(&test_file).ok();
}
#[test]
fn test_change_password() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "old-password-123");
let mut vault = Vault::create(opts).unwrap();
let test_file = std::env::temp_dir().join("aerovault-chpw-input.txt");
let mut f = File::create(&test_file).unwrap();
f.write_all(b"password change test").unwrap();
vault.add_files(&[&test_file]).unwrap();
vault.change_password("new-password-456").unwrap();
assert!(Vault::open(&path, "old-password-123").is_err());
let vault2 = Vault::open(&path, "new-password-456").unwrap();
let entries = vault2.list().unwrap();
assert_eq!(entries.len(), 1);
std::fs::remove_file(&path).ok();
std::fs::remove_file(&test_file).ok();
}
#[test]
fn test_is_vault_negative() {
let path = std::env::temp_dir().join("not-a-vault.txt");
std::fs::write(&path, b"just text").ok();
assert!(!Vault::is_vault(&path));
std::fs::remove_file(&path).ok();
assert!(!Vault::is_vault("/nonexistent/path"));
}
#[test]
fn test_security_info_display() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "test-password-123");
let vault = Vault::create(opts).unwrap();
let info = vault.security_info();
let display = format!("{info}");
assert!(display.contains("AES-256-GCM-SIV"));
assert!(display.contains("128 MiB"));
std::fs::remove_file(&path).ok();
}
#[test]
fn test_path_traversal_rejected() {
assert!(validate_entry_name("../etc/passwd").is_err());
assert!(validate_entry_name("/etc/passwd").is_err());
assert!(validate_entry_name("foo\0bar").is_err());
assert!(validate_entry_name("normal/file.txt").is_ok());
}
#[test]
fn test_rename_entry() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "test-password-123");
let vault = Vault::create(opts).unwrap();
let test_file = std::env::temp_dir().join("aerovault-rename-input.txt");
std::fs::write(&test_file, b"rename me").unwrap();
vault.add_files(&[&test_file]).unwrap();
vault
.rename_entry("aerovault-rename-input.txt", "renamed.txt")
.unwrap();
let entries = vault.list().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "renamed.txt");
let out_dir = std::env::temp_dir().join("aerovault-rename-output");
std::fs::create_dir_all(&out_dir).ok();
let extracted = vault.extract("renamed.txt", &out_dir).unwrap();
assert!(extracted.ends_with("renamed.txt"));
std::fs::remove_file(&path).ok();
std::fs::remove_file(&test_file).ok();
std::fs::remove_dir_all(&out_dir).ok();
}
#[test]
fn test_move_directory_recursive() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "test-password-123");
let vault = Vault::create(opts).unwrap();
vault.create_directory("docs/old").unwrap();
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("aerovault-move-input.txt");
std::fs::write(&test_file, b"move me").unwrap();
vault.add_files_to_dir(&[&test_file], "docs/old").unwrap();
vault.create_directory("archive").unwrap();
vault.move_entry("docs/old", "archive/old").unwrap();
let names: Vec<String> = vault.list().unwrap().into_iter().map(|e| e.name).collect();
assert!(names.iter().any(|n| n == "archive/old"));
assert!(names
.iter()
.any(|n| n == "archive/old/aerovault-move-input.txt"));
assert!(!names.iter().any(|n| n == "docs/old"));
let out_dir = temp_dir.join("aerovault-move-output");
std::fs::create_dir_all(&out_dir).ok();
let extracted = vault
.extract("archive/old/aerovault-move-input.txt", &out_dir)
.unwrap();
let content = std::fs::read_to_string(extracted).unwrap();
assert_eq!(content, "move me");
std::fs::remove_file(&path).ok();
std::fs::remove_file(&test_file).ok();
std::fs::remove_dir_all(&out_dir).ok();
}
#[test]
fn test_move_entry_rejects_self_subtree() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "test-password-123");
let vault = Vault::create(opts).unwrap();
vault.create_directory("docs/old").unwrap();
assert!(vault.move_entry("docs", "docs/archive").is_err());
std::fs::remove_file(&path).ok();
}
#[test]
fn test_copy_entry_file() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "test-password-123");
let vault = Vault::create(opts).unwrap();
let test_file = std::env::temp_dir().join("aerovault-copy-input.txt");
std::fs::write(&test_file, b"copy me").unwrap();
vault.add_files(&[&test_file]).unwrap();
vault
.copy_entry("aerovault-copy-input.txt", "aerovault-copy-output.txt")
.unwrap();
let names: Vec<String> = vault.list().unwrap().into_iter().map(|e| e.name).collect();
assert!(names.iter().any(|n| n == "aerovault-copy-input.txt"));
assert!(names.iter().any(|n| n == "aerovault-copy-output.txt"));
let out_dir = std::env::temp_dir().join("aerovault-copy-output-dir");
std::fs::create_dir_all(&out_dir).ok();
let extracted = vault
.extract("aerovault-copy-output.txt", &out_dir)
.unwrap();
let content = std::fs::read_to_string(extracted).unwrap();
assert_eq!(content, "copy me");
std::fs::remove_file(&path).ok();
std::fs::remove_file(&test_file).ok();
std::fs::remove_dir_all(&out_dir).ok();
}
#[test]
fn test_copy_entry_directory_recursive() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "test-password-123");
let vault = Vault::create(opts).unwrap();
vault.create_directory("docs/original").unwrap();
let test_file = std::env::temp_dir().join("aerovault-copy-tree-input.txt");
std::fs::write(&test_file, b"tree copy").unwrap();
vault
.add_files_to_dir(&[&test_file], "docs/original")
.unwrap();
vault.create_directory("archive").unwrap();
vault
.copy_entry("docs/original", "archive/original-copy")
.unwrap();
let names: Vec<String> = vault.list().unwrap().into_iter().map(|e| e.name).collect();
assert!(names.iter().any(|n| n == "docs/original"));
assert!(names.iter().any(|n| n == "archive/original-copy"));
assert!(names
.iter()
.any(|n| n == "archive/original-copy/aerovault-copy-tree-input.txt"));
std::fs::remove_file(&path).ok();
std::fs::remove_file(&test_file).ok();
}
#[test]
fn test_copy_entry_rejects_self_subtree() {
let path = temp_vault_path();
let opts = CreateOptions::new(&path, "test-password-123");
let vault = Vault::create(opts).unwrap();
vault.create_directory("docs/original").unwrap();
assert!(vault.copy_entry("docs", "docs/archive").is_err());
std::fs::remove_file(&path).ok();
}
}