Skip to content

Commit b196bf7

Browse files
committed
Merge remote-tracking branch 'origin/feat/backup-phase0a-redis-simple' into feat/backup-phase0a-s3
2 parents 46cb56f + e061b6d commit b196bf7

4 files changed

Lines changed: 159 additions & 5 deletions

File tree

internal/backup/disk_full_unix.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//go:build unix
2+
3+
package backup
4+
5+
import (
6+
"errors"
7+
"syscall"
8+
)
9+
10+
// isDiskFullError reports whether err is a POSIX ENOSPC anywhere in
11+
// the wrap chain. os.File.Write surfaces ENOSPC inside an
12+
// os.PathError which errors.Is unwraps natively.
13+
func isDiskFullError(err error) bool {
14+
return errors.Is(err, syscall.ENOSPC)
15+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//go:build windows
2+
3+
package backup
4+
5+
import (
6+
"errors"
7+
"syscall"
8+
9+
"golang.org/x/sys/windows"
10+
)
11+
12+
// isDiskFullError reports whether err is a Windows disk-full failure.
13+
// We accept both ERROR_DISK_FULL (Win32 errno 112) and
14+
// ERROR_HANDLE_DISK_FULL (39): the kernel returns 112 for write
15+
// failures driven by free-space exhaustion and 39 for the legacy
16+
// handle-level variant some FS drivers still surface. We also keep
17+
// syscall.ENOSPC in the chain because Go's os package occasionally
18+
// translates platform errors into the POSIX value before returning.
19+
func isDiskFullError(err error) bool {
20+
switch {
21+
case errors.Is(err, windows.ERROR_DISK_FULL),
22+
errors.Is(err, windows.ERROR_HANDLE_DISK_FULL),
23+
errors.Is(err, syscall.ENOSPC):
24+
return true
25+
}
26+
return false
27+
}

internal/backup/redis_string.go

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
"math"
1010
"os"
1111
"path/filepath"
12-
"syscall"
12+
"strings"
1313

1414
cockroachdberr "github.com/cockroachdb/errors"
1515
)
@@ -300,18 +300,92 @@ func intToDecimal(v int) string {
300300

301301
// ensureDir runs MkdirAll once per directory and remembers the result
302302
// in r.dirsCreated, so repeated calls on the hot path (one per blob
303-
// record) collapse to a map lookup.
303+
// record) collapse to a map lookup. After MkdirAll succeeds, every
304+
// path component under outRoot is Lstat-checked: a pre-existing
305+
// directory symlink at e.g. `<outRoot>/redis/db_0/strings` would
306+
// otherwise let `os.MkdirAll` succeed without creating anything,
307+
// then steer subsequent writes outside outRoot. Codex P1 round 9.
308+
//
309+
// This guard is best-effort against TOCTOU (an adversary that can
310+
// swap a directory for a symlink between this check and the open
311+
// races us either way); it closes the much more common case of a
312+
// stale symlink left in the output tree from a prior run or
313+
// configuration mistake. Hardening to fully race-free traversal
314+
// would require os.Root / openat-style traversal, which is a
315+
// larger refactor for marginal benefit at this layer.
304316
func (r *RedisDB) ensureDir(dir string) error {
305317
if _, ok := r.dirsCreated[dir]; ok {
306318
return nil
307319
}
308320
if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:mnd // 0755 == standard dir mode
309321
return cockroachdberr.WithStack(err)
310322
}
323+
if err := assertNoSymlinkAncestors(r.outRoot, dir); err != nil {
324+
return err
325+
}
311326
r.dirsCreated[dir] = struct{}{}
312327
return nil
313328
}
314329

