1use std::time::{SystemTime, UNIX_EPOCH};
2
3use iroh_db_blobs::BlobRef;
4use iroh_db_core::{AuthorId, CapabilityId, CommitId, SchemaActivation};
5use iroh_db_security::{CapabilityCertificate, Permission, verify_snapshot_signature};
6use iroh_db_store::{MaterializedRecord, SnapshotBaseline, StoreError};
7use tokio::io::AsyncReadExt as _;
8
9use crate::{DbError, IrohDb};
10
11const SNAPSHOT_VERSION: u16 = 3;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct Snapshot {
16 blob: BlobRef,
17 state_root: [u8; 32],
18}
19
20impl Snapshot {
21 pub const fn from_parts(blob: BlobRef, state_root: [u8; 32]) -> Self {
23 Self { blob, state_root }
24 }
25
26 pub const fn blob_ref(self) -> BlobRef {
28 self.blob
29 }
30
31 pub const fn state_root(self) -> [u8; 32] {
33 self.state_root
34 }
35}
36
37#[derive(minicbor::Encode, minicbor::Decode)]
38#[cbor(array)]
39struct SnapshotContent {
40 #[n(0)]
41 version: u16,
42 #[n(1)]
43 domain_id: iroh_db_core::DomainId,
44 #[n(2)]
45 frontier: Vec<CommitId>,
46 #[n(3)]
47 records: Vec<Vec<u8>>,
48 #[n(4)]
49 covered_commits: Vec<CommitId>,
50 #[n(5)]
51 author_sequences: Vec<(AuthorId, u64)>,
52 #[n(6)]
53 epoch: u64,
54 #[n(7)]
55 control_head: Option<[u8; 32]>,
56 #[n(8)]
57 capability_id: CapabilityId,
58 #[n(9)]
59 schema_activations: Vec<Vec<u8>>,
60}
61
62#[derive(minicbor::Encode, minicbor::Decode)]
63#[cbor(array)]
64struct SignedSnapshot {
65 #[n(0)]
66 content: Vec<u8>,
67 #[n(1)]
68 state_root: [u8; 32],
69 #[n(2)]
70 author: AuthorId,
71 #[n(3)]
72 signature: [u8; 64],
73}
74
75impl IrohDb {
76 #[tracing::instrument(name = "iroh_db.snapshot.create", skip_all)]
78 pub async fn snapshot(&self) -> Result<Snapshot, DbError> {
79 let _control_reader = self.inner.control_gate.read().await;
80 let _writer = self.inner.writer.lock().await;
81 let domain_id = self.domain_id();
82 let active_epoch = self.inner.store.current_epoch(domain_id)?;
83 if self.epoch() != active_epoch {
84 return Err(DbError::StaleEpoch {
85 handle: self.epoch(),
86 active: active_epoch,
87 });
88 }
89 let capability_id =
90 self.capability_for_permission(self.author_id(), Permission::Snapshot, active_epoch)?;
91 let records = self.inner.store.export_materialized(domain_id)?;
92 let content = SnapshotContent {
93 version: SNAPSHOT_VERSION,
94 domain_id,
95 frontier: self.inner.store.frontier(domain_id)?,
96 records: encode_records(&records)?,
97 covered_commits: self.inner.store.list_commit_ids(domain_id)?,
98 author_sequences: self.inner.store.author_sequences(domain_id)?,
99 epoch: active_epoch,
100 control_head: control_head_id(self)?,
101 capability_id,
102 schema_activations: encode_activations(
103 &self.inner.store.active_schema_activations(domain_id)?,
104 ),
105 };
106 let content = minicbor::to_vec(content).map_err(snapshot_codec)?;
107 let state_root = state_root(&records)?;
108 let signer = self.inner.credentials.signer();
109 let signing_bytes = snapshot_signing_bytes(&content, &state_root, signer.author())?;
110 let envelope = SignedSnapshot {
111 content,
112 state_root,
113 author: signer.author(),
114 signature: signer.sign_snapshot(&signing_bytes),
115 };
116 let bytes = minicbor::to_vec(envelope).map_err(snapshot_codec)?;
117 let blob = self.blob_engine().import_bytes(bytes, None).await?;
118 self.inner
119 .store
120 .record_snapshot_observed_at(domain_id, unix_seconds()?)?;
121 self.inner.telemetry.record_snapshot_created();
122 Ok(Snapshot { blob, state_root })
123 }
124
125 #[tracing::instrument(name = "iroh_db.snapshot.install", skip_all)]
127 pub async fn install_snapshot(&self, snapshot: Snapshot) -> Result<(), DbError> {
128 let _control_reader = self.inner.control_gate.read().await;
129 let _writer = self.inner.writer.lock().await;
130 let (envelope, content, records) = self.decode_snapshot(snapshot).await?;
131 let active_epoch = self.inner.store.current_epoch(self.domain_id())?;
132 if content.epoch != active_epoch || self.epoch() != active_epoch {
133 return Err(DbError::InvalidSnapshot(
134 "snapshot epoch does not match active authority".into(),
135 ));
136 }
137 if content.control_head != control_head_id(self)? {
138 return Err(DbError::InvalidSnapshot(
139 "snapshot control head does not match active authority".into(),
140 ));
141 }
142 let capability_id =
143 self.capability_for_permission(envelope.author, Permission::Snapshot, content.epoch)?;
144 if content.capability_id != capability_id {
145 return Err(DbError::UnauthorizedDevice);
146 }
147 let baseline = SnapshotBaseline::new(
148 content.domain_id,
149 content.epoch,
150 snapshot.blob.manifest_hash(),
151 envelope.state_root,
152 content.frontier.clone(),
153 content.author_sequences.clone(),
154 )?;
155 let activations = decode_activations(&content.schema_activations)?;
156 let observed_at = unix_seconds()?;
157 if let Err(error) = self.inner.store.install_snapshot(
158 content.domain_id,
159 &records,
160 &content.covered_commits,
161 &content.frontier,
162 &content.author_sequences,
163 &activations,
164 observed_at,
165 &baseline,
166 ) {
167 return match error {
168 StoreError::InvalidSnapshotMetadata(message) => {
169 Err(DbError::InvalidSnapshot(message))
170 }
171 error => Err(DbError::Store(error)),
172 };
173 }
174 self.inner.telemetry.record_snapshot_installed();
175 Ok(())
176 }
177
178 pub(crate) async fn verified_snapshot_baseline_records(
179 &self,
180 ) -> Result<Option<Vec<MaterializedRecord>>, DbError> {
181 let Some(baseline) = self.inner.store.snapshot_baseline(self.domain_id())? else {
182 if self.inner.store.has_snapshot_coverage(self.domain_id())? {
183 return Err(DbError::InvalidSnapshot(
184 "snapshot coverage has no durable recovery baseline".into(),
185 ));
186 }
187 return Ok(None);
188 };
189 let snapshot = Snapshot::from_parts(
190 BlobRef::new(
191 baseline.domain_id(),
192 baseline.epoch(),
193 baseline.manifest_hash(),
194 ),
195 baseline.state_root(),
196 );
197 let (envelope, content, records) = self.decode_snapshot(snapshot).await?;
198 if content.epoch != baseline.epoch()
199 || content.frontier != baseline.frontier()
200 || content.author_sequences != baseline.author_sequences()
201 {
202 return Err(DbError::InvalidSnapshot(
203 "retained snapshot differs from its recovery baseline".into(),
204 ));
205 }
206 let coverage = self.inner.store.snapshot_coverage(self.domain_id())?;
207 if coverage
208 .iter()
209 .any(|commit_id| content.covered_commits.binary_search(commit_id).is_err())
210 {
211 return Err(DbError::InvalidSnapshot(
212 "snapshot coverage contains an unsigned commit".into(),
213 ));
214 }
215 for commit_id in &content.covered_commits {
216 if !self
217 .inner
218 .store
219 .contains_commit(self.domain_id(), *commit_id)?
220 && !self.inner.store.is_commit_quarantined(*commit_id)?
221 {
222 return Err(DbError::InvalidSnapshot(
223 "signed snapshot coverage is incomplete".into(),
224 ));
225 }
226 }
227 self.verify_historical_snapshot_authority(&envelope, &content)?;
228 Ok(Some(records))
229 }
230
231 async fn decode_snapshot(
232 &self,
233 snapshot: Snapshot,
234 ) -> Result<(SignedSnapshot, SnapshotContent, Vec<MaterializedRecord>), DbError> {
235 if snapshot.blob.domain_id() != self.domain_id() {
236 return Err(DbError::InvalidSnapshot(
237 "snapshot blob belongs to another domain".into(),
238 ));
239 }
240 let mut stream = self
241 .blob_engine_for_epoch(snapshot.blob.epoch())?
242 .open(&snapshot.blob)
243 .await?;
244 let mut bytes = Vec::new();
245 stream
246 .read_to_end(&mut bytes)
247 .await
248 .map_err(|error| DbError::InvalidSnapshot(error.to_string()))?;
249 let envelope: SignedSnapshot = decode_canonical(&bytes)?;
250 if envelope.state_root != snapshot.state_root {
251 return Err(DbError::InvalidSnapshot("state root mismatch".into()));
252 }
253 let signing_bytes =
254 snapshot_signing_bytes(&envelope.content, &envelope.state_root, envelope.author)?;
255 verify_snapshot_signature(envelope.author, &signing_bytes, &envelope.signature)?;
256 let content: SnapshotContent = decode_canonical(&envelope.content)?;
257 if content.version != SNAPSHOT_VERSION
258 || content.domain_id != self.domain_id()
259 || content.epoch != snapshot.blob.epoch()
260 {
261 return Err(DbError::InvalidSnapshot(
262 "unsupported version, wrong domain, or wrong epoch".into(),
263 ));
264 }
265 validate_snapshot_metadata(&content)?;
266 let records = decode_records(&content.records)?;
267 if state_root(&records)? != envelope.state_root {
268 return Err(DbError::InvalidSnapshot("state root mismatch".into()));
269 }
270 Ok((envelope, content, records))
271 }
272
273 fn verify_historical_snapshot_authority(
274 &self,
275 envelope: &SignedSnapshot,
276 content: &SnapshotContent,
277 ) -> Result<(), DbError> {
278 let Some(descriptor) = self.inner.store.domain_descriptor(content.domain_id)? else {
279 if content.capability_id != crate::db::private_capability(content.domain_id) {
280 return Err(DbError::UnauthorizedDevice);
281 }
282 return Ok(());
283 };
284 let snapshot_control_sequence = match content.control_head {
285 Some(control_id) => {
286 let control = self
287 .inner
288 .store
289 .control_transition(content.domain_id, control_id)?
290 .ok_or_else(|| {
291 DbError::InvalidSnapshot("snapshot authority anchor is not retained".into())
292 })?;
293 if control.id()? != control_id || control.new_epoch() != content.epoch {
294 return Err(DbError::InvalidSnapshot(
295 "snapshot authority anchor does not authorize its epoch".into(),
296 ));
297 }
298 if self
299 .inner
300 .store
301 .accepted_control_at_sequence(content.domain_id, control.sequence())?
302 .as_ref()
303 .map(iroh_db_security::ControlTransition::id)
304 .transpose()?
305 != Some(control_id)
306 {
307 return Err(DbError::InvalidSnapshot(
308 "snapshot authority anchor was not accepted".into(),
309 ));
310 }
311 control.sequence()
312 }
313 None if content.epoch == 0 => 0,
314 None => {
315 return Err(DbError::InvalidSnapshot(
316 "snapshot epoch has no authority anchor".into(),
317 ));
318 }
319 };
320 let chain = self.inner.store.capability_chain(content.capability_id)?;
321 let Some(root) = chain.first() else {
322 return Err(DbError::UnauthorizedDevice);
323 };
324 let Some(leaf) = chain.last() else {
325 return Err(DbError::UnauthorizedDevice);
326 };
327 if root.domain_id() != content.domain_id
328 || root.subject() != descriptor.owner()
329 || leaf.subject() != envelope.author
330 || !leaf.permits(Permission::Snapshot, content.epoch)
331 {
332 return Err(DbError::UnauthorizedDevice);
333 }
334 if !self.historical_capability_anchors_accepted(
335 content.domain_id,
336 &chain,
337 snapshot_control_sequence,
338 )? {
339 return Err(DbError::UnauthorizedDevice);
340 }
341 let revocations: std::collections::BTreeMap<_, _> = self
342 .inner
343 .store
344 .subject_revocations(content.domain_id)?
345 .into_iter()
346 .collect();
347 if chain.iter().any(|capability| {
348 revocations
349 .get(&capability.subject())
350 .is_some_and(|revoked_at| {
351 *revoked_at > capability.issued_control_sequence()
352 && *revoked_at <= snapshot_control_sequence
353 })
354 }) {
355 return Err(DbError::UnauthorizedDevice);
356 }
357 Ok(())
358 }
359
360 fn historical_capability_anchors_accepted(
361 &self,
362 domain_id: iroh_db_core::DomainId,
363 chain: &[CapabilityCertificate],
364 snapshot_control_sequence: u64,
365 ) -> Result<bool, DbError> {
366 let checkpoint = self.inner.store.authority_checkpoint(domain_id)?;
367 let checkpoint_sequence = checkpoint
368 .as_ref()
369 .and_then(iroh_db_security::AuthorityCheckpoint::head)
370 .map(iroh_db_security::ControlTransition::sequence);
371 for capability in chain {
372 let sequence = capability.issued_control_sequence();
373 if sequence > snapshot_control_sequence {
374 return Ok(false);
375 }
376 let before_checkpoint = checkpoint_sequence.is_some_and(|cut| sequence < cut);
377 let anchor_is_accepted = if before_checkpoint {
378 let capability_id = capability.id()?;
379 checkpoint
380 .as_ref()
381 .is_some_and(|checkpoint| checkpoint.capability_ids().contains(&capability_id))
382 } else if sequence == 0 {
383 capability.issued_control_head().is_none()
384 } else {
385 let Some(control) = self
386 .inner
387 .store
388 .accepted_control_at_sequence(domain_id, sequence)?
389 else {
390 return Ok(false);
391 };
392 capability.issued_control_head() == Some(control.id()?)
393 };
394 if !anchor_is_accepted {
395 return Ok(false);
396 }
397 }
398 Ok(true)
399 }
400
401 pub async fn rebuild(&self) -> Result<[u8; 32], DbError> {
403 let _control_reader = self.inner.control_gate.read().await;
404 let _writer = self.inner.writer.lock().await;
405 let domain_id = self.domain_id();
406 let mutations = self.rebuild_mutations().await?;
407 self.inner
408 .store
409 .replace_materialized(domain_id, &mutations)?;
410 state_root(&mutations)
411 }
412
413 pub fn state_root(&self) -> Result<[u8; 32], DbError> {
415 let records = self.inner.store.export_materialized(self.domain_id())?;
416 state_root(&records)
417 }
418}
419
420fn validate_snapshot_metadata(content: &SnapshotContent) -> Result<(), DbError> {
421 if !strictly_sorted(&content.frontier)
422 || !strictly_sorted(&content.covered_commits)
423 || !content
424 .author_sequences
425 .windows(2)
426 .all(|pair| pair[0].0 < pair[1].0)
427 || content
428 .frontier
429 .iter()
430 .any(|commit_id| content.covered_commits.binary_search(commit_id).is_err())
431 || content.covered_commits.is_empty() != content.frontier.is_empty()
432 || content.covered_commits.is_empty() != content.author_sequences.is_empty()
433 {
434 return Err(DbError::InvalidSnapshot(
435 "snapshot causal metadata is malformed".into(),
436 ));
437 }
438 decode_activations(&content.schema_activations)?;
439 Ok(())
440}
441
442fn strictly_sorted<T: Ord>(values: &[T]) -> bool {
443 values.windows(2).all(|pair| pair[0] < pair[1])
444}
445
446fn control_head_id(db: &IrohDb) -> Result<Option<[u8; 32]>, DbError> {
447 db.inner
448 .store
449 .control_head(db.domain_id())?
450 .as_ref()
451 .map(iroh_db_security::ControlTransition::id)
452 .transpose()
453 .map_err(DbError::from)
454}
455
456fn encode_records(records: &[MaterializedRecord]) -> Result<Vec<Vec<u8>>, DbError> {
457 records
458 .iter()
459 .map(|record| record.encode_canonical().map_err(DbError::from))
460 .collect()
461}
462
463fn decode_records(records: &[Vec<u8>]) -> Result<Vec<MaterializedRecord>, DbError> {
464 records
465 .iter()
466 .map(|record| MaterializedRecord::decode_canonical(record).map_err(DbError::from))
467 .collect()
468}
469
470fn encode_activations(activations: &[SchemaActivation]) -> Vec<Vec<u8>> {
471 activations
472 .iter()
473 .map(SchemaActivation::encode_canonical)
474 .collect()
475}
476
477fn decode_activations(values: &[Vec<u8>]) -> Result<Vec<SchemaActivation>, DbError> {
478 let activations = values
479 .iter()
480 .map(|bytes| SchemaActivation::decode_canonical(bytes).map_err(DbError::from))
481 .collect::<Result<Vec<_>, _>>()?;
482 if activations
483 .windows(2)
484 .any(|pair| pair[0].to().collection_id() >= pair[1].to().collection_id())
485 {
486 return Err(DbError::InvalidSnapshot(
487 "schema activations are not unique and ordered".into(),
488 ));
489 }
490 Ok(activations)
491}
492
493fn state_root(records: &[MaterializedRecord]) -> Result<[u8; 32], DbError> {
494 let bytes = minicbor::to_vec(encode_records(records)?).map_err(snapshot_codec)?;
495 Ok(*blake3::hash(&bytes).as_bytes())
496}
497
498fn snapshot_signing_bytes(
499 content: &[u8],
500 state_root: &[u8; 32],
501 author: AuthorId,
502) -> Result<Vec<u8>, DbError> {
503 minicbor::to_vec((content, state_root, author)).map_err(snapshot_codec)
504}
505
506fn decode_canonical<T>(bytes: &[u8]) -> Result<T, DbError>
507where
508 T: minicbor::Encode<()> + for<'value> minicbor::Decode<'value, ()>,
509{
510 let mut decoder = minicbor::Decoder::new(bytes);
511 let value = decoder.decode().map_err(snapshot_codec)?;
512 if decoder.position() != bytes.len()
513 || minicbor::to_vec(&value).map_err(snapshot_codec)? != bytes
514 {
515 return Err(DbError::InvalidSnapshot("non-canonical encoding".into()));
516 }
517 Ok(value)
518}
519
520#[allow(clippy::needless_pass_by_value)]
521fn snapshot_codec(error: impl std::fmt::Display) -> DbError {
522 DbError::InvalidSnapshot(error.to_string())
523}
524
525fn unix_seconds() -> Result<u64, DbError> {
526 SystemTime::now()
527 .duration_since(UNIX_EPOCH)
528 .map(|duration| duration.as_secs())
529 .map_err(|_| DbError::Operation("system clock is before the Unix epoch".into()))
530}