syncweb_core/fs/
exporter.rs1use 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#[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#[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 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 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 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 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#[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 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 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}