217 lines
7.7 KiB
Go
217 lines
7.7 KiB
Go
package publish
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"go.maximumdirect.net/releasekit/internal/git"
|
|
)
|
|
|
|
const recordFormatVersion = 1
|
|
const recordByteFormatVersion = 2
|
|
|
|
type recordEnvelope struct {
|
|
FormatVersion int `json:"format_version"`
|
|
Evidence json.RawMessage `json:"evidence"`
|
|
EvidenceBytes json.RawMessage `json:"evidence_bytes,omitempty"`
|
|
}
|
|
|
|
// LoadEvidence reads one complete supported private publication record.
|
|
func LoadEvidence(path string) (Evidence, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return Evidence{}, &RecoveryError{Kind: "record_read", RecordPath: path, cause: fmt.Errorf("read record %q: %w", path, err)}
|
|
}
|
|
var envelope recordEnvelope
|
|
if err := json.Unmarshal(data, &envelope); err != nil {
|
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record %q: %w", path, err)}
|
|
}
|
|
if envelope.FormatVersion != recordFormatVersion && envelope.FormatVersion != recordByteFormatVersion {
|
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q has unsupported format version %d", path, envelope.FormatVersion)}
|
|
}
|
|
if len(envelope.Evidence) == 0 || string(envelope.Evidence) == "null" {
|
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q has no evidence object", path)}
|
|
}
|
|
var evidence Evidence
|
|
if err := json.Unmarshal(envelope.Evidence, &evidence); err != nil {
|
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record evidence %q: %w", path, err)}
|
|
}
|
|
if envelope.FormatVersion == recordByteFormatVersion && len(envelope.EvidenceBytes) > 0 {
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(envelope.EvidenceBytes, &fields); err != nil {
|
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record evidence bytes %q: %w", path, err)}
|
|
}
|
|
for _, field := range evidenceFields(&evidence) {
|
|
if raw, ok := fields[field.name]; ok {
|
|
var bytes []byte
|
|
if err := json.Unmarshal(raw, &bytes); err != nil {
|
|
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record field %s bytes %q: %w", field.name, path, err)}
|
|
}
|
|
*field.value = string(bytes)
|
|
}
|
|
}
|
|
}
|
|
return evidence, nil
|
|
}
|
|
|
|
type evidenceField struct {
|
|
name string
|
|
value *string
|
|
}
|
|
|
|
func evidenceFields(e *Evidence) [10]evidenceField {
|
|
return [10]evidenceField{
|
|
{"repository_dir", &e.RepositoryDir}, {"module_dir", &e.ModuleDir},
|
|
{"module_path", &e.ModulePath}, {"version", &e.Version}, {"tag", &e.Tag},
|
|
{"candidate_commit", &e.CandidateCommit}, {"tag_object", &e.TagObject},
|
|
{"endpoint", &e.Endpoint}, {"remote", &e.Remote}, {"recorded_at", &e.RecordedAt},
|
|
}
|
|
}
|
|
|
|
func encodeRecord(evidence Evidence) ([]byte, error) {
|
|
envelope := recordEnvelope{FormatVersion: recordFormatVersion}
|
|
var bytes map[string][]byte
|
|
for _, field := range evidenceFields(&evidence) {
|
|
if !utf8.ValidString(*field.value) {
|
|
if bytes == nil {
|
|
bytes = make(map[string][]byte)
|
|
}
|
|
bytes[field.name] = []byte(*field.value)
|
|
*field.value = ""
|
|
}
|
|
}
|
|
var err error
|
|
if bytes != nil {
|
|
envelope.FormatVersion = recordByteFormatVersion
|
|
envelope.EvidenceBytes, err = json.Marshal(bytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
envelope.Evidence, err = json.Marshal(evidence)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(envelope)
|
|
}
|
|
|
|
// SaveEvidence writes one private attempt record and returns its absolute path.
|
|
func SaveEvidence(ctx context.Context, repositoryDir, override string, evidence Evidence) (string, error) {
|
|
recordDir, root, err := resolveRecordDir(ctx, repositoryDir, override)
|
|
if err != nil {
|
|
return "", &RecoveryError{Kind: "record_write", cause: err}
|
|
}
|
|
return saveEvidenceInDir(ctx, repositoryDir, root, override, recordDir, evidence)
|
|
}
|
|
|
|
func saveEvidenceInDir(ctx context.Context, repositoryDir, root, override, recordDir string, evidence Evidence) (string, error) {
|
|
return saveEvidenceNamed(ctx, repositoryDir, root, override, recordDir, evidence, proposedRecordPath)
|
|
}
|
|
|
|
func saveEvidenceNamed(ctx context.Context, repositoryDir, root, override, recordDir string, evidence Evidence, nextPath func(string) (string, error)) (string, error) {
|
|
if evidence.RecordedAt == "" {
|
|
evidence.RecordedAt = time.Now().UTC().Format(time.RFC3339Nano)
|
|
}
|
|
if err := os.MkdirAll(recordDir, 0o700); err != nil {
|
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("create record directory %q: %w", recordDir, err)}
|
|
}
|
|
data, err := encodeRecord(evidence)
|
|
if err != nil {
|
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("encode publication evidence: %w", err)}
|
|
}
|
|
for attempt := 0; attempt < 5; attempt++ {
|
|
path, err := nextPath(recordDir)
|
|
if err != nil {
|
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("name publication record: %w", err)}
|
|
}
|
|
if override != "" && root != "" {
|
|
if err := rejectUnignoredWorktreePath(ctx, repositoryDir, root, path); err != nil {
|
|
return "", &RecoveryError{Kind: "record_write", cause: err}
|
|
}
|
|
}
|
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if errors.Is(err, os.ErrExist) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("create record %q: %w", path, err)}
|
|
}
|
|
if _, err = file.Write(data); err != nil {
|
|
_ = file.Close()
|
|
_ = os.Remove(path)
|
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("write record %q: %w", path, err)}
|
|
}
|
|
if err = file.Close(); err != nil {
|
|
_ = os.Remove(path)
|
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("close record %q: %w", path, err)}
|
|
}
|
|
return path, nil
|
|
}
|
|
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("could not allocate a unique record name in %q", recordDir)}
|
|
}
|
|
|
|
func resolveRecordDir(ctx context.Context, repositoryDir, override string) (string, string, error) {
|
|
root, err := git.RepositoryRoot(ctx, repositoryDir)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if override != "" {
|
|
if filepath.IsAbs(override) {
|
|
return filepath.Clean(override), root, nil
|
|
}
|
|
return filepath.Join(root, override), root, nil
|
|
}
|
|
common, err := git.CommonDirectory(ctx, repositoryDir)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return filepath.Join(common, "releasekit", "publication-attempts"), root, nil
|
|
}
|
|
|
|
func proposedRecordPath(directory string) (string, error) {
|
|
var nonce [16]byte
|
|
if _, err := rand.Read(nonce[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(directory, "attempt-"+hex.EncodeToString(nonce[:])+".json"), nil
|
|
}
|
|
|
|
func rejectUnignoredWorktreePath(ctx context.Context, repositoryDir, root, proposed string) error {
|
|
parent, err := filepath.EvalSymlinks(filepath.Dir(proposed))
|
|
if err != nil {
|
|
return fmt.Errorf("resolve record directory %q: %w", filepath.Dir(proposed), err)
|
|
}
|
|
actual := filepath.Join(parent, filepath.Base(proposed))
|
|
rel, err := filepath.Rel(root, actual)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return nil
|
|
}
|
|
metadata, err := git.MetadataDirectories(ctx, repositoryDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, directory := range metadata {
|
|
rel, err := filepath.Rel(directory, actual)
|
|
if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return nil
|
|
}
|
|
}
|
|
ignored, err := git.IsIgnored(ctx, repositoryDir, actual)
|
|
if err != nil {
|
|
return fmt.Errorf("check Git ignore rules for record path %q: %w", actual, err)
|
|
}
|
|
if !ignored {
|
|
return fmt.Errorf("record path %q is inside the worktree and is not ignored", actual)
|
|
}
|
|
return nil
|
|
}
|