1use anyhow::{Context, Result};
2use futures::stream::{self, StreamExt};
3use std::collections::HashSet;
4use std::path::Path;
5use std::sync::Arc;
6
7use crate::config::ensure_keys_string;
8use crate::fetch::{FetchConfig, Fetcher};
9use crate::HashtreeStore;
10use hashtree_core::{to_hex, Cid, HashTree, HashTreeConfig, Link, LinkType, Store, TreeNode};
11
12const BLOSSOM_PUSH_CONCURRENCY: usize = 16;
13const BLOSSOM_PUSH_PROGRESS_EVERY: usize = 512;
14
15fn parse_root_cid(cid_str: &str) -> Result<Cid> {
16 Cid::parse(cid_str).map_err(|e| anyhow::anyhow!("Invalid CID '{}': {}", cid_str, e))
17}
18
19fn child_cid(parent: &Cid, link: &Link) -> Cid {
20 let inherits_parent_key = link
21 .name
22 .as_deref()
23 .map(|name| {
24 name.starts_with("_chunk_")
25 || (name.starts_with('_') && name.chars().count() == 2 && link.link_type.is_tree())
26 })
27 .unwrap_or(false);
28
29 Cid {
30 hash: link.hash,
31 key: link.key.or(if inherits_parent_key {
32 parent.key
33 } else {
34 None
35 }),
36 }
37}
38
39async fn ensure_local_blob_for_push(
40 store: &HashtreeStore,
41 fetcher: Option<&Fetcher>,
42 cid: &Cid,
43) -> Result<()> {
44 if store.get_blob(&cid.hash)?.is_some() {
45 return Ok(());
46 }
47
48 if let Some(fetcher) = fetcher {
49 let data = fetcher
50 .fetch_chunk(&to_hex(&cid.hash))
51 .await
52 .with_context(|| format!("failed to hydrate missing local blob {}", cid))?;
53 store
54 .put_blob(&data)
55 .with_context(|| format!("failed to persist hydrated blob {}", cid))?;
56 if store.get_blob(&cid.hash)?.is_some() {
57 return Ok(());
58 }
59 }
60
61 anyhow::bail!("missing local blob while pushing DAG: {}", cid);
62}
63
64async fn node_for_push<S: Store>(
65 tree: &HashTree<S>,
66 cid: &Cid,
67 blob_size: Option<u64>,
68) -> Result<Option<TreeNode>> {
69 if let Some(size) = blob_size {
70 if let Some(data) = tree.get_blob(&cid.hash).await? {
71 let plaintext_size = match cid.key.as_ref() {
75 Some(key) => hashtree_core::crypto::decrypt_chk(&data, key)
76 .ok()
77 .map(|plaintext| plaintext.len() as u64),
78 None => Some(data.len() as u64),
79 };
80 if plaintext_size == Some(size) {
81 return Ok(None);
82 }
83 }
84 }
85
86 tree.get_node(cid)
87 .await
88 .map_err(|err| anyhow::anyhow!("Failed to inspect {cid}: {err}"))
89}
90
91pub(crate) async fn collect_cids_for_push(
92 store: &HashtreeStore,
93 root_cid: Cid,
94 fetcher: Option<&Fetcher>,
95) -> Result<Vec<Cid>> {
96 let mut cids_to_push = Vec::new();
97 let mut visited = HashSet::new();
100 let mut collected = HashSet::new();
101 let mut queue = vec![(root_cid, None)];
102 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
103
104 while let Some((cid, blob_size)) = queue.pop() {
105 if !visited.insert((cid.hash, blob_size)) {
106 continue;
107 }
108
109 ensure_local_blob_for_push(store, fetcher, &cid).await?;
110 if collected.insert(cid.hash) {
111 cids_to_push.push(cid.clone());
112 }
113
114 let node = node_for_push(&tree, &cid, blob_size).await?;
115
116 if let Some(node) = node {
117 for link in &node.links {
118 queue.push((
119 child_cid(&cid, link),
120 (link.link_type == LinkType::Blob).then_some(link.size),
121 ));
122 }
123 }
124 }
125
126 Ok(cids_to_push)
127}
128
129fn matching_old_child<'a>(
130 old_links: &'a [Link],
131 new_index: usize,
132 new_link: &Link,
133) -> Option<&'a Link> {
134 if let Some(name) = new_link.name.as_deref() {
135 old_links
136 .iter()
137 .find(|old_link| old_link.name.as_deref() == Some(name))
138 } else {
139 old_links
140 .get(new_index)
141 .filter(|old_link| old_link.name.is_none())
142 }
143}
144
145pub(crate) async fn collect_incremental_cids_for_push(
146 store: &HashtreeStore,
147 root_cid: Cid,
148 previous_root_cid: Cid,
149 fetcher: Option<&Fetcher>,
150) -> Result<Vec<Cid>> {
151 let mut cids_to_push = Vec::new();
152 let mut visited_new = HashSet::new();
153 let mut collected = HashSet::new();
154 let mut queue = vec![(root_cid, Some(previous_root_cid), None)];
155 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
156
157 while let Some((cid, old_cid, blob_size)) = queue.pop() {
158 if old_cid.as_ref().is_some_and(|old| old.hash == cid.hash) {
159 continue;
160 }
161 if !visited_new.insert((cid.hash, blob_size)) {
162 continue;
163 }
164
165 ensure_local_blob_for_push(store, fetcher, &cid).await?;
166 if collected.insert(cid.hash) {
167 cids_to_push.push(cid.clone());
168 }
169
170 let node = node_for_push(&tree, &cid, blob_size).await?;
171 let Some(node) = node else {
172 continue;
173 };
174
175 let old_node = match old_cid.as_ref() {
176 Some(old_cid) => match node_for_push(&tree, old_cid, blob_size).await {
177 Ok(old_node) => old_node,
178 Err(err) => {
179 tracing::warn!(
180 "Failed to inspect previous Blossom DAG node {}; uploading changed subtree: {}",
181 old_cid,
182 err
183 );
184 None
185 }
186 },
187 None => None,
188 };
189
190 for (index, link) in node.links.iter().enumerate() {
191 let child = child_cid(&cid, link);
192 let old_child = old_node
193 .as_ref()
194 .and_then(|old_node| matching_old_child(&old_node.links, index, link))
195 .filter(|old_link| {
196 (old_link.link_type == LinkType::Blob).then_some(old_link.size)
197 == (link.link_type == LinkType::Blob).then_some(link.size)
198 })
199 .map(|old_link| child_cid(old_cid.as_ref().expect("old node has cid"), old_link));
200
201 if old_child
202 .as_ref()
203 .is_some_and(|old_child| old_child.hash == child.hash)
204 {
205 continue;
206 }
207 queue.push((
208 child,
209 old_child,
210 (link.link_type == LinkType::Blob).then_some(link.size),
211 ));
212 }
213 }
214
215 Ok(cids_to_push)
216}
217
218async fn upload_cids_with_client(
219 store: Arc<HashtreeStore>,
220 fetcher: Option<Arc<Fetcher>>,
221 client: hashtree_blossom::BlossomClient,
222 cids_to_push: Vec<Cid>,
223 force_upload: bool,
224) -> Result<(usize, usize)> {
225 let total = cids_to_push.len();
226 let mut total_uploaded = 0usize;
227 let mut total_skipped = 0usize;
228 let mut total_errors = 0usize;
229 let mut last_error = None;
230 let mut processed = 0usize;
231
232 let mut uploads = stream::iter(cids_to_push.into_iter().map(|cid| {
233 let store = Arc::clone(&store);
234 let fetcher = fetcher.clone();
235 let client = client.clone();
236 async move {
237 ensure_local_blob_for_push(store.as_ref(), fetcher.as_deref(), &cid).await?;
238 let data = store
239 .get_blob(&cid.hash)?
240 .ok_or_else(|| anyhow::anyhow!("missing local blob while uploading {}", cid))?;
241 if force_upload {
242 client
243 .upload_to_selected_servers(&data, client.write_servers())
244 .await
245 .map(|(hash, _successes)| (hash, true))
246 .map_err(|error| anyhow::anyhow!(error.to_string()))
247 } else {
248 client
249 .upload_if_missing(&data)
250 .await
251 .map_err(|error| anyhow::anyhow!(error.to_string()))
252 }
253 }
254 }))
255 .buffer_unordered(BLOSSOM_PUSH_CONCURRENCY);
256
257 while let Some(result) = uploads.next().await {
258 processed += 1;
259 match result {
260 Ok((_hash, was_uploaded)) => {
261 if was_uploaded {
262 total_uploaded += 1;
263 } else {
264 total_skipped += 1;
265 }
266 }
267 Err(error) => {
268 tracing::warn!("Blossom upload failed: {}", error);
269 total_errors += 1;
270 last_error = Some(error.to_string());
271 }
272 }
273
274 if processed.is_multiple_of(BLOSSOM_PUSH_PROGRESS_EVERY) || processed == total {
275 println!(
276 " file servers: {processed}/{total} processed ({total_uploaded} uploaded, {total_skipped} already exist, {total_errors} failed)",
277 );
278 }
279 }
280
281 if total_errors > 0 {
282 let detail = last_error
283 .as_deref()
284 .map(|err| format!(" (last error: {err})"))
285 .unwrap_or_default();
286 anyhow::bail!(
287 "failed to upload {} blob(s) to configured file servers{}",
288 total_errors,
289 detail
290 );
291 }
292
293 Ok((total_uploaded, total_skipped))
294}
295
296pub async fn push_to_blossom(
298 data_dir: &Path,
299 cid_str: &str,
300 server_override: Option<String>,
301 force_upload: bool,
302 shallow: bool,
303) -> Result<()> {
304 use hashtree_blossom::BlossomClient;
305 use nostr::Keys;
306
307 let (nsec_str, _) = ensure_keys_string()?;
308 let keys = Keys::parse(&nsec_str).context("Failed to parse nsec")?;
309
310 let client = if let Some(server) = server_override {
311 BlossomClient::new(keys).with_write_servers(vec![server])
312 } else {
313 BlossomClient::new(keys)
314 };
315
316 if client.write_servers().is_empty() {
317 anyhow::bail!(
318 "No file servers configured. Use --server or add write_servers to config.toml"
319 );
320 }
321
322 let store = Arc::new(HashtreeStore::new(data_dir)?);
323 let fetcher = Arc::new(Fetcher::new(FetchConfig::default()));
324
325 let root_cid = parse_root_cid(cid_str)?;
326 let cids_to_push = if shallow {
327 ensure_local_blob_for_push(store.as_ref(), Some(fetcher.as_ref()), &root_cid).await?;
328 vec![root_cid]
329 } else {
330 println!("Collecting blocks...");
331 collect_cids_for_push(store.as_ref(), root_cid, Some(fetcher.as_ref())).await?
332 };
333
334 println!("Found {} blocks to push", cids_to_push.len());
335 let (uploaded, skipped) =
336 upload_cids_with_client(store, Some(fetcher), client, cids_to_push, force_upload).await?;
337
338 println!("\nUploaded: {uploaded}, Skipped: {skipped}, Errors: 0");
339 println!("Done!");
340 Ok(())
341}
342
343pub async fn background_blossom_push(
345 data_dir: &Path,
346 cid_str: &str,
347 servers: &[String],
348) -> Result<()> {
349 let store = Arc::new(HashtreeStore::new(data_dir)?);
350 background_blossom_push_with_store(store, cid_str, servers).await
351}
352
353pub async fn background_blossom_push_with_store(
354 store: Arc<HashtreeStore>,
355 cid_str: &str,
356 servers: &[String],
357) -> Result<()> {
358 let root_cid = parse_root_cid(cid_str)?;
359 background_blossom_push_incremental_with_store(store, root_cid, None, servers).await
360}
361
362pub async fn background_blossom_push_incremental_with_store(
363 store: Arc<HashtreeStore>,
364 root_cid: Cid,
365 previous_root_cid: Option<Cid>,
366 servers: &[String],
367) -> Result<()> {
368 use hashtree_blossom::BlossomClient;
369 use nostr::Keys;
370
371 let (nsec_str, _) = ensure_keys_string()?;
372 let keys = Keys::parse(&nsec_str).context("Failed to parse nsec")?;
373
374 let fetcher = Arc::new(Fetcher::new(FetchConfig::default()));
375 let cids_to_push = if let Some(previous_root_cid) = previous_root_cid.as_ref() {
376 println!("Collecting bounded DAG diff for file-server push...");
377 match collect_incremental_cids_for_push(
378 store.as_ref(),
379 root_cid.clone(),
380 previous_root_cid.clone(),
381 Some(fetcher.as_ref()),
382 )
383 .await
384 {
385 Ok(cids) => cids,
386 Err(err) => {
387 tracing::warn!(
388 "Blossom DAG diff failed; falling back to full push: {}",
389 err
390 );
391 collect_cids_for_push(store.as_ref(), root_cid, Some(fetcher.as_ref())).await?
392 }
393 }
394 } else {
395 println!("Collecting DAG for file-server push...");
396 collect_cids_for_push(store.as_ref(), root_cid, Some(fetcher.as_ref())).await?
397 };
398
399 if cids_to_push.is_empty() {
400 return Ok(());
401 }
402
403 let client = if servers.is_empty() {
404 BlossomClient::new(keys)
405 } else {
406 BlossomClient::new(keys).with_write_servers(servers.to_vec())
407 };
408 upload_cids_with_client(store, Some(fetcher), client, cids_to_push, false).await?;
409 Ok(())
410}
411
412#[cfg(test)]
413mod tests {
414 use super::{collect_cids_for_push, collect_incremental_cids_for_push};
415 use crate::HashtreeStore;
416 use futures::executor::block_on as sync_block_on;
417 use hashtree_core::{DirEntry, HashTree, HashTreeConfig, LinkType};
418 use tempfile::tempdir;
419
420 #[tokio::test]
421 async fn push_collectors_accept_tree_shaped_raw_blobs() {
422 let tmp = tempdir().expect("tempdir");
423 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
424 let payload = [0x82, 0xa1, b'l', 0x90, 0xa1, b't', 0x40];
425
426 for encrypted in [false, true] {
427 let mut config = HashTreeConfig::new(store.store_arc());
428 if !encrypted {
429 config = config.public();
430 }
431 let tree = HashTree::new(config);
432 let old_root = tree.put_directory(vec![]).await.expect("old root");
433 let (blob, size) = tree.put(&payload).await.expect("raw blob");
434 let root = tree
435 .put_directory(vec![
436 DirEntry::from_cid("raw.msgpack", &blob).with_size(size)
437 ])
438 .await
439 .expect("root");
440
441 for previous in [None, Some(old_root)] {
442 let cids = match previous {
443 Some(previous) => {
444 collect_incremental_cids_for_push(&store, root.clone(), previous, None)
445 .await
446 }
447 None => collect_cids_for_push(&store, root.clone(), None).await,
448 }
449 .expect("raw bytes must not be decoded as a structural node");
450 let hashes = cids
451 .iter()
452 .map(|cid| cid.hash)
453 .collect::<std::collections::HashSet<_>>();
454 assert_eq!(
455 hashes,
456 std::collections::HashSet::from([root.hash, blob.hash])
457 );
458 }
459 }
460 }
461
462 #[tokio::test]
463 async fn push_collectors_walk_mixed_chunk_sizes_and_reject_missing_chunks() {
464 let tmp = tempdir().expect("tempdir");
465 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
466 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()));
467 let old_root = tree.put_directory(vec![]).await.expect("old root");
468 let mut entries = Vec::new();
469 let mut required_hashes = std::collections::HashSet::new();
470 let mut missing_chunk = None;
471
472 for chunk_size in [64 * 1024, hashtree_core::DEFAULT_CHUNK_SIZE] {
473 let chunk_tree = HashTree::new(
474 HashTreeConfig::new(store.store_arc())
475 .with_chunk_size(chunk_size)
476 .with_max_links(2),
477 );
478 let data = [
479 vec![1; chunk_size],
480 vec![2; chunk_size],
481 vec![3; chunk_size],
482 vec![4; 7],
483 ]
484 .concat();
485 let (file, size) = chunk_tree.put(&data).await.expect("chunked file");
486 required_hashes.insert(file.hash);
487 for chunk in data.chunks(chunk_size) {
488 let (leaf, _) = chunk_tree.put(chunk).await.expect("leaf");
489 required_hashes.insert(leaf.hash);
490 missing_chunk.get_or_insert(leaf.hash);
491 }
492 entries.push(DirEntry::from_cid(chunk_size.to_string(), &file).with_size(size));
493 }
494 let root = tree.put_directory(entries).await.expect("mixed root");
495 required_hashes.insert(root.hash);
496
497 for previous in [None, Some(old_root.clone())] {
498 let cids = match previous {
499 Some(previous) => {
500 collect_incremental_cids_for_push(&store, root.clone(), previous, None).await
501 }
502 None => collect_cids_for_push(&store, root.clone(), None).await,
503 }
504 .expect("all chunk sizes must be traversed");
505 let hashes = cids
506 .iter()
507 .map(|cid| cid.hash)
508 .collect::<std::collections::HashSet<_>>();
509 assert!(required_hashes.is_subset(&hashes));
510 }
511
512 store
513 .router()
514 .delete_local_only(&missing_chunk.expect("leaf"))
515 .expect("delete chunk");
516 for previous in [None, Some(old_root)] {
517 let result = match previous {
518 Some(previous) => {
519 collect_incremental_cids_for_push(&store, root.clone(), previous, None).await
520 }
521 None => collect_cids_for_push(&store, root.clone(), None).await,
522 };
523 assert!(result
524 .expect_err("missing chunk must fail")
525 .to_string()
526 .contains("missing local blob"));
527 }
528 }
529
530 #[tokio::test]
531 async fn push_collectors_reject_invalid_structural_nodes() {
532 let tmp = tempdir().expect("tempdir");
533 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
534 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()));
535 let old_root = tree.put_directory(vec![]).await.expect("old root");
536 let (invalid, size) = tree
537 .put(&[0x82, 0xa1, b'l', 0x90, 0xa1, b't', 0x40])
538 .await
539 .expect("invalid node");
540
541 for kind in [LinkType::Dir, LinkType::File, LinkType::Fanout] {
542 let root = tree
543 .put_directory(vec![DirEntry::from_cid("invalid", &invalid)
544 .with_size(size)
545 .with_link_type(kind)])
546 .await
547 .expect("root");
548 for previous in [None, Some(old_root.clone())] {
549 let result = match previous {
550 Some(previous) => {
551 collect_incremental_cids_for_push(&store, root.clone(), previous, None)
552 .await
553 }
554 None => collect_cids_for_push(&store, root.clone(), None).await,
555 };
556 assert!(result
557 .expect_err("invalid structural node must fail")
558 .to_string()
559 .contains("Invalid node type: 64"));
560 }
561 }
562 }
563
564 #[tokio::test]
565 async fn push_collectors_expand_nodes_also_referenced_as_raw_blobs() {
566 let tmp = tempdir().expect("tempdir");
567 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
568 for encrypted in [false, true] {
569 let mut config = HashTreeConfig::new(store.store_arc());
570 if !encrypted {
571 config = config.public();
572 }
573 let chunk_tree = HashTree::new(HashTreeConfig {
574 chunk_size: 4,
575 encrypted,
576 ..HashTreeConfig::new(store.store_arc())
577 });
578 let tree = HashTree::new(config);
579 let (file, size) = chunk_tree.put(b"multiple chunks").await.expect("file");
580 let file_node = tree
581 .get_node(&file)
582 .await
583 .expect("file node")
584 .expect("node");
585 let encoded = tree
586 .get_blob(&file.hash)
587 .await
588 .expect("encoded file")
589 .expect("bytes");
590 let encoded = match file.key.as_ref() {
591 Some(key) => {
592 hashtree_core::crypto::decrypt_chk(&encoded, key).expect("decrypt file")
593 }
594 None => encoded,
595 };
596 let (raw, raw_size) = tree.put(&encoded).await.expect("raw encoded node");
597 assert_eq!(file, raw, "both interpretations must share the same CID");
598 let old_root = tree.put_directory(vec![]).await.expect("old root");
599 let raw_only_root = tree
600 .put_directory(vec![
601 DirEntry::from_cid("a-file", &raw).with_size(raw_size),
602 DirEntry::from_cid("z-raw", &raw).with_size(raw_size),
603 ])
604 .await
605 .expect("raw-only root");
606 let root = tree
607 .put_directory(vec![
608 DirEntry::from_cid("a-file", &file)
609 .with_size(size)
610 .with_link_type(LinkType::File),
611 DirEntry::from_cid("z-raw", &raw).with_size(raw_size),
612 ])
613 .await
614 .expect("root");
615 let mut expected = std::collections::HashSet::from([root.hash, file.hash]);
616 expected.extend(file_node.links.iter().map(|link| link.hash));
617
618 for previous in [None, Some(old_root), Some(raw_only_root)] {
619 let cids = match previous {
620 Some(previous) => {
621 collect_incremental_cids_for_push(&store, root.clone(), previous, None)
622 .await
623 }
624 None => collect_cids_for_push(&store, root.clone(), None).await,
625 }
626 .expect("structural reference must still expand after raw reference");
627 let hashes = cids
628 .iter()
629 .map(|cid| cid.hash)
630 .collect::<std::collections::HashSet<_>>();
631 assert_eq!(hashes, expected);
632 assert_eq!(
633 cids.len(),
634 hashes.len(),
635 "upload each shared block only once"
636 );
637 }
638 }
639 }
640
641 #[tokio::test]
642 async fn incremental_push_does_not_prune_children_of_old_raw_blobs() {
643 let tmp = tempdir().expect("tempdir");
644 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
645 for encrypted in [false, true] {
646 let config = || HashTreeConfig {
647 encrypted,
648 ..HashTreeConfig::new(store.store_arc())
649 };
650 let tree = HashTree::new(config());
651 let chunk_tree = HashTree::new(config().with_chunk_size(4));
652 let (shared, _) = chunk_tree.put(b"abcd").await.expect("shared leaf");
653 let (file, _) = chunk_tree.put(b"abcdabcd").await.expect("file node");
654 let encoded = tree
655 .get_blob(&file.hash)
656 .await
657 .expect("encoded node")
658 .expect("bytes");
659 let encoded = match file.key.as_ref() {
660 Some(key) => {
661 hashtree_core::crypto::decrypt_chk(&encoded, key).expect("decrypt node")
662 }
663 None => encoded,
664 };
665 let (raw, size) = tree.put(&encoded).await.expect("literal node bytes");
666 let old_root = tree
667 .put_directory(vec![DirEntry::from_cid("file", &raw).with_size(size)])
668 .await
669 .expect("old root");
670 let old_cids = collect_cids_for_push(&store, old_root.clone(), None)
671 .await
672 .expect("raw-only closure");
673 assert!(!old_cids.iter().any(|cid| cid.hash == shared.hash));
674
675 let mut data = vec![b'x'; size as usize];
676 data[..4].copy_from_slice(b"abcd");
677 let (new_file, new_size) = chunk_tree.put(&data).await.expect("new chunked file");
678 assert_eq!(new_size, size);
679 assert_ne!(new_file.hash, raw.hash);
680 let new_root = tree
681 .put_directory(vec![
682 DirEntry::from_cid("file", &new_file).with_size(new_size)
683 ])
684 .await
685 .expect("new root");
686 let cids = collect_incremental_cids_for_push(&store, new_root, old_root, None)
687 .await
688 .expect("changed closure");
689 assert!(
690 cids.iter().any(|cid| cid.hash == shared.hash),
691 "old raw bytes provide no descendant coverage"
692 );
693 }
694 }
695
696 #[tokio::test]
697 async fn failed_http_uploads_return_an_error() {
698 let tmp = tempdir().expect("tempdir");
699 let store = std::sync::Arc::new(
700 HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store"),
701 );
702 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()));
703 let (cid, _) = tree.put(b"upload rejection fixture").await.expect("blob");
704 let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
705 let observed_requests = requests.clone();
706 let app = axum::Router::new().route(
707 "/upload",
708 axum::routing::put(move |body: bytes::Bytes| async move {
709 assert!(!body.is_empty());
710 observed_requests.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
711 axum::http::StatusCode::BAD_REQUEST
712 }),
713 );
714 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
715 .await
716 .expect("bind");
717 let address = listener.local_addr().expect("address");
718 let server = tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
719 let client = hashtree_blossom::BlossomClient::new_empty(nostr::Keys::generate())
720 .with_write_servers(vec![format!("http://{address}")]);
721
722 for force_upload in [false, true] {
723 let result = super::upload_cids_with_client(
724 store.clone(),
725 None,
726 client.clone(),
727 vec![cid.clone()],
728 force_upload,
729 )
730 .await;
731 assert!(result
732 .expect_err("failed uploads must not report success")
733 .to_string()
734 .contains("failed to upload 1 blob(s)"));
735 }
736 assert_eq!(requests.load(std::sync::atomic::Ordering::Relaxed), 2);
737 server.abort();
738 let _ = server.await;
739 }
740
741 #[tokio::test]
742 async fn collect_cids_for_push_fails_on_missing_descendant_blob() {
743 let tmp = tempdir().expect("tempdir");
744 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
745 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
746
747 let root = sync_block_on(async {
748 let (file_cid, _size) = tree.put_file(b"hello").await.expect("file");
749 tree.put_directory(vec![hashtree_core::DirEntry::from_cid(
750 "greeting.txt",
751 &file_cid,
752 )])
753 .await
754 .expect("dir")
755 });
756
757 let entries = store
758 .get_tree_node(&root.hash)
759 .expect("root node")
760 .expect("root node present")
761 .links;
762 let child_hash = entries[0].hash;
763 store
764 .router()
765 .delete_local_only(&child_hash)
766 .expect("delete child locally");
767
768 let err = collect_cids_for_push(&store, root, None)
769 .await
770 .expect_err("missing child should fail");
771 assert!(err
772 .to_string()
773 .contains("missing local blob while pushing DAG"));
774 }
775
776 #[tokio::test]
777 async fn incremental_push_collects_only_changed_named_subtrees() {
778 let tmp = tempdir().expect("tempdir");
779 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
780 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
781
782 let stable_file = tree.put_blob(b"stable").await.expect("stable file");
783 let old_changed_file = tree.put_blob(b"old").await.expect("old file");
784 let old_subdir = tree
785 .put_directory(vec![
786 DirEntry::new("changed.txt", old_changed_file).with_size(3),
787 DirEntry::new("stable.txt", stable_file).with_size(6),
788 ])
789 .await
790 .expect("old subdir");
791 let old_root = tree
792 .put_directory(vec![
793 DirEntry::new("subdir", old_subdir.hash).with_link_type(LinkType::Dir),
794 DirEntry::new("stable-root.txt", stable_file).with_size(6),
795 ])
796 .await
797 .expect("old root");
798
799 let new_changed_file = tree.put_blob(b"new").await.expect("new file");
800 let new_subdir = tree
801 .put_directory(vec![
802 DirEntry::new("stable.txt", stable_file).with_size(6),
803 DirEntry::new("changed.txt", new_changed_file).with_size(3),
804 ])
805 .await
806 .expect("new subdir");
807 let new_root = tree
808 .put_directory(vec![
809 DirEntry::new("stable-root.txt", stable_file).with_size(6),
810 DirEntry::new("subdir", new_subdir.hash).with_link_type(LinkType::Dir),
811 ])
812 .await
813 .expect("new root");
814
815 let cids = collect_incremental_cids_for_push(&store, new_root.clone(), old_root, None)
816 .await
817 .expect("incremental cids");
818 let hashes = cids.iter().map(|cid| cid.hash).collect::<Vec<_>>();
819
820 assert_eq!(hashes.len(), 3);
821 assert!(hashes.contains(&new_root.hash));
822 assert!(hashes.contains(&new_subdir.hash));
823 assert!(hashes.contains(&new_changed_file));
824 assert!(!hashes.contains(&stable_file));
825 }
826}