Skip to main content

cavs_format/
writer.rs

1//! Streaming writer for CAVS-1 files.
2//!
3//! Chunk payloads are deduplicated at ingest and streamed straight into the
4//! DATA section as they arrive; only the (small) tables are buffered until
5//! `finish()`.
6
7use crate::wire::*;
8use crate::{
9    ChunkRecord, FormatError, Integrity, Result, SectionType, SegmentRecord, TrackRecord,
10    CHUNK_FLAG_ZSTD, COMPRESSION_NONE, COMPRESSION_ZSTD, MAGIC, SUPERBLOCK_LEN, VERSION_MAJOR,
11    VERSION_MINOR,
12};
13use cavs_hash::{content_signature_message, hash_chunk, merkle_root, ChunkHash, Hasher};
14use cavs_store::CasIndex;
15use ed25519_dalek::{Signer, SigningKey};
16use std::fs::File;
17use std::io::{BufWriter, Seek, SeekFrom, Write as _};
18use std::path::Path;
19
20/// Minimum chunk size worth attempting compression on.
21const COMPRESS_MIN_LEN: usize = 512;
22
23/// Summary of a finished pack, for reporting.
24#[derive(Debug, Clone)]
25pub struct PackStats {
26    pub file_size: u64,
27    pub unique_chunks: u64,
28    pub logical_chunks: u64,
29    /// Bytes before dedup (every add_chunk call counted).
30    pub logical_raw: u64,
31    /// Bytes of unique chunks, uncompressed.
32    pub unique_raw: u64,
33    /// Bytes of unique chunks as stored (after compression).
34    pub stored: u64,
35    pub merkle_root: ChunkHash,
36}
37
38pub struct Writer {
39    out: BufWriter<File>,
40    cas: CasIndex,
41    chunks: Vec<ChunkRecord>,
42    tracks: Vec<TrackRecord>,
43    segments: Vec<SegmentRecord>,
44    dict: Vec<u32>,
45    meta: Vec<(String, String)>,
46    data_len: u64,
47    data_hasher: Hasher,
48    logical_raw: u64,
49    logical_chunks: u64,
50    compression: u8,
51    zstd_level: i32,
52    timescale: u32,
53    asset_uuid: [u8; 16],
54    signer: Option<SigningKey>,
55}
56
57impl Writer {
58    /// Create a new CAVS-1 file at `path`. `compress` enables per-chunk zstd
59    /// (chunks that don't shrink are stored raw regardless).
60    pub fn create(
61        path: &Path,
62        asset_uuid: [u8; 16],
63        timescale: u32,
64        compress: bool,
65    ) -> Result<Self> {
66        let mut out = BufWriter::new(File::create(path)?);
67        // Superblock placeholder; patched in finish().
68        out.write_all(&[0u8; SUPERBLOCK_LEN as usize])?;
69        Ok(Self {
70            out,
71            cas: CasIndex::new(),
72            chunks: Vec::new(),
73            tracks: Vec::new(),
74            segments: Vec::new(),
75            dict: Vec::new(),
76            meta: Vec::new(),
77            data_len: 0,
78            data_hasher: Hasher::new(),
79            logical_raw: 0,
80            logical_chunks: 0,
81            compression: if compress {
82                COMPRESSION_ZSTD
83            } else {
84                COMPRESSION_NONE
85            },
86            zstd_level: 3,
87            timescale,
88            asset_uuid,
89            signer: None,
90        })
91    }
92
93    /// zstd level for chunk storage/wire compression (default 3).
94    pub fn set_zstd_level(&mut self, level: i32) {
95        self.zstd_level = level;
96    }
97
98    /// Sign the packed content with this Ed25519 secret key. The signature
99    /// (over the canonical content message, see
100    /// [`cavs_hash::content_signature_message`]) and the public key are
101    /// embedded as `sig.ed25519` / `sig.pubkey` meta entries at finish().
102    pub fn sign_with(&mut self, secret: &[u8; 32]) {
103        self.signer = Some(SigningKey::from_bytes(secret));
104    }
105
106    /// Add a batch of chunk payloads given as byte ranges of `data`,
107    /// preparing the expensive per-chunk work (BLAKE3 identity + zstd
108    /// compression) on all cores. Ingest order, dedup decisions and the
109    /// stored byte stream are exactly those of calling [`Writer::add_chunk`]
110    /// on each range in order — the resulting file is byte-identical; only
111    /// wall-clock changes (3–7× faster on real payloads, more at high zstd
112    /// levels).
113    pub fn add_chunks_parallel(
114        &mut self,
115        data: &[u8],
116        ranges: &[std::ops::Range<usize>],
117    ) -> Result<Vec<u32>> {
118        use rayon::prelude::*;
119
120        // Identity of every range, in parallel.
121        let hashes: Vec<ChunkHash> = ranges
122            .par_iter()
123            .map(|r| hash_chunk(&data[r.clone()]))
124            .collect();
125
126        // Only first occurrences of not-yet-stored payloads get compressed —
127        // exactly the set the serial path would compress.
128        let mut first_occurrence = vec![false; ranges.len()];
129        let mut batch_seen = std::collections::HashSet::new();
130        for (i, h) in hashes.iter().enumerate() {
131            if self.cas.get(h).is_none() && batch_seen.insert(*h) {
132                first_occurrence[i] = true;
133            }
134        }
135
136        let compress = self.compression == COMPRESSION_ZSTD;
137        let level = self.zstd_level;
138        let prepared: std::result::Result<Vec<Option<Vec<u8>>>, std::io::Error> = ranges
139            .par_iter()
140            .enumerate()
141            .map(|(i, r)| {
142                let raw = &data[r.clone()];
143                if !first_occurrence[i] || !compress || raw.len() < COMPRESS_MIN_LEN {
144                    return Ok(None);
145                }
146                let c = zstd::bulk::compress(raw, level)?;
147                // Keep compression only if it actually pays for itself.
148                Ok((c.len() < raw.len() - raw.len() / 16).then_some(c))
149            })
150            .collect();
151        let prepared = prepared.map_err(FormatError::Zstd)?;
152
153        let mut out = Vec::with_capacity(ranges.len());
154        for ((r, hash), comp) in ranges.iter().zip(hashes).zip(prepared) {
155            let raw = &data[r.clone()];
156            self.logical_raw += raw.len() as u64;
157            self.logical_chunks += 1;
158            let interned = self.cas.intern(hash, self.chunks.len() as u32);
159            if !interned.is_new() {
160                out.push(interned.index());
161                continue;
162            }
163            let (stored_slice, flags): (&[u8], u32) = match &comp {
164                Some(c) => (c, CHUNK_FLAG_ZSTD),
165                None => (raw, 0),
166            };
167            self.out.write_all(stored_slice)?;
168            self.data_hasher.update(stored_slice);
169            self.chunks.push(ChunkRecord {
170                hash,
171                data_offset: self.data_len,
172                len_raw: raw.len() as u32,
173                len_stored: stored_slice.len() as u32,
174                flags,
175            });
176            self.data_len += stored_slice.len() as u64;
177            out.push(interned.index());
178        }
179        Ok(out)
180    }
181
182    /// Add one chunk payload. Returns its chunk-table index. Duplicate
183    /// payloads (same BLAKE3) are not stored again.
184    pub fn add_chunk(&mut self, raw: &[u8]) -> Result<u32> {
185        self.logical_raw += raw.len() as u64;
186        self.logical_chunks += 1;
187        let hash = hash_chunk(raw);
188        let interned = self.cas.intern(hash, self.chunks.len() as u32);
189        if !interned.is_new() {
190            return Ok(interned.index());
191        }
192
193        let mut flags = 0u32;
194        let stored: Vec<u8>;
195        let stored_slice: &[u8] =
196            if self.compression == COMPRESSION_ZSTD && raw.len() >= COMPRESS_MIN_LEN {
197                stored = zstd::bulk::compress(raw, self.zstd_level).map_err(FormatError::Zstd)?;
198                // Keep compression only if it actually pays for itself.
199                if stored.len() < raw.len() - raw.len() / 16 {
200                    flags |= CHUNK_FLAG_ZSTD;
201                    &stored
202                } else {
203                    raw
204                }
205            } else {
206                raw
207            };
208
209        self.out.write_all(stored_slice)?;
210        self.data_hasher.update(stored_slice);
211        self.chunks.push(ChunkRecord {
212            hash,
213            data_offset: self.data_len,
214            len_raw: raw.len() as u32,
215            len_stored: stored_slice.len() as u32,
216            flags,
217        });
218        self.data_len += stored_slice.len() as u64;
219        Ok(interned.index())
220    }
221
222    pub fn add_track(&mut self, track: TrackRecord) -> Result<()> {
223        for &c in &track.init_chunks {
224            self.check_chunk_index(c)?;
225        }
226        self.tracks.push(track);
227        Ok(())
228    }
229
230    pub fn add_segment(&mut self, segment: SegmentRecord) -> Result<()> {
231        for &c in &segment.chunks {
232            self.check_chunk_index(c)?;
233        }
234        self.segments.push(segment);
235        Ok(())
236    }
237
238    /// Mark a chunk as part of the global dictionary (privileged reuse:
239    /// init segments, bootstrap assets, shared headers...).
240    pub fn pin_dict(&mut self, chunk_index: u32) -> Result<()> {
241        self.check_chunk_index(chunk_index)?;
242        if !self.dict.contains(&chunk_index) {
243            self.dict.push(chunk_index);
244        }
245        Ok(())
246    }
247
248    pub fn set_meta(&mut self, key: &str, value: &str) {
249        self.meta.push((key.to_string(), value.to_string()));
250    }
251
252    pub fn chunk_count(&self) -> u32 {
253        self.chunks.len() as u32
254    }
255
256    fn check_chunk_index(&self, index: u32) -> Result<()> {
257        if (index as usize) < self.chunks.len() {
258            Ok(())
259        } else {
260            Err(FormatError::ChunkIndexOutOfRange(index))
261        }
262    }
263
264    /// Write all tables, the section directory and the final superblock.
265    pub fn finish(mut self) -> Result<PackStats> {
266        let hashes: Vec<ChunkHash> = self.chunks.iter().map(|c| c.hash).collect();
267        let root = merkle_root(&hashes);
268        let unique_raw: u64 = self.chunks.iter().map(|c| c.len_raw as u64).sum();
269        let stored: u64 = self.chunks.iter().map(|c| c.len_stored as u64).sum();
270
271        let integrity = Integrity {
272            merkle_root: root,
273            chunk_count: self.chunks.len() as u64,
274            total_raw: unique_raw,
275            total_stored: stored,
276        };
277
278        if let Some(signer) = &self.signer {
279            let message = content_signature_message(&root, integrity.chunk_count);
280            let sig = signer.sign(&message);
281            let sig_hex: String = sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
282            let pk_hex: String = signer
283                .verifying_key()
284                .to_bytes()
285                .iter()
286                .map(|b| format!("{b:02x}"))
287                .collect();
288            self.meta.push(("sig.ed25519".to_string(), sig_hex));
289            self.meta.push(("sig.pubkey".to_string(), pk_hex));
290        }
291
292        // DATA was streamed right after the superblock.
293        let mut dir: Vec<(SectionType, u64, u64, ChunkHash)> = vec![(
294            SectionType::Data,
295            SUPERBLOCK_LEN,
296            self.data_len,
297            self.data_hasher.finalize(),
298        )];
299
300        let sections: Vec<(SectionType, Vec<u8>)> = vec![
301            (SectionType::Tracks, encode_tracks(&self.tracks)),
302            (SectionType::Dict, encode_dict(&self.dict)),
303            (SectionType::Chunks, encode_chunks(&self.chunks)),
304            (SectionType::Segments, encode_segments(&self.segments)),
305            (SectionType::Meta, encode_meta(&self.meta)),
306            (SectionType::Integrity, encode_integrity(&integrity)),
307        ];
308
309        let mut offset = SUPERBLOCK_LEN + self.data_len;
310        for (ty, bytes) in &sections {
311            self.out.write_all(bytes)?;
312            dir.push((*ty, offset, bytes.len() as u64, hash_chunk(bytes)));
313            offset += bytes.len() as u64;
314        }
315
316        let section_dir_offset = offset;
317        let mut dir_bytes = Vec::with_capacity(dir.len() * crate::SECTION_DIR_ENTRY_LEN);
318        for (ty, off, len, hash) in &dir {
319            put_u32(&mut dir_bytes, *ty as u32);
320            put_u64(&mut dir_bytes, *off);
321            put_u64(&mut dir_bytes, *len);
322            dir_bytes.extend_from_slice(hash);
323        }
324        self.out.write_all(&dir_bytes)?;
325        let file_size = section_dir_offset + dir_bytes.len() as u64;
326
327        // Patch the superblock.
328        let mut sb = Vec::with_capacity(SUPERBLOCK_LEN as usize);
329        sb.extend_from_slice(&MAGIC);
330        put_u16(&mut sb, VERSION_MAJOR);
331        put_u16(&mut sb, VERSION_MINOR);
332        put_u32(&mut sb, 0); // feature flags
333        sb.push(cavs_hash::HashAlgo::Blake3 as u8);
334        sb.push(self.compression);
335        put_u16(&mut sb, 0); // reserved
336        sb.extend_from_slice(&self.asset_uuid);
337        put_u32(&mut sb, self.timescale);
338        put_u32(&mut sb, dir.len() as u32);
339        put_u64(&mut sb, section_dir_offset);
340        put_u64(&mut sb, file_size);
341        sb.resize(SUPERBLOCK_LEN as usize, 0);
342
343        self.out.flush()?;
344        let file = self.out.get_mut();
345        file.seek(SeekFrom::Start(0))?;
346        file.write_all(&sb)?;
347        file.sync_all()?;
348
349        Ok(PackStats {
350            file_size,
351            unique_chunks: self.chunks.len() as u64,
352            logical_chunks: self.logical_chunks,
353            logical_raw: self.logical_raw,
354            unique_raw,
355            stored,
356            merkle_root: root,
357        })
358    }
359}
360
361fn encode_tracks(tracks: &[TrackRecord]) -> Vec<u8> {
362    let mut buf = Vec::new();
363    put_u32(&mut buf, tracks.len() as u32);
364    for t in tracks {
365        put_u32(&mut buf, t.track_id);
366        buf.push(t.kind as u8);
367        buf.push(t.flags);
368        put_str(&mut buf, &t.codec);
369        put_str(&mut buf, &t.name);
370        put_u32(&mut buf, t.timescale);
371        put_u32(&mut buf, t.init_chunks.len() as u32);
372        for &c in &t.init_chunks {
373            put_u32(&mut buf, c);
374        }
375    }
376    buf
377}
378
379fn encode_dict(dict: &[u32]) -> Vec<u8> {
380    let mut buf = Vec::new();
381    put_u32(&mut buf, dict.len() as u32);
382    for &c in dict {
383        put_u32(&mut buf, c);
384    }
385    buf
386}
387
388fn encode_chunks(chunks: &[ChunkRecord]) -> Vec<u8> {
389    let mut buf = Vec::with_capacity(4 + chunks.len() * 52);
390    put_u32(&mut buf, chunks.len() as u32);
391    for c in chunks {
392        buf.extend_from_slice(&c.hash);
393        put_u64(&mut buf, c.data_offset);
394        put_u32(&mut buf, c.len_raw);
395        put_u32(&mut buf, c.len_stored);
396        put_u32(&mut buf, c.flags);
397    }
398    buf
399}
400
401fn encode_segments(segments: &[SegmentRecord]) -> Vec<u8> {
402    let mut buf = Vec::new();
403    put_u32(&mut buf, segments.len() as u32);
404    for s in segments {
405        put_u64(&mut buf, s.segment_id);
406        put_u32(&mut buf, s.track_id);
407        put_u64(&mut buf, s.pts_start);
408        put_u32(&mut buf, s.duration);
409        put_u32(&mut buf, s.flags);
410        put_u32(&mut buf, s.chunks.len() as u32);
411        for &c in &s.chunks {
412            put_u32(&mut buf, c);
413        }
414    }
415    buf
416}
417
418fn encode_meta(meta: &[(String, String)]) -> Vec<u8> {
419    let mut buf = Vec::new();
420    put_u32(&mut buf, meta.len() as u32);
421    for (k, v) in meta {
422        put_str(&mut buf, k);
423        put_bytes32(&mut buf, v.as_bytes());
424    }
425    buf
426}
427
428fn encode_integrity(i: &Integrity) -> Vec<u8> {
429    let mut buf = Vec::with_capacity(56);
430    buf.extend_from_slice(&i.merkle_root);
431    put_u64(&mut buf, i.chunk_count);
432    put_u64(&mut buf, i.total_raw);
433    put_u64(&mut buf, i.total_stored);
434    buf
435}