a73x

src/build_provenance.rs

Ref:   Size: 7.8 KiB   History

// ---------------------------------------------------------------------------
// Capturing the commit a binary was built from, for `--version`.
//
// This file is `include!`d by `build.rs` and is also a module of the library,
// the same arrangement `src/cli.rs` uses — which is why the header is a plain
// comment and not a `//!` one: an inner doc comment cannot survive `include!`.
// The build script needs the code; the test suite needs to be able to reason
// about it. It therefore depends on nothing outside `std`.
//
// There are two ways a build can learn its commit, and they are not equally
// trustworthy in the same direction:
//
//   Discovered — `git rev-parse HEAD` in the enclosing checkout. Convenient and
//     usually right, but it is a guess made on the builder's behalf, and it is
//     absent exactly when it is most wanted: a container build excludes `.git/`
//     from its context, on purpose, and a release tarball never had one.
//   Passed — `GIT_COLLAB_BUILD_COMMIT` in the build script's environment, set
//     deliberately (`docker build --build-arg GIT_COMMIT=...`).
//
// A passed commit wins over a discovered one, and it bypasses the same-checkout
// guard below rather than being filtered by it. That is not a hole in the
// guard: the guard exists because *discovery* can silently pick up a stranger's
// repository when this crate is vendored into another tree, and nobody chose
// that. Passing a value is a choice, made by whoever is in a position to know —
// a wrapper that still has the repository when the build no longer does. The
// failure the guard prevents (reporting some other tree's commit because it
// happened to be lying around) cannot happen by accident down this path.
//
// The cost of that trust is that a passed value must be *checked*: a build
// handed `GIT_COMMIT=HEAD` by an unexpanded shell variable fails loudly instead
// of quietly shipping a binary that cannot say where it came from.
// ---------------------------------------------------------------------------

use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::Command;

/// The environment variable that carries a commit into a build, and also the
/// name the value is re-emitted under for `src/cli.rs` to read. One name for
/// one fact, going in and coming out.
pub const COMMIT_VAR: &str = "GIT_COLLAB_BUILD_COMMIT";

/// Counterpart of [`COMMIT_VAR`] for the dirty marker. Only consulted when a
/// commit was passed: a build that discovers its own checkout can look at the
/// working tree itself and does not need to be told.
pub const DIRTY_VAR: &str = "GIT_COLLAB_BUILD_DIRTY";

/// Whether a string is shaped like a git object name.
///
/// Length is bounded below at 7 — git's own shortest customary abbreviation,
/// and short enough already to be ambiguous in a large repository, so anything
/// shorter is a mistake — and above at 64, the width of a SHA-256 object name.
pub fn is_object_name(value: &str) -> bool {
    (7..=64).contains(&value.len()) && value.chars().all(|c| c.is_ascii_hexdigit())
}

/// Decide which commit a build reports, given what was passed to it and what
/// it could discover.
///
/// `Err` means the build should fail; `Ok(None)` means it should proceed and
/// report the crate version alone. The difference between those two is the
/// difference between a value someone supplied and a value nobody asked for:
/// a bad passed value is a broken invocation and must surface, while a bad or
/// missing discovered value is the ordinary condition of building outside a
/// checkout.
pub fn resolve_build_commit<'a>(
    passed: Option<&'a str>,
    discovered: Option<&'a str>,
) -> Result<Option<&'a str>, String> {
    // An empty or whitespace-only value is the absence of a decision, not a
    // decision to report nothing: `--build-arg GIT_COMMIT=` and an `ARG` with
    // no default both arrive here as "".
    if let Some(passed) = passed.map(str::trim).filter(|v| !v.is_empty()) {
        return if is_object_name(passed) {
            Ok(Some(passed))
        } else {
            Err(format!(
                "{COMMIT_VAR} is set to {passed:?}, which is not a git object name. \
                 Pass a commit (`--build-arg GIT_COMMIT=$(git rev-parse HEAD)`) or unset it."
            ))
        };
    }
    Ok(discovered
        .map(str::trim)
        .filter(|value| is_object_name(value)))
}

/// Emit the `cargo:` directives that put the commit into the binary.
///
/// Every step is allowed to fail and none of them may fail the build — except
/// a malformed passed commit, which is a broken invocation rather than a
/// missing convenience. Otherwise nothing is emitted, `option_env!` in
/// `src/cli.rs` yields `None`, and `--version` degrades to the crate version.
pub fn emit_build_provenance() {
    println!("cargo:rerun-if-env-changed={COMMIT_VAR}");
    println!("cargo:rerun-if-env-changed={DIRTY_VAR}");

    let own_checkout = in_our_own_checkout();
    if own_checkout {
        emit_git_rerun_paths();
    }

    let passed = env::var(COMMIT_VAR).ok();
    let discovered = if own_checkout {
        git(&["rev-parse", "HEAD"])
    } else {
        None
    };

    let commit = match resolve_build_commit(passed.as_deref(), discovered.as_deref()) {
        Ok(Some(commit)) => commit.to_string(),
        Ok(None) => return,
        // The one fatal case. A build told the wrong thing about its own
        // identity is worse than a build told nothing, because the wrong
        // answer is the one that gets believed.
        Err(message) => panic!("{message}"),
    };
    let dirty = if passed_a_commit(passed.as_deref()) {
        // Nothing here can inspect the tree the commit refers to, so the
        // caller has to say. The Makefile's `docker` target does.
        env::var(DIRTY_VAR).is_ok_and(|value| !value.trim().is_empty())
    } else {
        // `--untracked-files=no` matches `git describe --dirty`: a stray build
        // artifact or editor swapfile is not a modification of the source.
        git(&["status", "--porcelain", "--untracked-files=no"])
            .is_some_and(|status| !status.is_empty())
    };

    println!("cargo:rustc-env={COMMIT_VAR}={commit}");
    if dirty {
        println!("cargo:rustc-env={DIRTY_VAR}=1");
    }
}

fn passed_a_commit(passed: Option<&str>) -> bool {
    passed.map(str::trim).is_some_and(|value| !value.is_empty())
}

/// Rebuild when the checkout moves to another commit.
///
/// `--git-path` resolves through worktrees and `$GIT_DIR`, where `.git` is a
/// file rather than a directory, so a hard-coded `.git/HEAD` would silently
/// track nothing.
fn emit_git_rerun_paths() {
    for path in ["HEAD", "refs", "packed-refs"] {
        if let Some(resolved) = git(&["rev-parse", "--git-path", path]) {
            if PathBuf::from(&resolved).exists() {
                println!("cargo:rerun-if-changed={resolved}");
            }
        }
    }
}

/// Whether the enclosing git repository is this crate's own checkout.
///
/// Vendoring `git-collab` into another project's tree would otherwise make
/// `git rev-parse HEAD` report *that* project's commit, and a `--version` that
/// names the wrong commit is worse than one that names none: the whole point
/// is to settle arguments about which source a binary came from.
fn in_our_own_checkout() -> bool {
    let Some(toplevel) = git(&["rev-parse", "--show-toplevel"]) else {
        return false;
    };
    let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") else {
        return false;
    };
    match (fs::canonicalize(&toplevel), fs::canonicalize(&manifest_dir)) {
        (Ok(a), Ok(b)) => a == b,
        _ => false,
    }
}

/// Run git and return trimmed stdout, or `None` for any failure at all.
fn git(args: &[&str]) -> Option<String> {
    let output = Command::new("git").args(args).output().ok()?;
    if !output.status.success() {
        return None;
    }
    Some(String::from_utf8(output.stdout).ok()?.trim().to_string())
}