mod append;
pub(crate) mod options;
pub(crate) mod codecs;
pub(crate) mod compression;
mod encoding_utils;
mod entry_compression;
mod entry_input;
pub(crate) mod header_compression;
pub(crate) mod header_encode;
mod header_encryption;
mod metadata_encode;
mod streaming_entry;
mod writer_init;
pub use append::{AppendResult, ArchiveAppender};
pub use options::{EntryMeta, SolidOptions, WriteFilter, WriteOptions, WriteResult};
use crate::ArchivePath;
#[cfg(feature = "zstd")]
const ZSTD_LEVEL_MAP: [i32; 10] = [1, 1, 2, 3, 5, 7, 9, 12, 15, 19];
#[cfg(feature = "brotli")]
const BROTLI_QUALITY_MAP: [u32; 10] = [0, 1, 2, 3, 4, 5, 6, 8, 10, 11];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WriterState {
AcceptingEntries,
Building,
Finished,
Failed,
}
#[derive(Debug)]
pub(crate) struct PendingEntry {
pub(crate) path: ArchivePath,
pub(crate) meta: options::EntryMeta,
pub(crate) uncompressed_size: u64,
}
#[derive(Debug)]
pub(crate) struct BufferedEntry {
path: ArchivePath,
data: Vec<u8>,
meta: options::EntryMeta,
crc: u32,
options: std::sync::Arc<options::WriteOptions>,
}
#[cfg(feature = "aes")]
#[derive(Debug, Clone)]
pub(crate) struct EncryptedFolderInfo {
aes_properties: Vec<u8>,
compressed_size: u64,
}
#[derive(Debug, Clone)]
pub(crate) struct FilteredFolderInfo {
filter_method: Vec<u8>,
filter_properties: Option<Vec<u8>>,
filtered_size: u64,
}
#[derive(Debug, Clone)]
pub(crate) struct Bcj2FolderInfo {
pack_sizes: [u64; 4],
stream_sizes: [u64; 3],
properties: [Vec<u8>; 3],
method: crate::codec::CodecMethod,
}
#[derive(Debug, Default)]
pub(crate) struct StreamInfo {
pub(crate) pack_sizes: Vec<u64>,
pub(crate) unpack_sizes: Vec<u64>,
pub(crate) crcs: Vec<Option<u32>>,
pub(crate) num_unpack_streams_per_folder: Vec<u64>,
pub(crate) substream_sizes: Vec<u64>,
pub(crate) substream_crcs: Vec<u32>,
#[cfg(feature = "aes")]
pub(crate) encryption_info: Vec<Option<EncryptedFolderInfo>>,
pub(crate) filter_info: Vec<Option<FilteredFolderInfo>>,
pub(crate) bcj2_folder_info: Vec<Option<Bcj2FolderInfo>>,
pub(crate) coder_methods: Vec<crate::codec::CodecMethod>,
pub(crate) coder_properties: Vec<Vec<u8>>,
}
pub struct Writer<W> {
sink: W,
start_pos: u64,
options: options::WriteOptions,
state: WriterState,
entries: Vec<PendingEntry>,
stream_info: StreamInfo,
compressed_bytes: u64,
accepted_bytes: u64,
announced: Vec<String>,
solid_buffer: Vec<BufferedEntry>,
solid_buffer_size: u64,
pending_batch: Vec<BufferedEntry>,
pending_batch_size: u64,
active_options: std::sync::Arc<options::WriteOptions>,
last_path: Option<String>,
#[cfg(feature = "aes")]
archive_salt: Option<Vec<u8>>,
#[cfg(feature = "aes")]
archive_password: Option<crate::crypto::Password>,
progress: Option<std::sync::Arc<std::sync::Mutex<Box<dyn crate::progress::ProgressReporter>>>>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn test_writer_create() {
let buffer = Cursor::new(Vec::new());
let writer = Writer::create(buffer).unwrap();
assert_eq!(writer.state, WriterState::AcceptingEntries);
}
#[test]
fn test_writer_options() {
let buffer = Cursor::new(Vec::new());
let writer = Writer::create(buffer)
.unwrap()
.options(WriteOptions::new().level(9).unwrap());
assert_eq!(writer.options.level, 9);
}
#[cfg(feature = "lzma")]
#[test]
fn test_writer_add_bytes_and_finish() {
let buffer = Cursor::new(Vec::new());
let mut writer = Writer::create(buffer).unwrap();
let path = ArchivePath::new("test.txt").unwrap();
writer.add_bytes(path, b"Hello, World!").unwrap();
let result = writer.finish().unwrap();
assert_eq!(result.entries_written, 1);
assert_eq!(result.total_size, 13);
}
#[test]
fn test_writer_empty_archive() {
let buffer = Cursor::new(Vec::new());
let writer = Writer::create(buffer).unwrap();
let result = writer.finish().unwrap();
assert_eq!(result.entries_written, 0);
}
#[test]
fn test_writer_with_directory() {
let buffer = Cursor::new(Vec::new());
let mut writer = Writer::create(buffer).unwrap();
let dir_path = ArchivePath::new("mydir").unwrap();
writer
.add_directory(dir_path, options::EntryMeta::directory())
.unwrap();
let result = writer.finish().unwrap();
assert_eq!(result.entries_written, 0);
assert_eq!(result.directories_written, 1);
}
#[cfg(feature = "lzma")]
#[test]
fn test_writer_with_anti_item() {
let buffer = Cursor::new(Vec::new());
let mut writer = Writer::create(buffer).unwrap();
let file_path = ArchivePath::new("keep.txt").unwrap();
writer.add_bytes(file_path, b"Keep this file").unwrap();
let anti_path = ArchivePath::new("deleted.txt").unwrap();
writer.add_anti_item(anti_path).unwrap();
let anti_dir_path = ArchivePath::new("deleted_dir").unwrap();
writer.add_anti_directory(anti_dir_path).unwrap();
let result = writer.finish().unwrap();
assert_eq!(result.entries_written, 1); assert_eq!(result.directories_written, 1); }
#[cfg(feature = "lzma")]
#[test]
fn test_anti_item_roundtrip() {
use crate::read::Archive;
let buffer = Cursor::new(Vec::new());
let mut writer = Writer::create(buffer).unwrap();
let file_path = ArchivePath::new("normal.txt").unwrap();
writer.add_bytes(file_path, b"Normal content").unwrap();
let anti_path = ArchivePath::new("delete_me.txt").unwrap();
writer.add_anti_item(anti_path).unwrap();
let (_result, cursor) = writer.finish_into_inner().unwrap();
let data = cursor.into_inner();
let archive = Archive::open(Cursor::new(data)).unwrap();
let entries = archive.entries();
assert_eq!(entries.len(), 2);
let normal = &entries[0];
assert_eq!(normal.path.as_str(), "normal.txt");
assert!(!normal.is_anti);
assert!(!normal.is_directory);
let anti = &entries[1];
assert_eq!(anti.path.as_str(), "delete_me.txt");
assert!(anti.is_anti);
assert!(!anti.is_directory);
}
#[cfg(feature = "lzma")]
#[test]
fn test_comment_roundtrip() {
use crate::read::Archive;
let buffer = Cursor::new(Vec::new());
let options = WriteOptions::new().comment("Test archive comment with Unicode: ä½ å¥½ä¸–ç•Œ");
let mut writer = Writer::create(buffer).unwrap().options(options);
let file_path = ArchivePath::new("test.txt").unwrap();
writer.add_bytes(file_path, b"Hello").unwrap();
let (_result, cursor) = writer.finish_into_inner().unwrap();
let data = cursor.into_inner();
let archive = Archive::open(Cursor::new(data)).unwrap();
let comment = archive.comment();
assert!(comment.is_some());
assert_eq!(
comment.unwrap(),
"Test archive comment with Unicode: ä½ å¥½ä¸–ç•Œ"
);
}
#[cfg(feature = "lzma")]
#[test]
fn test_no_comment() {
use crate::read::Archive;
let buffer = Cursor::new(Vec::new());
let mut writer = Writer::create(buffer).unwrap();
let file_path = ArchivePath::new("test.txt").unwrap();
writer.add_bytes(file_path, b"Hello").unwrap();
let (_result, cursor) = writer.finish_into_inner().unwrap();
let data = cursor.into_inner();
let archive = Archive::open(Cursor::new(data)).unwrap();
assert!(archive.comment().is_none());
}
#[cfg(feature = "aes")]
#[test]
fn test_header_encryption_write() {
use crate::crypto::Password;
use crate::format::property_id;
let buffer = Cursor::new(Vec::new());
let password = Password::new("secret123");
let (result, cursor) = {
let mut writer = Writer::create(buffer).unwrap().options(
WriteOptions::new()
.password(password.clone())
.encrypt_header(true),
);
let path = ArchivePath::new("secret.txt").unwrap();
writer.add_bytes(path, b"Secret content!").unwrap();
writer.finish_into_inner().unwrap()
};
assert_eq!(result.entries_written, 1);
let archive_data = cursor.into_inner();
assert!(!archive_data.is_empty());
let header_pos = {
let offset = u64::from_le_bytes(archive_data[12..20].try_into().unwrap());
32 + offset as usize
};
assert_eq!(
archive_data[header_pos],
property_id::ENCODED_HEADER,
"Archive should have encrypted header"
);
}
#[cfg(feature = "aes")]
#[test]
fn test_encrypted_header_layout_is_standard() {
use crate::crypto::Password;
use crate::format::property_id;
use crate::format::reader::read_variable_u64;
const SIGNATURE_HEADER_SIZE: u64 = 32;
let buffer = Cursor::new(Vec::new());
let (_result, cursor) = {
let mut writer = Writer::create(buffer).unwrap().options(
WriteOptions::new()
.password(Password::new("secret123"))
.encrypt_header(true),
);
writer
.add_bytes(ArchivePath::new("secret.txt").unwrap(), b"Secret content!")
.unwrap();
writer.finish_into_inner().unwrap()
};
let archive = cursor.into_inner();
let next_header_offset = u64::from_le_bytes(archive[12..20].try_into().unwrap());
let next_header_size = u64::from_le_bytes(archive[20..28].try_into().unwrap());
let structure_start = (SIGNATURE_HEADER_SIZE + next_header_offset) as usize;
assert_eq!(archive[structure_start], property_id::ENCODED_HEADER);
assert_eq!(archive[structure_start + 1], property_id::PACK_INFO);
let mut rest = &archive[structure_start + 2..];
let pack_pos = read_variable_u64(&mut rest).unwrap();
let num_pack_streams = read_variable_u64(&mut rest).unwrap();
assert_eq!(num_pack_streams, 1);
assert_eq!(rest[0], property_id::SIZE);
rest = &rest[1..];
let pack_size = read_variable_u64(&mut rest).unwrap();
assert!(
pack_pos > 0,
"the packed header stream cannot start at the beginning of the data area, \
where the file data lives"
);
assert_eq!(
SIGNATURE_HEADER_SIZE + pack_pos + pack_size,
structure_start as u64,
"the packed header stream must end exactly where the structure describing it begins"
);
assert_eq!(
structure_start as u64 + next_header_size,
archive.len() as u64,
"the next header is the structure alone, not the structure plus its payload"
);
}
#[cfg(feature = "aes")]
#[test]
fn test_encryption_without_password_is_rejected() {
for options in [
WriteOptions::new().encrypt_header(true),
WriteOptions::new().encrypt_data(true),
] {
let mut writer = Writer::create(Cursor::new(Vec::new()))
.unwrap()
.options(options);
let error = writer
.add_bytes(ArchivePath::new("test.txt").unwrap(), b"Hello")
.expect_err("encryption without a password must be rejected");
assert!(
error.to_string().contains("without a password"),
"unexpected error: {error}"
);
}
}
#[cfg(all(feature = "aes", feature = "lzma2"))]
#[test]
fn test_content_encryption_write_and_read() {
use crate::crypto::Password;
use crate::read::Archive;
let buffer = Cursor::new(Vec::new());
let password = Password::new("secret_password_123");
let (result, cursor) = {
let mut writer = Writer::create(buffer).unwrap().options(
WriteOptions::new()
.password(password.clone())
.encrypt_data(true),
);
let path = ArchivePath::new("secret.txt").unwrap();
writer
.add_bytes(path, b"This is encrypted content!")
.unwrap();
writer.finish_into_inner().unwrap()
};
assert_eq!(result.entries_written, 1);
let archive_data = cursor.into_inner();
assert!(!archive_data.is_empty());
let mut archive =
Archive::open_with_password(Cursor::new(archive_data.clone()), password.clone())
.expect("Should open archive with correct password");
let extracted = archive
.extract_to_vec("secret.txt")
.expect("Should extract encrypted content");
assert_eq!(extracted, b"This is encrypted content!");
}
}