a73x

tests/version_test.rs

Ref:   Size: 15.0 KiB   History

//! `--version` and build provenance.
//!
//! The point of these tests is not that a version string exists but that it
//! names the *commit the binary was built from*. A binary that reports only
//! `0.1.0` cannot be distinguished from one built three weeks ago, which is
//! exactly the failure this is meant to make impossible.

mod common;

use std::fs;
use std::process::Command;

use common::TestRepo;
use git_collab::build_provenance::resolve_build_commit;
use git_collab::cli::{declared, format_version, version_string, BUILD_COMMIT};

/// A commit captured at build time must look like a git object name.
fn assert_looks_like_commit(commit: &str) {
    assert!(
        commit.len() >= 7,
        "build commit '{}' is too short to identify anything",
        commit
    );
    assert!(
        commit.chars().all(|c| c.is_ascii_hexdigit()),
        "build commit '{}' is not hex",
        commit
    );
}

/// Both binaries must answer `--version`, and when the build had a git
/// checkout the answer must carry the commit.
fn assert_version_output(binary: &str, stdout: &str) {
    assert!(
        stdout.contains(env!("CARGO_PKG_VERSION")),
        "{} --version should name the crate version: {}",
        binary,
        stdout
    );
    match BUILD_COMMIT {
        Some(commit) => {
            assert_looks_like_commit(commit);
            assert!(
                stdout.contains(commit),
                "{} --version should name the build commit '{}': {}",
                binary,
                commit,
                stdout
            );
        }
        // Built from a tarball with no `.git`. The contract is that the
        // version degrades to the crate version alone rather than failing.
        None => assert_eq!(stdout.trim(), format!("{} {}", binary, version_string())),
    }
}

