crates/gunnar-server/tests/blame_search_live.rs
//! **A RUNNING appliance can be blamed and searched, and the
//! store-local verb cannot.**
//!
//! The sealed appliance has no shell, and `gunnar blame` / `gunnar search` open
//! the data root directly under an exclusive redb lock — so while a `gunnar
//! serve` holds that root, they cannot run at all (`ServerStores::open` refuses
//! the second open). The fix is the control-plane path: `Refs.Blame` /
//! `Refs.Search` route through the store handle the running server already
//! holds. This drives that path against a live `ServerStores` and asserts the
//! EXACT per-line oids and the EXACT match line — the applied output that
//! proves the answer is right — and, in the same test, that the store-local
//! entry is LOCKED OUT while the server holds the root, which is the whole bug.
//!
//! The red these are written against: before the RPC existed, an operator on a
//! running appliance had NO way to blame or search. Wire the RPC to the default
//! `Unimplemented` and the exact-oid asserts below go red; leave the store-local
//! path as the only door and `blame_at` below succeeds instead of being locked
//! out, which would mean the shell-less gap was never real.
use std::sync::Arc;
use gix_object::Kind;
use gunnar_core::{HashKind, NewRepo, RepoRef};
use gunnar_grpc::ControlPlane as _;
use gunnar_proto::RepoPath;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use gunnar_store::conformance::pack::PackBuilder;
use gunnar_store::git_storage_trait::GitServe;
type Oid = gix_hash::ObjectId;
fn compute(hash: gix_hash::Kind, kind: Kind, content: &[u8]) -> Oid {
gix_object::compute_hash(hash, kind, content).expect("hashing an object")
}
/// A canonical tree body of `(mode, name, oid)` entries, sorted by name as git
/// writes them.
fn tree_bytes(entries: &[(&str, &str, Oid)]) -> Vec<u8> {
let mut sorted = entries.to_vec();
sorted.sort_by(|a, b| a.1.as_bytes().cmp(b.1.as_bytes()));
let mut body = Vec::new();
for (mode, name, oid) in sorted {
body.extend_from_slice(mode.as_bytes());
body.push(b' ');
body.extend_from_slice(name.as_bytes());
body.push(0);
body.extend_from_slice(oid.as_bytes());
}
body
}
fn commit_bytes(tree: Oid, parents: &[Oid], author: &str, message: &str) -> Vec<u8> {
let mut body = format!("tree {}\n", tree.to_hex());
for parent in parents {
body.push_str(&format!("parent {}\n", parent.to_hex()));
}
let who = format!("{author} <{author}@example.com> 1700000000 +0000");
body.push_str(&format!("author {who}\n"));
body.push_str(&format!("committer {who}\n\n"));
body.push_str(message);
body.push('\n');
body.into_bytes()
}
/// Build the two-commit graph the blame test knows the answer to, in ONE
/// self-contained pack, and store it in `git`. Returns `(commit_a, commit_b)`.
///
/// Commit A writes `src/x.rs` with three lines; commit B rewrites line two.
fn plant_two_commits(git: &dyn GitServe, hash: gix_hash::Kind) -> (Oid, Oid) {
let blob_a = b"line one\nline two\nline three\n".to_vec();
let ba = compute(hash, Kind::Blob, &blob_a);
let tsa = tree_bytes(&[("100644", "x.rs", ba)]);
let tsa_id = compute(hash, Kind::Tree, &tsa);
let ta = tree_bytes(&[("40000", "src", tsa_id)]);
let ta_id = compute(hash, Kind::Tree, &ta);
let ca = commit_bytes(ta_id, &[], "Alice", "first");
let ca_id = compute(hash, Kind::Commit, &ca);
let blob_b = b"line one\nLINE TWO changed\nline three\n".to_vec();
let bb = compute(hash, Kind::Blob, &blob_b);
let tsb = tree_bytes(&[("100644", "x.rs", bb)]);
let tsb_id = compute(hash, Kind::Tree, &tsb);
let tb = tree_bytes(&[("40000", "src", tsb_id)]);
let tb_id = compute(hash, Kind::Tree, &tb);
let cb = commit_bytes(tb_id, &[ca_id], "Bob", "second");
let cb_id = compute(hash, Kind::Commit, &cb);
let mut pb = PackBuilder::new(hash);
for (kind, content) in [
(Kind::Blob, blob_a),
(Kind::Tree, tsa),
(Kind::Tree, ta),
(Kind::Commit, ca),
(Kind::Blob, blob_b),
(Kind::Tree, tsb),
(Kind::Tree, tb),
(Kind::Commit, cb),
] {
pb.object(kind, content);
}
let fixture = pb.build().expect("building the pack");
git.put_pack(&fixture.bytes).expect("storing the pack");
(ca_id, cb_id)
}
#[test]
fn a_running_server_can_be_blamed_and_searched_while_the_local_verb_is_locked_out() {
let dir = tempfile::tempdir().unwrap();
// The RUNNING server: it holds the data root open for the rest of the test.
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
stores
.catalog()
.create_namespace("alice")
.expect("namespace");
let repo_ref = RepoRef::new("alice", "appliance").expect("legal repo path");
let repo = stores
.create_repo(
NewRepo::new(repo_ref.clone()).hash_kind(HashKind::Sha256),
false,
)
.expect("create repo");
let store = stores.repo_store(&repo).expect("open repo store");
let hash = store.hash_kind();
let (commit_a, commit_b) = plant_two_commits(store.serve(), hash);
let path = RepoPath::new("alice", "appliance");
let plane = CoreControlPlane::new(Arc::clone(&stores));
let tip = commit_b.to_hex().to_string();
// ── RED, the bug itself: the store-local verb cannot even OPEN the root
// while the server holds it. This is why a sealed appliance cannot be
// blamed down, and it is what the control-plane path exists to fix.
let down = gunnar_server::blame::blame_at(dir.path(), &path, &tip, "src/x.rs");
assert!(
down.is_err(),
"the store-local `gunnar blame` must be locked out while the server holds the root — \
if it succeeded here, the shell-less gap would not exist and this whole RPC would be redundant"
);
// ── GREEN, the applied output: the RPC blames the LIVE server and names the
// exact commit that wrote each line, by the oids this test chose.
let blamed = plane
.blame(&path, &tip, "src/x.rs")
.expect("blame over the control plane");
let lines: Vec<(String, String, String)> = blamed
.lines
.iter()
.map(|l| {
(
l.commit.clone(),
l.author.clone(),
String::from_utf8_lossy(&l.line).into_owned(),
)
})
.collect();
assert_eq!(lines.len(), 3, "the file at the tip has three lines");
assert_eq!(
lines[0],
(
commit_a.to_hex().to_string(),
"Alice".into(),
"line one".into()
),
"line 1 was written by commit A and never changed"
);
assert_eq!(
lines[1],
(
commit_b.to_hex().to_string(),
"Bob".into(),
"LINE TWO changed".into()
),
"line 2 was rewritten by commit B — a naive blame would say the tip for all three"
);
assert_eq!(
lines[2],
(
commit_a.to_hex().to_string(),
"Alice".into(),
"line three".into()
),
"line 3 was written by commit A and never changed"
);
// ── Search: a known string is found at its exact file:line…
let hits = plane
.search(&path, &tip, "LINE TWO changed", 0, 0)
.expect("search over the control plane");
assert_eq!(hits.matches.len(), 1, "the string sits on exactly one line");
assert_eq!(hits.matches[0].path, "src/x.rs");
assert_eq!(hits.matches[0].line_number, 2);
assert_eq!(
String::from_utf8_lossy(&hits.matches[0].line),
"LINE TWO changed"
);
// …and a string that is not there returns empty, not an error and not a
// false hit.
let absent = plane
.search(&path, &tip, "this-string-is-nowhere-in-the-tree", 0, 0)
.expect("an absent string is a legitimate empty answer");
assert!(
absent.matches.is_empty(),
"a string that is not in the tree must return no matches"
);
}