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 collect_incremental_cids(store, root_cid, previous_root_cid, fetcher, false).await
152}
153
154async fn collect_incremental_cids(
155 store: &HashtreeStore,
156 root_cid: Cid,
157 previous_root_cid: Cid,
158 fetcher: Option<&Fetcher>,
159 require_previous_nodes: bool,
160) -> Result<Vec<Cid>> {
161 let mut cids_to_push = Vec::new();
162 let mut visited_new = HashSet::new();
163 let mut collected = HashSet::new();
164 let mut queue = vec![(root_cid, Some(previous_root_cid), None)];
165 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
166
167 while let Some((cid, old_cid, blob_size)) = queue.pop() {
168 if old_cid.as_ref().is_some_and(|old| old.hash == cid.hash) {
169 continue;
170 }
171 if !visited_new.insert((cid.hash, blob_size)) {
172 continue;
173 }
174
175 ensure_local_blob_for_push(store, fetcher, &cid).await?;
176 if collected.insert(cid.hash) {
177 cids_to_push.push(cid.clone());
178 }
179
180 let node = node_for_push(&tree, &cid, blob_size).await?;
181 let Some(node) = node else {
182 continue;
183 };
184
185 if require_previous_nodes {
186 if let Some(old_cid) = old_cid.as_ref() {
187 ensure_local_blob_for_push(store, fetcher, old_cid)
188 .await
189 .context("previous DAG is unavailable for a bounded delta push")?;
190 }
191 }
192 let old_node = match old_cid.as_ref() {
193 Some(old_cid) => match node_for_push(&tree, old_cid, blob_size).await {
194 Ok(old_node) => old_node,
195 Err(err) if require_previous_nodes => {
196 return Err(err).context("previous DAG is unreadable for a bounded delta push");
197 }
198 Err(err) => {
199 tracing::warn!(
200 "Failed to inspect previous Blossom DAG node {}; uploading changed subtree: {}",
201 old_cid,
202 err
203 );
204 None
205 }
206 },
207 None => None,
208 };
209
210 for (index, link) in node.links.iter().enumerate() {
211 let child = child_cid(&cid, link);
212 let old_child = old_node
213 .as_ref()
214 .and_then(|old_node| matching_old_child(&old_node.links, index, link))
215 .filter(|old_link| {
216 (old_link.link_type == LinkType::Blob).then_some(old_link.size)
217 == (link.link_type == LinkType::Blob).then_some(link.size)
218 })
219 .map(|old_link| child_cid(old_cid.as_ref().expect("old node has cid"), old_link));
220
221 if old_child
222 .as_ref()
223 .is_some_and(|old_child| old_child.hash == child.hash)
224 {
225 continue;
226 }
227 queue.push((
228 child,
229 old_child,
230 (link.link_type == LinkType::Blob).then_some(link.size),
231 ));
232 }
233 }
234
235 Ok(cids_to_push)
236}
237
238async fn upload_cids_with_client(
239 store: Arc<HashtreeStore>,
240 fetcher: Option<Arc<Fetcher>>,
241 client: hashtree_blossom::BlossomClient,
242 cids_to_push: Vec<Cid>,
243 force_upload: bool,
244) -> Result<(usize, usize)> {
245 let total = cids_to_push.len();
246 let mut total_uploaded = 0usize;
247 let mut total_skipped = 0usize;
248 let mut total_errors = 0usize;
249 let mut last_error = None;
250 let mut processed = 0usize;
251
252 let mut uploads = stream::iter(cids_to_push.into_iter().map(|cid| {
253 let store = Arc::clone(&store);
254 let fetcher = fetcher.clone();
255 let client = client.clone();
256 async move {
257 ensure_local_blob_for_push(store.as_ref(), fetcher.as_deref(), &cid).await?;
258 let data = store
259 .get_blob(&cid.hash)?
260 .ok_or_else(|| anyhow::anyhow!("missing local blob while uploading {}", cid))?;
261 if force_upload {
262 client
263 .upload_to_selected_servers(&data, client.write_servers())
264 .await
265 .map(|(hash, _successes)| (hash, true))
266 .map_err(|error| anyhow::anyhow!(error.to_string()))
267 } else {
268 client
269 .upload_if_missing(&data)
270 .await
271 .map_err(|error| anyhow::anyhow!(error.to_string()))
272 }
273 }
274 }))
275 .buffer_unordered(BLOSSOM_PUSH_CONCURRENCY);
276
277 while let Some(result) = uploads.next().await {
278 processed += 1;
279 match result {
280 Ok((_hash, was_uploaded)) => {
281 if was_uploaded {
282 total_uploaded += 1;
283 } else {
284 total_skipped += 1;
285 }
286 }
287 Err(error) => {
288 tracing::warn!("Blossom upload failed: {}", error);
289 total_errors += 1;
290 last_error = Some(error.to_string());
291 }
292 }
293
294 if processed.is_multiple_of(BLOSSOM_PUSH_PROGRESS_EVERY) || processed == total {
295 println!(
296 " file servers: {processed}/{total} processed ({total_uploaded} uploaded, {total_skipped} already exist, {total_errors} failed)",
297 );
298 }
299 }
300
301 if total_errors > 0 {
302 let detail = last_error
303 .as_deref()
304 .map(|err| format!(" (last error: {err})"))
305 .unwrap_or_default();
306 anyhow::bail!(
307 "failed to upload {} blob(s) to configured file servers{}",
308 total_errors,
309 detail
310 );
311 }
312
313 Ok((total_uploaded, total_skipped))
314}
315
316pub async fn push_to_blossom(
318 data_dir: &Path,
319 cid_str: &str,
320 server_override: Option<String>,
321 force_upload: bool,
322 shallow: bool,
323 previous_root: Option<&str>,
324) -> Result<()> {
325 use hashtree_blossom::BlossomClient;
326 use nostr::Keys;
327
328 if shallow && previous_root.is_some() {
329 anyhow::bail!("a DAG delta cannot be combined with a shallow push");
330 }
331
332 let (nsec_str, _) = ensure_keys_string()?;
333 let keys = Keys::parse(&nsec_str).context("Failed to parse nsec")?;
334
335 let client = if let Some(server) = server_override {
336 BlossomClient::new(keys).with_write_servers(vec![server])
337 } else {
338 BlossomClient::new(keys)
339 };
340
341 if client.write_servers().is_empty() {
342 anyhow::bail!(
343 "No file servers configured. Use --server or add write_servers to config.toml"
344 );
345 }
346
347 let store = Arc::new(HashtreeStore::new(data_dir)?);
348 let fetcher = Arc::new(Fetcher::new(FetchConfig::default()));
349
350 let root_cid = parse_root_cid(cid_str)?;
351 let cids_to_push = if shallow {
352 ensure_local_blob_for_push(store.as_ref(), Some(fetcher.as_ref()), &root_cid).await?;
353 vec![root_cid]
354 } else if let Some(previous_root) = previous_root {
355 let previous = parse_root_cid(previous_root)?;
356 ensure_local_blob_for_push(store.as_ref(), Some(fetcher.as_ref()), &previous).await?;
357 println!("Collecting blocks changed since the retained previous root...");
358 collect_incremental_cids(
361 store.as_ref(),
362 root_cid,
363 previous,
364 Some(fetcher.as_ref()),
365 true,
366 )
367 .await?
368 } else {
369 println!("Collecting blocks...");
370 collect_cids_for_push(store.as_ref(), root_cid, Some(fetcher.as_ref())).await?
371 };
372
373 println!("Found {} blocks to push", cids_to_push.len());
374 let (uploaded, skipped) =
375 upload_cids_with_client(store, Some(fetcher), client, cids_to_push, force_upload).await?;
376
377 println!("\nUploaded: {uploaded}, Skipped: {skipped}, Errors: 0");
378 println!("Done!");
379 Ok(())
380}
381
382pub async fn background_blossom_push(
384 data_dir: &Path,
385 cid_str: &str,
386 servers: &[String],
387) -> Result<()> {
388 let store = Arc::new(HashtreeStore::new(data_dir)?);
389 background_blossom_push_with_store(store, cid_str, servers).await
390}
391
392pub async fn background_blossom_push_with_store(
393 store: Arc<HashtreeStore>,
394 cid_str: &str,
395 servers: &[String],
396) -> Result<()> {
397 let root_cid = parse_root_cid(cid_str)?;
398 background_blossom_push_incremental_with_store(store, root_cid, None, servers).await
399}
400
401pub async fn background_blossom_push_incremental_with_store(
402 store: Arc<HashtreeStore>,
403 root_cid: Cid,
404 previous_root_cid: Option<Cid>,
405 servers: &[String],
406) -> Result<()> {
407 use hashtree_blossom::BlossomClient;
408 use nostr::Keys;
409
410 let (nsec_str, _) = ensure_keys_string()?;
411 let keys = Keys::parse(&nsec_str).context("Failed to parse nsec")?;
412
413 let fetcher = Arc::new(Fetcher::new(FetchConfig::default()));
414 let cids_to_push = if let Some(previous_root_cid) = previous_root_cid.as_ref() {
415 println!("Collecting bounded DAG diff for file-server push...");
416 match collect_incremental_cids_for_push(
417 store.as_ref(),
418 root_cid.clone(),
419 previous_root_cid.clone(),
420 Some(fetcher.as_ref()),
421 )
422 .await
423 {
424 Ok(cids) => cids,
425 Err(err) => {
426 tracing::warn!(
427 "Blossom DAG diff failed; falling back to full push: {}",
428 err
429 );
430 collect_cids_for_push(store.as_ref(), root_cid, Some(fetcher.as_ref())).await?
431 }
432 }
433 } else {
434 println!("Collecting DAG for file-server push...");
435 collect_cids_for_push(store.as_ref(), root_cid, Some(fetcher.as_ref())).await?
436 };
437
438 if cids_to_push.is_empty() {
439 return Ok(());
440 }
441
442 let client = if servers.is_empty() {
443 BlossomClient::new(keys)
444 } else {
445 BlossomClient::new(keys).with_write_servers(servers.to_vec())
446 };
447 upload_cids_with_client(store, Some(fetcher), client, cids_to_push, false).await?;
448 Ok(())
449}
450
451#[cfg(test)]
452mod tests {
453 use super::{collect_cids_for_push, collect_incremental_cids_for_push};
454 use crate::HashtreeStore;
455 use futures::executor::block_on as sync_block_on;
456 use hashtree_core::{DirEntry, HashTree, HashTreeConfig, LinkType};
457 use tempfile::tempdir;
458
459 #[tokio::test]
460 async fn push_collectors_accept_tree_shaped_raw_blobs() {
461 let tmp = tempdir().expect("tempdir");
462 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
463 let payload = [0x82, 0xa1, b'l', 0x90, 0xa1, b't', 0x40];
464
465 for encrypted in [false, true] {
466 let mut config = HashTreeConfig::new(store.store_arc());
467 if !encrypted {
468 config = config.public();
469 }
470 let tree = HashTree::new(config);
471 let old_root = tree.put_directory(vec![]).await.expect("old root");
472 let (blob, size) = tree.put(&payload).await.expect("raw blob");
473 let root = tree
474 .put_directory(vec![
475 DirEntry::from_cid("raw.msgpack", &blob).with_size(size)
476 ])
477 .await
478 .expect("root");
479
480 for previous in [None, Some(old_root)] {
481 let cids = match previous {
482 Some(previous) => {
483 collect_incremental_cids_for_push(&store, root.clone(), previous, None)
484 .await
485 }
486 None => collect_cids_for_push(&store, root.clone(), None).await,
487 }
488 .expect("raw bytes must not be decoded as a structural node");
489 let hashes = cids
490 .iter()
491 .map(|cid| cid.hash)
492 .collect::<std::collections::HashSet<_>>();
493 assert_eq!(
494 hashes,
495 std::collections::HashSet::from([root.hash, blob.hash])
496 );
497 }
498 }
499 }
500
501 #[tokio::test]
502 async fn push_collectors_walk_mixed_chunk_sizes_and_reject_missing_chunks() {
503 let tmp = tempdir().expect("tempdir");
504 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
505 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()));
506 let old_root = tree.put_directory(vec![]).await.expect("old root");
507 let mut entries = Vec::new();
508 let mut required_hashes = std::collections::HashSet::new();
509 let mut missing_chunk = None;
510
511 for chunk_size in [64 * 1024, hashtree_core::DEFAULT_CHUNK_SIZE] {
512 let chunk_tree = HashTree::new(
513 HashTreeConfig::new(store.store_arc())
514 .with_chunk_size(chunk_size)
515 .with_max_links(2),
516 );
517 let data = [
518 vec![1; chunk_size],
519 vec![2; chunk_size],
520 vec![3; chunk_size],
521 vec![4; 7],
522 ]
523 .concat();
524 let (file, size) = chunk_tree.put(&data).await.expect("chunked file");
525 required_hashes.insert(file.hash);
526 for chunk in data.chunks(chunk_size) {
527 let (leaf, _) = chunk_tree.put(chunk).await.expect("leaf");
528 required_hashes.insert(leaf.hash);
529 missing_chunk.get_or_insert(leaf.hash);
530 }
531 entries.push(DirEntry::from_cid(chunk_size.to_string(), &file).with_size(size));
532 }
533 let root = tree.put_directory(entries).await.expect("mixed root");
534 required_hashes.insert(root.hash);
535
536 for previous in [None, Some(old_root.clone())] {
537 let cids = match previous {
538 Some(previous) => {
539 collect_incremental_cids_for_push(&store, root.clone(), previous, None).await
540 }
541 None => collect_cids_for_push(&store, root.clone(), None).await,
542 }
543 .expect("all chunk sizes must be traversed");
544 let hashes = cids
545 .iter()
546 .map(|cid| cid.hash)
547 .collect::<std::collections::HashSet<_>>();
548 assert!(required_hashes.is_subset(&hashes));
549 }
550
551 store
552 .router()
553 .delete_local_only(&missing_chunk.expect("leaf"))
554 .expect("delete chunk");
555 for previous in [None, Some(old_root)] {
556 let result = match previous {
557 Some(previous) => {
558 collect_incremental_cids_for_push(&store, root.clone(), previous, None).await
559 }
560 None => collect_cids_for_push(&store, root.clone(), None).await,
561 };
562 assert!(result
563 .expect_err("missing chunk must fail")
564 .to_string()
565 .contains("missing local blob"));
566 }
567 }
568
569 #[tokio::test]
570 async fn push_collectors_reject_invalid_structural_nodes() {
571 let tmp = tempdir().expect("tempdir");
572 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
573 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()));
574 let old_root = tree.put_directory(vec![]).await.expect("old root");
575 let (invalid, size) = tree
576 .put(&[0x82, 0xa1, b'l', 0x90, 0xa1, b't', 0x40])
577 .await
578 .expect("invalid node");
579
580 for kind in [LinkType::Dir, LinkType::File, LinkType::Fanout] {
581 let root = tree
582 .put_directory(vec![DirEntry::from_cid("invalid", &invalid)
583 .with_size(size)
584 .with_link_type(kind)])
585 .await
586 .expect("root");
587 for previous in [None, Some(old_root.clone())] {
588 let result = match previous {
589 Some(previous) => {
590 collect_incremental_cids_for_push(&store, root.clone(), previous, None)
591 .await
592 }
593 None => collect_cids_for_push(&store, root.clone(), None).await,
594 };
595 assert!(result
596 .expect_err("invalid structural node must fail")
597 .to_string()
598 .contains("Invalid node type: 64"));
599 }
600 }
601 }
602
603 #[tokio::test]
604 async fn push_collectors_expand_nodes_also_referenced_as_raw_blobs() {
605 let tmp = tempdir().expect("tempdir");
606 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
607 for encrypted in [false, true] {
608 let mut config = HashTreeConfig::new(store.store_arc());
609 if !encrypted {
610 config = config.public();
611 }
612 let chunk_tree = HashTree::new(HashTreeConfig {
613 chunk_size: 4,
614 encrypted,
615 ..HashTreeConfig::new(store.store_arc())
616 });
617 let tree = HashTree::new(config);
618 let (file, size) = chunk_tree.put(b"multiple chunks").await.expect("file");
619 let file_node = tree
620 .get_node(&file)
621 .await
622 .expect("file node")
623 .expect("node");
624 let encoded = tree
625 .get_blob(&file.hash)
626 .await
627 .expect("encoded file")
628 .expect("bytes");
629 let encoded = match file.key.as_ref() {
630 Some(key) => {
631 hashtree_core::crypto::decrypt_chk(&encoded, key).expect("decrypt file")
632 }
633 None => encoded,
634 };
635 let (raw, raw_size) = tree.put(&encoded).await.expect("raw encoded node");
636 assert_eq!(file, raw, "both interpretations must share the same CID");
637 let old_root = tree.put_directory(vec![]).await.expect("old root");
638 let raw_only_root = tree
639 .put_directory(vec![
640 DirEntry::from_cid("a-file", &raw).with_size(raw_size),
641 DirEntry::from_cid("z-raw", &raw).with_size(raw_size),
642 ])
643 .await
644 .expect("raw-only root");
645 let root = tree
646 .put_directory(vec![
647 DirEntry::from_cid("a-file", &file)
648 .with_size(size)
649 .with_link_type(LinkType::File),
650 DirEntry::from_cid("z-raw", &raw).with_size(raw_size),
651 ])
652 .await
653 .expect("root");
654 let mut expected = std::collections::HashSet::from([root.hash, file.hash]);
655 expected.extend(file_node.links.iter().map(|link| link.hash));
656
657 for previous in [None, Some(old_root), Some(raw_only_root)] {
658 let cids = match previous {
659 Some(previous) => {
660 collect_incremental_cids_for_push(&store, root.clone(), previous, None)
661 .await
662 }
663 None => collect_cids_for_push(&store, root.clone(), None).await,
664 }
665 .expect("structural reference must still expand after raw reference");
666 let hashes = cids
667 .iter()
668 .map(|cid| cid.hash)
669 .collect::<std::collections::HashSet<_>>();
670 assert_eq!(hashes, expected);
671 assert_eq!(
672 cids.len(),
673 hashes.len(),
674 "upload each shared block only once"
675 );
676 }
677 }
678 }
679
680 #[tokio::test]
681 async fn incremental_push_does_not_prune_children_of_old_raw_blobs() {
682 let tmp = tempdir().expect("tempdir");
683 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
684 for encrypted in [false, true] {
685 let config = || HashTreeConfig {
686 encrypted,
687 ..HashTreeConfig::new(store.store_arc())
688 };
689 let tree = HashTree::new(config());
690 let chunk_tree = HashTree::new(config().with_chunk_size(4));
691 let (shared, _) = chunk_tree.put(b"abcd").await.expect("shared leaf");
692 let (file, _) = chunk_tree.put(b"abcdabcd").await.expect("file node");
693 let encoded = tree
694 .get_blob(&file.hash)
695 .await
696 .expect("encoded node")
697 .expect("bytes");
698 let encoded = match file.key.as_ref() {
699 Some(key) => {
700 hashtree_core::crypto::decrypt_chk(&encoded, key).expect("decrypt node")
701 }
702 None => encoded,
703 };
704 let (raw, size) = tree.put(&encoded).await.expect("literal node bytes");
705 let old_root = tree
706 .put_directory(vec![DirEntry::from_cid("file", &raw).with_size(size)])
707 .await
708 .expect("old root");
709 let old_cids = collect_cids_for_push(&store, old_root.clone(), None)
710 .await
711 .expect("raw-only closure");
712 assert!(!old_cids.iter().any(|cid| cid.hash == shared.hash));
713
714 let mut data = vec![b'x'; size as usize];
715 data[..4].copy_from_slice(b"abcd");
716 let (new_file, new_size) = chunk_tree.put(&data).await.expect("new chunked file");
717 assert_eq!(new_size, size);
718 assert_ne!(new_file.hash, raw.hash);
719 let new_root = tree
720 .put_directory(vec![
721 DirEntry::from_cid("file", &new_file).with_size(new_size)
722 ])
723 .await
724 .expect("new root");
725 let cids = collect_incremental_cids_for_push(&store, new_root, old_root, None)
726 .await
727 .expect("changed closure");
728 assert!(
729 cids.iter().any(|cid| cid.hash == shared.hash),
730 "old raw bytes provide no descendant coverage"
731 );
732 }
733 }
734
735 #[tokio::test]
736 async fn failed_http_uploads_return_an_error() {
737 let tmp = tempdir().expect("tempdir");
738 let store = std::sync::Arc::new(
739 HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store"),
740 );
741 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()));
742 let (cid, _) = tree.put(b"upload rejection fixture").await.expect("blob");
743 let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
744 let observed_requests = requests.clone();
745 let app = axum::Router::new().route(
746 "/upload",
747 axum::routing::put(move |body: bytes::Bytes| async move {
748 assert!(!body.is_empty());
749 observed_requests.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
750 axum::http::StatusCode::BAD_REQUEST
751 }),
752 );
753 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
754 .await
755 .expect("bind");
756 let address = listener.local_addr().expect("address");
757 let server = tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
758 let client = hashtree_blossom::BlossomClient::new_empty(nostr::Keys::generate())
759 .with_write_servers(vec![format!("http://{address}")]);
760
761 for force_upload in [false, true] {
762 let result = super::upload_cids_with_client(
763 store.clone(),
764 None,
765 client.clone(),
766 vec![cid.clone()],
767 force_upload,
768 )
769 .await;
770 assert!(result
771 .expect_err("failed uploads must not report success")
772 .to_string()
773 .contains("failed to upload 1 blob(s)"));
774 }
775 assert_eq!(requests.load(std::sync::atomic::Ordering::Relaxed), 2);
776 server.abort();
777 let _ = server.await;
778 }
779
780 #[tokio::test]
781 async fn collect_cids_for_push_fails_on_missing_descendant_blob() {
782 let tmp = tempdir().expect("tempdir");
783 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
784 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
785
786 let root = sync_block_on(async {
787 let (file_cid, _size) = tree.put_file(b"hello").await.expect("file");
788 tree.put_directory(vec![hashtree_core::DirEntry::from_cid(
789 "greeting.txt",
790 &file_cid,
791 )])
792 .await
793 .expect("dir")
794 });
795
796 let entries = store
797 .get_tree_node(&root.hash)
798 .expect("root node")
799 .expect("root node present")
800 .links;
801 let child_hash = entries[0].hash;
802 store
803 .router()
804 .delete_local_only(&child_hash)
805 .expect("delete child locally");
806
807 let err = collect_cids_for_push(&store, root, None)
808 .await
809 .expect_err("missing child should fail");
810 assert!(err
811 .to_string()
812 .contains("missing local blob while pushing DAG"));
813 }
814
815 #[tokio::test]
816 async fn explicit_delta_fails_when_a_previous_comparison_subtree_is_missing() {
817 let tmp = tempdir().expect("tempdir");
818 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
819 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
820 let old_child = tree.put_directory(vec![]).await.expect("old child");
821 let old_root = tree
822 .put_directory(vec![
823 DirEntry::from_cid("subtree", &old_child).with_link_type(LinkType::Dir)
824 ])
825 .await
826 .expect("old root");
827 let leaf = tree.put_blob(b"new event").await.expect("leaf");
828 let new_child = tree
829 .put_directory(vec![DirEntry::new("event", leaf).with_size(9)])
830 .await
831 .expect("new child");
832 let new_root = tree
833 .put_directory(vec![
834 DirEntry::from_cid("subtree", &new_child).with_link_type(LinkType::Dir)
835 ])
836 .await
837 .expect("new root");
838 store
839 .router()
840 .delete_local_only(&old_child.hash)
841 .expect("remove old subtree");
842
843 let error =
844 super::collect_incremental_cids(&store, new_root.clone(), old_root.clone(), None, true)
845 .await
846 .expect_err("explicit deltas must not silently expand to a full traversal");
847 assert!(error.to_string().contains("previous DAG is unavailable"));
848 let fallback = collect_incremental_cids_for_push(&store, new_root, old_root, None)
849 .await
850 .expect("existing background behavior remains available");
851 assert_eq!(fallback.len(), 3);
852 }
853
854 #[tokio::test]
855 async fn incremental_push_collects_only_changed_named_subtrees() {
856 let tmp = tempdir().expect("tempdir");
857 let store = HashtreeStore::with_options(tmp.path(), None, 32 * 1024 * 1024).expect("store");
858 let tree = HashTree::new(HashTreeConfig::new(store.store_arc()).public());
859
860 let stable_file = tree.put_blob(b"stable").await.expect("stable file");
861 let old_changed_file = tree.put_blob(b"old").await.expect("old file");
862 let old_subdir = tree
863 .put_directory(vec![
864 DirEntry::new("changed.txt", old_changed_file).with_size(3),
865 DirEntry::new("stable.txt", stable_file).with_size(6),
866 ])
867 .await
868 .expect("old subdir");
869 let old_root = tree
870 .put_directory(vec![
871 DirEntry::new("subdir", old_subdir.hash).with_link_type(LinkType::Dir),
872 DirEntry::new("stable-root.txt", stable_file).with_size(6),
873 ])
874 .await
875 .expect("old root");
876
877 let new_changed_file = tree.put_blob(b"new").await.expect("new file");
878 let new_subdir = tree
879 .put_directory(vec![
880 DirEntry::new("stable.txt", stable_file).with_size(6),
881 DirEntry::new("changed.txt", new_changed_file).with_size(3),
882 ])
883 .await
884 .expect("new subdir");
885 let new_root = tree
886 .put_directory(vec![
887 DirEntry::new("stable-root.txt", stable_file).with_size(6),
888 DirEntry::new("subdir", new_subdir.hash).with_link_type(LinkType::Dir),
889 ])
890 .await
891 .expect("new root");
892
893 let cids = collect_incremental_cids_for_push(&store, new_root.clone(), old_root, None)
894 .await
895 .expect("incremental cids");
896 let hashes = cids.iter().map(|cid| cid.hash).collect::<Vec<_>>();
897
898 assert_eq!(hashes.len(), 3);
899 assert!(hashes.contains(&new_root.hash));
900 assert!(hashes.contains(&new_subdir.hash));
901 assert!(hashes.contains(&new_changed_file));
902 assert!(!hashes.contains(&stable_file));
903 }
904}