feat(auth): coordinate canonical projection publication
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,11 +2,14 @@ package authconfig
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aritmolab/thothii/tools/tht/internal/authprojection"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||||
"github.com/gofrs/flock"
|
"github.com/gofrs/flock"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -53,6 +56,34 @@ func load(directory string) (Config, Registry, error) {
|
|||||||
return config, registry, nil
|
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.
|
// 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.
|
// The resulting registry is revalidated and atomically replaced only after all invariants hold.
|
||||||
func MutateUsers(directory string, mutate func(*Registry) error) error {
|
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) {
|
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)
|
path := filepath.Join(directory, lockFileName)
|
||||||
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
|
||||||
if err := safeio.WriteCanonicalNewFile(path, nil, 0o600); err != nil {
|
if err := safeio.WriteCanonicalNewFile(path, nil, 0o600); err != nil {
|
||||||
@@ -153,9 +191,19 @@ func acquireLock(directory string) (*flock.Flock, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
lock := flock.New(path, flock.SetPermissions(0o600))
|
lock := flock.New(path, flock.SetPermissions(0o600))
|
||||||
if err := lock.Lock(); err != nil {
|
locked, err := lock.TryLock()
|
||||||
|
if err != nil {
|
||||||
return nil, errInvalidAuthenticationConfig
|
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 {
|
if _, err := readPrivateFile(path); err != nil {
|
||||||
_ = lock.Unlock()
|
_ = lock.Unlock()
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,16 +1,44 @@
|
|||||||
package authconfig
|
package authconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/aritmolab/thothii/tools/tht/internal/authprojection"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/testsupport"
|
"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"
|
const publicFixedHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4"
|
||||||
|
|
||||||
func TestLoadReadsTheStrictLocalConfigurationAndRegistry(t *testing.T) {
|
func TestLoadReadsTheStrictLocalConfigurationAndRegistry(t *testing.T) {
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
package authprojection
|
package authprojection
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"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) {
|
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)
|
root, err := openRoot(spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rendezvousBeforeFlock(root)
|
rendezvousBeforeFlock(root)
|
||||||
if unix.Flock(root, unix.LOCK_EX) != nil {
|
if err := flockContext(ctx, root); err != nil {
|
||||||
unix.Close(root)
|
unix.Close(root)
|
||||||
return nil, ErrIntegrity
|
return nil, err
|
||||||
}
|
}
|
||||||
transaction := &Transaction{spec: spec, before: before, rootFD: root, lockFD: root}
|
transaction := &Transaction{spec: spec, before: before, rootFD: root, lockFD: root}
|
||||||
selector, selectorErr := readSelector(root, spec)
|
selector, selectorErr := readSelector(root, spec)
|
||||||
@@ -132,6 +144,30 @@ func Begin(spec Spec, before *Snapshot, requireReadyMatch bool) (*Transaction, e
|
|||||||
return transaction, nil
|
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) {
|
func (transaction *Transaction) Commit(after Snapshot) (Status, error) {
|
||||||
if transaction == nil || transaction.closed || !transaction.blocked || validateSnapshot(after) != nil {
|
if transaction == nil || transaction.closed || !transaction.blocked || validateSnapshot(after) != nil {
|
||||||
return Status{}, ErrIntegrity
|
return Status{}, ErrIntegrity
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package authprojection
|
package authprojection
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -11,10 +12,50 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"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 {
|
func testSpec(t *testing.T) Spec {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|||||||
@@ -2,8 +2,15 @@
|
|||||||
|
|
||||||
package authprojection
|
package authprojection
|
||||||
|
|
||||||
func Inspect(Spec) (Status, error) { return Status{}, ErrUnsupported }
|
import "context"
|
||||||
func Begin(Spec, *Snapshot, bool) (*Transaction, error) { return nil, ErrUnsupported }
|
|
||||||
func (*Transaction) Commit(Snapshot) (Status, error) { return Status{}, ErrUnsupported }
|
func Inspect(Spec) (Status, error) { return Status{}, ErrUnsupported }
|
||||||
func (*Transaction) RestoreIfUnchanged(Snapshot) error { return ErrUnsupported }
|
func Begin(Spec, *Snapshot, bool) (*Transaction, error) {
|
||||||
func (*Transaction) Close() error { return ErrUnsupported }
|
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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user