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/heimdall_push.rs

//! **A pushed pack goes through the Heimdall gate before the ingest**
//! (Heimdall milestone 5, step c; feature `heimdall`).
//!
//! The push is driven through the real `WireTransport` (the `GitTransport`
//! seam `gunnar-ssh` hands an authenticated session to): advertisement,
//! command list, pack, report-status. The gate's runner plays Heimdall
//! in-process: it runs the real checker on the spooled pack and signs, or
//! refuses every pack.
#![cfg(feature = "heimdall")]

use std::sync::{Arc, Mutex};

use gunnar_core::{Level, NewRepo, Principal, PrincipalKind, RepoRef};
use gunnar_heimdall::{checker, PackGate, CHECKER_PATH, PACK, REPORT};
use gunnar_server::stores::ServerStores;
use gunnar_server::transport::WireTransport;
use gunnar_ssh::seam::{Delivery, GitTransport, ServiceRequest};
use gunnar_ssh::GitService;
use gunnar_store::conformance::pack::PackBuilder;
use gunnar_store::git_storage_trait::GitOps;
use heimdall_core::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

struct Fake {
    key: BootKey,
    refuse: bool,
    jobs: Mutex<u32>,
}

fn features() -> Features {
    Features {
        runner: "fake".into(),
        container: true,
        seccomp: Protection::Default,
        landlock: true,
        broker: false,
        wasm: false,
        image_verified_each_start: true,
    }
}

impl JobRunner for Fake {
    fn run(&self, job: Job) -> Result<SignedResult, RunError> {
        *self.jobs.lock().unwrap() += 1;
        let JobKind::Native { argv, .. } = &job.kind else {
            panic!("a native job")
        };
        assert_eq!(argv[0], CHECKER_PATH);
        let mut outputs = vec![];
        let verdict = if self.refuse {
            Verdict::Refused {
                code: "heimdall-exit-1".into(),
                reason: "the checker said no".into(),
            }
        } else {
            let kind = checker::parse_kind(&argv[1]).unwrap();
            let r = checker::check_pack_file(&job.input_dir.join(PACK), kind).unwrap();
            let b = serde_json::to_vec(&r).unwrap();
            std::fs::write(job.output_dir.join(REPORT), &b).unwrap();
            outputs.push(Output::of(REPORT, &b));
            Verdict::Accepted
        };
        let result = JobResult {
            job_id: job.id,
            nonce: job.nonce,
            verdict,
            outputs,
            exit: ExitInfo::exited(0),
            started_unix: 1,
            finished_unix: 2,
            runner: features(),
            events: vec![],
            kernel: None,
        };
        Ok(self.key.sign(result).unwrap())
    }
    fn verifying_key(&self) -> VerifyingKey {
        self.key.verifying_key()
    }
    fn features(&self) -> Features {
        features()
    }
}

struct Rig {
    _dir: tempfile::TempDir,
    stores: Arc<ServerStores>,
    path: RepoRef,
    fake: Arc<Fake>,
}

fn rig(refuse: bool) -> Rig {
    let dir = tempfile::tempdir().unwrap();
    let stores = Arc::new(ServerStores::open(dir.path()).unwrap());
    stores.catalog().create_namespace("team").unwrap();
    let path = RepoRef::new("team", "code").unwrap();
    stores
        .create_repo(NewRepo::new(path.clone()), false)
        .unwrap();
    let fake = Arc::new(Fake {
        key: BootKey::generate(),
        refuse,
        jobs: Mutex::new(0),
    });
    let image = ImageRef {
        name: "gunnar/pack-check".into(),
        digest: format!("sha256:{}", "ab".repeat(32)),
    };
    let gate = PackGate::new(
        fake.clone(),
        image,
        dir.path().join("heimdall/spool"),
        gix_hash::Kind::Sha1,
    );
    assert!(stores.set_pack_gate(Arc::new(gate)));
    Rig {
        _dir: dir,
        stores,
        path,
        fake,
    }
}

