239 lines
7.6 KiB
Go
239 lines
7.6 KiB
Go
// 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
|
|
}
|