Skip to main content

hashtree_cli/
blossom_push.rs

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            // Match the core file reader: a Blob's declared size describes its
72            // plaintext leaf, regardless of the writer's chunk size. Authenticate
73            // keyed bytes before treating tree-shaped file contents as a leaf.
74            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    // A shared CID can be both literal file bytes and a structural node.
98    // Traverse each interpretation, but upload each block only once.
99    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
296/// Push content to Blossom servers.
297pub 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
343/// Push tree to Blossom servers using BlossomClient.
344pub 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}