Skip to main content

nodedb_types/backup_envelope/
types.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shared types, constants, and error definitions for the backup envelope.
4
5use thiserror::Error;
6
7pub const MAGIC: &[u8; 4] = b"NDBB";
8
9/// Backup envelope version stamped in byte 4 of every envelope header.
10/// Plaintext and encrypted envelopes carry the same version. The crypto
11/// block (68 bytes after the header) distinguishes them.
12///
13/// Every section body is scoped to a database: data sections carry a
14/// [`DatabaseDataSection`], and the metadata sections name the database of
15/// each entry. Every envelope carries one `SECTION_ORIGIN_VERIFICATION`
16/// section. An envelope of any other version is refused.
17pub const VERSION: u8 = 3;
18
19/// Header is fixed-size — 52 bytes (48 framed + 4 crc).
20///
21/// The header grew by 8 bytes when `tenant_id` was widened from u32 to u64
22/// (format v1, pre-launch format break).
23pub const HEADER_LEN: usize = 52;
24/// Per-section framing overhead: origin(8) + len(4) + crc(4).
25pub const SECTION_OVERHEAD: usize = 16;
26/// Trailing crc.
27pub const TRAILER_LEN: usize = 4;
28
29/// Default cap on total envelope size: 16 GiB. Tunable per call.
30pub const DEFAULT_MAX_TOTAL_BYTES: u64 = 16 * 1024 * 1024 * 1024;
31/// Default cap on a single section body: 16 GiB.
32pub const DEFAULT_MAX_SECTION_BYTES: u64 = 16 * 1024 * 1024 * 1024;
33
34/// Sentinel `origin_node_id` values that mark sections carrying
35/// metadata rather than per-node engine data. Restore handlers
36/// recognize the sentinel and route the body to the correct
37/// catalog writer. Section CRCs validate independently of whether
38/// the reader acts on the section body.
39pub const SECTION_ORIGIN_CATALOG_ROWS: u64 = 0xFFFF_FFFF_FFFF_FFF0;
40pub const SECTION_ORIGIN_SOURCE_TOMBSTONES: u64 = 0xFFFF_FFFF_FFFF_FFF1;
41/// Section carrying the tenant's PK→surrogate identity bindings. The surrogate
42/// map is DATA-derived per-node state that the per-node data sections do NOT
43/// carry (the Data-Plane snapshot handler has no catalog access), so without
44/// this section a restored node has documents but cannot resolve PK
45/// point-lookups (`WHERE id=<pk>`). The body is a msgpack-encoded
46/// `Vec<SurrogateBindBlob>`.
47pub const SECTION_ORIGIN_SURROGATE_PK: u64 = 0xFFFF_FFFF_FFFF_FFF2;
48/// Section carrying every database the tenant has collections in. The body
49/// is a msgpack-encoded `Vec<DatabaseBlob>`. Restore reads it first: every
50/// other section names its database by the id recorded here.
51pub const SECTION_ORIGIN_DATABASES: u64 = 0xFFFF_FFFF_FFFF_FFF3;
52/// Section carrying the per-collection row counts and digests restore checks.
53/// The body is a msgpack-encoded `Vec<CollectionVerification>`. Every
54/// envelope carries exactly one.
55pub const SECTION_ORIGIN_VERIFICATION: u64 = 0xFFFF_FFFF_FFFF_FFF4;
56/// Section carrying the catalog row of each of the tenant's arrays. The body
57/// is a msgpack-encoded `Vec<ArrayCatalogBlob>`. The cells travel in the data
58/// sections.
59pub const SECTION_ORIGIN_ARRAY_CATALOG: u64 = 0xFFFF_FFFF_FFFF_FFF5;
60
61/// One database of the backed-up tenant, carried in a
62/// `SECTION_ORIGIN_DATABASES` section.
63///
64/// `database_id` is the id on the source cluster. Restore maps it to the
65/// destination database of the same `name`, and creates that database when
66/// the destination has none.
67#[derive(Debug, Clone, PartialEq, Eq, zerompk::ToMessagePack, zerompk::FromMessagePack)]
68pub struct DatabaseBlob {
69    pub database_id: u64,
70    pub name: String,
71    /// zerompk-encoded `DatabaseDescriptor` from the `nodedb` crate: the
72    /// database's settings.
73    pub descriptor: Vec<u8>,
74    /// The database's own quota, when one is set.
75    pub database_quota: Option<crate::QuotaRecord>,
76    /// The tenant's quota inside this database, when one is set.
77    pub tenant_quota: Option<crate::QuotaRecord>,
78}
79
80/// Body of a per-node data section: one database's slice of the tenant
81/// snapshot a source node took.
82#[derive(Debug, Clone, PartialEq, Eq, zerompk::ToMessagePack, zerompk::FromMessagePack)]
83pub struct DatabaseDataSection {
84    /// Source id of the database the snapshot covers.
85    pub database_id: u64,
86    /// zerompk-encoded `TenantDataSnapshot` from the `nodedb` crate.
87    pub snapshot: Vec<u8>,
88}
89
90/// Single catalog-row entry in a catalog-rows section. The outer
91/// container is `Vec<StoredCollectionBlob>` msgpack-encoded into the
92/// section body. Bytes are the zerompk-encoded `StoredCollection`
93/// from the `nodedb` crate — `nodedb-types` intentionally doesn't
94/// depend on the `nodedb` catalog types, so the blob is opaque here.
95#[derive(Debug, Clone, PartialEq, Eq, zerompk::ToMessagePack, zerompk::FromMessagePack)]
96pub struct StoredCollectionBlob {
97    /// Source id of the database the collection lives in.
98    pub database_id: u64,
99    pub name: String,
100    /// zerompk-encoded `StoredCollection`.
101    pub bytes: Vec<u8>,
102}
103
104/// One array's catalog row in a `SECTION_ORIGIN_ARRAY_CATALOG` section.
105#[derive(Debug, Clone, PartialEq, Eq, zerompk::ToMessagePack, zerompk::FromMessagePack)]
106pub struct ArrayCatalogBlob {
107    /// Source id of the database the array lives in.
108    pub database_id: u64,
109    pub name: String,
110    /// zerompk-encoded `ArrayCatalogEntry` from the `nodedb` crate.
111    pub bytes: Vec<u8>,
112}
113
114/// Single source-side tombstone entry. `purge_lsn` is the Origin WAL
115/// LSN at which the hard-delete committed — restore uses it as a
116/// per-collection replay barrier so rows older than the purge don't
117/// resurrect.
118#[derive(Debug, Clone, PartialEq, Eq, zerompk::ToMessagePack, zerompk::FromMessagePack)]
119pub struct SourceTombstoneEntry {
120    /// Source id of the database the collection lived in.
121    pub database_id: u64,
122    pub collection: String,
123    pub purge_lsn: u64,
124}
125
126/// Single PK→surrogate binding carried in a `SECTION_ORIGIN_SURROGATE_PK`
127/// section. The outer container is `Vec<SurrogateBindBlob>` msgpack-encoded into
128/// the section body. Mirrors one row of the source catalog's `surrogate_pk_v3`
129/// table for one `(database_id, tenant_id, collection)`; rebound on the restore
130/// side so PK point-lookups resolve.
131#[derive(Debug, Clone, PartialEq, Eq, zerompk::ToMessagePack, zerompk::FromMessagePack)]
132pub struct SurrogateBindBlob {
133    /// Source id of the database the collection lives in.
134    pub database_id: u64,
135    pub tenant_id: u64,
136    /// Bare catalog name of the collection.
137    pub collection: String,
138    pub pk: Vec<u8>,
139    pub surrogate: u32,
140}
141
142#[derive(Debug, Error, PartialEq, Eq)]
143#[non_exhaustive]
144pub enum EnvelopeError {
145    #[error("invalid backup format")]
146    BadMagic,
147    #[error("unsupported backup version: {0}")]
148    UnsupportedVersion(u8),
149    #[error("invalid backup format")]
150    HeaderCrcMismatch,
151    #[error("invalid backup format")]
152    BodyCrcMismatch,
153    #[error("invalid backup format")]
154    TrailerCrcMismatch,
155    #[error("backup truncated")]
156    Truncated,
157    #[error("backup tenant mismatch: expected {expected}, got {actual}")]
158    TenantMismatch { expected: u64, actual: u64 },
159    #[error("backup exceeds size cap of {cap} bytes")]
160    OverSizeTotal { cap: u64 },
161    #[error("backup section exceeds size cap of {cap} bytes")]
162    OverSizeSection { cap: u64 },
163    #[error("too many sections: {0}")]
164    TooManySections(u16),
165    /// The KEK presented at restore time does not match the KEK fingerprint
166    /// embedded in the envelope. Surfaces before any decryption attempt so
167    /// the caller receives a clear, actionable error rather than an opaque
168    /// authentication failure.
169    #[error("wrong backup KEK: presented key fingerprint does not match envelope")]
170    WrongBackupKek,
171    /// AES-256-GCM authentication tag verification failed. Either the
172    /// ciphertext or the key is corrupt.
173    #[error("backup decryption failed: authentication tag mismatch")]
174    DecryptionFailed,
175    /// AES-256-GCM encryption failed (e.g. plaintext exceeds the per-message
176    /// limit of 2^36 - 31 bytes). Distinct from `DecryptionFailed` so callers
177    /// can tell which side of the crypto pipeline produced the error.
178    #[error("backup encryption failed")]
179    EncryptionFailed,
180    /// `getrandom` returned an error when generating nonces or the DEK.
181    #[error("backup encryption failed: {0}")]
182    RandomFailure(String),
183}
184
185/// Header metadata captured at backup time.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub struct EnvelopeMeta {
188    pub tenant_id: u64,
189    pub source_vshard_count: u16,
190    pub hash_seed: u64,
191    pub snapshot_watermark: u64,
192}
193
194/// One contiguous body produced by one origin node.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct Section {
197    pub origin_node_id: u64,
198    pub body: Vec<u8>,
199}
200
201/// Decoded envelope.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct Envelope {
204    pub meta: EnvelopeMeta,
205    pub sections: Vec<Section>,
206}
207
208// ── byte helpers ─────────────────────────────────────────────────────────────
209
210pub fn read2(s: &[u8]) -> [u8; 2] {
211    [s[0], s[1]]
212}
213pub fn read4(s: &[u8]) -> [u8; 4] {
214    [s[0], s[1], s[2], s[3]]
215}
216pub fn read8(s: &[u8]) -> [u8; 8] {
217    [s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::backup_envelope::{parse_encrypted, write::EnvelopeWriter};
224
225    const KEK: [u8; 32] = [0x5Au8; 32];
226
227    fn meta() -> EnvelopeMeta {
228        EnvelopeMeta {
229            tenant_id: 3,
230            source_vshard_count: 1024,
231            hash_seed: 0,
232            snapshot_watermark: 17,
233        }
234    }
235
236    /// A database-scoped data section and the database list survive the
237    /// encrypted envelope intact, each database under its own id.
238    #[test]
239    fn database_sections_round_trip_through_an_encrypted_envelope() {
240        let databases = vec![
241            DatabaseBlob {
242                database_id: 0,
243                name: "default".into(),
244                descriptor: vec![1],
245                database_quota: None,
246                tenant_quota: None,
247            },
248            DatabaseBlob {
249                database_id: 1025,
250                name: "sales".into(),
251                descriptor: vec![2],
252                database_quota: Some(crate::QuotaRecord::DEFAULT),
253                tenant_quota: None,
254            },
255        ];
256        let data = DatabaseDataSection {
257            database_id: 1025,
258            snapshot: vec![9, 8, 7],
259        };
260        let mut writer = EnvelopeWriter::new(meta());
261        writer
262            .push_section(
263                SECTION_ORIGIN_DATABASES,
264                zerompk::to_msgpack_vec(&databases).expect("encode databases"),
265            )
266            .expect("push databases");
267        writer
268            .push_section(7, zerompk::to_msgpack_vec(&data).expect("encode data"))
269            .expect("push data");
270        let bytes = writer.finalize_encrypted(&KEK).expect("encrypt");
271
272        let env = parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &KEK).expect("parse");
273        let decoded: Vec<DatabaseBlob> =
274            zerompk::from_msgpack(&env.sections[0].body).expect("decode databases");
275        assert_eq!(decoded, databases);
276        let decoded: DatabaseDataSection =
277            zerompk::from_msgpack(&env.sections[1].body).expect("decode data");
278        assert_eq!(decoded, data);
279    }
280
281    /// The database id sits inside the encrypted body, so the AEAD tag
282    /// covers it: flipping a ciphertext byte fails the parse.
283    #[test]
284    fn a_tampered_database_section_fails_authentication() {
285        let data = DatabaseDataSection {
286            database_id: 1025,
287            snapshot: vec![1, 2, 3, 4],
288        };
289        let mut writer = EnvelopeWriter::new(meta());
290        writer
291            .push_section(7, zerompk::to_msgpack_vec(&data).expect("encode data"))
292            .expect("push data");
293        let mut bytes = writer.finalize_encrypted(&KEK).expect("encrypt");
294        // Header, crypto block, origin, length and nonce precede the body.
295        let body_start = HEADER_LEN + 68 + 8 + 4 + 12;
296        bytes[body_start] ^= 0xFF;
297        // Recompute the body and trailer CRCs so only the AEAD tag can refuse.
298        let body_len = u32::from_le_bytes(read4(&bytes[HEADER_LEN + 68 + 8..])) as usize;
299        let body_end = body_start + body_len;
300        let body_crc = crc32c::crc32c(&bytes[body_start..body_end]);
301        bytes[body_end..body_end + 4].copy_from_slice(&body_crc.to_le_bytes());
302        let trailer_start = bytes.len() - TRAILER_LEN;
303        let trailer_crc = crc32c::crc32c(&bytes[..trailer_start]);
304        bytes[trailer_start..].copy_from_slice(&trailer_crc.to_le_bytes());
305        assert_eq!(
306            parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &KEK),
307            Err(EnvelopeError::DecryptionFailed)
308        );
309    }
310}