use crate::archive::end_record::EndRecord;
use crate::archive::format::{
CompressionMethod, EncryptionMode, EntryInfo, FileHeader, FORMAT_VERSION_MAJOR,
FORMAT_VERSION_MINOR,
};
use crate::archive::frame_compression::{compress_frames, should_use_frames};
use crate::archive::local_entry::LocalEntryHeader;
use crate::archive::signature_block::SignatureBlock;
use crate::error::{EngramError, Result};
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use ed25519_dalek::SigningKey;
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Read as _, Seek, SeekFrom, Write};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
const MIN_COMPRESSION_SIZE: usize = 4096;
fn normalize_path(path: &str) -> String {
path.replace('\\', "/")
}
pub struct ArchiveWriter {
writer: BufWriter<File>,
entries: Vec<EntryInfo>,
current_offset: u64,
encryption_mode: EncryptionMode,
encryption_key: Option<[u8; 32]>,
signing_key: Option<SigningKey>,
}
impl ArchiveWriter {
pub fn create<P: AsRef<Path>>(path: P) -> Result<Self> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(path)?;
let mut writer = BufWriter::new(file);
let header = FileHeader::new();
header.write_to(&mut writer)?;
Ok(Self {
writer,
entries: Vec::new(),
current_offset: 64, encryption_mode: EncryptionMode::None,
encryption_key: None,
signing_key: None,
})
}
pub fn with_archive_encryption(mut self, key: &[u8; 32]) -> Self {
self.encryption_mode = EncryptionMode::Archive;
self.encryption_key = Some(*key);
self
}
pub fn with_per_file_encryption(mut self, key: &[u8; 32]) -> Self {
self.encryption_mode = EncryptionMode::PerFile;
self.encryption_key = Some(*key);
self
}
pub fn with_signing_key(mut self, key: &SigningKey) -> Self {
self.signing_key = Some(key.clone());
self
}
pub fn add_file(&mut self, path: &str, data: &[u8]) -> Result<()> {
let compression = Self::select_compression(path, data.len());
self.add_file_with_compression(path, data, compression)
}
pub fn add_file_with_compression(
&mut self,
path: &str,
data: &[u8],
compression: CompressionMethod,
) -> Result<()> {
let normalized_path = normalize_path(path);
let (compressed_data, actual_compression) = self.compress_data(data, compression)?;
let final_payload = if self.encryption_mode == EncryptionMode::PerFile {
self.encrypt_file_data(&compressed_data)?
} else {
compressed_data
};
let crc32 = crc32fast::hash(data);
let modified_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let entry_start_offset = self.current_offset;
let local_header = LocalEntryHeader::new(
data.len() as u64, final_payload.len() as u64, crc32,
modified_time,
actual_compression,
normalized_path.clone(),
);
let header_bytes_written = local_header.write_to(&mut self.writer)?;
self.current_offset += header_bytes_written as u64;
self.writer.write_all(&final_payload)?;
self.current_offset += final_payload.len() as u64;
let entry = EntryInfo {
path: normalized_path,
data_offset: entry_start_offset, uncompressed_size: data.len() as u64,
compressed_size: final_payload.len() as u64,
crc32,
modified_time,
compression: actual_compression,
flags: 0,
};
self.entries.push(entry);
Ok(())
}
pub fn add_file_from_disk(&mut self, archive_path: &str, disk_path: &Path) -> Result<()> {
let data = std::fs::read(disk_path)?;
self.add_file(archive_path, &data)
}
pub fn add_manifest(&mut self, manifest: &serde_json::Value) -> Result<()> {
let json = serde_json::to_vec_pretty(manifest).map_err(|e| {
EngramError::InvalidManifest(format!("Failed to serialize manifest: {}", e))
})?;
self.add_file_with_compression("manifest.json", &json, CompressionMethod::None)
}
pub fn finalize(mut self) -> Result<()> {
let cd_offset = self.current_offset;
for entry in &self.entries {
entry.write_to(&mut self.writer)?;
}
let cd_size = self.current_offset - cd_offset + (self.entries.len() as u64 * 320);
self.writer.flush()?;
let encryption_mode = self.encryption_mode;
let encryption_key = self.encryption_key;
let signing_key = self.signing_key.take();
let entry_count = self.entries.len() as u32;
let mut file = self.writer.into_inner().map_err(|e| e.into_error())?;
if encryption_mode == EncryptionMode::Archive {
Self::encrypt_archive_payload_static(
&mut file,
&encryption_key.ok_or(EngramError::InvalidEncryptionMode)?,
)?;
}
file.seek(SeekFrom::Start(0))?;
let mut header = FileHeader::new();
header.central_directory_offset = cd_offset;
header.central_directory_size = cd_size;
header.entry_count = entry_count;
header.set_encryption_mode(encryption_mode);
header.write_to(&mut file)?;
let archive_crc32 = Self::compute_archive_crc32(&mut file)?;
let has_signature = if let Some(ref key) = signing_key {
file.seek(SeekFrom::End(0))?;
let sig_block = SignatureBlock::sign_archive(&mut file, key)?;
sig_block.write_to(&mut file)?;
true
} else {
false
};
file.seek(SeekFrom::End(0))?;
let end_record = EndRecord::new(
FORMAT_VERSION_MAJOR,
FORMAT_VERSION_MINOR,
cd_offset,
cd_size,
entry_count,
archive_crc32,
);
end_record.write_to_with_flags(&mut file, has_signature)?;
file.flush()?;
Ok(())
}
fn compute_archive_crc32(file: &mut File) -> Result<u32> {
let file_size = file.metadata()?.len();
file.seek(SeekFrom::Start(0))?;
let mut hasher = crc32fast::Hasher::new();
let mut buf = [0u8; 8192];
let mut remaining = file_size;
while remaining > 0 {
let to_read = std::cmp::min(remaining, buf.len() as u64) as usize;
file.read_exact(&mut buf[..to_read])?;
hasher.update(&buf[..to_read]);
remaining -= to_read as u64;
}
Ok(hasher.finalize())
}
fn select_compression(path: &str, size: usize) -> CompressionMethod {
if size < MIN_COMPRESSION_SIZE {
return CompressionMethod::None;
}
let extension = path.rsplit('.').next().unwrap_or("").to_lowercase();
match extension.as_str() {
"jpg" | "jpeg" | "png" | "gif" | "webp" | "mp3" | "mp4" | "zip" | "gz" | "bz2" => {
CompressionMethod::None
}
"json" | "txt" | "xml" | "html" | "css" | "js" | "ts" | "md" | "csv" => {
CompressionMethod::Zstd
}
"db" | "sqlite" | "sqlite3" => CompressionMethod::Zstd,
_ => CompressionMethod::Lz4,
}
}
fn compress_data(
&self,
data: &[u8],
compression: CompressionMethod,
) -> Result<(Vec<u8>, CompressionMethod)> {
if should_use_frames(data.len()) {
match compression {
CompressionMethod::None => return Ok((data.to_vec(), CompressionMethod::None)),
CompressionMethod::Lz4 | CompressionMethod::Zstd => {
let compressed = compress_frames(data, compression)?;
return Ok((compressed, compression));
}
}
}
let compressed = match compression {
CompressionMethod::None => return Ok((data.to_vec(), CompressionMethod::None)),
CompressionMethod::Lz4 => Self::compress_lz4(data)?,
CompressionMethod::Zstd => Self::compress_zstd(data)?,
};
if compressed.len() < data.len() {
Ok((compressed, compression))
} else {
Ok((data.to_vec(), CompressionMethod::None))
}
}
fn compress_lz4(data: &[u8]) -> Result<Vec<u8>> {
Ok(lz4_flex::compress_prepend_size(data))
}
fn compress_zstd(data: &[u8]) -> Result<Vec<u8>> {
zstd::encode_all(data, 6)
.map_err(|e| EngramError::CompressionFailed(format!("Zstd compression failed: {}", e)))
}
fn encrypt_file_data(&self, data: &[u8]) -> Result<Vec<u8>> {
let key = self
.encryption_key
.as_ref()
.ok_or(EngramError::InvalidEncryptionMode)?;
let nonce_bytes: [u8; 12] = rand::random();
#[allow(deprecated)]
let nonce = Nonce::from_slice(&nonce_bytes);
let cipher = Aes256Gcm::new(key.into());
let ciphertext_with_tag = cipher
.encrypt(nonce, data)
.map_err(|_| EngramError::EncryptionFailed)?;
let mut payload = Vec::with_capacity(12 + ciphertext_with_tag.len());
payload.extend_from_slice(&nonce_bytes);
payload.extend_from_slice(&ciphertext_with_tag);
Ok(payload)
}
fn encrypt_archive_payload_static(file: &mut File, key: &[u8; 32]) -> Result<()> {
file.seek(SeekFrom::Start(64))?;
let mut payload = Vec::new();
std::io::Read::read_to_end(file, &mut payload)?;
let nonce_bytes: [u8; 12] = rand::random();
#[allow(deprecated)]
let nonce = Nonce::from_slice(&nonce_bytes);
let cipher = Aes256Gcm::new(key.into());
let ciphertext_with_tag = cipher
.encrypt(nonce, payload.as_ref())
.map_err(|_| EngramError::EncryptionFailed)?;
file.seek(SeekFrom::Start(64))?;
file.write_all(&nonce_bytes)?;
file.write_all(&ciphertext_with_tag)?;
let new_size = 64 + 12 + ciphertext_with_tag.len() as u64;
file.set_len(new_size)?;
file.flush()?;
Ok(())
}
}