Add private publication attempt records
This commit is contained in:
158
internal/publish/record.go
Normal file
158
internal/publish/record.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package publish
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
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", 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", 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", cause: fmt.Errorf("record %q contains trailing JSON", path)}
|
||||
}
|
||||
if envelope.FormatVersion != recordFormatVersion {
|
||||
return Evidence{}, &RecoveryError{Kind: "record_format", 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", 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", 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(repositoryDir, override string, evidence Evidence) (string, error) {
|
||||
recordDir, root, err := resolveRecordDir(repositoryDir, override)
|
||||
if err != nil {
|
||||
return "", &RecoveryError{Kind: "record_write", cause: err}
|
||||
}
|
||||
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)}
|
||||
}
|
||||
if override != "" && root != "" {
|
||||
path, pathErr := proposedRecordPath(recordDir)
|
||||
if pathErr != nil {
|
||||
return "", &RecoveryError{Kind: "record_write", cause: pathErr}
|
||||
}
|
||||
if err := rejectUnignoredWorktreePath(repositoryDir, root, path); err != nil {
|
||||
return "", &RecoveryError{Kind: "record_write", cause: 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++ {
|
||||
var nonce [16]byte
|
||||
if _, err := rand.Read(nonce[:]); err != nil {
|
||||
return "", &RecoveryError{Kind: "record_write", cause: fmt.Errorf("name publication record: %w", err)}
|
||||
}
|
||||
path := filepath.Join(recordDir, "attempt-"+hex.EncodeToString(nonce[:])+".json")
|
||||
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(repositoryDir, override string) (string, string, error) {
|
||||
rootOutput, err := exec.Command("git", "-C", repositoryDir, "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve source repository root: %w", err)
|
||||
}
|
||||
root, err := filepath.Abs(strings.TrimSpace(string(rootOutput)))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve source repository root: %w", err)
|
||||
}
|
||||
if override != "" {
|
||||
if filepath.IsAbs(override) {
|
||||
return filepath.Clean(override), root, nil
|
||||
}
|
||||
return filepath.Join(root, override), root, nil
|
||||
}
|
||||
common, err := exec.Command("git", "-C", repositoryDir, "rev-parse", "--path-format=absolute", "--git-common-dir").Output()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve shared Git metadata directory: %w", err)
|
||||
}
|
||||
return filepath.Join(strings.TrimSpace(string(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(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
|
||||
}
|
||||
cmd := exec.Command("git", "-C", repositoryDir, "check-ignore", "-q", "--", actual)
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||
return fmt.Errorf("record path %q is inside the worktree and is not ignored", actual)
|
||||
}
|
||||
return fmt.Errorf("check Git ignore rules for record path %q: %w", actual, err)
|
||||
}
|
||||
Reference in New Issue
Block a user