arete_server/snapshot/
envelope.rs1use anyhow::{bail, Context, Result};
7use arete_interpreter::snapshot::VmSnapshot;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::BTreeMap;
11
12const MAGIC: &[u8; 8] = b"ARSNAP01";
13const ZSTD_LEVEL: i32 = 3;
14const MAX_PAYLOAD_BYTES: usize = 1 << 30;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct SnapshotContract {
20 pub schema: String,
21 pub hash: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct SnapshotHeader {
27 pub format_version: u32,
29 pub bytecode_hash: String,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub state_contract: Option<SnapshotContract>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub projection_contract: Option<SnapshotContract>,
40 pub program_ids: Vec<String>,
41 pub resume_watermark: u64,
44 pub observed_slot: u64,
46 pub created_at_epoch_ms: u64,
47 #[serde(default)]
49 pub entry_counts: BTreeMap<String, u64>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub trigger: Option<crate::snapshot::SnapshotTrigger>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SnapshotPayload {
62 pub vm: VmSnapshot,
63 pub entity_cache: Vec<(String, Vec<(String, Value)>)>,
65 #[serde(default)]
68 pub entity_lifetimes: Option<crate::cache::EntityLifetimes>,
69 #[serde(default)]
71 pub entity_tombstones: crate::cache::EntityTombstones,
72 #[serde(default)]
75 pub journal: crate::journal::JournalSnapshot,
76}
77
78pub fn encode(header: &SnapshotHeader, payload: &SnapshotPayload) -> Result<Vec<u8>> {
79 let header_json = serde_json::to_vec(header).context("serialize snapshot header")?;
80 let payload_json = serde_json::to_vec(payload).context("serialize snapshot payload")?;
81 let compressed =
82 zstd::encode_all(payload_json.as_slice(), ZSTD_LEVEL).context("compress snapshot")?;
83
84 let mut bytes = Vec::with_capacity(MAGIC.len() + 4 + header_json.len() + compressed.len());
85 bytes.extend_from_slice(MAGIC);
86 bytes.extend_from_slice(&(header_json.len() as u32).to_le_bytes());
87 bytes.extend_from_slice(&header_json);
88 bytes.extend_from_slice(&compressed);
89 Ok(bytes)
90}
91
92pub fn decode_header(bytes: &[u8]) -> Result<SnapshotHeader> {
94 if bytes.len() < MAGIC.len() + 4 {
95 bail!("snapshot blob truncated ({} bytes)", bytes.len());
96 }
97 if &bytes[..MAGIC.len()] != MAGIC {
98 bail!("snapshot blob has wrong magic");
99 }
100 let header_len =
101 u32::from_le_bytes(bytes[MAGIC.len()..MAGIC.len() + 4].try_into().unwrap()) as usize;
102 let header_start = MAGIC.len() + 4;
103 let header_end = header_start
104 .checked_add(header_len)
105 .filter(|end| *end <= bytes.len())
106 .context("snapshot header length out of bounds")?;
107 serde_json::from_slice(&bytes[header_start..header_end]).context("parse snapshot header")
108}
109
110pub fn decode_payload(bytes: &[u8]) -> Result<SnapshotPayload> {
111 let header_len =
113 u32::from_le_bytes(bytes[MAGIC.len()..MAGIC.len() + 4].try_into().unwrap()) as usize;
114 let payload_start = MAGIC.len() + 4 + header_len;
115
116 use std::io::Read;
117 let decoder = zstd::Decoder::new(&bytes[payload_start..])?;
118 let mut payload_json = Vec::new();
119 decoder
120 .take(MAX_PAYLOAD_BYTES as u64 + 1)
121 .read_to_end(&mut payload_json)
122 .context("decompress snapshot payload")?;
123 if payload_json.len() > MAX_PAYLOAD_BYTES {
124 bail!("snapshot payload exceeds {} bytes", MAX_PAYLOAD_BYTES);
125 }
126 serde_json::from_slice(&payload_json).context("parse snapshot payload")
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 fn sample() -> (SnapshotHeader, SnapshotPayload) {
134 let header = SnapshotHeader {
135 format_version: arete_interpreter::snapshot::SNAPSHOT_FORMAT_VERSION,
136 bytecode_hash: "abc123".to_string(),
137 state_contract: Some(SnapshotContract {
138 schema: "arete.snapshot-state-contract/v1".to_string(),
139 hash: "state123".to_string(),
140 }),
141 projection_contract: Some(SnapshotContract {
142 schema: "arete.snapshot-projection-contract/v1".to_string(),
143 hash: "projection123".to_string(),
144 }),
145 program_ids: vec!["Program111".to_string()],
146 resume_watermark: 42,
147 observed_slot: 50,
148 created_at_epoch_ms: 1_000,
149 trigger: Some(crate::snapshot::SnapshotTrigger::Shutdown),
150 entry_counts: BTreeMap::new(),
151 };
152 let payload = SnapshotPayload {
153 vm: VmSnapshot::default(),
154 entity_lifetimes: Some(vec![(
155 "tokens/list".to_string(),
156 "key1".to_string(),
157 crate::cache::EntityLifetimeCheckpoint {
158 account_position: Some(arete_interpreter::AccountPosition::new(
159 9_007_199_254_740_993,
160 u64::MAX,
161 )),
162 source_seq: None,
163 source_sequences: Default::default(),
164 deleted: false,
165 },
166 )]),
167 entity_tombstones: Default::default(),
168 entity_cache: vec![(
169 "tokens/list".to_string(),
170 vec![("key1".to_string(), serde_json::json!({"id": 1}))],
171 )],
172 journal: crate::journal::JournalSnapshot::default(),
173 };
174 (header, payload)
175 }
176
177 #[test]
178 fn round_trips_header_and_payload() {
179 let (header, payload) = sample();
180 let bytes = encode(&header, &payload).unwrap();
181
182 let decoded_header = decode_header(&bytes).unwrap();
183 assert_eq!(decoded_header.bytecode_hash, "abc123");
184 assert_eq!(decoded_header.resume_watermark, 42);
185
186 let decoded_payload = decode_payload(&bytes).unwrap();
187 assert_eq!(decoded_payload.entity_cache.len(), 1);
188 assert_eq!(decoded_payload.entity_cache[0].0, "tokens/list");
189 let lifetimes = decoded_payload.entity_lifetimes.unwrap();
190 let checkpoint = &lifetimes[0].2;
191 assert_eq!(
192 checkpoint.account_position,
193 Some(arete_interpreter::AccountPosition::new(
194 9_007_199_254_740_993,
195 u64::MAX
196 ))
197 );
198 }
199
200 #[test]
201 fn rejects_truncated_and_corrupt_blobs() {
202 let (header, payload) = sample();
203 let bytes = encode(&header, &payload).unwrap();
204
205 assert!(decode_header(&bytes[..4]).is_err());
206 assert!(decode_header(&[0u8; 32]).is_err());
207
208 let mut truncated = bytes.clone();
209 truncated.truncate(bytes.len() - 5);
210 assert!(decode_payload(&truncated).is_err());
211
212 let mut corrupted = bytes.clone();
213 let last = corrupted.len() - 1;
214 corrupted[last] ^= 0xFF;
215 assert!(decode_payload(&corrupted).is_err());
216 }
217}