424 lines
14 KiB
Go
424 lines
14 KiB
Go
// 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[:])
|
|
}
|