Skip to main content

assetpack_core/
pack.rs

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    // Very small data compresses poorly; should store as raw even if codec asks for
1173    // zstd.
1174    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}