Skip to main content

hashtree_core/
builder.rs

1//! Tree builder with chunking and fanout support
2//!
3//! - Large files are split into chunks
4//! - Large directories are split into sub-trees
5//! - Supports streaming appends
6//! - Encryption enabled by default (CHK - Content Hash Key)
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use crate::codec::encode_and_hash;
12use crate::hash::sha256;
13use crate::store::Store;
14use crate::types::{Cid, DirEntry, Hash, Link, LinkType, TreeNode};
15
16use crate::crypto::{encrypt_chk, EncryptionKey};
17
18/// Default chunk size: 2MB (optimized for blossom uploads, matches hashtree-ts)
19pub const DEFAULT_CHUNK_SIZE: usize = 2 * 1024 * 1024;
20
21/// BEP52 chunk size: 16KB
22pub const BEP52_CHUNK_SIZE: usize = 16 * 1024;
23
24/// Default max links per tree node (fanout)
25pub const DEFAULT_MAX_LINKS: usize = 174;
26
27#[derive(Clone)]
28struct DirectoryFanoutSpan {
29    link: Link,
30    count: usize,
31    first: String,
32    last: String,
33}
34
35fn directory_fanout_meta(
36    count: usize,
37    first: &str,
38    last: &str,
39) -> HashMap<String, serde_json::Value> {
40    let mut meta = HashMap::new();
41    meta.insert("count".to_string(), serde_json::json!(count as u64));
42    meta.insert("first".to_string(), serde_json::json!(first));
43    meta.insert("last".to_string(), serde_json::json!(last));
44    meta
45}
46
47/// Builder configuration
48#[derive(Clone)]
49pub struct BuilderConfig<S: Store> {
50    pub store: Arc<S>,
51    pub chunk_size: usize,
52    pub max_links: usize,
53    /// Whether to encrypt content (default: true when encryption feature enabled)
54    pub encrypted: bool,
55}
56
57impl<S: Store> BuilderConfig<S> {
58    pub fn new(store: Arc<S>) -> Self {
59        Self {
60            store,
61            chunk_size: DEFAULT_CHUNK_SIZE,
62            max_links: DEFAULT_MAX_LINKS,
63            encrypted: true,
64        }
65    }
66
67    pub fn with_chunk_size(mut self, chunk_size: usize) -> Self {
68        self.chunk_size = chunk_size;
69        self
70    }
71
72    pub fn with_max_links(mut self, max_links: usize) -> Self {
73        self.max_links = max_links;
74        self
75    }
76
77    /// Disable encryption (store content publicly)
78    pub fn public(mut self) -> Self {
79        self.encrypted = false;
80        self
81    }
82
83    /// Enable encryption (CHK - Content Hash Key)
84    pub fn encrypted(mut self) -> Self {
85        self.encrypted = true;
86        self
87    }
88}
89
90/// TreeBuilder - builds content-addressed merkle trees
91pub struct TreeBuilder<S: Store> {
92    store: Arc<S>,
93    chunk_size: usize,
94    max_links: usize,
95    encrypted: bool,
96}
97
98impl<S: Store> TreeBuilder<S> {
99    pub fn new(config: BuilderConfig<S>) -> Self {
100        Self {
101            store: config.store,
102            chunk_size: config.chunk_size,
103            max_links: config.max_links,
104            encrypted: config.encrypted,
105        }
106    }
107
108    /// Check if encryption is enabled
109    pub fn is_encrypted(&self) -> bool {
110        self.encrypted
111    }
112
113    /// Store a blob directly (small data, no encryption)
114    /// Returns the content hash
115    pub async fn put_blob(&self, data: &[u8]) -> Result<Hash, BuilderError> {
116        let hash = sha256(data);
117        self.store
118            .put(hash, data.to_vec())
119            .await
120            .map_err(|e| BuilderError::Store(e.to_string()))?;
121        Ok(hash)
122    }
123
124    /// Store a chunk with optional encryption
125    /// Returns (hash, optional_key) where hash is of stored data
126    async fn put_chunk_internal(
127        &self,
128        data: &[u8],
129    ) -> Result<(Hash, Option<EncryptionKey>), BuilderError> {
130        if self.encrypted {
131            let (encrypted, key) =
132                encrypt_chk(data).map_err(|e| BuilderError::Encryption(e.to_string()))?;
133            let hash = sha256(&encrypted);
134            self.store
135                .put(hash, encrypted)
136                .await
137                .map_err(|e| BuilderError::Store(e.to_string()))?;
138            Ok((hash, Some(key)))
139        } else {
140            let hash = self.put_blob(data).await?;
141            Ok((hash, None))
142        }
143    }
144
145    /// Store a file, chunking if necessary
146    /// Returns (Cid, size) where Cid contains hash and optional encryption key
147    ///
148    /// When encryption is enabled (default), each chunk is CHK encrypted
149    /// and the result contains the decryption key.
150    pub async fn put(&self, data: &[u8]) -> Result<(Cid, u64), BuilderError> {
151        let size = data.len() as u64;
152
153        // Small file - store as single chunk
154        if data.len() <= self.chunk_size {
155            let (hash, key) = self.put_chunk_internal(data).await?;
156            return Ok((Cid { hash, key }, size));
157        }
158
159        // Large file - chunk it
160        let mut links: Vec<Link> = Vec::new();
161        let mut offset = 0;
162
163        while offset < data.len() {
164            let end = (offset + self.chunk_size).min(data.len());
165            let chunk = &data[offset..end];
166            let chunk_size = chunk.len() as u64;
167            let (hash, key) = self.put_chunk_internal(chunk).await?;
168            links.push(Link {
169                hash,
170                name: None,
171                size: chunk_size,
172                key,
173                link_type: LinkType::Blob, // leaf chunk
174                meta: None,
175            });
176            offset = end;
177        }
178
179        // Build tree from chunks
180        let (root_hash, root_key, _) = self.build_tree_internal(links, Some(size)).await?;
181
182        Ok((
183            Cid {
184                hash: root_hash,
185                key: root_key,
186            },
187            size,
188        ))
189    }
190
191    /// Build tree and return (hash, optional_key)
192    /// When encrypted, tree nodes are also CHK encrypted
193    async fn build_tree_internal(
194        &self,
195        links: Vec<Link>,
196        total_size: Option<u64>,
197    ) -> Result<(Hash, Option<[u8; 32]>, LinkType), BuilderError> {
198        // Single link with matching size - return directly
199        if links.len() == 1 {
200            if let Some(ts) = total_size {
201                if links[0].size == ts {
202                    return Ok((links[0].hash, links[0].key, links[0].link_type));
203                }
204            }
205        }
206
207        if links.len() <= self.max_links {
208            let node = TreeNode {
209                node_type: LinkType::File,
210                links,
211            };
212            let (data, _) = encode_and_hash(&node)?;
213
214            if self.encrypted {
215                let (encrypted, key) =
216                    encrypt_chk(&data).map_err(|e| BuilderError::Encryption(e.to_string()))?;
217                let hash = sha256(&encrypted);
218                self.store
219                    .put(hash, encrypted)
220                    .await
221                    .map_err(|e| BuilderError::Store(e.to_string()))?;
222                return Ok((hash, Some(key), LinkType::File));
223            }
224
225            // Unencrypted path
226            let hash = sha256(&data);
227            self.store
228                .put(hash, data)
229                .await
230                .map_err(|e| BuilderError::Store(e.to_string()))?;
231            return Ok((hash, None, LinkType::File));
232        }
233
234        // Too many links - create subtrees
235        let mut sub_links = Vec::new();
236        for batch in links.chunks(self.max_links) {
237            let batch_size: u64 = batch.iter().map(|l| l.size).sum();
238            let (hash, key, link_type) =
239                Box::pin(self.build_tree_internal(batch.to_vec(), Some(batch_size))).await?;
240            sub_links.push(Link {
241                hash,
242                name: None,
243                size: batch_size,
244                key,
245                link_type,
246                meta: None,
247            });
248        }
249
250        Box::pin(self.build_tree_internal(sub_links, total_size)).await
251    }
252
253    /// Build a balanced tree from links
254    /// Handles fanout by creating intermediate nodes
255    #[allow(dead_code)]
256    async fn build_tree(
257        &self,
258        links: Vec<Link>,
259        total_size: Option<u64>,
260    ) -> Result<Hash, BuilderError> {
261        // Single link with matching size - return it directly
262        if links.len() == 1 {
263            if let Some(ts) = total_size {
264                if links[0].size == ts {
265                    return Ok(links[0].hash);
266                }
267            }
268        }
269
270        // Fits in one node
271        if links.len() <= self.max_links {
272            let node = TreeNode {
273                node_type: LinkType::File,
274                links,
275            };
276            let (data, hash) = encode_and_hash(&node)?;
277            self.store
278                .put(hash, data)
279                .await
280                .map_err(|e| BuilderError::Store(e.to_string()))?;
281            return Ok(hash);
282        }
283
284        // Need to split into sub-trees
285        let mut sub_trees: Vec<Link> = Vec::new();
286
287        for batch in links.chunks(self.max_links) {
288            let batch_size: u64 = batch.iter().map(|l| l.size).sum();
289
290            let node = TreeNode {
291                node_type: LinkType::File,
292                links: batch.to_vec(),
293            };
294            let (data, hash) = encode_and_hash(&node)?;
295            self.store
296                .put(hash, data)
297                .await
298                .map_err(|e| BuilderError::Store(e.to_string()))?;
299
300            sub_trees.push(Link {
301                hash,
302                name: None,
303                size: batch_size,
304                key: None,
305                link_type: LinkType::File, // subtree
306                meta: None,
307            });
308        }
309
310        // Recursively build parent level
311        Box::pin(self.build_tree(sub_trees, total_size)).await
312    }
313
314    /// Build a directory from entries
315    /// Entries can be files or subdirectories
316    pub async fn put_directory(&self, entries: Vec<DirEntry>) -> Result<Hash, BuilderError> {
317        // Sort entries by name for deterministic hashing
318        let mut sorted = entries;
319        sorted.sort_by(|a, b| a.name.cmp(&b.name));
320
321        let links: Vec<Link> = sorted
322            .into_iter()
323            .map(|e| Link {
324                hash: e.hash,
325                name: Some(e.name),
326                size: e.size,
327                key: e.key,
328                link_type: e.link_type,
329                meta: e.meta,
330            })
331            .collect();
332
333        // Fits in one node
334        if links.len() <= self.max_links {
335            let node = TreeNode {
336                node_type: LinkType::Dir,
337                links,
338            };
339            let (data, hash) = encode_and_hash(&node)?;
340            self.store
341                .put(hash, data)
342                .await
343                .map_err(|e| BuilderError::Store(e.to_string()))?;
344            return Ok(hash);
345        }
346
347        self.build_directory_by_chunks(links).await
348    }
349
350    /// Split directories into canonical BUD-17 fanout nodes.
351    async fn build_directory_by_chunks(&self, links: Vec<Link>) -> Result<Hash, BuilderError> {
352        let spans = links
353            .into_iter()
354            .map(|link| {
355                let name = link
356                    .name
357                    .clone()
358                    .unwrap_or_else(|| crate::types::to_hex(&link.hash));
359                DirectoryFanoutSpan {
360                    link,
361                    count: 1,
362                    first: name.clone(),
363                    last: name,
364                }
365            })
366            .collect();
367        self.build_directory_fanout_level(spans, LinkType::Dir)
368            .await
369    }
370
371    async fn build_directory_fanout_level(
372        &self,
373        spans: Vec<DirectoryFanoutSpan>,
374        child_node_type: LinkType,
375    ) -> Result<Hash, BuilderError> {
376        let mut sub_trees: Vec<DirectoryFanoutSpan> = Vec::new();
377
378        for batch in spans.chunks(self.max_links) {
379            let Some(first_span) = batch.first() else {
380                continue;
381            };
382            let last_span = batch.last().expect("non-empty fanout batch");
383            let count: usize = batch.iter().map(|span| span.count).sum();
384            let batch_size: u64 = batch.iter().map(|span| span.link.size).sum();
385
386            let node = TreeNode {
387                node_type: child_node_type,
388                links: batch.iter().map(|span| span.link.clone()).collect(),
389            };
390            let (data, hash) = encode_and_hash(&node)?;
391            self.store
392                .put(hash, data)
393                .await
394                .map_err(|e| BuilderError::Store(e.to_string()))?;
395
396            sub_trees.push(DirectoryFanoutSpan {
397                link: Link {
398                    hash,
399                    name: None,
400                    size: batch_size,
401                    key: None,
402                    link_type: child_node_type,
403                    meta: Some(directory_fanout_meta(
404                        count,
405                        &first_span.first,
406                        &last_span.last,
407                    )),
408                },
409                count,
410                first: first_span.first.clone(),
411                last: last_span.last.clone(),
412            });
413        }
414
415        if sub_trees.len() <= self.max_links {
416            let node = TreeNode {
417                node_type: LinkType::Fanout,
418                links: sub_trees.into_iter().map(|span| span.link).collect(),
419            };
420            let (data, hash) = encode_and_hash(&node)?;
421            self.store
422                .put(hash, data)
423                .await
424                .map_err(|e| BuilderError::Store(e.to_string()))?;
425            return Ok(hash);
426        }
427
428        // Recursively build more levels
429        Box::pin(self.build_directory_fanout_level(sub_trees, LinkType::Fanout)).await
430    }
431
432    /// Create a tree node
433    pub async fn put_tree_node(&self, links: Vec<Link>) -> Result<Hash, BuilderError> {
434        let node = TreeNode {
435            node_type: LinkType::Dir,
436            links,
437        };
438
439        let (data, hash) = encode_and_hash(&node)?;
440        self.store
441            .put(hash, data)
442            .await
443            .map_err(|e| BuilderError::Store(e.to_string()))?;
444        Ok(hash)
445    }
446}
447
448/// StreamBuilder - supports incremental appends
449pub struct StreamBuilder<S: Store> {
450    store: Arc<S>,
451    chunk_size: usize,
452    max_links: usize,
453
454    // Current partial chunk being built
455    buffer: Vec<u8>,
456
457    // Completed chunks
458    chunks: Vec<Link>,
459    total_size: u64,
460}
461
462impl<S: Store> StreamBuilder<S> {
463    pub fn new(config: BuilderConfig<S>) -> Self {
464        Self {
465            store: config.store,
466            chunk_size: config.chunk_size,
467            max_links: config.max_links,
468            buffer: Vec::with_capacity(config.chunk_size),
469            chunks: Vec::new(),
470            total_size: 0,
471        }
472    }
473
474    /// Append data to the stream
475    pub async fn append(&mut self, data: &[u8]) -> Result<(), BuilderError> {
476        let mut offset = 0;
477
478        while offset < data.len() {
479            let space = self.chunk_size - self.buffer.len();
480            let to_write = space.min(data.len() - offset);
481
482            self.buffer
483                .extend_from_slice(&data[offset..offset + to_write]);
484            offset += to_write;
485
486            // Flush full chunk
487            if self.buffer.len() == self.chunk_size {
488                self.flush_chunk().await?;
489            }
490        }
491
492        self.total_size += data.len() as u64;
493        Ok(())
494    }
495
496    /// Flush current buffer as a chunk
497    async fn flush_chunk(&mut self) -> Result<(), BuilderError> {
498        if self.buffer.is_empty() {
499            return Ok(());
500        }
501
502        let chunk = std::mem::take(&mut self.buffer);
503        let hash = sha256(&chunk);
504        self.store
505            .put(hash, chunk.clone())
506            .await
507            .map_err(|e| BuilderError::Store(e.to_string()))?;
508
509        self.chunks.push(Link {
510            hash,
511            name: None,
512            size: chunk.len() as u64,
513            key: None,
514            link_type: LinkType::Blob, // Leaf chunk (raw blob)
515            meta: None,
516        });
517
518        self.buffer = Vec::with_capacity(self.chunk_size);
519        Ok(())
520    }
521
522    /// Get current root hash without finalizing
523    /// Useful for checkpoints
524    pub async fn current_root(&mut self) -> Result<Option<Hash>, BuilderError> {
525        if self.chunks.is_empty() && self.buffer.is_empty() {
526            return Ok(None);
527        }
528
529        // Temporarily include buffer
530        let mut temp_chunks = self.chunks.clone();
531        if !self.buffer.is_empty() {
532            let chunk = self.buffer.clone();
533            let hash = sha256(&chunk);
534            self.store
535                .put(hash, chunk.clone())
536                .await
537                .map_err(|e| BuilderError::Store(e.to_string()))?;
538            temp_chunks.push(Link {
539                hash,
540                name: None,
541                size: chunk.len() as u64,
542                key: None,
543                link_type: LinkType::Blob, // Leaf chunk (raw blob)
544                meta: None,
545            });
546        }
547
548        let hash = self
549            .build_tree_from_chunks(&temp_chunks, self.total_size)
550            .await?;
551        Ok(Some(hash))
552    }
553
554    /// Finalize the stream and return root hash
555    pub async fn finalize(mut self) -> Result<(Hash, u64), BuilderError> {
556        // Flush remaining buffer
557        self.flush_chunk().await?;
558
559        if self.chunks.is_empty() {
560            // Empty stream - return hash of empty data
561            let empty_hash = sha256(&[]);
562            self.store
563                .put(empty_hash, vec![])
564                .await
565                .map_err(|e| BuilderError::Store(e.to_string()))?;
566            return Ok((empty_hash, 0));
567        }
568
569        let hash = self
570            .build_tree_from_chunks(&self.chunks, self.total_size)
571            .await?;
572        Ok((hash, self.total_size))
573    }
574
575    /// Build balanced tree from chunks
576    async fn build_tree_from_chunks(
577        &self,
578        chunks: &[Link],
579        total_size: u64,
580    ) -> Result<Hash, BuilderError> {
581        if chunks.len() == 1 {
582            return Ok(chunks[0].hash);
583        }
584
585        if chunks.len() <= self.max_links {
586            let node = TreeNode {
587                node_type: LinkType::File,
588                links: chunks.to_vec(),
589            };
590            let (data, hash) = encode_and_hash(&node)?;
591            self.store
592                .put(hash, data)
593                .await
594                .map_err(|e| BuilderError::Store(e.to_string()))?;
595            return Ok(hash);
596        }
597
598        // Build intermediate level
599        let mut sub_trees: Vec<Link> = Vec::new();
600        for batch in chunks.chunks(self.max_links) {
601            let batch_size: u64 = batch.iter().map(|l| l.size).sum();
602
603            let node = TreeNode {
604                node_type: LinkType::File,
605                links: batch.to_vec(),
606            };
607            let (data, hash) = encode_and_hash(&node)?;
608            self.store
609                .put(hash, data)
610                .await
611                .map_err(|e| BuilderError::Store(e.to_string()))?;
612
613            sub_trees.push(Link {
614                hash,
615                name: None,
616                size: batch_size,
617                key: None,
618                link_type: LinkType::File, // Internal tree node
619                meta: None,
620            });
621        }
622
623        Box::pin(self.build_tree_from_chunks(&sub_trees, total_size)).await
624    }
625
626    /// Get stats
627    pub fn stats(&self) -> StreamStats {
628        StreamStats {
629            chunks: self.chunks.len(),
630            buffered: self.buffer.len(),
631            total_size: self.total_size,
632        }
633    }
634}
635
636#[derive(Debug, Clone, PartialEq)]
637pub struct StreamStats {
638    pub chunks: usize,
639    pub buffered: usize,
640    pub total_size: u64,
641}
642
643/// Builder error type
644#[derive(Debug, thiserror::Error)]
645pub enum BuilderError {
646    #[error("Store error: {0}")]
647    Store(String),
648    #[error("Codec error: {0}")]
649    Codec(#[from] crate::codec::CodecError),
650    #[error("Encryption error: {0}")]
651    Encryption(String),
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use crate::store::MemoryStore;
658    use crate::types::to_hex;
659    use std::collections::HashMap;
660
661    fn make_store() -> Arc<MemoryStore> {
662        Arc::new(MemoryStore::new())
663    }
664
665    #[tokio::test]
666    async fn test_put_blob() {
667        let store = make_store();
668        let builder = TreeBuilder::new(BuilderConfig::new(store.clone()));
669
670        let data = vec![1u8, 2, 3, 4, 5];
671        let hash = builder.put_blob(&data).await.unwrap();
672
673        assert_eq!(hash.len(), 32);
674        assert!(store.has(&hash).await.unwrap());
675
676        let retrieved = store.get(&hash).await.unwrap();
677        assert_eq!(retrieved, Some(data));
678    }
679
680    #[tokio::test]
681    async fn test_put_blob_correct_hash() {
682        let store = make_store();
683        let builder = TreeBuilder::new(BuilderConfig::new(store));
684
685        let data = vec![1u8, 2, 3];
686        let hash = builder.put_blob(&data).await.unwrap();
687        let expected_hash = sha256(&data);
688
689        assert_eq!(to_hex(&hash), to_hex(&expected_hash));
690    }
691
692    #[tokio::test]
693    async fn test_put_small() {
694        let store = make_store();
695        // Use public() to disable encryption for this test
696        let builder = TreeBuilder::new(BuilderConfig::new(store.clone()).public());
697
698        let data = vec![1u8, 2, 3, 4, 5];
699        let (cid, size) = builder.put(&data).await.unwrap();
700
701        assert_eq!(size, 5);
702        assert!(cid.key.is_none()); // public content
703        let retrieved = store.get(&cid.hash).await.unwrap();
704        assert_eq!(retrieved, Some(data));
705    }
706
707    #[tokio::test]
708    async fn test_put_chunked() {
709        let store = make_store();
710        let config = BuilderConfig::new(store.clone())
711            .with_chunk_size(1024)
712            .public();
713        let builder = TreeBuilder::new(config);
714
715        let mut data = vec![0u8; 1024 * 2 + 100];
716        for i in 0..data.len() {
717            data[i] = (i % 256) as u8;
718        }
719
720        let (_cid, size) = builder.put(&data).await.unwrap();
721        assert_eq!(size, data.len() as u64);
722
723        // Verify store has multiple items (chunks + tree node)
724        assert!(store.size() > 1);
725    }
726
727    #[tokio::test]
728    async fn test_put_directory() {
729        let store = make_store();
730        let builder = TreeBuilder::new(BuilderConfig::new(store.clone()));
731
732        let file1 = vec![1u8, 2, 3];
733        let file2 = vec![4u8, 5, 6, 7];
734
735        let hash1 = builder.put_blob(&file1).await.unwrap();
736        let hash2 = builder.put_blob(&file2).await.unwrap();
737
738        let dir_hash = builder
739            .put_directory(vec![
740                DirEntry::new("a.txt", hash1).with_size(file1.len() as u64),
741                DirEntry::new("b.txt", hash2).with_size(file2.len() as u64),
742            ])
743            .await
744            .unwrap();
745
746        assert!(store.has(&dir_hash).await.unwrap());
747    }
748
749    #[tokio::test]
750    async fn test_put_directory_sorted() {
751        let store = make_store();
752        let builder = TreeBuilder::new(BuilderConfig::new(store.clone()));
753
754        let hash = builder.put_blob(&[1u8]).await.unwrap();
755
756        let dir_hash = builder
757            .put_directory(vec![
758                DirEntry::new("zebra", hash),
759                DirEntry::new("apple", hash),
760                DirEntry::new("mango", hash),
761            ])
762            .await
763            .unwrap();
764
765        let data = store.get(&dir_hash).await.unwrap().unwrap();
766        let node = crate::codec::decode_tree_node(&data).unwrap();
767
768        let names: Vec<_> = node.links.iter().filter_map(|l| l.name.clone()).collect();
769        assert_eq!(names, vec!["apple", "mango", "zebra"]);
770    }
771
772    #[tokio::test]
773    async fn test_put_tree_node_with_link_meta() {
774        let store = make_store();
775        let builder = TreeBuilder::new(BuilderConfig::new(store.clone()));
776
777        let hash = builder.put_blob(&[1u8]).await.unwrap();
778
779        let mut meta = HashMap::new();
780        meta.insert("version".to_string(), serde_json::json!(2));
781        meta.insert("created".to_string(), serde_json::json!("2024-01-01"));
782
783        let node_hash = builder
784            .put_tree_node(vec![Link {
785                hash,
786                name: Some("test".to_string()),
787                size: 1,
788                key: None,
789                link_type: LinkType::Blob,
790                meta: Some(meta.clone()),
791            }])
792            .await
793            .unwrap();
794
795        let data = store.get(&node_hash).await.unwrap().unwrap();
796        let node = crate::codec::decode_tree_node(&data).unwrap();
797
798        assert!(node.links[0].meta.is_some());
799        let m = node.links[0].meta.as_ref().unwrap();
800        assert_eq!(m.get("version"), Some(&serde_json::json!(2)));
801    }
802
803    #[tokio::test]
804    async fn test_stream_builder() {
805        let store = make_store();
806        let config = BuilderConfig::new(store.clone()).with_chunk_size(100);
807        let mut stream = StreamBuilder::new(config);
808
809        stream.append(&[1u8, 2, 3]).await.unwrap();
810        stream.append(&[4u8, 5]).await.unwrap();
811        stream.append(&[6u8, 7, 8, 9]).await.unwrap();
812
813        let (hash, size) = stream.finalize().await.unwrap();
814
815        assert_eq!(size, 9);
816        assert!(store.has(&hash).await.unwrap());
817    }
818
819    #[tokio::test]
820    async fn test_stream_stats() {
821        let store = make_store();
822        let config = BuilderConfig::new(store).with_chunk_size(100);
823        let mut stream = StreamBuilder::new(config);
824
825        assert_eq!(stream.stats().chunks, 0);
826        assert_eq!(stream.stats().buffered, 0);
827        assert_eq!(stream.stats().total_size, 0);
828
829        stream.append(&[0u8; 50]).await.unwrap();
830        assert_eq!(stream.stats().buffered, 50);
831        assert_eq!(stream.stats().total_size, 50);
832
833        stream.append(&[0u8; 60]).await.unwrap(); // Crosses boundary
834        assert_eq!(stream.stats().chunks, 1);
835        assert_eq!(stream.stats().buffered, 10);
836        assert_eq!(stream.stats().total_size, 110);
837    }
838
839    #[tokio::test]
840    async fn test_stream_current_root() {
841        let store = make_store();
842        let config = BuilderConfig::new(store).with_chunk_size(100);
843        let mut stream = StreamBuilder::new(config);
844
845        stream.append(&[1u8, 2, 3]).await.unwrap();
846        let root1 = stream.current_root().await.unwrap();
847
848        stream.append(&[4u8, 5, 6]).await.unwrap();
849        let root2 = stream.current_root().await.unwrap();
850
851        // Roots should be different
852        assert_ne!(to_hex(&root1.unwrap()), to_hex(&root2.unwrap()));
853    }
854
855    #[tokio::test]
856    async fn test_stream_empty() {
857        let store = make_store();
858        let config = BuilderConfig::new(store.clone());
859        let stream = StreamBuilder::new(config);
860
861        let (hash, size) = stream.finalize().await.unwrap();
862        assert_eq!(size, 0);
863        assert!(store.has(&hash).await.unwrap());
864    }
865
866    #[tokio::test]
867    async fn test_unified_put_public() {
868        let store = make_store();
869        // Use .public() to disable encryption
870        let config = BuilderConfig::new(store.clone()).public();
871        let builder = TreeBuilder::new(config);
872
873        let data = b"Hello, World!";
874        let (cid, size) = builder.put(data).await.unwrap();
875
876        assert_eq!(size, data.len() as u64);
877        assert!(cid.key.is_none()); // No encryption key for public content
878        assert!(store.has(&cid.hash).await.unwrap());
879    }
880
881    #[tokio::test]
882    async fn test_unified_put_encrypted() {
883        use crate::reader::TreeReader;
884
885        let store = make_store();
886        // Default config has encryption enabled
887        let config = BuilderConfig::new(store.clone());
888        let builder = TreeBuilder::new(config);
889
890        let data = b"Hello, encrypted world!";
891        let (cid, size) = builder.put(data).await.unwrap();
892
893        assert_eq!(size, data.len() as u64);
894        assert!(cid.key.is_some()); // Has encryption key
895
896        // Verify we can read it back
897        let reader = TreeReader::new(store);
898        let retrieved = reader.get(&cid).await.unwrap().unwrap();
899        assert_eq!(retrieved, data);
900    }
901
902    #[tokio::test]
903    async fn test_unified_put_encrypted_chunked() {
904        use crate::reader::TreeReader;
905
906        let store = make_store();
907        let config = BuilderConfig::new(store.clone()).with_chunk_size(100);
908        let builder = TreeBuilder::new(config);
909
910        // Data larger than chunk size
911        let data: Vec<u8> = (0..500).map(|i| (i % 256) as u8).collect();
912        let (cid, size) = builder.put(&data).await.unwrap();
913
914        assert_eq!(size, data.len() as u64);
915        assert!(cid.key.is_some());
916
917        // Verify roundtrip
918        let reader = TreeReader::new(store);
919        let retrieved = reader.get(&cid).await.unwrap().unwrap();
920        assert_eq!(retrieved, data);
921    }
922
923    #[tokio::test]
924    async fn test_cid_deterministic() {
925        let store = make_store();
926        let config = BuilderConfig::new(store.clone());
927        let builder = TreeBuilder::new(config);
928
929        let data = b"Same content produces same CID";
930
931        let (cid1, _) = builder.put(data).await.unwrap();
932        let (cid2, _) = builder.put(data).await.unwrap();
933
934        // CHK: same content = same hash AND same key
935        assert_eq!(cid1.hash, cid2.hash);
936        assert_eq!(cid1.key, cid2.key);
937        assert_eq!(cid1.to_string(), cid2.to_string());
938    }
939}