diff --git a/internal/adapter/boxenv/boxenv_test.go b/internal/adapter/boxenv/boxenv_test.go new file mode 100644 index 0000000000..97e34ad36d --- /dev/null +++ b/internal/adapter/boxenv/boxenv_test.go @@ -0,0 +1,384 @@ +package boxenv + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/internal/adapter/server" +) + +func TestProviderLifecycleAndWorkspace(t *testing.T) { + requireLocalHelper(t) + fake := newFakeBoxAPI(t) + provider, err := New(Config{ + APIKey: "test-key", + BaseURL: fake.server.URL, + HTTPClient: fake.server.Client(), + Scope: "test", + TTLSeconds: 60, + ReadyTimeout: 2 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + provider.client.poll = time.Millisecond + + ctx := context.Background() + binding, err := provider.Bind(ctx, server.PlacementBindRequest{ + Selector: server.DefaultPlacement(), Scope: "test", Operation: server.PlacementOperationCreate, + }) + if err != nil { + t.Fatal(err) + } + if binding.Ref.Kind != Kind || !binding.Ref.Valid() { + t.Fatalf("ref = %+v", binding.Ref) + } + if got := fake.lastCreateNoEnv(); !got { + t.Fatal("Box create did not force noEnv=true") + } + + ws := binding.Environment.Workspace() + if ws == nil { + t.Fatal("nil workspace") + } + runner := binding.Environment.CommandRunner() + if runner == nil { + t.Fatal("nil runner") + } + bound, ok := runner.(interface{ BoundWorkspaceRoot() string }) + if !ok { + t.Fatal("runner does not expose its bound workspace root") + } + if bound.BoundWorkspaceRoot() != ws.Root() { + t.Fatalf("runner/workspace affinity mismatch: runner=%q workspace=%q", bound.BoundWorkspaceRoot(), ws.Root()) + } + + v1, err := ws.CreateFile(ctx, "dir/a.txt", []byte("one\ntwo\n")) + if err != nil { + t.Fatal(err) + } + data, readVersion, err := ws.ReadVersion(ctx, "dir/a.txt") + if err != nil || string(data) != "one\ntwo\n" || !v1.Equal(readVersion) { + t.Fatalf("read = %q, versionEqual=%v, err=%v", data, v1.Equal(readVersion), err) + } + if _, err := ws.ReplaceFile(ctx, "dir/a.txt", tool.NewFileVersion("stale"), []byte("bad")); err == nil { + t.Fatal("stale replace succeeded") + } else { + var mismatch *tool.VersionMismatchError + if !errors.As(err, &mismatch) { + t.Fatalf("stale replace error = %T %v", err, err) + } + } + v2, err := ws.ReplaceFile(ctx, "dir/a.txt", readVersion, []byte("two\nneedle\n")) + if err != nil || v2.Equal(readVersion) { + t.Fatalf("replace version=%v err=%v", v2, err) + } + + if _, err := ws.CreateFile(ctx, "dir/a.txt", []byte("duplicate")); !errors.Is(err, fs.ErrExist) { + t.Fatalf("duplicate create = %v", err) + } + if _, err := ws.Read(ctx, "../escape"); err == nil { + t.Fatal("path escape accepted") + } + + ns, ok := ws.(tool.WorkspaceNamespace) + if !ok { + t.Fatal("Box workspace does not expose namespace operations") + } + if _, err := ns.CopyFile(ctx, "dir/a.txt", "dir/b.txt"); err != nil { + t.Fatal(err) + } + entries, err := ns.ReadDir(ctx, "dir") + if err != nil || len(entries) != 2 { + t.Fatalf("readdir = %+v, err=%v", entries, err) + } + matches, err := ws.Grep(ctx, "needle", "**/*.txt") + if err != nil || len(matches) != 2 { + t.Fatalf("grep = %+v, err=%v", matches, err) + } + paths, err := ws.Glob(ctx, "**/*.txt") + if err != nil || len(paths) != 2 { + t.Fatalf("glob = %+v, err=%v", paths, err) + } + if err := ns.Rename(ctx, "dir/b.txt", "dir/c.txt"); err != nil { + t.Fatal(err) + } + if err := ns.Remove(ctx, "dir/c.txt"); err != nil { + t.Fatal(err) + } + + if _, err := runner.Run(ctx, "printf shell > shell.txt"); err != nil { + t.Fatal(err) + } + if got, err := ws.Read(ctx, "shell.txt"); err != nil || string(got) != "shell" { + t.Fatalf("shell/read affinity = %q, %v", got, err) + } + + if binding.Close == nil { + t.Fatal("binding has no provisional close") + } + if err := binding.Close(); err != nil { + t.Fatal(err) + } + if state := fake.state(binding.Ref.ID); state != "archived" { + t.Fatalf("state after close = %q", state) + } + + rebound, err := provider.Reattach(ctx, server.PlacementReattachRequest{Ref: binding.Ref, Scope: "test"}) + if err != nil { + t.Fatal(err) + } + if state := fake.state(binding.Ref.ID); state != "idle" { + t.Fatalf("state after reattach = %q", state) + } + if got, err := rebound.Environment.Workspace().Read(ctx, "shell.txt"); err != nil || string(got) != "shell" { + t.Fatalf("reattached content = %q, %v", got, err) + } + + stale := binding.Ref + stale.Revision = "old" + if _, err := provider.Reattach(ctx, server.PlacementReattachRequest{Ref: stale, Scope: "test"}); !errors.Is(err, server.ErrPlacementStale) { + t.Fatalf("stale reattach = %v", err) + } +} + +func TestConcurrentReplaceHasOneWinner(t *testing.T) { + requireLocalHelper(t) + fake := newFakeBoxAPI(t) + provider, err := New(Config{APIKey: "test-key", BaseURL: fake.server.URL, HTTPClient: fake.server.Client(), Scope: "test", TTLSeconds: 60, ReadyTimeout: time.Second}) + if err != nil { + t.Fatal(err) + } + provider.client.poll = time.Millisecond + binding, err := provider.Bind(context.Background(), server.PlacementBindRequest{Selector: server.DefaultPlacement(), Scope: "test", Operation: server.PlacementOperationCreate}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = binding.Close() }() + ws := binding.Environment.Workspace() + v, err := ws.CreateFile(context.Background(), "race.txt", []byte("base")) + if err != nil { + t.Fatal(err) + } + + start := make(chan struct{}) + results := make(chan error, 2) + for _, text := range []string{"left", "right"} { + text := text + go func() { + <-start + _, err := ws.ReplaceFile(context.Background(), "race.txt", v, []byte(text)) + results <- err + }() + } + close(start) + var success, conflict int + for range 2 { + err := <-results + if err == nil { + success++ + continue + } + var mismatch *tool.VersionMismatchError + if errors.As(err, &mismatch) { + conflict++ + } else { + t.Fatalf("unexpected replace error: %v", err) + } + } + if success != 1 || conflict != 1 { + t.Fatalf("success=%d conflict=%d", success, conflict) + } +} + +func TestNoFSAndConfigurationGuards(t *testing.T) { + fake := newFakeBoxAPI(t) + provider, err := New(Config{APIKey: "test-key", BaseURL: fake.server.URL, HTTPClient: fake.server.Client(), Scope: "test"}) + if err != nil { + t.Fatal(err) + } + binding, err := provider.Bind(context.Background(), server.PlacementBindRequest{Selector: server.NoFSPlacement(), Scope: "test", Operation: server.PlacementOperationCreate}) + if err != nil || binding.Ref.Kind != session.EnvKindNoFS || binding.Environment.CommandRunner() != nil { + t.Fatalf("no-fs binding=%+v err=%v", binding.Ref, err) + } + if fake.createCount() != 0 { + t.Fatal("no-fs binding provisioned a Box") + } + + if _, err := New(Config{Scope: "test"}); err == nil { + t.Fatal("missing API key accepted") + } + if _, err := New(Config{APIKey: "secret", Scope: "test", Workdir: "../escape"}); err == nil { + t.Fatal("escaping workdir accepted") + } +} + +func requireLocalHelper(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("local Box helper contract fixture requires a POSIX host") + } + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 is required to exercise the same helper used inside Box") + } +} + +type fakeBoxAPI struct { + t *testing.T + server *httptest.Server + mu sync.Mutex + next int + boxes map[string]*fakeBox + creates []createBoxRequest +} + +type fakeBox struct { + state string + root string +} + +func newFakeBoxAPI(t *testing.T) *fakeBoxAPI { + t.Helper() + f := &fakeBoxAPI{t: t, boxes: map[string]*fakeBox{}} + f.server = httptest.NewServer(http.HandlerFunc(f.serveHTTP)) + t.Cleanup(f.server.Close) + return f +} + +func (f *fakeBoxAPI) serveHTTP(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-key" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if r.URL.Path == "/boxes" && r.Method == http.MethodPost { + var req createBoxRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + root := f.t.TempDir() + f.mu.Lock() + f.next++ + id := fmt.Sprintf("box-%d", f.next) + f.boxes[id] = &fakeBox{state: "idle", root: root} + f.creates = append(f.creates, req) + f.mu.Unlock() + writeJSON(w, http.StatusAccepted, boxEnvelope{Box: boxState{ID: id, State: "idle"}}) + return + } + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) < 2 || parts[0] != "boxes" { + http.NotFound(w, r) + return + } + id := parts[1] + f.mu.Lock() + box := f.boxes[id] + f.mu.Unlock() + if box == nil { + http.NotFound(w, r) + return + } + if len(parts) == 2 && r.Method == http.MethodGet { + f.mu.Lock() + state := box.state + f.mu.Unlock() + writeJSON(w, http.StatusOK, boxEnvelope{Box: boxState{ID: id, State: state}}) + return + } + if len(parts) != 3 || r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + switch parts[2] { + case "stop": + f.mu.Lock() + box.state = "archived" + f.mu.Unlock() + w.WriteHeader(http.StatusAccepted) + case "resume": + var req resumeBoxRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || !req.NoEnv { + http.Error(w, "resume must preserve noEnv", http.StatusBadRequest) + return + } + f.mu.Lock() + box.state = "idle" + f.mu.Unlock() + w.WriteHeader(http.StatusAccepted) + case "commands": + var req commandRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad command", http.StatusBadRequest) + return + } + cwd := box.root + if req.CWD != "" && req.CWD != "." { + cwd = filepath.Join(box.root, filepath.FromSlash(req.CWD)) + } + if err := os.MkdirAll(cwd, 0o755); err != nil { + http.Error(w, "mkdir", http.StatusInternalServerError) + return + } + cmd := exec.CommandContext(r.Context(), "/bin/sh", "-c", req.Command) + cmd.Dir = cwd + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + exit := 0 + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exit = exitErr.ExitCode() + } else { + exit = 1 + } + } + writeJSON(w, http.StatusOK, commandResponse{Success: exit == 0, ExitCode: exit, Stdout: stdout.String(), Stderr: stderr.String()}) + default: + http.NotFound(w, r) + } +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func (f *fakeBoxAPI) lastCreateNoEnv() bool { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.creates) > 0 && f.creates[len(f.creates)-1].NoEnv +} + +func (f *fakeBoxAPI) createCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.creates) +} + +func (f *fakeBoxAPI) state(id string) string { + f.mu.Lock() + defer f.mu.Unlock() + if box := f.boxes[id]; box != nil { + return box.state + } + return "" +} diff --git a/internal/adapter/boxenv/client.go b/internal/adapter/boxenv/client.go new file mode 100644 index 0000000000..e062496aa1 --- /dev/null +++ b/internal/adapter/boxenv/client.go @@ -0,0 +1,240 @@ +package boxenv + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const defaultBaseURL = "https://ascii.dev/api/box/v1" + +var errBoxNotReady = errors.New("boxenv: box did not become ready") + +type apiClient struct { + baseURL string + apiKey string + http *http.Client + poll time.Duration +} + +type boxState struct { + ID string `json:"id"` + State string `json:"state"` +} + +type boxEnvelope struct { + Box boxState `json:"box"` +} + +type createBoxRequest struct { + Type string `json:"type,omitempty"` + TTLSeconds int `json:"ttlSeconds,omitempty"` + NoEnv bool `json:"noEnv"` +} + +type resumeBoxRequest struct { + NoEnv bool `json:"noEnv"` +} + +type commandRequest struct { + Command string `json:"command"` + CWD string `json:"cwd,omitempty"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` +} + +type commandResponse struct { + Success bool `json:"success"` + ExitCode int `json:"exitCode"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + TimedOut bool `json:"timedOut"` +} + +type apiStatusError struct{ status int } + +func (e *apiStatusError) Error() string { + return fmt.Sprintf("boxenv: Box API returned HTTP %d", e.status) +} + +func newAPIClient(apiKey, baseURL string, httpClient *http.Client) (*apiClient, error) { + if strings.TrimSpace(apiKey) == "" { + return nil, errors.New("boxenv: API key is required") + } + if baseURL == "" { + baseURL = defaultBaseURL + } + u, err := url.Parse(baseURL) + if err != nil || u.Host == "" || u.Scheme != "https" && u.Scheme != "http" { + return nil, errors.New("boxenv: invalid Box API base URL") + } + if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) { + return nil, errors.New("boxenv: non-loopback Box API base URL must use HTTPS") + } + if httpClient == nil { + httpClient = &http.Client{Timeout: 70 * time.Second} + } + return &apiClient{baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, http: httpClient, poll: 500 * time.Millisecond}, nil +} + +func isLoopbackHost(host string) bool { + switch strings.ToLower(strings.TrimSpace(host)) { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false + } +} + +func (c *apiClient) createBox(ctx context.Context, boxType string, ttlSeconds int) (boxState, error) { + var out boxEnvelope + err := c.doJSON(ctx, http.MethodPost, "/boxes", createBoxRequest{Type: boxType, TTLSeconds: ttlSeconds, NoEnv: true}, &out, http.StatusAccepted, http.StatusOK) + if err != nil { + return boxState{}, err + } + if out.Box.ID == "" { + return boxState{}, errors.New("boxenv: Box API create response omitted box id") + } + return out.Box, nil +} + +func (c *apiClient) getBox(ctx context.Context, id string) (boxState, error) { + var out boxEnvelope + if err := c.doJSON(ctx, http.MethodGet, "/boxes/"+url.PathEscape(id), nil, &out, http.StatusOK); err != nil { + return boxState{}, err + } + if out.Box.ID == "" { + out.Box.ID = id + } + return out.Box, nil +} + +func (c *apiClient) resumeBox(ctx context.Context, id string) error { + return c.doJSON(ctx, http.MethodPost, "/boxes/"+url.PathEscape(id)+"/resume", resumeBoxRequest{NoEnv: true}, nil, http.StatusAccepted, http.StatusOK, http.StatusNoContent) +} + +func (c *apiClient) stopBox(ctx context.Context, id string) error { + return c.doJSON(ctx, http.MethodPost, "/boxes/"+url.PathEscape(id)+"/stop", nil, nil, http.StatusAccepted, http.StatusOK, http.StatusNoContent) +} + +func (c *apiClient) ensureReady(ctx context.Context, id string) error { + state, err := c.getBox(ctx, id) + if err != nil { + return err + } + switch normalizeBoxState(state.State) { + case "idle", "ready", "running": + return nil + case "stopped", "archived": + if err := c.resumeBox(ctx, id); err != nil { + return err + } + case "failed", "error", "deleted": + return fmt.Errorf("%w: terminal state %q", errBoxNotReady, state.State) + } + return c.waitReady(ctx, id) +} + +func (c *apiClient) waitReady(ctx context.Context, id string) error { + ticker := time.NewTicker(c.poll) + defer ticker.Stop() + resumed := false + for { + state, err := c.getBox(ctx, id) + if err != nil { + return err + } + switch normalizeBoxState(state.State) { + case "idle", "ready", "running": + return nil + case "stopped", "archived": + if !resumed { + if err := c.resumeBox(ctx, id); err != nil { + return err + } + resumed = true + } + case "failed", "error", "deleted": + return fmt.Errorf("%w: terminal state %q", errBoxNotReady, state.State) + } + select { + case <-ctx.Done(): + return fmt.Errorf("%w: %w", errBoxNotReady, ctx.Err()) + case <-ticker.C: + } + } +} + +func normalizeBoxState(state string) string { return strings.ToLower(strings.TrimSpace(state)) } + +func (c *apiClient) runCommand(ctx context.Context, boxID, cwd, command string) (commandResponse, error) { + timeout := 60 + if deadline, ok := ctx.Deadline(); ok { + seconds := int(time.Until(deadline).Seconds()) + if seconds < 1 { + seconds = 1 + } + if seconds < timeout { + timeout = seconds + } + } + var out commandResponse + if err := c.doJSON(ctx, http.MethodPost, "/boxes/"+url.PathEscape(boxID)+"/commands", commandRequest{Command: command, CWD: cwd, TimeoutSeconds: timeout}, &out, http.StatusOK); err != nil { + return commandResponse{}, err + } + if out.TimedOut { + return out, context.DeadlineExceeded + } + return out, nil +} + +func (c *apiClient) doJSON(ctx context.Context, method, path string, in, out any, accepted ...int) error { + var body io.Reader + if in != nil { + payload, err := json.Marshal(in) + if err != nil { + return fmt.Errorf("boxenv: encode request: %w", err) + } + body = bytes.NewReader(payload) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return fmt.Errorf("boxenv: build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Accept", "application/json") + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("boxenv: Box API request: %w", err) + } + defer resp.Body.Close() + ok := false + for _, status := range accepted { + if resp.StatusCode == status { + ok = true + break + } + } + if !ok { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) + return &apiStatusError{status: resp.StatusCode} + } + if out == nil || resp.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) + return nil + } + dec := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)) + if err := dec.Decode(out); err != nil { + return fmt.Errorf("boxenv: decode Box API response: %w", err) + } + return nil +} diff --git a/internal/adapter/boxenv/live_test.go b/internal/adapter/boxenv/live_test.go new file mode 100644 index 0000000000..a0208ce382 --- /dev/null +++ b/internal/adapter/boxenv/live_test.go @@ -0,0 +1,54 @@ +package boxenv + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stacklok/mecatl/internal/adapter/server" +) + +// TestLiveBox is an opt-in contract smoke against the public Box API. It never +// logs or persists BOX_API_KEY. Ordinary CI skips it. +func TestLiveBox(t *testing.T) { + apiKey := os.Getenv("BOX_API_KEY") + if apiKey == "" { + t.Skip("set BOX_API_KEY to run the live Box contract smoke") + } + provider, err := New(Config{ + APIKey: apiKey, + Scope: "live-test", + TTLSeconds: 300, + ReadyTimeout: 2 * time.Minute, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + binding, err := provider.Bind(ctx, server.PlacementBindRequest{ + Selector: server.DefaultPlacement(), Scope: "live-test", Operation: server.PlacementOperationCreate, + }) + if err != nil { + t.Fatal(err) + } + if binding.Close != nil { + defer func() { + if err := binding.Close(); err != nil { + t.Errorf("stop/archive live Box: %v", err) + } + }() + } + ws := binding.Environment.Workspace() + if _, err := ws.CreateFile(ctx, "mecatl-box-live.txt", []byte("box-provider-ok\n")); err != nil { + t.Fatal(err) + } + if got, err := ws.Read(ctx, "mecatl-box-live.txt"); err != nil || string(got) != "box-provider-ok\n" { + t.Fatalf("read after write = %q, %v", got, err) + } + result, err := binding.Environment.CommandRunner().Run(ctx, "printf shell-ok") + if err != nil || result.ExitCode != 0 || result.Stdout != "shell-ok" { + t.Fatalf("shell result = %+v, %v", result, err) + } +} diff --git a/internal/adapter/boxenv/provider.go b/internal/adapter/boxenv/provider.go new file mode 100644 index 0000000000..66c017057c --- /dev/null +++ b/internal/adapter/boxenv/provider.go @@ -0,0 +1,228 @@ +// Package boxenv adapts ASCII Box VMs to mecatl execution environments. +package boxenv + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + pathpkg "path" + "strings" + "time" + + "github.com/stacklok/mecatl/engine/adapter/memledger" + "github.com/stacklok/mecatl/engine/adapter/nofs" + "github.com/stacklok/mecatl/engine/session" + "github.com/stacklok/mecatl/engine/tool" + "github.com/stacklok/mecatl/internal/adapter/server" +) + +const ( + // Kind is the open EnvironmentKind label minted by the Box adapter. + Kind session.EnvironmentKind = "box" + + defaultTTLSeconds = 3600 + noFSID = "no-fs" + noFSRevision = "nofs-v1" +) + +// Config configures a Box placement provider. APIKey is secret-shaped and must +// never be persisted or logged. Every Box is created/resumed with noEnv=true, +// so account-level model/GitHub/SSH secrets never enter the guest. +type Config struct { + APIKey string + // BaseURL is primarily a test seam. Empty uses the public Box v1 endpoint. + BaseURL string + // Scope is the trusted server-owned placement authorization scope. + Scope server.PlacementScope + // BoxType is passed to Box create when non-empty (for example "small"). + BoxType string + // TTLSeconds bounds idle leaked resources. Zero selects one hour. + TTLSeconds int + // Workdir is the relative Box working directory shared by Workspace and Shell. + // Empty selects the Box working directory root ("."). + Workdir string + // ReadyTimeout bounds create/resume readiness polling. Zero selects two minutes. + ReadyTimeout time.Duration + HTTPClient *http.Client +} + +// Provider is an exact, server-scoped Box placement provider. It supports the +// deployment default and the ordinary no-FS attenuation. Worktree selectors are +// deliberately unsupported; Box-native forks belong on EnvironmentForker. +type Provider struct { + client *apiClient + scope server.PlacementScope + boxType string + ttlSeconds int + workdir string + readyTimeout time.Duration + revision string +} + +var ( + _ server.PlacementProvider = (*Provider)(nil) + _ server.PlacementReattacher = (*Provider)(nil) +) + +// New constructs a Box placement provider. The API key remains only in the +// private HTTP client and never enters EnvironmentRef or placement metadata. +func New(cfg Config) (*Provider, error) { + if cfg.Scope == "" { + return nil, errors.New("boxenv: placement scope is required") + } + client, err := newAPIClient(cfg.APIKey, cfg.BaseURL, cfg.HTTPClient) + if err != nil { + return nil, err + } + if cfg.TTLSeconds == 0 { + cfg.TTLSeconds = defaultTTLSeconds + } + if cfg.TTLSeconds < 1 || cfg.TTLSeconds > 2_592_000 { + return nil, errors.New("boxenv: ttlSeconds must be between 1 and 2592000") + } + workdir, err := cleanWorkdir(cfg.Workdir) + if err != nil { + return nil, err + } + if cfg.ReadyTimeout == 0 { + cfg.ReadyTimeout = 2 * time.Minute + } + if cfg.ReadyTimeout < time.Second { + return nil, errors.New("boxenv: ready timeout must be at least one second") + } + return &Provider{ + client: client, + scope: cfg.Scope, + boxType: cfg.BoxType, + ttlSeconds: cfg.TTLSeconds, + workdir: workdir, + readyTimeout: cfg.ReadyTimeout, + revision: providerRevision(client.baseURL, cfg.BoxType, cfg.TTLSeconds, workdir), + }, nil +} + +func cleanWorkdir(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return ".", nil + } + if strings.HasPrefix(value, "/") { + return "", errors.New("boxenv: workdir must be relative") + } + clean := pathpkg.Clean(value) + if clean == ".." || strings.HasPrefix(clean, "../") { + return "", errors.New("boxenv: workdir escapes the Box workspace") + } + return clean, nil +} + +func providerRevision(baseURL, boxType string, ttlSeconds int, workdir string) string { + // Do not fold the credential into durable identity. Reattachment semantics are + // pinned to the endpoint and non-secret construction contract only. + material := fmt.Sprintf("boxenv/v1\n%s\n%s\n%d\n%s\nnoenv=true", baseURL, boxType, ttlSeconds, workdir) + sum := sha256.Sum256([]byte(material)) + return "box-v1-" + hex.EncodeToString(sum[:8]) +} + +// Bind atomically resolves one server-owned placement choice. Default creates a +// fresh Box. The returned Close archives that provisional live VM; persisted +// EnvironmentRef remains resumable and Reattach brings the exact Box back. +func (p *Provider) Bind(ctx context.Context, req server.PlacementBindRequest) (server.PlacementBinding, error) { + if p == nil || p.client == nil || req.Scope != p.scope { + return server.PlacementBinding{}, server.ErrPlacementNotFound + } + switch { + case req.Selector.IsNoFS(): + return p.bindNoFS() + case req.Selector.IsDefault(): + return p.create(ctx) + default: + return server.PlacementBinding{}, server.ErrPlacementNotFound + } +} + +// Reattach resolves only the exact persisted Box identity. It never follows the +// current default or fabricates a replacement when the box is gone/stale. +func (p *Provider) Reattach(ctx context.Context, req server.PlacementReattachRequest) (server.PlacementBinding, error) { + if p == nil || p.client == nil || req.Scope != p.scope { + return server.PlacementBinding{}, server.ErrPlacementNotFound + } + if req.Ref == noFSRef() { + return p.bindNoFS() + } + if req.Ref.Kind != Kind || req.Ref.ID == "" { + return server.PlacementBinding{}, server.ErrPlacementNotFound + } + if req.Ref.Revision != p.revision { + return server.PlacementBinding{}, server.ErrPlacementStale + } + readyCtx, cancel := context.WithTimeout(ctx, p.readyTimeout) + defer cancel() + if err := p.client.ensureReady(readyCtx, req.Ref.ID); err != nil { + var statusErr *apiStatusError + if errors.As(err, &statusErr) && statusErr.status == http.StatusNotFound { + return server.PlacementBinding{}, server.ErrPlacementNotFound + } + return server.PlacementBinding{}, fmt.Errorf("%w: %v", server.ErrPlacementUnavailable, err) + } + return p.binding(req.Ref), nil +} + +func (p *Provider) create(ctx context.Context) (server.PlacementBinding, error) { + created, err := p.client.createBox(ctx, p.boxType, p.ttlSeconds) + if err != nil { + return server.PlacementBinding{}, fmt.Errorf("%w: %v", server.ErrPlacementUnavailable, err) + } + ref := session.EnvironmentRef{Kind: Kind, ID: created.ID, Revision: p.revision} + readyCtx, cancel := context.WithTimeout(ctx, p.readyTimeout) + defer cancel() + if err := p.client.ensureReady(readyCtx, created.ID); err != nil { + // Best-effort cleanup of a partially provisioned resource. + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + _ = p.client.stopBox(cleanupCtx, created.ID) + cleanupCancel() + return server.PlacementBinding{}, fmt.Errorf("%w: %v", server.ErrPlacementUnavailable, err) + } + binding := p.binding(ref) + binding.Close = func() error { + closeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return p.client.stopBox(closeCtx, ref.ID) + } + return binding, nil +} + +func (p *Provider) binding(ref session.EnvironmentRef) server.PlacementBinding { + ws := &workspace{client: p.client, boxID: ref.ID, workdir: p.workdir} + runner := &runner{client: p.client, boxID: ref.ID, workdir: p.workdir, root: ws.Root()} + env := tool.MustEnvironment(ref, ws, memledger.New(), runner) + return server.PlacementBinding{ + Environment: env, + Ref: ref, + Metadata: server.PlacementMetadata{ + Kind: string(Kind), + Label: "Box remote environment", + Revision: ref.Revision, + }, + } +} + +func (*Provider) bindNoFS() (server.PlacementBinding, error) { + ref := noFSRef() + env, err := tool.NewEnvironment(ref, nofs.New(), memledger.New(), nil) + if err != nil { + return server.PlacementBinding{}, server.ErrPlacementUnavailable + } + return server.PlacementBinding{ + Environment: env, + Ref: ref, + Metadata: server.PlacementMetadata{Kind: string(session.EnvKindNoFS), Label: "No filesystem", Revision: ref.Revision}, + }, nil +} + +func noFSRef() session.EnvironmentRef { + return session.EnvironmentRef{Kind: session.EnvKindNoFS, ID: noFSID, Revision: noFSRevision} +} diff --git a/internal/adapter/boxenv/runner.go b/internal/adapter/boxenv/runner.go new file mode 100644 index 0000000000..c86e27d1f8 --- /dev/null +++ b/internal/adapter/boxenv/runner.go @@ -0,0 +1,34 @@ +package boxenv + +import ( + "context" + "errors" + "strings" + + "github.com/stacklok/mecatl/engine/tool" +) + +type runner struct { + client *apiClient + boxID string + workdir string + root string +} + +var _ tool.CommandRunner = (*runner)(nil) + +// BoundWorkspaceRoot is the placement-binding affinity proof consumed by the +// server. It exactly matches the sibling workspace's logical Root value. +func (r *runner) BoundWorkspaceRoot() string { return r.root } + +func (r *runner) Run(ctx context.Context, command string) (tool.CommandResult, error) { + if strings.TrimSpace(command) == "" { + return tool.CommandResult{}, errors.New("boxenv: empty command") + } + out, err := r.client.runCommand(ctx, r.boxID, r.workdir, command) + result := tool.CommandResult{Stdout: out.Stdout, Stderr: out.Stderr, ExitCode: out.ExitCode} + if err != nil { + return result, err + } + return result, nil +} diff --git a/internal/adapter/boxenv/workspace.go b/internal/adapter/boxenv/workspace.go new file mode 100644 index 0000000000..e631fdbc0e --- /dev/null +++ b/internal/adapter/boxenv/workspace.go @@ -0,0 +1,529 @@ +package boxenv + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + pathpkg "path" + "strings" + "time" + + "github.com/stacklok/mecatl/engine/tool" +) + +type workspace struct { + client *apiClient + boxID string + workdir string +} + +var ( + _ tool.Workspace = (*workspace)(nil) + _ tool.WorkspaceNamespace = (*workspace)(nil) +) + +func (w *workspace) Root() string { return "box:" + w.boxID + ":" + w.workdir } + +func (w *workspace) Read(ctx context.Context, p string) ([]byte, error) { + clean, err := cleanPath(p, false) + if err != nil { + return nil, err + } + out, err := w.helper(ctx, helperRequest{Op: "read", Path: clean}) + if err != nil { + return nil, classifyHelperError("read", clean, out, err) + } + data, err := base64.StdEncoding.DecodeString(out.Content) + if err != nil { + return nil, errors.New("boxenv: helper returned invalid file content") + } + return data, nil +} + +func (w *workspace) ReadVersion(ctx context.Context, p string) ([]byte, tool.FileVersion, error) { + clean, err := cleanPath(p, false) + if err != nil { + return nil, tool.FileVersion{}, err + } + out, err := w.helper(ctx, helperRequest{Op: "read", Path: clean}) + if err != nil { + return nil, tool.FileVersion{}, classifyHelperError("read", clean, out, err) + } + data, err := base64.StdEncoding.DecodeString(out.Content) + if err != nil || out.Version == "" { + return nil, tool.FileVersion{}, errors.New("boxenv: helper returned invalid versioned content") + } + return data, tool.NewFileVersion(out.Version), nil +} + +func (w *workspace) Stat(ctx context.Context, p string) (tool.FileInfo, error) { + clean, err := cleanPath(p, true) + if err != nil { + return tool.FileInfo{}, err + } + out, err := w.helper(ctx, helperRequest{Op: "stat", Path: clean}) + if err != nil { + return tool.FileInfo{}, classifyHelperError("stat", clean, out, err) + } + if out.Info == nil { + return tool.FileInfo{}, errors.New("boxenv: helper omitted file metadata") + } + return out.Info.toolFileInfo(), nil +} + +func (w *workspace) CreateFile(ctx context.Context, p string, data []byte) (tool.FileVersion, error) { + clean, err := cleanPath(p, false) + if err != nil { + return tool.FileVersion{}, err + } + out, err := w.helper(ctx, helperRequest{Op: "create", Path: clean, Content: base64.StdEncoding.EncodeToString(data)}) + if err != nil { + return tool.FileVersion{}, classifyHelperError("create", clean, out, err) + } + if out.Version == "" { + return tool.FileVersion{}, errors.New("boxenv: helper omitted created file version") + } + return tool.NewFileVersion(out.Version), nil +} + +func (w *workspace) ReplaceFile(ctx context.Context, p string, old tool.FileVersion, data []byte) (tool.FileVersion, error) { + clean, err := cleanPath(p, false) + if err != nil { + return tool.FileVersion{}, err + } + expected, err := tool.EncodeFileVersion(old) + if err != nil { + return tool.FileVersion{}, &tool.VersionMismatchError{Path: p} + } + out, err := w.helper(ctx, helperRequest{Op: "replace", Path: clean, Expected: expected, Content: base64.StdEncoding.EncodeToString(data)}) + if err != nil { + return tool.FileVersion{}, classifyHelperError("replace", clean, out, err) + } + if out.Version == "" { + return tool.FileVersion{}, errors.New("boxenv: helper omitted replacement file version") + } + return tool.NewFileVersion(out.Version), nil +} + +func (w *workspace) Glob(ctx context.Context, pattern string) ([]string, error) { + clean, err := cleanPattern(pattern) + if err != nil { + return nil, err + } + out, err := w.helper(ctx, helperRequest{Op: "glob", Pattern: clean}) + if err != nil { + return nil, classifyHelperError("glob", clean, out, err) + } + return out.Paths, nil +} + +func (w *workspace) Grep(ctx context.Context, pattern, pathGlob string) ([]tool.GrepMatch, error) { + if _, err := compilePatternGuard(pattern); err != nil { + return nil, err + } + if pathGlob != "" { + var err error + pathGlob, err = cleanPattern(pathGlob) + if err != nil { + return nil, err + } + } + out, err := w.helper(ctx, helperRequest{Op: "grep", Pattern: pattern, PathGlob: pathGlob}) + if err != nil { + return nil, classifyHelperError("grep", pathGlob, out, err) + } + matches := make([]tool.GrepMatch, 0, len(out.Matches)) + for _, hit := range out.Matches { + matches = append(matches, tool.GrepMatch{Path: hit.Path, Line: hit.Line, Text: hit.Text}) + } + return matches, nil +} + +func (w *workspace) ReadDir(ctx context.Context, p string) ([]tool.FileInfo, error) { + clean, err := cleanPath(p, true) + if err != nil { + return nil, err + } + out, err := w.helper(ctx, helperRequest{Op: "readdir", Path: clean}) + if err != nil { + return nil, classifyHelperError("readdir", clean, out, err) + } + entries := make([]tool.FileInfo, 0, len(out.Entries)) + for _, entry := range out.Entries { + entries = append(entries, entry.toolFileInfo()) + } + return entries, nil +} + +func (w *workspace) Remove(ctx context.Context, p string) error { + clean, err := cleanPath(p, false) + if err != nil { + return err + } + out, err := w.helper(ctx, helperRequest{Op: "remove", Path: clean}) + return classifyHelperError("remove", clean, out, err) +} + +func (w *workspace) Rename(ctx context.Context, oldPath, newPath string) error { + oldClean, err := cleanPath(oldPath, false) + if err != nil { + return err + } + newClean, err := cleanPath(newPath, false) + if err != nil { + return err + } + out, err := w.helper(ctx, helperRequest{Op: "rename", OldPath: oldClean, NewPath: newClean}) + return classifyHelperError("rename", oldClean, out, err) +} + +func (w *workspace) CopyFile(ctx context.Context, source, destination string) (tool.FileVersion, error) { + src, err := cleanPath(source, false) + if err != nil { + return tool.FileVersion{}, err + } + dst, err := cleanPath(destination, false) + if err != nil { + return tool.FileVersion{}, err + } + out, err := w.helper(ctx, helperRequest{Op: "copy", OldPath: src, NewPath: dst}) + if err != nil { + return tool.FileVersion{}, classifyHelperError("copy", src, out, err) + } + if out.Version == "" { + return tool.FileVersion{}, errors.New("boxenv: helper omitted copied file version") + } + return tool.NewFileVersion(out.Version), nil +} + +func cleanPath(value string, allowRoot bool) (string, error) { + if strings.ContainsRune(value, '\x00') || strings.HasPrefix(value, "/") { + return "", errors.New("boxenv: path must stay relative to the Box workspace") + } + clean := pathpkg.Clean(value) + if value == "" { + clean = "." + } + if clean == ".." || strings.HasPrefix(clean, "../") { + return "", errors.New("boxenv: path escapes the Box workspace") + } + if clean == "." && !allowRoot { + return "", errors.New("boxenv: operation requires a file path") + } + return clean, nil +} + +func cleanPattern(pattern string) (string, error) { + if pattern == "" { + return "", errors.New("boxenv: glob pattern is required") + } + if strings.ContainsRune(pattern, '\x00') || strings.HasPrefix(pattern, "/") { + return "", errors.New("boxenv: glob pattern must stay relative to the Box workspace") + } + for _, part := range strings.Split(pattern, "/") { + if part == ".." { + return "", errors.New("boxenv: glob pattern escapes the Box workspace") + } + } + return pattern, nil +} + +func compilePatternGuard(pattern string) (string, error) { + if strings.ContainsRune(pattern, '\x00') { + return "", errors.New("boxenv: grep pattern contains NUL") + } + return pattern, nil +} + +type helperRequest struct { + Op string `json:"op"` + Path string `json:"path,omitempty"` + OldPath string `json:"old_path,omitempty"` + NewPath string `json:"new_path,omitempty"` + Pattern string `json:"pattern,omitempty"` + PathGlob string `json:"path_glob,omitempty"` + Content string `json:"content,omitempty"` + Expected string `json:"expected,omitempty"` +} + +type helperResponse struct { + OK bool `json:"ok"` + Code string `json:"code,omitempty"` + Content string `json:"content,omitempty"` + Version string `json:"version,omitempty"` + Info *helperInfo `json:"info,omitempty"` + Paths []string `json:"paths,omitempty"` + Matches []helperMatch `json:"matches,omitempty"` + Entries []helperInfo `json:"entries,omitempty"` +} + +type helperInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + Perm uint32 `json:"perm"` + ModTimeNS int64 `json:"mod_time_ns"` + IsDir bool `json:"is_dir"` +} + +func (i helperInfo) toolFileInfo() tool.FileInfo { + mode := fs.FileMode(i.Perm & 0o777) + if i.IsDir { + mode |= fs.ModeDir + } + return tool.FileInfo{Name: i.Name, Size: i.Size, Mode: mode, ModTime: time.Unix(0, i.ModTimeNS), IsDir: i.IsDir} +} + +type helperMatch struct { + Path string `json:"path"` + Line int `json:"line"` + Text string `json:"text"` +} + +func (w *workspace) helper(ctx context.Context, req helperRequest) (helperResponse, error) { + payload, err := json.Marshal(req) + if err != nil { + return helperResponse{}, fmt.Errorf("boxenv: encode helper request: %w", err) + } + program := base64.StdEncoding.EncodeToString([]byte(pythonHelper)) + input := base64.StdEncoding.EncodeToString(payload) + command := "python3 -c 'import base64;exec(base64.b64decode(\"" + program + "\"))' '" + input + "'" + result, err := w.client.runCommand(ctx, w.boxID, w.workdir, command) + if err != nil { + return helperResponse{}, err + } + if result.ExitCode != 0 { + return helperResponse{}, fmt.Errorf("boxenv: filesystem helper failed with exit code %d", result.ExitCode) + } + var out helperResponse + if err := json.Unmarshal([]byte(strings.TrimSpace(result.Stdout)), &out); err != nil { + return helperResponse{}, errors.New("boxenv: filesystem helper returned invalid JSON") + } + if !out.OK { + return out, helperSemanticError{code: out.Code} + } + return out, nil +} + +type helperSemanticError struct{ code string } + +func (e helperSemanticError) Error() string { + return "boxenv: filesystem helper rejected operation: " + e.code +} + +func classifyHelperError(op, p string, out helperResponse, err error) error { + if err == nil { + return nil + } + var semantic helperSemanticError + if !errors.As(err, &semantic) { + return err + } + switch out.Code { + case "not_found": + return fmt.Errorf("boxenv: %s %q: %w", op, p, fs.ErrNotExist) + case "exists": + return fmt.Errorf("boxenv: %s %q: %w", op, p, fs.ErrExist) + case "version_mismatch": + return &tool.VersionMismatchError{Path: p} + case "not_empty": + return fmt.Errorf("boxenv: %s %q: %w", op, p, tool.ErrDirectoryNotEmpty) + case "unsupported": + return fmt.Errorf("boxenv: %s %q: %w", op, p, tool.ErrFileOperationUnsupported) + default: + return fmt.Errorf("boxenv: %s %q rejected by remote workspace", op, p) + } +} + +func versionOf(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +const pythonHelper = ` +import base64, errno, fcntl, hashlib, json, os, pathlib, re, stat, sys, tempfile + +def emit(value): + print(json.dumps(value, separators=(",", ":"))) + raise SystemExit(0) + +def fail(code): + emit({"ok": False, "code": code}) + +def version(data): + return hashlib.sha256(data).hexdigest() + +req = json.loads(base64.b64decode(sys.argv[1])) +root = pathlib.Path(os.getcwd()).resolve() +root_s = str(root) + +def confined(rel, allow_root=True): + if not isinstance(rel, str) or "\x00" in rel or rel.startswith("/"): + fail("invalid") + parts = pathlib.PurePosixPath(rel or ".").parts + if ".." in parts: + fail("invalid") + candidate = root.joinpath(*parts).resolve(strict=False) + try: + if os.path.commonpath([root_s, str(candidate)]) != root_s: + fail("invalid") + except ValueError: + fail("invalid") + if not allow_root and candidate == root: + fail("invalid") + return candidate + +def info(path, name=None): + st = path.stat() + return {"name": name if name is not None else path.name, "size": st.st_size, + "perm": stat.S_IMODE(st.st_mode), "mod_time_ns": st.st_mtime_ns, + "is_dir": stat.S_ISDIR(st.st_mode)} + +def locked(): + key = hashlib.sha256(root_s.encode()).hexdigest() + f = open("/tmp/mecatl-box-" + key + ".lock", "a+b") + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + return f + +op = req.get("op", "") +try: + if op == "read": + p = confined(req.get("path", ""), False) + data = p.read_bytes() + emit({"ok": True, "content": base64.b64encode(data).decode(), "version": version(data)}) + + if op == "stat": + p = confined(req.get("path", "."), True) + emit({"ok": True, "info": info(p, "." if p == root else p.name)}) + + if op == "create": + p = confined(req.get("path", ""), False) + data = base64.b64decode(req.get("content", "")) + with locked(): + p.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + except Exception: + try: os.unlink(p) + except OSError: pass + raise + emit({"ok": True, "version": version(data)}) + + if op == "replace": + p = confined(req.get("path", ""), False) + data = base64.b64decode(req.get("content", "")) + expected = req.get("expected", "") + with locked(): + if not p.exists(): fail("not_found") + if not p.is_file(): fail("unsupported") + current = p.read_bytes() + if version(current) != expected: fail("version_mismatch") + old_mode = stat.S_IMODE(p.stat().st_mode) + p.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=".mecatl-write-", dir=str(p.parent)) + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.chmod(tmp, old_mode) + os.replace(tmp, p) + finally: + try: os.unlink(tmp) + except FileNotFoundError: pass + emit({"ok": True, "version": version(data)}) + + if op == "glob": + pattern = req.get("pattern", "") + if pattern.startswith("/") or ".." in pathlib.PurePosixPath(pattern).parts: fail("invalid") + paths = [] + for raw in root.glob(pattern): + p = raw.resolve(strict=False) + try: + if os.path.commonpath([root_s, str(p)]) != root_s: continue + except ValueError: + continue + rel = p.relative_to(root).as_posix() + if rel != ".": paths.append(rel) + emit({"ok": True, "paths": sorted(set(paths))}) + + if op == "grep": + try: regex = re.compile(req.get("pattern", "")) + except re.error: fail("invalid") + pattern = req.get("path_glob") or "**/*" + if pattern.startswith("/") or ".." in pathlib.PurePosixPath(pattern).parts: fail("invalid") + hits = [] + for raw in root.glob(pattern): + p = raw.resolve(strict=False) + try: + if os.path.commonpath([root_s, str(p)]) != root_s or not p.is_file(): continue + except (ValueError, OSError): + continue + try: text = p.read_text(encoding="utf-8", errors="replace") + except OSError: continue + rel = p.relative_to(root).as_posix() + for n, line in enumerate(text.splitlines(), 1): + if regex.search(line): + hits.append({"path": rel, "line": n, "text": line}) + if len(hits) >= 1000: emit({"ok": True, "matches": hits}) + emit({"ok": True, "matches": hits}) + + if op == "readdir": + p = confined(req.get("path", "."), True) + if not p.exists(): fail("not_found") + if not p.is_dir(): fail("unsupported") + entries = [info(child, child.name) for child in sorted(p.iterdir(), key=lambda x: x.name)] + emit({"ok": True, "entries": entries}) + + if op == "remove": + p = confined(req.get("path", ""), False) + with locked(): + if not p.exists() and not p.is_symlink(): fail("not_found") + if p.is_dir(): + try: p.rmdir() + except OSError as e: + if e.errno in (errno.ENOTEMPTY, errno.EEXIST): fail("not_empty") + raise + else: p.unlink() + emit({"ok": True}) + + if op == "rename": + src = confined(req.get("old_path", ""), False) + dst = confined(req.get("new_path", ""), False) + with locked(): + if not src.exists() and not src.is_symlink(): fail("not_found") + if dst.exists() or dst.is_symlink(): fail("exists") + dst.parent.mkdir(parents=True, exist_ok=True) + os.rename(src, dst) + emit({"ok": True}) + + if op == "copy": + src = confined(req.get("old_path", ""), False) + dst = confined(req.get("new_path", ""), False) + with locked(): + if not src.exists(): fail("not_found") + if not src.is_file(): fail("unsupported") + if dst.exists() or dst.is_symlink(): fail("exists") + dst.parent.mkdir(parents=True, exist_ok=True) + data = src.read_bytes() + fd = os.open(str(dst), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) + with os.fdopen(fd, "wb") as f: f.write(data) + emit({"ok": True, "version": version(data)}) + + fail("unsupported") +except FileNotFoundError: + fail("not_found") +except FileExistsError: + fail("exists") +except PermissionError: + fail("unsupported") +except OSError as e: + if e.errno in (errno.ENOTEMPTY, errno.EEXIST): fail("not_empty") + fail("unsupported") +`