Skip to content

Commit 0259f64

Browse files
committed
fix(artifact): confine folder exchange locking
Folder targets may be writable by other participants, so opening the coordination file by absolute path lets a substituted symlink escape the marked namespace. Bind locking to a root-opened, identity-checked descriptor while preserving native Unix and Windows lock semantics.\n\nExercise corruption through journaled publication so the end-to-end quarantine coverage follows the transport contract.
1 parent 7e417a7 commit 0259f64

7 files changed

Lines changed: 252 additions & 45 deletions

internal/artifact/transport_folder.go

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,6 @@ import (
1313
"path/filepath"
1414
"strings"
1515
"sync"
16-
"time"
17-
18-
"github.com/gofrs/flock"
1916
)
2017

2118
const (
@@ -188,28 +185,6 @@ func (t *folderTransport) Exchange(
188185
return result, nil
189186
}
190187

191-
func (t *folderTransport) acquireExchangeLockLocked(
192-
ctx context.Context,
193-
) (*flock.Flock, error) {
194-
lock := flock.New(filepath.Join(t.target, folderExchangeLockName))
195-
locked, err := lock.TryLockContext(ctx, 100*time.Millisecond)
196-
if err != nil {
197-
_ = lock.Close()
198-
if ctxErr := ctx.Err(); ctxErr != nil {
199-
return nil, ctxErr
200-
}
201-
return nil, fmt.Errorf("acquiring artifact folder exchange lock: %w", err)
202-
}
203-
if !locked {
204-
_ = lock.Close()
205-
if ctxErr := ctx.Err(); ctxErr != nil {
206-
return nil, ctxErr
207-
}
208-
return nil, errors.New("artifact folder exchange is already running")
209-
}
210-
return lock, nil
211-
}
212-
213188
func (t *folderTransport) Close() error {
214189
if t == nil {
215190
return nil
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package artifact
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io/fs"
8+
"os"
9+
"time"
10+
)
11+
12+
const folderExchangeLockRetryDelay = 100 * time.Millisecond
13+
14+
type folderExchangeLock struct {
15+
file *os.File
16+
}
17+
18+
func (t *folderTransport) acquireExchangeLockLocked(
19+
ctx context.Context,
20+
) (*folderExchangeLock, error) {
21+
file, err := openFolderExchangeLockFile(t.root)
22+
if err != nil {
23+
return nil, fmt.Errorf("acquiring artifact folder exchange lock: %w", err)
24+
}
25+
for {
26+
locked, lockErr := tryLockFolderFile(file)
27+
if lockErr != nil {
28+
return nil, errors.Join(
29+
fmt.Errorf("acquiring artifact folder exchange lock: %w", lockErr),
30+
file.Close(),
31+
)
32+
}
33+
if locked {
34+
return &folderExchangeLock{file: file}, nil
35+
}
36+
37+
timer := time.NewTimer(folderExchangeLockRetryDelay)
38+
select {
39+
case <-ctx.Done():
40+
timer.Stop()
41+
return nil, errors.Join(ctx.Err(), file.Close())
42+
case <-timer.C:
43+
}
44+
}
45+
}
46+
47+
func openFolderExchangeLockFile(root *os.Root) (*os.File, error) {
48+
for range 8 {
49+
before, err := root.Lstat(folderExchangeLockName)
50+
if err != nil && !errors.Is(err, fs.ErrNotExist) {
51+
return nil, err
52+
}
53+
if err == nil && !before.Mode().IsRegular() {
54+
return nil, errors.New("artifact folder exchange lock is not a regular file")
55+
}
56+
57+
flags := os.O_RDWR
58+
if errors.Is(err, fs.ErrNotExist) {
59+
before = nil
60+
flags |= os.O_CREATE | os.O_EXCL
61+
}
62+
file, openErr := root.OpenFile(folderExchangeLockName, flags, 0o600)
63+
if errors.Is(openErr, fs.ErrExist) || errors.Is(openErr, fs.ErrNotExist) {
64+
continue
65+
}
66+
if openErr != nil {
67+
return nil, openErr
68+
}
69+
70+
opened, statErr := file.Stat()
71+
if statErr != nil {
72+
return nil, errors.Join(statErr, file.Close())
73+
}
74+
after, statErr := root.Lstat(folderExchangeLockName)
75+
if statErr != nil {
76+
return nil, errors.Join(statErr, file.Close())
77+
}
78+
if !opened.Mode().IsRegular() ||
79+
!after.Mode().IsRegular() ||
80+
(before != nil && !os.SameFile(before, opened)) ||
81+
!os.SameFile(opened, after) {
82+
return nil, errors.Join(
83+
errors.New("artifact folder exchange lock changed while opening"),
84+
file.Close(),
85+
)
86+
}
87+
return file, nil
88+
}
89+
return nil, errors.New("artifact folder exchange lock changed while opening")
90+
}
91+
92+
func (l *folderExchangeLock) Close() error {
93+
if l == nil || l.file == nil {
94+
return nil
95+
}
96+
file := l.file
97+
l.file = nil
98+
return errors.Join(unlockFolderFile(file), file.Close())
99+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//go:build !darwin && !dragonfly && !freebsd && !illumos && !linux && !netbsd && !openbsd && !windows
2+
3+
package artifact
4+
5+
import (
6+
"errors"
7+
"os"
8+
)
9+
10+
func tryLockFolderFile(*os.File) (bool, error) {
11+
return false, errors.ErrUnsupported
12+
}
13+
14+
func unlockFolderFile(*os.File) error {
15+
return errors.ErrUnsupported
16+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//go:build darwin || dragonfly || freebsd || illumos || linux || netbsd || openbsd
2+
3+
package artifact
4+
5+
import (
6+
"errors"
7+
"os"
8+
9+
"golang.org/x/sys/unix"
10+
)
11+
12+
func tryLockFolderFile(file *os.File) (bool, error) {
13+
err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
14+
switch {
15+
case errors.Is(err, unix.EWOULDBLOCK), errors.Is(err, unix.EAGAIN):
16+
return false, nil
17+
case err != nil:
18+
return false, err
19+
default:
20+
return true, nil
21+
}
22+
}
23+
24+
func unlockFolderFile(file *os.File) error {
25+
return unix.Flock(int(file.Fd()), unix.LOCK_UN)
26+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//go:build windows
2+
3+
package artifact
4+
5+
import (
6+
"errors"
7+
"os"
8+
9+
"golang.org/x/sys/windows"
10+
)
11+
12+
func tryLockFolderFile(file *os.File) (bool, error) {
13+
err := windows.LockFileEx(
14+
windows.Handle(file.Fd()),
15+
windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY,
16+
0,
17+
1,
18+
0,
19+
&windows.Overlapped{},
20+
)
21+
switch {
22+
case errors.Is(err, windows.ERROR_LOCK_VIOLATION),
23+
errors.Is(err, windows.ERROR_IO_PENDING):
24+
return false, nil
25+
case err != nil && !errors.Is(err, windows.Errno(0)):
26+
return false, err
27+
default:
28+
return true, nil
29+
}
30+
}
31+
32+
func unlockFolderFile(file *os.File) error {
33+
err := windows.UnlockFileEx(
34+
windows.Handle(file.Fd()),
35+
0,
36+
1,
37+
0,
38+
&windows.Overlapped{},
39+
)
40+
if errors.Is(err, windows.Errno(0)) {
41+
return nil
42+
}
43+
return err
44+
}

internal/artifact/transport_folder_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -999,6 +999,30 @@ func TestFolderTransportExchangeLockProtectsActivePublishTemp(t *testing.T) {
999999
assert.FileExists(t, active)
10001000
}
10011001

1002+
func TestFolderTransportExchangeLockRejectsSymlinkEscape(t *testing.T) {
1003+
t.Parallel()
1004+
1005+
target := t.TempDir()
1006+
transport, err := OpenFolderTransport(target, FolderTransportOptions{})
1007+
require.NoError(t, err)
1008+
t.Cleanup(func() { require.NoError(t, transport.Close()) })
1009+
1010+
escaped := filepath.Join(t.TempDir(), "escaped.lock")
1011+
lockPath := filepath.Join(target, folderExchangeLockName)
1012+
if err := os.Symlink(escaped, lockPath); err != nil {
1013+
t.Skipf("creating lock symlink: %v", err)
1014+
}
1015+
1016+
_, err = transport.Exchange(
1017+
t.Context(),
1018+
&transportRecordingStore{ArtifactStore: newTestArtifactStore(t)},
1019+
testFolderPublishOrigin,
1020+
)
1021+
require.Error(t, err)
1022+
assert.ErrorContains(t, err, "exchange lock is not a regular file")
1023+
assert.NoFileExists(t, escaped)
1024+
}
1025+
10021026
func TestFolderTransportRejectsOversizedStoreEntryBeforePublication(
10031027
t *testing.T,
10041028
) {

internal/e2e/artifact_sync_test.go

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package e2e_test
55
import (
66
"bytes"
77
"context"
8+
"crypto/sha256"
9+
"encoding/hex"
810
"os"
911
"path/filepath"
1012
"testing"
@@ -110,7 +112,7 @@ func TestArtifactSyncTwoInstanceFolder(t *testing.T) {
110112
3,
111113
)
112114

113-
writeCompleteCorruptCheckpoint(t, target)
115+
publishCompleteCorruptCheckpoint(t, target)
114116
afterCorruptionA := syncArtifactNode(t, nodeA, target)
115117
assert.Equal(t, 1, afterCorruptionA.Quarantined)
116118
afterCorruptionB := syncArtifactNode(t, nodeB, target)
@@ -268,39 +270,60 @@ func writeArtifactSyncSource(
268270
require.NoError(t, os.WriteFile(path, content, 0o600))
269271
}
270272

271-
func writeCompleteCorruptCheckpoint(
273+
func publishCompleteCorruptCheckpoint(
272274
t *testing.T,
273275
target string,
274276
) {
275277
t.Helper()
276278
origin := "broken-a7b8c9"
279+
body := []byte(`{"v":1}`)
277280
ref, err := artifact.NewRef(
278281
origin,
279282
artifact.KindCheckpoints,
280283
"cp-0000000001.json",
281284
)
282285
require.NoError(t, err)
283-
wire, err := artifact.ToWireRef(ref)
286+
sum := sha256.Sum256(body)
287+
identity, err := artifact.NewIdentity(
288+
hex.EncodeToString(sum[:]),
289+
int64(len(body)),
290+
)
284291
require.NoError(t, err)
285-
directory := filepath.Join(
286-
target,
287-
origin,
288-
string(artifact.KindCheckpoints),
292+
repository, err := artifact.OpenRepository(t.Context(), t.TempDir())
293+
require.NoError(t, err)
294+
t.Cleanup(func() { require.NoError(t, repository.Close()) })
295+
_, err = repository.Content().Create(
296+
t.Context(),
297+
ref,
298+
identity,
299+
"application/json",
300+
bytes.NewReader(body),
289301
)
290-
require.NoError(t, os.MkdirAll(directory, 0o755))
291-
file, err := os.OpenFile(
292-
filepath.Join(directory, wire.Name),
293-
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
294-
0o600,
302+
require.NoError(t, err)
303+
304+
transport, err := artifact.OpenFolderTransport(
305+
target,
306+
artifact.FolderTransportOptions{},
295307
)
296308
require.NoError(t, err)
297-
encodeErr := artifact.EncodeWire(
298-
context.Background(),
299-
ref,
300-
bytes.NewBufferString(`{"v":1}`),
301-
file,
309+
t.Cleanup(func() { require.NoError(t, transport.Close()) })
310+
result, err := transport.Exchange(
311+
t.Context(),
312+
artifactSyncTransportStore{ArtifactStore: repository.Content()},
313+
origin,
302314
)
303-
closeErr := file.Close()
304-
require.NoError(t, encodeErr)
305-
require.NoError(t, closeErr)
315+
require.NoError(t, err)
316+
assert.Equal(t, 1, result.Published)
317+
assert.False(t, result.More)
318+
}
319+
320+
type artifactSyncTransportStore struct {
321+
artifact.ArtifactStore
322+
}
323+
324+
func (artifactSyncTransportStore) RecordTransportChanged(
325+
context.Context,
326+
artifact.Entry,
327+
) error {
328+
return nil
306329
}

0 commit comments

Comments
 (0)