use super::*;
#[cfg(test)]
mod atomic_save_tests {
use super::*;
use crate::datatypes::{DataFrame, Value};
use crate::graph::dir_graph::DirGraph;
use crate::graph::storage::GraphRead;
fn tiny_graph(n: i64) -> Arc<DirGraph> {
let mut g = DirGraph::new();
let rows: Vec<Vec<Value>> = (1..=n)
.map(|i| vec![Value::Int64(i), Value::String(format!("t{i}"))])
.collect();
let df =
DataFrame::from_cypher_rows(vec!["id".to_string(), "title".to_string()], rows).unwrap();
crate::graph::mutation::maintain::add_nodes(
&mut g,
df,
"Doc".to_string(),
"id".to_string(),
Some("title".to_string()),
None,
)
.unwrap();
let mut arc = Arc::new(g);
prepare_save(&mut arc);
Arc::make_mut(&mut arc).enable_columnar();
arc
}
#[test]
fn atomic_save_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("g.kgl");
let g = tiny_graph(5);
let want = g.graph.node_count();
write_kgl(&g, path.to_str().unwrap()).unwrap();
let loaded = load_file(path.to_str().unwrap()).unwrap();
assert_eq!(loaded.graph.node_count(), want);
}
#[test]
fn save_with_fsync_false_still_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("g.kgl");
let g = tiny_graph(3);
write_kgl_with(&g, path.to_str().unwrap(), false).unwrap();
let loaded = load_file(path.to_str().unwrap()).unwrap();
assert_eq!(loaded.graph.node_count(), g.graph.node_count());
}
#[test]
fn to_bytes_roundtrips_via_load_kgl_bytes() {
let g = tiny_graph(4);
let mut buf: Vec<u8> = Vec::new();
write_kgl_to(&g, &mut buf).unwrap();
assert_eq!(&buf[..4], &V4_MAGIC, "buffer must carry the v4 magic");
let loaded = load_kgl_bytes(&buf).unwrap();
assert_eq!(loaded.graph.node_count(), g.graph.node_count());
}
#[test]
fn hard_break_errors_point_at_export_recovery() {
assert!(
V3_HARD_BREAK_MSG.contains("export_csv")
&& V3_HARD_BREAK_MSG.contains("from_blueprint"),
"v3 hard-break message must name the export_csv/from_blueprint recovery path"
);
let v3_buf = [V3_MAGIC[0], V3_MAGIC[1], V3_MAGIC[2], V3_MAGIC[3], 0, 0];
let err = load_kgl_bytes(&v3_buf).err().unwrap();
assert!(err.to_string().contains("export_csv"));
let bad = [0u8, 1, 2, 3, 4, 5];
let err = load_kgl_bytes(&bad).err().unwrap();
assert!(err.to_string().contains("from_blueprint"));
}
fn tiny_indexed_graph() -> Arc<DirGraph> {
use crate::graph::algorithms::hnsw::HnswParams;
use crate::graph::algorithms::vector::DistanceMetric;
use crate::graph::schema::EmbeddingStore;
let mut g = tiny_graph(40);
{
let dir = Arc::make_mut(&mut g);
let mut store = EmbeddingStore::with_metric(4, "cosine");
for i in 0..40usize {
let v = [i as f32, (i % 3) as f32, 1.0, (i % 7) as f32];
store.set_embedding(i, &v);
}
store
.build_index(DistanceMetric::Cosine, HnswParams::default(), 7)
.unwrap();
dir.embeddings
.insert(("Doc".to_string(), "vec_emb".to_string()), store);
}
g
}
#[test]
fn vector_index_section_roundtrips() {
let g = tiny_indexed_graph();
let mut buf: Vec<u8> = Vec::new();
write_kgl_to(&g, &mut buf).unwrap();
let loaded = load_kgl_bytes(&buf).unwrap();
let store = loaded
.embeddings
.get(&("Doc".to_string(), "vec_emb".to_string()))
.expect("embedding store survives round-trip");
assert!(store.has_index(), "HNSW index must persist in the .kgl");
assert_eq!(store.index.as_ref().unwrap().len(), 40);
}
#[test]
fn vector_index_decode_skips_unknown_version() {
let g = tiny_indexed_graph();
let payload = encode_vector_indexes(&g).unwrap().unwrap();
let mut bumped = payload.clone();
bumped[8] = bumped[8].wrapping_add(1); let mut dst = DirGraph::new();
dst.embeddings.insert(
("Doc".to_string(), "vec_emb".to_string()),
crate::graph::schema::EmbeddingStore::new(4),
);
decode_vector_indexes(&bumped, &mut dst);
assert!(
!dst.embeddings[&("Doc".to_string(), "vec_emb".to_string())].has_index(),
"an unknown index format version must be skipped"
);
let mut bad_magic = payload.clone();
bad_magic[0] = b'X';
decode_vector_indexes(&bad_magic, &mut dst);
assert!(!dst.embeddings[&("Doc".to_string(), "vec_emb".to_string())].has_index());
}
#[test]
fn load_kgl_bytes_rejects_bad_magic() {
let err = match load_kgl_bytes(b"NOPE and some trailing bytes that are long enough") {
Ok(_) => panic!("expected an error for a bad-magic buffer"),
Err(e) => e.to_string().to_lowercase(),
};
assert!(
err.contains("magic") || err.contains("unrecognized"),
"got: {err}"
);
}
#[test]
fn load_kgl_bytes_rejects_too_small() {
assert!(load_kgl_bytes(b"RG").is_err());
assert!(load_kgl_bytes(&[]).is_err());
}
#[test]
fn load_kgl_bytes_rejects_truncated() {
let g = tiny_graph(6);
let mut buf: Vec<u8> = Vec::new();
write_kgl_to(&g, &mut buf).unwrap();
let truncated = &buf[..buf.len() / 2];
assert!(
load_kgl_bytes(truncated).is_err(),
"a truncated buffer must be rejected, not silently half-loaded"
);
}
fn rewrite_metadata(buf: &[u8], mutate: impl FnOnce(&mut FileMetadata)) -> Vec<u8> {
let old_len = u32::from_le_bytes(buf[8..12].try_into().unwrap()) as usize;
let mut metadata: FileMetadata = serde_json::from_slice(&buf[12..12 + old_len]).unwrap();
mutate(&mut metadata);
let encoded = serde_json::to_vec(&metadata).unwrap();
let mut rewritten = Vec::with_capacity(buf.len() - old_len + encoded.len());
rewritten.extend_from_slice(&buf[..8]);
rewritten.extend_from_slice(&(encoded.len() as u32).to_le_bytes());
rewritten.extend_from_slice(&encoded);
rewritten.extend_from_slice(&buf[12 + old_len..]);
rewritten
}
fn assert_invalid_without_panic(bytes: &[u8]) {
let result = std::panic::catch_unwind(|| load_kgl_bytes(bytes));
let error = match result.expect("malformed .kgl must return an error, not panic") {
Ok(_) => panic!("malformed .kgl must not load successfully"),
Err(error) => error,
};
assert_eq!(error.kind(), io::ErrorKind::InvalidData, "{error}");
}
#[test]
fn malformed_section_metadata_is_checked_without_panics() {
let graph = tiny_graph(2);
let mut valid = Vec::new();
write_kgl_to(&graph, &mut valid).unwrap();
let oversized_topology = rewrite_metadata(&valid, |m| {
m.topology_compressed_size = u64::MAX;
});
assert_invalid_without_panic(&oversized_topology);
let oversized_column = rewrite_metadata(&valid, |m| {
m.column_sections[0].compressed_size = u64::MAX;
});
assert_invalid_without_panic(&oversized_column);
let oversized_rows = rewrite_metadata(&valid, |m| {
m.column_sections[0].row_count = u32::MAX;
});
assert_invalid_without_panic(&oversized_rows);
assert_invalid_without_panic(&valid[..valid.len() - 1]);
}
#[test]
fn serialized_type_names_never_become_temp_paths() {
let graph = tiny_graph(1);
let mut valid = Vec::new();
write_kgl_to(&graph, &mut valid).unwrap();
for hostile in ["../../outside", "/tmp/kglite-absolute-type"] {
let mutated = rewrite_metadata(&valid, |m| {
m.column_sections[0].type_name = hostile.to_string();
});
assert_invalid_without_panic(&mutated);
}
}
#[test]
fn zstd_decompression_respects_expansion_limit() {
let compressed = zstd_compress(&vec![0u8; 64 * 1024]).unwrap();
let error = zstd_decompress_limited(&compressed, 1024).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn legacy_index_readers_validate_exact_bounds_and_cardinality() {
let mut interner = crate::graph::storage::interner::StringInterner::new();
let key = interner.get_or_intern("Person").as_u64();
let mut type_payload = Vec::new();
type_payload.extend_from_slice(TYPE_INDICES_MAGIC);
type_payload.extend_from_slice(&TYPE_INDICES_VERSION.to_le_bytes());
type_payload.extend_from_slice(&1u32.to_le_bytes());
type_payload.extend_from_slice(&1u64.to_le_bytes());
type_payload.extend_from_slice(&key.to_le_bytes());
type_payload.extend_from_slice(&0u64.to_le_bytes());
type_payload.extend_from_slice(&1u64.to_le_bytes());
type_payload.extend_from_slice(&7u32.to_le_bytes());
assert!(read_type_indices_bin(&type_payload, &interner)
.unwrap()
.is_some());
type_payload.push(0);
assert_eq!(
read_type_indices_bin(&type_payload, &interner)
.unwrap_err()
.kind(),
io::ErrorKind::InvalidData
);
let mut id_payload = Vec::new();
id_payload.extend_from_slice(ID_INDICES_MAGIC);
id_payload.extend_from_slice(&ID_INDICES_VERSION.to_le_bytes());
id_payload.extend_from_slice(&1u32.to_le_bytes());
id_payload.extend_from_slice(&key.to_le_bytes());
id_payload.push(0);
id_payload.extend_from_slice(&[0; 7]);
id_payload.extend_from_slice(&1u64.to_le_bytes());
id_payload.extend_from_slice(&7u32.to_le_bytes());
id_payload.extend_from_slice(&3u32.to_le_bytes());
assert!(read_id_indices_bin(&id_payload, &interner)
.unwrap()
.is_some());
id_payload.push(0);
assert_eq!(
read_id_indices_bin(&id_payload, &interner)
.unwrap_err()
.kind(),
io::ErrorKind::InvalidData
);
}
#[test]
fn atomic_save_overwrites_existing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("g.kgl");
let p = path.to_str().unwrap();
write_kgl(&tiny_graph(2), p).unwrap();
write_kgl(&tiny_graph(9), p).unwrap();
let loaded = load_file(p).unwrap();
assert_eq!(loaded.graph.node_count(), tiny_graph(9).graph.node_count());
}
#[test]
fn successful_save_leaves_no_temp_litter() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("g.kgl");
write_kgl(&tiny_graph(3), path.to_str().unwrap()).unwrap();
let entries: Vec<String> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(entries, vec!["g.kgl".to_string()], "temp file must be gone");
}
#[test]
fn failed_save_to_bad_dir_leaves_dest_untouched() {
let dir = tempfile::tempdir().unwrap();
let good = dir.path().join("g.kgl");
write_kgl(&tiny_graph(4), good.to_str().unwrap()).unwrap();
let before = std::fs::read(&good).unwrap();
let bad = dir.path().join("missing_subdir").join("g.kgl");
assert!(write_kgl(&tiny_graph(7), bad.to_str().unwrap()).is_err());
assert_eq!(std::fs::read(&good).unwrap(), before);
}
}