security: Tier 3 — Android JNI length validation, pyo3 bump, WAL caps
CI / test (push) Failing after 2s

- clawhdf5-android: validate embedding_len/query_embedding_len against
  the handle's configured embedding_dim (and reject null pointers)
  before constructing a slice via from_raw_parts in edgehdf5_save and
  edgehdf5_hybrid_search. Strengthen the # Safety docs to state the
  now-enforced invariant and its limits. Add unit tests covering
  mismatched length and null-pointer rejection.
- clawhdf5-py: bump pyo3/numpy 0.28 -> 0.29, clearing RUSTSEC-2026-0176
  (OOB read in PyList/PyTuple iterator) and RUSTSEC-2026-0177 (missing
  Sync bound on PyCFunction::new_closure). No source changes needed;
  confirmed via cargo audit that both advisories no longer appear.
- clawhdf5-agent/wal.rs: cap read_len_prefixed_str/read_embedding's
  length claims at a new MAX_WAL_FIELD_LEN (64 MiB) before allocating,
  so a corrupted/truncated WAL length field fails cleanly instead of
  attempting a huge allocation. Add regression tests for both.
- BENCHMARKS.md: add a top-of-file traceability note distinguishing the
  dated/hardware-cited/reproducible h5bench and tank-validation sections
  from the older sections that don't yet meet that bar.
This commit is contained in:
Omar Sobh
2026-08-05 12:10:49 -07:00
parent 62595d5ac0
commit a319405ffc
5 changed files with 201 additions and 6 deletions
+50
View File
@@ -12,6 +12,11 @@ use crate::MemoryError;
const WAL_MAGIC: [u8; 4] = [0x45, 0x48, 0x57, 0x4C]; // "EHWL"
const WAL_VERSION: u8 = 1;
/// Upper bound on a single length-prefixed WAL field (string bytes, or
/// embedding element count), to reject a corrupted/truncated WAL length
/// claim before allocating a large buffer for it.
const MAX_WAL_FIELD_LEN: usize = 64 * 1024 * 1024;
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalEntryType {
@@ -349,6 +354,11 @@ fn read_len_prefixed_str(f: &mut File) -> Result<String, MemoryError> {
let mut len_buf = [0u8; 4];
f.read_exact(&mut len_buf)?;
let len = u32::from_le_bytes(len_buf) as usize;
if len > MAX_WAL_FIELD_LEN {
return Err(MemoryError::Schema(format!(
"WAL string field length {len} exceeds max {MAX_WAL_FIELD_LEN}"
)));
}
let mut buf = vec![0u8; len];
f.read_exact(&mut buf)?;
String::from_utf8(buf).map_err(|e| MemoryError::Schema(format!("invalid UTF-8 in WAL: {e}")))
@@ -358,6 +368,12 @@ fn read_embedding(f: &mut File) -> Result<Vec<f32>, MemoryError> {
let mut len_buf = [0u8; 4];
f.read_exact(&mut len_buf)?;
let count = u32::from_le_bytes(len_buf) as usize;
if count > MAX_WAL_FIELD_LEN / 4 {
return Err(MemoryError::Schema(format!(
"WAL embedding element count {count} exceeds max {}",
MAX_WAL_FIELD_LEN / 4
)));
}
let mut vals = Vec::with_capacity(count);
for _ in 0..count {
let mut val_buf = [0u8; 4];
@@ -427,6 +443,40 @@ mod tests {
assert_eq!(entries[2].embedding, vec![5.0, 6.0]);
}
#[test]
fn read_len_prefixed_str_rejects_oversized_len_claim() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("oversized_str.bin");
{
let mut f = File::create(&path).unwrap();
// Claim a length far beyond MAX_WAL_FIELD_LEN; no payload follows.
f.write_all(&(u32::MAX).to_le_bytes()).unwrap();
}
let mut f = File::open(&path).unwrap();
let result = read_len_prefixed_str(&mut f);
assert!(
matches!(result, Err(MemoryError::Schema(_))),
"expected a clean Schema error, got {result:?}"
);
}
#[test]
fn read_embedding_rejects_oversized_count_claim() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("oversized_embedding.bin");
{
let mut f = File::create(&path).unwrap();
// Claim a count far beyond MAX_WAL_FIELD_LEN / 4; no payload follows.
f.write_all(&(u32::MAX).to_le_bytes()).unwrap();
}
let mut f = File::open(&path).unwrap();
let result = read_embedding(&mut f);
assert!(
matches!(result, Err(MemoryError::Schema(_))),
"expected a clean Schema error, got {result:?}"
);
}
#[test]
fn test_wal_truncate() {
let dir = TempDir::new().unwrap();