1use std::{
2 collections::{HashMap, HashSet, VecDeque},
3 iter::repeat_n,
4 path::{Path, PathBuf},
5 sync::Arc,
6};
7
8use chrono::Utc;
9use futures_core::stream::Stream;
10use futures_util::{StreamExt, stream};
11use sqlx::{
12 Executor, QueryBuilder, Row, Sqlite, SqlitePool, Transaction,
13 sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteRow},
14};
15use tokio::sync::Mutex;
16
17use crate::{
18 codec::{Codec, compress, decompress},
19 error::{Error, Result},
20 hash::Hash32,
21 object::{ObjectKind, ObjectRecord, ObjectSource, VerifiedObject},
22};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum PackJournalMode {
26 Delete,
27 Wal,
28}
29
30impl PackJournalMode {
31 fn to_sqlite(self) -> SqliteJournalMode {
32 match self {
33 PackJournalMode::Delete => SqliteJournalMode::Delete,
34 PackJournalMode::Wal => SqliteJournalMode::Wal,
35 }
36 }
37}
38
39pub type PackWriteTx<'a> = Transaction<'a, Sqlite>;
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct MerkleProof {
43 pub leaf_pos: u64,
44 pub siblings: Vec<Hash32>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct MerkleSummary {
49 pub root: Hash32,
50 pub leaf_count: u64,
51}
52
53#[derive(Default)]
54struct MerkleCache {
55 summary: Option<MerkleSummary>,
56 leaf_pos: HashMap<Hash32, u64>,
57 leaf_hashes: HashMap<u64, Hash32>,
58 node_hashes: HashMap<i64, HashMap<u64, Hash32>>,
59}
60
61const FULL_SCAN_RATIO: f64 = 0.3;
62const SQLITE_PARAM_LIMIT: usize = 999;
63const OBJECT_COLUMNS: usize = 7;
64const MAX_OBJECTS_PER_BATCH: usize = SQLITE_PARAM_LIMIT / OBJECT_COLUMNS;
65const FILE_RECIPE_COLUMNS: usize = 3;
66const MAX_FILE_RECIPE_PER_BATCH: usize = SQLITE_PARAM_LIMIT / FILE_RECIPE_COLUMNS;
67
68#[derive(Clone)]
69pub struct Pack {
70 pool: SqlitePool,
71 path: PathBuf,
72 cache: Arc<Mutex<MerkleCache>>,
73 journal_mode: PackJournalMode,
74}
75
76impl Pack {
77 pub async fn open(path: impl AsRef<Path>) -> Result<Self> {
78 Self::open_with_journal(path, PackJournalMode::Delete).await
79 }
80
81 pub async fn open_with_journal(path: impl AsRef<Path>, journal_mode: PackJournalMode) -> Result<Self> {
82 let path = path.as_ref().to_path_buf();
83 if let Some(parent) = path.parent() {
84 tokio::fs::create_dir_all(parent).await?;
85 }
86 let mut opts = SqliteConnectOptions::new().filename(&path).create_if_missing(true);
87 opts = opts.journal_mode(journal_mode.to_sqlite());
88 opts = opts.vfs(local_vfs_name());
89 let pool = SqlitePool::connect_with(opts).await?;
90 apply_schema(&pool).await?;
91 Ok(Self {
92 pool,
93 path,
94 cache: Arc::new(Mutex::new(MerkleCache::default())),
95 journal_mode,
96 })
97 }
98
99 pub async fn open_readonly(path: impl AsRef<Path>) -> Result<Self> {
100 let path = path.as_ref().to_path_buf();
101 let mut opts = SqliteConnectOptions::new().filename(&path).create_if_missing(false).read_only(true);
102 opts = opts.vfs(local_vfs_name());
103 let pool = SqlitePool::connect_with(opts).await?;
104 Ok(Self {
105 pool,
106 path,
107 cache: Arc::new(Mutex::new(MerkleCache::default())),
108 journal_mode: PackJournalMode::Delete,
109 })
110 }
111
112 pub fn path(&self) -> &Path {
113 &self.path
114 }
115
116 pub async fn close(&self) {
117 if self.journal_mode == PackJournalMode::Wal {
118 let _ = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE);").execute(&self.pool).await;
119 }
120 self.pool.close().await;
121 }
122
123 pub async fn begin_write_tx(&self) -> Result<PackWriteTx<'_>> {
124 Ok(self.pool.begin().await?)
125 }
126
127 pub async fn put_objects_batch_tx(tx: &mut PackWriteTx<'_>, objects: &[ObjectRecord]) -> Result<()> {
128 if objects.is_empty() {
129 return Ok(());
130 }
131 let now = Utc::now().timestamp();
132 for chunk in objects.chunks(MAX_OBJECTS_PER_BATCH.max(1)) {
133 let mut builder = QueryBuilder::new("INSERT OR IGNORE INTO objects (hash, kind, size, stored_size, codec, content, created_at) ");
134 builder.push_values(chunk, |mut b, obj| {
135 b.push_bind(obj.hash.as_bytes().as_ref())
136 .push_bind(obj.kind as i64)
137 .push_bind(obj.decoded_len as i64)
138 .push_bind(obj.stored_bytes.len() as i64)
139 .push_bind(codec_to_str(obj.codec))
140 .push_bind(obj.stored_bytes.as_slice())
141 .push_bind(now);
142 });
143 builder.build().execute(&mut **tx).await?;
144 }
145 Ok(())
146 }
147
148 pub async fn put_file_recipe_cache_batch_tx(tx: &mut PackWriteTx<'_>, entries: &[(Hash32, Hash32)]) -> Result<()> {
149 if entries.is_empty() {
150 return Ok(());
151 }
152 let now = Utc::now().timestamp();
153 for chunk in entries.chunks(MAX_FILE_RECIPE_PER_BATCH.max(1)) {
154 let mut builder = QueryBuilder::new("INSERT INTO file_recipe_cache (file_hash, recipe_hash, updated_at) ");
155 builder.push_values(chunk, |mut b, (file_hash, recipe_hash)| {
156 b.push_bind(file_hash.as_bytes().as_ref())
157 .push_bind(recipe_hash.as_bytes().as_ref())
158 .push_bind(now);
159 });
160 builder.push(" ON CONFLICT(file_hash) DO UPDATE SET recipe_hash=excluded.recipe_hash, updated_at=excluded.updated_at");
161 builder.build().execute(&mut **tx).await?;
162 }
163 Ok(())
164 }
165
166 pub async fn put_chunk(&self, hash: Hash32, content: &[u8], codec: Codec) -> Result<()> {
167 self.put_object(hash, ObjectKind::Chunk, content, codec).await
168 }
169
170 pub async fn put_recipe(&self, hash: Hash32, content: &[u8], codec: Codec) -> Result<()> {
171 self.put_object(hash, ObjectKind::Recipe, content, codec).await
172 }
173
174 async fn put_object(&self, hash: Hash32, kind: ObjectKind, content: &[u8], codec: Codec) -> Result<()> {
175 let calc = Hash32::sha3_256(content);
176 if calc != hash {
177 return Err(Error::HashMismatch);
178 }
179
180 let size = content.len() as i64;
181 let mut final_codec = codec;
182 let compressed = if codec == Codec::Raw {
183 content.to_vec()
184 } else {
185 let candidate = compress(codec, content)?;
186 let ratio = candidate.len() as f64 / size.max(1) as f64;
187 if ratio >= 0.98 {
188 final_codec = Codec::Raw;
189 content.to_vec()
190 } else {
191 candidate
192 }
193 };
194 let stored_size = compressed.len() as i64;
195 let codec_str = codec_to_str(final_codec);
196 let now = Utc::now().timestamp();
197 let mut tx = self.pool.begin().await?;
198
199 sqlx::query(
200 r#"INSERT OR IGNORE INTO objects (hash, kind, size, stored_size, codec, content, created_at)
201 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
202 )
203 .bind(hash.as_bytes().as_ref())
204 .bind(kind as i64)
205 .bind(size)
206 .bind(stored_size)
207 .bind(codec_str)
208 .bind(compressed)
209 .bind(now)
210 .execute(&mut *tx)
211 .await?;
212
213 tx.commit().await?;
214 Ok(())
215 }
216
217 async fn read_verified_object(&self, hash: &Hash32) -> Result<Option<VerifiedObject>> {
218 let row = sqlx::query(r#"SELECT kind, size, codec, content FROM objects WHERE hash = ?1"#)
219 .bind(hash.as_bytes().as_ref())
220 .fetch_optional(&self.pool)
221 .await?;
222
223 let Some(row) = row else {
224 return Ok(None);
225 };
226
227 let kind = ObjectKind::from_i64(row.get::<i64, _>("kind")).ok_or_else(|| Error::Integrity("invalid kind".into()))?;
228 let size: i64 = row.get("size");
229 let codec_str: String = row.get("codec");
230 let codec = codec_from_str(&codec_str)?;
231 let stored: Vec<u8> = row.get("content");
232 let decompressed = decompress(codec, &stored)?;
233 let expected = u64::try_from(size).map_err(|_| Error::Integrity("negative object size".into()))?;
234 let actual = decompressed.len() as u64;
235 if actual != expected {
236 return Err(Error::ObjectLengthMismatch { expected, actual });
237 }
238 let actual_hash = Hash32::sha3_256(&decompressed);
239 if actual_hash != *hash {
240 return Err(Error::ObjectHashMismatch {
241 expected: *hash,
242 actual: actual_hash,
243 });
244 }
245 Ok(Some(VerifiedObject {
246 hash: *hash,
247 kind,
248 bytes: decompressed,
249 }))
250 }
251
252 pub async fn existing_hashes(&self, hashes: &[Hash32]) -> Result<HashSet<Hash32>> {
253 let mut found = HashSet::new();
254 if hashes.is_empty() {
255 return Ok(found);
256 }
257 for chunk in hashes.chunks(SQLITE_PARAM_LIMIT.max(1)) {
258 let mut builder = QueryBuilder::new("SELECT hash FROM objects WHERE hash IN (");
259 let mut separated = builder.separated(", ");
260 for hash in chunk {
261 separated.push_bind(hash.as_bytes().as_ref());
262 }
263 separated.push_unseparated(")");
264 let rows = builder.build().fetch_all(&self.pool).await?;
265 for row in rows {
266 let bytes: Vec<u8> = row.get("hash");
267 found.insert(Hash32::from_bytes(&bytes).map_err(|_| Error::HashMismatch)?);
268 }
269 }
270 Ok(found)
271 }
272
273 pub async fn chunk_stored_bytes(&self, hash: &Hash32) -> Result<Option<u64>> {
274 let row = sqlx::query("SELECT kind, stored_size FROM objects WHERE hash=?1")
275 .bind(hash.as_bytes().as_ref())
276 .fetch_optional(&self.pool)
277 .await?;
278 let Some(row) = row else {
279 return Ok(None);
280 };
281 let kind = ObjectKind::from_i64(row.get::<i64, _>("kind")).ok_or_else(|| Error::Integrity("invalid kind".into()))?;
282 if kind != ObjectKind::Chunk {
283 return Ok(None);
284 }
285 let stored_size: i64 = row.get("stored_size");
286 Ok(Some(stored_size as u64))
287 }
288
289 pub async fn chunk_sizes(&self) -> Result<Vec<(Hash32, u64)>> {
290 let rows = sqlx::query("SELECT hash, stored_size FROM objects WHERE kind=?1")
291 .bind(ObjectKind::Chunk as i64)
292 .fetch_all(&self.pool)
293 .await?;
294 let mut items = Vec::with_capacity(rows.len());
295 for row in rows {
296 let hash_bytes: Vec<u8> = row.get("hash");
297 let hash = Hash32::from_bytes(&hash_bytes).map_err(|_| Error::HashMismatch)?;
298 let stored_size: i64 = row.get("stored_size");
299 items.push((hash, stored_size as u64));
300 }
301 Ok(items)
302 }
303
304 pub async fn file_recipe_cache(&self) -> Result<HashMap<Hash32, Hash32>> {
305 if !self.file_recipe_cache_table_exists().await? {
306 return Ok(HashMap::new());
307 }
308 let rows = sqlx::query("SELECT file_hash, recipe_hash FROM file_recipe_cache")
309 .fetch_all(&self.pool)
310 .await?;
311 let mut map = HashMap::with_capacity(rows.len());
312 for row in rows {
313 let file_bytes: Vec<u8> = row.get("file_hash");
314 let recipe_bytes: Vec<u8> = row.get("recipe_hash");
315 let file_hash = Hash32::from_bytes(&file_bytes)?;
316 let recipe_hash = Hash32::from_bytes(&recipe_bytes)?;
317 map.insert(file_hash, recipe_hash);
318 }
319 Ok(map)
320 }
321
322 pub async fn recipe_for_file_hash(&self, file_hash: &Hash32) -> Result<Option<Hash32>> {
323 if !self.file_recipe_cache_table_exists().await? {
324 return Ok(None);
325 }
326 let row = sqlx::query("SELECT recipe_hash FROM file_recipe_cache WHERE file_hash=?1")
327 .bind(file_hash.as_bytes().as_ref())
328 .fetch_optional(&self.pool)
329 .await?;
330 let Some(row) = row else {
331 return Ok(None);
332 };
333 let bytes: Vec<u8> = row.get("recipe_hash");
334 Ok(Some(Hash32::from_bytes(&bytes)?))
335 }
336
337 pub async fn all_objects(&self) -> Result<Vec<VerifiedObject>> {
338 let rows = sqlx::query("SELECT hash, kind, size, codec, content FROM objects ORDER BY hash ASC")
339 .fetch_all(&self.pool)
340 .await?;
341 let mut objects = Vec::new();
342 for row in rows {
343 let hash_bytes: Vec<u8> = row.get("hash");
344 let hash = Hash32::from_bytes(&hash_bytes).map_err(|_| Error::HashMismatch)?;
345 let kind = ObjectKind::from_i64(row.get::<i64, _>("kind")).ok_or_else(|| Error::Integrity("invalid kind".into()))?;
346 let size: i64 = row.get("size");
347 let codec = codec_from_str(&row.get::<String, _>("codec"))?;
348 let stored: Vec<u8> = row.get("content");
349 let content = decompress(codec, &stored)?;
350 let expected = u64::try_from(size).map_err(|_| Error::Integrity("negative object size".into()))?;
351 let actual = content.len() as u64;
352 if actual != expected {
353 return Err(Error::ObjectLengthMismatch { expected, actual });
354 }
355 let actual_hash = Hash32::sha3_256(&content);
356 if actual_hash != hash {
357 return Err(Error::ObjectHashMismatch {
358 expected: hash,
359 actual: actual_hash,
360 });
361 }
362 objects.push(VerifiedObject {
363 hash,
364 kind,
365 bytes: content,
366 });
367 }
368 Ok(objects)
369 }
370
371 pub fn stream_object_records(&self) -> impl Stream<Item = Result<ObjectRecord>> + '_ {
372 sqlx::query("SELECT hash, kind, size, codec, content FROM objects ORDER BY hash ASC")
373 .fetch(&self.pool)
374 .map(|row| match row {
375 Ok(row) => object_record_from_row(row),
376 Err(err) => Err(Error::from(err)),
377 })
378 }
379
380 pub fn stream_object_records_with_batch_size(&self, batch_size: usize) -> impl Stream<Item = Result<ObjectRecord>> + '_ {
381 let batch_size = batch_size.max(1);
382 let pool = self.pool.clone();
383 stream::try_unfold((0i64, VecDeque::new()), move |(offset, mut buffer)| {
384 let pool = pool.clone();
385 async move {
386 if let Some(record) = buffer.pop_front() {
387 return Ok(Some((record, (offset, buffer))));
388 }
389
390 let rows = sqlx::query("SELECT hash, kind, size, codec, content FROM objects ORDER BY hash ASC LIMIT ?1 OFFSET ?2")
391 .bind(batch_size as i64)
392 .bind(offset)
393 .fetch_all(&pool)
394 .await?;
395 if rows.is_empty() {
396 return Ok(None);
397 }
398 let rows_len = rows.len();
399 for row in rows {
400 buffer.push_back(object_record_from_row(row)?);
401 }
402 let record = buffer.pop_front().expect("buffer should contain fetched rows");
403 Ok(Some((record, (offset + rows_len as i64, buffer))))
404 }
405 })
406 }
407
408 async fn file_recipe_cache_table_exists(&self) -> Result<bool> {
409 let row = sqlx::query("SELECT name FROM sqlite_master WHERE type='table' AND name='file_recipe_cache'")
410 .fetch_optional(&self.pool)
411 .await?;
412 Ok(row.is_some())
413 }
414
415 pub async fn rebuild_merkle_index(&self) -> Result<MerkleSummary> {
416 let mut tx = self.pool.begin().await?;
417 sqlx::query("DELETE FROM merkle_leaves").execute(&mut *tx).await?;
418 sqlx::query("DELETE FROM merkle_nodes").execute(&mut *tx).await?;
419 sqlx::query("DELETE FROM merkle_meta").execute(&mut *tx).await?;
420
421 let rows = sqlx::query("SELECT hash FROM objects ORDER BY hash ASC")
422 .fetch_all(&mut *tx)
423 .await?;
424 let mut leaves = Vec::new();
425 for row in rows {
426 let bytes: Vec<u8> = row.get("hash");
427 leaves.push(Hash32::from_bytes(&bytes).map_err(|_| Error::HashMismatch)?);
428 }
429
430 for (idx, hash) in leaves.iter().enumerate() {
431 sqlx::query("INSERT INTO merkle_leaves (hash, pos) VALUES (?1, ?2)")
432 .bind(hash.as_bytes().as_ref())
433 .bind(idx as i64)
434 .execute(&mut *tx)
435 .await?;
436 }
437
438 let leaf_count = leaves.len() as u64;
439 let mut current = leaves.clone();
440 let mut level = 0;
441 let mut next_level = Vec::new();
442 while current.len() > 1 {
443 next_level.clear();
444 for (i, chunk) in current.chunks(2).enumerate() {
445 let left = chunk[0];
446 let right = if chunk.len() > 1 { chunk[1] } else { chunk[0] };
447 let parent = hash_pair(&left, &right);
448 sqlx::query(
449 "INSERT INTO merkle_nodes (level, pos, hash) VALUES (?1, ?2, ?3)
450 ON CONFLICT(level, pos) DO UPDATE SET hash=excluded.hash",
451 )
452 .bind(level as i64)
453 .bind(i as i64)
454 .bind(parent.as_bytes().as_ref())
455 .execute(&mut *tx)
456 .await?;
457 next_level.push(parent);
458 }
459 current.clear();
460 current.extend_from_slice(&next_level);
461 level += 1;
462 }
463
464 let root = if current.is_empty() { Hash32::sha3_256([]) } else { current[0] };
465
466 for (key, value) in [
467 ("leaf_count", leaf_count.to_string()),
468 ("root_hash", root.to_hex()),
469 ("algo", "sha3-256".to_string()),
470 ("tree", "merkle-set-v1".to_string()),
471 ("sorted", "byte-lex".to_string()),
472 ] {
473 sqlx::query(
474 "INSERT INTO merkle_meta (key, value) VALUES (?1, ?2)
475 ON CONFLICT(key) DO UPDATE SET value=excluded.value",
476 )
477 .bind(key)
478 .bind(value)
479 .execute(&mut *tx)
480 .await?;
481 }
482
483 tx.commit().await?;
484 let summary = MerkleSummary { root, leaf_count };
485 let mut cache = self.cache.lock().await;
486 cache.summary = Some(summary.clone());
487 cache.leaf_pos.clear();
488 cache.leaf_hashes.clear();
489 cache.node_hashes.clear();
490 Ok(summary)
491 }
492
493 pub async fn merkle_summary(&self) -> Result<MerkleSummary> {
494 if let Some(summary) = self.cache.lock().await.summary.clone() {
495 return Ok(summary);
496 }
497 let leaf_count = read_meta_u64(&self.pool, "leaf_count").await?;
498 let root_hex = read_meta_value(&self.pool, "root_hash")
499 .await?
500 .ok_or_else(|| Error::MissingMeta("root_hash".into()))?;
501 let root = Hash32::from_hex(&root_hex).map_err(|_| Error::MissingMeta("root_hash".into()))?;
502 let summary = MerkleSummary { root, leaf_count };
503 self.cache.lock().await.summary = Some(summary.clone());
504 Ok(summary)
505 }
506
507 pub async fn recompute_merkle_summary(&self) -> Result<MerkleSummary> {
508 let rows = sqlx::query("SELECT hash FROM merkle_leaves ORDER BY pos ASC")
509 .fetch_all(&self.pool)
510 .await?;
511 let mut hashes = Vec::with_capacity(rows.len());
512 for row in rows {
513 let bytes: Vec<u8> = row.get("hash");
514 hashes.push(Hash32::from_bytes(&bytes).map_err(|_| Error::HashMismatch)?);
515 }
516 let summary = merkle_root_from_hashes(&hashes);
517 self.cache.lock().await.summary = Some(summary.clone());
518 Ok(summary)
519 }
520
521 pub async fn prove_membership(&self, hash: &Hash32) -> Result<Option<MerkleProof>> {
522 let summary = self.merkle_summary().await?;
523 if summary.leaf_count == 0 {
524 return Ok(None);
525 }
526
527 let row = sqlx::query("SELECT pos FROM merkle_leaves WHERE hash=?1")
528 .bind(hash.as_bytes().as_ref())
529 .fetch_optional(&self.pool)
530 .await?;
531 let Some(row) = row else {
532 return Ok(None);
533 };
534
535 let mut pos: u64 = row.get::<i64, _>("pos") as u64;
536 let mut count = summary.leaf_count;
537 let mut siblings = Vec::new();
538 let mut current_hash = *hash;
539 let mut node_level: i64 = 0;
540 let mut on_leaf = true;
541
542 while count > 1 {
543 let sibling_pos = pos ^ 1;
544 let sibling_hash = if on_leaf {
545 if sibling_pos >= count {
546 current_hash
547 } else {
548 fetch_leaf_hash(&self.pool, sibling_pos).await?
549 }
550 } else if sibling_pos >= count {
551 current_hash
552 } else {
553 fetch_node_hash(&self.pool, node_level, sibling_pos).await?
554 };
555
556 siblings.push(sibling_hash);
557 let (left, right) = if pos.is_multiple_of(2) {
558 (current_hash, sibling_hash)
559 } else {
560 (sibling_hash, current_hash)
561 };
562 current_hash = hash_pair(&left, &right);
563
564 pos /= 2;
565 count = count.div_ceil(2);
566 if on_leaf {
567 on_leaf = false;
568 } else {
569 node_level += 1;
570 }
571 }
572
573 Ok(Some(MerkleProof {
574 leaf_pos: row.get::<i64, _>("pos") as u64,
575 siblings,
576 }))
577 }
578
579 pub fn verify_proof(target: &Hash32, proof: &MerkleProof, expected_root: &Hash32, leaf_count: u64) -> bool {
580 if leaf_count == 0 {
581 return false;
582 }
583 let mut hash = *target;
584 let mut pos = proof.leaf_pos;
585 let mut count = leaf_count;
586 for sibling in &proof.siblings {
587 let (left, right) = if pos.is_multiple_of(2) {
588 (hash, *sibling)
589 } else {
590 (*sibling, hash)
591 };
592 hash = hash_pair(&left, &right);
593 pos /= 2;
594 count = count.div_ceil(2);
595 if count == 1 {
596 break;
597 }
598 }
599 hash == *expected_root
600 }
601
602 pub async fn integrity_check(&self) -> Result<()> {
603 let row = sqlx::query("PRAGMA integrity_check;").fetch_one(&self.pool).await?;
604 let status: String = row.get(0);
605 if status.trim() == "ok" {
606 Ok(())
607 } else {
608 Err(Error::Integrity(status))
609 }
610 }
611
612 pub async fn prove_memberships_batch(&self, hashes: &[Hash32]) -> Result<Vec<Option<MerkleProof>>> {
613 let summary = self.merkle_summary().await?;
614 if summary.leaf_count == 0 {
615 return Ok(vec![None; hashes.len()]);
616 }
617
618 let positions = self.fetch_positions(hashes, 800).await?;
619
620 let mut need_leaf_pos = HashSet::new();
621 for pos in positions.values() {
622 let sib = pos ^ 1;
623 if sib < summary.leaf_count {
624 need_leaf_pos.insert(sib);
625 }
626 }
627
628 let leaf_hash_by_pos = if need_leaf_pos.is_empty() {
629 HashMap::new()
630 } else if (need_leaf_pos.len() as f64) / (summary.leaf_count as f64) >= FULL_SCAN_RATIO {
631 self.fetch_all_leaf_hashes(summary.leaf_count).await?
632 } else {
633 self
634 .fetch_leaf_hashes_by_pos(&need_leaf_pos.into_iter().collect::<Vec<_>>(), 800)
635 .await?
636 };
637
638 let level_counts = level_node_counts(summary.leaf_count);
639 let mut need_nodes: Vec<HashSet<u64>> = vec![HashSet::new(); level_counts.len()];
640
641 for pos in positions.values() {
642 let mut current = *pos / 2;
643 for (level, count) in level_counts.iter().enumerate() {
644 let sib = current ^ 1;
645 if sib < *count {
646 need_nodes[level].insert(sib);
647 }
648 current /= 2;
649 }
650 }
651
652 let mut node_hashes = HashMap::new();
653 for (level, need) in need_nodes.iter().enumerate() {
654 if need.is_empty() {
655 continue;
656 }
657 let count = level_counts[level];
658 let use_full_scan = count > 0 && (need.len() as f64) / (count as f64) >= FULL_SCAN_RATIO;
659 let fetched = if use_full_scan {
660 self.fetch_all_node_hashes(level as i64, count).await?
661 } else {
662 self
663 .fetch_node_hashes_by_pos(level as i64, &need.iter().copied().collect::<Vec<_>>(), 800)
664 .await?
665 };
666 for (pos, hash) in fetched {
667 node_hashes.insert((level as i64, pos), hash);
668 }
669 }
670
671 let mut proofs = Vec::with_capacity(hashes.len());
672 for target in hashes {
673 let Some(&leaf_pos) = positions.get(target) else {
674 proofs.push(None);
675 continue;
676 };
677 let mut siblings = Vec::new();
678 let mut pos = leaf_pos;
679 let mut count = summary.leaf_count;
680 let mut level_idx: i64 = -1;
681 let mut current_hash = *target;
682
683 while count > 1 {
684 let sib_pos = pos ^ 1;
685 let sibling_hash = if level_idx == -1 {
686 if sib_pos >= count {
687 current_hash
688 } else {
689 match leaf_hash_by_pos.get(&sib_pos) {
690 Some(h) => *h,
691 None => fetch_leaf_hash(&self.pool, sib_pos).await?,
692 }
693 }
694 } else if sib_pos >= count {
695 current_hash
696 } else {
697 match node_hashes.get(&(level_idx, sib_pos)) {
698 Some(h) => *h,
699 None => fetch_node_hash(&self.pool, level_idx, sib_pos).await?,
700 }
701 };
702 siblings.push(sibling_hash);
703
704 let (left, right) = if pos % 2 == 0 {
705 (current_hash, sibling_hash)
706 } else {
707 (sibling_hash, current_hash)
708 };
709 current_hash = hash_pair(&left, &right);
710
711 pos /= 2;
712 count = count.div_ceil(2);
713 level_idx += 1;
714 }
715
716 proofs.push(Some(MerkleProof { leaf_pos, siblings }));
717 }
718
719 Ok(proofs)
720 }
721
722 async fn fetch_positions(&self, hashes: &[Hash32], chunk: usize) -> Result<HashMap<Hash32, u64>> {
723 let mut map = HashMap::new();
724 let mut missing = Vec::new();
725 {
726 let cache = self.cache.lock().await;
727 for h in hashes {
728 if let Some(pos) = cache.leaf_pos.get(h) {
729 map.insert(*h, *pos);
730 } else {
731 missing.push(*h);
732 }
733 }
734 }
735
736 let mut fetched = HashMap::new();
737 for group in missing.chunks(chunk) {
738 let placeholders = repeat_n("?", group.len()).collect::<Vec<_>>().join(",");
739 let sql = format!("SELECT hash, pos FROM merkle_leaves WHERE hash IN ({placeholders})");
740 let mut query = sqlx::query(&sql);
741 for h in group {
742 query = query.bind(h.as_bytes().as_ref());
743 }
744 let rows = query.fetch_all(&self.pool).await?;
745 for row in rows {
746 let hash_bytes: Vec<u8> = row.get("hash");
747 let hash = Hash32::from_bytes(&hash_bytes).map_err(|_| Error::HashMismatch)?;
748 let pos: i64 = row.get("pos");
749 fetched.insert(hash, pos as u64);
750 }
751 }
752
753 map.extend(fetched.iter().map(|(h, pos)| (*h, *pos)));
754 if !fetched.is_empty() {
755 let mut cache = self.cache.lock().await;
756 for (hash, pos) in fetched {
757 cache.leaf_pos.insert(hash, pos);
758 }
759 }
760 Ok(map)
761 }
762
763 async fn fetch_leaf_hashes_by_pos(&self, positions: &[u64], chunk: usize) -> Result<HashMap<u64, Hash32>> {
764 let mut map = HashMap::new();
765 let mut missing = Vec::new();
766 {
767 let cache = self.cache.lock().await;
768 for pos in positions {
769 if let Some(hash) = cache.leaf_hashes.get(pos) {
770 map.insert(*pos, *hash);
771 } else {
772 missing.push(*pos);
773 }
774 }
775 }
776
777 let mut fetched = HashMap::new();
778 for group in missing.chunks(chunk) {
779 if group.is_empty() {
780 continue;
781 }
782 let placeholders = repeat_n("?", group.len()).collect::<Vec<_>>().join(",");
783 let sql = format!("SELECT pos, hash FROM merkle_leaves WHERE pos IN ({placeholders})");
784 let mut query = sqlx::query(&sql);
785 for pos in group {
786 query = query.bind(*pos as i64);
787 }
788 let rows = query.fetch_all(&self.pool).await?;
789 for row in rows {
790 let pos: i64 = row.get("pos");
791 let bytes: Vec<u8> = row.get("hash");
792 fetched.insert(pos as u64, Hash32::from_bytes(&bytes).map_err(|_| Error::HashMismatch)?);
793 }
794 }
795
796 map.extend(fetched.iter().map(|(pos, hash)| (*pos, *hash)));
797 if !fetched.is_empty() {
798 let mut cache = self.cache.lock().await;
799 for (pos, hash) in fetched {
800 cache.leaf_hashes.insert(pos, hash);
801 }
802 }
803 Ok(map)
804 }
805
806 async fn fetch_node_hashes_by_pos(&self, level: i64, positions: &[u64], chunk: usize) -> Result<HashMap<u64, Hash32>> {
807 let mut map = HashMap::new();
808 let mut missing = Vec::new();
809 {
810 let cache = self.cache.lock().await;
811 if let Some(level_cache) = cache.node_hashes.get(&level) {
812 for pos in positions {
813 if let Some(hash) = level_cache.get(pos) {
814 map.insert(*pos, *hash);
815 } else {
816 missing.push(*pos);
817 }
818 }
819 } else {
820 missing.extend_from_slice(positions);
821 }
822 }
823
824 let mut fetched = HashMap::new();
825 for group in missing.chunks(chunk) {
826 if group.is_empty() {
827 continue;
828 }
829 let placeholders = repeat_n("?", group.len()).collect::<Vec<_>>().join(",");
830 let sql = format!("SELECT pos, hash FROM merkle_nodes WHERE level=?1 AND pos IN ({placeholders})");
831 let mut query = sqlx::query(&sql).bind(level);
832 for pos in group {
833 query = query.bind(*pos as i64);
834 }
835 let rows = query.fetch_all(&self.pool).await?;
836 for row in rows {
837 let pos: i64 = row.get("pos");
838 let bytes: Vec<u8> = row.get("hash");
839 fetched.insert(pos as u64, Hash32::from_bytes(&bytes).map_err(|_| Error::HashMismatch)?);
840 }
841 }
842
843 map.extend(fetched.iter().map(|(pos, hash)| (*pos, *hash)));
844 if !fetched.is_empty() {
845 let mut cache = self.cache.lock().await;
846 let level_cache = cache.node_hashes.entry(level).or_default();
847 for (pos, hash) in fetched {
848 level_cache.insert(pos, hash);
849 }
850 }
851 Ok(map)
852 }
853
854 async fn fetch_all_leaf_hashes(&self, leaf_count: u64) -> Result<HashMap<u64, Hash32>> {
855 if leaf_count == 0 {
856 return Ok(HashMap::new());
857 }
858 if let Some(cached) = {
859 let cache = self.cache.lock().await;
860 if cache.leaf_hashes.len() == leaf_count as usize {
861 Some(cache.leaf_hashes.clone())
862 } else {
863 None
864 }
865 } {
866 return Ok(cached);
867 }
868
869 let rows = sqlx::query("SELECT pos, hash FROM merkle_leaves ORDER BY pos ASC")
870 .fetch_all(&self.pool)
871 .await?;
872 let mut map = HashMap::with_capacity(rows.len());
873 for row in rows {
874 let pos: i64 = row.get("pos");
875 let bytes: Vec<u8> = row.get("hash");
876 map.insert(pos as u64, Hash32::from_bytes(&bytes).map_err(|_| Error::HashMismatch)?);
877 }
878 let mut cache = self.cache.lock().await;
879 cache.leaf_hashes = map.clone();
880 Ok(map)
881 }
882
883 async fn fetch_all_node_hashes(&self, level: i64, count: u64) -> Result<HashMap<u64, Hash32>> {
884 if count == 0 {
885 return Ok(HashMap::new());
886 }
887 if let Some(cached) = {
888 let cache = self.cache.lock().await;
889 if let Some(level_cache) = cache.node_hashes.get(&level) {
890 if level_cache.len() == count as usize {
891 Some(level_cache.clone())
892 } else {
893 None
894 }
895 } else {
896 None
897 }
898 } {
899 return Ok(cached);
900 }
901
902 let rows = sqlx::query("SELECT pos, hash FROM merkle_nodes WHERE level=?1")
903 .bind(level)
904 .fetch_all(&self.pool)
905 .await?;
906 let mut map = HashMap::with_capacity(rows.len());
907 for row in rows {
908 let pos: i64 = row.get("pos");
909 let bytes: Vec<u8> = row.get("hash");
910 map.insert(pos as u64, Hash32::from_bytes(&bytes).map_err(|_| Error::HashMismatch)?);
911 }
912 let mut cache = self.cache.lock().await;
913 cache.node_hashes.insert(level, map.clone());
914 Ok(map)
915 }
916
917 pub async fn verified_object_hashes(&self) -> Result<Vec<Hash32>> {
918 let rows = sqlx::query("SELECT hash, kind, size, codec, content FROM objects ORDER BY hash ASC")
919 .fetch_all(&self.pool)
920 .await?;
921 let mut hashes = Vec::with_capacity(rows.len());
922 for row in rows {
923 let hash_bytes: Vec<u8> = row.get("hash");
924 let stored_hash = Hash32::from_bytes(&hash_bytes).map_err(|_| Error::HashMismatch)?;
925 let size: i64 = row.get("size");
926 let codec = codec_from_str(&row.get::<String, _>("codec"))?;
927 let stored: Vec<u8> = row.get("content");
928 let content = decompress(codec, &stored)?;
929 if content.len() as i64 != size {
930 return Err(Error::Integrity("size mismatch".into()));
931 }
932 if Hash32::sha3_256(&content) != stored_hash {
933 return Err(Error::HashMismatch);
934 }
935 hashes.push(stored_hash);
936 }
937 Ok(hashes)
938 }
939}
940
941fn object_record_from_row(row: SqliteRow) -> Result<ObjectRecord> {
942 let hash_bytes: Vec<u8> = row.get("hash");
943 let hash = Hash32::from_bytes(&hash_bytes).map_err(|_| Error::HashMismatch)?;
944 let kind = ObjectKind::from_i64(row.get::<i64, _>("kind")).ok_or_else(|| Error::Integrity("invalid kind".into()))?;
945 let size: i64 = row.get("size");
946 let decoded_len = u64::try_from(size).map_err(|_| Error::Integrity("negative object size".into()))?;
947 let codec = codec_from_str(&row.get::<String, _>("codec"))?;
948 let stored_bytes: Vec<u8> = row.get("content");
949 Ok(ObjectRecord {
950 hash,
951 kind,
952 decoded_len,
953 codec,
954 stored_bytes,
955 })
956}
957
958impl ObjectSource for Pack {
959 async fn read_object(&self, hash: &Hash32) -> Result<Option<VerifiedObject>> {
960 self.read_verified_object(hash).await
961 }
962}
963
964fn local_vfs_name() -> &'static str {
965 if cfg!(windows) { "win32" } else { "unix" }
966}
967
968async fn apply_schema(pool: &SqlitePool) -> Result<()> {
969 let statements = [
970 r#"
971 CREATE TABLE IF NOT EXISTS pack_meta (
972 key TEXT PRIMARY KEY,
973 value TEXT NOT NULL
974 );
975 "#,
976 r#"
977 CREATE TABLE IF NOT EXISTS objects (
978 hash BLOB PRIMARY KEY,
979 kind INTEGER NOT NULL,
980 size INTEGER NOT NULL,
981 stored_size INTEGER NOT NULL,
982 codec TEXT NOT NULL,
983 content BLOB NOT NULL,
984 created_at INTEGER NOT NULL
985 );
986 "#,
987 r#"
988 CREATE TABLE IF NOT EXISTS file_recipe_cache (
989 file_hash BLOB PRIMARY KEY,
990 recipe_hash BLOB NOT NULL,
991 updated_at INTEGER NOT NULL
992 );
993 "#,
994 r#"
995 CREATE TABLE IF NOT EXISTS merkle_leaves (
996 hash BLOB PRIMARY KEY,
997 pos INTEGER NOT NULL
998 );
999 "#,
1000 r#"
1001 CREATE UNIQUE INDEX IF NOT EXISTS idx_merkle_leaves_pos ON merkle_leaves(pos);
1002 "#,
1003 r#"
1004 CREATE TABLE IF NOT EXISTS merkle_nodes (
1005 level INTEGER NOT NULL,
1006 pos INTEGER NOT NULL,
1007 hash BLOB NOT NULL,
1008 PRIMARY KEY(level, pos)
1009 );
1010 "#,
1011 r#"
1012 CREATE TABLE IF NOT EXISTS merkle_meta (
1013 key TEXT PRIMARY KEY,
1014 value TEXT NOT NULL
1015 );
1016 "#,
1017 ];
1018
1019 for stmt in statements {
1020 pool.execute(stmt).await?;
1021 }
1022 Ok(())
1023}
1024
1025async fn read_meta_value(pool: &SqlitePool, key: &str) -> Result<Option<String>> {
1026 let row = sqlx::query("SELECT value FROM merkle_meta WHERE key=?1")
1027 .bind(key)
1028 .fetch_optional(pool)
1029 .await?;
1030 Ok(row.map(|r| r.get::<String, _>("value")))
1031}
1032
1033async fn read_meta_u64(pool: &SqlitePool, key: &str) -> Result<u64> {
1034 let value = read_meta_value(pool, key)
1035 .await?
1036 .ok_or_else(|| Error::MissingMeta(key.to_string()))?;
1037 value.parse::<u64>().map_err(|_| Error::MissingMeta(key.to_string()))
1038}
1039
1040async fn fetch_leaf_hash(pool: &SqlitePool, pos: u64) -> Result<Hash32> {
1041 let row = sqlx::query("SELECT hash FROM merkle_leaves WHERE pos=?1")
1042 .bind(pos as i64)
1043 .fetch_one(pool)
1044 .await?;
1045 let bytes: Vec<u8> = row.get("hash");
1046 Hash32::from_bytes(&bytes).map_err(|_| Error::HashMismatch)
1047}
1048
1049async fn fetch_node_hash(pool: &SqlitePool, level: i64, pos: u64) -> Result<Hash32> {
1050 let row = sqlx::query("SELECT hash FROM merkle_nodes WHERE level=?1 AND pos=?2")
1051 .bind(level)
1052 .bind(pos as i64)
1053 .fetch_one(pool)
1054 .await?;
1055 let bytes: Vec<u8> = row.get("hash");
1056 Hash32::from_bytes(&bytes).map_err(|_| Error::HashMismatch)
1057}
1058
1059fn hash_pair(left: &Hash32, right: &Hash32) -> Hash32 {
1060 let mut buf = Vec::with_capacity(64);
1061 buf.extend_from_slice(left.as_bytes());
1062 buf.extend_from_slice(right.as_bytes());
1063 Hash32::sha3_256(buf)
1064}
1065
1066pub fn merkle_root_from_hashes(hashes: &[Hash32]) -> MerkleSummary {
1067 if hashes.is_empty() {
1068 return MerkleSummary {
1069 root: Hash32::sha3_256([]),
1070 leaf_count: 0,
1071 };
1072 }
1073 let mut current: Vec<Hash32> = hashes.to_vec();
1074 let mut next_level = Vec::new();
1075 while current.len() > 1 {
1076 next_level.clear();
1077 for chunk in current.chunks(2) {
1078 let left = chunk[0];
1079 let right = if chunk.len() > 1 { chunk[1] } else { chunk[0] };
1080 next_level.push(hash_pair(&left, &right));
1081 }
1082 current.clear();
1083 current.extend_from_slice(&next_level);
1084 }
1085 MerkleSummary {
1086 root: current[0],
1087 leaf_count: hashes.len() as u64,
1088 }
1089}
1090
1091fn codec_to_str(codec: Codec) -> &'static str {
1092 match codec {
1093 Codec::Raw => "raw",
1094 Codec::Zstd => "zstd",
1095 Codec::Brotli => "brotli",
1096 }
1097}
1098
1099fn codec_from_str(s: &str) -> Result<Codec> {
1100 match s {
1101 "raw" => Ok(Codec::Raw),
1102 "zstd" => Ok(Codec::Zstd),
1103 "brotli" => Ok(Codec::Brotli),
1104 other => Err(Error::InvalidCodec(other.to_string())),
1105 }
1106}
1107
1108fn level_node_counts(mut leaf_count: u64) -> Vec<u64> {
1109 let mut counts = Vec::new();
1110 while leaf_count > 1 {
1111 leaf_count = leaf_count.div_ceil(2);
1112 counts.push(leaf_count);
1113 }
1114 counts
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119 use std::{collections::HashMap, fs};
1120
1121 use futures_util::TryStreamExt;
1122 use tempfile::tempdir;
1123
1124 use super::*;
1125
1126 #[tokio::test]
1127 async fn write_and_read_object_roundtrip() {
1128 let dir = tempdir().unwrap();
1129 let pack_path = dir.path().join("test.db");
1130 let pack = Pack::open(&pack_path).await.unwrap();
1131
1132 let data = (0..4096).map(|index| (index % 251) as u8).collect::<Vec<_>>();
1133 let hash = Hash32::sha3_256(&data);
1134
1135 pack.put_chunk(hash, &data, Codec::Zstd).await.unwrap();
1136 let loaded = pack.read_object(&hash).await.unwrap().unwrap();
1137 assert_eq!(loaded.hash, hash);
1138 assert_eq!(loaded.kind, ObjectKind::Chunk);
1139 assert_eq!(loaded.bytes, data);
1140
1141 sqlx::query("UPDATE objects SET size = size + 1 WHERE hash = ?1")
1142 .bind(hash.as_bytes().as_ref())
1143 .execute(&pack.pool)
1144 .await
1145 .unwrap();
1146 assert!(matches!(
1147 pack.read_object(&hash).await,
1148 Err(Error::ObjectLengthMismatch { expected, actual }) if expected == data.len() as u64 + 1 && actual == data.len() as u64
1149 ));
1150 sqlx::query("UPDATE objects SET size = ?1, codec = 'raw', content = ?2 WHERE hash = ?3")
1151 .bind(data.len() as i64)
1152 .bind(vec![0_u8; data.len()])
1153 .bind(hash.as_bytes().as_ref())
1154 .execute(&pack.pool)
1155 .await
1156 .unwrap();
1157 assert!(matches!(pack.read_object(&hash).await, Err(Error::ObjectHashMismatch { expected, .. }) if expected == hash));
1158 sqlx::query("UPDATE objects SET kind = 99 WHERE hash = ?1")
1159 .bind(hash.as_bytes().as_ref())
1160 .execute(&pack.pool)
1161 .await
1162 .unwrap();
1163 assert!(matches!(pack.read_object(&hash).await, Err(Error::Integrity(_))));
1164 }
1165
1166 #[tokio::test]
1167 async fn compression_falls_back_to_raw_when_not_smaller() {
1168 let dir = tempdir().unwrap();
1169 let pack_path = dir.path().join("small.db");
1170 let pack = Pack::open(&pack_path).await.unwrap();
1171
1172 let data = b"abcde".to_vec();
1175 let hash = Hash32::sha3_256(&data);
1176 pack.put_chunk(hash, &data, Codec::Zstd).await.unwrap();
1177
1178 let stored = pack.stream_object_records().try_next().await.unwrap().unwrap();
1179 assert_eq!(stored.codec, Codec::Raw);
1180 assert_eq!(stored.stored_bytes, data);
1181 }
1182
1183 #[tokio::test]
1184 async fn rebuild_merkle_and_prove_membership() {
1185 let dir = tempdir().unwrap();
1186 let pack_path = dir.path().join("pack.db");
1187 let pack = Pack::open(&pack_path).await.unwrap();
1188 let items = vec![b"foo".to_vec(), b"bar".to_vec(), b"baz".to_vec()];
1189 for item in &items {
1190 let h = Hash32::sha3_256(item);
1191 pack.put_chunk(h, item, Codec::Raw).await.unwrap();
1192 }
1193
1194 let summary = pack.rebuild_merkle_index().await.unwrap();
1195 assert_eq!(summary.leaf_count, items.len() as u64);
1196
1197 let target = Hash32::sha3_256(b"bar");
1198 let proof = pack.prove_membership(&target).await.unwrap().unwrap();
1199 assert!(Pack::verify_proof(&target, &proof, &summary.root, summary.leaf_count));
1200 }
1201
1202 #[tokio::test]
1203 async fn batch_proofs_match_individual() {
1204 let dir = tempdir().unwrap();
1205 let pack_path = dir.path().join("pack.db");
1206 let pack = Pack::open(&pack_path).await.unwrap();
1207 let items = vec![b"foo".to_vec(), b"bar".to_vec(), b"baz".to_vec()];
1208 let mut hashes = Vec::new();
1209 for item in &items {
1210 let h = Hash32::sha3_256(item);
1211 pack.put_chunk(h, item, Codec::Raw).await.unwrap();
1212 hashes.push(h);
1213 }
1214 let summary = pack.rebuild_merkle_index().await.unwrap();
1215
1216 let batch = pack.prove_memberships_batch(&hashes).await.unwrap().into_iter().collect::<Vec<_>>();
1217 for (hash, proof_opt) in hashes.iter().zip(batch.into_iter()) {
1218 let proof = proof_opt.expect("proof expected");
1219 assert!(Pack::verify_proof(hash, &proof, &summary.root, summary.leaf_count));
1220 }
1221
1222 let missing = Hash32::sha3_256("missing");
1223 let proofs = pack
1224 .prove_memberships_batch(&[missing])
1225 .await
1226 .unwrap()
1227 .into_iter()
1228 .collect::<Vec<_>>();
1229 assert!(proofs[0].is_none());
1230 }
1231
1232 #[tokio::test]
1233 async fn batch_proofs_cover_sparse_and_full_scan() {
1234 let dir = tempdir().unwrap();
1235 let pack_path = dir.path().join("batch.db");
1236 let pack = Pack::open(&pack_path).await.unwrap();
1237 let mut hashes = Vec::new();
1238 for i in 0..64u8 {
1239 let data = format!("item-{i}").into_bytes();
1240 let hash = Hash32::sha3_256(&data);
1241 pack.put_chunk(hash, &data, Codec::Raw).await.unwrap();
1242 hashes.push(hash);
1243 }
1244 let summary = pack.rebuild_merkle_index().await.unwrap();
1245
1246 let sparse = vec![hashes[0], hashes[1], hashes[2]];
1247 let sparse_proofs = pack.prove_memberships_batch(&sparse).await.unwrap();
1248 for (hash, proof_opt) in sparse.iter().zip(sparse_proofs.into_iter()) {
1249 let proof = proof_opt.expect("sparse proof expected");
1250 assert!(Pack::verify_proof(hash, &proof, &summary.root, summary.leaf_count));
1251 }
1252
1253 let full_proofs = pack.prove_memberships_batch(&hashes).await.unwrap();
1254 for (hash, proof_opt) in hashes.iter().zip(full_proofs.into_iter()) {
1255 let proof = proof_opt.expect("full proof expected");
1256 assert!(Pack::verify_proof(hash, &proof, &summary.root, summary.leaf_count));
1257 }
1258 }
1259
1260 #[tokio::test]
1261 async fn stream_object_records_matches_all_objects() {
1262 let dir = tempdir().unwrap();
1263 let pack_path = dir.path().join("stream.db");
1264 let pack = Pack::open(&pack_path).await.unwrap();
1265
1266 let payloads = vec![b"alpha".to_vec(), b"beta".to_vec(), b"gamma".to_vec()];
1267 for payload in &payloads {
1268 let hash = Hash32::sha3_256(payload);
1269 pack.put_chunk(hash, payload, Codec::Raw).await.unwrap();
1270 }
1271
1272 let recipe = b"recipe-bytes".to_vec();
1273 let recipe_hash = Hash32::sha3_256(&recipe);
1274 pack.put_recipe(recipe_hash, &recipe, Codec::Raw).await.unwrap();
1275
1276 let expected = pack.all_objects().await.unwrap();
1277 let expected_map: HashMap<Hash32, (ObjectKind, u64)> = expected
1278 .into_iter()
1279 .map(|obj| (obj.hash, (obj.kind, obj.bytes.len() as u64)))
1280 .collect();
1281
1282 let streamed = pack.stream_object_records().try_collect::<Vec<_>>().await.unwrap();
1283 assert_eq!(streamed.len(), expected_map.len());
1284 for record in streamed {
1285 let expected = expected_map.get(&record.hash).unwrap();
1286 assert_eq!(record.kind, expected.0);
1287 assert_eq!(record.decoded_len, expected.1);
1288 assert_eq!(record.codec, Codec::Raw);
1289 }
1290
1291 let streamed_batched = pack.stream_object_records_with_batch_size(2).try_collect::<Vec<_>>().await.unwrap();
1292 assert_eq!(streamed_batched.len(), expected_map.len());
1293 for record in streamed_batched {
1294 let expected = expected_map.get(&record.hash).unwrap();
1295 assert_eq!(record.kind, expected.0);
1296 assert_eq!(record.decoded_len, expected.1);
1297 assert_eq!(record.codec, Codec::Raw);
1298 }
1299 }
1300
1301 #[tokio::test]
1302 async fn integrity_check_passes() {
1303 let dir = tempdir().unwrap();
1304 let pack_path = dir.path().join("ok.db");
1305 let pack = Pack::open(&pack_path).await.unwrap();
1306 pack.integrity_check().await.unwrap();
1307 }
1308
1309 #[tokio::test]
1310 async fn open_with_journal_wal_truncates() {
1311 let dir = tempdir().unwrap();
1312 let pack_path = dir.path().join("wal.db");
1313 let pack = Pack::open_with_journal(&pack_path, PackJournalMode::Wal).await.unwrap();
1314 let data = b"wal-check".to_vec();
1315 let hash = Hash32::sha3_256(&data);
1316 pack.put_chunk(hash, &data, Codec::Raw).await.unwrap();
1317 pack.close().await;
1318
1319 let wal_path = pack_path.with_extension("db-wal");
1320 if wal_path.exists() {
1321 let len = fs::metadata(&wal_path).unwrap().len();
1322 assert_eq!(len, 0);
1323 }
1324
1325 let reopened = Pack::open_readonly(&pack_path).await.unwrap();
1326 let loaded = reopened.read_object(&hash).await.unwrap().unwrap();
1327 assert_eq!(loaded.bytes, data);
1328 }
1329}