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::{BlobFormat, Hash, ticket::BlobTicket};
10use iroh_docs::NamespaceId;
11use serde::{Deserialize, Serialize};
12
13use crate::{
14 error::{Result, SyncwebError},
15 folder::SyncwebFolder,
16 fs::{ExportEntry, Exporter, ParallelScanner},
17 node::{blob_store::BlobStore, docs_engine::DocsEngine},
18};
19
20const SCHEMA_VERSION: u32 = 1;
21const SNAPSHOT_PIN_PREFIX: &str = "syncweb/snapshot/";
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 = format!("{SNAPSHOT_PIN_PREFIX}{id}/manifest");
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 = manifest_pin_name(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.unpin(content_pin_name(id, entry.hash)).await?;
419 }
420 Ok(())
421 }
422
423 pub async fn ticket(&self, endpoint: &Endpoint, snapshot: &Snapshot) -> Result<BlobTicket> {
429 let hash = Hash::new(snapshot.to_bytes()?);
430 if !self.blobs.has(hash).await? {
431 return Err(SyncwebError::InvalidConfig(
432 "snapshot manifest is not stored".to_owned(),
433 ));
434 }
435 Ok(self.blobs.ticket(endpoint, hash))
436 }
437
438 pub async fn import_ticket(&self, endpoint: &Endpoint, ticket: &BlobTicket) -> Result<Snapshot> {
444 if !self.blobs.has(ticket.hash()).await? {
445 self.blobs.fetch(endpoint, ticket).await?;
446 }
447 let snapshot = self.load_manifest(ticket.hash()).await?;
448 for entry in &snapshot.entries {
449 if !self.blobs.has(entry.hash).await? {
450 let content_ticket = BlobTicket::new(ticket.addr().clone(), entry.hash, BlobFormat::Raw);
451 self.blobs.fetch(endpoint, &content_ticket).await?;
452 }
453 }
454 self.store(&snapshot).await?;
455 Ok(snapshot)
456 }
457
458 async fn store(&self, snapshot: &Snapshot) -> Result<()> {
459 snapshot.validate()?;
460 for entry in &snapshot.entries {
461 if !self.blobs.has(entry.hash).await? {
462 return Err(SyncwebError::InvalidConfig(format!(
463 "snapshot content is missing: {}",
464 entry.path.display()
465 )));
466 }
467 self.blobs
468 .pin(content_pin_name(snapshot.id, entry.hash), entry.hash)
469 .await?;
470 }
471 let bytes = snapshot.to_bytes()?;
472 let manifest_hash = self.blobs.add_bytes(&bytes).await?;
473 self.blobs.pin(manifest_pin_name(snapshot.id), manifest_hash).await?;
474 Ok(())
475 }
476
477 async fn load_manifest(&self, hash: Hash) -> Result<Snapshot> {
478 let snapshot = Snapshot::from_bytes(self.blobs.get(hash).await?)?;
479 if snapshot.id != snapshot.root_hash {
480 return Err(SyncwebError::InvalidConfig(
481 "snapshot ID does not match its root".to_owned(),
482 ));
483 }
484 Ok(snapshot)
485 }
486
487 async fn verify_blobs(&self, snapshot: &Snapshot) -> Result<()> {
488 for entry in &snapshot.entries {
489 if !self.blobs.has(entry.hash).await? {
490 return Err(SyncwebError::InvalidConfig(format!(
491 "snapshot content is unavailable: {}",
492 entry.path.display()
493 )));
494 }
495 }
496 Ok(())
497 }
498}
499
500fn entries_hash(entries: &[SnapshotEntry]) -> Hash {
501 let bytes = serde_json::to_vec(entries).unwrap_or_default();
502 Hash::new(bytes)
503}
504
505fn validate_path(path: &Path) -> Result<()> {
506 if path.as_os_str().is_empty()
507 || path.is_absolute()
508 || path.components().any(|component| {
509 matches!(
510 component,
511 Component::ParentDir | Component::RootDir | Component::Prefix(_)
512 )
513 })
514 {
515 return Err(SyncwebError::InvalidConfig(format!(
516 "snapshot path must be a non-empty relative path: {}",
517 path.display()
518 )));
519 }
520 Ok(())
521}
522
523fn manifest_pin_name(id: Hash) -> String {
524 format!("{SNAPSHOT_PIN_PREFIX}{id}/manifest")
525}
526
527fn content_pin_name(id: Hash, hash: Hash) -> String {
528 format!("{SNAPSHOT_PIN_PREFIX}{id}/blob/{hash}")
529}