michal/tit

Browse tree · Show commit · Download archive

Blob: tests/git_http.rs

Raw · Blame

#[path = "../src/git/http.rs"]
mod http;
#[allow(dead_code, reason = "the HTTP test does not run maintenance")]
#[path = "../src/maintenance.rs"]
mod maintenance;
#[allow(
    dead_code,
    reason = "the HTTP test does not use each shared protocol API"
)]
#[path = "../src/git/packetline.rs"]
mod packetline;
#[allow(dead_code, reason = "the HTTP test does not use repository policy")]
#[path = "../src/policy.rs"]
mod policy;
#[allow(
    dead_code,
    reason = "the HTTP test does not inspect repository internals"
)]
#[path = "../src/git/repository.rs"]
mod repository;
#[allow(dead_code, reason = "the HTTP test does not use the intent store")]
#[path = "../src/store/mod.rs"]
mod store;
#[allow(
    dead_code,
    reason = "the HTTP test uses transport resolution through HTTP"
)]
#[path = "../src/git/transport.rs"]
mod transport;
#[allow(dead_code, reason = "the HTTP test uses upload-pack through HTTP")]
#[path = "../src/git/upload_pack.rs"]
mod upload_pack;

use std::fs;
use std::io::{Read, Write};
use std::net::{Ipv4Addr, SocketAddr};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;

use http::RunningGitHttpServer;
use tempfile::TempDir;
use transport::GitRepositories;
use upload_pack::{ProtocolVersion, UploadPack, UploadPackError};

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn stock_git_clones_and_fetches_both_hash_formats_over_smart_http() {
    for format in ["sha1", "sha256"] {
        let directory = TempDir::new().expect("create a Git HTTP fixture directory");
        let repositories_root = directory.path().join("repositories");
        let bare = repositories_root.join("alice/example.git");
        let worktree = directory.path().join("worktree");
        create_fixture(&worktree, &bare, format);
        let repositories =
            GitRepositories::new(&repositories_root).expect("open the repository root");
        let server =
            RunningGitHttpServer::start(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), repositories)
                .await
                .expect("start the Git HTTP server");

        let versions: &[&str] = if format == "sha1" {
            &["0", "1", "2"]
        } else {
            &["1", "2"]
        };
        for version in versions {
            let clone = directory.path().join(format!("clone-v{version}"));
            let url = format!("http://{}/alice/example", server.address());
            run(Command::new("git")
                .args(["-c", &format!("protocol.version={version}"), "clone", "-q"])
                .arg(&url)
                .arg(&clone));
            assert_eq!(head(&clone), head(&worktree));
            assert_eq!(
                rev_parse(&clone, "refs/remotes/origin/feature"),
                rev_parse(&worktree, "refs/heads/feature")
            );
            assert_eq!(
                rev_parse(&clone, "refs/tags/v1^{}"),
                rev_parse(&worktree, "refs/tags/v1^{}")
            );
            assert_eq!(
                fs::read(clone.join("non-ascii-\u{00e5}.txt")).expect("read a cloned file"),
                b"first payload\n"
            );
            assert_eq!(
                fs::metadata(clone.join("large-copy.bin"))
                    .expect("inspect a cloned large blob")
                    .len(),
                2 * 1024 * 1024
            );

            append_commit(&worktree, format!("fetch-v{version}.txt"));
            run(Command::new("git").arg("-C").arg(&worktree).args([
                "push",
                "-q",
                &bare.to_string_lossy(),
                "main",
            ]));
            run(Command::new("git").arg("-C").arg(&clone).args([
                "-c",
                &format!("protocol.version={version}"),
                "fetch",
                "-q",
            ]));
            assert_eq!(
                rev_parse(&clone, "refs/remotes/origin/main"),
                head(&worktree)
            );
        }

        server.shutdown().await.expect("stop the Git HTTP server");
    }
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rejects_invalid_http_routes_and_oversized_requests() {
    let directory = TempDir::new().expect("create a Git HTTP fixture directory");
    let repositories_root = directory.path().join("repositories");
    fs::create_dir_all(&repositories_root).expect("create the repository root");
    let repository = repositories_root.join("alice/example.git");
    fs::create_dir_all(repository.parent().expect("a repository owner directory"))
        .expect("create the repository owner directory");
    run(Command::new("git")
        .args(["init", "-q", "--bare"])
        .arg(&repository));
    let repositories = GitRepositories::new(&repositories_root).expect("open the repository root");
    let server =
        RunningGitHttpServer::start(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), repositories)
            .await
            .expect("start the Git HTTP server");

    let missing = Command::new("git")
        .args(["ls-remote", &format!("http://{}/../etc", server.address())])
        .output()
        .expect("run stock Git against an invalid route");
    assert!(!missing.status.success());

    let malformed = raw_http_request(
        server.address(),
        "/alice/example/git-upload-pack",
        "application/x-git-upload-pack-request",
        Some("version=2"),
        b"zzzz",
    );
    assert!(malformed.starts_with(b"HTTP/1.1 400"));

    let wrong_service = raw_http_get(
        server.address(),
        "/alice/example/info/refs?service=git-receive-pack",
    );
    assert!(wrong_service.starts_with(b"HTTP/1.1 400"));

    let wrong_content_type = raw_http_request(
        server.address(),
        "/alice/example/git-upload-pack",
        "text/plain",
        Some("version=2"),
        b"0000",
    );
    assert!(wrong_content_type.starts_with(b"HTTP/1.1 415"));

    let wrong_version = raw_http_request(
        server.address(),
        "/alice/example/git-upload-pack",
        "application/x-git-upload-pack-request",
        Some("version=2:extra"),
        b"0000",
    );
    assert!(wrong_version.starts_with(b"HTTP/1.1 400"));

    let oversized = raw_http_request(
        server.address(),
        "/alice/example/git-upload-pack",
        "application/x-git-upload-pack-request",
        Some("version=2"),
        &vec![b'0'; packetline::MAX_REQUEST_BYTES + 1],
    );
    assert!(oversized.starts_with(b"HTTP/1.1 413"));
    server.shutdown().await.expect("stop the Git HTTP server");
}

