Skip to main content

cas_kit/
pack.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Pack file support for the content-addressed store.
3//!
4//! Pack files bundle multiple blobs into a single file, reducing
5//! filesystem overhead for repositories with many small objects.
6//!
7//! # Pack Format (v1)
8//!
9//! ```text
10//! .pack file:
11//!   "SPCK"                    magic
12//!   u32 LE                    version (1)
13//!   u32 LE                    object count
14//!   per object:
15//!     u8                      object type (1 = blob)
16//!     u32 LE                  uncompressed length
17//!     u32 LE                  compressed length
18//!     [32]                    BLAKE3 digest
19//!     [compressed length]     Zstd frame
20//!
21//! .idx file:
22//!   "SIDX"                    magic
23//!   u32 LE                    version (1)
24//!   u32 LE                    entry count
25//!   per entry:
26//!     [32]                    BLAKE3 digest
27//!     u64 LE                  offset into the .pack file
28//! ```
29//!
30//! The index is sorted by digest on load so lookups binary-search.
31
32use std::collections::HashMap;
33use std::fs;
34use std::io::{self, BufReader, Read, Seek, SeekFrom};
35use std::path::{Path, PathBuf};
36
37use thiserror::Error;
38
39#[cfg(feature = "zstd")]
40use crate::compressor;
41use crate::hash::Hash;
42use crate::hasher;
43
44/// Errors that can occur while reading or writing pack files.
45#[derive(Error, Debug)]
46pub enum PackError {
47    /// A `.pack` file did not start with the `SPCK` magic.
48    #[error("invalid pack magic: {0}")]
49    InvalidMagic(String),
50    /// The pack version is newer than this build supports.
51    #[error("unsupported pack version: {0}")]
52    UnsupportedVersion(u32),
53    /// An `.idx` file did not start with the `SIDX` magic.
54    #[error("invalid index magic: {0}")]
55    InvalidIndexMagic(String),
56    /// The digest is not present in this pack.
57    #[error("blob not found in pack: {0}")]
58    BlobNotFound(String),
59    /// An underlying filesystem operation failed.
60    #[error("I/O error: {0}")]
61    Io(#[from] io::Error),
62    /// Zstd compression failed while writing the pack.
63    #[error("compression error: {0}")]
64    CompressionError(String),
65    /// Zstd decompression failed while reading the pack.
66    #[error("decompression error: {0}")]
67    DecompressionError(String),
68    /// At least one object is required to create a pack.
69    #[error("cannot create empty pack")]
70    EmptyPack,
71    /// An object record used a type byte this build does not know.
72    #[error("unexpected object type: {0}")]
73    UnexpectedObjectType(u8),
74    /// The blob read from the pack did not match its recorded digest.
75    #[error("hash mismatch in pack: expected {expected}, got {actual}")]
76    HashMismatch {
77        /// The digest the object was addressed by.
78        expected: String,
79        /// The digest of what was actually read.
80        actual: String,
81    },
82}
83
84const PACK_MAGIC: &[u8; 4] = b"SPCK";
85const INDEX_MAGIC: &[u8; 4] = b"SIDX";
86const PACK_VERSION: u32 = 1;
87const TYPE_BLOB: u8 = 1;
88
89#[derive(Clone, Debug)]
90struct PackIndexEntry {
91    hash: Hash,
92    offset: u64,
93}
94
95/// A parsed, sorted `.idx` file.
96#[derive(Clone, Debug)]
97pub struct PackIndex {
98    entries: Vec<PackIndexEntry>,
99}
100
101impl PackIndex {
102    /// Load and validate an index from disk.
103    pub fn load(path: &Path) -> Result<Self, PackError> {
104        let file = fs::File::open(path)?;
105        let mut reader = BufReader::new(file);
106
107        let mut magic = [0u8; 4];
108        reader.read_exact(&mut magic)?;
109        if &magic != INDEX_MAGIC {
110            return Err(PackError::InvalidIndexMagic(
111                String::from_utf8_lossy(&magic).to_string(),
112            ));
113        }
114
115        let mut version = [0u8; 4];
116        reader.read_exact(&mut version)?;
117        let version = u32::from_le_bytes(version);
118        if version != PACK_VERSION {
119            return Err(PackError::UnsupportedVersion(version));
120        }
121
122        let mut count = [0u8; 4];
123        reader.read_exact(&mut count)?;
124        let count = u32::from_le_bytes(count) as usize;
125
126        let mut entries = Vec::with_capacity(count);
127        for _ in 0..count {
128            let mut hash_bytes = [0u8; 32];
129            reader.read_exact(&mut hash_bytes)?;
130            let mut offset_bytes = [0u8; 8];
131            reader.read_exact(&mut offset_bytes)?;
132            entries.push(PackIndexEntry {
133                hash: Hash::from(hash_bytes),
134                offset: u64::from_le_bytes(offset_bytes),
135            });
136        }
137
138        entries.sort_by_key(|e| e.hash);
139
140        Ok(Self { entries })
141    }
142
143    /// Look up the pack offset for a digest, if present.
144    #[must_use]
145    pub fn find(&self, hash: &Hash) -> Option<u64> {
146        self.entries
147            .binary_search_by_key(hash, |e| e.hash)
148            .ok()
149            .map(|idx| self.entries[idx].offset)
150    }
151
152    /// All digests in the index (sorted).
153    #[must_use]
154    pub fn hashes(&self) -> Vec<Hash> {
155        self.entries.iter().map(|e| e.hash).collect()
156    }
157
158    /// Number of entries.
159    #[must_use]
160    pub fn len(&self) -> usize {
161        self.entries.len()
162    }
163
164    /// Whether the index has no entries.
165    #[must_use]
166    pub fn is_empty(&self) -> bool {
167        self.entries.is_empty()
168    }
169}
170
171/// Writer/reader for `.pack` files.
172pub struct PackFile;
173
174impl PackFile {
175    /// Write a new pack file (and its index) containing `objects`.
176    ///
177    /// The pack name is derived from the BLAKE3 hash of the index, so
178    /// identical packs are naturally deduplicated on disk.
179    pub fn create(
180        pack_dir: &Path,
181        objects: &[(Hash, Vec<u8>)],
182    ) -> Result<(PathBuf, PathBuf), PackError> {
183        if objects.is_empty() {
184            return Err(PackError::EmptyPack);
185        }
186
187        fs::create_dir_all(pack_dir)?;
188
189        let mut pack_data = Vec::new();
190        let mut index_entries = Vec::new();
191
192        pack_data.extend_from_slice(PACK_MAGIC);
193        pack_data.extend_from_slice(&PACK_VERSION.to_le_bytes());
194        pack_data.extend_from_slice(&(objects.len() as u32).to_le_bytes());
195
196        for (hash, data) in objects {
197            let offset = pack_data.len() as u64;
198
199            // Without the `zstd` feature the object payload is stored raw;
200            // packs remain self-consistent within a single build.
201            #[cfg(feature = "zstd")]
202            let compressed = compressor::compress_default(data)
203                .map_err(|e| PackError::CompressionError(e.to_string()))?;
204            #[cfg(not(feature = "zstd"))]
205            let compressed = data.clone();
206
207            pack_data.push(TYPE_BLOB);
208            pack_data.extend_from_slice(&(data.len() as u32).to_le_bytes());
209            pack_data.extend_from_slice(&(compressed.len() as u32).to_le_bytes());
210            pack_data.extend_from_slice(&hash.0);
211            pack_data.extend_from_slice(&compressed);
212
213            index_entries.push(PackIndexEntry {
214                hash: *hash,
215                offset,
216            });
217        }
218
219        let index_data = Self::serialize_index(&index_entries);
220        let index_hash = hasher::hash_bytes(&index_data);
221        let name = format!("pack-{}", index_hash.to_hex());
222
223        let pack_path = pack_dir.join(format!("{name}.pack"));
224        let idx_path = pack_dir.join(format!("{name}.idx"));
225
226        fs::write(&pack_path, &pack_data)?;
227        fs::write(&idx_path, &index_data)?;
228
229        Ok((pack_path, idx_path))
230    }
231
232    fn serialize_index(entries: &[PackIndexEntry]) -> Vec<u8> {
233        let mut data = Vec::new();
234        data.extend_from_slice(INDEX_MAGIC);
235        data.extend_from_slice(&PACK_VERSION.to_le_bytes());
236        data.extend_from_slice(&(entries.len() as u32).to_le_bytes());
237
238        for entry in entries {
239            data.extend_from_slice(&entry.hash.0);
240            data.extend_from_slice(&entry.offset.to_le_bytes());
241        }
242
243        data
244    }
245
246    /// Read a single blob out of a pack file, verifying its digest.
247    pub fn read_blob(
248        pack_path: &Path,
249        index: &PackIndex,
250        hash: &Hash,
251    ) -> Result<Vec<u8>, PackError> {
252        let offset = index
253            .find(hash)
254            .ok_or_else(|| PackError::BlobNotFound(hash.to_hex()))?;
255
256        let file = fs::File::open(pack_path)?;
257        let mut reader = BufReader::new(file);
258
259        reader.seek(SeekFrom::Start(offset))?;
260
261        let mut type_byte = [0u8; 1];
262        reader.read_exact(&mut type_byte)?;
263        if type_byte[0] != TYPE_BLOB {
264            return Err(PackError::UnexpectedObjectType(type_byte[0]));
265        }
266
267        let mut uncomp_size = [0u8; 4];
268        reader.read_exact(&mut uncomp_size)?;
269        let _uncomp_size = u32::from_le_bytes(uncomp_size) as usize;
270
271        let mut comp_size = [0u8; 4];
272        reader.read_exact(&mut comp_size)?;
273        let comp_size = u32::from_le_bytes(comp_size) as usize;
274
275        let mut stored_hash = [0u8; 32];
276        reader.read_exact(&mut stored_hash)?;
277
278        let mut compressed = vec![0u8; comp_size];
279        reader.read_exact(&mut compressed)?;
280
281        // Without the `zstd` feature the payload is expected raw; a frame
282        // written by a zstd-enabled build fails hash verification below
283        // instead of silently yielding its compressed bytes.
284        #[cfg(feature = "zstd")]
285        let data = compressor::decompress(&compressed)
286            .map_err(|e| PackError::DecompressionError(e.to_string()))?;
287        #[cfg(not(feature = "zstd"))]
288        let data = compressed;
289
290        let actual_hash = hasher::hash_bytes(&data);
291        if actual_hash != *hash {
292            return Err(PackError::HashMismatch {
293                expected: hash.to_hex(),
294                actual: actual_hash.to_hex(),
295            });
296        }
297
298        Ok(data)
299    }
300
301    /// List all `.pack` files in `pack_dir` (sorted). An absent directory
302    /// yields an empty list.
303    pub fn list_packs(pack_dir: &Path) -> io::Result<Vec<PathBuf>> {
304        if !pack_dir.exists() {
305            return Ok(Vec::new());
306        }
307        let mut packs = Vec::new();
308        for entry in fs::read_dir(pack_dir)? {
309            let entry = entry?;
310            if let Some(name) = entry.file_name().to_str() {
311                if name.ends_with(".pack") {
312                    packs.push(entry.path());
313                }
314            }
315        }
316        packs.sort();
317        Ok(packs)
318    }
319}
320
321/// Cache of loaded pack indices for efficient lookup.
322#[derive(Debug)]
323pub struct PackCache {
324    indices: HashMap<PathBuf, PackIndex>,
325}
326
327impl PackCache {
328    /// An empty cache.
329    #[must_use]
330    pub fn new() -> Self {
331        Self {
332            indices: HashMap::new(),
333        }
334    }
335
336    /// Load all pack indices from the pack directory.
337    pub fn load_all(pack_dir: &Path) -> Result<Self, PackError> {
338        let mut cache = Self::new();
339        let pack_files = PackFile::list_packs(pack_dir)?;
340
341        for pack_path in &pack_files {
342            let idx_path = pack_path.with_extension("idx");
343            if idx_path.exists() {
344                let index = PackIndex::load(&idx_path)?;
345                cache.indices.insert(pack_path.clone(), index);
346            }
347        }
348
349        Ok(cache)
350    }
351
352    /// Find a hash across all loaded pack indices, returning the pack
353    /// path and offset.
354    #[must_use]
355    pub fn find(&self, hash: &Hash) -> Option<(&PathBuf, u64)> {
356        for (pack_path, index) in &self.indices {
357            if let Some(offset) = index.find(hash) {
358                return Some((pack_path, offset));
359            }
360        }
361        None
362    }
363
364    /// List all hashes across all loaded pack indices (sorted, deduped).
365    #[must_use]
366    pub fn all_hashes(&self) -> Vec<Hash> {
367        let mut hashes = Vec::new();
368        for index in self.indices.values() {
369            hashes.extend(index.hashes());
370        }
371        hashes.sort();
372        hashes.dedup();
373        hashes
374    }
375
376    /// Number of pack files loaded.
377    #[must_use]
378    pub fn pack_count(&self) -> usize {
379        self.indices.len()
380    }
381
382    /// Total number of objects across all packs.
383    #[must_use]
384    pub fn object_count(&self) -> usize {
385        self.indices.values().map(PackIndex::len).sum()
386    }
387}
388
389impl Default for PackCache {
390    fn default() -> Self {
391        Self::new()
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    #![allow(clippy::expect_used, clippy::unwrap_used)] // test setup unwraps by design
398    use super::*;
399
400    /// Context type used to convert pack errors into `Box<dyn Error>` so
401    /// tests can use `?` instead of `unwrap`.
402    type TestResult = Result<(), Box<dyn std::error::Error>>;
403
404    fn make_test_objects() -> Vec<(Hash, Vec<u8>)> {
405        vec![
406            {
407                let data = b"hello, world!".to_vec();
408                let hash = hasher::hash_bytes(&data);
409                (hash, data)
410            },
411            {
412                let data = b"second blob content".to_vec();
413                let hash = hasher::hash_bytes(&data);
414                (hash, data)
415            },
416            {
417                let data = vec![0u8; 1024];
418                let hash = hasher::hash_bytes(&data);
419                (hash, data)
420            },
421        ]
422    }
423
424    #[test]
425    fn test_pack_create_and_read() -> TestResult {
426        let dir = tempfile::tempdir()?;
427        let pack_dir = dir.path().join("pack");
428        let objects = make_test_objects();
429
430        let (pack_path, idx_path) = PackFile::create(&pack_dir, &objects)?;
431        assert!(pack_path.exists());
432        assert!(idx_path.exists());
433        assert!(pack_path
434            .to_str()
435            .ok_or("non-utf8 pack path")?
436            .ends_with(".pack"));
437        assert!(idx_path
438            .to_str()
439            .ok_or("non-utf8 idx path")?
440            .ends_with(".idx"));
441
442        let index = PackIndex::load(&idx_path)?;
443        assert_eq!(index.len(), 3);
444
445        for (hash, data) in &objects {
446            let retrieved = PackFile::read_blob(&pack_path, &index, hash)?;
447            assert_eq!(*data, retrieved);
448        }
449        Ok(())
450    }
451
452    #[test]
453    fn test_pack_index_sorted() -> TestResult {
454        let dir = tempfile::tempdir()?;
455        let pack_dir = dir.path().join("pack");
456        let objects = make_test_objects();
457
458        let (_, idx_path) = PackFile::create(&pack_dir, &objects)?;
459        let index = PackIndex::load(&idx_path)?;
460
461        let hashes = index.hashes();
462        let mut sorted = hashes.clone();
463        sorted.sort();
464        assert_eq!(hashes, sorted);
465        Ok(())
466    }
467
468    #[test]
469    fn test_pack_index_find_missing() -> TestResult {
470        let dir = tempfile::tempdir()?;
471        let pack_dir = dir.path().join("pack");
472        let objects = make_test_objects();
473
474        let (_, idx_path) = PackFile::create(&pack_dir, &objects)?;
475        let index = PackIndex::load(&idx_path)?;
476
477        let missing = Hash::from_hex(&"f".repeat(64))?;
478        assert!(index.find(&missing).is_none());
479        Ok(())
480    }
481
482    #[test]
483    fn test_pack_create_empty_fails() {
484        let dir = tempfile::tempdir().expect("tempdir for empty-pack test");
485        let pack_dir = dir.path().join("pack");
486        let result = PackFile::create(&pack_dir, &[]);
487        assert!(matches!(result, Err(PackError::EmptyPack)));
488    }
489
490    #[test]
491    fn test_pack_list_packs() -> TestResult {
492        let dir = tempfile::tempdir()?;
493        let pack_dir = dir.path().join("pack");
494
495        assert_eq!(PackFile::list_packs(&pack_dir)?.len(), 0);
496
497        let objects = make_test_objects();
498        PackFile::create(&pack_dir, &objects)?;
499
500        let packs = PackFile::list_packs(&pack_dir)?;
501        assert_eq!(packs.len(), 1);
502        assert!(packs[0]
503            .to_str()
504            .ok_or("non-utf8 pack path")?
505            .ends_with(".pack"));
506        Ok(())
507    }
508
509    #[test]
510    fn test_pack_cache() -> TestResult {
511        let dir = tempfile::tempdir()?;
512        let pack_dir = dir.path().join("pack");
513        let objects = make_test_objects();
514
515        PackFile::create(&pack_dir, &objects)?;
516
517        let cache = PackCache::load_all(&pack_dir)?;
518        assert_eq!(cache.pack_count(), 1);
519        assert_eq!(cache.object_count(), 3);
520
521        let all_hashes = cache.all_hashes();
522        assert_eq!(all_hashes.len(), 3);
523
524        for (hash, _data) in &objects {
525            let (pack_path, offset) = cache.find(hash).ok_or("expected hash in cache")?;
526            assert!(pack_path.exists());
527            assert!(offset > 0);
528        }
529        Ok(())
530    }
531
532    #[test]
533    fn test_pack_cache_missing() -> TestResult {
534        let dir = tempfile::tempdir()?;
535        let pack_dir = dir.path().join("pack");
536        let objects = make_test_objects();
537
538        PackFile::create(&pack_dir, &objects)?;
539
540        let cache = PackCache::load_all(&pack_dir)?;
541        let missing = Hash::from_hex(&"a".repeat(64))?;
542        assert!(cache.find(&missing).is_none());
543        Ok(())
544    }
545
546    #[test]
547    fn test_pack_invalid_magic() -> TestResult {
548        let dir = tempfile::tempdir()?;
549        let bad_idx = dir.path().join("bad.idx");
550        fs::write(&bad_idx, b"XXXX")?;
551
552        let result = PackIndex::load(&bad_idx);
553        assert!(matches!(result, Err(PackError::InvalidIndexMagic(_))));
554        Ok(())
555    }
556
557    #[test]
558    fn test_pack_single_object() -> TestResult {
559        let dir = tempfile::tempdir()?;
560        let pack_dir = dir.path().join("pack");
561        let data = b"single object".to_vec();
562        let hash = hasher::hash_bytes(&data);
563
564        let (pack_path, idx_path) = PackFile::create(&pack_dir, &[(hash, data.clone())])?;
565
566        let index = PackIndex::load(&idx_path)?;
567        assert_eq!(index.len(), 1);
568
569        let retrieved = PackFile::read_blob(&pack_path, &index, &hash)?;
570        assert_eq!(data, retrieved);
571        Ok(())
572    }
573
574    #[test]
575    fn test_pack_index_corrupt_version() -> TestResult {
576        let dir = tempfile::tempdir()?;
577        let pack_dir = dir.path().join("pack");
578        let objects = make_test_objects();
579        let (_, idx_path) = PackFile::create(&pack_dir, &objects)?;
580
581        // Overwrite the version field (bytes 4..8) with 2.
582        let mut raw = fs::read(&idx_path)?;
583        raw[4..8].copy_from_slice(&2u32.to_le_bytes());
584        fs::write(&idx_path, &raw)?;
585
586        let result = PackIndex::load(&idx_path);
587        assert!(matches!(result, Err(PackError::UnsupportedVersion(2))));
588        Ok(())
589    }
590
591    #[test]
592    fn test_index_len_is_empty_consistency() -> TestResult {
593        let dir = tempfile::tempdir()?;
594        let pack_dir = dir.path().join("pack");
595        let objects = make_test_objects();
596        let (_, idx_path) = PackFile::create(&pack_dir, &objects)?;
597        let index = PackIndex::load(&idx_path)?;
598        assert!(!index.is_empty());
599
600        let empty = PackIndex {
601            entries: Vec::new(),
602        };
603        assert!(empty.is_empty());
604        assert_eq!(empty.hashes().len(), 0);
605        Ok(())
606    }
607}