Skip to main content

syncweb_core/folder/
archive_verify.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    path::Path,
4};
5
6use async_compression::tokio::bufread::ZstdDecoder;
7use iroh_blobs::Hash;
8use tokio::{
9    fs,
10    io::{AsyncRead, AsyncReadExt, BufReader},
11};
12use uuid::Uuid;
13
14use crate::{
15    error::{Result, SyncwebError},
16    folder::CollectionManifest,
17};
18
19const CAR_VERSION: u64 = 1;
20const CID_VERSION: u64 = 1;
21const RAW_CODEC: u64 = 0x55;
22const BLAKE3_MULTIHASH: u64 = 0x1e;
23const HASH_SIZE: u64 = 32;
24const MAX_HEADER_SIZE: u64 = 64 * 1024;
25const MAX_MANIFEST_SIZE: u64 = 16 * 1024 * 1024;
26const COPY_BUFFER_SIZE: usize = 64 * 1024;
27
28/// The result of verifying a complete compressed drop archive.
29#[derive(Clone, Debug, Eq, PartialEq)]
30#[non_exhaustive]
31pub struct DropVerifyResult {
32    pub manifest: Hash,
33    pub collection_id: Uuid,
34    pub version: String,
35    pub entry_count: usize,
36    pub block_count: usize,
37    pub bytes_verified: u64,
38}
39
40/// Compatibility name for [`DropVerifyResult`].
41pub type DropVerificationResult = DropVerifyResult;
42
43/// Streams and verifies a Zstandard-compressed CAR drop archive.
44#[derive(Clone, Copy, Debug, Default)]
45#[non_exhaustive]
46pub struct DropVerifier;
47
48impl DropVerifier {
49    #[must_use]
50    pub const fn new() -> Self {
51        Self
52    }
53
54    /// Verify a compressed drop archive at a filesystem path.
55    ///
56    /// # Errors
57    ///
58    /// Returns an error if the archive is truncated, malformed, tampered with,
59    /// or inconsistent with its manifest.
60    pub async fn verify(&self, path: impl AsRef<Path>) -> Result<DropVerifyResult> {
61        let file = fs::File::open(path)
62            .await
63            .map_err(|error| SyncwebError::operation("failed to open drop archive", error))?;
64        self.verify_reader(file).await
65    }
66
67    /// Verify a compressed drop archive from an asynchronous reader.
68    ///
69    /// The reader is consumed incrementally; the compressed archive is never
70    /// loaded into memory as a whole.
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if the archive is truncated, malformed, tampered with,
75    /// or inconsistent with its manifest.
76    pub async fn verify_reader<R>(&self, reader: R) -> Result<DropVerifyResult>
77    where
78        R: AsyncRead + Unpin,
79    {
80        let decoder = ZstdDecoder::new(BufReader::new(reader));
81        verify_car(decoder).await
82    }
83}
84
85/// Verify a compressed drop archive at a filesystem path.
86///
87/// # Errors
88///
89/// Returns an error if the archive is truncated, malformed, tampered with, or
90/// inconsistent with its manifest.
91pub async fn verify_archive(path: impl AsRef<Path>) -> Result<DropVerifyResult> {
92    DropVerifier::new().verify(path).await
93}
94
95/// Verify a compressed drop archive from an asynchronous reader.
96///
97/// # Errors
98///
99/// Returns an error if the archive is truncated, malformed, tampered with, or
100/// inconsistent with its manifest.
101pub async fn verify_drop_reader<R>(reader: R) -> Result<DropVerifyResult>
102where
103    R: AsyncRead + Unpin,
104{
105    DropVerifier::new().verify_reader(reader).await
106}
107
108async fn verify_car<R>(mut reader: R) -> Result<DropVerifyResult>
109where
110    R: AsyncRead + Unpin,
111{
112    let header_size = read_required_varint(&mut reader, "CAR header length").await?;
113    if header_size > MAX_HEADER_SIZE {
114        return Err(SyncwebError::InvalidConfig(format!(
115            "CAR header exceeds {MAX_HEADER_SIZE} bytes"
116        )));
117    }
118    let header_len = usize::try_from(header_size)
119        .map_err(|error| SyncwebError::operation("CAR header length is not representable", error))?;
120    let mut header = vec![0_u8; header_len];
121    read_exact(&mut reader, &mut header, "failed to read CAR header").await?;
122    let root = parse_car_header(&header)?;
123
124    let first_section = read_section_header(&mut reader)
125        .await?
126        .ok_or_else(|| SyncwebError::InvalidConfig("drop archive does not contain a manifest block".to_owned()))?;
127    if first_section.hash != root {
128        return Err(SyncwebError::InvalidConfig(
129            "drop manifest CID does not match the CAR root".to_owned(),
130        ));
131    }
132    if first_section.payload_size > MAX_MANIFEST_SIZE {
133        return Err(SyncwebError::InvalidConfig(format!(
134            "drop manifest exceeds {MAX_MANIFEST_SIZE} bytes"
135        )));
136    }
137    let (manifest_hash, manifest_data, manifest_size) =
138        read_payload(&mut reader, first_section.payload_size, true).await?;
139    if manifest_hash != root {
140        return Err(SyncwebError::InvalidConfig(
141            "drop manifest hash does not match the CAR root".to_owned(),
142        ));
143    }
144    let manifest_bytes = manifest_data
145        .ok_or_else(|| SyncwebError::InvalidConfig("drop manifest was not buffered for verification".to_owned()))?;
146    let manifest = CollectionManifest::from_bytes(&manifest_bytes)?;
147    manifest.verify_signature()?;
148    if manifest.blob_id()? != root {
149        return Err(SyncwebError::InvalidConfig(
150            "drop manifest hash does not match its serialized content".to_owned(),
151        ));
152    }
153
154    let mut expected = BTreeMap::new();
155    for entry in &manifest.entries {
156        if let Some(previous_size) = expected.insert(entry.content_id, entry.size)
157            && previous_size != entry.size
158        {
159            return Err(SyncwebError::InvalidConfig(format!(
160                "manifest uses content hash {} with inconsistent sizes",
161                entry.content_id
162            )));
163        }
164    }
165
166    let mut seen = BTreeSet::new();
167    let mut block_count = 1_usize;
168    let mut bytes_verified = manifest_size;
169    while let Some(section) = read_section_header(&mut reader).await? {
170        let expected_size = expected.get(&section.hash).ok_or_else(|| {
171            SyncwebError::InvalidConfig(format!(
172                "drop contains a block not referenced by the manifest: {}",
173                section.hash
174            ))
175        })?;
176        if !seen.insert(section.hash) {
177            return Err(SyncwebError::InvalidConfig(format!(
178                "drop contains duplicate content block: {}",
179                section.hash
180            )));
181        }
182        if section.payload_size != *expected_size {
183            return Err(SyncwebError::InvalidConfig(format!(
184                "drop content size does not match the manifest for {}",
185                section.hash
186            )));
187        }
188        let (actual_hash, _, payload_size) = read_payload(&mut reader, section.payload_size, false).await?;
189        if actual_hash != section.hash {
190            return Err(SyncwebError::InvalidConfig(format!(
191                "drop content hash does not match its CID: expected {}, got {}",
192                section.hash, actual_hash
193            )));
194        }
195        bytes_verified = bytes_verified
196            .checked_add(payload_size)
197            .ok_or_else(|| SyncwebError::operation("verified drop size overflow", "u64 limit exceeded"))?;
198        block_count = block_count
199            .checked_add(1)
200            .ok_or_else(|| SyncwebError::operation("drop block count overflow", "usize limit exceeded"))?;
201    }
202
203    if seen.len() != expected.len() {
204        let missing = expected
205            .keys()
206            .find(|hash| !seen.contains(*hash))
207            .copied()
208            .map_or_else(|| "unknown".to_owned(), |hash| hash.to_string());
209        return Err(SyncwebError::InvalidConfig(format!(
210            "drop is missing content referenced by the manifest: {missing}"
211        )));
212    }
213
214    Ok(DropVerifyResult {
215        manifest: root,
216        collection_id: manifest.collection_id,
217        version: manifest.version,
218        entry_count: manifest.entries.len(),
219        block_count,
220        bytes_verified,
221    })
222}
223
224struct SectionHeader {
225    hash: Hash,
226    payload_size: u64,
227}
228
229async fn read_section_header<R>(reader: &mut R) -> Result<Option<SectionHeader>>
230where
231    R: AsyncRead + Unpin,
232{
233    let Some(section_size) = read_varint(reader, true, "CAR section length").await? else {
234        return Ok(None);
235    };
236    let mut remaining = section_size;
237    let version = read_section_varint(reader, &mut remaining, "CID version").await?;
238    let codec = read_section_varint(reader, &mut remaining, "CID codec").await?;
239    let hash_code = read_section_varint(reader, &mut remaining, "CID multihash code").await?;
240    let hash_size = read_section_varint(reader, &mut remaining, "CID multihash length").await?;
241    if version != CID_VERSION || codec != RAW_CODEC || hash_code != BLAKE3_MULTIHASH || hash_size != HASH_SIZE {
242        return Err(SyncwebError::InvalidConfig(
243            "drop contains an unsupported content identifier".to_owned(),
244        ));
245    }
246    let mut hash_bytes = [0_u8; 32];
247    read_section_bytes(reader, &mut remaining, &mut hash_bytes, "CID digest").await?;
248    Ok(Some(SectionHeader {
249        hash: Hash::from_bytes(hash_bytes),
250        payload_size: remaining,
251    }))
252}
253
254async fn read_payload<R>(reader: &mut R, payload_size: u64, collect: bool) -> Result<(Hash, Option<Vec<u8>>, u64)>
255where
256    R: AsyncRead + Unpin,
257{
258    let mut remaining = payload_size;
259    let mut bytes_read = 0_u64;
260    let mut hasher = blake3::Hasher::new();
261    let mut buffer = vec![0_u8; COPY_BUFFER_SIZE];
262    let mut collected = if collect {
263        let capacity = usize::try_from(payload_size)
264            .map_err(|error| SyncwebError::operation("drop manifest is too large", error))?;
265        Some(Vec::with_capacity(capacity))
266    } else {
267        None
268    };
269    while remaining > 0 {
270        let requested_u64 = remaining.min(
271            u64::try_from(buffer.len())
272                .map_err(|error| SyncwebError::operation("drop read buffer length is not representable", error))?,
273        );
274        let requested = usize::try_from(requested_u64)
275            .map_err(|error| SyncwebError::operation("drop section length is not representable", error))?;
276        let chunk = buffer
277            .get_mut(..requested)
278            .ok_or_else(|| SyncwebError::operation("drop read buffer range is invalid", requested))?;
279        read_exact(reader, chunk, "drop section").await?;
280        hasher.update(chunk);
281        if let Some(bytes) = &mut collected {
282            bytes.extend_from_slice(chunk);
283        }
284        let requested_bytes = u64::try_from(requested)
285            .map_err(|error| SyncwebError::operation("drop section length is not representable", error))?;
286        remaining = remaining
287            .checked_sub(requested_bytes)
288            .ok_or_else(|| SyncwebError::operation("drop section length underflow", "invalid section length"))?;
289        bytes_read = bytes_read
290            .checked_add(requested_bytes)
291            .ok_or_else(|| SyncwebError::operation("verified drop size overflow", "u64 limit exceeded"))?;
292    }
293    Ok((Hash::from_bytes(*hasher.finalize().as_bytes()), collected, bytes_read))
294}
295
296async fn read_section_varint<R>(reader: &mut R, remaining: &mut u64, name: &'static str) -> Result<u64>
297where
298    R: AsyncRead + Unpin,
299{
300    let mut value = 0_u64;
301    let mut shift = 0_u32;
302    for index in 0..10 {
303        let byte = read_section_byte(reader, remaining, name).await?;
304        let bits = u64::from(byte & 0x7f);
305        if shift == 63 && bits > 1 {
306            return Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")));
307        }
308        value |= bits << shift;
309        if byte & 0x80 == 0 {
310            return Ok(value);
311        }
312        if index == 9 {
313            return Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")));
314        }
315        shift = shift
316            .checked_add(7)
317            .ok_or_else(|| SyncwebError::operation("CAR varint shift overflow", name))?;
318    }
319    Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")))
320}
321
322async fn read_section_byte<R>(reader: &mut R, remaining: &mut u64, name: &'static str) -> Result<u8>
323where
324    R: AsyncRead + Unpin,
325{
326    if *remaining == 0 {
327        return Err(SyncwebError::InvalidConfig(format!("CAR section ends before {name}")));
328    }
329    let mut byte = [0_u8; 1];
330    read_exact(reader, &mut byte, "failed to read drop section").await?;
331    *remaining = remaining
332        .checked_sub(1)
333        .ok_or_else(|| SyncwebError::operation("CAR section length underflow", name))?;
334    byte.first()
335        .copied()
336        .ok_or_else(|| SyncwebError::operation("CAR byte is missing", name))
337}
338
339async fn read_section_bytes<R>(reader: &mut R, remaining: &mut u64, bytes: &mut [u8], name: &'static str) -> Result<()>
340where
341    R: AsyncRead + Unpin,
342{
343    let length = u64::try_from(bytes.len())
344        .map_err(|error| SyncwebError::operation("CAR field length is not representable", error))?;
345    if length > *remaining {
346        return Err(SyncwebError::InvalidConfig(format!("CAR section ends before {name}")));
347    }
348    read_exact(reader, bytes, "failed to read drop section").await?;
349    *remaining = remaining
350        .checked_sub(length)
351        .ok_or_else(|| SyncwebError::operation("CAR section length underflow", name))?;
352    Ok(())
353}
354
355async fn read_required_varint<R>(reader: &mut R, name: &'static str) -> Result<u64>
356where
357    R: AsyncRead + Unpin,
358{
359    read_varint(reader, false, name)
360        .await?
361        .ok_or_else(|| SyncwebError::InvalidConfig(format!("drop archive ended before {name}")))
362}
363
364async fn read_varint<R>(reader: &mut R, allow_eof: bool, name: &'static str) -> Result<Option<u64>>
365where
366    R: AsyncRead + Unpin,
367{
368    let mut byte = [0_u8; 1];
369    let count = reader
370        .read(&mut byte)
371        .await
372        .map_err(|error| SyncwebError::operation("failed to read drop archive", error))?;
373    if count == 0 {
374        if allow_eof {
375            return Ok(None);
376        }
377        return Err(SyncwebError::InvalidConfig(format!("drop archive ended before {name}")));
378    }
379
380    let mut value = 0_u64;
381    let mut shift = 0_u32;
382    for index in 0..10 {
383        let current = if index == 0 {
384            byte.first()
385                .copied()
386                .ok_or_else(|| SyncwebError::operation("drop archive varint byte is missing", name))?
387        } else {
388            read_exact(reader, &mut byte, "failed to read drop archive").await?;
389            byte.first()
390                .copied()
391                .ok_or_else(|| SyncwebError::operation("drop archive varint byte is missing", name))?
392        };
393        let bits = u64::from(current & 0x7f);
394        if shift == 63 && bits > 1 {
395            return Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")));
396        }
397        value |= bits << shift;
398        if current & 0x80 == 0 {
399            return Ok(Some(value));
400        }
401        if index == 9 {
402            return Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")));
403        }
404        shift = shift
405            .checked_add(7)
406            .ok_or_else(|| SyncwebError::operation("CAR varint shift overflow", name))?;
407    }
408    Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")))
409}
410
411async fn read_exact<R>(reader: &mut R, bytes: &mut [u8], context: &'static str) -> Result<()>
412where
413    R: AsyncRead + Unpin,
414{
415    reader
416        .read_exact(bytes)
417        .await
418        .map(|_| ())
419        .map_err(|error| SyncwebError::operation(context, error))
420}
421
422fn parse_car_header(header: &[u8]) -> Result<Hash> {
423    let mut offset = 0_usize;
424    let root_marker = read_slice_byte(header, &mut offset, "CAR root field")?;
425    if root_marker != 0x0a {
426        return Err(SyncwebError::InvalidConfig(
427            "CAR header does not contain a root CID".to_owned(),
428        ));
429    }
430    let cid_length_raw = read_slice_varint(header, &mut offset, "CAR root CID length")?;
431    let cid_length = usize::try_from(cid_length_raw)
432        .map_err(|error| SyncwebError::operation("CAR root CID length is not representable", error))?;
433    let cid_end = offset
434        .checked_add(cid_length)
435        .ok_or_else(|| SyncwebError::operation("CAR root CID range overflow", "usize limit exceeded"))?;
436    let cid = header
437        .get(offset..cid_end)
438        .ok_or_else(|| SyncwebError::InvalidConfig("CAR root CID is truncated".to_owned()))?;
439    offset = cid_end;
440    let version_marker = read_slice_byte(header, &mut offset, "CAR version field")?;
441    if version_marker != 0x10 {
442        return Err(SyncwebError::InvalidConfig(
443            "CAR header does not contain a version".to_owned(),
444        ));
445    }
446    let version = read_slice_varint(header, &mut offset, "CAR version")?;
447    if version != CAR_VERSION || offset != header.len() {
448        return Err(SyncwebError::InvalidConfig(
449            "unsupported or malformed CAR header".to_owned(),
450        ));
451    }
452    parse_cid(cid)
453}
454
455fn parse_cid(cid: &[u8]) -> Result<Hash> {
456    let mut offset = 0_usize;
457    let version = read_slice_varint(cid, &mut offset, "CID version")?;
458    let codec = read_slice_varint(cid, &mut offset, "CID codec")?;
459    let hash_code = read_slice_varint(cid, &mut offset, "CID multihash code")?;
460    let hash_size = read_slice_varint(cid, &mut offset, "CID multihash length")?;
461    if version != CID_VERSION || codec != RAW_CODEC || hash_code != BLAKE3_MULTIHASH || hash_size != HASH_SIZE {
462        return Err(SyncwebError::InvalidConfig(
463            "drop contains an unsupported root content identifier".to_owned(),
464        ));
465    }
466    let hash_end = offset
467        .checked_add(
468            usize::try_from(HASH_SIZE)
469                .map_err(|error| SyncwebError::operation("CID hash length is not representable", error))?,
470        )
471        .ok_or_else(|| SyncwebError::operation("CID hash range overflow", "usize limit exceeded"))?;
472    let digest = cid
473        .get(offset..hash_end)
474        .ok_or_else(|| SyncwebError::InvalidConfig("CID digest is truncated".to_owned()))?;
475    if hash_end != cid.len() {
476        return Err(SyncwebError::InvalidConfig("CID contains trailing bytes".to_owned()));
477    }
478    let hash: [u8; 32] = digest
479        .try_into()
480        .map_err(|error| SyncwebError::InvalidConfig(format!("CID digest must be 32 bytes: {error}")))?;
481    Ok(Hash::from_bytes(hash))
482}
483
484fn read_slice_byte(bytes: &[u8], offset: &mut usize, name: &'static str) -> Result<u8> {
485    let byte = bytes
486        .get(*offset)
487        .copied()
488        .ok_or_else(|| SyncwebError::InvalidConfig(format!("{name} is truncated")))?;
489    *offset = offset
490        .checked_add(1)
491        .ok_or_else(|| SyncwebError::operation("CAR offset overflow", name))?;
492    Ok(byte)
493}
494
495fn read_slice_varint(bytes: &[u8], offset: &mut usize, name: &'static str) -> Result<u64> {
496    let mut value = 0_u64;
497    let mut shift = 0_u32;
498    for index in 0..10 {
499        let byte = read_slice_byte(bytes, offset, name)?;
500        let bits = u64::from(byte & 0x7f);
501        if shift == 63 && bits > 1 {
502            return Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")));
503        }
504        value |= bits << shift;
505        if byte & 0x80 == 0 {
506            return Ok(value);
507        }
508        if index == 9 {
509            return Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")));
510        }
511        shift = shift
512            .checked_add(7)
513            .ok_or_else(|| SyncwebError::operation("CAR varint shift overflow", name))?;
514    }
515    Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")))
516}