Files
go-sip/internal/agent/recording_cleanup_proof.go
T

169 lines
5.1 KiB
Go

package agent
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
)
// This temporary copy of an existing delivered receipt survives the final
// info unlink and rmdir. It is removed after the exact owned leaf is durably
// gone; it is not a completion history or an acknowledgment protocol.
const recordingCleanupDirectory = ".recording-cleanup"
func (r *RecordingRecovery) cleanupProofPath(bucket, objectKey string) (string, error) {
if _, _, _, err := r.paths(bucket, objectKey); err != nil {
return "", err
}
digest := sha256.Sum256([]byte(bucket + "/" + objectKey))
return filepath.Join(r.Root, recordingCleanupDirectory, fmt.Sprintf("%x.json", digest)), nil
}
func privateCleanupDirectory(path string) error {
stat, err := os.Lstat(path)
if err != nil {
return err
}
if !stat.IsDir() || stat.Mode()&os.ModeSymlink != 0 || stat.Mode().Perm()&0077 != 0 {
return errors.New("recording cleanup directory is not an owned private directory")
}
return nil
}
func (r *RecordingRecovery) readCleanupProof(path string) (RecordingRecoveryEntry, error) {
for _, dir := range []string{r.Root, filepath.Join(r.Root, recordingCleanupDirectory)} {
if err := privateCleanupDirectory(dir); err != nil {
return RecordingRecoveryEntry{}, err
}
}
stat, err := os.Lstat(path)
if err != nil {
return RecordingRecoveryEntry{}, err
}
if !stat.Mode().IsRegular() || stat.Mode().Perm()&0077 != 0 {
return RecordingRecoveryEntry{}, errors.New("recording cleanup proof is not a private regular file")
}
data, err := os.ReadFile(path)
if err != nil {
return RecordingRecoveryEntry{}, err
}
var identity RecordingRecoveryEntry
if err := json.Unmarshal(data, &identity); err != nil {
return identity, err
}
entry, err := decodeRecordingInfo(data, identity.Bucket, identity.ObjectKey)
if err != nil {
return entry, err
}
if entry.State != "delivered" {
return entry, errors.New("recording cleanup proof is not delivered")
}
expected, err := r.cleanupProofPath(entry.Bucket, entry.ObjectKey)
if err != nil {
return entry, err
}
if filepath.Clean(path) != filepath.Clean(expected) {
return entry, errors.New("recording cleanup proof filename conflicts with its original target")
}
return entry, nil
}
func (r *RecordingRecovery) preserveCleanupProof(entry RecordingRecoveryEntry) (string, error) {
path, err := r.cleanupProofPath(entry.Bucket, entry.ObjectKey)
if err != nil {
return "", err
}
if err := privateCleanupDirectory(r.Root); err != nil {
return "", err
}
dir := filepath.Dir(path)
if err := os.Mkdir(dir, 0700); err == nil {
if err := syncDirectory(r.Root); err != nil {
return "", err
}
} else if !errors.Is(err, os.ErrExist) {
return "", err
}
if err := privateCleanupDirectory(dir); err != nil {
return "", err
}
// A prior attempt may have created the namespace but failed its sync.
if err := syncDirectory(r.Root); err != nil {
return "", err
}
before, err := r.readCleanupProof(path)
if err == nil {
original, marshalErr := json.Marshal(entry)
persisted, beforeErr := json.Marshal(before)
if marshalErr != nil || beforeErr != nil {
return "", errors.Join(marshalErr, beforeErr)
}
if !bytes.Equal(original, persisted) {
return "", errors.New("recording cleanup proof conflicts with current recording")
}
// Also retry a previously failed rename-directory sync before unlinking
// originals; seeing the receipt in this process is not durability proof.
if err := syncDirectory(dir); err != nil {
return "", err
}
return path, nil
}
if !errors.Is(err, os.ErrNotExist) {
return "", err
}
if err := r.persistState(path, entry); err != nil {
return "", err
}
return path, nil
}
// Scan only explicitly proved targets, never arbitrary empty directories.
func (r *RecordingRecovery) resumeCleanupProofs(ctx context.Context) (int, error) {
dir := filepath.Join(r.Root, recordingCleanupDirectory)
if err := privateCleanupDirectory(dir); errors.Is(err, os.ErrNotExist) {
return 0, nil
} else if err != nil {
return 0, err
}
items, err := os.ReadDir(dir)
if err != nil {
return 0, err
}
count := 0
var blocked []error
for _, item := range items {
if err := ctx.Err(); err != nil {
return count, errors.Join(append(blocked, err)...)
}
path := filepath.Join(dir, item.Name())
entry, err := r.readCleanupProof(path)
if errors.Is(err, os.ErrNotExist) {
continue
} // independent scanner completed it
if err == nil {
_, err = r.Retry(ctx, entry.Bucket, entry.ObjectKey, nil, nil)
if errors.Is(err, os.ErrNotExist) {
continue
}
}
if err != nil {
digest := sha256.Sum256([]byte(item.Name()))
slog.ErrorContext(ctx, "Delivered recording cleanup proof retained", "stage", "delivered_cleanup_resume", "proof_digest", fmt.Sprintf("%x", digest[:6]), "error", err)
blocked = append(blocked, fmt.Errorf("recording cleanup proof %x: %w", digest[:6], err))
continue
}
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
count++
} else if err != nil {
blocked = append(blocked, err)
}
}
return count, errors.Join(blocked...)
}