crates/gunnar-proto/build.rs
//! Compiles `proto/gunnar/v1/*.proto` into `$OUT_DIR/gunnar.v1.rs` with
//! `tonic-build`.
//!
//! This runs `protoc`. `tonic-build` does not vendor one, so protoc must be on
//! PATH or pointed at by `$PROTOC`. The failure mode without the check below is
//! a `prost-build` error that names a missing binary but not what to do about
//! it, so the check exists purely to make the message actionable.
use std::path::{Path, PathBuf};
const PROTOS: &[&str] = &[
"proto/gunnar/v1/common.proto",
"proto/gunnar/v1/repos.proto",
"proto/gunnar/v1/refs.proto",
"proto/gunnar/v1/access.proto",
"proto/gunnar/v1/policy.proto",
"proto/gunnar/v1/events.proto",
"proto/gunnar/v1/admin.proto",
"proto/gunnar/v1/mirror.proto",
"proto/gunnar/v1/bootstrap.proto",
"proto/gunnar/v1/forge.proto",
"proto/gunnar/v1/pulls.proto",
"proto/gunnar/v1/webhooks.proto",
"proto/gunnar/v1/usage.proto",
// Many namespaces' meters in one call, only what changed (2026-10-06): its
// own service and file so every implementor and vendored copy of
// `usage.proto` stays as it was.
"proto/gunnar/v1/usage_batch.proto",
// Clans (2026-10-05): ordinary accounts grouped, each giving the clan
// part of its own room; its own file, as `accounts.proto` is.
"proto/gunnar/v1/clans.proto",
// The per-USER verbs — gate an account, hard-purge an account (2026-09-07).
// Its own file and not more RPCs on `repos.proto`, because its unit is a
// user rather than a path; the service comment says why.
"proto/gunnar/v1/accounts.proto",
// The owner's enterprise list (2026-10-05): the account audit log, the
// operator's aggregates and look, the name filter.
"proto/gunnar/v1/enterprise.proto",
"proto/gunnar/v1/sso.proto",
];
/// **Compiled only with the `metered` feature.** The `Entitlement` service is
/// the capability a self-hoster's build does not CONTAIN — the
/// metered variant is a cargo feature: with the feature off, these types never enter codegen anywhere in the
/// workspace, so a symbol-table read of the community `gunnar-server` finds no
/// `entitlement` family at all — `cargo tree` and `OptionAudit` can both say
/// so. `usage.proto` above is deliberately NOT here: counting is open source.
///
/// `billing.proto` joined it on 2026-09-17 for the same reason and with one of
/// its own: the community build never mentions money at all, so an appliance
/// that cannot sell a period must not carry the service that mints the
/// permission to buy one.
#[cfg(feature = "metered")]
const METERED_PROTOS: &[&str] = &[
"proto/gunnar/v1/entitlement.proto",
"proto/gunnar/v1/billing.proto",
// The named sealed slot (2026-10-09): a plain appliance carries no slot.
"proto/gunnar/v1/sealed.proto",
];
#[cfg(not(feature = "metered"))]
const METERED_PROTOS: &[&str] = &[];
/// Types reachable from a `PushAttestation`. The attestation is appended
/// to gunnar's log as one JSON line, so these, and nothing else, need serde.
const SERDE_TYPES: &[&str] = &[
".gunnar.v1.PushAttestation",
".gunnar.v1.RefUpdate",
".gunnar.v1.RepoPath",
];
fn main() {
for proto in PROTOS.iter().chain(METERED_PROTOS) {
println!("cargo:rerun-if-changed={proto}");
}
println!("cargo:rerun-if-env-changed=CARGO_FEATURE_METERED");
println!("cargo:rerun-if-changed=proto");
println!("cargo:rerun-if-env-changed=PROTOC");
// A `protoc` on PATH or in $PROTOC wins. Without one, the vendored binary
// from `protoc-bin-vendored` (a BUILD dependency only: it never reaches the
// shipped binary) is used, so `cargo install gunnar` needs no pre-step on a
// machine that has never heard of protobuf.
if let Err(why) = locate_protoc() {
match protoc_bin_vendored::protoc_bin_path() {
Ok(vendored) => std::env::set_var("PROTOC", vendored),
Err(e) => panic!(
"gunnar-proto cannot build: {why}, and the vendored protoc is not \
available for this platform ({e}).\n\
Install one (Debian/Ubuntu: apt install protobuf-compiler) or point \
at one: PROTOC=/path/to/protoc cargo build"
),
}
}
let mut config = tonic_prost_build::configure()
.build_server(true)
.build_client(true)
// `EntitlementFact.caps` as a `BTreeMap`: it is field for field
// `monetize_product::EntitlementFact`, whose `caps` is one, and the
// metered server moves the map between the two without a copy that
// could reorder it. A `HashMap` here would be a second shape of the
// same fact. Harmless with the feature off: the path names no message.
.btree_map(".gunnar.v1.EntitlementFact.caps")
// `MintActorTicketRequest.caps` for the same reason one step further
// along: it is moved into `monetize_embed::ticket::ActorTicket::caps`,
// which is a `BTreeMap` because the canonical JSON the SIGNATURE covers
// sorts keys. A `HashMap` here would mean the caps were re-ordered
// between the request and the signed message — harmless today, and
// exactly the kind of "harmless" that stops being so the moment
// anything hashes or logs the map on the way past. Harmless with the
// feature off: the path names no message.
.btree_map(".gunnar.v1.MintActorTicketRequest.caps");
// `#[serde(default)]` on the TYPE, not on a field: the attestation log is
// append-only and outlives every release, so every field the schema
// grows after a log has lines is absent from those lines. 1.4.0 on
// gunnar.rs refused `Repos.Chain` with "attestation line 0 does not
// parse: missing field `new_object_oids_unknown`" — a field 1.3.x never
// wrote. Per-field `default` would have fixed that one and left the
// next one to be found the same way. prost messages are `Default`, and
// an absent field reading as the wire's own default is exactly proto3's
// rule for a missing field. `gunnar-server/src/attest.rs` holds the test.
for ty in SERDE_TYPES {
config = config.type_attribute(
ty,
"#[derive(::serde::Serialize, ::serde::Deserialize)]\n#[serde(rename_all = \"snake_case\", default)]",
);
}
let protos: Vec<&str> = PROTOS.iter().chain(METERED_PROTOS).copied().collect();
config
.compile_protos(&protos, &["proto"])
.expect("tonic-prost-build failed to compile gunnar's .proto files");
}
/// `Ok(path)` if a usable protoc was found, `Err(reason)` otherwise.
fn locate_protoc() -> Result<PathBuf, String> {
if let Some(explicit) = std::env::var_os("PROTOC") {
let path = PathBuf::from(explicit);
return if path.is_file() {
Ok(path)
} else {
Err(format!("$PROTOC points at {path:?}, which is not a file"))
};
}
let path_var = std::env::var_os("PATH").ok_or_else(|| "PATH is unset".to_string())?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(protoc_file_name());
if candidate.is_file() {
return Ok(candidate);
}
}
Err("no `protoc` on PATH and $PROTOC is unset".to_string())
}
fn protoc_file_name() -> &'static Path {
if cfg!(windows) {
Path::new("protoc.exe")
} else {
Path::new("protoc")
}
}