Skip to main content

syncweb_core/folder/
archive_import.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    path::{Path, PathBuf},
4    sync::Arc,
5};
6
7use async_compression::tokio::bufread::ZstdDecoder;
8use iroh_blobs::Hash;
9use iroh_docs::NamespaceId;
10use semver::Version;
11use serde::{Deserialize, Serialize};
12use tokio::{
13    fs,
14    io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader},
15    sync::Mutex,
16};
17use uuid::Uuid;
18
19use crate::{
20    daemon::ManagedPool,
21    error::{Result, SyncwebError},
22    filter::{FilterAction, FilterEngine, FilterEntry},
23    node::iroh_node::IrohNode,
24};
25
26use super::{CollectionManifest, CollectionStore, FolderManager, SyncMode};
27
28const CAR_VERSION: u64 = 1;
29const CID_VERSION: u64 = 1;
30const RAW_CODEC: u64 = 0x55;
31const BLAKE3_MULTIHASH: u64 = 0x1e;
32const HASH_SIZE: u64 = 32;
33const MAX_HEADER_SIZE: u64 = 64 * 1024;
34const MAX_MANIFEST_SIZE: u64 = 16 * 1024 * 1024;
35const COPY_BUFFER_SIZE: usize = 64 * 1024;
36
37/// Options controlling validation and filtering during drop import.
38#[derive(Clone, Debug, Default)]
39#[non_exhaustive]
40pub struct DropImportOptions {
41    filter: Option<FilterEngine>,
42    available_dependencies: BTreeMap<Uuid, Version>,
43}
44
45impl DropImportOptions {
46    #[must_use]
47    pub fn with_filter(mut self, filter: FilterEngine) -> Self {
48        self.filter = Some(filter);
49        self
50    }
51
52    #[must_use]
53    pub fn with_available_dependencies(mut self, available: BTreeMap<Uuid, Version>) -> Self {
54        self.available_dependencies = available;
55        self
56    }
57
58    #[must_use]
59    pub const fn filter(&self) -> Option<&FilterEngine> {
60        self.filter.as_ref()
61    }
62
63    #[must_use]
64    pub const fn available_dependencies(&self) -> &BTreeMap<Uuid, Version> {
65        &self.available_dependencies
66    }
67}
68
69/// Information about a successfully imported drop.
70#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
71#[non_exhaustive]
72pub struct DropImportResult {
73    pub manifest: Hash,
74    pub collection_manifest: CollectionManifest,
75    pub collection_id: Uuid,
76    pub version: String,
77    pub entry_count: usize,
78    pub imported_entry_count: usize,
79    pub skipped_entry_count: usize,
80    pub block_count: usize,
81    pub imported_block_count: usize,
82    pub namespace_id: Option<NamespaceId>,
83}
84
85/// Streams, verifies, and imports compressed CAR drop archives.
86#[derive(Clone)]
87pub struct DropImporter {
88    blob_store: crate::node::blob_store::BlobStore,
89    import_lock: Arc<Mutex<()>>,
90}
91
92impl DropImporter {
93    #[must_use]
94    pub fn new(blob_store: crate::node::blob_store::BlobStore) -> Self {
95        Self {
96            blob_store,
97            import_lock: Arc::new(Mutex::new(())),
98        }
99    }
100
101    /// Import a drop into the local blob store.
102    ///
103    /// The archive is fully validated before any blob is added. Content is
104    /// staged in bounded streaming writes, so importing does not load the
105    /// archive or individual content blocks into memory.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if decompression, CAR parsing, signature validation,
110    /// dependency validation, or blob ingestion fails.
111    pub async fn import_archive(
112        &self,
113        input: impl AsRef<Path>,
114        options: DropImportOptions,
115        pool: Option<&ManagedPool>,
116    ) -> Result<DropImportResult> {
117        let _import_guard = self.import_lock.lock().await;
118        let input_path = input.as_ref();
119        let file = fs::File::open(input_path)
120            .await
121            .map_err(|error| SyncwebError::operation("failed to open drop archive", error))?;
122        let parent = input_path.parent().unwrap_or_else(|| Path::new("."));
123        let staging = parent.join(format!(
124            "{}{}",
125            crate::constants::DROP_IMPORT_STAGING_PREFIX,
126            Uuid::new_v4()
127        ));
128        fs::create_dir(&staging)
129            .await
130            .map_err(|error| SyncwebError::operation("failed to create drop import staging directory", error))?;
131
132        let parse_result = read_archive(ZstdDecoder::new(BufReader::new(file)), &staging, &options, pool).await;
133        let parsed = match parse_result {
134            Ok(parsed) => parsed,
135            Err(error) => {
136                remove_staging(&staging).await?;
137                return Err(error);
138            }
139        };
140
141        let import_result = self.ingest(parsed, pool).await;
142        let cleanup_result = remove_staging(&staging).await;
143        match (import_result, cleanup_result) {
144            (Ok(result), Ok(())) => Ok(result),
145            (Err(error), Ok(())) | (Ok(_), Err(error)) => Err(error),
146            (Err(error), Err(cleanup_error)) => Err(SyncwebError::operation(
147                "failed to clean up drop import staging directory",
148                format!("{error}; cleanup failed: {cleanup_error}"),
149            )),
150        }
151    }
152
153    /// Materialize an imported manifest into a new directory.
154    ///
155    /// The destination must not already exist. Files are written to a sibling
156    /// staging directory and renamed into place only after every blob has been
157    /// exported successfully.
158    ///
159    /// # Errors
160    ///
161    /// Returns an error if the destination exists, a blob is unavailable, or
162    /// materialization cannot be finalized.
163    pub async fn materialize(&self, result: &DropImportResult, target: impl AsRef<Path>) -> Result<()> {
164        let target_path = target.as_ref();
165        if fs::try_exists(target_path).await? {
166            return Err(SyncwebError::InvalidConfig(format!(
167                "drop materialization target already exists: {}",
168                target_path.display()
169            )));
170        }
171        let parent = target_path.parent().unwrap_or_else(|| Path::new("."));
172        fs::create_dir_all(parent).await?;
173        let staging = parent.join(format!(
174            "{}{}",
175            crate::constants::MATERIALIZATION_STAGING_PREFIX,
176            Uuid::new_v4()
177        ));
178        fs::create_dir(&staging)
179            .await
180            .map_err(|error| SyncwebError::operation("failed to create materialization staging directory", error))?;
181
182        let write_result = async {
183            for entry in &result.collection_manifest.entries {
184                let destination = staging.join(&entry.logical_path);
185                let entry_parent = destination
186                    .parent()
187                    .ok_or_else(|| SyncwebError::InvalidConfig("drop entry has no parent directory".to_owned()))?;
188                fs::create_dir_all(entry_parent).await?;
189                let size = self.blob_store.export_to_path(entry.content_id, &destination).await?;
190                if size != entry.size {
191                    return Err(SyncwebError::InvalidConfig(format!(
192                        "materialized drop entry size does not match manifest: {}",
193                        entry.logical_path.display()
194                    )));
195                }
196            }
197            fs::rename(&staging, target_path)
198                .await
199                .map_err(|error| SyncwebError::operation("failed to finalize drop materialization", error))?;
200            Ok(())
201        }
202        .await;
203        if let Err(error) = write_result {
204            remove_staging(&staging).await?;
205            return Err(error);
206        }
207        Ok(())
208    }
209
210    async fn ingest(&self, parsed: ParsedDrop, pool: Option<&ManagedPool>) -> Result<DropImportResult> {
211        let manifest_hash = in_pool(pool, || Hash::new(&parsed.manifest_bytes));
212        let stored_manifest = self.blob_store.add_bytes(&parsed.manifest_bytes).await?;
213        if stored_manifest != manifest_hash {
214            return Err(SyncwebError::InvalidConfig(
215                "imported manifest hash does not match its content".to_owned(),
216            ));
217        }
218
219        for block in &parsed.blocks {
220            let stored = self.blob_store.add_file(&block.path).await?;
221            if stored != block.hash {
222                return Err(SyncwebError::InvalidConfig(format!(
223                    "imported content hash does not match its CID: expected {}, got {}",
224                    block.hash, stored
225                )));
226            }
227        }
228
229        let collection_id = parsed.manifest.collection_id;
230        let version = parsed.manifest.version.clone();
231        let entry_count = parsed.entry_count;
232        let imported_entry_count = parsed.manifest.entries.len();
233        let skipped_entry_count = entry_count.saturating_sub(imported_entry_count);
234        let block_count = parsed.block_count;
235        let imported_block_count = parsed.blocks.len().saturating_add(1);
236        let collection_manifest = parsed.manifest;
237        Ok(DropImportResult {
238            manifest: manifest_hash,
239            collection_manifest,
240            collection_id,
241            version,
242            entry_count,
243            imported_entry_count,
244            skipped_entry_count,
245            block_count,
246            imported_block_count,
247            namespace_id: None,
248        })
249    }
250}
251
252/// Import, register, and materialize a drop in one operation.
253///
254/// A new writable folder namespace is created for the imported collection and
255/// its manifest is published there. The destination must not already exist.
256///
257/// # Errors
258///
259/// Returns an error if import, materialization, namespace creation, or
260/// manifest publication fails.
261pub async fn import_archive(
262    node: &IrohNode,
263    input: impl AsRef<Path>,
264    target_dir: impl AsRef<Path>,
265    filter: Option<&FilterEngine>,
266) -> Result<DropImportResult> {
267    let options = filter.map_or_else(DropImportOptions::default, |value| {
268        DropImportOptions::default().with_filter(value.clone())
269    });
270    let importer = DropImporter::new(node.blob_store().clone());
271    let mut result = importer.import_archive(input, options, None).await?;
272    importer.materialize(&result, target_dir).await?;
273
274    let folder = FolderManager::new(node).create(SyncMode::SendReceive).await?;
275    CollectionStore::for_node(node, &folder)
276        .publish(&result.collection_manifest, 1)
277        .await?;
278    result.namespace_id = Some(folder.namespace_id());
279    Ok(result)
280}
281
282struct ParsedDrop {
283    manifest: CollectionManifest,
284    manifest_bytes: Vec<u8>,
285    blocks: Vec<StagedBlock>,
286    entry_count: usize,
287    block_count: usize,
288}
289
290struct StagedBlock {
291    hash: Hash,
292    path: PathBuf,
293}
294
295struct SectionHeader {
296    hash: Hash,
297    payload_size: u64,
298}
299
300struct PreparedManifest {
301    manifest: CollectionManifest,
302    filtered_manifest: CollectionManifest,
303    expected: BTreeMap<Hash, (u64, bool)>,
304}
305
306async fn read_archive<R>(
307    mut reader: R,
308    staging: &Path,
309    options: &DropImportOptions,
310    pool: Option<&ManagedPool>,
311) -> Result<ParsedDrop>
312where
313    R: AsyncRead + Unpin,
314{
315    let header_size = read_required_varint(&mut reader, "CAR header length").await?;
316    if header_size > MAX_HEADER_SIZE {
317        return Err(SyncwebError::InvalidConfig(format!(
318            "CAR header exceeds {MAX_HEADER_SIZE} bytes"
319        )));
320    }
321    let header_len = usize::try_from(header_size)
322        .map_err(|error| SyncwebError::operation("CAR header length is not representable", error))?;
323    let mut header = vec![0_u8; header_len];
324    read_exact(&mut reader, &mut header, "failed to read CAR header").await?;
325    let root = parse_car_header(&header)?;
326
327    let manifest_section = read_section_header(&mut reader)
328        .await?
329        .ok_or_else(|| SyncwebError::InvalidConfig("drop archive has no manifest block".to_owned()))?;
330    if manifest_section.hash != root {
331        return Err(SyncwebError::InvalidConfig(
332            "drop manifest CID does not match the CAR root".to_owned(),
333        ));
334    }
335    if manifest_section.payload_size > MAX_MANIFEST_SIZE {
336        return Err(SyncwebError::InvalidConfig(format!(
337            "drop manifest exceeds {MAX_MANIFEST_SIZE} bytes"
338        )));
339    }
340    let manifest_bytes = read_payload_to_vec(&mut reader, manifest_section.payload_size, "drop manifest").await?;
341    let prepared = prepare_manifest(&manifest_bytes, root, options, pool)?;
342
343    let mut seen = BTreeSet::new();
344    let mut blocks = Vec::new();
345    let mut block_count = 1_usize;
346    while let Some(section) = read_section_header(&mut reader).await? {
347        let (expected_size, accepted) = prepared.expected.get(&section.hash).copied().ok_or_else(|| {
348            SyncwebError::InvalidConfig(format!(
349                "drop contains a block not referenced by the manifest: {}",
350                section.hash
351            ))
352        })?;
353        if !seen.insert(section.hash) {
354            return Err(SyncwebError::InvalidConfig(format!(
355                "drop contains duplicate content block: {}",
356                section.hash
357            )));
358        }
359        if section.payload_size != expected_size {
360            return Err(SyncwebError::InvalidConfig(format!(
361                "drop content size does not match the manifest for {}",
362                section.hash
363            )));
364        }
365        let staged_path = accepted.then(|| staging.join(format!("block-{}.blob", blocks.len())));
366        let (actual_hash, actual_size) =
367            read_payload_to_path(&mut reader, section.payload_size, staged_path.as_deref(), pool).await?;
368        if actual_hash != section.hash {
369            return Err(SyncwebError::InvalidConfig(format!(
370                "drop content hash does not match its CID: expected {}, got {}",
371                section.hash, actual_hash
372            )));
373        }
374        if actual_size != expected_size {
375            return Err(SyncwebError::InvalidConfig(format!(
376                "drop content size does not match the manifest for {}",
377                section.hash
378            )));
379        }
380        if let Some(block_path) = staged_path {
381            blocks.push(StagedBlock {
382                hash: section.hash,
383                path: block_path,
384            });
385        }
386        block_count = block_count
387            .checked_add(1)
388            .ok_or_else(|| SyncwebError::operation("drop block count overflow", "usize limit exceeded"))?;
389    }
390
391    if seen.len() != prepared.expected.len() {
392        let missing = prepared
393            .expected
394            .keys()
395            .find(|hash| !seen.contains(*hash))
396            .copied()
397            .map_or_else(|| "unknown".to_owned(), |hash| hash.to_string());
398        return Err(SyncwebError::InvalidConfig(format!(
399            "drop is missing content referenced by the manifest: {missing}"
400        )));
401    }
402
403    let filtered_manifest_bytes = prepared.filtered_manifest.to_bytes()?;
404    Ok(ParsedDrop {
405        manifest: prepared.filtered_manifest,
406        manifest_bytes: filtered_manifest_bytes,
407        blocks,
408        entry_count: prepared.manifest.entries.len(),
409        block_count,
410    })
411}
412
413fn prepare_manifest(
414    manifest_bytes: &[u8],
415    root: Hash,
416    options: &DropImportOptions,
417    pool: Option<&ManagedPool>,
418) -> Result<PreparedManifest> {
419    let manifest = in_pool(pool, || CollectionManifest::from_bytes(manifest_bytes))?;
420    in_pool(pool, || manifest.verify_signature())?;
421    let manifest_hash = in_pool(pool, || Hash::new(manifest_bytes));
422    let manifest_blob_id = in_pool(pool, || manifest.blob_id())?;
423    if manifest_hash != root || manifest_blob_id != root {
424        return Err(SyncwebError::InvalidConfig(
425            "drop manifest hash does not match the CAR root".to_owned(),
426        ));
427    }
428    if !manifest.dependencies_satisfied(options.available_dependencies())? {
429        return Err(SyncwebError::InvalidConfig(
430            "drop package has missing or incompatible dependencies".to_owned(),
431        ));
432    }
433
434    let filtered_manifest = in_pool(pool, || filter_manifest(manifest.clone(), options.filter()))?;
435    let mut expected = BTreeMap::<Hash, (u64, bool)>::new();
436    let accepted_hashes = filtered_manifest
437        .entries
438        .iter()
439        .map(|entry| entry.content_id)
440        .collect::<BTreeSet<_>>();
441    for entry in &manifest.entries {
442        match expected.entry(entry.content_id) {
443            std::collections::btree_map::Entry::Vacant(slot) => {
444                slot.insert((entry.size, accepted_hashes.contains(&entry.content_id)));
445            }
446            std::collections::btree_map::Entry::Occupied(slot) => {
447                let (size, accepted) = slot.into_mut();
448                if *size != entry.size {
449                    return Err(SyncwebError::InvalidConfig(format!(
450                        "manifest uses content hash {} with inconsistent sizes",
451                        entry.content_id
452                    )));
453                }
454                *accepted |= accepted_hashes.contains(&entry.content_id);
455            }
456        }
457    }
458    Ok(PreparedManifest {
459        manifest,
460        filtered_manifest,
461        expected,
462    })
463}
464
465fn filter_manifest(manifest: CollectionManifest, filter_option: Option<&FilterEngine>) -> Result<CollectionManifest> {
466    let Some(filter) = filter_option else {
467        return Ok(manifest);
468    };
469    let version = Version::parse(&manifest.version)
470        .map_err(|error| SyncwebError::InvalidConfig(format!("invalid collection version: {error}")))?;
471    let mut filtered = manifest;
472    filtered.entries.retain(|entry| {
473        let filter_entry = FilterEntry::new(&entry.logical_path, entry.size).with_version(version.clone());
474        filter.evaluate(&filter_entry) == FilterAction::Accept
475    });
476    filtered.signature = None;
477    filtered.public_key = None;
478    filtered.validate()?;
479    Ok(filtered)
480}
481
482async fn read_payload_to_vec<R>(reader: &mut R, size: u64, context: &'static str) -> Result<Vec<u8>>
483where
484    R: AsyncRead + Unpin,
485{
486    let capacity =
487        usize::try_from(size).map_err(|error| SyncwebError::operation("drop payload is too large", error))?;
488    let mut bytes = Vec::with_capacity(capacity);
489    let mut buffer = vec![0_u8; COPY_BUFFER_SIZE];
490    let mut remaining = size;
491    while remaining > 0 {
492        let requested = usize::try_from(
493            remaining.min(
494                u64::try_from(buffer.len())
495                    .map_err(|error| SyncwebError::operation("drop buffer length is not representable", error))?,
496            ),
497        )
498        .map_err(|error| SyncwebError::operation("drop payload size is not representable", error))?;
499        let chunk = buffer
500            .get_mut(..requested)
501            .ok_or_else(|| SyncwebError::operation("drop payload range is invalid", requested))?;
502        read_exact(reader, chunk, context).await?;
503        bytes.extend_from_slice(chunk);
504        remaining = remaining
505            .checked_sub(
506                u64::try_from(requested)
507                    .map_err(|error| SyncwebError::operation("drop payload is too large", error))?,
508            )
509            .ok_or_else(|| SyncwebError::operation("drop payload size underflow", context))?;
510    }
511    Ok(bytes)
512}
513
514async fn read_payload_to_path<R>(
515    reader: &mut R,
516    size: u64,
517    destination: Option<&Path>,
518    pool: Option<&ManagedPool>,
519) -> Result<(Hash, u64)>
520where
521    R: AsyncRead + Unpin,
522{
523    let mut output = match destination {
524        Some(destination_path) => Some(
525            fs::File::create(destination_path)
526                .await
527                .map_err(|error| SyncwebError::operation("failed to stage imported content", error))?,
528        ),
529        None => None,
530    };
531    let mut buffer = vec![0_u8; COPY_BUFFER_SIZE];
532    let mut remaining = size;
533    let mut copied = 0_u64;
534    let mut hasher = blake3::Hasher::new();
535    while remaining > 0 {
536        let requested = usize::try_from(
537            remaining.min(
538                u64::try_from(buffer.len())
539                    .map_err(|error| SyncwebError::operation("drop buffer length is not representable", error))?,
540            ),
541        )
542        .map_err(|error| SyncwebError::operation("drop payload size is not representable", error))?;
543        let chunk = buffer
544            .get_mut(..requested)
545            .ok_or_else(|| SyncwebError::operation("drop payload range is invalid", requested))?;
546        read_exact(reader, chunk, "failed to read drop content").await?;
547        if let Some(output_file) = &mut output {
548            output_file.write_all(chunk).await?;
549        }
550        in_pool(pool, || hasher.update(chunk));
551        let requested_u64 =
552            u64::try_from(requested).map_err(|error| SyncwebError::operation("drop payload is too large", error))?;
553        remaining = remaining
554            .checked_sub(requested_u64)
555            .ok_or_else(|| SyncwebError::operation("drop payload size underflow", "invalid section length"))?;
556        copied = copied
557            .checked_add(requested_u64)
558            .ok_or_else(|| SyncwebError::operation("drop payload size overflow", "u64 limit exceeded"))?;
559    }
560    if let Some(output_file) = &mut output {
561        output_file.flush().await?;
562    }
563    let hash = in_pool(pool, || Hash::from_bytes(*hasher.finalize().as_bytes()));
564    Ok((hash, copied))
565}
566
567fn in_pool<F, R>(pool: Option<&ManagedPool>, operation: F) -> R
568where
569    F: FnOnce() -> R + Send,
570    R: Send,
571{
572    match pool {
573        Some(managed_pool) => managed_pool.install(operation),
574        None => operation(),
575    }
576}
577
578async fn read_section_header<R>(reader: &mut R) -> Result<Option<SectionHeader>>
579where
580    R: AsyncRead + Unpin,
581{
582    let Some(section_size) = read_varint(reader, true, "CAR section length").await? else {
583        return Ok(None);
584    };
585    let mut remaining = section_size;
586    let version = read_section_varint(reader, &mut remaining, "CID version").await?;
587    let codec = read_section_varint(reader, &mut remaining, "CID codec").await?;
588    let hash_code = read_section_varint(reader, &mut remaining, "CID multihash code").await?;
589    let hash_size = read_section_varint(reader, &mut remaining, "CID multihash length").await?;
590    if version != CID_VERSION || codec != RAW_CODEC || hash_code != BLAKE3_MULTIHASH || hash_size != HASH_SIZE {
591        return Err(SyncwebError::InvalidConfig(
592            "drop contains an unsupported content identifier".to_owned(),
593        ));
594    }
595    let mut hash_bytes = [0_u8; 32];
596    read_section_bytes(reader, &mut remaining, &mut hash_bytes, "CID digest").await?;
597    Ok(Some(SectionHeader {
598        hash: Hash::from_bytes(hash_bytes),
599        payload_size: remaining,
600    }))
601}
602
603async fn read_required_varint<R>(reader: &mut R, name: &'static str) -> Result<u64>
604where
605    R: AsyncRead + Unpin,
606{
607    read_varint(reader, false, name)
608        .await?
609        .ok_or_else(|| SyncwebError::InvalidConfig(format!("drop archive ended before {name}")))
610}
611
612async fn read_varint<R>(reader: &mut R, allow_eof: bool, name: &'static str) -> Result<Option<u64>>
613where
614    R: AsyncRead + Unpin,
615{
616    let mut byte = [0_u8; 1];
617    let count = reader
618        .read(&mut byte)
619        .await
620        .map_err(|error| SyncwebError::operation("failed to read drop archive", error))?;
621    if count == 0 {
622        if allow_eof {
623            return Ok(None);
624        }
625        return Err(SyncwebError::InvalidConfig(format!("drop archive ended before {name}")));
626    }
627
628    let mut value = 0_u64;
629    let mut shift = 0_u32;
630    for index in 0..10 {
631        if index > 0 {
632            read_exact(reader, &mut byte, "failed to read drop archive").await?;
633        }
634        let current = byte[0];
635        let bits = u64::from(current & 0x7f);
636        if shift == 63 && bits > 1 {
637            return Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")));
638        }
639        value |= bits << shift;
640        if current & 0x80 == 0 {
641            return Ok(Some(value));
642        }
643        if index == 9 {
644            return Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")));
645        }
646        shift = shift
647            .checked_add(7)
648            .ok_or_else(|| SyncwebError::operation("CAR varint shift overflow", name))?;
649    }
650    Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")))
651}
652
653async fn read_section_varint<R>(reader: &mut R, remaining: &mut u64, name: &'static str) -> Result<u64>
654where
655    R: AsyncRead + Unpin,
656{
657    let mut value = 0_u64;
658    let mut shift = 0_u32;
659    for index in 0..10 {
660        let byte = read_section_byte(reader, remaining, name).await?;
661        let bits = u64::from(byte & 0x7f);
662        if shift == 63 && bits > 1 {
663            return Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")));
664        }
665        value |= bits << shift;
666        if byte & 0x80 == 0 {
667            return Ok(value);
668        }
669        if index == 9 {
670            return Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")));
671        }
672        shift = shift
673            .checked_add(7)
674            .ok_or_else(|| SyncwebError::operation("CAR varint shift overflow", name))?;
675    }
676    Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")))
677}
678
679async fn read_section_byte<R>(reader: &mut R, remaining: &mut u64, name: &'static str) -> Result<u8>
680where
681    R: AsyncRead + Unpin,
682{
683    if *remaining == 0 {
684        return Err(SyncwebError::InvalidConfig(format!("CAR section ends before {name}")));
685    }
686    let mut byte = [0_u8; 1];
687    read_exact(reader, &mut byte, "failed to read drop section").await?;
688    *remaining = remaining
689        .checked_sub(1)
690        .ok_or_else(|| SyncwebError::operation("CAR section length underflow", name))?;
691    Ok(byte[0])
692}
693
694async fn read_section_bytes<R>(reader: &mut R, remaining: &mut u64, bytes: &mut [u8], name: &'static str) -> Result<()>
695where
696    R: AsyncRead + Unpin,
697{
698    let length = u64::try_from(bytes.len())
699        .map_err(|error| SyncwebError::operation("CAR field length is not representable", error))?;
700    if length > *remaining {
701        return Err(SyncwebError::InvalidConfig(format!("CAR section ends before {name}")));
702    }
703    read_exact(reader, bytes, "failed to read drop section").await?;
704    *remaining = remaining
705        .checked_sub(length)
706        .ok_or_else(|| SyncwebError::operation("CAR section length underflow", name))?;
707    Ok(())
708}
709
710async fn read_exact<R>(reader: &mut R, bytes: &mut [u8], context: &'static str) -> Result<()>
711where
712    R: AsyncRead + Unpin,
713{
714    reader
715        .read_exact(bytes)
716        .await
717        .map(|_| ())
718        .map_err(|error| SyncwebError::operation(context, error))
719}
720
721fn parse_car_header(header: &[u8]) -> Result<Hash> {
722    let mut offset = 0_usize;
723    if read_slice_byte(header, &mut offset, "CAR root field")? != 0x0a {
724        return Err(SyncwebError::InvalidConfig(
725            "CAR header does not contain a root CID".to_owned(),
726        ));
727    }
728    let cid_length = usize::try_from(read_slice_varint(header, &mut offset, "CAR root CID length")?)
729        .map_err(|error| SyncwebError::operation("CAR root CID length is not representable", error))?;
730    let cid_end = offset
731        .checked_add(cid_length)
732        .ok_or_else(|| SyncwebError::operation("CAR root CID range overflow", "usize limit exceeded"))?;
733    let cid = header
734        .get(offset..cid_end)
735        .ok_or_else(|| SyncwebError::InvalidConfig("CAR root CID is truncated".to_owned()))?;
736    offset = cid_end;
737    if read_slice_byte(header, &mut offset, "CAR version field")? != 0x10 {
738        return Err(SyncwebError::InvalidConfig(
739            "CAR header does not contain a version".to_owned(),
740        ));
741    }
742    if read_slice_varint(header, &mut offset, "CAR version")? != CAR_VERSION || offset != header.len() {
743        return Err(SyncwebError::InvalidConfig(
744            "unsupported or malformed CAR header".to_owned(),
745        ));
746    }
747    parse_cid(cid)
748}
749
750fn parse_cid(cid: &[u8]) -> Result<Hash> {
751    let mut offset = 0_usize;
752    let version = read_slice_varint(cid, &mut offset, "CID version")?;
753    let codec = read_slice_varint(cid, &mut offset, "CID codec")?;
754    let hash_code = read_slice_varint(cid, &mut offset, "CID multihash code")?;
755    let hash_size = read_slice_varint(cid, &mut offset, "CID multihash length")?;
756    if version != CID_VERSION || codec != RAW_CODEC || hash_code != BLAKE3_MULTIHASH || hash_size != HASH_SIZE {
757        return Err(SyncwebError::InvalidConfig(
758            "drop contains an unsupported root content identifier".to_owned(),
759        ));
760    }
761    let hash_end = offset
762        .checked_add(
763            usize::try_from(HASH_SIZE)
764                .map_err(|error| SyncwebError::operation("CID hash length is not representable", error))?,
765        )
766        .ok_or_else(|| SyncwebError::operation("CID hash range overflow", "usize limit exceeded"))?;
767    let digest = cid
768        .get(offset..hash_end)
769        .ok_or_else(|| SyncwebError::InvalidConfig("CID digest is truncated".to_owned()))?;
770    if hash_end != cid.len() {
771        return Err(SyncwebError::InvalidConfig("CID contains trailing bytes".to_owned()));
772    }
773    let hash_bytes: [u8; 32] = digest
774        .try_into()
775        .map_err(|error| SyncwebError::InvalidConfig(format!("CID digest must be 32 bytes: {error}")))?;
776    Ok(Hash::from_bytes(hash_bytes))
777}
778
779fn read_slice_byte(bytes: &[u8], offset: &mut usize, name: &'static str) -> Result<u8> {
780    let byte = bytes
781        .get(*offset)
782        .copied()
783        .ok_or_else(|| SyncwebError::InvalidConfig(format!("{name} is truncated")))?;
784    *offset = offset
785        .checked_add(1)
786        .ok_or_else(|| SyncwebError::operation("CAR offset overflow", name))?;
787    Ok(byte)
788}
789
790fn read_slice_varint(bytes: &[u8], offset: &mut usize, name: &'static str) -> Result<u64> {
791    let mut value = 0_u64;
792    let mut shift = 0_u32;
793    for index in 0..10 {
794        let byte = read_slice_byte(bytes, offset, name)?;
795        let bits = u64::from(byte & 0x7f);
796        if shift == 63 && bits > 1 {
797            return Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")));
798        }
799        value |= bits << shift;
800        if byte & 0x80 == 0 {
801            return Ok(value);
802        }
803        if index == 9 {
804            return Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")));
805        }
806        shift = shift
807            .checked_add(7)
808            .ok_or_else(|| SyncwebError::operation("CAR varint shift overflow", name))?;
809    }
810    Err(SyncwebError::InvalidConfig(format!("{name} varint is too large")))
811}
812
813async fn remove_staging(path: &Path) -> Result<()> {
814    match fs::remove_dir_all(path).await {
815        Ok(()) => Ok(()),
816        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
817        Err(error) => Err(SyncwebError::operation(
818            "failed to remove drop staging directory",
819            error,
820        )),
821    }
822}