diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a63c9be..f766b17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,37 @@ jobs: name: coverage-${{ matrix.go }} path: coverage.out + workspace-runtime: + name: Workspace Runtime (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24.2" + cache: true + + - name: Test workspace + run: go test ./internal/workspace -count=1 -timeout=20m + + workspace-platform-compile: + name: Workspace Platform Compile + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.24.2" + cache: true + + - name: Compile workspace platform families + run: scripts/check-workspace-platforms.sh + web: name: Web runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index fe4bce8..3270f26 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ BIN := bin MODULE := github.com/Duang777/drove VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev) -.PHONY: all build build-cli build-daemon test vet lint fmt clean install help +.PHONY: all build build-cli build-daemon test test-workspace-platforms vet lint fmt clean install help all: build @@ -21,6 +21,10 @@ test: $(GO) test ./... -race -coverprofile=coverage.out $(GO) tool cover -func=coverage.out | tail -n 1 +## test-workspace-platforms: 编译 workspace 的全部平台实现与测试 +test-workspace-platforms: + GO=$(GO) scripts/check-workspace-platforms.sh + ## vet: 静态检查 vet: $(GO) vet ./... diff --git a/cmd/drove/AGENTS.md b/cmd/drove/AGENTS.md index be9e846..7b859d2 100644 --- a/cmd/drove/AGENTS.md +++ b/cmd/drove/AGENTS.md @@ -55,6 +55,7 @@ log / timeline / explain / attach / tui / stop / worktree / token rotate / web / - main 必须保持极薄:解析参数 → 调用 client → 退出码语义化(0 成功 / 1 用户错误 / 2 运行时错误)。 - 未知顶层或嵌套子命令属于用户输入错误,必须返回退出码 1。 +- Cobra 的隐藏补全入口 `__complete` 与 `__completeNoDesc` 必须绕过未知命令预检。 - 禁止在 cmd 中复制业务逻辑或直接构造 session/pty/store;一律走 `internal/client`。 - 本地自定义类型必须与 daemon API 的 JSON 契约一致(增删字段需同步 API 层测试)。 - 导出符号:无(main 包不导出)。 diff --git a/cmd/drove/main.go b/cmd/drove/main.go index a2bb657..711ca92 100644 --- a/cmd/drove/main.go +++ b/cmd/drove/main.go @@ -16,6 +16,7 @@ import ( "path/filepath" "runtime" "strings" + "syscall" "time" "unicode/utf8" @@ -59,7 +60,7 @@ func main() { // CLI 静默日志,避免污染输出。 slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + ctx, stop := newRootContext() defer stop() root := newRootCmd() root.SetContext(ctx) @@ -69,28 +70,87 @@ func main() { } } +func newRootContext() (context.Context, context.CancelFunc) { + return signal.NotifyContext( + context.Background(), + os.Interrupt, + syscall.SIGTERM, + ) +} + func executeRoot(root *cobra.Command, args []string) error { root.InitDefaultHelpCmd() root.InitDefaultCompletionCmd() - command, remaining, err := root.Find(args) - if err != nil { + if err := validateDefaultHelpArgs(root, args); err != nil { + return err + } + if !isCompletionRequest(args) { + command, remaining, err := root.Find(args) + if err != nil { + return markUsageError(err) + } + afterTerminator := len(remaining) > 0 && remaining[0] == "--" + if len(remaining) > 0 && remaining[0] == "--" { + remaining = remaining[1:] + } + if command.HasSubCommands() && + !command.Runnable() && + len(remaining) > 0 && + (afterTerminator || !strings.HasPrefix(remaining[0], "-")) { + return markUsageError(fmt.Errorf( + "unknown command %q for %q", + remaining[0], + command.CommandPath(), + )) + } + } + root.SetArgs(args) + err := root.Execute() + if err != nil && len(args) > 0 && args[0] == "completion" { return markUsageError(err) } - if len(remaining) > 0 && remaining[0] == "--" { - remaining = remaining[1:] + return err +} + +func validateDefaultHelpArgs(root *cobra.Command, args []string) error { + if len(args) < 2 || args[0] != "help" { + return nil + } + topic := make([]string, 0, len(args)-1) + afterTerminator := false + for _, argument := range args[1:] { + if afterTerminator { + topic = append(topic, argument) + continue + } + if argument == "--" { + afterTerminator = true + continue + } + if argument == "-h" || argument == "--help" { + return nil + } + if strings.HasPrefix(argument, "-") { + continue + } + topic = append(topic, argument) } - if command.HasSubCommands() && - !command.Runnable() && - len(remaining) > 0 && - !strings.HasPrefix(remaining[0], "-") { - return markUsageError(fmt.Errorf( - "unknown command %q for %q", - remaining[0], - command.CommandPath(), - )) + if len(topic) == 0 { + return nil } - root.SetArgs(args) - return root.Execute() + _, remaining, err := root.Find(topic) + if err == nil && len(remaining) == 0 { + return nil + } + return markUsageError(fmt.Errorf( + "unknown help topic %q", + strings.Join(topic, " "), + )) +} + +func isCompletionRequest(args []string) bool { + return len(args) > 0 && + (args[0] == "__complete" || args[0] == "__completeNoDesc") } func commandExitCode(err error) int { @@ -174,6 +234,7 @@ func newInitCmd() *cobra.Command { return &cobra.Command{ Use: "init", Short: "初始化配置与数据目录", + Args: usageArgs(cobra.NoArgs), RunE: func(_ *cobra.Command, _ []string) error { cfg := config.Defaults() path := config.DefaultPath() @@ -259,6 +320,7 @@ func newPSCmd() *cobra.Command { return &cobra.Command{ Use: "ps", Short: "列出全部 Agent 会话", + Args: usageArgs(cobra.NoArgs), RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() c, err := newClient(ctx) @@ -1129,6 +1191,7 @@ func newVersionCmd() *cobra.Command { return &cobra.Command{ Use: "version", Short: "输出版本信息", + Args: usageArgs(cobra.NoArgs), Run: func(_ *cobra.Command, _ []string) { fmt.Printf("drove %s (commit=%s, built=%s)\n", version.Version, version.Commit, version.Date) }, diff --git a/cmd/drove/main_test.go b/cmd/drove/main_test.go index ce95194..0069608 100644 --- a/cmd/drove/main_test.go +++ b/cmd/drove/main_test.go @@ -11,11 +11,15 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" + "syscall" "testing" "time" "unicode/utf8" + "github.com/spf13/cobra" + "github.com/Duang777/drove/internal/agent" "github.com/Duang777/drove/internal/auth" "github.com/Duang777/drove/internal/cliattach" @@ -30,6 +34,29 @@ import ( "github.com/Duang777/drove/internal/workspace" ) +func TestRootContextCancelsOnSIGTERM(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not deliver POSIX SIGTERM") + } + ctx, stop := newRootContext() + defer stop() + process, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("find test process: %v", err) + } + if err := process.Signal(syscall.SIGTERM); err != nil { + t.Fatalf("send SIGTERM: %v", err) + } + select { + case <-ctx.Done(): + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("root context error = %v, want context canceled", ctx.Err()) + } + case <-time.After(time.Second): + t.Fatal("SIGTERM did not cancel the root context") + } +} + func TestSessionStartRequestMapsRunMode(t *testing.T) { tests := []struct { name string @@ -269,6 +296,8 @@ func TestExecuteRootTreatsUnknownCommandsAsUsageErrors(t *testing.T) { {"worktree", "unknown"}, {"--", "unknown"}, {"worktree", "--", "unknown"}, + {"--", "--bogus"}, + {"worktree", "--", "--bogus"}, } for _, args := range tests { t.Run(strings.Join(args, "/"), func(t *testing.T) { @@ -283,6 +312,57 @@ func TestExecuteRootTreatsUnknownCommandsAsUsageErrors(t *testing.T) { } } +func TestZeroArgumentCommandsRejectArguments(t *testing.T) { + for _, command := range []*cobra.Command{ + newInitCmd(), + newPSCmd(), + newVersionCmd(), + } { + t.Run(command.Name(), func(t *testing.T) { + if command.Args == nil { + t.Fatal("argument validator is nil") + } + if err := command.Args(command, nil); err != nil { + t.Fatalf("validate no arguments: %v", err) + } + err := command.Args(command, []string{"unexpected"}) + if err == nil { + t.Fatal("accepted an unexpected argument") + } + if got := commandExitCode(err); got != exitUsage { + t.Fatalf("exit code = %d, want %d for %v", got, exitUsage, err) + } + }) + } +} + +func TestExecuteRootTreatsDefaultCommandArgumentErrorsAsUsageErrors( + t *testing.T, +) { + tests := [][]string{ + {"help", "definitely-not-a-command"}, + {"help", "--", "definitely-not-a-command"}, + {"help", "worktree", "--", "definitely-not-a-command"}, + {"completion", "bash", "extra"}, + } + for _, args := range tests { + t.Run(strings.Join(args, "/"), func(t *testing.T) { + err := executeRoot(newRootCmd(), args) + if err == nil { + t.Fatalf("execute %v succeeded", args) + } + if got := commandExitCode(err); got != exitUsage { + t.Fatalf( + "exit code = %d, want %d for %v", + got, + exitUsage, + err, + ) + } + }) + } +} + func TestExecuteRootSupportsDefaultCommands(t *testing.T) { tests := []struct { name string @@ -294,11 +374,50 @@ func TestExecuteRootSupportsDefaultCommands(t *testing.T) { args: []string{"help", "worktree"}, wantOutput: "Usage:", }, + { + name: "help command flag", + args: []string{"help", "--help"}, + wantOutput: "Usage:", + }, + { + name: "help target flag", + args: []string{"help", "worktree", "--help"}, + wantOutput: "Usage:", + }, + { + name: "help flag overrides invalid topic", + args: []string{ + "help", + "--help", + "definitely-not-a-command", + }, + wantOutput: "Usage:", + }, + { + name: "target help flag overrides invalid topic", + args: []string{ + "help", + "worktree", + "-h", + "definitely-not-a-command", + }, + wantOutput: "Usage:", + }, { name: "completion", args: []string{"completion", "bash"}, wantOutput: "__start_drove", }, + { + name: "completion request", + args: []string{"__complete", "workt"}, + wantOutput: "worktree", + }, + { + name: "completion request without descriptions", + args: []string{"__completeNoDesc", "workt"}, + wantOutput: "worktree", + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/go.mod b/go.mod index 19886b1..61a30f6 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/muesli/cancelreader v0.2.2 github.com/spf13/cobra v1.8.1 golang.org/x/sys v0.41.0 + golang.org/x/text v0.29.0 modernc.org/sqlite v1.33.1 ) @@ -45,7 +46,6 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/text v0.3.8 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/libc v1.55.3 // indirect modernc.org/mathutil v1.6.0 // indirect diff --git a/go.sum b/go.sum index ff672b3..1a5d731 100644 --- a/go.sum +++ b/go.sum @@ -78,18 +78,18 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w= golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= -golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= -golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= -golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= diff --git a/internal/api/server.go b/internal/api/server.go index 4991c9f..ad85fcd 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -279,12 +279,20 @@ func parseExplainLimit(r *http.Request) (int, error) { func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := s.opts.Manager.Stop(agent.ID(id)); err != nil { - writeErr(w, http.StatusNotFound, err.Error()) + writeStopError(w, err) return } w.WriteHeader(http.StatusNoContent) } +func writeStopError(w http.ResponseWriter, err error) { + if errors.Is(err, session.ErrUnknownAgent) { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + writeErr(w, http.StatusInternalServerError, err.Error()) +} + type workspaceCleanupResponse struct { AgentID string `json:"agent_id"` Branch string `json:"branch"` diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 4b9eea0..61b74ad 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -456,6 +456,39 @@ func TestWriteResumeErrorMapsUnavailableHookAuthority(t *testing.T) { } } +func TestWriteStopErrorMapsOnlyUnknownAgentToNotFound(t *testing.T) { + tests := []struct { + name string + err error + wantStatus int + }{ + { + name: "unknown agent", + err: fmt.Errorf("wrapped: %w", session.ErrUnknownAgent), + wantStatus: http.StatusNotFound, + }, + { + name: "runtime failure", + err: errors.New("close PTY"), + wantStatus: http.StatusInternalServerError, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := httptest.NewRecorder() + writeStopError(response, test.err) + if response.Code != test.wantStatus { + t.Fatalf( + "status = %d, want %d; body=%s", + response.Code, + test.wantStatus, + response.Body.String(), + ) + } + }) + } +} + func TestHandleExplainReturnsAttachedTypedResponse(t *testing.T) { server, manager, _ := newTestServer(t) status, err := manager.Start(context.Background(), session.StartRequest{ diff --git a/internal/cliattach/AGENTS.md b/internal/cliattach/AGENTS.md index aa29b58..407de96 100644 --- a/internal/cliattach/AGENTS.md +++ b/internal/cliattach/AGENTS.md @@ -7,8 +7,9 @@ stdin/stdout pumps、resize signal 和退出清理。Cobra 只解析参数并调 ## 关键设计 -- `Run` 只接受具体 `client.Client`;测试通过包内 runner dependencies 替换 stream、 - TTY、cancel reader 和 signal source。 +- `Run` 只接受具体 `client.Client`;`Options` 可携带调用方已经租用的 stdin/stdout, + 未指定时使用进程标准流。测试通过包内 runner dependencies 替换 stream、TTY、 + cancel reader 和 signal source。 - stdin 必须是终端。runner 保存原状态、进入 raw mode,并在所有退出路径恢复一次。 - stdin 每次最多读取 32 KiB;UTF-8 不完整后缀跨读取保留,发送请求保持合法 UTF-8 且不超过 session 的 64 KiB 上限。 diff --git a/internal/cliattach/run.go b/internal/cliattach/run.go index 9925485..a6cc545 100644 --- a/internal/cliattach/run.go +++ b/internal/cliattach/run.go @@ -27,6 +27,8 @@ var ( // Options configures one CLI terminal attachment. type Options struct { ReadOnly bool + Stdin io.Reader + Stdout io.Writer } type terminalStream interface { @@ -76,9 +78,13 @@ func Run( if daemon == nil { return errors.New("cliattach: client is required") } + stdin, stdout, err := terminalStreams(options) + if err != nil { + return err + } return run(ctx, agentID, options, runnerDependencies{ - stdin: os.Stdin, - stdout: os.Stdout, + stdin: stdin, + stdout: stdout, open: func(ctx context.Context) (terminalStream, error) { return daemon.OpenTerminal(ctx) }, @@ -99,6 +105,22 @@ func Run( }) } +func terminalStreams(options Options) (inputTerminal, io.Writer, error) { + input := io.Reader(os.Stdin) + if options.Stdin != nil { + input = options.Stdin + } + stdin, ok := input.(inputTerminal) + if !ok { + return nil, nil, errStdinNotTerminal + } + stdout := io.Writer(os.Stdout) + if options.Stdout != nil { + stdout = options.Stdout + } + return stdin, stdout, nil +} + func run( ctx context.Context, agentID string, diff --git a/internal/cliattach/run_test.go b/internal/cliattach/run_test.go index b70f380..a8be0da 100644 --- a/internal/cliattach/run_test.go +++ b/internal/cliattach/run_test.go @@ -87,6 +87,24 @@ func TestAttachTTYRejectsNonTerminalBeforeSetup(t *testing.T) { } } +func TestTerminalStreamsUseInjectedTerminalAndOutput(t *testing.T) { + input := fakeInputTerminal{} + var output bytes.Buffer + stdin, stdout, err := terminalStreams(Options{ + Stdin: input, + Stdout: &output, + }) + if err != nil { + t.Fatalf("resolve terminal streams: %v", err) + } + if stdin != input { + t.Fatalf("stdin = %T, want injected terminal", stdin) + } + if stdout != &output { + t.Fatalf("stdout = %T, want injected output", stdout) + } +} + func TestAttachCleanupAfterPumpFailure(t *testing.T) { stream := newFakeTerminalStream() stream.messages <- client.TerminalOutput{ diff --git a/internal/client/client.go b/internal/client/client.go index fda85f8..25cc033 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -65,6 +65,11 @@ func IsUserError(err error) bool { } } + var expired *recording.OutputExpiredError + if errors.As(err, &expired) { + return true + } + var terminalErr *TerminalStreamError if !errors.As(err, &terminalErr) { return false diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 28e4021..0b2944a 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -180,6 +180,44 @@ func TestCleanupWorktreeKeepsCurrentDaemonNotFoundAsUserError(t *testing.T) { } } +func TestStopClassifiesOnlyNotFoundAsUserError(t *testing.T) { + for _, test := range []struct { + name string + statusCode int + wantUser bool + }{ + { + name: "unknown agent", + statusCode: http.StatusNotFound, + wantUser: true, + }, + { + name: "runtime failure", + statusCode: http.StatusInternalServerError, + wantUser: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + w http.ResponseWriter, + _ *http.Request, + ) { + http.Error(w, `{"error":"stop failed"}`, test.statusCode) + })) + defer server.Close() + + client := New(strings.TrimPrefix(server.URL, "http://")) + err := client.Stop(context.Background(), "agent-1") + if err == nil { + t.Fatal("stop succeeded") + } + if got := IsUserError(err); got != test.wantUser { + t.Fatalf("IsUserError(%v) = %v, want %v", err, got, test.wantUser) + } + }) + } +} + func TestStartUsesDedicatedWorktreeEndpointWithoutClientTimeout(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func( w http.ResponseWriter, @@ -595,6 +633,9 @@ func TestFrameReturnsTypedOutputExpiry(t *testing.T) { if !errors.As(err, &expired) { t.Fatalf("frame error = %v, want OutputExpiredError", err) } + if !IsUserError(err) { + t.Fatalf("frame expiry was not classified as a user error: %v", err) + } if expired.SessionID != "agent-1" || len(expired.Missing) != 1 || expired.Missing[0] != (recording.OutputRange{Start: 3, End: 8}) { diff --git a/internal/clitui/AGENTS.md b/internal/clitui/AGENTS.md index 4c900b3..a561e04 100644 --- a/internal/clitui/AGENTS.md +++ b/internal/clitui/AGENTS.md @@ -12,7 +12,8 @@ - 只有选中的 Agent 持有 snapshot 订阅;preview actor 独占 stream 和 `Next` 调用, 切换目标时先取消并等待旧 generation 退出。 - Bubble Tea 负责总览终端状态与 resize;attach 通过 `tea.Exec` 暂停总览,并把 - raw mode、输入输出 pump、SIGWINCH 和 Ctrl-Q 交给 `cliattach.Run`。 + 它注入的终端 stdin/stdout 交给 `cliattach.Run`,后者接管 raw mode、输入输出 + pump、SIGWINCH 和 Ctrl-Q。 - 同时最多执行一个 send、stop 或 explain;选择的进程代次变化时取消旧操作并要求 用户重新确认。attach 返回不得覆盖用户在终端恢复后做出的新选择。 - 退出总览只清理本地请求、stream 和 worker,不停止远端 Agent。 diff --git a/internal/clitui/model.go b/internal/clitui/model.go index 58d54d2..4585975 100644 --- a/internal/clitui/model.go +++ b/internal/clitui/model.go @@ -403,6 +403,7 @@ func (m *model) handleSendKey(message tea.KeyMsg) tea.Cmd { m.focus = focusFleet return m.startSend(id, payload) default: + m.notice = "" var command tea.Cmd m.input, command = m.input.Update(message) return command @@ -499,6 +500,10 @@ func filterProjectedFleet(rows []fleetRow, filter fleetFilter) []fleetRow { } func (m *model) replacePreview(agentID string) { + row, selected := m.selectedRow() + if !selected || row.AgentID != agentID || row.PID <= 0 { + agentID = "" + } m.previewTarget.Generation++ m.previewTarget.AgentID = agentID m.previewSnapshot = nil @@ -737,14 +742,21 @@ type attachCommand struct { run attachRunner agentID string options cliattach.Options + stderr io.Writer } func (c *attachCommand) Run() error { return c.run(c.ctx, c.agentID, c.options) } -func (*attachCommand) SetStdin(io.Reader) {} +func (c *attachCommand) SetStdin(stdin io.Reader) { + c.options.Stdin = stdin +} -func (*attachCommand) SetStdout(io.Writer) {} +func (c *attachCommand) SetStdout(stdout io.Writer) { + c.options.Stdout = stdout +} -func (*attachCommand) SetStderr(io.Writer) {} +func (c *attachCommand) SetStderr(stderr io.Writer) { + c.stderr = stderr +} diff --git a/internal/clitui/model_test.go b/internal/clitui/model_test.go index 8dd3db3..e745c73 100644 --- a/internal/clitui/model_test.go +++ b/internal/clitui/model_test.go @@ -3,7 +3,6 @@ package clitui import ( "context" "errors" - "io" "reflect" "strings" "sync" @@ -31,9 +30,9 @@ func TestModelSelectionFilterAndPreviewGeneration(t *testing.T) { _, _ = m.Update(fleetResultMsg{ RequestID: 1, Statuses: []*session.Status{ - newFleetStatus("pending", agent.StatePending, time.Now()), - newFleetStatus("blocked", agent.StateBlocked, time.Now()), - newFleetStatus("working", agent.StateWorking, time.Now()), + newAttachedFleetStatus("pending", agent.StatePending), + newAttachedFleetStatus("blocked", agent.StateBlocked), + newAttachedFleetStatus("working", agent.StateWorking), }, }) @@ -230,6 +229,33 @@ func TestPollReplacesPreviewWhenSelectedAgentProcessChanges(t *testing.T) { }) { t.Fatalf("replacement target = %+v", got) } + + stopped := newFleetStatus("agent-1", agent.StateStopped, time.Now()) + m.fleetInFlight = true + m.fleetRequestID = 3 + _, _ = m.Update(fleetResultMsg{ + RequestID: 3, + Statuses: []*session.Status{stopped}, + }) + if got := preview.lastReplacement(t); got != (previewTarget{ + Generation: 3, + }) { + t.Fatalf("stopped target = %+v, want canceled preview", got) + } + + restarted := newAttachedFleetStatus("agent-1", agent.StateStarting) + m.fleetInFlight = true + m.fleetRequestID = 4 + _, _ = m.Update(fleetResultMsg{ + RequestID: 4, + Statuses: []*session.Status{restarted}, + }) + if got := preview.lastReplacement(t); got != (previewTarget{ + AgentID: "agent-1", + Generation: 4, + }) { + t.Fatalf("restarted target = %+v", got) + } } func TestActionSendAddsExactlyOneNewlineAndEnforcesByteLimit(t *testing.T) { @@ -277,6 +303,17 @@ func TestActionSendAddsExactlyOneNewlineAndEnforcesByteLimit(t *testing.T) { if !strings.Contains(m.notice, "maximum") || m.focus != focusSend { t.Fatalf("oversized input notice=%q focus=%d", m.notice, m.focus) } + if view := m.View(); !strings.Contains(view, "maximum") { + t.Fatalf("oversized input notice is not visible:\n%s", view) + } + + _, _ = m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + if m.notice != "" { + t.Fatalf("editing oversized input retained notice %q", m.notice) + } + if view := m.View(); !strings.Contains(view, "SEND agent-1") { + t.Fatalf("send editor did not return after editing:\n%s", view) + } } func TestPendingActionBlocksOtherActionsAndCancellationStopsRequest(t *testing.T) { @@ -438,10 +475,10 @@ func TestAttachUsesExecModesAndPreservesSelection(t *testing.T) { var ( options []cliattach.Options - stdin io.Reader - stdout io.Writer - stderr io.Writer ) + stdin := strings.NewReader("terminal input") + stdout := &strings.Builder{} + stderr := &strings.Builder{} attachErr := errors.New("attach failed") preview := newRecordingPreview() m := newModelWithDependencies(context.Background(), modelDependencies{ @@ -451,7 +488,16 @@ func TestAttachUsesExecModesAndPreservesSelection(t *testing.T) { if id != "agent-1" { t.Fatalf("attached agent = %q, want agent-1", id) } - options = append(options, option) + if option.Stdin != stdin || option.Stdout != stdout { + t.Fatalf( + "attach streams = (%T, %T), want Bubble Tea streams", + option.Stdin, + option.Stdout, + ) + } + options = append(options, cliattach.Options{ + ReadOnly: option.ReadOnly, + }) if option.ReadOnly { return attachErr } @@ -650,6 +696,7 @@ func seedModel(m *model, ids ...string) { Name: id, Vendor: "generic", State: agent.StateWorking, + PID: index + 1, HookStatus: detect.HookOff, UpdatedAt: time.Now(), } @@ -657,6 +704,12 @@ func seedModel(m *model, ids ...string) { m.rebuildVisibleRows() } +func newAttachedFleetStatus(id string, state agent.State) *session.Status { + status := newFleetStatus(id, state, time.Now()) + status.PID = 1 + return status +} + func keyMessage(key string) tea.KeyMsg { switch key { case "enter": diff --git a/internal/clitui/view.go b/internal/clitui/view.go index 1a5afb4..1843d7b 100644 --- a/internal/clitui/view.go +++ b/internal/clitui/view.go @@ -221,6 +221,9 @@ func (m *model) renderExplainPane(width, height int) string { func (m *model) renderStatus() string { switch m.focus { case focusSend: + if m.notice != "" { + return errorStyle.Render(terminalInlineText(m.notice)) + } return fmt.Sprintf( "SEND %s %s", terminalInlineText(m.actionID), diff --git a/internal/pty/AGENTS.md b/internal/pty/AGENTS.md index 9c2106c..5945d40 100644 --- a/internal/pty/AGENTS.md +++ b/internal/pty/AGENTS.md @@ -17,7 +17,9 @@ 正常块不拆分合法 UTF-8 码点,EOF 原样送出无效或不完整尾部字节。 - `Config.OnOutputEnd` 在最后一个输出块后调用一次;它与 `Config.OnExit` 没有顺序保证。 - `Config.OnOutput`、`Config.OnOutputEnd` 与 `Config.OnExit` 在读取和等待 - goroutine 启动前固定,运行中不得替换。 + goroutine 启动前固定,运行中不得替换。正常退出时 `OnOutputEnd` 与 `OnExit` + 没有顺序保证;进程组清理失败时必须先调用 `OnExit` 建立进程退出 fence,再关闭 + master 触发 `OnOutputEnd`。 - `creack/pty` 为子进程创建独立 session;主动停止先向 PID 对应的进程组发送 SIGTERM,等待 `Config.TerminationGrace`,超时再发送 SIGKILL。默认宽限 5 秒。 - 直接子进程自然退出后也通过同一个幂等步骤清理仍存活的进程组后代,避免遗留 @@ -29,9 +31,13 @@ 完整的 termination grace。 - 进程退出回调通过 `ExitInfo.CleanupErr` 暴露进程组清理失败;直接子进程退出成功 不得掩盖仍存活但当前用户无权终止的后代。 +- `ProcessGroupAlive` 是 session 层解除持久化 cleanup fence 的唯一探测入口;只有 + signal 0 返回 `ESRCH` 才报告不存在,`EPERM` 仍报告存活。 - PTY master 只在直接子进程已回收后关闭;`Close` 幂等,并等待读取、进程退出和 - 全部回调及 writer 完成。自然退出仍在读取结束后关闭 master,并与主动关闭共享 - 唯一的 master close。 + 全部回调及 writer 完成。并发 `Close` 在关闭 master 前还必须等待 `OnExit` 返回, + 不能越过进程组清理失败建立的退出 fence。健康的自然退出先排空读取再关闭 master; + 进程组清理失败时先关闭 master 打断继承 slave 的不可清理后代,再等待读取结束。 + 两条路径共享唯一的 master close。 - 进程退出码经 `WaitCh` 返回,供状态机迁移到 `Stopped`/`Done`。 ## 约束 diff --git a/internal/pty/process_group_unix.go b/internal/pty/process_group_unix.go index 9f47688..eafe67d 100644 --- a/internal/pty/process_group_unix.go +++ b/internal/pty/process_group_unix.go @@ -4,6 +4,7 @@ package pty import ( "errors" + "fmt" "syscall" ) @@ -45,6 +46,14 @@ func processGroupAlive(pid int) (bool, error) { return processGroupAliveWith(unixProcessGroupSignal, pid) } +// ProcessGroupAlive reports whether the process group remains reachable. +func ProcessGroupAlive(pid int) (bool, error) { + if pid <= 0 { + return false, fmt.Errorf("pty: process group PID must be positive") + } + return processGroupAlive(pid) +} + func processGroupAliveWith(signal processGroupSignalFunc, pid int) (bool, error) { err := signal(pid, 0) switch { diff --git a/internal/pty/pty.go b/internal/pty/pty.go index 02e0d76..12c51a2 100644 --- a/internal/pty/pty.go +++ b/internal/pty/pty.go @@ -122,6 +122,7 @@ type Session struct { onExit func(info ExitInfo) readDone chan struct{} processExited chan struct{} + exitNotified chan struct{} done chan struct{} // WaitCh 返回进程退出信息(Close 后仍可读取一次)。 @@ -184,6 +185,7 @@ func startWithProcessGroupSignal( onExit: cfg.OnExit, readDone: make(chan struct{}), processExited: make(chan struct{}), + exitNotified: make(chan struct{}), done: make(chan struct{}), WaitCh: make(chan ExitInfo, 1), } @@ -286,6 +288,9 @@ func (s *Session) waitLoop() { s.beginClose() close(s.processExited) s.closeProcessGroup() + s.mu.Lock() + info.CleanupErr = s.groupCloseErr + s.mu.Unlock() if err != nil { var ee *exec.ExitError if errors.As(err, &ee) { @@ -295,12 +300,18 @@ func (s *Session) waitLoop() { } else { info.Code = 0 } - s.mu.Lock() - info.CleanupErr = s.groupCloseErr - s.mu.Unlock() - if s.onExit != nil { + exitNotified := false + if info.CleanupErr != nil && s.onExit != nil { + s.onExit(info) + exitNotified = true + } + if info.CleanupErr != nil { + s.closeMaster() + } + if !exitNotified && s.onExit != nil { s.onExit(info) } + close(s.exitNotified) select { case s.WaitCh <- info: default: @@ -524,6 +535,7 @@ func (s *Session) Close() error { s.beginClose() s.closeProcessGroup() <-s.processExited + <-s.exitNotified s.closeMaster() s.joinWriter() <-s.done diff --git a/internal/pty/pty_test.go b/internal/pty/pty_test.go index f412cee..705a7cd 100644 --- a/internal/pty/pty_test.go +++ b/internal/pty/pty_test.go @@ -639,6 +639,128 @@ func TestCloseReportsInaccessibleProcessGroup(t *testing.T) { } } +func TestCleanupFailureClosesMasterHeldBySurvivingDescendant(t *testing.T) { + const grace = time.Millisecond + childPIDPath := filepath.Join(t.TempDir(), "child.pid") + childReadyPath := filepath.Join(t.TempDir(), "child.ready") + exits := make(chan ExitInfo, 1) + exitStarted := make(chan struct{}) + releaseExit := make(chan struct{}) + outputEnded := make(chan struct{}) + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("create inherited output pipe: %v", err) + } + command := exec.Command( + os.Args[0], + ptyHelperArgs( + "natural-group-leader", + childPIDPath, + childReadyPath, + )..., + ) + command.Env = append(os.Environ(), "DROVE_PTY_HELPER=1") + command.Stdout = writer + command.Stderr = writer + if err := command.Start(); err != nil { + _ = reader.Close() + _ = writer.Close() + t.Fatalf("start process helper: %v", err) + } + if err := writer.Close(); err != nil { + _ = command.Process.Kill() + _ = reader.Close() + t.Fatalf("close parent output writer: %v", err) + } + + sess := &Session{ + cmd: command, + ptmx: reader, + grace: grace, + groupSignal: func(_ int, _ syscall.Signal) error { return syscall.EPERM }, + onOutputEnd: func(uint64) { + close(outputEnded) + }, + onExit: func(info ExitInfo) { + close(exitStarted) + <-releaseExit + exits <- info + }, + readDone: make(chan struct{}), + processExited: make(chan struct{}), + exitNotified: make(chan struct{}), + done: make(chan struct{}), + WaitCh: make(chan ExitInfo, 1), + } + go sess.readLoop() + go sess.waitLoop() + + select { + case <-sess.processExited: + case <-time.After(2 * time.Second): + t.Fatal("direct child did not exit") + } + rawPID, err := os.ReadFile(childPIDPath) + if err != nil { + t.Fatalf("read child pid: %v", err) + } + childPID, err := strconv.Atoi(string(rawPID)) + if err != nil { + t.Fatalf("parse child pid %q: %v", rawPID, err) + } + select { + case <-exitStarted: + case <-time.After(2 * time.Second): + t.Fatal("exit callback did not start") + } + closeResult := make(chan error, 1) + go func() { + closeResult <- sess.Close() + }() + outputPassedExitFence := false + select { + case <-outputEnded: + outputPassedExitFence = true + case <-time.After(50 * time.Millisecond): + } + close(releaseExit) + if outputPassedExitFence { + t.Fatal("output end ran before the cleanup-failure exit callback") + } + childNeedsCleanup := true + t.Cleanup(func() { + if childNeedsCleanup { + _ = syscall.Kill(childPID, syscall.SIGKILL) + } + }) + + select { + case closeErr := <-closeResult: + if !errors.Is(closeErr, syscall.EPERM) { + t.Fatalf("close error = %v, want EPERM", closeErr) + } + case <-time.After(2 * time.Second): + _ = syscall.Kill(childPID, syscall.SIGKILL) + childNeedsCleanup = false + <-closeResult + t.Fatal("session waited for an inaccessible descendant to close the PTY") + } + if err := syscall.Kill(childPID, 0); err != nil { + t.Fatalf("descendant exited before PTY cleanup completed: %v", err) + } + if exit := waitExit(t, exits); !errors.Is(exit.CleanupErr, syscall.EPERM) { + t.Fatalf("exit cleanup error = %v, want EPERM", exit.CleanupErr) + } + + if err := syscall.Kill(childPID, syscall.SIGKILL); err != nil && + !errors.Is(err, syscall.ESRCH) { + t.Fatalf("kill descendant: %v", err) + } + childNeedsCleanup = false + waitForProcessNotRunning(t, childPID) +} + func TestCloseSuppressesTermPermissionErrorAfterProcessGroupExits(t *testing.T) { const grace = time.Second var signalsMu sync.Mutex diff --git a/internal/session/AGENTS.md b/internal/session/AGENTS.md index 5bea7f8..fa91cac 100644 --- a/internal/session/AGENTS.md +++ b/internal/session/AGENTS.md @@ -28,7 +28,8 @@ 本身不持有 goroutine 或回调。 - 一个全局 Committer goroutine 独占运行时事件序号和写入顺序。Store batch 成功后, Committer 才应用 Agent 投影并按序发布 Hub。workspace removal 使用 typed - operation,在 append 后清除 working directory、关闭 Resume,再发布事件。 + operation,在 append 后清除 working directory、关闭 Resume,再发布事件;该元数据 + 事件不改变 Agent 状态的 UpdatedAt,恢复投影必须保持相同语义。 - `Start(ctx, req)`:校验并默认 `RunMode` → 按 vendor 取适配器 → 构造 agent → 持久化 `starting` → 以统一的 40 行 × 120 列初始尺寸创建带固定回调的 PTY → 创建 terminal actor → 持久化 `working` → 依次放行 signal 与 PTY callback → @@ -76,6 +77,11 @@ - oneshot 自然成功退出为 `done`;interactive、失败退出和已登记的主动停止为 `stopped`。 - PTY 进程组清理失败优先于自然成功或主动停止原因,必须持久化 error 并进入 `stopped`,不得把仍有不可控后代的会话记录为 `done`。 +- PTY 进程组清理失败还必须把 leader PID 作为 typed + `session_lifecycle(process_group_cleanup_failed)` 与退出终态同批持久化。运行时和恢复 + 投影据此关闭 `Status.Resumable`、手动/启动恢复及 workspace cleanup;操作前只有 + `internal/pty` 以 signal 0 明确确认进程组不存在,才能先持久化 + `process_group_cleanup_completed` 再解除门禁。 - `Close()`:拒绝新 Start → 等待进行中的 Start 和 workspace cleanup → 关闭全部 PTY 并等待回调 → 幂等关闭 recording/terminal/observation actor → 清空运行中会话索引。 - Manager 把同一 `terminationGrace` 传给新建和恢复的 PTY;关闭顺序仍按 Agent ID @@ -120,7 +126,14 @@ 只更新已有会话的事件事实。 - 恢复投影只接受紧邻同 Agent `agent.resumed` 的 `Stopped -> Starting`;启动自动恢复 只消费重启前非终态且已有 ref 的一次性候选,并按创建时间排序。候选等待同 Agent - cleanup completion,只有成功安装 resume reservation 后才消费;取消等待保留候选。 + cleanup completion;自动或普通 `Resume` 消费此候选时,恢复进程及 required hook + 均成功后必须用后台上下文持久化 `session_lifecycle(startup_resume_completed)`,只有 + 该事件 durable 后才清除内存资格并释放 resume reservation,避免短命进程退出后 + workspace tombstone 抢先提交。取消等待、PTY 启动失败、required hook 失败及完成事件 + 未落库都保留候选。用户停止仍在进程组清理失败时以同批 + `session_lifecycle(startup_resume_cancelled)` 持久取消候选,不能在 cleanup fence + 解除后再次自动恢复。daemon 重启生成并持久化的 recovery `Stopped` 在投影读到完成 + 或取消事件前始终保留该资格,连续启动失败不能把候选静默降级为普通停止会话。 - 信号与状态证据 reader 同时接受 v1、v2、typed screen v3 和 typed terminal v4;v2 的 notify 只在 fallback 下确认 Idle。未知补充版本按既有计数策略跳过,已知畸形版本报错。 adapter 标记为忽略的厂商内部通知不提交事件。 diff --git a/internal/session/detector.go b/internal/session/detector.go index 4e816a3..8b5a00a 100644 --- a/internal/session/detector.go +++ b/internal/session/detector.go @@ -59,6 +59,7 @@ func (t *systemObservationTimer) Stop() bool { type observationRequest struct { observation detect.Observation + drafts []event.Draft result chan error } @@ -148,6 +149,13 @@ func (a *observationActor) Deliver( } func (a *observationActor) Terminate(observation detect.Observation) error { + return a.terminate(observation, nil) +} + +func (a *observationActor) terminate( + observation detect.Observation, + drafts []event.Draft, +) error { a.admissionMu.Lock() if a.closing { a.admissionMu.Unlock() @@ -158,6 +166,7 @@ func (a *observationActor) Terminate(observation detect.Observation) error { request := observationRequest{ observation: observation, + drafts: append([]event.Draft(nil), drafts...), result: make(chan error, 1), } select { @@ -199,12 +208,18 @@ func (a *observationActor) run() { for { select { case request := <-a.requests: - err := a.handle(request.observation, &timer, &timerC, &timerRef) + err := a.handle( + request.observation, + request.drafts, + &timer, + &timerC, + &timerRef, + ) request.result <- err case firedAt := <-timerC: observation, err := detect.ObserveTimer(timerRef, firedAt) if err == nil { - _ = a.handle(observation, &timer, &timerC, &timerRef) + _ = a.handle(observation, nil, &timer, &timerC, &timerRef) } case <-a.stop: for { @@ -212,6 +227,7 @@ func (a *observationActor) run() { case request := <-a.requests: err := a.handle( request.observation, + request.drafts, &timer, &timerC, &timerRef, @@ -230,6 +246,7 @@ func (a *observationActor) run() { func (a *observationActor) handle( observation detect.Observation, + drafts []event.Draft, timer *observationTimer, timerC *<-chan time.Time, timerRef *detect.TimerRef, @@ -243,6 +260,10 @@ func (a *observationActor) handle( return err } if decision.Duplicate() { + if len(drafts) > 0 { + _, err := a.committer.CommitEvents(context.Background(), drafts) + return err + } return nil } signal, outcome, ok := decision.Signal() @@ -253,18 +274,20 @@ func (a *observationActor) handle( if err != nil { return err } + decisionDrafts := []event.Draft{ + event.NewAgentSignalDraft( + string(a.target.ID()), + string(a.target.ID()), + string(payload), + ), + } + decisionDrafts = append(decisionDrafts, drafts...) _, err = a.committer.CommitDecision( context.Background(), a.managed, a.state, decision, - []event.Draft{ - event.NewAgentSignalDraft( - string(a.target.ID()), - string(a.target.ID()), - string(payload), - ), - }, + decisionDrafts, ) if err != nil { return fmt.Errorf("%w: %v", errObservationCommit, err) diff --git a/internal/session/managed.go b/internal/session/managed.go index 60f0f83..9c9e35b 100644 --- a/internal/session/managed.go +++ b/internal/session/managed.go @@ -14,6 +14,9 @@ type managedAgent struct { workspaceMu sync.RWMutex workspace workspaceRuntimeState + + processGroupMu sync.Mutex + processGroupCleanup processGroupCleanupState } type workspaceRuntimeState struct { @@ -23,6 +26,11 @@ type workspaceRuntimeState struct { removed bool } +type processGroupCleanupState struct { + pending bool + pid int +} + func newManagedAgent(target *agent.Agent) *managedAgent { return &managedAgent{agent: target} } @@ -78,6 +86,13 @@ func (m *managedAgent) applyWorkspaceRemoved() { } func (m *managedAgent) shouldResumeOnStart() bool { + if m.processGroupCleanupPending() { + return false + } + return m.hasResumeOnStart() +} + +func (m *managedAgent) hasResumeOnStart() bool { m.workspaceMu.RLock() defer m.workspaceMu.RUnlock() return m.workspace.resumeOnStart && @@ -85,8 +100,46 @@ func (m *managedAgent) shouldResumeOnStart() bool { !m.workspace.removed } -func (m *managedAgent) consumeResumeOnStart() { +func (m *managedAgent) completeResumeOnStart( + commit func() (commitReceipt, error), +) (bool, error) { + m.workspaceMu.Lock() + defer m.workspaceMu.Unlock() + if !m.workspace.resumeOnStart || + m.workspace.removalPending || + m.workspace.removed { + return false, nil + } + receipt, err := commit() + if receipt.Durable { + m.workspace.resumeOnStart = false + } + return true, err +} + +func (m *managedAgent) cancelResumeOnStartAfter( + commit func(pending bool) error, +) error { m.workspaceMu.Lock() + defer m.workspaceMu.Unlock() + if err := commit(m.workspace.resumeOnStart); err != nil { + return err + } m.workspace.resumeOnStart = false - m.workspaceMu.Unlock() + return nil +} + +func (m *managedAgent) setProcessGroupCleanupPending(pid int) { + m.processGroupMu.Lock() + m.processGroupCleanup = processGroupCleanupState{ + pending: true, + pid: pid, + } + m.processGroupMu.Unlock() +} + +func (m *managedAgent) processGroupCleanupPending() bool { + m.processGroupMu.Lock() + defer m.processGroupMu.Unlock() + return m.processGroupCleanup.pending } diff --git a/internal/session/process_group.go b/internal/session/process_group.go new file mode 100644 index 0000000..8d05f0e --- /dev/null +++ b/internal/session/process_group.go @@ -0,0 +1,81 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/Duang777/drove/internal/agent" + "github.com/Duang777/drove/internal/event" +) + +func (m *Manager) resolveProcessGroupCleanup( + ctx context.Context, + id agent.ID, + managed *managedAgent, +) (bool, error) { + if ctx == nil { + ctx = context.Background() + } + managed.processGroupMu.Lock() + defer managed.processGroupMu.Unlock() + + state := managed.processGroupCleanup + if !state.pending { + return false, nil + } + if state.pid <= 0 { + return true, fmt.Errorf( + "%w: agent %q has invalid process group PID %d", + errProcessGroupCleanupPending, + id, + state.pid, + ) + } + alive, err := m.processGroupAlive(state.pid) + if err != nil { + return true, errors.Join( + errProcessGroupCleanupPending, + fmt.Errorf( + "session: inspect process group %d for agent %q: %w", + state.pid, + id, + err, + ), + ) + } + if alive { + return true, nil + } + + payload, err := json.Marshal(processGroupCleanupPayload{ + Version: 1, + PID: state.pid, + }) + if err != nil { + return true, fmt.Errorf( + "session: encode process group cleanup completion: %w", + err, + ) + } + receipt, commitErr := m.committer.CommitEvents( + ctx, + []event.Draft{event.NewSessionLifecycleDraft( + string(id), + string(id), + processGroupCleanupCompleted, + string(payload), + )}, + ) + if receipt.Durable { + managed.processGroupCleanup = processGroupCleanupState{} + } + if commitErr != nil { + return !receipt.Durable, fmt.Errorf( + "session: persist process group cleanup completion: %w", + commitErr, + ) + } + return false, nil +} diff --git a/internal/session/projection.go b/internal/session/projection.go index d3f7d5c..79b1fb1 100644 --- a/internal/session/projection.go +++ b/internal/session/projection.go @@ -60,19 +60,23 @@ type sessionDraft struct { workingDir string workspace *workspaceMetadata workspaceRemoved bool + processGroupCleanupPID int + restartStopped bool + startupResumePending bool hasCreated bool hasState bool } type recoveryPlan struct { - Snapshots []agent.RestoreSnapshot - VendorSessionRefs map[string]string - WorkingDirs map[string]string - Workspaces map[string]workspaceMetadata - ResumeOnStart map[string]bool - WorkspaceRemoved map[string]bool - Reconciliation []store.EventRow - Report RecoveryReport + Snapshots []agent.RestoreSnapshot + VendorSessionRefs map[string]string + WorkingDirs map[string]string + Workspaces map[string]workspaceMetadata + ResumeOnStart map[string]bool + WorkspaceRemoved map[string]bool + ProcessGroupCleanup map[string]int + Reconciliation []store.EventRow + Report RecoveryReport } func newRecoveryProjector() *recoveryProjector { @@ -165,8 +169,16 @@ func (p *recoveryProjector) applyLifecycle(row store.EventRow) error { switch row.Reason { case "created": return p.applyCreated(row) + case startupResumeCompletedReason: + return p.applyStartupResumeCompleted(row) + case startupResumeCancelledReason: + return p.applyStartupResumeCancelled(row) case workspaceRemovedReason: return p.applyWorkspaceRemoved(row) + case processGroupCleanupFailed: + return p.applyProcessGroupCleanupFailed(row) + case processGroupCleanupCompleted: + return p.applyProcessGroupCleanupCompleted(row) default: return projectionError(row, "unknown lifecycle reason %q", row.Reason) } @@ -282,6 +294,78 @@ func (p *recoveryProjector) applyCreated(row store.EventRow) error { return nil } +func (p *recoveryProjector) applyStartupResumeCompleted( + row store.EventRow, +) error { + draft := p.draft(row) + if !draft.hasCreated && !draft.hasState { + return projectionError( + row, + "startup resume completion has no session history", + ) + } + var payload startupResumeCompletedPayload + if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil { + return projectionWrapError( + row, + "decode startup resume completion", + err, + ) + } + if payload.Version != 1 { + return projectionError( + row, + "unsupported startup resume completion version %d", + payload.Version, + ) + } + if !draft.startupResumePending { + return projectionError( + row, + "startup resume completion has no restart resume candidate", + ) + } + draft.restartStopped = false + draft.startupResumePending = false + return nil +} + +func (p *recoveryProjector) applyStartupResumeCancelled( + row store.EventRow, +) error { + draft := p.draft(row) + if !draft.hasCreated && !draft.hasState { + return projectionError( + row, + "startup resume cancellation has no session history", + ) + } + var payload startupResumeCancelledPayload + if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil { + return projectionWrapError( + row, + "decode startup resume cancellation", + err, + ) + } + if payload.Version != 1 { + return projectionError( + row, + "unsupported startup resume cancellation version %d", + payload.Version, + ) + } + if !draft.startupResumePending { + return projectionError( + row, + "startup resume cancellation has no restart resume candidate", + ) + } + draft.restartStopped = false + draft.startupResumePending = false + return nil +} + func (p *recoveryProjector) applyWorkspaceRemoved(row store.EventRow) error { draft := p.draft(row) if !draft.hasCreated && !draft.hasState { @@ -290,6 +374,12 @@ func (p *recoveryProjector) applyWorkspaceRemoved(row store.EventRow) error { if draft.workspaceRemoved { return projectionError(row, "duplicate workspace removal") } + if draft.processGroupCleanupPID != 0 { + return projectionError( + row, + "workspace removal precedes process group cleanup completion", + ) + } var payload workspaceRemovedPayload if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil { return projectionWrapError(row, "decode workspace removal", err) @@ -302,10 +392,91 @@ func (p *recoveryProjector) applyWorkspaceRemoved(row store.EventRow) error { ) } draft.workspaceRemoved = true - draft.updatedAt = row.Timestamp + draft.restartStopped = false + draft.startupResumePending = false return nil } +func (p *recoveryProjector) applyProcessGroupCleanupFailed( + row store.EventRow, +) error { + draft := p.draft(row) + if !draft.hasCreated && !draft.hasState { + return projectionError( + row, + "process group cleanup failure has no session history", + ) + } + if draft.workspaceRemoved { + return projectionError( + row, + "process group cleanup failure follows workspace removal", + ) + } + if draft.processGroupCleanupPID != 0 { + return projectionError(row, "duplicate process group cleanup failure") + } + payload, err := decodeProcessGroupCleanupPayload(row) + if err != nil { + return err + } + draft.processGroupCleanupPID = payload.PID + return nil +} + +func (p *recoveryProjector) applyProcessGroupCleanupCompleted( + row store.EventRow, +) error { + draft := p.draft(row) + if draft.processGroupCleanupPID == 0 { + return projectionError( + row, + "process group cleanup completion has no pending failure", + ) + } + payload, err := decodeProcessGroupCleanupPayload(row) + if err != nil { + return err + } + if payload.PID != draft.processGroupCleanupPID { + return projectionError( + row, + "process group cleanup completion PID %d does not match pending PID %d", + payload.PID, + draft.processGroupCleanupPID, + ) + } + draft.processGroupCleanupPID = 0 + return nil +} + +func decodeProcessGroupCleanupPayload( + row store.EventRow, +) (processGroupCleanupPayload, error) { + var payload processGroupCleanupPayload + if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil { + return processGroupCleanupPayload{}, projectionWrapError( + row, + "decode process group cleanup lifecycle", + err, + ) + } + if payload.Version != 1 { + return processGroupCleanupPayload{}, projectionError( + row, + "unsupported process group cleanup version %d", + payload.Version, + ) + } + if payload.PID <= 0 { + return processGroupCleanupPayload{}, projectionError( + row, + "process group cleanup PID must be positive", + ) + } + return payload, nil +} + func (p *recoveryProjector) applyState(row store.EventRow) error { if row.SessionID == "" { return projectionError(row, "state event has empty session ID") @@ -323,6 +494,13 @@ func (p *recoveryProjector) applyState(row store.EventRow) error { return projectionError(row, "invalid to state %q", row.To) } isResume := from == agent.StateStopped && to == agent.StateStarting + draft := p.draft(row) + if isResume && draft.processGroupCleanupPID != 0 { + return projectionError( + row, + "stopped -> starting precedes process group cleanup completion", + ) + } if isResume && (p.lastResumeSeq+1 != row.Seq || p.lastResumeID != row.SessionID) { return projectionError( @@ -341,7 +519,6 @@ func (p *recoveryProjector) applyState(row store.EventRow) error { p.report.UnknownStateEvidenceVersions++ } - draft := p.draft(row) if !draft.hasState { draft.state = from } else if draft.state != from { @@ -357,6 +534,19 @@ func (p *recoveryProjector) applyState(row store.EventRow) error { p.report.PartialHistory++ } draft.state = to + isRestartStop := to == agent.StateStopped && + from != agent.StateDone && + from != agent.StateStopped && + row.Reason == restartStopReason + if isRestartStop { + draft.restartStopped = true + draft.startupResumePending = true + } else if to == agent.StateStopped && row.Reason == "user stop" { + draft.restartStopped = false + draft.startupResumePending = false + } else if to == agent.StateDone { + draft.restartStopped = false + } if known { draft.lastTransition = evidence } @@ -656,13 +846,14 @@ func (p *recoveryProjector) Finish(recoveryTime time.Time) (recoveryPlan, error) }) plan := recoveryPlan{ - Snapshots: make([]agent.RestoreSnapshot, 0, len(drafts)), - VendorSessionRefs: make(map[string]string), - WorkingDirs: make(map[string]string), - Workspaces: make(map[string]workspaceMetadata), - ResumeOnStart: make(map[string]bool), - WorkspaceRemoved: make(map[string]bool), - Report: p.report, + Snapshots: make([]agent.RestoreSnapshot, 0, len(drafts)), + VendorSessionRefs: make(map[string]string), + WorkingDirs: make(map[string]string), + Workspaces: make(map[string]workspaceMetadata), + ResumeOnStart: make(map[string]bool), + WorkspaceRemoved: make(map[string]bool), + ProcessGroupCleanup: make(map[string]int), + Report: p.report, } nextSeq := p.lastSeq for _, draft := range drafts { @@ -683,7 +874,8 @@ func (p *recoveryProjector) Finish(recoveryTime time.Time) (recoveryPlan, error) plan.ResumeOnStart[draft.id] = !draft.workspaceRemoved && state != agent.StateDone && - state != agent.StateStopped + (state != agent.StateStopped || + draft.restartStopped) } workingDir := draft.workingDir if draft.workspaceRemoved { @@ -696,6 +888,10 @@ func (p *recoveryProjector) Finish(recoveryTime time.Time) (recoveryPlan, error) if draft.workspace != nil { plan.Workspaces[draft.id] = *draft.workspace } + if draft.processGroupCleanupPID != 0 { + plan.ProcessGroupCleanup[draft.id] = + draft.processGroupCleanupPID + } lastError := draft.lastError updatedAt := draft.updatedAt if state != agent.StateStopped { diff --git a/internal/session/projection_test.go b/internal/session/projection_test.go index f049e15..4c24e3f 100644 --- a/internal/session/projection_test.go +++ b/internal/session/projection_test.go @@ -376,6 +376,537 @@ func TestRecoveryProjectorRestoresLatestVendorSessionReference(t *testing.T) { } } +func TestRecoveryProjectorRestoresProcessGroupCleanupFence(t *testing.T) { + base := time.Date(2026, time.October, 6, 15, 0, 0, 0, time.UTC) + rows := []store.EventRow{ + { + Seq: 1, + Timestamp: base, + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "created", + Payload: `{"version":1,"name":"agent","vendor":"claude"}`, + }, + { + Seq: 2, + Timestamp: base.Add(time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "pending", + To: "starting", + }, + { + Seq: 3, + Timestamp: base.Add(2 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "starting", + To: "working", + }, + { + Seq: 4, + Timestamp: base.Add(3 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: processGroupCleanupFailed, + Payload: `{"version":1,"pid":4242}`, + }, + { + Seq: 5, + Timestamp: base.Add(4 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "working", + To: "stopped", + }, + } + project := func(t *testing.T, rows []store.EventRow) recoveryPlan { + t.Helper() + projector := newRecoveryProjector() + for _, row := range rows { + if err := projector.Apply(row); err != nil { + t.Fatalf("apply seq %d: %v", row.Seq, err) + } + } + plan, err := projector.Finish(base.Add(time.Hour)) + if err != nil { + t.Fatalf("finish projection: %v", err) + } + return plan + } + + pending := project(t, rows) + if got := pending.ProcessGroupCleanup["agent-1"]; got != 4242 { + t.Fatalf("restored process group PID = %d, want 4242", got) + } + + rows = append(rows, store.EventRow{ + Seq: 6, + Timestamp: base.Add(5 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: processGroupCleanupCompleted, + Payload: `{"version":1,"pid":4242}`, + }) + completed := project(t, rows) + if _, exists := completed.ProcessGroupCleanup["agent-1"]; exists { + t.Fatalf( + "completed process group cleanup remained pending: %+v", + completed.ProcessGroupCleanup, + ) + } +} + +func TestRecoveryProjectorPreservesResumeAfterPersistedRestartStop( + t *testing.T, +) { + base := time.Date(2026, time.October, 5, 12, 0, 0, 0, time.UTC) + rows := []store.EventRow{ + { + Seq: 1, + Timestamp: base, + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "created", + Payload: `{"version":1,"name":"agent","vendor":"claude"}`, + }, + { + Seq: 2, + Timestamp: base.Add(time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "pending", + To: "starting", + }, + { + Seq: 3, + Timestamp: base.Add(2 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "starting", + To: "working", + }, + { + Seq: 4, + Timestamp: base.Add(3 * time.Second), + Type: string(event.TypeAgentSignal), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "hook", + Payload: `{"version":1,"source":"hook","kind":"observed","vendor":"claude",` + + `"vendor_event":"Notification","scope":"root","vendor_session_ref":"resume-ref",` + + `"confidence":1,"received_at":"2026-10-05T12:00:02Z",` + + `"delivery_id":"550e8400-e29b-41d4-a716-446655440001","outcome":"observed"}`, + }, + } + first := newRecoveryProjector() + for _, row := range rows { + if err := first.Apply(row); err != nil { + t.Fatalf("apply initial seq %d: %v", row.Seq, err) + } + } + firstPlan, err := first.Finish(base.Add(time.Hour)) + if err != nil { + t.Fatalf("finish initial projection: %v", err) + } + rows = append(rows, firstPlan.Reconciliation...) + + restarted := newRecoveryProjector() + for _, row := range rows { + if err := restarted.Apply(row); err != nil { + t.Fatalf("apply restarted seq %d: %v", row.Seq, err) + } + } + restartedPlan, err := restarted.Finish(base.Add(2 * time.Hour)) + if err != nil { + t.Fatalf("finish restarted projection: %v", err) + } + if !restartedPlan.ResumeOnStart["agent-1"] { + t.Fatal("persisted daemon restart stop lost automatic resume intent") + } + if len(restartedPlan.Reconciliation) != 0 { + t.Fatalf( + "restarted reconciliation = %+v, want no duplicate stop", + restartedPlan.Reconciliation, + ) + } + + nextSeq := uint64(len(rows) + 1) + rows = append( + rows, + store.EventRow{ + Seq: nextSeq, + Timestamp: base.Add(3 * time.Hour), + Type: string(event.TypeAgentResumed), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "requested", + Payload: `{"version":1,"vendor_session_ref":"resume-ref"}`, + }, + store.EventRow{ + Seq: nextSeq + 1, + Timestamp: base.Add(3*time.Hour + time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "stopped", + To: "starting", + }, + store.EventRow{ + Seq: nextSeq + 2, + Timestamp: base.Add(3*time.Hour + 2*time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "starting", + To: "stopped", + Reason: "startup failed", + Payload: `{"version":1,"source":"process","event":"process_start_failed",` + + `"confidence":1}`, + }, + ) + failedResume := newRecoveryProjector() + for _, row := range rows { + if err := failedResume.Apply(row); err != nil { + t.Fatalf("apply failed resume seq %d: %v", row.Seq, err) + } + } + failedPlan, err := failedResume.Finish(base.Add(4 * time.Hour)) + if err != nil { + t.Fatalf("finish failed resume projection: %v", err) + } + if !failedPlan.ResumeOnStart["agent-1"] { + t.Fatal("failed startup resume lost automatic resume intent") + } +} + +func TestRecoveryProjectorDoesNotResumeDoneStartupResume(t *testing.T) { + base := time.Date(2026, time.October, 6, 1, 0, 0, 0, time.UTC) + rows := []store.EventRow{ + { + Seq: 1, Timestamp: base, Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", AgentID: "agent-1", Reason: "created", + Payload: `{"version":1,"name":"agent","vendor":"claude"}`, + }, + { + Seq: 2, Timestamp: base.Add(time.Second), Type: string(event.TypeAgentSignal), + SessionID: "agent-1", AgentID: "agent-1", Reason: "hook", + Payload: `{"version":1,"source":"hook","kind":"observed","vendor":"claude",` + + `"vendor_event":"Notification","scope":"root","vendor_session_ref":"resume-ref",` + + `"confidence":1,"received_at":"2026-10-06T01:00:01Z",` + + `"delivery_id":"550e8400-e29b-41d4-a716-446655440001","outcome":"observed"}`, + }, + { + Seq: 3, Timestamp: base.Add(2 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "pending", To: "starting", + }, + { + Seq: 4, Timestamp: base.Add(3 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "starting", To: "working", + }, + { + Seq: 5, Timestamp: base.Add(4 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "working", To: "stopped", + Reason: restartStopReason, + }, + { + Seq: 6, Timestamp: base.Add(5 * time.Second), Type: string(event.TypeAgentResumed), + SessionID: "agent-1", AgentID: "agent-1", Reason: "requested", + Payload: `{"version":1,"vendor_session_ref":"resume-ref"}`, + }, + { + Seq: 7, Timestamp: base.Add(6 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "stopped", To: "starting", + }, + { + Seq: 8, Timestamp: base.Add(7 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "starting", To: "working", + }, + { + Seq: 9, Timestamp: base.Add(8 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "working", To: "done", + }, + } + projector := newRecoveryProjector() + for _, row := range rows { + if err := projector.Apply(row); err != nil { + t.Fatalf("apply seq %d: %v", row.Seq, err) + } + } + plan, err := projector.Finish(base.Add(time.Hour)) + if err != nil { + t.Fatalf("finish projection: %v", err) + } + if plan.ResumeOnStart["agent-1"] { + t.Fatal("completed startup resume remained eligible without completion marker") + } + rows = append(rows, plan.Reconciliation...) + restarted := newRecoveryProjector() + for _, row := range rows { + if err := restarted.Apply(row); err != nil { + t.Fatalf("apply restarted seq %d: %v", row.Seq, err) + } + } + restartedPlan, err := restarted.Finish(base.Add(2 * time.Hour)) + if err != nil { + t.Fatalf("finish restarted projection: %v", err) + } + if restartedPlan.ResumeOnStart["agent-1"] { + t.Fatal("completed startup resume became eligible after reconciliation") + } +} + +func TestRecoveryProjectorRequiresDurableCompletionToConsumeStartupResume( + t *testing.T, +) { + base := time.Date(2026, time.October, 5, 13, 0, 0, 0, time.UTC) + baseRows := []store.EventRow{ + { + Seq: 1, Timestamp: base, Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", AgentID: "agent-1", Reason: "created", + Payload: `{"version":1,"name":"agent","vendor":"claude"}`, + }, + { + Seq: 2, Timestamp: base.Add(time.Second), Type: string(event.TypeAgentSignal), + SessionID: "agent-1", AgentID: "agent-1", Reason: "hook", + Payload: `{"version":1,"source":"hook","kind":"observed","vendor":"claude",` + + `"vendor_event":"Notification","scope":"root","vendor_session_ref":"resume-ref",` + + `"confidence":1,"received_at":"2026-10-05T13:00:01Z",` + + `"delivery_id":"550e8400-e29b-41d4-a716-446655440001","outcome":"observed"}`, + }, + { + Seq: 3, Timestamp: base.Add(2 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "pending", To: "starting", + }, + { + Seq: 4, Timestamp: base.Add(3 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "starting", To: "working", + }, + { + Seq: 5, Timestamp: base.Add(4 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "working", To: "stopped", + Reason: restartStopReason, + }, + { + Seq: 6, Timestamp: base.Add(5 * time.Second), Type: string(event.TypeAgentResumed), + SessionID: "agent-1", AgentID: "agent-1", Reason: "requested", + Payload: `{"version":1,"vendor_session_ref":"resume-ref"}`, + }, + { + Seq: 7, Timestamp: base.Add(6 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "stopped", To: "starting", + }, + { + Seq: 8, Timestamp: base.Add(7 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "starting", To: "working", + }, + } + tests := []struct { + name string + finalRows []store.EventRow + wantResume bool + }{ + { + name: "legacy explicit user stop", + finalRows: []store.EventRow{{ + Seq: 9, Timestamp: base.Add(8 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "working", To: "stopped", + Reason: "user stop", + }}, + wantResume: false, + }, + { + name: "user stop with process group cleanup failure", + finalRows: []store.EventRow{ + { + Seq: 9, Timestamp: base.Add(8 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", AgentID: "agent-1", + Reason: processGroupCleanupFailed, + Payload: `{"version":1,"pid":4242}`, + }, + { + Seq: 10, Timestamp: base.Add(9 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", AgentID: "agent-1", + Reason: startupResumeCancelledReason, + Payload: `{"version":1}`, + }, + { + Seq: 11, Timestamp: base.Add(10 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", + From: "working", To: "stopped", + Reason: "process exited code=0", + }, + { + Seq: 12, Timestamp: base.Add(11 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", AgentID: "agent-1", + Reason: processGroupCleanupCompleted, + Payload: `{"version":1,"pid":4242}`, + }, + }, + wantResume: false, + }, + { + name: "required hook failure", + finalRows: []store.EventRow{{ + Seq: 9, Timestamp: base.Add(8 * time.Second), Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", From: "working", To: "stopped", + Reason: "required hook activation failed", + }}, + wantResume: true, + }, + { + name: "durable completion", + finalRows: []store.EventRow{ + { + Seq: 9, Timestamp: base.Add(8 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", AgentID: "agent-1", + Reason: startupResumeCompletedReason, + Payload: `{"version":1}`, + }, + { + Seq: 10, Timestamp: base.Add(9 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", + From: "working", To: "stopped", + Reason: "process exited", + }, + }, + wantResume: false, + }, + { + name: "terminal exit before durable completion", + finalRows: []store.EventRow{ + { + Seq: 9, Timestamp: base.Add(8 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", AgentID: "agent-1", + From: "working", To: "done", + Reason: "process exited", + }, + { + Seq: 10, Timestamp: base.Add(9 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", AgentID: "agent-1", + Reason: startupResumeCompletedReason, + Payload: `{"version":1}`, + }, + }, + wantResume: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + projector := newRecoveryProjector() + rows := append( + append([]store.EventRow(nil), baseRows...), + test.finalRows..., + ) + for _, row := range rows { + if err := projector.Apply(row); err != nil { + t.Fatalf("apply seq %d: %v", row.Seq, err) + } + } + plan, err := projector.Finish(base.Add(time.Hour)) + if err != nil { + t.Fatalf("finish projection: %v", err) + } + if got := plan.ResumeOnStart["agent-1"]; got != test.wantResume { + t.Fatalf( + "startup resume eligibility = %t, want %t", + got, + test.wantResume, + ) + } + }) + } +} + +func TestRecoveryProjectorDoesNotResumeCompletedSessionAfterRestartStop( + t *testing.T, +) { + base := time.Date(2026, time.October, 5, 12, 0, 0, 0, time.UTC) + rows := []store.EventRow{ + { + Seq: 1, + Timestamp: base, + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "created", + Payload: `{"version":1,"name":"agent","vendor":"claude"}`, + }, + { + Seq: 2, + Timestamp: base.Add(time.Second), + Type: string(event.TypeAgentSignal), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "hook", + Payload: `{"version":1,"source":"hook","kind":"observed","vendor":"claude",` + + `"vendor_event":"Notification","scope":"root","vendor_session_ref":"done-ref",` + + `"confidence":1,"received_at":"2026-10-05T12:00:01Z",` + + `"delivery_id":"550e8400-e29b-41d4-a716-446655440001","outcome":"observed"}`, + }, + { + Seq: 3, + Timestamp: base.Add(2 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "working", + To: "done", + }, + } + first := newRecoveryProjector() + for _, row := range rows { + if err := first.Apply(row); err != nil { + t.Fatalf("apply initial seq %d: %v", row.Seq, err) + } + } + firstPlan, err := first.Finish(base.Add(time.Hour)) + if err != nil { + t.Fatalf("finish initial projection: %v", err) + } + if firstPlan.ResumeOnStart["agent-1"] { + t.Fatal("completed session is eligible for automatic resume") + } + rows = append(rows, firstPlan.Reconciliation...) + + restarted := newRecoveryProjector() + for _, row := range rows { + if err := restarted.Apply(row); err != nil { + t.Fatalf("apply restarted seq %d: %v", row.Seq, err) + } + } + restartedPlan, err := restarted.Finish(base.Add(2 * time.Hour)) + if err != nil { + t.Fatalf("finish restarted projection: %v", err) + } + if restartedPlan.ResumeOnStart["agent-1"] { + t.Fatal("persisted done restart stop enabled automatic resume") + } + if len(restartedPlan.Reconciliation) != 0 { + t.Fatalf( + "restarted reconciliation = %+v, want no duplicate stop", + restartedPlan.Reconciliation, + ) + } +} + func TestRecoveryProjectorReadsVersionTwoMetadataAndEvidence(t *testing.T) { base := time.Date(2026, time.October, 3, 5, 0, 0, 0, time.UTC) projector := newRecoveryProjector() @@ -1073,6 +1604,60 @@ func TestRecoveryProjectorAcceptsValidatedResizeWithoutChangingState(t *testing. } } +func TestRecoveryProjectorWorkspaceRemovalDoesNotChangeStateTimestamp( + t *testing.T, +) { + projector := newRecoveryProjector() + base := time.Date(2026, time.October, 4, 12, 30, 0, 0, time.UTC) + stoppedAt := base.Add(time.Second) + rows := []store.EventRow{ + { + Seq: 1, + Timestamp: base, + Type: string(event.TypeSessionLifecycle), + SessionID: "s1", + AgentID: "s1", + Reason: "created", + Payload: `{"version":1,"name":"agent","vendor":"generic"}`, + }, + { + Seq: 2, + Timestamp: stoppedAt, + Type: string(event.TypeStateChanged), + SessionID: "s1", + AgentID: "s1", + From: string(agent.StatePending), + To: string(agent.StateStopped), + }, + { + Seq: 3, + Timestamp: base.Add(2 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "s1", + AgentID: "s1", + Reason: workspaceRemovedReason, + Payload: `{"version":1}`, + }, + } + for _, row := range rows { + if err := projector.Apply(row); err != nil { + t.Fatalf("apply seq %d: %v", row.Seq, err) + } + } + draft := projector.sessions["s1"] + if draft == nil || !draft.updatedAt.Equal(stoppedAt) { + t.Fatalf("workspace removal changed recovered state time: %+v", draft) + } + plan, err := projector.Finish(base.Add(time.Hour)) + if err != nil { + t.Fatalf("finish projection: %v", err) + } + if len(plan.Snapshots) != 1 || + !plan.Snapshots[0].UpdatedAt.Equal(stoppedAt) { + t.Fatalf("workspace removal snapshot = %+v", plan.Snapshots) + } +} + func TestRecoveryProjectorAcceptsAttachmentAuditWithoutChangingState(t *testing.T) { projector := newRecoveryProjector() base := time.Date(2026, time.October, 4, 13, 0, 0, 0, time.UTC) diff --git a/internal/session/session.go b/internal/session/session.go index 1b19ecc..3f6974b 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -91,10 +91,27 @@ type inputAuditPayload struct { const MaxInputBytes = 64 * 1024 const ( - initialTerminalRows = 40 - initialTerminalColumns = 120 + initialTerminalRows = 40 + initialTerminalColumns = 120 + startupResumeCompletedReason = "startup_resume_completed" + startupResumeCancelledReason = "startup_resume_cancelled" + processGroupCleanupFailed = "process_group_cleanup_failed" + processGroupCleanupCompleted = "process_group_cleanup_completed" ) +type startupResumeCompletedPayload struct { + Version int `json:"version"` +} + +type startupResumeCancelledPayload struct { + Version int `json:"version"` +} + +type processGroupCleanupPayload struct { + Version int `json:"version"` + PID int `json:"pid"` +} + var ( // ErrUnknownAgent 表示目标 Agent 不存在。 ErrUnknownAgent = errors.New("session: unknown agent") @@ -117,7 +134,10 @@ var ( // ErrInputAudit 表示输入已送达,但审计事件持久化失败。 ErrInputAudit = errors.New("session: input delivered but audit failed") // ErrResumeConflict means the Agent cannot begin a native resume now. - ErrResumeConflict = errors.New("session: agent cannot be resumed") + ErrResumeConflict = errors.New("session: agent cannot be resumed") + errProcessGroupCleanupPending = errors.New( + "session: process group cleanup remains unresolved", + ) ) type stopCause uint8 @@ -174,21 +194,22 @@ type Manager struct { committer *committer archive *recording.Archive - signalOrigin string - originConfigured bool - detectConfig detect.Config - clock observationClock - newCredential signalCredentialSource - startPTY func(pty.Config) (launchedSession, error) - terminationGrace time.Duration - injectionEnabled bool - injectionDataDir string - injectionRelay string - injectionModes map[string]agent.SignalInjectionMode - injectionFS signalInjectionFS - signalSocketPath string - workspaces workspaceLifecycle - workspaceErr error + signalOrigin string + originConfigured bool + detectConfig detect.Config + clock observationClock + newCredential signalCredentialSource + startPTY func(pty.Config) (launchedSession, error) + processGroupAlive func(int) (bool, error) + terminationGrace time.Duration + injectionEnabled bool + injectionDataDir string + injectionRelay string + injectionModes map[string]agent.SignalInjectionMode + injectionFS signalInjectionFS + signalSocketPath string + workspaces workspaceLifecycle + workspaceErr error mu sync.RWMutex agents map[agent.ID]*managedAgent @@ -248,7 +269,8 @@ func NewManager( startPTY: func(config pty.Config) (launchedSession, error) { return pty.Start(config) }, - injectionFS: defaultSignalInjectionFS(), + processGroupAlive: pty.ProcessGroupAlive, + injectionFS: defaultSignalInjectionFS(), } for _, option := range options { option(manager) @@ -287,6 +309,9 @@ func Bootstrap( resumeOnStart: plan.ResumeOnStart[string(snapshot.ID)], removed: plan.WorkspaceRemoved[string(snapshot.ID)], }) + if pid := plan.ProcessGroupCleanup[string(snapshot.ID)]; pid > 0 { + managed.setProcessGroupCleanupPending(pid) + } restoredAgents[snapshot.ID] = managed } @@ -683,11 +708,11 @@ func (m *Manager) Resume(ctx context.Context, id agent.ID) (*Status, error) { } defer m.endStart() - managed, entry, ref, err := m.reserveResume(id) + managed, entry, ref, startupResume, err := m.reserveResume(ctx, id) if err != nil { return nil, err } - return m.resumeReserved(ctx, id, managed, entry, ref) + return m.resumeReserved(ctx, id, managed, entry, ref, startupResume) } func (m *Manager) resumeReserved( @@ -696,13 +721,9 @@ func (m *Manager) resumeReserved( managed *managedAgent, entry adapter.Entry, ref string, + startupResume bool, ) (*Status, error) { - reserved := true - defer func() { - if reserved { - m.releaseResume(id) - } - }() + defer m.releaseResume(id) target := managed.agent command, err := entry.ResumeCommand( @@ -791,9 +812,7 @@ func (m *Manager) resumeReserved( m.mu.Lock() m.sessions[id] = running - delete(m.resuming, id) m.mu.Unlock() - reserved = false if err := m.activate(ctx, activation{ id: id, @@ -809,6 +828,11 @@ func (m *Manager) resumeReserved( }); err != nil { return nil, err } + if startupResume { + if err := m.completeStartupResume(id, managed); err != nil { + return nil, err + } + } return m.Status(id) } @@ -827,11 +851,38 @@ func closePreparedRuntime(running *runningSession) error { } func (m *Manager) reserveResume( + ctx context.Context, id agent.ID, -) (*managedAgent, adapter.Entry, string, error) { +) (*managedAgent, adapter.Entry, string, bool, error) { + managed, ok := m.managed(id) + if !ok { + return nil, adapter.Entry{}, "", false, fmt.Errorf( + "%w: %q", + ErrUnknownAgent, + id, + ) + } + pending, err := m.resolveProcessGroupCleanup(ctx, id, managed) + if err != nil { + return nil, adapter.Entry{}, "", false, errors.Join( + ErrResumeConflict, + err, + ) + } + if pending { + return nil, adapter.Entry{}, "", false, fmt.Errorf( + "%w: agent %q process group cleanup remains unresolved", + ErrResumeConflict, + id, + ) + } m.mu.Lock() defer m.mu.Unlock() - return m.reserveResumeLocked(id) + managed, entry, ref, err := m.reserveResumeLocked(id) + if err != nil { + return nil, adapter.Entry{}, "", false, err + } + return managed, entry, ref, managed.hasResumeOnStart(), nil } func (m *Manager) reserveResumeLocked( @@ -877,6 +928,13 @@ func (m *Manager) reserveResumeLocked( id, ) } + if managed.processGroupCleanupPending() { + return nil, adapter.Entry{}, "", fmt.Errorf( + "%w: agent %q process group cleanup remains unresolved", + ErrResumeConflict, + id, + ) + } if managed.agent.State() != agent.StateStopped { return nil, adapter.Entry{}, "", fmt.Errorf( "%w: agent %q is not stopped", @@ -999,7 +1057,7 @@ func (m *Manager) Close() error { func (m *Manager) Stop(id agent.ID) error { a, ok := m.agent(id) if !ok { - return fmt.Errorf("session: unknown agent %q", id) + return fmt.Errorf("%w: %q", ErrUnknownAgent, id) } m.mu.RLock() @@ -1054,6 +1112,7 @@ func (m *Manager) Status(id agent.ID) (*Status, error) { !cleaning && !workspaceState.removalPending && !workspaceState.removed && + !managed.processGroupCleanupPending() && ref != "" && exactVendor && entry.SupportsResume() @@ -1121,7 +1180,7 @@ func (m *Manager) ResumeOnStart(ctx context.Context) []StartupResumeResult { m.mu.RLock() candidates := make([]candidate, 0) for id, managed := range m.agents { - if !managed.shouldResumeOnStart() { + if !managed.hasResumeOnStart() { continue } candidates = append(candidates, candidate{ @@ -1169,7 +1228,7 @@ func (m *Manager) resumeOnStart( m.mu.Lock() managed, exists := m.agents[id] - if !exists || !managed.shouldResumeOnStart() { + if !exists || !managed.hasResumeOnStart() { m.mu.Unlock() m.endStart() return false, nil @@ -1188,20 +1247,77 @@ func (m *Manager) resumeOnStart( ) } } - managed, entry, ref, err := m.reserveResumeLocked(id) - if err == nil { - managed.consumeResumeOnStart() + m.mu.Unlock() + + pending, err := m.resolveProcessGroupCleanup(ctx, id, managed) + if err != nil { + m.endStart() + return true, errors.Join(ErrResumeConflict, err) + } + if pending { + m.endStart() + return true, fmt.Errorf( + "%w: agent %q process group cleanup remains unresolved", + ErrResumeConflict, + id, + ) } + + m.mu.Lock() + current, exists := m.agents[id] + if !exists || current != managed || !managed.shouldResumeOnStart() { + m.mu.Unlock() + m.endStart() + return false, nil + } + managed, entry, ref, err := m.reserveResumeLocked(id) m.mu.Unlock() if err != nil { m.endStart() return false, err } - _, err = m.resumeReserved(ctx, id, managed, entry, ref) + _, err = m.resumeReserved(ctx, id, managed, entry, ref, true) + if err != nil { + m.endStart() + return true, err + } m.endStart() - return true, err + return true, nil + } +} + +func (m *Manager) completeStartupResume( + id agent.ID, + managed *managedAgent, +) error { + payload, err := json.Marshal(startupResumeCompletedPayload{Version: 1}) + if err != nil { + return fmt.Errorf( + "session: encode startup resume completion: %w", + err, + ) + } + _, commitErr := managed.completeResumeOnStart( + func() (commitReceipt, error) { + return m.committer.CommitEvents( + context.Background(), + []event.Draft{event.NewSessionLifecycleDraft( + string(id), + string(id), + startupResumeCompletedReason, + string(payload), + )}, + ) + }, + ) + if commitErr != nil { + return fmt.Errorf( + "session: persist startup resume completion: %w", + commitErr, + ) } + return nil } // Replay 返回某会话的事件流(来自 store,按 seq 升序)。 @@ -1504,6 +1620,19 @@ func (m *Manager) onExit(id agent.ID, running *runningSession, info pty.ExitInfo } defer m.markExitHandled(id, running) defer m.finalizeSignalInjection(id, running) + cleanupPending := info.CleanupErr != nil + cleanupPID := info.PID + if cleanupPending && cleanupPID <= 0 && running.process != nil { + cleanupPID = running.process.PID() + } + defer func() { + if !cleanupPending { + return + } + if managed, exists := m.managed(id); exists { + managed.setProcessGroupCleanupPending(cleanupPID) + } + }() if running.observer == nil { return } @@ -1512,6 +1641,7 @@ func (m *Manager) onExit(id agent.ID, running *runningSession, info pty.ExitInfo exitKind := detect.ExitFailure reason := "" errorMessage := "" + var lifecycleDrafts []event.Draft switch { case info.CleanupErr != nil: exitErr := info.CleanupErr @@ -1519,6 +1649,33 @@ func (m *Manager) onExit(id agent.ID, running *runningSession, info pty.ExitInfo exitErr = errors.Join(info.Err, info.CleanupErr) } errorMessage = exitErr.Error() + if cleanupPID <= 0 { + m.committer.Fail(fmt.Errorf( + "session: process group cleanup failed for agent %q without a valid PID", + id, + )) + break + } + payload, err := json.Marshal(processGroupCleanupPayload{ + Version: 1, + PID: cleanupPID, + }) + if err != nil { + m.committer.Fail(fmt.Errorf( + "session: encode process group cleanup failure: %w", + err, + )) + break + } + lifecycleDrafts = append( + lifecycleDrafts, + event.NewSessionLifecycleDraft( + string(id), + string(id), + processGroupCleanupFailed, + string(payload), + ), + ) case cause == stopCauseUser: exitKind = detect.ExitStopped reason = "user stop" @@ -1546,7 +1703,34 @@ func (m *Manager) onExit(id agent.ID, running *runningSession, info pty.ExitInfo if err != nil { return } - _ = running.observer.Terminate(observation) + terminate := func(cancelStartupResume bool) error { + drafts := append([]event.Draft(nil), lifecycleDrafts...) + if cancelStartupResume && info.CleanupErr != nil { + payload, err := json.Marshal(startupResumeCancelledPayload{ + Version: 1, + }) + if err != nil { + return fmt.Errorf( + "session: encode startup resume cancellation: %w", + err, + ) + } + drafts = append(drafts, event.NewSessionLifecycleDraft( + string(id), + string(id), + startupResumeCancelledReason, + string(payload), + )) + } + return running.observer.terminate(observation, drafts) + } + if cause == stopCauseUser { + if managed, exists := m.managed(id); exists { + _ = managed.cancelResumeOnStartAfter(terminate) + return + } + } + _ = terminate(false) } func processObservation( diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 65c2504..eda364f 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -206,6 +206,121 @@ func TestBootstrapRestoresLegacySessionIdempotently(t *testing.T) { } } +func TestBootstrapRestoresProcessGroupCleanupFence(t *testing.T) { + st := newTestStore(t) + base := time.Date(2026, time.October, 6, 15, 30, 0, 0, time.UTC) + rows := []store.EventRow{ + { + Seq: 1, + Timestamp: base, + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "created", + Payload: `{"version":2,"name":"agent","vendor":"claude",` + + `"mode":"interactive","hook_policy":"off"}`, + }, + { + Seq: 2, + Timestamp: base.Add(time.Second), + Type: string(event.TypeAgentSignal), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: "hook", + Payload: `{"version":1,"source":"hook","kind":"observed","vendor":"claude",` + + `"vendor_event":"Notification","scope":"root","vendor_session_ref":"resume-ref",` + + `"confidence":1,"received_at":"2026-10-06T15:30:01Z",` + + `"delivery_id":"550e8400-e29b-41d4-a716-446655440001","outcome":"observed"}`, + }, + { + Seq: 3, + Timestamp: base.Add(2 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "pending", + To: "starting", + }, + { + Seq: 4, + Timestamp: base.Add(3 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "starting", + To: "working", + }, + { + Seq: 5, + Timestamp: base.Add(4 * time.Second), + Type: string(event.TypeSessionLifecycle), + SessionID: "agent-1", + AgentID: "agent-1", + Reason: processGroupCleanupFailed, + Payload: `{"version":1,"pid":4242}`, + }, + { + Seq: 6, + Timestamp: base.Add(5 * time.Second), + Type: string(event.TypeError), + SessionID: "agent-1", + AgentID: "agent-1", + Payload: "process group remains", + }, + { + Seq: 7, + Timestamp: base.Add(6 * time.Second), + Type: string(event.TypeStateChanged), + SessionID: "agent-1", + AgentID: "agent-1", + From: "working", + To: "stopped", + }, + } + if _, err := st.AppendEvents(context.Background(), 0, rows); err != nil { + t.Fatalf("seed cleanup failure history: %v", err) + } + + result, err := Bootstrap(context.Background(), adapter.NewRegistry(), st) + if err != nil { + t.Fatalf("bootstrap cleanup failure: %v", err) + } + defer result.Manager.Close() + result.Manager.processGroupAlive = func(pid int) (bool, error) { + if pid != 4242 { + t.Fatalf("process group PID = %d, want 4242", pid) + } + return true, nil + } + status, err := result.Manager.Status("agent-1") + if err != nil { + t.Fatalf("restored status: %v", err) + } + if status.State != agent.StateStopped || status.Resumable { + t.Fatalf("restored status = %+v", status) + } + if _, err := result.Manager.Resume( + context.Background(), + "agent-1", + ); !errors.Is(err, ErrResumeConflict) { + t.Fatalf("resume restored cleanup fence = %v", err) + } + + result.Manager.processGroupAlive = func(int) (bool, error) { + return false, nil + } + result.Manager.startPTY = func(pty.Config) (launchedSession, error) { + return &fakeProcessSession{}, nil + } + resumed, err := result.Manager.Resume(context.Background(), "agent-1") + if err != nil { + t.Fatalf("resume after restored process group disappeared: %v", err) + } + if resumed.State != agent.StateWorking { + t.Fatalf("resumed status = %+v", resumed) + } +} + func TestBootstrapAcceptsPreUpgradeUnicodeVendorSessionID(t *testing.T) { st := newTestStore(t) base := time.Date(2026, time.October, 3, 6, 0, 0, 0, time.UTC) @@ -412,8 +527,8 @@ func TestStopRestoredStoppedAgentIsIdempotent(t *testing.T) { if err := result.Manager.Stop("agent-1"); err != nil { t.Fatalf("stop restored agent: %v", err) } - if err := result.Manager.Stop("missing"); err == nil { - t.Fatal("stop unknown agent succeeded") + if err := result.Manager.Stop("missing"); !errors.Is(err, ErrUnknownAgent) { + t.Fatalf("stop unknown agent error = %v, want ErrUnknownAgent", err) } if _, err := result.Manager.SendInput("agent-1", []byte("input")); !errors.Is(err, ErrNotAttached) { t.Fatalf("send input to restored agent error = %v, want ErrNotAttached", err) @@ -1164,6 +1279,236 @@ func TestResumeOnStartRunsEligibleAgentsInCreationOrderOnce(t *testing.T) { } } +func TestResumeOnStartRetriesFailedCandidate(t *testing.T) { + manager, _ := newTestManager(t) + managed := addStoppedAgent(t, manager, "agent-1", "claude", "vendor-ref") + state := managed.workspaceState() + state.resumeOnStart = true + managed.setWorkspaceState(state) + + attempts := 0 + manager.startPTY = func(pty.Config) (launchedSession, error) { + attempts++ + if attempts == 1 { + return nil, errors.New("exec unavailable") + } + return &fakeProcessSession{}, nil + } + + first := manager.ResumeOnStart(context.Background()) + if len(first) != 1 || + first[0].AgentID != managed.agent.ID() || + first[0].Err == nil { + t.Fatalf("first startup resume results = %+v", first) + } + if !managed.shouldResumeOnStart() { + t.Fatal("failed startup resume consumed candidate") + } + + second := manager.ResumeOnStart(context.Background()) + if len(second) != 1 || + second[0].AgentID != managed.agent.ID() || + second[0].Err != nil { + t.Fatalf("second startup resume results = %+v", second) + } + if managed.shouldResumeOnStart() { + t.Fatal("successful startup resume retained candidate") + } + if third := manager.ResumeOnStart(context.Background()); len(third) != 0 { + t.Fatalf("third startup resume results = %+v, want none", third) + } +} + +func TestResumeOnStartPersistsCompletionAfterContextCancellation(t *testing.T) { + manager, st := newTestManager(t) + managed := addStoppedAgent(t, manager, "agent-1", "claude", "vendor-ref") + state := managed.workspaceState() + state.resumeOnStart = true + managed.setWorkspaceState(state) + + ctx, cancel := context.WithCancel(context.Background()) + manager.startPTY = func(pty.Config) (launchedSession, error) { + cancel() + return &fakeProcessSession{}, nil + } + + results := manager.ResumeOnStart(ctx) + if len(results) != 1 || + results[0].AgentID != managed.agent.ID() || + results[0].Err != nil { + t.Fatalf("startup resume results = %+v", results) + } + if managed.shouldResumeOnStart() { + t.Fatal("durably completed startup resume retained candidate") + } + rows, err := st.Replay(string(managed.agent.ID())) + if err != nil { + t.Fatalf("replay startup resume: %v", err) + } + last := rows[len(rows)-1] + if last.Type != string(event.TypeSessionLifecycle) || + last.Reason != startupResumeCompletedReason || + last.Payload != `{"version":1}` { + t.Fatalf("startup resume completion = %+v", last) + } +} + +func TestManualResumeCompletesStartupResumeIntent(t *testing.T) { + manager, st := newTestManager(t) + managed := addStoppedAgent(t, manager, "agent-1", "claude", "vendor-ref") + state := managed.workspaceState() + state.resumeOnStart = true + managed.setWorkspaceState(state) + manager.committer.Close() + commitStore := &resumeReservationCommitStore{ + commitStore: st, + manager: manager, + id: managed.agent.ID(), + } + manager.committer = newCommitter(0, commitStore, manager.hub) + manager.startPTY = func(pty.Config) (launchedSession, error) { + return &fakeProcessSession{}, nil + } + + status, err := manager.Resume(context.Background(), managed.agent.ID()) + if err != nil { + t.Fatalf("manual resume: %v", err) + } + if status.State != agent.StateWorking { + t.Fatalf("manual resume status = %+v", status) + } + if managed.shouldResumeOnStart() { + t.Fatal("manual resume retained startup resume intent") + } + if !commitStore.observedCompletion { + t.Fatal("startup resume completion was not committed") + } + rows, err := st.Replay(string(managed.agent.ID())) + if err != nil { + t.Fatalf("replay manual resume: %v", err) + } + last := rows[len(rows)-1] + if last.Type != string(event.TypeSessionLifecycle) || + last.Reason != startupResumeCompletedReason || + last.Payload != `{"version":1}` { + t.Fatalf("startup resume completion = %+v", last) + } +} + +func TestUserStopConsumesLiveStartupResumeIntent(t *testing.T) { + manager, st := newTestManager(t) + managed := addStoppedAgent(t, manager, "agent-1", "claude", "vendor-ref") + var started pty.Config + manager.startPTY = func(config pty.Config) (launchedSession, error) { + started = config + return &fakeProcessSession{}, nil + } + if _, err := manager.Resume( + context.Background(), + managed.agent.ID(), + ); err != nil { + t.Fatalf("resume agent: %v", err) + } + state := managed.workspaceState() + state.resumeOnStart = true + managed.setWorkspaceState(state) + + manager.mu.RLock() + running := manager.sessions[managed.agent.ID()] + manager.mu.RUnlock() + if running == nil { + t.Fatal("resumed session is missing") + } + manager.requestStop(managed.agent.ID(), running, stopCauseUser) + started.OnExit(pty.ExitInfo{PID: 1, Code: 0}) + started.OnOutputEnd(0) + + if managed.hasResumeOnStart() { + t.Fatal("durable user stop retained the live startup resume intent") + } + rows, err := st.Replay(string(managed.agent.ID())) + if err != nil { + t.Fatalf("replay stopped resume: %v", err) + } + for _, row := range rows { + if row.Type == string(event.TypeSessionLifecycle) && + row.Reason == startupResumeCompletedReason { + t.Fatalf("user-stopped resume committed stale completion: %+v", row) + } + } +} + +func TestUserStopWithCleanupFailureCancelsStartupResumeIntent(t *testing.T) { + manager, st := newTestManager(t) + managed := addStoppedAgent(t, manager, "agent-1", "claude", "vendor-ref") + var started pty.Config + manager.startPTY = func(config pty.Config) (launchedSession, error) { + started = config + return &fakeProcessSession{}, nil + } + if _, err := manager.Resume( + context.Background(), + managed.agent.ID(), + ); err != nil { + t.Fatalf("resume agent: %v", err) + } + state := managed.workspaceState() + state.resumeOnStart = true + managed.setWorkspaceState(state) + + manager.mu.RLock() + running := manager.sessions[managed.agent.ID()] + manager.mu.RUnlock() + if running == nil { + t.Fatal("resumed session is missing") + } + manager.requestStop(managed.agent.ID(), running, stopCauseUser) + started.OnExit(pty.ExitInfo{ + PID: 4242, + CleanupErr: errors.New("process group remains"), + }) + started.OnOutputEnd(0) + + if managed.hasResumeOnStart() { + t.Fatal("cleanup failure retained the live startup resume intent") + } + rows, err := st.Replay(string(managed.agent.ID())) + if err != nil { + t.Fatalf("replay stopped resume: %v", err) + } + cancellationIndex := -1 + cleanupIndex := -1 + stoppedIndex := -1 + for index, row := range rows { + if row.Type == string(event.TypeSessionLifecycle) { + switch row.Reason { + case startupResumeCancelledReason: + cancellationIndex = index + if row.Payload != `{"version":1}` { + t.Fatalf("startup resume cancellation = %+v", row) + } + case processGroupCleanupFailed: + cleanupIndex = index + } + } + if row.Type == string(event.TypeStateChanged) && + row.To == string(agent.StateStopped) { + stoppedIndex = index + } + } + if cleanupIndex < 0 || + cancellationIndex <= cleanupIndex || + stoppedIndex <= cancellationIndex { + t.Fatalf( + "cleanup index = %d, cancellation index = %d, stopped index = %d, rows = %+v", + cleanupIndex, + cancellationIndex, + stoppedIndex, + rows, + ) + } +} + func TestNormalizeRunMode(t *testing.T) { tests := []struct { name string @@ -1893,6 +2238,7 @@ func TestProcessGroupCleanupFailurePersistsErrorAndStopsOneshot(t *testing.T) { cleanupErr := errors.New("process group remains after permission error") manager.onExit(id, running, pty.ExitInfo{ + PID: 4242, Code: test.code, Err: test.processErr, CleanupErr: cleanupErr, @@ -1924,8 +2270,20 @@ func TestProcessGroupCleanupFailurePersistsErrorAndStopsOneshot(t *testing.T) { t.Fatalf("replay: %v", err) } errorIndex := -1 + cleanupIndex := -1 stoppedIndex := -1 for index, row := range rows { + if row.Type == string(event.TypeSessionLifecycle) && + row.Reason == processGroupCleanupFailed { + cleanupIndex = index + var payload processGroupCleanupPayload + if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil { + t.Fatalf("decode cleanup failure payload: %v", err) + } + if payload.Version != 1 || payload.PID != 4242 { + t.Fatalf("cleanup failure payload = %+v", payload) + } + } if row.Type == string(event.TypeError) && strings.Contains(row.Payload, cleanupErr.Error()) { errorIndex = index @@ -1935,9 +2293,14 @@ func TestProcessGroupCleanupFailurePersistsErrorAndStopsOneshot(t *testing.T) { stoppedIndex = index } } - if errorIndex < 0 || stoppedIndex < 0 || errorIndex >= stoppedIndex { + if cleanupIndex < 0 || + errorIndex < 0 || + stoppedIndex < 0 || + cleanupIndex >= errorIndex || + errorIndex >= stoppedIndex { t.Fatalf( - "error index = %d, stopped index = %d, rows = %+v", + "cleanup index = %d, error index = %d, stopped index = %d, rows = %+v", + cleanupIndex, errorIndex, stoppedIndex, rows, @@ -1947,6 +2310,126 @@ func TestProcessGroupCleanupFailurePersistsErrorAndStopsOneshot(t *testing.T) { } } +func TestProcessGroupCleanupFenceBlocksAndThenAllowsResume(t *testing.T) { + manager, _ := newTestManager(t) + id := agent.ID("cleanup-resume") + target := agent.New( + id, + agent.WithName(string(id)), + agent.WithVendor("claude"), + agent.WithRunMode(agent.RunModeInteractive), + agent.WithHookPolicy(agent.HooksOff), + ) + managed := newManagedAgent(target) + managed.setVendorSessionReference("vendor-ref") + manager.mu.Lock() + manager.agents[id] = managed + manager.mu.Unlock() + commitTestState(t, manager, target, agent.StateStarting, "test start") + commitTestState(t, manager, target, agent.StateWorking, "test working") + running := attachTestRuntime(t, manager, target, manager.reg.For("claude")) + + manager.processGroupAlive = func(pid int) (bool, error) { + if pid != 4242 { + t.Fatalf("process group PID = %d, want 4242", pid) + } + return true, nil + } + manager.onExit(id, running, pty.ExitInfo{ + PID: 4242, + CleanupErr: errors.New("process group remains"), + }) + manager.markOutputDrained(id, running) + + status, err := manager.Status(id) + if err != nil { + t.Fatalf("status after cleanup failure: %v", err) + } + if status.State != agent.StateStopped || status.Resumable { + t.Fatalf("status after cleanup failure = %+v", status) + } + if _, err := manager.Resume(context.Background(), id); !errors.Is( + err, + ErrResumeConflict, + ) { + t.Fatalf("resume while process group is alive = %v", err) + } + + manager.processGroupAlive = func(pid int) (bool, error) { + if pid != 4242 { + t.Fatalf("process group PID = %d, want 4242", pid) + } + return false, nil + } + manager.startPTY = func(pty.Config) (launchedSession, error) { + return &fakeProcessSession{}, nil + } + resumed, err := manager.Resume(context.Background(), id) + if err != nil { + t.Fatalf("resume after process group disappeared: %v", err) + } + if resumed.State != agent.StateWorking || resumed.Resumable { + t.Fatalf("resumed status = %+v", resumed) + } + + rows, err := manager.Replay(string(id)) + if err != nil { + t.Fatalf("replay cleanup fence: %v", err) + } + completedIndex := -1 + resumedIndex := -1 + for index, row := range rows { + if row.Type == string(event.TypeSessionLifecycle) && + row.Reason == processGroupCleanupCompleted { + completedIndex = index + } + if row.Type == string(event.TypeAgentResumed) { + resumedIndex = index + } + } + if completedIndex < 0 || + resumedIndex < 0 || + completedIndex >= resumedIndex { + t.Fatalf( + "cleanup completion index = %d, resume index = %d, rows = %+v", + completedIndex, + resumedIndex, + rows, + ) + } +} + +func TestStartupResumeReportsUnresolvedProcessGroupCleanup(t *testing.T) { + manager, _ := newTestManager(t) + managed := addStoppedAgent( + t, + manager, + "cleanup-startup-resume", + "claude", + "vendor-ref", + ) + state := managed.workspaceState() + state.resumeOnStart = true + managed.setWorkspaceState(state) + managed.setProcessGroupCleanupPending(4242) + manager.processGroupAlive = func(pid int) (bool, error) { + if pid != 4242 { + t.Fatalf("process group PID = %d, want 4242", pid) + } + return true, nil + } + + if managed.shouldResumeOnStart() { + t.Fatal("cleanup fence did not block startup resume eligibility") + } + results := manager.ResumeOnStart(context.Background()) + if len(results) != 1 || + results[0].AgentID != managed.agent.ID() || + !errors.Is(results[0].Err, ErrResumeConflict) { + t.Fatalf("startup resume results = %+v", results) + } +} + func TestUserStopOneshotEndsStoppedWithoutProcessError(t *testing.T) { manager, _ := newTestManager(t) @@ -2390,6 +2873,36 @@ type cancelAfterAppendStore struct { once sync.Once } +type resumeReservationCommitStore struct { + commitStore + manager *Manager + id agent.ID + observedCompletion bool +} + +func (s *resumeReservationCommitStore) AppendEvents( + ctx context.Context, + expectedLastSeq uint64, + rows []store.EventRow, +) (uint64, error) { + for _, row := range rows { + if row.Type != string(event.TypeSessionLifecycle) || + row.Reason != startupResumeCompletedReason { + continue + } + s.manager.mu.RLock() + _, reserved := s.manager.resuming[s.id] + s.manager.mu.RUnlock() + if !reserved { + return expectedLastSeq, errors.New( + "startup resume completion committed without reservation", + ) + } + s.observedCompletion = true + } + return s.commitStore.AppendEvents(ctx, expectedLastSeq, rows) +} + func (s *cancelAfterAppendStore) AppendEvents( ctx context.Context, expectedLastSeq uint64, diff --git a/internal/session/workspace.go b/internal/session/workspace.go index b7c3aa4..c9a5c48 100644 --- a/internal/session/workspace.go +++ b/internal/session/workspace.go @@ -140,7 +140,7 @@ func (m *Manager) CleanupWorkspace( return workspace.Workspace{}, ErrWorkspaceUnavailable } agentID := agent.ID(id) - completion, err := m.reserveWorkspaceCleanup(agentID) + completion, err := m.reserveWorkspaceCleanup(ctx, agentID) if err != nil { return workspace.Workspace{}, err } @@ -234,8 +234,22 @@ func mapWorkspaceRemovalError(err error) error { } func (m *Manager) reserveWorkspaceCleanup( + ctx context.Context, id agent.ID, ) (chan struct{}, error) { + if managed, ok := m.managed(id); ok { + pending, err := m.resolveProcessGroupCleanup(ctx, id, managed) + if err != nil { + return nil, errors.Join(ErrWorkspaceInUse, err) + } + if pending { + return nil, fmt.Errorf( + "%w: agent %q process group cleanup remains unresolved", + ErrWorkspaceInUse, + id, + ) + } + } m.mu.Lock() defer m.mu.Unlock() if m.closed { @@ -260,6 +274,7 @@ func (m *Manager) reserveWorkspaceCleanup( _, resuming := m.resuming[id] state := managed.agent.State() if attached || resuming || + managed.processGroupCleanupPending() || (state != agent.StateDone && state != agent.StateStopped) { return nil, fmt.Errorf( "%w: agent %q is %s", diff --git a/internal/session/workspace_test.go b/internal/session/workspace_test.go index dcbea4b..c8b0d18 100644 --- a/internal/session/workspace_test.go +++ b/internal/session/workspace_test.go @@ -519,6 +519,42 @@ func TestCleanupWorkspacePreventsConcurrentResume(t *testing.T) { } } +func TestCleanupWorkspaceRejectsUnresolvedProcessGroupCleanup(t *testing.T) { + manager, _ := newTestManager(t) + id := agent.ID("11111111-1111-4111-8111-111111111111") + managed := addStoppedAgent(t, manager, id, "claude", "vendor-session") + managed.setProcessGroupCleanupPending(4242) + manager.processGroupAlive = func(pid int) (bool, error) { + if pid != 4242 { + t.Fatalf("process group PID = %d, want 4242", pid) + } + return true, nil + } + workspaces := &fakeWorkspaceLifecycle{} + manager.workspaces = workspaces + + if _, err := manager.CleanupWorkspace( + context.Background(), + string(id), + true, + ); !errors.Is(err, ErrWorkspaceInUse) { + t.Fatalf("cleanup with unresolved process group = %v", err) + } + if workspaces.cleanupCount != 0 { + t.Fatalf( + "unresolved process group reached workspace cleanup %d times", + workspaces.cleanupCount, + ) + } + status, err := manager.Status(id) + if err != nil { + t.Fatalf("status with unresolved process group: %v", err) + } + if status.Resumable { + t.Fatalf("status with unresolved process group = %+v", status) + } +} + func TestCleanupWorkspaceAcceptsCompletedRemovalWithCommandError(t *testing.T) { manager, _ := newTestManager(t) id := "11111111-1111-4111-8111-111111111111" diff --git a/internal/workspace/AGENTS.md b/internal/workspace/AGENTS.md index 9bd5ae2..d0eb5d3 100644 --- a/internal/workspace/AGENTS.md +++ b/internal/workspace/AGENTS.md @@ -13,7 +13,8 @@ `drove/`。 - 指向同一规范 DataDir 的所有进程内 Manager 共享互斥锁;Prepare、List、Remove、 ReconcilePreparations、AcknowledgePreparation、ReconcileRemovals 和 Discard 串行 - 执行。workspace 记录先写入同目录临时文件并 fsync,原子安装最终文件后再 fsync + 执行;Windows 的锁 key 必须折叠路径大小写,缺失目录的大小写别名也不能取得不同锁。 + workspace 记录先写入同目录临时文件并 fsync,原子安装最终文件后再 fsync 父目录。 - Prepare 全程固定已打开的源仓库根目录;仓库识别、分支查询与创建、include 匹配和 `git worktree add` 都必须从该根句柄执行,并禁用 Git hooks。Linux 使用 @@ -22,8 +23,20 @@ macOS/BSD 上只依赖私有 Git directory 的查询必须从该目录句柄执行,禁止经由使用 公开私有 Git 路径的 worktree helper;同时依赖 index 与 worktree 的查询必须由两个 各自固定一侧的视角交叉验证。 - `git worktree add` 使用 `--no-checkout`,新 worktree 打开后先核对预先持久化的 - 文件系统目录身份,再持久化 Git 私有目录,最后从固定目标根执行 `git reset --hard`。仓库根目录的 + `git worktree add` 使用 `--no-checkout`,只接收 bucket 内随机且与 Agent ID 同前缀的 + staging 目录,禁止接触公开 Agent 路径。新 worktree 打开后先核对预先持久化的 + 文件系统目录身份,再持久化 Git 私有目录。Linux 和 Windows 从同时固定的目标根与 + Git 根执行 `read-tree --reset -u`;macOS/BSD 只从固定私有 Git 根更新 index, + 用 `checkout-index --temp` 导出,并由 Drove 通过固定目标根安装普通文件、符号链接 + 和 gitlink。普通文件临时项必须逐个固定已打开文件身份并按 index object ID 校验, + 复制期间持续使用同一文件句柄,不能让打开的描述符数量随 tracked 文件数增长;清理时 + 只能按该身份删除,禁止仅凭临时文件名删除。 + macOS/BSD 遇到普通文件的 `filter` 属性必须 fail-stop,因为 filter + 无法同时取得固定 worktree cwd 与描述符绑定的私有 Git 上下文。初始化完成后把 + staging 目录 no-replace 原子提升到公开路径,再从固定 worktree 根执行 + `git worktree repair .`。私有 `gitdir` 指针替换中断后,重启恢复必须先从绑定私有 + Git 目录校验并恢复 `old-*` artifact,不能先要求 Git worktree 列表仍能看到缺失 + `gitdir` 的 staging 注册;唯一允许的旧指针从 operation staging 路径推导。仓库根目录的 `.worktreeinclude` 使用 gitignore 语义,并从已打开文件读取一次。Unix 通过继承的 只读文件描述符交给 Git,其余平台通过受复核的私有临时副本交给 Git,禁止 Git 再按 源 manifest 路径打开。 @@ -34,7 +47,7 @@ 不得重新解释目标 worktree 的 manifest。目标父目录必须从已固定 worktree root 向下创建,不能从 repository bucket 重新解析可替换的公开路径。 - `Prepare` 在创建新分支和执行 `git worktree add` 前持久化未提交的 preparation - sidecar,并在受约束 repository bucket 内预创建空目标目录、固定目录身份、同步父目录 + sidecar,并在受约束 repository bucket 内预创建随机 staging 目录、固定目录身份、同步父目录 后把身份写回 sidecar。每次 preparation 都有唯一 operation ID,用于拒绝另一 Manager 的冲突清理。 version 5 sidecar 记录源 worktree、源私有 Git directory 和 common Git directory @@ -64,14 +77,20 @@ 目录身份、Git registration、私有 Git directory,以及源 worktree、源私有 Git directory 和 common Git directory 的公开路径仍绑定 preparation lease 固定的目录 实例;源 worktree 的 `.git` 指针还必须重新解析到固定的私有 Git directory 和 - common Git directory,两个目录必须由同一次 rooted Git 查询取得,禁止跨进程拼接 - 解析结果。提交 sidecar 后和清理 ownership ref 前后也要复核,不能只信 session 携带 + common Git directory,两个目录必须由同一次 rooted Git 查询取得,输出只接受唯一一对 + 可打开的绝对目录,不能按固定行数切分或跨进程拼接。提交 sidecar 后、清理 ownership ref + 前后及最终记录更新后都要重新复核目标与仓库,不能只信 session 携带 的历史值。 - `List` 只枚举 Drove 根目录下符合路径约定的 worktree,并从 Git 查询仓库、分支和 dirty 状态。每个成功创建的 worktree 都有同目录私有记录,用于识别 detached HEAD 和修复目录已丢失但 Git 注册仍存在的情况。路径存在时必须同时匹配记录中的 Git 私有目录和文件系统目录身份,复制或替换同名目录必须 fail-stop。 - `Remove` 默认拒绝 dirty、detached HEAD 和保护来源未知的 version 1 sidecar; + 调用方必须先停止所有可通过 worktree 路径或既有文件/目录句柄写入的进程;session + 层在 Agent 为 Done/Stopped、无 attached/resuming session 且持有 cleanup reservation + 时才调用。成功删除以绑定目录身份的 canonical 到 quarantine 原子 rename 为逻辑 + 线性化点;非强制 dirty 检查只保护该点前可观察到的改动,因为各平台都无法可移植地 + 撤销 rename 前已被其他进程持有的 cwd、dirfd 或可写文件句柄。 `force` 可显式放宽这些检查。删除前必须先原子持久化带 operation ID 的 removal intent。物理删除完成后保留 sidecar,直到 session tombstone durable 后由 `AcknowledgeRemoval` 校验 token 并删除。`ReconcileRemovals` 在重启时收敛 path 与 @@ -93,19 +112,33 @@ sidecar repository evidence 重开的 capability;拒绝未开始的 removal 时必须先验证并 持久删除目录 marker,再清除 intent。ack sidecar 隔离名在正式记录缺失时必须按 Agent ID 恢复并回读 operation ID,使 session 重试和重启 reconciliation 能继续; - 正式记录已存在时不得恢复旧 ack。清理始终保留分支。 -- Manager 创建不预先查找 Git;只有实际查询或变更 worktree 时才解析并执行 `git`, - 因此没有受管 workspace 的 daemon 可在未安装 Git 时启动。 + 同 operation 且 `os.SameFile` 的多个 ack 名必须折叠,任一不同身份仍 fail-stop; + 正式记录已存在时不得恢复旧 ack。对 version 1-4 记录发起新删除时,必须在写 intent + 前取得并持久化完整的 version 5 repository evidence;已经存在但缺少该证据的 removal + intent 只能 fail-stop,禁止在恢复时重新信任记录中的公开仓库路径。清理始终保留分支。 +- Manager 创建不预先查找 Git,也不创建目录;只有实际查询或变更 worktree 时才解析并 + 执行 `git`,因此没有受管 workspace 的 daemon 可在未安装 Git 时启动。缺失的 + DataDir 必须从已打开的卷根通过 `os.Root` 逐级创建,禁止 `MkdirAll` 沿可替换 + 的公开路径创建。 - Manager 初始化时固定既有 data directory、worktrees root 和 repository bucket 的 文件身份,新建目录则在首次打开时固定。后续通过 `os.Root` 逐级打开并持续复核; 任一中间实目录或 symlink 被替换时必须 fail-stop。List 与 record scan 全程持有固定 root/bucket 句柄。sidecar 的原子写、确认读取和删除必须在受约束 bucket 句柄内完成。 初次 sidecar 安装必须使用 no-replace 原语;removal acknowledgement 先把匹配 token 的 - sidecar 原子移动到 operation ID 隔离名,再校验并删除,崩溃后从隔离名恢复。 + 空 marker 目录从私有 staging 名 no-replace 提升到 canonical Agent 路径,持有该 + namespace reservation 复核 quarantine 与 Git registration 均缺失,再把匹配 token 的 + sidecar 原子移动到 operation ID 隔离名,校验后再移动到同格式的新随机私有名并按已打开 + 文件身份删除;sidecar 进入隔离名后才把 reservation 原子移回私有名并删除,崩溃后从任一 + 隔离名或 canonical marker reservation 恢复。List 与 removal reconciliation 必须把 + 匹配 sidecar token 的 reservation 视为缺失 worktree,其他同名对象一律 fail-stop。 + sidecar 与确认隔离名均缺失时必须 fail-closed; + 只有当前 Manager 已成功删除同一 Agent ID 与 operation ID 的 sidecar 后,进程内重复确认 + 才可幂等成功。 sidecar 安装在原子改名前后都要确认已打开的 repository bucket 仍位于规范 hash 路径, 文件改名先把已校验源链接到内部随机别名,再从别名安装最终目标;公开临时路径被替换 只能导致失败,不得覆盖最终记录。清理临时名时也必须确认它仍指向已打开文件。缺少 - 对应原子原语的平台必须返回错误。 + 对应原子原语的平台必须返回错误。空 repository bucket 在创建后永久保留,禁止按公开 + 名称删除并清除已固定的目录身份。 - `Discard` 只供创建事务在会话元数据持久化前回滚;它会删除本次新建的 worktree 和 本次新建的分支。普通或重启后的 Discard 遇到路径存在但 preparation sidecar 尚未持久化 完整 Git 身份时必须 fail-stop;同一次 Prepare 仍持有原始仓库根句柄时,如果 sidecar @@ -126,7 +159,12 @@ ## 约束 -- 禁止直接读取或修改 `.git` 内部结构;仓库事实必须通过 Git 命令查询。 +- 禁止直接读取或修改 `.git` 内部结构;仓库事实必须通过 Git 命令查询。唯一例外是 + linked worktree 创建和提升期间,可在已打开且身份已复核的 target root 与 private + Git root 内原子改写 target `.git` 和 private `gitdir` 两个指针文件;该例外不得用于 + 查询 branch、HEAD、index 或其他仓库事实,也不得把指针中的公开路径当作 mutation + authority。Linux、Windows 与 macOS/BSD 均不得为提升后的路径调用公开路径版 + `git worktree repair`。 - 删除路径必须先证明位于 Drove worktree 根目录内,禁止接受任意路径。 - 不自动 merge、rebase、push 或删除已交付会话的分支。 - 导出类型:`Manager`、`Workspace`、`Removal`、`RemovalState`、`RemovalResult`、 diff --git a/internal/workspace/directory_error.go b/internal/workspace/directory_error.go deleted file mode 100644 index b1e2c28..0000000 --- a/internal/workspace/directory_error.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !plan9 - -package workspace - -import ( - "errors" - "io/fs" - "syscall" -) - -func isDirectoryNotEmptyError(err error) bool { - return errors.Is(err, syscall.ENOTEMPTY) || - errors.Is(err, fs.ErrExist) -} diff --git a/internal/workspace/directory_error_plan9.go b/internal/workspace/directory_error_plan9.go deleted file mode 100644 index 4026604..0000000 --- a/internal/workspace/directory_error_plan9.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build plan9 - -package workspace - -import ( - "errors" - "io/fs" -) - -func isDirectoryNotEmptyError(err error) bool { - return errors.Is(err, fs.ErrExist) -} diff --git a/internal/workspace/directory_rename_bound_unix.go b/internal/workspace/directory_rename_bound_unix.go new file mode 100644 index 0000000..9405943 --- /dev/null +++ b/internal/workspace/directory_rename_bound_unix.go @@ -0,0 +1,124 @@ +//go:build darwin || linux + +package workspace + +import ( + "errors" + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +type directoryRenameNoReplace func( + directoryFD int, + sourceName string, + targetName string, +) error + +func renameBoundDirectoryNoReplace( + directory *os.File, + expected os.FileInfo, + sourceName string, + isolatedName string, + targetName string, + rename directoryRenameNoReplace, +) (bool, error) { + if sourceName == isolatedName || + sourceName == targetName || + isolatedName == targetName { + return false, errors.New( + "directory rename source, isolation, and target names must differ", + ) + } + fd := int(directory.Fd()) + if err := rename(fd, sourceName, isolatedName); err != nil { + return false, err + } + restore := func() error { + if err := rename(fd, isolatedName, sourceName); err != nil { + return fmt.Errorf( + "restore isolated directory %q: %w", + sourceName, + err, + ) + } + return syncRecordDirectory(directory) + } + if err := syncRecordDirectory(directory); err != nil { + return false, errors.Join( + fmt.Errorf( + "sync isolated directory %q: %w", + sourceName, + err, + ), + restore(), + ) + } + isolated, err := statDirectoryAt(directory, isolatedName) + if err != nil { + return false, errors.Join( + fmt.Errorf( + "inspect isolated directory %q: %w", + sourceName, + err, + ), + restore(), + ) + } + if !isolated.IsDir() || !os.SameFile(expected, isolated) { + return false, errors.Join( + fmt.Errorf("directory %q changed before rename", sourceName), + restore(), + ) + } + if err := rename(fd, isolatedName, targetName); err != nil { + return false, errors.Join(err, restore()) + } + if err := syncRecordDirectory(directory); err != nil { + return true, fmt.Errorf( + "sync renamed directory %q: %w", + targetName, + err, + ) + } + target, err := statDirectoryAt(directory, targetName) + if err != nil { + return true, fmt.Errorf( + "inspect renamed directory %q: %w", + targetName, + err, + ) + } + if !target.IsDir() || !os.SameFile(expected, target) { + return true, fmt.Errorf( + "renamed directory %q changed identity", + targetName, + ) + } + return true, nil +} + +func statDirectoryAt( + directory *os.File, + name string, +) (_ os.FileInfo, result error) { + fd, err := unix.Openat( + int(directory.Fd()), + name, + unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, + 0, + ) + if err != nil { + return nil, err + } + opened := os.NewFile(uintptr(fd), name) + if opened == nil { + _ = unix.Close(fd) + return nil, errors.New("wrap opened directory") + } + defer func() { + result = errors.Join(result, opened.Close()) + }() + return opened.Stat() +} diff --git a/internal/workspace/directory_rename_darwin.go b/internal/workspace/directory_rename_darwin.go index 16a936d..250c160 100644 --- a/internal/workspace/directory_rename_darwin.go +++ b/internal/workspace/directory_rename_darwin.go @@ -10,19 +10,25 @@ import ( func renameDirectoryNoReplace( directory *os.File, - _ os.FileInfo, + expected os.FileInfo, sourceName string, + isolatedName string, targetName string, ) (bool, error) { - fd := int(directory.Fd()) - if err := unix.RenameatxNp( - fd, + return renameBoundDirectoryNoReplace( + directory, + expected, sourceName, - fd, + isolatedName, targetName, - unix.RENAME_EXCL, - ); err != nil { - return false, err - } - return true, nil + func(fd int, source string, target string) error { + return unix.RenameatxNp( + fd, + source, + fd, + target, + unix.RENAME_EXCL, + ) + }, + ) } diff --git a/internal/workspace/directory_rename_legacy_unix.go b/internal/workspace/directory_rename_legacy_unix.go index 0b730d4..17af160 100644 --- a/internal/workspace/directory_rename_legacy_unix.go +++ b/internal/workspace/directory_rename_legacy_unix.go @@ -12,6 +12,7 @@ func renameDirectoryNoReplace( _ os.FileInfo, _ string, _ string, + _ string, ) (bool, error) { return false, errors.New( "atomic no-replace directory rename is unsupported", diff --git a/internal/workspace/directory_rename_linux.go b/internal/workspace/directory_rename_linux.go index 495759e..05cb910 100644 --- a/internal/workspace/directory_rename_linux.go +++ b/internal/workspace/directory_rename_linux.go @@ -10,19 +10,25 @@ import ( func renameDirectoryNoReplace( directory *os.File, - _ os.FileInfo, + expected os.FileInfo, sourceName string, + isolatedName string, targetName string, ) (bool, error) { - fd := int(directory.Fd()) - if err := unix.Renameat2( - fd, + return renameBoundDirectoryNoReplace( + directory, + expected, sourceName, - fd, + isolatedName, targetName, - unix.RENAME_NOREPLACE, - ); err != nil { - return false, err - } - return true, nil + func(fd int, source string, target string) error { + return unix.Renameat2( + fd, + source, + fd, + target, + unix.RENAME_NOREPLACE, + ) + }, + ) } diff --git a/internal/workspace/directory_rename_linux_darwin_test.go b/internal/workspace/directory_rename_linux_darwin_test.go new file mode 100644 index 0000000..01c92b6 --- /dev/null +++ b/internal/workspace/directory_rename_linux_darwin_test.go @@ -0,0 +1,61 @@ +//go:build darwin || linux + +package workspace + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestRenameDirectoryNoReplaceRejectsReplacementSource(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.Mkdir(sourcePath, 0o700); err != nil { + t.Fatalf("create source directory: %v", err) + } + expected, err := os.Lstat(sourcePath) + if err != nil { + t.Fatalf("inspect source directory: %v", err) + } + originalPath := filepath.Join(rootPath, "source-original") + if err := os.Rename(sourcePath, originalPath); err != nil { + t.Fatalf("move expected source directory: %v", err) + } + if err := os.Mkdir(sourcePath, 0o700); err != nil { + t.Fatalf("create replacement source directory: %v", err) + } + sentinel := filepath.Join(sourcePath, "replacement") + if err := os.WriteFile(sentinel, []byte("replacement\n"), 0o600); err != nil { + t.Fatalf("write replacement sentinel: %v", err) + } + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open parent directory: %v", err) + } + defer directory.Close() + + moved, err := renameDirectoryNoReplace( + directory, + expected, + "source", + "source-isolated", + "target", + ) + if err == nil || moved { + t.Fatalf("rename replacement = moved %v, err=%v", moved, err) + } + if _, err := os.Stat(sentinel); err != nil { + t.Fatalf("replacement source was not restored: %v", err) + } + if _, err := os.Stat(originalPath); err != nil { + t.Fatalf("expected source changed: %v", err) + } + if _, err := os.Lstat(filepath.Join(rootPath, "target")); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("target exists after rejected replacement: %v", err) + } +} diff --git a/internal/workspace/directory_rename_other.go b/internal/workspace/directory_rename_other.go index 0867f86..a2ba055 100644 --- a/internal/workspace/directory_rename_other.go +++ b/internal/workspace/directory_rename_other.go @@ -12,6 +12,7 @@ func renameDirectoryNoReplace( _ os.FileInfo, _ string, _ string, + _ string, ) (bool, error) { return false, errors.New( "atomic no-replace directory rename is unsupported", diff --git a/internal/workspace/directory_rename_windows.go b/internal/workspace/directory_rename_windows.go index 02c056b..5627c2a 100644 --- a/internal/workspace/directory_rename_windows.go +++ b/internal/workspace/directory_rename_windows.go @@ -14,6 +14,7 @@ func renameDirectoryNoReplace( directory *os.File, expected os.FileInfo, sourceName string, + _ string, targetName string, ) (moved bool, result error) { renaming, err := openDirectoryForRename(directory, sourceName) @@ -62,7 +63,7 @@ func openDirectoryForRename( &status, nil, windows.FILE_ATTRIBUTE_NORMAL, - windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, windows.FILE_OPEN, windows.FILE_SYNCHRONOUS_IO_NONALERT| windows.FILE_DIRECTORY_FILE| diff --git a/internal/workspace/include.go b/internal/workspace/include.go index 02fe69d..744451c 100644 --- a/internal/workspace/include.go +++ b/internal/workspace/include.go @@ -3,17 +3,22 @@ package workspace import ( "bytes" "context" + "crypto/sha256" "errors" "fmt" "io" "os" "os/exec" "path/filepath" + "runtime" "slices" "sort" "strings" + "unicode/utf8" "github.com/google/uuid" + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" ) const ( @@ -27,6 +32,29 @@ type includeSelection struct { exists bool } +func includeManifestTemporaryName() string { + return "." + worktreeIncludeFile + "-" + uuid.NewString() +} + +func includedFileTemporaryName(destinationName string) string { + return "." + destinationName + ".include-" + uuid.NewString() +} + +func removeIncludeTemporary( + root *os.Root, + name string, + expected *os.File, + nextName string, +) error { + return removeOwnedRecordPathIfSame( + root, + name, + expected, + nextName, + nil, + ) +} + func (m *Manager) copyIncludedSelection( ctx context.Context, sourcePath string, @@ -45,7 +73,6 @@ func (m *Manager) copyIncludedSelection( return nil, err } copiedPaths, err := m.copyIncludedFiles( - ctx, sourceRoot, target, selection.paths, @@ -66,7 +93,6 @@ func (m *Manager) copyIncludedSelection( } func (m *Manager) copyIncludedFiles( - ctx context.Context, source *os.Root, target Workspace, paths []string, @@ -122,18 +148,15 @@ func (m *Manager) copyIncludedFiles( ) } + if len(paths) > 0 && target.trackedPaths == nil { + return nil, errors.New( + "workspace: target tracked-path snapshot is unavailable", + ) + } copiedPaths := make([]string, 0, len(paths)) for _, relative := range paths { - tracked, err := m.worktreePathTracked( - ctx, - target.Path, - destination, - relative, - ) - if err != nil { - return nil, err - } - if tracked { + key := trackedPathKey(relative, target.trackedIgnoreCase) + if _, tracked := target.trackedPaths[key]; tracked { continue } if err := copyIncludedPath( @@ -176,34 +199,84 @@ func (m *Manager) copyIncludedFiles( return copiedPaths, nil } -func (m *Manager) worktreePathTracked( +func (m *Manager) worktreeTrackedPaths( ctx context.Context, path string, - root *os.Root, - relative string, -) (bool, error) { - _, err := m.runRootedGit( + repository repositoryCapability, +) (map[string]struct{}, bool, error) { + ignoreCase, err := repositoryIgnoreCase(ctx, path, repository) + if err != nil { + return nil, false, err + } + output, err := repository.runPrivateGitAt( ctx, path, - root, + "", "--literal-pathspecs", "ls-files", - "--error-unmatch", - "--", - filepath.ToSlash(relative), + "--cached", + "--full-name", + "-z", ) - switch { - case err == nil: - return true, nil - case isExitCode(err, 1): + if err != nil { + return nil, false, fmt.Errorf( + "workspace: inspect target tracked paths: %w", + err, + ) + } + tracked := make(map[string]struct{}) + for _, rawPath := range strings.Split(string(output), "\x00") { + if rawPath != "" { + tracked[trackedPathKey(rawPath, ignoreCase)] = struct{}{} + } + } + return tracked, ignoreCase, nil +} + +func repositoryIgnoreCase( + ctx context.Context, + path string, + repository repositoryCapability, +) (bool, error) { + output, err := repository.runPrivateGitAt( + ctx, + path, + "", + "config", + "--type=bool", + "--get", + "core.ignoreCase", + ) + if isExitCode(err, 1) { return false, nil - default: + } + if err != nil { return false, fmt.Errorf( - "workspace: inspect target include path %q: %w", - relative, + "workspace: inspect repository path case setting: %w", err, ) } + switch strings.TrimSpace(string(output)) { + case "true": + return true, nil + case "false": + return false, nil + default: + return false, errors.New( + "workspace: repository path case setting is invalid", + ) + } +} + +func trackedPathKey(path string, ignoreCase bool) string { + key := filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) + if ignoreCase { + key = cases.Fold().String(key) + } + if runtime.GOOS == "darwin" { + key = norm.NFD.String(key) + } + return key } func (m *Manager) includedPaths( @@ -347,15 +420,25 @@ func (m *Manager) runIncludeManifest( } defer func() { verifyErr := verifyRealPathRoot(directory, root) + var cleanupErr error + if verifyErr == nil { + cleanupErr = cleanupRecordDeletionNamespace(root) + } closeErr := root.Close() var removeErr error - if verifyErr == nil { + if verifyErr == nil && cleanupErr == nil { removeErr = os.Remove(directory) } - result = errors.Join(result, verifyErr, closeErr, removeErr) + result = errors.Join( + result, + verifyErr, + cleanupErr, + closeErr, + removeErr, + ) }() - name := "." + worktreeIncludeFile + "-" + uuid.NewString() + name := includeManifestTemporaryName() writer, err := root.OpenFile( name, os.O_RDWR|os.O_CREATE|os.O_EXCL, @@ -367,14 +450,24 @@ func (m *Manager) runIncludeManifest( if _, err := writer.Write(manifest); err != nil { return nil, errors.Join( fmt.Errorf("workspace: write include rule file: %w", err), - removeRecordPathIfSame(root, name, writer), + removeIncludeTemporary( + root, + name, + writer, + includeManifestTemporaryName(), + ), writer.Close(), ) } if err := writer.Sync(); err != nil { return nil, errors.Join( fmt.Errorf("workspace: sync include rule file: %w", err), - removeRecordPathIfSame(root, name, writer), + removeIncludeTemporary( + root, + name, + writer, + includeManifestTemporaryName(), + ), writer.Close(), ) } @@ -382,7 +475,12 @@ func (m *Manager) runIncludeManifest( if err != nil { return nil, errors.Join( fmt.Errorf("workspace: reopen include rule file: %w", err), - removeRecordPathIfSame(root, name, writer), + removeIncludeTemporary( + root, + name, + writer, + includeManifestTemporaryName(), + ), writer.Close(), ) } @@ -392,14 +490,24 @@ func (m *Manager) runIncludeManifest( if err := errors.Join(statErr, readerStatErr, closeErr); err != nil { return nil, errors.Join( fmt.Errorf("workspace: inspect include rule file: %w", err), - removeRecordPathIfSame(root, name, reader), + removeIncludeTemporary( + root, + name, + reader, + includeManifestTemporaryName(), + ), reader.Close(), ) } if !os.SameFile(writerInfo, readerInfo) { return nil, errors.Join( errors.New("workspace: include rule file changed while reopening"), - removeRecordPathIfSame(root, name, reader), + removeIncludeTemporary( + root, + name, + reader, + includeManifestTemporaryName(), + ), reader.Close(), ) } @@ -407,7 +515,12 @@ func (m *Manager) runIncludeManifest( if name != "" { result = errors.Join( result, - removeRecordPathIfSame(root, name, reader), + removeIncludeTemporary( + root, + name, + reader, + includeManifestTemporaryName(), + ), ) } result = errors.Join(result, reader.Close()) @@ -424,16 +537,25 @@ func (m *Manager) runIncludeManifest( defer func() { commandResult = errors.Join(commandResult, cleanup()) }() - excludePath, unlinkBeforeRun, err := configureIncludeManifestCommand( - command, - reader, - manifestPath, - ) + excludePath, unlinkBeforeRun, cleanupManifest, err := + configureIncludeManifestCommand( + command, + reader, + manifestPath, + ) if err != nil { return nil, err } + defer func() { + commandResult = errors.Join(commandResult, cleanupManifest()) + }() if unlinkBeforeRun && name != "" { - if err := root.Remove(name); err != nil { + if err := removeIncludeTemporary( + root, + name, + reader, + includeManifestTemporaryName(), + ); err != nil { return nil, fmt.Errorf( "workspace: unlink include rule file: %w", err, @@ -460,9 +582,20 @@ func (m *Manager) runIncludeManifest( output, commandErr := runCommand(command, cleanupCommand) var sourceVerificationErr error - if commandErr == nil && - repository.gitRoot != nil && - verifyRealPathRoot(repository.gitPath, repository.gitRoot) == nil { + if commandErr == nil && repository.gitRoot != nil { + sourceVerificationErr = verifyRealPathRoot( + repository.gitPath, + repository.gitRoot, + ) + if sourceVerificationErr == nil && repository.commonRoot != nil { + sourceVerificationErr = verifyRealPathRoot( + repository.commonPath, + repository.commonRoot, + ) + } + } + if commandErr == nil && sourceVerificationErr == nil && + repository.gitRoot != nil { if _, err := reader.Seek(0, io.SeekStart); err != nil { sourceVerificationErr = err } else { @@ -642,6 +775,9 @@ func readWorktreeInclude( } func validateIncludedPath(path string) (string, error) { + if !utf8.ValidString(path) { + return "", fmt.Errorf("workspace: invalid included path %q", path) + } path = filepath.FromSlash(path) clean := filepath.Clean(path) if clean == "." || @@ -659,6 +795,24 @@ func copyIncludedPath( destinationRoot *os.Root, destinationName string, relative string, +) error { + return copyIncludedPathAfterCopy( + sourceRoot, + destinationBucket, + destinationRoot, + destinationName, + relative, + nil, + ) +} + +func copyIncludedPathAfterCopy( + sourceRoot *os.Root, + destinationBucket *os.Root, + destinationRoot *os.Root, + destinationName string, + relative string, + afterCopy func(), ) (result error) { relative, err := validateIncludedPath(relative) if err != nil { @@ -741,11 +895,11 @@ func copyIncludedPath( return fmt.Errorf("verify destination parent: %w", err) } - temporaryName := "." + destinationName + ".include-" + uuid.NewString() + temporaryName := includedFileTemporaryName(destinationName) output, err := destinationParent.OpenFile( temporaryName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, - sourceInfo.Mode().Perm(), + openedInfo.Mode().Perm(), ) if err != nil { return fmt.Errorf("create staged destination: %w", err) @@ -755,10 +909,11 @@ func copyIncludedPath( if temporaryName != "" { result = errors.Join( result, - removeRecordPathIfSame( + removeIncludeTemporary( destinationParent, temporaryName, output, + includedFileTemporaryName(destinationName), ), ) } @@ -766,20 +921,53 @@ func copyIncludedPath( result = errors.Join(result, output.Close()) } }() - copied, err := io.Copy(output, input) + copiedDigest := sha256.New() + copied, err := io.Copy(io.MultiWriter(output, copiedDigest), input) if err != nil { return fmt.Errorf("copy staged contents: %w", err) } - afterCopy, err := input.Stat() + if afterCopy != nil { + afterCopy() + } + afterCopyInfo, err := input.Stat() if err != nil { return fmt.Errorf("reinspect copied source: %w", err) } if copied != openedInfo.Size() || - afterCopy.Size() != openedInfo.Size() || - afterCopy.Mode() != openedInfo.Mode() || - !afterCopy.ModTime().Equal(openedInfo.ModTime()) { + afterCopyInfo.Size() != openedInfo.Size() || + afterCopyInfo.Mode() != openedInfo.Mode() || + !afterCopyInfo.ModTime().Equal(openedInfo.ModTime()) { return errors.New("source changed while copying") } + if _, err := input.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("rewind copied source: %w", err) + } + verifiedDigest := sha256.New() + verified, err := io.Copy(verifiedDigest, input) + if err != nil { + return fmt.Errorf("verify copied source contents: %w", err) + } + afterVerification, err := input.Stat() + if err != nil { + return fmt.Errorf("reinspect verified source: %w", err) + } + if verified != openedInfo.Size() || + afterVerification.Size() != openedInfo.Size() || + afterVerification.Mode() != openedInfo.Mode() || + !afterVerification.ModTime().Equal(openedInfo.ModTime()) || + !bytes.Equal(copiedDigest.Sum(nil), verifiedDigest.Sum(nil)) { + return errors.New("source changed while verifying copied contents") + } + if err := output.Chmod(openedInfo.Mode().Perm()); err != nil { + return fmt.Errorf("set staged destination mode: %w", err) + } + stagedInfo, err := output.Stat() + if err != nil { + return fmt.Errorf("inspect staged destination: %w", err) + } + if stagedInfo.Mode().Perm() != openedInfo.Mode().Perm() { + return errors.New("staged destination mode does not match source") + } if err := output.Sync(); err != nil { return fmt.Errorf("sync staged destination: %w", err) } @@ -799,7 +987,7 @@ func copyIncludedPath( return fmt.Errorf("verify destination parent: %w", err) } - directoryFile, err := destinationParent.Open(".") + directoryFile, err := openRecordDirectory(destinationParent) if err != nil { return fmt.Errorf("open destination parent for install: %w", err) } @@ -876,7 +1064,7 @@ func openIncludedParent( err, ) } - directory, openErr := current.Open(".") + directory, openErr := openRecordDirectory(current) if openErr != nil { return nil, false, fmt.Errorf( "open destination parent for sync: %w", diff --git a/internal/workspace/include_manifest_other.go b/internal/workspace/include_manifest_other.go index a745e53..f7452d9 100644 --- a/internal/workspace/include_manifest_other.go +++ b/internal/workspace/include_manifest_other.go @@ -11,6 +11,10 @@ func configureIncludeManifestCommand( _ *exec.Cmd, _ *os.File, path string, -) (string, bool, error) { - return path, false, nil +) (string, bool, func() error, error) { + return path, false, noCleanup, nil +} + +func noCleanup() error { + return nil } diff --git a/internal/workspace/include_manifest_unix.go b/internal/workspace/include_manifest_unix.go index fcdf982..8345532 100644 --- a/internal/workspace/include_manifest_unix.go +++ b/internal/workspace/include_manifest_unix.go @@ -13,11 +13,15 @@ func configureIncludeManifestCommand( command *exec.Cmd, manifest *os.File, _ string, -) (string, bool, error) { +) (string, bool, func() error, error) { fd := 3 + len(command.ExtraFiles) command.ExtraFiles = append(command.ExtraFiles, manifest) if runtime.GOOS == "linux" { - return fmt.Sprintf("/proc/self/fd/%d", fd), true, nil + return fmt.Sprintf("/proc/self/fd/%d", fd), true, noCleanup, nil } - return fmt.Sprintf("/dev/fd/%d", fd), true, nil + return fmt.Sprintf("/dev/fd/%d", fd), false, noCleanup, nil +} + +func noCleanup() error { + return nil } diff --git a/internal/workspace/include_manifest_windows.go b/internal/workspace/include_manifest_windows.go index 6ae4e5d..d4155c5 100644 --- a/internal/workspace/include_manifest_windows.go +++ b/internal/workspace/include_manifest_windows.go @@ -3,14 +3,87 @@ package workspace import ( + "errors" + "fmt" "os" "os/exec" + "path/filepath" + + "golang.org/x/sys/windows" ) func configureIncludeManifestCommand( _ *exec.Cmd, - _ *os.File, + manifest *os.File, path string, -) (string, bool, error) { - return path, false, nil +) (string, bool, func() error, error) { + expected, err := manifest.Stat() + if err != nil { + return "", false, nil, fmt.Errorf( + "workspace: inspect opened include manifest: %w", + err, + ) + } + name, err := windows.UTF16PtrFromString(filepath.Clean(path)) + if err != nil { + return "", false, nil, fmt.Errorf( + "workspace: encode include manifest path: %w", + err, + ) + } + handle, err := windows.CreateFile( + name, + windows.FILE_GENERIC_READ, + windows.FILE_SHARE_READ, + nil, + windows.OPEN_EXISTING, + windows.FILE_ATTRIBUTE_NORMAL| + windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + if err != nil { + return "", false, nil, fmt.Errorf( + "workspace: lock include manifest: %w", + err, + ) + } + var handleInfo windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle( + handle, + &handleInfo, + ); err != nil { + _ = windows.CloseHandle(handle) + return "", false, nil, fmt.Errorf( + "workspace: inspect include manifest handle: %w", + err, + ) + } + if handleInfo.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + _ = windows.CloseHandle(handle) + return "", false, nil, errors.New( + "workspace: include manifest is a reparse point", + ) + } + guard := os.NewFile(uintptr(handle), path) + if guard == nil { + _ = windows.CloseHandle(handle) + return "", false, nil, errors.New( + "workspace: wrap include manifest guard", + ) + } + locked, err := guard.Stat() + if err != nil { + _ = guard.Close() + return "", false, nil, fmt.Errorf( + "workspace: inspect locked include manifest: %w", + err, + ) + } + if !locked.Mode().IsRegular() || !os.SameFile(expected, locked) { + _ = guard.Close() + return "", false, nil, errors.New( + "workspace: include manifest changed while locking", + ) + } + return path, false, guard.Close, nil } diff --git a/internal/workspace/include_manifest_windows_test.go b/internal/workspace/include_manifest_windows_test.go new file mode 100644 index 0000000..994b2cf --- /dev/null +++ b/internal/workspace/include_manifest_windows_test.go @@ -0,0 +1,101 @@ +//go:build windows + +package workspace + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" +) + +func TestConfigureIncludeManifestCommandRejectsReparsePoint(t *testing.T) { + path := filepath.Join(t.TempDir(), "manifest") + if err := os.WriteFile(path, []byte("allowed.txt\n"), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + t.Fatalf("open manifest parent: %v", err) + } + defer root.Close() + manifest, err := root.Open(filepath.Base(path)) + if err != nil { + t.Fatalf("open manifest: %v", err) + } + defer manifest.Close() + + movedPath := path + ".moved" + if err := os.Rename(path, movedPath); err != nil { + t.Fatalf("rename opened manifest: %v", err) + } + if err := os.Symlink(movedPath, path); err != nil { + if errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) { + t.Skipf("creating symlinks requires additional privilege: %v", err) + } + t.Fatalf("replace manifest with symlink: %v", err) + } + + _, _, cleanup, err := configureIncludeManifestCommand( + exec.Command("cmd"), + manifest, + path, + ) + if cleanup != nil { + defer cleanup() + } + if err == nil { + t.Fatal("configure manifest command accepted a reparse point") + } +} + +func TestConfigureIncludeManifestCommandLocksManifestIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "manifest") + if err := os.WriteFile(path, []byte("allowed.txt\n"), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + t.Fatalf("open manifest parent: %v", err) + } + defer root.Close() + manifest, err := root.Open(filepath.Base(path)) + if err != nil { + t.Fatalf("open manifest: %v", err) + } + defer manifest.Close() + _, _, cleanup, err := configureIncludeManifestCommand( + exec.Command("cmd"), + manifest, + path, + ) + if err != nil { + t.Fatalf("configure manifest command: %v", err) + } + movedPath := path + ".moved" + if err := os.Rename(path, movedPath); err == nil { + _ = cleanup() + t.Fatal("manifest could be replaced while command guard was held") + } + if file, err := os.OpenFile(path, os.O_WRONLY, 0); err == nil { + _ = file.Close() + _ = cleanup() + t.Fatal("manifest could be rewritten while command guard was held") + } + if err := cleanup(); err != nil { + t.Fatalf("release manifest guard: %v", err) + } + file, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + t.Fatalf("open manifest for writing after releasing guard: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close writable manifest: %v", err) + } + if err := os.Rename(path, movedPath); err != nil { + t.Fatalf("rename manifest after releasing guard: %v", err) + } +} diff --git a/internal/workspace/include_mode_unix_test.go b/internal/workspace/include_mode_unix_test.go new file mode 100644 index 0000000..e99269c --- /dev/null +++ b/internal/workspace/include_mode_unix_test.go @@ -0,0 +1,59 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package workspace + +import ( + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestCopyIncludedPathPreservesModeUnderRestrictiveUmask(t *testing.T) { + sourcePath := t.TempDir() + if err := os.WriteFile( + filepath.Join(sourcePath, "shared"), + []byte("contents\n"), + 0o644, + ); err != nil { + t.Fatalf("write source: %v", err) + } + destinationPath := t.TempDir() + source, err := openRealPathRoot(sourcePath) + if err != nil { + t.Fatalf("open source root: %v", err) + } + defer source.Close() + destinationBucket, err := openRealPathRoot(filepath.Dir(destinationPath)) + if err != nil { + t.Fatalf("open destination bucket: %v", err) + } + defer destinationBucket.Close() + destination, err := openRealRootFromRoot( + destinationBucket, + filepath.Base(destinationPath), + ) + if err != nil { + t.Fatalf("open destination root: %v", err) + } + defer destination.Close() + + previousUmask := syscall.Umask(0o077) + defer syscall.Umask(previousUmask) + if err := copyIncludedPath( + source, + destinationBucket, + destination, + filepath.Base(destinationPath), + "shared", + ); err != nil { + t.Fatalf("copy included path: %v", err) + } + info, err := os.Stat(filepath.Join(destinationPath, "shared")) + if err != nil { + t.Fatalf("inspect copied destination: %v", err) + } + if got, want := info.Mode().Perm(), os.FileMode(0o644); got != want { + t.Fatalf("copied destination mode = %o, want %o", got, want) + } +} diff --git a/internal/workspace/include_security_test.go b/internal/workspace/include_security_test.go index 28d6ad2..cfbe189 100644 --- a/internal/workspace/include_security_test.go +++ b/internal/workspace/include_security_test.go @@ -11,6 +11,27 @@ import ( "testing" ) +func TestTrackedPathKeyPreservesCaseForCaseSensitiveRepository(t *testing.T) { + got := trackedPathKey("Directory/Secret.local", false) + if got != "Directory/Secret.local" { + t.Fatalf("tracked path key = %q, want case preserved", got) + } +} + +func TestTrackedPathKeyFoldsCaseForCaseInsensitiveRepository(t *testing.T) { + got := trackedPathKey("Directory/Secret.local", true) + if got != "directory/secret.local" { + t.Fatalf("tracked path key = %q, want folded case", got) + } +} + +func TestValidateIncludedPathRejectsInvalidUTF8(t *testing.T) { + path := string([]byte{'i', 'n', 'v', 'a', 'l', 'i', 'd', '-', 0xff}) + if _, err := validateIncludedPath(path); err == nil { + t.Fatal("included path validation accepted invalid UTF-8") + } +} + func TestCopyIncludedPathUsesOpenedSourceRoot(t *testing.T) { sourcePath := filepath.Join(t.TempDir(), "source") if err := os.Mkdir(sourcePath, 0o700); err != nil { @@ -47,18 +68,21 @@ func TestCopyIncludedPathUsesOpenedSourceRoot(t *testing.T) { defer destination.Close() openedSourcePath := sourcePath + "-opened" - if err := os.Rename(sourcePath, openedSourcePath); err != nil { - t.Fatalf("move opened source: %v", err) - } - if err := os.Mkdir(sourcePath, 0o700); err != nil { - t.Fatalf("replace source directory: %v", err) - } - if err := os.WriteFile( - filepath.Join(sourcePath, "secret"), - []byte("replacement\n"), - 0o600, - ); err != nil { - t.Fatalf("write replacement source: %v", err) + renameErr := os.Rename(sourcePath, openedSourcePath) + if renameErr != nil && runtime.GOOS != "windows" { + t.Fatalf("move opened source: %v", renameErr) + } + if renameErr == nil { + if err := os.Mkdir(sourcePath, 0o700); err != nil { + t.Fatalf("replace source directory: %v", err) + } + if err := os.WriteFile( + filepath.Join(sourcePath, "secret"), + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement source: %v", err) + } } if err := copyIncludedPath( @@ -77,6 +101,73 @@ func TestCopyIncludedPathUsesOpenedSourceRoot(t *testing.T) { ) } +func TestCopyIncludedPathRejectsSameSizeRewriteDuringCopy(t *testing.T) { + sourcePath := t.TempDir() + sourceFile := filepath.Join(sourcePath, "secret") + if err := os.WriteFile(sourceFile, []byte("before\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + sourceInfo, err := os.Stat(sourceFile) + if err != nil { + t.Fatalf("inspect source: %v", err) + } + destinationPath := t.TempDir() + source, err := openRealPathRoot(sourcePath) + if err != nil { + t.Fatalf("open source root: %v", err) + } + defer source.Close() + destinationBucket, err := openRealPathRoot(filepath.Dir(destinationPath)) + if err != nil { + t.Fatalf("open destination bucket: %v", err) + } + defer destinationBucket.Close() + destination, err := openRealRootFromRoot( + destinationBucket, + filepath.Base(destinationPath), + ) + if err != nil { + t.Fatalf("open destination root: %v", err) + } + defer destination.Close() + + err = copyIncludedPathAfterCopy( + source, + destinationBucket, + destination, + filepath.Base(destinationPath), + "secret", + func() { + if err := os.WriteFile( + sourceFile, + []byte("after!\n"), + 0o600, + ); err != nil { + t.Fatalf("rewrite source: %v", err) + } + if err := os.Chtimes( + sourceFile, + sourceInfo.ModTime(), + sourceInfo.ModTime(), + ); err != nil { + t.Fatalf("restore source modification time: %v", err) + } + }, + ) + if err == nil || + !strings.Contains( + err.Error(), + "source changed while verifying copied contents", + ) { + t.Fatalf("same-size source rewrite error = %v", err) + } + if _, err := os.Lstat( + filepath.Join(destinationPath, "secret"), + ); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rejected source rewrite installed destination: %v", err) + } +} + func TestCopyIncludedFilesUsesPinnedSourceRoot(t *testing.T) { sourcePath := filepath.Join(t.TempDir(), "source") if err := os.Mkdir(sourcePath, 0o700); err != nil { @@ -114,7 +205,8 @@ func TestCopyIncludedFilesUsesPinnedSourceRoot(t *testing.T) { repositoryHash(repository), testAgentID, ), - Branch: "pinned-source", + Branch: "pinned-source", + trackedPaths: map[string]struct{}{}, } if err := os.Mkdir(target.Path, 0o700); err != nil { t.Fatalf("create destination: %v", err) @@ -133,22 +225,24 @@ func TestCopyIncludedFilesUsesPinnedSourceRoot(t *testing.T) { } openedSourcePath := sourcePath + "-opened" - if err := os.Rename(sourcePath, openedSourcePath); err != nil { - t.Fatalf("move opened source: %v", err) - } - if err := os.Mkdir(sourcePath, 0o700); err != nil { - t.Fatalf("replace source directory: %v", err) - } - if err := os.WriteFile( - filepath.Join(sourcePath, "secret"), - []byte("replacement\n"), - 0o600, - ); err != nil { - t.Fatalf("write replacement source: %v", err) + renameErr := os.Rename(sourcePath, openedSourcePath) + if renameErr != nil && runtime.GOOS != "windows" { + t.Fatalf("move opened source: %v", renameErr) + } + if renameErr == nil { + if err := os.Mkdir(sourcePath, 0o700); err != nil { + t.Fatalf("replace source directory: %v", err) + } + if err := os.WriteFile( + filepath.Join(sourcePath, "secret"), + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement source: %v", err) + } } if _, err := manager.copyIncludedFiles( - context.Background(), source, target, []string{"secret"}, @@ -156,9 +250,13 @@ func TestCopyIncludedFilesUsesPinnedSourceRoot(t *testing.T) { t.Fatalf("copy included files: %v", err) } assertFileContents(t, filepath.Join(target.Path, "secret"), "original\n") - if err := verifyRealPathRoot(sourcePath, source); err == nil { + verifyErr := verifyRealPathRoot(sourcePath, source) + if renameErr == nil && verifyErr == nil { t.Fatal("source replacement passed final identity verification") } + if renameErr != nil && verifyErr != nil { + t.Fatalf("locked source failed final identity verification: %v", verifyErr) + } } func TestCopyIncludedFilesRejectsReplacementTargetIdentity(t *testing.T) { @@ -195,7 +293,8 @@ func TestCopyIncludedFilesRejectsReplacementTargetIdentity(t *testing.T) { repositoryHash(repository), testAgentID, ), - Branch: "replacement-target", + Branch: "replacement-target", + trackedPaths: map[string]struct{}{}, } if err := os.Mkdir(target.Path, 0o700); err != nil { t.Fatalf("create destination: %v", err) @@ -219,7 +318,6 @@ func TestCopyIncludedFilesRejectsReplacementTargetIdentity(t *testing.T) { } if _, err := manager.copyIncludedFiles( - context.Background(), source, target, []string{"secret"}, @@ -549,3 +647,220 @@ func TestPrepareCopiesIncludedLiteralPathspecName(t *testing.T) { t.Fatalf("included literal path was not copied: %v", err) } } + +func TestPrepareReadsTargetTrackedPathsOnce(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test requires a POSIX shell") + } + repository := newTestRepository(t) + if err := os.WriteFile( + filepath.Join(repository, ".gitignore"), + []byte("*.local\n"), + 0o600, + ); err != nil { + t.Fatalf("write gitignore: %v", err) + } + runGit(t, repository, "add", ".gitignore") + runGit(t, repository, "commit", "-m", "ignore local files") + if err := os.WriteFile( + filepath.Join(repository, worktreeIncludeFile), + []byte("*.local\n"), + 0o600, + ); err != nil { + t.Fatalf("write include manifest: %v", err) + } + for _, name := range []string{"first.local", "second.local", "third.local"} { + if err := os.WriteFile( + filepath.Join(repository, name), + []byte(name+"\n"), + 0o600, + ); err != nil { + t.Fatalf("write include candidate %q: %v", name, err) + } + } + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + countPath := filepath.Join(t.TempDir(), "tracked-count") + wrapper := filepath.Join(t.TempDir(), "git-wrapper") + script := `#!/bin/sh +saw_ls= +saw_cached= +for argument in "$@"; do + [ "$argument" = "ls-files" ] && saw_ls=1 + [ "$argument" = "--cached" ] && saw_cached=1 + [ "$argument" = "--error-unmatch" ] && exit 97 +done +if [ -n "$saw_ls" ] && [ -n "$saw_cached" ]; then + printf 'x\n' >> "$DROVE_TEST_COUNT" +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_COUNT", countPath) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + t.Cleanup(func() { + _ = manager.Discard(context.Background(), prepared) + }) + count, err := os.ReadFile(countPath) + if err != nil { + t.Fatalf("read tracked-path query count: %v", err) + } + if string(count) != "x\n" { + t.Fatalf("target tracked-path queries = %q, want one", count) + } + for _, name := range []string{"first.local", "second.local", "third.local"} { + assertFileContents( + t, + filepath.Join(prepared.Path, name), + name+"\n", + ) + } +} + +func TestPrepareSnapshotsTrackedPathsFromPrivateGitDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test requires a POSIX shell") + } + parent := t.TempDir() + repository := newTestRepository(t) + if err := os.WriteFile( + filepath.Join(repository, ".gitignore"), + []byte("*.local\n"), + 0o600, + ); err != nil { + t.Fatalf("write gitignore: %v", err) + } + runGit(t, repository, "add", ".gitignore") + runGit(t, repository, "commit", "-m", "ignore local files") + + replacement := filepath.Join(parent, "replacement") + runGit(t, repository, "branch", "tracked-replacement", "HEAD") + runGit( + t, + repository, + "worktree", + "add", + "--quiet", + replacement, + "tracked-replacement", + ) + const includedPath = "secret.local" + if err := os.WriteFile( + filepath.Join(replacement, includedPath), + []byte("tracked replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement tracked file: %v", err) + } + runGit(t, replacement, "add", "-f", includedPath) + runGit(t, replacement, "commit", "-m", "track replacement local file") + + if err := os.WriteFile( + filepath.Join(repository, worktreeIncludeFile), + []byte(includedPath+"\n"), + 0o600, + ); err != nil { + t.Fatalf("write include manifest: %v", err) + } + if err := os.WriteFile( + filepath.Join(repository, includedPath), + []byte("source local\n"), + 0o600, + ); err != nil { + t.Fatalf("write source included file: %v", err) + } + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + marker := filepath.Join(parent, "tracked-path-swap-ran") + wrapper := filepath.Join(parent, "git-wrapper") + script := `#!/bin/sh +saw_ls= +saw_cached= +saw_full_name= +for argument in "$@"; do + [ "$argument" = "ls-files" ] && saw_ls=1 + [ "$argument" = "--cached" ] && saw_cached=1 + [ "$argument" = "--full-name" ] && saw_full_name=1 +done +if [ -n "$saw_ls" ] && [ -n "$saw_cached" ] && + [ -n "$saw_full_name" ]; then + worktree=$GIT_WORK_TREE + if [ -z "$worktree" ]; then + if [ -d /proc/self/fd/3 ]; then + worktree=$(readlink /proc/self/fd/3) || exit 90 + else + worktree=$(pwd -P) || exit 91 + fi + fi + pointer="$worktree/.git" + opened="$worktree/.git.drove-test-original" + mv "$pointer" "$opened" || exit 92 + cp "$DROVE_TEST_REPLACEMENT_POINTER" "$pointer" || exit 93 + "$DROVE_TEST_REAL_GIT" "$@" + status=$? + rm -f "$pointer" || exit 94 + mv "$opened" "$pointer" || exit 95 + : > "$DROVE_TEST_MARKER" + exit "$status" +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv( + "DROVE_TEST_REPLACEMENT_POINTER", + filepath.Join(replacement, ".git"), + ) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_MARKER", marker) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + t.Cleanup(func() { + _ = manager.Discard(context.Background(), prepared) + }) + if _, err := os.Stat(marker); err != nil { + t.Fatalf("tracked-path pointer swap did not run: %v", err) + } + assertFileContents( + t, + filepath.Join(prepared.Path, includedPath), + "source local\n", + ) +} diff --git a/internal/workspace/manager_lock_key_darwin.go b/internal/workspace/manager_lock_key_darwin.go new file mode 100644 index 0000000..5838ca0 --- /dev/null +++ b/internal/workspace/manager_lock_key_darwin.go @@ -0,0 +1,14 @@ +//go:build darwin + +package workspace + +import ( + "path/filepath" + + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" +) + +func workspaceManagerLockKey(path string) string { + return norm.NFD.String(cases.Fold().String(filepath.Clean(path))) +} diff --git a/internal/workspace/manager_lock_key_darwin_test.go b/internal/workspace/manager_lock_key_darwin_test.go new file mode 100644 index 0000000..04c59f2 --- /dev/null +++ b/internal/workspace/manager_lock_key_darwin_test.go @@ -0,0 +1,82 @@ +//go:build darwin + +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWorkspaceManagerLockKeyFoldsDarwinPathCase(t *testing.T) { + root := t.TempDir() + lower := filepath.Join(root, "data", "worktrees") + upper := filepath.Join(root, "DATA", "WORKTREES") + if workspaceManagerLockKey(lower) != workspaceManagerLockKey(upper) { + t.Fatal("case aliases received different manager lock keys") + } + if workspaceManagerLock(lower) != workspaceManagerLock(upper) { + t.Fatal("case aliases received different manager locks") + } +} + +func TestNewFoldsDarwinUnicodePathAliases(t *testing.T) { + parent := t.TempDir() + composed := filepath.Join(parent, "\u00e9") + decomposed := filepath.Join(parent, "e\u0301") + if err := os.Mkdir(composed, 0o700); err != nil { + t.Fatalf("create composed data directory: %v", err) + } + if _, err := os.Stat(decomposed); err != nil { + t.Skip("test volume does not canonicalize Unicode path aliases") + } + first, err := New(composed) + if err != nil { + t.Fatalf("new composed manager: %v", err) + } + second, err := New(decomposed) + if err != nil { + t.Fatalf("new decomposed manager: %v", err) + } + if first.mu != second.mu { + t.Fatal("Unicode path aliases received different manager locks") + } +} + +func TestNewFoldsDarwinUnicodeCaseAliases(t *testing.T) { + parent := t.TempDir() + lower := filepath.Join(parent, "\u00df") + upper := filepath.Join(parent, "\u1e9e") + if err := os.Mkdir(lower, 0o700); err != nil { + t.Fatalf("create lowercase data directory: %v", err) + } + if _, err := os.Stat(upper); err != nil { + t.Skip("test volume does not fold Unicode path case") + } + first, err := New(lower) + if err != nil { + t.Fatalf("new lowercase manager: %v", err) + } + second, err := New(upper) + if err != nil { + t.Fatalf("new uppercase manager: %v", err) + } + if first.mu != second.mu { + t.Fatal("Unicode case aliases received different manager locks") + } +} + +func TestRegisteredWorktreePathAcceptsDarwinCaseAlias(t *testing.T) { + root := t.TempDir() + actual := filepath.Join(root, "Worktree") + if err := os.Mkdir(actual, 0o700); err != nil { + t.Fatalf("create worktree: %v", err) + } + alias := filepath.Join(root, "worktree") + if _, err := os.Stat(alias); err != nil { + t.Skip("test volume is case-sensitive") + } + if !sameRegisteredWorktreePath(actual, alias) { + t.Fatal("case aliases were treated as different worktree registrations") + } +} diff --git a/internal/workspace/manager_lock_key_other.go b/internal/workspace/manager_lock_key_other.go new file mode 100644 index 0000000..e53cd5a --- /dev/null +++ b/internal/workspace/manager_lock_key_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !windows + +package workspace + +import "path/filepath" + +func workspaceManagerLockKey(path string) string { + return filepath.Clean(path) +} diff --git a/internal/workspace/manager_lock_key_windows.go b/internal/workspace/manager_lock_key_windows.go new file mode 100644 index 0000000..b30732e --- /dev/null +++ b/internal/workspace/manager_lock_key_windows.go @@ -0,0 +1,39 @@ +//go:build windows + +package workspace + +import ( + "path/filepath" + "strings" +) + +func workspaceManagerLockKey(path string) string { + return strings.ToUpper(filepath.Clean(normalizeWindowsManagerPath(path))) +} + +func normalizeWindowsManagerPath(path string) string { + path = filepath.Clean(path) + for _, prefix := range []string{`\\?\`, `\??\`} { + remainder, found := cutWindowsPrefix(path, prefix) + if !found { + continue + } + if unc, found := cutWindowsPrefix(remainder, `UNC\`); found { + return `\\` + unc + } + if len(remainder) >= 3 && + remainder[1] == ':' && + (remainder[2] == '\\' || remainder[2] == '/') { + return remainder + } + } + return path +} + +func cutWindowsPrefix(path string, prefix string) (string, bool) { + if len(path) < len(prefix) || + !strings.EqualFold(path[:len(prefix)], prefix) { + return "", false + } + return path[len(prefix):], true +} diff --git a/internal/workspace/manager_lock_key_windows_test.go b/internal/workspace/manager_lock_key_windows_test.go new file mode 100644 index 0000000..1fb0a58 --- /dev/null +++ b/internal/workspace/manager_lock_key_windows_test.go @@ -0,0 +1,63 @@ +//go:build windows + +package workspace + +import "testing" + +func TestWorkspaceManagerLockKeyFoldsPathCase(t *testing.T) { + lower := workspaceManagerLockKey(`C:\drove\data\worktrees`) + upper := workspaceManagerLockKey(`c:\DROVE\DATA\WORKTREES`) + if lower != upper { + t.Fatalf("manager lock keys differ: %q != %q", lower, upper) + } + if workspaceManagerLock(`C:\drove\data\worktrees`) != + workspaceManagerLock(`c:\DROVE\DATA\WORKTREES`) { + t.Fatal("case aliases received different manager locks") + } +} + +func TestWorkspaceManagerLockKeyFoldsNamespaceAliases(t *testing.T) { + for _, test := range []struct { + name string + regular string + aliases []string + }{ + { + name: "drive", + regular: `C:\drove\data\worktrees`, + aliases: []string{ + `\\?\C:\drove\data\worktrees`, + `\??\C:\drove\data\worktrees`, + }, + }, + { + name: "unc", + regular: `\\server\share\drove\data\worktrees`, + aliases: []string{ + `\\?\UNC\server\share\drove\data\worktrees`, + `\??\UNC\server\share\drove\data\worktrees`, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + wantKey := workspaceManagerLockKey(test.regular) + wantLock := workspaceManagerLock(test.regular) + for _, alias := range test.aliases { + if got := workspaceManagerLockKey(alias); got != wantKey { + t.Errorf( + "manager lock key for %q = %q, want %q", + alias, + got, + wantKey, + ) + } + if got := workspaceManagerLock(alias); got != wantLock { + t.Errorf( + "manager lock for %q differs from canonical path", + alias, + ) + } + } + }) + } +} diff --git a/internal/workspace/preparation.go b/internal/workspace/preparation.go index 7d4f03f..bb7dce7 100644 --- a/internal/workspace/preparation.go +++ b/internal/workspace/preparation.go @@ -32,19 +32,24 @@ func (m *Manager) acknowledgePreparation( ) } if record.PreparationCommitted && - record.Version < workspaceRecordVersion { + record.Version < removalWorkspaceRecordVersion { return target.preparation.Close() } if record.PreparationCommitted && record.BranchOperationID == "" && - (target.preparation != nil || - (record.Removal != nil && - record.RepositoryEvidence == nil)) { + record.Removal != nil && + record.RepositoryEvidence == nil { + return target.preparation.Close() + } + if record.PreparationCommitted && + record.BranchOperationID == "" && + record.Version >= workspaceRecordVersion && + target.preparation.isClosed() { return target.preparation.Close() } lease := target.preparation temporaryLease := false - if lease == nil { + if lease == nil || lease.isClosed() { lease, err = openRecordedPreparationLease( context.Background(), m, @@ -58,6 +63,31 @@ func (m *Manager) acknowledgePreparation( result = errors.Join(result, lease.Close()) }() } + if record.Version == removalWorkspaceRecordVersion && + record.ExpectedHeadOID == "" { + deriveCtx, cancel := context.WithTimeout( + context.Background(), + 10*time.Second, + ) + expectedHeadOID, deriveErr := + lease.repository.derivePreparedWorktreeExpectedHead( + deriveCtx, + record.workspace(), + ) + cancel() + if deriveErr != nil { + return fmt.Errorf( + "workspace: derive historical preparation HEAD: %w", + deriveErr, + ) + } + record.ExpectedHeadOID = expectedHeadOID + if !sameWorkspace(record.workspace(), target) { + return errors.New( + "workspace: derived preparation acknowledgement does not match target", + ) + } + } verifyRepository := func(stage string) error { ctx, cancel := context.WithTimeout( context.Background(), @@ -73,6 +103,26 @@ func (m *Manager) acknowledgePreparation( } return nil } + verifyWorktree := func(stage string) error { + ctx, cancel := context.WithTimeout( + context.Background(), + 10*time.Second, + ) + defer cancel() + if err := m.verifyPreparedWorktreeForAcknowledgement( + ctx, + record.workspace(), + record, + lease.repository, + ); err != nil { + return fmt.Errorf( + "workspace: verify prepared worktree %s: %w", + stage, + err, + ) + } + return nil + } verifyCtx, cancelVerify := context.WithTimeout( context.Background(), 10*time.Second, @@ -90,6 +140,25 @@ func (m *Manager) acknowledgePreparation( verifyErr, ) } + if record.Version == removalWorkspaceRecordVersion { + upgradeWorkspaceRecord(&record) + if err := m.replaceWorkspaceRecord(record); err != nil { + return fmt.Errorf( + "workspace: migrate historical preparation record: %w", + err, + ) + } + if err := verifyRepository( + "after migrating historical preparation", + ); err != nil { + return err + } + if err := verifyWorktree( + "after migrating historical preparation", + ); err != nil { + return err + } + } if !record.PreparationCommitted { record.PreparationCommitted = true if err := m.replaceWorkspaceRecord(record); err != nil { @@ -98,11 +167,17 @@ func (m *Manager) acknowledgePreparation( if err := verifyRepository("after committing preparation"); err != nil { return err } + if err := verifyWorktree("after committing preparation"); err != nil { + return err + } } if record.BranchOperationID != "" { if err := verifyRepository("before ownership cleanup"); err != nil { return err } + if err := verifyWorktree("before ownership cleanup"); err != nil { + return err + } cleanupCtx, cancel := context.WithTimeout( context.Background(), 10*time.Second, @@ -117,6 +192,9 @@ func (m *Manager) acknowledgePreparation( if err := verifyRepository("after ownership cleanup"); err != nil { return err } + if err := verifyWorktree("after ownership cleanup"); err != nil { + return err + } record.BranchOperationID = "" if err := m.replaceWorkspaceRecord(record); err != nil { return fmt.Errorf( @@ -124,6 +202,16 @@ func (m *Manager) acknowledgePreparation( err, ) } + if err := verifyRepository( + "after persisting ownership cleanup", + ); err != nil { + return err + } + if err := verifyWorktree( + "after persisting ownership cleanup", + ); err != nil { + return err + } } if temporaryLease { return nil @@ -234,6 +322,11 @@ func sameWorkspace(left Workspace, right Workspace) bool { left.directoryIdentity != right.directoryIdentity { return false } + if left.expectedHeadOID != "" && + right.expectedHeadOID != "" && + left.expectedHeadOID != right.expectedHeadOID { + return false + } return left.AgentID == right.AgentID && left.Repository == right.Repository && left.Path == right.Path && diff --git a/internal/workspace/preparation_lease.go b/internal/workspace/preparation_lease.go index f1da850..9009631 100644 --- a/internal/workspace/preparation_lease.go +++ b/internal/workspace/preparation_lease.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "sync" + "sync/atomic" ) type preparationLease struct { @@ -20,6 +21,7 @@ type preparationLease struct { closeOnce sync.Once closeErr error + closed atomic.Bool } func newPreparationLease( @@ -242,7 +244,7 @@ func openRecordedPreparationLease( manager *Manager, record workspaceRecord, ) (*preparationLease, error) { - if record.Version < workspaceRecordVersion || + if record.Version < removalWorkspaceRecordVersion || record.RepositoryEvidence == nil { return nil, errors.New( "workspace: pending preparation has no repository identity evidence", @@ -327,6 +329,11 @@ func (l *preparationLease) Close() error { l.guard.Close(), l.root.Close(), ) + l.closed.Store(true) }) return l.closeErr } + +func (l *preparationLease) isClosed() bool { + return l != nil && l.closed.Load() +} diff --git a/internal/workspace/preparation_test.go b/internal/workspace/preparation_test.go index b87c52f..447f44b 100644 --- a/internal/workspace/preparation_test.go +++ b/internal/workspace/preparation_test.go @@ -2,8 +2,10 @@ package workspace import ( "context" + "crypto/sha256" "encoding/json" "errors" + "fmt" "os" "os/exec" "path/filepath" @@ -15,7 +17,8 @@ import ( func TestReconcilePreparationsAdoptsDurableAndDiscardsOrphan(t *testing.T) { repository := newTestRepository(t) - manager, err := New(filepath.Join(t.TempDir(), "data")) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) if err != nil { t.Fatalf("new manager: %v", err) } @@ -37,15 +40,27 @@ func TestReconcilePreparationsAdoptsDurableAndDiscardsOrphan(t *testing.T) { if err != nil { t.Fatalf("prepare orphan workspace: %v", err) } + if err := durable.preparation.Close(); err != nil { + t.Fatalf("close durable preparation lease: %v", err) + } + durable.preparation = nil + if err := orphan.preparation.Close(); err != nil { + t.Fatalf("close orphan preparation lease: %v", err) + } + orphan.preparation = nil + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } - if err := manager.ReconcilePreparations( + if err := restarted.ReconcilePreparations( context.Background(), []Workspace{durable}, ); err != nil { t.Fatalf("reconcile preparations: %v", err) } - record, exists, err := manager.readWorkspaceRecord(durable.Path) + record, exists, err := restarted.readWorkspaceRecord(durable.Path) if err != nil || !exists || !record.PreparationCommitted { t.Fatalf( "durable preparation record = %+v, exists=%v err=%v", @@ -149,6 +164,495 @@ func TestReconcilePreparationsDiscardsPendingAfterRestart(t *testing.T) { } } +func TestReconcilePreparationsAcceptsVersionFiveEvidence(t *testing.T) { + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists || record.RepositoryEvidence == nil { + t.Fatalf( + "read preparation record: exists=%v err=%v record=%+v", + exists, + err, + record, + ) + } + expectedHeadOID := record.ExpectedHeadOID + record.Version = removalWorkspaceRecordVersion + record.ExpectedHeadOID = "" + if err := manager.replaceWorkspaceRecord(record); err != nil { + t.Fatalf("write version 5 preparation record: %v", err) + } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + []Workspace{{ + AgentID: prepared.AgentID, + Repository: prepared.Repository, + Path: prepared.Path, + Branch: prepared.Branch, + }}, + ); err != nil { + t.Fatalf("adopt version 5 preparation: %v", err) + } + adopted, exists, err := restarted.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf( + "read adopted preparation: exists=%v err=%v", + exists, + err, + ) + } + if adopted.Version != workspaceRecordVersion || + !adopted.PreparationCommitted || + adopted.ExpectedHeadOID != expectedHeadOID || + adopted.BranchOperationID != "" || + adopted.RepositoryEvidence == nil { + t.Fatalf("adopted version 5 preparation = %+v", adopted) + } +} + +func TestAcknowledgePreparationMigratesCommittedVersionFiveRecord( + t *testing.T, +) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read preparation record: exists=%v err=%v", exists, err) + } + expectedHeadOID := record.ExpectedHeadOID + record.Version = removalWorkspaceRecordVersion + record.ExpectedHeadOID = "" + if err := manager.replaceWorkspaceRecord(record); err != nil { + t.Fatalf("write committed version 5 preparation record: %v", err) + } + + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("migrate committed version 5 preparation: %v", err) + } + migrated, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf( + "read migrated preparation: exists=%v err=%v", + exists, + err, + ) + } + if migrated.Version != workspaceRecordVersion || + !migrated.PreparationCommitted || + migrated.ExpectedHeadOID != expectedHeadOID || + migrated.BranchOperationID != "" || + migrated.RepositoryEvidence == nil { + t.Fatalf("migrated version 5 preparation = %+v", migrated) + } +} + +func TestReconcilePreparationsAcceptsCommittedBranchAdvance(t *testing.T) { + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + if err := os.WriteFile( + filepath.Join(prepared.Path, "advanced.txt"), + []byte("advanced\n"), + 0o600, + ); err != nil { + t.Fatalf("write committed workspace change: %v", err) + } + runGit(t, prepared.Path, "add", "advanced.txt") + runGit(t, prepared.Path, "commit", "-m", "advance managed branch") + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + recovered := Workspace{ + AgentID: prepared.AgentID, + Repository: prepared.Repository, + Path: prepared.Path, + Branch: prepared.Branch, + } + if err := restarted.ReconcilePreparations( + context.Background(), + []Workspace{recovered}, + ); err != nil { + t.Fatalf("adopt advanced committed preparation: %v", err) + } + record, exists, err := restarted.readWorkspaceRecord(prepared.Path) + if err != nil || !exists || !record.PreparationCommitted { + t.Fatalf( + "advanced committed record = %+v, exists=%v err=%v", + record, + exists, + err, + ) + } +} + +func TestReconcilePreparationsDiscardsPromotePendingStagingWorktree( + t *testing.T, +) { + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read preparation record: exists=%v err=%v", exists, err) + } + stagingName, err := preparedWorktreeStagingName( + prepared.AgentID, + record.BranchOperationID, + ) + if err != nil { + t.Fatalf("derive staging name: %v", err) + } + stagingPath := filepath.Join(filepath.Dir(prepared.Path), stagingName) + if err := os.Rename(prepared.Path, stagingPath); err != nil { + t.Fatalf("restore promote-pending staging path: %v", err) + } + runGit(t, stagingPath, "worktree", "repair", ".") + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + nil, + ); err != nil { + t.Fatalf("reconcile staging preparation: %v", err) + } + for _, path := range []string{ + prepared.Path, + stagingPath, + workspaceRecordPath(prepared.Path), + } { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("reconciled path %q remains: %v", path, err) + } + } + if strings.Contains( + runGit(t, repository, "worktree", "list", "--porcelain"), + stagingPath, + ) { + t.Fatal("staging worktree registration remains") + } +} + +func TestReconcilePreparationsDiscardsRenameIsolatedStagingWorktree( + t *testing.T, +) { + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read preparation record: exists=%v err=%v", exists, err) + } + stagingName, err := preparedWorktreeStagingName( + prepared.AgentID, + record.BranchOperationID, + ) + if err != nil { + t.Fatalf("derive staging name: %v", err) + } + parent := filepath.Dir(prepared.Path) + stagingPath := filepath.Join(parent, stagingName) + isolationPath := filepath.Join( + parent, + preparedWorktreeIsolationName(stagingName), + ) + if err := os.Rename(prepared.Path, stagingPath); err != nil { + t.Fatalf("restore staging path: %v", err) + } + runGit(t, stagingPath, "worktree", "repair", ".") + if err := os.Rename(stagingPath, isolationPath); err != nil { + t.Fatalf("isolate staging path: %v", err) + } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + nil, + ); err != nil { + t.Fatalf("reconcile isolated preparation: %v", err) + } + for _, path := range []string{ + prepared.Path, + stagingPath, + isolationPath, + workspaceRecordPath(prepared.Path), + } { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("reconciled path %q remains: %v", path, err) + } + } + listing := runGit(t, repository, "worktree", "list", "--porcelain") + if strings.Contains(listing, prepared.Path) || + strings.Contains(listing, stagingPath) || + strings.Contains(listing, isolationPath) { + t.Fatalf("reconciled worktree registration remains:\n%s", listing) + } +} + +func TestReconcilePreparationsRepairsPromotionBeforeDiscard(t *testing.T) { + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read preparation record: exists=%v err=%v", exists, err) + } + stagingName, err := preparedWorktreeStagingName( + prepared.AgentID, + record.BranchOperationID, + ) + if err != nil { + t.Fatalf("derive staging name: %v", err) + } + stagingPath := filepath.Join(filepath.Dir(prepared.Path), stagingName) + if err := os.Rename(prepared.Path, stagingPath); err != nil { + t.Fatalf("move worktree to staging path: %v", err) + } + runGit(t, stagingPath, "worktree", "repair", ".") + if err := os.Rename(stagingPath, prepared.Path); err != nil { + t.Fatalf("promote worktree without repairing registration: %v", err) + } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + nil, + ); err != nil { + t.Fatalf("reconcile promote-pending preparation: %v", err) + } + for _, path := range []string{ + prepared.Path, + stagingPath, + workspaceRecordPath(prepared.Path), + } { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("reconciled path %q remains: %v", path, err) + } + } + listing := runGit(t, repository, "worktree", "list", "--porcelain") + if strings.Contains(listing, prepared.Path) || + strings.Contains(listing, stagingPath) { + t.Fatalf("reconciled worktree registration remains:\n%s", listing) + } +} + +func TestReconcilePreparationsRecoversIsolatedPrivateGitPointer( + t *testing.T, +) { + switch runtime.GOOS { + case "darwin", "dragonfly", "freebsd", "linux", + "netbsd", "openbsd", "windows": + default: + t.Skip("bound Git pointer recovery is unsupported") + } + + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read preparation record: exists=%v err=%v", exists, err) + } + stagingName, err := preparedWorktreeStagingName( + prepared.AgentID, + record.BranchOperationID, + ) + if err != nil { + t.Fatalf("derive staging name: %v", err) + } + stagingPath := filepath.Join(filepath.Dir(prepared.Path), stagingName) + privatePointerPath := filepath.Join(record.GitDirectory, "gitdir") + stalePointer := filepath.ToSlash(filepath.Join(stagingPath, ".git")) + "\n" + if err := os.WriteFile( + privatePointerPath, + []byte(stalePointer), + 0o600, + ); err != nil { + t.Fatalf("restore stale private Git pointer: %v", err) + } + digest := sha256.Sum256( + []byte(filepath.Base(record.GitDirectory) + "\x00gitdir"), + ) + backupName := fmt.Sprintf( + ".drove-git-pointer-%x-old-%s", + digest[:8], + "78787878-7878-4787-8787-787878787878", + ) + backupPath := filepath.Join(record.GitDirectory, backupName) + if err := os.Rename(privatePointerPath, backupPath); err != nil { + t.Fatalf("isolate stale private Git pointer: %v", err) + } + if _, err := os.Lstat(privatePointerPath); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("private Git pointer still exists: %v", err) + } + listing := runGit(t, repository, "worktree", "list", "--porcelain") + if strings.Contains(listing, prepared.Path) || + strings.Contains(listing, stagingPath) { + t.Fatalf("isolated private pointer remained registered:\n%s", listing) + } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + nil, + ); err != nil { + t.Fatalf("reconcile isolated private pointer: %v", err) + } + for _, path := range []string{ + prepared.Path, + stagingPath, + workspaceRecordPath(prepared.Path), + } { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("reconciled path %q remains: %v", path, err) + } + } + listing = runGit(t, repository, "worktree", "list", "--porcelain") + if strings.Contains(listing, prepared.Path) || + strings.Contains(listing, stagingPath) { + t.Fatalf("reconciled worktree registration remains:\n%s", listing) + } + command := exec.Command( + "git", + "-C", + repository, + "show-ref", + "--verify", + "--quiet", + "refs/heads/"+prepared.Branch, + ) + if err := command.Run(); !isExitCode(err, 1) { + t.Fatalf("pending branch remains after recovery: %v", err) + } +} + func TestReconcilePreparationWithIncompleteGitIdentityFailsClosed( t *testing.T, ) { @@ -210,8 +714,11 @@ func TestReconcilePreparationWithIncompleteGitIdentityFailsClosed( func TestReconcilePreparationsDiscardsGitSuffixedWorktreeAfterRestart( t *testing.T, ) { + if runtime.GOOS != "linux" { + t.Skip("BSD registration rejects deterministic admin collisions") + } repository := newTestRepository(t) - stalePath := filepath.Join(t.TempDir(), testAgentID) + stalePath := filepath.Join(t.TempDir(), "-") runGit( t, repository, @@ -241,7 +748,7 @@ func TestReconcilePreparationsDiscardsGitSuffixedWorktreeAfterRestart( t.Fatalf("prepare workspace: %v", err) } gitDirectoryName := filepath.Base(prepared.gitDirectory) - suffix := strings.TrimPrefix(gitDirectoryName, testAgentID) + suffix := strings.TrimPrefix(gitDirectoryName, "-") if suffix == "" || strings.Trim(suffix, "0123456789") != "" { t.Fatalf( "prepared Git directory %q has no collision suffix", @@ -576,6 +1083,9 @@ func TestAcknowledgePreparationClearsBranchOwnershipMarker(t *testing.T) { if !record.PreparationCommitted || record.BranchOperationID != "" { t.Fatalf("acknowledged record = %+v", record) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("repeat acknowledgement: %v", err) + } } func TestAcknowledgePreparationRejectsReplacementTarget(t *testing.T) { diff --git a/internal/workspace/prepared_checkout_bound.go b/internal/workspace/prepared_checkout_bound.go new file mode 100644 index 0000000..f34b8e2 --- /dev/null +++ b/internal/workspace/prepared_checkout_bound.go @@ -0,0 +1,28 @@ +//go:build linux || windows + +package workspace + +import ( + "context" + "os" +) + +func checkoutPreparedWorktree( + ctx context.Context, + repository repositoryCapability, + worktreePath string, + worktreeRoot *os.Root, + expectedHeadOID string, +) error { + _, err := repository.runWorktreeAt( + ctx, + worktreePath, + worktreeRoot, + "", + "read-tree", + "--reset", + "-u", + expectedHeadOID, + ) + return err +} diff --git a/internal/workspace/prepared_checkout_other.go b/internal/workspace/prepared_checkout_other.go new file mode 100644 index 0000000..5b14989 --- /dev/null +++ b/internal/workspace/prepared_checkout_other.go @@ -0,0 +1,21 @@ +//go:build aix || illumos || js || plan9 || solaris || wasip1 + +package workspace + +import ( + "context" + "errors" + "os" +) + +func checkoutPreparedWorktree( + context.Context, + repositoryCapability, + string, + *os.Root, + string, +) error { + return errors.New( + "workspace: prepared worktree checkout is unsupported on this platform", + ) +} diff --git a/internal/workspace/prepared_checkout_replacement_unix_test.go b/internal/workspace/prepared_checkout_replacement_unix_test.go new file mode 100644 index 0000000..ef7a3b3 --- /dev/null +++ b/internal/workspace/prepared_checkout_replacement_unix_test.go @@ -0,0 +1,83 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package workspace + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestPreparedCheckoutRejectsReplacedGitTemporary(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + initSourceSwapRepository(t, repository) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + outputFile := filepath.Join(parent, "checkout-output") + wrapper := filepath.Join(parent, "git-wrapper-checkout-temp") + script := `#!/bin/sh +checkout= +for argument in "$@"; do + [ "$argument" = "checkout-index" ] && checkout=1 +done +if [ -n "$checkout" ]; then + "$DROVE_TEST_REAL_GIT" "$@" > "$DROVE_TEST_CHECKOUT_OUTPUT" + status=$? + [ "$status" -eq 0 ] || exit "$status" + temp=$( + tr '\000' '\n' < "$DROVE_TEST_CHECKOUT_OUTPUT" | + awk -F ' ' 'NF >= 2 { print $1; exit }' + ) || exit 90 + [ -n "$temp" ] || exit 91 + original=$temp.original + mv "$temp" "$original" || exit 92 + printf 'replacement\n' > "$temp" || exit 93 + cat "$DROVE_TEST_CHECKOUT_OUTPUT" + exit 0 +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_CHECKOUT_OUTPUT", outputFile) + + dataDir := filepath.Join(parent, "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ); err == nil || !strings.Contains( + err.Error(), + "does not match the index", + ) { + t.Fatalf("prepare after temporary replacement error = %v", err) + } + + canonicalRepository, err := resolvePath(repository) + if err != nil { + t.Fatalf("resolve repository: %v", err) + } + target := filepath.Join( + manager.root, + repositoryHash(canonicalRepository), + testAgentID, + ) + if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("replacement bytes reached a prepared worktree: %v", err) + } +} diff --git a/internal/workspace/prepared_checkout_unix.go b/internal/workspace/prepared_checkout_unix.go new file mode 100644 index 0000000..290f12c --- /dev/null +++ b/internal/workspace/prepared_checkout_unix.go @@ -0,0 +1,873 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package workspace + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" + "golang.org/x/sys/unix" +) + +const ( + preparedCheckoutRegularMode = "100644" + preparedCheckoutExecutableMode = "100755" + preparedCheckoutSymlinkMode = "120000" + preparedCheckoutGitlinkMode = "160000" +) + +type preparedCheckoutEntry struct { + mode string + oid string + path string + temp *preparedCheckoutTemp +} + +type preparedCheckoutTemp struct { + name string + file *os.File + info os.FileInfo + verified bool +} + +func checkoutPreparedWorktree( + ctx context.Context, + repository repositoryCapability, + _ string, + worktreeRoot *os.Root, + expectedHeadOID string, +) (result error) { + if _, err := repository.runPrivateGitAt( + ctx, + ".", + "", + "read-tree", + "--reset", + expectedHeadOID, + ); err != nil { + return err + } + index, err := repository.runPrivateGitAt( + ctx, + ".", + "", + "ls-files", + "--stage", + "-z", + "--", + ) + if err != nil { + return err + } + entries, err := parsePreparedCheckoutIndex(index) + if err != nil { + return err + } + if err := rejectPreparedCheckoutFilters(ctx, repository, entries); err != nil { + return err + } + + var paths strings.Builder + for _, entry := range entries { + if entry.mode != preparedCheckoutRegularMode && + entry.mode != preparedCheckoutExecutableMode { + continue + } + paths.WriteString(entry.path) + paths.WriteByte(0) + } + if paths.Len() != 0 { + output, checkoutErr := repository.runPrivateGitAtKeepingOutput( + ctx, + ".", + paths.String(), + "checkout-index", + "--temp", + "--stdin", + "-z", + ) + if checkoutErr != nil { + return checkoutErr + } + if err := bindPreparedCheckoutTemps(entries, output); err != nil { + return err + } + } + + for _, entry := range entries { + switch entry.mode { + case preparedCheckoutRegularMode, preparedCheckoutExecutableMode: + if err := installPreparedCheckoutFile( + ctx, + repository, + worktreeRoot, + expectedHeadOID, + entry, + ); err != nil { + return err + } + case preparedCheckoutSymlinkMode: + if err := installPreparedCheckoutSymlink( + ctx, + repository, + worktreeRoot, + entry, + ); err != nil { + return err + } + case preparedCheckoutGitlinkMode: + if err := installPreparedCheckoutGitlink( + worktreeRoot, + entry.path, + ); err != nil { + return err + } + } + } + directory, err := openRecordDirectory(worktreeRoot) + if err != nil { + return err + } + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + return errors.Join(syncErr, closeErr) +} + +func parsePreparedCheckoutIndex(raw []byte) ([]*preparedCheckoutEntry, error) { + if len(raw) == 0 { + return nil, nil + } + if raw[len(raw)-1] != 0 { + return nil, errors.New( + "workspace: prepared index returned an incomplete entry", + ) + } + records := bytes.Split(raw[:len(raw)-1], []byte{0}) + entries := make([]*preparedCheckoutEntry, 0, len(records)) + seen := make(map[string]struct{}, len(records)) + for _, record := range records { + header, rawPath, ok := bytes.Cut(record, []byte{'\t'}) + if !ok { + return nil, errors.New( + "workspace: prepared index returned an invalid entry", + ) + } + fields := strings.Fields(string(header)) + if len(fields) != 3 || fields[2] != "0" { + return nil, errors.New( + "workspace: prepared index returned an unsupported stage", + ) + } + switch fields[0] { + case preparedCheckoutRegularMode, + preparedCheckoutExecutableMode, + preparedCheckoutSymlinkMode, + preparedCheckoutGitlinkMode: + default: + return nil, fmt.Errorf( + "workspace: prepared index mode %q is unsupported", + fields[0], + ) + } + if err := validatePreparedCheckoutOID(fields[1]); err != nil { + return nil, err + } + path, err := validatePreparedCheckoutPath(string(rawPath)) + if err != nil { + return nil, err + } + if _, exists := seen[path]; exists { + return nil, fmt.Errorf( + "workspace: prepared index path %q is duplicated", + path, + ) + } + seen[path] = struct{}{} + entries = append(entries, &preparedCheckoutEntry{ + mode: fields[0], + oid: fields[1], + path: path, + }) + } + return entries, nil +} + +func rejectPreparedCheckoutFilters( + ctx context.Context, + repository repositoryCapability, + entries []*preparedCheckoutEntry, +) error { + expected := make(map[string]struct{}) + var paths strings.Builder + for _, entry := range entries { + if entry.mode != preparedCheckoutRegularMode && + entry.mode != preparedCheckoutExecutableMode { + continue + } + expected[entry.path] = struct{}{} + paths.WriteString(entry.path) + paths.WriteByte(0) + } + if paths.Len() == 0 { + return nil + } + output, err := repository.runPrivateGitAt( + ctx, + ".", + paths.String(), + "check-attr", + "--cached", + "-z", + "--stdin", + "filter", + ) + if err != nil { + return fmt.Errorf( + "workspace: inspect prepared checkout filters: %w", + err, + ) + } + if len(output) == 0 || output[len(output)-1] != 0 { + return errors.New( + "workspace: prepared checkout filter query returned incomplete output", + ) + } + fields := bytes.Split(output[:len(output)-1], []byte{0}) + if len(fields)%3 != 0 || len(fields)/3 != len(expected) { + return errors.New( + "workspace: prepared checkout filter query returned invalid output", + ) + } + seen := make(map[string]struct{}, len(expected)) + for index := 0; index < len(fields); index += 3 { + path, err := validatePreparedCheckoutPath(string(fields[index])) + if err != nil { + return err + } + if _, exists := expected[path]; !exists { + return fmt.Errorf( + "workspace: prepared checkout filter query returned unexpected path %q", + path, + ) + } + if _, exists := seen[path]; exists { + return fmt.Errorf( + "workspace: prepared checkout filter query returned path %q twice", + path, + ) + } + seen[path] = struct{}{} + if string(fields[index+1]) != "filter" { + return errors.New( + "workspace: prepared checkout filter query returned an invalid attribute", + ) + } + value := string(fields[index+2]) + if value != "unspecified" && value != "unset" { + return fmt.Errorf( + "workspace: working-tree filter %q for %q is unsupported on this platform", + value, + path, + ) + } + } + return nil +} + +func validatePreparedCheckoutOID(oid string) error { + if len(oid) != 40 && len(oid) != 64 { + return errors.New( + "workspace: prepared index returned an invalid object ID", + ) + } + if _, err := hex.DecodeString(oid); err != nil { + return errors.New( + "workspace: prepared index returned an invalid object ID", + ) + } + return nil +} + +func validatePreparedCheckoutPath(path string) (string, error) { + clean, err := validateIncludedPath(path) + if err != nil || filepath.ToSlash(clean) != path { + return "", fmt.Errorf( + "workspace: prepared index path %q is invalid", + path, + ) + } + first, _, _ := strings.Cut(path, "/") + if strings.EqualFold(first, ".git") { + return "", fmt.Errorf( + "workspace: prepared index path %q targets Git metadata", + path, + ) + } + return clean, nil +} + +func validPreparedCheckoutTempName(name string) bool { + return filepath.Base(name) == name && + name != "." && + strings.HasPrefix(name, ".merge_file_") +} + +func openPreparedCheckoutTemp( + root *os.Root, + temp *preparedCheckoutTemp, +) error { + if temp == nil || temp.file != nil || + !validPreparedCheckoutTempName(temp.name) { + return errors.New( + "workspace: prepared checkout temporary handle is invalid", + ) + } + info, err := root.Lstat(temp.name) + if err != nil { + return fmt.Errorf( + "workspace: inspect prepared checkout temporary path %q: %w", + temp.name, + err, + ) + } + if !info.Mode().IsRegular() || + info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf( + "workspace: prepared checkout temporary path %q is not a regular file", + temp.name, + ) + } + file, err := root.Open(temp.name) + if err != nil { + return fmt.Errorf( + "workspace: open prepared checkout temporary path %q: %w", + temp.name, + err, + ) + } + opened, err := file.Stat() + if err != nil { + return errors.Join( + fmt.Errorf( + "workspace: inspect opened prepared checkout temporary path %q: %w", + temp.name, + err, + ), + file.Close(), + ) + } + if !opened.Mode().IsRegular() || + opened.Mode()&os.ModeSymlink != 0 || + !os.SameFile(info, opened) { + return errors.Join( + fmt.Errorf( + "workspace: prepared checkout temporary path %q changed while opening", + temp.name, + ), + file.Close(), + ) + } + temp.file = file + temp.info = opened + return nil +} + +func bindPreparedCheckoutTemps( + entries []*preparedCheckoutEntry, + raw []byte, +) error { + if len(raw) == 0 || raw[len(raw)-1] != 0 { + return errors.New( + "workspace: prepared checkout returned incomplete output", + ) + } + byPath := make(map[string]*preparedCheckoutEntry, len(entries)) + for _, entry := range entries { + if entry.mode == preparedCheckoutRegularMode || + entry.mode == preparedCheckoutExecutableMode { + byPath[entry.path] = entry + } + } + seenTemps := make(map[string]struct{}, len(entries)) + for _, record := range bytes.Split(raw[:len(raw)-1], []byte{0}) { + rawTemp, rawPath, ok := bytes.Cut(record, []byte{'\t'}) + if !ok { + return errors.New( + "workspace: prepared checkout returned an invalid entry", + ) + } + temp := string(rawTemp) + if !validPreparedCheckoutTempName(temp) { + return errors.New( + "workspace: prepared checkout returned an invalid temporary path", + ) + } + if _, exists := seenTemps[temp]; exists { + return errors.New( + "workspace: prepared checkout reused a temporary path", + ) + } + seenTemps[temp] = struct{}{} + path, err := validatePreparedCheckoutPath(string(rawPath)) + if err != nil { + return err + } + entry, exists := byPath[path] + if !exists { + return fmt.Errorf( + "workspace: prepared checkout returned unexpected path %q", + path, + ) + } + if entry.temp != nil { + return fmt.Errorf( + "workspace: prepared checkout returned path %q twice", + path, + ) + } + entry.temp = &preparedCheckoutTemp{name: temp} + } + for _, entry := range entries { + if (entry.mode == preparedCheckoutRegularMode || + entry.mode == preparedCheckoutExecutableMode) && + entry.temp == nil { + return fmt.Errorf( + "workspace: prepared checkout omitted path %q", + entry.path, + ) + } + } + return nil +} + +func hashPreparedCheckoutBytes( + ctx context.Context, + repository repositoryCapability, + input io.Reader, + output io.Writer, + path string, + expectedHeadOID string, +) (string, error) { + command, cleanup, err := repository.privateGitCommandAt( + ctx, + ".", + "--attr-source="+expectedHeadOID, + "hash-object", + "--stdin", + "--path="+path, + ) + if err != nil { + return "", err + } + command.Stdin = io.TeeReader(input, output) + rawOID, commandErr := runGitCommand(command, "") + if err := errors.Join(commandErr, cleanup()); err != nil { + return "", fmt.Errorf( + "workspace: hash prepared checkout bytes for %q: %w", + path, + err, + ) + } + oid := strings.TrimSpace(string(rawOID)) + if err := validatePreparedCheckoutOID(oid); err != nil { + return "", fmt.Errorf( + "workspace: hash prepared checkout bytes for %q: %w", + path, + err, + ) + } + return oid, nil +} + +func samePreparedCheckoutTempState( + expected os.FileInfo, + current os.FileInfo, +) bool { + return expected.Mode().IsRegular() && + current.Mode().IsRegular() && + expected.Mode()&os.ModeSymlink == 0 && + current.Mode()&os.ModeSymlink == 0 && + os.SameFile(expected, current) && + expected.Size() == current.Size() && + expected.Mode() == current.Mode() && + expected.ModTime().Equal(current.ModTime()) +} + +func removePreparedCheckoutTemps( + root *os.Root, + temps []*preparedCheckoutTemp, +) error { + var result error + for _, temp := range temps { + if temp == nil || temp.file == nil { + continue + } + if temp.verified { + result = errors.Join( + result, + removeOwnedRecordPathIfSame( + root, + temp.name, + temp.file, + ".drove-checkout-temp-"+uuid.NewString(), + nil, + ), + ) + } + result = errors.Join(result, temp.file.Close()) + } + return result +} + +func installPreparedCheckoutFile( + ctx context.Context, + repository repositoryCapability, + worktreeRoot *os.Root, + expectedHeadOID string, + entry *preparedCheckoutEntry, +) (result error) { + if entry.temp == nil { + return fmt.Errorf( + "workspace: prepared checkout source for %q is unavailable", + entry.path, + ) + } + if err := openPreparedCheckoutTemp( + repository.gitRoot, + entry.temp, + ); err != nil { + return err + } + defer func() { + result = errors.Join( + result, + removePreparedCheckoutTemps( + repository.gitRoot, + []*preparedCheckoutTemp{entry.temp}, + ), + ) + }() + input := entry.temp.file + sourceInfo, err := input.Stat() + if err != nil { + return fmt.Errorf( + "workspace: inspect prepared checkout source for %q: %w", + entry.path, + err, + ) + } + if !samePreparedCheckoutTempState(entry.temp.info, sourceInfo) { + return fmt.Errorf( + "workspace: prepared checkout source for %q changed before copying", + entry.path, + ) + } + + parent, owned, err := openPreparedCheckoutParent( + worktreeRoot, + filepath.Dir(entry.path), + ) + if err != nil { + return err + } + if owned { + defer func() { + result = errors.Join(result, parent.Close()) + }() + } + name := filepath.Base(entry.path) + mode := os.FileMode(0o666) + if entry.mode == preparedCheckoutExecutableMode { + mode = 0o777 + } + output, err := parent.OpenFile( + name, + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + mode, + ) + if err != nil { + return fmt.Errorf( + "workspace: create prepared checkout path %q: %w", + entry.path, + err, + ) + } + outputOpen := true + installed := false + defer func() { + if !installed { + result = errors.Join( + result, + removeOwnedRecordPathIfSame( + parent, + name, + output, + ".drove-checkout-discard-"+uuid.NewString(), + nil, + ), + ) + } + if outputOpen { + result = errors.Join(result, output.Close()) + } + }() + + if _, err := input.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf( + "workspace: rewind prepared checkout source for %q: %w", + entry.path, + err, + ) + } + copiedOID, err := hashPreparedCheckoutBytes( + ctx, + repository, + input, + output, + entry.path, + expectedHeadOID, + ) + if err != nil { + return err + } + afterCopy, err := input.Stat() + if err != nil { + return err + } + outputInfo, err := output.Stat() + if err != nil { + return err + } + if !samePreparedCheckoutTempState(sourceInfo, afterCopy) || + !samePreparedCheckoutTempState(entry.temp.info, afterCopy) { + return fmt.Errorf( + "workspace: prepared checkout source for %q changed while copying", + entry.path, + ) + } + if outputInfo.Size() != sourceInfo.Size() { + return fmt.Errorf( + "workspace: prepared checkout copy for %q is incomplete", + entry.path, + ) + } + if !strings.EqualFold(copiedOID, entry.oid) { + return fmt.Errorf( + "workspace: prepared checkout source for %q does not match the index", + entry.path, + ) + } + entry.temp.verified = true + if err := output.Sync(); err != nil { + return err + } + outputInfo, err = output.Stat() + if err != nil { + return err + } + gotExecutable := outputInfo.Mode().Perm()&0o111 != 0 + wantExecutable := entry.mode == preparedCheckoutExecutableMode + if gotExecutable != wantExecutable { + return fmt.Errorf( + "workspace: prepared checkout mode for %q does not match the index", + entry.path, + ) + } + if err := output.Close(); err != nil { + outputOpen = false + installed = true + return err + } + outputOpen = false + installed = true + directory, err := openRecordDirectory(parent) + if err != nil { + return err + } + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + return errors.Join(syncErr, closeErr) +} + +func installPreparedCheckoutSymlink( + ctx context.Context, + repository repositoryCapability, + worktreeRoot *os.Root, + entry *preparedCheckoutEntry, +) (result error) { + target, err := repository.runPrivateGitAt( + ctx, + ".", + "", + "cat-file", + "blob", + entry.oid, + ) + if err != nil { + return err + } + if bytes.IndexByte(target, 0) >= 0 { + return fmt.Errorf( + "workspace: prepared checkout symlink %q contains a null byte", + entry.path, + ) + } + parent, owned, err := openPreparedCheckoutParent( + worktreeRoot, + filepath.Dir(entry.path), + ) + if err != nil { + return err + } + if owned { + defer func() { + result = errors.Join(result, parent.Close()) + }() + } + directory, err := openRecordDirectory(parent) + if err != nil { + return err + } + if err := unix.Symlinkat( + string(target), + int(directory.Fd()), + filepath.Base(entry.path), + ); err != nil { + _ = directory.Close() + return fmt.Errorf( + "workspace: create prepared checkout symlink %q: %w", + entry.path, + err, + ) + } + info, inspectErr := parent.Lstat(filepath.Base(entry.path)) + if inspectErr == nil && info.Mode()&os.ModeSymlink == 0 { + inspectErr = errors.New("installed path is not a symlink") + } + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(inspectErr, syncErr, closeErr); err != nil { + return fmt.Errorf( + "workspace: verify prepared checkout symlink %q: %w", + entry.path, + err, + ) + } + return nil +} + +func installPreparedCheckoutGitlink( + worktreeRoot *os.Root, + path string, +) (result error) { + parent, owned, err := openPreparedCheckoutParent( + worktreeRoot, + filepath.Dir(path), + ) + if err != nil { + return err + } + if owned { + defer func() { + result = errors.Join(result, parent.Close()) + }() + } + name := filepath.Base(path) + if err := parent.Mkdir(name, 0o777); err != nil { + return fmt.Errorf( + "workspace: create prepared checkout gitlink %q: %w", + path, + err, + ) + } + info, err := parent.Lstat(name) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf( + "workspace: prepared checkout gitlink %q is not a real directory", + path, + ) + } + directory, err := openRecordDirectory(parent) + if err != nil { + return err + } + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + return errors.Join(syncErr, closeErr) +} + +func openPreparedCheckoutParent( + root *os.Root, + path string, +) (_ *os.Root, _ bool, result error) { + current := root + owned := false + defer func() { + if result != nil && owned { + result = errors.Join(result, current.Close()) + } + }() + for _, component := range strings.Split( + path, + string(filepath.Separator), + ) { + if component == "." || component == "" { + continue + } + info, err := current.Lstat(component) + switch { + case errors.Is(err, os.ErrNotExist): + if err := current.Mkdir(component, 0o777); err != nil { + return nil, false, fmt.Errorf( + "workspace: create prepared checkout directory %q: %w", + component, + err, + ) + } + directory, openErr := openRecordDirectory(current) + if openErr != nil { + return nil, false, openErr + } + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(syncErr, closeErr); err != nil { + return nil, false, err + } + info, err = current.Lstat(component) + if err != nil { + return nil, false, err + } + case err != nil: + return nil, false, err + case !info.IsDir() || info.Mode()&os.ModeSymlink != 0: + return nil, false, fmt.Errorf( + "workspace: prepared checkout parent %q is not a real directory", + component, + ) + } + next, err := openRealRootFromRoot(current, component) + if err != nil { + return nil, false, err + } + if owned { + if err := current.Close(); err != nil { + _ = next.Close() + return nil, false, err + } + } + current = next + owned = true + } + return current, owned, nil +} diff --git a/internal/workspace/prepared_checkout_unix_test.go b/internal/workspace/prepared_checkout_unix_test.go new file mode 100644 index 0000000..71e74f6 --- /dev/null +++ b/internal/workspace/prepared_checkout_unix_test.go @@ -0,0 +1,385 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package workspace + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func TestPreparedCheckoutMaterializesTrackedEntryTypes(t *testing.T) { + repository := newTestRepository(t) + executable := filepath.Join(repository, "run.sh") + if err := os.WriteFile( + executable, + []byte("#!/bin/sh\nexit 0\n"), + 0o700, + ); err != nil { + t.Fatalf("write executable: %v", err) + } + if err := os.Symlink( + "tracked.txt", + filepath.Join(repository, "relative-link"), + ); err != nil { + t.Skipf("create relative symlink: %v", err) + } + absoluteTarget := filepath.Join(t.TempDir(), "outside") + if err := os.Symlink( + absoluteTarget, + filepath.Join(repository, "absolute-link"), + ); err != nil { + t.Skipf("create absolute symlink: %v", err) + } + runGit( + t, + repository, + "add", + "run.sh", + "relative-link", + "absolute-link", + ) + runGit(t, repository, "update-index", "--chmod=+x", "run.sh") + runGit(t, repository, "commit", "-m", "add tracked entry types") + head := strings.TrimSpace(runGitOutput(t, repository, "rev-parse", "HEAD")) + runGit( + t, + repository, + "update-index", + "--add", + "--cacheinfo", + "160000,"+head+",modules/sub", + ) + runGit(t, repository, "commit", "-m", "add gitlink") + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + content, err := os.ReadFile(filepath.Join(prepared.Path, "tracked.txt")) + if err != nil { + t.Fatalf("read tracked file: %v", err) + } + if string(content) != "tracked\n" { + t.Fatalf("tracked file = %q", content) + } + info, err := os.Stat(filepath.Join(prepared.Path, "run.sh")) + if err != nil { + t.Fatalf("inspect executable: %v", err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Fatalf("executable mode = %v", info.Mode()) + } + relative, err := os.Readlink( + filepath.Join(prepared.Path, "relative-link"), + ) + if err != nil { + t.Fatalf("read relative symlink: %v", err) + } + if relative != "tracked.txt" { + t.Fatalf("relative symlink = %q", relative) + } + absolute, err := os.Readlink( + filepath.Join(prepared.Path, "absolute-link"), + ) + if err != nil { + t.Fatalf("read absolute symlink: %v", err) + } + if absolute != absoluteTarget { + t.Fatalf("absolute symlink = %q", absolute) + } + gitlink, err := os.Lstat( + filepath.Join(prepared.Path, "modules", "sub"), + ) + if err != nil { + t.Fatalf("inspect gitlink: %v", err) + } + if !gitlink.IsDir() || gitlink.Mode()&os.ModeSymlink != 0 { + t.Fatalf("gitlink mode = %v", gitlink.Mode()) + } +} + +func TestPreparedCheckoutRejectsWorkingTreeFilter(t *testing.T) { + repository := newTestRepository(t) + sentinel := filepath.Join(t.TempDir(), "filter-ran") + filter := filepath.Join(t.TempDir(), "filter") + if err := os.WriteFile( + filter, + []byte("#!/bin/sh\nprintf ran > \"$DROVE_TEST_SENTINEL\"\ncat\n"), + 0o700, + ); err != nil { + t.Fatalf("write smudge filter: %v", err) + } + t.Setenv("DROVE_TEST_SENTINEL", sentinel) + runGit(t, repository, "config", "filter.review.smudge", filter) + runGit(t, repository, "config", "filter.review.clean", "cat") + runGit(t, repository, "config", "filter.review.required", "true") + if err := os.WriteFile( + filepath.Join(repository, ".gitattributes"), + []byte("tracked.txt filter=review\n"), + 0o600, + ); err != nil { + t.Fatalf("write attributes: %v", err) + } + runGit(t, repository, "add", ".gitattributes") + runGit(t, repository, "commit", "-m", "add checkout filter") + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + if _, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ); err == nil || !strings.Contains( + err.Error(), + "working-tree filter", + ) { + t.Fatalf("prepare filtered worktree error = %v", err) + } + if _, err := os.Stat(sentinel); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rejected smudge filter ran: %v", err) + } +} + +func TestPreparedCheckoutPreservesTextConversion(t *testing.T) { + repository := newTestRepository(t) + if err := os.WriteFile( + filepath.Join(repository, ".gitattributes"), + []byte("tracked.txt text eol=crlf\n"), + 0o600, + ); err != nil { + t.Fatalf("write attributes: %v", err) + } + runGit(t, repository, "add", ".gitattributes") + runGit(t, repository, "commit", "-m", "add checkout text conversion") + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare converted worktree: %v", err) + } + content, err := os.ReadFile(filepath.Join(prepared.Path, "tracked.txt")) + if err != nil { + t.Fatalf("read converted tracked file: %v", err) + } + if string(content) != "tracked\r\n" { + t.Fatalf("converted tracked file = %q, want CRLF", content) + } +} + +func TestRemovePreparedCheckoutTempsPreservesReplacement(t *testing.T) { + path := t.TempDir() + const ( + name = ".merge_file_owned" + original = ".merge_file_owned.original" + content = "trusted\n" + ) + if err := os.WriteFile( + filepath.Join(path, name), + []byte(content), + 0o600, + ); err != nil { + t.Fatalf("write prepared checkout temporary: %v", err) + } + root, err := os.OpenRoot(path) + if err != nil { + t.Fatalf("open temporary root: %v", err) + } + defer root.Close() + temp := &preparedCheckoutTemp{name: name} + if err := openPreparedCheckoutTemp(root, temp); err != nil { + t.Fatalf("open prepared checkout temporary: %v", err) + } + temps := []*preparedCheckoutTemp{temp} + closed := false + defer func() { + if !closed { + for _, temp := range temps { + _ = temp.file.Close() + } + } + }() + temp.verified = true + if err := os.Rename( + filepath.Join(path, name), + filepath.Join(path, original), + ); err != nil { + t.Fatalf("rename original temporary: %v", err) + } + if err := os.WriteFile( + filepath.Join(path, name), + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement temporary: %v", err) + } + err = removePreparedCheckoutTemps(root, temps) + closed = true + if err == nil || !strings.Contains(err.Error(), "changed identity") { + t.Fatalf("remove replaced prepared checkout temporary error = %v", err) + } + assertFileContents(t, filepath.Join(path, name), "replacement\n") + assertFileContents(t, filepath.Join(path, original), content) +} + +func TestPreparedCheckoutKeepsOpenFilesBounded(t *testing.T) { + var original unix.Rlimit + if err := unix.Getrlimit(unix.RLIMIT_NOFILE, &original); err != nil { + t.Skipf("read open file limit: %v", err) + } + if original.Cur < 64 { + t.Skipf("open file limit %d is already below test limit", original.Cur) + } + limited := original + limited.Cur = 64 + if err := unix.Setrlimit(unix.RLIMIT_NOFILE, &limited); err != nil { + t.Skipf("lower open file limit: %v", err) + } + defer func() { + if err := unix.Setrlimit(unix.RLIMIT_NOFILE, &original); err != nil { + t.Errorf("restore open file limit: %v", err) + } + }() + + repository := newTestRepository(t) + for index := range 96 { + name := filepath.Join(repository, fmt.Sprintf("tracked-%03d.txt", index)) + if err := os.WriteFile(name, []byte("tracked\n"), 0o600); err != nil { + t.Fatalf("write tracked file %d: %v", index, err) + } + } + runGit(t, repository, "add", "--all") + runGit(t, repository, "commit", "-m", "add many tracked files") + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + if _, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ); err != nil { + t.Fatalf("prepare worktree with bounded open files: %v", err) + } +} + +func TestPreparedCheckoutSupportsSHA256Objects(t *testing.T) { + repository := filepath.Join(t.TempDir(), "repository") + if err := os.MkdirAll(repository, 0o700); err != nil { + t.Fatalf("create SHA-256 repository: %v", err) + } + command := exec.Command( + "git", + "-C", + repository, + "init", + "--object-format=sha256", + "--initial-branch=main", + ) + if output, err := command.CombinedOutput(); err != nil { + t.Skipf("Git does not support SHA-256 repositories: %v\n%s", err, output) + } + runGit(t, repository, "config", "user.name", "Drove Test") + runGit(t, repository, "config", "user.email", "drove@example.invalid") + if err := os.WriteFile( + filepath.Join(repository, "tracked.txt"), + []byte("sha256\n"), + 0o600, + ); err != nil { + t.Fatalf("write SHA-256 tracked file: %v", err) + } + runGit(t, repository, "add", "tracked.txt") + runGit(t, repository, "commit", "-m", "initial") + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare SHA-256 worktree: %v", err) + } + assertFileContents( + t, + filepath.Join(prepared.Path, "tracked.txt"), + "sha256\n", + ) +} + +func TestParsePreparedCheckoutIndexRejectsInvalidEntries(t *testing.T) { + validOID := strings.Repeat("0", 40) + tests := map[string]string{ + "incomplete": preparedCheckoutRegularMode + " " + validOID + " 0\tfile", + "nonzero stage": preparedCheckoutRegularMode + " " + validOID + " 1\tfile\x00", + "unsupported mode": "100600 " + validOID + " 0\tfile\x00", + "invalid oid": preparedCheckoutRegularMode + " xyz 0\tfile\x00", + "escaping path": preparedCheckoutRegularMode + " " + validOID + " 0\t../file\x00", + "git metadata": preparedCheckoutRegularMode + " " + validOID + " 0\t.GIT/config\x00", + "duplicate path": preparedCheckoutRegularMode + " " + validOID + " 0\tfile\x00" + + preparedCheckoutRegularMode + " " + validOID + " 0\tfile\x00", + } + for name, raw := range tests { + t.Run(name, func(t *testing.T) { + if _, err := parsePreparedCheckoutIndex([]byte(raw)); err == nil { + t.Fatal("invalid prepared index entry was accepted") + } + }) + } +} + +func runGitOutput( + t *testing.T, + repository string, + arguments ...string, +) string { + t.Helper() + command := exec.Command("git", append([]string{"-C", repository}, arguments...)...) + output, err := command.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + t.Fatalf( + "git %v: %v\n%s", + arguments, + err, + exitErr.Stderr, + ) + } + t.Fatalf("git %v: %v", arguments, err) + } + return string(output) +} diff --git a/internal/workspace/prepared_registration_bound.go b/internal/workspace/prepared_registration_bound.go new file mode 100644 index 0000000..5a537cd --- /dev/null +++ b/internal/workspace/prepared_registration_bound.go @@ -0,0 +1,639 @@ +//go:build linux || windows + +package workspace + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/google/uuid" +) + +func preparePreparedWorktreeAdd( + _ Workspace, + _ *preparedWorktreeTarget, + _ repositoryCapability, +) (bool, error) { + return false, nil +} + +func finalizePreparedWorktreeAdd( + _ context.Context, + _ repositoryCapability, + _ Workspace, + _ *preparedWorktreeTarget, + _ bool, +) error { + return nil +} + +func repairBoundPreparedWorktreeRegistration( + ctx context.Context, + target Workspace, + prepared *preparedWorktreeTarget, + repository repositoryCapability, + stalePath string, +) (handled bool, result error) { + if err := errors.Join( + repository.verifyBinding(ctx), + verifyRealPathRoot(target.Path, prepared.root), + ); err != nil { + return true, err + } + relativeGitDirectory, err := repository.boundGitDirectory( + prepared.gitDirectory, + target.AgentID, + ) + if err != nil { + return true, err + } + gitRoot, err := openRealRootFromRoot( + repository.commonRoot, + relativeGitDirectory, + ) + if err != nil { + return true, fmt.Errorf( + "workspace: open prepared private Git directory: %w", + err, + ) + } + defer func() { + result = errors.Join(result, gitRoot.Close()) + }() + if err := validateGitPointerTarget( + prepared.root, + ".git", + "gitdir: ", + prepared.gitDirectory, + ); err != nil { + return true, fmt.Errorf( + "workspace: validate prepared worktree Git pointer: %w", + err, + ) + } + oldPointer := filepath.ToSlash(filepath.Join(stalePath, ".git")) + "\n" + newPointer := filepath.ToSlash(filepath.Join(target.Path, ".git")) + "\n" + if err := ensureBoundGitPointer( + gitRoot, + "gitdir", + boundGitPointerTempPrefix( + filepath.Base(relativeGitDirectory), + "gitdir", + ), + newPointer, + oldPointer, + ); err != nil { + return true, fmt.Errorf( + "workspace: rebind prepared private Git directory: %w", + err, + ) + } + if err := validateGitPointerTarget( + prepared.root, + ".git", + "gitdir: ", + prepared.gitDirectory, + ); err != nil { + return true, fmt.Errorf( + "workspace: revalidate prepared worktree Git pointer: %w", + err, + ) + } + privateRepository := repository.withPrivateGitBinding( + prepared.gitDirectory, + gitRoot, + ) + registered, exists, err := repository.worktreeRegistration( + ctx, + target.Path, + ) + if err != nil { + return true, err + } + if !exists { + return true, errors.New( + "workspace: promoted worktree registration is missing", + ) + } + if err := privateRepository.verifyPreparedWorktree( + ctx, + target, + prepared.gitDirectory, + registered, + ); err != nil { + return true, err + } + return true, errors.Join( + repository.verifyBinding(ctx), + verifyRealPathRoot(target.Path, prepared.root), + verifyRealPathRoot(prepared.gitDirectory, gitRoot), + ) +} + +func cleanupPreparedWorktreeAddDebris( + _ context.Context, + _ repositoryCapability, + _ Workspace, + _ workspaceRecord, +) error { + return nil +} + +func validateGitPointerTarget( + root *os.Root, + name string, + prefix string, + expectedPath string, +) error { + content, err := readBoundRegularFile(root, name) + if err != nil { + return err + } + return validateGitPointerContent(content, prefix, expectedPath) +} + +func validateGitPointerContent( + content string, + prefix string, + expectedPath string, +) error { + value, found := strings.CutPrefix(content, prefix) + if !found || !strings.HasSuffix(value, "\n") { + return errors.New("workspace: Git pointer has invalid contents") + } + value = strings.TrimSuffix(value, "\n") + if value == "" { + return errors.New("workspace: Git pointer has invalid contents") + } + actual := filepath.Clean(filepath.FromSlash(value)) + expected := filepath.Clean(expectedPath) + matches := actual == expected + if runtime.GOOS == "windows" { + matches = strings.EqualFold(actual, expected) + } + if !matches { + return errors.New("workspace: Git pointer has an unexpected target") + } + return nil +} + +func boundGitPointerTempPrefix(stageName string, targetName string) string { + digest := sha256.Sum256([]byte(stageName + "\x00" + targetName)) + return fmt.Sprintf(".drove-git-pointer-%x-", digest[:8]) +} + +func ensureBoundGitPointer( + root *os.Root, + targetName string, + tempPrefix string, + content string, + allowedOld string, +) (result error) { + if err := recoverRecordRenameDebris(root); err != nil { + return err + } + newPrefix := tempPrefix + "new-" + oldPrefix := tempPrefix + "old-" + if err := validateBoundGitPointerNamespace( + root, + tempPrefix, + newPrefix, + oldPrefix, + ); err != nil { + return err + } + current, exists, err := readOptionalBoundRegularFile(root, targetName) + if err != nil { + return err + } + _, oldFile, err := findBoundGitPointerTemp( + root, + oldPrefix, + allowedOld, + ) + if err != nil { + return err + } + closeOldFile := func() error { + if oldFile == nil { + return nil + } + closeErr := oldFile.Close() + oldFile = nil + return closeErr + } + defer func() { + result = errors.Join(result, closeOldFile()) + }() + if exists && current == content { + if err := closeOldFile(); err != nil { + return err + } + return errors.Join( + cleanupBoundGitPointerTemps(root, newPrefix, content), + cleanupBoundGitPointerTemps(root, oldPrefix, allowedOld), + ) + } + switch { + case exists && current != allowedOld: + return fmt.Errorf( + "workspace: Git pointer %q has unexpected contents", + targetName, + ) + case allowedOld == "" && oldFile != nil: + return fmt.Errorf( + "workspace: Git pointer %q has an unexpected backup", + targetName, + ) + case !exists && allowedOld != "" && oldFile == nil: + return fmt.Errorf( + "workspace: Git pointer %q is missing", + targetName, + ) + } + if exists { + if oldFile == nil { + if err := isolateBoundGitPointer( + root, + targetName, + oldPrefix+uuid.NewString(), + allowedOld, + nil, + ); err != nil { + return err + } + } else { + currentFile, err := openBoundRegularFile(root, targetName) + if err != nil { + return err + } + currentInfo, currentErr := currentFile.Stat() + oldInfo, oldErr := oldFile.Stat() + if err := errors.Join(currentErr, oldErr); err != nil { + return errors.Join(err, currentFile.Close()) + } + if !os.SameFile(currentInfo, oldInfo) { + return errors.Join( + fmt.Errorf( + "workspace: Git pointer %q conflicts with its backup", + targetName, + ), + currentFile.Close(), + ) + } + removeErr := removeOwnedRecordPath( + root, + targetName, + currentFile, + oldPrefix+uuid.NewString(), + func(candidate *os.File) error { + info, err := candidate.Stat() + if err != nil { + return err + } + if !os.SameFile(info, oldInfo) { + return errors.New( + "workspace: Git pointer changed before isolation", + ) + } + return nil + }, + nil, + ) + closeErr := currentFile.Close() + if err := errors.Join(removeErr, closeErr); err != nil { + return err + } + } + } + if err := closeOldFile(); err != nil { + return err + } + tempName, tempFile, err := findBoundGitPointerTemp( + root, + newPrefix, + content, + ) + if err != nil { + return err + } + if tempFile == nil { + tempName = newPrefix + uuid.NewString() + tempFile, err = root.OpenFile( + tempName, + os.O_RDWR|os.O_CREATE|os.O_EXCL, + 0o600, + ) + if err != nil { + return err + } + if _, err := io.WriteString(tempFile, content); err != nil { + return errors.Join(err, tempFile.Close()) + } + if err := tempFile.Sync(); err != nil { + return errors.Join(err, tempFile.Close()) + } + } + defer func() { + result = errors.Join(result, tempFile.Close()) + }() + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + installed, renameErr := renameRecordFile( + directory, + tempFile, + tempName, + targetName, + false, + ) + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(renameErr, syncErr, closeErr); err != nil { + return err + } + if !installed { + return fmt.Errorf( + "workspace: Git pointer %q was not installed", + targetName, + ) + } + if err := validateBoundGitPointer(root, targetName, content); err != nil { + return err + } + return errors.Join( + cleanupBoundGitPointerTemps(root, newPrefix, content), + cleanupBoundGitPointerTemps(root, oldPrefix, allowedOld), + ) +} + +func isolateBoundGitPointer( + root *os.Root, + targetName string, + isolatedName string, + expected string, + afterValidation func(), +) (result error) { + file, err := openBoundRegularFile(root, targetName) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, file.Close()) + }() + raw, err := readOpenedBoundFile(file) + if err != nil { + return err + } + if raw != expected { + return fmt.Errorf( + "workspace: Git pointer %q changed contents", + targetName, + ) + } + if afterValidation != nil { + afterValidation() + } + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + moved, moveErr := moveRecordFile( + directory, + file, + targetName, + isolatedName, + ) + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(moveErr, syncErr, closeErr); err != nil { + return err + } + if !moved { + return fmt.Errorf( + "workspace: Git pointer %q was not isolated", + targetName, + ) + } + return nil +} + +func validateBoundGitPointerNamespace( + root *os.Root, + basePrefix string, + newPrefix string, + oldPrefix string, +) error { + entries, err := readRootDirectory(root) + if err != nil { + return err + } + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), basePrefix) { + continue + } + prefix := "" + switch { + case strings.HasPrefix(entry.Name(), newPrefix): + prefix = newPrefix + case strings.HasPrefix(entry.Name(), oldPrefix): + prefix = oldPrefix + } + if prefix == "" || + !canonicalUUID(strings.TrimPrefix(entry.Name(), prefix)) { + return fmt.Errorf( + "workspace: Git pointer artifact %q has an invalid name", + entry.Name(), + ) + } + } + return nil +} + +func findBoundGitPointerTemp( + root *os.Root, + prefix string, + content string, +) (string, *os.File, error) { + entries, err := readRootDirectory(root) + if err != nil { + return "", nil, err + } + var foundName string + var found *os.File + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), prefix) { + continue + } + suffix := strings.TrimPrefix(entry.Name(), prefix) + if !canonicalUUID(suffix) { + var closeErr error + if found != nil { + closeErr = found.Close() + } + return "", nil, errors.Join( + fmt.Errorf( + "workspace: Git pointer temporary %q has an invalid name", + entry.Name(), + ), + closeErr, + ) + } + file, err := openBoundRegularFile(root, entry.Name()) + if err != nil { + if found != nil { + err = errors.Join(err, found.Close()) + } + return "", nil, err + } + raw, err := readOpenedBoundFile(file) + if err != nil || raw != content { + closeErr := file.Close() + if found != nil { + closeErr = errors.Join(closeErr, found.Close()) + } + if err == nil { + err = errors.New( + "workspace: Git pointer temporary has unexpected contents", + ) + } + return "", nil, errors.Join(err, closeErr) + } + if found == nil { + foundName = entry.Name() + found = file + continue + } + if err := file.Close(); err != nil { + return "", nil, errors.Join(err, found.Close()) + } + } + return foundName, found, nil +} + +func cleanupBoundGitPointerTemps( + root *os.Root, + prefix string, + content string, +) error { + for { + name, file, err := findBoundGitPointerTemp(root, prefix, content) + if err != nil || file == nil { + return err + } + removeErr := removeOwnedRecordPath( + root, + name, + file, + prefix+uuid.NewString(), + func(candidate *os.File) error { + raw, err := readOpenedBoundFile(candidate) + if err != nil { + return err + } + if raw != content { + return errors.New( + "workspace: Git pointer temporary changed contents", + ) + } + return nil + }, + nil, + ) + closeErr := file.Close() + if err := errors.Join(removeErr, closeErr); err != nil { + return err + } + } +} + +func validateBoundGitPointer( + root *os.Root, + name string, + expected string, +) error { + raw, err := readBoundRegularFile(root, name) + if err != nil { + return err + } + if raw != expected { + return fmt.Errorf( + "workspace: Git pointer %q has unexpected contents", + name, + ) + } + return nil +} + +func readOptionalBoundRegularFile( + root *os.Root, + name string, +) (string, bool, error) { + _, err := root.Lstat(name) + if errors.Is(err, os.ErrNotExist) { + return "", false, nil + } + if err != nil { + return "", false, err + } + raw, err := readBoundRegularFile(root, name) + return raw, err == nil, err +} + +func readBoundRegularFile(root *os.Root, name string) (result string, err error) { + file, err := openBoundRegularFile(root, name) + if err != nil { + return "", err + } + defer func() { + err = errors.Join(err, file.Close()) + }() + return readOpenedBoundFile(file) +} + +func openBoundRegularFile(root *os.Root, name string) (*os.File, error) { + info, err := root.Lstat(name) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("workspace: %q is not a regular file", name) + } + file, err := root.Open(name) + if err != nil { + return nil, err + } + opened, err := file.Stat() + if err != nil { + return nil, errors.Join(err, file.Close()) + } + if !opened.Mode().IsRegular() || !os.SameFile(info, opened) { + return nil, errors.Join( + fmt.Errorf("workspace: %q changed while opening", name), + file.Close(), + ) + } + return file, nil +} + +func readOpenedBoundFile(file *os.File) (string, error) { + if _, err := file.Seek(0, io.SeekStart); err != nil { + return "", err + } + raw, err := io.ReadAll(io.LimitReader(file, 4097)) + if err != nil { + return "", err + } + if len(raw) > 4096 { + return "", errors.New("workspace: Git pointer is too large") + } + return string(raw), nil +} diff --git a/internal/workspace/prepared_registration_bsd.go b/internal/workspace/prepared_registration_bsd.go new file mode 100644 index 0000000..0c833b2 --- /dev/null +++ b/internal/workspace/prepared_registration_bsd.go @@ -0,0 +1,1128 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package workspace + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +func bsdPreparedWorktreeStageName(worktreePath string) (string, error) { + if !filepath.IsAbs(worktreePath) || + filepath.Clean(worktreePath) != worktreePath { + return "", errors.New( + "workspace: prepared worktree path is not a clean absolute path", + ) + } + base := filepath.Base(worktreePath) + if base == "." || base == string(filepath.Separator) { + return "", errors.New("workspace: prepared worktree name is invalid") + } + name := base + if len(name) > 200 { + return "", errors.New("workspace: prepared worktree name is too long") + } + return name, nil +} + +func validateBSDPreparedWorktreeNamesAvailable( + commonRoot *os.Root, + stageName string, +) (result error) { + if debris, err := findRootDeletionDebris( + commonRoot, + stageName, + "", + ); err != nil { + return err + } else if debris != "" { + return fmt.Errorf( + "workspace: prepared registration has deletion debris %q", + debris, + ) + } + if _, err := commonRoot.Lstat(stageName); !errors.Is(err, os.ErrNotExist) { + if err == nil { + return fmt.Errorf( + "workspace: prepared registration stage %q already exists", + stageName, + ) + } + return err + } + worktrees, err := openRealRootFromRoot(commonRoot, "worktrees") + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + defer func() { + result = errors.Join(result, worktrees.Close()) + }() + entries, err := readRootDirectory(worktrees) + if err != nil { + return err + } + for _, entry := range entries { + if entry.Name() == stageName || + strings.HasPrefix(entry.Name(), stageName) { + return fmt.Errorf( + "workspace: prepared private Git directory %q already exists", + entry.Name(), + ) + } + } + return nil +} + +func preparePreparedWorktreeAdd( + target Workspace, + prepared *preparedWorktreeTarget, + repository repositoryCapability, +) (_ bool, result error) { + stageName, err := bsdPreparedWorktreeStageName(prepared.path) + if err != nil { + return false, err + } + if err := validateBSDPreparedWorktreeNamesAvailable( + repository.commonRoot, + stageName, + ); err != nil { + return false, err + } + if err := repository.commonRoot.Mkdir(stageName, 0o700); err != nil { + return false, fmt.Errorf( + "workspace: create prepared registration stage: %w", + err, + ) + } + stageRoot, err := openRealRootFromRoot( + repository.commonRoot, + stageName, + ) + if err != nil { + return false, fmt.Errorf( + "workspace: open prepared registration stage: %w", + err, + ) + } + identityPersisted := false + defer func() { + if stageRoot == nil { + return + } + if !identityPersisted { + entries, readErr := readRootDirectory(stageRoot) + if readErr == nil && len(entries) == 0 { + result = errors.Join( + result, + removeOpenedDirectoryFromRoot( + repository.commonRoot, + stageName, + stageRoot, + ), + ) + stageRoot = nil + } else { + result = errors.Join(result, readErr) + } + } + if stageRoot != nil { + result = errors.Join(result, stageRoot.Close()) + } + }() + stageIdentity, err := openedDirectoryIdentity(stageRoot) + if err != nil { + return false, fmt.Errorf( + "workspace: inspect prepared registration stage identity: %w", + err, + ) + } + if err := verifyRootEntryUnchanged( + repository.commonRoot, + stageName, + stageRoot, + ); err != nil { + return false, err + } + if err := syncRecordBucket( + repository.commonRoot, + stageName, + ); err != nil { + return false, err + } + record, exists, err := repository.manager.readWorkspaceRecord( + target.Path, + ) + if err == nil && !exists { + err = errors.New("workspace: preparation record is missing") + } + if err != nil { + return false, err + } + if target.branchOperationID == "" || + record.BranchOperationID != target.branchOperationID || + record.PreparedStageDirectoryIdentity != "" || + !sameWorkspace(record.workspace(), target) { + return false, errors.New( + "workspace: preparation record does not accept a registration stage", + ) + } + record.PreparedStageDirectoryIdentity = stageIdentity + if err := repository.manager.replaceWorkspaceRecord(record); err != nil { + return false, fmt.Errorf( + "workspace: persist prepared registration stage identity: %w", + err, + ) + } + identityPersisted = true + persisted, exists, err := repository.manager.readWorkspaceRecord( + target.Path, + ) + if err != nil { + return false, err + } + if !exists || + persisted.PreparedStageDirectoryIdentity != stageIdentity || + persisted.BranchOperationID != target.branchOperationID || + !sameWorkspace(persisted.workspace(), target) { + return false, errors.New( + "workspace: prepared registration stage identity was not persisted", + ) + } + currentIdentity, err := openedDirectoryIdentity(stageRoot) + if err != nil { + return false, err + } + if currentIdentity != stageIdentity { + return false, errors.New( + "workspace: prepared registration stage identity changed after persistence", + ) + } + if err := verifyRootEntryUnchanged( + repository.commonRoot, + stageName, + stageRoot, + ); err != nil { + return false, err + } + prepared.registrationStageRoot = stageRoot + stageRoot = nil + return true, nil +} + +func finalizePreparedWorktreeAdd( + ctx context.Context, + repository repositoryCapability, + target Workspace, + prepared *preparedWorktreeTarget, + commandSucceeded bool, +) (result error) { + worktreePath := prepared.path + worktreeRoot := prepared.root + stageName, err := bsdPreparedWorktreeStageName(worktreePath) + if err != nil { + return err + } + stageRoot := prepared.registrationStageRoot + prepared.registrationStageRoot = nil + if stageRoot == nil { + return errors.New( + "workspace: prepared registration stage is unavailable", + ) + } + if !commandSucceeded { + return cleanupOpenedBSDPreparedWorktreeStage( + ctx, + repository, + stageName, + target.AgentID, + "", + stageRoot, + ) + } + finalized := false + defer func() { + if !finalized && stageRoot != nil { + result = errors.Join( + result, + cleanupOpenedBSDPreparedWorktreeStage( + ctx, + repository, + stageName, + target.AgentID, + worktreePath, + stageRoot, + ), + ) + stageRoot = nil + } + }() + if err := errors.Join( + repository.verifyBinding(ctx), + verifyRealPathRoot(worktreePath, worktreeRoot), + ); err != nil { + return err + } + privatePath, privateName, privateRoot, err := + openBSDPreparedPrivateGitDirectory( + repository, + stageRoot, + target.AgentID, + ) + if err != nil { + return fmt.Errorf( + "workspace: open prepared private Git directory: %w", + err, + ) + } + defer func() { + result = errors.Join(result, privateRoot.Close()) + }() + if err := validateBSDPrivateAdminSet( + repository.commonRoot, + stageName, + privateName, + ); err != nil { + return err + } + internalPath := filepath.Join(repository.commonPath, stageName) + if err := ensureBoundGitPointer( + worktreeRoot, + ".git", + boundGitPointerTempPrefix(stageName, ".git"), + "gitdir: "+privatePath+"\n", + "", + ); err != nil { + return fmt.Errorf( + "workspace: install prepared worktree Git pointer: %w", + err, + ) + } + if err := ensureBoundGitPointer( + privateRoot, + "gitdir", + boundGitPointerTempPrefix(privateName, "gitdir"), + worktreePath+string(filepath.Separator)+".git\n", + internalPath+string(filepath.Separator)+".git\n", + ); err != nil { + return fmt.Errorf( + "workspace: rebind prepared private Git directory: %w", + err, + ) + } + _, exists, err := repository.worktreeRegistration( + ctx, + worktreePath, + ) + if err != nil { + return err + } + if !exists { + return errors.New( + "workspace: relocated prepared worktree registration is missing", + ) + } + if err := validateBSDStageContents(stageRoot); err != nil { + return err + } + ownedStageRoot := stageRoot + stageRoot = nil + if err := removeOpenedDirectoryFromRoot( + repository.commonRoot, + stageName, + ownedStageRoot, + ); err != nil { + return fmt.Errorf( + "workspace: remove prepared registration stage: %w", + err, + ) + } + if err := errors.Join( + repository.verifyBinding(ctx), + verifyRealPathRoot(worktreePath, worktreeRoot), + ); err != nil { + return err + } + finalized = true + return nil +} + +func repairBoundPreparedWorktreeRegistration( + ctx context.Context, + target Workspace, + prepared *preparedWorktreeTarget, + repository repositoryCapability, + stalePath string, +) (handled bool, result error) { + if _, err := bsdPreparedWorktreeStageName(stalePath); err != nil { + return true, err + } + if err := errors.Join( + repository.verifyBinding(ctx), + verifyRealPathRoot(target.Path, prepared.root), + ); err != nil { + return true, err + } + relativePrivatePath, err := repository.boundGitDirectory( + prepared.gitDirectory, + target.AgentID, + ) + if err != nil { + return true, err + } + privatePath := prepared.gitDirectory + privateRoot, err := openRealRootFromRoot( + repository.commonRoot, + relativePrivatePath, + ) + if err != nil { + return true, err + } + defer func() { + result = errors.Join(result, privateRoot.Close()) + }() + if err := validateBoundGitPointer( + prepared.root, + ".git", + "gitdir: "+privatePath+"\n", + ); err != nil { + return true, err + } + if err := ensureBoundGitPointer( + privateRoot, + "gitdir", + boundGitPointerTempPrefix( + filepath.Base(relativePrivatePath), + "gitdir", + ), + target.Path+string(filepath.Separator)+".git\n", + stalePath+string(filepath.Separator)+".git\n", + ); err != nil { + return true, err + } + registered, exists, err := repository.worktreeRegistration( + ctx, + target.Path, + ) + if err != nil { + return true, err + } + if !exists { + return true, errors.New( + "workspace: promoted worktree registration is missing", + ) + } + if err := repository.verifyPreparedWorktree( + ctx, + target, + prepared.gitDirectory, + registered, + ); err != nil { + return true, err + } + return true, errors.Join( + repository.verifyBinding(ctx), + verifyRealPathRoot(target.Path, prepared.root), + ) +} + +func cleanupPreparedWorktreeAddDebris( + ctx context.Context, + repository repositoryCapability, + target Workspace, + record workspaceRecord, +) error { + if record.BranchOperationID == "" { + return nil + } + stagingName, err := preparedWorktreeStagingName( + target.AgentID, + record.BranchOperationID, + ) + if err != nil { + return err + } + stageName, err := bsdPreparedWorktreeStageName( + filepath.Join(filepath.Dir(target.Path), stagingName), + ) + if err != nil { + return err + } + return cleanupBSDPreparedWorktreeStage( + ctx, + repository, + stageName, + target.AgentID, + target.Path, + record.PreparedStageDirectoryIdentity, + ) +} + +func cleanupBSDPreparedWorktreeStage( + ctx context.Context, + repository repositoryCapability, + stageName string, + agentID string, + allowedTargetPath string, + expectedIdentity string, +) (result error) { + if err := removeRootDeletionDebris( + repository.commonRoot, + stageName, + expectedIdentity, + ); err != nil { + return err + } + stageRoot, err := openRealRootFromRoot(repository.commonRoot, stageName) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + defer func() { + if stageRoot != nil { + result = errors.Join(result, stageRoot.Close()) + } + }() + if expectedIdentity == "" { + return errors.New( + "workspace: prepared registration stage identity is unavailable", + ) + } + stageIdentity, err := openedDirectoryIdentity(stageRoot) + if err != nil { + return err + } + if stageIdentity != expectedIdentity { + return errors.New( + "workspace: prepared registration stage identity changed", + ) + } + ownedStageRoot := stageRoot + stageRoot = nil + return cleanupOpenedBSDPreparedWorktreeStage( + ctx, + repository, + stageName, + agentID, + allowedTargetPath, + ownedStageRoot, + ) +} + +func cleanupOpenedBSDPreparedWorktreeStage( + ctx context.Context, + repository repositoryCapability, + stageName string, + agentID string, + allowedTargetPath string, + stageRoot *os.Root, +) (result error) { + defer func() { + if stageRoot != nil { + result = errors.Join(result, stageRoot.Close()) + } + }() + if err := verifyRootEntryUnchanged( + repository.commonRoot, + stageName, + stageRoot, + ); err != nil { + return err + } + entries, err := readRootDirectory(stageRoot) + if err != nil { + return err + } + if len(entries) == 0 { + ownedStageRoot := stageRoot + stageRoot = nil + if err := removeOpenedDirectoryFromRoot( + repository.commonRoot, + stageName, + ownedStageRoot, + ); err != nil { + return err + } + return repository.pruneWorktrees(ctx) + } + _, _, privateRoot, err := openBSDPreparedPrivateGitDirectory( + repository, + stageRoot, + agentID, + ) + if err != nil { + return err + } + adminTarget, err := readBoundRegularFile(privateRoot, "gitdir") + closeErr := privateRoot.Close() + if err := errors.Join(err, closeErr); err != nil { + return err + } + internalTarget := filepath.Join( + repository.commonPath, + stageName, + ".git", + ) + "\n" + allowedTarget := "" + if allowedTargetPath != "" { + allowedTarget = filepath.Join(allowedTargetPath, ".git") + "\n" + } + if adminTarget != internalTarget && adminTarget != allowedTarget { + return errors.New( + "workspace: prepared private Git directory has an unexpected target", + ) + } + if err := validateBSDStageContents(stageRoot); err != nil { + return err + } + ownedStageRoot := stageRoot + stageRoot = nil + if err := removeOpenedDirectoryFromRoot( + repository.commonRoot, + stageName, + ownedStageRoot, + ); err != nil { + return err + } + return repository.pruneWorktrees(ctx) +} + +func validateBSDPrivateAdminSet( + commonRoot *os.Root, + stageName string, + privateName string, +) (result error) { + worktrees, err := openRealRootFromRoot(commonRoot, "worktrees") + if err != nil { + return err + } + defer func() { + result = errors.Join(result, worktrees.Close()) + }() + entries, err := readRootDirectory(worktrees) + if err != nil { + return err + } + found := false + for _, entry := range entries { + if entry.Name() == privateName { + found = true + continue + } + if strings.HasPrefix(entry.Name(), stageName) { + return fmt.Errorf( + "workspace: Git selected unexpected private directory %q", + entry.Name(), + ) + } + } + if !found { + return errors.New( + "workspace: expected prepared private Git directory is missing", + ) + } + return nil +} + +func openBSDPreparedPrivateGitDirectory( + repository repositoryCapability, + stageRoot *os.Root, + agentID string, +) (string, string, *os.Root, error) { + pointer, err := readBoundRegularFile(stageRoot, ".git") + if err != nil { + return "", "", nil, err + } + privatePath, found := strings.CutPrefix(pointer, "gitdir: ") + if !found || !strings.HasSuffix(privatePath, "\n") { + return "", "", nil, errors.New( + "workspace: prepared registration stage has an invalid Git pointer", + ) + } + privatePath = strings.TrimSuffix(privatePath, "\n") + relativePath, err := repository.boundGitDirectory( + privatePath, + agentID, + ) + if err != nil { + return "", "", nil, err + } + privateRoot, err := openRealRootFromRoot( + repository.commonRoot, + relativePath, + ) + if err != nil { + return "", "", nil, err + } + return privatePath, filepath.Base(relativePath), privateRoot, nil +} + +func validateBSDStageContents(stageRoot *os.Root) error { + if err := recoverRecordRenameDebris(stageRoot); err != nil { + return err + } + entries, err := readRootDirectory(stageRoot) + if err != nil { + return err + } + if len(entries) != 1 || entries[0].Name() != ".git" { + return errors.New( + "workspace: prepared registration stage contains unexpected entries", + ) + } + return nil +} + +func boundGitPointerTempPrefix(stageName string, targetName string) string { + digest := sha256.Sum256([]byte(stageName + "\x00" + targetName)) + return fmt.Sprintf(".drove-git-pointer-%x-", digest[:8]) +} + +func ensureBoundGitPointer( + root *os.Root, + targetName string, + tempPrefix string, + content string, + allowedOld string, +) (result error) { + if err := recoverRecordRenameDebris(root); err != nil { + return err + } + newPrefix := tempPrefix + "new-" + oldPrefix := tempPrefix + "old-" + if err := validateBoundGitPointerNamespace( + root, + tempPrefix, + newPrefix, + oldPrefix, + ); err != nil { + return err + } + current, exists, err := readOptionalBoundRegularFile(root, targetName) + if err != nil { + return err + } + _, oldFile, err := findBoundGitPointerTemp( + root, + oldPrefix, + allowedOld, + ) + if err != nil { + return err + } + if oldFile != nil { + defer func() { + result = errors.Join(result, oldFile.Close()) + }() + } + if exists && current == content { + return errors.Join( + cleanupBoundGitPointerTemps(root, newPrefix, content), + cleanupBoundGitPointerTemps(root, oldPrefix, allowedOld), + ) + } + switch { + case exists && current != allowedOld: + return fmt.Errorf( + "workspace: Git pointer %q has unexpected contents", + targetName, + ) + case allowedOld == "" && oldFile != nil: + return fmt.Errorf( + "workspace: Git pointer %q has an unexpected backup", + targetName, + ) + case !exists && allowedOld != "" && oldFile == nil: + return fmt.Errorf( + "workspace: Git pointer %q is missing", + targetName, + ) + } + if exists { + if oldFile == nil { + if err := isolateBoundGitPointer( + root, + targetName, + oldPrefix+uuid.NewString(), + allowedOld, + nil, + ); err != nil { + return err + } + } else { + currentFile, err := openBoundRegularFile(root, targetName) + if err != nil { + return err + } + currentInfo, currentErr := currentFile.Stat() + oldInfo, oldErr := oldFile.Stat() + if err := errors.Join(currentErr, oldErr); err != nil { + return errors.Join(err, currentFile.Close()) + } + if !os.SameFile(currentInfo, oldInfo) { + return errors.Join( + fmt.Errorf( + "workspace: Git pointer %q conflicts with its backup", + targetName, + ), + currentFile.Close(), + ) + } + removeErr := removeOwnedRecordPath( + root, + targetName, + currentFile, + oldPrefix+uuid.NewString(), + func(candidate *os.File) error { + info, err := candidate.Stat() + if err != nil { + return err + } + if !os.SameFile(info, oldInfo) { + return errors.New( + "workspace: Git pointer changed before isolation", + ) + } + return nil + }, + nil, + ) + closeErr := currentFile.Close() + if err := errors.Join(removeErr, closeErr); err != nil { + return err + } + } + } + tempName, tempFile, err := findBoundGitPointerTemp( + root, + newPrefix, + content, + ) + if err != nil { + return err + } + if tempFile == nil { + tempName = newPrefix + uuid.NewString() + tempFile, err = root.OpenFile( + tempName, + os.O_RDWR|os.O_CREATE|os.O_EXCL, + 0o600, + ) + if err != nil { + return err + } + if _, err := io.WriteString(tempFile, content); err != nil { + return errors.Join(err, tempFile.Close()) + } + if err := tempFile.Sync(); err != nil { + return errors.Join(err, tempFile.Close()) + } + } + defer func() { + result = errors.Join(result, tempFile.Close()) + }() + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + installed, renameErr := renameRecordFile( + directory, + tempFile, + tempName, + targetName, + false, + ) + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(renameErr, syncErr, closeErr); err != nil { + return err + } + if !installed { + return fmt.Errorf( + "workspace: Git pointer %q was not installed", + targetName, + ) + } + if err := validateBoundGitPointer(root, targetName, content); err != nil { + return err + } + return errors.Join( + cleanupBoundGitPointerTemps(root, newPrefix, content), + cleanupBoundGitPointerTemps(root, oldPrefix, allowedOld), + ) +} + +func isolateBoundGitPointer( + root *os.Root, + targetName string, + isolatedName string, + expected string, + afterValidation func(), +) (result error) { + file, err := openBoundRegularFile(root, targetName) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, file.Close()) + }() + raw, err := readOpenedBoundFile(file) + if err != nil { + return err + } + if raw != expected { + return fmt.Errorf( + "workspace: Git pointer %q changed contents", + targetName, + ) + } + if afterValidation != nil { + afterValidation() + } + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + moved, moveErr := moveRecordFile( + directory, + file, + targetName, + isolatedName, + ) + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(moveErr, syncErr, closeErr); err != nil { + return err + } + if !moved { + return fmt.Errorf( + "workspace: Git pointer %q was not isolated", + targetName, + ) + } + return nil +} + +func validateBoundGitPointerNamespace( + root *os.Root, + basePrefix string, + newPrefix string, + oldPrefix string, +) error { + entries, err := readRootDirectory(root) + if err != nil { + return err + } + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), basePrefix) { + continue + } + prefix := "" + switch { + case strings.HasPrefix(entry.Name(), newPrefix): + prefix = newPrefix + case strings.HasPrefix(entry.Name(), oldPrefix): + prefix = oldPrefix + } + if prefix == "" || + !canonicalUUID(strings.TrimPrefix(entry.Name(), prefix)) { + return fmt.Errorf( + "workspace: Git pointer artifact %q has an invalid name", + entry.Name(), + ) + } + } + return nil +} + +func findBoundGitPointerTemp( + root *os.Root, + prefix string, + content string, +) (string, *os.File, error) { + entries, err := readRootDirectory(root) + if err != nil { + return "", nil, err + } + var foundName string + var found *os.File + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), prefix) { + continue + } + suffix := strings.TrimPrefix(entry.Name(), prefix) + if !canonicalUUID(suffix) { + var closeErr error + if found != nil { + closeErr = found.Close() + } + return "", nil, errors.Join( + fmt.Errorf( + "workspace: Git pointer temporary %q has an invalid name", + entry.Name(), + ), + closeErr, + ) + } + file, err := openBoundRegularFile(root, entry.Name()) + if err != nil { + if found != nil { + err = errors.Join(err, found.Close()) + } + return "", nil, err + } + raw, err := readOpenedBoundFile(file) + if err != nil || raw != content { + closeErr := file.Close() + if found != nil { + closeErr = errors.Join(closeErr, found.Close()) + } + if err == nil { + err = errors.New( + "workspace: Git pointer temporary has unexpected contents", + ) + } + return "", nil, errors.Join(err, closeErr) + } + if found == nil { + foundName = entry.Name() + found = file + continue + } + if err := file.Close(); err != nil { + return "", nil, errors.Join(err, found.Close()) + } + } + return foundName, found, nil +} + +func cleanupBoundGitPointerTemps( + root *os.Root, + prefix string, + content string, +) error { + for { + name, file, err := findBoundGitPointerTemp(root, prefix, content) + if err != nil || file == nil { + return err + } + removeErr := removeOwnedRecordPath( + root, + name, + file, + prefix+uuid.NewString(), + func(candidate *os.File) error { + raw, err := readOpenedBoundFile(candidate) + if err != nil { + return err + } + if raw != content { + return errors.New( + "workspace: Git pointer temporary changed contents", + ) + } + return nil + }, + nil, + ) + closeErr := file.Close() + if err := errors.Join(removeErr, closeErr); err != nil { + return err + } + } +} + +func validateBoundGitPointer( + root *os.Root, + name string, + expected string, +) error { + raw, err := readBoundRegularFile(root, name) + if err != nil { + return err + } + if raw != expected { + return fmt.Errorf( + "workspace: Git pointer %q has unexpected contents", + name, + ) + } + return nil +} + +func readOptionalBoundRegularFile( + root *os.Root, + name string, +) (string, bool, error) { + _, err := root.Lstat(name) + if errors.Is(err, os.ErrNotExist) { + return "", false, nil + } + if err != nil { + return "", false, err + } + raw, err := readBoundRegularFile(root, name) + return raw, err == nil, err +} + +func readBoundRegularFile(root *os.Root, name string) (result string, err error) { + file, err := openBoundRegularFile(root, name) + if err != nil { + return "", err + } + defer func() { + err = errors.Join(err, file.Close()) + }() + return readOpenedBoundFile(file) +} + +func openBoundRegularFile(root *os.Root, name string) (*os.File, error) { + info, err := root.Lstat(name) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("workspace: %q is not a regular file", name) + } + file, err := root.Open(name) + if err != nil { + return nil, err + } + opened, err := file.Stat() + if err != nil { + return nil, errors.Join(err, file.Close()) + } + if !opened.Mode().IsRegular() || !os.SameFile(info, opened) { + return nil, errors.Join( + fmt.Errorf("workspace: %q changed while opening", name), + file.Close(), + ) + } + return file, nil +} + +func readOpenedBoundFile(file *os.File) (string, error) { + if _, err := file.Seek(0, io.SeekStart); err != nil { + return "", err + } + raw, err := io.ReadAll(io.LimitReader(file, 4097)) + if err != nil { + return "", err + } + if len(raw) > 4096 { + return "", errors.New("workspace: Git pointer is too large") + } + return string(raw), nil +} diff --git a/internal/workspace/prepared_registration_bsd_test.go b/internal/workspace/prepared_registration_bsd_test.go new file mode 100644 index 0000000..36dfa14 --- /dev/null +++ b/internal/workspace/prepared_registration_bsd_test.go @@ -0,0 +1,492 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd + +package workspace + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestBSDPrepareDoesNotWriteThroughReplacedCommonGitPath(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + initSourceSwapRepository(t, repository) + commonPath := filepath.Join(repository, ".git") + openedCommon := commonPath + "-opened" + replacement := filepath.Join(parent, "replacement-common") + if err := os.Mkdir(replacement, 0o700); err != nil { + t.Fatalf("create replacement common directory: %v", err) + } + sentinel := filepath.Join(replacement, "must-remain") + if err := os.WriteFile(sentinel, []byte("replacement\n"), 0o600); err != nil { + t.Fatalf("write replacement sentinel: %v", err) + } + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + swapped := filepath.Join(parent, "common-swapped") + stageFile := filepath.Join(parent, "common-stage") + wrapper := filepath.Join(parent, "git-wrapper-common-swap") + script := `#!/bin/sh +target= +next_target= +for argument in "$@"; do + if [ -n "$next_target" ]; then + target=$argument + next_target= + fi + [ "$argument" = "--no-checkout" ] && next_target=1 +done +"$DROVE_TEST_REAL_GIT" "$@" +status=$? +if [ "$status" -eq 0 ] && [ -n "$target" ] && + [ ! -e "$DROVE_TEST_SWAPPED" ]; then + printf '%s/%s' "$PWD" "$target" > "$DROVE_TEST_STAGE" || exit 90 + mv "$DROVE_TEST_COMMON" "$DROVE_TEST_OPENED_COMMON" || exit 91 + mv "$DROVE_TEST_REPLACEMENT" "$DROVE_TEST_COMMON" || exit 92 + : > "$DROVE_TEST_SWAPPED" +fi +exit "$status" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_COMMON", commonPath) + t.Setenv("DROVE_TEST_OPENED_COMMON", openedCommon) + t.Setenv("DROVE_TEST_REPLACEMENT", replacement) + t.Setenv("DROVE_TEST_SWAPPED", swapped) + t.Setenv("DROVE_TEST_STAGE", stageFile) + + dataDir := filepath.Join(parent, "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ); err == nil { + t.Fatal("prepare accepted a replaced common Git path") + } + if _, err := os.Stat(swapped); err != nil { + t.Fatalf("worktree add did not reach the swap: %v", err) + } + assertFileContents( + t, + filepath.Join(commonPath, "must-remain"), + "replacement\n", + ) + if _, err := os.Lstat(filepath.Join(commonPath, "worktrees")); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("replacement common directory received Git writes: %v", err) + } + + if err := os.Rename(commonPath, replacement); err != nil { + t.Fatalf("restore replacement directory: %v", err) + } + if err := os.Rename(openedCommon, commonPath); err != nil { + t.Fatalf("restore common Git directory: %v", err) + } + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + nil, + ); err != nil { + t.Fatalf("reconcile failed preparation: %v", err) + } + rawStage, err := os.ReadFile(stageFile) + if err != nil { + t.Fatalf("read internal stage path: %v", err) + } + stagePath := filepath.Clean(string(rawStage)) + if _, err := os.Lstat(stagePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("internal common-root stage remains: %v", err) + } + if _, err := os.Lstat( + filepath.Join(commonPath, "worktrees", filepath.Base(stagePath)), + ); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("internal private Git directory remains: %v", err) + } + assertFileContents(t, sentinel, "replacement\n") +} + +func TestBSDPrepareDoesNotInvokePublicWorktreeRepair(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + initSourceSwapRepository(t, repository) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + repairInvoked := filepath.Join(parent, "repair-invoked") + wrapper := filepath.Join(parent, "git-wrapper-reject-repair") + script := `#!/bin/sh +case " $* " in + *" worktree repair "*) + : > "$DROVE_TEST_REPAIR_INVOKED" + exit 91 + ;; +esac +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_REPAIR_INVOKED", repairInvoked) + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + if _, err := os.Lstat(repairInvoked); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("public worktree repair was invoked: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf( + "read prepared workspace record: exists=%v err=%v", + exists, + err, + ) + } + if record.PreparedStageDirectoryIdentity != "" { + t.Fatal("completed preparation retained registration stage identity") + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard prepared worktree: %v", err) + } +} + +func TestBSDPrepareCleansRegistrationWhenFinalizationFails(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + initSourceSwapRepository(t, repository) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + stageFile := filepath.Join(parent, "common-stage") + wrapper := filepath.Join(parent, "git-wrapper-finalization-failure") + script := `#!/bin/sh +target= +next_target= +for argument in "$@"; do + if [ -n "$next_target" ]; then + target=$argument + next_target= + fi + [ "$argument" = "--no-checkout" ] && next_target=1 +done +"$DROVE_TEST_REAL_GIT" "$@" +status=$? +if [ "$status" -eq 0 ] && [ -n "$target" ]; then + stage=${target#./} + printf '%s/%s' "$PWD" "$stage" > "$DROVE_TEST_STAGE" || exit 90 + mkdir "$PWD/worktrees/$stage-unexpected" || exit 91 +fi +exit "$status" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_STAGE", stageFile) + + dataDir := filepath.Join(parent, "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ); err == nil { + t.Fatal("prepare succeeded after finalization validation failed") + } + + rawStage, err := os.ReadFile(stageFile) + if err != nil { + t.Fatalf("read internal stage path: %v", err) + } + stagePath := filepath.Clean(string(rawStage)) + if _, err := os.Lstat(stagePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("internal common-root stage remains: %v", err) + } + privatePath := filepath.Join( + repository, + ".git", + "worktrees", + filepath.Base(stagePath), + ) + if _, err := os.Lstat(privatePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("private Git registration remains: %v", err) + } + if output := runGit( + t, + repository, + "worktree", + "list", + "--porcelain", + ); strings.Contains(output, stagePath) { + t.Fatalf("failed worktree remains registered:\n%s", output) + } +} + +func TestBSDPreparePreservesReplacedRegistrationStage(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + initSourceSwapRepository(t, repository) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + stageFile := filepath.Join(parent, "common-stage") + originalFile := filepath.Join(parent, "original-common-stage") + wrapper := filepath.Join(parent, "git-wrapper-stage-replacement") + script := `#!/bin/sh +target= +next_target= +for argument in "$@"; do + if [ -n "$next_target" ]; then + target=$argument + next_target= + fi + [ "$argument" = "--no-checkout" ] && next_target=1 +done +"$DROVE_TEST_REAL_GIT" "$@" +status=$? +if [ "$status" -eq 0 ] && [ -n "$target" ]; then + stage=$(CDPATH= cd -- "$target" && pwd -P) || exit 90 + original=$stage-original + mv "$stage" "$original" || exit 91 + mkdir "$stage" || exit 92 + cp "$original/.git" "$stage/.git" || exit 93 + printf '%s' "$stage" > "$DROVE_TEST_STAGE" || exit 94 + printf '%s' "$original" > "$DROVE_TEST_ORIGINAL_STAGE" || exit 95 + name=${stage##*/} + mkdir "${stage%/*}/worktrees/$name-unexpected" || exit 96 +fi +exit "$status" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_STAGE", stageFile) + t.Setenv("DROVE_TEST_ORIGINAL_STAGE", originalFile) + + dataDir := filepath.Join(parent, "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ); err == nil { + t.Fatal("prepare succeeded after registration stage replacement") + } + + rawStage, err := os.ReadFile(stageFile) + if err != nil { + t.Fatalf("read replacement stage path: %v", err) + } + rawOriginal, err := os.ReadFile(originalFile) + if err != nil { + t.Fatalf("read original stage path: %v", err) + } + stagePath := filepath.Clean(string(rawStage)) + originalPath := filepath.Clean(string(rawOriginal)) + replacementInfo, err := os.Stat(stagePath) + if err != nil { + t.Fatalf("replacement registration stage was removed: %v", err) + } + originalInfo, err := os.Stat(originalPath) + if err != nil { + t.Fatalf("original registration stage was removed: %v", err) + } + if os.SameFile(replacementInfo, originalInfo) { + t.Fatal("replacement and original registration stages share identity") + } + replacementPointer, err := os.ReadFile(filepath.Join(stagePath, ".git")) + if err != nil { + t.Fatalf("read replacement Git pointer: %v", err) + } + originalPointer, err := os.ReadFile(filepath.Join(originalPath, ".git")) + if err != nil { + t.Fatalf("read original Git pointer: %v", err) + } + if string(replacementPointer) != string(originalPointer) { + t.Fatal("replacement Git pointer does not match the original") + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + nil, + ); err == nil { + t.Fatal("reconcile accepted a replaced registration stage") + } + current, err := os.Stat(stagePath) + if err != nil { + t.Fatalf("reconcile removed the replacement registration stage: %v", err) + } + if !os.SameFile(current, replacementInfo) { + t.Fatal("reconcile changed the replacement registration stage") + } +} + +func TestEnsureBoundGitPointerRecoversTemporary(t *testing.T) { + path := t.TempDir() + root, err := os.OpenRoot(path) + if err != nil { + t.Fatalf("open pointer root: %v", err) + } + defer root.Close() + const ( + target = "gitdir" + old = "old-target/.git\n" + desired = "new-target/.git\n" + ) + if err := os.WriteFile(filepath.Join(path, target), []byte(old), 0o600); err != nil { + t.Fatalf("write old pointer: %v", err) + } + prefix := boundGitPointerTempPrefix("stage", target) + tempName := prefix + "new-" + + "78787878-7878-4787-8787-787878787878" + if err := os.WriteFile( + filepath.Join(path, tempName), + []byte(desired), + 0o600, + ); err != nil { + t.Fatalf("write pointer temporary: %v", err) + } + + if err := ensureBoundGitPointer( + root, + target, + prefix, + desired, + old, + ); err != nil { + t.Fatalf("recover pointer temporary: %v", err) + } + if err := ensureBoundGitPointer( + root, + target, + prefix, + desired, + old, + ); err != nil { + t.Fatalf("repeat pointer recovery: %v", err) + } + assertFileContents(t, filepath.Join(path, target), desired) + if _, err := os.Lstat(filepath.Join(path, tempName)); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("pointer temporary remains: %v", err) + } +} + +func TestIsolateBoundGitPointerPreservesReplacement(t *testing.T) { + path := t.TempDir() + targetPath := filepath.Join(path, "gitdir") + originalPath := filepath.Join(path, "gitdir-original") + if err := os.WriteFile( + targetPath, + []byte("original/.git\n"), + 0o600, + ); err != nil { + t.Fatalf("write pointer: %v", err) + } + root, err := os.OpenRoot(path) + if err != nil { + t.Fatalf("open pointer root: %v", err) + } + defer root.Close() + + err = isolateBoundGitPointer( + root, + "gitdir", + ".drove-pointer-old-78787878-7878-4787-8787-787878787878", + "original/.git\n", + func() { + if err := os.Rename(targetPath, originalPath); err != nil { + t.Fatalf("move validated pointer: %v", err) + } + if err := os.WriteFile( + targetPath, + []byte("replacement/.git\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement pointer: %v", err) + } + }, + ) + if err == nil { + t.Fatal("pointer isolation accepted a replacement") + } + assertFileContents(t, targetPath, "replacement/.git\n") + assertFileContents(t, originalPath, "original/.git\n") +} + +func TestValidateBSDPreparedWorktreeNamesRejectsAdminCollision(t *testing.T) { + path := t.TempDir() + if err := os.Mkdir(filepath.Join(path, "worktrees"), 0o700); err != nil { + t.Fatalf("create worktrees directory: %v", err) + } + const stageName = "11111111-1111-4111-8111-111111111111123" + if err := os.Mkdir( + filepath.Join(path, "worktrees", stageName), + 0o700, + ); err != nil { + t.Fatalf("create private Git collision: %v", err) + } + root, err := os.OpenRoot(path) + if err != nil { + t.Fatalf("open common root: %v", err) + } + defer root.Close() + + if err := validateBSDPreparedWorktreeNamesAvailable( + root, + stageName, + ); err == nil { + t.Fatal("validation accepted a deterministic admin collision") + } +} diff --git a/internal/workspace/prepared_registration_direct.go b/internal/workspace/prepared_registration_direct.go new file mode 100644 index 0000000..8d73578 --- /dev/null +++ b/internal/workspace/prepared_registration_direct.go @@ -0,0 +1,44 @@ +//go:build !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !windows + +package workspace + +import ( + "context" +) + +func preparePreparedWorktreeAdd( + _ Workspace, + _ *preparedWorktreeTarget, + _ repositoryCapability, +) (bool, error) { + return false, nil +} + +func finalizePreparedWorktreeAdd( + _ context.Context, + _ repositoryCapability, + _ Workspace, + _ *preparedWorktreeTarget, + _ bool, +) error { + return nil +} + +func repairBoundPreparedWorktreeRegistration( + _ context.Context, + _ Workspace, + _ *preparedWorktreeTarget, + _ repositoryCapability, + _ string, +) (bool, error) { + return false, nil +} + +func cleanupPreparedWorktreeAddDebris( + _ context.Context, + _ repositoryCapability, + _ Workspace, + _ workspaceRecord, +) error { + return nil +} diff --git a/internal/workspace/prepared_rollback.go b/internal/workspace/prepared_rollback.go index fb9d079..9258f9d 100644 --- a/internal/workspace/prepared_rollback.go +++ b/internal/workspace/prepared_rollback.go @@ -3,6 +3,8 @@ package workspace import ( "context" "errors" + "fmt" + "path/filepath" ) func (m *Manager) removePreparedWorktreeAtRoot( @@ -17,6 +19,14 @@ func (m *Manager) removePreparedWorktreeAtRoot( "workspace: prepared worktree identity is incomplete", ) } + if err := cleanupPreparedWorktreeAddDebris( + ctx, + repository, + target, + record, + ); err != nil { + return err + } registered, exists, err := repository.worktreeRegistration( ctx, target.Path, @@ -24,6 +34,26 @@ func (m *Manager) removePreparedWorktreeAtRoot( if err != nil { return err } + if record.GitDirectory != "" && !exists { + repaired, repairErr := m.repairPromotedPreparedWorktree( + ctx, + target, + record, + repository, + ) + if repairErr != nil { + return repairErr + } + if repaired { + registered, exists, err = repository.worktreeRegistration( + ctx, + target.Path, + ) + if err != nil { + return err + } + } + } switch { case record.GitDirectory != "" && !exists: return errors.New( @@ -113,3 +143,108 @@ func (m *Manager) removePreparedWorktreeAtRoot( opened, ) } + +func (m *Manager) repairPromotedPreparedWorktree( + ctx context.Context, + target Workspace, + record workspaceRecord, + repository repositoryCapability, +) (result bool, resultErr error) { + if record.BranchOperationID == "" { + return false, nil + } + stagingName, err := preparedWorktreeStagingName( + target.AgentID, + record.BranchOperationID, + ) + if err != nil { + return false, err + } + stagingPath := filepath.Join(filepath.Dir(target.Path), stagingName) + stale, exists, err := repository.worktreeRegistration(ctx, stagingPath) + if err != nil { + return false, err + } + if exists { + stagingTarget := target + stagingTarget.Path = stagingPath + if err := repository.verifyPreparedWorktree( + ctx, + stagingTarget, + record.GitDirectory, + stale, + ); err != nil { + return false, fmt.Errorf( + "workspace: verify promoted worktree staging registration: %w", + err, + ) + } + } + + bucket, err := m.openManagedBucketRoot(target) + if err != nil { + return false, err + } + defer func() { + resultErr = errors.Join(resultErr, bucket.Close()) + }() + opened, err := openRealRootFromRoot(bucket, target.AgentID) + if err != nil { + return false, err + } + defer func() { + resultErr = errors.Join(resultErr, opened.Close()) + }() + identity, err := openedDirectoryIdentity(opened) + if err != nil { + return false, err + } + if identity != record.DirectoryIdentity { + return false, errors.New( + "workspace: promoted worktree directory identity changed", + ) + } + gitDirectory, err := m.worktreeGitDirectoryAtRoot( + ctx, + target.Path, + opened, + ) + if err != nil { + return false, err + } + if gitDirectory != record.GitDirectory { + return false, errors.New( + "workspace: promoted worktree Git directory changed", + ) + } + if err := verifyRootEntryUnchanged( + bucket, + target.AgentID, + opened, + ); err != nil { + return false, err + } + prepared := &preparedWorktreeTarget{ + root: opened, + name: target.AgentID, + path: target.Path, + gitDirectory: record.GitDirectory, + } + if err := m.repairPreparedWorktreeRegistration( + ctx, + target, + prepared, + repository, + stagingPath, + ); err != nil { + return false, err + } + if err := verifyRootEntryUnchanged( + bucket, + target.AgentID, + opened, + ); err != nil { + return false, err + } + return true, nil +} diff --git a/internal/workspace/prepared_worktree.go b/internal/workspace/prepared_worktree.go index a73dfa8..9b40874 100644 --- a/internal/workspace/prepared_worktree.go +++ b/internal/workspace/prepared_worktree.go @@ -7,24 +7,71 @@ import ( "os" "path/filepath" "strings" + + "github.com/google/uuid" ) +type preparedWorktreeTarget struct { + root *os.Root + registrationStageRoot *os.Root + name string + path string + gitDirectory string +} + +func preparedWorktreeStagingName( + agentID string, + operationID string, +) (string, error) { + operation, err := uuid.Parse(operationID) + if err != nil || operation.String() != operationID { + return "", errors.New( + "workspace: preparation operation ID is invalid", + ) + } + name := agentID + for _, value := range operation { + name += fmt.Sprintf("%03d", value) + } + return name, nil +} + +func preparedWorktreeIsolationName(stagingName string) string { + return stagingName + ".rename" +} + func (m *Manager) createPreparedWorktreeTarget( target *Workspace, -) (result error) { +) (_ *preparedWorktreeTarget, result error) { bucket, err := m.openManagedBucketRoot(*target) if err != nil { - return err + return nil, err } defer func() { - result = errors.Join(result, bucket.Close()) + if bucket != nil { + result = errors.Join(result, bucket.Close()) + } }() - if err := bucket.Mkdir(target.AgentID, 0o700); err != nil { - return fmt.Errorf("workspace: create prepared worktree target: %w", err) + name, err := preparedWorktreeStagingName( + target.AgentID, + target.branchOperationID, + ) + if err != nil { + return nil, err } - opened, err := openRealRootFromRoot(bucket, target.AgentID) + path := filepath.Join(filepath.Dir(target.Path), name) + if err := bucket.Mkdir(name, 0o700); err != nil { + return nil, fmt.Errorf( + "workspace: create prepared worktree target: %w", + err, + ) + } + opened, err := openRealRootFromRoot(bucket, name) if err != nil { - return fmt.Errorf("workspace: open prepared worktree target: %w", err) + return nil, fmt.Errorf( + "workspace: open prepared worktree target: %w", + err, + ) } removeOnFailure := true defer func() { @@ -33,7 +80,7 @@ func (m *Manager) createPreparedWorktreeTarget( result, removeOpenedDirectoryFromRoot( bucket, - target.AgentID, + name, opened, ), ) @@ -46,21 +93,21 @@ func (m *Manager) createPreparedWorktreeTarget( directoryIdentity, err := openedDirectoryIdentity(opened) if err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "workspace: inspect prepared worktree target identity: %w", err, ) } if err := verifyRootEntryUnchanged( bucket, - target.AgentID, + name, opened, ); err != nil { - return err + return nil, err } - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "workspace: open prepared worktree parent: %w", err, ) @@ -68,7 +115,7 @@ func (m *Manager) createPreparedWorktreeTarget( syncErr := syncRecordDirectory(directory) closeErr := directory.Close() if err := errors.Join(syncErr, closeErr); err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "workspace: sync prepared worktree parent: %w", err, ) @@ -84,18 +131,18 @@ func (m *Manager) createPreparedWorktreeTarget( err = errors.New("workspace: preparation record is missing") } if err != nil { - return err + return nil, err } if target.branchOperationID == "" || record.BranchOperationID != target.branchOperationID || !sameWorkspace(record.workspace(), *target) { - return errors.New( + return nil, errors.New( "workspace: preparation record does not match prepared target", ) } record.DirectoryIdentity = directoryIdentity if err := m.replaceWorkspaceRecord(record); err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "workspace: persist prepared worktree target identity: %w", err, ) @@ -106,48 +153,55 @@ func (m *Manager) createPreparedWorktreeTarget( target.Path, ) if err != nil { - return err + return nil, err } if !exists || persisted.DirectoryIdentity != directoryIdentity || persisted.BranchOperationID != target.branchOperationID || !sameWorkspace(persisted.workspace(), *target) { - return errors.New( + return nil, errors.New( "workspace: prepared worktree target identity was not persisted", ) } currentIdentity, err := openedDirectoryIdentity(opened) if err != nil { - return err + return nil, err } if currentIdentity != directoryIdentity { - return errors.New( + return nil, errors.New( "workspace: prepared worktree target identity changed after persistence", ) } if err := verifyRootEntryUnchanged( bucket, - target.AgentID, + name, opened, ); err != nil { - return err + return nil, err + } + openedResult := &preparedWorktreeTarget{ + root: opened, + name: name, + path: path, } + opened = nil removeOnFailure = false - return nil + if err := bucket.Close(); err != nil { + bucket = nil + return openedResult, err + } + bucket = nil + return openedResult, nil } func (m *Manager) initializePreparedWorktree( ctx context.Context, target *Workspace, + prepared *preparedWorktreeTarget, + repository repositoryCapability, + includedPaths []string, ) (result error) { - root, err := openRealPathRoot(target.Path) - if err != nil { - return fmt.Errorf("workspace: open prepared worktree: %w", err) - } - defer func() { - result = errors.Join(result, root.Close()) - }() - + root := prepared.root directoryIdentity, err := openedDirectoryIdentity(root) if err != nil { return fmt.Errorf( @@ -165,15 +219,175 @@ func (m *Manager) initializePreparedWorktree( "workspace: prepared worktree target identity changed", ) } + registered, exists, err := repository.worktreeRegistration( + ctx, + prepared.path, + ) + if err != nil { + return err + } + preparedIntent := *target + preparedIntent.Path = prepared.path + if !exists { + return errors.New( + "workspace: prepared worktree registration is missing", + ) + } + if err := repository.verifyPreparedWorktreeRegistration( + preparedIntent, + registered, + target.expectedHeadOID, + ); err != nil { + return err + } gitDirectory, err := m.worktreeGitDirectoryAtRoot( ctx, - target.Path, + prepared.path, root, ) if err != nil { return err } - if err := verifyRealPathRoot(target.Path, root); err != nil { + if err := verifyRealPathRoot(prepared.path, root); err != nil { + return err + } + if _, err := repository.boundGitDirectory( + gitDirectory, + target.AgentID, + ); err != nil { + return err + } + gitRoot, err := openRealPathRoot(gitDirectory) + if err != nil { + return fmt.Errorf( + "workspace: open prepared private Git directory: %w", + err, + ) + } + defer func() { + result = errors.Join(result, gitRoot.Close()) + }() + gitGuard, err := openRepositoryGuard(gitDirectory, gitRoot) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, gitGuard.Close()) + }() + privateRepository := repository.withPrivateGitBinding( + gitDirectory, + gitRoot, + ) + privateHead, err := privateRepository.runPrivateGit( + ctx, + "", + "rev-parse", + "--verify", + "HEAD", + ) + if err != nil { + return fmt.Errorf( + "workspace: inspect prepared private HEAD: %w", + err, + ) + } + if strings.TrimSpace(string(privateHead)) != target.expectedHeadOID { + return errors.New( + "workspace: prepared private HEAD does not match intent", + ) + } + if err := repository.verifyPreparationRefs(ctx, *target, true); err != nil { + return err + } + if err := checkoutPreparedWorktree( + ctx, + privateRepository, + prepared.path, + root, + target.expectedHeadOID, + ); err != nil { + return fmt.Errorf("workspace: checkout prepared worktree: %w", err) + } + expectedTree, err := privateRepository.runPrivateGit( + ctx, + "", + "rev-parse", + "--verify", + target.expectedHeadOID+"^{tree}", + ) + if err != nil { + return fmt.Errorf( + "workspace: inspect prepared expected tree: %w", + err, + ) + } + indexTree, err := privateRepository.runPrivateGit( + ctx, + "", + "write-tree", + ) + if err != nil { + return fmt.Errorf( + "workspace: inspect prepared index tree: %w", + err, + ) + } + if strings.TrimSpace(string(indexTree)) != + strings.TrimSpace(string(expectedTree)) { + return errors.New( + "workspace: prepared index does not match expected tree", + ) + } + if len(includedPaths) > 0 { + target.trackedPaths, target.trackedIgnoreCase, err = + m.worktreeTrackedPaths( + ctx, + prepared.path, + privateRepository, + ) + if err != nil { + return err + } + } + if err := repository.verifyPreparationRefs(ctx, *target, true); err != nil { + return err + } + registered, exists, err = repository.worktreeRegistration( + ctx, + prepared.path, + ) + if err != nil { + return err + } + if !exists { + return errors.New( + "workspace: prepared worktree registration disappeared", + ) + } + if err := privateRepository.verifyBoundPreparedWorktree( + ctx, + preparedIntent, + registered, + ); err != nil { + return err + } + confirmedGitDirectory, err := m.worktreeGitDirectoryAtRoot( + ctx, + prepared.path, + root, + ) + if err != nil { + return err + } + if confirmedGitDirectory != gitDirectory { + return errors.New( + "workspace: prepared worktree Git pointer changed", + ) + } + if err := verifyRealPathRoot(gitDirectory, gitRoot); err != nil { + return err + } + if err := privateRepository.verifyPrivateGitCommonDirectory(); err != nil { return err } target.gitDirectory = gitDirectory @@ -196,16 +410,279 @@ func (m *Manager) initializePreparedWorktree( if err != nil { return fmt.Errorf("workspace: persist worktree identity: %w", err) } - if _, err := m.runRootedGit( + prepared.gitDirectory = gitDirectory + return nil +} + +func (m *Manager) promotePreparedWorktreeTarget( + ctx context.Context, + target Workspace, + prepared *preparedWorktreeTarget, + repository repositoryCapability, +) (result error) { + bucket, err := m.openManagedBucketRoot(target) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, bucket.Close()) + }() + if err := verifyRootEntryUnchanged( + bucket, + prepared.name, + prepared.root, + ); err != nil { + return err + } + if _, err := bucket.Lstat(target.AgentID); !errors.Is( + err, + os.ErrNotExist, + ) { + if err == nil { + return errors.New( + "workspace: prepared worktree target already exists", + ) + } + return fmt.Errorf( + "workspace: inspect prepared worktree destination: %w", + err, + ) + } + opened, err := prepared.root.Stat(".") + if err != nil { + return err + } + directory, err := openRecordDirectory(bucket) + if err != nil { + return err + } + stagingPath := prepared.path + moved, renameErr := renameDirectoryNoReplace( + directory, + opened, + prepared.name, + preparedWorktreeIsolationName(prepared.name), + target.AgentID, + ) + if moved { + prepared.name = target.AgentID + prepared.path = target.Path + } + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if err := errors.Join(renameErr, syncErr, closeErr); err != nil { + return fmt.Errorf( + "workspace: promote prepared worktree target: %w", + err, + ) + } + if !moved { + return errors.New("workspace: prepared worktree target was not promoted") + } + if err := verifyRootEntryUnchanged( + bucket, + target.AgentID, + prepared.root, + ); err != nil { + return err + } + if err := m.repairPreparedWorktreeRegistration( ctx, - target.Path, - root, - "reset", - "--hard", + target, + prepared, + repository, + stagingPath, ); err != nil { - return fmt.Errorf("workspace: checkout prepared worktree: %w", err) + return err } - return nil + return verifyRootEntryUnchanged( + bucket, + target.AgentID, + prepared.root, + ) +} + +func (m *Manager) repairPreparedWorktreeRegistration( + ctx context.Context, + target Workspace, + prepared *preparedWorktreeTarget, + repository repositoryCapability, + stalePath string, +) error { + if prepared.gitDirectory == "" { + return errors.New( + "workspace: prepared private Git directory is unavailable", + ) + } + handled, err := repairBoundPreparedWorktreeRegistration( + ctx, + target, + prepared, + repository, + stalePath, + ) + if handled { + return err + } + return errors.New( + "workspace: bound prepared worktree registration repair is unsupported", + ) +} + +func (m *Manager) cleanupPreparedWorktreeTarget( + target Workspace, + prepared *preparedWorktreeTarget, +) (result error) { + if prepared == nil || prepared.root == nil { + return nil + } + bucket, err := m.openManagedBucketRoot(target) + if err != nil { + return errors.Join(err, prepared.root.Close()) + } + defer func() { + result = errors.Join(result, bucket.Close()) + }() + root := prepared.root + prepared.root = nil + return removeOpenedDirectoryFromRoot(bucket, prepared.name, root) +} + +func (prepared *preparedWorktreeTarget) Close() error { + if prepared == nil { + return nil + } + var result error + if prepared.registrationStageRoot != nil { + result = errors.Join( + result, + prepared.registrationStageRoot.Close(), + ) + prepared.registrationStageRoot = nil + } + if prepared.root == nil { + return result + } + result = errors.Join(result, prepared.root.Close()) + prepared.root = nil + return result +} + +func (m *Manager) preparedWorktreeStagingPaths( + target Workspace, + record workspaceRecord, +) (_ string, _ string, _ bool, result error) { + name, err := preparedWorktreeStagingName( + target.AgentID, + record.BranchOperationID, + ) + if err != nil { + return "", "", false, err + } + bucket, err := m.openManagedBucketRoot(target) + if err != nil { + return "", "", false, err + } + defer func() { + result = errors.Join(result, bucket.Close()) + }() + names := []string{name, preparedWorktreeIsolationName(name)} + active := "" + for _, candidate := range names { + info, err := bucket.Lstat(candidate) + switch { + case errors.Is(err, os.ErrNotExist): + continue + case err != nil: + return "", "", false, err + case !info.IsDir() || info.Mode()&os.ModeSymlink != 0: + return "", "", false, errors.New( + "workspace: prepared worktree staging path is not a real directory", + ) + case active != "": + return "", "", false, errors.New( + "workspace: preparation has multiple staging worktree paths", + ) + default: + active = candidate + } + } + parent := filepath.Dir(target.Path) + registrationPath := filepath.Join(parent, name) + if active == "" { + return registrationPath, registrationPath, false, nil + } + return filepath.Join(parent, active), registrationPath, true, nil +} + +func (m *Manager) removeStagedPreparedWorktree( + ctx context.Context, + target Workspace, + record workspaceRecord, + path string, + registrationPath string, + repository repositoryCapability, +) (result error) { + bucket, err := m.openManagedBucketRoot(target) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, bucket.Close()) + }() + name := filepath.Base(path) + opened, err := openRealRootFromRoot(bucket, name) + if err != nil { + return err + } + openedOwned := true + defer func() { + if openedOwned { + result = errors.Join(result, opened.Close()) + } + }() + identity, err := openedDirectoryIdentity(opened) + if err != nil { + return err + } + if record.DirectoryIdentity == "" || + identity != record.DirectoryIdentity { + return errors.New( + "workspace: prepared worktree staging identity changed", + ) + } + registered, exists, err := repository.worktreeRegistration( + ctx, + registrationPath, + ) + if err != nil { + return err + } + if !exists { + return errors.New( + "workspace: prepared worktree staging registration disappeared", + ) + } + if record.GitDirectory == "" { + return errors.New( + "workspace: prepared worktree staging Git identity is incomplete", + ) + } + stagingTarget := target + stagingTarget.Path = registrationPath + if err := repository.verifyPreparedWorktree( + ctx, + stagingTarget, + record.GitDirectory, + registered, + ); err != nil { + return err + } + if err := verifyRootEntryUnchanged(bucket, name, opened); err != nil { + return err + } + openedOwned = false + return removeOpenedDirectoryFromRoot(bucket, name, opened) } func (m *Manager) verifyPreparedWorktreeForAcknowledgement( @@ -265,6 +742,53 @@ func (m *Manager) verifyPreparedWorktreeForAcknowledgement( "workspace: prepared worktree Git directory changed", ) } + if target.expectedHeadOID == "" || + target.expectedHeadOID != record.ExpectedHeadOID { + return errors.New( + "workspace: prepared worktree expected HEAD changed", + ) + } + if record.PreparationCommitted { + currentHeadOID, err := + repository.derivePreparedWorktreeExpectedHead(ctx, target) + if err != nil { + return fmt.Errorf( + "workspace: inspect committed worktree HEAD: %w", + err, + ) + } + target.expectedHeadOID = currentHeadOID + } + if _, err := repository.boundGitDirectory( + record.GitDirectory, + target.AgentID, + ); err != nil { + return err + } + gitRoot, err := openRealPathRoot(record.GitDirectory) + if err != nil { + return fmt.Errorf( + "workspace: open prepared private Git directory: %w", + err, + ) + } + defer func() { + result = errors.Join(result, gitRoot.Close()) + }() + gitGuard, err := openRepositoryGuard(record.GitDirectory, gitRoot) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, gitGuard.Close()) + }() + if err := repository.verifyPreparationRefs( + ctx, + target, + !record.PreparationCommitted, + ); err != nil { + return err + } registered, exists, err := repository.worktreeRegistration( ctx, target.Path, @@ -277,10 +801,13 @@ func (m *Manager) verifyPreparedWorktreeForAcknowledgement( "workspace: prepared worktree registration disappeared", ) } - if err := repository.verifyPreparedWorktree( + privateRepository := repository.withPrivateGitBinding( + record.GitDirectory, + gitRoot, + ) + if err := privateRepository.verifyBoundPreparedWorktree( ctx, target, - record.GitDirectory, registered, ); err != nil { return err @@ -301,6 +828,19 @@ func (m *Manager) verifyPreparedWorktreeForAcknowledgement( ); err != nil { return err } + if err := verifyRealPathRoot(record.GitDirectory, gitRoot); err != nil { + return err + } + if err := repository.verifyPreparationRefs( + ctx, + target, + !record.PreparationCommitted, + ); err != nil { + return err + } + if err := privateRepository.verifyPrivateGitCommonDirectory(); err != nil { + return err + } return repository.verifyBinding(ctx) } @@ -323,7 +863,7 @@ func (m *Manager) worktreeGitDirectoryAtRoot( err, ) } - gitDirectory := strings.TrimSpace(string(output)) + gitDirectory := trimGitLineTerminator(output) if !filepath.IsAbs(gitDirectory) { return "", fmt.Errorf( "workspace: Git directory for %q is not absolute", diff --git a/internal/workspace/record.go b/internal/workspace/record.go index 05829e4..3a7afcc 100644 --- a/internal/workspace/record.go +++ b/internal/workspace/record.go @@ -8,6 +8,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "github.com/google/uuid" @@ -19,25 +20,28 @@ const ( protectedWorkspaceRecordVersion = 2 preparationWorkspaceRecordVersion = 3 repositoryWorkspaceRecordVersion = 4 - workspaceRecordVersion = 5 + removalWorkspaceRecordVersion = 5 + workspaceRecordVersion = 6 maxWorkspaceRecordSize = 64 * 1024 ) type workspaceRecord struct { - Version int `json:"version"` - AgentID string `json:"agent_id"` - Repository string `json:"repository"` - Path string `json:"path"` - Branch string `json:"branch"` - ProtectionKnown bool `json:"protection_known"` - IncludedPaths []string `json:"included_paths"` - PreparationCommitted bool `json:"preparation_committed"` - CreatedBranch bool `json:"created_branch"` - BranchOperationID string `json:"branch_operation_id,omitempty"` - GitDirectory string `json:"git_directory,omitempty"` - DirectoryIdentity string `json:"directory_identity,omitempty"` - RepositoryEvidence *repositoryEvidence `json:"repository_evidence,omitempty"` - Removal *workspaceRemovalRecord `json:"removal,omitempty"` + Version int `json:"version"` + AgentID string `json:"agent_id"` + Repository string `json:"repository"` + Path string `json:"path"` + Branch string `json:"branch"` + ProtectionKnown bool `json:"protection_known"` + IncludedPaths []string `json:"included_paths"` + PreparationCommitted bool `json:"preparation_committed"` + CreatedBranch bool `json:"created_branch"` + BranchOperationID string `json:"branch_operation_id,omitempty"` + ExpectedHeadOID string `json:"expected_head_oid,omitempty"` + GitDirectory string `json:"git_directory,omitempty"` + DirectoryIdentity string `json:"directory_identity,omitempty"` + PreparedStageDirectoryIdentity string `json:"prepared_stage_directory_identity,omitempty"` + RepositoryEvidence *repositoryEvidence `json:"repository_evidence,omitempty"` + Removal *workspaceRemovalRecord `json:"removal,omitempty"` } type repositoryEvidence struct { @@ -50,12 +54,18 @@ type repositoryEvidence struct { } type workspaceRemovalRecord struct { - OperationID string `json:"operation_id"` - DirectoryToken string `json:"directory_token,omitempty"` - Force bool `json:"force"` - Started bool `json:"started"` - Quarantined bool `json:"quarantined,omitempty"` - PathAbsent bool `json:"path_absent,omitempty"` + OperationID string `json:"operation_id"` + DirectoryToken string `json:"directory_token,omitempty"` + Force bool `json:"force"` + Started bool `json:"started"` + Quarantined bool `json:"quarantined,omitempty"` + ContentsCleared bool `json:"contents_cleared,omitempty"` + PathAbsent bool `json:"path_absent,omitempty"` +} + +type recordAcknowledgement struct { + name string + operationID string } func workspaceRecordPath(worktreePath string) string { @@ -84,6 +94,7 @@ func newWorkspaceRecord(target Workspace, includedPaths []string) workspaceRecor IncludedPaths: append([]string{}, includedPaths...), CreatedBranch: target.createdBranch, BranchOperationID: target.branchOperationID, + ExpectedHeadOID: target.expectedHeadOID, GitDirectory: target.gitDirectory, DirectoryIdentity: target.directoryIdentity, RepositoryEvidence: cloneRepositoryEvidence( @@ -100,6 +111,7 @@ func (r workspaceRecord) workspace() Workspace { Branch: r.Branch, createdBranch: r.CreatedBranch, branchOperationID: r.BranchOperationID, + expectedHeadOID: r.ExpectedHeadOID, gitDirectory: r.GitDirectory, directoryIdentity: r.DirectoryIdentity, repositoryEvidence: cloneRepositoryEvidence( @@ -122,10 +134,15 @@ func upgradeWorkspaceRecord(record *workspaceRecord) { if record.Version >= workspaceRecordVersion { return } + previousVersion := record.Version record.Version = workspaceRecordVersion - record.RepositoryEvidence = nil - if record.PreparationCommitted { - record.BranchOperationID = "" + record.PreparedStageDirectoryIdentity = "" + if previousVersion < removalWorkspaceRecordVersion { + record.RepositoryEvidence = nil + record.ExpectedHeadOID = "" + if record.PreparationCommitted { + record.BranchOperationID = "" + } } } @@ -179,6 +196,22 @@ func (m *Manager) installWorkspaceRecordState( defer func() { result = errors.Join(result, bucket.Close()) }() + if err := recoverRecordRenameDebris(bucket); err != nil { + return false, fmt.Errorf( + "workspace: recover record file operations: %w", + err, + ) + } + if err := m.recoverWorkspaceRecordArtifacts( + bucket, + filepath.Base(filepath.Dir(record.Path)), + record.AgentID, + ); err != nil { + return false, fmt.Errorf( + "workspace: recover record artifacts: %w", + err, + ) + } name := agentID + workspaceRecordSuffix if err := checkRecordTarget(bucket, name, recordPath, requireAbsent); err != nil { return false, err @@ -195,7 +228,7 @@ func (m *Manager) installWorkspaceRecordState( ) } - temporaryName := "." + name + ".tmp-" + uuid.NewString() + temporaryName := workspaceRecordTemporaryName(name) file, err := bucket.OpenFile( temporaryName, os.O_RDWR|os.O_CREATE|os.O_EXCL, @@ -213,7 +246,13 @@ func (m *Manager) installWorkspaceRecordState( if temporaryName != "" { result = errors.Join( result, - removeRecordPathIfSame(bucket, temporaryName, file), + removeOwnedRecordPathIfSame( + bucket, + temporaryName, + file, + workspaceRecordTemporaryName(name), + nil, + ), ) } if fileOpen { @@ -248,7 +287,7 @@ func (m *Manager) installWorkspaceRecordState( if err := m.verifyRecordBucket(record.Path, bucket); err != nil { return false, err } - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return false, fmt.Errorf( "workspace: open record directory %q: %w", @@ -326,31 +365,6 @@ func checkRecordTarget( return nil } -func removeRecordPathIfSame( - root *os.Root, - name string, - expected *os.File, -) error { - current, err := root.Lstat(name) - if errors.Is(err, os.ErrNotExist) { - return nil - } - if err != nil { - return err - } - opened, err := expected.Stat() - if err != nil { - return err - } - if !os.SameFile(opened, current) { - return fmt.Errorf( - "workspace: temporary path %q changed identity", - name, - ) - } - return root.Remove(name) -} - func (m *Manager) readWorkspaceRecord( worktreePath string, ) (_ workspaceRecord, _ bool, result error) { @@ -379,6 +393,22 @@ func (m *Manager) readWorkspaceRecordFromBucket( ) (_ workspaceRecord, _ bool, result error) { recordPath := workspaceRecordPath(worktreePath) name := agentID + workspaceRecordSuffix + if err := recoverRecordRenameDebris(bucket); err != nil { + return workspaceRecord{}, false, fmt.Errorf( + "workspace: recover record file operations: %w", + err, + ) + } + if err := m.recoverWorkspaceRecordArtifacts( + bucket, + filepath.Base(filepath.Dir(worktreePath)), + agentID, + ); err != nil { + return workspaceRecord{}, false, fmt.Errorf( + "workspace: recover record artifacts: %w", + err, + ) + } info, err := bucket.Lstat(name) if errors.Is(err, os.ErrNotExist) { return workspaceRecord{}, false, nil @@ -428,16 +458,32 @@ func (m *Manager) readWorkspaceRecordFromBucket( recordPath, ) } + record, err := m.decodeWorkspaceRecordFile( + file, + recordPath, + worktreePath, + ) + if err != nil { + return workspaceRecord{}, false, err + } + return record, true, nil +} + +func (m *Manager) decodeWorkspaceRecordFile( + file *os.File, + recordPath string, + worktreePath string, +) (workspaceRecord, error) { raw, err := io.ReadAll(io.LimitReader(file, maxWorkspaceRecordSize+1)) if err != nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: read record %q: %w", recordPath, err, ) } if len(raw) > maxWorkspaceRecordSize { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: record %q exceeds %d bytes", recordPath, maxWorkspaceRecordSize, @@ -447,14 +493,14 @@ func (m *Manager) readWorkspaceRecordFromBucket( decoder.DisallowUnknownFields() var record workspaceRecord if err := decoder.Decode(&record); err != nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: decode record %q: %w", recordPath, err, ) } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: record %q has trailing data", recordPath, ) @@ -466,11 +512,12 @@ func (m *Manager) readWorkspaceRecordFromBucket( record.PreparationCommitted || record.CreatedBranch || record.BranchOperationID != "" || + record.ExpectedHeadOID != "" || record.GitDirectory != "" || record.DirectoryIdentity != "" || record.RepositoryEvidence != nil || record.Removal != nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: legacy record %q contains newer fields", recordPath, ) @@ -478,7 +525,7 @@ func (m *Manager) readWorkspaceRecordFromBucket( record.PreparationCommitted = true case protectedWorkspaceRecordVersion: if record.IncludedPaths == nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: version 2 record %q has no included paths", recordPath, ) @@ -486,10 +533,11 @@ func (m *Manager) readWorkspaceRecordFromBucket( if record.PreparationCommitted || record.CreatedBranch || record.BranchOperationID != "" || + record.ExpectedHeadOID != "" || record.GitDirectory != "" || record.DirectoryIdentity != "" || record.RepositoryEvidence != nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: version 2 record %q contains version 3 fields", recordPath, ) @@ -500,52 +548,66 @@ func (m *Manager) readWorkspaceRecordFromBucket( } case preparationWorkspaceRecordVersion: if record.IncludedPaths == nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: version 3 record %q has no included paths", recordPath, ) } if record.RepositoryEvidence != nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: version 3 record %q contains version 4 fields", recordPath, ) } case repositoryWorkspaceRecordVersion: if record.IncludedPaths == nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: version 4 record %q has no included paths", recordPath, ) } - case workspaceRecordVersion: + case removalWorkspaceRecordVersion: if record.IncludedPaths == nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: version 5 record %q has no included paths", recordPath, ) } + case workspaceRecordVersion: + if record.IncludedPaths == nil { + return workspaceRecord{}, fmt.Errorf( + "workspace: version 6 record %q has no included paths", + recordPath, + ) + } default: - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: record %q has unsupported version %d", recordPath, record.Version, ) } if filepath.Clean(worktreePath) != record.Path { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: record %q path mismatch", recordPath, ) } + if record.Version < workspaceRecordVersion && + record.PreparedStageDirectoryIdentity != "" { + return workspaceRecord{}, fmt.Errorf( + "workspace: record %q contains version 6 fields", + recordPath, + ) + } if err := m.validateWorkspaceRecord(record); err != nil { - return workspaceRecord{}, false, fmt.Errorf( + return workspaceRecord{}, fmt.Errorf( "workspace: validate record %q: %w", recordPath, err, ) } - return record, true, nil + return record, nil } func (m *Manager) validateWorkspaceRecord(record workspaceRecord) error { @@ -553,6 +615,7 @@ func (m *Manager) validateWorkspaceRecord(record workspaceRecord) error { record.Version != protectedWorkspaceRecordVersion && record.Version != preparationWorkspaceRecordVersion && record.Version != repositoryWorkspaceRecordVersion && + record.Version != removalWorkspaceRecordVersion && record.Version != workspaceRecordVersion { return fmt.Errorf("workspace: unsupported record version %d", record.Version) } @@ -600,6 +663,20 @@ func (m *Manager) validateWorkspaceRecord(record workspaceRecord) error { "workspace: quarantined removal has not been started", ) } + if record.Version >= removalWorkspaceRecordVersion && + record.Removal.Started && + !record.Removal.Quarantined && + !record.Removal.PathAbsent { + return errors.New( + "workspace: started removal has no quarantined or absent path", + ) + } + if record.Removal.ContentsCleared && + (!record.Removal.Started || !record.Removal.Quarantined) { + return errors.New( + "workspace: cleared removal contents require a started quarantine", + ) + } if record.Removal.PathAbsent && record.Removal.Quarantined { return errors.New( "workspace: absent-path removal cannot be quarantined", @@ -623,6 +700,13 @@ func (m *Manager) validateWorkspaceRecord(record workspaceRecord) error { ) } } + if record.ExpectedHeadOID != "" && + (len(record.ExpectedHeadOID) > 128 || + strings.ContainsAny(record.ExpectedHeadOID, " \t\r\n")) { + return errors.New( + "workspace: expected HEAD object ID is invalid", + ) + } if record.GitDirectory != "" && (!filepath.IsAbs(record.GitDirectory) || filepath.Clean(record.GitDirectory) != record.GitDirectory) { @@ -637,6 +721,18 @@ func (m *Manager) validateWorkspaceRecord(record workspaceRecord) error { "workspace: directory identity is invalid", ) } + if record.PreparedStageDirectoryIdentity != "" && + !validDirectoryIdentity(record.PreparedStageDirectoryIdentity) { + return errors.New( + "workspace: prepared stage directory identity is invalid", + ) + } + if record.PreparedStageDirectoryIdentity != "" && + (record.BranchOperationID == "" || record.PreparationCommitted) { + return errors.New( + "workspace: prepared stage identity has no pending preparation", + ) + } if record.RepositoryEvidence != nil { var err error if record.Version == repositoryWorkspaceRecordVersion { @@ -719,6 +815,13 @@ func validDirectoryIdentity(identity string) bool { func (m *Manager) removeWorkspaceRecord( worktreePath string, +) error { + return m.removeWorkspaceRecordAfterValidation(worktreePath, nil) +} + +func (m *Manager) removeWorkspaceRecordAfterValidation( + worktreePath string, + afterValidation func(), ) (result error) { recordPath := workspaceRecordPath(worktreePath) bucket, agentID, err := m.openRecordBucket(worktreePath) @@ -749,14 +852,113 @@ func (m *Manager) removeWorkspaceRecord( recordPath, ) } - if err := bucket.Remove(name); err != nil { - return fmt.Errorf("workspace: remove record %q: %w", recordPath, err) + opened, err := bucket.Open(name) + if err != nil { + return fmt.Errorf("workspace: open record %q: %w", recordPath, err) + } + defer func() { + result = errors.Join(result, opened.Close()) + }() + openedInfo, err := opened.Stat() + if err != nil { + return fmt.Errorf( + "workspace: inspect opened record %q: %w", + recordPath, + err, + ) + } + if !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) { + return fmt.Errorf( + "workspace: record %q changed while opening", + recordPath, + ) + } + record, err := m.decodeWorkspaceRecordFile( + opened, + recordPath, + worktreePath, + ) + if err != nil { + return err + } + if err := m.verifyRecordBucket(worktreePath, bucket); err != nil { + return err + } + isolatedName := workspaceRecordRemovalName(record) + if err := removeOwnedRecordPath( + bucket, + name, + opened, + isolatedName, + func(candidate *os.File) error { + if _, err := candidate.Seek(0, io.SeekStart); err != nil { + return err + } + current, err := m.decodeWorkspaceRecordFile( + candidate, + recordPath, + worktreePath, + ) + if err != nil { + return err + } + if current.AgentID != record.AgentID { + return errors.New( + "workspace: isolated record changed ownership", + ) + } + return nil + }, + afterValidation, + ); err != nil { + return fmt.Errorf( + "workspace: remove record %q: %w", + recordPath, + err, + ) + } + if err := m.verifyRecordBucket(worktreePath, bucket); err != nil { + return err } - return syncRecordBucket(bucket, recordPath) + return nil } func (m *Manager) removeAcknowledgedWorkspaceRecord( removal Removal, +) error { + return m.removeAcknowledgedWorkspaceRecordWithHooks( + removal, + nil, + nil, + ) +} + +func (m *Manager) removeAcknowledgedWorkspaceRecordAfterValidation( + removal Removal, + afterValidation func(), +) error { + return m.removeAcknowledgedWorkspaceRecordWithHooks( + removal, + afterValidation, + nil, + ) +} + +func (m *Manager) removeAcknowledgedWorkspaceRecordAfterQuarantine( + removal Removal, + afterQuarantine func() error, +) error { + return m.removeAcknowledgedWorkspaceRecordWithHooks( + removal, + nil, + afterQuarantine, + ) +} + +func (m *Manager) removeAcknowledgedWorkspaceRecordWithHooks( + removal Removal, + afterValidation func(), + afterQuarantine func() error, ) (result error) { recordPath := workspaceRecordPath(removal.Workspace.Path) bucket, agentID, err := m.openRecordBucket(removal.Workspace.Path) @@ -771,8 +973,15 @@ func (m *Manager) removeAcknowledgedWorkspaceRecord( ) } defer func() { - result = errors.Join(result, bucket.Close()) + result = errors.Join( + result, + m.verifyRecordBucket(removal.Workspace.Path, bucket), + bucket.Close(), + ) }() + if afterValidation != nil { + afterValidation() + } quarantineName, exists, err := findRecordAcknowledgementQuarantine( bucket, @@ -811,11 +1020,8 @@ func (m *Manager) removeAcknowledgedWorkspaceRecord( if err != nil { return fmt.Errorf("workspace: open removal record %q: %w", recordPath, err) } - fileOpen := true defer func() { - if fileOpen { - result = errors.Join(result, file.Close()) - } + result = errors.Join(result, file.Close()) }() opened, err := m.verifyRemovalRecordFile( file, @@ -826,7 +1032,7 @@ func (m *Manager) removeAcknowledgedWorkspaceRecord( return err } - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return fmt.Errorf( "workspace: open record directory %q: %w", @@ -882,22 +1088,78 @@ func (m *Manager) removeAcknowledgedWorkspaceRecord( restoreErr, ) } - if err := file.Close(); err != nil { - return fmt.Errorf( - "workspace: close quarantined removal record %q: %w", - recordPath, - err, - ) + if afterQuarantine != nil { + if err := afterQuarantine(); err != nil { + return fmt.Errorf( + "workspace: release removal acknowledgement reservation: %w", + err, + ) + } } - fileOpen = false - return m.removeRecordAcknowledgementQuarantine( + return m.removeRecordAcknowledgementQuarantineWithFile( bucket, quarantineName, recordPath, removal, + file, ) } +func (m *Manager) removalAcknowledgementPending( + removal Removal, +) (_ bool, result error) { + recordPath := workspaceRecordPath(removal.Workspace.Path) + bucket, agentID, err := m.openRecordBucket(removal.Workspace.Path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf( + "workspace: open record directory %q: %w", + recordPath, + err, + ) + } + defer func() { + result = errors.Join(result, bucket.Close()) + }() + entries, err := readRootDirectory(bucket) + if err != nil { + return false, fmt.Errorf( + "workspace: read record directory %q: %w", + recordPath, + err, + ) + } + recordName := agentID + workspaceRecordSuffix + acknowledgementPrefix := "." + recordName + ".ack-" + pending := false + for _, entry := range entries { + if entry.Name() == recordName { + pending = true + continue + } + if !strings.HasPrefix(entry.Name(), acknowledgementPrefix) { + continue + } + acknowledgementAgentID, operationID, ok := + recordAcknowledgementIdentity(entry.Name()) + if !ok || acknowledgementAgentID != agentID { + return false, fmt.Errorf( + "workspace: acknowledgement record %q is invalid", + entry.Name(), + ) + } + if operationID != removal.operationID { + return false, errors.New( + "workspace: removal acknowledgement token does not match quarantined record", + ) + } + pending = true + } + return pending, nil +} + func recordAcknowledgementPrefix( agentID string, operationID string, @@ -965,17 +1227,31 @@ func (m *Manager) recoverRecordAcknowledgements() (result error) { _ = bucketRoot.Close() return err } + if err := recoverRecordRenameDebris(bucketRoot); err != nil { + _ = bucketRoot.Close() + return fmt.Errorf( + "workspace: recover record file operations: %w", + err, + ) + } + if err := m.recoverWorkspaceRecordArtifacts( + bucketRoot, + bucket.Name(), + "", + ); err != nil { + _ = bucketRoot.Close() + return fmt.Errorf( + "workspace: recover record artifacts: %w", + err, + ) + } entries, err := readRootDirectory(bucketRoot) if err != nil { _ = bucketRoot.Close() return err } normalRecords := make(map[string]struct{}) - type acknowledgement struct { - name string - operationID string - } - acknowledgements := make(map[string][]acknowledgement) + acknowledgements := make(map[string][]recordAcknowledgement) for _, entry := range entries { if agentID, ok := workspaceRecordAgentID(entry.Name()); ok { normalRecords[agentID] = struct{}{} @@ -989,7 +1265,7 @@ func (m *Manager) recoverRecordAcknowledgements() (result error) { } acknowledgements[agentID] = append( acknowledgements[agentID], - acknowledgement{ + recordAcknowledgement{ name: entry.Name(), operationID: operationID, }, @@ -999,15 +1275,18 @@ func (m *Manager) recoverRecordAcknowledgements() (result error) { if _, exists := normalRecords[agentID]; exists { continue } - if len(candidates) != 1 { + candidate, err := coalesceRecordAcknowledgements( + bucketRoot, + candidates, + ) + if err != nil { _ = bucketRoot.Close() return fmt.Errorf( - "workspace: agent %q has %d acknowledgement records", + "workspace: recover acknowledgement aliases for agent %q: %w", agentID, - len(candidates), + err, ) } - candidate := candidates[0] name := agentID + workspaceRecordSuffix if err := restoreRecordAcknowledgement( bucketRoot, @@ -1058,31 +1337,141 @@ func findRecordAcknowledgementQuarantine( return "", false, err } prefix := recordAcknowledgementPrefix(agentID, operationID) - var matched string + var candidates []recordAcknowledgement for _, entry := range entries { if !strings.HasPrefix(entry.Name(), prefix) { continue } - if matched != "" { - return "", false, fmt.Errorf( - "workspace: removal acknowledgement %q has multiple quarantined records", - operationID, + candidates = append(candidates, recordAcknowledgement{ + name: entry.Name(), + operationID: operationID, + }) + } + if len(candidates) == 0 { + return "", false, nil + } + matched, err := coalesceRecordAcknowledgements(bucket, candidates) + if err != nil { + return "", false, err + } + return matched.name, true, nil +} + +func coalesceRecordAcknowledgements( + bucket *os.Root, + candidates []recordAcknowledgement, +) (recordAcknowledgement, error) { + return coalesceRecordAcknowledgementsAfterValidation( + bucket, + candidates, + nil, + ) +} + +func coalesceRecordAcknowledgementsAfterValidation( + bucket *os.Root, + candidates []recordAcknowledgement, + afterValidation func(), +) (_ recordAcknowledgement, result error) { + if len(candidates) == 0 { + return recordAcknowledgement{}, errors.New( + "workspace: acknowledgement candidate list is empty", + ) + } + sort.Slice(candidates, func(i, j int) bool { + return candidates[i].name < candidates[j].name + }) + operationID := candidates[0].operationID + var expected *os.File + for _, candidate := range candidates { + if candidate.operationID != operationID { + return recordAcknowledgement{}, errors.New( + "workspace: acknowledgement aliases have different operation IDs", ) } - info, err := bucket.Lstat(entry.Name()) + info, err := bucket.Lstat(candidate.name) if err != nil { - return "", false, err + return recordAcknowledgement{}, err } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { - return "", false, fmt.Errorf( + return recordAcknowledgement{}, fmt.Errorf( "workspace: acknowledgement quarantine %q is not a regular file", - entry.Name(), + candidate.name, + ) + } + file, err := bucket.Open(candidate.name) + if err != nil { + return recordAcknowledgement{}, err + } + opened, statErr := file.Stat() + if statErr == nil && + (!opened.Mode().IsRegular() || !os.SameFile(info, opened)) { + statErr = fmt.Errorf( + "workspace: acknowledgement quarantine %q changed while opening", + candidate.name, ) } - matched = entry.Name() + if statErr == nil && expected != nil { + expectedInfo, expectedErr := expected.Stat() + if expectedErr != nil { + statErr = expectedErr + } else if !os.SameFile(expectedInfo, opened) { + statErr = errors.New( + "workspace: acknowledgement aliases refer to different records", + ) + } + } + if statErr != nil { + _ = file.Close() + return recordAcknowledgement{}, statErr + } + if expected == nil { + expected = file + defer func() { + result = errors.Join(result, expected.Close()) + }() + continue + } + if err := file.Close(); err != nil { + return recordAcknowledgement{}, err + } + } + agentID, parsedOperationID, ok := recordAcknowledgementIdentity( + candidates[0].name, + ) + if !ok || parsedOperationID != operationID { + return recordAcknowledgement{}, errors.New( + "workspace: acknowledgement candidate name is invalid", + ) + } + for _, duplicate := range candidates[1:] { + isolatedName := recordAcknowledgementPrefix( + agentID, + operationID, + ) + uuid.NewString() + if err := removeOwnedRecordPath( + bucket, + duplicate.name, + expected, + isolatedName, + nil, + afterValidation, + ); err != nil { + return recordAcknowledgement{}, fmt.Errorf( + "workspace: remove acknowledgement alias %q: %w", + duplicate.name, + err, + ) + } + afterValidation = nil + } + if len(candidates) > 1 { + if err := syncRecordBucket(bucket, candidates[0].name); err != nil { + return recordAcknowledgement{}, err + } } - return matched, matched != "", nil + return candidates[0], nil } func (m *Manager) removeRecordAcknowledgementQuarantine( @@ -1099,35 +1488,54 @@ func (m *Manager) removeRecordAcknowledgementQuarantine( err, ) } - fileOpen := true defer func() { - if fileOpen { - result = errors.Join(result, file.Close()) - } + result = errors.Join(result, file.Close()) }() - if _, err := m.verifyRemovalRecordFile( - file, + return m.removeRecordAcknowledgementQuarantineWithFile( + bucket, + name, recordPath, removal, + file, + ) +} + +func (m *Manager) removeRecordAcknowledgementQuarantineWithFile( + bucket *os.Root, + name string, + recordPath string, + removal Removal, + file *os.File, +) error { + deleteName := recordAcknowledgementPrefix( + removal.Workspace.AgentID, + removal.operationID, + ) + uuid.NewString() + if err := removeOwnedRecordPath( + bucket, + name, + file, + deleteName, + func(candidate *os.File) error { + if _, err := candidate.Seek(0, io.SeekStart); err != nil { + return err + } + _, err := m.verifyRemovalRecordFile( + candidate, + recordPath, + removal, + ) + return err + }, + nil, ); err != nil { - return err - } - if err := file.Close(); err != nil { - return fmt.Errorf( - "workspace: close quarantined removal record %q: %w", - recordPath, - err, - ) - } - fileOpen = false - if err := bucket.Remove(name); err != nil { return fmt.Errorf( "workspace: remove quarantined record %q: %w", recordPath, err, ) } - return syncRecordBucket(bucket, recordPath) + return nil } func restoreRecordAcknowledgement( @@ -1146,7 +1554,7 @@ func restoreRecordAcknowledgement( if err != nil { return err } - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return err } @@ -1183,7 +1591,7 @@ func syncRecordBucket( bucket *os.Root, recordPath string, ) error { - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return fmt.Errorf( "workspace: open record directory %q for sync: %w", diff --git a/internal/workspace/record_artifact.go b/internal/workspace/record_artifact.go new file mode 100644 index 0000000..d281170 --- /dev/null +++ b/internal/workspace/record_artifact.go @@ -0,0 +1,282 @@ +package workspace + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +const ( + workspaceRecordRemovalLegacyPrefix = ".drove-record-remove-" + workspaceRecordRemovalPrefix = workspaceRecordRemovalLegacyPrefix + "v1-" +) + +type workspaceRecordArtifactKind uint8 + +const ( + workspaceRecordTemporaryArtifact workspaceRecordArtifactKind = iota + 1 + workspaceRecordRemovalArtifact +) + +type workspaceRecordArtifact struct { + name string + agentID string + operationID string + kind workspaceRecordArtifactKind +} + +func workspaceRecordTemporaryName(recordName string) string { + return "." + recordName + ".tmp-" + uuid.NewString() +} + +func workspaceRecordRemovalName(record workspaceRecord) string { + operationID := "none" + if record.Removal != nil { + operationID = record.Removal.OperationID + } + return workspaceRecordRemovalPrefix + record.AgentID + "-" + + operationID + "-" + uuid.NewString() +} + +func parseWorkspaceRecordArtifactName( + name string, +) (workspaceRecordArtifact, bool) { + if temporary, valid := parseWorkspaceRecordTemporaryName(name); valid { + return temporary, true + } + return parseWorkspaceRecordRemovalName(name) +} + +func parseWorkspaceRecordTemporaryName( + name string, +) (workspaceRecordArtifact, bool) { + if !strings.HasPrefix(name, ".") { + return workspaceRecordArtifact{}, false + } + const marker = workspaceRecordSuffix + ".tmp-" + raw := strings.TrimPrefix(name, ".") + index := strings.Index(raw, marker) + if index < 0 { + return workspaceRecordArtifact{}, false + } + agentID := raw[:index] + if validateAgentID(agentID) != nil { + return workspaceRecordArtifact{}, false + } + nonce := raw[index+len(marker):] + if !canonicalUUID(nonce) { + return workspaceRecordArtifact{}, false + } + return workspaceRecordArtifact{ + name: name, + agentID: agentID, + kind: workspaceRecordTemporaryArtifact, + }, true +} + +func parseWorkspaceRecordRemovalName( + name string, +) (workspaceRecordArtifact, bool) { + raw, found := strings.CutPrefix(name, workspaceRecordRemovalPrefix) + if !found || len(raw) < 36+1+4+1+36 { + return workspaceRecordArtifact{}, false + } + agentID := raw[:36] + if validateAgentID(agentID) != nil || raw[36] != '-' { + return workspaceRecordArtifact{}, false + } + raw = raw[37:] + operationID := "none" + switch { + case strings.HasPrefix(raw, "none-"): + raw = strings.TrimPrefix(raw, "none-") + default: + if len(raw) < 36+1+36 || raw[36] != '-' { + return workspaceRecordArtifact{}, false + } + operationID = raw[:36] + if !canonicalUUID(operationID) { + return workspaceRecordArtifact{}, false + } + raw = raw[37:] + } + if !canonicalUUID(raw) { + return workspaceRecordArtifact{}, false + } + return workspaceRecordArtifact{ + name: name, + agentID: agentID, + operationID: operationID, + kind: workspaceRecordRemovalArtifact, + }, true +} + +func canonicalUUID(raw string) bool { + if len(raw) != 36 { + return false + } + parsed, err := uuid.Parse(raw) + return err == nil && parsed.String() == raw +} + +func (m *Manager) recoverWorkspaceRecordArtifacts( + bucket *os.Root, + bucketName string, + agentFilter string, +) error { + entries, err := readRootDirectory(bucket) + if err != nil { + return err + } + for _, entry := range entries { + artifact, valid := parseWorkspaceRecordArtifactName(entry.Name()) + if !valid { + if strings.HasPrefix( + entry.Name(), + workspaceRecordRemovalLegacyPrefix, + ) || + strings.Contains( + entry.Name(), + workspaceRecordSuffix+".tmp-", + ) { + return fmt.Errorf( + "workspace: record artifact %q has an invalid name", + entry.Name(), + ) + } + continue + } + if agentFilter != "" && artifact.agentID != agentFilter { + continue + } + if err := m.recoverWorkspaceRecordArtifact( + bucket, + bucketName, + artifact, + ); err != nil { + return err + } + } + return nil +} + +func (m *Manager) recoverWorkspaceRecordArtifact( + bucket *os.Root, + bucketName string, + artifact workspaceRecordArtifact, +) (result error) { + info, err := bucket.Lstat(artifact.name) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf( + "workspace: record artifact %q is not a regular file", + artifact.name, + ) + } + file, err := bucket.Open(artifact.name) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, file.Close()) + }() + opened, err := file.Stat() + if err != nil { + return err + } + if !opened.Mode().IsRegular() || !os.SameFile(info, opened) { + return fmt.Errorf( + "workspace: record artifact %q changed while opening", + artifact.name, + ) + } + worktreePath := filepath.Join(m.root, bucketName, artifact.agentID) + recordPath := workspaceRecordPath(worktreePath) + if artifact.kind == workspaceRecordTemporaryArtifact { + return removeOwnedRecordPath( + bucket, + artifact.name, + file, + workspaceRecordTemporaryName( + artifact.agentID+workspaceRecordSuffix, + ), + nil, + nil, + ) + } + if artifact.kind != workspaceRecordRemovalArtifact { + return fmt.Errorf( + "workspace: record artifact %q has an invalid kind", + artifact.name, + ) + } + record, err := m.decodeWorkspaceRecordFile( + file, + recordPath, + worktreePath, + ) + if err != nil { + return err + } + if record.AgentID != artifact.agentID { + return fmt.Errorf( + "workspace: record artifact %q has the wrong agent", + artifact.name, + ) + } + expectedOperation := "none" + if record.Removal != nil { + expectedOperation = record.Removal.OperationID + } + if artifact.operationID != expectedOperation { + return fmt.Errorf( + "workspace: record artifact %q has the wrong operation", + artifact.name, + ) + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + return removeOwnedRecordPath( + bucket, + artifact.name, + file, + workspaceRecordRemovalName(record), + func(candidate *os.File) error { + if _, err := candidate.Seek(0, io.SeekStart); err != nil { + return err + } + current, err := m.decodeWorkspaceRecordFile( + candidate, + recordPath, + worktreePath, + ) + if err != nil { + return err + } + if current.AgentID != artifact.agentID { + return errors.New( + "workspace: record artifact changed ownership", + ) + } + currentOperation := "none" + if current.Removal != nil { + currentOperation = current.Removal.OperationID + } + if currentOperation != artifact.operationID { + return errors.New( + "workspace: record artifact changed operation", + ) + } + return nil + }, + nil, + ) +} diff --git a/internal/workspace/record_artifact_test.go b/internal/workspace/record_artifact_test.go new file mode 100644 index 0000000..ef57d0b --- /dev/null +++ b/internal/workspace/record_artifact_test.go @@ -0,0 +1,204 @@ +package workspace + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestRecoverWorkspaceRecordTemporaryArtifact(t *testing.T) { + manager, target, _, dataDir := newRecordArtifactFixture(t) + recordPath := workspaceRecordPath(target.Path) + recordName := filepath.Base(recordPath) + temporaryName := workspaceRecordTemporaryName(recordName) + temporaryPath := filepath.Join(filepath.Dir(recordPath), temporaryName) + if err := os.Link(recordPath, temporaryPath); err != nil { + t.Fatalf("link temporary record: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.recoverRecordAcknowledgements(); err != nil { + t.Fatalf("recover record artifacts: %v", err) + } + if err := restarted.recoverRecordAcknowledgements(); err != nil { + t.Fatalf("repeat record artifact recovery: %v", err) + } + if _, err := os.Stat(recordPath); err != nil { + t.Fatalf("canonical record changed: %v", err) + } + if _, err := os.Lstat(temporaryPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("temporary record remains: %v", err) + } + _ = manager +} + +func TestRecoverWorkspaceRecordTemporaryDebrisBesideCanonical( + t *testing.T, +) { + for _, test := range []struct { + name string + content func([]byte) []byte + }{ + { + name: "complete separate inode", + content: func(canonical []byte) []byte { + return append([]byte(nil), canonical...) + }, + }, + { + name: "partial write", + content: func([]byte) []byte { + return []byte("{") + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + _, target, _, dataDir := newRecordArtifactFixture(t) + recordPath := workspaceRecordPath(target.Path) + canonical, err := os.ReadFile(recordPath) + if err != nil { + t.Fatalf("read canonical record: %v", err) + } + temporaryPath := filepath.Join( + filepath.Dir(recordPath), + workspaceRecordTemporaryName(filepath.Base(recordPath)), + ) + if err := os.WriteFile( + temporaryPath, + test.content(canonical), + 0o600, + ); err != nil { + t.Fatalf("write temporary record debris: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.recoverRecordAcknowledgements(); err != nil { + t.Fatalf("recover record artifacts: %v", err) + } + current, err := os.ReadFile(recordPath) + if err != nil { + t.Fatalf("read recovered canonical record: %v", err) + } + if !bytes.Equal(current, canonical) { + t.Fatal("temporary debris changed the canonical record") + } + if _, err := os.Lstat(temporaryPath); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("temporary record debris remains: %v", err) + } + }) + } +} + +func TestRecoverWorkspaceRecordRemovalArtifact(t *testing.T) { + _, target, record, dataDir := newRecordArtifactFixture(t) + recordPath := workspaceRecordPath(target.Path) + removalName := workspaceRecordRemovalName(record) + removalPath := filepath.Join(filepath.Dir(recordPath), removalName) + if err := os.Rename(recordPath, removalPath); err != nil { + t.Fatalf("isolate removal record: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.recoverRecordAcknowledgements(); err != nil { + t.Fatalf("recover record artifacts: %v", err) + } + if err := restarted.recoverRecordAcknowledgements(); err != nil { + t.Fatalf("repeat record artifact recovery: %v", err) + } + for _, path := range []string{recordPath, removalPath} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("record artifact %q remains: %v", path, err) + } + } +} + +func TestRecoverWorkspaceRecordArtifactsRejectsLegacyRemoval(t *testing.T) { + _, target, _, dataDir := newRecordArtifactFixture(t) + recordPath := workspaceRecordPath(target.Path) + legacyName := workspaceRecordRemovalLegacyPrefix + + "78787878-7878-4787-8787-787878787878" + legacyPath := filepath.Join(filepath.Dir(recordPath), legacyName) + if err := os.Rename(recordPath, legacyPath); err != nil { + t.Fatalf("isolate legacy removal record: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.recoverRecordAcknowledgements(); err == nil { + t.Fatal("recovery accepted a legacy removal artifact") + } + if _, err := os.Stat(legacyPath); err != nil { + t.Fatalf("legacy removal artifact changed: %v", err) + } +} + +func TestRemoveWorkspaceRecordLeavesNoUnixTransactionDebris(t *testing.T) { + manager, target, _, _ := newRecordArtifactFixture(t) + if err := manager.removeWorkspaceRecord(target.Path); err != nil { + t.Fatalf("remove workspace record: %v", err) + } + entries, err := os.ReadDir(filepath.Dir(target.Path)) + if err != nil { + t.Fatalf("read record bucket: %v", err) + } + for _, entry := range entries { + if entry.Name() == target.AgentID+workspaceRecordSuffix { + t.Fatalf("canonical record remains: %q", entry.Name()) + } + if _, valid := parseWorkspaceRecordArtifactName(entry.Name()); valid { + t.Fatalf("record artifact remains: %q", entry.Name()) + } + } +} + +func newRecordArtifactFixture( + t *testing.T, +) (*Manager, Workspace, workspaceRecord, string) { + t.Helper() + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + repository := filepath.Join(t.TempDir(), "repository") + if err := manager.ensureManagedRoot(); err != nil { + t.Fatalf("ensure managed root: %v", err) + } + if err := manager.ensureManagedBucket(repository); err != nil { + t.Fatalf("ensure managed bucket: %v", err) + } + target := Workspace{ + AgentID: testAgentID, + Repository: repository, + Path: filepath.Join( + manager.root, + repositoryHash(repository), + testAgentID, + ), + Branch: "record-artifact", + } + if err := manager.writeWorkspaceRecord(target, nil); err != nil { + t.Fatalf("write workspace record: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(target.Path) + if err != nil || !exists { + t.Fatalf("read workspace record: exists=%v err=%v", exists, err) + } + return manager, target, record, dataDir +} diff --git a/internal/workspace/record_owned.go b/internal/workspace/record_owned.go new file mode 100644 index 0000000..71b17cd --- /dev/null +++ b/internal/workspace/record_owned.go @@ -0,0 +1,107 @@ +package workspace + +import ( + "errors" + "fmt" + "os" +) + +func removeOwnedRecordPathIfSame( + root *os.Root, + name string, + expected *os.File, + isolatedName string, + validate func(*os.File) error, +) error { + _, err := root.Lstat(name) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + return removeOwnedRecordPath( + root, + name, + expected, + isolatedName, + validate, + nil, + ) +} + +func removeOwnedRecordPath( + root *os.Root, + name string, + expected *os.File, + isolatedName string, + validate func(*os.File) error, + afterValidation func(), +) (result error) { + if name == isolatedName { + return errors.New( + "workspace: owned record source and isolation names match", + ) + } + if validate != nil { + if err := validate(expected); err != nil { + return err + } + } + if afterValidation != nil { + afterValidation() + } + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, directory.Close()) + }() + moved, err := moveRecordFile( + directory, + expected, + name, + isolatedName, + ) + if err != nil { + return fmt.Errorf( + "workspace: isolate owned record path %q: %w", + name, + err, + ) + } + if !moved { + return fmt.Errorf( + "workspace: owned record path %q was not isolated", + name, + ) + } + if err := syncRecordDirectory(directory); err != nil { + return fmt.Errorf( + "workspace: sync isolated owned record path %q: %w", + name, + err, + ) + } + if validate != nil { + if err := validate(expected); err != nil { + return err + } + } + if err := unlinkOwnedRecordPath( + directory, + expected, + isolatedName, + ); err != nil { + return err + } + if err := syncOwnedRecordDirectoryAfterUnlink(directory); err != nil { + return fmt.Errorf( + "workspace: sync removed owned record path %q: %w", + name, + err, + ) + } + return nil +} diff --git a/internal/workspace/record_owned_other.go b/internal/workspace/record_owned_other.go new file mode 100644 index 0000000..42cf6a6 --- /dev/null +++ b/internal/workspace/record_owned_other.go @@ -0,0 +1,22 @@ +//go:build !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !windows + +package workspace + +import ( + "errors" + "os" +) + +func unlinkOwnedRecordPath( + _ *os.File, + _ *os.File, + _ string, +) error { + return errors.New( + "workspace: owned record removal is unsupported on this platform", + ) +} + +func syncOwnedRecordDirectoryAfterUnlink(directory *os.File) error { + return syncRecordDirectory(directory) +} diff --git a/internal/workspace/record_owned_unix.go b/internal/workspace/record_owned_unix.go new file mode 100644 index 0000000..d4a3aa6 --- /dev/null +++ b/internal/workspace/record_owned_unix.go @@ -0,0 +1,19 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd + +package workspace + +import ( + "os" +) + +func unlinkOwnedRecordPath( + directory *os.File, + expected *os.File, + name string, +) error { + return unlinkRecordPath(directory, expected, name) +} + +func syncOwnedRecordDirectoryAfterUnlink(directory *os.File) error { + return syncRecordDirectory(directory) +} diff --git a/internal/workspace/record_owned_windows.go b/internal/workspace/record_owned_windows.go new file mode 100644 index 0000000..98e4f91 --- /dev/null +++ b/internal/workspace/record_owned_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package workspace + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +func unlinkOwnedRecordPath( + directory *os.File, + expected *os.File, + name string, +) error { + return unlinkRecordPath(directory, expected, name) +} + +func syncOwnedRecordDirectoryAfterUnlink(directory *os.File) error { + err := syncRecordDirectory(directory) + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + return nil + } + return err +} diff --git a/internal/workspace/record_recovery_other.go b/internal/workspace/record_recovery_other.go new file mode 100644 index 0000000..a253c29 --- /dev/null +++ b/internal/workspace/record_recovery_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd + +package workspace + +import "os" + +func recoverRecordRenameDebris(_ *os.Root) error { + return nil +} diff --git a/internal/workspace/record_recovery_unix.go b/internal/workspace/record_recovery_unix.go new file mode 100644 index 0000000..b6b26c5 --- /dev/null +++ b/internal/workspace/record_recovery_unix.go @@ -0,0 +1,164 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd + +package workspace + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/google/uuid" +) + +const ( + recordInstallAliasPrefix = ".drove-install-" + recordRemoveLegacyPrefix = ".drove-remove-" +) + +type recordInstallAlias struct { + name string +} + +func recoverRecordRenameDebris(root *os.Root) (result error) { + if err := cleanupRecordDeletionNamespace(root); err != nil { + return fmt.Errorf( + "workspace: recover record deletion transactions: %w", + err, + ) + } + entries, err := readRootDirectory(root) + if err != nil { + return err + } + aliases := make([]recordInstallAlias, 0) + for _, entry := range entries { + switch { + case strings.HasPrefix(entry.Name(), recordRemoveLegacyPrefix): + return fmt.Errorf( + "workspace: legacy record removal artifact %q has no recoverable owner", + entry.Name(), + ) + case strings.HasPrefix(entry.Name(), recordInstallAliasPrefix): + if !validRecordInstallAliasName(entry.Name()) { + return fmt.Errorf( + "workspace: record install artifact %q has an invalid name", + entry.Name(), + ) + } + if !entry.Type().IsRegular() { + return fmt.Errorf( + "workspace: record install artifact %q is not a regular file", + entry.Name(), + ) + } + aliases = append(aliases, recordInstallAlias{name: entry.Name()}) + } + } + if len(aliases) == 0 { + return nil + } + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + directoryOpen := true + defer func() { + if directoryOpen { + result = errors.Join(result, directory.Close()) + } + }() + for _, alias := range aliases { + file, err := openRecordPath(directory, alias.name) + if err != nil { + return fmt.Errorf( + "workspace: open record install artifact %q: %w", + alias.name, + err, + ) + } + witness, witnessErr := findRecordInstallAliasWitness( + directory, + entries, + alias.name, + file, + ) + if witnessErr == nil { + witnessErr = unlinkLinkedRecordPath( + directory, + file, + alias.name, + witness, + ) + } + closeErr := file.Close() + if err := errors.Join(witnessErr, closeErr); err != nil { + return err + } + } + if err := syncRecordDirectory(directory); err != nil { + return err + } + directoryOpen = false + if err := directory.Close(); err != nil { + return err + } + if err := cleanupRecordDeletionNamespace(root); err != nil { + return fmt.Errorf( + "workspace: cleanup record deletion transactions: %w", + err, + ) + } + return nil +} + +func validRecordInstallAliasName(name string) bool { + raw, found := strings.CutPrefix(name, recordInstallAliasPrefix) + if !found || len(raw) != 36 { + return false + } + parsed, err := uuid.Parse(raw) + return err == nil && parsed.String() == raw +} + +func findRecordInstallAliasWitness( + directory *os.File, + entries []os.DirEntry, + aliasName string, + alias *os.File, +) (string, error) { + aliasInfo, err := alias.Stat() + if err != nil { + return "", err + } + if !aliasInfo.Mode().IsRegular() { + return "", fmt.Errorf( + "workspace: record install artifact %q is not a regular file", + aliasName, + ) + } + for _, entry := range entries { + if entry.Name() == aliasName || + strings.HasPrefix(entry.Name(), recordInstallAliasPrefix) || + !entry.Type().IsRegular() { + continue + } + candidate, err := openRecordPath(directory, entry.Name()) + if err != nil { + return "", err + } + candidateInfo, statErr := candidate.Stat() + closeErr := candidate.Close() + if err := errors.Join(statErr, closeErr); err != nil { + return "", err + } + if candidateInfo.Mode().IsRegular() && + os.SameFile(aliasInfo, candidateInfo) { + return entry.Name(), nil + } + } + return "", fmt.Errorf( + "workspace: record install artifact %q has no same-file owner", + aliasName, + ) +} diff --git a/internal/workspace/record_removal_security_test.go b/internal/workspace/record_removal_security_test.go new file mode 100644 index 0000000..35bbf97 --- /dev/null +++ b/internal/workspace/record_removal_security_test.go @@ -0,0 +1,188 @@ +package workspace + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRemoveRecordPathPreservesReplacementAfterValidation(t *testing.T) { + rootPath := t.TempDir() + recordPath := filepath.Join(rootPath, "temporary") + if err := os.WriteFile( + recordPath, + []byte("original\n"), + 0o600, + ); err != nil { + t.Fatalf("write temporary record: %v", err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open record root: %v", err) + } + defer root.Close() + expected, err := root.Open("temporary") + if err != nil { + t.Fatalf("open temporary record: %v", err) + } + defer expected.Close() + originalPath := recordPath + ".original" + + err = removeOwnedRecordPath( + root, + "temporary", + expected, + "temporary-owned-removal", + nil, + func() { + if err := os.Rename(recordPath, originalPath); err != nil { + t.Fatalf("move validated temporary record: %v", err) + } + if err := os.WriteFile( + recordPath, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("install replacement temporary record: %v", err) + } + }, + ) + if err == nil { + t.Fatal("temporary record removal accepted a replacement path") + } + assertFileContents(t, recordPath, "replacement\n") + assertFileContents(t, originalPath, "original\n") +} + +func TestRemoveWorkspaceRecordPreservesReplacementAfterValidation(t *testing.T) { + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + repository := filepath.Join(t.TempDir(), "repository") + if err := manager.ensureManagedRoot(); err != nil { + t.Fatalf("ensure managed root: %v", err) + } + if err := manager.ensureManagedBucket(repository); err != nil { + t.Fatalf("ensure managed bucket: %v", err) + } + target := Workspace{ + AgentID: testAgentID, + Repository: repository, + Path: filepath.Join( + manager.root, + repositoryHash(repository), + testAgentID, + ), + Branch: "record-removal", + } + if err := manager.writeWorkspaceRecord(target, nil); err != nil { + t.Fatalf("write workspace record: %v", err) + } + recordPath := workspaceRecordPath(target.Path) + original, err := os.ReadFile(recordPath) + if err != nil { + t.Fatalf("read workspace record: %v", err) + } + originalPath := recordPath + ".original" + + err = manager.removeWorkspaceRecordAfterValidation( + target.Path, + func() { + if err := os.Rename(recordPath, originalPath); err != nil { + t.Fatalf("move validated record: %v", err) + } + if err := os.WriteFile( + recordPath, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("install replacement record: %v", err) + } + }, + ) + if err == nil { + t.Fatal("record removal accepted a replacement sidecar") + } + assertFileContents(t, recordPath, "replacement\n") + gotOriginal, err := os.ReadFile(originalPath) + if err != nil { + t.Fatalf("read original sidecar: %v", err) + } + if string(gotOriginal) != string(original) { + t.Fatal("original sidecar changed") + } +} + +func TestAcknowledgedRecordRemovalPreservesQuarantineReplacement(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) + } + + recordPath := workspaceRecordPath(prepared.Path) + recordContents, err := os.ReadFile(recordPath) + if err != nil { + t.Fatalf("read removal record: %v", err) + } + prefix := recordAcknowledgementPrefix( + prepared.AgentID, + result.Removal.operationID, + ) + var quarantinePath, originalPath string + err = manager.removeAcknowledgedWorkspaceRecordAfterQuarantine( + result.Removal, + func() error { + entries, err := os.ReadDir(filepath.Dir(recordPath)) + if err != nil { + return err + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), prefix) { + quarantinePath = filepath.Join( + filepath.Dir(recordPath), + entry.Name(), + ) + break + } + } + if quarantinePath == "" { + return errors.New("acknowledgement quarantine is missing") + } + originalPath = quarantinePath + ".original" + if err := os.Rename(quarantinePath, originalPath); err != nil { + return err + } + return os.WriteFile(quarantinePath, recordContents, 0o600) + }, + ) + if err == nil { + t.Fatal("acknowledgement removed a replacement quarantine") + } + assertFileContents(t, originalPath, string(recordContents)) + assertFileContents(t, quarantinePath, string(recordContents)) +} diff --git a/internal/workspace/record_rename_legacy_unix.go b/internal/workspace/record_rename_legacy_unix.go index 3caf6a2..28cf2f8 100644 --- a/internal/workspace/record_rename_legacy_unix.go +++ b/internal/workspace/record_rename_legacy_unix.go @@ -30,6 +30,31 @@ func moveRecordFile( ) } +func unlinkRecordPath( + _ *os.File, + _ *os.File, + _ string, +) error { + return errors.New( + "identity-preserving record unlink is unsupported", + ) +} + +func unlinkRecordPathAfterValidation( + _ *os.File, + _ *os.File, + _ string, + _ func(), +) error { + return errors.New( + "identity-preserving record unlink is unsupported", + ) +} + +func openRecordDirectory(root *os.Root) (*os.File, error) { + return root.Open(".") +} + func syncRecordDirectory(directory *os.File) error { return directory.Sync() } diff --git a/internal/workspace/record_rename_other.go b/internal/workspace/record_rename_other.go index da6b72b..3e18538 100644 --- a/internal/workspace/record_rename_other.go +++ b/internal/workspace/record_rename_other.go @@ -30,6 +30,31 @@ func moveRecordFile( ) } +func unlinkRecordPath( + _ *os.File, + _ *os.File, + _ string, +) error { + return errors.New( + "identity-preserving record unlink is unsupported", + ) +} + +func unlinkRecordPathAfterValidation( + _ *os.File, + _ *os.File, + _ string, + _ func(), +) error { + return errors.New( + "identity-preserving record unlink is unsupported", + ) +} + +func openRecordDirectory(root *os.Root) (*os.File, error) { + return root.Open(".") +} + func syncRecordDirectory(directory *os.File) error { return directory.Sync() } diff --git a/internal/workspace/record_rename_unix.go b/internal/workspace/record_rename_unix.go index a39da44..4119eee 100644 --- a/internal/workspace/record_rename_unix.go +++ b/internal/workspace/record_rename_unix.go @@ -66,7 +66,7 @@ func renameRecordPathAfterValidation( targetName string, replace bool, afterValidation func(), -) (bool, error) { +) (installed bool, result error) { if err := verifyRecordPathIdentity( directory, expected, @@ -82,17 +82,24 @@ func renameRecordPathAfterValidation( if err := unix.Linkat(fd, sourceName, fd, alias, 0); err != nil { return false, err } + aliasFile, err := openRecordPath(directory, alias) + if err != nil { + return false, fmt.Errorf("open staged record alias: %w", err) + } aliasPresent := true defer func() { if aliasPresent { - if verifyRecordPathIdentity( - directory, - expected, - alias, - ) == nil { - _ = unix.Unlinkat(fd, alias, 0) - } + result = errors.Join( + result, + unlinkLinkedRecordPath( + directory, + aliasFile, + alias, + sourceName, + ), + ) } + result = errors.Join(result, aliasFile.Close()) }() if err := verifyRecordPathIdentity( directory, @@ -119,10 +126,11 @@ func renameRecordPathAfterValidation( err, ) } - if err := unlinkRecordPath( + if err := unlinkLinkedRecordPath( directory, expected, sourceName, + targetName, ); err != nil { return true, fmt.Errorf( "remove installed record source: %w", @@ -144,7 +152,12 @@ func renameRecordPathAfterValidation( err, ) } - if err := unix.Unlinkat(fd, alias, 0); err != nil { + if err := unlinkLinkedRecordPath( + directory, + aliasFile, + alias, + targetName, + ); err != nil { return true, err } aliasPresent = false @@ -158,10 +171,11 @@ func renameRecordPathAfterValidation( err, ) } - if err := unlinkRecordPath( + if err := unlinkLinkedRecordPath( directory, expected, sourceName, + targetName, ); err != nil { return true, fmt.Errorf( "remove installed record source: %w", @@ -171,19 +185,65 @@ func renameRecordPathAfterValidation( return true, nil } -func unlinkRecordPath( +func unlinkLinkedRecordPath( directory *os.File, expected *os.File, name string, + witnessName string, ) error { - if err := verifyRecordPathIdentity( + return unlinkLinkedRecordPathAfterValidation( directory, expected, name, + witnessName, + nil, + ) +} + +func unlinkLinkedRecordPathAfterValidation( + directory *os.File, + expected *os.File, + name string, + witnessName string, + afterValidation func(string), +) (result error) { + if err := validateRecordDeletionSourceName(name); err != nil { + return err + } + if err := lockRecordDeletionDirectory(directory); err != nil { + return err + } + defer func() { + result = errors.Join( + result, + unlockRecordDeletionDirectory(directory), + ) + }() + if err := verifyRecordPathIdentity( + directory, + expected, + witnessName, ); err != nil { + return fmt.Errorf("verify linked record witness: %w", err) + } + if err := verifyRecordPathIdentity( + directory, + expected, + name, + ); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { return err } - return unix.Unlinkat(int(directory.Fd()), name, 0) + if err := unlinkRecordPathAfterValidationLocked( + directory, + expected, + name, + afterValidation, + ); err != nil { + return fmt.Errorf("remove linked record path %q: %w", name, err) + } + return nil } func verifyRecordPathIdentity( @@ -195,6 +255,27 @@ func verifyRecordPathIdentity( if err != nil { return fmt.Errorf("inspect opened record: %w", err) } + current, err := openRecordPath(directory, name) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, current.Close()) + }() + currentInfo, err := current.Stat() + if err != nil { + return fmt.Errorf("inspect record path %q: %w", name, err) + } + if !os.SameFile(expectedInfo, currentInfo) { + return fmt.Errorf("record path %q changed identity", name) + } + return nil +} + +func openRecordPath( + directory *os.File, + name string, +) (*os.File, error) { fd, err := unix.Openat( int(directory.Fd()), name, @@ -202,26 +283,28 @@ func verifyRecordPathIdentity( 0, ) if err != nil { - return fmt.Errorf("open record path %q: %w", name, err) + return nil, fmt.Errorf("open record path %q: %w", name, err) } current := os.NewFile(uintptr(fd), name) if current == nil { _ = unix.Close(fd) - return errors.New("create record file from descriptor") + return nil, errors.New("create record file from descriptor") } - defer func() { - result = errors.Join(result, current.Close()) - }() currentInfo, err := current.Stat() if err != nil { - return fmt.Errorf("inspect record path %q: %w", name, err) + _ = current.Close() + return nil, fmt.Errorf("inspect record path %q: %w", name, err) } if !currentInfo.Mode().IsRegular() || - currentInfo.Mode()&os.ModeSymlink != 0 || - !os.SameFile(expectedInfo, currentInfo) { - return fmt.Errorf("record path %q changed identity", name) + currentInfo.Mode()&os.ModeSymlink != 0 { + _ = current.Close() + return nil, fmt.Errorf("record path %q is not a regular file", name) } - return nil + return current, nil +} + +func openRecordDirectory(root *os.Root) (*os.File, error) { + return root.Open(".") } func syncRecordDirectory(directory *os.File) error { diff --git a/internal/workspace/record_rename_unix_test.go b/internal/workspace/record_rename_unix_test.go index 46bcd3a..cbea4be 100644 --- a/internal/workspace/record_rename_unix_test.go +++ b/internal/workspace/record_rename_unix_test.go @@ -3,10 +3,15 @@ package workspace import ( + "context" "errors" "os" "path/filepath" + "runtime" + "strings" "testing" + + "golang.org/x/sys/unix" ) func TestRenameRecordRejectsSourceReplacementAfterValidation(t *testing.T) { @@ -65,4 +70,872 @@ func TestRenameRecordRejectsSourceReplacementAfterValidation(t *testing.T) { } assertFileContents(t, sourcePath, "replacement\n") assertFileContents(t, movedPath, "original\n") + entries, err := os.ReadDir(rootPath) + if err != nil { + t.Fatalf("read record directory: %v", err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".drove-install-") { + t.Fatalf("staged alias remains after rejected replacement: %q", entry.Name()) + } + } +} + +func TestUnlinkRecordPreservesReplacementAfterValidation(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + source, err := os.OpenFile( + sourcePath, + os.O_RDWR|os.O_CREATE|os.O_EXCL, + 0o600, + ) + if err != nil { + t.Fatalf("create source: %v", err) + } + defer source.Close() + if _, err := source.WriteString("original\n"); err != nil { + t.Fatalf("write source: %v", err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + movedPath := filepath.Join(rootPath, "source-original") + + err = removeOwnedRecordPath( + root, + "source", + source, + "source-owned-removal", + nil, + func() { + if err := os.Rename(sourcePath, movedPath); err != nil { + t.Fatalf("move validated source: %v", err) + } + if err := os.WriteFile( + sourcePath, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("install replacement source: %v", err) + } + }, + ) + if err == nil { + t.Fatal("unlink accepted a replacement source") + } + assertFileContents(t, sourcePath, "replacement\n") + assertFileContents(t, movedPath, "original\n") +} + +func TestUnlinkRecordPreservesReplacementAfterFinalValidation(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + source, err := os.OpenFile( + sourcePath, + os.O_RDWR|os.O_CREATE|os.O_EXCL, + 0o600, + ) + if err != nil { + t.Fatalf("create source: %v", err) + } + defer source.Close() + if _, err := source.WriteString("original\n"); err != nil { + t.Fatalf("write source: %v", err) + } + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + movedPath := filepath.Join(rootPath, "source-original") + + err = unlinkRecordPathAfterValidation( + directory, + source, + "source", + func(name string) { + path := filepath.Join(rootPath, name) + if err := os.Rename(path, movedPath); err != nil { + t.Fatalf("move validated source: %v", err) + } + if err := os.WriteFile( + path, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("install replacement source: %v", err) + } + }, + ) + if err == nil { + t.Fatal("unlink accepted a final replacement") + } + assertFileContents(t, sourcePath, "replacement\n") + assertFileContents(t, movedPath, "original\n") +} + +func TestUnlinkLinkedRecordPreservesReplacementAfterFinalValidation( + t *testing.T, +) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("original\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + targetPath := filepath.Join(rootPath, "target") + if err := os.Link(sourcePath, targetPath); err != nil { + t.Fatalf("link target witness: %v", err) + } + expected, err := os.Open(sourcePath) + if err != nil { + t.Fatalf("open source: %v", err) + } + defer expected.Close() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + var replacementPath, movedPath string + + err = unlinkLinkedRecordPathAfterValidation( + directory, + expected, + "source", + "target", + func(name string) { + replacementPath = filepath.Join(rootPath, name) + movedPath = replacementPath + ".original" + if err := os.Rename(replacementPath, movedPath); err != nil { + t.Fatalf("move validated linked source: %v", err) + } + if err := os.WriteFile( + replacementPath, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("install replacement linked source: %v", err) + } + }, + ) + if err == nil { + t.Fatal("linked unlink accepted a final replacement") + } + assertFileContents(t, replacementPath, "replacement\n") + assertFileContents(t, movedPath, "original\n") + assertFileContents(t, targetPath, "original\n") +} + +func TestRecoverRecordDeletionRestoresReplacement(t *testing.T) { + requireAtomicRecordDeletion(t) + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("original\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + expected, err := os.Open(sourcePath) + if err != nil { + t.Fatalf("open source: %v", err) + } + defer expected.Close() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + transactionName := "12121212-1212-4121-8121-121212121212" + transaction, err := createRecordDeletionTransaction( + namespace, + transactionName, + "source", + ) + if err != nil { + t.Fatalf("create deletion transaction: %v", err) + } + if err := unix.Linkat( + int(directory.Fd()), + "source", + int(transaction.Fd()), + recordDeletionWitness, + 0, + ); err != nil { + t.Fatalf("link deletion witness: %v", err) + } + if err := verifyRecordPathIdentity( + transaction, + expected, + recordDeletionWitness, + ); err != nil { + t.Fatalf("verify deletion witness: %v", err) + } + if err := markRecordDeletionValidated(transaction); err != nil { + t.Fatalf("mark deletion validated: %v", err) + } + movedPath := filepath.Join(rootPath, "source-original") + if err := os.Rename(sourcePath, movedPath); err != nil { + t.Fatalf("move validated source: %v", err) + } + if err := os.WriteFile( + sourcePath, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("install replacement source: %v", err) + } + if err := unix.Renameat( + int(directory.Fd()), + "source", + int(transaction.Fd()), + recordDeletionCandidate, + ); err != nil { + t.Fatalf("move replacement candidate: %v", err) + } + if err := transaction.Close(); err != nil { + t.Fatalf("close deletion transaction: %v", err) + } + if err := namespace.Close(); err != nil { + t.Fatalf("close deletion namespace: %v", err) + } + + namespace, err = openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("reopen deletion namespace: %v", err) + } + defer namespace.Close() + if err := recoverRecordDeletionTransactions( + directory, + namespace, + ); err != nil { + t.Fatalf("recover deletion transaction: %v", err) + } + assertFileContents(t, sourcePath, "replacement\n") + assertFileContents(t, movedPath, "original\n") + entries, err := readRecordDeletionDirectory(namespace) + if err != nil { + t.Fatalf("read recovered deletion namespace: %v", err) + } + if len(entries) != 0 { + t.Fatalf("recovered deletion artifacts remain: %v", entries) + } +} + +func TestRecoverRecordDeletionCompletesValidatedCleanup(t *testing.T) { + requireAtomicRecordDeletion(t) + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("original\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + defer namespace.Close() + transactionName := "34343434-3434-4343-8343-343434343434" + transaction, err := createRecordDeletionTransaction( + namespace, + transactionName, + "source", + ) + if err != nil { + t.Fatalf("create deletion transaction: %v", err) + } + if err := unix.Linkat( + int(directory.Fd()), + "source", + int(transaction.Fd()), + recordDeletionWitness, + 0, + ); err != nil { + t.Fatalf("link deletion witness: %v", err) + } + if err := markRecordDeletionValidated(transaction); err != nil { + t.Fatalf("mark deletion validated: %v", err) + } + if err := unix.Renameat( + int(directory.Fd()), + "source", + int(transaction.Fd()), + recordDeletionCandidate, + ); err != nil { + t.Fatalf("move deletion candidate: %v", err) + } + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionCandidate, + ); err != nil { + t.Fatalf("remove deletion candidate: %v", err) + } + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionWitness, + ); err != nil { + t.Fatalf("remove deletion witness: %v", err) + } + if err := transaction.Sync(); err != nil { + t.Fatalf("sync interrupted deletion transaction: %v", err) + } + if err := transaction.Close(); err != nil { + t.Fatalf("close deletion transaction: %v", err) + } + + if err := recoverRecordDeletionTransactions( + directory, + namespace, + ); err != nil { + t.Fatalf("recover validated deletion cleanup: %v", err) + } + if _, err := os.Lstat(sourcePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("deleted source returned: %v", err) + } + entries, err := readRecordDeletionDirectory(namespace) + if err != nil { + t.Fatalf("read recovered deletion namespace: %v", err) + } + if len(entries) != 0 { + t.Fatalf("recovered deletion artifacts remain: %v", entries) + } +} + +func TestRecoverRecordDeletionRemovesEmptyTransaction(t *testing.T) { + requireAtomicRecordDeletion(t) + rootPath := t.TempDir() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + defer namespace.Close() + transactionName := "56565656-5656-4565-8565-565656565656" + if err := unix.Mkdirat( + int(namespace.Fd()), + transactionName, + 0o700, + ); err != nil { + t.Fatalf("create empty deletion transaction: %v", err) + } + if err := namespace.Sync(); err != nil { + t.Fatalf("sync empty deletion transaction: %v", err) + } + + if err := recoverRecordDeletionTransactions( + directory, + namespace, + ); err != nil { + t.Fatalf("recover empty deletion transaction: %v", err) + } + entries, err := readRecordDeletionDirectory(namespace) + if err != nil { + t.Fatalf("read recovered deletion namespace: %v", err) + } + if len(entries) != 0 { + t.Fatalf("empty deletion transaction remains: %v", entries) + } +} + +func TestRecoverRecordDeletionRemovesIsolatedEmptyTransaction(t *testing.T) { + requireAtomicRecordDeletion(t) + for _, suffix := range []string{"", ".rename"} { + t.Run(suffix, func(t *testing.T) { + rootPath := t.TempDir() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + defer namespace.Close() + name := recordDeletionTransactionIsolationPrefix + + "67676767-6767-4767-8767-676767676767" + suffix + if err := unix.Mkdirat( + int(namespace.Fd()), + name, + 0o700, + ); err != nil { + t.Fatalf("create isolated deletion transaction: %v", err) + } + if err := namespace.Sync(); err != nil { + t.Fatalf("sync isolated deletion transaction: %v", err) + } + + if err := recoverRecordDeletionTransactions( + directory, + namespace, + ); err != nil { + t.Fatalf("recover isolated deletion transaction: %v", err) + } + entries, err := readRecordDeletionDirectory(namespace) + if err != nil { + t.Fatalf("read recovered deletion namespace: %v", err) + } + if len(entries) != 0 { + t.Fatalf( + "isolated deletion transaction remains: %v", + entries, + ) + } + }) + } +} + +func TestRecoverRecordDeletionRejectsNonemptyIsolatedTransaction(t *testing.T) { + requireAtomicRecordDeletion(t) + rootPath := t.TempDir() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + defer namespace.Close() + name := recordDeletionTransactionIsolationPrefix + + "78787878-7878-4787-8787-787878787878" + if err := unix.Mkdirat(int(namespace.Fd()), name, 0o700); err != nil { + t.Fatalf("create isolated deletion transaction: %v", err) + } + sentinel := filepath.Join( + rootPath, + recordDeletionNamespace, + name, + "must-remain", + ) + if err := os.WriteFile(sentinel, []byte("sentinel\n"), 0o600); err != nil { + t.Fatalf("write isolated transaction sentinel: %v", err) + } + if err := namespace.Sync(); err != nil { + t.Fatalf("sync isolated deletion transaction: %v", err) + } + + if err := recoverRecordDeletionTransactions( + directory, + namespace, + ); err == nil { + t.Fatal("recovery accepted a nonempty isolated deletion transaction") + } + assertFileContents(t, sentinel, "sentinel\n") +} + +func TestRemoveSettledRecordDeletionTransactionRejectsReplacement( + t *testing.T, +) { + rootPath := t.TempDir() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open record directory: %v", err) + } + defer directory.Close() + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + defer namespace.Close() + transactionName := "45454545-4545-4454-8454-454545454545" + if err := unix.Mkdirat( + int(namespace.Fd()), + transactionName, + 0o700, + ); err != nil { + t.Fatalf("create deletion transaction: %v", err) + } + transactionPath := filepath.Join( + rootPath, + recordDeletionNamespace, + transactionName, + ) + originalInfo, err := os.Stat(transactionPath) + if err != nil { + t.Fatalf("inspect deletion transaction: %v", err) + } + originalPath := transactionPath + "-original" + var replacementInfo os.FileInfo + + err = removeSettledRecordDeletionTransactionAfterValidation( + namespace, + transactionName, + originalInfo, + func() { + if err := os.Rename(transactionPath, originalPath); err != nil { + t.Fatalf("move validated deletion transaction: %v", err) + } + if err := os.Mkdir(transactionPath, 0o700); err != nil { + t.Fatalf("create replacement deletion transaction: %v", err) + } + replacementInfo, err = os.Stat(transactionPath) + if err != nil { + t.Fatalf("inspect replacement deletion transaction: %v", err) + } + }, + ) + if err == nil { + t.Fatal("removed a replacement deletion transaction") + } + currentInfo, err := os.Stat(transactionPath) + if err != nil { + t.Fatalf("replacement deletion transaction was removed: %v", err) + } + if !os.SameFile(replacementInfo, currentInfo) { + t.Fatal("replacement deletion transaction changed identity") + } + displacedInfo, err := os.Stat(originalPath) + if err != nil { + t.Fatalf("validated deletion transaction was removed: %v", err) + } + if !os.SameFile(originalInfo, displacedInfo) { + t.Fatal("validated deletion transaction changed identity") + } +} + +func TestCleanupRecordDeletionNamespaceRejectsReplacementAfterValidation( + t *testing.T, +) { + rootPath := t.TempDir() + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open record root: %v", err) + } + defer root.Close() + directory, err := openRecordDirectory(root) + if err != nil { + t.Fatalf("open record directory: %v", err) + } + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + if err := errors.Join(namespace.Close(), directory.Close()); err != nil { + t.Fatalf("close deletion namespace: %v", err) + } + namespacePath := filepath.Join(rootPath, recordDeletionNamespace) + originalInfo, err := os.Stat(namespacePath) + if err != nil { + t.Fatalf("inspect deletion namespace: %v", err) + } + originalPath := namespacePath + "-original" + var replacementInfo os.FileInfo + + err = cleanupRecordDeletionNamespaceAfterValidation(root, func() { + if err := os.Rename(namespacePath, originalPath); err != nil { + t.Fatalf("move validated deletion namespace: %v", err) + } + if err := os.Mkdir(namespacePath, 0o700); err != nil { + t.Fatalf("create replacement deletion namespace: %v", err) + } + replacementInfo, err = os.Stat(namespacePath) + if err != nil { + t.Fatalf("inspect replacement deletion namespace: %v", err) + } + }) + if err == nil { + t.Fatal("removed a replacement deletion namespace") + } + currentInfo, err := os.Stat(namespacePath) + if err != nil { + t.Fatalf("replacement deletion namespace was removed: %v", err) + } + if !os.SameFile(replacementInfo, currentInfo) { + t.Fatal("replacement deletion namespace changed identity") + } + displacedInfo, err := os.Stat(originalPath) + if err != nil { + t.Fatalf("validated deletion namespace was removed: %v", err) + } + if !os.SameFile(originalInfo, displacedInfo) { + t.Fatal("validated deletion namespace changed identity") + } +} + +func TestCleanupRecordDeletionNamespaceRejectsIsolationDebris(t *testing.T) { + rootPath := t.TempDir() + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open record root: %v", err) + } + defer root.Close() + name := recordDeletionNamespaceIsolationPrefix + + "23232323-2323-4232-8232-232323232323" + if err := root.Mkdir(name, 0o700); err != nil { + t.Fatalf("create deletion namespace debris: %v", err) + } + + err = cleanupRecordDeletionNamespace(root) + if err == nil || !strings.Contains(err.Error(), "no recoverable owner") { + t.Fatalf("cleanup deletion namespace debris error = %v", err) + } + info, err := root.Stat(name) + if err != nil { + t.Fatalf("deletion namespace debris was removed: %v", err) + } + if !info.IsDir() { + t.Fatal("deletion namespace debris changed type") + } +} + +func TestRecoverRecordRenameDebrisSettlesDeletionNamespace(t *testing.T) { + requireAtomicRecordDeletion(t) + rootPath := t.TempDir() + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open record root: %v", err) + } + defer root.Close() + directory, err := openRecordDirectory(root) + if err != nil { + t.Fatalf("open record directory: %v", err) + } + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + transaction, err := createRecordDeletionTransaction( + namespace, + "67676767-6767-4767-8767-676767676767", + "interrupted-record", + ) + if err != nil { + t.Fatalf("create deletion transaction: %v", err) + } + if err := errors.Join( + transaction.Close(), + namespace.Close(), + directory.Close(), + ); err != nil { + t.Fatalf("close deletion transaction: %v", err) + } + + if err := recoverRecordRenameDebris(root); err != nil { + t.Fatalf("recover deletion namespace: %v", err) + } + if _, err := root.Lstat(recordDeletionNamespace); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("deletion namespace remains: %v", err) + } +} + +func TestRemoveRecoversRecordDeletionNamespaceBeforeStatus(t *testing.T) { + requireAtomicRecordDeletion(t) + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + root, err := os.OpenRoot(prepared.Path) + if err != nil { + t.Fatalf("open workspace root: %v", err) + } + directory, err := openRecordDirectory(root) + if err != nil { + t.Fatalf("open workspace directory: %v", err) + } + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open workspace deletion namespace: %v", err) + } + transaction, err := createRecordDeletionTransaction( + namespace, + "89898989-8989-4898-8989-898989898989", + "interrupted-marker", + ) + if err != nil { + t.Fatalf("create marker deletion transaction: %v", err) + } + if err := errors.Join( + transaction.Close(), + namespace.Close(), + directory.Close(), + root.Close(), + ); err != nil { + t.Fatalf("close marker deletion transaction: %v", err) + } + status := runGit( + t, + prepared.Path, + "status", + "--porcelain=v1", + "--untracked-files=all", + ) + if !strings.Contains(status, recordDeletionNamespace) { + t.Fatalf("Git status did not observe deletion namespace: %q", status) + } + + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + false, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove after deletion recovery = %+v, %v", result, err) + } + if err := manager.AcknowledgeRemoval(result.Removal); err != nil { + t.Fatalf("acknowledge removal: %v", err) + } +} + +func TestUnlinkLinkedRecordPathAcceptsMissingSourceWithMatchingWitness( + t *testing.T, +) { + requireAtomicRecordDeletion(t) + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("record\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + targetPath := filepath.Join(rootPath, "target") + if err := os.Link(sourcePath, targetPath); err != nil { + t.Fatalf("link target witness: %v", err) + } + expected, err := os.Open(sourcePath) + if err != nil { + t.Fatalf("open source: %v", err) + } + defer expected.Close() + if err := os.Remove(sourcePath); err != nil { + t.Fatalf("remove source: %v", err) + } + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + defer directory.Close() + + if err := unlinkLinkedRecordPath( + directory, + expected, + "source", + "target", + ); err != nil { + t.Fatalf("accept missing linked source: %v", err) + } + assertFileContents(t, targetPath, "record\n") +} + +func TestRecoverRecordInstallAliasUsesSameFileWitness(t *testing.T) { + requireAtomicRecordDeletion(t) + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("record\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + aliasName := recordInstallAliasPrefix + + "78787878-7878-4787-8787-787878787878" + aliasPath := filepath.Join(rootPath, aliasName) + if err := os.Link(sourcePath, aliasPath); err != nil { + t.Fatalf("link install alias: %v", err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + + if err := recoverRecordRenameDebris(root); err != nil { + t.Fatalf("recover install alias: %v", err) + } + if err := recoverRecordRenameDebris(root); err != nil { + t.Fatalf("repeat install alias recovery: %v", err) + } + assertFileContents(t, sourcePath, "record\n") + if _, err := os.Lstat(aliasPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("install alias remains: %v", err) + } + if _, err := root.Lstat(recordDeletionNamespace); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("record deletion namespace remains: %v", err) + } +} + +func TestRecoverRecordInstallAliasRejectsMissingWitness(t *testing.T) { + rootPath := t.TempDir() + aliasName := recordInstallAliasPrefix + + "89898989-8989-4898-8989-898989898989" + aliasPath := filepath.Join(rootPath, aliasName) + if err := os.WriteFile(aliasPath, []byte("record\n"), 0o600); err != nil { + t.Fatalf("write install alias: %v", err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + + if err := recoverRecordRenameDebris(root); err == nil { + t.Fatal("recovery accepted an install alias without an owner") + } + assertFileContents(t, aliasPath, "record\n") +} + +func TestRecoverRecordRenameDebrisRejectsLegacyRemovalBeforeMutation( + t *testing.T, +) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("record\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + aliasName := recordInstallAliasPrefix + + "90909090-9090-4090-8090-909090909090" + aliasPath := filepath.Join(rootPath, aliasName) + if err := os.Link(sourcePath, aliasPath); err != nil { + t.Fatalf("link install alias: %v", err) + } + legacyName := recordRemoveLegacyPrefix + + "67676767-6767-4767-8767-676767676767" + legacyPath := filepath.Join(rootPath, legacyName) + if err := os.WriteFile(legacyPath, []byte("legacy\n"), 0o600); err != nil { + t.Fatalf("write legacy removal artifact: %v", err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + + if err := recoverRecordRenameDebris(root); err == nil { + t.Fatal("recovery accepted a legacy removal artifact") + } + assertFileContents(t, aliasPath, "record\n") + assertFileContents(t, legacyPath, "legacy\n") +} + +func requireAtomicRecordDeletion(t *testing.T) { + t.Helper() + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skip("atomic no-replace directory rename is unavailable") + } } diff --git a/internal/workspace/record_rename_windows.go b/internal/workspace/record_rename_windows.go index 67d1b09..da72f98 100644 --- a/internal/workspace/record_rename_windows.go +++ b/internal/workspace/record_rename_windows.go @@ -25,7 +25,7 @@ func renameRecordFile( recordName string, replace bool, ) (installed bool, result error) { - renaming, err := openRecordForRename(directory, temporaryName) + renaming, err := openRecordForMutation(directory, temporaryName) if err != nil { return false, err } @@ -53,11 +53,6 @@ func renameRecordFile( if err != nil { return installed, err } - if err := windows.FlushFileBuffers( - windows.Handle(temporary.Fd()), - ); err != nil { - return true, fmt.Errorf("flush renamed record: %w", err) - } return true, nil } @@ -76,6 +71,101 @@ func moveRecordFile( ) } +func unlinkRecordPath( + directory *os.File, + expected *os.File, + name string, +) error { + return unlinkRecordPathAfterValidation( + directory, + expected, + name, + nil, + ) +} + +func unlinkRecordPathAfterValidation( + directory *os.File, + expected *os.File, + name string, + afterValidation func(), +) (result error) { + deleting, err := openRecordForMutation(directory, name) + if err != nil { + return err + } + deletingOpen := true + defer func() { + if deletingOpen { + result = errors.Join(result, deleting.Close()) + } + }() + expectedInfo, err := expected.Stat() + if err != nil { + return fmt.Errorf("inspect opened record: %w", err) + } + deletingInfo, err := deleting.Stat() + if err != nil { + return fmt.Errorf("inspect record path %q: %w", name, err) + } + if !deletingInfo.Mode().IsRegular() || + !os.SameFile(expectedInfo, deletingInfo) { + return fmt.Errorf("record path %q changed identity", name) + } + if afterValidation != nil { + afterValidation() + } + + current, err := openRecordForIdentity(directory, name) + if err != nil { + return fmt.Errorf("reopen record path %q: %w", name, err) + } + currentInfo, statErr := current.Stat() + closeErr := current.Close() + if err := errors.Join(statErr, closeErr); err != nil { + return fmt.Errorf("reinspect record path %q: %w", name, err) + } + if !currentInfo.Mode().IsRegular() || + !os.SameFile(expectedInfo, currentInfo) { + return fmt.Errorf("record path %q changed identity", name) + } + + disposition := uint32( + windows.FILE_DISPOSITION_DELETE | + windows.FILE_DISPOSITION_POSIX_SEMANTICS | + windows.FILE_DISPOSITION_FORCE_IMAGE_SECTION_CHECK, + ) + var status windows.IO_STATUS_BLOCK + if err := windows.NtSetInformationFile( + windows.Handle(deleting.Fd()), + &status, + (*byte)(unsafe.Pointer(&disposition)), + uint32(unsafe.Sizeof(disposition)), + windows.FileDispositionInformationEx, + ); err != nil { + return fmt.Errorf("remove record path %q: %w", name, err) + } + if err := deleting.Close(); err != nil { + deletingOpen = false + return fmt.Errorf("close removed record path %q: %w", name, err) + } + deletingOpen = false + + current, err = openRecordForIdentity(directory, name) + if err == nil { + closeErr := current.Close() + return errors.Join( + fmt.Errorf("record path %q remains after removal", name), + closeErr, + ) + } + if err != windows.STATUS_OBJECT_NAME_NOT_FOUND && + err != windows.STATUS_OBJECT_PATH_NOT_FOUND { + return fmt.Errorf("verify removed record path %q: %w", name, err) + } + return nil +} + func renameWindowsHandle( directory *os.File, renaming *os.File, @@ -111,7 +201,32 @@ func renameWindowsHandle( return true, nil } -func openRecordForRename(directory *os.File, name string) (*os.File, error) { +func openRecordForMutation(directory *os.File, name string) (*os.File, error) { + return openRecordWithAccess( + directory, + name, + windows.FILE_READ_ATTRIBUTES|windows.DELETE|windows.SYNCHRONIZE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + ) +} + +func openRecordForIdentity(directory *os.File, name string) (*os.File, error) { + return openRecordWithAccess( + directory, + name, + windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, + windows.FILE_SHARE_READ| + windows.FILE_SHARE_WRITE| + windows.FILE_SHARE_DELETE, + ) +} + +func openRecordWithAccess( + directory *os.File, + name string, + access uint32, + share uint32, +) (*os.File, error) { objectName, err := windows.NewNTUnicodeString(name) if err != nil { return nil, err @@ -127,12 +242,12 @@ func openRecordForRename(directory *os.File, name string) (*os.File, error) { ) err = windows.NtCreateFile( &handle, - windows.FILE_READ_ATTRIBUTES|windows.DELETE|windows.SYNCHRONIZE, + access, attributes, &status, nil, windows.FILE_ATTRIBUTE_NORMAL, - windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + share, windows.FILE_OPEN, windows.FILE_SYNCHRONOUS_IO_NONALERT| windows.FILE_NON_DIRECTORY_FILE| @@ -151,6 +266,82 @@ func openRecordForRename(directory *os.File, name string) (*os.File, error) { return file, nil } -func syncRecordDirectory(*os.File) error { +func openRecordDirectory(root *os.Root) (_ *os.File, result error) { + anchor, err := root.Open(".") + if err != nil { + return nil, err + } + defer func() { + if anchor != nil { + result = errors.Join(result, anchor.Close()) + } + }() + anchorInfo, err := anchor.Stat() + if err != nil { + return nil, fmt.Errorf("inspect directory anchor: %w", err) + } + objectName, err := windows.NewNTUnicodeString("") + if err != nil { + return nil, err + } + attributes := &windows.OBJECT_ATTRIBUTES{ + Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})), + RootDirectory: windows.Handle(anchor.Fd()), + ObjectName: objectName, + } + var ( + handle windows.Handle + status windows.IO_STATUS_BLOCK + ) + err = windows.NtCreateFile( + &handle, + windows.FILE_GENERIC_READ|windows.FILE_GENERIC_WRITE, + attributes, + &status, + nil, + windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_OPEN, + windows.FILE_SYNCHRONOUS_IO_NONALERT| + windows.FILE_DIRECTORY_FILE| + windows.FILE_OPEN_REPARSE_POINT, + 0, + 0, + ) + if err != nil { + return nil, fmt.Errorf("reopen writable directory handle: %w", err) + } + directory := os.NewFile(uintptr(handle), ".") + if directory == nil { + _ = windows.CloseHandle(handle) + return nil, errors.New("create directory file from Windows handle") + } + openedInfo, err := directory.Stat() + if err != nil { + return nil, errors.Join( + fmt.Errorf("inspect writable directory handle: %w", err), + directory.Close(), + ) + } + if !openedInfo.IsDir() || !os.SameFile(anchorInfo, openedInfo) { + return nil, errors.Join( + errors.New("writable directory handle changed identity"), + directory.Close(), + ) + } + closeErr := anchor.Close() + anchor = nil + if closeErr != nil { + return nil, errors.Join(closeErr, directory.Close()) + } + return directory, nil +} + +func syncRecordDirectory(directory *os.File) error { + if err := windows.FlushFileBuffers( + windows.Handle(directory.Fd()), + ); err != nil { + return fmt.Errorf("flush directory metadata: %w", err) + } return nil } diff --git a/internal/workspace/record_rename_windows_test.go b/internal/workspace/record_rename_windows_test.go index 44bdc1d..61f56cf 100644 --- a/internal/workspace/record_rename_windows_test.go +++ b/internal/workspace/record_rename_windows_test.go @@ -8,6 +8,111 @@ import ( "testing" ) +func TestOpenRecordDirectoryProvidesSyncableWindowsHandle(t *testing.T) { + root, err := os.OpenRoot(t.TempDir()) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + directory, err := openRecordDirectory(root) + if err != nil { + t.Fatalf("open syncable directory: %v", err) + } + defer directory.Close() + if err := syncRecordDirectory(directory); err != nil { + t.Fatalf("sync directory handle: %v", err) + } +} + +func TestWindowsRenameHandlesBlockCanonicalReplacement(t *testing.T) { + for _, test := range []struct { + name string + make func(string) error + open func(*os.File, string) (*os.File, error) + }{ + { + name: "record", + make: func(path string) error { + return os.WriteFile(path, []byte("record\n"), 0o600) + }, + open: openRecordForMutation, + }, + { + name: "directory", + make: func(path string) error { + return os.Mkdir(path, 0o700) + }, + open: openDirectoryForRename, + }, + } { + t.Run(test.name, func(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := test.make(sourcePath); err != nil { + t.Fatalf("create source: %v", err) + } + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open parent directory: %v", err) + } + defer directory.Close() + renaming, err := test.open(directory, "source") + if err != nil { + t.Fatalf("open source for rename: %v", err) + } + targetPath := filepath.Join(rootPath, "moved") + if err := os.Rename(sourcePath, targetPath); err == nil { + _ = renaming.Close() + t.Fatal("canonical source was renamed while protected") + } + if err := renaming.Close(); err != nil { + t.Fatalf("close protected source: %v", err) + } + if err := os.Rename(sourcePath, targetPath); err != nil { + t.Fatalf("rename source after releasing protection: %v", err) + } + }) + } +} + +func TestUnlinkRecordUsesCompatibleIdentityHandle(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("record\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + expected, err := root.Open("source") + if err != nil { + t.Fatalf("open source: %v", err) + } + directory, err := openRecordDirectory(root) + if err != nil { + t.Fatalf("open syncable parent directory: %v", err) + } + defer directory.Close() + if err := syncRecordDirectory(directory); err != nil { + t.Fatalf("sync parent before unlink: %v", err) + } + + if err := unlinkRecordPath(directory, expected, "source"); err != nil { + t.Fatalf("unlink source while mutation handle is held: %v", err) + } + if _, err := os.Lstat(sourcePath); !os.IsNotExist(err) { + t.Fatalf("source remains while identity handle is held: %v", err) + } + if err := syncOwnedRecordDirectoryAfterUnlink(directory); err != nil { + t.Fatalf("sync parent after unlink: %v", err) + } + if err := expected.Close(); err != nil { + t.Fatalf("close identity handle: %v", err) + } +} + func TestInstallAndReplaceWorkspaceRecordOnWindows(t *testing.T) { manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { @@ -117,6 +222,7 @@ func TestNoReplaceRenamesPreserveWindowsTargets(t *testing.T) { directory, sourceInfo, "source-dir", + "source-dir-isolated", "target-dir", ) closeErr = directory.Close() @@ -180,3 +286,44 @@ func TestRenameWindowsHandleAcceptsReadOnlyFile(t *testing.T) { } assertFileContents(t, filepath.Join(rootPath, "target"), "source\n") } + +func TestMoveRecordFileAcceptsReadOnlySourceHandle(t *testing.T) { + rootPath := t.TempDir() + if err := os.WriteFile( + filepath.Join(rootPath, "source"), + []byte("source\n"), + 0o600, + ); err != nil { + t.Fatalf("write source: %v", err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open root: %v", err) + } + defer root.Close() + source, err := root.Open("source") + if err != nil { + t.Fatalf("open read-only source: %v", err) + } + defer source.Close() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open directory: %v", err) + } + moved, moveErr := moveRecordFile( + directory, + source, + "source", + "target", + ) + closeErr := directory.Close() + if moveErr != nil || !moved || closeErr != nil { + t.Fatalf( + "move read-only source = moved %v, move %v, close %v", + moved, + moveErr, + closeErr, + ) + } + assertFileContents(t, filepath.Join(rootPath, "target"), "source\n") +} diff --git a/internal/workspace/record_unlink_namespace_legacy_unix_test.go b/internal/workspace/record_unlink_namespace_legacy_unix_test.go new file mode 100644 index 0000000..dfc2e6a --- /dev/null +++ b/internal/workspace/record_unlink_namespace_legacy_unix_test.go @@ -0,0 +1,54 @@ +//go:build dragonfly || freebsd || netbsd || openbsd + +package workspace + +import ( + "os" + "strings" + "testing" +) + +func TestCleanupRecordDeletionNamespaceFailsWithoutNoReplaceRename( + t *testing.T, +) { + rootPath := t.TempDir() + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open record root: %v", err) + } + defer root.Close() + directory, err := openRecordDirectory(root) + if err != nil { + t.Fatalf("open record directory: %v", err) + } + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + expected, err := namespace.Stat() + if err != nil { + t.Fatalf("inspect deletion namespace: %v", err) + } + if err := namespace.Close(); err != nil { + t.Fatalf("close deletion namespace: %v", err) + } + if err := directory.Close(); err != nil { + t.Fatalf("close record directory: %v", err) + } + + err = cleanupRecordDeletionNamespace(root) + if err == nil || + !strings.Contains( + err.Error(), + "atomic no-replace directory rename is unsupported", + ) { + t.Fatalf("cleanup without no-replace rename error = %v", err) + } + current, err := root.Stat(recordDeletionNamespace) + if err != nil { + t.Fatalf("deletion namespace was removed: %v", err) + } + if !os.SameFile(expected, current) { + t.Fatal("deletion namespace changed identity") + } +} diff --git a/internal/workspace/record_unlink_namespace_linux_darwin_test.go b/internal/workspace/record_unlink_namespace_linux_darwin_test.go new file mode 100644 index 0000000..698fc73 --- /dev/null +++ b/internal/workspace/record_unlink_namespace_linux_darwin_test.go @@ -0,0 +1,139 @@ +//go:build darwin || linux + +package workspace + +import ( + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +func TestCleanupRecordDeletionNamespaceWaitsForParentLock(t *testing.T) { + rootPath := t.TempDir() + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open record root: %v", err) + } + defer root.Close() + directory, err := openRecordDirectory(root) + if err != nil { + t.Fatalf("open record directory: %v", err) + } + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + t.Fatalf("open deletion namespace: %v", err) + } + if err := namespace.Close(); err != nil { + t.Fatalf("close deletion namespace: %v", err) + } + if err := directory.Close(); err != nil { + t.Fatalf("close record directory: %v", err) + } + + lockHolder, err := os.Open(filepath.Clean(rootPath)) + if err != nil { + t.Fatalf("open parent lock holder: %v", err) + } + defer lockHolder.Close() + if err := unix.Flock(int(lockHolder.Fd()), unix.LOCK_EX); err != nil { + t.Fatalf("lock record directory: %v", err) + } + locked := true + defer func() { + if locked { + _ = unix.Flock(int(lockHolder.Fd()), unix.LOCK_UN) + } + }() + + done := make(chan error, 1) + go func() { + done <- cleanupRecordDeletionNamespace(root) + }() + select { + case err := <-done: + t.Fatalf("cleanup bypassed parent lock: %v", err) + case <-time.After(100 * time.Millisecond): + } + if err := unix.Flock(int(lockHolder.Fd()), unix.LOCK_UN); err != nil { + t.Fatalf("unlock record directory: %v", err) + } + locked = false + select { + case err := <-done: + if err != nil { + t.Fatalf("cleanup after parent unlock: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("cleanup remained blocked after parent unlock") + } +} + +func TestUnlinkLinkedRecordPathLocksParentBeforeSourceCheck(t *testing.T) { + rootPath := t.TempDir() + sourcePath := filepath.Join(rootPath, "source") + if err := os.WriteFile(sourcePath, []byte("record\n"), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + targetPath := filepath.Join(rootPath, "target") + if err := os.Link(sourcePath, targetPath); err != nil { + t.Fatalf("link target witness: %v", err) + } + expected, err := os.Open(sourcePath) + if err != nil { + t.Fatalf("open source: %v", err) + } + defer expected.Close() + directory, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open record directory: %v", err) + } + defer directory.Close() + lockHolder, err := os.Open(rootPath) + if err != nil { + t.Fatalf("open parent lock holder: %v", err) + } + defer lockHolder.Close() + if err := unix.Flock(int(lockHolder.Fd()), unix.LOCK_EX); err != nil { + t.Fatalf("lock record directory: %v", err) + } + locked := true + defer func() { + if locked { + _ = unix.Flock(int(lockHolder.Fd()), unix.LOCK_UN) + } + }() + + done := make(chan error, 1) + go func() { + done <- unlinkLinkedRecordPath( + directory, + expected, + "source", + "target", + ) + }() + select { + case err := <-done: + t.Fatalf("linked unlink bypassed parent lock: %v", err) + case <-time.After(100 * time.Millisecond): + } + if err := os.Remove(sourcePath); err != nil { + t.Fatalf("remove source while unlink waits: %v", err) + } + if err := unix.Flock(int(lockHolder.Fd()), unix.LOCK_UN); err != nil { + t.Fatalf("unlock record directory: %v", err) + } + locked = false + select { + case err := <-done: + if err != nil { + t.Fatalf("accept missing linked source after lock: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("linked unlink remained blocked after parent unlock") + } + assertFileContents(t, targetPath, "record\n") +} diff --git a/internal/workspace/record_unlink_namespace_other.go b/internal/workspace/record_unlink_namespace_other.go new file mode 100644 index 0000000..9d4a860 --- /dev/null +++ b/internal/workspace/record_unlink_namespace_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd + +package workspace + +import "os" + +func cleanupRecordDeletionNamespace(_ *os.Root) error { + return nil +} diff --git a/internal/workspace/record_unlink_namespace_unix.go b/internal/workspace/record_unlink_namespace_unix.go new file mode 100644 index 0000000..5d7d799 --- /dev/null +++ b/internal/workspace/record_unlink_namespace_unix.go @@ -0,0 +1,170 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd + +package workspace + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/google/uuid" + "golang.org/x/sys/unix" +) + +func cleanupRecordDeletionNamespace(root *os.Root) (result error) { + return cleanupRecordDeletionNamespaceAfterValidation(root, nil) +} + +func cleanupRecordDeletionNamespaceAfterValidation( + root *os.Root, + afterValidation func(), +) (result error) { + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, directory.Close()) + }() + if err := lockRecordDeletionDirectory(directory); err != nil { + return err + } + defer func() { + result = errors.Join( + result, + unlockRecordDeletionDirectory(directory), + ) + }() + if err := rejectRecordDeletionNamespaceIsolationDebris(directory); err != nil { + return err + } + namespace, err := openRecordDeletionDirectoryAt( + directory, + recordDeletionNamespace, + ) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf( + "open private record deletion namespace for cleanup: %w", + err, + ) + } + namespaceOpen := true + defer func() { + if namespaceOpen { + result = errors.Join(result, namespace.Close()) + } + }() + if err := validateRecordDeletionDirectory(namespace); err != nil { + return err + } + if err := unix.Flock(int(namespace.Fd()), unix.LOCK_EX); err != nil { + return fmt.Errorf( + "lock private record deletion namespace for cleanup: %w", + err, + ) + } + if err := recoverRecordDeletionTransactions(directory, namespace); err != nil { + return err + } + entries, err := readRecordDeletionDirectory(namespace) + if err != nil { + return err + } + if len(entries) != 0 { + return errors.New( + "workspace: private record deletion namespace is not empty", + ) + } + current, err := openRecordDeletionDirectoryAt( + directory, + recordDeletionNamespace, + ) + if err != nil { + return err + } + expectedInfo, expectedErr := namespace.Stat() + currentInfo, currentErr := current.Stat() + closeErr := current.Close() + if err := errors.Join(expectedErr, currentErr, closeErr); err != nil { + return err + } + if !os.SameFile(expectedInfo, currentInfo) { + return errors.New( + "workspace: private record deletion namespace changed identity", + ) + } + if afterValidation != nil { + afterValidation() + } + isolatedName := recordDeletionNamespaceIsolationPrefix + uuid.NewString() + moved, err := renameDirectoryNoReplace( + directory, + expectedInfo, + recordDeletionNamespace, + isolatedName+".rename", + isolatedName, + ) + if err != nil { + return fmt.Errorf( + "isolate private record deletion namespace: %w", + err, + ) + } + if !moved { + return errors.New( + "workspace: private record deletion namespace was not isolated", + ) + } + namespaceOpen = false + if err := namespace.Close(); err != nil { + return fmt.Errorf( + "close isolated private record deletion namespace: %w", + err, + ) + } + if err := unix.Unlinkat( + int(directory.Fd()), + isolatedName, + unix.AT_REMOVEDIR, + ); err != nil { + return fmt.Errorf( + "remove private record deletion namespace: %w", + err, + ) + } + if err := directory.Sync(); err != nil { + return fmt.Errorf( + "sync removed private record deletion namespace: %w", + err, + ) + } + return nil +} + +func rejectRecordDeletionNamespaceIsolationDebris( + directory *os.File, +) error { + entries, err := readRecordDeletionDirectory(directory) + if err != nil { + return fmt.Errorf( + "inspect private record deletion namespace debris: %w", + err, + ) + } + for _, entry := range entries { + if strings.HasPrefix( + entry.Name(), + recordDeletionNamespaceIsolationPrefix, + ) { + return fmt.Errorf( + "workspace: private record deletion namespace debris %q has no recoverable owner", + entry.Name(), + ) + } + } + return nil +} diff --git a/internal/workspace/record_unlink_recovery_unix.go b/internal/workspace/record_unlink_recovery_unix.go new file mode 100644 index 0000000..68d167e --- /dev/null +++ b/internal/workspace/record_unlink_recovery_unix.go @@ -0,0 +1,403 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd + +package workspace + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/google/uuid" + "golang.org/x/sys/unix" +) + +func recoverRecordDeletionTransactions( + directory *os.File, + namespace *os.File, +) error { + entries, err := readRecordDeletionDirectory(namespace) + if err != nil { + return fmt.Errorf("read private record deletion namespace: %w", err) + } + for _, entry := range entries { + var recoverErr error + switch { + case canonicalUUID(entry.Name()): + recoverErr = settleRecordDeletionTransaction( + directory, + namespace, + entry.Name(), + ) + case isRecordDeletionTransactionIsolationName(entry.Name()): + recoverErr = removeIsolatedRecordDeletionTransaction( + namespace, + entry.Name(), + ) + default: + return fmt.Errorf( + "workspace: private record deletion artifact %q is invalid", + entry.Name(), + ) + } + if recoverErr != nil { + return fmt.Errorf( + "workspace: recover record deletion transaction %q: %w", + entry.Name(), + recoverErr, + ) + } + } + return nil +} + +func isRecordDeletionTransactionIsolationName(name string) bool { + suffix, found := strings.CutPrefix( + name, + recordDeletionTransactionIsolationPrefix, + ) + if !found { + return false + } + return canonicalUUID(strings.TrimSuffix(suffix, ".rename")) +} + +func removeIsolatedRecordDeletionTransaction( + namespace *os.File, + name string, +) (result error) { + transaction, err := openRecordDeletionDirectoryAt(namespace, name) + if err != nil { + return err + } + transactionOpen := true + defer func() { + if transactionOpen { + result = errors.Join(result, transaction.Close()) + } + }() + if err := validateRecordDeletionDirectory(transaction); err != nil { + return err + } + entries, err := readRecordDeletionDirectory(transaction) + if err != nil { + return err + } + if len(entries) != 0 { + return errors.New( + "workspace: isolated record deletion transaction is not empty", + ) + } + info, err := transaction.Stat() + if err != nil { + return err + } + if err := transaction.Close(); err != nil { + return err + } + transactionOpen = false + return removeSettledRecordDeletionTransaction(namespace, name, info) +} + +func settleRecordDeletionTransaction( + directory *os.File, + namespace *os.File, + name string, +) (result error) { + transaction, err := openRecordDeletionDirectoryAt(namespace, name) + if err != nil { + return err + } + transactionOpen := true + defer func() { + if transactionOpen { + result = errors.Join(result, transaction.Close()) + } + }() + if err := validateRecordDeletionDirectory(transaction); err != nil { + return err + } + entries, err := readRecordDeletionDirectory(transaction) + if err != nil { + return err + } + if len(entries) == 0 { + transactionInfo, err := transaction.Stat() + if err != nil { + return err + } + if err := transaction.Close(); err != nil { + return err + } + transactionOpen = false + return removeSettledRecordDeletionTransaction( + namespace, + name, + transactionInfo, + ) + } + sourceName, validated, witness, candidate, err := + inspectRecordDeletionTransaction(transaction) + if err != nil { + return err + } + closeFiles := func() error { + var closeErr error + if candidate != nil { + closeErr = errors.Join(closeErr, candidate.Close()) + candidate = nil + } + if witness != nil { + closeErr = errors.Join(closeErr, witness.Close()) + witness = nil + } + return closeErr + } + defer func() { + result = errors.Join(result, closeFiles()) + }() + + switch { + case witness == nil && candidate != nil: + return errors.New( + "workspace: record deletion candidate has no witness", + ) + case !validated && candidate != nil: + return errors.New( + "workspace: unvalidated record deletion has a candidate", + ) + case witness != nil && !validated: + if err := preserveRecordDeletionPath( + directory, + sourceName, + transaction, + recordDeletionWitness, + witness, + ); err != nil { + return err + } + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionWitness, + ); err != nil { + return err + } + case witness != nil && candidate != nil: + witnessInfo, statErr := witness.Stat() + candidateInfo, candidateErr := candidate.Stat() + if err := errors.Join(statErr, candidateErr); err != nil { + return err + } + if !os.SameFile(witnessInfo, candidateInfo) { + if err := preserveRecordDeletionPath( + directory, + sourceName, + transaction, + recordDeletionCandidate, + candidate, + ); err != nil { + return err + } + } + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionCandidate, + ); err != nil { + return err + } + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionWitness, + ); err != nil { + return err + } + case witness != nil: + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionWitness, + ); err != nil { + return err + } + } + if err := closeFiles(); err != nil { + return err + } + if validated { + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionValidated, + ); err != nil { + return err + } + } + if err := unlinkPrivateRecordPath( + transaction, + recordDeletionSource, + ); err != nil { + return err + } + transactionInfo, err := transaction.Stat() + if err != nil { + return err + } + if err := transaction.Close(); err != nil { + return err + } + transactionOpen = false + return removeSettledRecordDeletionTransaction( + namespace, + name, + transactionInfo, + ) +} + +func removeSettledRecordDeletionTransaction( + namespace *os.File, + name string, + expected os.FileInfo, +) error { + return removeSettledRecordDeletionTransactionAfterValidation( + namespace, + name, + expected, + nil, + ) +} + +func removeSettledRecordDeletionTransactionAfterValidation( + namespace *os.File, + name string, + expected os.FileInfo, + afterValidation func(), +) error { + if expected == nil || !expected.IsDir() { + return errors.New( + "workspace: settled record deletion transaction identity is invalid", + ) + } + if afterValidation != nil { + afterValidation() + } + isolatedName := recordDeletionTransactionIsolationPrefix + + uuid.NewString() + moved, err := renameDirectoryNoReplace( + namespace, + expected, + name, + isolatedName+".rename", + isolatedName, + ) + if err != nil { + return fmt.Errorf( + "isolate settled record deletion transaction: %w", + err, + ) + } + if !moved { + return errors.New( + "workspace: settled record deletion transaction was not isolated", + ) + } + if err := unix.Unlinkat( + int(namespace.Fd()), + isolatedName, + unix.AT_REMOVEDIR, + ); err != nil { + return fmt.Errorf("remove settled record deletion transaction: %w", err) + } + if err := namespace.Sync(); err != nil { + return fmt.Errorf("sync settled record deletion namespace: %w", err) + } + return nil +} + +func inspectRecordDeletionTransaction( + transaction *os.File, +) ( + sourceName string, + validated bool, + witness *os.File, + candidate *os.File, + result error, +) { + entries, err := readRecordDeletionDirectory(transaction) + if err != nil { + return "", false, nil, nil, err + } + seen := make(map[string]bool, len(entries)) + for _, entry := range entries { + switch entry.Name() { + case recordDeletionSource, + recordDeletionValidated, + recordDeletionWitness, + recordDeletionCandidate: + default: + return "", false, nil, nil, fmt.Errorf( + "workspace: record deletion transaction entry %q is invalid", + entry.Name(), + ) + } + if seen[entry.Name()] || !entry.Type().IsRegular() { + return "", false, nil, nil, fmt.Errorf( + "workspace: record deletion transaction entry %q is invalid", + entry.Name(), + ) + } + seen[entry.Name()] = true + } + if !seen[recordDeletionSource] { + return "", false, nil, nil, errors.New( + "workspace: record deletion transaction has no source", + ) + } + sourceName, err = readRecordDeletionSource(transaction) + if err != nil { + return "", false, nil, nil, err + } + validated = seen[recordDeletionValidated] + if seen[recordDeletionWitness] { + witness, err = openRecordPath( + transaction, + recordDeletionWitness, + ) + if err != nil { + return "", false, nil, nil, err + } + } + if seen[recordDeletionCandidate] { + candidate, err = openRecordPath( + transaction, + recordDeletionCandidate, + ) + if err != nil { + if witness != nil { + err = errors.Join(err, witness.Close()) + } + return "", false, nil, nil, err + } + } + return sourceName, validated, witness, candidate, nil +} + +func readRecordDeletionSource(transaction *os.File) (string, error) { + file, err := openRecordPath(transaction, recordDeletionSource) + if err != nil { + return "", err + } + raw, readErr := io.ReadAll(io.LimitReader(file, 4097)) + closeErr := file.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return "", err + } + if len(raw) > 4096 { + return "", errors.New( + "workspace: record deletion source name is too long", + ) + } + name := string(raw) + if err := validateRecordDeletionSourceName(name); err != nil { + return "", err + } + return name, nil +} diff --git a/internal/workspace/record_unlink_unix.go b/internal/workspace/record_unlink_unix.go new file mode 100644 index 0000000..3bc5401 --- /dev/null +++ b/internal/workspace/record_unlink_unix.go @@ -0,0 +1,497 @@ +//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd + +package workspace + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/google/uuid" + "golang.org/x/sys/unix" +) + +const ( + recordDeletionNamespace = ".drove-delete-v1" + + recordDeletionNamespaceIsolationPrefix = ".drove-delete-v1-isolated-" + recordDeletionTransactionIsolationPrefix = ".drove-transaction-isolated-" + recordDeletionSource = "source" + recordDeletionWitness = "witness" + recordDeletionValidated = "validated" + recordDeletionCandidate = "candidate" +) + +func unlinkRecordPath( + directory *os.File, + expected *os.File, + name string, +) error { + return unlinkRecordPathAfterValidation( + directory, + expected, + name, + nil, + ) +} + +func unlinkRecordPathAfterValidation( + directory *os.File, + expected *os.File, + name string, + afterValidation func(string), +) (result error) { + if err := validateRecordDeletionSourceName(name); err != nil { + return err + } + if err := lockRecordDeletionDirectory(directory); err != nil { + return err + } + defer func() { + result = errors.Join( + result, + unlockRecordDeletionDirectory(directory), + ) + }() + return unlinkRecordPathAfterValidationLocked( + directory, + expected, + name, + afterValidation, + ) +} + +func unlinkRecordPathAfterValidationLocked( + directory *os.File, + expected *os.File, + name string, + afterValidation func(string), +) (result error) { + namespace, err := openRecordDeletionNamespace(directory) + if err != nil { + return err + } + defer func() { + result = errors.Join(result, namespace.Close()) + }() + if err := recoverRecordDeletionTransactions(directory, namespace); err != nil { + return err + } + + transactionName := uuid.NewString() + transaction, err := createRecordDeletionTransaction( + namespace, + transactionName, + name, + ) + if err != nil { + return err + } + transactionPending := true + finish := func(operationErr error) error { + closeErr := transaction.Close() + transactionPending = false + settleErr := settleRecordDeletionTransaction( + directory, + namespace, + transactionName, + ) + return errors.Join(operationErr, closeErr, settleErr) + } + defer func() { + if transactionPending { + result = errors.Join(result, finish(nil)) + } + }() + + // The private witness lets recovery distinguish the owned file from a + // replacement moved into the transaction after validation. + if err := unix.Linkat( + int(directory.Fd()), + name, + int(transaction.Fd()), + recordDeletionWitness, + 0, + ); err != nil { + return finish(fmt.Errorf( + "create record deletion witness for %q: %w", + name, + err, + )) + } + if err := transaction.Sync(); err != nil { + return finish(fmt.Errorf( + "sync record deletion witness for %q: %w", + name, + err, + )) + } + if err := verifyRecordPathIdentity( + transaction, + expected, + recordDeletionWitness, + ); err != nil { + return finish(fmt.Errorf( + "verify record deletion witness for %q: %w", + name, + err, + )) + } + if err := markRecordDeletionValidated(transaction); err != nil { + return finish(err) + } + if afterValidation != nil { + afterValidation(name) + } + if err := unix.Renameat( + int(directory.Fd()), + name, + int(transaction.Fd()), + recordDeletionCandidate, + ); err != nil { + return finish(fmt.Errorf( + "move record deletion candidate %q: %w", + name, + err, + )) + } + verifyErr := verifyRecordPathIdentity( + transaction, + expected, + recordDeletionCandidate, + ) + if verifyErr != nil { + verifyErr = fmt.Errorf( + "record deletion candidate %q changed identity: %w", + name, + verifyErr, + ) + } + return finish(verifyErr) +} + +func openRecordDeletionNamespace( + directory *os.File, +) (*os.File, error) { + parentFD := int(directory.Fd()) + created := false + if err := unix.Mkdirat( + parentFD, + recordDeletionNamespace, + 0o700, + ); err == nil { + created = true + } else if !errors.Is(err, unix.EEXIST) { + return nil, fmt.Errorf( + "create private record deletion namespace: %w", + err, + ) + } + namespace, err := openRecordDeletionDirectoryAt( + directory, + recordDeletionNamespace, + ) + if err != nil { + return nil, fmt.Errorf( + "open private record deletion namespace: %w", + err, + ) + } + if created { + if err := unix.Fchmod(int(namespace.Fd()), 0o700); err != nil { + return nil, errors.Join( + fmt.Errorf( + "protect private record deletion namespace: %w", + err, + ), + namespace.Close(), + ) + } + } + if err := validateRecordDeletionDirectory(namespace); err != nil { + return nil, errors.Join(err, namespace.Close()) + } + if err := unix.Flock(int(namespace.Fd()), unix.LOCK_EX); err != nil { + return nil, errors.Join( + fmt.Errorf("lock private record deletion namespace: %w", err), + namespace.Close(), + ) + } + if created { + if err := errors.Join(namespace.Sync(), directory.Sync()); err != nil { + return nil, errors.Join( + fmt.Errorf( + "sync private record deletion namespace: %w", + err, + ), + namespace.Close(), + ) + } + } + return namespace, nil +} + +func lockRecordDeletionDirectory(directory *os.File) error { + if err := unix.Flock(int(directory.Fd()), unix.LOCK_EX); err != nil { + return fmt.Errorf( + "lock record directory for private deletion: %w", + err, + ) + } + return nil +} + +func unlockRecordDeletionDirectory(directory *os.File) error { + if err := unix.Flock(int(directory.Fd()), unix.LOCK_UN); err != nil { + return fmt.Errorf( + "unlock record directory after private deletion: %w", + err, + ) + } + return nil +} + +func openRecordDeletionDirectoryAt( + parent *os.File, + name string, +) (*os.File, error) { + fd, err := unix.Openat( + int(parent.Fd()), + name, + unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_DIRECTORY, + 0, + ) + if err != nil { + return nil, err + } + directory := os.NewFile(uintptr(fd), name) + if directory == nil { + _ = unix.Close(fd) + return nil, errors.New("create record deletion directory handle") + } + return directory, nil +} + +func validateRecordDeletionDirectory(directory *os.File) error { + var info unix.Stat_t + if err := unix.Fstat(int(directory.Fd()), &info); err != nil { + return fmt.Errorf("inspect private record deletion directory: %w", err) + } + mode := uint32(info.Mode) + if mode&unix.S_IFMT != unix.S_IFDIR { + return errors.New( + "workspace: private record deletion path is not a directory", + ) + } + if info.Uid != uint32(os.Geteuid()) { + return errors.New( + "workspace: private record deletion directory has the wrong owner", + ) + } + if mode&0o777 != 0o700 { + return errors.New( + "workspace: private record deletion directory has unsafe permissions", + ) + } + return nil +} + +func createRecordDeletionTransaction( + namespace *os.File, + name string, + sourceName string, +) (*os.File, error) { + if err := unix.Mkdirat(int(namespace.Fd()), name, 0o700); err != nil { + return nil, fmt.Errorf("create record deletion transaction: %w", err) + } + transaction, err := openRecordDeletionDirectoryAt(namespace, name) + if err != nil { + return nil, errors.Join( + fmt.Errorf("open record deletion transaction: %w", err), + namespace.Sync(), + ) + } + discard := func(cause error) (*os.File, error) { + removeSourceErr := unix.Unlinkat( + int(transaction.Fd()), + recordDeletionSource, + 0, + ) + if errors.Is(removeSourceErr, unix.ENOENT) { + removeSourceErr = nil + } + transactionInfo, statErr := transaction.Stat() + closeErr := transaction.Close() + var removeTransactionErr error + if removeSourceErr == nil && statErr == nil && closeErr == nil { + removeTransactionErr = removeSettledRecordDeletionTransaction( + namespace, + name, + transactionInfo, + ) + } + return nil, errors.Join( + cause, + removeSourceErr, + statErr, + closeErr, + removeTransactionErr, + namespace.Sync(), + ) + } + if err := unix.Fchmod(int(transaction.Fd()), 0o700); err != nil { + return discard(fmt.Errorf( + "protect record deletion transaction: %w", + err, + )) + } + if err := writeRecordDeletionFile( + transaction, + recordDeletionSource, + []byte(sourceName), + ); err != nil { + return discard(fmt.Errorf( + "write record deletion source: %w", + err, + )) + } + if err := errors.Join(transaction.Sync(), namespace.Sync()); err != nil { + return discard(fmt.Errorf( + "sync record deletion transaction: %w", + err, + )) + } + return transaction, nil +} + +func markRecordDeletionValidated(transaction *os.File) error { + if err := writeRecordDeletionFile( + transaction, + recordDeletionValidated, + nil, + ); err != nil { + return fmt.Errorf("mark record deletion witness validated: %w", err) + } + if err := transaction.Sync(); err != nil { + return fmt.Errorf("sync validated record deletion witness: %w", err) + } + return nil +} + +func writeRecordDeletionFile( + directory *os.File, + name string, + content []byte, +) error { + fd, err := unix.Openat( + int(directory.Fd()), + name, + unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, + 0o600, + ) + if err != nil { + return err + } + file := os.NewFile(uintptr(fd), name) + if file == nil { + _ = unix.Close(fd) + return errors.New("create record deletion metadata handle") + } + written, writeErr := file.Write(content) + if writeErr == nil && written != len(content) { + writeErr = io.ErrShortWrite + } + syncErr := file.Sync() + closeErr := file.Close() + return errors.Join(writeErr, syncErr, closeErr) +} + +func validateRecordDeletionSourceName(name string) error { + if name == "" || + name == "." || + name == ".." || + name == recordDeletionNamespace || + strings.ContainsAny(name, "/\x00") { + return fmt.Errorf( + "workspace: record deletion source name %q is invalid", + name, + ) + } + return nil +} + +func preserveRecordDeletionPath( + directory *os.File, + name string, + transaction *os.File, + privateName string, + privateFile *os.File, +) error { + err := unix.Linkat( + int(transaction.Fd()), + privateName, + int(directory.Fd()), + name, + 0, + ) + if err != nil && !errors.Is(err, unix.EEXIST) { + return fmt.Errorf( + "restore record deletion path %q: %w", + name, + err, + ) + } + current, openErr := openRecordPath(directory, name) + if openErr != nil { + return fmt.Errorf( + "open restored record deletion path %q: %w", + name, + openErr, + ) + } + privateInfo, statErr := privateFile.Stat() + currentInfo, currentErr := current.Stat() + closeErr := current.Close() + if err := errors.Join(statErr, currentErr, closeErr); err != nil { + return err + } + if !os.SameFile(privateInfo, currentInfo) { + return fmt.Errorf( + "workspace: record deletion path %q is occupied by another file", + name, + ) + } + if err := directory.Sync(); err != nil { + return fmt.Errorf( + "sync restored record deletion path %q: %w", + name, + err, + ) + } + return nil +} + +func unlinkPrivateRecordPath(directory *os.File, name string) error { + if err := unix.Unlinkat(int(directory.Fd()), name, 0); err != nil { + return fmt.Errorf( + "remove private record deletion path %q: %w", + name, + err, + ) + } + return nil +} + +func readRecordDeletionDirectory( + directory *os.File, +) ([]os.DirEntry, error) { + duplicate, err := openRecordDeletionDirectoryAt(directory, ".") + if err != nil { + return nil, err + } + entries, readErr := duplicate.ReadDir(-1) + closeErr := duplicate.Close() + return entries, errors.Join(readErr, closeErr) +} diff --git a/internal/workspace/recovery_security_test.go b/internal/workspace/recovery_security_test.go index 8ae998b..a8feb35 100644 --- a/internal/workspace/recovery_security_test.go +++ b/internal/workspace/recovery_security_test.go @@ -11,6 +11,65 @@ import ( "testing" ) +func TestEnsureManagedRootRejectsLateParentSymlink(t *testing.T) { + parent := t.TempDir() + redirect := filepath.Join(parent, "redirect") + dataDir := filepath.Join(redirect, "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + if _, err := os.Lstat(redirect); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("new manager changed the filesystem: %v", err) + } + + outside := t.TempDir() + if err := os.Symlink(outside, redirect); err != nil { + t.Skipf("create late parent symlink: %v", err) + } + if err := manager.ensureManagedRoot(); err == nil { + t.Fatal("managed root creation followed a late parent symlink") + } + if _, err := os.Lstat(filepath.Join(outside, "data")); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("managed root creation changed the symlink target: %v", err) + } +} + +func TestEnsureManagedRootRejectsLateAncestorSymlink(t *testing.T) { + parent := t.TempDir() + ancestor := filepath.Join(parent, "ancestor") + stable := filepath.Join(ancestor, "stable") + if err := os.MkdirAll(stable, 0o700); err != nil { + t.Fatalf("create stable ancestor: %v", err) + } + manager, err := New(filepath.Join(stable, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + if err := os.Rename(ancestor, ancestor+"-opened"); err != nil { + t.Fatalf("move original ancestor: %v", err) + } + outside := filepath.Join(parent, "outside") + if err := os.MkdirAll(filepath.Join(outside, "stable"), 0o700); err != nil { + t.Fatalf("create redirect target: %v", err) + } + if err := os.Symlink(outside, ancestor); err != nil { + t.Skipf("create ancestor symlink: %v", err) + } + + if err := manager.ensureManagedRoot(); err == nil { + t.Fatal("managed root creation followed a symlink in an earlier ancestor") + } + if _, err := os.Lstat( + filepath.Join(outside, "stable", "data"), + ); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed root creation changed the symlink target: %v", err) + } +} + func TestDiscardRejectsReplacedDataDirectory(t *testing.T) { repository := newTestRepository(t) parent := t.TempDir() @@ -126,6 +185,180 @@ func TestRemoveRootDirectoryContentsRejectsReplacementParent(t *testing.T) { } } +func TestRemoveOpenedDirectoryPreservesCanonicalReplacement(t *testing.T) { + parentPath := t.TempDir() + originalPath := filepath.Join(parentPath, "workspace") + if err := os.Mkdir(originalPath, 0o700); err != nil { + t.Fatalf("create original directory: %v", err) + } + if err := os.WriteFile( + filepath.Join(originalPath, "old"), + []byte("old\n"), + 0o600, + ); err != nil { + t.Fatalf("write original file: %v", err) + } + parent, err := openRealPathRoot(parentPath) + if err != nil { + t.Fatalf("open parent root: %v", err) + } + defer parent.Close() + opened, err := openRealRootFromRoot(parent, "workspace") + if err != nil { + t.Fatalf("open original directory: %v", err) + } + replacement := filepath.Join(originalPath, "replacement") + + err = removeOpenedDirectoryFromRootAfterIsolation( + parent, + "workspace", + opened, + "workspace", + func(string) { + if err := os.Mkdir(originalPath, 0o700); err != nil { + t.Fatalf("create canonical replacement: %v", err) + } + if err := os.WriteFile( + replacement, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement file: %v", err) + } + }, + ) + if err != nil { + t.Fatalf("remove opened directory: %v", err) + } + assertFileContents(t, replacement, "replacement\n") + entries, err := os.ReadDir(parentPath) + if err != nil { + t.Fatalf("read parent directory: %v", err) + } + for _, entry := range entries { + if isRootDeletionName(entry.Name(), "workspace") { + t.Fatalf("deletion debris remains: %q", entry.Name()) + } + } +} + +func TestRemoveAllRejectsUnownedIsolatedDirectoryDeletion(t *testing.T) { + parentPath := t.TempDir() + debrisName := rootDeletionPrefix("workspace") + + "49494949-4949-4949-8949-494949494949" + debrisPath := filepath.Join(parentPath, debrisName) + if err := os.Mkdir(debrisPath, 0o700); err != nil { + t.Fatalf("create deletion debris: %v", err) + } + if err := os.WriteFile( + filepath.Join(debrisPath, "old"), + []byte("old\n"), + 0o600, + ); err != nil { + t.Fatalf("write deletion debris: %v", err) + } + parent, err := openRealPathRoot(parentPath) + if err != nil { + t.Fatalf("open parent root: %v", err) + } + defer parent.Close() + + if err := removeAllFromRoot(parent, "workspace"); err == nil { + t.Fatal("remove accepted deletion debris without persisted identity") + } + assertFileContents(t, filepath.Join(debrisPath, "old"), "old\n") +} + +func TestRemoveRootDeletionDebrisRecoversPersistedIdentity(t *testing.T) { + parentPath := t.TempDir() + originalPath := filepath.Join(parentPath, "workspace") + if err := os.Mkdir(originalPath, 0o700); err != nil { + t.Fatalf("create original directory: %v", err) + } + original, err := openRealPathRoot(originalPath) + if err != nil { + t.Fatalf("open original directory: %v", err) + } + identity, identityErr := openedDirectoryIdentity(original) + closeErr := original.Close() + if err := errors.Join(identityErr, closeErr); err != nil { + t.Fatalf("inspect original directory identity: %v", err) + } + debrisName := rootDeletionName("workspace", identity) + debrisPath := filepath.Join(parentPath, debrisName) + if err := os.Rename(originalPath, debrisPath); err != nil { + t.Fatalf("isolate original directory: %v", err) + } + parent, err := openRealPathRoot(parentPath) + if err != nil { + t.Fatalf("open parent root: %v", err) + } + defer parent.Close() + + if err := removeAllFromRoot(parent, "workspace"); err == nil { + t.Fatal("remove inferred deletion ownership from a missing original name") + } + if _, err := os.Lstat(debrisPath); err != nil { + t.Fatalf("untrusted identity-bound debris changed: %v", err) + } + if err := removeRootDeletionDebris( + parent, + "workspace", + identity, + ); err != nil { + t.Fatalf("recover identity-bound deletion: %v", err) + } + if _, err := os.Lstat(debrisPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("identity-bound deletion debris remains: %v", err) + } +} + +func TestRemoveRootDeletionDebrisRejectsReplacedIdentity(t *testing.T) { + parentPath := t.TempDir() + originalPath := filepath.Join(parentPath, "workspace") + if err := os.Mkdir(originalPath, 0o700); err != nil { + t.Fatalf("create original directory: %v", err) + } + original, err := openRealPathRoot(originalPath) + if err != nil { + t.Fatalf("open original directory: %v", err) + } + identity, identityErr := openedDirectoryIdentity(original) + closeErr := original.Close() + if err := errors.Join(identityErr, closeErr); err != nil { + t.Fatalf("inspect original directory identity: %v", err) + } + if err := os.Rename( + originalPath, + filepath.Join(parentPath, "original-held"), + ); err != nil { + t.Fatalf("retain original directory identity: %v", err) + } + debrisName := rootDeletionName("workspace", identity) + debrisPath := filepath.Join(parentPath, debrisName) + if err := os.Mkdir(debrisPath, 0o700); err != nil { + t.Fatalf("create replacement debris: %v", err) + } + replacement := filepath.Join(debrisPath, "replacement") + if err := os.WriteFile(replacement, []byte("replacement\n"), 0o600); err != nil { + t.Fatalf("write replacement debris: %v", err) + } + parent, err := openRealPathRoot(parentPath) + if err != nil { + t.Fatalf("open parent root: %v", err) + } + defer parent.Close() + + if err := removeRootDeletionDebris( + parent, + "workspace", + identity, + ); err == nil { + t.Fatal("remove accepted replaced identity-bound deletion debris") + } + assertFileContents(t, replacement, "replacement\n") +} + func TestRenameRecordRejectsReplacedTemporaryPath(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("test requires renaming an open file") @@ -251,6 +484,9 @@ func TestAcknowledgeRemovalRejectsReplacedRepositoryBucket(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove( context.Background(), prepared.AgentID, @@ -296,7 +532,9 @@ func TestAcknowledgeRemovalRejectsReplacedRepositoryBucket(t *testing.T) { } } -func TestDiscardRejectsReplacementRepositoryBucket(t *testing.T) { +func TestAcknowledgedRecordRemovalDetectsBucketReplacementAfterOpening( + t *testing.T, +) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { @@ -311,31 +549,52 @@ func TestDiscardRejectsReplacementRepositoryBucket(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } - bucket := filepath.Dir(prepared.Path) - original := bucket + "-original" - if err := os.Rename(bucket, original); err != nil { - t.Fatalf("move repository bucket: %v", err) - } - replacement := filepath.Join(bucket, testAgentID) - if err := os.MkdirAll(replacement, 0o700); err != nil { - t.Fatalf("create replacement bucket: %v", err) + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) } - sentinel := filepath.Join(replacement, "must-remain") - if err := os.WriteFile(sentinel, []byte("replacement\n"), 0o600); err != nil { - t.Fatalf("write replacement sentinel: %v", err) + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) } + + bucketPath := filepath.Dir(prepared.Path) + openedBucketPath := bucketPath + "-opened" + replacementSentinel := filepath.Join(bucketPath, "must-remain") t.Cleanup(func() { - _ = os.RemoveAll(bucket) - _ = os.Rename(original, bucket) + _ = os.RemoveAll(bucketPath) + _ = os.Rename(openedBucketPath, bucketPath) }) - - if err := manager.Discard(context.Background(), prepared); err == nil { - t.Fatal("discard accepted a replacement repository bucket") + err = manager.removeAcknowledgedWorkspaceRecordAfterValidation( + result.Removal, + func() { + if err := os.Rename(bucketPath, openedBucketPath); err != nil { + t.Fatalf("move opened repository bucket: %v", err) + } + if err := os.Mkdir(bucketPath, 0o700); err != nil { + t.Fatalf("create replacement repository bucket: %v", err) + } + if err := os.WriteFile( + replacementSentinel, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement sentinel: %v", err) + } + }, + ) + if err == nil { + t.Fatal("acknowledged record removal missed a replaced repository bucket") } - assertFileContents(t, sentinel, "replacement\n") + assertFileContents(t, replacementSentinel, "replacement\n") } -func TestDiscardRejectsReplacementWorkspacePath(t *testing.T) { +func TestAcknowledgeRemovalPreservesRecordWhenRepositoryDisappears( + t *testing.T, +) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { @@ -350,34 +609,47 @@ func TestDiscardRejectsReplacementWorkspacePath(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } - originalPath := prepared.Path + "-original" - if err := os.Rename(prepared.Path, originalPath); err != nil { - t.Fatalf("move prepared workspace: %v", err) - } - if err := os.Mkdir(prepared.Path, 0o700); err != nil { - t.Fatalf("create replacement workspace: %v", err) + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) } - sentinel := filepath.Join(prepared.Path, "must-remain") - if err := os.WriteFile(sentinel, []byte("replacement\n"), 0o600); err != nil { - t.Fatalf("write replacement sentinel: %v", err) + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) } - if err := manager.Discard(context.Background(), prepared); err == nil { - t.Fatal("discard accepted a replacement workspace path") + movedRepository := repository + "-moved" + if err := os.Rename(repository, movedRepository); err != nil { + t.Fatalf("move source repository: %v", err) } - assertFileContents(t, sentinel, "replacement\n") - if err := os.RemoveAll(prepared.Path); err != nil { - t.Fatalf("remove replacement workspace: %v", err) + repositoryMoved := true + t.Cleanup(func() { + if repositoryMoved { + _ = os.Rename(movedRepository, repository) + } + }) + if err := manager.AcknowledgeRemoval(result.Removal); err == nil { + t.Fatal("acknowledgement ignored a missing source repository") } - if err := os.Rename(originalPath, prepared.Path); err != nil { - t.Fatalf("restore prepared workspace: %v", err) + if _, err := os.Lstat(workspaceRecordPath(prepared.Path)); err != nil { + t.Fatalf("failed acknowledgement removed the sidecar: %v", err) } - if err := manager.Discard(context.Background(), prepared); err != nil { - t.Fatalf("discard restored workspace: %v", err) + + if err := os.Rename(movedRepository, repository); err != nil { + t.Fatalf("restore source repository: %v", err) + } + repositoryMoved = false + if err := manager.AcknowledgeRemoval(result.Removal); err != nil { + t.Fatalf("acknowledge restored removal: %v", err) } } -func TestRemoveRejectsReplacementGitWorktree(t *testing.T) { +func TestAcknowledgeRemovalRetryDoesNotRequireDeletedRepository( + t *testing.T, +) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { @@ -390,56 +662,38 @@ func TestRemoveRejectsReplacementGitWorktree(t *testing.T) { testAgentID, ) if err != nil { - t.Fatalf("prepare workspace: %v", err) + t.Fatalf("prepare worktree: %v", err) } if err := manager.AcknowledgePreparation(prepared); err != nil { t.Fatalf("acknowledge preparation: %v", err) } - replacementPath := filepath.Join(t.TempDir(), "replacement-worktree") - runGit(t, repository, "branch", "replacement-remove", "HEAD") - runGit( - t, - repository, - "worktree", - "add", - "--quiet", - replacementPath, - "replacement-remove", + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, ) - if err := os.WriteFile( - filepath.Join(replacementPath, "must-remain"), - []byte("replacement\n"), - 0o600, - ); err != nil { - t.Fatalf("write replacement sentinel: %v", err) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) } - originalPath := prepared.Path + "-original" - if err := os.Rename(prepared.Path, originalPath); err != nil { - t.Fatalf("move original workspace: %v", err) + if err := manager.AcknowledgeRemoval(result.Removal); err != nil { + t.Fatalf("acknowledge removal: %v", err) } - if err := os.Rename(replacementPath, prepared.Path); err != nil { - t.Fatalf("install replacement workspace: %v", err) + movedRepository := repository + "-moved" + if err := os.Rename(repository, movedRepository); err != nil { + t.Fatalf("move acknowledged source repository: %v", err) } defer func() { - _ = os.Rename(prepared.Path, replacementPath) - _ = os.Rename(originalPath, prepared.Path) + _ = os.Rename(movedRepository, repository) }() - if _, err := manager.Remove( - context.Background(), - prepared.AgentID, - true, - ); err == nil { - t.Fatal("remove accepted a replacement Git worktree") + if err := manager.AcknowledgeRemoval(result.Removal); err != nil { + t.Fatalf("retry acknowledgement without source repository: %v", err) } - assertFileContents( - t, - filepath.Join(prepared.Path, "must-remain"), - "replacement\n", - ) } -func TestRemoveRejectsCopiedWorkspaceDirectory(t *testing.T) { +func TestAcknowledgeRemovalRejectsMissingRecordWithReplacementWorkspace( + t *testing.T, +) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { @@ -452,34 +706,43 @@ func TestRemoveRejectsCopiedWorkspaceDirectory(t *testing.T) { testAgentID, ) if err != nil { - t.Fatalf("prepare workspace: %v", err) + t.Fatalf("prepare worktree: %v", err) } if err := manager.AcknowledgePreparation(prepared); err != nil { t.Fatalf("acknowledge preparation: %v", err) } - originalPath := prepared.Path + "-original" - if err := os.Rename(prepared.Path, originalPath); err != nil { - t.Fatalf("move original workspace: %v", err) + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) } - if err := os.CopyFS(prepared.Path, os.DirFS(originalPath)); err != nil { - t.Fatalf("copy workspace directory: %v", err) + if err := os.Remove(workspaceRecordPath(prepared.Path)); err != nil { + t.Fatalf("remove pending acknowledgement record: %v", err) } - sentinel := filepath.Join(prepared.Path, "must-remain") - if err := os.WriteFile(sentinel, []byte("replacement\n"), 0o600); err != nil { - t.Fatalf("write replacement sentinel: %v", err) + if err := os.Mkdir(prepared.Path, 0o700); err != nil { + t.Fatalf("create replacement workspace: %v", err) + } + replacementMarker := filepath.Join(prepared.Path, "replacement") + if err := os.WriteFile(replacementMarker, []byte("replacement\n"), 0o600); err != nil { + t.Fatalf("write replacement marker: %v", err) } - if _, err := manager.Remove( - context.Background(), - prepared.AgentID, - true, - ); err == nil { - t.Fatal("remove accepted a copied workspace directory") + if err := manager.AcknowledgeRemoval(result.Removal); err == nil { + t.Fatal("acknowledgement accepted a missing record") + } + contents, err := os.ReadFile(replacementMarker) + if err != nil { + t.Fatalf("read replacement marker: %v", err) + } + if string(contents) != "replacement\n" { + t.Fatalf("replacement marker = %q", contents) } - assertFileContents(t, sentinel, "replacement\n") } -func TestListRejectsCopiedWorkspaceDirectory(t *testing.T) { +func TestDiscardRejectsReplacementRepositoryBucket(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { @@ -492,7 +755,266 @@ func TestListRejectsCopiedWorkspaceDirectory(t *testing.T) { testAgentID, ) if err != nil { - t.Fatalf("prepare workspace: %v", err) + t.Fatalf("prepare worktree: %v", err) + } + bucket := filepath.Dir(prepared.Path) + original := bucket + "-original" + if err := os.Rename(bucket, original); err != nil { + t.Fatalf("move repository bucket: %v", err) + } + replacement := filepath.Join(bucket, testAgentID) + if err := os.MkdirAll(replacement, 0o700); err != nil { + t.Fatalf("create replacement bucket: %v", err) + } + sentinel := filepath.Join(replacement, "must-remain") + if err := os.WriteFile(sentinel, []byte("replacement\n"), 0o600); err != nil { + t.Fatalf("write replacement sentinel: %v", err) + } + t.Cleanup(func() { + _ = os.RemoveAll(bucket) + _ = os.Rename(original, bucket) + }) + + if err := manager.Discard(context.Background(), prepared); err == nil { + t.Fatal("discard accepted a replacement repository bucket") + } + assertFileContents(t, sentinel, "replacement\n") +} + +func TestDiscardRejectsReplacementWorkspacePath(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + originalPath := prepared.Path + "-original" + if err := os.Rename(prepared.Path, originalPath); err != nil { + t.Fatalf("move prepared workspace: %v", err) + } + if err := os.Mkdir(prepared.Path, 0o700); err != nil { + t.Fatalf("create replacement workspace: %v", err) + } + sentinel := filepath.Join(prepared.Path, "must-remain") + if err := os.WriteFile(sentinel, []byte("replacement\n"), 0o600); err != nil { + t.Fatalf("write replacement sentinel: %v", err) + } + + if err := manager.Discard(context.Background(), prepared); err == nil { + t.Fatal("discard accepted a replacement workspace path") + } + assertFileContents(t, sentinel, "replacement\n") + if err := os.RemoveAll(prepared.Path); err != nil { + t.Fatalf("remove replacement workspace: %v", err) + } + if err := os.Rename(originalPath, prepared.Path); err != nil { + t.Fatalf("restore prepared workspace: %v", err) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard restored workspace: %v", err) + } +} + +func TestDiscardPreservesRecoveryEvidenceWhenStagingProbeFails(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + originalPath := prepared.Path + "-original" + if err := os.Rename(prepared.Path, originalPath); err != nil { + t.Fatalf("move prepared workspace: %v", err) + } + stagingName, err := preparedWorktreeStagingName( + prepared.AgentID, + prepared.branchOperationID, + ) + if err != nil { + t.Fatalf("derive staging name: %v", err) + } + stagingPath := filepath.Join(filepath.Dir(prepared.Path), stagingName) + if err := os.Symlink(originalPath, stagingPath); err != nil { + t.Skipf("create replacement staging symlink: %v", err) + } + + if err := manager.Discard(context.Background(), prepared); err == nil { + t.Fatal("discard accepted a replacement staging path") + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf( + "recovery record after failed staging probe: exists=%v err=%v", + exists, + err, + ) + } + if record.BranchOperationID != prepared.branchOperationID { + t.Fatalf("recovery record changed after failed staging probe: %+v", record) + } + if err := exec.Command( + "git", + "-C", + repository, + "show-ref", + "--verify", + "--quiet", + "refs/heads/"+prepared.Branch, + ).Run(); err != nil { + t.Fatalf("owned branch was removed after failed staging probe: %v", err) + } + marker, err := manager.branchOwnershipMarkerExists( + context.Background(), + repository, + prepared.branchOperationID, + ) + if err != nil || !marker { + t.Fatalf("branch ownership marker = %v, err=%v", marker, err) + } + + if err := os.Remove(stagingPath); err != nil { + t.Fatalf("remove replacement staging path: %v", err) + } + if err := os.Rename(originalPath, prepared.Path); err != nil { + t.Fatalf("restore prepared workspace: %v", err) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard restored workspace: %v", err) + } +} + +func TestRemoveRejectsReplacementGitWorktree(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + replacementPath := filepath.Join(t.TempDir(), "replacement-worktree") + runGit(t, repository, "branch", "replacement-remove", "HEAD") + runGit( + t, + repository, + "worktree", + "add", + "--quiet", + replacementPath, + "replacement-remove", + ) + if err := os.WriteFile( + filepath.Join(replacementPath, "must-remain"), + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement sentinel: %v", err) + } + originalPath := prepared.Path + "-original" + if err := os.Rename(prepared.Path, originalPath); err != nil { + t.Fatalf("move original workspace: %v", err) + } + if err := os.Rename(replacementPath, prepared.Path); err != nil { + t.Fatalf("install replacement workspace: %v", err) + } + defer func() { + _ = os.Rename(prepared.Path, replacementPath) + _ = os.Rename(originalPath, prepared.Path) + }() + + if _, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ); err == nil { + t.Fatal("remove accepted a replacement Git worktree") + } + assertFileContents( + t, + filepath.Join(prepared.Path, "must-remain"), + "replacement\n", + ) +} + +func TestRemoveRejectsCopiedWorkspaceDirectory(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + originalPath := prepared.Path + "-original" + if err := os.Rename(prepared.Path, originalPath); err != nil { + t.Fatalf("move original workspace: %v", err) + } + if err := os.CopyFS(prepared.Path, os.DirFS(originalPath)); err != nil { + t.Fatalf("copy workspace directory: %v", err) + } + sentinel := filepath.Join(prepared.Path, "must-remain") + if err := os.WriteFile(sentinel, []byte("replacement\n"), 0o600); err != nil { + t.Fatalf("write replacement sentinel: %v", err) + } + + if _, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ); err == nil { + t.Fatal("remove accepted a copied workspace directory") + } + assertFileContents(t, sentinel, "replacement\n") +} + +func TestListRejectsCopiedWorkspaceDirectory(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) } if err := manager.AcknowledgePreparation(prepared); err != nil { t.Fatalf("acknowledge preparation: %v", err) @@ -525,6 +1047,9 @@ func TestReconcilePreparationWithoutIdentityPreservesPresentPath(t *testing.T) { if err != nil { t.Fatalf("prepare workspace: %v", err) } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -672,14 +1197,18 @@ func TestListRejectsReplacementDataDirectory(t *testing.T) { if err != nil { t.Fatalf("new manager: %v", err) } - if _, err := manager.Prepare( + prepared, err := manager.Prepare( context.Background(), repository, "", testAgentID, - ); err != nil { + ) + if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } original := dataDir + "-original" if err := os.Rename(dataDir, original); err != nil { t.Fatalf("move data directory: %v", err) @@ -1169,7 +1698,7 @@ func TestPreparedRefTransactionLocksBranchDuringValidation(t *testing.T) { } } -func TestManagerRecreatesAcknowledgedRepositoryBucket(t *testing.T) { +func TestManagerRetainsAcknowledgedRepositoryBucket(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { @@ -1191,6 +1720,14 @@ func TestManagerRecreatesAcknowledgedRepositoryBucket(t *testing.T) { if err := manager.AcknowledgeRemoval(result.Removal); err != nil { t.Fatalf("acknowledge first removal: %v", err) } + bucket := filepath.Dir(first.Path) + info, err := os.Lstat(bucket) + if err != nil { + t.Fatalf("inspect retained repository bucket: %v", err) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("retained repository bucket mode = %v", info.Mode()) + } second, err := manager.Prepare( context.Background(), repository, @@ -1220,6 +1757,9 @@ func TestReconcileQuarantinedRemovalPreservesReplacementPath(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1232,6 +1772,17 @@ func TestReconcileQuarantinedRemovalPreservesReplacementPath(t *testing.T) { if err := manager.replaceWorkspaceRecord(record); err != nil { t.Fatalf("write removal intent: %v", err) } + workspaceRoot, err := openRealPathRoot(prepared.Path) + if err != nil { + t.Fatalf("open workspace root: %v", err) + } + if err := ensureRemovalMarker(workspaceRoot, record); err != nil { + _ = workspaceRoot.Close() + t.Fatalf("install removal marker: %v", err) + } + if err := workspaceRoot.Close(); err != nil { + t.Fatalf("close workspace root: %v", err) + } quarantineName := removalQuarantinePrefix(record) + "57575757-5757-4757-8757-575757575757" quarantinePath := filepath.Join(filepath.Dir(prepared.Path), quarantineName) @@ -1246,12 +1797,17 @@ func TestReconcileQuarantinedRemovalPreservesReplacementPath(t *testing.T) { t.Fatalf("write replacement sentinel: %v", err) } - if _, err := manager.ReconcileRemovals(context.Background()); err == nil { + _, reconcileErr := manager.ReconcileRemovals(context.Background()) + if reconcileErr == nil { t.Fatal("reconciliation accepted a replacement at the original path") } assertFileContents(t, sentinel, "replacement\n") if _, err := os.Lstat(quarantinePath); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("quarantined worktree remains or inspect failed: %v", err) + t.Fatalf( + "quarantined worktree remains or inspect failed: %v (reconcile error: %v)", + err, + reconcileErr, + ) } current, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists || @@ -1337,6 +1893,92 @@ func TestReconcileStartedRemovalRejectsReplacementQuarantine(t *testing.T) { } } +func TestEnsureRemovalMarkerRecoversInterruptedPublication(t *testing.T) { + record := workspaceRecord{ + AgentID: "11111111-1111-4111-8111-111111111111", + Removal: &workspaceRemovalRecord{ + OperationID: "47474747-4747-4747-8747-474747474747", + DirectoryToken: "48484848-4848-4848-8848-484848484848", + }, + } + for _, test := range []struct { + name string + path string + }{ + { + name: "staged payload", + path: removalMarkerTemporaryPrefix(record) + + "49494949-4949-4949-8949-494949494949", + }, + { + name: "canonical payload", + path: removalMarkerName(record), + }, + } { + t.Run(test.name, func(t *testing.T) { + path := t.TempDir() + if err := os.WriteFile( + filepath.Join(path, test.path), + []byte("partial"), + 0o600, + ); err != nil { + t.Fatalf("write interrupted marker: %v", err) + } + root, err := openRealPathRoot(path) + if err != nil { + t.Fatalf("open marker root: %v", err) + } + defer root.Close() + + if err := ensureRemovalMarker(root, record); err != nil { + t.Fatalf("recover removal marker: %v", err) + } + if err := verifyRemovalMarker(root, record); err != nil { + t.Fatalf("verify recovered marker: %v", err) + } + if test.path != removalMarkerName(record) { + if _, err := root.Lstat(test.path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("staged marker remains after recovery: %v", err) + } + } + entries, err := readRootDirectory(root) + if err != nil { + t.Fatalf("read recovered marker directory: %v", err) + } + if len(entries) != 1 || + entries[0].Name() != removalMarkerName(record) { + t.Fatalf("recovered marker entries = %v", entries) + } + }) + } +} + +func TestWorkspaceStatusDirtyIgnoresOnlyActiveRemovalFiles(t *testing.T) { + record := workspaceRecord{ + Removal: &workspaceRemovalRecord{ + OperationID: "47474747-4747-4747-8747-474747474747", + DirectoryToken: "48484848-4848-4848-8848-484848484848", + }, + } + temporary := removalMarkerTemporaryPrefix(record) + + "49494949-4949-4949-8949-494949494949" + clean := []byte( + "?? " + removalMarkerName(record) + "\n" + + "?? " + temporary + "\n", + ) + if workspaceStatusDirty(clean, &record) { + t.Fatalf("active removal files were classified as dirty: %q", clean) + } + for _, dirty := range [][]byte{ + []byte("?? " + removalMarkerTemporaryPrefix(record) + "not-a-uuid\n"), + []byte("?? .drove-removal-other.install-49494949-4949-4949-8949-494949494949\n"), + } { + if !workspaceStatusDirty(dirty, &record) { + t.Fatalf("unrelated removal file was ignored: %q", dirty) + } + } +} + func TestReconcileStartedRemovalDoesNotRecreateMissingMarker(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) @@ -1352,6 +1994,9 @@ func TestReconcileStartedRemovalDoesNotRecreateMissingMarker(t *testing.T) { if err != nil { t.Fatalf("prepare workspace: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1399,6 +2044,181 @@ func TestReconcileStartedRemovalDoesNotRecreateMissingMarker(t *testing.T) { } } +func TestReconcileContentsClearedRemovalAcceptsMissingMarker(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read workspace record: exists=%v err=%v", exists, err) + } + record.Removal = &workspaceRemovalRecord{ + OperationID: "53535353-5353-4353-8353-535353535353", + DirectoryToken: "54545454-5454-4454-8454-545454545454", + Force: true, + Started: true, + Quarantined: true, + ContentsCleared: true, + } + quarantineName := removalQuarantinePrefix(record) + + "55555555-5555-4555-8555-555555555555" + quarantinePath := filepath.Join(filepath.Dir(prepared.Path), quarantineName) + if err := os.Rename(prepared.Path, quarantinePath); err != nil { + t.Fatalf("quarantine workspace: %v", err) + } + entries, err := os.ReadDir(quarantinePath) + if err != nil { + t.Fatalf("read quarantine: %v", err) + } + for _, entry := range entries { + if err := os.RemoveAll(filepath.Join(quarantinePath, entry.Name())); err != nil { + t.Fatalf("clear quarantine entry %q: %v", entry.Name(), err) + } + } + if err := manager.replaceWorkspaceRecord(record); err != nil { + t.Fatalf("persist cleared removal: %v", err) + } + + removals, err := manager.ReconcileRemovals(context.Background()) + if err != nil { + t.Fatalf("reconcile cleared removal: %v", err) + } + if len(removals) != 1 || removals[0].Workspace.Path != prepared.Path { + t.Fatalf("reconciled removals = %+v", removals) + } + if _, err := os.Lstat(quarantinePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("cleared quarantine remains or inspect failed: %v", err) + } +} + +func TestReconcileContentsClearedRemovalRejectsUnexpectedEntry(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read workspace record: exists=%v err=%v", exists, err) + } + record.Removal = &workspaceRemovalRecord{ + OperationID: "56565656-5656-4656-8656-565656565656", + DirectoryToken: "57575757-5757-4757-8757-575757575757", + Force: true, + Started: true, + Quarantined: true, + ContentsCleared: true, + } + quarantineName := removalQuarantinePrefix(record) + + "58585858-5858-4858-8858-585858585858" + quarantinePath := filepath.Join(filepath.Dir(prepared.Path), quarantineName) + if err := os.Rename(prepared.Path, quarantinePath); err != nil { + t.Fatalf("quarantine workspace: %v", err) + } + entries, err := os.ReadDir(quarantinePath) + if err != nil { + t.Fatalf("read quarantine: %v", err) + } + for _, entry := range entries { + if err := os.RemoveAll(filepath.Join(quarantinePath, entry.Name())); err != nil { + t.Fatalf("clear quarantine entry %q: %v", entry.Name(), err) + } + } + unexpected := filepath.Join(quarantinePath, "unexpected") + if err := os.WriteFile(unexpected, []byte("preserve\n"), 0o600); err != nil { + t.Fatalf("write unexpected entry: %v", err) + } + if err := manager.replaceWorkspaceRecord(record); err != nil { + t.Fatalf("persist cleared removal: %v", err) + } + + if _, err := manager.ReconcileRemovals( + context.Background(), + ); err == nil || !strings.Contains(err.Error(), "unexpected entry") { + t.Fatalf("reconcile unexpected cleared entry error = %v", err) + } + assertFileContents(t, unexpected, "preserve\n") +} + +func TestPersistRemovalStartRecordsAbsentPath(t *testing.T) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read workspace record: exists=%v err=%v", exists, err) + } + record.Removal = &workspaceRemovalRecord{ + OperationID: "59595959-5959-4959-8959-595959595959", + DirectoryToken: "60606060-6060-4060-8060-606060606060", + } + if err := manager.replaceWorkspaceRecord(record); err != nil { + t.Fatalf("persist removal intent: %v", err) + } + savedPath := filepath.Join(t.TempDir(), "saved-worktree") + if err := os.Rename(prepared.Path, savedPath); err != nil { + t.Fatalf("move workspace out of managed path: %v", err) + } + started, err := manager.persistRemovalStart(record, false) + if err != nil { + t.Fatalf("persist absent-path removal start: %v", err) + } + if !started.Removal.Started || !started.Removal.PathAbsent { + t.Fatalf("started removal = %+v", started.Removal) + } + if err := os.Rename(savedPath, prepared.Path); err != nil { + t.Fatalf("restore workspace path: %v", err) + } + if _, err := manager.ReconcileRemovals( + context.Background(), + ); err == nil || !strings.Contains(err.Error(), "appeared after removal began") { + t.Fatalf("reconcile restored absent path error = %v", err) + } + if _, err := os.Lstat(prepared.Path); err != nil { + t.Fatalf("restored workspace was removed: %v", err) + } +} + func TestReconcileAbsentPathRemovalPreservesReplacementPath(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) @@ -1414,6 +2234,9 @@ func TestReconcileAbsentPathRemovalPreservesReplacementPath(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := prepared.preparation.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1471,6 +2294,9 @@ func TestAcknowledgeRemovalRecoversQuarantinedRecord(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove(context.Background(), prepared.AgentID, true) if err != nil || result.State != RemovalComplete { t.Fatalf("remove workspace = %+v, %v", result, err) @@ -1493,6 +2319,167 @@ func TestAcknowledgeRemovalRecoversQuarantinedRecord(t *testing.T) { } } +func TestAcknowledgeRemovalRejectsWrongTokenForQuarantinedRecord( + t *testing.T, +) { + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + result, err := manager.Remove(context.Background(), prepared.AgentID, true) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) + } + recordPath := workspaceRecordPath(prepared.Path) + quarantineName := recordAcknowledgementPrefix( + prepared.AgentID, + result.Removal.operationID, + ) + "69696969-6969-4969-8969-696969696969" + quarantinePath := filepath.Join(filepath.Dir(recordPath), quarantineName) + if err := os.Rename(recordPath, quarantinePath); err != nil { + t.Fatalf("quarantine removal record: %v", err) + } + wrong := result.Removal + wrong.operationID = "70707070-7070-4070-8070-707070707070" + + if err := manager.AcknowledgeRemoval(wrong); err == nil { + t.Fatal("wrong token acknowledged a quarantined removal record") + } + if _, err := os.Lstat(quarantinePath); err != nil { + t.Fatalf("wrong token changed acknowledgement quarantine: %v", err) + } + if err := manager.AcknowledgeRemoval(result.Removal); err != nil { + t.Fatalf("acknowledge removal with correct token: %v", err) + } +} + +func TestReconcileRemovalCoalescesAcknowledgementHardLinks(t *testing.T) { + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + result, err := manager.Remove(context.Background(), prepared.AgentID, true) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) + } + recordPath := workspaceRecordPath(prepared.Path) + prefix := recordAcknowledgementPrefix( + prepared.AgentID, + result.Removal.operationID, + ) + firstPath := filepath.Join( + filepath.Dir(recordPath), + prefix+"71717171-7171-4717-8717-717171717171", + ) + secondPath := filepath.Join( + filepath.Dir(recordPath), + prefix+"72727272-7272-4727-8727-727272727272", + ) + if err := os.Rename(recordPath, firstPath); err != nil { + t.Fatalf("quarantine removal record: %v", err) + } + if err := os.Link(firstPath, secondPath); err != nil { + t.Skipf("create acknowledgement hard link: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + removals, err := restarted.ReconcileRemovals(context.Background()) + if err != nil { + t.Fatalf("reconcile acknowledgement aliases: %v", err) + } + if len(removals) != 1 { + t.Fatalf("reconciled removals = %+v, want one", removals) + } + if err := restarted.AcknowledgeRemoval(removals[0]); err != nil { + t.Fatalf("acknowledge reconciled removal: %v", err) + } + for _, path := range []string{recordPath, firstPath, secondPath} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("acknowledgement path %q remains: %v", path, err) + } + } +} + +func TestCoalesceAcknowledgementAliasesPreservesReplacementAfterValidation( + t *testing.T, +) { + rootPath := t.TempDir() + operationID := "73737373-7373-4737-8737-737373737373" + prefix := recordAcknowledgementPrefix(testAgentID, operationID) + firstName := prefix + "74747474-7474-4747-8747-747474747474" + secondName := prefix + "75757575-7575-4757-8757-757575757575" + firstPath := filepath.Join(rootPath, firstName) + secondPath := filepath.Join(rootPath, secondName) + if err := os.WriteFile(firstPath, []byte("record\n"), 0o600); err != nil { + t.Fatalf("write first acknowledgement alias: %v", err) + } + if err := os.Link(firstPath, secondPath); err != nil { + t.Skipf("create acknowledgement hard link: %v", err) + } + root, err := os.OpenRoot(rootPath) + if err != nil { + t.Fatalf("open acknowledgement root: %v", err) + } + defer root.Close() + originalSecondPath := secondPath + "-original" + + _, err = coalesceRecordAcknowledgementsAfterValidation( + root, + []recordAcknowledgement{ + {name: firstName, operationID: operationID}, + {name: secondName, operationID: operationID}, + }, + func() { + if err := os.Rename(secondPath, originalSecondPath); err != nil { + t.Fatalf("move validated acknowledgement alias: %v", err) + } + if err := os.WriteFile( + secondPath, + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("install replacement acknowledgement path: %v", err) + } + }, + ) + if err == nil { + t.Fatal("coalescing accepted a replacement acknowledgement alias") + } + assertFileContents(t, firstPath, "record\n") + assertFileContents(t, originalSecondPath, "record\n") + assertFileContents(t, secondPath, "replacement\n") +} + func TestRemoveRecoversQuarantinedAcknowledgementRecord(t *testing.T) { repository := newTestRepository(t) manager, err := New(filepath.Join(t.TempDir(), "data")) @@ -1508,6 +2495,9 @@ func TestRemoveRecoversQuarantinedAcknowledgementRecord(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove(context.Background(), prepared.AgentID, true) if err != nil || result.State != RemovalComplete { t.Fatalf("remove workspace = %+v, %v", result, err) @@ -1560,6 +2550,9 @@ func TestStaleAcknowledgementPreservesNewWorkspaceRecord(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove(context.Background(), prepared.AgentID, true) if err != nil || result.State != RemovalComplete { t.Fatalf("remove workspace = %+v, %v", result, err) @@ -1581,6 +2574,7 @@ func TestStaleAcknowledgementPreservesNewWorkspaceRecord(t *testing.T) { DirectoryToken: "90909090-9090-4090-8090-909090909090", Force: true, Started: true, + PathAbsent: true, } if err := manager.installWorkspaceRecord(replacement, true); err != nil { t.Fatalf("install replacement record: %v", err) diff --git a/internal/workspace/removal.go b/internal/workspace/removal.go index e7b71db..24f227c 100644 --- a/internal/workspace/removal.go +++ b/internal/workspace/removal.go @@ -39,13 +39,20 @@ type RemovalResult struct { func workspaceForRemoval(record workspaceRecord) Workspace { target := record.workspace() - if record.Version < workspaceRecordVersion { + if record.Version < removalWorkspaceRecordVersion { target.repositoryEvidence = nil } return target } // Remove removes one managed worktree and preserves its branch. +// +// The caller must first stop every process that can write through the worktree +// path or a retained file or directory handle. A successful removal is +// logically ordered at the identity-bound rename from the managed path to its +// private quarantine. The non-force checks reject changes visible before that +// point; operating systems do not provide a portable way to revoke stale +// handles after the rename. func (m *Manager) Remove( ctx context.Context, agentID string, @@ -86,6 +93,12 @@ func (m *Manager) remove( } } + if record.Removal == nil { + record, err = m.ensureRemovalRepositoryEvidence(ctx, record) + if err != nil { + return RemovalResult{}, err + } + } removal := Removal{Workspace: workspaceForRemoval(record)} if record.Removal != nil { removal.operationID = record.Removal.OperationID @@ -191,7 +204,9 @@ func (m *Manager) ReconcileRemovals(ctx context.Context) ([]Removal, error) { } // AcknowledgeRemoval removes a sidecar after its session tombstone is durable. -func (m *Manager) AcknowledgeRemoval(removal Removal) error { +func (m *Manager) AcknowledgeRemoval( + removal Removal, +) (result error) { m.mu.Lock() defer m.mu.Unlock() @@ -204,32 +219,87 @@ func (m *Manager) AcknowledgeRemoval(removal Removal) error { "workspace: removal acknowledgement token is invalid", ) } - record := newWorkspaceRecord(removal.Workspace, nil) - record.Removal = &workspaceRemovalRecord{ - OperationID: removal.operationID, - Started: true, - Quarantined: true, + receipt := removalAcknowledgementReceipt{ + agentID: removal.Workspace.AgentID, + operationID: removal.operationID, } - inspectCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - facts, err := m.removalFacts(inspectCtx, record) - if errors.Is(err, os.ErrNotExist) { - return nil + pending, err := m.removalAcknowledgementPending(removal) + if err != nil { + return err + } + if !pending { + if _, acknowledged := m.acknowledgedRemovals[receipt]; acknowledged { + return nil + } + return errors.New( + "workspace: removal acknowledgement record is missing", + ) } + if err := m.recoverRecordAcknowledgements(); err != nil { + return err + } + record, exists, err := m.readWorkspaceRecord(removal.Workspace.Path) if err != nil { return err } - if facts.pathExists || - facts.quarantineName != "" || - facts.registered { - return errors.New("workspace: cannot acknowledge an incomplete removal") + if !exists { + return errors.New( + "workspace: removal acknowledgement record is missing", + ) } - if err := m.removeAcknowledgedWorkspaceRecord(removal); err != nil { + if !sameWorkspace(record.workspace(), removal.Workspace) || + record.Removal == nil || + record.Removal.OperationID != removal.operationID { + return m.removeAcknowledgedWorkspaceRecord(removal) + } + reservation, err := m.reserveRemovalAcknowledgementPath(record) + if err != nil { return err } - if err := m.removeManagedBucketIfEmpty(removal.Workspace); err != nil { + reservationOwned := true + defer func() { + if reservationOwned { + result = errors.Join(result, reservation.release()) + } + }() + inspectCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + repository, closeRepository, err := m.removalRepository(inspectCtx, record) + if err != nil { return err } + bucket := reservation.bucket + quarantineName, _, quarantineErr := findRemovalQuarantine(bucket, record) + _, registered, registrationErr := repository.worktreeRegistration( + inspectCtx, + record.Path, + ) + verifyErr := reservation.verify() + closeRepositoryErr := closeRepository() + if err := errors.Join( + quarantineErr, + registrationErr, + verifyErr, + closeRepositoryErr, + ); err != nil { + return err + } + if quarantineName != "" || registered { + return errors.New("workspace: cannot acknowledge an incomplete removal") + } + if err := m.removeAcknowledgedWorkspaceRecordAfterQuarantine( + removal, + func() error { + err := reservation.release() + if err == nil { + reservationOwned = false + } + return err + }, + ); err != nil { + return err + } + m.acknowledgedRemovals[receipt] = struct{}{} return nil } @@ -244,11 +314,11 @@ func (m *Manager) removalRepository( ctx context.Context, record workspaceRecord, ) (repositoryCapability, func() error, error) { - if record.Version < workspaceRecordVersion || + if record.Version < removalWorkspaceRecordVersion || record.RepositoryEvidence == nil { - return pathRepositoryCapability(m, record.Repository), - func() error { return nil }, - nil + return repositoryCapability{}, nil, errors.New( + "workspace: pending removal has no repository identity evidence", + ) } lease, err := openRecordedPreparationLease(ctx, m, record) if err != nil { @@ -257,6 +327,98 @@ func (m *Manager) removalRepository( return lease.repository, lease.Close, nil } +func (m *Manager) ensureRemovalRepositoryEvidence( + ctx context.Context, + record workspaceRecord, +) (_ workspaceRecord, result error) { + if record.Version >= removalWorkspaceRecordVersion && + record.RepositoryEvidence != nil { + return record, nil + } + if record.Removal != nil { + return workspaceRecord{}, errors.New( + "workspace: pending removal has no repository identity evidence", + ) + } + sourcePath := record.Repository + var legacyEvidence *repositoryEvidence + if record.Version == repositoryWorkspaceRecordVersion && + record.RepositoryEvidence != nil { + legacyEvidence = cloneRepositoryEvidence(record.RepositoryEvidence) + if err := validateLegacyRepositoryEvidence(*legacyEvidence); err != nil { + return workspaceRecord{}, err + } + sourcePath = legacyEvidence.SourcePath + } + root, err := openRealPathRoot(sourcePath) + if err != nil { + return workspaceRecord{}, fmt.Errorf( + "workspace: open removal repository: %w", + err, + ) + } + rootOwned := true + defer func() { + if rootOwned { + result = errors.Join(result, root.Close()) + } + }() + repository, err := m.repositoryRootAtRoot( + ctx, + sourcePath, + root, + ) + if err != nil { + return workspaceRecord{}, err + } + if repository != record.Repository { + return workspaceRecord{}, errors.New( + "workspace: removal repository changed while opening", + ) + } + lease, err := newPreparationLease( + ctx, + m, + sourcePath, + root, + ) + if err != nil { + return workspaceRecord{}, fmt.Errorf( + "workspace: retain removal repository: %w", + err, + ) + } + rootOwned = false + defer func() { + result = errors.Join(result, lease.Close()) + }() + if legacyEvidence != nil && + (legacyEvidence.SourcePath != lease.evidence.SourcePath || + legacyEvidence.SourceDirectoryIdentity != + lease.evidence.SourceDirectoryIdentity || + legacyEvidence.CommonGitDirectory != + lease.evidence.CommonGitDirectory || + legacyEvidence.CommonGitDirectoryIdentity != + lease.evidence.CommonGitDirectoryIdentity) { + return workspaceRecord{}, errors.New( + "workspace: legacy removal repository identity evidence changed", + ) + } + + upgradeWorkspaceRecord(&record) + record.RepositoryEvidence = cloneRepositoryEvidence(&lease.evidence) + if record.IncludedPaths == nil { + record.IncludedPaths = []string{} + } + if err := m.replaceWorkspaceRecord(record); err != nil { + return workspaceRecord{}, fmt.Errorf( + "workspace: persist removal repository identity: %w", + err, + ) + } + return record, nil +} + func (m *Manager) removalFacts( ctx context.Context, record workspaceRecord, @@ -291,6 +453,18 @@ func (m *Manager) removalFacts( record.Path, ) } + if pathExists { + reserved, err := removalPathIsAcknowledgementReservation(bucket, record) + if err != nil { + return removalFacts{}, fmt.Errorf( + "workspace: inspect removal acknowledgement reservation: %w", + err, + ) + } + if reserved { + pathExists = false + } + } var quarantineName string if record.Removal != nil { quarantineName, _, err = findRemovalQuarantine(bucket, record) @@ -481,10 +655,11 @@ func (m *Manager) resumePendingRemoval( filepath.Dir(record.Path), facts.quarantineName, ) - identityErr := m.validateRemovalIdentity( + identityErr := m.validateRemovalQuarantineIdentity( ctx, record, quarantinePath, + quarantined, ) var markerErr error if identityErr == nil { @@ -683,6 +858,9 @@ func (m *Manager) persistRemovalStart( started := *record.Removal started.Started = true started.Quarantined = started.Quarantined || quarantined + if !quarantined { + started.PathAbsent = true + } upgradeWorkspaceRecord(&record) record.Removal = &started _, err := m.replaceWorkspaceRecordState(record) @@ -695,6 +873,33 @@ func (m *Manager) persistRemovalStart( return record, nil } +func (m *Manager) persistRemovalContentsCleared( + record workspaceRecord, +) (workspaceRecord, error) { + if record.Removal == nil || + !record.Removal.Started || + !record.Removal.Quarantined { + return workspaceRecord{}, errors.New( + "workspace: removal quarantine is not ready to clear contents", + ) + } + if record.Removal.ContentsCleared { + return record, nil + } + cleared := *record.Removal + cleared.ContentsCleared = true + upgradeWorkspaceRecord(&record) + record.Removal = &cleared + _, err := m.replaceWorkspaceRecordState(record) + if err != nil { + return workspaceRecord{}, fmt.Errorf( + "workspace: persist cleared removal contents: %w", + err, + ) + } + return record, nil +} + func (m *Manager) validateRemovalIdentity( ctx context.Context, record workspaceRecord, @@ -759,6 +964,38 @@ func validateRemovalMarkerPath( return verifyRemovalMarker(root, record) } +func (m *Manager) validateRemovalQuarantineIdentity( + ctx context.Context, + record workspaceRecord, + path string, + root *os.Root, +) error { + identity, err := openedDirectoryIdentity(root) + if err != nil { + return err + } + if record.DirectoryIdentity == "" || + identity != record.DirectoryIdentity { + return errors.New( + "workspace: removal quarantine directory identity changed", + ) + } + if record.Removal != nil && record.Removal.ContentsCleared { + return validateClearedRemovalDirectory(root, record, false) + } + onlyMarker, err := removalDirectoryContainsOnlyMarker(root, record) + if err != nil { + return err + } + if onlyMarker && + record.Removal != nil && + record.Removal.Started && + record.Removal.Quarantined { + return nil + } + return m.validateRemovalIdentity(ctx, record, path) +} + func (m *Manager) removeRemovalQuarantine( ctx context.Context, record workspaceRecord, @@ -783,14 +1020,105 @@ func (m *Manager) removeRemovalQuarantine( }() path := filepath.Join(filepath.Dir(record.Path), name) if err := errors.Join( - m.validateRemovalIdentity(ctx, record, path), - verifyRemovalMarker(opened, record), + m.validateRemovalQuarantineIdentity(ctx, record, path, opened), + validateRemovalMarkerPhase(opened, record), verifyRootEntryUnchanged(bucket, name, opened), ); err != nil { return err } + if !record.Removal.ContentsCleared { + onlyMarker, err := removalDirectoryContainsOnlyMarker(opened, record) + if err != nil { + return err + } + if !onlyMarker { + entries, err := readRootDirectory(opened) + if err != nil { + return err + } + if err := removeRootEntriesExcept( + opened, + entries, + removalMarkerName(record), + ); err != nil { + return err + } + if err := validateClearedRemovalDirectory( + opened, + record, + true, + ); err != nil { + return err + } + } + record, err = m.persistRemovalContentsCleared(record) + if err != nil { + return err + } + } + if err := validateClearedRemovalDirectory( + opened, + record, + false, + ); err != nil { + return err + } + if err := removeRemovalMarkerIfPresent(opened, record); err != nil { + return err + } + if err := validateClearedRemovalDirectory( + opened, + record, + false, + ); err != nil { + return err + } + if err := verifyRootEntryUnchanged(bucket, name, opened); err != nil { + return err + } + openedInfo, err := opened.Stat(".") + if err != nil { + return err + } + deleteName := removalQuarantinePrefix(record) + uuid.NewString() + directory, err := openRecordDirectory(bucket) + if err != nil { + return err + } + moved, renameErr := renameDirectoryNoReplace( + directory, + openedInfo, + name, + removalQuarantineIsolationName(deleteName), + deleteName, + ) + closeDirectoryErr := directory.Close() + if moved { + name = deleteName + } + if err := errors.Join(renameErr, closeDirectoryErr); err != nil { + return fmt.Errorf( + "workspace: isolate cleared removal quarantine: %w", + err, + ) + } + if !moved { + return errors.New( + "workspace: cleared removal quarantine was not isolated", + ) + } + if err := verifyRootEntryUnchanged(bucket, name, opened); err != nil { + return err + } + closeErr := opened.Close() openedOwned = false - return removeOpenedDirectoryFromRoot(bucket, name, opened) + if closeErr != nil { + return closeErr + } + if err := bucket.Remove(name); err != nil { + return err + } + return syncRecordBucket(bucket, name) } func (m *Manager) rejectQuarantinedRemoval( diff --git a/internal/workspace/removal_acknowledgement.go b/internal/workspace/removal_acknowledgement.go new file mode 100644 index 0000000..633916a --- /dev/null +++ b/internal/workspace/removal_acknowledgement.go @@ -0,0 +1,461 @@ +package workspace + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/google/uuid" +) + +type removalAcknowledgementReservationCandidate struct { + name string + id uuid.UUID + renamed bool +} + +type removalAcknowledgementReservation struct { + bucket *os.Root + root *os.Root + record workspaceRecord + name string +} + +func removalAcknowledgementReservationPrefix( + record workspaceRecord, +) string { + return "." + record.AgentID + workspaceRecordSuffix + ".ack-path-" + + record.Removal.OperationID + "-" +} + +func removalAcknowledgementReservationName( + record workspaceRecord, +) string { + return removalAcknowledgementReservationPrefix(record) + uuid.NewString() +} + +func (m *Manager) reserveRemovalAcknowledgementPath( + record workspaceRecord, +) (_ *removalAcknowledgementReservation, result error) { + if record.Removal == nil || + !record.Removal.Started || + record.Removal.OperationID == "" || + record.Removal.DirectoryToken == "" { + return nil, errors.New( + "workspace: completed removal has no acknowledgement reservation identity", + ) + } + bucket, err := m.openManagedBucketRoot(record.workspace()) + if err != nil { + return nil, err + } + bucketOwned := true + defer func() { + if bucketOwned { + result = errors.Join(result, bucket.Close()) + } + }() + if err := cleanupRemovalAcknowledgementReservations(bucket, record); err != nil { + return nil, err + } + + info, err := bucket.Lstat(record.AgentID) + switch { + case err == nil: + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf( + "workspace: managed path %q appeared before removal acknowledgement", + record.Path, + ) + } + root, err := openRealRootFromRoot(bucket, record.AgentID) + if err != nil { + return nil, err + } + reservation := &removalAcknowledgementReservation{ + bucket: bucket, + root: root, + record: record, + name: record.AgentID, + } + if err := reservation.verify(); err != nil { + _ = root.Close() + return nil, fmt.Errorf( + "workspace: managed path %q appeared before removal acknowledgement: %w", + record.Path, + err, + ) + } + bucketOwned = false + return reservation, nil + case !errors.Is(err, os.ErrNotExist): + return nil, fmt.Errorf( + "workspace: inspect removal acknowledgement path %q: %w", + record.Path, + err, + ) + } + + stagingName := removalAcknowledgementReservationName(record) + if err := bucket.Mkdir(stagingName, 0o700); err != nil { + return nil, fmt.Errorf( + "workspace: create removal acknowledgement reservation: %w", + err, + ) + } + root, err := openRealRootFromRoot(bucket, stagingName) + if err != nil { + return nil, fmt.Errorf( + "workspace: open removal acknowledgement reservation: %w", + err, + ) + } + reservation := &removalAcknowledgementReservation{ + bucket: bucket, + root: root, + record: record, + name: stagingName, + } + reservationOwned := true + defer func() { + if reservationOwned { + result = errors.Join(result, reservation.release()) + } + }() + if err := installRemovalMarker( + root, + removalMarkerName(record), + record.Removal.DirectoryToken, + ); err != nil { + return nil, err + } + if err := reservation.verify(); err != nil { + return nil, err + } + opened, err := root.Stat(".") + if err != nil { + return nil, err + } + directory, err := openRecordDirectory(bucket) + if err != nil { + return nil, err + } + moved, renameErr := renameDirectoryNoReplace( + directory, + opened, + stagingName, + removalQuarantineIsolationName(stagingName), + record.AgentID, + ) + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if moved { + reservation.name = record.AgentID + } + if err := errors.Join(renameErr, syncErr, closeErr); err != nil { + return nil, fmt.Errorf( + "workspace: install removal acknowledgement reservation: %w", + err, + ) + } + if !moved { + return nil, errors.New( + "workspace: removal acknowledgement path was not reserved", + ) + } + if err := reservation.verify(); err != nil { + return nil, err + } + reservationOwned = false + bucketOwned = false + return reservation, nil +} + +func (r *removalAcknowledgementReservation) verify() error { + if r == nil || r.bucket == nil || r.root == nil { + return errors.New( + "workspace: removal acknowledgement reservation is closed", + ) + } + onlyMarker, err := removalDirectoryContainsOnlyMarker(r.root, r.record) + if err != nil { + return err + } + if !onlyMarker { + return errors.New( + "workspace: removal acknowledgement reservation contains unexpected entries", + ) + } + return verifyRootEntryUnchanged(r.bucket, r.name, r.root) +} + +func (r *removalAcknowledgementReservation) release() (result error) { + if r == nil { + return nil + } + if r.bucket == nil { + return nil + } + defer func() { + if r.root != nil { + result = errors.Join(result, r.root.Close()) + r.root = nil + } + result = errors.Join(result, r.bucket.Close()) + r.bucket = nil + }() + if r.root == nil { + return errors.New( + "workspace: removal acknowledgement reservation root is closed", + ) + } + if r.name == r.record.AgentID { + if err := r.verify(); err != nil { + return err + } + opened, err := r.root.Stat(".") + if err != nil { + return err + } + isolatedName := removalAcknowledgementReservationName(r.record) + directory, err := openRecordDirectory(r.bucket) + if err != nil { + return err + } + moved, renameErr := renameDirectoryNoReplace( + directory, + opened, + r.name, + removalQuarantineIsolationName(isolatedName), + isolatedName, + ) + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + if moved { + r.name = isolatedName + } + if err := errors.Join(renameErr, syncErr, closeErr); err != nil { + return fmt.Errorf( + "workspace: isolate removal acknowledgement reservation: %w", + err, + ) + } + if !moved { + return errors.New( + "workspace: removal acknowledgement reservation was not isolated", + ) + } + } else { + if err := validateClearedRemovalDirectory( + r.root, + r.record, + false, + ); err != nil { + return err + } + if err := verifyRootEntryUnchanged( + r.bucket, + r.name, + r.root, + ); err != nil { + return err + } + } + if err := removeRemovalMarkerIfPresent(r.root, r.record); err != nil { + return err + } + if err := validateClearedRemovalDirectory( + r.root, + r.record, + false, + ); err != nil { + return err + } + if err := verifyRootEntryUnchanged(r.bucket, r.name, r.root); err != nil { + return err + } + if err := r.root.Close(); err != nil { + r.root = nil + return err + } + r.root = nil + if err := r.bucket.Remove(r.name); err != nil { + return err + } + return syncRecordBucket(r.bucket, r.record.Path) +} + +func cleanupRemovalAcknowledgementReservations( + bucket *os.Root, + record workspaceRecord, +) error { + entries, err := readRootDirectory(bucket) + if err != nil { + return err + } + prefix := removalAcknowledgementReservationPrefix(record) + candidates := make( + []removalAcknowledgementReservationCandidate, + 0, + ) + seen := make(map[uuid.UUID]removalAcknowledgementReservationCandidate) + for _, entry := range entries { + if !strings.HasPrefix(entry.Name(), prefix) { + continue + } + candidate, valid := parseRemovalAcknowledgementReservationName( + entry.Name(), + prefix, + ) + if !valid { + return fmt.Errorf( + "workspace: removal acknowledgement reservation %q has an invalid name", + entry.Name(), + ) + } + if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf( + "workspace: removal acknowledgement reservation %q is not a real directory", + entry.Name(), + ) + } + if previous, exists := seen[candidate.id]; exists { + return fmt.Errorf( + "workspace: removal acknowledgement reservations %q and %q have ambiguous phases", + previous.name, + candidate.name, + ) + } + seen[candidate.id] = candidate + candidates = append(candidates, candidate) + } + for _, candidate := range candidates { + root, err := openRealRootFromRoot(bucket, candidate.name) + if err != nil { + return err + } + reservation := &removalAcknowledgementReservation{ + bucket: bucket, + root: root, + record: record, + name: candidate.name, + } + if err := cleanupIsolatedRemovalAcknowledgementReservation( + reservation, + ); err != nil { + _ = root.Close() + return err + } + } + return nil +} + +func cleanupIsolatedRemovalAcknowledgementReservation( + reservation *removalAcknowledgementReservation, +) error { + if err := cleanupRemovalMarkerTemps( + reservation.root, + reservation.record, + ); err != nil { + return err + } + if err := validateClearedRemovalDirectory( + reservation.root, + reservation.record, + false, + ); err != nil { + return err + } + if err := removeRemovalMarkerIfPresent( + reservation.root, + reservation.record, + ); err != nil { + return err + } + if err := verifyRootEntryUnchanged( + reservation.bucket, + reservation.name, + reservation.root, + ); err != nil { + return err + } + if err := reservation.root.Close(); err != nil { + reservation.root = nil + return err + } + reservation.root = nil + if err := reservation.bucket.Remove(reservation.name); err != nil { + return err + } + return syncRecordBucket(reservation.bucket, reservation.record.Path) +} + +func parseRemovalAcknowledgementReservationName( + name string, + prefix string, +) (removalAcknowledgementReservationCandidate, bool) { + raw, found := strings.CutPrefix(name, prefix) + if !found { + return removalAcknowledgementReservationCandidate{}, false + } + const renameSuffix = ".rename" + renamed := strings.HasSuffix(raw, renameSuffix) + if renamed { + raw = strings.TrimSuffix(raw, renameSuffix) + } + if len(raw) != 36 { + return removalAcknowledgementReservationCandidate{}, false + } + parsed, err := uuid.Parse(raw) + if err != nil || parsed.String() != raw { + return removalAcknowledgementReservationCandidate{}, false + } + return removalAcknowledgementReservationCandidate{ + name: name, + id: parsed, + renamed: renamed, + }, true +} + +func removalPathIsAcknowledgementReservation( + bucket *os.Root, + record workspaceRecord, +) (_ bool, result error) { + if record.Removal == nil || !record.Removal.Started { + return false, nil + } + root, err := openRealRootFromRoot(bucket, record.AgentID) + if err != nil { + return false, err + } + defer func() { + result = errors.Join(result, root.Close()) + }() + return openedRemovalPathIsAcknowledgementReservation( + bucket, + root, + record, + ) +} + +func openedRemovalPathIsAcknowledgementReservation( + bucket *os.Root, + root *os.Root, + record workspaceRecord, +) (bool, error) { + if record.Removal == nil || !record.Removal.Started { + return false, nil + } + onlyMarker, err := removalDirectoryContainsOnlyMarker(root, record) + if err != nil { + return false, err + } + if !onlyMarker { + return false, nil + } + if err := verifyRootEntryUnchanged(bucket, record.AgentID, root); err != nil { + return false, err + } + return true, nil +} diff --git a/internal/workspace/removal_acknowledgement_test.go b/internal/workspace/removal_acknowledgement_test.go new file mode 100644 index 0000000..62b6ba9 --- /dev/null +++ b/internal/workspace/removal_acknowledgement_test.go @@ -0,0 +1,367 @@ +package workspace + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestParseRemovalAcknowledgementReservationName(t *testing.T) { + const ( + prefix = ".record.ack-path-operation-" + id = "abcdefab-cdef-4abc-8def-abcdefabcdef" + ) + for _, test := range []struct { + name string + value string + valid bool + renamed bool + }{ + {name: "base", value: prefix + id, valid: true}, + { + name: "rename", + value: prefix + id + ".rename", + valid: true, + renamed: true, + }, + {name: "wrong prefix", value: "other-" + id}, + {name: "uppercase UUID", value: prefix + strings.ToUpper(id)}, + {name: "double rename", value: prefix + id + ".rename.rename"}, + {name: "extra suffix", value: prefix + id + ".extra"}, + } { + t.Run(test.name, func(t *testing.T) { + candidate, valid := parseRemovalAcknowledgementReservationName( + test.value, + prefix, + ) + if valid != test.valid { + t.Fatalf("valid = %v, want %v", valid, test.valid) + } + if valid && candidate.renamed != test.renamed { + t.Fatalf( + "renamed = %v, want %v", + candidate.renamed, + test.renamed, + ) + } + }) + } +} + +func TestCleanupRemovalAcknowledgementReservationsRecoversRenamePhase( + t *testing.T, +) { + for _, renamed := range []bool{false, true} { + t.Run(map[bool]string{false: "base", true: "rename"}[renamed], func(t *testing.T) { + bucketPath := t.TempDir() + bucket, err := os.OpenRoot(bucketPath) + if err != nil { + t.Fatalf("open bucket: %v", err) + } + defer bucket.Close() + record := removalAcknowledgementTestRecord(bucketPath) + name := removalAcknowledgementReservationPrefix(record) + + "89898989-8989-4898-8989-898989898989" + createRemovalAcknowledgementReservationFixture( + t, + bucket, + record, + name, + ) + if renamed { + renamedPath := filepath.Join(bucketPath, name+".rename") + if err := os.Rename( + filepath.Join(bucketPath, name), + renamedPath, + ); err != nil { + t.Fatalf("isolate reservation: %v", err) + } + name += ".rename" + } + + if err := cleanupRemovalAcknowledgementReservations( + bucket, + record, + ); err != nil { + t.Fatalf("cleanup reservation: %v", err) + } + if _, err := os.Lstat(filepath.Join(bucketPath, name)); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("reservation remains: %v", err) + } + if err := cleanupRemovalAcknowledgementReservations( + bucket, + record, + ); err != nil { + t.Fatalf("repeat cleanup reservation: %v", err) + } + }) + } +} + +func TestCleanupRemovalAcknowledgementReservationsRecoversMarkerDiscard( + t *testing.T, +) { + bucketPath := t.TempDir() + bucket, err := os.OpenRoot(bucketPath) + if err != nil { + t.Fatalf("open bucket: %v", err) + } + defer bucket.Close() + record := removalAcknowledgementTestRecord(bucketPath) + name := removalAcknowledgementReservationPrefix(record) + + "91919191-9191-4191-8191-919191919191" + createRemovalAcknowledgementReservationFixture( + t, + bucket, + record, + name, + ) + reservationPath := filepath.Join(bucketPath, name) + if err := os.Rename( + filepath.Join(reservationPath, removalMarkerName(record)), + filepath.Join(reservationPath, removalMarkerDiscardName(record)), + ); err != nil { + t.Fatalf("isolate removal marker: %v", err) + } + + if err := cleanupRemovalAcknowledgementReservations( + bucket, + record, + ); err != nil { + t.Fatalf("cleanup reservation: %v", err) + } + if _, err := os.Lstat(reservationPath); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("reservation remains: %v", err) + } +} + +func TestCleanupRemovalAcknowledgementReservationsRejectsAmbiguousPhases( + t *testing.T, +) { + bucketPath := t.TempDir() + bucket, err := os.OpenRoot(bucketPath) + if err != nil { + t.Fatalf("open bucket: %v", err) + } + defer bucket.Close() + record := removalAcknowledgementTestRecord(bucketPath) + baseName := removalAcknowledgementReservationPrefix(record) + + "90909090-9090-4090-8090-909090909090" + for _, name := range []string{baseName, baseName + ".rename"} { + createRemovalAcknowledgementReservationFixture( + t, + bucket, + record, + name, + ) + } + + if err := cleanupRemovalAcknowledgementReservations( + bucket, + record, + ); err == nil { + t.Fatal("cleanup accepted ambiguous reservation phases") + } + for _, name := range []string{baseName, baseName + ".rename"} { + if _, err := os.Stat(filepath.Join(bucketPath, name)); err != nil { + t.Fatalf("ambiguous reservation %q changed: %v", name, err) + } + } +} + +func removalAcknowledgementTestRecord(bucketPath string) workspaceRecord { + return workspaceRecord{ + AgentID: testAgentID, + Path: filepath.Join(bucketPath, testAgentID), + Removal: &workspaceRemovalRecord{ + OperationID: "67676767-6767-4767-8767-676767676767", + DirectoryToken: "68686868-6868-4868-8868-686868686868", + Started: true, + }, + } +} + +func createRemovalAcknowledgementReservationFixture( + t *testing.T, + bucket *os.Root, + record workspaceRecord, + name string, +) { + t.Helper() + if err := bucket.Mkdir(name, 0o700); err != nil { + t.Fatalf("create reservation %q: %v", name, err) + } + root, err := openRealRootFromRoot(bucket, name) + if err != nil { + t.Fatalf("open reservation %q: %v", name, err) + } + if err := installRemovalMarker( + root, + removalMarkerName(record), + record.Removal.DirectoryToken, + ); err != nil { + _ = root.Close() + t.Fatalf("install reservation marker: %v", err) + } + if err := root.Close(); err != nil { + t.Fatalf("close reservation %q: %v", name, err) + } +} + +func TestAcknowledgeRemovalReservesManagedPathDuringRepositoryCheck( + t *testing.T, +) { + if runtime.GOOS == "windows" { + t.Skip("test requires a POSIX shell") + } + repository := newTestRepository(t) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) + } + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + hookMarker := filepath.Join(t.TempDir(), "reservation-observed") + wrapper := filepath.Join(t.TempDir(), "git-wrapper") + script := `#!/bin/sh +"$DROVE_TEST_REAL_GIT" "$@" +status=$? +case " $* " in + *" worktree list --porcelain -z "*) + if mkdir "$DROVE_TEST_WORKSPACE_PATH" 2>/dev/null; then + exit 91 + fi + [ -d "$DROVE_TEST_WORKSPACE_PATH" ] || exit 92 + : > "$DROVE_TEST_HOOK_MARKER" + ;; +esac +exit "$status" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_WORKSPACE_PATH", prepared.Path) + t.Setenv("DROVE_TEST_HOOK_MARKER", hookMarker) + manager.git = wrapper + + if err := manager.AcknowledgeRemoval(result.Removal); err != nil { + t.Fatalf("acknowledge removal: %v", err) + } + if _, err := os.Stat(hookMarker); err != nil { + t.Fatalf("repository check did not observe reservation: %v", err) + } + if _, err := os.Lstat(prepared.Path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("acknowledgement reservation remains: %v", err) + } + if _, err := os.Lstat(workspaceRecordPath(prepared.Path)); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("acknowledged record remains: %v", err) + } +} + +func TestAcknowledgeRemovalRecoversManagedPathReservation( + t *testing.T, +) { + repository := newTestRepository(t) + dataDir := filepath.Join(t.TempDir(), "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare worktree: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err != nil || result.State != RemovalComplete { + t.Fatalf("remove workspace = %+v, %v", result, err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read removal record: exists=%v err=%v", exists, err) + } + reservation, err := manager.reserveRemovalAcknowledgementPath(record) + if err != nil { + t.Fatalf("reserve acknowledgement path: %v", err) + } + listed, err := manager.List(context.Background()) + if err != nil { + t.Fatalf("list acknowledgement reservation: %v", err) + } + if len(listed) != 1 || !listed[0].Missing { + t.Fatalf("listed acknowledgement reservation = %+v", listed) + } + if err := reservation.root.Close(); err != nil { + t.Fatalf("close reservation root: %v", err) + } + reservation.root = nil + if err := reservation.bucket.Close(); err != nil { + t.Fatalf("close reservation bucket: %v", err) + } + reservation.bucket = nil + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + removals, err := restarted.ReconcileRemovals(context.Background()) + if err != nil { + t.Fatalf("reconcile removal with reservation: %v", err) + } + if len(removals) != 1 || + removals[0].operationID != result.Removal.operationID { + t.Fatalf("reconciled removals = %+v", removals) + } + if err := restarted.AcknowledgeRemoval(removals[0]); err != nil { + t.Fatalf("acknowledge recovered reservation: %v", err) + } + if _, err := os.Lstat(prepared.Path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("recovered acknowledgement reservation remains: %v", err) + } +} diff --git a/internal/workspace/removal_marker_artifact_test.go b/internal/workspace/removal_marker_artifact_test.go new file mode 100644 index 0000000..362d785 --- /dev/null +++ b/internal/workspace/removal_marker_artifact_test.go @@ -0,0 +1,77 @@ +package workspace + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestCleanupRemovalMarkerDiscardIsIdempotent(t *testing.T) { + record := removalMarkerArtifactTestRecord() + path := t.TempDir() + name := removalMarkerDiscardName(record) + if err := os.WriteFile( + filepath.Join(path, name), + []byte("partial\n"), + 0o600, + ); err != nil { + t.Fatalf("write marker discard: %v", err) + } + root, err := os.OpenRoot(path) + if err != nil { + t.Fatalf("open marker root: %v", err) + } + defer root.Close() + + if err := cleanupRemovalMarkerTemps(root, record); err != nil { + t.Fatalf("cleanup marker discard: %v", err) + } + if err := cleanupRemovalMarkerTemps(root, record); err != nil { + t.Fatalf("repeat marker discard cleanup: %v", err) + } + if _, err := root.Lstat(name); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("marker discard remains: %v", err) + } +} + +func TestRemoveRemovalMarkerRecoversDiscardedMarker(t *testing.T) { + record := removalMarkerArtifactTestRecord() + path := t.TempDir() + root, err := os.OpenRoot(path) + if err != nil { + t.Fatalf("open marker root: %v", err) + } + defer root.Close() + if err := installRemovalMarker( + root, + removalMarkerName(record), + record.Removal.DirectoryToken, + ); err != nil { + t.Fatalf("install removal marker: %v", err) + } + discardName := removalMarkerDiscardName(record) + if err := os.Rename( + filepath.Join(path, removalMarkerName(record)), + filepath.Join(path, discardName), + ); err != nil { + t.Fatalf("isolate removal marker: %v", err) + } + + if err := removeRemovalMarkerIfPresent(root, record); err != nil { + t.Fatalf("recover discarded marker: %v", err) + } + if _, err := root.Lstat(discardName); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("marker discard remains: %v", err) + } +} + +func removalMarkerArtifactTestRecord() workspaceRecord { + return workspaceRecord{ + AgentID: testAgentID, + Removal: &workspaceRemovalRecord{ + OperationID: "78787878-7878-4787-8787-787878787878", + DirectoryToken: "89898989-8989-4898-8989-898989898989", + }, + } +} diff --git a/internal/workspace/removal_reconcile_test.go b/internal/workspace/removal_reconcile_test.go index 1e9af49..36bff97 100644 --- a/internal/workspace/removal_reconcile_test.go +++ b/internal/workspace/removal_reconcile_test.go @@ -283,6 +283,9 @@ func TestReconcileDiscoversAcknowledgementQuarantineAfterRestart(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove( context.Background(), prepared.AgentID, diff --git a/internal/workspace/repository_capability.go b/internal/workspace/repository_capability.go index 56d8113..bfd651a 100644 --- a/internal/workspace/repository_capability.go +++ b/internal/workspace/repository_capability.go @@ -5,12 +5,15 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" "strings" ) +const maxPrivateGitCommonDirectorySize = 64 * 1024 + type repositoryCapability struct { manager *Manager path string @@ -59,6 +62,15 @@ func (r repositoryCapability) withRepositoryBinding( return r } +func (r repositoryCapability) withPrivateGitBinding( + gitPath string, + gitRoot *os.Root, +) repositoryCapability { + r.gitPath = gitPath + r.gitRoot = gitRoot + return r +} + func (r repositoryCapability) verifyBinding(ctx context.Context) error { if r.root == nil || r.gitRoot == nil || @@ -158,12 +170,201 @@ func (r repositoryCapability) runForward( return r.run(ctx, input, arguments...) } +func (r repositoryCapability) addPreparedWorktree( + ctx context.Context, + target Workspace, + prepared *preparedWorktreeTarget, + branch string, + expectedHeadOID string, +) ([]byte, error) { + path := prepared.path + root := prepared.root + verifyBranch := func() error { + oid, exists, err := r.refOID(ctx, "refs/heads/"+branch) + if err != nil { + return err + } + if !exists || oid != expectedHeadOID { + return errors.New( + "workspace: prepared branch moved from its expected HEAD", + ) + } + return nil + } + if err := errors.Join(r.verifyBinding(ctx), verifyBranch()); err != nil { + return nil, err + } + stagePrepared, err := preparePreparedWorktreeAdd( + target, + prepared, + r, + ) + if err != nil { + return nil, err + } + command, cleanup, err := rootedPreparedWorktreeGitCommand( + ctx, + r.manager.git, + r.path, + r.root, + r.commonPath, + r.commonRoot, + path, + root, + prepared.registrationStageRoot, + []string{ + "worktree", + "add", + "--quiet", + "--no-checkout", + ".", + branch, + }, + ) + if err != nil { + return nil, err + } + output, commandErr := runGitCommand(command, "") + finalizeErr := finalizePreparedWorktreeAdd( + ctx, + r, + target, + prepared, + commandErr == nil, + ) + var clearStageErr error + if stagePrepared && finalizeErr == nil { + clearStageErr = r.clearPreparedWorktreeStageIdentity(target) + } + verifyErr := errors.Join( + r.verifyBinding(ctx), + verifyRealPathRoot(path, root), + verifyBranch(), + ) + return output, errors.Join( + commandErr, + finalizeErr, + clearStageErr, + verifyErr, + cleanup(), + ) +} + +func (r repositoryCapability) clearPreparedWorktreeStageIdentity( + target Workspace, +) error { + record, exists, err := r.manager.readWorkspaceRecord(target.Path) + if err == nil && !exists { + err = errors.New("workspace: preparation record is missing") + } + if err != nil { + return err + } + if record.PreparedStageDirectoryIdentity == "" { + return errors.New( + "workspace: prepared stage directory identity is missing", + ) + } + if target.branchOperationID == "" || + record.BranchOperationID != target.branchOperationID || + !sameWorkspace(record.workspace(), target) { + return errors.New( + "workspace: preparation record changed while clearing stage identity", + ) + } + record.PreparedStageDirectoryIdentity = "" + if err := r.manager.replaceWorkspaceRecord(record); err != nil { + return fmt.Errorf( + "workspace: clear prepared stage directory identity: %w", + err, + ) + } + persisted, exists, err := r.manager.readWorkspaceRecord(target.Path) + if err != nil { + return err + } + if !exists || persisted.PreparedStageDirectoryIdentity != "" { + return errors.New( + "workspace: prepared stage directory identity was not cleared", + ) + } + return nil +} + func (r repositoryCapability) runPrivateGit( ctx context.Context, input string, arguments ...string, ) ([]byte, error) { - command, cleanup, err := r.privateGitCommand(ctx, arguments...) + return r.runPrivateGitAt(ctx, r.path, input, arguments...) +} + +func (r repositoryCapability) runPrivateGitAt( + ctx context.Context, + worktreePath string, + input string, + arguments ...string, +) ([]byte, error) { + return r.runPrivateGitAtWithOutputPolicy( + ctx, + worktreePath, + input, + false, + arguments..., + ) +} + +func (r repositoryCapability) runPrivateGitAtKeepingOutput( + ctx context.Context, + worktreePath string, + input string, + arguments ...string, +) ([]byte, error) { + return r.runPrivateGitAtWithOutputPolicy( + ctx, + worktreePath, + input, + true, + arguments..., + ) +} + +func (r repositoryCapability) runPrivateGitAtWithOutputPolicy( + ctx context.Context, + worktreePath string, + input string, + keepOutputOnError bool, + arguments ...string, +) ([]byte, error) { + command, cleanup, err := r.privateGitCommandAt( + ctx, + worktreePath, + arguments..., + ) + if err != nil { + return nil, err + } + run := runGitCommand + if keepOutputOnError { + run = runGitCommandKeepingOutput + } + output, commandErr := run(command, input) + return output, errors.Join(commandErr, cleanup()) +} + +func (r repositoryCapability) runWorktreeAt( + ctx context.Context, + worktreePath string, + worktreeRoot *os.Root, + input string, + arguments ...string, +) ([]byte, error) { + command, cleanup, err := r.worktreeCommandAt( + ctx, + worktreePath, + worktreeRoot, + arguments..., + ) if err != nil { return nil, err } @@ -188,49 +389,81 @@ func (r repositoryCapability) repositoryDirectories( err, ) } - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - if len(lines) != 2 { + return parseRepositoryDirectories(output) +} + +func parseRepositoryDirectories(output []byte) (string, string, error) { + raw := trimGitLineTerminator(output) + type directoryPair struct { + git string + common string + } + var candidates []directoryPair + for index := strings.IndexByte(raw, '\n'); index >= 0; { + gitPath := raw[:index] + commonPath := raw[index+1:] + if filepath.IsAbs(gitPath) && filepath.IsAbs(commonPath) { + resolvedGit, gitErr := resolveExistingDirectory(gitPath) + resolvedCommon, commonErr := resolveExistingDirectory(commonPath) + if gitErr == nil && commonErr == nil { + candidates = append(candidates, directoryPair{ + git: resolvedGit, + common: resolvedCommon, + }) + } + } + next := strings.IndexByte(raw[index+1:], '\n') + if next < 0 { + break + } + index += next + 1 + } + if len(candidates) != 1 { return "", "", errors.New( "workspace: Git directory query returned an invalid response", ) } - resolve := func(kind string, path string) (string, error) { - path = strings.TrimSpace(path) - if !filepath.IsAbs(path) { - return "", fmt.Errorf( - "workspace: %s Git directory is not absolute", - kind, - ) - } - resolved, resolveErr := resolvePath(path) - if resolveErr != nil { - return "", fmt.Errorf( - "workspace: resolve %s Git directory: %w", - kind, - resolveErr, - ) - } - return resolved, nil - } - gitPath, err := resolve("source", lines[0]) + return candidates[0].git, candidates[0].common, nil +} + +func resolveExistingDirectory(path string) (string, error) { + resolved, err := resolvePath(path) if err != nil { - return "", "", err + return "", err } - commonPath, err := resolve("common", lines[1]) + root, err := openRealPathRoot(resolved) if err != nil { - return "", "", err + return "", err } - return gitPath, commonPath, nil + if err := root.Close(); err != nil { + return "", err + } + return resolved, nil } func (r repositoryCapability) headOID( ctx context.Context, ) (string, error) { - output, err := r.runPrivateGit(ctx, "", "rev-parse", "--verify", "HEAD") + if sameRegisteredWorktreePath(r.gitPath, r.commonPath) { + output, err := r.run(ctx, "", "rev-parse", "--verify", "HEAD") + if err != nil { + return "", fmt.Errorf("workspace: resolve branch start: %w", err) + } + return validateHeadOID(strings.TrimSpace(string(output))) + } + registered, exists, err := r.worktreeRegistration(ctx, r.path) if err != nil { return "", fmt.Errorf("workspace: resolve branch start: %w", err) } - oid := strings.TrimSpace(string(output)) + if !exists { + return "", errors.New( + "workspace: source worktree registration is missing", + ) + } + return validateHeadOID(registered.head) +} + +func validateHeadOID(oid string) (string, error) { if oid == "" || strings.ContainsAny(oid, " \t\r\n") { return "", errors.New( "workspace: source HEAD returned an invalid object ID", @@ -266,15 +499,26 @@ func (r repositoryCapability) command( func (r repositoryCapability) worktreeCommand( ctx context.Context, arguments ...string, +) (*exec.Cmd, func() error, error) { + return r.worktreeCommandAt(ctx, r.path, r.root, arguments...) +} + +func (r repositoryCapability) worktreeCommandAt( + ctx context.Context, + worktreePath string, + worktreeRoot *os.Root, + arguments ...string, ) (*exec.Cmd, func() error, error) { if r.root != nil && r.gitRoot != nil { return rootedWorktreeGitCommand( ctx, r.manager.git, - r.path, - r.root, + worktreePath, + worktreeRoot, r.gitPath, r.gitRoot, + r.commonPath, + r.commonRoot, arguments, ) } @@ -284,14 +528,29 @@ func (r repositoryCapability) worktreeCommand( func (r repositoryCapability) privateGitCommand( ctx context.Context, arguments ...string, +) (*exec.Cmd, func() error, error) { + return r.privateGitCommandAt(ctx, r.path, arguments...) +} + +func (r repositoryCapability) privateGitCommandAt( + ctx context.Context, + worktreePath string, + arguments ...string, ) (*exec.Cmd, func() error, error) { if r.root != nil && r.gitRoot != nil { + if r.commonPath == "" || r.commonRoot == nil { + return nil, nil, errors.New( + "workspace: private Git binding has no common directory", + ) + } return rootedPrivateGitCommand( ctx, r.manager.git, - r.path, + worktreePath, r.gitPath, r.gitRoot, + r.commonPath, + r.commonRoot, arguments, ) } @@ -412,7 +671,7 @@ func (r repositoryCapability) worktreeRegistration( } path = filepath.Clean(path) for _, registered := range worktrees { - if registered.path == path { + if sameRegisteredWorktreePath(registered.path, path) { return registered, true, nil } } @@ -436,61 +695,272 @@ func (r repositoryCapability) pruneWorktrees( } func (r repositoryCapability) verifyPreparedWorktree( - ctx context.Context, + _ context.Context, target Workspace, gitDirectory string, registered registeredWorktree, ) error { + gitDirectory, err := r.boundGitDirectory( + gitDirectory, + target.AgentID, + ) + if err != nil { + return err + } if err := r.verifyPreparedWorktreeRegistration( target, registered, - "", + target.expectedHeadOID, ); err != nil { return err } - gitDirectory, err := r.boundGitDirectory( - gitDirectory, - target.AgentID, + return nil +} + +func (r repositoryCapability) verifyBoundPreparedWorktree( + _ context.Context, + target Workspace, + registered registeredWorktree, +) error { + if err := r.verifyPrivateGitCommonDirectory(); err != nil { + return err + } + if err := r.verifyPreparedWorktreeRegistration( + target, + registered, + target.expectedHeadOID, + ); err != nil { + return err + } + return r.verifyPrivateGitCommonDirectory() +} + +func (r repositoryCapability) verifyPrivateGitCommonDirectory() ( + result error, +) { + if r.gitRoot == nil || + r.commonRoot == nil || + r.gitPath == "" || + r.commonPath == "" { + return errors.New( + "workspace: private Git common directory binding is incomplete", + ) + } + if err := verifyRealPathRoot(r.gitPath, r.gitRoot); err != nil { + return err + } + if err := verifyRealPathRoot(r.commonPath, r.commonRoot); err != nil { + return err + } + + const name = "commondir" + pathInfo, err := r.gitRoot.Lstat(name) + if err != nil { + return fmt.Errorf( + "workspace: inspect private Git common directory pointer: %w", + err, + ) + } + if !pathInfo.Mode().IsRegular() || + pathInfo.Mode()&os.ModeSymlink != 0 { + return errors.New( + "workspace: private Git common directory pointer is not a regular file", + ) + } + file, err := r.gitRoot.Open(name) + if err != nil { + return fmt.Errorf( + "workspace: open private Git common directory pointer: %w", + err, + ) + } + defer func() { + result = errors.Join(result, file.Close()) + }() + before, err := file.Stat() + if err != nil { + return fmt.Errorf( + "workspace: inspect opened private Git common directory pointer: %w", + err, + ) + } + if !before.Mode().IsRegular() || + before.Mode()&os.ModeSymlink != 0 || + !os.SameFile(pathInfo, before) { + return errors.New( + "workspace: private Git common directory pointer changed while opening", + ) + } + raw, err := io.ReadAll( + io.LimitReader(file, maxPrivateGitCommonDirectorySize+1), ) + if err != nil { + return fmt.Errorf( + "workspace: read private Git common directory pointer: %w", + err, + ) + } + if len(raw) > maxPrivateGitCommonDirectorySize { + return errors.New( + "workspace: private Git common directory pointer is too large", + ) + } + after, err := file.Stat() + if err != nil { + return fmt.Errorf( + "workspace: reinspect private Git common directory pointer: %w", + err, + ) + } + current, err := r.gitRoot.Lstat(name) + if err != nil { + return fmt.Errorf( + "workspace: reinspect private Git common directory pointer path: %w", + err, + ) + } + if !after.Mode().IsRegular() || + after.Mode()&os.ModeSymlink != 0 || + !os.SameFile(before, after) || + before.Size() != after.Size() || + before.Mode() != after.Mode() || + !before.ModTime().Equal(after.ModTime()) || + !current.Mode().IsRegular() || + current.Mode()&os.ModeSymlink != 0 || + !os.SameFile(before, current) { + return errors.New( + "workspace: private Git common directory pointer changed while reading", + ) + } + + value := strings.TrimSuffix(string(raw), "\n") + if value == "" || strings.ContainsAny(value, "\x00\r\n") { + return errors.New( + "workspace: private Git common directory pointer is invalid", + ) + } + commonPath := value + if !filepath.IsAbs(commonPath) { + commonPath = filepath.Join(r.gitPath, commonPath) + } + commonPath, err = resolvePath(commonPath) + if err != nil { + return fmt.Errorf( + "workspace: resolve private Git common directory pointer: %w", + err, + ) + } + if commonPath != r.commonPath { + return errors.New( + "workspace: private Git common directory binding changed", + ) + } + commonRoot, err := openRealPathRoot(commonPath) + if err != nil { + return fmt.Errorf( + "workspace: open private Git common directory target: %w", + err, + ) + } + defer func() { + result = errors.Join(result, commonRoot.Close()) + }() + expectedIdentity, err := openedDirectoryIdentity(r.commonRoot) if err != nil { return err } - head, err := r.run( - ctx, + currentIdentity, err := openedDirectoryIdentity(commonRoot) + if err != nil { + return err + } + if currentIdentity != expectedIdentity { + return errors.New( + "workspace: private Git common directory identity changed", + ) + } + return errors.Join( + verifyRealPathRoot(r.gitPath, r.gitRoot), + verifyRealPathRoot(r.commonPath, r.commonRoot), + verifyRealPathRoot(commonPath, commonRoot), + ) +} + +func (r repositoryCapability) derivePreparedWorktreeExpectedHead( + ctx context.Context, + target Workspace, +) (string, error) { + if err := r.verifyBinding(ctx); err != nil { + return "", err + } + registered, exists, err := r.worktreeRegistration(ctx, target.Path) + if err != nil { + return "", err + } + if !exists { + return "", errors.New( + "workspace: prepared worktree registration disappeared", + ) + } + if err := r.verifyPreparedWorktreeRegistration( + target, + registered, "", - "--git-dir="+gitDirectory, - "rev-parse", - "--verify", - "HEAD", + ); err != nil { + return "", err + } + branchOID, exists, err := r.refOID( + ctx, + "refs/heads/"+target.Branch, ) if err != nil { - return fmt.Errorf( - "workspace: inspect prepared worktree HEAD: %w", - err, + return "", err + } + if !exists || branchOID != registered.head { + return "", errors.New( + "workspace: prepared branch does not match worktree registration", ) } - if strings.TrimSpace(string(head)) != registered.head { + if err := r.verifyBinding(ctx); err != nil { + return "", err + } + return branchOID, nil +} + +func (r repositoryCapability) verifyPreparationRefs( + ctx context.Context, + target Workspace, + requireOwnershipMarker bool, +) error { + if target.expectedHeadOID == "" { return errors.New( - "workspace: prepared worktree HEAD does not match registration", + "workspace: prepared worktree expected HEAD is unavailable", ) } - branch, err := r.run( + branchOID, exists, err := r.refOID( ctx, - "", - "--git-dir="+gitDirectory, - "symbolic-ref", - "--quiet", - "HEAD", + "refs/heads/"+target.Branch, ) if err != nil { - return fmt.Errorf( - "workspace: inspect prepared worktree branch: %w", - err, + return err + } + if !exists || branchOID != target.expectedHeadOID { + return errors.New( + "workspace: prepared branch moved from its expected HEAD", ) } - if strings.TrimSpace(string(branch)) != registered.branch { + if !target.createdBranch || !requireOwnershipMarker { + return nil + } + markerOID, exists, err := r.refOID( + ctx, + branchOwnershipRef(target.branchOperationID), + ) + if err != nil { + return err + } + if !exists || markerOID != target.expectedHeadOID { return errors.New( - "workspace: prepared worktree branch does not match registration", + "workspace: prepared branch ownership marker changed", ) } return nil @@ -501,7 +971,7 @@ func (repositoryCapability) verifyPreparedWorktreeRegistration( registered registeredWorktree, expectedHeadOID string, ) error { - if registered.path != target.Path || + if !sameRegisteredWorktreePath(registered.path, target.Path) || registered.detached || registered.branch != "refs/heads/"+target.Branch || registered.head == "" || @@ -518,6 +988,17 @@ func (repositoryCapability) verifyPreparedWorktreeRegistration( return nil } +func sameRegisteredWorktreePath(left string, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if left == right { + return true + } + leftInfo, leftErr := os.Stat(left) + rightInfo, rightErr := os.Stat(right) + return leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo) +} + func (r repositoryCapability) boundGitDirectory( path string, agentID string, @@ -531,12 +1012,19 @@ func (r repositoryCapability) boundGitDirectory( if strings.HasPrefix(name, agentID) && len(name) > len(agentID) { validName = strings.Trim(name[len(agentID):], "0123456789") == "" } + if name == "-" || + (strings.HasPrefix(name, "-") && + strings.Trim(name[1:], "0123456789") == "") { + validName = true + } if err != nil || !filepath.IsAbs(path) || filepath.Dir(name) != "." || !validName { - return "", errors.New( - "workspace: prepared Git directory does not match the managed worktree", + return "", fmt.Errorf( + "workspace: prepared Git directory %q does not match agent %q", + path, + agentID, ) } return filepath.Join("worktrees", name), nil diff --git a/internal/workspace/repository_guard_windows.go b/internal/workspace/repository_guard_windows.go index aaff520..f807cbf 100644 --- a/internal/workspace/repository_guard_windows.go +++ b/internal/workspace/repository_guard_windows.go @@ -39,7 +39,8 @@ func openRepositoryGuard( windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, nil, windows.OPEN_EXISTING, - windows.FILE_FLAG_BACKUP_SEMANTICS, + windows.FILE_FLAG_BACKUP_SEMANTICS| + windows.FILE_FLAG_OPEN_REPARSE_POINT, 0, ) if err != nil { @@ -48,6 +49,23 @@ func openRepositoryGuard( err, ) } + var handleInfo windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle( + handle, + &handleInfo, + ); err != nil { + _ = windows.CloseHandle(handle) + return nil, fmt.Errorf( + "workspace: inspect rooted Git directory handle: %w", + err, + ) + } + if handleInfo.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + _ = windows.CloseHandle(handle) + return nil, errors.New( + "workspace: rooted Git directory is a reparse point", + ) + } lock := os.NewFile(uintptr(handle), path) if lock == nil { _ = windows.CloseHandle(handle) diff --git a/internal/workspace/repository_guard_windows_test.go b/internal/workspace/repository_guard_windows_test.go new file mode 100644 index 0000000..5b00e37 --- /dev/null +++ b/internal/workspace/repository_guard_windows_test.go @@ -0,0 +1,41 @@ +//go:build windows + +package workspace + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestOpenRepositoryGuardRejectsDirectoryJunction(t *testing.T) { + parent := t.TempDir() + target := filepath.Join(parent, "target") + junction := filepath.Join(parent, "junction") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatalf("create target directory: %v", err) + } + command := exec.Command("cmd", "/c", "mklink", "/J", junction, target) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf( + "create directory junction: %v: %s", + err, + strings.TrimSpace(string(output)), + ) + } + root, err := openRealPathRoot(target) + if err != nil { + t.Fatalf("open target root: %v", err) + } + defer root.Close() + + guard, err := openRepositoryGuard(junction, root) + if guard != nil { + _ = guard.Close() + } + if err == nil || !strings.Contains(err.Error(), "reparse point") { + t.Fatalf("guard directory junction error = %v", err) + } +} diff --git a/internal/workspace/rooted_git.go b/internal/workspace/rooted_git.go index 19022b3..6fd6a0f 100644 --- a/internal/workspace/rooted_git.go +++ b/internal/workspace/rooted_git.go @@ -104,6 +104,17 @@ func (m *Manager) runRootedGitInputWithPolicy( } func runGitCommand(command *exec.Cmd, input string) ([]byte, error) { + output, err := runGitCommandKeepingOutput(command, input) + if err != nil { + return nil, err + } + return output, nil +} + +func runGitCommandKeepingOutput( + command *exec.Cmd, + input string, +) ([]byte, error) { command.Env = append(command.Environ(), "LC_ALL=C", "LANG=C") if input != "" { command.Stdin = strings.NewReader(input) @@ -119,10 +130,10 @@ func runGitCommand(command *exec.Cmd, input string) ([]byte, error) { detail = detail[:4096] } if detail != "" { - return nil, fmt.Errorf("%s: %w", detail, err) + return output, fmt.Errorf("%s: %w", detail, err) } } - return nil, err + return output, err } func rootedGitEnvironment(environment []string) []string { @@ -148,3 +159,15 @@ func boundGitEnvironment( "GIT_WORK_TREE="+worktree, ) } + +func boundGitEnvironmentWithCommon( + environment []string, + gitDirectory string, + worktree string, + commonDirectory string, +) []string { + return append( + boundGitEnvironment(environment, gitDirectory, worktree), + "GIT_COMMON_DIR="+commonDirectory, + ) +} diff --git a/internal/workspace/rooted_git_descriptor_unix_test.go b/internal/workspace/rooted_git_descriptor_unix_test.go new file mode 100644 index 0000000..1f47a3b --- /dev/null +++ b/internal/workspace/rooted_git_descriptor_unix_test.go @@ -0,0 +1,184 @@ +//go:build linux + +package workspace + +import ( + "context" + "fmt" + "os" + "path/filepath" + "slices" + "testing" +) + +func inheritedDirectoryPath(fd int) string { + return "/proc/self/fd/" + fmt.Sprint(fd) +} + +func TestRootedPrivateGitCommandBindsCommonDirectory(t *testing.T) { + parent := t.TempDir() + gitPath := filepath.Join(parent, "git") + commonPath := filepath.Join(parent, "common") + for _, path := range []string{gitPath, commonPath} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("create directory %q: %v", path, err) + } + } + gitRoot, err := openRealPathRoot(gitPath) + if err != nil { + t.Fatalf("open Git root: %v", err) + } + defer gitRoot.Close() + commonRoot, err := openRealPathRoot(commonPath) + if err != nil { + t.Fatalf("open common root: %v", err) + } + defer commonRoot.Close() + + command, cleanup, err := rootedPrivateGitCommand( + context.Background(), + "git", + filepath.Join(parent, "worktree"), + gitPath, + gitRoot, + commonPath, + commonRoot, + []string{"version"}, + ) + if err != nil { + t.Fatalf("create rooted private command: %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Errorf("cleanup rooted private command: %v", err) + } + }() + if len(command.ExtraFiles) != 2 { + t.Fatalf( + "private command inherited files = %d, want 2", + len(command.ExtraFiles), + ) + } + want := "GIT_COMMON_DIR=" + inheritedDirectoryPath(4) + if !slices.Contains(command.Env, want) { + t.Fatalf("private command environment lacks %q: %q", want, command.Env) + } +} + +func TestRootedPreparedWorktreeGitCommandBindsAllDirectories(t *testing.T) { + parent := t.TempDir() + repositoryPath := filepath.Join(parent, "repository") + commonPath := filepath.Join(parent, "common") + worktreePath := filepath.Join(parent, "worktree") + for _, path := range []string{repositoryPath, commonPath, worktreePath} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("create directory %q: %v", path, err) + } + } + repositoryRoot, err := openRealPathRoot(repositoryPath) + if err != nil { + t.Fatalf("open repository root: %v", err) + } + defer repositoryRoot.Close() + commonRoot, err := openRealPathRoot(commonPath) + if err != nil { + t.Fatalf("open common root: %v", err) + } + defer commonRoot.Close() + worktreeRoot, err := openRealPathRoot(worktreePath) + if err != nil { + t.Fatalf("open worktree root: %v", err) + } + defer worktreeRoot.Close() + + command, cleanup, err := rootedPreparedWorktreeGitCommand( + context.Background(), + "git", + repositoryPath, + repositoryRoot, + commonPath, + commonRoot, + worktreePath, + worktreeRoot, + nil, + []string{"version"}, + ) + if err != nil { + t.Fatalf("create rooted prepared command: %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Errorf("cleanup rooted prepared command: %v", err) + } + }() + if len(command.ExtraFiles) != 3 { + t.Fatalf("prepared command inherited files = %d, want 3", len(command.ExtraFiles)) + } + for _, want := range []string{ + "GIT_DIR=" + inheritedDirectoryPath(4), + "GIT_WORK_TREE=" + inheritedDirectoryPath(5), + } { + if !slices.Contains(command.Env, want) { + t.Fatalf("prepared command environment lacks %q: %q", want, command.Env) + } + } +} + +func TestRootedWorktreeGitCommandBindsAllDirectories(t *testing.T) { + parent := t.TempDir() + worktreePath := filepath.Join(parent, "worktree") + gitPath := filepath.Join(parent, "git") + commonPath := filepath.Join(parent, "common") + for _, path := range []string{worktreePath, gitPath, commonPath} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("create directory %q: %v", path, err) + } + } + worktreeRoot, err := openRealPathRoot(worktreePath) + if err != nil { + t.Fatalf("open worktree root: %v", err) + } + defer worktreeRoot.Close() + gitRoot, err := openRealPathRoot(gitPath) + if err != nil { + t.Fatalf("open Git root: %v", err) + } + defer gitRoot.Close() + commonRoot, err := openRealPathRoot(commonPath) + if err != nil { + t.Fatalf("open common root: %v", err) + } + defer commonRoot.Close() + + command, cleanup, err := rootedWorktreeGitCommand( + context.Background(), + "git", + worktreePath, + worktreeRoot, + gitPath, + gitRoot, + commonPath, + commonRoot, + []string{"version"}, + ) + if err != nil { + t.Fatalf("create rooted worktree command: %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Errorf("cleanup rooted worktree command: %v", err) + } + }() + if len(command.ExtraFiles) != 3 { + t.Fatalf("worktree command inherited files = %d, want 3", len(command.ExtraFiles)) + } + for _, want := range []string{ + "GIT_DIR=" + inheritedDirectoryPath(4), + "GIT_WORK_TREE=" + inheritedDirectoryPath(3), + "GIT_COMMON_DIR=" + inheritedDirectoryPath(5), + } { + if !slices.Contains(command.Env, want) { + t.Fatalf("worktree command environment lacks %q: %q", want, command.Env) + } + } +} diff --git a/internal/workspace/rooted_git_linux.go b/internal/workspace/rooted_git_linux.go index 51ad9fd..cd54d0b 100644 --- a/internal/workspace/rooted_git_linux.go +++ b/internal/workspace/rooted_git_linux.go @@ -60,6 +60,8 @@ func rootedPrivateGitCommand( worktreePath string, _ string, gitRoot *os.Root, + _ string, + commonRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { gitDirectory, err := gitRoot.Open(".") @@ -69,12 +71,21 @@ func rootedPrivateGitCommand( err, ) } + commonDirectory, err := commonRoot.Open(".") + if err != nil { + _ = gitDirectory.Close() + return nil, nil, fmt.Errorf( + "workspace: open rooted common Git directory: %w", + err, + ) + } command := exec.CommandContext(ctx, git) - command.ExtraFiles = []*os.File{gitDirectory} - command.Env = boundGitEnvironment( + command.ExtraFiles = []*os.File{gitDirectory, commonDirectory} + command.Env = boundGitEnvironmentWithCommon( command.Environ(), - ".", + "/proc/self/fd/3", worktreePath, + "/proc/self/fd/4", ) command.Args = append( command.Args, @@ -84,7 +95,13 @@ func rootedPrivateGitCommand( "/proc/self/fd/3", ) command.Args = append(command.Args, arguments...) - return command, gitDirectory.Close, nil + cleanup := func() error { + return errors.Join( + commonDirectory.Close(), + gitDirectory.Close(), + ) + } + return command, cleanup, nil } func rootedWorktreeGitCommand( @@ -94,29 +111,102 @@ func rootedWorktreeGitCommand( root *os.Root, _ string, gitRoot *os.Root, + _ string, + commonRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { + worktree, err := root.Open(".") + if err != nil { + return nil, nil, fmt.Errorf( + "workspace: open rooted Git worktree: %w", + err, + ) + } gitDirectory, err := gitRoot.Open(".") if err != nil { + _ = worktree.Close() return nil, nil, fmt.Errorf( "workspace: open rooted private Git directory: %w", err, ) } - worktree, err := root.Open(".") + commonDirectory, err := commonRoot.Open(".") if err != nil { + _ = worktree.Close() _ = gitDirectory.Close() return nil, nil, fmt.Errorf( - "workspace: open rooted Git worktree: %w", + "workspace: open rooted common Git directory: %w", err, ) } command := exec.CommandContext(ctx, git) - command.ExtraFiles = []*os.File{gitDirectory, worktree} + command.ExtraFiles = []*os.File{worktree, gitDirectory, commonDirectory} + command.Env = boundGitEnvironmentWithCommon( + command.Environ(), + "/proc/self/fd/4", + "/proc/self/fd/3", + "/proc/self/fd/5", + ) + command.Args = append( + command.Args, + "-c", + "core.hooksPath=/dev/null", + "-C", + "/proc/self/fd/3", + ) + command.Args = append(command.Args, arguments...) + cleanup := func() error { + return errors.Join( + commonDirectory.Close(), + gitDirectory.Close(), + worktree.Close(), + ) + } + return command, cleanup, nil +} + +func rootedPreparedWorktreeGitCommand( + ctx context.Context, + git string, + repositoryPath string, + repositoryRoot *os.Root, + commonPath string, + commonRoot *os.Root, + _ string, + worktreeRoot *os.Root, + _ *os.Root, + arguments []string, +) (*exec.Cmd, func() error, error) { + worktree, err := worktreeRoot.Open(".") + if err != nil { + return nil, nil, fmt.Errorf( + "workspace: open prepared worktree target: %w", + err, + ) + } + commonDirectory, err := commonRoot.Open(".") + if err != nil { + _ = worktree.Close() + return nil, nil, fmt.Errorf( + "workspace: open rooted common Git directory: %w", + err, + ) + } + repository, err := repositoryRoot.Open(".") + if err != nil { + _ = commonDirectory.Close() + _ = worktree.Close() + return nil, nil, fmt.Errorf( + "workspace: open rooted source repository: %w", + err, + ) + } + command := exec.CommandContext(ctx, git) + command.ExtraFiles = []*os.File{worktree, commonDirectory, repository} command.Env = boundGitEnvironment( command.Environ(), - ".", "/proc/self/fd/4", + "/proc/self/fd/5", ) command.Args = append( command.Args, @@ -127,7 +217,13 @@ func rootedWorktreeGitCommand( ) command.Args = append(command.Args, arguments...) cleanup := func() error { - return errors.Join(worktree.Close(), gitDirectory.Close()) + return errors.Join( + verifyRealPathRoot(repositoryPath, repositoryRoot), + verifyRealPathRoot(commonPath, commonRoot), + repository.Close(), + commonDirectory.Close(), + worktree.Close(), + ) } return command, cleanup, nil } diff --git a/internal/workspace/rooted_git_other.go b/internal/workspace/rooted_git_other.go index 0817629..20f9d84 100644 --- a/internal/workspace/rooted_git_other.go +++ b/internal/workspace/rooted_git_other.go @@ -29,6 +29,8 @@ func rootedPrivateGitCommand( string, string, *os.Root, + string, + *os.Root, []string, ) (*exec.Cmd, func() error, error) { return nil, nil, errors.New( @@ -43,9 +45,28 @@ func rootedWorktreeGitCommand( _ *os.Root, _ string, _ *os.Root, + _ string, + _ *os.Root, _ []string, ) (*exec.Cmd, func() error, error) { return nil, nil, errors.New( "workspace: rooted worktree Git commands are unsupported on this platform", ) } + +func rootedPreparedWorktreeGitCommand( + _ context.Context, + _ string, + _ string, + _ *os.Root, + _ string, + _ *os.Root, + _ string, + _ *os.Root, + _ *os.Root, + _ []string, +) (*exec.Cmd, func() error, error) { + return nil, nil, errors.New( + "workspace: rooted prepared worktree Git commands are unsupported on this platform", + ) +} diff --git a/internal/workspace/rooted_git_test.go b/internal/workspace/rooted_git_test.go index 8b7614d..d185190 100644 --- a/internal/workspace/rooted_git_test.go +++ b/internal/workspace/rooted_git_test.go @@ -1,11 +1,97 @@ package workspace import ( + "context" + "os" + "path/filepath" + "runtime" "slices" "strings" "testing" ) +func TestRootedPrivateGitCommandBindsCommonDirectoryForPlatform( + t *testing.T, +) { + switch runtime.GOOS { + case "darwin", "dragonfly", "freebsd", "linux", "netbsd", "openbsd", + "windows": + default: + t.Skip("rooted private Git execution is unsupported") + } + parent := t.TempDir() + gitPath := filepath.Join(parent, "git") + commonPath := filepath.Join(parent, "common") + for _, path := range []string{gitPath, commonPath} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("create directory %q: %v", path, err) + } + } + gitRoot, err := openRealPathRoot(gitPath) + if err != nil { + t.Fatalf("open Git root: %v", err) + } + defer gitRoot.Close() + commonRoot, err := openRealPathRoot(commonPath) + if err != nil { + t.Fatalf("open common root: %v", err) + } + defer commonRoot.Close() + + command, cleanup, err := rootedPrivateGitCommand( + context.Background(), + "git", + filepath.Join(parent, "worktree"), + gitPath, + gitRoot, + commonPath, + commonRoot, + []string{"version"}, + ) + if err != nil { + t.Fatalf("create rooted private command: %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Errorf("cleanup rooted private command: %v", err) + } + }() + wantExtraFiles := 1 + if runtime.GOOS == "linux" { + wantExtraFiles = 2 + } else if runtime.GOOS == "windows" { + wantExtraFiles = 0 + } + if len(command.ExtraFiles) != wantExtraFiles { + t.Fatalf( + "private command inherited files = %d, want %d", + len(command.ExtraFiles), + wantExtraFiles, + ) + } + switch runtime.GOOS { + case "linux": + want := "GIT_COMMON_DIR=/proc/self/fd/4" + if !slices.Contains(command.Env, want) { + t.Fatalf("private command environment lacks %q", want) + } + case "windows": + want := "GIT_COMMON_DIR=" + commonPath + if !slices.Contains(command.Env, want) { + t.Fatalf("private command environment lacks %q", want) + } + default: + for _, entry := range command.Env { + if strings.HasPrefix(entry, "GIT_COMMON_DIR=") { + t.Fatalf( + "private command reopens common Git directory: %q", + entry, + ) + } + } + } +} + func TestBoundGitEnvironmentRemovesInheritedGitConfiguration(t *testing.T) { environment := boundGitEnvironment( []string{ @@ -36,3 +122,23 @@ func TestBoundGitEnvironmentRemovesInheritedGitConfiguration(t *testing.T) { } } } + +func TestBoundGitEnvironmentWithCommonSetsExplicitCommonDirectory( + t *testing.T, +) { + environment := boundGitEnvironmentWithCommon( + []string{"GIT_COMMON_DIR=/replacement"}, + "/git", + "/worktree", + "/common", + ) + for _, want := range []string{ + "GIT_DIR=/git", + "GIT_WORK_TREE=/worktree", + "GIT_COMMON_DIR=/common", + } { + if !slices.Contains(environment, want) { + t.Fatalf("bound environment %q does not contain %q", environment, want) + } + } +} diff --git a/internal/workspace/rooted_git_unix.go b/internal/workspace/rooted_git_unix.go index 5625592..a00cf0b 100644 --- a/internal/workspace/rooted_git_unix.go +++ b/internal/workspace/rooted_git_unix.go @@ -93,8 +93,16 @@ func rootedPrivateGitCommand( worktreePath string, _ string, gitRoot *os.Root, + commonPath string, + commonRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { + if err := verifyRealPathRoot(commonPath, commonRoot); err != nil { + return nil, nil, fmt.Errorf( + "workspace: verify rooted common Git directory: %w", + err, + ) + } directory, err := gitRoot.Open(".") if err != nil { return nil, nil, fmt.Errorf( @@ -129,7 +137,13 @@ func rootedPrivateGitCommand( ".", worktreePath, ) - return command, directory.Close, nil + cleanup := func() error { + return errors.Join( + verifyRealPathRoot(commonPath, commonRoot), + directory.Close(), + ) + } + return command, cleanup, nil } func rootedWorktreeGitCommand( @@ -139,6 +153,8 @@ func rootedWorktreeGitCommand( root *os.Root, gitPath string, gitRoot *os.Root, + commonPath string, + commonRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { if err := verifyRealPathRoot(gitPath, gitRoot); err != nil { @@ -147,6 +163,12 @@ func rootedWorktreeGitCommand( err, ) } + if err := verifyRealPathRoot(commonPath, commonRoot); err != nil { + return nil, nil, fmt.Errorf( + "workspace: verify rooted common Git directory: %w", + err, + ) + } directory, err := root.Open(".") if err != nil { return nil, nil, fmt.Errorf( @@ -184,6 +206,114 @@ func rootedWorktreeGitCommand( cleanup := func() error { return errors.Join( verifyRealPathRoot(gitPath, gitRoot), + verifyRealPathRoot(commonPath, commonRoot), + directory.Close(), + ) + } + return command, cleanup, nil +} + +func rootedPreparedWorktreeGitCommand( + ctx context.Context, + git string, + repositoryPath string, + repositoryRoot *os.Root, + commonPath string, + commonRoot *os.Root, + worktreePath string, + worktreeRoot *os.Root, + stageRoot *os.Root, + arguments []string, +) (*exec.Cmd, func() error, error) { + if err := verifyRealPathRoot(repositoryPath, repositoryRoot); err != nil { + return nil, nil, fmt.Errorf( + "workspace: verify rooted source repository: %w", + err, + ) + } + if err := verifyRealPathRoot(worktreePath, worktreeRoot); err != nil { + return nil, nil, fmt.Errorf( + "workspace: verify prepared worktree target: %w", + err, + ) + } + stageName, err := bsdPreparedWorktreeStageName(worktreePath) + if err != nil { + return nil, nil, err + } + if stageRoot == nil { + return nil, nil, errors.New( + "workspace: prepared registration stage is unavailable", + ) + } + if err := verifyRootEntryUnchanged( + commonRoot, + stageName, + stageRoot, + ); err != nil { + return nil, nil, err + } + entries, err := readRootDirectory(stageRoot) + if err != nil { + return nil, nil, err + } + if len(entries) != 0 { + return nil, nil, errors.New( + "workspace: prepared registration stage is not empty", + ) + } + if len(arguments) < 2 || arguments[len(arguments)-2] != "." { + return nil, nil, errors.New( + "workspace: prepared worktree command has an unexpected target", + ) + } + directory, err := stageRoot.Open(".") + if err != nil { + return nil, nil, fmt.Errorf( + "workspace: open rooted prepared registration stage: %w", + err, + ) + } + commonGuard, err := openRepositoryGuard(commonPath, commonRoot) + if err != nil { + _ = directory.Close() + return nil, nil, err + } + executable, err := os.Executable() + if err != nil { + _ = commonGuard.Close() + _ = directory.Close() + return nil, nil, fmt.Errorf( + "workspace: locate rooted Git helper: %w", + err, + ) + } + git, err = exec.LookPath(git) + if err != nil { + _ = commonGuard.Close() + _ = directory.Close() + return nil, nil, err + } + helperArguments := []string{ + rootedGitHelperArgument, + git, + "-c", + "core.hooksPath=/dev/null", + } + helperArguments = append(helperArguments, arguments...) + command := exec.CommandContext(ctx, executable, helperArguments...) + command.ExtraFiles = []*os.File{directory} + command.Env = boundGitEnvironment( + command.Environ(), + "..", + "..", + ) + cleanup := func() error { + return errors.Join( + verifyRealPathRoot(repositoryPath, repositoryRoot), + verifyRealPathRoot(commonPath, commonRoot), + verifyRealPathRoot(worktreePath, worktreeRoot), + commonGuard.Close(), directory.Close(), ) } diff --git a/internal/workspace/rooted_git_windows.go b/internal/workspace/rooted_git_windows.go index 40be6e7..d77e2fd 100644 --- a/internal/workspace/rooted_git_windows.go +++ b/internal/workspace/rooted_git_windows.go @@ -48,12 +48,18 @@ func rootedPrivateGitCommand( worktreePath string, gitPath string, gitRoot *os.Root, + commonPath string, + commonRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { - guard, err := openRepositoryGuard(gitPath, gitRoot) + gitGuard, err := openRepositoryGuard(gitPath, gitRoot) if err != nil { return nil, nil, err } + commonGuard, err := openRepositoryGuard(commonPath, commonRoot) + if err != nil { + return nil, nil, errors.Join(err, gitGuard.Close()) + } command := exec.CommandContext( ctx, git, @@ -63,12 +69,19 @@ func rootedPrivateGitCommand( )..., ) command.Dir = gitPath - command.Env = boundGitEnvironment( + command.Env = boundGitEnvironmentWithCommon( command.Environ(), gitPath, worktreePath, + commonPath, ) - return command, guard.Close, nil + cleanup := func() error { + return errors.Join( + commonGuard.Close(), + gitGuard.Close(), + ) + } + return command, cleanup, nil } func rootedWorktreeGitCommand( @@ -78,6 +91,8 @@ func rootedWorktreeGitCommand( root *os.Root, gitPath string, gitRoot *os.Root, + commonPath string, + commonRoot *os.Root, arguments []string, ) (*exec.Cmd, func() error, error) { command, closeWorktree, err := rootedGitCommand( @@ -96,8 +111,81 @@ func rootedWorktreeGitCommand( if err != nil { return nil, nil, errors.Join(err, closeWorktree()) } + commonGuard, err := openRepositoryGuard(commonPath, commonRoot) + if err != nil { + return nil, nil, errors.Join( + err, + gitGuard.Close(), + closeWorktree(), + ) + } + command.Env = boundGitEnvironmentWithCommon( + command.Environ(), + gitPath, + path, + commonPath, + ) + cleanup := func() error { + return errors.Join( + commonGuard.Close(), + gitGuard.Close(), + closeWorktree(), + ) + } + return command, cleanup, nil +} + +func rootedPreparedWorktreeGitCommand( + ctx context.Context, + git string, + repositoryPath string, + repositoryRoot *os.Root, + commonPath string, + commonRoot *os.Root, + worktreePath string, + worktreeRoot *os.Root, + _ *os.Root, + arguments []string, +) (*exec.Cmd, func() error, error) { + worktreeGuard, err := openRepositoryGuard(worktreePath, worktreeRoot) + if err != nil { + return nil, nil, err + } + commonGuard, err := openRepositoryGuard(commonPath, commonRoot) + if err != nil { + return nil, nil, errors.Join(err, worktreeGuard.Close()) + } + repositoryGuard, err := openRepositoryGuard( + repositoryPath, + repositoryRoot, + ) + if err != nil { + return nil, nil, errors.Join( + err, + commonGuard.Close(), + worktreeGuard.Close(), + ) + } + command := exec.CommandContext( + ctx, + git, + append( + []string{"-c", "core.hooksPath=NUL"}, + arguments..., + )..., + ) + command.Dir = worktreePath + command.Env = boundGitEnvironment( + command.Environ(), + commonPath, + repositoryPath, + ) cleanup := func() error { - return errors.Join(gitGuard.Close(), closeWorktree()) + return errors.Join( + repositoryGuard.Close(), + commonGuard.Close(), + worktreeGuard.Close(), + ) } return command, cleanup, nil } diff --git a/internal/workspace/rooted_git_windows_test.go b/internal/workspace/rooted_git_windows_test.go new file mode 100644 index 0000000..4abb18b --- /dev/null +++ b/internal/workspace/rooted_git_windows_test.go @@ -0,0 +1,67 @@ +//go:build windows + +package workspace + +import ( + "context" + "os" + "path/filepath" + "slices" + "testing" +) + +func TestRootedWorktreeGitCommandBindsCommonDirectory(t *testing.T) { + parent := t.TempDir() + worktreePath := filepath.Join(parent, "worktree") + gitPath := filepath.Join(parent, "git") + commonPath := filepath.Join(parent, "common") + for _, path := range []string{worktreePath, gitPath, commonPath} { + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("create directory %q: %v", path, err) + } + } + worktreeRoot, err := openRealPathRoot(worktreePath) + if err != nil { + t.Fatalf("open worktree root: %v", err) + } + defer worktreeRoot.Close() + gitRoot, err := openRealPathRoot(gitPath) + if err != nil { + t.Fatalf("open Git root: %v", err) + } + defer gitRoot.Close() + commonRoot, err := openRealPathRoot(commonPath) + if err != nil { + t.Fatalf("open common root: %v", err) + } + defer commonRoot.Close() + + command, cleanup, err := rootedWorktreeGitCommand( + context.Background(), + "git", + worktreePath, + worktreeRoot, + gitPath, + gitRoot, + commonPath, + commonRoot, + []string{"read-tree", "--reset", "-u", "HEAD"}, + ) + if err != nil { + t.Fatalf("create rooted worktree command: %v", err) + } + defer func() { + if err := cleanup(); err != nil { + t.Errorf("cleanup rooted worktree command: %v", err) + } + }() + for _, want := range []string{ + "GIT_DIR=" + gitPath, + "GIT_WORK_TREE=" + worktreePath, + "GIT_COMMON_DIR=" + commonPath, + } { + if !slices.Contains(command.Env, want) { + t.Fatalf("worktree command environment lacks %q: %q", want, command.Env) + } + } +} diff --git a/internal/workspace/secure_remove.go b/internal/workspace/secure_remove.go index d3e2728..7ae2116 100644 --- a/internal/workspace/secure_remove.go +++ b/internal/workspace/secure_remove.go @@ -1,6 +1,8 @@ package workspace import ( + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -15,11 +17,7 @@ func (m *Manager) ensureManagedRoot() (result error) { if m.rootErr != nil { return m.rootErr } - dataDir := filepath.Dir(m.root) - if err := ensureDirectory(dataDir); err != nil { - return err - } - root, err := m.openDataDirectoryRoot() + root, err := m.openOrCreateDataDirectoryRoot() if err != nil { return err } @@ -56,6 +54,28 @@ func (m *Manager) ensureManagedRoot() (result error) { return opened.Close() } +func (m *Manager) openOrCreateDataDirectoryRoot() (*os.Root, error) { + if m.dataDirInfo != nil { + return m.openDataDirectoryRoot() + } + path := filepath.Dir(m.root) + root, err := openOrCreateRealPathRoot(path) + if err != nil { + return nil, fmt.Errorf("workspace: create data directory: %w", err) + } + opened, err := root.Stat(".") + if err != nil { + _ = root.Close() + return nil, fmt.Errorf("workspace: inspect created data directory: %w", err) + } + if err := verifyRealPathRoot(path, root); err != nil { + _ = root.Close() + return nil, err + } + m.dataDirInfo = opened + return root, nil +} + func (m *Manager) pinDataDirectory() error { root, err := m.openDataDirectoryRoot() if err != nil { @@ -262,10 +282,67 @@ func removalMarkerName(record workspaceRecord) string { return ".drove-removal-" + record.Removal.OperationID } +func removalMarkerTemporaryPrefix(record workspaceRecord) string { + return removalMarkerName(record) + ".install-" + + record.Removal.DirectoryToken + "-" +} + +func removalMarkerTemporaryName(record workspaceRecord) string { + return removalMarkerTemporaryPrefix(record) + uuid.NewString() +} + +func removalMarkerDiscardPrefix(record workspaceRecord) string { + return removalMarkerName(record) + ".discard-" + + record.Removal.DirectoryToken + "-" +} + +func removalMarkerDiscardName(record workspaceRecord) string { + return removalMarkerDiscardPrefix(record) + uuid.NewString() +} + +func isRemovalMarkerTemporaryName( + record workspaceRecord, + name string, +) bool { + return validRemovalMarkerArtifactName( + name, + removalMarkerTemporaryPrefix(record), + ) +} + +func isRemovalMarkerDiscardName( + record workspaceRecord, + name string, +) bool { + return validRemovalMarkerArtifactName( + name, + removalMarkerDiscardPrefix(record), + ) +} + +func validRemovalMarkerArtifactName(name string, prefix string) bool { + suffix, found := strings.CutPrefix(name, prefix) + return found && canonicalUUID(suffix) +} + +func removalQuarantineIsolationName(name string) string { + const suffix = ".rename" + if strings.HasSuffix(name, suffix) { + return strings.TrimSuffix(name, suffix) + } + return name + suffix +} + func validateRemovalMarkerPhase( root *os.Root, record workspaceRecord, ) error { + if err := cleanupRemovalMarkerTemps(root, record); err != nil { + return err + } + if record.Removal != nil && record.Removal.ContentsCleared { + return validateClearedRemovalDirectory(root, record, false) + } if record.Removal != nil && record.Removal.Started { return verifyRemovalMarker(root, record) } @@ -280,58 +357,236 @@ func ensureRemovalMarker( record.Removal.DirectoryToken == "" { return errors.New("workspace: removal record has no directory token") } + if err := cleanupRemovalMarkerTemps(root, record); err != nil { + return err + } + defer func() { + result = errors.Join( + result, + cleanupRecordDeletionNamespace(root), + ) + }() name := removalMarkerName(record) _, err := root.Lstat(name) switch { case errors.Is(err, os.ErrNotExist): - file, err := root.OpenFile( - name, - os.O_WRONLY|os.O_CREATE|os.O_EXCL, - 0o600, + if err := installRemovalMarker(root, name, record.Removal.DirectoryToken); err != nil { + return err + } + case err != nil: + return fmt.Errorf("workspace: inspect removal marker: %w", err) + } + if err := verifyRemovalMarker(root, record); err == nil { + return nil + } + if err := removeIncompleteRemovalMarker(root, name, record); err != nil { + return err + } + if err := installRemovalMarker( + root, + name, + record.Removal.DirectoryToken, + ); err != nil { + return err + } + return verifyRemovalMarker(root, record) +} + +func installRemovalMarker( + root *os.Root, + name string, + token string, +) (result error) { + temporaryName := name + ".install-" + token + "-" + uuid.NewString() + file, err := root.OpenFile( + temporaryName, + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + 0o600, + ) + if err != nil { + return fmt.Errorf("workspace: create removal marker: %w", err) + } + cleanupOnFailure := true + defer func() { + if cleanupOnFailure { + result = errors.Join( + result, + removeOwnedRecordPathIfSame( + root, + temporaryName, + file, + name+".install-"+token+"-"+uuid.NewString(), + nil, + ), + syncRecordBucket(root, temporaryName), + ) + } + result = errors.Join(result, file.Close()) + }() + payload := token + "\n" + written, err := io.WriteString(file, payload) + if err != nil { + return fmt.Errorf("workspace: write removal marker: %w", err) + } + if written != len(payload) { + return fmt.Errorf("workspace: write removal marker: %w", io.ErrShortWrite) + } + if err := file.Sync(); err != nil { + return fmt.Errorf("workspace: sync removal marker: %w", err) + } + directory, err := openRecordDirectory(root) + if err != nil { + return fmt.Errorf("workspace: open removal marker directory: %w", err) + } + installed, renameErr := renameRecordFile( + directory, + file, + temporaryName, + name, + false, + ) + if !installed { + closeDirectoryErr := directory.Close() + return fmt.Errorf( + "workspace: install removal marker: %w", + errors.Join(renameErr, closeDirectoryErr), ) + } + cleanupOnFailure = false + syncErr := syncRecordDirectory(directory) + closeDirectoryErr := directory.Close() + if err := errors.Join(renameErr, syncErr, closeDirectoryErr); err != nil { + return fmt.Errorf("workspace: sync removal marker directory: %w", err) + } + return nil +} + +func cleanupRemovalMarkerTemps( + root *os.Root, + record workspaceRecord, +) (result error) { + if record.Removal == nil || + record.Removal.OperationID == "" || + record.Removal.DirectoryToken == "" { + return errors.New( + "workspace: removal record has no marker identity", + ) + } + entries, err := readRootDirectory(root) + if err != nil { + return fmt.Errorf("workspace: inspect removal marker staging files: %w", err) + } + removed := false + for _, entry := range entries { + temporary := isRemovalMarkerTemporaryName(record, entry.Name()) + discard := isRemovalMarkerDiscardName(record, entry.Name()) + if !temporary && !discard { + if strings.HasPrefix( + entry.Name(), + removalMarkerTemporaryPrefix(record), + ) || strings.HasPrefix( + entry.Name(), + removalMarkerDiscardPrefix(record), + ) { + return fmt.Errorf( + "workspace: removal marker artifact %q has an invalid name", + entry.Name(), + ) + } + continue + } + info, err := root.Lstat(entry.Name()) if err != nil { return fmt.Errorf( - "workspace: create removal marker: %w", + "workspace: inspect removal marker staging file: %w", err, ) } - if _, err := io.WriteString( - file, - record.Removal.DirectoryToken+"\n", - ); err != nil { - _ = file.Close() - return fmt.Errorf( - "workspace: write removal marker: %w", - err, + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New( + "workspace: removal marker staging path is not a regular file", ) } - syncErr := file.Sync() - closeErr := file.Close() - if err := errors.Join(syncErr, closeErr); err != nil { + file, err := root.Open(entry.Name()) + if err != nil { return fmt.Errorf( - "workspace: sync removal marker: %w", + "workspace: open removal marker staging file: %w", err, ) } - directory, err := root.Open(".") - if err != nil { - return fmt.Errorf( - "workspace: open removal marker directory: %w", - err, + opened, statErr := file.Stat() + if statErr == nil && !os.SameFile(info, opened) { + statErr = errors.New( + "workspace: removal marker staging file changed while opening", ) } - syncErr = syncRecordDirectory(directory) - closeErr = directory.Close() - if err := errors.Join(syncErr, closeErr); err != nil { - return fmt.Errorf( - "workspace: sync removal marker directory: %w", - err, + removeErr := error(nil) + if statErr == nil { + isolatedName := removalMarkerDiscardName(record) + if temporary { + isolatedName = removalMarkerTemporaryName(record) + } + removeErr = removeOwnedRecordPath( + root, + entry.Name(), + file, + isolatedName, + nil, + nil, ) } - case err != nil: - return fmt.Errorf("workspace: inspect removal marker: %w", err) + closeErr := file.Close() + if err := errors.Join(statErr, removeErr, closeErr); err != nil { + return err + } + removed = true } - return verifyRemovalMarker(root, record) + var syncErr error + if removed { + syncErr = syncRecordBucket(root, removalMarkerName(record)) + } + return errors.Join(syncErr, cleanupRecordDeletionNamespace(root)) +} + +func removeIncompleteRemovalMarker( + root *os.Root, + name string, + record workspaceRecord, +) (result error) { + info, err := root.Lstat(name) + if err != nil { + return fmt.Errorf("workspace: inspect incomplete removal marker: %w", err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("workspace: incomplete removal marker is not a regular file") + } + file, err := root.Open(name) + if err != nil { + return fmt.Errorf("workspace: open incomplete removal marker: %w", err) + } + defer func() { + result = errors.Join(result, file.Close()) + }() + opened, err := file.Stat() + if err != nil { + return fmt.Errorf("workspace: inspect incomplete removal marker: %w", err) + } + if !os.SameFile(info, opened) { + return errors.New( + "workspace: incomplete removal marker changed while opening", + ) + } + if err := removeOwnedRecordPath( + root, + name, + file, + removalMarkerDiscardName(record), + nil, + nil, + ); err != nil { + return fmt.Errorf("workspace: remove incomplete removal marker: %w", err) + } + return nil } func verifyRemovalMarker( @@ -364,6 +619,29 @@ func verifyRemovalMarker( if !os.SameFile(info, opened) { return errors.New("workspace: removal marker changed while opening") } + if err := validateRemovalMarkerFile(file, record); err != nil { + return err + } + current, err := root.Lstat(name) + if err != nil { + return fmt.Errorf("workspace: recheck removal marker: %w", err) + } + if !os.SameFile(opened, current) { + return errors.New("workspace: removal marker changed while reading") + } + return nil +} + +func validateRemovalMarkerFile( + file *os.File, + record workspaceRecord, +) error { + if record.Removal == nil || record.Removal.DirectoryToken == "" { + return errors.New("workspace: removal record has no directory token") + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("workspace: rewind removal marker: %w", err) + } raw, err := io.ReadAll(io.LimitReader(file, 128)) if err != nil { return fmt.Errorf("workspace: read removal marker: %w", err) @@ -371,50 +649,134 @@ func verifyRemovalMarker( if string(raw) != record.Removal.DirectoryToken+"\n" { return errors.New("workspace: removal marker token mismatch") } - current, err := root.Lstat(name) + return nil +} + +func removalDirectoryContainsOnlyMarker( + root *os.Root, + record workspaceRecord, +) (bool, error) { + if err := cleanupRemovalMarkerTemps(root, record); err != nil { + return false, err + } + entries, err := readRootDirectory(root) if err != nil { - return fmt.Errorf("workspace: recheck removal marker: %w", err) + return false, err } - if !os.SameFile(opened, current) { - return errors.New("workspace: removal marker changed while reading") + if len(entries) != 1 || entries[0].Name() != removalMarkerName(record) { + return false, nil } - return nil + if err := verifyRemovalMarker(root, record); err != nil { + return false, err + } + return true, nil } -func removeRemovalMarkerIfPresent( +func validateClearedRemovalDirectory( root *os.Root, record workspaceRecord, + requireMarker bool, ) error { + if err := cleanupRecordDeletionNamespace(root); err != nil { + return err + } + entries, err := readRootDirectory(root) + if err != nil { + return err + } + markerPresent := false + for _, entry := range entries { + if entry.Name() != removalMarkerName(record) { + return fmt.Errorf( + "workspace: cleared removal quarantine contains unexpected entry %q", + entry.Name(), + ) + } + if markerPresent { + return errors.New( + "workspace: cleared removal quarantine contains duplicate markers", + ) + } + markerPresent = true + } + if !markerPresent { + if requireMarker { + return errors.New( + "workspace: cleared removal quarantine marker is missing", + ) + } + return nil + } + return verifyRemovalMarker(root, record) +} + +func removeRootEntriesExcept( + root *os.Root, + entries []os.DirEntry, + preserved string, +) error { + for _, entry := range entries { + if entry.Name() == preserved { + continue + } + if err := removeAllFromRoot(root, entry.Name()); err != nil { + return err + } + } + directory, err := openRecordDirectory(root) + if err != nil { + return err + } + syncErr := syncRecordDirectory(directory) + closeErr := directory.Close() + return errors.Join(syncErr, closeErr) +} + +func removeRemovalMarkerIfPresent( + root *os.Root, + record workspaceRecord, +) (result error) { + if err := cleanupRemovalMarkerTemps(root, record); err != nil { + return err + } name := removalMarkerName(record) - _, err := root.Lstat(name) + info, err := root.Lstat(name) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return fmt.Errorf("workspace: inspect removal marker: %w", err) } - if err := verifyRemovalMarker(root, record); err != nil { - return err + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("workspace: removal marker is not a regular file") } - if err := root.Remove(name); err != nil { - return fmt.Errorf("workspace: remove removal marker: %w", err) + file, err := root.Open(name) + if err != nil { + return fmt.Errorf("workspace: open removal marker: %w", err) } - directory, err := root.Open(".") + defer func() { + result = errors.Join(result, file.Close()) + }() + opened, err := file.Stat() if err != nil { - return fmt.Errorf( - "workspace: open removal marker directory: %w", - err, - ) + return fmt.Errorf("workspace: inspect opened removal marker: %w", err) } - syncErr := syncRecordDirectory(directory) - closeErr := directory.Close() - if err := errors.Join(syncErr, closeErr); err != nil { - return fmt.Errorf( - "workspace: sync removal marker deletion: %w", - err, - ) + if !os.SameFile(info, opened) { + return errors.New("workspace: removal marker changed while opening") } - return nil + if err := removeOwnedRecordPath( + root, + name, + file, + removalMarkerDiscardName(record), + func(candidate *os.File) error { + return validateRemovalMarkerFile(candidate, record) + }, + nil, + ); err != nil { + return fmt.Errorf("workspace: remove removal marker: %w", err) + } + return cleanupRecordDeletionNamespace(root) } func findRemovalQuarantine( @@ -513,7 +875,7 @@ func quarantineManagedPath( return "", false, err } name = removalQuarantinePrefix(record) + uuid.NewString() - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return "", false, err } @@ -521,6 +883,7 @@ func quarantineManagedPath( directory, openedInfo, record.AgentID, + removalQuarantineIsolationName(name), name, ) syncErr := syncRecordDirectory(directory) @@ -535,12 +898,8 @@ func quarantineManagedPath( if !current.IsDir() || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(openedInfo, current) { - restoreErr := restoreManagedQuarantine(bucket, record, name) - return name, true, errors.Join( - errors.New( - "workspace: managed path changed while entering quarantine", - ), - restoreErr, + return name, true, errors.New( + "workspace: managed path changed while entering quarantine", ) } return name, true, nil @@ -562,7 +921,7 @@ func restoreManagedQuarantine( if err != nil { return err } - directory, err := bucket.Open(".") + directory, err := openRecordDirectory(bucket) if err != nil { return err } @@ -570,6 +929,7 @@ func restoreManagedQuarantine( directory, openedInfo, name, + removalQuarantineIsolationName(name), record.AgentID, ) syncErr := syncRecordDirectory(directory) @@ -583,34 +943,6 @@ func restoreManagedQuarantine( return verifyRootEntryUnchanged(bucket, record.AgentID, opened) } -func (m *Manager) removeManagedBucketIfEmpty( - target Workspace, -) (result error) { - if err := m.validateManagedPath(target); err != nil { - return err - } - root, err := m.openWorktreeRoot() - if err != nil { - return err - } - defer func() { - result = errors.Join(result, root.Close()) - }() - name := repositoryHash(target.Repository) - err = root.Remove(name) - switch { - case err == nil: - delete(m.bucketInfo, name) - return nil - case errors.Is(err, os.ErrNotExist): - return nil - case isDirectoryNotEmptyError(err): - return nil - default: - return fmt.Errorf("remove repository bucket: %w", err) - } -} - func (m *Manager) openRecordBucket( worktreePath string, ) (*os.Root, string, error) { @@ -711,6 +1043,89 @@ func openRealPathRoot(path string) (*os.Root, error) { return root, nil } +func openOrCreateRealPathRoot(path string) (*os.Root, error) { + path = filepath.Clean(path) + if !filepath.IsAbs(path) { + return nil, fmt.Errorf("%q is not an absolute path", path) + } + volumeRoot := filepath.VolumeName(path) + string(filepath.Separator) + relative, err := filepath.Rel(volumeRoot, path) + if err != nil { + return nil, fmt.Errorf("resolve path below volume root: %w", err) + } + root, err := openRealPathRoot(volumeRoot) + if err != nil { + return nil, fmt.Errorf("open volume root %q: %w", volumeRoot, err) + } + if relative == "." { + return root, nil + } + current := volumeRoot + for _, name := range strings.Split(relative, string(filepath.Separator)) { + if name == "" || name == "." || name == ".." { + _ = root.Close() + return nil, fmt.Errorf("invalid path component %q in %q", name, path) + } + info, inspectErr := root.Lstat(name) + if errors.Is(inspectErr, os.ErrNotExist) { + if err := root.Mkdir(name, 0o700); err != nil && + !errors.Is(err, os.ErrExist) { + _ = root.Close() + return nil, fmt.Errorf( + "create directory %q: %w", + filepath.Join(current, name), + err, + ) + } + info, inspectErr = root.Lstat(name) + } + if inspectErr != nil { + _ = root.Close() + return nil, fmt.Errorf( + "inspect directory %q: %w", + filepath.Join(current, name), + inspectErr, + ) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + _ = root.Close() + return nil, fmt.Errorf( + "%q is not a real directory", + filepath.Join(current, name), + ) + } + child, err := openRealRootFromRoot(root, name) + if err != nil { + _ = root.Close() + return nil, fmt.Errorf( + "open directory %q: %w", + filepath.Join(current, name), + err, + ) + } + if err := verifyRootEntryUnchanged(root, name, child); err != nil { + _ = child.Close() + _ = root.Close() + return nil, err + } + if err := root.Close(); err != nil { + _ = child.Close() + return nil, fmt.Errorf( + "close parent directory %q: %w", + current, + err, + ) + } + current = filepath.Join(current, name) + root = child + } + if err := verifyRealPathRoot(path, root); err != nil { + _ = root.Close() + return nil, err + } + return root, nil +} + func verifyRealPathRoot(path string, opened *os.Root) error { openedInfo, err := opened.Stat(".") if err != nil { @@ -786,6 +1201,17 @@ func readRootDirectory(root *os.Root) ([]os.DirEntry, error) { func removeAllFromRoot(root *os.Root, name string) (result error) { info, err := root.Lstat(name) if errors.Is(err, os.ErrNotExist) { + debris, debrisErr := findRootDeletionDebris(root, name, "") + if debrisErr != nil { + return debrisErr + } + if debris != "" { + return fmt.Errorf( + "directory %q has deletion debris %q without persisted identity", + name, + debris, + ) + } return nil } if err != nil { @@ -806,6 +1232,22 @@ func removeOpenedDirectoryFromRoot( parent *os.Root, name string, root *os.Root, +) (result error) { + return removeOpenedDirectoryFromRootAfterIsolation( + parent, + name, + root, + name, + nil, + ) +} + +func removeOpenedDirectoryFromRootAfterIsolation( + parent *os.Root, + name string, + root *os.Root, + recoveryName string, + afterIsolation func(string), ) (result error) { defer func() { if root != nil { @@ -824,6 +1266,19 @@ func removeOpenedDirectoryFromRoot( if err := verifyRootEntryUnchanged(parent, name, root); err != nil { return err } + if debris, err := findRootDeletionDebris( + parent, + recoveryName, + name, + ); err != nil { + return err + } else if debris != "" { + return fmt.Errorf( + "directory %q has existing deletion debris %q", + recoveryName, + debris, + ) + } if err := removeRootDirectoryContents( parent, name, @@ -832,12 +1287,210 @@ func removeOpenedDirectoryFromRoot( ); err != nil { return err } + openedInfo, err := root.Stat(".") + if err != nil { + return err + } + directoryIdentity, err := openedDirectoryIdentity(root) + if err != nil { + return fmt.Errorf("inspect removed directory identity: %w", err) + } + deleteName := rootDeletionName(recoveryName, directoryIdentity) + directory, err = openRecordDirectory(parent) + if err != nil { + return err + } + moved, renameErr := renameDirectoryNoReplace( + directory, + openedInfo, + name, + deleteName+".rename", + deleteName, + ) + closeDirectoryErr := directory.Close() + if moved { + name = deleteName + } + if err := errors.Join(renameErr, closeDirectoryErr); err != nil { + return fmt.Errorf("isolate removed directory: %w", err) + } + if !moved { + return errors.New("removed directory was not isolated") + } + if err := verifyRootEntryUnchanged(parent, name, root); err != nil { + return err + } + isolatedIdentity, err := openedDirectoryIdentity(root) + if err != nil { + return fmt.Errorf("reinspect removed directory identity: %w", err) + } + if isolatedIdentity != directoryIdentity { + return errors.New("removed directory changed identity during isolation") + } + if afterIsolation != nil { + afterIsolation(name) + } closeErr = root.Close() root = nil if closeErr != nil { return closeErr } - return parent.Remove(name) + if err := parent.Remove(name); err != nil { + return err + } + return syncRecordBucket(parent, name) +} + +func rootDeletionPrefix(name string) string { + digest := sha256.Sum256([]byte(name)) + return fmt.Sprintf(".drove-delete-%x-", digest[:8]) +} + +func rootDeletionName(name string, directoryIdentity string) string { + digest := sha256.Sum256([]byte(directoryIdentity)) + return rootDeletionPrefix(name) + + hex.EncodeToString(digest[:]) + "-" + uuid.NewString() +} + +func isRootDeletionName(name string, recoveryName string) bool { + _, valid := rootDeletionIdentityDigest(name, recoveryName) + return valid +} + +func rootDeletionIdentityDigest( + name string, + recoveryName string, +) (string, bool) { + suffix, found := strings.CutPrefix(name, rootDeletionPrefix(recoveryName)) + if !found { + return "", false + } + suffix = strings.TrimSuffix(suffix, ".rename") + identityDigest, operationID, found := strings.Cut(suffix, "-") + if !found || len(identityDigest) != sha256.Size*2 { + return "", false + } + if _, err := hex.DecodeString(identityDigest); err != nil { + return "", false + } + parsed, err := uuid.Parse(operationID) + if err != nil || parsed.String() != operationID { + return "", false + } + return identityDigest, true +} + +func findRootDeletionDebris( + parent *os.Root, + recoveryName string, + ignoredName string, +) (string, error) { + entries, err := readRootDirectory(parent) + if err != nil { + return "", err + } + found := "" + for _, entry := range entries { + if entry.Name() == ignoredName { + continue + } + if !strings.HasPrefix( + entry.Name(), + rootDeletionPrefix(recoveryName), + ) { + continue + } + if !isRootDeletionName(entry.Name(), recoveryName) { + return "", fmt.Errorf( + "deletion debris %q has no persisted directory identity", + entry.Name(), + ) + } + info, err := parent.Lstat(entry.Name()) + if err != nil { + return "", err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf( + "deletion debris %q is not a real directory", + entry.Name(), + ) + } + if found != "" { + return "", fmt.Errorf( + "directory %q has multiple deletion debris paths", + recoveryName, + ) + } + found = entry.Name() + } + return found, nil +} + +func removeRootDeletionDebris( + parent *os.Root, + recoveryName string, + expectedIdentity string, +) (result error) { + name, err := findRootDeletionDebris(parent, recoveryName, "") + if err != nil || name == "" { + return err + } + if !validDirectoryIdentity(expectedIdentity) { + return fmt.Errorf( + "deletion debris %q has no trusted directory identity", + name, + ) + } + expectedIdentityDigest, valid := rootDeletionIdentityDigest( + name, + recoveryName, + ) + if !valid { + return fmt.Errorf( + "deletion debris %q has no persisted directory identity", + name, + ) + } + digest := sha256.Sum256([]byte(expectedIdentity)) + if hex.EncodeToString(digest[:]) != expectedIdentityDigest { + return fmt.Errorf( + "deletion debris %q does not match the trusted directory identity", + name, + ) + } + root, err := openRealRootFromRoot(parent, name) + if err != nil { + return err + } + rootOwned := true + defer func() { + if rootOwned { + result = errors.Join(result, root.Close()) + } + }() + directoryIdentity, err := openedDirectoryIdentity(root) + if err != nil { + return fmt.Errorf( + "inspect deletion debris %q identity: %w", + name, + err, + ) + } + if directoryIdentity != expectedIdentity { + return fmt.Errorf( + "deletion debris %q changed directory identity", + name, + ) + } + rootOwned = false + return removeOpenedDirectoryFromRootAfterIsolation( + parent, + name, + root, + recoveryName, + nil, + ) } func removeRootDirectoryContents( diff --git a/internal/workspace/source_swap_test.go b/internal/workspace/source_swap_test.go index 8e067d2..d16d3a1 100644 --- a/internal/workspace/source_swap_test.go +++ b/internal/workspace/source_swap_test.go @@ -4,9 +4,11 @@ package workspace import ( "context" + "errors" "os" "os/exec" "path/filepath" + "runtime" "strconv" "strings" "testing" @@ -66,11 +68,7 @@ func TestPrepareRollsBackRootedWorktreeAfterPostSuccessSourceSwap( if err != nil { t.Fatalf("resolve source: %v", err) } - targetPath := filepath.Join( - manager.root, - repositoryHash(canonicalSource), - testAgentID, - ) + targetPath := filepath.Join(manager.root, repositoryHash(canonicalSource), testAgentID) manager.git = postSuccessSwapGitWrapper( t, parent, @@ -234,6 +232,9 @@ func TestPrepareRollsBackAfterUnlistedAdminDirectoryCollisions( } func TestPrepareRemovesPrecreatedTargetWhenWorktreeAddFails(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("BSD registration uses a separate common-root stage") + } parent := t.TempDir() source := filepath.Join(parent, "source") initSourceSwapRepository(t, source) @@ -258,21 +259,33 @@ func TestPrepareRemovesPrecreatedTargetWhenWorktreeAddFails(t *testing.T) { invoked := filepath.Join(parent, "worktree-add-invoked") wrapper := filepath.Join(parent, "git-wrapper-add-failure") script := `#!/bin/sh -saw_worktree= +command_root= +next_root= +next_target= for argument in "$@"; do - if [ "$saw_worktree" = "1" ] && [ "$argument" = "add" ]; then - test -d "$DROVE_TEST_TARGET" || exit 98 - : > "$DROVE_TEST_INVOKED" + if [ -n "$next_root" ]; then + command_root=$argument + next_root= + continue + fi + if [ -n "$next_target" ]; then + test -d "$argument" || exit 98 + if [ -n "$command_root" ]; then + target=$(CDPATH= cd -- "$command_root/$argument" && pwd -P) || exit 99 + else + target=$(CDPATH= cd -- "$argument" && pwd -P) || exit 99 + fi + printf '%s' "$target" > "$DROVE_TEST_INVOKED" exit 97 fi - [ "$argument" = "worktree" ] && saw_worktree=1 + [ "$argument" = "-C" ] && next_root=1 + [ "$argument" = "--no-checkout" ] && next_target=1 done exec "$DROVE_TEST_REAL_GIT" "$@" ` if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { t.Fatalf("write Git wrapper: %v", err) } - t.Setenv("DROVE_TEST_TARGET", targetPath) t.Setenv("DROVE_TEST_INVOKED", invoked) t.Setenv("DROVE_TEST_REAL_GIT", realGit) manager.git = wrapper @@ -288,6 +301,13 @@ exec "$DROVE_TEST_REAL_GIT" "$@" if _, err := os.Lstat(invoked); err != nil { t.Fatalf("worktree add wrapper was not invoked: %v", err) } + stagingPath, err := os.ReadFile(invoked) + if err != nil { + t.Fatalf("read worktree add target: %v", err) + } + if _, err := os.Lstat(string(stagingPath)); !os.IsNotExist(err) { + t.Fatalf("staging target remains or inspect failed: %v", err) + } if _, err := os.Lstat(targetPath); !os.IsNotExist(err) { t.Fatalf("precreated target remains or inspect failed: %v", err) } @@ -299,6 +319,9 @@ exec "$DROVE_TEST_REAL_GIT" "$@" } func TestPreparePreservesReplacedPrecreatedTarget(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("BSD registration does not run Git from the managed target") + } parent := t.TempDir() source := filepath.Join(parent, "source") initSourceSwapRepository(t, source) @@ -307,16 +330,6 @@ func TestPreparePreservesReplacedPrecreatedTarget(t *testing.T) { if err != nil { t.Fatalf("new manager: %v", err) } - canonicalSource, err := resolvePath(source) - if err != nil { - t.Fatalf("resolve source: %v", err) - } - targetPath := filepath.Join( - manager.root, - repositoryHash(canonicalSource), - testAgentID, - ) - originalPath := targetPath + "-original" replacementPath := filepath.Join(parent, "replacement-target") if err := os.Mkdir(replacementPath, 0o700); err != nil { t.Fatalf("create replacement target: %v", err) @@ -325,35 +338,48 @@ func TestPreparePreservesReplacedPrecreatedTarget(t *testing.T) { if err := os.WriteFile(sentinel, []byte("replacement\n"), 0o600); err != nil { t.Fatalf("write replacement sentinel: %v", err) } + swappedPath := filepath.Join(parent, "swapped-target") realGit, err := exec.LookPath("git") if err != nil { t.Fatalf("find Git: %v", err) } wrapper := filepath.Join(parent, "git-wrapper-target-swap") script := `#!/bin/sh -matched= -saw_worktree= +command_root= +next_root= +target= +next_target= for argument in "$@"; do - if [ "$saw_worktree" = "1" ] && [ "$argument" = "add" ]; then - matched=1 - break + if [ -n "$next_root" ]; then + command_root=$argument + next_root= + continue fi - [ "$argument" = "worktree" ] && saw_worktree=1 + if [ -n "$next_target" ]; then + if [ -n "$command_root" ]; then + target=$(CDPATH= cd -- "$command_root/$argument" && pwd -P) || exit 90 + else + target=$(CDPATH= cd -- "$argument" && pwd -P) || exit 90 + fi + next_target= + fi + [ "$argument" = "-C" ] && next_root=1 + [ "$argument" = "--no-checkout" ] && next_target=1 done "$DROVE_TEST_REAL_GIT" "$@" status=$? -if [ "$status" -eq 0 ] && [ -n "$matched" ]; then - mv "$DROVE_TEST_TARGET" "$DROVE_TEST_ORIGINAL" || exit 91 - mv "$DROVE_TEST_REPLACEMENT" "$DROVE_TEST_TARGET" || exit 92 +if [ "$status" -eq 0 ] && [ -n "$target" ]; then + mv "$target" "$target-opened" || exit 91 + mv "$DROVE_TEST_REPLACEMENT" "$target" || exit 92 + printf '%s' "$target" > "$DROVE_TEST_SWAPPED_PATH" || exit 93 fi exit "$status" ` if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { t.Fatalf("write Git wrapper: %v", err) } - t.Setenv("DROVE_TEST_TARGET", targetPath) - t.Setenv("DROVE_TEST_ORIGINAL", originalPath) t.Setenv("DROVE_TEST_REPLACEMENT", replacementPath) + t.Setenv("DROVE_TEST_SWAPPED_PATH", swappedPath) t.Setenv("DROVE_TEST_REAL_GIT", realGit) manager.git = wrapper @@ -365,7 +391,242 @@ exit "$status" ); err == nil { t.Fatal("prepare accepted a replaced precreated target") } - assertFileContents(t, filepath.Join(targetPath, "must-remain"), "replacement\n") + rawPath, err := os.ReadFile(swappedPath) + if err != nil { + t.Fatalf("read swapped target path: %v", err) + } + assertFileContents( + t, + filepath.Join(string(rawPath), "must-remain"), + "replacement\n", + ) + manager.git = realGit + stagingPath := string(rawPath) + registered, err := manager.worktreeRegistered( + context.Background(), + source, + stagingPath, + ) + if err != nil || !registered { + t.Fatalf("original staging registration = %v, err=%v", registered, err) + } + if err := os.Rename(stagingPath, replacementPath); err != nil { + t.Fatalf("restore replacement target: %v", err) + } + if err := os.Rename(stagingPath+"-opened", stagingPath); err != nil { + t.Fatalf("restore original staging target: %v", err) + } + _, repository, err := manager.repositoryPaths( + context.Background(), + source, + ) + if err != nil { + t.Fatalf("resolve restored source repository: %v", err) + } + registered, err = manager.worktreeRegistered( + context.Background(), + repository, + stagingPath, + ) + if err != nil || !registered { + t.Fatalf("restored staging registration = %v, err=%v", registered, err) + } + if _, err := os.Lstat(workspaceRecordPath(filepath.Join( + manager.root, + repositoryHash(repository), + testAgentID, + ))); err != nil { + t.Fatalf("preparation record was removed: %v", err) + } + runGit(t, stagingPath, "status", "--porcelain") + assertFileContents(t, sentinel, "replacement\n") +} + +func TestPrepareDoesNotWriteThroughReplacedStagingPath(t *testing.T) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + outside := filepath.Join(parent, "outside") + if err := os.Mkdir(outside, 0o700); err != nil { + t.Fatalf("create outside target: %v", err) + } + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + wrapper := filepath.Join(parent, "git-wrapper-replace-staging") + script := `#!/bin/sh +command_root= +next_root= +target= +next_target= +for argument in "$@"; do + if [ -n "$next_root" ]; then + command_root=$argument + next_root= + continue + fi + if [ -n "$next_target" ]; then + if [ -n "$command_root" ]; then + target=$(CDPATH= cd -- "$command_root/$argument" && pwd -P) || exit 90 + else + target=$(CDPATH= cd -- "$argument" && pwd -P) || exit 90 + fi + next_target= + fi + [ "$argument" = "-C" ] && next_root=1 + [ "$argument" = "--no-checkout" ] && next_target=1 +done +if [ -n "$target" ]; then + mv "$target" "$target-opened" || exit 91 + ln -s "$DROVE_TEST_OUTSIDE" "$target" || exit 92 +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_OUTSIDE", outside) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ); err == nil { + t.Fatal("prepare accepted a replaced staging path") + } + if _, err := os.Lstat(filepath.Join(outside, ".git")); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("Git wrote through the replaced staging path: %v", err) + } +} + +func TestPrepareCheckoutAndPruneFailuresPreserveRecoveryRecord(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("BSD checkout uses a different implementation") + } + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + dataDir := filepath.Join(parent, "data") + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + canonicalSource, err := resolvePath(source) + if err != nil { + t.Fatalf("resolve source: %v", err) + } + targetPath := filepath.Join( + manager.root, + repositoryHash(canonicalSource), + testAgentID, + ) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + checkoutFailed := filepath.Join(parent, "checkout-failed") + pruneFailed := filepath.Join(parent, "prune-failed") + wrapper := filepath.Join(parent, "git-wrapper-checkout-prune-failure") + script := `#!/bin/sh +case " $* " in + *" read-tree "*) + : > "$DROVE_TEST_CHECKOUT_FAILED" + exit 91 + ;; + *" worktree prune "*) + : > "$DROVE_TEST_PRUNE_FAILED" + exit 92 + ;; +esac +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_CHECKOUT_FAILED", checkoutFailed) + t.Setenv("DROVE_TEST_PRUNE_FAILED", pruneFailed) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + manager.git = wrapper + + if _, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ); err == nil { + t.Fatal("prepare succeeded after checkout and prune failures") + } + if _, err := os.Lstat(checkoutFailed); err != nil { + t.Fatalf("checkout failure was not reached: %v", err) + } + if _, err := os.Lstat(pruneFailed); err != nil { + t.Fatalf("prune failure was not reached: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(targetPath) + if err != nil || !exists { + t.Fatalf("recovery record: exists=%v err=%v", exists, err) + } + stagingName, err := preparedWorktreeStagingName( + record.AgentID, + record.BranchOperationID, + ) + if err != nil { + t.Fatalf("derive staging name: %v", err) + } + stagingPath := filepath.Join(filepath.Dir(targetPath), stagingName) + registered, err := manager.worktreeRegistered( + context.Background(), + record.Repository, + stagingPath, + ) + if err != nil || !registered { + t.Fatalf("staging registration = %v, err=%v", registered, err) + } + marker, err := manager.branchOwnershipMarkerExists( + context.Background(), + record.Repository, + record.BranchOperationID, + ) + if err != nil || !marker { + t.Fatalf("branch ownership marker = %v, err=%v", marker, err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if err := restarted.ReconcilePreparations( + context.Background(), + nil, + ); err != nil { + t.Fatalf("reconcile prunable registration: %v", err) + } + if _, err := os.Lstat(workspaceRecordPath(targetPath)); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("reconciled preparation record remains: %v", err) + } + registered, err = restarted.worktreeRegistered( + context.Background(), + record.Repository, + stagingPath, + ) + if err != nil || registered { + t.Fatalf("reconciled staging registration = %v, err=%v", registered, err) + } } func TestPrepareRollsBackExistingBranchMovedBeforeWorktreeAdd(t *testing.T) { @@ -458,6 +719,87 @@ exit "$status" } } +func TestPrepareRejectsCreatedBranchMovedBeforeWorktreeAdd(t *testing.T) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + startOID := strings.TrimSpace(runGit(t, source, "rev-parse", "HEAD")) + if err := os.WriteFile( + filepath.Join(source, "replacement.txt"), + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement commit: %v", err) + } + runGit(t, source, "add", "replacement.txt") + runGit(t, source, "commit", "-m", "replacement") + replacementOID := strings.TrimSpace(runGit(t, source, "rev-parse", "HEAD")) + runGit(t, source, "reset", "--hard", startOID) + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + wrapper := filepath.Join(parent, "git-wrapper-move-created-branch") + script := `#!/bin/sh +matched= +saw_worktree= +for argument in "$@"; do + if [ "$saw_worktree" = "1" ] && [ "$argument" = "add" ]; then + matched=1 + break + fi + [ "$argument" = "worktree" ] && saw_worktree=1 +done +if [ -n "$matched" ]; then + "$DROVE_TEST_REAL_GIT" --git-dir="$DROVE_TEST_GIT_DIR" update-ref \ + "$DROVE_TEST_BRANCH_REF" "$DROVE_TEST_REPLACEMENT_OID" \ + "$DROVE_TEST_START_OID" || exit 96 +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + branch := "drove/" + testAgentID + t.Setenv("DROVE_TEST_GIT_DIR", filepath.Join(source, ".git")) + t.Setenv("DROVE_TEST_BRANCH_REF", "refs/heads/"+branch) + t.Setenv("DROVE_TEST_REPLACEMENT_OID", replacementOID) + t.Setenv("DROVE_TEST_START_OID", startOID) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ); err == nil { + t.Fatal("prepare accepted a moved created branch") + } + if got := strings.TrimSpace(runGit( + t, + source, + "rev-parse", + "refs/heads/"+branch, + )); got != replacementOID { + t.Fatalf("moved branch OID = %q, want %q", got, replacementOID) + } + if markers := strings.TrimSpace(runGit( + t, + source, + "for-each-ref", + "--format=%(refname)", + branchOwnershipRefPrefix, + )); markers != "" { + t.Fatalf("branch ownership marker remains: %q", markers) + } +} + func TestDiscardUsesRetainedRepositoryAfterPrepareSourceSwap( t *testing.T, ) { @@ -640,14 +982,18 @@ exec "$DROVE_TEST_REAL_GIT" "$@" assertPreparationRefsAbsent(t, source, prepared.Branch) } -func TestAcknowledgeRejectsReplacementCommonGitDirectory( +func TestReconcileUpgradedRemovalRejectsReplacementSourceRepository( t *testing.T, ) { parent := t.TempDir() source := filepath.Join(parent, "source") + replacement := filepath.Join(parent, "replacement") + openedSource := source + "-opened" + dataDir := filepath.Join(parent, "data") initSourceSwapRepository(t, source) + initSourceSwapRepository(t, replacement) - manager, err := New(filepath.Join(parent, "data")) + manager, err := New(dataDir) if err != nil { t.Fatalf("new manager: %v", err) } @@ -660,29 +1006,308 @@ func TestAcknowledgeRejectsReplacementCommonGitDirectory( if err != nil { t.Fatalf("prepare workspace: %v", err) } - openedGitDirectory := replaceCommonGitDirectoryWithCopy(t, source) - replacementGitDirectory := filepath.Join(source, ".git") - t.Setenv("GIT_COMMON_DIR", replacementGitDirectory) - - if err := manager.AcknowledgePreparation(prepared); err == nil { - t.Fatal("acknowledgement accepted a replacement common Git directory") + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge workspace: %v", err) } - - assertGitRefExists( - t, - openedGitDirectory, - branchOwnershipRef(prepared.branchOperationID), - ) - assertGitRefExists( - t, - replacementGitDirectory, - branchOwnershipRef(prepared.branchOperationID), - ) record, exists, err := manager.readWorkspaceRecord(prepared.Path) - if err != nil || !exists { - t.Fatalf("read acknowledged record: exists=%v err=%v", exists, err) + if err != nil || !exists || record.RepositoryEvidence == nil { + t.Fatalf("read workspace record: exists=%v err=%v", exists, err) } - if record.PreparationCommitted || record.BranchOperationID == "" { + record.Version = repositoryWorkspaceRecordVersion + record.RepositoryEvidence.GitDirectory = "" + record.RepositoryEvidence.GitDirectoryIdentity = "" + if err := manager.replaceWorkspaceRecord(record); err != nil { + t.Fatalf("write version 4 workspace record: %v", err) + } + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + wrapper := filepath.Join(parent, "git-wrapper-prune-failure") + script := `#!/bin/sh +saw_worktree= +for argument in "$@"; do + if [ "$saw_worktree" = "1" ] && [ "$argument" = "prune" ]; then + exit 97 + fi + [ "$argument" = "worktree" ] && saw_worktree=1 +done +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + manager.git = wrapper + + result, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ) + if err == nil || result.State != RemovalPending { + t.Fatalf("removal with prune failure = %+v, err=%v", result, err) + } + upgraded, exists, recordErr := manager.readWorkspaceRecord(prepared.Path) + if recordErr != nil || !exists || + upgraded.Version != workspaceRecordVersion || + upgraded.RepositoryEvidence == nil { + t.Fatalf( + "upgraded removal record = %+v, exists=%v err=%v", + upgraded, + exists, + recordErr, + ) + } + manager.git = realGit + if err := os.Rename(source, openedSource); err != nil { + t.Fatalf("move source repository: %v", err) + } + if err := os.Rename(replacement, source); err != nil { + t.Fatalf("install replacement repository: %v", err) + } + + restarted, err := New(dataDir) + if err != nil { + t.Fatalf("restart manager: %v", err) + } + if _, err := restarted.ReconcileRemovals( + context.Background(), + ); err == nil { + t.Fatal("upgraded removal accepted a replacement repository") + } + if listing := runGit( + t, + openedSource, + "worktree", + "list", + "--porcelain", + ); !strings.Contains(listing, prepared.Path) { + t.Fatal("original repository registration was unexpectedly pruned") + } +} + +func TestRemoveLegacyRecordRejectsReplacementSourceBeforeUpgrade( + t *testing.T, +) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + replacement := filepath.Join(parent, "replacement") + openedSource := source + "-opened" + initSourceSwapRepository(t, source) + initSourceSwapRepository(t, replacement) + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists || record.RepositoryEvidence == nil { + t.Fatalf("read workspace record: exists=%v err=%v", exists, err) + } + record.Version = repositoryWorkspaceRecordVersion + record.RepositoryEvidence.GitDirectory = "" + record.RepositoryEvidence.GitDirectoryIdentity = "" + if err := manager.replaceWorkspaceRecord(record); err != nil { + t.Fatalf("write version 4 workspace record: %v", err) + } + if err := os.Rename(source, openedSource); err != nil { + t.Fatalf("move source repository: %v", err) + } + if err := os.Rename(replacement, source); err != nil { + t.Fatalf("install replacement repository: %v", err) + } + + if _, err := manager.Remove( + context.Background(), + prepared.AgentID, + true, + ); err == nil || !strings.Contains( + err.Error(), + "legacy removal repository identity evidence changed", + ) { + t.Fatalf("remove replacement source error = %v", err) + } + current, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists || + current.Version != repositoryWorkspaceRecordVersion || + current.Removal != nil { + t.Fatalf( + "failed upgrade record = %+v, exists=%v err=%v", + current, + exists, + err, + ) + } + if listing := runGit( + t, + openedSource, + "worktree", + "list", + "--porcelain", + ); !strings.Contains(listing, prepared.Path) { + t.Fatal("original repository registration was unexpectedly changed") + } +} + +func TestListRunsStatusFromOpenedWorktreeRoot(t *testing.T) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge workspace: %v", err) + } + if err := os.WriteFile( + filepath.Join(prepared.Path, "tracked.txt"), + []byte("dirty\n"), + 0o600, + ); err != nil { + t.Fatalf("dirty worktree: %v", err) + } + replacement := filepath.Join(parent, "replacement-worktree") + if err := os.Mkdir(replacement, 0o700); err != nil { + t.Fatalf("create replacement worktree: %v", err) + } + gitPointer, err := os.ReadFile(filepath.Join(prepared.Path, ".git")) + if err != nil { + t.Fatalf("read worktree Git pointer: %v", err) + } + if err := os.WriteFile( + filepath.Join(replacement, ".git"), + gitPointer, + 0o600, + ); err != nil { + t.Fatalf("write replacement Git pointer: %v", err) + } + if err := os.WriteFile( + filepath.Join(replacement, "tracked.txt"), + []byte("tracked\n"), + 0o600, + ); err != nil { + t.Fatalf("write clean replacement: %v", err) + } + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + opened := prepared.Path + "-opened" + swapped := filepath.Join(parent, "status-swapped") + wrapper := filepath.Join(parent, "git-wrapper") + script := `#!/bin/sh +matched= +for argument in "$@"; do + [ "$argument" = "status" ] && matched=1 +done +if [ -n "$matched" ] && [ ! -e "$DROVE_TEST_SWAPPED" ]; then + mv "$DROVE_TEST_TARGET" "$DROVE_TEST_OPENED" || exit 91 + mv "$DROVE_TEST_REPLACEMENT" "$DROVE_TEST_TARGET" || exit 92 + "$DROVE_TEST_REAL_GIT" "$@" + status=$? + mv "$DROVE_TEST_TARGET" "$DROVE_TEST_REPLACEMENT" || exit 93 + mv "$DROVE_TEST_OPENED" "$DROVE_TEST_TARGET" || exit 94 + : > "$DROVE_TEST_SWAPPED" + exit "$status" +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_TARGET", prepared.Path) + t.Setenv("DROVE_TEST_OPENED", opened) + t.Setenv("DROVE_TEST_REPLACEMENT", replacement) + t.Setenv("DROVE_TEST_SWAPPED", swapped) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + manager.git = wrapper + + listed, err := manager.List(context.Background()) + if err != nil { + t.Fatalf("list workspaces: %v", err) + } + if len(listed) != 1 || !listed[0].Dirty { + t.Fatalf("listed workspaces = %+v, want dirty original", listed) + } + if _, err := os.Stat(swapped); err != nil { + t.Fatalf("status swap hook was not invoked: %v", err) + } + manager.git = realGit + result, err := manager.Remove(context.Background(), prepared.AgentID, true) + if err != nil { + t.Fatalf("remove workspace: %v", err) + } + if err := manager.AcknowledgeRemoval(result.Removal); err != nil { + t.Fatalf("acknowledge removal: %v", err) + } +} + +func TestAcknowledgeRejectsReplacementCommonGitDirectory( + t *testing.T, +) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + openedGitDirectory := replaceCommonGitDirectoryWithCopy(t, source) + replacementGitDirectory := filepath.Join(source, ".git") + t.Setenv("GIT_COMMON_DIR", replacementGitDirectory) + + if err := manager.AcknowledgePreparation(prepared); err == nil { + t.Fatal("acknowledgement accepted a replacement common Git directory") + } + + assertGitRefExists( + t, + openedGitDirectory, + branchOwnershipRef(prepared.branchOperationID), + ) + assertGitRefExists( + t, + replacementGitDirectory, + branchOwnershipRef(prepared.branchOperationID), + ) + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read acknowledged record: exists=%v err=%v", exists, err) + } + if record.PreparationCommitted || record.BranchOperationID == "" { t.Fatalf("failed acknowledgement record = %+v", record) } @@ -698,6 +1323,89 @@ func TestAcknowledgeRejectsReplacementCommonGitDirectory( } } +func TestAcknowledgeRejectsRedirectedPreparedCommonGitDirectory( + t *testing.T, +) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists || + record.GitDirectory == "" || + record.RepositoryEvidence == nil { + t.Fatalf( + "read preparation record: exists=%v err=%v record=%+v", + exists, + err, + record, + ) + } + commonDirectory := record.RepositoryEvidence.CommonGitDirectory + replacementCommon := filepath.Join(parent, "replacement-common") + if err := os.CopyFS( + replacementCommon, + os.DirFS(commonDirectory), + ); err != nil { + t.Fatalf("copy common Git directory: %v", err) + } + commonPointer := filepath.Join(record.GitDirectory, "commondir") + originalPointer, err := os.ReadFile(commonPointer) + if err != nil { + t.Fatalf("read prepared common directory pointer: %v", err) + } + if err := os.WriteFile( + commonPointer, + []byte(replacementCommon+"\n"), + 0o600, + ); err != nil { + t.Fatalf("redirect prepared common directory pointer: %v", err) + } + + err = manager.AcknowledgePreparation(prepared) + if err == nil || !strings.Contains( + err.Error(), + "private Git common directory binding changed", + ) { + t.Fatalf( + "acknowledge redirected prepared common directory error = %v", + err, + ) + } + assertGitRefExists( + t, + commonDirectory, + branchOwnershipRef(prepared.branchOperationID), + ) + failed, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read failed preparation record: exists=%v err=%v", exists, err) + } + if failed.PreparationCommitted || failed.BranchOperationID == "" { + t.Fatalf("failed acknowledgement record = %+v", failed) + } + + if err := os.WriteFile(commonPointer, originalPointer, 0o600); err != nil { + t.Fatalf("restore prepared common directory pointer: %v", err) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard restored preparation: %v", err) + } +} + func TestAcknowledgeRejectsReplacementSourceGitPointer(t *testing.T) { parent := t.TempDir() repository := filepath.Join(parent, "repository") @@ -736,19 +1444,184 @@ func TestAcknowledgeRejectsReplacementSourceGitPointer(t *testing.T) { if err := manager.AcknowledgePreparation(prepared); err == nil { t.Fatal("acknowledgement accepted a replacement source Git pointer") } - record, exists, err := manager.readWorkspaceRecord(prepared.Path) - if err != nil || !exists { - t.Fatalf("read failed acknowledgement: exists=%v err=%v", exists, err) + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read failed acknowledgement: exists=%v err=%v", exists, err) + } + if record.PreparationCommitted || record.BranchOperationID == "" { + t.Fatalf("failed acknowledgement record = %+v", record) + } + + if err := os.WriteFile(sourcePointer, originalPointer, 0o600); err != nil { + t.Fatalf("restore source Git pointer: %v", err) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard restored preparation: %v", err) + } +} + +func TestPrepareDoesNotExposeCanonicalTargetToWorktreeAdd(t *testing.T) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + dataDir := filepath.Join(parent, "data") + outside := filepath.Join(parent, "outside") + initSourceSwapRepository(t, source) + if err := os.Mkdir(outside, 0o700); err != nil { + t.Fatalf("create outside directory: %v", err) + } + canonicalSource, err := resolvePath(source) + if err != nil { + t.Fatalf("resolve source repository: %v", err) + } + targetPath := filepath.Join( + dataDir, + worktreeDirectory, + repositoryHash(canonicalSource), + testAgentID, + ) + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + swapped := filepath.Join(parent, "swapped") + wrapper := filepath.Join(parent, "git-wrapper") + script := `#!/bin/sh +case " $* " in + *" worktree add "*) + if [ -d "$DROVE_TEST_TARGET" ]; then + mv "$DROVE_TEST_TARGET" "$DROVE_TEST_TARGET-opened" || exit 91 + ln -s "$DROVE_TEST_OUTSIDE" "$DROVE_TEST_TARGET" || exit 92 + : > "$DROVE_TEST_SWAPPED" + fi + ;; +esac +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_TARGET", targetPath) + t.Setenv("DROVE_TEST_OUTSIDE", outside) + t.Setenv("DROVE_TEST_SWAPPED", swapped) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + manager, err := New(dataDir) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + + prepared, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + if _, err := os.Lstat(swapped); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("canonical target was exposed during worktree add: %v", err) + } + if _, err := os.Lstat(filepath.Join(outside, ".git")); !errors.Is( + err, + os.ErrNotExist, + ) { + t.Fatalf("Git wrote outside the managed bucket: %v", err) + } + manager.git = realGit + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard prepared workspace: %v", err) + } +} + +func TestAcknowledgeRejectsTargetSwapDuringOwnershipCleanup(t *testing.T) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare workspace: %v", err) + } + openedTarget := prepared.Path + "-opened" + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + swapped := filepath.Join(parent, "swapped") + wrapper := filepath.Join(parent, "git-wrapper") + script := `#!/bin/sh +case " $* " in + *" update-ref -d refs/drove/preparations/"*) + if [ ! -e "$DROVE_TEST_SWAPPED" ]; then + mv "$DROVE_TEST_TARGET" "$DROVE_TEST_OPENED_TARGET" || exit 91 + mkdir "$DROVE_TEST_TARGET" || exit 92 + printf 'replacement\n' > "$DROVE_TEST_TARGET/must-remain" || exit 93 + : > "$DROVE_TEST_SWAPPED" + fi + ;; +esac +"$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_TARGET", prepared.Path) + t.Setenv("DROVE_TEST_OPENED_TARGET", openedTarget) + t.Setenv("DROVE_TEST_SWAPPED", swapped) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + manager.git = wrapper + + err = manager.AcknowledgePreparation(prepared) + if err == nil || !strings.Contains( + err.Error(), + "prepared worktree directory identity changed", + ) { + t.Fatalf("acknowledgement target swap error = %v", err) + } + assertFileContents( + t, + filepath.Join(prepared.Path, "must-remain"), + "replacement\n", + ) + record, exists, recordErr := manager.readWorkspaceRecord(prepared.Path) + if recordErr != nil || !exists || + !record.PreparationCommitted || + record.BranchOperationID == "" { + t.Fatalf( + "failed acknowledgement record = %+v, exists=%v err=%v", + record, + exists, + recordErr, + ) + } + + if err := os.RemoveAll(prepared.Path); err != nil { + t.Fatalf("remove replacement target: %v", err) } - if record.PreparationCommitted || record.BranchOperationID == "" { - t.Fatalf("failed acknowledgement record = %+v", record) + if err := os.Rename(openedTarget, prepared.Path); err != nil { + t.Fatalf("restore prepared target: %v", err) } - - if err := os.WriteFile(sourcePointer, originalPointer, 0o600); err != nil { - t.Fatalf("restore source Git pointer: %v", err) + manager.git = realGit + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("retry acknowledgement: %v", err) } - if err := manager.Discard(context.Background(), prepared); err != nil { - t.Fatalf("discard restored preparation: %v", err) + if _, err := removeWorkspace( + t, + manager, + context.Background(), + prepared.AgentID, + true, + ); err != nil { + t.Fatalf("remove acknowledged workspace: %v", err) } } @@ -796,7 +1669,8 @@ for argument in "$@"; do esac done matched= -if [ -n "$has_git" ] && [ -z "$has_common" ]; then +if [ -n "$has_git" ] && [ -z "$has_common" ] && + [ "$GIT_WORK_TREE" = "$DROVE_TEST_SOURCE" ]; then count=0 if [ -f "$DROVE_TEST_COUNTER" ]; then count=$(cat "$DROVE_TEST_COUNTER") || exit 90 @@ -824,6 +1698,7 @@ exit "$status" filepath.Join(replacement, ".git"), ) t.Setenv("DROVE_TEST_SOURCE_POINTER", sourcePointer) + t.Setenv("DROVE_TEST_SOURCE", source) t.Setenv("DROVE_TEST_SWAPPED", swapped) t.Setenv("DROVE_TEST_REAL_GIT", realGit) manager.git = wrapper @@ -1038,7 +1913,7 @@ func TestDiscardUsesRetainedCommonGitDirectoryAfterReplacement( ) } -func TestIncludedPathsUsesRetainedCommonGitDirectoryAfterReplacement( +func TestIncludedPathsRejectsReplacedCommonGitDirectory( t *testing.T, ) { parent := t.TempDir() @@ -1118,17 +1993,14 @@ exec "$DROVE_TEST_REAL_GIT" "$@" t.Setenv("DROVE_TEST_REAL_GIT", realGit) manager.git = wrapper - paths, err := manager.includedPaths( + _, err = manager.includedPaths( context.Background(), source, sourceRoot, lease.repository, ) - if err != nil { - t.Fatalf("evaluate include manifest through retained repository: %v", err) - } - if len(paths) != 1 || paths[0] != includedPath { - t.Fatalf("included paths = %q, want [%s]", paths, includedPath) + if err == nil || !strings.Contains(err.Error(), "changed while in use") { + t.Fatalf("replaced common Git directory error = %v", err) } } @@ -1444,6 +2316,412 @@ exec "$DROVE_TEST_REAL_GIT" "$@" } } +func TestPrepareDoesNotCheckoutThroughReplacedWorktreeGitPointer( + t *testing.T, +) { + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + replacement := filepath.Join(parent, "replacement-worktree") + runGit( + t, + source, + "worktree", + "add", + "-b", + "replacement-worktree", + replacement, + ) + + sentinel := filepath.Join(parent, "filter-ran") + filter := filepath.Join(parent, "filter") + script := "#!/bin/sh\nprintf ran > \"$DROVE_TEST_SENTINEL\"\ncat\n" + if err := os.WriteFile(filter, []byte(script), 0o700); err != nil { + t.Fatalf("write filter: %v", err) + } + runGit(t, source, "config", "filter.review.smudge", filter) + runGit(t, source, "config", "filter.review.clean", "cat") + runGit(t, source, "config", "filter.review.required", "true") + if err := os.WriteFile( + filepath.Join(replacement, ".gitattributes"), + []byte("replacement.txt filter=review\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement attributes: %v", err) + } + if err := os.WriteFile( + filepath.Join(replacement, "replacement.txt"), + []byte("replacement\n"), + 0o600, + ); err != nil { + t.Fatalf("write replacement file: %v", err) + } + runGit(t, replacement, "add", ".gitattributes", "replacement.txt") + runGit(t, replacement, "commit", "-m", "replacement worktree") + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + wrapper := filepath.Join(parent, "git-wrapper-swap-worktree-pointer") + wrapperScript := `#!/bin/sh +target= +next_target= +for argument in "$@"; do + if [ -n "$next_target" ]; then + target=$argument + next_target= + fi + [ "$argument" = "--no-checkout" ] && next_target=1 +done +"$DROVE_TEST_REAL_GIT" "$@" +status=$? +if [ "$status" -eq 0 ] && [ -n "$target" ]; then + mv "$target/.git" "$target/.git-original" || exit 91 + cp "$DROVE_TEST_REPLACEMENT/.git" "$target/.git" || exit 92 +fi +exit "$status" +` + if err := os.WriteFile(wrapper, []byte(wrapperScript), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_REPLACEMENT", replacement) + t.Setenv("DROVE_TEST_SENTINEL", sentinel) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ); err == nil { + t.Fatal("prepare accepted a replaced worktree Git pointer") + } + if _, err := os.Stat(sentinel); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("replacement worktree checkout filter ran: %v", err) + } +} + +func TestPrepareIgnoresTransientPrivateGitReplacementDuringCheckout( + t *testing.T, +) { + switch runtime.GOOS { + case "darwin", "dragonfly", "freebsd", "netbsd", "openbsd": + default: + t.Skip("test covers descriptor-rooted BSD checkout") + } + parent := t.TempDir() + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, source) + if err := os.WriteFile( + filepath.Join(source, ".gitattributes"), + []byte("tracked.txt filter=review\n"), + 0o600, + ); err != nil { + t.Fatalf("write attributes: %v", err) + } + runGit(t, source, "add", ".gitattributes") + runGit(t, source, "commit", "-m", "add checkout filter attributes") + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + sentinel := filepath.Join(parent, "replacement-filter-ran") + filter := filepath.Join(parent, "replacement-filter") + filterScript := "#!/bin/sh\nprintf ran > \"$DROVE_TEST_SENTINEL\"\ncat\n" + if err := os.WriteFile(filter, []byte(filterScript), 0o700); err != nil { + t.Fatalf("write replacement filter: %v", err) + } + maliciousCommon := filepath.Join(parent, "malicious-common") + if err := os.CopyFS(maliciousCommon, os.DirFS(filepath.Join(source, ".git"))); err != nil { + t.Fatalf("copy malicious common Git directory: %v", err) + } + for _, arguments := range [][]string{ + {"--git-dir=" + maliciousCommon, "config", "filter.review.smudge", filter}, + {"--git-dir=" + maliciousCommon, "config", "filter.review.clean", "cat"}, + {"--git-dir=" + maliciousCommon, "config", "filter.review.required", "true"}, + } { + command := exec.Command(realGit, arguments...) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf( + "configure malicious common Git directory: %v\n%s", + err, + output, + ) + } + } + marker := filepath.Join(parent, "swapped-private-git") + gitPath := filepath.Join(parent, "prepared-private-git-path") + wrapper := filepath.Join(parent, "git-wrapper-transient-private-git") + script := `#!/bin/sh +worktree= +add= +no_checkout= +checkout= +target= +next_target= +for argument in "$@"; do + if [ -n "$next_target" ]; then + target=$argument + next_target= + fi + [ "$argument" = "worktree" ] && worktree=1 + [ "$argument" = "add" ] && add=1 + if [ "$argument" = "--no-checkout" ]; then + no_checkout=1 + next_target=1 + fi + [ "$argument" = "read-tree" ] && checkout=1 +done +if [ -n "$worktree" ] && [ -n "$add" ] && [ -n "$no_checkout" ]; then + "$DROVE_TEST_REAL_GIT" "$@" + status=$? + if [ "$status" -eq 0 ]; then + sed 's/^gitdir: //' "$target/.git" > "$DROVE_TEST_GIT_PATH" || exit 90 + fi + exit "$status" +fi +if [ -n "$checkout" ] && [ -s "$DROVE_TEST_GIT_PATH" ] && [ ! -e "$DROVE_TEST_MARKER" ]; then + public=$(cat "$DROVE_TEST_GIT_PATH") || exit 91 + original="$public.drove-original" + mv "$public" "$original" || exit 92 + cp -R "$original" "$public" || exit 93 + printf '%s\n' "$DROVE_TEST_MALICIOUS_COMMON" > "$public/commondir" || exit 94 + "$DROVE_TEST_REAL_GIT" "$@" + status=$? + if [ "$GIT_DIR" != "." ] && [ -f "$public/index" ]; then + cp "$public/index" "$original/index" || exit 95 + fi + rm -rf "$public" || exit 96 + mv "$original" "$public" || exit 97 + : > "$DROVE_TEST_MARKER" + exit "$status" +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_MARKER", marker) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + t.Setenv("DROVE_TEST_GIT_PATH", gitPath) + t.Setenv("DROVE_TEST_MALICIOUS_COMMON", maliciousCommon) + t.Setenv("DROVE_TEST_SENTINEL", sentinel) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + if _, err := manager.Prepare( + context.Background(), + source, + "", + testAgentID, + ); err == nil || !strings.Contains( + err.Error(), + "working-tree filter", + ) { + t.Fatalf( + "prepare through transient private Git replacement error = %v", + err, + ) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("transient private Git replacement did not run: %v", err) + } + if _, err := os.Stat(sentinel); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("replacement checkout filter ran: %v", err) + } +} + +func TestPreparationHeadUsesPinnedWorktreeRegistration(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + source := filepath.Join(parent, "source") + initSourceSwapRepository(t, repository) + runGit(t, repository, "worktree", "add", "-b", "source-branch", source) + sourceOID := strings.TrimSpace(runGit(t, source, "rev-parse", "HEAD")) + if err := os.WriteFile( + filepath.Join(repository, "second.txt"), + []byte("second\n"), + 0o600, + ); err != nil { + t.Fatalf("write second commit file: %v", err) + } + runGit(t, repository, "add", "second.txt") + runGit(t, repository, "commit", "-m", "second") + replacementOID := strings.TrimSpace(runGit(t, repository, "rev-parse", "HEAD")) + if replacementOID == sourceOID { + t.Fatal("source and replacement OIDs unexpectedly match") + } + + maliciousCommon := filepath.Join(parent, "malicious-common") + if err := os.CopyFS( + maliciousCommon, + os.DirFS(filepath.Join(repository, ".git")), + ); err != nil { + t.Fatalf("copy malicious common Git directory: %v", err) + } + runGit( + t, + parent, + "--git-dir="+maliciousCommon, + "update-ref", + "refs/heads/source-branch", + replacementOID, + ) + + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + marker := filepath.Join(parent, "head-commondir-swapped") + wrapper := filepath.Join(parent, "git-wrapper-head-commondir") + script := `#!/bin/sh +matched= +has_rev_parse= +has_verify= +has_head= +for argument in "$@"; do + [ "$argument" = "rev-parse" ] && has_rev_parse=1 + [ "$argument" = "--verify" ] && has_verify=1 + [ "$argument" = "HEAD" ] && has_head=1 +done +if [ -n "$has_rev_parse" ] && [ -n "$has_verify" ] && [ -n "$has_head" ] && + [ "$GIT_WORK_TREE" = "$DROVE_TEST_SOURCE" ] && + [ ! -e "$DROVE_TEST_MARKER" ]; then + private=$("$DROVE_TEST_REAL_GIT" rev-parse --absolute-git-dir) || exit 90 + original=$(cat "$private/commondir") || exit 91 + printf '%s\n' "$DROVE_TEST_MALICIOUS_COMMON" > "$private/commondir" || exit 92 + "$DROVE_TEST_REAL_GIT" "$@" + status=$? + printf '%s\n' "$original" > "$private/commondir" || exit 93 + : > "$DROVE_TEST_MARKER" + exit "$status" +fi +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_SOURCE", source) + t.Setenv("DROVE_TEST_MARKER", marker) + t.Setenv("DROVE_TEST_MALICIOUS_COMMON", maliciousCommon) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + root, err := openRealPathRoot(source) + if err != nil { + t.Fatalf("open source worktree: %v", err) + } + lease, err := newPreparationLease( + context.Background(), + manager, + source, + root, + ) + if err != nil { + t.Fatalf("open preparation lease: %v", err) + } + if lease.repository.startOID != sourceOID { + t.Fatalf( + "preparation start OID = %q, want registered %q", + lease.repository.startOID, + sourceOID, + ) + } + if err := lease.Close(); err != nil { + t.Fatalf("close preparation lease: %v", err) + } +} + +func TestPrepareDoesNotUsePublicWorktreeRepair(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Linux promotion rewrites the bound private pointer directly") + } + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + sibling := filepath.Join(parent, "sibling") + initSourceSwapRepository(t, repository) + runGit(t, repository, "worktree", "add", "-b", "sibling", sibling) + sibling, err := resolvePath(sibling) + if err != nil { + t.Fatalf("resolve sibling worktree: %v", err) + } + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find Git: %v", err) + } + repairResult := filepath.Join(parent, "worktree-repair-result") + wrapper := filepath.Join(parent, "git-wrapper-reject-worktree-repair") + script := `#!/bin/sh +saw_worktree= +for argument in "$@"; do + if [ "$saw_worktree" = "1" ] && [ "$argument" = "repair" ]; then + printf invoked > "$DROVE_TEST_REPAIR_RESULT" + exit 94 + fi + [ "$argument" = "worktree" ] && saw_worktree=1 +done +exec "$DROVE_TEST_REAL_GIT" "$@" +` + if err := os.WriteFile(wrapper, []byte(script), 0o700); err != nil { + t.Fatalf("write Git wrapper: %v", err) + } + t.Setenv("DROVE_TEST_REPAIR_RESULT", repairResult) + t.Setenv("DROVE_TEST_REAL_GIT", realGit) + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + manager.git = wrapper + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare without public worktree repair: %v", err) + } + if _, err := os.Lstat(repairResult); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("worktree repair was invoked: %v", err) + } + assertGitWorktreeRegistration( + t, + filepath.Join(repository, ".git"), + prepared.Path, + true, + ) + registered, err := manager.worktreeRegistered( + context.Background(), + repository, + sibling, + ) + if err != nil || !registered { + t.Fatalf( + "sibling registration restored = %v, err=%v\n%s", + registered, + err, + runGit(t, repository, "worktree", "list", "--porcelain"), + ) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard prepared worktree: %v", err) + } +} + func postSuccessSwapGitWrapper( t *testing.T, parent string, diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 8b96600..2b9bd6d 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -49,23 +49,32 @@ type Workspace struct { createdBranch bool branchOperationID string + expectedHeadOID string gitDirectory string directoryIdentity string protectionKnown bool includedPaths []string + trackedPaths map[string]struct{} + trackedIgnoreCase bool preparation *preparationLease repositoryEvidence *repositoryEvidence } // Manager owns worktrees below one Drove data directory. type Manager struct { - root string - git string - dataDirInfo os.FileInfo - worktreeInfo os.FileInfo - bucketInfo map[string]os.FileInfo - rootErr error - mu *sync.Mutex + root string + git string + dataDirInfo os.FileInfo + worktreeInfo os.FileInfo + bucketInfo map[string]os.FileInfo + acknowledgedRemovals map[removalAcknowledgementReceipt]struct{} + rootErr error + mu *sync.Mutex +} + +type removalAcknowledgementReceipt struct { + agentID string + operationID string } // New creates a worktree manager without changing the filesystem. @@ -120,13 +129,14 @@ func New(dataDir string) (*Manager, error) { rootErr = fmt.Errorf("workspace: inspect worktree root: %w", err) } manager := &Manager{ - root: rootPath, - git: "git", - dataDirInfo: dataDirInfo, - worktreeInfo: worktreeInfo, - bucketInfo: bucketInfo, - rootErr: rootErr, - mu: workspaceManagerLock(rootPath), + root: rootPath, + git: "git", + dataDirInfo: dataDirInfo, + worktreeInfo: worktreeInfo, + bucketInfo: bucketInfo, + acknowledgedRemovals: make(map[removalAcknowledgementReceipt]struct{}), + rootErr: rootErr, + mu: workspaceManagerLock(rootPath), } if worktreeInfo == nil || rootErr != nil { return manager, nil @@ -270,6 +280,22 @@ func (m *Manager) prepare( return Workspace{}, err } createdBranch := !exists + expectedHeadOID := sourceRepository.startOID + if exists { + var branchExists bool + expectedHeadOID, branchExists, err = sourceRepository.refOID( + ctx, + "refs/heads/"+branch, + ) + if err != nil { + return Workspace{}, err + } + if !branchExists { + return Workspace{}, errors.New( + "workspace: branch disappeared while preparing", + ) + } + } branchOperationID := uuid.NewString() result := Workspace{ AgentID: agentID, @@ -277,6 +303,7 @@ func (m *Manager) prepare( Path: path, Branch: branch, branchOperationID: branchOperationID, + expectedHeadOID: expectedHeadOID, repositoryEvidence: cloneRepositoryEvidence( &lease.evidence, ), @@ -299,10 +326,7 @@ func (m *Manager) prepare( ) defer cancel() if !recordInstalled { - return Workspace{}, errors.Join( - err, - m.removeManagedBucketIfEmpty(result), - ) + return Workspace{}, err } cleanupTarget := result cleanupTarget.createdBranch = false @@ -311,7 +335,8 @@ func (m *Manager) prepare( m.discard(cleanupCtx, cleanupTarget), ) } - if err := m.createPreparedWorktreeTarget(&result); err != nil { + preparedTarget, err := m.createPreparedWorktreeTarget(&result) + if err != nil { cleanupCtx, cancel := context.WithTimeout( context.Background(), 10*time.Second, @@ -319,6 +344,7 @@ func (m *Manager) prepare( defer cancel() return Workspace{}, errors.Join( err, + m.cleanupPreparedWorktreeTarget(result, preparedTarget), m.discardWithRepository( cleanupCtx, result, @@ -327,29 +353,48 @@ func (m *Manager) prepare( ), ) } + defer func() { + if resultErr != nil { + cleanupCtx, cancel := context.WithTimeout( + context.Background(), + 10*time.Second, + ) + defer cancel() + cleanupErr := m.cleanupPreparedWorktreeTarget( + result, + preparedTarget, + ) + var pruneErr, discardErr error + if cleanupErr == nil { + pruneErr = sourceRepository.pruneWorktrees(cleanupCtx) + if pruneErr == nil { + discardErr = m.discardWithRepository( + cleanupCtx, + result, + sourceRepository, + true, + ) + } + } + resultErr = errors.Join( + resultErr, + cleanupErr, + pruneErr, + discardErr, + ) + } + resultErr = errors.Join(resultErr, preparedTarget.Close()) + }() if createdBranch { if err := sourceRepository.createOwnedBranch( ctx, branch, branchOperationID, ); err != nil { - cleanupCtx, cancel := context.WithTimeout( - context.Background(), - 10*time.Second, - ) - defer cancel() - return Workspace{}, errors.Join( - fmt.Errorf( - "workspace: create branch %q: %w", - branch, - err, - ), - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), + return Workspace{}, fmt.Errorf( + "workspace: create branch %q: %w", + branch, + err, ) } result.createdBranch = true @@ -358,96 +403,51 @@ func (m *Manager) prepare( err = errors.New("workspace: branch ownership record is missing") } if err != nil { - cleanupCtx, cancel := context.WithTimeout( - context.Background(), - 10*time.Second, - ) - defer cancel() - return Workspace{}, errors.Join( - fmt.Errorf( - "workspace: read branch ownership record: %w", - err, - ), - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), + return Workspace{}, fmt.Errorf( + "workspace: read branch ownership record: %w", + err, ) } record.CreatedBranch = true if err := m.replaceWorkspaceRecord(record); err != nil { - cleanupCtx, cancel := context.WithTimeout( - context.Background(), - 10*time.Second, - ) - defer cancel() - return Workspace{}, errors.Join( - fmt.Errorf( - "workspace: confirm branch ownership: %w", - err, - ), - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), + return Workspace{}, fmt.Errorf( + "workspace: confirm branch ownership: %w", + err, ) } } - arguments := []string{"worktree", "add", "--quiet", "--no-checkout"} - arguments = append(arguments, path, branch) - if _, err := sourceRepository.runForward( + if _, err := sourceRepository.addPreparedWorktree( ctx, - "", - arguments..., + result, + preparedTarget, + result.Branch, + expectedHeadOID, ); err != nil { - cleanupCtx, cancel := context.WithTimeout( - context.Background(), - 10*time.Second, - ) - defer cancel() - return Workspace{}, errors.Join( - fmt.Errorf("workspace: create worktree: %w", err), - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), - ) + return Workspace{}, fmt.Errorf("workspace: create worktree: %w", err) } - if err := m.initializePreparedWorktree(ctx, &result); err != nil { - cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return Workspace{}, errors.Join( - err, - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), - ) + if err := m.initializePreparedWorktree( + ctx, + &result, + preparedTarget, + sourceRepository, + includedPaths, + ); err != nil { + return Workspace{}, err + } + if err := m.promotePreparedWorktreeTarget( + ctx, + result, + preparedTarget, + sourceRepository, + ); err != nil { + return Workspace{}, err } if err := verifyRealPathRoot(sourcePath, sourceRoot); err != nil { - cleanupCtx, cancel := context.WithTimeout( - context.Background(), - 10*time.Second, - ) - defer cancel() - return Workspace{}, errors.Join( - fmt.Errorf("workspace: source repository changed: %w", err), - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), + return Workspace{}, fmt.Errorf( + "workspace: source repository changed: %w", + err, ) } copiedPaths, err := m.copyIncludedSelection( @@ -458,18 +458,10 @@ func (m *Manager) prepare( result, includeSelection, ) + result.trackedPaths = nil + result.trackedIgnoreCase = false if err != nil { - cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return Workspace{}, errors.Join( - err, - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), - ) + return Workspace{}, err } if len(copiedPaths) != len(includedPaths) { record, exists, err := m.readWorkspaceRecord(result.Path) @@ -481,36 +473,16 @@ func (m *Manager) prepare( err = m.replaceWorkspaceRecord(record) } if err != nil { - cleanupCtx, cancel := context.WithTimeout( - context.Background(), - 10*time.Second, - ) - defer cancel() - return Workspace{}, errors.Join( - fmt.Errorf( - "workspace: persist copied include paths: %w", - err, - ), - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), + return Workspace{}, fmt.Errorf( + "workspace: persist copied include paths: %w", + err, ) } } if err := verifyRealPathRoot(sourcePath, sourceRoot); err != nil { - cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return Workspace{}, errors.Join( - fmt.Errorf("workspace: source repository changed: %w", err), - m.discardWithRepository( - cleanupCtx, - result, - sourceRepository, - true, - ), + return Workspace{}, fmt.Errorf( + "workspace: source repository changed: %w", + err, ) } result.protectionKnown = true @@ -521,7 +493,8 @@ func (m *Manager) prepare( } func workspaceManagerLock(root string) *sync.Mutex { - lock, _ := managerLocks.LoadOrStore(root, &sync.Mutex{}) + key := workspaceManagerLockKey(root) + lock, _ := managerLocks.LoadOrStore(key, &sync.Mutex{}) return lock.(*sync.Mutex) } @@ -661,6 +634,39 @@ func (m *Manager) listRepositoryBucket( err, ) } + if hasRecord { + reserved, reservationErr := + openedRemovalPathIsAcknowledgementReservation( + bucket, + workspaceRoot, + record, + ) + if reserved { + closeErr := workspaceRoot.Close() + if err := errors.Join( + reservationErr, + closeErr, + ); err != nil { + return nil, err + } + missing := record.workspace() + missing.Missing = true + missing.protectionKnown = record.ProtectionKnown + missing.includedPaths = append( + []string(nil), + record.IncludedPaths..., + ) + result = append(result, missing) + continue + } + if reservationErr != nil { + _ = workspaceRoot.Close() + return nil, fmt.Errorf( + "workspace: inspect removal acknowledgement reservation: %w", + reservationErr, + ) + } + } if err := m.pinDataDirectory(); err != nil { _ = workspaceRoot.Close() return nil, err @@ -669,7 +675,12 @@ func (m *Manager) listRepositoryBucket( if hasRecord { recorded = &record } - current, inspectErr := m.inspect(ctx, path, recorded) + current, inspectErr := m.inspectAtRoot( + ctx, + path, + workspaceRoot, + recorded, + ) verifyErr := verifyRootEntryUnchanged( bucket, agentID, @@ -702,8 +713,8 @@ func (m *Manager) discard( target Workspace, ) (result error) { lease := target.preparation - temporaryLease := false - if lease == nil { + liveLease := lease != nil && !lease.isClosed() + if !liveLease { record, exists, err := m.readWorkspaceRecord(target.Path) if err != nil { return err @@ -722,23 +733,19 @@ func (m *Manager) discard( if err != nil { return err } - temporaryLease = true - defer func() { - result = errors.Join(result, lease.Close()) - }() } + defer func() { + result = errors.Join(result, lease.Close()) + }() if err := m.discardWithRepository( ctx, target, lease.repository, - target.preparation != nil, + liveLease, ); err != nil { return err } - if temporaryLease { - return nil - } - return lease.Close() + return nil } func (m *Manager) discardWithRepository( @@ -767,38 +774,85 @@ func (m *Manager) discardWithRepository( "workspace: preparation record does not match discard request", ) } - var result error + if err := cleanupPreparedWorktreeAddDebris( + ctx, + repository, + target, + record, + ); err != nil { + return fmt.Errorf( + "workspace: clean prepared worktree registration stage: %w", + err, + ) + } + stagingPath, stagingRegistrationPath, stagingExists, err := + m.preparedWorktreeStagingPaths(target, record) + if err != nil { + return err + } _, pathErr := os.Lstat(target.Path) pathExists := pathErr == nil - pathMissing := errors.Is(pathErr, os.ErrNotExist) if pathErr != nil && !errors.Is(pathErr, os.ErrNotExist) { - result = errors.Join( - result, - fmt.Errorf("workspace: inspect discarded worktree: %w", pathErr), + return fmt.Errorf( + "workspace: inspect discarded worktree: %w", + pathErr, ) } + if pathExists && stagingExists { + return errors.New( + "workspace: preparation has both public and staging worktree paths", + ) + } + activePath := target.Path + if stagingExists { + activePath = stagingPath + pathExists = true + } if pathExists && (record.DirectoryIdentity == "" || (record.GitDirectory == "" && !allowIncompleteGitIdentity)) { - return errors.Join( - result, - errors.New( - "workspace: preparation record has incomplete worktree identity", - ), + return errors.New( + "workspace: preparation record has incomplete worktree identity", ) } - registered, err := repository.worktreeRegistered( - ctx, - target.Path, - ) + registrationPaths := []string{target.Path} + if stagingRegistrationPath != target.Path { + registrationPaths = append( + registrationPaths, + stagingRegistrationPath, + ) + } + anyRegistered := func() (bool, error) { + for _, path := range registrationPaths { + registered, err := repository.worktreeRegistered(ctx, path) + if err != nil { + return false, err + } + if registered { + return true, nil + } + } + return false, nil + } + registered, err := anyRegistered() + var result error worktreeRemoved := false if err != nil { result = errors.Join(result, err) } else { - pathRemoved := pathMissing + pathRemoved := !pathExists if pathExists { var removeErr error - if repository.root != nil { + if activePath != target.Path { + removeErr = m.removeStagedPreparedWorktree( + ctx, + target, + record, + activePath, + stagingRegistrationPath, + repository, + ) + } else if repository.root != nil { removeErr = m.removePreparedWorktreeAtRoot( ctx, target, @@ -822,6 +876,12 @@ func (m *Manager) discardWithRepository( ), ) } else { + if !registered { + registered, removeErr = anyRegistered() + if removeErr != nil { + result = errors.Join(result, removeErr) + } + } pathRemoved = true } } @@ -835,10 +895,7 @@ func (m *Manager) discardWithRepository( } } if pathRemoved { - stillRegistered, err := repository.worktreeRegistered( - ctx, - target.Path, - ) + stillRegistered, err := anyRegistered() if err != nil { result = errors.Join(result, err) } else if stillRegistered { @@ -865,17 +922,9 @@ func (m *Manager) discardWithRepository( branchCleaned = true } } - recordRemoved := false if worktreeRemoved && branchCleaned { if err := m.removeWorkspaceRecord(target.Path); err != nil { result = errors.Join(result, err) - } else { - recordRemoved = true - } - } - if recordRemoved { - if err := m.removeManagedBucketIfEmpty(target); err != nil { - result = errors.Join(result, err) } } return result @@ -903,7 +952,12 @@ func (m *Manager) removeDiscardedPath( result = errors.Join(result, opened.Close()) } }() - current, inspectErr := m.inspect(ctx, target.Path, &record) + current, inspectErr := m.inspectAtRoot( + ctx, + target.Path, + opened, + &record, + ) verifyErr := verifyRootEntryUnchanged( bucket, target.AgentID, @@ -953,9 +1007,27 @@ func (m *Manager) inspect( ctx context.Context, path string, recorded *workspaceRecord, +) (_ Workspace, result error) { + root, err := openRealPathRoot(path) + if err != nil { + return Workspace{}, err + } + defer func() { + result = errors.Join(result, root.Close()) + }() + current, err := m.inspectAtRoot(ctx, path, root, recorded) + verifyErr := verifyRealPathRoot(path, root) + return current, errors.Join(err, verifyErr) +} + +func (m *Manager) inspectAtRoot( + ctx context.Context, + path string, + root *os.Root, + recorded *workspaceRecord, ) (Workspace, error) { agentID := filepath.Base(path) - directoryIdentity, err := worktreeDirectoryIdentity(path) + directoryIdentity, err := openedDirectoryIdentity(root) if err != nil { return Workspace{}, err } @@ -967,7 +1039,7 @@ func (m *Manager) inspect( path, ) } - repository, err := m.repositoryRoot(ctx, path) + repository, err := m.repositoryRootAtRoot(ctx, path, root) if err != nil { return Workspace{}, fmt.Errorf("workspace: inspect repository for %q: %w", path, err) } @@ -977,7 +1049,7 @@ func (m *Manager) inspect( path, ) } - gitDirectory, err := m.worktreeGitDirectory(ctx, path) + gitDirectory, err := m.worktreeGitDirectoryAtRoot(ctx, path, root) if err != nil { return Workspace{}, err } @@ -989,8 +1061,26 @@ func (m *Manager) inspect( path, ) } + if err := verifyRealPathRoot(path, root); err != nil { + return Workspace{}, err + } + if err := cleanupRecordDeletionNamespace(root); err != nil { + return Workspace{}, fmt.Errorf( + "workspace: recover internal deletions for %q: %w", + path, + err, + ) + } - branchOutput, err := m.run(ctx, "-C", path, "symbolic-ref", "--quiet", "--short", "HEAD") + branchOutput, err := m.runRootedGit( + ctx, + path, + root, + "symbolic-ref", + "--quiet", + "--short", + "HEAD", + ) detached := false branch := strings.TrimSpace(string(branchOutput)) if isExitCode(err, 1) { @@ -998,7 +1088,14 @@ func (m *Manager) inspect( if recorded != nil { branch = recorded.Branch } else { - head, headErr := m.run(ctx, "-C", path, "rev-parse", "--short", "HEAD") + head, headErr := m.runRootedGit( + ctx, + path, + root, + "rev-parse", + "--short", + "HEAD", + ) if headErr != nil { return Workspace{}, fmt.Errorf( "workspace: inspect detached HEAD for %q: %w", @@ -1011,10 +1108,10 @@ func (m *Manager) inspect( } else if err != nil { return Workspace{}, fmt.Errorf("workspace: inspect branch for %q: %w", path, err) } - status, err := m.run( + status, err := m.runRootedGit( ctx, - "-C", path, + root, "status", "--porcelain=v1", "--untracked-files=all", @@ -1028,7 +1125,7 @@ func (m *Manager) inspect( if recorded != nil { includedPaths = append([]string(nil), recorded.IncludedPaths...) for _, relative := range includedPaths { - _, pathErr := os.Lstat(filepath.Join(path, relative)) + _, pathErr := root.Lstat(relative) switch { case pathErr == nil: includedDirty = true @@ -1060,18 +1157,12 @@ func workspaceStatusDirty( status []byte, recorded *workspaceRecord, ) bool { - ignored := "" - if recorded != nil && - recorded.Removal != nil && - recorded.Removal.DirectoryToken != "" { - ignored = "?? " + removalMarkerName(*recorded) - } for _, line := range strings.Split( strings.TrimSpace(string(status)), "\n", ) { line = strings.TrimSuffix(line, "\r") - if line == "" || line == ignored { + if line == "" || isRemovalInternalStatus(line, recorded) { continue } return true @@ -1079,6 +1170,23 @@ func workspaceStatusDirty( return false } +func isRemovalInternalStatus( + line string, + recorded *workspaceRecord, +) bool { + if recorded == nil || + recorded.Removal == nil || + recorded.Removal.DirectoryToken == "" { + return false + } + name, found := strings.CutPrefix(line, "?? ") + if !found { + return false + } + return name == removalMarkerName(*recorded) || + isRemovalMarkerTemporaryName(*recorded, name) +} + func (m *Manager) worktreeGitDirectory( ctx context.Context, path string, @@ -1097,7 +1205,7 @@ func (m *Manager) worktreeGitDirectory( err, ) } - gitDirectory := strings.TrimSpace(string(output)) + gitDirectory := trimGitLineTerminator(output) if !filepath.IsAbs(gitDirectory) { return "", fmt.Errorf( "workspace: Git directory for %q is not absolute", @@ -1183,7 +1291,7 @@ func (m *Manager) repositoryPaths( err, ) } - sourcePath := strings.TrimSpace(string(output)) + sourcePath := trimGitLineTerminator(output) sourcePath, err = filepath.EvalSymlinks(sourcePath) if err != nil { return "", "", fmt.Errorf( @@ -1203,12 +1311,17 @@ func (m *Manager) repositoryPaths( func (m *Manager) validateBranch(ctx context.Context, branch string) error { if branch == "" || + branch == "HEAD" || strings.HasPrefix(branch, "-") || strings.ContainsRune(branch, '\x00') { return fmt.Errorf("%w: %q", ErrInvalidBranch, branch) } - if _, err := m.run(ctx, "check-ref-format", "refs/heads/"+branch); err != nil { - if isExitCode(err, 1) { + if _, err := m.run( + ctx, + "check-ref-format", + "refs/heads/"+branch, + ); err != nil { + if isExitCode(err, 1) || isExitCode(err, 128) { return errors.Join( fmt.Errorf("%w: %q", ErrInvalidBranch, branch), err, @@ -1277,7 +1390,7 @@ func (m *Manager) repositoryRootWith( if err != nil { return "", fmt.Errorf("workspace: inspect common Git directory: %w", err) } - commonPath := strings.TrimSpace(string(commonOutput)) + commonPath := trimGitLineTerminator(commonOutput) if !filepath.IsAbs(commonPath) { commonPath = filepath.Join(worktreePath, commonPath) } @@ -1296,7 +1409,7 @@ func (m *Manager) repositoryRootWith( "core.worktree", ) if err == nil { - configured := strings.TrimSpace(string(worktreeOutput)) + configured := trimGitLineTerminator(worktreeOutput) if !filepath.IsAbs(configured) { configured = filepath.Join(commonPath, configured) } @@ -1313,7 +1426,35 @@ func (m *Manager) repositoryRootWith( return "", fmt.Errorf("workspace: inspect configured worktree: %w", err) } - root := filepath.Dir(commonPath) + worktreesOutput, err := run( + "worktree", + "list", + "--porcelain", + "-z", + ) + if err != nil { + return "", fmt.Errorf("workspace: list repository worktrees: %w", err) + } + worktrees, err := parseRegisteredWorktrees(worktreesOutput) + if err != nil { + return "", err + } + if len(worktrees) == 0 { + return "", errors.New("workspace: repository has no registered worktree") + } + root := worktrees[0].path + if sameRegisteredWorktreePath(root, commonPath) { + topLevelOutput, err := run("rev-parse", "--show-toplevel") + if err != nil { + return "", fmt.Errorf("workspace: inspect repository worktree: %w", err) + } + root = trimGitLineTerminator(topLevelOutput) + if !filepath.IsAbs(root) { + return "", errors.New( + "workspace: repository worktree is not absolute", + ) + } + } resolved, err := resolvePath(root) if err != nil { return "", fmt.Errorf("workspace: resolve repository root: %w", err) @@ -1392,7 +1533,7 @@ func (m *Manager) worktreeRegistration( } path = filepath.Clean(path) for _, registered := range worktrees { - if registered.path == path { + if sameRegisteredWorktreePath(registered.path, path) { return registered, true, nil } } @@ -1571,6 +1712,10 @@ func isExitCode(err error, code int) bool { return errors.As(err, &exitErr) && exitErr.ExitCode() == code } +func trimGitLineTerminator(output []byte) string { + return strings.TrimSuffix(string(output), "\n") +} + func resolvePath(path string) (string, error) { path = filepath.Clean(path) var suffix []string @@ -1620,20 +1765,6 @@ func validRepositoryHash(value string) bool { return true } -func ensureDirectory(path string) error { - if err := os.MkdirAll(path, 0o700); err != nil { - return fmt.Errorf("workspace: create directory %q: %w", path, err) - } - info, err := os.Lstat(path) - if err != nil { - return fmt.Errorf("workspace: inspect directory %q: %w", path, err) - } - if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("workspace: path %q is not a real directory", path) - } - return nil -} - func ensureAbsent(path string) error { _, err := os.Lstat(path) switch { diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go index fd2e1b3..ac964bf 100644 --- a/internal/workspace/workspace_test.go +++ b/internal/workspace/workspace_test.go @@ -250,6 +250,9 @@ func TestPrepareListAndCleanupWorktree(t *testing.T) { if _, err := os.Lstat(filepath.Join(prepared.Path, "ignored.key")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("unlisted ignored file exists or inspect failed: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } listed, err := manager.List(context.Background()) if err != nil { @@ -371,6 +374,9 @@ func TestPrepareIsolatesConcurrentAgentChanges(t *testing.T) { if err != nil { t.Fatalf("prepare first worktree: %v", err) } + if err := manager.AcknowledgePreparation(first); err != nil { + t.Fatalf("acknowledge first preparation: %v", err) + } second, err := manager.Prepare( context.Background(), repository, @@ -380,6 +386,9 @@ func TestPrepareIsolatesConcurrentAgentChanges(t *testing.T) { if err != nil { t.Fatalf("prepare second worktree: %v", err) } + if err := manager.AcknowledgePreparation(second); err != nil { + t.Fatalf("acknowledge second preparation: %v", err) + } if err := os.WriteFile( filepath.Join(first.Path, "tracked.txt"), @@ -434,6 +443,9 @@ func TestListAndCleanupDetachedWorktree(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } runGit(t, prepared.Path, "checkout", "--detach") if err := os.WriteFile( filepath.Join(prepared.Path, "detached.txt"), @@ -490,6 +502,9 @@ func TestCleanupRepairsMissingWorktreeRegistration(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } if err := os.RemoveAll(prepared.Path); err != nil { t.Fatalf("remove worktree directory externally: %v", err) } @@ -665,6 +680,9 @@ func TestDiscardPreservesWorkspaceWhenRegistrationCheckFails(t *testing.T) { if err := manager.Discard(ctx, prepared); err == nil { t.Fatal("discard succeeded with a canceled registration check") } + if !prepared.preparation.isClosed() { + t.Fatal("failed discard retained its preparation lease") + } if _, err := os.Lstat(prepared.Path); err != nil { t.Fatalf("failed discard removed worktree: %v", err) } @@ -847,6 +865,9 @@ func TestVersionOneRecordRequiresForceEvenWhenPathIsMissing(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } legacy, err := json.Marshal(struct { Version int `json:"version"` AgentID string `json:"agent_id"` @@ -953,7 +974,7 @@ func TestRemoveUpgradesCommittedVersionFourRecord(t *testing.T) { t.Fatalf("read upgraded removal record: exists=%v err=%v", exists, err) } if upgraded.Version != workspaceRecordVersion || - upgraded.RepositoryEvidence != nil || + upgraded.RepositoryEvidence == nil || upgraded.BranchOperationID != "" || upgraded.Removal == nil { t.Fatalf("upgraded removal record = %+v", upgraded) @@ -963,6 +984,35 @@ func TestRemoveUpgradesCommittedVersionFourRecord(t *testing.T) { } } +func TestUpgradeVersionFiveRecordPreservesRecoveryEvidence(t *testing.T) { + evidence := &repositoryEvidence{ + SourcePath: "/source", + SourceDirectoryIdentity: "source-identity", + GitDirectory: "/source/.git", + GitDirectoryIdentity: "git-identity", + CommonGitDirectory: "/source/.git", + CommonGitDirectoryIdentity: "common-identity", + } + record := workspaceRecord{ + Version: removalWorkspaceRecordVersion, + PreparationCommitted: true, + BranchOperationID: "11111111-1111-4111-8111-111111111111", + ExpectedHeadOID: "expected-head", + RepositoryEvidence: evidence, + } + + upgradeWorkspaceRecord(&record) + + if record.Version != workspaceRecordVersion || + record.BranchOperationID != + "11111111-1111-4111-8111-111111111111" || + record.ExpectedHeadOID != "expected-head" || + record.RepositoryEvidence != evidence || + record.PreparedStageDirectoryIdentity != "" { + t.Fatalf("upgraded version 5 record = %+v", record) + } +} + func TestRemoveReturnsPendingAfterPartialGitMutation(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("test requires a POSIX shell") @@ -1048,6 +1098,9 @@ func TestRemoveRevalidatesExistingNonForceIntent(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1112,6 +1165,9 @@ func TestRemoveUpgradesExistingIntentToForce(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1168,6 +1224,9 @@ func TestReconcileRemovalDeletesPresentUnregisteredPath(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1230,6 +1289,9 @@ func TestReconcileNonForceRemovalPreservesPresentUnregisteredPath(t *testing.T) if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1282,6 +1344,9 @@ func TestReconcileNonForceRemovalPreservesMissingDetachedRegistration(t *testing if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } runGit(t, prepared.Path, "checkout", "--detach") if err := os.WriteFile( filepath.Join(prepared.Path, "detached-only.txt"), @@ -1316,7 +1381,7 @@ func TestReconcileNonForceRemovalPreservesMissingDetachedRegistration(t *testing t.Fatalf("reconcile missing detached worktree error = %v, want ErrDirty", err) } porcelain := runGit(t, repository, "worktree", "list", "--porcelain") - if !strings.Contains(porcelain, prepared.Path) || + if !strings.Contains(porcelain, filepath.ToSlash(prepared.Path)) || !strings.Contains(porcelain, detachedHead) || !strings.Contains(porcelain, "detached") { t.Fatalf("detached registration was not preserved:\n%s", porcelain) @@ -1343,6 +1408,9 @@ func TestReconcilePendingRemovalRequiresGit(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1385,6 +1453,9 @@ func TestReconcileRemovalReturnsAlreadyAbsentWorkspace(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1426,6 +1497,9 @@ func TestRemoveRecordsInitiallyAbsentPath(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } runGit(t, repository, "worktree", "remove", "--force", prepared.Path) result, err := manager.Remove( @@ -1468,6 +1542,9 @@ func TestReconcileClearsUnsafeNonForceIntent(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1518,6 +1595,9 @@ func TestAcknowledgeRemovalValidatesTokenAndIsIdempotent(t *testing.T) { if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } result, err := manager.Remove(context.Background(), prepared.AgentID, true) if err != nil || result.State != RemovalComplete { t.Fatalf("remove workspace = %+v, %v", result, err) @@ -1557,6 +1637,35 @@ func TestPrepareUsesExistingBranchAndDiscardPreservesIt(t *testing.T) { runGit(t, repository, "show-ref", "--verify", "refs/heads/existing") } +func TestPrepareRejectsBranchCheckedOutInSourceWorktree(t *testing.T) { + repository := newTestRepository(t) + branch := strings.TrimSpace( + runGit(t, repository, "symbolic-ref", "--short", "HEAD"), + ) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + if prepared, err := manager.Prepare( + context.Background(), + repository, + branch, + testAgentID, + ); err == nil { + _ = manager.Discard(context.Background(), prepared) + t.Fatal("prepare reused the source worktree branch") + } + if got := strings.TrimSpace( + runGit(t, repository, "symbolic-ref", "--short", "HEAD"), + ); got != branch { + t.Fatalf("source branch = %q, want %q", got, branch) + } + if status := runGit(t, repository, "status", "--porcelain"); status != "" { + t.Fatalf("source worktree changed after rejected prepare:\n%s", status) + } +} + func TestPrepareSkipsIncludeTrackedByTargetBranch(t *testing.T) { repository := newTestRepository(t) if err := os.WriteFile( @@ -1620,6 +1729,70 @@ func TestPrepareSkipsIncludeTrackedByTargetBranch(t *testing.T) { } } +func TestPrepareSkipsCaseAliasTrackedByTargetBranch(t *testing.T) { + repository := newTestRepository(t) + runGit(t, repository, "config", "core.ignoreCase", "true") + if err := os.WriteFile( + filepath.Join(repository, "Secret.local"), + []byte("tracked\n"), + 0o600, + ); err != nil { + t.Fatalf("write tracked target file: %v", err) + } + runGit(t, repository, "add", "Secret.local") + runGit(t, repository, "commit", "-m", "track target local file") + runGit(t, repository, "branch", "target-with-case-alias") + runGit(t, repository, "rm", "Secret.local") + if err := os.WriteFile( + filepath.Join(repository, ".gitignore"), + []byte("*.local\n"), + 0o600, + ); err != nil { + t.Fatalf("write gitignore: %v", err) + } + runGit(t, repository, "add", ".gitignore") + runGit(t, repository, "commit", "-m", "ignore local files on source") + if err := os.WriteFile( + filepath.Join(repository, worktreeIncludeFile), + []byte("secret.local\n"), + 0o600, + ); err != nil { + t.Fatalf("write include manifest: %v", err) + } + if err := os.WriteFile( + filepath.Join(repository, "secret.local"), + []byte("source local\n"), + 0o600, + ); err != nil { + t.Fatalf("write source local file: %v", err) + } + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "target-with-case-alias", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare target branch: %v", err) + } + assertFileContents(t, filepath.Join(prepared.Path, "Secret.local"), "tracked\n") + record, exists, err := manager.readWorkspaceRecord(prepared.Path) + if err != nil || !exists { + t.Fatalf("read preparation record: exists=%v err=%v", exists, err) + } + if len(record.IncludedPaths) != 0 { + t.Fatalf("protected include paths = %q, want none", record.IncludedPaths) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard target branch worktree: %v", err) + } +} + func TestPrepareFromLinkedWorktreeUsesCommonRepositoryIdentity(t *testing.T) { repository := newTestRepository(t) source := filepath.Join(t.TempDir(), "source-worktree") @@ -1656,6 +1829,61 @@ func TestPrepareFromLinkedWorktreeUsesCommonRepositoryIdentity(t *testing.T) { } } +func TestPrepareFromSeparateGitDirectoryUsesWorktreeIdentity(t *testing.T) { + parent := t.TempDir() + repository := filepath.Join(parent, "repository") + gitDirectory := filepath.Join(parent, "metadata", "repository.git") + if err := os.MkdirAll(filepath.Dir(gitDirectory), 0o700); err != nil { + t.Fatalf("create metadata directory: %v", err) + } + runGit( + t, + parent, + "init", + "--separate-git-dir="+gitDirectory, + repository, + ) + runGit(t, repository, "config", "user.email", "drove@example.com") + runGit(t, repository, "config", "user.name", "Drove Test") + if err := os.WriteFile( + filepath.Join(repository, "tracked.txt"), + []byte("tracked\n"), + 0o600, + ); err != nil { + t.Fatalf("write tracked file: %v", err) + } + runGit(t, repository, "add", "tracked.txt") + runGit(t, repository, "commit", "-m", "initial") + repository, err := resolvePath(repository) + if err != nil { + t.Fatalf("resolve worktree path: %v", err) + } + + manager, err := New(filepath.Join(parent, "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + prepared, err := manager.Prepare( + context.Background(), + repository, + "separate-git-directory", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare from separate Git directory: %v", err) + } + if prepared.Repository != repository { + t.Fatalf( + "repository = %q, want worktree %q", + prepared.Repository, + repository, + ) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard separate Git directory worktree: %v", err) + } +} + func TestPrepareFromLinkedWorktreeUsesSourceIndexForIncludes(t *testing.T) { repository := newTestRepository(t) const includedPath = "linked-local.env" @@ -1869,6 +2097,9 @@ func TestReconcileStartedRemovalDoesNotRollbackAfterWorkspaceBecomesDirty( if err != nil { t.Fatalf("prepare worktree: %v", err) } + if err := manager.AcknowledgePreparation(prepared); err != nil { + t.Fatalf("acknowledge preparation: %v", err) + } record, exists, err := manager.readWorkspaceRecord(prepared.Path) if err != nil || !exists { t.Fatalf("read workspace record: exists=%v err=%v", exists, err) @@ -1877,6 +2108,7 @@ func TestReconcileStartedRemovalDoesNotRollbackAfterWorkspaceBecomesDirty( OperationID: "12121212-1212-4212-8212-121212121212", DirectoryToken: "13131313-1313-4313-8313-131313131313", Started: true, + Quarantined: true, } root, err := openRealPathRoot(prepared.Path) if err != nil { @@ -1889,11 +2121,18 @@ func TestReconcileStartedRemovalDoesNotRollbackAfterWorkspaceBecomesDirty( if err := root.Close(); err != nil { t.Fatalf("close prepared workspace: %v", err) } + quarantinePath := filepath.Join( + filepath.Dir(prepared.Path), + removalQuarantinePrefix(record)+"14141414-1414-4414-8414-141414141414", + ) + if err := os.Rename(prepared.Path, quarantinePath); err != nil { + t.Fatalf("quarantine prepared workspace: %v", err) + } if err := manager.replaceWorkspaceRecord(record); err != nil { t.Fatalf("write started removal: %v", err) } if err := os.WriteFile( - filepath.Join(prepared.Path, "tracked.txt"), + filepath.Join(quarantinePath, "tracked.txt"), []byte("changed after physical removal started\n"), 0o600, ); err != nil { @@ -1911,6 +2150,9 @@ func TestReconcileStartedRemovalDoesNotRollbackAfterWorkspaceBecomesDirty( if _, err := os.Lstat(prepared.Path); !errors.Is(err, os.ErrNotExist) { t.Fatalf("started removal retained path or inspect failed: %v", err) } + if _, err := os.Lstat(quarantinePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("started removal retained quarantine or inspect failed: %v", err) + } if err := manager.AcknowledgeRemoval(removals[0]); err != nil { t.Fatalf("acknowledge started removal: %v", err) } @@ -1974,6 +2216,78 @@ exec "$DROVE_TEST_REAL_GIT" "$@" } } +func TestPreparePreservesTrailingSpaceInRepositoryPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows paths cannot end in a space") + } + parent, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("resolve repository parent: %v", err) + } + repository := filepath.Join(parent, "repository ") + initTestRepositoryAt(t, repository) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare trailing-space repository: %v", err) + } + if prepared.Repository != repository { + t.Fatalf( + "prepared repository = %q, want %q", + prepared.Repository, + repository, + ) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard trailing-space repository: %v", err) + } +} + +func TestPrepareSupportsNewlineInRepositoryPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows paths cannot contain a newline") + } + parent, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("resolve repository parent: %v", err) + } + repository := filepath.Join(parent, "repository\nline") + initTestRepositoryAt(t, repository) + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + prepared, err := manager.Prepare( + context.Background(), + repository, + "", + testAgentID, + ) + if err != nil { + t.Fatalf("prepare newline repository: %v", err) + } + if prepared.Repository != repository { + t.Fatalf( + "prepared repository = %q, want %q", + prepared.Repository, + repository, + ) + } + if err := manager.Discard(context.Background(), prepared); err != nil { + t.Fatalf("discard newline repository: %v", err) + } +} + func TestPrepareRejectsMissingRepositoryWithoutRunningGit(t *testing.T) { manager, err := New(filepath.Join(t.TempDir(), "data")) if err != nil { @@ -2015,6 +2329,35 @@ func TestValidateBranchPreservesRuntimeGitFailure(t *testing.T) { } } +func TestValidateBranchRejectsHEAD(t *testing.T) { + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + err = manager.validateBranch(context.Background(), "HEAD") + if !errors.Is(err, ErrInvalidBranch) { + t.Fatalf("validate HEAD error = %v, want ErrInvalidBranch", err) + } +} + +func TestValidateBranchRejectsPreviousCheckoutExpression(t *testing.T) { + repository := newTestRepository(t) + runGit(t, repository, "checkout", "-b", "current") + t.Chdir(repository) + + manager, err := New(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("new manager: %v", err) + } + err = manager.validateBranch(context.Background(), "@{-1}") + if !errors.Is(err, ErrInvalidBranch) { + t.Fatalf( + "validate previous checkout expression error = %v, want ErrInvalidBranch", + err, + ) + } +} + func newTestRepository(t *testing.T) string { t.Helper() if _, err := exec.LookPath("git"); err != nil { @@ -2025,6 +2368,8 @@ func newTestRepository(t *testing.T) string { t.Fatalf("create repository: %v", err) } runGit(t, repository, "init", "--initial-branch=main") + runGit(t, repository, "config", "core.autocrlf", "false") + runGit(t, repository, "config", "core.longpaths", "true") runGit(t, repository, "config", "user.name", "Drove Test") runGit(t, repository, "config", "user.email", "drove@example.invalid") files := map[string]string{ @@ -2060,6 +2405,7 @@ func initTestRepositoryAt(t *testing.T, path string) { t.Fatalf("create repository: %v", err) } runGit(t, path, "init", "--initial-branch=main") + runGit(t, path, "config", "core.autocrlf", "false") runGit(t, path, "config", "user.name", "Drove Test") runGit(t, path, "config", "user.email", "drove@example.invalid") if err := os.WriteFile( diff --git a/scripts/check-workspace-platforms.sh b/scripts/check-workspace-platforms.sh new file mode 100755 index 0000000..44e7356 --- /dev/null +++ b/scripts/check-workspace-platforms.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +go_bin="${GO:-go}" +output_dir="$(mktemp -d)" +trap 'rm -rf "$output_dir"' EXIT + +targets=( + linux/amd64 + linux/arm64 + windows/amd64 + darwin/amd64 + darwin/arm64 + dragonfly/amd64 + freebsd/amd64 + netbsd/amd64 + openbsd/amd64 + aix/ppc64 + illumos/amd64 + solaris/amd64 + plan9/amd64 + js/wasm + wasip1/wasm +) + +for target in "${targets[@]}"; do + goos="${target%/*}" + goarch="${target#*/}" + echo "compile internal/workspace for ${goos}/${goarch}" + GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 \ + "$go_bin" test -c ./internal/workspace \ + -o "$output_dir/workspace-${goos}-${goarch}.test" +done