zesven/write/
mod.rs

1//! Archive writing API for 7z archives.
2//!
3//! This module provides the public API for creating 7z archives, including
4//! adding files, directories, and streams with various compression options.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use zesven::write::{Writer, WriteOptions};
10//!
11//! // Create an archive
12//! let mut writer = Writer::create_path("archive.7z")?;
13//!
14//! // Add files
15//! writer.add_path("file.txt", "file.txt".try_into()?)?;
16//!
17//! // Finish writing
18//! let result = writer.finish()?;
19//! println!("Wrote {} entries", result.entries_written);
20//! ```
21
22// Existing modules
23mod append;
24pub(crate) mod options;
25
26// Refactored modules
27mod codecs;
28mod compression;
29mod encoding_utils;
30mod entry_compression;
31mod entry_input;
32mod header_encode;
33mod header_encryption;
34mod metadata_encode;
35mod writer_init;
36
37// Re-exports
38pub use append::{AppendResult, ArchiveAppender};
39pub use options::{EntryMeta, Lzma2Variant, SolidOptions, WriteFilter, WriteOptions, WriteResult};
40
41use crate::ArchivePath;
42
43/// Maps zesven compression level (0-9) to Zstd level (1-22).
44///
45/// | Input | Zstd | Characteristic |
46/// |-------|------|----------------|
47/// | 0-1   | 1    | Fastest        |
48/// | 2     | 2    | Fast           |
49/// | 3     | 3    | Fast           |
50/// | 4     | 5    | Balanced       |
51/// | 5     | 7    | Balanced       |
52/// | 6     | 9    | Balanced       |
53/// | 7     | 12   | High           |
54/// | 8     | 15   | High           |
55/// | 9     | 19   | Maximum        |
56#[cfg(feature = "zstd")]
57const ZSTD_LEVEL_MAP: [i32; 10] = [1, 1, 2, 3, 5, 7, 9, 12, 15, 19];
58
59/// Maps zesven compression level (0-9) to Brotli quality (0-11).
60///
61/// | Input | Brotli | Characteristic |
62/// |-------|--------|----------------|
63/// | 0-6   | 0-6    | Direct mapping |
64/// | 7     | 8      | High           |
65/// | 8     | 10     | High           |
66/// | 9     | 11     | Maximum        |
67#[cfg(feature = "brotli")]
68const BROTLI_QUALITY_MAP: [u32; 10] = [0, 1, 2, 3, 4, 5, 6, 8, 10, 11];
69
70/// State of the writer.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72enum WriterState {
73    /// Accepting new entries.
74    AcceptingEntries,
75    /// Building the archive (flushing, writing headers).
76    Building,
77    /// Archive is finished.
78    Finished,
79}
80
81/// Entry data stored for header writing.
82#[derive(Debug)]
83pub(crate) struct PendingEntry {
84    /// Archive path.
85    path: ArchivePath,
86    /// Entry metadata.
87    meta: options::EntryMeta,
88    /// Uncompressed size.
89    uncompressed_size: u64,
90}
91
92/// Entry buffered for solid compression.
93#[derive(Debug)]
94struct SolidBufferEntry {
95    /// Archive path.
96    path: ArchivePath,
97    /// Entry data (uncompressed).
98    data: Vec<u8>,
99    /// Entry metadata.
100    meta: options::EntryMeta,
101    /// CRC32 of uncompressed data.
102    crc: u32,
103}
104
105/// Encryption metadata for a folder (used when content encryption is enabled).
106#[cfg(feature = "aes")]
107#[derive(Debug, Clone)]
108pub(crate) struct EncryptedFolderInfo {
109    /// AES properties (salt, iv, cycles) for this folder.
110    aes_properties: Vec<u8>,
111    /// Size after compression (before encryption) - needed for unpack_sizes in 2-coder chain.
112    compressed_size: u64,
113}
114
115/// Filter metadata for a folder (used when pre-compression filter is enabled).
116#[derive(Debug, Clone)]
117pub(crate) struct FilteredFolderInfo {
118    /// Filter method ID bytes.
119    filter_method: Vec<u8>,
120    /// Filter properties (e.g., delta distance).
121    filter_properties: Option<Vec<u8>>,
122    /// Size after filtering (before compression) - same as uncompressed size for filters.
123    filtered_size: u64,
124}
125
126/// Metadata for BCJ2 4-stream folder.
127///
128/// BCJ2 is a special filter that splits x86 code into 4 streams:
129/// - Stream 0 (Main): Main code with E8/E9 instructions
130/// - Stream 1 (Call): CALL destinations, big-endian
131/// - Stream 2 (Jump): JMP destinations, big-endian
132/// - Stream 3 (Range): Range encoder output
133#[derive(Debug, Clone)]
134struct Bcj2FolderInfo {
135    /// Sizes of the 4 pack streams [main, call, jump, range]
136    pack_sizes: [u64; 4],
137}
138
139/// Stream info for pack/unpack info.
140#[derive(Debug, Default)]
141struct StreamInfo {
142    /// Packed sizes for each folder. Most folders have 1, BCJ2 has 4.
143    /// For BCJ2 folders, this is empty; use bcj2_folder_info instead.
144    pack_sizes: Vec<u64>,
145    /// Total unpacked size for each folder.
146    unpack_sizes: Vec<u64>,
147    /// CRCs for each folder (used for non-solid).
148    crcs: Vec<u32>,
149    /// Number of unpack streams in each folder (for solid archives).
150    num_unpack_streams_per_folder: Vec<u64>,
151    /// Sizes of each substream within solid blocks.
152    substream_sizes: Vec<u64>,
153    /// CRCs of each substream within solid blocks.
154    substream_crcs: Vec<u32>,
155    /// Per-folder encryption info (Some if encrypted, None if not).
156    #[cfg(feature = "aes")]
157    encryption_info: Vec<Option<EncryptedFolderInfo>>,
158    /// Per-folder filter info (Some if filtered, None if not).
159    filter_info: Vec<Option<FilteredFolderInfo>>,
160    /// Per-folder BCJ2 info (Some for BCJ2 folders, None for regular).
161    bcj2_folder_info: Vec<Option<Bcj2FolderInfo>>,
162}
163
164/// A 7z archive writer.
165pub struct Writer<W> {
166    sink: W,
167    options: options::WriteOptions,
168    state: WriterState,
169    entries: Vec<PendingEntry>,
170    stream_info: StreamInfo,
171    /// Total compressed bytes written.
172    compressed_bytes: u64,
173    /// Buffer for solid compression.
174    solid_buffer: Vec<SolidBufferEntry>,
175    /// Current size of solid buffer (uncompressed bytes).
176    solid_buffer_size: u64,
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use std::io::Cursor;
183
184    #[test]
185    fn test_writer_create() {
186        let buffer = Cursor::new(Vec::new());
187        let writer = Writer::create(buffer).unwrap();
188        assert_eq!(writer.state, WriterState::AcceptingEntries);
189    }
190
191    #[test]
192    fn test_writer_options() {
193        let buffer = Cursor::new(Vec::new());
194        let writer = Writer::create(buffer)
195            .unwrap()
196            .options(WriteOptions::new().level(9).unwrap());
197        assert_eq!(writer.options.level, 9);
198    }
199
200    #[cfg(feature = "lzma")]
201    #[test]
202    fn test_writer_add_bytes_and_finish() {
203        let buffer = Cursor::new(Vec::new());
204        let mut writer = Writer::create(buffer).unwrap();
205
206        let path = ArchivePath::new("test.txt").unwrap();
207        writer.add_bytes(path, b"Hello, World!").unwrap();
208
209        let result = writer.finish().unwrap();
210        assert_eq!(result.entries_written, 1);
211        assert_eq!(result.total_size, 13);
212    }
213
214    #[test]
215    fn test_writer_empty_archive() {
216        let buffer = Cursor::new(Vec::new());
217        let writer = Writer::create(buffer).unwrap();
218        let result = writer.finish().unwrap();
219        assert_eq!(result.entries_written, 0);
220    }
221
222    #[test]
223    fn test_writer_with_directory() {
224        let buffer = Cursor::new(Vec::new());
225        let mut writer = Writer::create(buffer).unwrap();
226
227        let dir_path = ArchivePath::new("mydir").unwrap();
228        writer
229            .add_directory(dir_path, options::EntryMeta::directory())
230            .unwrap();
231
232        let result = writer.finish().unwrap();
233        assert_eq!(result.entries_written, 0);
234        assert_eq!(result.directories_written, 1);
235    }
236
237    #[cfg(feature = "lzma")]
238    #[test]
239    fn test_writer_with_anti_item() {
240        let buffer = Cursor::new(Vec::new());
241        let mut writer = Writer::create(buffer).unwrap();
242
243        // Add a regular file
244        let file_path = ArchivePath::new("keep.txt").unwrap();
245        writer.add_bytes(file_path, b"Keep this file").unwrap();
246
247        // Add an anti-item (marks a file for deletion)
248        let anti_path = ArchivePath::new("deleted.txt").unwrap();
249        writer.add_anti_item(anti_path).unwrap();
250
251        // Add an anti-directory
252        let anti_dir_path = ArchivePath::new("deleted_dir").unwrap();
253        writer.add_anti_directory(anti_dir_path).unwrap();
254
255        let result = writer.finish().unwrap();
256        assert_eq!(result.entries_written, 1); // Only the regular file counts as written
257        assert_eq!(result.directories_written, 1); // Anti-directory is counted as directory
258    }
259
260    #[cfg(feature = "lzma")]
261    #[test]
262    fn test_anti_item_roundtrip() {
263        use crate::read::Archive;
264
265        let buffer = Cursor::new(Vec::new());
266        let mut writer = Writer::create(buffer).unwrap();
267
268        // Add mixed entries
269        let file_path = ArchivePath::new("normal.txt").unwrap();
270        writer.add_bytes(file_path, b"Normal content").unwrap();
271
272        let anti_path = ArchivePath::new("delete_me.txt").unwrap();
273        writer.add_anti_item(anti_path).unwrap();
274
275        let (_result, cursor) = writer.finish_into_inner().unwrap();
276        let data = cursor.into_inner();
277
278        // Read it back
279        let archive = Archive::open(Cursor::new(data)).unwrap();
280
281        // Check entries
282        let entries = archive.entries();
283        assert_eq!(entries.len(), 2);
284
285        // Normal file
286        let normal = &entries[0];
287        assert_eq!(normal.path.as_str(), "normal.txt");
288        assert!(!normal.is_anti);
289        assert!(!normal.is_directory);
290
291        // Anti-item
292        let anti = &entries[1];
293        assert_eq!(anti.path.as_str(), "delete_me.txt");
294        assert!(anti.is_anti);
295        assert!(!anti.is_directory);
296    }
297
298    #[cfg(feature = "lzma")]
299    #[test]
300    fn test_comment_roundtrip() {
301        use crate::read::Archive;
302
303        let buffer = Cursor::new(Vec::new());
304        let options = WriteOptions::new().comment("Test archive comment with Unicode: 你好世界");
305        let mut writer = Writer::create(buffer).unwrap().options(options);
306
307        let file_path = ArchivePath::new("test.txt").unwrap();
308        writer.add_bytes(file_path, b"Hello").unwrap();
309
310        let (_result, cursor) = writer.finish_into_inner().unwrap();
311        let data = cursor.into_inner();
312
313        // Read it back
314        let archive = Archive::open(Cursor::new(data)).unwrap();
315
316        // Verify comment
317        let comment = archive.comment();
318        assert!(comment.is_some());
319        assert_eq!(
320            comment.unwrap(),
321            "Test archive comment with Unicode: 你好世界"
322        );
323    }
324
325    #[cfg(feature = "lzma")]
326    #[test]
327    fn test_no_comment() {
328        use crate::read::Archive;
329
330        let buffer = Cursor::new(Vec::new());
331        let mut writer = Writer::create(buffer).unwrap();
332
333        let file_path = ArchivePath::new("test.txt").unwrap();
334        writer.add_bytes(file_path, b"Hello").unwrap();
335
336        let (_result, cursor) = writer.finish_into_inner().unwrap();
337        let data = cursor.into_inner();
338
339        // Read it back
340        let archive = Archive::open(Cursor::new(data)).unwrap();
341
342        // Verify no comment
343        assert!(archive.comment().is_none());
344    }
345
346    #[cfg(feature = "aes")]
347    #[test]
348    fn test_header_encryption_write() {
349        use crate::crypto::Password;
350        use crate::format::property_id;
351
352        let buffer = Cursor::new(Vec::new());
353        let password = Password::new("secret123");
354
355        // Create archive with encrypted header
356        let (result, cursor) = {
357            let mut writer = Writer::create(buffer).unwrap().options(
358                WriteOptions::new()
359                    .password(password.clone())
360                    .encrypt_header(true),
361            );
362
363            let path = ArchivePath::new("secret.txt").unwrap();
364            writer.add_bytes(path, b"Secret content!").unwrap();
365
366            writer.finish_into_inner().unwrap()
367        };
368
369        assert_eq!(result.entries_written, 1);
370        let archive_data = cursor.into_inner();
371        assert!(!archive_data.is_empty());
372
373        // Verify the archive structure:
374        // - First 32 bytes are signature header
375        // - After compressed data, we should have an ENCODED_HEADER marker
376        // Find the header marker
377        let header_pos = {
378            // Read signature header to get next header offset
379            let offset = u64::from_le_bytes(archive_data[12..20].try_into().unwrap());
380            32 + offset as usize
381        };
382
383        // The header should start with ENCODED_HEADER (0x17)
384        assert_eq!(
385            archive_data[header_pos],
386            property_id::ENCODED_HEADER,
387            "Archive should have encrypted header"
388        );
389    }
390
391    #[cfg(feature = "aes")]
392    #[test]
393    fn test_header_encryption_without_password() {
394        // Verify that encrypt_header(true) without a password does nothing
395        let buffer = Cursor::new(Vec::new());
396
397        let (result, cursor) = {
398            let mut writer = Writer::create(buffer)
399                .unwrap()
400                .options(WriteOptions::new().encrypt_header(true)); // No password set
401
402            let path = ArchivePath::new("test.txt").unwrap();
403            writer.add_bytes(path, b"Hello").unwrap();
404
405            writer.finish_into_inner().unwrap()
406        };
407
408        assert_eq!(result.entries_written, 1);
409        let archive_data = cursor.into_inner();
410
411        // Without password, header should NOT be encrypted
412        let header_pos = {
413            let offset = u64::from_le_bytes(archive_data[12..20].try_into().unwrap());
414            32 + offset as usize
415        };
416
417        // Should start with regular HEADER marker (0x01), not ENCODED_HEADER (0x17)
418        assert_eq!(
419            archive_data[header_pos],
420            crate::format::property_id::HEADER,
421            "Without password, header should not be encrypted"
422        );
423    }
424
425    #[cfg(all(feature = "aes", feature = "lzma2"))]
426    #[test]
427    fn test_content_encryption_write_and_read() {
428        use crate::crypto::Password;
429        use crate::read::Archive;
430
431        let buffer = Cursor::new(Vec::new());
432        let password = Password::new("secret_password_123");
433
434        // Create archive with encrypted content
435        let (result, cursor) = {
436            let mut writer = Writer::create(buffer).unwrap().options(
437                WriteOptions::new()
438                    .password(password.clone())
439                    .encrypt_data(true),
440            );
441
442            let path = ArchivePath::new("secret.txt").unwrap();
443            writer
444                .add_bytes(path, b"This is encrypted content!")
445                .unwrap();
446
447            writer.finish_into_inner().unwrap()
448        };
449
450        assert_eq!(result.entries_written, 1);
451        let archive_data = cursor.into_inner();
452        assert!(!archive_data.is_empty());
453
454        // Read the archive back with correct password
455        let mut archive =
456            Archive::open_with_password(Cursor::new(archive_data.clone()), password.clone())
457                .expect("Should open archive with correct password");
458
459        // Extract content to verify
460        let extracted = archive
461            .extract_to_vec("secret.txt")
462            .expect("Should extract encrypted content");
463
464        assert_eq!(extracted, b"This is encrypted content!");
465    }
466}