Files
releasekit/internal/publish/record.go

168 lines
6.2 KiB
Go

package publish
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"go.maximumdirect.net/releasekit/internal/git"
)
const recordFormatVersion = 1
type recordEnvelope struct {
FormatVersion int `json:"format_version"`
Evidence json.RawMessage `json:"evidence"`
}
// 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)}
}
decoder := json.NewDecoder(strings.NewReader(string(data)))
var envelope recordEnvelope
if err := decoder.Decode(&envelope); err != nil {
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("decode record %q: %w", path, err)}
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return Evidence{}, &RecoveryError{Kind: "record_format", RecordPath: path, cause: fmt.Errorf("record %q contains trailing JSON", path)}
}
if envelope.FormatVersion != recordFormatVersion {
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)}
}
return evidence, nil
}
// 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)}
}
evidenceJSON, err := json.Marshal(evidence)
if err != nil {
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("encode publication evidence: %w", err)}
}
data, err := json.Marshal(recordEnvelope{FormatVersion: recordFormatVersion, Evidence: evidenceJSON})
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
}