Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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