Skip to content

Commit 8cb3162

Browse files
committed
fix(lockutil): harden cross-platform lock files
1 parent fd34723 commit 8cb3162

10 files changed

Lines changed: 444 additions & 11 deletions

‎internal/daemon/lock.go‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,20 @@ func readPidFile(path string) (int, error) {
6565
if err != nil {
6666
return 0, err
6767
}
68-
return strconv.Atoi(strings.TrimSpace(string(data)))
68+
metadata := strings.TrimSpace(string(data))
69+
if pid, err := strconv.Atoi(metadata); err == nil {
70+
return pid, nil
71+
}
72+
pidText, sequenceText, ok := strings.Cut(metadata, "-")
73+
if !ok || pidText == "" || sequenceText == "" || strings.Contains(sequenceText, "-") {
74+
return 0, fmt.Errorf("daemon: parse lock PID metadata %q", metadata)
75+
}
76+
if _, err := strconv.ParseUint(sequenceText, 10, 64); err != nil {
77+
return 0, fmt.Errorf("daemon: parse lock holder sequence: %w", err)
78+
}
79+
pid, err := strconv.Atoi(pidText)
80+
if err != nil {
81+
return 0, fmt.Errorf("daemon: parse lock PID: %w", err)
82+
}
83+
return pid, nil
6984
}

‎internal/daemon/lock_test.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,35 @@ func TestLockReleaseKeepsStableFile(t *testing.T) {
8787
}
8888
}
8989

