fix: harden done cleanup identity and path safety (#3195)

Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Alassane
2026-08-31 20:02:44 +00:00
committed by GitHub
parent 0b7bc8b31b
commit 71de0df83e
4 changed files with 281 additions and 2 deletions
@@ -275,6 +275,11 @@ func (m *Manager) removeWorktree(ctx context.Context, wt *Worktree, removeBranch
if wt == nil {
return errors.New("worktree cleanup requires a worktree")
}
// Cleanup runs after the inventory snapshot has been selected. Keep a
// private copy so a later session/path projection update cannot redirect
// the destructive operation or its reference release.
worktreeSnapshot := *wt
wt = &worktreeSnapshot
if strings.TrimSpace(wt.RepositoryPath) == "" || strings.TrimSpace(wt.Path) == "" {
return errors.New("worktree cleanup requires repository and worktree paths")
}
@@ -572,6 +577,12 @@ func (m *Manager) removeWorktreeDir(
if err := pathHandles[0].RemoveDirectory(ctx); err != nil {
return fmt.Errorf("remove audited worktree directory: %w", err)
}
// The parent cleanup is a separate path-based fallback. Revalidate the
// audited target immediately before it so a replacement cannot turn an
// otherwise safe cleanup into recursive deletion of another directory.
if err := pathHandles[0].VerifyPath(worktreePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("worktree path changed before parent cleanup: %w", err)
}
pruneCmd := newGitCommand(ctx, "worktree", "prune")
pruneCmd.Dir = repoPath
if err := runGitCmd(ctx, pruneCmd); err != nil {
@@ -43,7 +43,7 @@ func (m *Manager) auditWorktreeCleanup(
_ = pathHandle.Close()
}
}()
branchRef, branchOID, err := m.cleanupBranchIdentity(ctx, wt)
branchRef, branchOID, err := m.cleanupBranchIdentity(ctx, wt, pathPresent, removeBranch)
if err != nil {
return worktreeCleanupAudit{}, err
}
@@ -119,12 +119,24 @@ func (m *Manager) openCleanupPathHandle(
return handle, nil
}
func (m *Manager) cleanupBranchIdentity(ctx context.Context, wt *Worktree) (string, string, error) {
func (m *Manager) cleanupBranchIdentity(
ctx context.Context, wt *Worktree, pathPresent, requireImmutableIdentity bool,
) (string, string, error) {
branchRef := ""
if wt.Branch != "" {
branchRef = "refs/heads/" + wt.Branch
}
expectedOID := strings.TrimSpace(wt.CleanupHeadOID)
if expectedOID == "" && pathPresent {
output, err := m.runBoundedGitInspect(ctx, wt.Path, "rev-parse", "--verify", "HEAD^{commit}")
if err != nil {
return "", "", fmt.Errorf("capture cleanup worktree HEAD: %w", err)
}
expectedOID = strings.TrimSpace(output)
if expectedOID == "" {
return "", "", errors.New("capture cleanup worktree HEAD returned an empty commit")
}
}
if branchRef == "" {
return branchRef, expectedOID, nil
}
@@ -138,6 +150,9 @@ func (m *Manager) cleanupBranchIdentity(ctx context.Context, wt *Worktree) (stri
// classifier will fail closed rather than deleting another checkout.
return branchRef, "", nil
}
if expectedOID == "" && requireImmutableIdentity {
return "", "", fmt.Errorf("cleanup branch %q has no immutable expected commit", wt.Branch)
}
output, err := m.runBoundedGitInspect(ctx, wt.RepositoryPath, "rev-parse", "--verify", branchRef+"^{commit}")
if err != nil {
return "", "", fmt.Errorf("resolve cleanup branch %q: %w", wt.Branch, err)
@@ -0,0 +1,160 @@
package worktree
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/kandev/kandev/internal/task/models"
)
// blockingReferenceStore pauses cleanup after removeWorktree has copied the
// inventory record. This is reviewer-requested contract coverage for the
// snapshot boundary.
type blockingReferenceStore struct {
*SQLiteStore
entered chan struct{}
release chan struct{}
releaseOnce sync.Once
}
func (s *blockingReferenceStore) CountActiveWorktreeReferences(
ctx context.Context,
_ string,
_ []string,
) (int, error) {
close(s.entered)
select {
case <-s.release:
return 0, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
func (s *blockingReferenceStore) unblock() {
s.releaseOnce.Do(func() { close(s.release) })
}
// postRemovalSwapDirectoryHandle moves the audited directory away before the
// second verification. It lets the test prove that parent cleanup is skipped
// when the lexical path no longer identifies the audited directory.
type postRemovalSwapDirectoryHandle struct {
path string
replacementPath string
verifyCalls int
}
func (h *postRemovalSwapDirectoryHandle) Close() error { return nil }
func (h *postRemovalSwapDirectoryHandle) VerifyPath(string) error {
h.verifyCalls++
if h.verifyCalls == 1 {
return nil
}
return errors.New("pinned directory no longer matches lexical path")
}
func (h *postRemovalSwapDirectoryHandle) IsValidWorktree() bool { return true }
func (h *postRemovalSwapDirectoryHandle) RemoveDirectory(context.Context) error {
return os.Rename(h.path, h.replacementPath)
}
func (h *postRemovalSwapDirectoryHandle) ReadFile(string) ([]byte, error) {
return nil, os.ErrNotExist
}
func (h *postRemovalSwapDirectoryHandle) WriteFile(string, []byte, os.FileMode) error {
return errors.New("fake directory handle does not support writes")
}
// Reviewer-requested contract coverage: the post-removal identity check must
// run before the best-effort parent-directory cleanup.
func TestRemoveWorktreeDir_RevalidatesBeforeParentCleanup(t *testing.T) {
mgr, _ := newReferenceCleanupTestManager(t)
tasksBase, err := mgr.config.ExpandedTasksBasePath()
if err != nil {
t.Fatalf("expand tasks base path: %v", err)
}
taskDir := filepath.Join(tasksBase, "task-post-removal")
worktreePath := filepath.Join(taskDir, "repository")
replacementPath := filepath.Join(t.TempDir(), "replacement")
if err := os.MkdirAll(worktreePath, 0o755); err != nil {
t.Fatalf("create worktree path: %v", err)
}
handle := &postRemovalSwapDirectoryHandle{
path: worktreePath,
replacementPath: replacementPath,
}
err = mgr.removeWorktreeDir(
context.Background(),
worktreePath,
filepath.Join(t.TempDir(), "repository.git"),
handle,
)
if err == nil || !strings.Contains(err.Error(), "before parent cleanup") {
t.Fatalf("removeWorktreeDir error = %v, want post-removal identity failure", err)
}
if handle.verifyCalls != 2 {
t.Fatalf("VerifyPath calls = %d, want 2", handle.verifyCalls)
}
if _, err := os.Stat(replacementPath); err != nil {
t.Fatalf("renamed original worktree missing: %v", err)
}
if _, err := os.Stat(taskDir); err != nil {
t.Fatalf("parent task directory was removed after identity failure: %v", err)
}
}
// Reviewer-requested contract coverage: cleanup must use the copied record
// even when a caller mutates its worktree projection while cleanup waits.
func TestCleanupWorktrees_UsesSnapshotWhenCallerMutatesPath(t *testing.T) {
mgr, store := newReferenceCleanupTestManager(t)
seedReferenceCleanupSession(t, store, "task-snapshot", "session-snapshot", models.TaskSessionStateCompleted)
wt := createReferenceCleanupWorktree(t, mgr, "task-snapshot", "session-snapshot")
originalPath := wt.Path
blockingStore := &blockingReferenceStore{
SQLiteStore: store,
entered: make(chan struct{}),
release: make(chan struct{}),
}
mgr.store = blockingStore
t.Cleanup(blockingStore.unblock)
cleanupDone := make(chan error, 1)
go func() {
cleanupDone <- mgr.CleanupWorktrees(context.Background(), []*Worktree{wt})
}()
<-blockingStore.entered
replacementPath := originalPath + "-caller-replacement"
if err := os.MkdirAll(replacementPath, 0o755); err != nil {
blockingStore.unblock()
t.Fatalf("create caller replacement path: %v", err)
}
sentinel := filepath.Join(replacementPath, "sentinel.txt")
if err := os.WriteFile(sentinel, []byte("preserve me\n"), 0o644); err != nil {
blockingStore.unblock()
t.Fatalf("write caller replacement sentinel: %v", err)
}
wt.Path = replacementPath
blockingStore.unblock()
if err := <-cleanupDone; err != nil {
t.Fatalf("cleanup after caller projection mutation: %v", err)
}
if _, err := os.Stat(originalPath); !os.IsNotExist(err) {
t.Fatalf("snapshotted worktree path remains, stat error = %v", err)
}
if got, err := os.ReadFile(sentinel); err != nil || string(got) != "preserve me\n" {
t.Fatalf("caller replacement changed: contents=%q err=%v", got, err)
}
assertWorktreeReferenceStatus(t, store, wt.ID, StatusDeleted)
}
@@ -2,6 +2,7 @@ package worktree
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
@@ -27,6 +28,35 @@ type cancellingCleanupScriptHandler struct {
cancel context.CancelFunc
}
type failingReleaseStore struct {
*SQLiteStore
failUpdate bool
}
func (s *failingReleaseStore) UpdateWorktree(ctx context.Context, wt *Worktree) error {
if s.failUpdate {
return errors.New("injected release failure")
}
return s.SQLiteStore.UpdateWorktree(ctx, wt)
}
type swappingCleanupScriptHandler struct{}
func (h *swappingCleanupScriptHandler) ExecuteSetupScript(context.Context, ScriptExecutionRequest) error {
return nil
}
func (h *swappingCleanupScriptHandler) ExecuteCleanupScript(_ context.Context, req ScriptExecutionRequest) error {
replacement := req.WorkingDir + "-replacement"
if err := os.Rename(req.WorkingDir, replacement); err != nil {
return err
}
if err := os.MkdirAll(req.WorkingDir, 0o755); err != nil {
return err
}
return os.WriteFile(filepath.Join(req.WorkingDir, "replacement.txt"), []byte("preserve me\n"), 0o644)
}
func (h *cancellingCleanupScriptHandler) ExecuteSetupScript(context.Context, ScriptExecutionRequest) error {
return nil
}
@@ -40,6 +70,7 @@ func TestCleanupWorktrees_RecoversAfterPathOnlyRemoval(t *testing.T) {
mgr, store := newReferenceCleanupTestManager(t)
seedReferenceCleanupSession(t, store, "task-partial", "session-partial", models.TaskSessionStateCompleted)
wt := createReferenceCleanupWorktree(t, mgr, "task-partial", "session-partial")
wt.CleanupHeadOID = strings.TrimSpace(runGit(t, wt.Path, "rev-parse", "HEAD"))
if err := os.RemoveAll(wt.Path); err != nil {
t.Fatalf("remove worktree path without shared Git metadata: %v", err)
@@ -53,10 +84,71 @@ func TestCleanupWorktrees_RecoversAfterPathOnlyRemoval(t *testing.T) {
assertWorktreeReferenceStatus(t, store, wt.ID, StatusDeleted)
}
func TestCleanupWorktrees_RejectsPathlessRetryWithoutImmutableHead(t *testing.T) {
mgr, store := newReferenceCleanupTestManager(t)
seedReferenceCleanupSession(t, store, "task-no-snapshot", "session-no-snapshot", models.TaskSessionStateCompleted)
wt := createReferenceCleanupWorktree(t, mgr, "task-no-snapshot", "session-no-snapshot")
if err := os.RemoveAll(wt.Path); err != nil {
t.Fatalf("remove worktree path without shared Git metadata: %v", err)
}
if err := mgr.CleanupWorktrees(context.Background(), []*Worktree{wt}); err == nil {
t.Fatal("cleanup accepted pathless worktree without immutable cleanup identity")
}
assertCleanupBranchPresent(t, wt.RepositoryPath, wt.Branch)
assertWorktreeReferenceStatus(t, store, wt.ID, StatusActive)
}
func TestCleanupWorktreesPreservingBranches_RetriesAfterReleaseFailure(t *testing.T) {
mgr, store := newReferenceCleanupTestManager(t)
seedReferenceCleanupSession(t, store, "task-preserve-retry", "session-preserve-retry", models.TaskSessionStateCompleted)
wt := createReferenceCleanupWorktree(t, mgr, "task-preserve-retry", "session-preserve-retry")
mgr.store = &failingReleaseStore{SQLiteStore: store, failUpdate: true}
if err := mgr.CleanupWorktreesPreservingBranches(context.Background(), []*Worktree{wt}); err == nil {
t.Fatal("cleanup succeeded despite injected reference-release failure")
}
if _, err := os.Stat(wt.Path); !os.IsNotExist(err) {
t.Fatalf("worktree path remains after first removal: %v", err)
}
assertCleanupBranchPresent(t, wt.RepositoryPath, wt.Branch)
mgr.store = store
if err := mgr.CleanupWorktreesPreservingBranches(context.Background(), []*Worktree{wt}); err != nil {
t.Fatalf("retry pathless branch-preserving cleanup: %v", err)
}
assertCleanupBranchPresent(t, wt.RepositoryPath, wt.Branch)
assertWorktreeReferenceStatus(t, store, wt.ID, StatusDeleted)
}
func TestCleanupWorktrees_PreservesReplacementAfterPostAuditPathSwap(t *testing.T) {
mgr, store := newReferenceCleanupTestManager(t)
seedReferenceCleanupSession(t, store, "task-path-swap", "session-path-swap", models.TaskSessionStateCompleted)
wt := createReferenceCleanupWorktree(t, mgr, "task-path-swap", "session-path-swap")
wt.CleanupHeadOID = strings.TrimSpace(runGit(t, wt.Path, "rev-parse", "HEAD"))
mgr.SetRepositoryProvider(&fakeRepoProvider{repo: &Repository{ID: wt.RepositoryID, CleanupScript: "swap"}})
mgr.SetScriptMessageHandler(&swappingCleanupScriptHandler{})
if err := mgr.CleanupWorktrees(context.Background(), []*Worktree{wt}); err == nil {
t.Fatal("cleanup accepted a path replacement after audit")
}
replacement := filepath.Join(filepath.Dir(wt.Path), filepath.Base(wt.Path)+"-replacement")
if got, err := os.ReadFile(filepath.Join(wt.Path, "replacement.txt")); err != nil || string(got) != "preserve me\n" {
t.Fatalf("replacement path changed: contents=%q err=%v", got, err)
}
if _, err := os.Stat(replacement); err != nil {
t.Fatalf("renamed original worktree missing: %v", err)
}
assertCleanupBranchPresent(t, wt.RepositoryPath, wt.Branch)
assertWorktreeReferenceStatus(t, store, wt.ID, StatusActive)
}
func TestCleanupWorktrees_PartialFailureRemainsRetryable(t *testing.T) {
mgr, store := newReferenceCleanupTestManager(t)
seedReferenceCleanupSession(t, store, "task-retry", "session-retry", models.TaskSessionStateCompleted)
wt := createReferenceCleanupWorktree(t, mgr, "task-retry", "session-retry")
wt.CleanupHeadOID = strings.TrimSpace(runGit(t, wt.Path, "rev-parse", "HEAD"))
if err := os.RemoveAll(wt.Path); err != nil {
t.Fatalf("remove worktree path without shared Git metadata: %v", err)
@@ -82,6 +174,7 @@ func TestCleanupWorktrees_IsIdempotentAfterVerifiedRemoval(t *testing.T) {
mgr, store := newReferenceCleanupTestManager(t)
seedReferenceCleanupSession(t, store, "task-idempotent", "session-idempotent", models.TaskSessionStateCompleted)
wt := createReferenceCleanupWorktree(t, mgr, "task-idempotent", "session-idempotent")
wt.CleanupHeadOID = strings.TrimSpace(runGit(t, wt.Path, "rev-parse", "HEAD"))
for attempt := 1; attempt <= 2; attempt++ {
if err := mgr.CleanupWorktrees(context.Background(), []*Worktree{wt}); err != nil {