Prepare Sandwich Hime v1 release candidate source
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Package releaseartifact creates deterministic Hime-san release archives and
|
||||
// checksummed native-platform verification receipts.
|
||||
package releaseartifact
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const receiptSchema = "himesan.native-verification.v1"
|
||||
|
||||
var (
|
||||
releaseVersionPattern = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
|
||||
gitObjectPattern = regexp.MustCompile(`^[0-9a-f]{40,64}$`)
|
||||
digestPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
goVersionPattern = regexp.MustCompile(`^go(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$`)
|
||||
platformPattern = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||
)
|
||||
|
||||
// PackageOptions contains the reviewed inputs to one release archive.
|
||||
type PackageOptions struct {
|
||||
Version string
|
||||
Commit string
|
||||
Tree string
|
||||
GoVersion string
|
||||
GOOS string
|
||||
GOARCH string
|
||||
BinaryPath string
|
||||
LicensePath string
|
||||
ReleaseNotes string
|
||||
OutputDirectory string
|
||||
SourceDateEpoch int64
|
||||
}
|
||||
|
||||
// PackageResult identifies the immutable unsigned release archive.
|
||||
type PackageResult struct {
|
||||
ArchivePath string `json:"archive_path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type buildInfo struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
Tree string `json:"tree"`
|
||||
GoVersion string `json:"go_version"`
|
||||
GOOS string `json:"goos"`
|
||||
GOARCH string `json:"goarch"`
|
||||
BinarySHA256 string `json:"binary_sha256"`
|
||||
UnsignedBinarySHA256 string `json:"unsigned_binary_sha256,omitempty"`
|
||||
UnsignedArchiveSHA256 string `json:"unsigned_archive_sha256,omitempty"`
|
||||
SigningIdentifier string `json:"signing_identifier,omitempty"`
|
||||
}
|
||||
|
||||
type spdxDocument struct {
|
||||
SPDXVersion string `json:"spdxVersion"`
|
||||
DataLicense string `json:"dataLicense"`
|
||||
SPDXID string `json:"SPDXID"`
|
||||
Name string `json:"name"`
|
||||
DocumentNamespace string `json:"documentNamespace"`
|
||||
CreationInfo creationInfo `json:"creationInfo"`
|
||||
Packages []spdxPackage `json:"packages"`
|
||||
}
|
||||
|
||||
type creationInfo 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"`
|
||||
Checksums []spdxChecksum `json:"checksums"`
|
||||
}
|
||||
|
||||
type spdxChecksum struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
ChecksumValue string `json:"checksumValue"`
|
||||
}
|
||||
|
||||
// Receipt describes one completed native verification run.
|
||||
type Receipt struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
Tree string `json:"tree"`
|
||||
GOOS string `json:"goos"`
|
||||
GOARCH string `json:"goarch"`
|
||||
GoVersion string `json:"go_version"`
|
||||
RunnerVersion string `json:"runner_version"`
|
||||
RunnerName string `json:"runner_name"`
|
||||
GeneratedDigest string `json:"generated_output_sha256"`
|
||||
UnsignedArtifactSHA string `json:"unsigned_artifact_sha256,omitempty"`
|
||||
SuccessfulGates []string `json:"successful_gates"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
}
|
||||
|
||||
type archiveMember struct {
|
||||
name string
|
||||
mode int64
|
||||
data []byte
|
||||
}
|
||||
|
||||
// Package creates a byte-reproducible unsigned tar.gz and its SHA-256 sidecar.
|
||||
func Package(options PackageOptions) (PackageResult, error) {
|
||||
if err := validatePackageOptions(options); err != nil {
|
||||
return PackageResult{}, err
|
||||
}
|
||||
binary, err := os.ReadFile(options.BinaryPath)
|
||||
if err != nil {
|
||||
return PackageResult{}, fmt.Errorf("read binary: %w", err)
|
||||
}
|
||||
license, err := os.ReadFile(options.LicensePath)
|
||||
if err != nil {
|
||||
return PackageResult{}, fmt.Errorf("read license: %w", err)
|
||||
}
|
||||
releaseNotes, err := os.ReadFile(options.ReleaseNotes)
|
||||
if err != nil {
|
||||
return PackageResult{}, fmt.Errorf("read release notes: %w", err)
|
||||
}
|
||||
|
||||
binaryDigest := digest(binary)
|
||||
infoBytes, err := indentedJSON(buildInfo{
|
||||
SchemaVersion: 1,
|
||||
Version: options.Version,
|
||||
Commit: options.Commit,
|
||||
Tree: options.Tree,
|
||||
GoVersion: options.GoVersion,
|
||||
GOOS: options.GOOS,
|
||||
GOARCH: options.GOARCH,
|
||||
BinarySHA256: binaryDigest,
|
||||
})
|
||||
if err != nil {
|
||||
return PackageResult{}, err
|
||||
}
|
||||
|
||||
created := time.Unix(options.SourceDateEpoch, 0).UTC().Format(time.RFC3339)
|
||||
sbomBytes, err := indentedJSON(spdxDocument{
|
||||
SPDXVersion: "SPDX-2.3",
|
||||
DataLicense: "CC0-1.0",
|
||||
SPDXID: "SPDXRef-DOCUMENT",
|
||||
Name: "himesan-" + options.Version + "-" + options.GOOS + "-" + options.GOARCH,
|
||||
DocumentNamespace: "https://sandwichhime.com/spdx/himesan/" + options.Version + "/" + options.Commit + "/" + options.GOOS + "-" + options.GOARCH,
|
||||
CreationInfo: creationInfo{
|
||||
Created: created,
|
||||
Creators: []string{"Organization: Gamertan"},
|
||||
},
|
||||
Packages: []spdxPackage{{
|
||||
Name: "himesan",
|
||||
SPDXID: "SPDXRef-Package-himesan",
|
||||
VersionInfo: options.Version,
|
||||
DownloadLocation: "NOASSERTION",
|
||||
FilesAnalyzed: false,
|
||||
LicenseConcluded: "AGPL-3.0-only",
|
||||
LicenseDeclared: "AGPL-3.0-only",
|
||||
Checksums: []spdxChecksum{{
|
||||
Algorithm: "SHA256",
|
||||
ChecksumValue: binaryDigest,
|
||||
}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
return PackageResult{}, err
|
||||
}
|
||||
|
||||
members := []archiveMember{
|
||||
{name: "BUILDINFO.json", mode: 0o444, data: infoBytes},
|
||||
{name: "LICENSE", mode: 0o444, data: license},
|
||||
{name: "RELEASE.md", mode: 0o444, data: releaseNotes},
|
||||
{name: "SBOM.spdx.json", mode: 0o444, data: sbomBytes},
|
||||
{name: "himesan", mode: 0o555, data: binary},
|
||||
}
|
||||
checksumLines := make([]string, 0, len(members))
|
||||
for _, member := range members {
|
||||
checksumLines = append(checksumLines, digest(member.data)+" "+member.name)
|
||||
}
|
||||
members = append(members, archiveMember{
|
||||
name: "SHA256SUMS", mode: 0o444,
|
||||
data: []byte(strings.Join(checksumLines, "\n") + "\n"),
|
||||
})
|
||||
sort.Slice(members, func(i, j int) bool { return members[i].name < members[j].name })
|
||||
|
||||
if err := os.MkdirAll(options.OutputDirectory, 0o755); err != nil {
|
||||
return PackageResult{}, fmt.Errorf("create output directory: %w", err)
|
||||
}
|
||||
base := "himesan-" + strings.TrimPrefix(options.Version, "v") + "-" + options.GOOS + "-" + options.GOARCH
|
||||
archivePath := filepath.Join(options.OutputDirectory, base+".tar.gz")
|
||||
if err := writeArchive(archivePath, base, members, time.Unix(options.SourceDateEpoch, 0).UTC()); err != nil {
|
||||
return PackageResult{}, err
|
||||
}
|
||||
archive, err := os.ReadFile(archivePath)
|
||||
if err != nil {
|
||||
return PackageResult{}, fmt.Errorf("read completed archive: %w", err)
|
||||
}
|
||||
archiveDigest := digest(archive)
|
||||
if err := writeExclusive(archivePath+".sha256", []byte(archiveDigest+" "+filepath.Base(archivePath)+"\n"), 0o444); err != nil {
|
||||
return PackageResult{}, fmt.Errorf("write archive checksum: %w", err)
|
||||
}
|
||||
return PackageResult{ArchivePath: archivePath, SHA256: archiveDigest}, nil
|
||||
}
|
||||
|
||||
// WriteReceipt validates, canonicalizes, and writes a receipt plus SHA sidecar.
|
||||
func WriteReceipt(path string, receipt Receipt) (string, error) {
|
||||
receipt.SchemaVersion = receiptSchema
|
||||
if err := validateReceipt(receipt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sort.Strings(receipt.SuccessfulGates)
|
||||
contents, err := indentedJSON(receipt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeExclusive(path, contents, 0o444); err != nil {
|
||||
return "", fmt.Errorf("write receipt: %w", err)
|
||||
}
|
||||
checksum := digest(contents)
|
||||
if err := writeExclusive(path+".sha256", []byte(checksum+" "+filepath.Base(path)+"\n"), 0o444); err != nil {
|
||||
return "", fmt.Errorf("write receipt checksum: %w", err)
|
||||
}
|
||||
return checksum, nil
|
||||
}
|
||||
|
||||
// DigestFiles returns a stable digest over sorted names and file contents.
|
||||
func DigestFiles(paths []string) (string, error) {
|
||||
if len(paths) == 0 {
|
||||
return "", errors.New("at least one generated file is required")
|
||||
}
|
||||
sorted := append([]string(nil), paths...)
|
||||
sort.Strings(sorted)
|
||||
hash := sha256.New()
|
||||
for _, path := range sorted {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
_, _ = io.WriteString(hash, filepath.ToSlash(path))
|
||||
_, _ = hash.Write([]byte{0})
|
||||
_, _ = hash.Write(contents)
|
||||
_, _ = hash.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func validatePackageOptions(options PackageOptions) error {
|
||||
for name, value := range map[string]string{
|
||||
"version": options.Version, "commit": options.Commit, "tree": options.Tree,
|
||||
"go version": options.GoVersion, "GOOS": options.GOOS, "GOARCH": options.GOARCH,
|
||||
"binary": options.BinaryPath, "license": options.LicensePath,
|
||||
"release notes": options.ReleaseNotes, "output directory": options.OutputDirectory,
|
||||
} {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("%s is required", name)
|
||||
}
|
||||
}
|
||||
if options.SourceDateEpoch <= 0 {
|
||||
return errors.New("source date epoch must be positive")
|
||||
}
|
||||
if !releaseVersionPattern.MatchString(options.Version) {
|
||||
return errors.New("version must be a canonical v-prefixed semantic version without build metadata")
|
||||
}
|
||||
if !gitObjectPattern.MatchString(options.Commit) || !gitObjectPattern.MatchString(options.Tree) {
|
||||
return errors.New("commit and tree must be full lowercase Git object identities")
|
||||
}
|
||||
if !goVersionPattern.MatchString(options.GoVersion) {
|
||||
return errors.New("Go version must be a complete goX.Y.Z toolchain identity")
|
||||
}
|
||||
if !platformPattern.MatchString(options.GOOS) || !platformPattern.MatchString(options.GOARCH) {
|
||||
return errors.New("GOOS and GOARCH must contain only lowercase letters and digits")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateReceipt(receipt Receipt) error {
|
||||
for name, value := range map[string]string{
|
||||
"repository": receipt.Repository, "commit": receipt.Commit, "tree": receipt.Tree,
|
||||
"GOOS": receipt.GOOS, "GOARCH": receipt.GOARCH, "Go version": receipt.GoVersion,
|
||||
"runner version": receipt.RunnerVersion, "runner name": receipt.RunnerName,
|
||||
"generated digest": receipt.GeneratedDigest, "completed at": receipt.CompletedAt,
|
||||
} {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("%s is required", name)
|
||||
}
|
||||
}
|
||||
if len(receipt.SuccessfulGates) == 0 {
|
||||
return errors.New("at least one successful gate is required")
|
||||
}
|
||||
if !gitObjectPattern.MatchString(receipt.Commit) || !gitObjectPattern.MatchString(receipt.Tree) {
|
||||
return errors.New("commit and tree must be full lowercase Git object identities")
|
||||
}
|
||||
if !goVersionPattern.MatchString(receipt.GoVersion) {
|
||||
return errors.New("Go version must be a complete goX.Y.Z toolchain identity")
|
||||
}
|
||||
if !platformPattern.MatchString(receipt.GOOS) || !platformPattern.MatchString(receipt.GOARCH) {
|
||||
return errors.New("GOOS and GOARCH must contain only lowercase letters and digits")
|
||||
}
|
||||
if !digestPattern.MatchString(receipt.GeneratedDigest) {
|
||||
return errors.New("generated-output digest must be a lowercase SHA-256")
|
||||
}
|
||||
if receipt.UnsignedArtifactSHA != "" && !digestPattern.MatchString(receipt.UnsignedArtifactSHA) {
|
||||
return errors.New("unsigned-artifact digest must be a lowercase SHA-256")
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, receipt.CompletedAt); err != nil {
|
||||
return fmt.Errorf("completed at must be RFC3339: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeArchive(path, root string, members []archiveMember, modified time.Time) error {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create archive: %w", err)
|
||||
}
|
||||
success := false
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
if !success {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}()
|
||||
|
||||
gzipWriter := gzip.NewWriter(file)
|
||||
gzipWriter.Header.ModTime = modified
|
||||
gzipWriter.Header.OS = 255
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
for _, member := range members {
|
||||
header := &tar.Header{
|
||||
Name: root + "/" + member.name,
|
||||
Mode: member.mode,
|
||||
Size: int64(len(member.data)),
|
||||
ModTime: modified,
|
||||
AccessTime: time.Time{},
|
||||
ChangeTime: time.Time{},
|
||||
Uid: 0,
|
||||
Gid: 0,
|
||||
Uname: "",
|
||||
Gname: "",
|
||||
Format: tar.FormatUSTAR,
|
||||
}
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
return fmt.Errorf("write archive header: %w", err)
|
||||
}
|
||||
if _, err := tarWriter.Write(member.data); err != nil {
|
||||
return fmt.Errorf("write archive member: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
return fmt.Errorf("close tar stream: %w", err)
|
||||
}
|
||||
if err := gzipWriter.Close(); err != nil {
|
||||
return fmt.Errorf("close gzip stream: %w", err)
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
return fmt.Errorf("sync archive: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return fmt.Errorf("close archive: %w", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o444); err != nil {
|
||||
return fmt.Errorf("set archive permissions: %w", err)
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeExclusive(path string, contents []byte, mode os.FileMode) error {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
success := false
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
if !success {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}()
|
||||
if _, err := file.Write(contents); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func indentedJSON(value any) ([]byte, error) {
|
||||
contents, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode JSON: %w", err)
|
||||
}
|
||||
return append(contents, '\n'), nil
|
||||
}
|
||||
|
||||
func digest(contents []byte) string {
|
||||
sum := sha256.Sum256(contents)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package releaseartifact
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPackageIsByteReproducible(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
binary := writeFixture(t, directory, "himesan", "native binary")
|
||||
license := writeFixture(t, directory, "LICENSE", "license")
|
||||
releaseNotes := writeFixture(t, directory, "RELEASE.md", "release")
|
||||
options := PackageOptions{
|
||||
Version: "v1.0.0-rc.1", Commit: strings.Repeat("a", 40), Tree: strings.Repeat("b", 40), GoVersion: "go1.27.0",
|
||||
GOOS: "darwin", GOARCH: "arm64", BinaryPath: binary, LicensePath: license,
|
||||
ReleaseNotes: releaseNotes, SourceDateEpoch: 1_700_000_000,
|
||||
}
|
||||
options.OutputDirectory = filepath.Join(directory, "first")
|
||||
first, err := Package(options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options.OutputDirectory = filepath.Join(directory, "second")
|
||||
second, err := Package(options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.SHA256 != second.SHA256 {
|
||||
t.Fatalf("repeated packages differ: %s != %s", first.SHA256, second.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReceiptSortsGatesAndWritesChecksum(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "receipt.json")
|
||||
checksum, err := WriteReceipt(path, Receipt{
|
||||
Repository: "example.test/development-source", Commit: strings.Repeat("a", 40), Tree: strings.Repeat("b", 40),
|
||||
GOOS: "darwin", GOARCH: "arm64", GoVersion: "go1.27.0",
|
||||
RunnerVersion: "v3.3.0", RunnerName: "mac", GeneratedDigest: strings.Repeat("c", 64),
|
||||
SuccessfulGates: []string{"vet", "test"}, CompletedAt: time.Unix(1_700_000_000, 0).UTC().Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if checksum == "" {
|
||||
t.Fatal("empty checksum")
|
||||
}
|
||||
if _, err := os.Stat(path + ".sha256"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageRejectsUnsafeVersionAndExistingArtifact(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
options := PackageOptions{
|
||||
Version: "../../outside", Commit: strings.Repeat("a", 40), Tree: strings.Repeat("b", 40),
|
||||
GoVersion: "go1.27.0", GOOS: "darwin", GOARCH: "arm64",
|
||||
BinaryPath: writeFixture(t, directory, "himesan", "native binary"),
|
||||
LicensePath: writeFixture(t, directory, "LICENSE", "license"),
|
||||
ReleaseNotes: writeFixture(t, directory, "RELEASE.md", "release"),
|
||||
OutputDirectory: filepath.Join(directory, "output"), SourceDateEpoch: 1_700_000_000,
|
||||
}
|
||||
if _, err := Package(options); err == nil {
|
||||
t.Fatal("unsafe version was accepted")
|
||||
}
|
||||
options.Version = "v1.0.0-rc.1"
|
||||
if _, err := Package(options); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Package(options); err == nil {
|
||||
t.Fatal("existing immutable artifact was overwritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestFilesBindsNamesAndBytes(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
one := writeFixture(t, directory, "one", "same")
|
||||
two := writeFixture(t, directory, "two", "same")
|
||||
forward, err := DigestFiles([]string{two, one})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reverse, err := DigestFiles([]string{one, two})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if forward != reverse {
|
||||
t.Fatal("file ordering changed digest")
|
||||
}
|
||||
}
|
||||
|
||||
func writeFixture(t *testing.T, directory, name, contents string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(directory, name)
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package releaseartifact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
evidenceSchema = "himesan.release-evidence.v1"
|
||||
evidenceManifestName = "RELEASE-EVIDENCE.json"
|
||||
maximumEvidenceSize = 1 << 20
|
||||
)
|
||||
|
||||
var requiredEvidenceFiles = []string{
|
||||
"benchmark-methodology.md",
|
||||
"development-supervisor.md",
|
||||
"legal-review.md",
|
||||
"native-platforms.md",
|
||||
"security.md",
|
||||
"signing-and-recovery.md",
|
||||
"vanity-imports.md",
|
||||
}
|
||||
|
||||
// EvidenceIdentity binds human-reviewed release evidence to one source state.
|
||||
type EvidenceIdentity struct {
|
||||
Repository string
|
||||
Version string
|
||||
Commit string
|
||||
Tree string
|
||||
ReviewedBy string
|
||||
ReviewedAt string
|
||||
}
|
||||
|
||||
type evidenceManifest struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Repository string `json:"repository"`
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
Tree string `json:"tree"`
|
||||
ReviewStatus string `json:"review_status"`
|
||||
ReviewedBy string `json:"reviewed_by"`
|
||||
ReviewedAt string `json:"reviewed_at"`
|
||||
Files []evidenceFile `json:"files"`
|
||||
}
|
||||
|
||||
type evidenceFile struct {
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// RequiredEvidenceFiles returns the fixed v1 human-review document set.
|
||||
func RequiredEvidenceFiles() []string {
|
||||
return append([]string(nil), requiredEvidenceFiles...)
|
||||
}
|
||||
|
||||
// WriteEvidenceManifest seals the reviewed evidence documents without
|
||||
// modifying them. The manifest is immutable and fails if it already exists.
|
||||
func WriteEvidenceManifest(directory string, identity EvidenceIdentity) (string, error) {
|
||||
if err := validateEvidenceIdentity(identity, true); err != nil {
|
||||
return "", err
|
||||
}
|
||||
files, err := inspectEvidenceFiles(directory)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
manifest := evidenceManifest{
|
||||
SchemaVersion: evidenceSchema,
|
||||
Repository: identity.Repository,
|
||||
Version: identity.Version,
|
||||
Commit: identity.Commit,
|
||||
Tree: identity.Tree,
|
||||
ReviewStatus: "reviewed",
|
||||
ReviewedBy: identity.ReviewedBy,
|
||||
ReviewedAt: identity.ReviewedAt,
|
||||
Files: files,
|
||||
}
|
||||
contents, err := indentedJSON(manifest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(directory, evidenceManifestName)
|
||||
if err := writeExclusive(path, contents, 0o444); err != nil {
|
||||
return "", fmt.Errorf("write release evidence manifest: %w", err)
|
||||
}
|
||||
return digest(contents), nil
|
||||
}
|
||||
|
||||
// VerifyEvidenceManifest proves that the sealed evidence is for the expected
|
||||
// source state and that every reviewed byte remains unchanged.
|
||||
func VerifyEvidenceManifest(directory string, expected EvidenceIdentity) error {
|
||||
if err := validateEvidenceIdentity(expected, false); err != nil {
|
||||
return err
|
||||
}
|
||||
manifestPath := filepath.Join(directory, evidenceManifestName)
|
||||
contents, err := readEvidenceFile(manifestPath, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read release evidence manifest: %w", err)
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(contents))
|
||||
decoder.DisallowUnknownFields()
|
||||
var manifest evidenceManifest
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return fmt.Errorf("decode release evidence manifest: %w", err)
|
||||
}
|
||||
if err := requireJSONEOF(decoder); err != nil {
|
||||
return fmt.Errorf("decode release evidence manifest: %w", err)
|
||||
}
|
||||
if manifest.SchemaVersion != evidenceSchema {
|
||||
return fmt.Errorf("release evidence schema = %q, want %q", manifest.SchemaVersion, evidenceSchema)
|
||||
}
|
||||
if manifest.ReviewStatus != "reviewed" {
|
||||
return errors.New("release evidence review status must be reviewed")
|
||||
}
|
||||
actual := EvidenceIdentity{
|
||||
Repository: manifest.Repository,
|
||||
Version: manifest.Version,
|
||||
Commit: manifest.Commit,
|
||||
Tree: manifest.Tree,
|
||||
ReviewedBy: manifest.ReviewedBy,
|
||||
ReviewedAt: manifest.ReviewedAt,
|
||||
}
|
||||
if err := validateEvidenceIdentity(actual, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if actual.Repository != expected.Repository || actual.Version != expected.Version ||
|
||||
actual.Commit != expected.Commit || actual.Tree != expected.Tree {
|
||||
return errors.New("release evidence repository, version, commit, or tree does not match the candidate")
|
||||
}
|
||||
|
||||
observed, err := inspectEvidenceFiles(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(manifest.Files) != len(observed) {
|
||||
return fmt.Errorf("release evidence manifest contains %d files, want %d", len(manifest.Files), len(observed))
|
||||
}
|
||||
for index := range observed {
|
||||
if manifest.Files[index] != observed[index] {
|
||||
return fmt.Errorf("release evidence file %q is missing, reordered, or has a changed digest", observed[index].Path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inspectEvidenceFiles(directory string) ([]evidenceFile, error) {
|
||||
info, err := os.Lstat(directory)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect evidence directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, errors.New("evidence directory must be a real directory, not a symlink")
|
||||
}
|
||||
files := make([]evidenceFile, 0, len(requiredEvidenceFiles))
|
||||
for _, name := range requiredEvidenceFiles {
|
||||
contents, err := readEvidenceFile(filepath.Join(directory, name), false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect %s: %w", name, err)
|
||||
}
|
||||
if !utf8.Valid(contents) || bytes.IndexByte(contents, 0) >= 0 {
|
||||
return nil, fmt.Errorf("%s must be NUL-free UTF-8 text", name)
|
||||
}
|
||||
if !strings.Contains(string(contents), "# ") {
|
||||
return nil, fmt.Errorf("%s must contain a Markdown heading", name)
|
||||
}
|
||||
files = append(files, evidenceFile{Path: name, SHA256: digest(contents)})
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func readEvidenceFile(path string, manifest bool) ([]byte, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, errors.New("must be a regular file, not a symlink")
|
||||
}
|
||||
if info.Size() == 0 || info.Size() > maximumEvidenceSize {
|
||||
return nil, fmt.Errorf("size must be between 1 and %d bytes", maximumEvidenceSize)
|
||||
}
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if manifest && len(contents) > maximumEvidenceSize {
|
||||
return nil, errors.New("manifest exceeds the size limit")
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func validateEvidenceIdentity(identity EvidenceIdentity, requireReview bool) error {
|
||||
if strings.TrimSpace(identity.Repository) == "" || strings.ContainsAny(identity.Repository, "\r\n\x00") {
|
||||
return errors.New("evidence repository is required and must be one line")
|
||||
}
|
||||
if !releaseVersionPattern.MatchString(identity.Version) {
|
||||
return errors.New("evidence version must be a canonical v-prefixed semantic version")
|
||||
}
|
||||
if !gitObjectPattern.MatchString(identity.Commit) || !gitObjectPattern.MatchString(identity.Tree) {
|
||||
return errors.New("evidence commit and tree must be full lowercase Git object identities")
|
||||
}
|
||||
if !requireReview {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(identity.ReviewedBy) == "" || strings.ContainsAny(identity.ReviewedBy, "\r\n\x00") {
|
||||
return errors.New("evidence reviewer is required and must be one line")
|
||||
}
|
||||
reviewedAt, err := time.Parse(time.RFC3339, identity.ReviewedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evidence review time must be RFC3339: %w", err)
|
||||
}
|
||||
if reviewedAt.After(time.Now().UTC().Add(5 * time.Minute)) {
|
||||
return errors.New("evidence review time cannot be in the future")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireJSONEOF(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("multiple JSON values are not allowed")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package releaseartifact
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvidenceManifestBindsReviewedContentAndIdentity(t *testing.T) {
|
||||
directory := evidenceFixture(t)
|
||||
identity := evidenceIdentity()
|
||||
checksum, err := WriteEvidenceManifest(directory, identity)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !digestPattern.MatchString(checksum) {
|
||||
t.Fatalf("manifest checksum = %q", checksum)
|
||||
}
|
||||
if err := VerifyEvidenceManifest(directory, identity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
path := filepath.Join(directory, requiredEvidenceFiles[0])
|
||||
if err := os.WriteFile(path, []byte("# Changed after review\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := VerifyEvidenceManifest(directory, identity); err == nil {
|
||||
t.Fatal("changed evidence bytes were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvidenceManifestRejectsCandidateMismatchAndUnknownFields(t *testing.T) {
|
||||
t.Run("candidate identity", func(t *testing.T) {
|
||||
directory := evidenceFixture(t)
|
||||
identity := evidenceIdentity()
|
||||
if _, err := WriteEvidenceManifest(directory, identity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity.Commit = strings.Repeat("d", 40)
|
||||
if err := VerifyEvidenceManifest(directory, identity); err == nil {
|
||||
t.Fatal("mismatched candidate was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown manifest field", func(t *testing.T) {
|
||||
directory := evidenceFixture(t)
|
||||
identity := evidenceIdentity()
|
||||
if _, err := WriteEvidenceManifest(directory, identity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(directory, evidenceManifestName)
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents = []byte(strings.Replace(string(contents), `"files":`, `"unexpected": true, "files":`, 1))
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := VerifyEvidenceManifest(directory, identity); err == nil {
|
||||
t.Fatal("unknown manifest field was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvidenceManifestRejectsSymlinkAndPlaceholderDocuments(t *testing.T) {
|
||||
t.Run("symlink", func(t *testing.T) {
|
||||
directory := evidenceFixture(t)
|
||||
name := requiredEvidenceFiles[0]
|
||||
path := filepath.Join(directory, name)
|
||||
target := filepath.Join(directory, "target.md")
|
||||
if err := os.WriteFile(target, []byte("# Target\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := WriteEvidenceManifest(directory, evidenceIdentity()); err == nil {
|
||||
t.Fatal("symlinked evidence was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("placeholder", func(t *testing.T) {
|
||||
directory := evidenceFixture(t)
|
||||
if err := os.WriteFile(filepath.Join(directory, requiredEvidenceFiles[0]), []byte("not reviewed\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := WriteEvidenceManifest(directory, evidenceIdentity()); err == nil {
|
||||
t.Fatal("heading-free placeholder was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func evidenceFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
for _, name := range requiredEvidenceFiles {
|
||||
if err := os.WriteFile(filepath.Join(directory, name), []byte("# Reviewed "+name+"\n\nExact bounded evidence.\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
func evidenceIdentity() EvidenceIdentity {
|
||||
return EvidenceIdentity{
|
||||
Repository: "gamertan/sandwich-hime",
|
||||
Version: "v1.0.0-rc.1",
|
||||
Commit: strings.Repeat("a", 40),
|
||||
Tree: strings.Repeat("b", 40),
|
||||
ReviewedBy: "release operator",
|
||||
ReviewedAt: "2026-08-23T18:00:00Z",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package releaseartifact
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maximumNativeArchiveSize = 256 << 20
|
||||
|
||||
var unsignedPackageMembers = map[string]os.FileMode{
|
||||
"BUILDINFO.json": 0o444,
|
||||
"LICENSE": 0o444,
|
||||
"RELEASE.md": 0o444,
|
||||
"SBOM.spdx.json": 0o444,
|
||||
"SHA256SUMS": 0o444,
|
||||
"himesan": 0o555,
|
||||
}
|
||||
|
||||
// MacOSSigningOptions are the reviewed identities added after Developer ID
|
||||
// signing changes the native binary bytes.
|
||||
type MacOSSigningOptions struct {
|
||||
Directory string
|
||||
UnsignedArchiveSHA256 string
|
||||
Identity string
|
||||
Identifier string
|
||||
FinalizedAt string
|
||||
}
|
||||
|
||||
type signingInfo struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
UnsignedArchiveSHA256 string `json:"unsigned_archive_sha256"`
|
||||
UnsignedBinarySHA256 string `json:"unsigned_binary_sha256"`
|
||||
SignedBinarySHA256 string `json:"signed_binary_sha256"`
|
||||
Identity string `json:"identity"`
|
||||
Identifier string `json:"identifier"`
|
||||
FinalizedAt string `json:"finalized_at"`
|
||||
}
|
||||
|
||||
// ExtractVerifiedMacOSPackage verifies the approved unsigned archive and
|
||||
// extracts its fixed file set without delegating path handling to system tar.
|
||||
func ExtractVerifiedMacOSPackage(archivePath, expectedSHA256, outputDirectory string) (string, error) {
|
||||
if !digestPattern.MatchString(expectedSHA256) {
|
||||
return "", errors.New("approved archive digest must be a lowercase SHA-256")
|
||||
}
|
||||
info, err := os.Lstat(archivePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("inspect unsigned archive: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", errors.New("unsigned archive must be a regular file, not a symlink")
|
||||
}
|
||||
if info.Size() <= 0 || info.Size() > maximumNativeArchiveSize {
|
||||
return "", errors.New("unsigned archive size is outside the permitted range")
|
||||
}
|
||||
archive, err := os.ReadFile(archivePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read unsigned archive: %w", err)
|
||||
}
|
||||
if digest(archive) != expectedSHA256 {
|
||||
return "", errors.New("unsigned archive does not match the approved digest")
|
||||
}
|
||||
members, root, err := readUnsignedPackage(archive)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateUnsignedPackage(members, root); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
outputInfo, err := os.Lstat(outputDirectory)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("inspect extraction directory: %w", err)
|
||||
}
|
||||
if !outputInfo.IsDir() || outputInfo.Mode()&os.ModeSymlink != 0 {
|
||||
return "", errors.New("extraction destination must be a real directory")
|
||||
}
|
||||
rootPath := filepath.Join(outputDirectory, root)
|
||||
if err := os.Mkdir(rootPath, 0o700); err != nil {
|
||||
return "", fmt.Errorf("create extracted package root: %w", err)
|
||||
}
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
_ = os.RemoveAll(rootPath)
|
||||
}
|
||||
}()
|
||||
for name, mode := range unsignedPackageMembers {
|
||||
if err := writeExclusive(filepath.Join(rootPath, name), members[name], mode); err != nil {
|
||||
return "", fmt.Errorf("extract %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
success = true
|
||||
return rootPath, nil
|
||||
}
|
||||
|
||||
// FinalizeSignedMacOSDistribution replaces unsigned binary provenance with a
|
||||
// complete signed-distribution record and regenerates every internal checksum.
|
||||
func FinalizeSignedMacOSDistribution(options MacOSSigningOptions) error {
|
||||
if !digestPattern.MatchString(options.UnsignedArchiveSHA256) {
|
||||
return errors.New("unsigned archive digest must be a lowercase SHA-256")
|
||||
}
|
||||
if strings.TrimSpace(options.Identity) == "" || strings.ContainsAny(options.Identity, "\r\n\x00") {
|
||||
return errors.New("signing identity is required and must be one line")
|
||||
}
|
||||
if strings.TrimSpace(options.Identifier) == "" || strings.ContainsAny(options.Identifier, "\r\n\x00") {
|
||||
return errors.New("signing identifier is required and must be one line")
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, options.FinalizedAt); err != nil {
|
||||
return fmt.Errorf("finalization time must be RFC3339: %w", err)
|
||||
}
|
||||
|
||||
for name := range unsignedPackageMembers {
|
||||
if _, err := readRegularFile(filepath.Join(options.Directory, name), maximumNativeArchiveSize); err != nil {
|
||||
return fmt.Errorf("inspect signed distribution %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
buildPath := filepath.Join(options.Directory, "BUILDINFO.json")
|
||||
buildContents, err := os.ReadFile(buildPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read unsigned build information: %w", err)
|
||||
}
|
||||
var build buildInfo
|
||||
if err := decodeStrictJSON(buildContents, &build); err != nil {
|
||||
return fmt.Errorf("decode unsigned build information: %w", err)
|
||||
}
|
||||
if build.SchemaVersion != 1 || !digestPattern.MatchString(build.BinarySHA256) || build.GOOS != "darwin" || build.GOARCH != "arm64" {
|
||||
return errors.New("unsigned build information is not a supported Darwin/arm64 package")
|
||||
}
|
||||
unsignedBinaryDigest := build.BinarySHA256
|
||||
signedBinary, err := os.ReadFile(filepath.Join(options.Directory, "himesan"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read signed binary: %w", err)
|
||||
}
|
||||
signedBinaryDigest := digest(signedBinary)
|
||||
if signedBinaryDigest == unsignedBinaryDigest {
|
||||
return errors.New("Developer ID signing did not change the native binary bytes")
|
||||
}
|
||||
|
||||
build.SchemaVersion = 2
|
||||
build.BinarySHA256 = signedBinaryDigest
|
||||
build.UnsignedBinarySHA256 = unsignedBinaryDigest
|
||||
build.UnsignedArchiveSHA256 = options.UnsignedArchiveSHA256
|
||||
build.SigningIdentifier = options.Identifier
|
||||
newBuild, err := indentedJSON(build)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sbomPath := filepath.Join(options.Directory, "SBOM.spdx.json")
|
||||
sbomContents, err := os.ReadFile(sbomPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read unsigned SBOM: %w", err)
|
||||
}
|
||||
var sbom spdxDocument
|
||||
if err := decodeStrictJSON(sbomContents, &sbom); err != nil {
|
||||
return fmt.Errorf("decode unsigned SBOM: %w", err)
|
||||
}
|
||||
if len(sbom.Packages) != 1 || len(sbom.Packages[0].Checksums) != 1 ||
|
||||
sbom.Packages[0].Checksums[0].Algorithm != "SHA256" ||
|
||||
sbom.Packages[0].Checksums[0].ChecksumValue != unsignedBinaryDigest {
|
||||
return errors.New("unsigned SBOM does not match the unsigned binary")
|
||||
}
|
||||
sbom.Packages[0].Checksums[0].ChecksumValue = signedBinaryDigest
|
||||
newSBOM, err := indentedJSON(sbom)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signingContents, err := indentedJSON(signingInfo{
|
||||
SchemaVersion: 1,
|
||||
UnsignedArchiveSHA256: options.UnsignedArchiveSHA256,
|
||||
UnsignedBinarySHA256: unsignedBinaryDigest,
|
||||
SignedBinarySHA256: signedBinaryDigest,
|
||||
Identity: options.Identity,
|
||||
Identifier: options.Identifier,
|
||||
FinalizedAt: options.FinalizedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := replaceRegularFile(buildPath, newBuild, 0o444); err != nil {
|
||||
return fmt.Errorf("replace signed build information: %w", err)
|
||||
}
|
||||
if err := replaceRegularFile(sbomPath, newSBOM, 0o444); err != nil {
|
||||
return fmt.Errorf("replace signed SBOM: %w", err)
|
||||
}
|
||||
if err := writeExclusive(filepath.Join(options.Directory, "SIGNING.json"), signingContents, 0o444); err != nil {
|
||||
return fmt.Errorf("write signing information: %w", err)
|
||||
}
|
||||
|
||||
checksumNames := []string{"BUILDINFO.json", "LICENSE", "RELEASE.md", "SBOM.spdx.json", "SIGNING.json", "himesan"}
|
||||
var checksumLines []string
|
||||
for _, name := range checksumNames {
|
||||
contents, err := os.ReadFile(filepath.Join(options.Directory, name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read signed distribution member %s: %w", name, err)
|
||||
}
|
||||
checksumLines = append(checksumLines, digest(contents)+" "+name)
|
||||
}
|
||||
checksums := []byte(strings.Join(checksumLines, "\n") + "\n")
|
||||
if err := replaceRegularFile(filepath.Join(options.Directory, "SHA256SUMS"), checksums, 0o444); err != nil {
|
||||
return fmt.Errorf("replace signed distribution checksums: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readUnsignedPackage(archive []byte) (map[string][]byte, string, error) {
|
||||
gzipReader, err := gzip.NewReader(bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("open unsigned gzip archive: %w", err)
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
tarReader := tar.NewReader(io.LimitReader(gzipReader, maximumNativeArchiveSize+1))
|
||||
members := make(map[string][]byte, len(unsignedPackageMembers))
|
||||
root := ""
|
||||
var totalSize int64
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read unsigned tar archive: %w", err)
|
||||
}
|
||||
if header.Typeflag != tar.TypeReg || header.Size < 0 || header.Size > maximumNativeArchiveSize {
|
||||
return nil, "", errors.New("unsigned archive contains a non-regular or oversized member")
|
||||
}
|
||||
totalSize += header.Size
|
||||
if totalSize > maximumNativeArchiveSize {
|
||||
return nil, "", errors.New("unsigned archive expands beyond the permitted size")
|
||||
}
|
||||
clean := filepath.ToSlash(filepath.Clean(header.Name))
|
||||
parts := strings.Split(clean, "/")
|
||||
if len(parts) != 2 || parts[0] == "" || parts[0] == "." || parts[0] == ".." {
|
||||
return nil, "", fmt.Errorf("unsigned archive member has an unsafe path: %q", header.Name)
|
||||
}
|
||||
if root == "" {
|
||||
root = parts[0]
|
||||
} else if root != parts[0] {
|
||||
return nil, "", errors.New("unsigned archive contains more than one package root")
|
||||
}
|
||||
name := parts[1]
|
||||
expectedMode, ok := unsignedPackageMembers[name]
|
||||
if !ok || os.FileMode(header.Mode).Perm() != expectedMode || header.Linkname != "" {
|
||||
return nil, "", fmt.Errorf("unsigned archive member %q has an unexpected name, mode, or link", name)
|
||||
}
|
||||
if _, exists := members[name]; exists {
|
||||
return nil, "", fmt.Errorf("unsigned archive repeats member %q", name)
|
||||
}
|
||||
contents, err := io.ReadAll(io.LimitReader(tarReader, header.Size+1))
|
||||
if err != nil || int64(len(contents)) != header.Size {
|
||||
return nil, "", fmt.Errorf("read unsigned archive member %q", name)
|
||||
}
|
||||
members[name] = contents
|
||||
}
|
||||
if err := gzipReader.Close(); err != nil {
|
||||
return nil, "", fmt.Errorf("finish unsigned gzip archive: %w", err)
|
||||
}
|
||||
return members, root, nil
|
||||
}
|
||||
|
||||
func validateUnsignedPackage(members map[string][]byte, root string) error {
|
||||
if len(members) != len(unsignedPackageMembers) || root == "" {
|
||||
return errors.New("unsigned archive does not contain the exact release file set")
|
||||
}
|
||||
var build buildInfo
|
||||
if err := decodeStrictJSON(members["BUILDINFO.json"], &build); err != nil {
|
||||
return fmt.Errorf("decode unsigned build information: %w", err)
|
||||
}
|
||||
if build.SchemaVersion != 1 || build.GOOS != "darwin" || build.GOARCH != "arm64" ||
|
||||
!releaseVersionPattern.MatchString(build.Version) || root != "himesan-"+strings.TrimPrefix(build.Version, "v")+"-darwin-arm64" ||
|
||||
!gitObjectPattern.MatchString(build.Commit) || !gitObjectPattern.MatchString(build.Tree) ||
|
||||
!goVersionPattern.MatchString(build.GoVersion) || build.BinarySHA256 != digest(members["himesan"]) {
|
||||
return errors.New("unsigned build information does not match the archive")
|
||||
}
|
||||
var sbom spdxDocument
|
||||
if err := decodeStrictJSON(members["SBOM.spdx.json"], &sbom); err != nil {
|
||||
return fmt.Errorf("decode unsigned SBOM: %w", err)
|
||||
}
|
||||
if len(sbom.Packages) != 1 || len(sbom.Packages[0].Checksums) != 1 ||
|
||||
sbom.Packages[0].Checksums[0].Algorithm != "SHA256" ||
|
||||
sbom.Packages[0].Checksums[0].ChecksumValue != build.BinarySHA256 {
|
||||
return errors.New("unsigned SBOM does not match the native binary")
|
||||
}
|
||||
expectedNames := []string{"BUILDINFO.json", "LICENSE", "RELEASE.md", "SBOM.spdx.json", "himesan"}
|
||||
var expectedLines []string
|
||||
for _, name := range expectedNames {
|
||||
expectedLines = append(expectedLines, digest(members[name])+" "+name)
|
||||
}
|
||||
if string(members["SHA256SUMS"]) != strings.Join(expectedLines, "\n")+"\n" {
|
||||
return errors.New("unsigned package checksum manifest does not match its members")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readRegularFile(path string, maximum int64) ([]byte, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() < 0 || info.Size() > maximum {
|
||||
return nil, errors.New("must be a bounded regular file, not a symlink")
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func decodeStrictJSON(contents []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(contents))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
return requireJSONEOF(decoder)
|
||||
}
|
||||
|
||||
func replaceRegularFile(path string, contents []byte, mode os.FileMode) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return errors.New("replacement target must be a regular file, not a symlink")
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Dir(path), ".himesan-release-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
success := false
|
||||
defer func() {
|
||||
_ = temporary.Close()
|
||||
if !success {
|
||||
_ = os.Remove(temporaryPath)
|
||||
}
|
||||
}()
|
||||
if _, err := temporary.Write(contents); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(temporaryPath, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(temporaryPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package releaseartifact
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractAndFinalizeSignedMacOSDistribution(t *testing.T) {
|
||||
archive, archiveDigest := macOSPackageFixture(t)
|
||||
extraction := t.TempDir()
|
||||
root, err := ExtractVerifiedMacOSPackage(archive, archiveDigest, extraction)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binaryPath := filepath.Join(root, "himesan")
|
||||
unsignedBinary, err := os.ReadFile(binaryPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(binaryPath, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
binary, err := os.OpenFile(binaryPath, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := binary.WriteString("developer-id-signature"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := binary.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
options := MacOSSigningOptions{
|
||||
Directory: root, UnsignedArchiveSHA256: archiveDigest,
|
||||
Identity: "Developer ID Application: Example (TEAMID)",
|
||||
Identifier: "com.gamertan.sandwich-hime.himesan",
|
||||
FinalizedAt: "2026-08-23T18:00:00Z",
|
||||
}
|
||||
if err := FinalizeSignedMacOSDistribution(options); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var build buildInfo
|
||||
buildContents, err := os.ReadFile(filepath.Join(root, "BUILDINFO.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := decodeStrictJSON(buildContents, &build); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signedBinary, err := os.ReadFile(binaryPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if build.SchemaVersion != 2 || build.BinarySHA256 != digest(signedBinary) ||
|
||||
build.UnsignedBinarySHA256 != digest(unsignedBinary) || build.UnsignedArchiveSHA256 != archiveDigest ||
|
||||
build.SigningIdentifier != options.Identifier {
|
||||
t.Fatalf("signed build information is incomplete: %#v", build)
|
||||
}
|
||||
|
||||
var signing signingInfo
|
||||
signingContents, err := os.ReadFile(filepath.Join(root, "SIGNING.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := decodeStrictJSON(signingContents, &signing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if signing.SignedBinarySHA256 != build.BinarySHA256 || signing.UnsignedBinarySHA256 != build.UnsignedBinarySHA256 {
|
||||
t.Fatalf("signing record does not match build information: %#v", signing)
|
||||
}
|
||||
|
||||
var sbom spdxDocument
|
||||
sbomContents, err := os.ReadFile(filepath.Join(root, "SBOM.spdx.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := decodeStrictJSON(sbomContents, &sbom); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := sbom.Packages[0].Checksums[0].ChecksumValue; got != build.BinarySHA256 {
|
||||
t.Fatalf("signed SBOM checksum = %s, want %s", got, build.BinarySHA256)
|
||||
}
|
||||
assertChecksumManifest(t, root, []string{"BUILDINFO.json", "LICENSE", "RELEASE.md", "SBOM.spdx.json", "SIGNING.json", "himesan"})
|
||||
if err := FinalizeSignedMacOSDistribution(options); err == nil {
|
||||
t.Fatal("signed distribution was finalized twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractVerifiedMacOSPackageRejectsSubstitutionAndSymlink(t *testing.T) {
|
||||
archive, archiveDigest := macOSPackageFixture(t)
|
||||
if _, err := ExtractVerifiedMacOSPackage(archive, strings.Repeat("0", 64), t.TempDir()); err == nil {
|
||||
t.Fatal("archive substitution was accepted")
|
||||
}
|
||||
symlink := filepath.Join(t.TempDir(), "candidate.tar.gz")
|
||||
if err := os.Symlink(archive, symlink); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ExtractVerifiedMacOSPackage(symlink, archiveDigest, t.TempDir()); err == nil {
|
||||
t.Fatal("symlinked archive was accepted")
|
||||
}
|
||||
|
||||
unsafeArchive := filepath.Join(t.TempDir(), "unsafe.tar.gz")
|
||||
destination := t.TempDir()
|
||||
escapeName := filepath.Base(destination) + "-outside"
|
||||
unsafeContents := unsafeTarGzip(t, "../"+escapeName, []byte("not a package"))
|
||||
if err := os.WriteFile(unsafeArchive, unsafeContents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ExtractVerifiedMacOSPackage(unsafeArchive, digest(unsafeContents), destination); err == nil {
|
||||
t.Fatal("archive path traversal was accepted")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(destination), escapeName)); !os.IsNotExist(err) {
|
||||
t.Fatal("unsafe archive wrote outside the extraction directory")
|
||||
}
|
||||
}
|
||||
|
||||
func macOSPackageFixture(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
options := PackageOptions{
|
||||
Version: "v1.0.0-rc.1", Commit: strings.Repeat("a", 40), Tree: strings.Repeat("b", 40),
|
||||
GoVersion: "go1.27.0", GOOS: "darwin", GOARCH: "arm64",
|
||||
BinaryPath: writeFixture(t, directory, "himesan", "unsigned Mach-O fixture"),
|
||||
LicensePath: writeFixture(t, directory, "LICENSE", "licence"),
|
||||
ReleaseNotes: writeFixture(t, directory, "RELEASE.md", "release"),
|
||||
OutputDirectory: filepath.Join(directory, "package"), SourceDateEpoch: 1_700_000_000,
|
||||
}
|
||||
result, err := Package(options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result.ArchivePath, result.SHA256
|
||||
}
|
||||
|
||||
func assertChecksumManifest(t *testing.T, directory string, names []string) {
|
||||
t.Helper()
|
||||
var lines []string
|
||||
for _, name := range names {
|
||||
contents, err := os.ReadFile(filepath.Join(directory, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines = append(lines, digest(contents)+" "+name)
|
||||
}
|
||||
checksums, err := os.ReadFile(filepath.Join(directory, "SHA256SUMS"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := string(checksums), strings.Join(lines, "\n")+"\n"; got != want {
|
||||
t.Fatalf("checksum manifest mismatch\ngot:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func unsafeTarGzip(t *testing.T, name string, contents []byte) []byte {
|
||||
t.Helper()
|
||||
var output bytes.Buffer
|
||||
gzipWriter := gzip.NewWriter(&output)
|
||||
tarWriter := tar.NewWriter(gzipWriter)
|
||||
if err := tarWriter.WriteHeader(&tar.Header{Name: name, Mode: 0o444, Size: int64(len(contents)), Typeflag: tar.TypeReg}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tarWriter.Write(contents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gzipWriter.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return output.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package releaseartifact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const nativeReceiptMaximumAge = 30 * 24 * time.Hour
|
||||
|
||||
var runnerVersionPattern = regexp.MustCompile(`^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$`)
|
||||
|
||||
type nativeLane struct {
|
||||
directory string
|
||||
goos string
|
||||
goarch string
|
||||
goVersion string
|
||||
runner string
|
||||
}
|
||||
|
||||
var requiredNativeLanes = []nativeLane{
|
||||
{directory: "darwin-arm64-go1.26.7", goos: "darwin", goarch: "arm64", goVersion: "go1.26.7", runner: "macbook-air-himesan-darwin-arm64"},
|
||||
{directory: "darwin-arm64-go1.27.0", goos: "darwin", goarch: "arm64", goVersion: "go1.27.0", runner: "macbook-air-himesan-darwin-arm64"},
|
||||
{directory: "linux-amd64-go1.26.7", goos: "linux", goarch: "amd64", goVersion: "go1.26.7", runner: "cliff-himesan-linux-amd64"},
|
||||
{directory: "linux-amd64-go1.27.0", goos: "linux", goarch: "amd64", goVersion: "go1.27.0", runner: "cliff-himesan-linux-amd64"},
|
||||
}
|
||||
|
||||
var requiredNativeGates = []string{
|
||||
"build", "consumer", "contracts", "fuzz", "generation", "package",
|
||||
"public-snapshot", "race", "test", "vet", "vulnerability",
|
||||
}
|
||||
|
||||
// NativeReceiptExpectation identifies the exact source verified by all native
|
||||
// runner lanes.
|
||||
type NativeReceiptExpectation struct {
|
||||
Repository string
|
||||
Commit string
|
||||
Tree string
|
||||
}
|
||||
|
||||
// NativeReceiptSummary is safe to include in human release evidence.
|
||||
type NativeReceiptSummary struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
Tree string `json:"tree"`
|
||||
GeneratedDigest string `json:"generated_output_sha256"`
|
||||
Lanes []NativeLaneSummary `json:"lanes"`
|
||||
}
|
||||
|
||||
// NativeLaneSummary records one verified native receipt without local paths.
|
||||
type NativeLaneSummary struct {
|
||||
Platform string `json:"platform"`
|
||||
GoVersion string `json:"go_version"`
|
||||
RunnerVersion string `json:"runner_version"`
|
||||
ArtifactSHA256 string `json:"unsigned_artifact_sha256"`
|
||||
ReceiptSHA256 string `json:"receipt_sha256"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
}
|
||||
|
||||
// VerifyNativeReceiptSet requires all four maintained target/toolchain lanes,
|
||||
// validates their checksum sidecars, and proves identical generated output.
|
||||
func VerifyNativeReceiptSet(directory string, expected NativeReceiptExpectation) (NativeReceiptSummary, error) {
|
||||
if strings.TrimSpace(expected.Repository) == "" || strings.ContainsAny(expected.Repository, "\r\n\x00") {
|
||||
return NativeReceiptSummary{}, errors.New("native receipt repository is required and must be one line")
|
||||
}
|
||||
if !gitObjectPattern.MatchString(expected.Commit) || !gitObjectPattern.MatchString(expected.Tree) {
|
||||
return NativeReceiptSummary{}, errors.New("native receipt commit and tree must be full lowercase Git object identities")
|
||||
}
|
||||
if err := validateNativeDirectory(directory); err != nil {
|
||||
return NativeReceiptSummary{}, err
|
||||
}
|
||||
|
||||
summary := NativeReceiptSummary{
|
||||
SchemaVersion: "himesan.native-receipt-set.v1",
|
||||
Repository: expected.Repository, Commit: expected.Commit, Tree: expected.Tree,
|
||||
}
|
||||
for _, lane := range requiredNativeLanes {
|
||||
receipt, receiptDigest, err := readNativeLane(filepath.Join(directory, lane.directory))
|
||||
if err != nil {
|
||||
return NativeReceiptSummary{}, fmt.Errorf("verify native lane %s: %w", lane.directory, err)
|
||||
}
|
||||
if receipt.SchemaVersion != receiptSchema || receipt.Repository != expected.Repository ||
|
||||
receipt.Commit != expected.Commit || receipt.Tree != expected.Tree ||
|
||||
receipt.GOOS != lane.goos || receipt.GOARCH != lane.goarch ||
|
||||
receipt.GoVersion != lane.goVersion || receipt.RunnerName != lane.runner {
|
||||
return NativeReceiptSummary{}, fmt.Errorf("native lane %s does not match its source, platform, toolchain, or runner", lane.directory)
|
||||
}
|
||||
if !runnerVersionPattern.MatchString(receipt.RunnerVersion) || !digestPattern.MatchString(receipt.UnsignedArtifactSHA) {
|
||||
return NativeReceiptSummary{}, fmt.Errorf("native lane %s has an invalid runner or artifact identity", lane.directory)
|
||||
}
|
||||
completedAt, err := time.Parse(time.RFC3339, receipt.CompletedAt)
|
||||
if err != nil || completedAt.After(time.Now().UTC().Add(5*time.Minute)) || time.Since(completedAt) > nativeReceiptMaximumAge {
|
||||
return NativeReceiptSummary{}, fmt.Errorf("native lane %s is not fresh, completed, RFC3339 evidence", lane.directory)
|
||||
}
|
||||
if err := requireNativeGates(receipt.SuccessfulGates); err != nil {
|
||||
return NativeReceiptSummary{}, fmt.Errorf("native lane %s: %w", lane.directory, err)
|
||||
}
|
||||
if summary.GeneratedDigest == "" {
|
||||
summary.GeneratedDigest = receipt.GeneratedDigest
|
||||
} else if summary.GeneratedDigest != receipt.GeneratedDigest {
|
||||
return NativeReceiptSummary{}, errors.New("native lanes did not produce identical generated output")
|
||||
}
|
||||
summary.Lanes = append(summary.Lanes, NativeLaneSummary{
|
||||
Platform: lane.goos + "/" + lane.goarch, GoVersion: lane.goVersion,
|
||||
RunnerVersion: receipt.RunnerVersion, ArtifactSHA256: receipt.UnsignedArtifactSHA,
|
||||
ReceiptSHA256: receiptDigest, CompletedAt: receipt.CompletedAt,
|
||||
})
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func validateNativeDirectory(directory string) error {
|
||||
info, err := os.Lstat(directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect native evidence directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return errors.New("native evidence must be a real directory")
|
||||
}
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read native evidence directory: %w", err)
|
||||
}
|
||||
expected := make([]string, 0, len(requiredNativeLanes))
|
||||
for _, lane := range requiredNativeLanes {
|
||||
expected = append(expected, lane.directory)
|
||||
}
|
||||
observed := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
observed = append(observed, entry.Name())
|
||||
}
|
||||
sort.Strings(expected)
|
||||
sort.Strings(observed)
|
||||
if !equalStrings(expected, observed) {
|
||||
return fmt.Errorf("native evidence directories = %v, want exactly %v", observed, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readNativeLane(directory string) (Receipt, string, error) {
|
||||
info, err := os.Lstat(directory)
|
||||
if err != nil {
|
||||
return Receipt{}, "", err
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return Receipt{}, "", errors.New("lane must be a real directory")
|
||||
}
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
return Receipt{}, "", err
|
||||
}
|
||||
if len(entries) != 2 || entries[0].Name() != "TEND-CI-VERIFICATION.json" || entries[1].Name() != "TEND-CI-VERIFICATION.json.sha256" {
|
||||
return Receipt{}, "", errors.New("lane must contain only its receipt and checksum sidecar")
|
||||
}
|
||||
receiptPath := filepath.Join(directory, "TEND-CI-VERIFICATION.json")
|
||||
contents, err := readRegularFile(receiptPath, maximumEvidenceSize)
|
||||
if err != nil || len(contents) == 0 {
|
||||
return Receipt{}, "", errors.New("receipt must be a non-empty bounded regular file")
|
||||
}
|
||||
receiptDigest := digest(contents)
|
||||
sidecar, err := readRegularFile(receiptPath+".sha256", 512)
|
||||
if err != nil {
|
||||
return Receipt{}, "", fmt.Errorf("read receipt checksum: %w", err)
|
||||
}
|
||||
expectedSidecar := receiptDigest + " TEND-CI-VERIFICATION.json\n"
|
||||
if string(sidecar) != expectedSidecar {
|
||||
return Receipt{}, "", errors.New("receipt checksum sidecar does not match")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(contents))
|
||||
decoder.DisallowUnknownFields()
|
||||
var receipt Receipt
|
||||
if err := decoder.Decode(&receipt); err != nil {
|
||||
return Receipt{}, "", fmt.Errorf("decode receipt: %w", err)
|
||||
}
|
||||
if err := requireJSONEOF(decoder); err != nil {
|
||||
return Receipt{}, "", fmt.Errorf("decode receipt: %w", err)
|
||||
}
|
||||
if err := validateReceipt(receipt); err != nil {
|
||||
return Receipt{}, "", err
|
||||
}
|
||||
return receipt, receiptDigest, nil
|
||||
}
|
||||
|
||||
func requireNativeGates(gates []string) error {
|
||||
observed := make(map[string]bool, len(gates))
|
||||
for _, gate := range gates {
|
||||
if observed[gate] {
|
||||
return fmt.Errorf("successful gate %q is duplicated", gate)
|
||||
}
|
||||
observed[gate] = true
|
||||
}
|
||||
for _, gate := range requiredNativeGates {
|
||||
if !observed[gate] {
|
||||
return fmt.Errorf("required successful gate %q is missing", gate)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func equalStrings(left, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package releaseartifact
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVerifyNativeReceiptSetRequiresEveryLaneAndMatchingGeneratedOutput(t *testing.T) {
|
||||
expected := NativeReceiptExpectation{
|
||||
Repository: "gamertan/sandwich-hime",
|
||||
Commit: strings.Repeat("a", 40), Tree: strings.Repeat("b", 40),
|
||||
}
|
||||
directory := nativeReceiptFixture(t, expected, strings.Repeat("c", 64))
|
||||
summary, err := VerifyNativeReceiptSet(directory, expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.GeneratedDigest != strings.Repeat("c", 64) || len(summary.Lanes) != 4 {
|
||||
t.Fatalf("native summary is incomplete: %#v", summary)
|
||||
}
|
||||
|
||||
t.Run("source mismatch", func(t *testing.T) {
|
||||
mismatch := expected
|
||||
mismatch.Commit = strings.Repeat("d", 40)
|
||||
if _, err := VerifyNativeReceiptSet(directory, mismatch); err == nil {
|
||||
t.Fatal("mismatched source commit was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generated mismatch", func(t *testing.T) {
|
||||
mismatched := nativeReceiptFixture(t, expected, strings.Repeat("c", 64))
|
||||
lane := requiredNativeLanes[0]
|
||||
laneDirectory := filepath.Join(mismatched, lane.directory)
|
||||
if err := os.RemoveAll(laneDirectory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeNativeLaneFixture(t, laneDirectory, lane, expected, strings.Repeat("e", 64))
|
||||
if _, err := VerifyNativeReceiptSet(mismatched, expected); err == nil {
|
||||
t.Fatal("different generated output was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyNativeReceiptSetRejectsTamperingAndUnexpectedFiles(t *testing.T) {
|
||||
expected := NativeReceiptExpectation{
|
||||
Repository: "gamertan/sandwich-hime",
|
||||
Commit: strings.Repeat("a", 40), Tree: strings.Repeat("b", 40),
|
||||
}
|
||||
|
||||
t.Run("checksum", func(t *testing.T) {
|
||||
directory := nativeReceiptFixture(t, expected, strings.Repeat("c", 64))
|
||||
sidecar := filepath.Join(directory, requiredNativeLanes[0].directory, "TEND-CI-VERIFICATION.json.sha256")
|
||||
if err := os.Chmod(sidecar, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sidecar, []byte(strings.Repeat("0", 64)+" TEND-CI-VERIFICATION.json\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := VerifyNativeReceiptSet(directory, expected); err == nil {
|
||||
t.Fatal("tampered checksum was accepted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unexpected entry", func(t *testing.T) {
|
||||
directory := nativeReceiptFixture(t, expected, strings.Repeat("c", 64))
|
||||
if err := os.WriteFile(filepath.Join(directory, "notes.txt"), []byte("not a receipt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := VerifyNativeReceiptSet(directory, expected); err == nil {
|
||||
t.Fatal("unexpected native evidence entry was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func nativeReceiptFixture(t *testing.T, expected NativeReceiptExpectation, generatedDigest string) string {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
for _, lane := range requiredNativeLanes {
|
||||
writeNativeLaneFixture(t, filepath.Join(directory, lane.directory), lane, expected, generatedDigest)
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
func writeNativeLaneFixture(t *testing.T, directory string, lane nativeLane, expected NativeReceiptExpectation, generatedDigest string) {
|
||||
t.Helper()
|
||||
if err := os.Mkdir(directory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runnerVersion := "v3.1.0"
|
||||
if lane.goos == "darwin" {
|
||||
runnerVersion = "v3.3.0"
|
||||
}
|
||||
receipt := Receipt{
|
||||
Repository: expected.Repository, Commit: expected.Commit, Tree: expected.Tree,
|
||||
GOOS: lane.goos, GOARCH: lane.goarch, GoVersion: lane.goVersion,
|
||||
RunnerVersion: runnerVersion, RunnerName: lane.runner,
|
||||
GeneratedDigest: generatedDigest, UnsignedArtifactSHA: strings.Repeat("f", 64),
|
||||
SuccessfulGates: append([]string(nil), requiredNativeGates...),
|
||||
CompletedAt: time.Now().UTC().Add(-time.Minute).Format(time.RFC3339),
|
||||
}
|
||||
if _, err := WriteReceipt(filepath.Join(directory, "TEND-CI-VERIFICATION.json"), receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user