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 one chunk payload. Returns its chunk-table index. Duplicate
107    /// payloads (same BLAKE3) are not stored again.
108    pub fn add_chunk(&mut self, raw: &[u8]) -> Result<u32> {
109        self.logical_raw += raw.len() as u64;
110        self.logical_chunks += 1;
111        let hash = hash_chunk(raw);
112        let interned = self.cas.intern(hash, self.chunks.len() as u32);
113        if !interned.is_new() {
114            return Ok(interned.index());
115        }
116
117        let mut flags = 0u32;
118        let stored: Vec<u8>;
119        let stored_slice: &[u8] =
120            if self.compression == COMPRESSION_ZSTD && raw.len() >= COMPRESS_MIN_LEN {
121                stored = zstd::bulk::compress(raw, self.zstd_level).map_err(FormatError::Zstd)?;
122                // Keep compression only if it actually pays for itself.
123                if stored.len() < raw.len() - raw.len() / 16 {
124                    flags |= CHUNK_FLAG_ZSTD;
125                    &stored
126                } else {
127                    raw
128                }
129            } else {
130                raw
131            };
132
133        self.out.write_all(stored_slice)?;
134        self.data_hasher.update(stored_slice);
135        self.chunks.push(ChunkRecord {
136            hash,
137            data_offset: self.data_len,
138            len_raw: raw.len() as u32,
139            len_stored: stored_slice.len() as u32,
140            flags,
141        });
142        self.data_len += stored_slice.len() as u64;
143        Ok(interned.index())
144    }
145
146    pub fn add_track(&mut self, track: TrackRecord) -> Result<()> {
147        for &c in &track.init_chunks {
148            self.check_chunk_index(c)?;
149        }
150        self.tracks.push(track);
151        Ok(())
152    }
153
154    pub fn add_segment(&mut self, segment: SegmentRecord) -> Result<()> {
155        for &c in &segment.chunks {
156            self.check_chunk_index(c)?;
157        }
158        self.segments.push(segment);
159        Ok(())
160    }
161
162    /// Mark a chunk as part of the global dictionary (privileged reuse:
163    /// init segments, bootstrap assets, shared headers...).
164    pub fn pin_dict(&mut self, chunk_index: u32) -> Result<()> {
165        self.check_chunk_index(chunk_index)?;
166        if !self.dict.contains(&chunk_index) {
167            self.dict.push(chunk_index);
168        }
169        Ok(())
170    }
171
172    pub fn set_meta(&mut self, key: &str, value: &str) {
173        self.meta.push((key.to_string(), value.to_string()));
174    }
175
176    pub fn chunk_count(&self) -> u32 {
177        self.chunks.len() as u32
178    }
179
180    fn check_chunk_index(&self, index: u32) -> Result<()> {
181        if (index as usize) < self.chunks.len() {
182            Ok(())
183        } else {
184            Err(FormatError::ChunkIndexOutOfRange(index))
185        }
186    }
187
188    /// Write all tables, the section directory and the final superblock.
189    pub fn finish(mut self) -> Result<PackStats> {
190        let hashes: Vec<ChunkHash> = self.chunks.iter().map(|c| c.hash).collect();
191        let root = merkle_root(&hashes);
192        let unique_raw: u64 = self.chunks.iter().map(|c| c.len_raw as u64).sum();
193        let stored: u64 = self.chunks.iter().map(|c| c.len_stored as u64).sum();
194
195        let integrity = Integrity {
196            merkle_root: root,
197            chunk_count: self.chunks.len() as u64,
198            total_raw: unique_raw,
199            total_stored: stored,
200        };
201
202        if let Some(signer) = &self.signer {
203            let message = content_signature_message(&root, integrity.chunk_count);
204            let sig = signer.sign(&message);
205            let sig_hex: String = sig.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
206            let pk_hex: String = signer
207                .verifying_key()
208                .to_bytes()
209                .iter()
210                .map(|b| format!("{b:02x}"))
211                .collect();
212            self.meta.push(("sig.ed25519".to_string(), sig_hex));
213            self.meta.push(("sig.pubkey".to_string(), pk_hex));
214        }
215
216        // DATA was streamed right after the superblock.
217        let mut dir: Vec<(SectionType, u64, u64, ChunkHash)> = vec![(
218            SectionType::Data,
219            SUPERBLOCK_LEN,
220            self.data_len,
221            self.data_hasher.finalize(),
222        )];
223
224        let sections: Vec<(SectionType, Vec<u8>)> = vec![
225            (SectionType::Tracks, encode_tracks(&self.tracks)),
226            (SectionType::Dict, encode_dict(&self.dict)),
227            (SectionType::Chunks, encode_chunks(&self.chunks)),
228            (SectionType::Segments, encode_segments(&self.segments)),
229            (SectionType::Meta, encode_meta(&self.meta)),
230            (SectionType::Integrity, encode_integrity(&integrity)),
231        ];
232
233        let mut offset = SUPERBLOCK_LEN + self.data_len;
234        for (ty, bytes) in &sections {
235            self.out.write_all(bytes)?;
236            dir.push((*ty, offset, bytes.len() as u64, hash_chunk(bytes)));
237            offset += bytes.len() as u64;
238        }
239
240        let section_dir_offset = offset;
241        let mut dir_bytes = Vec::with_capacity(dir.len() * crate::SECTION_DIR_ENTRY_LEN);
242        for (ty, off, len, hash) in &dir {
243            put_u32(&mut dir_bytes, *ty as u32);
244            put_u64(&mut dir_bytes, *off);
245            put_u64(&mut dir_bytes, *len);
246            dir_bytes.extend_from_slice(hash);
247        }
248        self.out.write_all(&dir_bytes)?;
249        let file_size = section_dir_offset + dir_bytes.len() as u64;
250
251        // Patch the superblock.
252        let mut sb = Vec::with_capacity(SUPERBLOCK_LEN as usize);
253        sb.extend_from_slice(&MAGIC);
254        put_u16(&mut sb, VERSION_MAJOR);
255        put_u16(&mut sb, VERSION_MINOR);
256        put_u32(&mut sb, 0); // feature flags
257        sb.push(cavs_hash::HashAlgo::Blake3 as u8);
258        sb.push(self.compression);
259        put_u16(&mut sb, 0); // reserved
260        sb.extend_from_slice(&self.asset_uuid);
261        put_u32(&mut sb, self.timescale);
262        put_u32(&mut sb, dir.len() as u32);
263        put_u64(&mut sb, section_dir_offset);
264        put_u64(&mut sb, file_size);
265        sb.resize(SUPERBLOCK_LEN as usize, 0);
266
267        self.out.flush()?;
268        let file = self.out.get_mut();
269        file.seek(SeekFrom::Start(0))?;
270        file.write_all(&sb)?;
271        file.sync_all()?;
272
273        Ok(PackStats {
274            file_size,
275            unique_chunks: self.chunks.len() as u64,
276            logical_chunks: self.logical_chunks,
277            logical_raw: self.logical_raw,
278            unique_raw,
279            stored,
280            merkle_root: root,
281        })
282    }
283}
284
285fn encode_tracks(tracks: &[TrackRecord]) -> Vec<u8> {
286    let mut buf = Vec::new();
287    put_u32(&mut buf, tracks.len() as u32);
288    for t in tracks {
289        put_u32(&mut buf, t.track_id);
290        buf.push(t.kind as u8);
291        buf.push(t.flags);
292        put_str(&mut buf, &t.codec);
293        put_str(&mut buf, &t.name);
294        put_u32(&mut buf, t.timescale);
295        put_u32(&mut buf, t.init_chunks.len() as u32);
296        for &c in &t.init_chunks {
297            put_u32(&mut buf, c);
298        }
299    }
300    buf
301}
302
303fn encode_dict(dict: &[u32]) -> Vec<u8> {
304    let mut buf = Vec::new();
305    put_u32(&mut buf, dict.len() as u32);
306    for &c in dict {
307        put_u32(&mut buf, c);
308    }
309    buf
310}
311
312fn encode_chunks(chunks: &[ChunkRecord]) -> Vec<u8> {
313    let mut buf = Vec::with_capacity(4 + chunks.len() * 52);
314    put_u32(&mut buf, chunks.len() as u32);
315    for c in chunks {
316        buf.extend_from_slice(&c.hash);
317        put_u64(&mut buf, c.data_offset);
318        put_u32(&mut buf, c.len_raw);
319        put_u32(&mut buf, c.len_stored);
320        put_u32(&mut buf, c.flags);
321    }
322    buf
323}
324
325fn encode_segments(segments: &[SegmentRecord]) -> Vec<u8> {
326    let mut buf = Vec::new();
327    put_u32(&mut buf, segments.len() as u32);
328    for s in segments {
329        put_u64(&mut buf, s.segment_id);
330        put_u32(&mut buf, s.track_id);
331        put_u64(&mut buf, s.pts_start);
332        put_u32(&mut buf, s.duration);
333        put_u32(&mut buf, s.flags);
334        put_u32(&mut buf, s.chunks.len() as u32);
335        for &c in &s.chunks {
336            put_u32(&mut buf, c);
337        }
338    }
339    buf
340}
341
342fn encode_meta(meta: &[(String, String)]) -> Vec<u8> {
343    let mut buf = Vec::new();
344    put_u32(&mut buf, meta.len() as u32);
345    for (k, v) in meta {
346        put_str(&mut buf, k);
347        put_bytes32(&mut buf, v.as_bytes());
348    }
349    buf
350}
351
352fn encode_integrity(i: &Integrity) -> Vec<u8> {
353    let mut buf = Vec::with_capacity(56);
354    buf.extend_from_slice(&i.merkle_root);
355    put_u64(&mut buf, i.chunk_count);
356    put_u64(&mut buf, i.total_raw);
357    put_u64(&mut buf, i.total_stored);
358    buf
359}