From e470774f6a362f66a7eed2f1c534941aea31f3a3 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 29 Apr 2026 07:59:05 +0000 Subject: [PATCH] cmd/vendorhash: track vendor SRI in flakehashes.json Move the headscale vendorHash out of flake.nix into a content- addressed flakehashes.json maintained by a small Go tool. The schema and goModFingerprint algorithm mirror upstream tailscale's tool/updateflakes so a future shared library extraction is trivial. vendorhash check verifies flakehashes.json against the current go.mod/go.sum. Hot path is a sha256 over those two files, so re-runs without input change are essentially free; only an actual fingerprint drift triggers go mod vendor + nardump.SRI. vendorhash update recomputes both fields and rewrites the JSON. The nix-vendor-sri devShell shim now wraps it. --- cmd/vendorhash/main.go | 221 +++++++++++++++++++++++++++++++++++++++++ flake.nix | 14 +-- flakehashes.json | 6 ++ 3 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 cmd/vendorhash/main.go create mode 100644 flakehashes.json diff --git a/cmd/vendorhash/main.go b/cmd/vendorhash/main.go new file mode 100644 index 00000000..ca6e35fc --- /dev/null +++ b/cmd/vendorhash/main.go @@ -0,0 +1,221 @@ +// vendorhash maintains the Nix SRI hash for the Go module vendor tree +// and stores it in flakehashes.json alongside a content fingerprint of +// go.mod and go.sum. +// +// Each block records its input fingerprint (goModSum) so that re-runs +// with no input change are essentially free: the fast path is just a +// sha256 over two small files. The vendor tree is only re-walked when +// the fingerprint actually drifts. +// +// Subcommands: +// +// vendorhash check exit non-zero if flakehashes.json is stale +// vendorhash update recompute and rewrite flakehashes.json +// +// The JSON schema and goModFingerprint algorithm mirror upstream +// tailscale's tool/updateflakes so a future shared library extraction +// is straightforward. +package main + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + + "tailscale.com/cmd/nardump/nardump" +) + +const ( + hashesFile = "flakehashes.json" + goModFile = "go.mod" + goSumFile = "go.sum" +) + +type FlakeHashes struct { + Vendor VendorBlock `json:"vendor"` +} + +type VendorBlock struct { + GoModSum string `json:"goModSum"` + SRI string `json:"sri"` +} + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + + ctx := context.Background() + + var err error + + switch os.Args[1] { + case "check": + err = cmdCheck(ctx) + case "update": + err = cmdUpdate(ctx) + case "-h", "--help", "help": + usage() + return + default: + usage() + os.Exit(2) + } + + if err != nil { + if errors.Is(err, errStale) { + os.Exit(1) + } + + fmt.Fprintln(os.Stderr, "vendorhash:", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: vendorhash ") +} + +// errStale signals to main that the check found a mismatch; it has +// already printed a remediation message, so main should exit 1 +// silently. +var errStale = errors.New("vendor hash stale") + +// cmdCheck verifies that flakehashes.json matches the current +// go.mod/go.sum. The fast path (fingerprint unchanged) costs only +// a sha256 over the two files. On mismatch, it computes the actual +// SRI so the failure message gives the developer the value to paste +// (or to run `vendorhash update`). +func cmdCheck(ctx context.Context) error { + hashes, err := loadHashes() + if err != nil { + return err + } + + curFP, err := goModFingerprint() + if err != nil { + return err + } + + if curFP == hashes.Vendor.GoModSum { + return nil + } + + curSRI, err := hashVendor(ctx) + if err != nil { + return err + } + + fmt.Fprintln(os.Stderr, "vendor hash is stale.") + fmt.Fprintf(os.Stderr, " expected goModSum: %s\n", hashes.Vendor.GoModSum) + fmt.Fprintf(os.Stderr, " actual goModSum: %s\n", curFP) + fmt.Fprintf(os.Stderr, " expected sri: %s\n", hashes.Vendor.SRI) + fmt.Fprintf(os.Stderr, " actual sri: %s\n", curSRI) + fmt.Fprintln(os.Stderr, "run: go run ./cmd/vendorhash update") + // Also emit machine-parseable lines so CI can pick them up. + fmt.Printf("expected_sri=%s\n", hashes.Vendor.SRI) + fmt.Printf("actual_sri=%s\n", curSRI) + + return errStale +} + +func cmdUpdate(ctx context.Context) error { + fp, err := goModFingerprint() + if err != nil { + return err + } + + sri, err := hashVendor(ctx) + if err != nil { + return err + } + + return writeHashes(FlakeHashes{ + Vendor: VendorBlock{ + GoModSum: fp, + SRI: sri, + }, + }) +} + +// goModFingerprint returns a content fingerprint of go.mod and go.sum +// that changes whenever either file changes. The byte layout matches +// upstream tailscale's tool/updateflakes. +func goModFingerprint() (string, error) { + h := sha256.New() + + for _, f := range []string{goModFile, goSumFile} { + b, err := os.ReadFile(f) + if err != nil { + return "", err + } + + fmt.Fprintf(h, "%s %d\n", f, len(b)) + h.Write(b) + } + + return "sha256-" + base64.StdEncoding.EncodeToString(h.Sum(nil)), nil +} + +// hashVendor runs `go mod vendor` into a temporary directory and +// returns the Nix SRI hash of the resulting tree. +func hashVendor(ctx context.Context) (string, error) { + out, err := os.MkdirTemp("", "nar-vendor-") + if err != nil { + return "", err + } + // `go mod vendor -o` requires the destination to not already exist. + err = os.Remove(out) + if err != nil { + return "", err + } + + defer os.RemoveAll(out) + + cmd := exec.CommandContext(ctx, "go", "mod", "vendor", "-o", out) + + cmd.Env = append(os.Environ(), "GOWORK=off") + cmd.Stderr = os.Stderr + + err = cmd.Run() + if err != nil { + return "", fmt.Errorf("go mod vendor: %w", err) + } + + return nardump.SRI(os.DirFS(out)) +} + +func loadHashes() (FlakeHashes, error) { + var h FlakeHashes + + b, err := os.ReadFile(hashesFile) + if err != nil { + return h, err + } + + err = json.Unmarshal(b, &h) + if err != nil { + return h, fmt.Errorf("%s: %w", hashesFile, err) + } + + return h, nil +} + +func writeHashes(h FlakeHashes) error { + b, err := json.MarshalIndent(h, "", " ") + if err != nil { + return err + } + + b = append(b, '\n') + + // flakehashes.json is committed source read by Nix during evaluation; + // world-readable matches every other tracked file in the repo. + return os.WriteFile(hashesFile, b, 0o644) //nolint:gosec +} diff --git a/flake.nix b/flake.nix index 98fa50f4..ef0b40b8 100644 --- a/flake.nix +++ b/flake.nix @@ -27,7 +27,7 @@ let pkgs = nixpkgs.legacyPackages.${prev.stdenv.hostPlatform.system}; buildGo = pkgs.buildGo126Module; - vendorHash = "sha256-Jquzx8xIkV28S8DnZVH8apQ4q+Q92e5yVX13fxodw8Y="; + vendorHash = (builtins.fromJSON (builtins.readFile ./flakehashes.json)).vendor.sri; in { headscale = buildGo { @@ -38,8 +38,8 @@ # Only run unit tests when testing a build checkFlags = [ "-short" ]; - # When updating go.mod or go.sum, a new sha will need to be calculated, - # update this if you have a mismatch after doing a change to those files. + # vendorHash is read from flakehashes.json; refresh via: + # go run ./cmd/vendorhash update inherit vendorHash; subPackages = [ "cmd/headscale" ]; @@ -223,13 +223,7 @@ "nix-vendor-sri" '' set -eu - - OUT=$(mktemp -d -t nar-hash-XXXXXX) - rm -rf "$OUT" - - go mod vendor -o "$OUT" - go run tailscale.com/cmd/nardump --sri "$OUT" - rm -rf "$OUT" + exec go run ./cmd/vendorhash update "$@" '') (pkgs.writeShellScriptBin diff --git a/flakehashes.json b/flakehashes.json new file mode 100644 index 00000000..5c88162e --- /dev/null +++ b/flakehashes.json @@ -0,0 +1,6 @@ +{ + "vendor": { + "goModSum": "sha256-IE0n9cSqO4XNX4RN+CGBk9VC46iACiZKDFf/215iivk=", + "sri": "sha256-ijEIP9NSomhlWOgsVN7tPvSuvkTiLtnvXvhZmatIDLM=" + } +}