296 lines
9.5 KiB
Go
296 lines
9.5 KiB
Go
package artifacts
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"path"
|
|
"strings"
|
|
)
|
|
|
|
const RemoteCommitFormatVersion = 1
|
|
|
|
// RemoteArtifactType identifies the durable purpose of an object in a remote commit.
|
|
type RemoteArtifactType string
|
|
|
|
const (
|
|
RemoteArtifactTypeSessionManifest RemoteArtifactType = "session_manifest"
|
|
RemoteArtifactTypeRunManifest RemoteArtifactType = "run_manifest"
|
|
RemoteArtifactTypeRunFile RemoteArtifactType = "run_file"
|
|
RemoteArtifactTypePublishedOutput RemoteArtifactType = "published_output"
|
|
RemoteArtifactTypePreviousArtifact RemoteArtifactType = "previous_artifact"
|
|
)
|
|
|
|
// RemoteArtifact maps one typed source to an immutable remote destination.
|
|
type RemoteArtifact struct {
|
|
Type RemoteArtifactType `json:"type"`
|
|
Source string `json:"source"`
|
|
DestinationKey string `json:"destination_key"`
|
|
SHA256 string `json:"sha256"`
|
|
Size int64 `json:"size"`
|
|
Generation string `json:"generation"`
|
|
}
|
|
|
|
// RemoteCommitManifest declares the complete immutable object set for one run.
|
|
type RemoteCommitManifest struct {
|
|
FormatVersion int `json:"format_version"`
|
|
Campaign string `json:"campaign"`
|
|
SessionID string `json:"session_id"`
|
|
RunID string `json:"run_id"`
|
|
Artifacts []RemoteArtifact `json:"artifacts"`
|
|
}
|
|
|
|
// CurrentCommitPointer is the sole mutable selector for a remote commit manifest.
|
|
type CurrentCommitPointer struct {
|
|
FormatVersion int `json:"format_version"`
|
|
Campaign string `json:"campaign"`
|
|
SessionID string `json:"session_id"`
|
|
RunID string `json:"run_id"`
|
|
CommitKey string `json:"commit_key"`
|
|
CommitSHA256 string `json:"commit_sha256"`
|
|
CommitSize int64 `json:"commit_size"`
|
|
CommitGeneration string `json:"commit_generation"`
|
|
}
|
|
|
|
func (m RemoteCommitManifest) Validate() error {
|
|
if m.FormatVersion != RemoteCommitFormatVersion {
|
|
return fmt.Errorf("unsupported remote commit format version %d", m.FormatVersion)
|
|
}
|
|
if err := ValidateSessionIdentity(m.Campaign, m.SessionID); err != nil {
|
|
return fmt.Errorf("remote commit identity: %w", err)
|
|
}
|
|
if err := ValidateRunIdentity(m.RunID); err != nil {
|
|
return fmt.Errorf("remote commit run identity: %w", err)
|
|
}
|
|
if len(m.Artifacts) == 0 {
|
|
return fmt.Errorf("remote commit artifacts are required")
|
|
}
|
|
|
|
if err := ValidateRemoteArtifactMapping(m.Artifacts); err != nil {
|
|
return err
|
|
}
|
|
for index, artifact := range m.Artifacts {
|
|
if err := artifact.Validate(); err != nil {
|
|
return fmt.Errorf("remote commit artifact %d: %w", index, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateRemoteArtifactMapping verifies that source and destination identities
|
|
// form one unambiguous immutable-object mapping.
|
|
func ValidateRemoteArtifactMapping(artifacts []RemoteArtifact) error {
|
|
if len(artifacts) == 0 {
|
|
return fmt.Errorf("remote commit artifacts are required")
|
|
}
|
|
destinations := make(map[string]struct{}, len(artifacts))
|
|
sources := make(map[string]struct{}, len(artifacts))
|
|
for index, artifact := range artifacts {
|
|
if err := validateRemoteArtifactIdentity(artifact); err != nil {
|
|
return fmt.Errorf("remote commit artifact %d: %w", index, err)
|
|
}
|
|
if _, exists := destinations[artifact.DestinationKey]; exists {
|
|
return fmt.Errorf("remote commit declares duplicate destination %q", artifact.DestinationKey)
|
|
}
|
|
destinations[artifact.DestinationKey] = struct{}{}
|
|
sourceKey := string(artifact.Type) + "\x00" + artifact.Source
|
|
if _, exists := sources[sourceKey]; exists {
|
|
return fmt.Errorf("remote commit declares ambiguous %s source %q", artifact.Type, artifact.Source)
|
|
}
|
|
sources[sourceKey] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m RemoteCommitManifest) ValidateForSessionPrefix(sessionPrefix string) error {
|
|
if err := m.Validate(); err != nil {
|
|
return err
|
|
}
|
|
runPrefix := S3RunPrefix(sessionPrefix, m.RunID)
|
|
if runPrefix == "" {
|
|
return fmt.Errorf("remote commit run prefix is required")
|
|
}
|
|
for _, artifact := range m.Artifacts {
|
|
if !strings.HasPrefix(artifact.DestinationKey, runPrefix) {
|
|
return fmt.Errorf("remote commit artifact destination %q is outside immutable run prefix %q", artifact.DestinationKey, runPrefix)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a RemoteArtifact) Validate() error {
|
|
if err := validateRemoteArtifactIdentity(a); err != nil {
|
|
return err
|
|
}
|
|
if err := validateSHA256(a.SHA256); err != nil {
|
|
return fmt.Errorf("sha256: %w", err)
|
|
}
|
|
if a.Size < 0 {
|
|
return fmt.Errorf("size must not be negative")
|
|
}
|
|
if strings.TrimSpace(a.Generation) == "" {
|
|
return fmt.Errorf("generation is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRemoteArtifactIdentity(a RemoteArtifact) error {
|
|
switch a.Type {
|
|
case RemoteArtifactTypeSessionManifest, RemoteArtifactTypeRunManifest, RemoteArtifactTypeRunFile, RemoteArtifactTypePublishedOutput, RemoteArtifactTypePreviousArtifact:
|
|
default:
|
|
return fmt.Errorf("unsupported artifact type %q", a.Type)
|
|
}
|
|
if strings.TrimSpace(a.Source) == "" {
|
|
return fmt.Errorf("source is required")
|
|
}
|
|
if err := validateRemoteObjectKey(a.DestinationKey); err != nil {
|
|
return fmt.Errorf("destination key: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p CurrentCommitPointer) Validate() error {
|
|
if p.FormatVersion != RemoteCommitFormatVersion {
|
|
return fmt.Errorf("unsupported current commit pointer format version %d", p.FormatVersion)
|
|
}
|
|
if err := ValidateSessionIdentity(p.Campaign, p.SessionID); err != nil {
|
|
return fmt.Errorf("current commit pointer identity: %w", err)
|
|
}
|
|
if err := ValidateRunIdentity(p.RunID); err != nil {
|
|
return fmt.Errorf("current commit pointer run identity: %w", err)
|
|
}
|
|
if err := validateRemoteObjectKey(p.CommitKey); err != nil {
|
|
return fmt.Errorf("current commit pointer key: %w", err)
|
|
}
|
|
if err := validateSHA256(p.CommitSHA256); err != nil {
|
|
return fmt.Errorf("current commit pointer checksum: %w", err)
|
|
}
|
|
if p.CommitSize < 0 {
|
|
return fmt.Errorf("current commit pointer size must not be negative")
|
|
}
|
|
if strings.TrimSpace(p.CommitGeneration) == "" {
|
|
return fmt.Errorf("current commit pointer generation is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p CurrentCommitPointer) ValidateForSessionPrefix(sessionPrefix string) error {
|
|
if err := p.Validate(); err != nil {
|
|
return err
|
|
}
|
|
expectedKey := S3RunCommitKey(sessionPrefix, p.RunID)
|
|
if p.CommitKey != expectedKey {
|
|
return fmt.Errorf("current commit pointer key %q does not match canonical commit key %q", p.CommitKey, expectedKey)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EncodeRemoteCommitManifest validates and serializes an immutable commit manifest.
|
|
func EncodeRemoteCommitManifest(m RemoteCommitManifest) ([]byte, error) {
|
|
if err := m.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := json.Marshal(m)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode remote commit manifest: %w", err)
|
|
}
|
|
return append(data, '\n'), nil
|
|
}
|
|
|
|
// DecodeRemoteCommitManifest strictly decodes a remote commit manifest.
|
|
func DecodeRemoteCommitManifest(data []byte) (*RemoteCommitManifest, error) {
|
|
var commit RemoteCommitManifest
|
|
if err := decodeStrictJSON(data, &commit); err != nil {
|
|
return nil, fmt.Errorf("decode remote commit manifest: %w", err)
|
|
}
|
|
if err := commit.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &commit, nil
|
|
}
|
|
|
|
// EncodeCurrentCommitPointer validates and serializes a current commit pointer.
|
|
func EncodeCurrentCommitPointer(p CurrentCommitPointer) ([]byte, error) {
|
|
if err := p.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := json.Marshal(p)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode current commit pointer: %w", err)
|
|
}
|
|
return append(data, '\n'), nil
|
|
}
|
|
|
|
// DecodeCurrentCommitPointer strictly decodes a current commit pointer.
|
|
func DecodeCurrentCommitPointer(data []byte) (*CurrentCommitPointer, error) {
|
|
var pointer CurrentCommitPointer
|
|
if err := decodeStrictJSON(data, &pointer); err != nil {
|
|
return nil, fmt.Errorf("decode current commit pointer: %w", err)
|
|
}
|
|
if err := pointer.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &pointer, nil
|
|
}
|
|
|
|
func (m RemoteCommitManifest) Artifact(artifactType RemoteArtifactType) (RemoteArtifact, bool) {
|
|
var found RemoteArtifact
|
|
for _, artifact := range m.Artifacts {
|
|
if artifact.Type != artifactType {
|
|
continue
|
|
}
|
|
if found.DestinationKey != "" {
|
|
return RemoteArtifact{}, false
|
|
}
|
|
found = artifact
|
|
}
|
|
return found, found.DestinationKey != ""
|
|
}
|
|
|
|
func remoteObjectSHA256(data []byte) string {
|
|
sum := sha256.Sum256(data)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func validateSHA256(value string) error {
|
|
if len(value) != sha256.Size*2 || strings.ToLower(value) != value {
|
|
return fmt.Errorf("must be a lowercase SHA-256 digest")
|
|
}
|
|
if _, err := hex.DecodeString(value); err != nil {
|
|
return fmt.Errorf("must be hexadecimal: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRemoteObjectKey(value string) error {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fmt.Errorf("is required")
|
|
}
|
|
if strings.TrimSpace(value) != value || strings.HasPrefix(value, "/") || strings.Contains(value, `\\`) {
|
|
return fmt.Errorf("must be a clean relative object key")
|
|
}
|
|
if cleaned := path.Clean(value); cleaned != value || cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
|
|
return fmt.Errorf("must be a clean relative object key")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodeStrictJSON(data []byte, destination any) error {
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(destination); err != nil {
|
|
return err
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
if err == nil {
|
|
return fmt.Errorf("multiple JSON values are not allowed")
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|