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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user