Skip to main content

syncweb_core/
verify.rs

1use std::path::PathBuf;
2
3use iroh_blobs::Hash;
4
5use crate::{
6    Result, SyncwebError,
7    folder::SyncwebFolder,
8    node::{blob_store::BlobStore, docs_engine::DocsEngine},
9};
10
11/// Details about a blob whose local bytes do not match its expected hash.
12#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
13#[non_exhaustive]
14pub struct CorruptionInfo {
15    pub path: PathBuf,
16    pub expected_hash: Hash,
17    pub actual_hash: Hash,
18}
19
20/// Result of checking local content referenced by a folder.
21#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize)]
22#[non_exhaustive]
23pub struct VerifyResult {
24    pub total: u64,
25    pub verified: u64,
26    pub corrupted: Vec<CorruptionInfo>,
27    pub missing: Vec<PathBuf>,
28}
29
30impl VerifyResult {
31    #[must_use]
32    pub const fn is_valid(&self) -> bool {
33        self.corrupted.is_empty() && self.missing.is_empty()
34    }
35}
36
37/// Filter constraints for selective verification.
38#[derive(Clone, Debug, Default)]
39#[non_exhaustive]
40pub struct VerifyFilter {
41    pub hashes: Option<Vec<Hash>>,
42    pub path: Option<PathBuf>,
43    pub glob: Option<String>,
44}
45
46impl VerifyFilter {
47    #[must_use]
48    pub const fn new() -> Self {
49        Self {
50            hashes: None,
51            path: None,
52            glob: None,
53        }
54    }
55
56    #[must_use]
57    pub fn with_hashes(mut self, hashes: Vec<Hash>) -> Self {
58        self.hashes = Some(hashes);
59        self
60    }
61
62    #[must_use]
63    pub fn with_path(mut self, path: PathBuf) -> Self {
64        self.path = Some(path);
65        self
66    }
67
68    /// Check whether an entry matches this filter.
69    #[must_use]
70    pub fn matches(&self, entry_key: &[u8], entry_hash: &Hash) -> bool {
71        if let Some(ref hashes) = self.hashes
72            && !hashes.contains(entry_hash)
73        {
74            return false;
75        }
76        if let Some(ref path) = self.path {
77            let entry_path = String::from_utf8_lossy(entry_key);
78            if entry_path != path.to_string_lossy() {
79                return false;
80            }
81        }
82        if let Some(ref glob) = self.glob {
83            let entry_path = String::from_utf8_lossy(entry_key);
84            if !glob_match(glob, &entry_path) {
85                return false;
86            }
87        }
88        true
89    }
90}
91
92fn glob_match(pattern: &str, path: &str) -> bool {
93    globset::Glob::new(pattern).is_ok_and(|glob| glob.compile_matcher().is_match(path))
94}
95
96/// Outcome of attempting to repair a single corrupt blob.
97#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
98#[non_exhaustive]
99pub struct RepairOutcome {
100    pub path: PathBuf,
101    pub hash: Hash,
102    pub success: bool,
103    pub error: Option<String>,
104}
105
106impl RepairOutcome {
107    #[must_use]
108    pub const fn new(path: PathBuf, hash: Hash, success: bool, error: Option<String>) -> Self {
109        Self {
110            path,
111            hash,
112            success,
113            error,
114        }
115    }
116}
117
118/// Result of a repair attempt across corrupt blobs.
119#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize)]
120#[non_exhaustive]
121pub struct RepairResult {
122    pub attempted: u64,
123    pub repaired: u64,
124    pub failed: Vec<RepairOutcome>,
125}
126
127/// Result of a combined verify+repair operation.
128#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
129#[non_exhaustive]
130pub struct FixedVerifyResult {
131    pub verify: VerifyResult,
132    pub repair: Option<RepairResult>,
133}
134
135impl FixedVerifyResult {
136    #[must_use]
137    pub const fn new(verify: VerifyResult, repair: Option<RepairResult>) -> Self {
138        Self { verify, repair }
139    }
140}
141
142/// Re-checks the BLAKE3 content hash of locally stored folder blobs.
143#[derive(Clone)]
144pub struct IntegrityChecker {
145    blob_store: BlobStore,
146    docs_engine: DocsEngine,
147}
148
149impl IntegrityChecker {
150    #[must_use]
151    pub const fn new(blob_store: BlobStore, docs_engine: DocsEngine) -> Self {
152        Self {
153            blob_store,
154            docs_engine,
155        }
156    }
157
158    #[must_use]
159    pub const fn blob_store(&self) -> &BlobStore {
160        &self.blob_store
161    }
162
163    #[must_use]
164    pub const fn docs_engine(&self) -> &DocsEngine {
165        &self.docs_engine
166    }
167
168    /// Verify every non-system entry in a folder.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if folder entries or local blobs cannot be read.
173    pub async fn verify_folder(&self, folder: &SyncwebFolder) -> Result<VerifyResult> {
174        let entries = folder.content_entries().await?;
175        let mut result = VerifyResult::default();
176        for entry in entries {
177            result.total = result.total.saturating_add(1);
178            let path = String::from_utf8(entry.key().to_vec())
179                .map(PathBuf::from)
180                .map_err(|error| SyncwebError::operation("folder entry path is not UTF-8", error))?;
181            let expected = entry.content_hash();
182            if !self.blob_store.has(expected).await? {
183                result.missing.push(path);
184                continue;
185            }
186            let bytes = self.blob_store.get(expected).await?;
187            let actual = Hash::from_bytes(*blake3::hash(&bytes).as_bytes());
188            if actual == expected {
189                result.verified = result.verified.saturating_add(1);
190            } else {
191                result.corrupted.push(CorruptionInfo {
192                    path,
193                    expected_hash: expected,
194                    actual_hash: actual,
195                });
196            }
197        }
198        Ok(result)
199    }
200
201    /// Verify folder entries matching the optional filter.
202    ///
203    /// When the filter is `None`, all non-system entries are verified.
204    ///
205    /// # Errors
206    ///
207    /// Returns an error if folder entries or local blobs cannot be read.
208    pub async fn verify_folder_filtered(
209        &self,
210        folder: &SyncwebFolder,
211        filter: Option<&VerifyFilter>,
212    ) -> Result<VerifyResult> {
213        let entries = folder.content_entries().await?;
214        let mut result = VerifyResult::default();
215        for entry in entries {
216            let expected = entry.content_hash();
217            if filter.is_some_and(|f| !f.matches(entry.key(), &expected)) {
218                continue;
219            }
220            result.total = result.total.saturating_add(1);
221            let path = String::from_utf8(entry.key().to_vec())
222                .map(PathBuf::from)
223                .map_err(|error| SyncwebError::operation("folder entry path is not UTF-8", error))?;
224            if !self.blob_store.has(expected).await? {
225                result.missing.push(path);
226                continue;
227            }
228            let bytes = self.blob_store.get(expected).await?;
229            let actual = Hash::from_bytes(*blake3::hash(&bytes).as_bytes());
230            if actual == expected {
231                result.verified = result.verified.saturating_add(1);
232            } else {
233                result.corrupted.push(CorruptionInfo {
234                    path,
235                    expected_hash: expected,
236                    actual_hash: actual,
237                });
238            }
239        }
240        Ok(result)
241    }
242}