#[test]
fn cli_binary_reports_version_with_build_commit() {
    let output = Command::new(env!("CARGO_BIN_EXE_git-collab"))
        .arg("--version")
        .output()
        .expect("failed to run git-collab");
    assert!(
        output.status.success(),
        "git-collab --version should exit 0: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert_version_output("git-collab", &String::from_utf8_lossy(&output.stdout));
}

#[test]
fn server_binary_reports_version_with_build_commit() {
    let output = Command::new(env!("CARGO_BIN_EXE_git-collab-server"))
        .arg("--version")
        .output()
        .expect("failed to run git-collab-server");
    assert!(
        output.status.success(),
        "git-collab-server --version should exit 0: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert_version_output(
        "git-collab-server",
        &String::from_utf8_lossy(&output.stdout),
    );
}

/// `--version` is a question about the binary, not about a repository, so it
/// must answer outside a git checkout. `git-collab` otherwise exits 1 with
/// "could not find repository".
#[test]
fn version_works_outside_a_git_repo() {
    let output = Command::new(env!("CARGO_BIN_EXE_git-collab"))
        .arg("--version")
        .current_dir(std::env::temp_dir())
        .output()
        .expect("failed to run git-collab");
    assert!(
        output.status.success(),
        "--version should not need a repo: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}

/// `-V` is the conventional short form and clap wires it alongside `--version`.
#[test]
fn short_version_flag_works_on_both_binaries() {
    for binary in [
        env!("CARGO_BIN_EXE_git-collab"),
        env!("CARGO_BIN_EXE_git-collab-server"),
    ] {
        let output = Command::new(binary)
            .arg("-V")
            .output()
            .expect("failed to run binary");
        assert!(output.status.success(), "-V should exit 0 for {}", binary);
        assert!(
            String::from_utf8_lossy(&output.stdout).contains(env!("CARGO_PKG_VERSION")),
            "-V should name the crate version for {}",
            binary
        );
    }
}

/// `status` is where someone looks when behaviour is inexplicable, so the
/// build identity belongs there too.
#[test]
fn status_names_the_build_version() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let out = repo.run_ok(&["status"]);
    assert!(
        out.contains(env!("CARGO_PKG_VERSION")),
        "status should name the build version: {}",
        out
    );
    if let Some(commit) = BUILD_COMMIT {
        assert!(
            out.contains(commit),
            "status should name the build commit '{}': {}",
            commit,
            out
        );
    }
}

// ---------------------------------------------------------------------------
// The formatting contract, including the degraded no-git case, which cannot be
// exercised through the built binary because this checkout always has a `.git`.
// ---------------------------------------------------------------------------

#[test]
fn version_without_a_commit_is_the_bare_crate_version() {
    assert_eq!(format_version("0.1.0", None, false), "0.1.0");
}

/// A dirty marker with no commit would claim more than is known: without a
/// commit there is nothing for "dirty" to be relative to.
#[test]
fn version_without_a_commit_ignores_the_dirty_marker() {
    assert_eq!(format_version("0.1.0", None, true), "0.1.0");
}

#[test]
fn version_with_a_clean_commit_names_it() {
    assert_eq!(
        format_version("0.1.0", Some("29768e2"), false),
        "0.1.0 (29768e2)"
    );
}

#[test]
fn version_with_a_dirty_tree_says_so() {
    assert_eq!(
        format_version("0.1.0", Some("29768e2"), true),
        "0.1.0 (29768e2-dirty)"
    );
}

// ---------------------------------------------------------------------------
// Where the commit comes from.
//
// The container build is the case that motivates this: `.dockerignore` excludes
// `.git/`, correctly, so a build inside the image has no repository to
// interrogate, and the graceful degradation costs exactly the artifact whose
// identity is hardest to establish from the outside — you cannot `git log` a
// container. The fix is to pass the commit in, which makes "which commit is
// this" a decision with two possible sources, and therefore a rule about which
// source wins.
// ---------------------------------------------------------------------------

const FULL: &str = "b2db9cf477d1b655cf3a3bde35cb15f0b034536e";
const OTHER: &str = "7a13f3011ee40e28c1abed8f1c58e0a7d1e51a2c";

/// The whole point of the override: a build with no repository still knows
/// which commit it is.
#[test]
fn a_passed_commit_is_used_when_there_is_no_checkout() {
    assert_eq!(resolve_build_commit(Some(FULL), None), Ok(Some(FULL)));
}

/// Passing a commit is a deliberate act by whoever started the build; the
/// enclosing checkout is a guess made on their behalf. When the two disagree
/// the deliberate one wins, which is what makes `--build-arg` usable from a
/// wrapper that knows more than the build tree does.
#[test]
fn a_passed_commit_overrides_the_discovered_one() {
    assert_eq!(
        resolve_build_commit(Some(FULL), Some(OTHER)),
        Ok(Some(FULL))
    );
}

#[test]
fn without_a_passed_commit_the_discovered_one_is_used() {
    assert_eq!(resolve_build_commit(None, Some(OTHER)), Ok(Some(OTHER)));
}

/// The degraded case, which must stay degraded rather than become an error: a
/// release tarball with no `.git` and no build argument still builds.
#[test]
fn with_neither_source_there_is_no_commit() {
    assert_eq!(resolve_build_commit(None, None), Ok(None));
}

/// `--build-arg GIT_COMMIT=` and an `ARG` left at an empty default both arrive
/// as an empty string, which is the absence of a decision rather than a
/// decision to report an empty commit.
#[test]
fn an_empty_or_blank_passed_commit_is_not_a_decision() {
    assert_eq!(resolve_build_commit(Some(""), Some(OTHER)), Ok(Some(OTHER)));
    assert_eq!(
        resolve_build_commit(Some("  \n"), Some(OTHER)),
        Ok(Some(OTHER))
    );
    assert_eq!(resolve_build_commit(Some(""), None), Ok(None));
}

/// Surrounding whitespace is a shell artefact, not part of the name.
#[test]
fn a_passed_commit_is_trimmed() {
    assert_eq!(
        resolve_build_commit(Some("  b2db9cf  "), None),
        Ok(Some("b2db9cf"))
    );
}

/// A passed value that is not a commit is a mistake in the invocation, and the
/// build must say so rather than quietly producing the untraceable binary this
/// whole mechanism exists to prevent. Degrading here would hide the typo in
/// precisely the pipeline that cares most about the answer.
#[test]
fn a_malformed_passed_commit_fails_the_build() {
    let long = "a".repeat(65);
    for bad in [
        "HEAD",
        "not-a-sha",
        "b2db",
        "$(git rev-parse HEAD)",
        long.as_str(),
    ] {
        assert!(
            resolve_build_commit(Some(bad), None).is_err(),
            "'{bad}' should be rejected as a passed commit"
        );
    }
}

/// The asymmetry: a discovered value nobody asked for degrades to silence,
/// because failing the build over an odd local checkout would be a regression
/// against the tarball case that already works.
#[test]
fn a_malformed_discovered_commit_degrades_to_silence() {
    assert_eq!(
        resolve_build_commit(None, Some("ref: refs/heads/main")),
        Ok(None)
    );
    assert_eq!(resolve_build_commit(None, Some("")), Ok(None));
}

/// The rejection names the variable and the value, because whoever reads it is
/// looking at a `docker build` line and not at this file.
#[test]
fn the_rejection_names_the_variable_and_the_value() {
    let err = resolve_build_commit(Some("HEAD"), None).unwrap_err();
    assert!(err.contains("GIT_COLLAB_BUILD_COMMIT"), "{err}");
    assert!(err.contains("HEAD"), "{err}");
}

// ---------------------------------------------------------------------------
// End to end: what a real `cargo build` emits, and so what `--version` says.
//
// The binaries under test are built from this checkout and can never exercise
// either the passed-commit path or the no-repository path themselves. A
// throwaway crate whose build script *is* this crate's provenance code can
// exercise both, under a real cargo build, including the `option_env!` that
// `src/cli.rs` reads.
// ---------------------------------------------------------------------------

/// What a build ends up reporting: the two values `src/cli.rs` derives from
/// `option_env!`, run through the same `declared` filter and formatter the
/// binaries use.
fn probe_version(env: &[(&str, &str)]) -> String {
    let (commit, dirty) = probe_raw(env);
    format_version(
        "0.1.0",
        declared(commit.as_deref()),
        declared(dirty.as_deref()).is_some(),
    )
}

/// Build a probe crate whose `build.rs` is our `emit_build_provenance`, and
/// report the pair `src/cli.rs` would see — distinguishing a variable that is
/// unset from one that is set to the empty string, because that distinction is
/// exactly where this went wrong.
fn probe_raw(env: &[(&str, &str)]) -> (Option<String>, Option<String>) {
    let dir = tempfile::tempdir().expect("tempdir");
    let root = dir.path();
    let provenance = concat!(env!("CARGO_MANIFEST_DIR"), "/src/build_provenance.rs");

    fs::write(
        root.join("Cargo.toml"),
        // The empty `[workspace]` detaches the probe from any workspace above
        // the temporary directory.
        "[package]\nname = \"provenance-probe\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n",
    )
    .unwrap();
    fs::write(
        root.join("build.rs"),
        format!("include!(\"{provenance}\");\nfn main() {{ emit_build_provenance(); }}\n"),
    )
    .unwrap();
    fs::create_dir(root.join("src")).unwrap();
    fs::write(
        root.join("src/main.rs"),
        r#"fn report(name: &str, value: Option<&str>) {
    match value {
        Some(value) => println!("{name}=set:{value}"),
        None => println!("{name}=unset"),
    }
}
fn main() {
    report("commit", option_env!("GIT_COLLAB_BUILD_COMMIT"));
    report("dirty", option_env!("GIT_COLLAB_BUILD_DIRTY"));
}
"#,
    )
    .unwrap();

    let mut cmd = Command::new(env!("CARGO"));
    cmd.args(["run", "--quiet", "--offline"])
        .current_dir(root)
        .env("CARGO_TARGET_DIR", root.join("target"))
        .env_remove("GIT_COLLAB_BUILD_COMMIT")
        .env_remove("GIT_COLLAB_BUILD_DIRTY");
    for (key, value) in env {
        cmd.env(key, value);
    }
    let out = cmd
        .output()
        .expect("failed to run cargo for the probe crate");
    assert!(
        out.status.success(),
        "probe build failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
    let value = |prefix: &str| {
        stdout
            .lines()
            .find_map(|line| line.trim().strip_prefix(prefix))
            .and_then(|line| line.strip_prefix("set:"))
            .map(str::to_string)
    };
    (value("commit="), value("dirty="))
}

/// The container case: no `.git` in the build context, the commit passed in,
/// and `--version` naming it.
#[test]
fn a_build_given_a_commit_reports_it_in_the_version() {
    let (commit, _) = probe_raw(&[("GIT_COLLAB_BUILD_COMMIT", FULL)]);
    assert_eq!(commit.as_deref(), Some(FULL));
    assert_eq!(
        probe_version(&[("GIT_COLLAB_BUILD_COMMIT", FULL)]),
        format!("0.1.0 ({FULL})")
    );
}

/// The tarball case: nothing passed, no checkout of our own to read, the build
/// still succeeding, and the version degrading to the crate version alone.
#[test]
fn a_build_with_neither_a_commit_nor_a_repository_degrades() {
    let (commit, dirty) = probe_raw(&[]);
    assert_eq!(
        commit, None,
        "an absent or foreign checkout must not be reported"
    );
    assert_eq!(dirty, None);
    assert_eq!(probe_version(&[]), "0.1.0");
}

/// The shape a Dockerfile actually produces. `ENV FOO=$BAR` with `ARG BAR`
/// unset does not leave `FOO` unset — it sets it to the empty string, and
/// `option_env!` reads the compiler's whole environment and not merely what the
/// build script emitted. So both variables arrive as `Some("")` however little
/// the build was told, which is why "is it set" is the wrong question and "does
/// it have a value" is the right one.
///
/// Found by running the built image rather than by reasoning about it: the
/// first container built from this change reported a commit it had been given
/// correctly, and `-dirty` from a spotlessly clean tree.
#[test]
fn empty_environment_variables_are_not_provenance() {
    assert_eq!(
        probe_version(&[
            ("GIT_COLLAB_BUILD_COMMIT", FULL),
            ("GIT_COLLAB_BUILD_DIRTY", ""),
        ]),
        format!("0.1.0 ({FULL})"),
        "an empty dirty variable must not make a clean build claim to be dirty"
    );
    assert_eq!(
        probe_version(&[
            ("GIT_COLLAB_BUILD_COMMIT", ""),
            ("GIT_COLLAB_BUILD_DIRTY", ""),
        ]),
        "0.1.0",
        "an empty commit variable must degrade, not report an empty commit"
    );
}

/// The filter itself, at the boundary the constants use.
#[test]
fn a_variable_set_to_nothing_declares_nothing() {
    assert_eq!(declared(Some("")), None);
    assert_eq!(declared(None), None);
    assert_eq!(declared(Some("29768e2")), Some("29768e2"));
}