From 4cfe0e28e60abb6ce344496e802510f885c8953a Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:11:53 +0100 Subject: [PATCH 1/2] Add bounded WSL container inspection --- internal/wsldocker/inspect.go | 95 ++++++++++++++++++++++++++ internal/wsldocker/inspect_linux.go | 17 +++++ internal/wsldocker/inspect_other.go | 14 ++++ internal/wsldocker/inspect_test.go | 100 ++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 internal/wsldocker/inspect.go create mode 100644 internal/wsldocker/inspect_linux.go create mode 100644 internal/wsldocker/inspect_other.go create mode 100644 internal/wsldocker/inspect_test.go diff --git a/internal/wsldocker/inspect.go b/internal/wsldocker/inspect.go new file mode 100644 index 0000000..8df5738 --- /dev/null +++ b/internal/wsldocker/inspect.go @@ -0,0 +1,95 @@ +package wsldocker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" +) + +const maxContainerInspectOutput = 1 << 20 + +// ContainerSnapshot is the bounded immutable subset of one exact Docker +// container inspection needed by later ownership and lifecycle checks. +type ContainerSnapshot struct { + id string + labels map[string]string + running bool + tty bool + openStdin bool +} + +func (s ContainerSnapshot) ID() string { return s.id } +func (s ContainerSnapshot) Running() bool { return s.running } +func (s ContainerSnapshot) TTY() bool { return s.tty } +func (s ContainerSnapshot) OpenStdin() bool { return s.openStdin } +func (s ContainerSnapshot) Labels() map[string]string { return cloneContainerLabels(s.labels) } + +func inspectContainer(ctx context.Context, containerID string, deps operationDependencies) (ContainerSnapshot, error) { + if ctx == nil { + return ContainerSnapshot{}, errors.New("Docker Desktop WSL container inspect requires a context") + } + if err := validateContainerID(containerID); err != nil { + return ContainerSnapshot{}, fmt.Errorf("Docker Desktop WSL container inspect: %w", err) + } + if deps.check == nil || deps.statSocket == nil || deps.perform == nil { + return ContainerSnapshot{}, errors.New("Docker Desktop WSL container inspect dependencies are incomplete") + } + response, err := execute(ctx, Request{ + Method: http.MethodGet, + Path: "/containers/" + containerID + "/json", + SuccessStatuses: []int{http.StatusOK}, + }, deps) + if err != nil { + return ContainerSnapshot{}, err + } + return decodeContainerInspectResponse(response.Body, containerID) +} + +func decodeContainerInspectResponse(raw []byte, expectedID string) (ContainerSnapshot, error) { + if len(raw) > maxContainerInspectOutput { + return ContainerSnapshot{}, fmt.Errorf("Docker container inspect response exceeds %d bytes", maxContainerInspectOutput) + } + var response struct { + ID string `json:"Id"` + Config *struct { + Labels map[string]string `json:"Labels"` + TTY bool `json:"Tty"` + OpenStdin bool `json:"OpenStdin"` + } `json:"Config"` + State *struct { + Running bool `json:"Running"` + } `json:"State"` + } + if err := json.Unmarshal(raw, &response); err != nil { + return ContainerSnapshot{}, fmt.Errorf("decode Docker container inspect response: %w", err) + } + if err := validateContainerID(response.ID); err != nil { + return ContainerSnapshot{}, fmt.Errorf("Docker container inspect returned invalid ID: %w", err) + } + if response.ID != expectedID { + return ContainerSnapshot{}, fmt.Errorf("Docker container inspect returned ID %q, expected exact requested ID", response.ID) + } + if response.Config == nil { + return ContainerSnapshot{}, errors.New("Docker container inspect response is missing Config") + } + if response.State == nil { + return ContainerSnapshot{}, errors.New("Docker container inspect response is missing State") + } + return ContainerSnapshot{ + id: response.ID, + labels: cloneContainerLabels(response.Config.Labels), + running: response.State.Running, + tty: response.Config.TTY, + openStdin: response.Config.OpenStdin, + }, nil +} + +func cloneContainerLabels(labels map[string]string) map[string]string { + cloned := make(map[string]string, len(labels)) + for key, value := range labels { + cloned[key] = value + } + return cloned +} diff --git a/internal/wsldocker/inspect_linux.go b/internal/wsldocker/inspect_linux.go new file mode 100644 index 0000000..67d0fec --- /dev/null +++ b/internal/wsldocker/inspect_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package wsldocker + +import "context" + +// InspectContainer returns the bounded immutable lifecycle subset for one exact +// container reached through the proven Docker Desktop WSL socket. +func InspectContainer(ctx context.Context, containerID string) (ContainerSnapshot, error) { + return inspectContainer(ctx, containerID, operationDependencies{ + check: Check, + statSocket: statDockerSocket, + perform: func(ctx context.Context, socketPath string, request Request) (operationResult, error) { + return performDockerRequest(ctx, socketPath, request, operationTimeout, maxContainerInspectOutput, 0) + }, + }) +} diff --git a/internal/wsldocker/inspect_other.go b/internal/wsldocker/inspect_other.go new file mode 100644 index 0000000..e3bc582 --- /dev/null +++ b/internal/wsldocker/inspect_other.go @@ -0,0 +1,14 @@ +//go:build !linux + +package wsldocker + +import ( + "context" + "errors" +) + +// InspectContainer is unavailable outside Linux because the fixed Unix socket +// and peer credentials are part of the Docker Desktop WSL trust boundary. +func InspectContainer(context.Context, string) (ContainerSnapshot, error) { + return ContainerSnapshot{}, errors.New("Docker Desktop WSL container inspect requires Linux") +} diff --git a/internal/wsldocker/inspect_test.go b/internal/wsldocker/inspect_test.go new file mode 100644 index 0000000..41ae18e --- /dev/null +++ b/internal/wsldocker/inspect_test.go @@ -0,0 +1,100 @@ +package wsldocker + +import ( + "context" + "net/http" + "strings" + "testing" +) + +func TestInspectContainerBindsExactSnapshotToProvenSocket(t *testing.T) { + statCalls := 0 + deps := validOperationDependencies(validSocketInfo()) + deps.statSocket = func(path string) (socketInfo, error) { + if path != DockerSocketPath { + t.Fatalf("stat path = %q", path) + } + statCalls++ + return validSocketInfo(), nil + } + deps.perform = func(_ context.Context, path string, request Request) (operationResult, error) { + if path != DockerSocketPath || request.Method != http.MethodGet || request.Path != "/containers/"+testContainerID+"/json" { + t.Fatalf("perform(%q, %+v)", path, request) + } + if len(request.Query) != 0 || len(request.Body) != 0 || len(request.SuccessStatuses) != 1 || request.SuccessStatuses[0] != http.StatusOK { + t.Fatalf("inspect request = %+v", request) + } + return operationResult{StatusCode: http.StatusOK, PeerUID: 0, Raw: []byte(`{ + "Id":"` + testContainerID + `", + "Config":{"Labels":{"cb.managed":"true","cb.run_id":"run-1"},"Tty":true,"OpenStdin":true}, + "State":{"Running":true} + }`)}, nil + } + snapshot, err := inspectContainer(context.Background(), testContainerID, deps) + if err != nil { + t.Fatal(err) + } + if snapshot.ID() != testContainerID || !snapshot.Running() || !snapshot.TTY() || !snapshot.OpenStdin() || statCalls != 2 { + t.Fatalf("snapshot = %#v, stat calls = %d", snapshot, statCalls) + } + labels := snapshot.Labels() + if labels["cb.managed"] != "true" || labels["cb.run_id"] != "run-1" { + t.Fatalf("labels = %#v", labels) + } + labels["cb.managed"] = "mutated" + if snapshot.Labels()["cb.managed"] != "true" { + t.Fatal("Labels returned mutable internal state") + } +} + +func TestInspectContainerRejectsInvalidInputsBeforeProof(t *testing.T) { + for name, test := range map[string]struct { + ctx context.Context + containerID string + }{ + "nil context": {containerID: testContainerID}, + "short ID": {ctx: context.Background(), containerID: "abc"}, + "uppercase": {ctx: context.Background(), containerID: strings.ToUpper(testContainerID)}, + } { + t.Run(name, func(t *testing.T) { + deps := validOperationDependencies(validSocketInfo()) + deps.check = func(context.Context) (Result, error) { panic("proof reached for invalid inspect") } + if _, err := inspectContainer(test.ctx, test.containerID, deps); err == nil { + t.Fatal("inspectContainer() succeeded") + } + }) + } + if _, err := inspectContainer(context.Background(), testContainerID, operationDependencies{}); err == nil || !strings.Contains(err.Error(), "dependencies are incomplete") { + t.Fatalf("incomplete-dependencies error = %v", err) + } +} + +func TestDecodeContainerInspectResponseRejectsUnsafeShapes(t *testing.T) { + valid := `{"Id":"` + testContainerID + `","Config":{"Labels":null},"State":{"Running":false}}` + otherID := strings.Repeat("a", 64) + tests := map[string]struct { + raw []byte + want string + }{ + "malformed": {raw: []byte(`{`), want: "decode"}, + "oversized": {raw: make([]byte, maxContainerInspectOutput+1), want: "exceeds"}, + "missing ID": {raw: []byte(`{"Config":{},"State":{}}`), want: "invalid ID"}, + "invalid ID": {raw: []byte(`{"Id":"abc","Config":{},"State":{}}`), want: "invalid ID"}, + "mismatched ID": {raw: []byte(`{"Id":"` + otherID + `","Config":{},"State":{}}`), want: "expected exact"}, + "missing Config": {raw: []byte(`{"Id":"` + testContainerID + `","State":{}}`), want: "missing Config"}, + "null Config": {raw: []byte(`{"Id":"` + testContainerID + `","Config":null,"State":{}}`), want: "missing Config"}, + "missing State": {raw: []byte(`{"Id":"` + testContainerID + `","Config":{}}`), want: "missing State"}, + "null State": {raw: []byte(`{"Id":"` + testContainerID + `","Config":{},"State":null}`), want: "missing State"}, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if _, err := decodeContainerInspectResponse(test.raw, testContainerID); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("decode error = %v, want containing %q", err, test.want) + } + }) + } + snapshot, err := decodeContainerInspectResponse([]byte(valid), testContainerID) + if err != nil || snapshot.Labels() == nil || len(snapshot.Labels()) != 0 || snapshot.Running() || snapshot.TTY() || snapshot.OpenStdin() { + t.Fatalf("minimal snapshot = %#v, err = %v", snapshot, err) + } +} From 0284cbe2f80803488506b8c222c174b20f5d309d Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:31:35 +0100 Subject: [PATCH 2/2] Reject incomplete container inspection state --- internal/wsldocker/inspect.go | 21 +++++++++++++++------ internal/wsldocker/inspect_test.go | 26 ++++++++++++++++---------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/internal/wsldocker/inspect.go b/internal/wsldocker/inspect.go index 8df5738..13caf4c 100644 --- a/internal/wsldocker/inspect.go +++ b/internal/wsldocker/inspect.go @@ -55,11 +55,11 @@ func decodeContainerInspectResponse(raw []byte, expectedID string) (ContainerSna ID string `json:"Id"` Config *struct { Labels map[string]string `json:"Labels"` - TTY bool `json:"Tty"` - OpenStdin bool `json:"OpenStdin"` + TTY *bool `json:"Tty"` + OpenStdin *bool `json:"OpenStdin"` } `json:"Config"` State *struct { - Running bool `json:"Running"` + Running *bool `json:"Running"` } `json:"State"` } if err := json.Unmarshal(raw, &response); err != nil { @@ -77,12 +77,21 @@ func decodeContainerInspectResponse(raw []byte, expectedID string) (ContainerSna if response.State == nil { return ContainerSnapshot{}, errors.New("Docker container inspect response is missing State") } + if response.Config.TTY == nil { + return ContainerSnapshot{}, errors.New("Docker container inspect response is missing Config.Tty") + } + if response.Config.OpenStdin == nil { + return ContainerSnapshot{}, errors.New("Docker container inspect response is missing Config.OpenStdin") + } + if response.State.Running == nil { + return ContainerSnapshot{}, errors.New("Docker container inspect response is missing State.Running") + } return ContainerSnapshot{ id: response.ID, labels: cloneContainerLabels(response.Config.Labels), - running: response.State.Running, - tty: response.Config.TTY, - openStdin: response.Config.OpenStdin, + running: *response.State.Running, + tty: *response.Config.TTY, + openStdin: *response.Config.OpenStdin, }, nil } diff --git a/internal/wsldocker/inspect_test.go b/internal/wsldocker/inspect_test.go index 41ae18e..65d38c7 100644 --- a/internal/wsldocker/inspect_test.go +++ b/internal/wsldocker/inspect_test.go @@ -70,21 +70,27 @@ func TestInspectContainerRejectsInvalidInputsBeforeProof(t *testing.T) { } func TestDecodeContainerInspectResponseRejectsUnsafeShapes(t *testing.T) { - valid := `{"Id":"` + testContainerID + `","Config":{"Labels":null},"State":{"Running":false}}` + valid := `{"Id":"` + testContainerID + `","Config":{"Labels":null,"Tty":false,"OpenStdin":false},"State":{"Running":false}}` otherID := strings.Repeat("a", 64) tests := map[string]struct { raw []byte want string }{ - "malformed": {raw: []byte(`{`), want: "decode"}, - "oversized": {raw: make([]byte, maxContainerInspectOutput+1), want: "exceeds"}, - "missing ID": {raw: []byte(`{"Config":{},"State":{}}`), want: "invalid ID"}, - "invalid ID": {raw: []byte(`{"Id":"abc","Config":{},"State":{}}`), want: "invalid ID"}, - "mismatched ID": {raw: []byte(`{"Id":"` + otherID + `","Config":{},"State":{}}`), want: "expected exact"}, - "missing Config": {raw: []byte(`{"Id":"` + testContainerID + `","State":{}}`), want: "missing Config"}, - "null Config": {raw: []byte(`{"Id":"` + testContainerID + `","Config":null,"State":{}}`), want: "missing Config"}, - "missing State": {raw: []byte(`{"Id":"` + testContainerID + `","Config":{}}`), want: "missing State"}, - "null State": {raw: []byte(`{"Id":"` + testContainerID + `","Config":{},"State":null}`), want: "missing State"}, + "malformed": {raw: []byte(`{`), want: "decode"}, + "oversized": {raw: make([]byte, maxContainerInspectOutput+1), want: "exceeds"}, + "missing ID": {raw: []byte(`{"Config":{},"State":{}}`), want: "invalid ID"}, + "invalid ID": {raw: []byte(`{"Id":"abc","Config":{},"State":{}}`), want: "invalid ID"}, + "mismatched ID": {raw: []byte(`{"Id":"` + otherID + `","Config":{},"State":{}}`), want: "expected exact"}, + "missing Config": {raw: []byte(`{"Id":"` + testContainerID + `","State":{}}`), want: "missing Config"}, + "null Config": {raw: []byte(`{"Id":"` + testContainerID + `","Config":null,"State":{}}`), want: "missing Config"}, + "missing State": {raw: []byte(`{"Id":"` + testContainerID + `","Config":{}}`), want: "missing State"}, + "null State": {raw: []byte(`{"Id":"` + testContainerID + `","Config":{},"State":null}`), want: "missing State"}, + "missing Tty": {raw: []byte(`{"Id":"` + testContainerID + `","Config":{"OpenStdin":false},"State":{"Running":false}}`), want: "missing Config.Tty"}, + "null Tty": {raw: []byte(`{"Id":"` + testContainerID + `","Config":{"Tty":null,"OpenStdin":false},"State":{"Running":false}}`), want: "missing Config.Tty"}, + "missing stdin": {raw: []byte(`{"Id":"` + testContainerID + `","Config":{"Tty":false},"State":{"Running":false}}`), want: "missing Config.OpenStdin"}, + "null stdin": {raw: []byte(`{"Id":"` + testContainerID + `","Config":{"Tty":false,"OpenStdin":null},"State":{"Running":false}}`), want: "missing Config.OpenStdin"}, + "missing Running": {raw: []byte(`{"Id":"` + testContainerID + `","Config":{"Tty":false,"OpenStdin":false},"State":{}}`), want: "missing State.Running"}, + "null Running": {raw: []byte(`{"Id":"` + testContainerID + `","Config":{"Tty":false,"OpenStdin":false},"State":{"Running":null}}`), want: "missing State.Running"}, } for name, test := range tests { t.Run(name, func(t *testing.T) {