//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} }