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    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
316/// Push content to Blossom servers.
317pub 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        // The caller must already retain the previous DAG on this server. A
359        // collection error aborts this push rather than restarting a full scan.
360        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
382/// Push tree to Blossom servers using BlossomClient.
383pub 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}