90+
func TestReadPidFileMetadataFormats(t *testing.T) {
91+
path := filepath.Join(t.TempDir(), "daemon.lock")
92+
for _, test := range []struct {
93+
name string
94+
metadata string
95+
wantPID int
96+
wantErr bool
97+
}{
98+
{name: "legacy PID", metadata: "4242\n", wantPID: 4242},
99+
{name: "PID and holder sequence", metadata: "4242-7\n", wantPID: 4242},
100+
{name: "missing sequence", metadata: "4242-", wantErr: true},
101+
{name: "non-numeric sequence", metadata: "4242-next", wantErr: true},
102+
{name: "extra component", metadata: "4242-7-8", wantErr: true},
103+
} {
104+
t.Run(test.name, func(t *testing.T) {
105+
if err := os.WriteFile(path, []byte(test.metadata), 0o600); err != nil {
106+
t.Fatal(err)
107+
}
108+
got, err := readPidFile(path)
109+
if (err != nil) != test.wantErr {
110+
t.Fatalf("readPidFile() error = %v, wantErr %v", err, test.wantErr)
111+
}
112+
if !test.wantErr && got != test.wantPID {
113+
t.Fatalf("readPidFile() = %d, want %d", got, test.wantPID)
114+
}
115+
})
116+
}
117+
}
118+
90119
func TestProcessAliveSelfAndDead(t *testing.T) {
91120
if !osProcessAlive(os.Getpid()) {
92121
t.Fatal("osProcessAlive(self) = false, want true")

‎internal/lockutil/lockutil.go‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"errors"
1010
"fmt"
1111
"os"
12+
"path/filepath"
13+
"strings"
1214
"sync"
1315
"sync/atomic"
1416
)
@@ -38,7 +40,27 @@ type FileLock struct {
3840
// waiting. A contended lock returns ErrLockHeld. The kernel releases a lock if
3941
// its process exits, so an old unlocked file never needs stale reclamation.
4042
func TryAcquireFileLock(path string) (*FileLock, error) {
41-
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
43+
return TryAcquireFileLockAt(filepath.Dir(path), path)
44+
}
45+
46+
// TryAcquireFileLockAt attempts to lock path while confining every component
47+
// below root to a handle-relative traversal. root is the caller's trusted
48+
// boundary and may itself be reached through a legitimate link; links and
49+
// reparse points below it are rejected before metadata can be written.
50+
func TryAcquireFileLockAt(root, path string) (*FileLock, error) {
51+
if root == "" {
52+
return nil, errors.New("lockutil: lock root is empty")
53+
}
54+
relative, err := filepath.Rel(root, path)
55+
if err != nil {
56+
return nil, fmt.Errorf("lockutil: resolve lock path relative to root: %w", err)
57+
}
58+
relative = filepath.Clean(relative)
59+
if relative == "." || relative == ".." || filepath.IsAbs(relative) || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
60+
return nil, fmt.Errorf("lockutil: lock path %q escapes root %q", path, root)
61+
}
62+
63+
file, err := openLockFileAt(root, relative, path)
4264
if err != nil {
4365
return nil, fmt.Errorf("lockutil: open lock file: %w", err)
4466
}

‎internal/lockutil/lockutil_other.go‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,78 @@ package lockutil
44

55
import (
66
"errors"
7+
"fmt"
78
"os"
9+
"path/filepath"
10+
"strings"
811

912
"golang.org/x/sys/unix"
1013
)
1114

1215
type platformLockState struct{}
1316

17+
func openLockFileAt(root, relative, displayPath string) (_ *os.File, resultErr error) {
18+
parentFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
19+
if err != nil {
20+
return nil, fmt.Errorf("open lock root: %w", err)
21+
}
22+
defer func() {
23+
if parentFD >= 0 {
24+
resultErr = errors.Join(resultErr, unix.Close(parentFD))
25+
}
26+
}()
27+
28+
components := strings.Split(filepath.Clean(relative), string(filepath.Separator))
29+
for _, component := range components[:len(components)-1] {
30+
nextFD, openErr := unix.Openat(parentFD, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
31+
if openErr != nil {
32+
return nil, fmt.Errorf("open lock path component %q: %w", component, openErr)
33+
}
34+
if closeErr := unix.Close(parentFD); closeErr != nil {
35+
_ = unix.Close(nextFD)
36+
parentFD = -1
37+
return nil, fmt.Errorf("close lock path component: %w", closeErr)
38+
}
39+
parentFD = nextFD
40+
}
41+
42+
name := components[len(components)-1]
43+
fd := -1
44+
// Darwin can transiently return ENOENT when many callers race to create the
45+
// same O_NOFOLLOW entry. The bound parent handle cannot be redirected, so a
46+
// bounded retry preserves first-use contention without weakening no-follow.
47+
for attempt := 0; attempt < 10; attempt++ {
48+
fd, err = unix.Openat(parentFD, name, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600)
49+
if !errors.Is(err, unix.ENOENT) {
50+
break
51+
}
52+
}
53+
if err != nil {
54+
return nil, err
55+
}
56+
file := os.NewFile(uintptr(fd), displayPath)
57+
if file == nil {
58+
_ = unix.Close(fd)
59+
return nil, errors.New("convert lock handle to file")
60+
}
61+
var stat unix.Stat_t
62+
if err := unix.Fstat(fd, &stat); err != nil {
63+
return nil, errors.Join(fmt.Errorf("inspect lock file: %w", err), file.Close())
64+
}
65+
if stat.Mode&unix.S_IFMT != unix.S_IFREG {
66+
return nil, errors.Join(errors.New("refusing non-regular lock file"), file.Close())
67+
}
68+
if stat.Nlink != 1 {
69+
return nil, errors.Join(errors.New("refusing multiply-linked lock file"), file.Close())
70+
}
71+
if err := unix.Close(parentFD); err != nil {
72+
parentFD = -1
73+
return nil, errors.Join(fmt.Errorf("close lock parent: %w", err), file.Close())
74+
}
75+
parentFD = -1
76+
return file, nil
77+
}
78+
1479
func tryLockFile(file *os.File) (platformLockState, bool, error) {
1580
err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
1681
if err == nil {

‎internal/lockutil/lockutil_test.go‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"path/filepath"
1111
"runtime"
1212
"strings"
13+
"sync"
1314
"testing"
1415
)
1516

@@ -77,6 +78,77 @@ func TestFileLockMetadata(t *testing.T) {
7778
}
7879
}
7980

81+
func TestFileLockConcurrentFirstAcquisition(t *testing.T) {
82+
path := filepath.Join(t.TempDir(), "lock")
83+
const contenders = 200
84+
start := make(chan struct{})
85+
results := make(chan error, contenders)
86+
locks := make(chan *FileLock, contenders)
87+
var ready sync.WaitGroup
88+
ready.Add(contenders)
89+
for range contenders {
90+
go func() {
91+
ready.Done()
92+
<-start
93+
lock, err := TryAcquireFileLock(path)
94+
if lock != nil {
95+
locks <- lock
96+
}
97+
results <- err
98+
}()
99+
}
100+
ready.Wait()
101+
close(start)
102+
for range contenders {
103+
err := <-results
104+
if err != nil && !errors.Is(err, ErrLockHeld) {
105+
t.Errorf("concurrent first acquire: %v", err)
106+
}
107+
}
108+
close(locks)
109+
for lock := range locks {
110+
if err := lock.Release(); err != nil {
111+
t.Error(err)
112+
}
113+
}
114+
}
115+
116+
func TestFileLockRefusesMultiplyLinkedEntry(t *testing.T) {
117+
dir := t.TempDir()
118+
target := filepath.Join(dir, "target")
119+
lockPath := filepath.Join(dir, "lock")
120+
const sentinel = "do not overwrite"
121+
if err := os.WriteFile(target, []byte(sentinel), 0o600); err != nil {
122+
t.Fatal(err)
123+
}
124+
if err := os.Link(target, lockPath); err != nil {
125+
t.Fatalf("create redirected hard link: %v", err)
126+
}
127+
if lock, err := TryAcquireFileLock(lockPath); err == nil {
128+
_ = lock.Release()
129+
t.Fatal("TryAcquireFileLock accepted a multiply-linked entry")
130+
}
131+
data, err := os.ReadFile(target)
132+
if err != nil {
133+
t.Fatal(err)
134+
}
135+
if string(data) != sentinel {
136+
t.Fatalf("redirected target was modified: %q", data)
137+
}
138+
}
139+
140+
func TestFileLockAtRejectsEscapingPath(t *testing.T) {
141+
base := t.TempDir()
142+
outside := filepath.Join(filepath.Dir(base), "outside.lock")
143+
if lock, err := TryAcquireFileLockAt(base, outside); err == nil {
144+
_ = lock.Release()
145+
t.Fatal("TryAcquireFileLockAt accepted a path outside its root")
146+
}
147+
if _, err := os.Stat(outside); !errors.Is(err, os.ErrNotExist) {
148+
t.Fatalf("escaping path was created: %v", err)
149+
}
150+
}
151+
80152
// TestFileLockReleasedByProcessExit proves crash recovery is provided by the
81153
// kernel lock lifetime. The child exits without calling Release; the next
82154
// process can then lock the same persistent file without moving or deleting it.

0 commit comments

Comments
 (0)