diff --git a/crates/ledger-store/src/blob.rs b/crates/ledger-store/src/blob.rs index 4c6091d..d1ddb7f 100644 --- a/crates/ledger-store/src/blob.rs +++ b/crates/ledger-store/src/blob.rs @@ -33,18 +33,32 @@ impl BlobStore { } } - fn path_for(&self, sha_hex: &str) -> PathBuf { - self.root - .join(&sha_hex[0..2]) - .join(&sha_hex[2..4]) - .join(sha_hex) + /// Where a blob lives, or `None` for anything that isn't a blob id. + /// + /// ```text + /// GOAL: map a blob id to its file, and nothing else to anything. + /// SOURCE: docs/receipt-store.md (content-addressed by SHA-256); the + /// max-testing pass (2026-10-13): ids come from the journal, + /// untrusted on disk, and a short one panicked. + /// INVARIANTS: + /// 1. NEVER a path outside the blob directory, whatever the input. + /// 2. ONLY a 64-hex-digit id (a SHA-256) names a blob. + /// ``` + fn path_for(&self, sha_hex: &str) -> Option { + let valid = sha_hex.len() == 64 && sha_hex.bytes().all(|b| b.is_ascii_hexdigit()); + valid.then(|| { + self.root + .join(&sha_hex[0..2]) + .join(&sha_hex[2..4]) + .join(sha_hex) + }) } /// Store bytes. Returns `(sha256_hex, deduplicated)`. Identical bytes /// store once: the second put is a no-op that reports `deduplicated`. pub fn put(&self, bytes: &[u8]) -> Result<(String, bool), StoreError> { let sha = sha256_hex(bytes); - let path = self.path_for(&sha); + let path = self.path_for(&sha).expect("a SHA-256 is a blob id"); if path.exists() { return Ok((sha, true)); } @@ -75,7 +89,10 @@ impl BlobStore { /// is reported — never served. pub fn get(&self, sha_hex: &str) -> Result, StoreError> { let sha_hex = sha_hex.to_lowercase(); - let bytes = fs::read(self.path_for(&sha_hex))?; + let path = self + .path_for(&sha_hex) + .ok_or_else(|| StoreError::CorruptBlob(sha_hex.clone()))?; + let bytes = fs::read(path)?; let actual = sha256_hex(&bytes); if actual != sha_hex { return Err(StoreError::CorruptBlob(sha_hex)); @@ -86,6 +103,7 @@ impl BlobStore { /// True when the blob exists (content not verified; use [`Self::get`] /// for a verifying read). pub fn exists(&self, sha_hex: &str) -> bool { - self.path_for(&sha_hex.to_lowercase()).exists() + self.path_for(&sha_hex.to_lowercase()) + .is_some_and(|p| p.exists()) } } diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 3eae5c0..9d9d6c9 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1114,3 +1114,37 @@ fn a_second_writer_is_refused_while_the_first_has_the_books() { reopened.verify().unwrap(); assert_eq!(reopened.ledger().chart().len(), 1); } + +/// Security (rank 2; max-testing pass on the store, 2026-10-13): a blob id +/// comes from the journal, which is untrusted input on disk. Whatever the +/// id, the blob store never panics and never touches a path outside its +/// own directory: only a 64-hex-digit id names a blob. +#[test] +fn a_blob_id_never_reaches_outside_the_blob_store() { + let dir = test_dir("blob-ids"); + let blobs = ledger_store::BlobStore::open(&dir.join("blobs")).unwrap(); + let outside = dir.join("secret.txt"); + std::fs::write(&outside, b"not a blob").unwrap(); + for id in [ + "", + "a", + "ab", + "abc", + "é", + "日本語", + "../secret.txt", + "../../secret.txt", + "ab/../../secret.txt", + "/etc/passwd", + "zz", + &"g".repeat(64), + &"a".repeat(63), + &"a".repeat(65), + ] { + assert!(blobs.get(id).is_err(), "{id:?} was served"); + assert!(!blobs.exists(id), "{id:?} probed a path"); + } + let (sha, _) = blobs.put(b"receipt").unwrap(); + assert_eq!(blobs.get(&sha).unwrap(), b"receipt"); + assert_eq!(blobs.get(&sha.to_uppercase()).unwrap(), b"receipt"); +}