Skip to main content

syncweb_core/fs/
exporter.rs

1use std::{
2    fs,
3    path::{Path, PathBuf},
4    sync::Arc,
5};
6
7use iroh_blobs::Hash;
8use n0_future::{BufferedStreamExt, TryStreamExt, stream};
9use rayon::prelude::*;
10
11use crate::{
12    error::{Result, SyncwebError},
13    node::blob_store::BlobStore,
14};
15
16/// A content-addressed file to write to disk.
17#[derive(Clone, Debug, Eq, PartialEq)]
18#[non_exhaustive]
19pub struct ExportEntry {
20    pub relative_path: PathBuf,
21    pub hash: Hash,
22    pub size: u64,
23}
24
25impl ExportEntry {
26    #[must_use]
27    pub fn new(relative_path: impl Into<PathBuf>, hash: Hash, size: u64) -> Self {
28        Self {
29            relative_path: relative_path.into(),
30            hash,
31            size,
32        }
33    }
34}
35
36/// Sequential blob exporter.
37#[derive(Clone)]
38pub struct Exporter {
39    blob_store: BlobStore,
40    destination: PathBuf,
41}
42
43impl Exporter {
44    #[must_use]
45    pub fn new(blob_store: BlobStore, destination: impl Into<PathBuf>) -> Self {
46        Self {
47            blob_store,
48            destination: destination.into(),
49        }
50    }
51
52    #[must_use]
53    pub fn destination(&self) -> &Path {
54        &self.destination
55    }
56
57    /// Export one entry, creating parent directories as needed.
58    /// # Errors
59    ///
60    /// Returns an error if the filesystem or database cannot be accessed.
61    pub async fn export_entry(&self, entry: &ExportEntry) -> Result<PathBuf> {
62        let target = safe_join(&self.destination, &entry.relative_path)?;
63        if let Some(parent) = target.parent() {
64            fs::create_dir_all(parent)?;
65        }
66        let bytes = self.blob_store.get(entry.hash).await?;
67        fs::write(&target, &bytes)?;
68        Ok(target)
69    }
70
71    /// Export one blob and verify that the written bytes have the expected hash.
72    /// # Errors
73    ///
74    /// Returns an error if the filesystem or database cannot be accessed.
75    pub async fn export_verified(&self, entry: &ExportEntry) -> Result<PathBuf> {
76        let target = self.export_entry(entry).await?;
77        let actual = blake3::hash(&fs::read(&target)?);
78        if actual.as_bytes() != entry.hash.as_bytes() {
79            return Err(SyncwebError::operation(
80                "exported blob hash does not match entry",
81                target.display(),
82            ));
83        }
84        Ok(target)
85    }
86
87    /// Export all entries in order.
88    /// # Errors
89    ///
90    /// Returns an error if the filesystem or database cannot be accessed.
91    pub async fn export(&self, entries: &[ExportEntry]) -> Result<Vec<PathBuf>> {
92        let mut output = Vec::with_capacity(entries.len());
93        for entry in entries {
94            output.push(self.export_entry(entry).await?);
95        }
96        Ok(output)
97    }
98
99    /// Export all entries and verify each written blob.
100    /// # Errors
101    ///
102    /// Returns an error if the filesystem or database cannot be accessed.
103    pub async fn export_all_verified(&self, entries: &[ExportEntry]) -> Result<Vec<PathBuf>> {
104        let mut output = Vec::with_capacity(entries.len());
105        for entry in entries {
106            output.push(self.export_verified(entry).await?);
107        }
108        Ok(output)
109    }
110}
111
112/// Rayon-backed exporter. Blob retrieval remains asynchronous, while writes
113/// are performed by a bounded rayon pool.
114#[derive(Clone)]
115pub struct ParallelExporter {
116    exporter: Arc<Exporter>,
117    threads: Option<usize>,
118}
119
120impl ParallelExporter {
121    #[must_use]
122    pub fn new(blob_store: BlobStore, destination: impl Into<PathBuf>) -> Self {
123        Self {
124            exporter: Arc::new(Exporter::new(blob_store, destination)),
125            threads: None,
126        }
127    }
128
129    #[must_use]
130    pub const fn with_threads(mut self, threads: usize) -> Self {
131        self.threads = if threads > 0 { Some(threads) } else { None };
132        self
133    }
134
135    /// Export all entries concurrently.
136    /// # Errors
137    ///
138    /// Returns an error if the filesystem or database cannot be accessed.
139    pub async fn export(&self, entries: &[ExportEntry]) -> Result<Vec<PathBuf>> {
140        let concurrency = self
141            .threads
142            .unwrap_or_else(|| std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get));
143        let store = self.exporter.blob_store.clone();
144        let mut contents = stream::iter(entries.iter().enumerate().map(|(index, item)| {
145            let store_cloned = store.clone();
146            let entry_cloned = item.clone();
147            async move {
148                let bytes = store_cloned.get(entry_cloned.hash).await?;
149                Ok::<_, SyncwebError>((index, entry_cloned, bytes))
150            }
151        }))
152        .buffered_unordered(concurrency)
153        .try_collect::<Vec<_>>()
154        .await?;
155        contents.sort_by_key(|(index, _, _)| *index);
156        let destination = self.exporter.destination.clone();
157        let write = move || {
158            contents
159                .par_iter()
160                .map(|(_, entry, bytes)| {
161                    let target = safe_join(&destination, &entry.relative_path)?;
162                    if let Some(parent) = target.parent() {
163                        fs::create_dir_all(parent)?;
164                    }
165                    fs::write(&target, bytes)?;
166                    Ok(target)
167                })
168                .collect::<Result<Vec<_>>>()
169        };
170        let paths = if let Some(threads) = self.threads {
171            rayon::ThreadPoolBuilder::new()
172                .num_threads(threads)
173                .build()
174                .map_err(|error| SyncwebError::operation("failed to create exporter thread pool", error))?
175                .install(write)?
176        } else {
177            write()?
178        };
179        Ok(paths)
180    }
181
182    /// Export all entries concurrently and verify each written blob.
183    /// # Errors
184    ///
185    /// Returns an error if the filesystem or database cannot be accessed.
186    pub async fn export_parallel(&self, entries: &[ExportEntry]) -> Result<Vec<PathBuf>> {
187        let paths = self.export(entries).await?;
188        let verified = entries
189            .par_iter()
190            .zip(paths.par_iter())
191            .map(|(entry, path)| {
192                let actual = blake3::hash(&fs::read(path)?);
193                if actual.as_bytes() != entry.hash.as_bytes() {
194                    return Err(SyncwebError::operation(
195                        "exported blob hash does not match entry",
196                        path.display(),
197                    ));
198                }
199                Ok(path.clone())
200            })
201            .collect::<Result<Vec<_>>>()?;
202        Ok(verified)
203    }
204}
205
206fn safe_join(root: &Path, relative: &Path) -> Result<PathBuf> {
207    if relative.is_absolute()
208        || relative
209            .components()
210            .any(|component| component == std::path::Component::ParentDir)
211    {
212        return Err(SyncwebError::InvalidConfig(format!(
213            "export path escapes destination: {}",
214            relative.display()
215        )));
216    }
217    Ok(root.join(relative))
218}