Skip to main content

tsaur_core/
format.rs

1//! On-disk framing: fixed header, section table, trailer, canonical CBOR helpers.
2//!
3//! ```text
4//! +----------------------------------------------------------------+
5//! | header (32 B): "TSR\x1A" | version u16 | flags u16 | hlen u32 | 0 |
6//! | sections ... (recipients, dictionary, blobs, chunk index,       |
7//! |               manifest, signatures)                             |
8//! | section table (CBOR array of {t, off, len, h})                  |
9//! | trailer (24 B): table_off u64 | table_len u64 | crc32 u32 | "RST\x1A" |
10//! +----------------------------------------------------------------+
11//! ```
12//! Every section is hashed (BLAKE3) in the table; the table itself is protected by the CRC in
13//! the trailer and, when present, by the signature section. There is exactly one source of truth
14//! for every value: readers refuse archives whose redundant fields disagree.
15
16use crate::error::{Error, Result};
17use serde::de::DeserializeOwned;
18use serde::{Deserialize, Serialize};
19use serde_bytes::ByteBuf;
20
21pub const MAGIC: [u8; 4] = *b"TSR\x1A";
22pub const TRAILER_MAGIC: [u8; 4] = *b"RST\x1A";
23pub const VERSION: u16 = 1;
24pub const HEADER_LEN: usize = 32;
25pub const TRAILER_LEN: usize = 24;
26
27pub const FLAG_ENCRYPTED: u16 = 0x0001;
28pub const FLAG_SIGNED: u16 = 0x0002;
29
30/// Section type identifiers.
31pub mod section {
32    pub const RECIPIENTS: u8 = 1;
33    pub const DICTIONARY: u8 = 2;
34    pub const BLOBS: u8 = 3;
35    pub const CHUNK_INDEX: u8 = 4;
36    pub const MANIFEST: u8 = 5;
37    pub const SIGNATURES: u8 = 6;
38}
39
40#[derive(Clone, Debug, Serialize, Deserialize)]
41pub struct SectionEntry {
42    pub t: u8,
43    pub off: u64,
44    pub len: u64,
45    pub h: ByteBuf,
46}
47
48#[derive(Clone, Copy, Debug)]
49pub struct Header {
50    pub version: u16,
51    pub flags: u16,
52}
53
54pub fn encode_header(h: &Header) -> [u8; HEADER_LEN] {
55    let mut b = [0u8; HEADER_LEN];
56    b[0..4].copy_from_slice(&MAGIC);
57    b[4..6].copy_from_slice(&h.version.to_le_bytes());
58    b[6..8].copy_from_slice(&h.flags.to_le_bytes());
59    b[8..12].copy_from_slice(&(HEADER_LEN as u32).to_le_bytes());
60    b
61}
62
63pub fn decode_header(b: &[u8]) -> Result<Header> {
64    if b.len() < HEADER_LEN || b[0..4] != MAGIC {
65        return Err(Error::BadMagic);
66    }
67    let version = u16::from_le_bytes([b[4], b[5]]);
68    if version != VERSION {
69        return Err(Error::Version(version));
70    }
71    let flags = u16::from_le_bytes([b[6], b[7]]);
72    let hlen = u32::from_le_bytes([b[8], b[9], b[10], b[11]]) as usize;
73    if hlen != HEADER_LEN {
74        return Err(Error::Corrupt("header length field".into()));
75    }
76    if b[12..HEADER_LEN].iter().any(|&x| x != 0) {
77        return Err(Error::Corrupt("reserved header bytes must be zero".into()));
78    }
79    Ok(Header { version, flags })
80}
81
82pub fn encode_trailer(table_off: u64, table_len: u64, crc: u32) -> [u8; TRAILER_LEN] {
83    let mut b = [0u8; TRAILER_LEN];
84    b[0..8].copy_from_slice(&table_off.to_le_bytes());
85    b[8..16].copy_from_slice(&table_len.to_le_bytes());
86    b[16..20].copy_from_slice(&crc.to_le_bytes());
87    b[20..24].copy_from_slice(&TRAILER_MAGIC);
88    b
89}
90
91/// Returns (table_off, table_len, crc32).
92pub fn decode_trailer(b: &[u8]) -> Result<(u64, u64, u32)> {
93    if b.len() != TRAILER_LEN || b[20..24] != TRAILER_MAGIC {
94        return Err(Error::Corrupt("trailer magic".into()));
95    }
96    let off = u64::from_le_bytes(b[0..8].try_into().unwrap());
97    let len = u64::from_le_bytes(b[8..16].try_into().unwrap());
98    let crc = u32::from_le_bytes(b[16..20].try_into().unwrap());
99    Ok((off, len, crc))
100}
101
102pub fn cbor_encode<T: Serialize>(v: &T) -> Result<Vec<u8>> {
103    let mut out = Vec::new();
104    ciborium::into_writer(v, &mut out).map_err(|e| Error::Encoding(format!("cbor encode: {e}")))?;
105    Ok(out)
106}
107
108pub fn cbor_decode<T: DeserializeOwned>(b: &[u8]) -> Result<T> {
109    ciborium::from_reader(b).map_err(|e| Error::Encoding(format!("cbor decode: {e}")))
110}
111
112pub fn crc32(b: &[u8]) -> u32 {
113    let mut h = crc32fast::Hasher::new();
114    h.update(b);
115    h.finalize()
116}
117
118/// The message covered by the archive signature: header bytes and every non-signature section
119/// (type, offset, length, hash), domain separated.
120pub fn signing_message(header: &[u8], sections: &[SectionEntry]) -> [u8; 32] {
121    let mut h = blake3::Hasher::new();
122    h.update(b"tsaur-sig-v1");
123    h.update(header);
124    for s in sections {
125        if s.t == section::SIGNATURES {
126            continue;
127        }
128        h.update(&[s.t]);
129        h.update(&s.off.to_le_bytes());
130        h.update(&s.len.to_le_bytes());
131        h.update(&s.h);
132    }
133    *h.finalize().as_bytes()
134}
135
136#[derive(Clone, Debug, Serialize, Deserialize)]
137pub struct SignatureBlock {
138    pub alg: String,
139    pub key: ByteBuf,
140    pub sig: ByteBuf,
141}