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

nordisk/gunnar

crates/gunnar-cli/tests/http_push_is_attributable.rs

#![cfg(feature = "http")]

//! **GUN-014: an audit record can name the credential that pushed, on both
//! transports.**
//!
//! `session_key_fingerprint` returns an empty string when
//! `ServiceRequest.key_fingerprint` is `None`, and its own doc stated that an
//! empty value has *"one reason only — the anonymous, read-only path… That path
//! cannot push, so it cannot reach an attestation."*
//!
//! Smart HTTP passes `None` for **every** request, `git-receive-pack`
//! included: HTTP authenticates with a token and there is no key to name. So
//! the claim was false exactly where it mattered — `RefUpdated`, `RefDeleted`
//! and the push attestation all carried an empty fingerprint for an
//! authenticated write, and two HTTP pushes by two different people produced
//! the same audit line.
//!
//! The fix is a `credential` field, not a fingerprint that lies: `key:SHA256:…`
//! over SSH, `token:<hash prefix>` over HTTP, empty only for a caller that
//! presented nothing. This file pushes over BOTH transports into one server and
//! reads the attestation log back, because the property is that the two
//! transports answer the same question in the same vocabulary — one of them
//! alone would not show that.

mod common;

use std::path::{Path, PathBuf};

use common::{tools_present, Harness, HarnessOptions};
use gunnar_core::{HashKind, Visibility};

const NS: &str = "team";
const REPO: &str = "project";

/// A local repository with one commit on `main`.
fn local(harness: &Harness, at: &Path, text: &str) {
    harness
        .git(
            harness.dir.path(),
            &["init", "--quiet", "-b", "main", at.to_str().expect("utf-8")],
        )
        .expect_ok("git init");
    std::fs::write(at.join("a.txt"), text).expect("write");
    harness.git(at, &["add", "."]).expect_ok("add");
    harness
        .git(at, &["commit", "--quiet", "-m", text])
        .expect_ok("commit");
}

/// The `credential` field of the most recent attestation.
fn last_credential(harness: &Harness) -> (String, String) {
    let records = harness
        .attestation_log()
        .read()
        .expect("read attestations.jsonl");
    let last = records.last().expect("a push writes an attestation");
    (last.credential.clone(), last.key_fingerprint.clone())
}

/// **The finding, and its other half.**
///
/// Mutation-proven 2026-09-11: drop the `with_credential` call from
/// `gunnar-http`'s service and the HTTP push's `credential` is the empty
/// string — indistinguishable from the anonymous read the field's own doc says
/// is the only thing an empty value can mean.
#[test]
fn a_push_names_its_credential_over_ssh_and_over_http() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start_with_options(
        NS,
        REPO,
        HashKind::Sha1,
        HarnessOptions {
            http: true,
            visibility: Visibility::Private,
            ..HarnessOptions::default()
        },
    );

    // ── over SSH: the key ──────────────────────────────────────────────────
    let ssh_work: PathBuf = harness.dir.path().join("over-ssh");
    local(&harness, &ssh_work, "over ssh\n");
    harness
        .git(
            &ssh_work,
            &["push", "--quiet", &harness.url(NS, REPO), "main"],
        )
        .expect_ok("push over ssh");
    let (credential, fingerprint) = last_credential(&harness);
    assert_eq!(
        credential,
        format!("key:{}", harness.key_fingerprint),
        "an SSH push must be attributed to the key that authenticated it"
    );
    assert_eq!(
        fingerprint, harness.key_fingerprint,
        "and the fingerprint field is unchanged, so nothing that reads it broke"
    );

    // ── over HTTP: the token ───────────────────────────────────────────────
    //
    // The same server, the same principal, a different credential. Before the
    // fix this push produced an attestation with an empty fingerprint and
    // nothing else naming who made it.
    let token = harness.mint_token("tester");
    let url = harness.http_url_with_token(NS, REPO, &token);
    let http_work = harness.dir.path().join("over-http");
    local(&harness, &http_work, "over http\n");
    harness
        .git_http(&http_work, &["push", "--quiet", "--force", &url, "main"])
        .expect_ok("push over http");

    let (credential, fingerprint) = last_credential(&harness);
    assert!(
        credential.starts_with("token:"),
        "an HTTP push must be attributed to the token that authenticated it; the record said \
         {credential:?}"
    );
    assert!(
        !credential.ends_with(':'),
        "the credential must carry a hash prefix, not a bare scheme: {credential:?}"
    );
    assert!(
        !credential.contains(&token),
        "the audit record must never carry the token itself: {credential:?}"
    );
    assert!(
        credential.len() < "token:".len() + 32,
        "a PREFIX of the hash, not the whole of it: {credential:?}"
    );
    assert!(
        fingerprint.is_empty(),
        "there is no key on the HTTP path, and inventing one would be worse than an empty \
         field: {fingerprint:?}"
    );

    // …and the two pushes are distinguishable, which is the whole of what the
    // audit trail could not do. Two records, two different credentials.
    let records = harness
        .attestation_log()
        .read()
        .expect("read attestations.jsonl");
    assert_eq!(records.len(), 2, "two pushes, two attestations");
    assert_ne!(
        records[0].credential, records[1].credential,
        "two pushes by two different credentials must not produce the same audit line"
    );

    harness.stop();
}