#[test]
fn upload_pack_deduplicates_and_limits_negotiation_ids() {
    let directory = TempDir::new().expect("create an upload-pack fixture directory");
    let worktree = directory.path().join("worktree");
    let bare = directory.path().join("repository.git");
    create_fixture(&worktree, &bare, "sha1");
    let head = rev_parse(&bare, "refs/heads/main");
    let service = UploadPack::open(&bare).expect("open upload-pack");

    let mut duplicate = Vec::new();
    for _ in 0..512 {
        packetline::encode_data(format!("want {head}\n").as_bytes(), &mut duplicate)
            .expect("encode a duplicate want");
    }
    packetline::encode_data(b"done\n", &mut duplicate).expect("encode done");
    packetline::encode_flush(&mut duplicate);
    let response = service
        .respond(ProtocolVersion::V1, &duplicate)
        .expect("deduplicate negotiation IDs");
    assert!(response.windows(4).any(|window| window == b"PACK"));

    let mut excessive = Vec::new();
    for value in 1_u64..=257 {
        packetline::encode_data(format!("want {value:040x}\n").as_bytes(), &mut excessive)
            .expect("encode a distinct want");
    }
    packetline::encode_data(b"done\n", &mut excessive).expect("encode done");
    packetline::encode_flush(&mut excessive);
    assert!(matches!(
        service.respond(ProtocolVersion::V1, &excessive),
        Err(UploadPackError::NegotiationLimit)
    ));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stock_git_clones_empty_repositories_for_both_hash_formats() {
    for format in ["sha1", "sha256"] {
        let directory = TempDir::new().expect("create an empty repository fixture directory");
        let repositories_root = directory.path().join("repositories");
        let bare = repositories_root.join("alice/empty.git");
        fs::create_dir_all(bare.parent().expect("an empty repository parent"))
            .expect("create an empty repository owner directory");
        run(Command::new("git")
            .args(["init", "-q", "--bare", "--object-format", format])
            .arg(&bare));
        let repositories =
            GitRepositories::new(&repositories_root).expect("open the repository root");
        let server =
            RunningGitHttpServer::start(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), repositories)
                .await
                .expect("start the Git HTTP server");
        let clone = directory.path().join("clone");
        run(Command::new("git")
            .args(["-c", "protocol.version=2", "clone", "-q"])
            .arg(format!("http://{}/alice/empty", server.address()))
            .arg(&clone));
        let head = Command::new("git")
            .arg("-C")
            .arg(&clone)
            .args(["rev-parse", "HEAD"])
            .output()
            .expect("inspect the empty clone");
        assert!(!head.status.success());
        server.shutdown().await.expect("stop the Git HTTP server");
    }
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_process_does_not_invoke_git() {
    const CHILD_VARIABLE: &str = "TIT_M1C_INSTRUMENTED_CHILD";
    const ROOT_VARIABLE: &str = "TIT_M1C_REPOSITORY_ROOT";
    const GIT_VARIABLE: &str = "TIT_M1C_GIT_BINARY";
    const PATH_VARIABLE: &str = "TIT_M1C_DRIVER_PATH";
    const EXEC_PATH_VARIABLE: &str = "TIT_M1C_GIT_EXEC_PATH";

    if std::env::var_os(CHILD_VARIABLE).is_some() {
        let root = PathBuf::from(std::env::var_os(ROOT_VARIABLE).expect("a repository root"));
        let git = PathBuf::from(std::env::var_os(GIT_VARIABLE).expect("a stock Git binary"));
        let driver_path = std::env::var_os(PATH_VARIABLE).expect("a stock Git PATH");
        let git_exec_path = std::env::var_os(EXEC_PATH_VARIABLE).expect("a stock Git exec path");
        let repositories = GitRepositories::new(&root).expect("open the repository root");
        let server =
            RunningGitHttpServer::start(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), repositories)
                .await
                .expect("start the instrumented Git HTTP server");
        let clone = root
            .parent()
            .expect("a fixture directory")
            .join("instrumented-clone");
        let output = Command::new(git)
            .args(["-c", "protocol.version=2", "clone", "-q"])
            .arg(format!("http://{}/alice/example", server.address()))
            .arg(clone)
            .env("PATH", driver_path)
            .env("GIT_EXEC_PATH", git_exec_path)
            .output()
            .expect("run the instrumented stock Git client");
        assert!(
            output.status.success(),
            "instrumented stock Git failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
        server.shutdown().await.expect("stop the Git HTTP server");
        return;
    }

    let directory = TempDir::new().expect("create an instrumentation fixture directory");
    let repositories_root = directory.path().join("repositories");
    create_fixture(
        &directory.path().join("worktree"),
        &repositories_root.join("alice/example.git"),
        "sha1",
    );
    let marker = directory.path().join("git-was-invoked");
    let sentinel_directory = directory.path().join("sentinel-path");
    fs::create_dir(&sentinel_directory).expect("create the sentinel PATH");
    let sentinel = sentinel_directory.join("git");
    fs::write(
        &sentinel,
        format!(
            "#!/bin/sh\nprintf invoked > '{}'\nexit 97\n",
            marker.display()
        ),
    )
    .expect("write the Git sentinel");
    fs::set_permissions(&sentinel, fs::Permissions::from_mode(0o700))
        .expect("make the Git sentinel executable");

    let git_binary = command_output(Command::new("which").arg("git"));
    let git_exec_path = command_output(Command::new("git").arg("--exec-path"));
    let driver_path = std::env::var_os("PATH").expect("the test driver PATH");
    let output = Command::new(std::env::current_exe().expect("find the test executable"))
        .args([
            "--exact",
            "server_process_does_not_invoke_git",
            "--nocapture",
        ])
        .env(CHILD_VARIABLE, "1")
        .env(ROOT_VARIABLE, &repositories_root)
        .env(GIT_VARIABLE, git_binary.trim())
        .env(PATH_VARIABLE, driver_path)
        .env(EXEC_PATH_VARIABLE, git_exec_path.trim())
        .env("PATH", &sentinel_directory)
        .output()
        .expect("run the instrumented server process");
    assert!(
        output.status.success(),
        "instrumented server process failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(!marker.exists(), "the server process invoked Git");
}

fn command_output(command: &mut Command) -> String {
    let output = run(command);
    String::from_utf8(output.stdout)
        .expect("read command output")
        .trim()
        .to_owned()
}

fn raw_http_request(
    address: SocketAddr,
    path: &str,
    content_type: &str,
    git_protocol: Option<&str>,
    body: &[u8],
) -> Vec<u8> {
    let mut stream = std::net::TcpStream::connect(address).expect("connect to the Git HTTP server");
    let protocol_header = git_protocol
        .map(|value| format!("Git-Protocol: {value}\r\n"))
        .unwrap_or_default();
    write!(
        stream,
        "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: {content_type}\r\n{protocol_header}Content-Length: {}\r\nConnection: close\r\n\r\n",
        body.len()
    )
    .expect("write HTTP request headers");
    stream.write_all(body).expect("write HTTP request body");
    let mut response = Vec::new();
    stream
        .read_to_end(&mut response)
        .expect("read the Git HTTP response");
    response
}

fn raw_http_get(address: SocketAddr, path: &str) -> Vec<u8> {
    let mut stream = std::net::TcpStream::connect(address).expect("connect to the Git HTTP server");
    write!(
        stream,
        "GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n"
    )
    .expect("write HTTP request");
    let mut response = Vec::new();
    stream
        .read_to_end(&mut response)
        .expect("read the Git HTTP response");
    response
}

fn create_fixture(worktree: &Path, bare: &Path, format: &str) {
    fs::create_dir_all(bare.parent().expect("a bare repository parent"))
        .expect("create the repository owner directory");
    run(Command::new("git")
        .args(["init", "-q", "--object-format", format, "-b", "main"])
        .arg(worktree));
    run(Command::new("git")
        .arg("-C")
        .arg(worktree)
        .args(["config", "user.name", "Tit Test"]));
    run(Command::new("git").arg("-C").arg(worktree).args([
        "config",
        "user.email",
        "tit@example.invalid",
    ]));
    run(Command::new("git")
        .arg("-C")
        .arg(worktree)
        .args(["config", "commit.gpgsign", "false"]));
    run(Command::new("git")
        .arg("-C")
        .arg(worktree)
        .args(["config", "tag.gpgsign", "false"]));
    fs::write(worktree.join("non-ascii-\u{00e5}.txt"), b"first payload\n")
        .expect("write the first fixture file");
    run(Command::new("git")
        .arg("-C")
        .arg(worktree)
        .args(["add", "."]));
    run(Command::new("git")
        .arg("-C")
        .arg(worktree)
        .args(["commit", "-q", "-m", "first commit"]));
    run(Command::new("git")
        .arg("-C")
        .arg(worktree)
        .args(["tag", "-a", "v1", "-m", "version one"]));
    let large = vec![b'a'; 2 * 1024 * 1024];
    let mut similar = large.clone();
    similar[1024 * 1024..1024 * 1024 + 16].copy_from_slice(b"different bytes!");
    fs::write(worktree.join("large-original.bin"), large).expect("write a large fixture blob");
    fs::write(worktree.join("large-copy.bin"), similar).expect("write a similar fixture blob");
    run(Command::new("git")
        .arg("-C")
        .arg(worktree)
        .args(["add", "."]));
    run(Command::new("git").arg("-C").arg(worktree).args([
        "commit",
        "-q",
        "-m",
        "add large similar blobs",
    ]));
    run(Command::new("git")
        .arg("-C")
        .arg(worktree)
        .args(["branch", "feature", "HEAD~1"]));
    run(Command::new("git")
        .args(["clone", "-q", "--bare"])
        .arg(worktree)
        .arg(bare));
    run(Command::new("git")
        .arg("--git-dir")
        .arg(bare)
        .args(["gc", "--aggressive", "--prune=now"]));
    assert_has_delta(bare);
}

fn assert_has_delta(repository: &Path) {
    let index = fs::read_dir(repository.join("objects/pack"))
        .expect("read the pack directory")
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .find(|path| path.extension().is_some_and(|extension| extension == "idx"))
        .expect("find a pack index");
    let output = run(Command::new("git")
        .arg("--git-dir")
        .arg(repository)
        .args(["verify-pack", "-v"])
        .arg(index));
    assert!(
        String::from_utf8(output.stdout)
            .expect("read verify-pack output")
            .lines()
            .any(|line| line.split_whitespace().count() >= 7),
        "the fixture pack does not contain a delta"
    );
}

fn append_commit(worktree: &Path, filename: String) {
    fs::write(
        worktree.join(&filename),
        format!("payload for {filename}\n"),
    )
    .expect("write a fetch fixture file");
    run(Command::new("git")
        .arg("-C")
        .arg(worktree)
        .args(["add", &filename]));
    run(Command::new("git").arg("-C").arg(worktree).args([
        "commit",
        "-q",
        "-m",
        &format!("add {filename}"),
    ]));
}

fn head(path: &Path) -> String {
    rev_parse(path, "HEAD")
}

fn rev_parse(path: &Path, name: &str) -> String {
    let output = run(Command::new("git")
        .arg("-C")
        .arg(path)
        .args(["rev-parse", name]));
    String::from_utf8(output.stdout)
        .expect("read an object ID")
        .trim()
        .to_owned()
}

fn run(command: &mut Command) -> std::process::Output {
    let output = command.output().expect("run stock Git");
    assert!(
        output.status.success(),
        "stock Git failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    output
}