#![cfg(feature = "store")] use graph_r::format::{base, wal};
use graph_r::prelude::*;
use graph_r::writer::{MAX_EDGES, MAX_LABEL, MAX_SEGS};
fn seed(dir: &std::path::Path) -> Store {
let store = Store::create(dir, Config::default()).unwrap();
{
let mut w = store.writer().unwrap();
let url = "https://x.dev/a";
w.upsert_node(&DocRecord {
url,
url_key: UrlKey::of(url),
content_hash: 7,
fetched_at_ms: 1,
title: Some("A"),
snippet: Some("body"),
etag: Some("\"v1\""),
pinned: false,
})
.unwrap();
w.commit().unwrap();
}
store.compact().unwrap();
store
}
#[test]
fn a_commit_under_another_writers_lock_is_refused() {
let dir = tempfile::tempdir().unwrap();
let first = seed(dir.path());
let cfg = Config { lock_wait: std::time::Duration::from_millis(20), ..Config::default() };
let second = Store::open_cfg(dir.path(), cfg).expect("a second writable handle opens");
let wal = dir.path().join(graph_r::store::WAL_FILE);
let before = std::fs::read(&wal).unwrap();
let held = first.commit_lock().unwrap();
let mut w = second.writer().unwrap();
let url = "https://x.dev/b";
w.upsert_node(&DocRecord {
url,
url_key: UrlKey::of(url),
content_hash: 1,
fetched_at_ms: 1,
title: None,
snippet: None,
etag: None,
pinned: false,
})
.unwrap();
match w.commit() {
Err(Error::Locked) => {}
Ok(_) => panic!("a commit landed under another writer's lock"),
Err(e) => panic!("expected Error::Locked, got {e}"),
}
assert_eq!(std::fs::read(&wal).unwrap(), before, "a refused commit wrote nothing");
drop(held);
w.commit().expect("the staged op commits once the lock is free");
drop(w);
assert!(first.reload_if_stale().unwrap());
assert_eq!(first.len(), 2);
}
#[test]
fn read_only_store_refuses_every_mutation() {
let dir = tempfile::tempdir().unwrap();
let writer = seed(dir.path());
drop(writer);
let ro = Store::open_read_only(dir.path()).unwrap();
assert!(matches!(ro.writer().err(), Some(Error::ReadOnly)), "writer() must refuse");
assert!(matches!(ro.compact().err(), Some(Error::ReadOnly)), "compact() must refuse");
let snap = ro.snapshot();
assert_eq!(snap.len(), 1);
}
#[test]
fn read_only_open_does_not_take_the_writer_lock() {
let dir = tempfile::tempdir().unwrap();
let _writer = seed(dir.path()); let ro = Store::open_read_only(dir.path()).expect("read-only must open under a live writer");
assert_eq!(ro.snapshot().len(), 1);
}
#[test]
fn bit_flip_anywhere_in_the_base_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let store = seed(dir.path());
drop(store);
let good = std::fs::read(dir.path().join("graph.base")).unwrap();
for i in 0..good.len() {
let mut bad = good.clone();
bad[i] ^= 0x01;
assert!(base::validate(&bad).is_err(), "flipped byte {i} was accepted");
}
}
#[test]
fn truncation_at_any_offset_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let store = seed(dir.path());
drop(store);
let good = std::fs::read(dir.path().join("graph.base")).unwrap();
for cut in 0..good.len() {
assert!(base::validate(&good[..cut]).is_err(), "truncation at {cut} was accepted");
}
}
#[test]
fn arbitrary_bytes_as_a_base_file_never_panic() {
for bytes in [Vec::new(), vec![0u8; 128], vec![0xffu8; 4096], b"GRPR".to_vec(), {
let mut v = b"GRPR".to_vec();
v.extend_from_slice(&u32::MAX.to_le_bytes());
v.resize(1024, 0xab);
v
}] {
let _ = base::validate(&bytes);
}
}
#[test]
fn torn_wal_tail_recovers_the_committed_prefix() {
let dir = tempfile::tempdir().unwrap();
let store = seed(dir.path());
{
let mut w = store.writer().unwrap();
let url = "https://x.dev/b";
w.upsert_node(&DocRecord {
url,
url_key: UrlKey::of(url),
content_hash: 9,
fetched_at_ms: 2,
title: Some("B"),
snippet: None,
etag: None,
pinned: false,
})
.unwrap();
w.commit().unwrap();
}
drop(store);
let wal_path = dir.path().join("graph.wal");
let mut raw = std::fs::read(&wal_path).unwrap();
let intact_len = raw.len();
raw.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef, 0x00, 0x01, 0x02]);
std::fs::write(&wal_path, &raw).unwrap();
let image = std::fs::read(&wal_path).unwrap();
let replayed = wal::replay(&image).unwrap();
assert_eq!(replayed.good_len, intact_len, "torn tail must not be replayed");
let reopened = Store::open(dir.path()).unwrap();
assert_eq!(reopened.snapshot().len(), 2);
assert_eq!(
std::fs::metadata(&wal_path).unwrap().len(),
intact_len as u64,
"a writable open must truncate the torn tail"
);
}
#[test]
fn wal_sequence_regression_is_corruption() {
let mut image = wal::encode_header(1, 0);
for (first, key) in [(5, 1), (4, 2)] {
let mut rec = wal::RecordBuf::new();
rec.push_remove(UrlKey(key));
rec.seal(first).unwrap();
image.extend_from_slice(rec.as_bytes());
}
assert!(matches!(wal::replay(&image), Err(Error::Corrupt { .. })));
}
fn reseal(rec: &mut [u8]) {
let mut h = graph_r::hash::Hasher::new();
h.update(&rec[..24]);
h.update(&rec[wal::RECORD_HEADER_LEN..]);
let sum = h.digest64();
rec[24..32].copy_from_slice(&sum.to_le_bytes());
}
type Lie = fn(&mut Vec<u8>);
#[test]
fn a_lying_record_is_corrupt() {
let mut b = wal::RecordBuf::new();
for url in ["https://x.dev/a", "https://x.dev/b"] {
b.push_upsert(
&DocRecord {
url,
url_key: UrlKey::of(url),
content_hash: 1,
fetched_at_ms: 1,
title: Some("T"),
snippet: None,
etag: None,
pinned: false,
},
0,
)
.unwrap();
}
b.seal(1).unwrap();
let good = b.into_vec();
let lies: [(&str, Lie); 4] = [
("op_count high", |r| r[16] = 3),
("op_count huge", |r| r[16..20].copy_from_slice(&u32::MAX.to_le_bytes())),
("url overlaps the title", |r| r[32 + 36] += 1),
("title runs past the op", |r| r[32 + 38] = 0xff),
];
for (lie, tamper) in lies {
let mut rec = good.clone();
tamper(&mut rec);
reseal(&mut rec);
let mut image = wal::encode_header(1, 0);
image.extend_from_slice(&rec);
match wal::replay(&image) {
Err(Error::Corrupt { message }) => {
assert!(message.contains("byte 64"), "{lie}: {message}");
}
other => panic!("{lie}: {other:?}"),
}
assert_eq!(wal::committed_len(&image), wal::HEADER_LEN, "{lie}: shipped as committed");
}
}
#[test]
fn hostile_postings_error_instead_of_over_allocating() {
assert!(base::decode_postings(&[0xff; 64]).is_err());
let mut hostile = Vec::new();
graph_r::bytesio::put_varint(&mut hostile, 1);
graph_r::bytesio::put_varint(&mut hostile, u64::MAX);
assert!(base::decode_postings(&hostile).is_err());
}
#[test]
fn oversized_wal_record_length_is_not_allocated() {
let mut image = wal::encode_header(1, 0);
image.extend_from_slice(&u32::MAX.to_le_bytes()); image.extend_from_slice(&0x5854u16.to_le_bytes()); image.extend_from_slice(&[1, 0]); image.extend_from_slice(&1u64.to_le_bytes()); image.extend_from_slice(&1u32.to_le_bytes()); image.resize(wal::HEADER_LEN + wal::RECORD_HEADER_LEN, 0);
let replayed = wal::replay(&image).unwrap();
assert_eq!(replayed.records().count(), 0, "an unbacked record must not be replayed");
assert_eq!(replayed.good_len, wal::HEADER_LEN);
}
#[test]
fn oversized_labels_are_refused() {
let dir = tempfile::tempdir().unwrap();
let store = Store::create(dir.path(), Config::default()).unwrap();
let mut w = store.writer().unwrap();
let huge = "x".repeat(MAX_LABEL + 1);
let err = w
.upsert_node(&DocRecord {
url: &huge,
url_key: UrlKey::of(&huge),
content_hash: 0,
fetched_at_ms: 0,
title: None,
snippet: None,
etag: None,
pinned: false,
})
.unwrap_err();
assert!(matches!(err, Error::Format { .. }), "over-long url must be a format error");
let err = w
.upsert_node(&DocRecord {
url: "",
url_key: UrlKey::of(""),
content_hash: 0,
fetched_at_ms: 0,
title: None,
snippet: None,
etag: None,
pinned: false,
})
.unwrap_err();
assert!(matches!(err, Error::Format { .. }), "empty url must be a format error");
}
#[test]
fn too_many_segments_is_an_error_not_a_silent_drop() {
let dir = tempfile::tempdir().unwrap();
let store = Store::create(dir.path(), Config::default()).unwrap();
let mut w = store.writer().unwrap();
let url = "https://x.dev/a";
w.upsert_node(&DocRecord {
url,
url_key: UrlKey::of(url),
content_hash: 0,
fetched_at_ms: 0,
title: None,
snippet: None,
etag: None,
pinned: false,
})
.unwrap();
let labels: Vec<String> = (0..=MAX_SEGS).map(|i| format!("H{i}")).collect();
let segs: Vec<SegmentRecord<'_>> = labels
.iter()
.map(|l| SegmentRecord { label: l, byte_range: None, depth: 1, importance: 1 })
.collect();
assert!(matches!(w.set_segments(UrlKey::of(url), &segs), Err(Error::Format { .. })));
}
#[test]
fn edge_count_is_capped_in_document_order() {
let dir = tempfile::tempdir().unwrap();
let store = Store::create(dir.path(), Config::default()).unwrap();
let url = "https://x.dev/hub";
let targets: Vec<String> = (0..MAX_EDGES * 4).map(|i| format!("https://x.dev/t{i}")).collect();
{
let mut w = store.writer().unwrap();
w.upsert_node(&DocRecord {
url,
url_key: UrlKey::of(url),
content_hash: 0,
fetched_at_ms: 0,
title: None,
snippet: None,
etag: None,
pinned: false,
})
.unwrap();
let edges: Vec<(UrlKey, EdgeType, u16)> =
targets.iter().map(|t| (UrlKey::of(t), EdgeType::Link, 65_535)).collect();
w.set_edges(UrlKey::of(url), &edges);
w.commit().unwrap();
}
let snap = store.snapshot();
let mut count = 0usize;
let mut seen: Vec<u64> = Vec::new();
let mut it = snap.neighbors(UrlKey::of(url));
while let Some(e) = graph_r::traverse::LendingIterator::next(&mut it) {
seen.push(e.dst_key.0);
count += 1;
}
assert_eq!(count, MAX_EDGES, "edge set must be capped at MAX_EDGES");
let kept_prefix: std::collections::HashSet<u64> =
targets.iter().take(MAX_EDGES).map(|t| UrlKey::of(t).0).collect();
assert!(seen.iter().all(|k| kept_prefix.contains(k)), "cap must keep the prominent prefix");
}
#[test]
fn only_supplied_metadata_reaches_the_base_file() {
let dir = tempfile::tempdir().unwrap();
let store = Store::create(dir.path(), Config::default()).unwrap();
let url = "https://x.dev/page";
{
let mut w = store.writer().unwrap();
w.upsert_node(&DocRecord {
url,
url_key: UrlKey::of(url),
content_hash: 1,
fetched_at_ms: 1,
title: Some("Title"),
snippet: Some("SNIPPET-MARKER"),
etag: None,
pinned: false,
})
.unwrap();
w.set_segments(
UrlKey::of(url),
&[SegmentRecord {
label: "Heading",
byte_range: Some((0, 4096)),
depth: 1,
importance: 1,
}],
)
.unwrap();
w.commit().unwrap();
}
store.compact().unwrap();
let bytes = std::fs::read(dir.path().join("graph.base")).unwrap();
let haystack = String::from_utf8_lossy(&bytes);
assert!(haystack.contains(url));
assert!(haystack.contains("SNIPPET-MARKER"));
assert!(haystack.contains("Heading"));
assert!(
bytes.len() < 4096,
"a 4 KiB byte_range must store an offset, not the region ({} bytes)",
bytes.len()
);
}
#[test]
fn another_format_version_is_a_typed_refusal() {
use graph_r::store::{BASE_FILE, WAL_FILE};
for (file, supported) in
[(BASE_FILE, graph_r::format::base::VERSION), (WAL_FILE, graph_r::format::wal::VERSION)]
{
let dir = tempfile::tempdir().unwrap();
drop(Store::create(dir.path(), Config::default()).unwrap());
let path = dir.path().join(file);
let mut bytes = std::fs::read(&path).unwrap();
bytes[4..6].copy_from_slice(&1u16.to_le_bytes());
std::fs::write(&path, &bytes).unwrap();
for opened in [Store::open(dir.path()), Store::open_read_only(dir.path())] {
match opened {
Err(Error::Version { found, supported: s }) => {
assert_eq!((found, s), (1, supported), "{file}");
}
other => panic!("{file}: expected Error::Version, got {other:?}"),
}
}
let text = Store::open(dir.path()).unwrap_err().to_string();
assert!(text.starts_with("format error: unsupported version 1"), "{text}");
}
}
#[test]
fn a_store_written_by_format_v1_is_a_typed_refusal() {
let dir = tempfile::tempdir().unwrap();
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/format_v1");
for f in [graph_r::store::BASE_FILE, graph_r::store::WAL_FILE] {
std::fs::copy(fixture.join(f), dir.path().join(f)).unwrap();
}
assert!(matches!(
Store::open(dir.path()),
Err(Error::Version { found: 1, supported: graph_r::format::base::VERSION })
));
assert!(matches!(Store::open_read_only(dir.path()), Err(Error::Version { found: 1, .. })));
}