diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index e8a243d..5f13bfc 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -124,6 +124,8 @@ rejected-push, endpoint-change, and cancellation behavior. ## Stage 2: Implement private attempt records +**Completed:** 2026-11-02 + **Outcome:** A supported internal codec can save and load a single attempt without relying on diagnostic output. diff --git a/internal/publish/reconcile.go b/internal/publish/reconcile.go index 7f9c2cc..2fe400c 100644 --- a/internal/publish/reconcile.go +++ b/internal/publish/reconcile.go @@ -9,16 +9,16 @@ import ( // Evidence is the identity captured when one publication was attempted. type Evidence struct { - RepositoryDir string - ModuleDir string - ModulePath string - Version string - Tag string - CandidateCommit string - TagObject string - Endpoint string - Remote string - RecordedAt string + RepositoryDir string `json:"repository_dir,omitempty"` + ModuleDir string `json:"module_dir,omitempty"` + ModulePath string `json:"module_path,omitempty"` + Version string `json:"version,omitempty"` + Tag string `json:"tag,omitempty"` + CandidateCommit string `json:"candidate_commit,omitempty"` + TagObject string `json:"tag_object,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Remote string `json:"remote,omitempty"` + RecordedAt string `json:"recorded_at,omitempty"` } // Observation combines the original evidence with authoritative remote state. diff --git a/internal/publish/record.go b/internal/publish/record.go new file mode 100644 index 0000000..c3df3b2 --- /dev/null +++ b/internal/publish/record.go @@ -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) +} diff --git a/internal/publish/record_test.go b/internal/publish/record_test.go new file mode 100644 index 0000000..675b37c --- /dev/null +++ b/internal/publish/record_test.go @@ -0,0 +1,156 @@ +package publish + +import ( + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func recordGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + return string(out) +} + +func recordRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + recordGit(t, dir, "init", "-q") + recordGit(t, dir, "config", "user.name", "Test") + recordGit(t, dir, "config", "user.email", "test@example.invalid") + if err := os.WriteFile(filepath.Join(dir, "file"), []byte("x"), 0600); err != nil { + t.Fatal(err) + } + recordGit(t, dir, "add", "file") + recordGit(t, dir, "commit", "-qm", "init") + return dir +} + +func TestLoadEvidenceAcceptsPartialAndRejectsMalformedRecords(t *testing.T) { + dir := t.TempDir() + partial := filepath.Join(dir, "partial.json") + if err := os.WriteFile(partial, []byte(`{"format_version":1,"evidence":{}}`), 0600); err != nil { + t.Fatal(err) + } + got, err := LoadEvidence(partial) + if err != nil || got != (Evidence{}) { + t.Fatalf("partial record = %#v, %v", got, err) + } + for name, data := range map[string]string{"bad-json": "{", "unsupported": `{"format_version":2,"evidence":{}}`, "type": `{"format_version":1,"evidence":{"tag":false}}`, "trailing": `{"format_version":1,"evidence":{}} {}`} { + t.Run(name, func(t *testing.T) { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(data), 0600); err != nil { + t.Fatal(err) + } + _, err := LoadEvidence(path) + var recovery *RecoveryError + if !errors.As(err, &recovery) || recovery.Kind != "record_format" { + t.Fatalf("LoadEvidence() error=%v", err) + } + }) + } +} + +func TestLoadEvidenceReadFailureHasTypedError(t *testing.T) { + _, err := LoadEvidence(filepath.Join(t.TempDir(), "missing.json")) + var recovery *RecoveryError + if !errors.As(err, &recovery) || recovery.Kind != "record_read" || !errors.Is(err, os.ErrNotExist) { + t.Fatalf("LoadEvidence() error = %v", err) + } +} + +func TestSaveEvidenceUsesPrivateUniqueRecordsAndLinkedWorktreeMetadata(t *testing.T) { + repo := recordRepo(t) + worktree := filepath.Join(t.TempDir(), "linked") + recordGit(t, repo, "worktree", "add", "--detach", worktree) + evidence := Evidence{Tag: "v1.2.3", Endpoint: "https://user:secret@example.invalid/repo.git"} + first, err := SaveEvidence(worktree, "", evidence) + if err != nil { + t.Fatal(err) + } + second, err := SaveEvidence(worktree, "", evidence) + if err != nil { + t.Fatal(err) + } + if first == second { + t.Fatalf("record paths collided: %q", first) + } + common := recordGit(t, repo, "rev-parse", "--path-format=absolute", "--git-common-dir") + wantDir := filepath.Join(strings.TrimSpace(common), "releasekit", "publication-attempts") + if filepath.Dir(first) != wantDir { + t.Fatalf("record dir=%q want %q", filepath.Dir(first), wantDir) + } + dirInfo, err := os.Stat(wantDir) + if err != nil { + t.Fatal(err) + } + if dirInfo.Mode().Perm() != 0700 { + t.Fatalf("record directory permissions=%o", dirInfo.Mode().Perm()) + } + info, err := os.Stat(first) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("record permissions=%o", info.Mode().Perm()) + } + loaded, err := LoadEvidence(first) + if err != nil { + t.Fatal(err) + } + if loaded.Endpoint != evidence.Endpoint || loaded.RecordedAt == "" { + t.Fatalf("loaded evidence=%#v", loaded) + } + var envelope map[string]json.RawMessage + data, err := os.ReadFile(first) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &envelope); err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "user:secret@example.invalid") { + t.Fatal("private record did not retain the exact endpoint") + } +} + +func TestSaveEvidenceResolvesRelativeAndIgnoredOverrides(t *testing.T) { + repo := recordRepo(t) + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("saved-records/\n"), 0600); err != nil { + t.Fatal(err) + } + if _, err := SaveEvidence(repo, "saved-records", Evidence{Tag: "v1.2.3"}); err != nil { + t.Fatalf("ignored override: %v", err) + } + if _, err := os.Stat(filepath.Join(repo, "saved-records")); err != nil { + t.Fatal(err) + } + external := filepath.Join(t.TempDir(), "external-store") + path, err := SaveEvidence(repo, external, Evidence{Tag: "v2.0.0"}) + if err != nil || !strings.HasPrefix(path, external+string(filepath.Separator)) { + t.Fatalf("absolute external override path=%q error=%v", path, err) + } +} + +func TestSaveEvidenceRejectsUnignoredOverrideInsideWorktree(t *testing.T) { + repo := recordRepo(t) + _, err := SaveEvidence(repo, "records", Evidence{Tag: "v1.2.3"}) + var recovery *RecoveryError + if !errors.As(err, &recovery) || recovery.Kind != "record_write" { + t.Fatalf("SaveEvidence() error=%v", err) + } + if _, err := os.Stat(filepath.Join(repo, "records")); err != nil { + t.Fatalf("expected parent directory to exist without record: %v", err) + } + if _, err := os.Stat(filepath.Join(repo, "records", "attempt")); !os.IsNotExist(err) { + t.Fatalf("unexpected record: %v", err) + } +}