From 716dcda9acf1620ba821f37b53985b1b136f1473 Mon Sep 17 00:00:00 2001 From: "Bruce J. Hart" Date: Mon, 13 Jul 2026 17:38:14 -0400 Subject: [PATCH] Fail closed on every prune row decode Propagate malformed entry hashes, orphan blob hashes, and pruned external paths instead of silently skipping them. Add regressions proving malformed pruned paths and entry hashes roll back the import transaction. --- src/commands/prune.rs | 9 ++-- tests/integration.rs | 105 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/commands/prune.rs b/src/commands/prune.rs index 398ac84..b8ed74d 100644 --- a/src/commands/prune.rs +++ b/src/commands/prune.rs @@ -61,8 +61,7 @@ pub fn run_prune_with_options( )?; let hashes = stmt .query_map(params![import_id], |row| row.get(0))? - .filter_map(|row| row.ok()) - .collect(); + .collect::>>()?; hashes }; @@ -123,8 +122,7 @@ pub fn run_prune_with_options( let orphan_hashes: Vec = tx .prepare(&sql_orphans)? .query_map(params_vec.as_slice(), |row| row.get(0))? - .filter_map(|row| row.ok()) - .collect(); + .collect::>>()?; if orphan_hashes.is_empty() { continue; @@ -145,8 +143,7 @@ pub fn run_prune_with_options( "SELECT external_path FROM blobs WHERE hash IN ({orphan_placeholders}) AND external_path IS NOT NULL" ))? .query_map(orphan_params.as_slice(), |row| row.get(0))? - .filter_map(|row| row.ok()) - .collect(); + .collect::>>()?; for raw_path in external_paths { let Some(path) = external_path_policy.resolve_file(&raw_path) else { diff --git a/tests/integration.rs b/tests/integration.rs index a00cd39..b374464 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -429,6 +429,111 @@ fn test_prune_rolls_back_on_malformed_surviving_external_path() { assert_eq!(orphan_count, 1); } +#[test] +fn test_prune_rolls_back_on_malformed_pruned_external_path() { + let tmp = TempDir::new().unwrap(); + let db_path = tmp.path().join("test.db"); + let bodies_dir = tmp.path().join("bodies"); + let external_path = bodies_dir.join("external-body"); + fs::create_dir(&bodies_dir).unwrap(); + fs::write(&external_path, b"external").unwrap(); + + harlite() + .args(["import", "tests/fixtures/simple.har", "--bodies", "-o"]) + .arg(&db_path) + .assert() + .success(); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + let import_id: i64 = conn + .query_row("SELECT id FROM imports LIMIT 1", [], |row| row.get(0)) + .unwrap(); + let external = external_path.to_string_lossy(); + conn.execute( + "INSERT INTO blobs(hash, content, size, mime_type, external_path) VALUES('malformed-path', X'', 8, 'text/plain', CAST(?1 AS BLOB))", + [external.as_ref()], + ) + .unwrap(); + conn.execute( + "UPDATE entries SET request_body_hash='malformed-path', request_body_size=8 WHERE id=(SELECT MIN(id) FROM entries)", + [], + ) + .unwrap(); + drop(conn); + + harlite() + .args([ + "prune", + "--import-id", + &import_id.to_string(), + "--allow-external-paths", + "--external-path-root", + ]) + .arg(&bodies_dir) + .arg(&db_path) + .assert() + .failure(); + + assert!(external_path.exists()); + let conn = rusqlite::Connection::open(&db_path).unwrap(); + let import_count: i64 = conn + .query_row("SELECT COUNT(*) FROM imports", [], |row| row.get(0)) + .unwrap(); + let blob_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM blobs WHERE hash='malformed-path'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(import_count, 1); + assert_eq!(blob_count, 1); +} + +#[test] +fn test_prune_rolls_back_on_malformed_entry_hash() { + let tmp = TempDir::new().unwrap(); + let db_path = tmp.path().join("test.db"); + + harlite() + .args(["import", "tests/fixtures/simple.har", "--bodies", "-o"]) + .arg(&db_path) + .assert() + .success(); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + let import_id: i64 = conn + .query_row("SELECT id FROM imports LIMIT 1", [], |row| row.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO blobs(hash, content, size, mime_type) VALUES(CAST('malformed-hash' AS BLOB), X'', 0, 'text/plain')", + [], + ) + .unwrap(); + conn.execute( + "UPDATE entries SET request_body_hash=CAST('malformed-hash' AS BLOB) WHERE id=(SELECT MIN(id) FROM entries)", + [], + ) + .unwrap(); + drop(conn); + + harlite() + .args(["prune", "--import-id", &import_id.to_string()]) + .arg(&db_path) + .assert() + .failure(); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + let import_count: i64 = conn + .query_row("SELECT COUNT(*) FROM imports", [], |row| row.get(0)) + .unwrap(); + let entry_count: i64 = conn + .query_row("SELECT COUNT(*) FROM entries", [], |row| row.get(0)) + .unwrap(); + assert_eq!(import_count, 1); + assert_eq!(entry_count, 2); +} + #[cfg(unix)] #[test] fn test_project_config_cannot_implicitly_execute_plugin() {