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())
}