/// A one-commit pack: README blob, tree, commit.
fn commit_pack(kind: gix_hash::Kind) -> (Vec<u8>, gix_hash::ObjectId) {
    let blob = b"pushed through the gate\n".to_vec();
    let blob_id = gix_object::compute_hash(kind, gix_object::Kind::Blob, &blob).unwrap();
    let mut tree = b"100644 README\0".to_vec();
    tree.extend_from_slice(blob_id.as_slice());
    let tree_id = gix_object::compute_hash(kind, gix_object::Kind::Tree, &tree).unwrap();
    let commit = format!(
        "tree {tree_id}\nauthor A <a@example.invalid> 1700000000 +0000\ncommitter A \
         <a@example.invalid> 1700000000 +0000\n\nfirst\n"
    )
    .into_bytes();
    let commit_id = gix_object::compute_hash(kind, gix_object::Kind::Commit, &commit).unwrap();
    let mut pb = PackBuilder::new(kind);
    pb.object(gix_object::Kind::Commit, commit);
    pb.object(gix_object::Kind::Tree, tree);
    pb.object(gix_object::Kind::Blob, blob);
    (pb.build().unwrap().bytes, commit_id)
}

impl Rig {
    fn kind(&self) -> gix_hash::Kind {
        let row = self.stores.catalog().get(&self.path).unwrap().unwrap();
        self.stores.repo_store(&row).unwrap().hash_kind()
    }

    fn tip(&self) -> Option<Vec<u8>> {
        let row = self.stores.catalog().get(&self.path).unwrap().unwrap();
        let store = self.stores.repo_store(&row).unwrap();
        GitOps::refs(store.serve())
            .unwrap()
            .into_iter()
            .find(|r| r.name == "refs/heads/main")
            .and_then(|r| r.oid)
    }

    /// Push `pack` creating refs/heads/main at `tip`; the server's answer.
    fn push(&self, pack: &[u8], tip: gix_hash::ObjectId) -> String {
        let kind = self.kind();
        let format = if kind == gix_hash::Kind::Sha1 {
            "sha1"
        } else {
            "sha256"
        };
        let line = format!(
            "{} {tip} refs/heads/main\0report-status object-format={format}\n",
            kind.null()
        );
        let mut input = format!("{:04x}", line.len() + 4).into_bytes();
        input.extend_from_slice(line.as_bytes());
        input.extend_from_slice(b"0000");
        input.extend_from_slice(pack);
        let row = self.stores.catalog().get(&self.path).unwrap().unwrap();
        let request = ServiceRequest {
            service: GitService::ReceivePack,
            repo: row,
            prospective: false,
            principal: Principal::new("alice", "alice", PrincipalKind::User).unwrap(),
            access: Level::Admin,
            key_fingerprint: None,
            git_protocol: None,
            delivery: Delivery::Session,
            credential: None,
            plan_notice: None,
            pack_ceiling: None,
            pack_mark: None,
            lfs_allowance: None,
            cancel: gunnar_ssh::channel::Cancel::new(),
        };
        let transport = WireTransport::new(self.stores.clone());
        let rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async {
            let (mut client, server) = tokio::io::duplex(1 << 20);
            let serve = transport.serve(request, Box::new(server));
            let talk = async {
                client.write_all(&input).await.unwrap();
                client.shutdown().await.unwrap();
                let mut out = Vec::new();
                let _ = client.read_to_end(&mut out).await;
                out
            };
            let (served, out) = tokio::join!(serve, talk);
            format!("{served:?} {}", String::from_utf8_lossy(&out))
        })
    }
}

/// ★ A push whose pack Heimdall refuses lands nothing: the reference does not
/// move and the client hears the push was refused.
#[test]
fn a_push_whose_pack_heimdall_refuses_lands_nothing() {
    let r = rig(true);
    let (pack, tip) = commit_pack(r.kind());
    let said = r.push(&pack, tip);
    assert_eq!(r.tip(), None, "{said}");
    // The client hears that the pack was refused; the wire never tells a
    // pusher the server-side reason (gunnar-wire's unpack status).
    assert!(said.contains("ng refs/heads/main"), "{said}");
    assert!(!said.contains("unpack ok"), "{said}");
    assert_eq!(*r.fake.jobs.lock().unwrap(), 1);
}

/// The same push through a gate whose check passes lands exactly.
#[test]
fn a_push_through_a_passing_gate_lands() {
    let r = rig(false);
    let (pack, tip) = commit_pack(r.kind());
    let said = r.push(&pack, tip);
    assert_eq!(r.tip().as_deref(), Some(tip.as_slice()), "{said}");
    assert_eq!(*r.fake.jobs.lock().unwrap(), 1, "{said}");
}