mkit-cli 0.4.0

The mkit command-line tool: a content-addressed VCS with native attestation support
Documentation
//! End-to-end HTTP-transport sparse-checkout coverage (issue #158).
//! Spins up a `mockito` server that responds to
//! `POST /<project>/trees/<hex>/sparse?sparse=<filter-hex>` with a
//! server-built sparse envelope, then asserts the client decodes and
//! verifies it.

#![cfg(feature = "sparse-checkout")]
#![allow(clippy::unwrap_used)] // unwrap is the assertion in test helpers

use std::path::PathBuf;

use mkit_core::hash::{Hash, to_hex};
use mkit_core::object::{EntryMode, Tree, TreeEntry};
use mkit_core::sparse::{
    SparseResponse, build_sparse, encode_sparse_response, hash_filter, tree_hash, verify_sparse,
};
use mkit_transport_http::HttpTransport;
use mockito::{Matcher, Server};

fn entry(name: &[u8]) -> TreeEntry {
    TreeEntry {
        name: name.to_vec(),
        mode: EntryMode::Blob,
        object_hash: [0u8; 32],
    }
}

fn tree_for(names: &[&[u8]]) -> Tree {
    Tree {
        entries: names.iter().copied().map(entry).collect(),
    }
}

fn make_transport(server: &Server) -> HttpTransport {
    // `HttpTransport::connect` parses the `mkit+http://` form and
    // validates loopback for plain http; mockito always binds on
    // 127.0.0.1, so this is the production code path.
    let url = format!("mkit+{}/myproj", server.url());
    HttpTransport::connect(&url).expect("connect to mockito")
}

fn sparse_path(tree: &Hash) -> String {
    format!("/myproj/trees/{}/sparse", to_hex(tree))
}

#[test]
fn http_sparse_fetch_round_trip_verifies() {
    let tree = tree_for(&[b"a", b"b", b"c"]);
    let th = tree_hash(&tree);
    let filter = vec![PathBuf::from("a")];
    let fh = hash_filter(&filter);

    // Server-side: build the sparse response just like the
    // Cloudflare Worker reference impl would.
    let (entries, manifest, proof) = build_sparse(&tree, &filter).unwrap();
    let body = encode_sparse_response(&SparseResponse {
        manifest,
        entries,
        proof,
    })
    .unwrap();

    let mut server = Server::new();
    let path = sparse_path(&th);
    let query = format!("sparse={}", to_hex(&fh));
    let _m = server
        .mock("POST", path.as_str())
        .match_query(Matcher::Exact(query))
        .match_header("content-type", "application/json")
        .with_status(200)
        .with_header("content-type", "application/x-mkit-sparse")
        .with_body(body)
        .create();

    let tx = make_transport(&server);
    let resp = tx
        .fetch_sparse_tree(&th, &filter)
        .expect("HTTP sparse fetch must succeed");

    // Wire-level decode succeeded; now run the verifier end-to-end.
    assert!(verify_sparse(
        &resp.manifest,
        &resp.entries,
        &filter,
        &resp.proof
    ));
    assert_eq!(resp.manifest.tree_hash, th);
    assert_eq!(resp.manifest.filter_hash, fh);
    assert_eq!(resp.entries.len(), 1);
    assert_eq!(resp.entries[0].name, b"a");
}

#[test]
fn http_sparse_fetch_rejects_tampered_bitmap() {
    let tree = tree_for(&[b"a", b"b", b"c"]);
    let th = tree_hash(&tree);
    let filter = vec![PathBuf::from("a")];

    // Build a legitimate response, then *tamper* with the bitmap bytes
    // before encoding. The manifest's bitmap_root still commits to
    // the original bitmap, so verify_sparse must reject after decode.
    let (entries, manifest, mut proof) = build_sparse(&tree, &filter).unwrap();
    proof.bitmap_bytes[0] ^= 0b0000_0010; // flip a high bit
    let body = encode_sparse_response(&SparseResponse {
        manifest,
        entries,
        proof,
    })
    .unwrap();

    let mut server = Server::new();
    let path = sparse_path(&th);
    let _m = server
        .mock("POST", path.as_str())
        .match_query(Matcher::Any)
        .with_status(200)
        .with_header("content-type", "application/x-mkit-sparse")
        .with_body(body)
        .create();

    let tx = make_transport(&server);
    // The wire decode still succeeds — the transport doesn't verify
    // semantic correctness — so we get a `SparseResponse` and the
    // verifier rejects it.
    let resp = tx.fetch_sparse_tree(&th, &filter).unwrap();
    assert!(
        !verify_sparse(&resp.manifest, &resp.entries, &filter, &resp.proof),
        "tampered bitmap MUST be rejected by verify_sparse"
    );
}

#[test]
fn http_sparse_fetch_404_is_pack_not_found() {
    let tree = tree_for(&[b"a"]);
    let th = tree_hash(&tree);
    let filter = vec![PathBuf::from("a")];

    let mut server = Server::new();
    let _m = server
        .mock("POST", sparse_path(&th).as_str())
        .match_query(Matcher::Any)
        .with_status(404)
        .create();

    let tx = make_transport(&server);
    let err = tx.fetch_sparse_tree(&th, &filter).unwrap_err();
    assert!(
        matches!(err, mkit_core::protocol::TransportError::PackNotFound),
        "404 must surface as PackNotFound, got {err:?}"
    );
}

#[test]
fn http_sparse_fetch_garbage_body_is_invalid_response() {
    let tree = tree_for(&[b"a"]);
    let th = tree_hash(&tree);
    let filter = vec![PathBuf::from("a")];

    let mut server = Server::new();
    let _m = server
        .mock("POST", sparse_path(&th).as_str())
        .match_query(Matcher::Any)
        .with_status(200)
        .with_body(b"this is not a sparse envelope")
        .create();

    let tx = make_transport(&server);
    let err = tx.fetch_sparse_tree(&th, &filter).unwrap_err();
    assert!(
        matches!(err, mkit_core::protocol::TransportError::InvalidResponse),
        "bad envelope must surface as InvalidResponse, got {err:?}"
    );
}