1use crate::error::{BlobError, Result, StorageError};
9use std::fs;
10use std::path::{Path, PathBuf};
11use zstd::stream::{decode_all, encode_all};
12
13const ZSTD_LEVEL: i32 = 3;
16
17const HASH_HEX_LEN: usize = 64;
19
20fn is_valid_hash(hash: &str) -> bool {
27 hash.len() == HASH_HEX_LEN && hash.bytes().all(|b| b.is_ascii_hexdigit())
28}
29
30pub struct BlobStore {
39 blobs_dir: PathBuf,
40 redactor: Option<std::sync::Arc<crate::redact::Detectors>>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct PutOutcome {
46 pub hash: String,
48 pub size: u64,
50}
51
52impl BlobStore {
53 pub fn new(blobs_dir: PathBuf) -> Self {
56 Self {
57 blobs_dir,
58 redactor: None,
59 }
60 }
61
62 pub fn with_redactor(
65 blobs_dir: PathBuf,
66 redactor: std::sync::Arc<crate::redact::Detectors>,
67 ) -> Self {
68 Self {
69 blobs_dir,
70 redactor: Some(redactor),
71 }
72 }
73
74 pub(crate) fn blob_path(&self, hash: &str) -> PathBuf {
76 let prefix = &hash[..2];
77 self.blobs_dir.join(prefix).join(format!("{hash}.zst"))
78 }
79
80 #[must_use]
84 pub fn hash(content: &[u8]) -> String {
85 blake3::hash(content).to_hex().to_string()
86 }
87
88 pub fn put(&self, content: &[u8]) -> Result<PutOutcome> {
99 if let Some(redactor) = &self.redactor {
100 if let Some(redacted) = redactor.redact_bytes(content) {
101 return self.put_raw(&redacted.bytes);
102 }
103 }
104 self.put_raw(content)
105 }
106
107 fn put_raw(&self, content: &[u8]) -> Result<PutOutcome> {
109 let hash = blake3::hash(content).to_hex().to_string();
110 let size = u64::try_from(content.len()).unwrap_or(u64::MAX);
111 let path = self.blob_path(&hash);
112 if !path.exists() {
113 Self::write_blob(&path, content)?;
114 }
115 Ok(PutOutcome { hash, size })
116 }
117
118 pub fn get(&self, hash: &str) -> Result<Vec<u8>> {
120 if !is_valid_hash(hash) {
121 return Err(BlobError::HashMismatch {
122 expected: format!("{HASH_HEX_LEN}-char blake3 hex"),
123 actual: hash.to_string(),
124 }
125 .into());
126 }
127 let path = self.blob_path(hash);
128 let compressed = match fs::read(&path) {
129 Ok(b) => b,
130 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
131 return Err(StorageError::MissingBlob(hash.to_string()).into());
132 }
133 Err(e) => {
134 return Err(BlobError::Io { path, source: e }.into());
135 }
136 };
137 let decompressed =
138 decode_all(&compressed[..]).map_err(|e| BlobError::Zstd(e.to_string()))?;
139 let actual = blake3::hash(&decompressed).to_hex().to_string();
141 if actual != hash {
142 return Err(BlobError::HashMismatch {
143 expected: hash.to_string(),
144 actual,
145 }
146 .into());
147 }
148 Ok(decompressed)
149 }
150
151 fn write_blob(path: &Path, content: &[u8]) -> Result<()> {
152 let parent = path.parent().ok_or_else(|| BlobError::Io {
153 path: path.to_path_buf(),
154 source: std::io::Error::new(
155 std::io::ErrorKind::InvalidInput,
156 "blob path has no parent",
157 ),
158 })?;
159 create_dir_secure(parent)?;
161 let compressed =
163 encode_all(content, ZSTD_LEVEL).map_err(|e| BlobError::Zstd(e.to_string()))?;
164 write_file_secure(path, &compressed)
165 }
166
167 pub fn remove_if_unreferenced(&self, hash: &str, refcount: i64) -> Result<bool> {
171 if refcount > 0 {
172 return Ok(false);
173 }
174 if !is_valid_hash(hash) {
175 return Err(BlobError::HashMismatch {
176 expected: format!("{HASH_HEX_LEN}-char blake3 hex"),
177 actual: hash.to_string(),
178 }
179 .into());
180 }
181 let path = self.blob_path(hash);
182 match fs::remove_file(&path) {
183 Ok(()) => {
184 if let Some(shard) = path.parent() {
186 let _ = fs::remove_dir(shard);
187 }
188 Ok(true)
189 }
190 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
191 Err(e) => Err(BlobError::Io { path, source: e }.into()),
192 }
193 }
194
195 pub fn shred(&self, hash: &str) -> Result<bool> {
203 if !is_valid_hash(hash) {
204 return Err(BlobError::HashMismatch {
205 expected: format!("{HASH_HEX_LEN}-char blake3 hex"),
206 actual: hash.to_string(),
207 }
208 .into());
209 }
210 let path = self.blob_path(hash);
211 let len = match fs::metadata(&path) {
212 Ok(m) => m.len(),
213 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
214 Err(e) => return Err(BlobError::Io { path, source: e }.into()),
215 };
216 {
219 use std::io::Write;
220 let mut f = fs::OpenOptions::new()
221 .write(true)
222 .open(&path)
223 .map_err(|e| BlobError::Io {
224 path: path.clone(),
225 source: e,
226 })?;
227 let zeros = vec![0u8; usize::try_from(len).unwrap_or(0)];
228 f.write_all(&zeros).map_err(|e| BlobError::Io {
229 path: path.clone(),
230 source: e,
231 })?;
232 f.sync_all().map_err(|e| BlobError::Io {
233 path: path.clone(),
234 source: e,
235 })?;
236 }
237 match fs::remove_file(&path) {
238 Ok(()) => {
239 if let Some(shard) = path.parent() {
240 let _ = fs::remove_dir(shard);
241 }
242 Ok(true)
243 }
244 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
245 Err(e) => Err(BlobError::Io { path, source: e }.into()),
246 }
247 }
248
249 pub fn root(&self) -> &Path {
251 &self.blobs_dir
252 }
253
254 pub fn iter_hashes(&self) -> Result<Vec<String>> {
262 let mut out = Vec::new();
263 if !self.blobs_dir.exists() {
264 return Ok(out);
265 }
266 for shard in fs::read_dir(&self.blobs_dir).map_err(|e| BlobError::Io {
267 path: self.blobs_dir.clone(),
268 source: e,
269 })? {
270 let shard = shard.map_err(|e| BlobError::Io {
271 path: self.blobs_dir.clone(),
272 source: e,
273 })?;
274 if !shard.file_type().is_ok_and(|t| t.is_dir()) {
275 continue;
276 }
277 let shard_path = shard.path();
278 for entry in fs::read_dir(&shard_path).map_err(|e| BlobError::Io {
279 path: shard_path.clone(),
280 source: e,
281 })? {
282 let entry = entry.map_err(|e| BlobError::Io {
283 path: shard_path.clone(),
284 source: e,
285 })?;
286 if let Some(hash) = entry.file_name().to_string_lossy().strip_suffix(".zst") {
287 if is_valid_hash(hash) {
288 out.push(hash.to_string());
289 }
290 }
291 }
292 }
293 Ok(out)
294 }
295}
296
297fn create_dir_secure(path: &Path) -> Result<()> {
300 #[cfg(unix)]
301 {
302 use std::os::unix::fs::DirBuilderExt;
303 std::fs::DirBuilder::new()
304 .recursive(true)
305 .mode(0o700)
306 .create(path)
307 .map_err(|e| BlobError::Io {
308 path: path.to_path_buf(),
309 source: e,
310 })?;
311 }
312 #[cfg(not(unix))]
313 {
314 std::fs::create_dir_all(path).map_err(|e| BlobError::Io {
315 path: path.to_path_buf(),
316 source: e,
317 })?;
318 }
319 Ok(())
320}
321
322fn write_file_secure(path: &Path, bytes: &[u8]) -> Result<()> {
325 let tmp = path.with_extension("zst.tmp");
326 {
327 #[cfg(unix)]
328 {
329 use std::io::Write;
330 use std::os::unix::fs::OpenOptionsExt;
331 let mut f = std::fs::OpenOptions::new()
332 .write(true)
333 .create_new(true)
334 .mode(0o600)
335 .open(&tmp)
336 .map_err(|e| BlobError::Io {
337 path: tmp.clone(),
338 source: e,
339 })?;
340 f.write_all(bytes).map_err(|e| BlobError::Io {
341 path: tmp.clone(),
342 source: e,
343 })?;
344 f.sync_all().map_err(|e| BlobError::Io {
345 path: tmp.clone(),
346 source: e,
347 })?;
348 }
349 #[cfg(not(unix))]
350 {
351 use std::io::Write;
352 let mut f = std::fs::File::create(&tmp).map_err(|e| BlobError::Io {
353 path: tmp.clone(),
354 source: e,
355 })?;
356 f.write_all(bytes).map_err(|e| BlobError::Io {
357 path: tmp.clone(),
358 source: e,
359 })?;
360 f.sync_all().map_err(|e| BlobError::Io {
361 path: tmp.clone(),
362 source: e,
363 })?;
364 }
365 }
366 fs::rename(&tmp, path).map_err(|e| BlobError::Io {
367 path: path.to_path_buf(),
368 source: e,
369 })?;
370 #[cfg(unix)]
372 {
373 use std::os::unix::fs::PermissionsExt;
374 let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
375 }
376 Ok(())
377}
378
379#[cfg(feature = "fuzzing")]
383pub mod fuzzing {
384 use super::{BlobStore, HASH_HEX_LEN};
385 use std::sync::OnceLock;
386
387 fn store() -> &'static BlobStore {
388 static STORE: OnceLock<BlobStore> = OnceLock::new();
389 STORE.get_or_init(|| {
390 let dir = std::env::temp_dir().join(format!("hh-fuzz-blob-{}", std::process::id()));
391 BlobStore::new(dir)
392 })
393 }
394
395 pub fn fuzz_get_arbitrary_hash(hash: &str) {
400 let _ = store().get(hash);
401 }
402
403 pub fn fuzz_decompress(bytes: &[u8]) {
409 let s = store();
410 let hash = "0".repeat(HASH_HEX_LEN);
411 let path = s.blob_path(&hash);
412 if let Some(parent) = path.parent() {
413 let _ = std::fs::create_dir_all(parent);
414 }
415 if std::fs::write(&path, bytes).is_ok() {
416 let _ = s.get(&hash);
417 }
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use tempfile::TempDir;
425
426 fn store() -> (TempDir, BlobStore) {
427 let tmp = TempDir::new().unwrap();
428 let s = BlobStore::new(tmp.path().join("blobs"));
429 (tmp, s)
430 }
431
432 #[test]
433 fn put_get_roundtrip_small() {
434 let (_tmp, s) = store();
435 let content = b"hello halfhand blobs";
436 let out = s.put(content).unwrap();
437 assert_eq!(out.size, content.len() as u64);
438 let p = s.blob_path(&out.hash);
440 assert!(p.exists());
441 assert!(p.starts_with(s.root()));
442 assert_eq!(p.parent().unwrap().file_name().unwrap().len(), 2);
443 let got = s.get(&out.hash).unwrap();
444 assert_eq!(got, content);
445 }
446
447 #[test]
448 fn put_get_roundtrip_compressible() {
449 let (_tmp, s) = store();
450 let content = "a".repeat(64 * 1024).into_bytes();
452 let out = s.put(&content).unwrap();
453 let on_disk = fs::read(s.blob_path(&out.hash)).unwrap();
454 assert!(
455 on_disk.len() < content.len(),
456 "zstd should compress repetitive input"
457 );
458 let got = s.get(&out.hash).unwrap();
459 assert_eq!(got, content);
460 }
461
462 #[test]
463 fn put_is_idempotent_on_disk() {
464 let (_tmp, s) = store();
465 let out1 = s.put(b"same content").unwrap();
466 let out2 = s.put(b"same content").unwrap();
467 assert_eq!(out1.hash, out2.hash);
468 assert_eq!(fs::read_dir(s.root()).unwrap().count(), 1);
470 }
471
472 #[test]
473 fn get_missing_blob_errors() {
474 let (_tmp, s) = store();
475 let h = "a".repeat(64);
476 let err = s.get(&h).unwrap_err();
477 assert!(matches!(
478 err,
479 crate::Error::Storage(StorageError::MissingBlob(_))
480 ));
481 }
482
483 #[test]
484 fn get_detects_corruption() {
485 let (_tmp, s) = store();
486 let out = s.put(b"original content").unwrap();
487 let path = s.blob_path(&out.hash);
488 let bad = zstd::stream::encode_all(b"different content".as_ref(), 3).unwrap();
490 fs::write(&path, &bad).unwrap();
491 let err = s.get(&out.hash).unwrap_err();
492 assert!(matches!(
493 err,
494 crate::Error::Blob(BlobError::HashMismatch { .. })
495 ));
496 }
497
498 #[test]
499 fn remove_deletes_when_unreferenced() {
500 let (_tmp, s) = store();
501 let out = s.put(b"to be deleted").unwrap();
502 let path = s.blob_path(&out.hash);
503 assert!(path.exists());
504 assert!(s.remove_if_unreferenced(&out.hash, 0).unwrap());
505 assert!(!path.exists());
506 assert!(!s.remove_if_unreferenced(&out.hash, 0).unwrap());
508 }
509
510 #[test]
511 fn remove_keeps_when_still_referenced() {
512 let (_tmp, s) = store();
513 let out = s.put(b"still referenced").unwrap();
514 let path = s.blob_path(&out.hash);
515 assert!(!s.remove_if_unreferenced(&out.hash, 1).unwrap());
516 assert!(path.exists());
517 }
518
519 #[test]
520 fn put_with_redactor_stores_redacted_content_under_its_own_hash() {
521 let tmp = TempDir::new().unwrap();
522 let redactor = std::sync::Arc::new(
523 crate::redact::Detectors::new(&crate::config::RedactionConfig::default()).unwrap(),
524 );
525 let s = BlobStore::with_redactor(tmp.path().join("blobs"), redactor);
526 let secret = "AKIAIOSFODNN7EXAMPLE";
527 let content = format!("creds: {secret}\n");
528 let out = s.put(content.as_bytes()).unwrap();
529 assert_ne!(out.hash, BlobStore::hash(content.as_bytes()));
531 let stored = s.get(&out.hash).unwrap();
532 let text = String::from_utf8(stored).unwrap();
533 assert!(!text.contains(secret), "secret must not hit disk: {text}");
534 assert!(text.contains("{{REDACTED:aws-access-key-id:"));
535 assert_eq!(out.size, text.len() as u64);
536 let clean = s.put(b"nothing sensitive").unwrap();
538 assert_eq!(clean.hash, BlobStore::hash(b"nothing sensitive"));
539 let binary = [0u8, 1, 2, 255];
540 let bin_out = s.put(&binary).unwrap();
541 assert_eq!(bin_out.hash, BlobStore::hash(&binary));
542 }
543
544 #[test]
545 fn shred_overwrites_then_removes() {
546 let (_tmp, s) = store();
547 let out = s.put(b"secret to shred").unwrap();
548 let path = s.blob_path(&out.hash);
549 assert!(path.exists());
550 assert!(s.shred(&out.hash).unwrap());
551 assert!(!path.exists());
552 assert!(!s.shred(&out.hash).unwrap());
554 assert!(s.shred("../../etc/passwd").is_err());
556 }
557
558 #[cfg(unix)]
559 #[test]
560 fn blob_files_are_0600_and_dirs_0700() {
561 use std::os::unix::fs::PermissionsExt;
562 let (_tmp, s) = store();
563 let out = s.put(b"secret bytes").unwrap();
564 let path = s.blob_path(&out.hash);
565 let mode = fs::metadata(&path).unwrap().permissions().mode();
566 assert_eq!(mode & 0o777, 0o600);
567 let shard = path.parent().unwrap();
568 let dmode = fs::metadata(shard).unwrap().permissions().mode();
569 assert_eq!(dmode & 0o777, 0o700);
570 }
571}