diff --git a/tools/tht/internal/authconfig/projection_lock_linux.go b/tools/tht/internal/authconfig/projection_lock_linux.go new file mode 100644 index 00000000..b6df3988 --- /dev/null +++ b/tools/tht/internal/authconfig/projection_lock_linux.go @@ -0,0 +1,177 @@ +//go:build linux + +package authconfig + +import ( + "context" + "errors" + "os" + "sync" + "time" + + "golang.org/x/sys/unix" +) + +type transactionLock struct { + directoryFD int + fileFD int + closed bool +} + +type projectionLockHooks struct { + beforeCreate func() + afterOpen func() +} + +var projectionLockHookState struct { + sync.Mutex + hooks projectionLockHooks +} + +func setProjectionLockHooksForTest(hooks projectionLockHooks) func() { + projectionLockHookState.Lock() + previous := projectionLockHookState.hooks + projectionLockHookState.hooks = hooks + projectionLockHookState.Unlock() + return func() { + projectionLockHookState.Lock() + projectionLockHookState.hooks = previous + projectionLockHookState.Unlock() + } +} + +func projectionLockHooksForTest() projectionLockHooks { + projectionLockHookState.Lock() + defer projectionLockHookState.Unlock() + return projectionLockHookState.hooks +} + +func acquireTransactionLock(ctx context.Context, directory string) (*transactionLock, error) { + if ctx == nil || ctx.Err() != nil || requirePrivateDirectory(directory) != nil { + return nil, errProjectionIntegrity + } + directoryFD, err := unix.Open(directory, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, errProjectionIntegrity + } + lock := &transactionLock{directoryFD: directoryFD, fileFD: -1} + fail := func() (*transactionLock, error) { + _ = lock.Close() + return nil, errProjectionIntegrity + } + if !validTransactionDirectory(directoryFD) { + return fail() + } + fileFD, err := openTransactionLockFile(directoryFD) + if err != nil { + return fail() + } + lock.fileFD = fileFD + if hook := projectionLockHooksForTest().afterOpen; hook != nil { + hook() + } + var opened unix.Stat_t + if unix.Fstat(fileFD, &opened) != nil || !validTransactionLockFile(opened) || !sameTransactionLockName(directoryFD, opened) { + return fail() + } + if err := flockTransactionFile(ctx, fileFD); err != nil { + _ = lock.Close() + return nil, err + } + if !sameTransactionLockName(directoryFD, opened) { + return fail() + } + return lock, nil +} + +func openTransactionLockFile(directoryFD int) (int, error) { + for attempt := 0; attempt < 2; attempt++ { + fd, err := unix.Openat(directoryFD, transactionLockFileName, unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err == nil { + return fd, nil + } + if !errors.Is(err, unix.ENOENT) { + return -1, errProjectionIntegrity + } + if hook := projectionLockHooksForTest().beforeCreate; hook != nil { + hook() + } + fd, err = unix.Openat(directoryFD, transactionLockFileName, unix.O_RDWR|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o600) + if err == nil { + if unix.Fchmod(fd, 0o600) != nil || unix.Fsync(fd) != nil || unix.Fsync(directoryFD) != nil { + unix.Close(fd) + return -1, errProjectionIntegrity + } + return fd, nil + } + if !errors.Is(err, unix.EEXIST) { + return -1, errProjectionIntegrity + } + } + return -1, errProjectionIntegrity +} + +func validTransactionDirectory(fd int) bool { + var stat unix.Stat_t + return unix.Fstat(fd, &stat) == nil && stat.Mode&unix.S_IFMT == unix.S_IFDIR && stat.Uid == uint32(os.Geteuid()) && stat.Mode&0o077 == 0 +} + +func validTransactionLockFile(stat unix.Stat_t) bool { + return stat.Mode&unix.S_IFMT == unix.S_IFREG && stat.Nlink == 1 && stat.Uid == uint32(os.Geteuid()) && stat.Mode&0o7777 == 0o600 +} + +func sameTransactionLockName(directoryFD int, opened unix.Stat_t) bool { + var named unix.Stat_t + return unix.Fstatat(directoryFD, transactionLockFileName, &named, unix.AT_SYMLINK_NOFOLLOW) == nil && validTransactionLockFile(named) && named.Dev == opened.Dev && named.Ino == opened.Ino +} + +func flockTransactionFile(ctx context.Context, fd int) error { + for { + if err := ctx.Err(); err != nil { + return err + } + err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB) + if err == nil { + return nil + } + if !errors.Is(err, unix.EWOULDBLOCK) { + return errProjectionIntegrity + } + if hook := coordinatorHooks().onOuterLockContention; hook != nil { + hook() + } + timer := time.NewTimer(10 * time.Millisecond) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return ctx.Err() + case <-timer.C: + } + } +} + +func (lock *transactionLock) Close() error { + if lock == nil || lock.closed { + return nil + } + lock.closed = true + var err error + if lock.fileFD >= 0 { + if unlockErr := unix.Flock(lock.fileFD, unix.LOCK_UN); unlockErr != nil { + err = errors.Join(err, errProjectionCleanup) + } + if closeErr := unix.Close(lock.fileFD); closeErr != nil { + err = errors.Join(err, errProjectionCleanup) + } + lock.fileFD = -1 + } + if lock.directoryFD >= 0 { + if closeErr := unix.Close(lock.directoryFD); closeErr != nil { + err = errors.Join(err, errProjectionCleanup) + } + lock.directoryFD = -1 + } + return err +} diff --git a/tools/tht/internal/authconfig/projection_lock_unsupported.go b/tools/tht/internal/authconfig/projection_lock_unsupported.go new file mode 100644 index 00000000..bb7c12ed --- /dev/null +++ b/tools/tht/internal/authconfig/projection_lock_unsupported.go @@ -0,0 +1,13 @@ +//go:build !linux + +package authconfig + +import "context" + +type transactionLock struct{} + +func acquireTransactionLock(context.Context, string) (*transactionLock, error) { + return nil, errProjectionIntegrity +} + +func (*transactionLock) Close() error { return nil } diff --git a/tools/tht/internal/authconfig/projection_transaction.go b/tools/tht/internal/authconfig/projection_transaction.go new file mode 100644 index 00000000..631b4f58 --- /dev/null +++ b/tools/tht/internal/authconfig/projection_transaction.go @@ -0,0 +1,349 @@ +package authconfig + +import ( + "context" + "errors" + "sync" + + "github.com/aritmolab/thothii/tools/tht/internal/authprojection" +) + +const transactionLockFileName = ".auth-transaction.lock" + +var ( + errProjectionMutation = errors.New("authentication projection mutation failed") + errProjectionPublish = errors.New("authentication projection publication failed") + errProjectionCleanup = errors.New("authentication projection cleanup failed") + errProjectionIntegrity = errors.New("authentication projection coordination failed") +) + +var runtimeProjectionBegin = authprojection.BeginContext + +func setRuntimeProjectionBeginForTest(begin func(context.Context, authprojection.Spec, *authprojection.Snapshot, bool) (*authprojection.Transaction, error)) func() { + previous := runtimeProjectionBegin + runtimeProjectionBegin = begin + return func() { runtimeProjectionBegin = previous } +} + +type ProjectionSpec struct { + RuntimeRoot string + UID uint32 + GID uint32 +} + +type ProjectionStatus struct { + State string + Generation string + CanonicalRevision string + Equal bool +} + +type ExternalProjectionTransaction struct { + ctx context.Context + canonicalRoot string + spec authprojection.Spec + outerLock *transactionLock + projection *authprojection.Transaction + before authprojection.Snapshot + hasBefore bool + published bool + closed bool +} + +type projectionCoordinatorHooks struct { + onOuterLockContention func() + onCanonicalLockContention func() + beforeProjectionBegin func() + verifyCommittedProjection func(authprojection.Status, authprojection.Snapshot) error +} + +var projectionCoordinatorHookState struct { + sync.Mutex + hooks projectionCoordinatorHooks +} + +func setProjectionCoordinatorHooksForTest(hooks projectionCoordinatorHooks) func() { + projectionCoordinatorHookState.Lock() + previous := projectionCoordinatorHookState.hooks + projectionCoordinatorHookState.hooks = hooks + projectionCoordinatorHookState.Unlock() + return func() { + projectionCoordinatorHookState.Lock() + projectionCoordinatorHookState.hooks = previous + projectionCoordinatorHookState.Unlock() + } +} + +func coordinatorHooks() projectionCoordinatorHooks { + projectionCoordinatorHookState.Lock() + defer projectionCoordinatorHookState.Unlock() + return projectionCoordinatorHookState.hooks +} + +func RunProjectedMutation(ctx context.Context, canonicalRoot string, spec ProjectionSpec, mutate func() error) (err error) { + if mutate == nil { + return errProjectionMutation + } + transaction, err := BeginExternalProjectionTransaction(ctx, canonicalRoot, spec) + if err != nil { + return errProjectionIntegrity + } + defer func() { + if closeErr := transaction.Close(); closeErr != nil { + if err == nil { + err = errProjectionCleanup + } else { + err = errors.Join(err, errProjectionCleanup) + } + } + }() + if mutateErr := mutate(); mutateErr != nil { + if restoreErr := transaction.restorePriorIfCanonicalUnchanged(ctx); restoreErr != nil { + return errors.Join(errProjectionMutation, errProjectionCleanup) + } + return errProjectionMutation + } + if _, publishErr := transaction.publishCanonicalContext(ctx); publishErr != nil { + return errProjectionPublish + } + return nil +} + +func PublishProjectedCanonical(ctx context.Context, canonicalRoot string, spec ProjectionSpec) (status ProjectionStatus, err error) { + transaction, err := BeginExternalProjectionTransaction(ctx, canonicalRoot, spec) + if err != nil { + return ProjectionStatus{}, errProjectionIntegrity + } + defer func() { + if closeErr := transaction.Close(); closeErr != nil { + if err == nil { + err = errProjectionCleanup + } else { + err = errors.Join(err, errProjectionCleanup) + } + } + }() + return transaction.publishCanonicalContext(ctx) +} + +func BeginExternalProjectionTransaction(ctx context.Context, canonicalRoot string, spec ProjectionSpec) (transaction *ExternalProjectionTransaction, err error) { + if ctx == nil { + return nil, errProjectionIntegrity + } + if err := requirePrivateDirectory(canonicalRoot); err != nil { + return nil, errProjectionIntegrity + } + outerLock, err := acquireTransactionLock(ctx, canonicalRoot) + if err != nil { + return nil, errProjectionIntegrity + } + transaction = &ExternalProjectionTransaction{ + ctx: ctx, + canonicalRoot: canonicalRoot, + spec: authprojection.Spec{RuntimeRoot: spec.RuntimeRoot, UID: spec.UID, GID: spec.GID}, + outerLock: outerLock, + } + created := transaction + defer func() { + if err != nil { + if closeErr := created.closeProjectionAndOuterLock(); closeErr != nil { + err = errors.Join(errProjectionIntegrity, errProjectionCleanup) + } else { + err = errProjectionIntegrity + } + } + }() + if err = transaction.beginProjection(ctx); err != nil { + return nil, err + } + return transaction, nil +} + +func (transaction *ExternalProjectionTransaction) PublishCanonical() (ProjectionStatus, error) { + if transaction == nil { + return ProjectionStatus{}, errProjectionIntegrity + } + return transaction.publishCanonicalContext(transaction.ctx) +} + +func (transaction *ExternalProjectionTransaction) publishCanonicalContext(ctx context.Context) (ProjectionStatus, error) { + if transaction == nil || transaction.closed { + return ProjectionStatus{}, errProjectionIntegrity + } + if transaction.projection == nil { + if err := transaction.beginProjection(ctx); err != nil { + return ProjectionStatus{}, errProjectionIntegrity + } + } + var published authprojection.Status + var expected authprojection.Snapshot + committed := false + err := transaction.withCanonicalLock(ctx, func() error { + snapshot, err := loadSnapshotBytes(transaction.canonicalRoot) + if err != nil { + return err + } + expected = snapshot + published, err = transaction.projection.Commit(snapshot) + if err != nil { + return err + } + committed = true + if !equalProjection(published, snapshot) { + return errProjectionIntegrity + } + if verify := coordinatorHooks().verifyCommittedProjection; verify != nil { + return verify(published, snapshot) + } + return nil + }) + if err != nil { + if committed { + if blockErr := transaction.ensureBlocked(); blockErr != nil { + return ProjectionStatus{}, errors.Join(errProjectionPublish, errProjectionCleanup) + } + } + return ProjectionStatus{}, errProjectionPublish + } + transaction.published = true + if err := transaction.releaseProjection(); err != nil { + if blockErr := transaction.ensureBlocked(); blockErr != nil { + return ProjectionStatus{}, errors.Join(errProjectionCleanup, errProjectionIntegrity) + } + return ProjectionStatus{}, errProjectionCleanup + } + return projectionStatus(published, expected), nil +} + +func (transaction *ExternalProjectionTransaction) RestorePriorIfCanonicalUnchanged() error { + if transaction == nil { + return errProjectionIntegrity + } + return transaction.restorePriorIfCanonicalUnchanged(transaction.ctx) +} + +func (transaction *ExternalProjectionTransaction) restorePriorIfCanonicalUnchanged(ctx context.Context) error { + if transaction == nil || transaction.closed || transaction.projection == nil || !transaction.hasBefore { + return errProjectionIntegrity + } + err := transaction.withCanonicalLock(ctx, func() error { + current, err := loadSnapshotBytes(transaction.canonicalRoot) + if err != nil { + return err + } + if current.Generation != transaction.before.Generation { + return errProjectionIntegrity + } + return transaction.projection.RestoreIfUnchanged(current) + }) + if err != nil { + return errProjectionIntegrity + } + return transaction.releaseProjection() +} + +func (transaction *ExternalProjectionTransaction) Close() (err error) { + if transaction == nil || transaction.closed { + return nil + } + defer func() { + transaction.closed = true + err = errors.Join(err, transaction.closeProjectionAndOuterLock()) + }() + if transaction.projection == nil && transaction.published { + if beginErr := transaction.beginProjection(context.Background()); beginErr != nil { + return errProjectionIntegrity + } + if restoreErr := transaction.restorePriorIfCanonicalUnchanged(context.Background()); restoreErr != nil { + return errProjectionIntegrity + } + } + return nil +} + +func (transaction *ExternalProjectionTransaction) beginProjection(ctx context.Context) error { + status, err := authprojection.Inspect(transaction.spec) + var before *authprojection.Snapshot + requireReadyMatch := false + if err == nil { + captured := status.Snapshot + before = &captured + requireReadyMatch = true + } + if hook := coordinatorHooks().beforeProjectionBegin; hook != nil { + hook() + } + projection, err := runtimeProjectionBegin(ctx, transaction.spec, before, requireReadyMatch) + if err != nil { + return errProjectionIntegrity + } + transaction.projection = projection + transaction.hasBefore = before != nil + if before != nil { + transaction.before = *before + } else { + transaction.before = authprojection.Snapshot{} + } + return nil +} + +func (transaction *ExternalProjectionTransaction) ensureBlocked() error { + if transaction.projection != nil { + if err := transaction.releaseProjection(); err != nil { + return err + } + } + return transaction.beginProjection(context.Background()) +} + +func (transaction *ExternalProjectionTransaction) releaseProjection() error { + if transaction.projection == nil { + return nil + } + projection := transaction.projection + transaction.projection = nil + if err := projection.Close(); err != nil { + return errProjectionIntegrity + } + return nil +} + +func (transaction *ExternalProjectionTransaction) closeProjectionAndOuterLock() error { + var err error + if closeErr := transaction.releaseProjection(); closeErr != nil { + err = errors.Join(err, closeErr) + } + if transaction.outerLock != nil { + if unlockErr := transaction.outerLock.Close(); unlockErr != nil { + err = errors.Join(err, errProjectionCleanup) + } + transaction.outerLock = nil + } + return err +} + +func (transaction *ExternalProjectionTransaction) withCanonicalLock(ctx context.Context, callback func() error) (err error) { + lock, err := acquireLockContext(ctx, transaction.canonicalRoot) + if err != nil { + return errProjectionIntegrity + } + defer func() { + if unlockErr := lock.Unlock(); unlockErr != nil { + err = errors.Join(err, errProjectionCleanup) + } + }() + return callback() +} + +func equalProjection(status authprojection.Status, snapshot authprojection.Snapshot) bool { + return status.Selector.State == "ready" && status.Snapshot.Generation == snapshot.Generation && status.Snapshot.CanonicalRevision == snapshot.CanonicalRevision +} + +func projectionStatus(status authprojection.Status, expected authprojection.Snapshot) ProjectionStatus { + return ProjectionStatus{ + State: status.Selector.State, + Generation: status.Snapshot.Generation, + CanonicalRevision: status.Snapshot.CanonicalRevision, + Equal: equalProjection(status, expected), + } +} diff --git a/tools/tht/internal/authconfig/projection_transaction_linux_test.go b/tools/tht/internal/authconfig/projection_transaction_linux_test.go new file mode 100644 index 00000000..13008679 --- /dev/null +++ b/tools/tht/internal/authconfig/projection_transaction_linux_test.go @@ -0,0 +1,300 @@ +//go:build linux + +package authconfig + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/aritmolab/thothii/tools/tht/internal/authprojection" + "github.com/aritmolab/thothii/tools/tht/internal/safeio" + "github.com/aritmolab/thothii/tools/tht/internal/testsupport" +) + +func TestBeginExternalTransactionReleasesOuterLockAfterRuntimeCancellation(t *testing.T) { + canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))) + spec := testProjectionSpec(t) + raw, err := authprojection.Begin(toRuntimeSpec(spec), nil, false) + if err != nil { + t.Fatal(err) + } + defer func() { + if raw != nil { + _ = raw.Close() + } + }() + + admitted := make(chan struct{}) + restore := setRuntimeProjectionBeginForTest(func(ctx context.Context, runtimeSpec authprojection.Spec, before *authprojection.Snapshot, requireReadyMatch bool) (*authprojection.Transaction, error) { + close(admitted) + return authprojection.BeginContext(ctx, runtimeSpec, before, requireReadyMatch) + }) + t.Cleanup(restore) + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + transaction, err := BeginExternalProjectionTransaction(ctx, canonicalRoot, spec) + if err == nil { + err = transaction.Close() + } + result <- err + }() + <-admitted + cancel() + select { + case err := <-result: + if err == nil { + t.Fatal("BeginExternalProjectionTransaction() unexpectedly succeeded after cancellation") + } + case <-time.After(2 * time.Second): + t.Fatal("BeginExternalProjectionTransaction() did not return after runtime cancellation") + } + restore() + + if err := raw.Close(); err != nil { + t.Fatal(err) + } + raw = nil + next, err := BeginExternalProjectionTransaction(context.Background(), canonicalRoot, spec) + if err != nil { + t.Fatalf("outer lock remained held after runtime cancellation: %v", err) + } + if err := next.Close(); err != nil { + t.Fatal(err) + } +} + +func TestExternalPublishContextReturnsWhenCanonicalLockIsHeld(t *testing.T) { + canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))) + ctx, cancel := context.WithCancel(context.Background()) + transaction, err := BeginExternalProjectionTransaction(ctx, canonicalRoot, testProjectionSpec(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = transaction.Close() }) + canonicalLock, err := acquireLock(canonicalRoot) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = canonicalLock.Unlock() }) + contended := make(chan struct{}, 1) + reblocked := make(chan struct{}, 1) + restore := setProjectionCoordinatorHooksForTest(projectionCoordinatorHooks{ + onCanonicalLockContention: func() { contended <- struct{}{} }, + beforeProjectionBegin: func() { reblocked <- struct{}{} }, + }) + t.Cleanup(restore) + result := make(chan error, 1) + go func() { + _, err := transaction.PublishCanonical() + result <- err + }() + <-contended + if _, err := authprojection.Inspect(toRuntimeSpec(testProjectionSpecFromTransaction(transaction))); !errors.Is(err, authprojection.ErrBlocked) { + t.Fatalf("Inspect() error = %v, want blocked while waiting for canonical lock", err) + } + cancel() + select { + case <-reblocked: + t.Fatal("publishCanonicalContext() re-blocked even though Commit was never reached") + case err := <-result: + if !errors.Is(err, errProjectionPublish) { + t.Fatalf("publishCanonicalContext() error = %v, want sanitized publish failure", err) + } + case <-time.After(2 * time.Second): + t.Fatal("publishCanonicalContext() did not return after cancellation") + } +} + +func TestExternalRestoreContextReturnsWhenCanonicalLockIsHeld(t *testing.T) { + canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))) + spec := testProjectionSpec(t) + publishCanonical(t, canonicalRoot, spec) + ctx, cancel := context.WithCancel(context.Background()) + transaction, err := BeginExternalProjectionTransaction(ctx, canonicalRoot, spec) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = transaction.Close() }) + canonicalLock, err := acquireLock(canonicalRoot) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = canonicalLock.Unlock() }) + contended := make(chan struct{}, 1) + restoreHooks := setProjectionCoordinatorHooksForTest(projectionCoordinatorHooks{ + onCanonicalLockContention: func() { contended <- struct{}{} }, + }) + t.Cleanup(restoreHooks) + result := make(chan error, 1) + go func() { result <- transaction.RestorePriorIfCanonicalUnchanged() }() + <-contended + if _, err := authprojection.Inspect(toRuntimeSpec(spec)); !errors.Is(err, authprojection.ErrBlocked) { + t.Fatalf("Inspect() error = %v, want blocked while waiting for canonical lock", err) + } + cancel() + select { + case err := <-result: + if !errors.Is(err, errProjectionIntegrity) { + t.Fatalf("RestorePriorIfCanonicalUnchanged() error = %v, want sanitized integrity failure", err) + } + case <-time.After(2 * time.Second): + t.Fatal("RestorePriorIfCanonicalUnchanged() did not return after cancellation") + } +} + +func TestAcquireTransactionLockRejectsSwapBetweenOpenAndRevalidation(t *testing.T) { + canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))) + opened := make(chan struct{}) + release := make(chan struct{}) + var firstOpen atomic.Bool + restore := setProjectionLockHooksForTest(projectionLockHooks{afterOpen: func() { + if firstOpen.CompareAndSwap(false, true) { + close(opened) + <-release + } + }}) + t.Cleanup(restore) + first := make(chan struct { + lock *transactionLock + err error + }, 1) + go func() { + lock, err := acquireTransactionLock(context.Background(), canonicalRoot) + first <- struct { + lock *transactionLock + err error + }{lock, err} + }() + <-opened + replacement := filepath.Join(canonicalRoot, ".replacement-lock") + if err := safeio.WriteCanonicalNewFile(replacement, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Rename(replacement, filepath.Join(canonicalRoot, transactionLockFileName)); err != nil { + t.Fatal(err) + } + second, err := acquireTransactionLock(context.Background(), canonicalRoot) + if err != nil { + t.Fatalf("replacement-domain acquisition error = %v", err) + } + t.Cleanup(func() { _ = second.Close() }) + close(release) + firstResult := <-first + if firstResult.lock != nil || !errors.Is(firstResult.err, errProjectionIntegrity) { + t.Fatalf("swapped original acquisition = %#v, %v; want no old-domain lock", firstResult.lock, firstResult.err) + } +} + +func TestAcquireTransactionLockRejectsUnsafeMetadata(t *testing.T) { + for _, fixture := range []struct { + name string + arrange func(*testing.T, string) + }{ + { + name: "symlink", + arrange: func(t *testing.T, path string) { + target := path + ".target" + if err := safeio.WriteCanonicalNewFile(target, nil, 0o600); err != nil { + t.Fatal(err) + } + testsupport.SymlinkOrSkip(t, target, path) + }, + }, + { + name: "hardlink", + arrange: func(t *testing.T, path string) { + if err := safeio.WriteCanonicalNewFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Link(path, path+".linked"); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "non-0600 mode", + arrange: func(t *testing.T, path string) { + if err := safeio.WriteCanonicalNewFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o640); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "wrong owner", + arrange: func(t *testing.T, path string) { + if os.Geteuid() != 0 { + t.Skip("requires root to create a wrong numeric owner") + } + if err := safeio.WriteCanonicalNewFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chown(path, 10001, 10001); err != nil { + t.Fatal(err) + } + }, + }, + } { + t.Run(fixture.name, func(t *testing.T) { + canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))) + fixture.arrange(t, filepath.Join(canonicalRoot, transactionLockFileName)) + lock, err := acquireTransactionLock(context.Background(), canonicalRoot) + if lock != nil || !errors.Is(err, errProjectionIntegrity) { + t.Fatalf("acquireTransactionLock() = %#v, %v; want unsafe metadata rejection", lock, err) + } + }) + } +} + +func TestAcquireTransactionLockAcceptsSafeCreatorRace(t *testing.T) { + canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))) + paused := make(chan struct{}) + release := make(chan struct{}) + var firstCreate atomic.Bool + restore := setProjectionLockHooksForTest(projectionLockHooks{beforeCreate: func() { + if firstCreate.CompareAndSwap(false, true) { + close(paused) + <-release + } + }}) + t.Cleanup(restore) + first := make(chan struct { + lock *transactionLock + err error + }, 1) + go func() { + lock, err := acquireTransactionLock(context.Background(), canonicalRoot) + first <- struct { + lock *transactionLock + err error + }{lock, err} + }() + <-paused + second, err := acquireTransactionLock(context.Background(), canonicalRoot) + if err != nil { + t.Fatalf("creator-race winner error = %v", err) + } + if err := second.Close(); err != nil { + t.Fatal(err) + } + close(release) + result := <-first + if result.err != nil || result.lock == nil { + t.Fatalf("creator-race loser = %#v, %v; want same validated lock domain", result.lock, result.err) + } + if err := result.lock.Close(); err != nil { + t.Fatal(err) + } +} + +func testProjectionSpecFromTransaction(transaction *ExternalProjectionTransaction) ProjectionSpec { + return ProjectionSpec{RuntimeRoot: transaction.spec.RuntimeRoot, UID: transaction.spec.UID, GID: transaction.spec.GID} +} diff --git a/tools/tht/internal/authconfig/projection_transaction_test.go b/tools/tht/internal/authconfig/projection_transaction_test.go new file mode 100644 index 00000000..fd082847 --- /dev/null +++ b/tools/tht/internal/authconfig/projection_transaction_test.go @@ -0,0 +1,265 @@ +package authconfig + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aritmolab/thothii/tools/tht/internal/authprojection" + "github.com/aritmolab/thothii/tools/tht/internal/safeio" +) + +func TestRunProjectedMutationHoldsOuterLockAcrossCanonicalAndProjection(t *testing.T) { + canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))) + spec := testProjectionSpec(t) + entered := make(chan struct{}) + release := make(chan struct{}) + contended := make(chan struct{}, 8) + restoreHooks := setProjectionCoordinatorHooksForTest(projectionCoordinatorHooks{ + onOuterLockContention: func() { + select { + case contended <- struct{}{}: + default: + } + }, + }) + t.Cleanup(restoreHooks) + first := make(chan error, 1) + go func() { + first <- RunProjectedMutation(context.Background(), canonicalRoot, spec, func() error { + close(entered) + <-release + return nil + }) + }() + <-entered + if _, err := authprojection.Inspect(toRuntimeSpec(spec)); !errors.Is(err, authprojection.ErrBlocked) { + t.Fatalf("Inspect() error = %v, want blocked while mutation is inside the coordinator", err) + } + second := make(chan error, 1) + go func() { + second <- RunProjectedMutation(context.Background(), canonicalRoot, spec, func() error { return nil }) + }() + external := make(chan error, 1) + go func() { + transaction, err := BeginExternalProjectionTransaction(context.Background(), canonicalRoot, spec) + if err == nil { + err = transaction.Close() + } + external <- err + }() + <-contended + <-contended + close(release) + if err := <-first; err != nil { + t.Fatalf("first RunProjectedMutation() error = %v", err) + } + if err := <-second; err != nil { + t.Fatalf("second RunProjectedMutation() error = %v", err) + } + if err := <-external; err != nil { + t.Fatalf("BeginExternalProjectionTransaction() error = %v", err) + } +} + +func TestRunProjectedMutationPublishesExactLocalAndOIDCSnapshots(t *testing.T) { + for _, fixture := range []struct{ name, auth, users string }{ + {"local", defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))}, + {"oidc", "version: 1\nmode: oidc\npublicUrl: https://example.invalid\n", ""}, + } { + t.Run(fixture.name, func(t *testing.T) { + canonicalRoot := writeAuthFiles(t, fixture.auth, fixture.users) + spec := testProjectionSpec(t) + if err := RunProjectedMutation(context.Background(), canonicalRoot, spec, func() error { return nil }); err != nil { + t.Fatalf("RunProjectedMutation() error = %v", err) + } + status, err := authprojection.Inspect(toRuntimeSpec(spec)) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Snapshot.Mode != fixture.name || !bytes.Equal(status.Snapshot.Auth, []byte(fixture.auth)) { + t.Fatal("published snapshot does not match canonical auth.yaml") + } + if fixture.name == "local" && !bytes.Equal(status.Snapshot.Users, []byte(fixture.users)) { + t.Fatal("published local snapshot does not match canonical users.yaml") + } + if fixture.name == "oidc" && status.Snapshot.Users != nil { + t.Fatal("published OIDC snapshot unexpectedly includes users.yaml") + } + }) + } +} + +func TestRunProjectedMutationRestoresPriorReadyWhenMutationFailsWithoutChangingCanonical(t *testing.T) { + canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))) + spec := testProjectionSpec(t) + before := publishCanonical(t, canonicalRoot, spec) + if err := RunProjectedMutation(context.Background(), canonicalRoot, spec, func() error { return errors.New("mutation failed") }); err == nil { + t.Fatal("RunProjectedMutation() succeeded after a failed mutation") + } + after := inspectCanonicalProjection(t, spec) + if after.Generation != before.Generation { + t.Fatalf("generation = %s, want restored %s", after.Generation, before.Generation) + } +} + +func TestRunProjectedMutationLeavesBlockedWhenMutationChangesCanonicalThenFails(t *testing.T) { + canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))) + spec := testProjectionSpec(t) + publishCanonical(t, canonicalRoot, spec) + if err := RunProjectedMutation(context.Background(), canonicalRoot, spec, func() error { + return writeCanonicalAuth(canonicalRoot, strings.Replace(defaultAuthYAML, "8080", "8181", 1), errors.New("mutation failed")) + }); err == nil { + t.Fatal("RunProjectedMutation() succeeded after changing canonical authentication then failing") + } + if _, err := authprojection.Inspect(toRuntimeSpec(spec)); !errors.Is(err, authprojection.ErrBlocked) { + t.Fatalf("Inspect() error = %v, want blocked after divergent mutation failure", err) + } +} + +func TestRunProjectedMutationLeavesBlockedWhenPublicationOrVerificationFails(t *testing.T) { + for _, fixture := range []struct { + name string + mutate func(string) error + hooks projectionCoordinatorHooks + }{ + {"invalid canonical after mutation", func(directory string) error { + return safeio.ReplaceCanonicalRegular(filepath.Join(directory, authFileName), []byte("not: [valid\n"), 0o600) + }, projectionCoordinatorHooks{}}, + {"post-commit equality verification", func(string) error { return nil }, projectionCoordinatorHooks{ + verifyCommittedProjection: func(authprojection.Status, authprojection.Snapshot) error { + return errors.New("synthetic verification failure") + }, + }}, + } { + t.Run(fixture.name, func(t *testing.T) { + canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))) + spec := testProjectionSpec(t) + publishCanonical(t, canonicalRoot, spec) + restoreHooks := setProjectionCoordinatorHooksForTest(fixture.hooks) + t.Cleanup(restoreHooks) + if err := RunProjectedMutation(context.Background(), canonicalRoot, spec, func() error { return fixture.mutate(canonicalRoot) }); err == nil { + t.Fatal("RunProjectedMutation() unexpectedly succeeded") + } + if _, err := authprojection.Inspect(toRuntimeSpec(spec)); !errors.Is(err, authprojection.ErrBlocked) { + t.Fatalf("Inspect() error = %v, want blocked after failed publication", err) + } + }) + } +} + +func TestPublishProjectedCanonicalRepairsBlockedStateFromCanonicalOnly(t *testing.T) { + canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))) + spec := testProjectionSpec(t) + publishCanonical(t, canonicalRoot, spec) + if err := RunProjectedMutation(context.Background(), canonicalRoot, spec, func() error { + return writeCanonicalAuth(canonicalRoot, strings.Replace(defaultAuthYAML, "8080", "8181", 1), errors.New("fail after changing canonical bytes")) + }); err == nil { + t.Fatal("RunProjectedMutation() succeeded after a divergent failed mutation") + } + if _, err := authprojection.Inspect(toRuntimeSpec(spec)); !errors.Is(err, authprojection.ErrBlocked) { + t.Fatalf("Inspect() error = %v, want blocked before canonical repair", err) + } + status, err := PublishProjectedCanonical(context.Background(), canonicalRoot, spec) + if err != nil || !status.Equal || status.State != "ready" { + t.Fatalf("PublishProjectedCanonical() = %#v, %v; want an equal ready projection", status, err) + } +} + +func TestExternalProjectionTransactionRepublishesRecoveredCanonicalUnderOneOuterLock(t *testing.T) { + canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))) + spec := testProjectionSpec(t) + initial := publishCanonical(t, canonicalRoot, spec) + transaction, err := BeginExternalProjectionTransaction(context.Background(), canonicalRoot, spec) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = transaction.Close() }) + if err := writeCanonicalAuth(canonicalRoot, strings.Replace(defaultAuthYAML, "8080", "8181", 1), nil); err != nil { + t.Fatal(err) + } + candidate, err := transaction.PublishCanonical() + if err != nil || !candidate.Equal || candidate.Generation == initial.Generation { + t.Fatalf("candidate PublishCanonical() = %#v, %v", candidate, err) + } + if err := writeCanonicalAuth(canonicalRoot, defaultAuthYAML, nil); err != nil { + t.Fatal(err) + } + recovered, err := transaction.PublishCanonical() + if err != nil || !recovered.Equal || recovered.Generation != initial.Generation { + t.Fatalf("recovery PublishCanonical() = %#v, %v; want original generation", recovered, err) + } +} + +func TestExternalProjectionTransactionCloseNeverMakesChangedCanonicalReady(t *testing.T) { + canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))) + spec := testProjectionSpec(t) + publishCanonical(t, canonicalRoot, spec) + transaction, err := BeginExternalProjectionTransaction(context.Background(), canonicalRoot, spec) + if err != nil { + t.Fatal(err) + } + if err := writeCanonicalAuth(canonicalRoot, strings.Replace(defaultAuthYAML, "8080", "8181", 1), nil); err != nil { + t.Fatal(err) + } + if err := transaction.Close(); err != nil { + t.Fatal(err) + } + if _, err := authprojection.Inspect(toRuntimeSpec(spec)); !errors.Is(err, authprojection.ErrBlocked) { + t.Fatalf("Inspect() error = %v, want blocked after closing with changed canonical bytes", err) + } +} + +func TestProjectionCoordinatorErrorsAndLogsNeverContainSyntheticPasswordsOrHashes(t *testing.T) { + const secret = "synthetic-password-or-hash-must-not-leak" + canonicalRoot := writeAuthFiles(t, defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))) + err := RunProjectedMutation(context.Background(), canonicalRoot, testProjectionSpec(t), func() error { + return fmt.Errorf("mutation failed: %s", secret) + }) + if err == nil || strings.Contains(err.Error(), secret) { + t.Fatalf("RunProjectedMutation() error = %q, must be sanitized", err) + } +} + +func testProjectionSpec(t *testing.T) ProjectionSpec { + t.Helper() + runtimeRoot := t.TempDir() + if err := os.Chmod(runtimeRoot, 0o700); err != nil { + t.Fatal(err) + } + return ProjectionSpec{RuntimeRoot: runtimeRoot, UID: uint32(os.Getuid()), GID: uint32(os.Getgid())} +} + +func toRuntimeSpec(spec ProjectionSpec) authprojection.Spec { + return authprojection.Spec{RuntimeRoot: spec.RuntimeRoot, UID: spec.UID, GID: spec.GID} +} + +func publishCanonical(t *testing.T, canonicalRoot string, spec ProjectionSpec) ProjectionStatus { + t.Helper() + status, err := PublishProjectedCanonical(context.Background(), canonicalRoot, spec) + if err != nil || !status.Equal || status.State != "ready" { + t.Fatalf("PublishProjectedCanonical() = %#v, %v", status, err) + } + return status +} + +func inspectCanonicalProjection(t *testing.T, spec ProjectionSpec) ProjectionStatus { + t.Helper() + status, err := authprojection.Inspect(toRuntimeSpec(spec)) + if err != nil { + t.Fatal(err) + } + return ProjectionStatus{State: status.Selector.State, Generation: status.Snapshot.Generation, CanonicalRevision: status.Snapshot.CanonicalRevision, Equal: true} +} + +func writeCanonicalAuth(directory, contents string, after error) error { + if err := safeio.ReplaceCanonicalRegular(filepath.Join(directory, authFileName), []byte(contents), 0o600); err != nil { + return err + } + return after +} diff --git a/tools/tht/internal/authconfig/store.go b/tools/tht/internal/authconfig/store.go index c770ecef..7a8dc4f0 100644 --- a/tools/tht/internal/authconfig/store.go +++ b/tools/tht/internal/authconfig/store.go @@ -2,11 +2,14 @@ package authconfig import ( "bytes" + "context" "errors" "io" "os" "path/filepath" + "time" + "github.com/aritmolab/thothii/tools/tht/internal/authprojection" "github.com/aritmolab/thothii/tools/tht/internal/safeio" "github.com/gofrs/flock" "gopkg.in/yaml.v3" @@ -53,6 +56,34 @@ func load(directory string) (Config, Registry, error) { return config, registry, nil } +// loadSnapshotBytes reads only canonical, validated authentication files and returns a detached, +// bounded projection snapshot. Callers that coordinate a mutation hold .auth.lock around it. +func loadSnapshotBytes(directory string) (authprojection.Snapshot, error) { + if err := requirePrivateDirectory(directory); err != nil { + return authprojection.Snapshot{}, err + } + authContents, err := readPrivateFile(filepath.Join(directory, authFileName)) + if err != nil { + return authprojection.Snapshot{}, err + } + var config Config + if err := decodeStrictYAML(authContents, &config); err != nil || !validConfig(config) { + return authprojection.Snapshot{}, errInvalidAuthenticationConfig + } + var usersContents []byte + if config.Mode == "local" { + usersContents, err = readPrivateFile(filepath.Join(directory, config.Local.UsersFile)) + if err != nil { + return authprojection.Snapshot{}, err + } + var registry Registry + if err := decodeStrictYAML(usersContents, ®istry); err != nil || validateRegistry(registry) != nil { + return authprojection.Snapshot{}, errInvalidAuthenticationConfig + } + } + return authprojection.NewSnapshot(config.Mode, append([]byte(nil), authContents...), append([]byte(nil), usersContents...)) +} + // MutateUsers serializes the entire read-check-write transaction under the configuration lock. // The resulting registry is revalidated and atomically replaced only after all invariants hold. func MutateUsers(directory string, mutate func(*Registry) error) error { @@ -137,6 +168,13 @@ func readPrivateFile(path string) ([]byte, error) { } func acquireLock(directory string) (*flock.Flock, error) { + return acquireLockContext(context.Background(), directory) +} + +func acquireLockContext(ctx context.Context, directory string) (*flock.Flock, error) { + if ctx == nil || ctx.Err() != nil { + return nil, errInvalidAuthenticationConfig + } path := filepath.Join(directory, lockFileName) if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { if err := safeio.WriteCanonicalNewFile(path, nil, 0o600); err != nil { @@ -153,9 +191,19 @@ func acquireLock(directory string) (*flock.Flock, error) { return nil, err } lock := flock.New(path, flock.SetPermissions(0o600)) - if err := lock.Lock(); err != nil { + locked, err := lock.TryLock() + if err != nil { return nil, errInvalidAuthenticationConfig } + if !locked { + if hook := coordinatorHooks().onCanonicalLockContention; hook != nil { + hook() + } + locked, err = lock.TryLockContext(ctx, 10*time.Millisecond) + if err != nil || !locked { + return nil, errInvalidAuthenticationConfig + } + } if _, err := readPrivateFile(path); err != nil { _ = lock.Unlock() return nil, err diff --git a/tools/tht/internal/authconfig/store_test.go b/tools/tht/internal/authconfig/store_test.go index 5b32c929..54edb688 100644 --- a/tools/tht/internal/authconfig/store_test.go +++ b/tools/tht/internal/authconfig/store_test.go @@ -1,16 +1,44 @@ package authconfig import ( + "bytes" "errors" "os" "path/filepath" "strings" "testing" + "github.com/aritmolab/thothii/tools/tht/internal/authprojection" "github.com/aritmolab/thothii/tools/tht/internal/safeio" "github.com/aritmolab/thothii/tools/tht/internal/testsupport" ) +func TestLoadSnapshotBytesReturnsValidatedCopiedCanonicalBytes(t *testing.T) { + for _, fixture := range []struct{ name, auth, users string }{ + {"local", defaultAuthYAML, registryYAML(adminUserYAML("admin", "Admin", true, "admin"))}, + {"oidc", "version: 1\nmode: oidc\npublicUrl: https://example.invalid\n", ""}, + } { + t.Run(fixture.name, func(t *testing.T) { + directory := writeAuthFiles(t, fixture.auth, fixture.users) + snapshot, err := loadSnapshotBytes(directory) + if err != nil { + t.Fatal(err) + } + if snapshot.Mode != fixture.name || !bytes.Equal(snapshot.Auth, []byte(fixture.auth)) || !bytes.Equal(snapshot.Users, []byte(fixture.users)) { + t.Fatal("loadSnapshotBytes() did not preserve the validated canonical bytes") + } + if _, err := authprojection.NewSnapshot(snapshot.Mode, snapshot.Auth, snapshot.Users); err != nil { + t.Fatalf("loadSnapshotBytes() returned invalid projection data: %v", err) + } + snapshot.Auth[0] ^= 1 + onDisk, err := os.ReadFile(filepath.Join(directory, authFileName)) + if err != nil || !bytes.Equal(onDisk, []byte(fixture.auth)) { + t.Fatal("loadSnapshotBytes() returned aliasing canonical storage") + } + }) + } +} + const publicFixedHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4" func TestLoadReadsTheStrictLocalConfigurationAndRegistry(t *testing.T) { diff --git a/tools/tht/internal/authprojection/projection_linux.go b/tools/tht/internal/authprojection/projection_linux.go index 05cad487..0751143c 100644 --- a/tools/tht/internal/authprojection/projection_linux.go +++ b/tools/tht/internal/authprojection/projection_linux.go @@ -3,12 +3,14 @@ package authprojection import ( + "context" "crypto/rand" "encoding/hex" "errors" "path/filepath" "sync" "syscall" + "time" "golang.org/x/sys/unix" ) @@ -71,14 +73,24 @@ func Inspect(spec Spec) (Status, error) { } func Begin(spec Spec, before *Snapshot, requireReadyMatch bool) (*Transaction, error) { + return BeginContext(context.Background(), spec, before, requireReadyMatch) +} + +func BeginContext(ctx context.Context, spec Spec, before *Snapshot, requireReadyMatch bool) (*Transaction, error) { + if ctx == nil { + return nil, ErrIntegrity + } + if err := ctx.Err(); err != nil { + return nil, err + } root, err := openRoot(spec) if err != nil { return nil, err } rendezvousBeforeFlock(root) - if unix.Flock(root, unix.LOCK_EX) != nil { + if err := flockContext(ctx, root); err != nil { unix.Close(root) - return nil, ErrIntegrity + return nil, err } transaction := &Transaction{spec: spec, before: before, rootFD: root, lockFD: root} selector, selectorErr := readSelector(root, spec) @@ -132,6 +144,30 @@ func Begin(spec Spec, before *Snapshot, requireReadyMatch bool) (*Transaction, e return transaction, nil } +func flockContext(ctx context.Context, descriptor int) error { + for { + if err := ctx.Err(); err != nil { + return err + } + err := unix.Flock(descriptor, unix.LOCK_EX|unix.LOCK_NB) + if err == nil { + return nil + } + if !errors.Is(err, unix.EWOULDBLOCK) { + return ErrIntegrity + } + timer := time.NewTimer(10 * time.Millisecond) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return ctx.Err() + case <-timer.C: + } + } +} + func (transaction *Transaction) Commit(after Snapshot) (Status, error) { if transaction == nil || transaction.closed || !transaction.blocked || validateSnapshot(after) != nil { return Status{}, ErrIntegrity diff --git a/tools/tht/internal/authprojection/projection_linux_test.go b/tools/tht/internal/authprojection/projection_linux_test.go index 86983927..c9591635 100644 --- a/tools/tht/internal/authprojection/projection_linux_test.go +++ b/tools/tht/internal/authprojection/projection_linux_test.go @@ -3,6 +3,7 @@ package authprojection import ( + "context" "errors" "fmt" "os" @@ -11,10 +12,50 @@ import ( "sync" "syscall" "testing" + "time" "golang.org/x/sys/unix" ) +func TestBeginContextReturnsWhenRuntimeFlockIsHeld(t *testing.T) { + spec := testSpec(t) + first, err := Begin(spec, nil, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = first.Close() }) + contended := make(chan error, 1) + restore := setTestHooksForTest(testHooks{beforeFlock: func(fd int) { + if err := unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB); !errors.Is(err, unix.EWOULDBLOCK) { + contended <- fmt.Errorf("runtime lock contention = %v", err) + return + } + contended <- nil + }}) + t.Cleanup(restore) + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + transaction, err := BeginContext(ctx, spec, nil, false) + if err == nil { + err = transaction.Close() + } + result <- err + }() + if err := <-contended; err != nil { + t.Fatal(err) + } + cancel() + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("BeginContext() error = %v, want context cancellation", err) + } + case <-time.After(2 * time.Second): + t.Fatal("BeginContext() did not return after cancellation") + } +} + func testSpec(t *testing.T) Spec { t.Helper() root := t.TempDir() diff --git a/tools/tht/internal/authprojection/projection_unsupported.go b/tools/tht/internal/authprojection/projection_unsupported.go index e576c6d8..85b6bc58 100644 --- a/tools/tht/internal/authprojection/projection_unsupported.go +++ b/tools/tht/internal/authprojection/projection_unsupported.go @@ -2,8 +2,15 @@ package authprojection -func Inspect(Spec) (Status, error) { return Status{}, ErrUnsupported } -func Begin(Spec, *Snapshot, bool) (*Transaction, error) { return nil, ErrUnsupported } -func (*Transaction) Commit(Snapshot) (Status, error) { return Status{}, ErrUnsupported } -func (*Transaction) RestoreIfUnchanged(Snapshot) error { return ErrUnsupported } -func (*Transaction) Close() error { return ErrUnsupported } +import "context" + +func Inspect(Spec) (Status, error) { return Status{}, ErrUnsupported } +func Begin(Spec, *Snapshot, bool) (*Transaction, error) { + return nil, ErrUnsupported +} +func BeginContext(context.Context, Spec, *Snapshot, bool) (*Transaction, error) { + return nil, ErrUnsupported +} +func (*Transaction) Commit(Snapshot) (Status, error) { return Status{}, ErrUnsupported } +func (*Transaction) RestoreIfUnchanged(Snapshot) error { return ErrUnsupported } +func (*Transaction) Close() error { return ErrUnsupported }