Sanitized root snapshot from private source commit a72903c63e1753f9e6ffbf40453c0830bdfc05c5 and tree 295641e67eef5979da76746d8ae271249568263e. Private development history and workflows are excluded by the exact allowlist. AI-assisted: OpenAI Codex helped implement, test, and audit this preview. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
400 lines
13 KiB
Go
400 lines
13 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
package packager
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"debug/buildinfo"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gamertan.com/tend/internal/config"
|
|
"gamertan.com/tend/internal/process"
|
|
"gamertan.com/tend/internal/provenance"
|
|
)
|
|
|
|
var versionPattern = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+-preview\.[1-9][0-9]*$`)
|
|
|
|
type Result struct {
|
|
Artifact string `json:"artifact"`
|
|
SHA256 string `json:"sha256"`
|
|
Commit string `json:"commit"`
|
|
Version string `json:"version"`
|
|
}
|
|
type Manifest struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
Service string `json:"service"`
|
|
Version string `json:"version"`
|
|
Commit string `json:"commit"`
|
|
SourceEpoch int64 `json:"source_date_epoch"`
|
|
GOOS string `json:"goos"`
|
|
GOARCH string `json:"goarch"`
|
|
CGOEnabled bool `json:"cgo_enabled"`
|
|
Binary string `json:"binary"`
|
|
BinarySHA256 string `json:"binary_sha256"`
|
|
GoVersion string `json:"go_version"`
|
|
ModulePath string `json:"module_path"`
|
|
ModuleVersion string `json:"module_version"`
|
|
}
|
|
type buildRecord struct {
|
|
GoVersion string `json:"go_version"`
|
|
Path string `json:"path"`
|
|
Main moduleRecord `json:"main"`
|
|
Deps []moduleRecord `json:"dependencies"`
|
|
Settings []settingRecord `json:"settings"`
|
|
}
|
|
type moduleRecord struct {
|
|
Path string `json:"path"`
|
|
Version string `json:"version"`
|
|
Sum string `json:"sum,omitempty"`
|
|
}
|
|
type settingRecord struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
}
|
|
type spdxDocument struct {
|
|
SPDXVersion string `json:"spdxVersion"`
|
|
DataLicense string `json:"dataLicense"`
|
|
SPDXID string `json:"SPDXID"`
|
|
Name string `json:"name"`
|
|
DocumentNamespace string `json:"documentNamespace"`
|
|
CreationInfo spdxCreation `json:"creationInfo"`
|
|
Packages []spdxPackage `json:"packages"`
|
|
Relationships []spdxRelationship `json:"relationships"`
|
|
}
|
|
type spdxCreation struct {
|
|
Created string `json:"created"`
|
|
Creators []string `json:"creators"`
|
|
}
|
|
type spdxPackage struct {
|
|
Name string `json:"name"`
|
|
SPDXID string `json:"SPDXID"`
|
|
VersionInfo string `json:"versionInfo"`
|
|
DownloadLocation string `json:"downloadLocation"`
|
|
FilesAnalyzed bool `json:"filesAnalyzed"`
|
|
LicenseConcluded string `json:"licenseConcluded"`
|
|
LicenseDeclared string `json:"licenseDeclared"`
|
|
}
|
|
type spdxRelationship struct {
|
|
SPDXElementID string `json:"spdxElementId"`
|
|
RelationshipType string `json:"relationshipType"`
|
|
RelatedSPDXElement string `json:"relatedSpdxElement"`
|
|
}
|
|
|
|
func Package(ctx context.Context, runner process.Runner, cfg config.Config, sourceDir, outDir, version string) (Result, error) {
|
|
if !versionPattern.MatchString(version) {
|
|
return Result{}, errors.New("version must use vX.Y.Z-preview.N")
|
|
}
|
|
if !filepath.IsAbs(sourceDir) || !filepath.IsAbs(outDir) {
|
|
return Result{}, errors.New("source and output directories must be absolute")
|
|
}
|
|
source, err := provenance.Inspect(ctx, runner, sourceDir, cfg.Build.Branch)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := verifyModules(ctx, runner, sourceDir); err != nil {
|
|
return Result{}, err
|
|
}
|
|
work, err := os.MkdirTemp("", "tend-package-")
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
defer os.RemoveAll(work)
|
|
first := filepath.Join(work, "first", cfg.Build.Binary)
|
|
second := filepath.Join(work, "second", cfg.Build.Binary)
|
|
if err := build(ctx, runner, cfg, source, sourceDir, version, first); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := build(ctx, runner, cfg, source, sourceDir, version, second); err != nil {
|
|
return Result{}, err
|
|
}
|
|
firstSHA, err := fileSHA(first)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
secondSHA, err := fileSHA(second)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if firstSHA != secondSHA {
|
|
return Result{}, errors.New("two clean builds were not byte-identical")
|
|
}
|
|
record, err := readBuildRecord(first)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if record.Main.Path == "" {
|
|
return Result{}, errors.New("built binary has no main module provenance")
|
|
}
|
|
if err := verifyBuildSettings(record, source.Commit); err != nil {
|
|
return Result{}, err
|
|
}
|
|
manifest := Manifest{SchemaVersion: 1, Service: cfg.Service.Name, Version: version, Commit: source.Commit, SourceEpoch: source.Epoch, GOOS: "linux", GOARCH: "amd64", CGOEnabled: false, Binary: cfg.Build.Binary, BinarySHA256: firstSHA, GoVersion: record.GoVersion, ModulePath: record.Main.Path, ModuleVersion: record.Main.Version}
|
|
bundle := filepath.Join(work, "bundle")
|
|
if err := os.Mkdir(bundle, 0o700); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := copyFile(first, filepath.Join(bundle, cfg.Build.Binary), 0o755); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := writeJSON(filepath.Join(bundle, "RELEASE.json"), manifest, 0o644); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := writeJSON(filepath.Join(bundle, "BUILDINFO.json"), record, 0o644); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := writeJSON(filepath.Join(bundle, "SBOM.spdx.json"), makeSPDX(cfg, version, source, record), 0o644); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := writeSums(bundle, []string{cfg.Build.Binary, "BUILDINFO.json", "RELEASE.json", "SBOM.spdx.json"}); err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
|
return Result{}, err
|
|
}
|
|
artifactName := fmt.Sprintf("%s-%s-linux-amd64.tar.gz", cfg.Service.Name, strings.TrimPrefix(version, "v"))
|
|
artifact := filepath.Join(outDir, artifactName)
|
|
if err := writeArchive(artifact, bundle, source.Epoch); err != nil {
|
|
return Result{}, err
|
|
}
|
|
artifactSHA, err := fileSHA(artifact)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if err := os.WriteFile(artifact+".sha256", []byte(artifactSHA+" "+artifactName+"\n"), 0o644); err != nil {
|
|
return Result{}, err
|
|
}
|
|
return Result{Artifact: artifact, SHA256: artifactSHA, Commit: source.Commit, Version: version}, nil
|
|
}
|
|
|
|
func verifyModules(ctx context.Context, runner process.Runner, dir string) error {
|
|
out, err := runner.Run(ctx, dir, map[string]string{"GOWORK": "off", "GOFLAGS": "-mod=readonly"}, "go", "list", "-m", "-json", "all")
|
|
if err != nil {
|
|
return fmt.Errorf("list modules: %w", err)
|
|
}
|
|
dec := json.NewDecoder(strings.NewReader(string(out)))
|
|
count := 0
|
|
for {
|
|
var module struct {
|
|
Path, Version string
|
|
Main bool
|
|
Replace *json.RawMessage
|
|
}
|
|
if err := dec.Decode(&module); errors.Is(err, io.EOF) {
|
|
break
|
|
} else if err != nil {
|
|
return fmt.Errorf("decode module graph: %w", err)
|
|
}
|
|
count++
|
|
if module.Replace != nil {
|
|
return fmt.Errorf("module %s uses a replacement", module.Path)
|
|
}
|
|
if !module.Main && module.Version == "" {
|
|
return fmt.Errorf("module %s is not pinned", module.Path)
|
|
}
|
|
}
|
|
if count == 0 {
|
|
return errors.New("module graph is empty")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func build(ctx context.Context, runner process.Runner, cfg config.Config, source provenance.Source, dir, version, output string) error {
|
|
if err := os.MkdirAll(filepath.Dir(output), 0o700); err != nil {
|
|
return err
|
|
}
|
|
ldflags := []string{"-s", "-w"}
|
|
date := time.Unix(source.Epoch, 0).UTC().Format(time.RFC3339)
|
|
pairs := [][2]string{{cfg.Build.VersionSymbol, version}, {cfg.Build.CommitSymbol, source.Commit}, {cfg.Build.DateSymbol, date}}
|
|
for _, pair := range pairs {
|
|
if pair[0] != "" {
|
|
ldflags = append(ldflags, "-X", pair[0]+"="+pair[1])
|
|
}
|
|
}
|
|
args := []string{"build", "-mod=readonly", "-trimpath", "-buildvcs=true", "-ldflags", strings.Join(ldflags, " "), "-o", output, cfg.Build.Package}
|
|
env := map[string]string{"GOWORK": "off", "GOFLAGS": "-mod=readonly", "GOOS": "linux", "GOARCH": "amd64", "CGO_ENABLED": "0", "SOURCE_DATE_EPOCH": fmt.Sprint(source.Epoch)}
|
|
if _, err := runner.Run(ctx, dir, env, "go", args...); err != nil {
|
|
return fmt.Errorf("build candidate: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readBuildRecord(path string) (buildRecord, error) {
|
|
info, err := buildinfo.ReadFile(path)
|
|
if err != nil {
|
|
return buildRecord{}, fmt.Errorf("read Go build info: %w", err)
|
|
}
|
|
record := buildRecord{GoVersion: info.GoVersion, Path: info.Path, Main: moduleRecord{Path: info.Main.Path, Version: info.Main.Version, Sum: info.Main.Sum}}
|
|
for _, dep := range info.Deps {
|
|
if dep.Replace != nil {
|
|
return buildRecord{}, fmt.Errorf("built binary contains replacement for %s", dep.Path)
|
|
}
|
|
record.Deps = append(record.Deps, moduleRecord{Path: dep.Path, Version: dep.Version, Sum: dep.Sum})
|
|
}
|
|
for _, setting := range info.Settings {
|
|
record.Settings = append(record.Settings, settingRecord{Key: setting.Key, Value: setting.Value})
|
|
}
|
|
sort.Slice(record.Deps, func(i, j int) bool { return record.Deps[i].Path < record.Deps[j].Path })
|
|
sort.Slice(record.Settings, func(i, j int) bool { return record.Settings[i].Key < record.Settings[j].Key })
|
|
return record, nil
|
|
}
|
|
|
|
func verifyBuildSettings(record buildRecord, commit string) error {
|
|
settings := map[string]string{}
|
|
for _, setting := range record.Settings {
|
|
settings[setting.Key] = setting.Value
|
|
}
|
|
for key, expected := range map[string]string{"vcs.revision": commit, "vcs.modified": "false", "GOOS": "linux", "GOARCH": "amd64", "CGO_ENABLED": "0"} {
|
|
if settings[key] != expected {
|
|
return fmt.Errorf("build setting %s is %q, expected %q", key, settings[key], expected)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func makeSPDX(cfg config.Config, version string, source provenance.Source, record buildRecord) spdxDocument {
|
|
created := time.Unix(source.Epoch, 0).UTC().Format("2006-01-02T15:04:05Z")
|
|
modules := append([]moduleRecord{record.Main}, record.Deps...)
|
|
doc := spdxDocument{SPDXVersion: "SPDX-2.3", DataLicense: "CC0-1.0", SPDXID: "SPDXRef-DOCUMENT", Name: cfg.Service.Name + "-" + version, DocumentNamespace: "https://gamertan.com/tend/sbom/" + source.Commit + "/" + cfg.Service.Name, CreationInfo: spdxCreation{Created: created, Creators: []string{"Tool: gamertan.com/tend"}}}
|
|
for i, module := range modules {
|
|
id := fmt.Sprintf("SPDXRef-Package-%d", i+1)
|
|
versionInfo := module.Version
|
|
if versionInfo == "" {
|
|
versionInfo = source.Commit
|
|
}
|
|
doc.Packages = append(doc.Packages, spdxPackage{Name: module.Path, SPDXID: id, VersionInfo: versionInfo, DownloadLocation: "NOASSERTION", FilesAnalyzed: false, LicenseConcluded: "NOASSERTION", LicenseDeclared: "NOASSERTION"})
|
|
doc.Relationships = append(doc.Relationships, spdxRelationship{SPDXElementID: "SPDXRef-DOCUMENT", RelationshipType: "DESCRIBES", RelatedSPDXElement: id})
|
|
}
|
|
return doc
|
|
}
|
|
func writeJSON(path string, value any, mode os.FileMode) error {
|
|
b, err := json.MarshalIndent(value, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b = append(b, '\n')
|
|
return os.WriteFile(path, b, mode)
|
|
}
|
|
func writeSums(dir string, names []string) error {
|
|
sort.Strings(names)
|
|
var b strings.Builder
|
|
for _, name := range names {
|
|
sum, err := fileSHA(filepath.Join(dir, name))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(&b, "%s %s\n", sum, name)
|
|
}
|
|
return os.WriteFile(filepath.Join(dir, "SHA256SUMS"), []byte(b.String()), 0o644)
|
|
}
|
|
func writeArchive(path, bundle string, epoch int64) error {
|
|
tmp := path + ".tmp"
|
|
_ = os.Remove(tmp)
|
|
file, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ok := false
|
|
defer func() {
|
|
_ = file.Close()
|
|
if !ok {
|
|
_ = os.Remove(tmp)
|
|
}
|
|
}()
|
|
gz := gzip.NewWriter(file)
|
|
gz.Header.ModTime = time.Unix(0, 0)
|
|
gz.Header.OS = 255
|
|
tw := tar.NewWriter(gz)
|
|
entries, err := os.ReadDir(bundle)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
names := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.Type().IsRegular() {
|
|
names = append(names, entry.Name())
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
root := filepath.Base(bundle)
|
|
if err := tw.WriteHeader(&tar.Header{Name: root + "/", Typeflag: tar.TypeDir, Mode: 0o755, ModTime: time.Unix(epoch, 0), Uid: 0, Gid: 0}); err != nil {
|
|
return err
|
|
}
|
|
for _, name := range names {
|
|
data, err := os.ReadFile(filepath.Join(bundle, name))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mode := int64(0o644)
|
|
if name != "BUILDINFO.json" && !strings.HasSuffix(name, ".json") && name != "SHA256SUMS" {
|
|
mode = 0o755
|
|
}
|
|
if err := tw.WriteHeader(&tar.Header{Name: root + "/" + name, Typeflag: tar.TypeReg, Mode: mode, Size: int64(len(data)), ModTime: time.Unix(epoch, 0), Uid: 0, Gid: 0}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tw.Write(data); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := gz.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := file.Sync(); err != nil {
|
|
return err
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
return err
|
|
}
|
|
ok = true
|
|
return nil
|
|
}
|
|
func copyFile(source, target string, mode os.FileMode) error {
|
|
in, err := os.Open(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
_ = out.Close()
|
|
return err
|
|
}
|
|
if err := out.Sync(); err != nil {
|
|
_ = out.Close()
|
|
return err
|
|
}
|
|
return out.Close()
|
|
}
|
|
func fileSHA(path string) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil)), nil
|
|
}
|