1use 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#[derive(Error, Debug)]
46pub enum PackError {
47 #[error("invalid pack magic: {0}")]
49 InvalidMagic(String),
50 #[error("unsupported pack version: {0}")]
52 UnsupportedVersion(u32),
53 #[error("invalid index magic: {0}")]
55 InvalidIndexMagic(String),
56 #[error("blob not found in pack: {0}")]
58 BlobNotFound(String),
59 #[error("I/O error: {0}")]
61 Io(#[from] io::Error),
62 #[error("compression error: {0}")]
64 CompressionError(String),
65 #[error("decompression error: {0}")]
67 DecompressionError(String),
68 #[error("cannot create empty pack")]
70 EmptyPack,
71 #[error("unexpected object type: {0}")]
73 UnexpectedObjectType(u8),
74 #[error("hash mismatch in pack: expected {expected}, got {actual}")]
76 HashMismatch {
77 expected: String,
79 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#[derive(Clone, Debug)]
97pub struct PackIndex {
98 entries: Vec<PackIndexEntry>,
99}
100
101impl PackIndex {
102 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 #[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 #[must_use]
154 pub fn hashes(&self) -> Vec<Hash> {
155 self.entries.iter().map(|e| e.hash).collect()
156 }
157
158 #[must_use]
160 pub fn len(&self) -> usize {
161 self.entries.len()
162 }
163
164 #[must_use]
166 pub fn is_empty(&self) -> bool {
167 self.entries.is_empty()
168 }
169}
170
171pub struct PackFile;
173
174impl PackFile {
175 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 #[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 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 #[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 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#[derive(Debug)]
323pub struct PackCache {
324 indices: HashMap<PathBuf, PackIndex>,
325}
326
327impl PackCache {
328 #[must_use]
330 pub fn new() -> Self {
331 Self {
332 indices: HashMap::new(),
333 }
334 }
335
336 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 #[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 #[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 #[must_use]
378 pub fn pack_count(&self) -> usize {
379 self.indices.len()
380 }
381
382 #[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)] use super::*;
399
400 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 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}