Skip to content

Commit 46cb56f

Browse files
committed
backup: close S3 KEYMAP fd + use openSidecarFile (PR #718, round 7)
Codex round 9 raised two issues on commit ab38eb0: 1. P1: closeBucketKeymap leaked file descriptors. recordKeymap stored only the *KeymapWriter; closeBucketKeymap called KeymapWriter.Close() which flushes the bufio buffer but does NOT close the underlying *os.File. A dump producing keymaps for many buckets accumulated descriptors until EMFILE, after which subsequent bucket flushes failed and the dump output was incomplete. Track the *os.File on s3BucketState and close it from closeBucketKeymap alongside the KeymapWriter flush. 2. P2: recordKeymap used os.Create for KEYMAP.jsonl, which follows symlinks and clobbers hard links. The redis encoder already routes through openSidecarFile for the same kind of sidecar; mirror that path so a stale prior run (or local adversary) cannot turn a missing KEYMAP into an arbitrary-write primitive against /etc/passwd or similar. Test: TestS3_KeymapRefusesSymlinkAtFinalize pre-creates KEYMAP.jsonl as a symlink to a bait file, drives a meta-suffix rename (so recordKeymap fires), and asserts both that the finalize returns the symlink-refusal error and that the bait file is untouched.
1 parent ab38eb0 commit 46cb56f

2 files changed

Lines changed: 64 additions & 9 deletions

File tree

internal/backup/s3.go

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,14 @@ type s3BucketState struct {
147147
// every object flushes (the prior orphan-warning path covers it).
148148
activeGen uint64
149149
objects map[string]*s3ObjectState // keyed by "object\x00generation"
150-
keymap *KeymapWriter
151-
keymapDir string
150+
// keymap / keymapFile / keymapDir are lazily set on the first
151+
// rename. KeymapWriter.Close only flushes the bufio buffer, so
152+
// the *os.File is tracked separately to be closed at finalize —
153+
// otherwise a dump that produces keymaps for many buckets
154+
// accumulates descriptors until EMFILE (Codex P1 round 9).
155+
keymap *KeymapWriter
156+
keymapFile *os.File
157+
keymapDir string
152158
// incompleteUploadsJL is opened lazily on the first
153159
// !s3|upload|meta or !s3|upload|part record under
154160
// --include-incomplete-uploads, then reused for every subsequent
@@ -564,18 +570,27 @@ func (s *S3Encoder) computeDirPrefixes(b *s3BucketState) map[string]bool {
564570
}
565571

566572
// closeBucketKeymap closes the per-bucket KEYMAP.jsonl writer (if
567-
// opened) and removes the file when no rename was recorded.
573+
// opened) and removes the file when no rename was recorded. The
574+
// *os.File is closed separately because KeymapWriter.Close only
575+
// flushes its bufio buffer; without explicit fd close, dumps that
576+
// produce keymaps for many buckets leak descriptors until EMFILE
577+
// (Codex P1 round 9).
568578
func closeBucketKeymap(b *s3BucketState) error {
569579
if b.keymap == nil {
570580
return nil
571581
}
572-
if err := b.keymap.Close(); err != nil {
573-
return err
582+
flushErr := b.keymap.Close()
583+
var closeErr error
584+
if b.keymapFile != nil {
585+
closeErr = b.keymapFile.Close()
586+
}
587+
if flushErr == nil && closeErr != nil {
588+
flushErr = errors.WithStack(closeErr)
574589
}
575590
if b.keymap.Count() == 0 && b.keymapDir != "" {
576591
_ = os.Remove(filepath.Join(b.keymapDir, "KEYMAP.jsonl"))
577592
}
578-
return nil
593+
return flushErr
579594
}
580595

581596
func (s *S3Encoder) flushObjectWithCollision(b *s3BucketState, bucketDir string, obj *s3ObjectState, needsLeafDataRename bool) error {
@@ -744,12 +759,17 @@ func (s *S3Encoder) resolveObjectFilename(b *s3BucketState, obj *s3ObjectState,
744759

745760
func (s *S3Encoder) recordKeymap(b *s3BucketState, bucketDir, encodedFilename string, original []byte, kind string) error {
746761
if b.keymap == nil {
747-
path := filepath.Join(bucketDir, "KEYMAP.jsonl")
748-
f, err := os.Create(path) //nolint:gosec // path composed from output root
762+
// openSidecarFile (per-platform) refuses both symlinks and
763+
// hard-link clobber attacks. The previous os.Create here
764+
// followed both, leaving an arbitrary-write primitive if a
765+
// stale prior run or local adversary placed a link at the
766+
// path. Codex P2 round 9.
767+
f, err := openSidecarFile(filepath.Join(bucketDir, "KEYMAP.jsonl"))
749768
if err != nil {
750-
return errors.WithStack(err)
769+
return err
751770
}
752771
b.keymap = NewKeymapWriter(f)
772+
b.keymapFile = f
753773
b.keymapDir = bucketDir
754774
}
755775
return b.keymap.WriteOriginal(encodedFilename, original, kind)

internal/backup/s3_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"os"
77
"path/filepath"
8+
"strings"
89
"testing"
910

1011
"github.com/bootjp/elastickv/internal/s3keys"
@@ -284,6 +285,40 @@ func TestS3_PathTraversalAttemptRejected(t *testing.T) {
284285
}
285286
}
286287

288+
// TestS3_KeymapRefusesSymlinkAtFinalize is the regression for Codex
289+
// P2 round 9 on PR #718: the S3 encoder's recordKeymap was using
290+
// os.Create directly, which follows symlinks. A bucket whose
291+
// KEYMAP.jsonl path is a pre-existing symlink (from a stale prior
292+
// run or a local adversary) would have its target truncated when
293+
// the first rename was recorded. recordKeymap now goes through
294+
// openSidecarFile, mirroring the redis encoder's guarded open.
295+
func TestS3_KeymapRefusesSymlinkAtFinalize(t *testing.T) {
296+
t.Parallel()
297+
enc, root := newS3Encoder(t)
298+
bucket := "b"
299+
bucketDir := filepath.Join(root, "s3", bucket)
300+
if err := os.MkdirAll(bucketDir, 0o755); err != nil {
301+
t.Fatal(err)
302+
}
303+
bait := filepath.Join(root, "bait-keymap")
304+
if err := os.WriteFile(bait, []byte("stay-out"), 0o600); err != nil {
305+
t.Fatal(err)
306+
}
307+
if err := os.Symlink(bait, filepath.Join(bucketDir, "KEYMAP.jsonl")); err != nil {
308+
t.Fatal(err)
309+
}
310+
// Drive a meta-suffix-collision rename so recordKeymap fires.
311+
enc.WithRenameCollisions(true)
312+
emitObject(t, enc, bucket, 1, "evil.elastickv-meta.json", []byte("payload"), "")
313+
err := enc.Finalize()
314+
if err == nil || !strings.Contains(err.Error(), "refusing to overwrite symlink") {
315+
t.Fatalf("expected symlink-refusal error from KEYMAP open, got %v", err)
316+
}
317+
if got, _ := os.ReadFile(bait); string(got) != "stay-out" { //nolint:gosec // test path
318+
t.Fatalf("bait file written through KEYMAP symlink: %q", got)
319+
}
320+
}
321+
287322
// TestS3_BackslashObjectKeyRejected is the regression for Codex P1
288323
// round 6: filepath.Join treats '\' as a separator on Windows, so
289324
// keys like `a\..\b` would bypass the '/'-based dot-segment scan

0 commit comments

Comments
 (0)