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 }