fix(windows): serialize claim operations

This commit is contained in:
2026-08-18 15:05:04 +02:00
parent b261dd4d3a
commit b48e9e9189
2 changed files with 77 additions and 0 deletions
@@ -4,10 +4,16 @@ package safeio
import ( import (
"path/filepath" "path/filepath"
"sync"
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
) )
// Windows retained directory handles intentionally omit FILE_SHARE_DELETE. Serialize the
// complete path-based claim operations within this process so one operation cannot exhaust
// another's bounded external-contention retry while its validated parent handle is retained.
var windowsPrivateClaimOperationMu sync.Mutex
type windowsPrivateRegular struct { type windowsPrivateRegular struct {
parents *windowsParentHandles parents *windowsParentHandles
handle windows.Handle handle windows.Handle
@@ -48,6 +54,8 @@ func openWindowsPrivateRegular(path string, links uint32) (*windowsPrivateRegula
} }
func claimCanonicalPrivateRegular(source, claim string) (claimed bool, resultErr error) { func claimCanonicalPrivateRegular(source, claim string) (claimed bool, resultErr error) {
windowsPrivateClaimOperationMu.Lock()
defer windowsPrivateClaimOperationMu.Unlock()
directory, sourceName, claimName, err := openWindowsPrivateClaimDirectory(source, claim) directory, sourceName, claimName, err := openWindowsPrivateClaimDirectory(source, claim)
if err != nil { if err != nil {
return false, ErrUnsafeFile return false, ErrUnsafeFile
@@ -62,6 +70,8 @@ func claimCanonicalPrivateRegular(source, claim string) (claimed bool, resultErr
} }
func readCanonicalPrivateClaim(source, claim string, maximum int64) (contents []byte, found bool, resultErr error) { func readCanonicalPrivateClaim(source, claim string, maximum int64) (contents []byte, found bool, resultErr error) {
windowsPrivateClaimOperationMu.Lock()
defer windowsPrivateClaimOperationMu.Unlock()
directory, sourceName, claimName, err := openWindowsPrivateClaimDirectory(source, claim) directory, sourceName, claimName, err := openWindowsPrivateClaimDirectory(source, claim)
if err != nil { if err != nil {
return nil, false, ErrUnsafeFile return nil, false, ErrUnsafeFile
@@ -94,6 +104,8 @@ func openWindowsPrivateClaimDirectory(source, claim string) (PrivateDirectoryHan
} }
func removeCanonicalPrivateClaim(source, claim string) (removed bool, resultErr error) { func removeCanonicalPrivateClaim(source, claim string) (removed bool, resultErr error) {
windowsPrivateClaimOperationMu.Lock()
defer windowsPrivateClaimOperationMu.Unlock()
directory, sourceName, claimName, err := openWindowsPrivateClaimDirectory(source, claim) directory, sourceName, claimName, err := openWindowsPrivateClaimDirectory(source, claim)
if err != nil { if err != nil {
return false, ErrUnsafeFile return false, ErrUnsafeFile
@@ -9,6 +9,7 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"testing" "testing"
"time"
) )
func createWindowsPrivateTestFile(t *testing.T, path string, contents []byte) { func createWindowsPrivateTestFile(t *testing.T, path string, contents []byte) {
@@ -100,6 +101,70 @@ func TestRemoveCanonicalPrivateClaimPreservesOrphan(t *testing.T) {
} }
} }
func TestCanonicalPrivateClaimWaitsForRetainedRemoveOperation(t *testing.T) {
parent := filepath.Join(t.TempDir(), "claims")
if err := os.Mkdir(parent, 0o700); err != nil {
t.Fatal(err)
}
if err := ProtectPrivateDirectory(parent); err != nil {
t.Fatal(err)
}
source := filepath.Join(parent, "state.json")
claim := filepath.Join(parent, "state.claim")
createWindowsPrivateTestFile(t, source, []byte("state"))
if claimed, err := ClaimCanonicalPrivateRegular(source, claim); err != nil || !claimed {
t.Fatalf("ClaimCanonicalPrivateRegular() = claimed %v, err %v", claimed, err)
}
removeOpened := make(chan struct{})
releaseRemove := make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(releaseRemove) }) }
defer release()
restoreHook := SetPrivateDirectoryTestHookForTest(func(stage string) {
if stage != "after-canonical-private-claim-parent-open" {
return
}
select {
case <-removeOpened:
default:
close(removeOpened)
}
<-releaseRemove
})
defer restoreHook()
type result struct {
changed bool
err error
}
removeResult := make(chan result, 1)
go func() {
removed, err := RemoveCanonicalPrivateClaim(source, claim)
removeResult <- result{changed: removed, err: err}
}()
<-removeOpened
claimResult := make(chan result, 1)
go func() {
claimed, err := ClaimCanonicalPrivateRegular(source, claim)
claimResult <- result{changed: claimed, err: err}
}()
select {
case got := <-claimResult:
t.Fatalf("concurrent claim returned before retained removal completed: claimed %v, err %v", got.changed, got.err)
case <-time.After(250 * time.Millisecond):
}
release()
if got := <-removeResult; got.err != nil || !got.changed {
t.Fatalf("RemoveCanonicalPrivateClaim() = removed %v, err %v", got.changed, got.err)
}
if got := <-claimResult; got.err != nil || got.changed {
t.Fatalf("concurrent ClaimCanonicalPrivateRegular() = claimed %v, err %v, want false/nil", got.changed, got.err)
}
}
func TestRemoveCanonicalPrivateClaimRejectsMismatchedTwoLinkFiles(t *testing.T) { func TestRemoveCanonicalPrivateClaimRejectsMismatchedTwoLinkFiles(t *testing.T) {
parent := filepath.Join(t.TempDir(), "claims") parent := filepath.Join(t.TempDir(), "claims")
if err := os.Mkdir(parent, 0o700); err != nil { if err := os.Mkdir(parent, 0o700); err != nil {