1use std::{
2 collections::{BTreeMap, BTreeSet},
3 fs,
4 path::{Component, Path, PathBuf},
5 time::{SystemTime, UNIX_EPOCH},
6};
7
8use iroh::Endpoint;
9use iroh_blobs::{Hash, ticket::BlobTicket};
10use iroh_docs::NamespaceId;
11use serde::{Deserialize, Serialize};
12
13use crate::{
14 constants::SNAPSHOT_PIN_PREFIX,
15 error::{Result, SyncwebError},
16 folder::SyncwebFolder,
17 fs::{ExportEntry, Exporter, ParallelScanner},
18 node::{blob_store::BlobStore, docs_engine::DocsEngine},
19};
20
21const SCHEMA_VERSION: u32 = 1;
22
23pub type SnapshotId = Hash;
25
26#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
28#[non_exhaustive]
29pub struct SnapshotEntry {
30 pub path: PathBuf,
31 pub hash: Hash,
32 pub size: u64,
33}
34
35impl SnapshotEntry {
36 pub fn new(path_value: impl Into<PathBuf>, hash: Hash, size: u64) -> Result<Self> {
40 let path = path_value.into();
41 validate_path(&path)?;
42 Ok(Self { path, hash, size })
43 }
44}
45
46#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
48#[non_exhaustive]
49pub struct Snapshot {
50 pub schema_version: u32,
51 pub id: SnapshotId,
52 pub namespace_id: Option<NamespaceId>,
53 pub root_hash: Hash,
54 pub created_at: u64,
55 pub description: Option<String>,
56 pub total_size: u64,
57 pub file_count: u64,
58 pub entries: Vec<SnapshotEntry>,
59}
60
61impl Snapshot {
62 #[must_use]
66 pub fn new(
67 namespace_id: Option<NamespaceId>,
68 mut entries: Vec<SnapshotEntry>,
69 description: Option<String>,
70 ) -> Self {
71 entries.sort_by(|left, right| left.path.cmp(&right.path));
72 let root_hash = entries_hash(&entries);
73 let total_size = entries.iter().map(|entry| entry.size).sum();
74 let file_count = u64::try_from(entries.len()).unwrap_or(u64::MAX);
75 let created_at = SystemTime::now()
76 .duration_since(UNIX_EPOCH)
77 .map_or(0, |duration| duration.as_secs());
78 Self {
79 schema_version: SCHEMA_VERSION,
80 id: root_hash,
81 namespace_id,
82 root_hash,
83 created_at,
84 description,
85 total_size,
86 file_count,
87 entries,
88 }
89 }
90
91 pub fn validate(&self) -> Result<()> {
97 if self.schema_version != SCHEMA_VERSION {
98 return Err(SyncwebError::InvalidConfig(format!(
99 "unsupported snapshot schema version {}",
100 self.schema_version
101 )));
102 }
103 let mut paths = BTreeSet::new();
104 for entry in &self.entries {
105 validate_path(&entry.path)?;
106 if !paths.insert(&entry.path) {
107 return Err(SyncwebError::InvalidConfig(format!(
108 "duplicate snapshot path {}",
109 entry.path.display()
110 )));
111 }
112 }
113 let expected_count = u64::try_from(self.entries.len()).unwrap_or(u64::MAX);
114 if self.file_count != expected_count {
115 return Err(SyncwebError::InvalidConfig(
116 "snapshot file count is incorrect".to_owned(),
117 ));
118 }
119 let expected_size: u64 = self.entries.iter().map(|entry| entry.size).sum();
120 if self.total_size != expected_size {
121 return Err(SyncwebError::InvalidConfig(
122 "snapshot total size is incorrect".to_owned(),
123 ));
124 }
125 if self.root_hash != entries_hash(&self.entries) || self.id != self.root_hash {
126 return Err(SyncwebError::InvalidConfig(
127 "snapshot content root is incorrect".to_owned(),
128 ));
129 }
130 Ok(())
131 }
132
133 pub fn to_bytes(&self) -> Result<Vec<u8>> {
139 self.validate()?;
140 serde_json::to_vec(self).map_err(|error| SyncwebError::operation("failed to serialize snapshot", error))
141 }
142
143 pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self> {
149 let snapshot: Self = serde_json::from_slice(bytes.as_ref())
150 .map_err(|error| SyncwebError::operation("failed to deserialize snapshot", error))?;
151 snapshot.validate()?;
152 Ok(snapshot)
153 }
154
155 pub fn diff(&self, other: &Self) -> Result<SnapshotDiff> {
161 self.validate()?;
162 other.validate()?;
163 let left = self
164 .entries
165 .iter()
166 .map(|entry| (&entry.path, entry))
167 .collect::<BTreeMap<_, _>>();
168 let right = other
169 .entries
170 .iter()
171 .map(|entry| (&entry.path, entry))
172 .collect::<BTreeMap<_, _>>();
173 let mut diff = SnapshotDiff::default();
174 for (path, entry) in &right {
175 match left.get(path) {
176 None => diff.added.push((*entry).clone()),
177 Some(previous) if *previous != *entry => diff.modified.push(((*previous).clone(), (*entry).clone())),
178 Some(_) => {}
179 }
180 }
181 for (path, entry) in &left {
182 if !right.contains_key(path) {
183 diff.removed.push((*entry).clone());
184 }
185 }
186 Ok(diff)
187 }
188}
189
190#[derive(Clone, Debug, Default, Eq, PartialEq)]
192#[non_exhaustive]
193pub struct SnapshotDiff {
194 pub added: Vec<SnapshotEntry>,
195 pub removed: Vec<SnapshotEntry>,
196 pub modified: Vec<(SnapshotEntry, SnapshotEntry)>,
197}
198
199#[derive(Clone)]
201pub struct SnapshotStore {
202 blobs: BlobStore,
203 docs: Option<DocsEngine>,
204}
205
206impl SnapshotStore {
207 #[must_use]
208 pub const fn new(blobs: BlobStore) -> Self {
209 Self { blobs, docs: None }
210 }
211
212 #[must_use]
213 pub const fn with_docs(blobs: BlobStore, docs: DocsEngine) -> Self {
214 Self {
215 blobs,
216 docs: Some(docs),
217 }
218 }
219
220 #[must_use]
221 pub fn from_node(node: &crate::node::iroh_node::IrohNode) -> Self {
222 Self::with_docs(node.blob_store().clone(), node.docs_engine().clone())
223 }
224
225 pub async fn create_snapshot(
231 &self,
232 namespace_id: Option<NamespaceId>,
233 entries: Vec<SnapshotEntry>,
234 description: Option<String>,
235 ) -> Result<Snapshot> {
236 let snapshot = Snapshot::new(namespace_id, entries, description);
237 self.store(&snapshot).await?;
238 Ok(snapshot)
239 }
240
241 pub async fn create_from_path(
250 &self,
251 source_value: impl AsRef<Path>,
252 threads: usize,
253 description: Option<String>,
254 ) -> Result<Snapshot> {
255 let source_path = source_value.as_ref();
256 let entries = ParallelScanner::new(source_path, Vec::<String>::new(), threads).scan()?;
257 let mut snapshot_entries = Vec::with_capacity(entries.len());
258 for entry in entries {
259 let hash = self.blobs.add_file(&entry.path).await?;
260 let expected = Hash::from_bytes(*entry.hash.as_bytes());
261 if hash != expected {
262 return Err(SyncwebError::InvalidConfig(format!(
263 "file changed while creating snapshot: {}",
264 entry.path.display()
265 )));
266 }
267 snapshot_entries.push(SnapshotEntry::new(entry.relative_path, hash, entry.size)?);
268 }
269 self.create_snapshot(None, snapshot_entries, description).await
270 }
271
272 pub async fn create_for_folder(&self, folder: &SyncwebFolder, description: Option<String>) -> Result<Snapshot> {
278 let docs = self
279 .docs
280 .as_ref()
281 .ok_or_else(|| SyncwebError::InvalidConfig("snapshot store has no document engine".to_owned()))?;
282 let entries = docs.list_latest(folder.doc()).await?;
283 let snapshot_entries = entries
284 .into_iter()
285 .filter(|entry| !entry.key().starts_with(b"sys/"))
286 .map(|entry| {
287 let path = String::from_utf8(entry.key().to_vec())
288 .map_err(|error| SyncwebError::operation("folder entry path is not UTF-8", error))?;
289 SnapshotEntry::new(path, entry.content_hash(), entry.content_len())
290 })
291 .collect::<Result<Vec<_>>>()?;
292 self.create_snapshot(Some(folder.namespace_id()), snapshot_entries, description)
293 .await
294 }
295
296 pub async fn restore_to_path(
302 &self,
303 snapshot: &Snapshot,
304 destination_value: impl AsRef<Path>,
305 ) -> Result<Vec<PathBuf>> {
306 snapshot.validate()?;
307 self.verify_blobs(snapshot).await?;
308 let destination = destination_value.as_ref();
309 fs::create_dir_all(destination)?;
310 let expected = snapshot
311 .entries
312 .iter()
313 .map(|entry| entry.path.clone())
314 .collect::<BTreeSet<_>>();
315 for entry in ParallelScanner::new(destination, Vec::<String>::new(), 1).scan()? {
316 if !expected.contains(&entry.relative_path) {
317 fs::remove_file(entry.path)?;
318 }
319 }
320 let exports = snapshot
321 .entries
322 .iter()
323 .map(|entry| ExportEntry::new(entry.path.clone(), entry.hash, entry.size))
324 .collect::<Vec<_>>();
325 Exporter::new(self.blobs.clone(), destination)
326 .export_all_verified(&exports)
327 .await
328 }
329
330 pub async fn restore_for_folder(&self, folder: &SyncwebFolder, snapshot: &Snapshot) -> Result<()> {
337 snapshot.validate()?;
338 if snapshot.namespace_id != Some(folder.namespace_id()) {
339 return Err(SyncwebError::InvalidConfig(
340 "snapshot belongs to a different folder".to_owned(),
341 ));
342 }
343 self.verify_blobs(snapshot).await?;
344 let docs = self
345 .docs
346 .as_ref()
347 .ok_or_else(|| SyncwebError::InvalidConfig("snapshot store has no document engine".to_owned()))?;
348 let expected = snapshot
349 .entries
350 .iter()
351 .map(|entry| entry.path.as_os_str().as_encoded_bytes().to_vec())
352 .collect::<BTreeSet<_>>();
353 for entry in docs.list_latest(folder.doc()).await? {
354 if !entry.key().starts_with(b"sys/") && !expected.contains(entry.key()) {
355 folder.delete_entry(entry.key()).await?;
356 }
357 }
358 for entry in &snapshot.entries {
359 folder
360 .set_blob_ref(entry.path.as_os_str().as_encoded_bytes(), entry.hash, entry.size)
361 .await?;
362 }
363 Ok(())
364 }
365
366 pub async fn list(&self) -> Result<Vec<Snapshot>> {
372 let mut snapshots = Vec::new();
373 for (name, hash) in self.blobs.list_pins(SNAPSHOT_PIN_PREFIX).await? {
374 if !name.ends_with("/manifest") {
375 continue;
376 }
377 let snapshot = self.load_manifest(hash).await?;
378 snapshots.push(snapshot);
379 }
380 snapshots.sort_by(|left, right| right.created_at.cmp(&left.created_at).then(left.id.cmp(&right.id)));
381 Ok(snapshots)
382 }
383
384 pub async fn load(&self, id: SnapshotId) -> Result<Snapshot> {
390 let prefix = crate::pins::snapshot_manifest_pin(id);
391 let hash = self
392 .blobs
393 .list_pins(prefix.as_bytes())
394 .await?
395 .into_iter()
396 .find_map(|(name, hash)| (name == prefix).then_some(hash))
397 .ok_or_else(|| SyncwebError::InvalidConfig(format!("snapshot not found: {id}")))?;
398 self.load_manifest(hash).await
399 }
400
401 pub async fn delete(&self, id: SnapshotId) -> Result<()> {
407 let manifest_name = crate::pins::snapshot_manifest_pin(id);
408 let manifest_hash = self
409 .blobs
410 .list_pins(manifest_name.as_bytes())
411 .await?
412 .into_iter()
413 .find_map(|(name, hash)| (name == manifest_name).then_some(hash))
414 .ok_or_else(|| SyncwebError::InvalidConfig(format!("snapshot not found: {id}")))?;
415 let snapshot = self.load_manifest(manifest_hash).await?;
416 self.blobs.unpin(manifest_name).await?;
417 for entry in &snapshot.entries {
418 self.blobs
419 .unpin(crate::pins::snapshot_content_pin(id, entry.hash))
420 .await?;
421 }
422 Ok(())
423 }
424
425 pub async fn ticket(&self, endpoint: &Endpoint, snapshot: &Snapshot) -> Result<BlobTicket> {
431 let hash = Hash::new(snapshot.to_bytes()?);
432 if !self.blobs.has(hash).await? {
433 return Err(SyncwebError::InvalidConfig(
434 "snapshot manifest is not stored".to_owned(),
435 ));
436 }
437 Ok(self.blobs.ticket(endpoint, hash))
438 }
439
440 pub async fn import_ticket(&self, endpoint: &Endpoint, ticket: &BlobTicket) -> Result<Snapshot> {
446 if !self.blobs.has(ticket.hash()).await? {
447 self.blobs.fetch(endpoint, ticket).await?;
448 }
449 let snapshot = self.load_manifest(ticket.hash()).await?;
450 for entry in &snapshot.entries {
451 if !self.blobs.has(entry.hash).await? {
452 let content_ticket = self.blobs.ticket_for_addr(ticket.addr().clone(), entry.hash);
453 self.blobs.fetch(endpoint, &content_ticket).await?;
454 }
455 }
456 self.store(&snapshot).await?;
457 Ok(snapshot)
458 }
459
460 async fn store(&self, snapshot: &Snapshot) -> Result<()> {
461 snapshot.validate()?;
462 for entry in &snapshot.entries {
463 if !self.blobs.has(entry.hash).await? {
464 return Err(SyncwebError::InvalidConfig(format!(
465 "snapshot content is missing: {}",
466 entry.path.display()
467 )));
468 }
469 self.blobs
470 .pin(crate::pins::snapshot_content_pin(snapshot.id, entry.hash), entry.hash)
471 .await?;
472 }
473 let bytes = snapshot.to_bytes()?;
474 let manifest_hash = self.blobs.add_bytes(&bytes).await?;
475 self.blobs
476 .pin(crate::pins::snapshot_manifest_pin(snapshot.id), manifest_hash)
477 .await?;
478 Ok(())
479 }
480
481 async fn load_manifest(&self, hash: Hash) -> Result<Snapshot> {
482 let snapshot = Snapshot::from_bytes(self.blobs.get(hash).await?)?;
483 if snapshot.id != snapshot.root_hash {
484 return Err(SyncwebError::InvalidConfig(
485 "snapshot ID does not match its root".to_owned(),
486 ));
487 }
488 Ok(snapshot)
489 }
490
491 async fn verify_blobs(&self, snapshot: &Snapshot) -> Result<()> {
492 for entry in &snapshot.entries {
493 if !self.blobs.has(entry.hash).await? {
494 return Err(SyncwebError::InvalidConfig(format!(
495 "snapshot content is unavailable: {}",
496 entry.path.display()
497 )));
498 }
499 }
500 Ok(())
501 }
502}
503
504fn entries_hash(entries: &[SnapshotEntry]) -> Hash {
505 let bytes = serde_json::to_vec(entries).unwrap_or_default();
506 Hash::new(bytes)
507}
508
509fn validate_path(path: &Path) -> Result<()> {
510 if path.as_os_str().is_empty()
511 || path.is_absolute()
512 || path.components().any(|component| {
513 matches!(
514 component,
515 Component::ParentDir | Component::RootDir | Component::Prefix(_)
516 )
517 })
518 {
519 return Err(SyncwebError::InvalidConfig(format!(
520 "snapshot path must be a non-empty relative path: {}",
521 path.display()
522 )));
523 }
524 Ok(())
525}