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.
This commit is contained in:
parent
980622e9a5
commit
e470774f6a
221
cmd/vendorhash/main.go
Normal file
221
cmd/vendorhash/main.go
Normal file
@ -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 <check|update>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
14
flake.nix
14
flake.nix
@ -27,7 +27,7 @@
|
|||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${prev.stdenv.hostPlatform.system};
|
pkgs = nixpkgs.legacyPackages.${prev.stdenv.hostPlatform.system};
|
||||||
buildGo = pkgs.buildGo126Module;
|
buildGo = pkgs.buildGo126Module;
|
||||||
vendorHash = "sha256-Jquzx8xIkV28S8DnZVH8apQ4q+Q92e5yVX13fxodw8Y=";
|
vendorHash = (builtins.fromJSON (builtins.readFile ./flakehashes.json)).vendor.sri;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
headscale = buildGo {
|
headscale = buildGo {
|
||||||
@ -38,8 +38,8 @@
|
|||||||
# Only run unit tests when testing a build
|
# Only run unit tests when testing a build
|
||||||
checkFlags = [ "-short" ];
|
checkFlags = [ "-short" ];
|
||||||
|
|
||||||
# When updating go.mod or go.sum, a new sha will need to be calculated,
|
# vendorHash is read from flakehashes.json; refresh via:
|
||||||
# update this if you have a mismatch after doing a change to those files.
|
# go run ./cmd/vendorhash update
|
||||||
inherit vendorHash;
|
inherit vendorHash;
|
||||||
|
|
||||||
subPackages = [ "cmd/headscale" ];
|
subPackages = [ "cmd/headscale" ];
|
||||||
@ -223,13 +223,7 @@
|
|||||||
"nix-vendor-sri"
|
"nix-vendor-sri"
|
||||||
''
|
''
|
||||||
set -eu
|
set -eu
|
||||||
|
exec go run ./cmd/vendorhash update "$@"
|
||||||
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"
|
|
||||||
'')
|
'')
|
||||||
|
|
||||||
(pkgs.writeShellScriptBin
|
(pkgs.writeShellScriptBin
|
||||||
|
|||||||
6
flakehashes.json
Normal file
6
flakehashes.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"vendor": {
|
||||||
|
"goModSum": "sha256-IE0n9cSqO4XNX4RN+CGBk9VC46iACiZKDFf/215iivk=",
|
||||||
|
"sri": "sha256-ijEIP9NSomhlWOgsVN7tPvSuvkTiLtnvXvhZmatIDLM="
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user