Skip to main content

cavs_format/
lib.rs

1//! CAVS-1 binary container format: types, writer and reader.
2//!
3//! CAVS-1 (Content-Addressable Versioned Streaming, v1) is a packaging layer
4//! for game content (builds, packs, bundles, patches) — and, secondarily,
5//! video. It stores deduplicated, content-hashed chunks plus the tables needed
6//! to reconstruct the original files byte-for-byte (raw assets, or fMP4/CMAF
7//! segments and playlists when packaging video).
8//!
9//! On-disk layout (little-endian throughout):
10//!
11//! ```text
12//! +-----------------------------+ offset 0
13//! | Superblock (64 bytes)       |
14//! +-----------------------------+ offset 64
15//! | DATA section (chunk bytes)  |  streamed while packing
16//! +-----------------------------+
17//! | TRACKS section              |
18//! | DICT section                |
19//! | CHUNKS section              |
20//! | SEGMENTS section            |
21//! | META section                |
22//! | INTEGRITY section           |
23//! +-----------------------------+
24//! | Section directory           |  pointed to by the superblock
25//! +-----------------------------+
26//! ```
27//!
28//! See `FORMAT.md` at the workspace root for the full byte-level spec.
29
30mod reader;
31mod writer;
32
33pub use reader::{Reader, SignatureStatus, VerifyReport};
34pub use writer::{PackStats, Writer};
35
36use cavs_hash::ChunkHash;
37
38/// File magic: "CAVS".
39pub const MAGIC: [u8; 4] = *b"CAVS";
40pub const VERSION_MAJOR: u16 = 1;
41pub const VERSION_MINOR: u16 = 0;
42/// Fixed superblock size in bytes.
43pub const SUPERBLOCK_LEN: u64 = 64;
44/// Size of one section-directory entry: type(4) + offset(8) + len(8) + hash(32).
45pub const SECTION_DIR_ENTRY_LEN: usize = 52;
46/// Upper bound on a single chunk's uncompressed size (256 MiB). Larger than
47/// any chunker's max; used to cap decompression allocations from untrusted
48/// or corrupted files.
49pub const MAX_CHUNK_RAW: u64 = 256 * 1024 * 1024;
50
51/// Section identifiers.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53#[repr(u32)]
54pub enum SectionType {
55    Tracks = 1,
56    Dict = 2,
57    Chunks = 3,
58    Segments = 4,
59    Data = 5,
60    Integrity = 6,
61    Meta = 7,
62}
63
64impl SectionType {
65    pub fn from_u32(v: u32) -> Option<Self> {
66        Some(match v {
67            1 => SectionType::Tracks,
68            2 => SectionType::Dict,
69            3 => SectionType::Chunks,
70            4 => SectionType::Segments,
71            5 => SectionType::Data,
72            6 => SectionType::Integrity,
73            7 => SectionType::Meta,
74            _ => return None,
75        })
76    }
77}
78
79/// Compression algorithm ids (superblock default and per-chunk flag).
80pub const COMPRESSION_NONE: u8 = 0;
81pub const COMPRESSION_ZSTD: u8 = 1;
82
83/// Chunk flag bit: payload stored zstd-compressed.
84pub const CHUNK_FLAG_ZSTD: u32 = 1 << 0;
85
86/// Segment flag bit: random-access point (keyframe bundle boundary).
87pub const SEGMENT_FLAG_RANDOM_ACCESS: u32 = 1 << 0;
88
89/// Track kinds.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91#[repr(u8)]
92pub enum TrackKind {
93    Video = 0,
94    Audio = 1,
95    Subtitle = 2,
96    /// Auxiliary binary asset (playlists, raw files, textures, ...).
97    Data = 3,
98}
99
100impl TrackKind {
101    pub fn from_u8(v: u8) -> Option<Self> {
102        Some(match v {
103            0 => TrackKind::Video,
104            1 => TrackKind::Audio,
105            2 => TrackKind::Subtitle,
106            3 => TrackKind::Data,
107            _ => return None,
108        })
109    }
110
111    pub fn label(&self) -> &'static str {
112        match self {
113            TrackKind::Video => "video",
114            TrackKind::Audio => "audio",
115            TrackKind::Subtitle => "subtitle",
116            TrackKind::Data => "data",
117        }
118    }
119}
120
121/// Superblock contents.
122#[derive(Debug, Clone)]
123pub struct Superblock {
124    pub version_major: u16,
125    pub version_minor: u16,
126    pub feature_flags: u32,
127    pub hash_algo: u8,
128    pub compression_algo: u8,
129    pub asset_uuid: [u8; 16],
130    pub timescale: u32,
131    pub section_count: u32,
132    pub section_dir_offset: u64,
133    pub file_size: u64,
134}
135
136/// One entry of the section directory.
137#[derive(Debug, Clone)]
138pub struct SectionEntry {
139    pub section_type: SectionType,
140    pub offset: u64,
141    pub length: u64,
142    /// blake3 of the section's raw bytes.
143    pub hash: ChunkHash,
144}
145
146/// Chunk-table record. `data_offset` is relative to the DATA section start.
147#[derive(Debug, Clone)]
148pub struct ChunkRecord {
149    pub hash: ChunkHash,
150    pub data_offset: u64,
151    pub len_raw: u32,
152    pub len_stored: u32,
153    pub flags: u32,
154}
155
156/// Track-table record.
157#[derive(Debug, Clone)]
158pub struct TrackRecord {
159    pub track_id: u32,
160    pub kind: TrackKind,
161    pub flags: u8,
162    pub codec: String,
163    /// Logical name, e.g. original file/segment naming hint.
164    pub name: String,
165    pub timescale: u32,
166    /// Chunks of the track init payload (e.g. CMAF init segment), in order.
167    pub init_chunks: Vec<u32>,
168}
169
170/// Segment-directory record. Reconstruction of the segment payload is the
171/// ordered concatenation of the raw bytes of `chunks`.
172#[derive(Debug, Clone)]
173pub struct SegmentRecord {
174    pub segment_id: u64,
175    pub track_id: u32,
176    pub pts_start: u64,
177    pub duration: u32,
178    pub flags: u32,
179    pub chunks: Vec<u32>,
180}
181
182/// Integrity section contents.
183#[derive(Debug, Clone)]
184pub struct Integrity {
185    /// Merkle root over the chunk table's hashes, in table order.
186    pub merkle_root: ChunkHash,
187    pub chunk_count: u64,
188    pub total_raw: u64,
189    pub total_stored: u64,
190}
191
192#[derive(Debug, thiserror::Error)]
193pub enum FormatError {
194    #[error("io error: {0}")]
195    Io(#[from] std::io::Error),
196    #[error("not a CAVS file (bad magic)")]
197    BadMagic,
198    #[error("unsupported CAVS major version {0}")]
199    UnsupportedVersion(u16),
200    #[error("truncated or malformed {0} section")]
201    Malformed(&'static str),
202    #[error("missing required section {0:?}")]
203    MissingSection(SectionType),
204    #[error("unknown enum value {value} for {what}")]
205    UnknownValue { what: &'static str, value: u32 },
206    #[error("chunk {index} hash mismatch (corrupted payload)")]
207    ChunkHashMismatch { index: u32 },
208    #[error("section {0:?} hash mismatch (corrupted section)")]
209    SectionHashMismatch(SectionType),
210    #[error("merkle root mismatch (chunk table tampered)")]
211    MerkleMismatch,
212    #[error("embedded content signature is invalid")]
213    SignatureInvalid,
214    #[error("chunk index {0} out of range")]
215    ChunkIndexOutOfRange(u32),
216    #[error("track {0} not found")]
217    TrackNotFound(u32),
218    #[error("zstd error: {0}")]
219    Zstd(std::io::Error),
220}
221
222pub type Result<T> = std::result::Result<T, FormatError>;
223
224// ---------------------------------------------------------------------------
225// Little-endian encode/decode helpers shared by reader and writer.
226// ---------------------------------------------------------------------------
227
228pub(crate) mod wire {
229    use super::{FormatError, Result};
230
231    pub fn put_u16(buf: &mut Vec<u8>, v: u16) {
232        buf.extend_from_slice(&v.to_le_bytes());
233    }
234    pub fn put_u32(buf: &mut Vec<u8>, v: u32) {
235        buf.extend_from_slice(&v.to_le_bytes());
236    }
237    pub fn put_u64(buf: &mut Vec<u8>, v: u64) {
238        buf.extend_from_slice(&v.to_le_bytes());
239    }
240    /// String with u16 length prefix.
241    pub fn put_str(buf: &mut Vec<u8>, s: &str) {
242        let bytes = s.as_bytes();
243        assert!(bytes.len() <= u16::MAX as usize, "string too long for wire");
244        put_u16(buf, bytes.len() as u16);
245        buf.extend_from_slice(bytes);
246    }
247    /// Bytes with u32 length prefix.
248    pub fn put_bytes32(buf: &mut Vec<u8>, b: &[u8]) {
249        put_u32(buf, b.len() as u32);
250        buf.extend_from_slice(b);
251    }
252
253    /// Sequential decoder over a byte slice.
254    pub struct Cursor<'a> {
255        buf: &'a [u8],
256        pos: usize,
257        what: &'static str,
258    }
259
260    impl<'a> Cursor<'a> {
261        pub fn new(buf: &'a [u8], what: &'static str) -> Self {
262            Self { buf, pos: 0, what }
263        }
264
265        fn take(&mut self, n: usize) -> Result<&'a [u8]> {
266            if self.pos + n > self.buf.len() {
267                return Err(FormatError::Malformed(self.what));
268            }
269            let s = &self.buf[self.pos..self.pos + n];
270            self.pos += n;
271            Ok(s)
272        }
273
274        pub fn u8(&mut self) -> Result<u8> {
275            Ok(self.take(1)?[0])
276        }
277        pub fn u16(&mut self) -> Result<u16> {
278            Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap()))
279        }
280        pub fn u32(&mut self) -> Result<u32> {
281            Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
282        }
283        pub fn u64(&mut self) -> Result<u64> {
284            Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap()))
285        }
286        pub fn hash(&mut self) -> Result<[u8; 32]> {
287            Ok(self.take(32)?.try_into().unwrap())
288        }
289        pub fn str16(&mut self) -> Result<String> {
290            let len = self.u16()? as usize;
291            let bytes = self.take(len)?;
292            String::from_utf8(bytes.to_vec()).map_err(|_| FormatError::Malformed(self.what))
293        }
294        pub fn bytes32(&mut self) -> Result<Vec<u8>> {
295            let len = self.u32()? as usize;
296            Ok(self.take(len)?.to_vec())
297        }
298    }
299}