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