1use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use n0_future::StreamExt;
8
9use crate::{
10 allocation::{AllocationCandidate, StorageRoot, materialization_path},
11 error::{Result, SyncwebError},
12 folder::FolderManager,
13 node::iroh_node::IrohNode,
14 storage::node_db::{NodeDatabase, TransferJobRecord},
15 sync::{FetchFilter, FetchStrategy, SyncEngine, SyncEvent},
16};
17
18const TRANSFER_TIMEOUT: Duration = Duration::from_mins(5);
19
20#[derive(Clone, Copy, Debug, Default)]
22#[non_exhaustive]
23pub struct TransferJobSummary {
24 pub completed: u64,
25 pub failed: u64,
26}
27
28impl TransferJobSummary {
29 #[must_use]
31 pub const fn new(completed: u64, failed: u64) -> Self {
32 Self { completed, failed }
33 }
34}
35
36pub async fn process_transfer_jobs(
43 node: &IrohNode,
44 node_db: &NodeDatabase,
45 namespace: Option<&str>,
46) -> Result<TransferJobSummary> {
47 let jobs = node_db.list_transfer_jobs(namespace, Some("queued"))?;
48 let mut summary = TransferJobSummary::default();
49 for job in jobs {
50 match process_transfer_job(node, node_db, job).await {
51 TransferJobOutcome::Completed => summary.completed = summary.completed.saturating_add(1),
52 TransferJobOutcome::Failed => summary.failed = summary.failed.saturating_add(1),
53 TransferJobOutcome::Skipped => {}
54 }
55 }
56 Ok(summary)
57}
58
59#[derive(Clone, Copy)]
60enum TransferJobOutcome {
61 Completed,
62 Failed,
63 Skipped,
64}
65
66async fn process_transfer_job(node: &IrohNode, node_db: &NodeDatabase, job: TransferJobRecord) -> TransferJobOutcome {
67 let Some(destination) = job.destination.as_ref() else {
68 mark_transfer_job_failed(
69 node_db,
70 &job.id,
71 &"job has no allocated destination",
72 "missing transfer destination",
73 );
74 return TransferJobOutcome::Failed;
75 };
76 let hash = iroh_blobs::Hash::from(job.hash);
77 if let Err(error) = validate_transfer_job(node_db, &job, destination, hash) {
78 mark_transfer_job_failed(node_db, &job.id, &error, "transfer path validation error");
79 return TransferJobOutcome::Failed;
80 }
81
82 let fetch_claimed = match node_db.transition_transfer_job_state(&job.id, "queued", "fetching", None) {
83 Ok(claimed) => claimed,
84 Err(error) => {
85 tracing::error!(%error, job_id = %job.id, "failed to mark transfer job fetching");
86 return TransferJobOutcome::Failed;
87 }
88 };
89 if !fetch_claimed {
90 return TransferJobOutcome::Skipped;
91 }
92
93 let has_blob = match node.blob_store().has(hash).await {
94 Ok(has_blob) => has_blob,
95 Err(error) => {
96 mark_transfer_job_failed(node_db, &job.id, &error, "blob lookup error");
97 return TransferJobOutcome::Failed;
98 }
99 };
100 if !has_blob {
101 let path = match String::from_utf8(job.entry_key.clone()) {
102 Ok(path) => path,
103 Err(error) => {
104 mark_transfer_job_failed(
105 node_db,
106 &job.id,
107 &format!("job entry path is not UTF-8: {error}"),
108 "invalid transfer path",
109 );
110 return TransferJobOutcome::Failed;
111 }
112 };
113 let namespace = match job.namespace_id.parse::<iroh_docs::NamespaceId>() {
114 Ok(namespace) => namespace,
115 Err(error) => {
116 mark_transfer_job_failed(
117 node_db,
118 &job.id,
119 &format!("job has an invalid namespace: {error}"),
120 "invalid transfer namespace",
121 );
122 return TransferJobOutcome::Failed;
123 }
124 };
125 if let Err(error) = fetch_transfer_blob(node, namespace, &path).await {
126 mark_transfer_job_failed(node_db, &job.id, &error, "transfer fetch error");
127 return TransferJobOutcome::Failed;
128 }
129 }
130
131 let materialization_claimed =
132 match node_db.transition_transfer_job_state(&job.id, "fetching", "materializing", None) {
133 Ok(claimed) => claimed,
134 Err(error) => {
135 tracing::error!(%error, job_id = %job.id, "failed to mark transfer job materializing");
136 return TransferJobOutcome::Failed;
137 }
138 };
139 if !materialization_claimed {
140 return TransferJobOutcome::Skipped;
141 }
142
143 match materialize_transfer(node, destination, hash).await {
144 Ok(size) => complete_transfer_job(node_db, &job, size),
145 Err(error) => {
146 mark_transfer_job_failed(node_db, &job.id, &error, "transfer materialization error");
147 TransferJobOutcome::Failed
148 }
149 }
150}
151
152async fn fetch_transfer_blob(node: &IrohNode, namespace_id: iroh_docs::NamespaceId, path: &str) -> Result<()> {
154 let sync = SyncEngine::new(
155 FolderManager::new(node),
156 node.blob_store().clone(),
157 node.docs_engine().clone(),
158 Some(node.topic_tracker().clone()),
159 );
160 let filter = FetchFilter::new().with_paths(vec![PathBuf::from(path)]);
161 let mut intent = sync.fetch(namespace_id, FetchStrategy::filter(filter)).await?;
162 let body = async {
163 while let Some(event) = intent.next().await {
164 match event {
165 SyncEvent::Failed(message) => {
166 return Err(SyncwebError::operation("transfer fetch failed", message));
167 }
168 SyncEvent::Finished => return Ok(()),
169 SyncEvent::Stats(_)
170 | SyncEvent::Started
171 | SyncEvent::Progress { .. }
172 | SyncEvent::Paused
173 | SyncEvent::Resumed
174 | SyncEvent::Cancelled => {}
175 }
176 }
177 Ok(())
178 };
179 match tokio::time::timeout(TRANSFER_TIMEOUT, body).await {
180 Ok(result) => result,
181 Err(_elapsed) => {
182 let _ = intent.cancel();
183 Ok(())
184 }
185 }
186}
187
188fn validate_transfer_job(
190 node_db: &NodeDatabase,
191 job: &TransferJobRecord,
192 destination: &Path,
193 hash: iroh_blobs::Hash,
194) -> Result<()> {
195 let root_id = job
196 .root_id
197 .as_deref()
198 .ok_or_else(|| SyncwebError::InvalidConfig("job has no storage root".to_owned()))?;
199 let root = node_db
200 .list_storage_roots()?
201 .into_iter()
202 .find(|root| root.id == root_id)
203 .ok_or_else(|| SyncwebError::InvalidConfig(format!("storage root not found: {root_id}")))?;
204 let job_namespace = job
205 .namespace_id
206 .parse::<iroh_docs::NamespaceId>()
207 .map_err(|error| SyncwebError::operation("job has invalid namespace", error))?;
208 let path = String::from_utf8(job.entry_key.clone())
209 .map_err(|error| SyncwebError::operation("job entry path is not UTF-8", error))?;
210 let root_path = root.path.clone();
211 let candidate = AllocationCandidate::new(
212 job_namespace,
213 PathBuf::from(path),
214 hash,
215 job.size,
216 usize::try_from(job.peer_count).unwrap_or(usize::MAX),
217 false,
218 );
219 let expected = materialization_path(
220 &StorageRoot::new(root.id, &root_path, root.min_free).with_enabled(root.enabled),
221 &candidate,
222 )?;
223 if expected != destination {
224 return Err(SyncwebError::InvalidConfig(format!(
225 "job destination does not match its allocated root: {}",
226 destination.display()
227 )));
228 }
229 reject_symlink_components(
230 expected.strip_prefix(&root_path).unwrap_or_else(|_| Path::new("")),
231 &root_path,
232 )?;
233 Ok(())
234}
235
236async fn materialize_transfer(node: &IrohNode, destination: &Path, hash: iroh_blobs::Hash) -> Result<u64> {
237 if let Ok(metadata) = std::fs::symlink_metadata(destination) {
238 if metadata.file_type().is_symlink() || !metadata.is_file() {
239 return Err(SyncwebError::InvalidConfig(format!(
240 "materialization destination is not a regular file: {}",
241 destination.display()
242 )));
243 }
244 let existing = std::fs::read(destination)?;
245 if blake3::hash(&existing).as_bytes() != hash.as_bytes() {
246 return Err(SyncwebError::InvalidConfig(format!(
247 "materialization destination has a different blob: {}",
248 destination.display()
249 )));
250 }
251 }
252 if let Some(parent) = destination.parent() {
253 std::fs::create_dir_all(parent)?;
254 }
255 node.blob_store().export_to_path(hash, destination).await?;
256 let bytes = std::fs::read(destination)?;
257 if blake3::hash(&bytes).as_bytes() != hash.as_bytes() {
258 return Err(SyncwebError::operation(
259 "materialized transfer hash does not match",
260 destination.display(),
261 ));
262 }
263 u64::try_from(bytes.len()).map_err(|error| SyncwebError::operation("materialized transfer size is invalid", error))
264}
265
266fn complete_transfer_job(node_db: &NodeDatabase, job: &TransferJobRecord, size: u64) -> TransferJobOutcome {
267 if let Err(error) = node_db.update_transfer_job_progress(&job.id, size, job.peer_count, None, job.retries) {
268 tracing::error!(%error, job_id = %job.id, "failed to record transfer completion progress");
269 return TransferJobOutcome::Failed;
270 }
271 match node_db.transition_transfer_job_state(&job.id, "materializing", "completed", None) {
272 Ok(true) => TransferJobOutcome::Completed,
273 Ok(false) => TransferJobOutcome::Skipped,
274 Err(error) => {
275 tracing::error!(%error, job_id = %job.id, "failed to record transfer completion");
276 TransferJobOutcome::Failed
277 }
278 }
279}
280
281fn mark_transfer_job_failed(node_db: &NodeDatabase, job_id: &str, message: &dyn std::fmt::Display, context: &str) {
282 let rendered_message = message.to_string();
283 if let Err(error) = node_db.update_transfer_job_state(job_id, "failed", Some(&rendered_message)) {
284 tracing::error!(%error, %job_id, context, "failed to record transfer job failure");
285 }
286}
287
288fn reject_symlink_components(relative: &Path, root: &Path) -> Result<()> {
289 let mut current = root.to_path_buf();
290 for component in relative.components() {
291 current.push(component.as_os_str());
292 let metadata = match std::fs::symlink_metadata(¤t) {
293 Ok(metadata) => metadata,
294 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
295 Err(error) => return Err(error.into()),
296 };
297 if metadata.file_type().is_symlink() {
298 return Err(SyncwebError::InvalidConfig(format!(
299 "materialization path contains a symlink: {}",
300 current.display()
301 )));
302 }
303 }
304 Ok(())
305}