330+
// assertNoSymlinkAncestors walks every path component from rootDir up
331+
// to (and including) target, Lstat'ing each. Returns ErrSymlinkInPath
332+
// if any component is a symbolic link. rootDir itself is also
333+
// Lstat'd: if the dump root is a symlink to somewhere else, all bets
334+
// are off.
335+
func assertNoSymlinkAncestors(rootDir, target string) error {
336+
cleanRoot := filepath.Clean(rootDir)
337+
cleanTarget := filepath.Clean(target)
338+
rel, err := filepath.Rel(cleanRoot, cleanTarget)
339+
if err != nil {
340+
return cockroachdberr.WithStack(err)
341+
}
342+
// Defensive: if target escapes rootDir (which the callers' path
343+
// construction already prevents), refuse rather than silently
344+
// validate an unrelated path.
345+
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
346+
return cockroachdberr.WithStack(cockroachdberr.Newf(
347+
"backup: target %s escapes root %s", target, rootDir))
348+
}
349+
if err := lstatRefuseSymlink(cleanRoot); err != nil {
350+
return err
351+
}
352+
cur := cleanRoot
353+
if rel == "." {
354+
return nil
355+
}
356+
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
357+
if seg == "" {
358+
continue
359+
}
360+
cur = filepath.Join(cur, seg)
361+
if err := lstatRefuseSymlink(cur); err != nil {
362+
return err
363+
}
364+
}
365+
return nil
366+
}
367+
368+
// lstatRefuseSymlink returns an error wrapped over the underlying
369+
// stat call when path is a symbolic link. A non-existent path is
370+
// treated as fine: the caller has just MkdirAll'd it, so a missing
371+
// component is impossible — but if it were, the symlink-check
372+
// contract is "if it exists, it must not be a symlink", and we
373+
// return nil rather than synthesize a false positive.
374+
func lstatRefuseSymlink(path string) error {
375+
info, err := os.Lstat(path)
376+
if err != nil {
377+
if os.IsNotExist(err) {
378+
return nil
379+
}
380+
return cockroachdberr.WithStack(err)
381+
}
382+
if info.Mode()&os.ModeSymlink != 0 {
383+
return cockroachdberr.WithStack(cockroachdberr.Newf(
384+
"backup: refusing to traverse symlinked ancestor at %s", path))
385+
}
386+
return nil
387+
}
388+
315389
func (r *RedisDB) writeBlob(subdir string, userKey, value []byte) error {
316390
encoded := EncodeSegment(userKey)
317391
if err := r.recordIfFallback(encoded, userKey); err != nil {
@@ -546,11 +620,14 @@ func IsBlobAtomicWriteRetriable(err error) bool {
546620

547621
// IsBlobAtomicWriteOutOfSpace reports whether err from writeFileAtomic
548622
// (or any os.File write the master pipeline issues) was driven by a
549-
// full disk. Tested via syscall.ENOSPC + os.PathError unwrap, which
550-
// matches what os.File.Write returns on POSIX and Windows.
623+
// full disk. The platform-specific error codes (POSIX ENOSPC vs.
624+
// Windows ERROR_DISK_FULL / ERROR_HANDLE_DISK_FULL) live in
625+
// disk_full_{unix,windows}.go so retry/alarm logic in callers
626+
// classifies disk-full uniformly across operating systems
627+
// (Codex P2 round 9).
551628
func IsBlobAtomicWriteOutOfSpace(err error) bool {
552629
if err == nil {
553630
return false
554631
}
555-
return errors.Is(err, syscall.ENOSPC)
632+
return isDiskFullError(err)
556633
}

internal/backup/redis_string_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,41 @@ func TestRedisDB_NewFormatStringTTLNotDuplicatedByScanIndex(t *testing.T) {
199199
assertTTLSidecar(t, filepath.Join(root, "redis", "db_0", "strings_ttl.jsonl"), "expiring", fixedExpireMs)
200200
}
201201

202+
// TestRedisDB_RefusesSymlinkedAncestor is the regression for Codex P1
203+
// round 9: O_NOFOLLOW only blocks the final-component symlink. A
204+
// pre-existing directory symlink anywhere up the path (e.g.
205+
// `<outRoot>/redis/db_0/strings -> /tmp/outside`) lets MkdirAll
206+
// silently honor it and steers writeFileAtomic / openSidecarFile
207+
// outside outRoot. ensureDir now Lstat-walks each ancestor under
208+
// outRoot and refuses if any is a symlink.
209+
func TestRedisDB_RefusesSymlinkedAncestor(t *testing.T) {
210+
t.Parallel()
211+
root := t.TempDir()
212+
// Pre-place the symlink trap before constructing the encoder so
213+
// the directory tree contains a poisoned ancestor at
214+
// <root>/redis/db_0/strings.
215+
bait := filepath.Join(root, "bait-tree")
216+
if err := os.MkdirAll(bait, 0o755); err != nil {
217+
t.Fatal(err)
218+
}
219+
parent := filepath.Join(root, "redis", "db_0")
220+
if err := os.MkdirAll(parent, 0o755); err != nil {
221+
t.Fatal(err)
222+
}
223+
if err := os.Symlink(bait, filepath.Join(parent, "strings")); err != nil {
224+
t.Fatal(err)
225+
}
226+
db := NewRedisDB(root, 0)
227+
err := db.HandleString([]byte("k"), encodeNewStringValue(t, []byte("v"), 0))
228+
if err == nil || !strings.Contains(err.Error(), "refusing to traverse symlinked ancestor") {
229+
t.Fatalf("expected symlinked-ancestor refusal, got %v", err)
230+
}
231+
// The symlink target must be untouched: no `k.bin` written.
232+
if _, statErr := os.Stat(filepath.Join(bait, "k.bin")); !os.IsNotExist(statErr) {
233+
t.Fatalf("blob written through ancestor symlink: stat err=%v", statErr)
234+
}
235+
}
236+
202237
// TestRedisDB_OpenJSONLRefusesHardLinkClobber is the regression for
203238
// Codex P2 round 9: O_NOFOLLOW only blocks symlinks; an adversary
204239
// who can write the output directory could pre-create

0 commit comments

Comments
 (0)