169 lines
5.1 KiB
Go
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...)
|
|
}
|