Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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"
    );
}