diff --git a/gozero.go b/gozero.go index ad0f028..097dd21 100644 --- a/gozero.go +++ b/gozero.go @@ -73,8 +73,11 @@ func (g *Gozero) Eval(ctx context.Context, src, input *Source, args ...string) ( return gcmd.Execute(ctx) } -// EvalWithVirtualEnv evaluates the source code in a virtual environment and returns the output -// This function passes the source code into the virtual environment and external parameters as environment variables +// EvalWithVirtualEnv evaluates the source code in a virtual environment and +// returns the output. As in Eval, the engine gets Options.Args before the +// source path and args after it, input is the standard input, and the +// variables of src and input are environment variables. A source that starts +// with a shebang runs directly, without the engine and Options.Args. func (g *Gozero) EvalWithVirtualEnv(ctx context.Context, envType VirtualEnvType, src, input *Source, dockerConfig *sandbox.DockerConfiguration, args ...string) (*types.Result, error) { // Read source code content srcContent, err := src.ReadAll() @@ -119,7 +122,12 @@ func (g *Gozero) EvalWithVirtualEnv(ctx context.Context, envType VirtualEnvType, } // Execute the source code in the Docker container - result, err := dockerSandbox.RunSource(ctx, string(srcContent), interpreter) + result, err := dockerSandbox.RunSourceWithOptions(ctx, string(srcContent), sandbox.DockerSourceOptions{ + Interpreter: interpreter, + InterpreterArgs: g.Options.Args, + Args: args, + Stdin: input.File, + }) if err != nil { return nil, err } diff --git a/sandbox/error.go b/sandbox/error.go index 752adc6..31bbee2 100644 --- a/sandbox/error.go +++ b/sandbox/error.go @@ -5,4 +5,6 @@ import "errors" var ( ErrNotImplemented = errors.New("not implemented") ErrAgentRequired = errors.New("requires agent installed on the sandbox") + + errEmptyCommand = errors.New("empty command") ) diff --git a/sandbox/virtual_env_docker.go b/sandbox/virtual_env_docker.go index ca4e9d5..561f905 100644 --- a/sandbox/virtual_env_docker.go +++ b/sandbox/virtual_env_docker.go @@ -1,8 +1,10 @@ +//nolint:exhaustruct,exhaustruct_v5 // the Docker API option and config structs use zero values for unset fields package sandbox import ( "context" "fmt" + "io" "log" "os/exec" "strings" @@ -75,142 +77,27 @@ func NewDockerSandbox(ctx context.Context, config *DockerConfiguration) (*Sandbo }, nil } -// runCommand executes a command in the Docker container with the given command parts -func (s *SandboxDocker) runCommand(ctx context.Context, cmdParts []string, command string, createFile bool, fileContent string) (*types.Result, error) { - if len(cmdParts) == 0 { - return nil, fmt.Errorf("empty command") - } - - // Create a new context with timeout - runCtx, cancel := context.WithTimeout(ctx, s.config.Timeout) - defer cancel() - - // Prepare environment variables - env := []string{} - for key, value := range s.config.Environment { - env = append(env, fmt.Sprintf("%s=%s", key, value)) - } - - // If we need to create a file, modify the command to create it first - finalCmd := cmdParts - if createFile && len(fileContent) > 0 { - // Generate a temporary filename - tmpFileName := fmt.Sprintf("/tmp/gozero_script_%d.sh", time.Now().UnixNano()) - - // Create a script that writes the file content and then executes it - scriptContent := fmt.Sprintf(`#!/bin/sh -cat > %s << 'EOF' -%s -EOF -chmod +x %s -exec %s -`, tmpFileName, fileContent, tmpFileName, tmpFileName) - - // Create a command that writes the script and executes it - finalCmd = []string{"/bin/sh", "-c", scriptContent} - } - - // Create container configuration - containerConfig := &container.Config{ - Image: s.config.Image, - Cmd: finalCmd, - WorkingDir: s.config.WorkingDir, - Env: env, - User: s.config.User, - AttachStdout: true, - AttachStderr: true, - } - - // Create host configuration - hostConfig := &container.HostConfig{ - AutoRemove: false, // Don't auto-remove so we can get logs - } - - // Set network configuration - if s.config.NetworkDisabled { - hostConfig.NetworkMode = "none" - } else if s.config.NetworkMode != "" { - hostConfig.NetworkMode = container.NetworkMode(s.config.NetworkMode) - } - - // Set resource limits if specified - if s.config.Memory != "" { - hostConfig.Memory = parseMemoryLimit(s.config.Memory) - } - - // Pull image if it doesn't exist locally - err := s.pullImageIfNeeded(runCtx, s.config.Image) - if err != nil { - return nil, fmt.Errorf("failed to pull image %s: %w", s.config.Image, err) - } - - // Create container - createResp, err := s.dockerClient.ContainerCreate(runCtx, client.ContainerCreateOptions{Config: containerConfig, HostConfig: hostConfig}) - if err != nil { - return nil, fmt.Errorf("failed to create container: %w", err) - } - - containerID := createResp.ID - - // Start container - _, err = s.dockerClient.ContainerStart(runCtx, containerID, client.ContainerStartOptions{}) - if err != nil { - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - return nil, fmt.Errorf("failed to start container: %w", err) - } - - // Wait for container to finish - waitRes := s.dockerClient.ContainerWait(runCtx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}) - - select { - case err := <-waitRes.Error: - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - return nil, fmt.Errorf("container wait error: %w", err) - case result := <-waitRes.Result: - // Get container logs - logs, err := s.dockerClient.ContainerLogs(runCtx, containerID, client.ContainerLogsOptions{ - ShowStdout: true, - ShowStderr: true, - }) - if err != nil { - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - return nil, fmt.Errorf("failed to get container logs: %w", err) - } - defer func() { - _ = logs.Close() - }() - - // Read logs - logData := make([]byte, 1024*1024) // 1MB buffer - n, err := logs.Read(logData) - if err != nil && err.Error() != "EOF" { - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - return nil, fmt.Errorf("failed to read container logs: %w", err) - } - - // Create result - cmdResult := &types.Result{ - Command: command, - } - cmdResult.Stdout.Write(logData[:n]) - - // Set exit code - if result.StatusCode != 0 { - cmdResult.SetExitError(&exec.ExitError{}) - } - - // Always clean up container manually - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - - return cmdResult, nil - } +// DockerSourceOptions holds the per-run inputs of RunSourceWithOptions. +type DockerSourceOptions struct { + // Interpreter runs the source file. If it is empty or the source starts + // with a shebang, the source file runs directly. + Interpreter string + // InterpreterArgs are passed to Interpreter before the source path. + InterpreterArgs []string + // Args are passed to the source after its path. + Args []string + // Stdin is copied to the standard input of the source until EOF or + // until the container exits. If nil, the container has no standard + // input. A read from Stdin can still be pending when the run returns. + Stdin io.Reader } // Run executes a command in the Docker container (synchronous execution) func (s *SandboxDocker) Run(ctx context.Context, cmd string) (*types.Result, error) { // Parse command into parts cmdParts := strings.Fields(cmd) - return s.runCommand(ctx, cmdParts, cmd, false, "") + + return s.runCommand(ctx, cmdParts, cmd, nil) } // RunScript executes a script in the Docker container @@ -218,33 +105,51 @@ func (s *SandboxDocker) RunScript(ctx context.Context, source string, interprete return s.RunSource(ctx, source, interpreter) } -// RunSource writes source code to a temporary file inside the container and executes it +// RunSource writes source code to a temporary file inside the container and executes it. +// The interpreter can include arguments, such as "python3 -u". func (s *SandboxDocker) RunSource(ctx context.Context, source string, interpreter string) (*types.Result, error) { + var opts DockerSourceOptions + if fields := strings.Fields(interpreter); len(fields) > 0 { + opts.Interpreter, opts.InterpreterArgs = fields[0], fields[1:] + } + + return s.RunSourceWithOptions(ctx, source, opts) +} + +// RunSourceWithOptions writes source code to a temporary file inside the +// container and executes it with the arguments and stdin in opts. +func (s *SandboxDocker) RunSourceWithOptions( + ctx context.Context, source string, opts DockerSourceOptions, +) (*types.Result, error) { // Generate a temporary filename tmpFileName := fmt.Sprintf("/tmp/gozero_script_%d", time.Now().UnixNano()) - // Check if source has a shebang - if so, use it instead of the interpreter - // Otherwise, use the provided interpreter - execCmd := fmt.Sprintf("%s %s", interpreter, tmpFileName) - if strings.HasPrefix(source, "#!") { - // Source has shebang, let it execute directly - execCmd = tmpFileName + // A source with a shebang, or without an interpreter, runs directly + var execArgs []string + if opts.Interpreter != "" && !strings.HasPrefix(source, "#!") { + execArgs = append(execArgs, opts.Interpreter) + execArgs = append(execArgs, opts.InterpreterArgs...) } - // Create a script that writes the source content and then executes it + execArgs = append(execArgs, tmpFileName) + execArgs = append(execArgs, opts.Args...) + + // Create a script that writes the source content and then executes its + // positional parameters, so the shell never parses the arguments scriptContent := fmt.Sprintf(`cat > %s << 'EOF' %s EOF chmod +x %s -exec %s -`, tmpFileName, source, tmpFileName, execCmd) +exec "$@" +`, tmpFileName, source, tmpFileName) log.Println(tmpFileName) log.Println(scriptContent) - // Execute the script directly - cmdParts := []string{"/bin/sh", "-c", scriptContent} - return s.runCommand(ctx, cmdParts, fmt.Sprintf("exec %s", tmpFileName), false, "") + // "sh" is the script's $0 and execArgs are its "$@" + cmdParts := append([]string{"/bin/sh", "-c", scriptContent, "sh"}, execArgs...) + + return s.runCommand(ctx, cmdParts, "exec "+tmpFileName, opts.Stdin) } // Start is not implemented for Docker sandbox as it's stateless @@ -342,3 +247,178 @@ func (s *SandboxDocker) pullImageIfNeeded(ctx context.Context, imageName string) return nil } + +// runCommand executes a command in the Docker container with the given command parts. +func (s *SandboxDocker) runCommand( + ctx context.Context, cmdParts []string, command string, stdin io.Reader, +) (*types.Result, error) { + if len(cmdParts) == 0 { + return nil, errEmptyCommand + } + + // Create a new context with timeout + runCtx, cancel := context.WithTimeout(ctx, s.config.Timeout) + defer cancel() + + // Pull image if it doesn't exist locally + err := s.pullImageIfNeeded(runCtx, s.config.Image) + if err != nil { + return nil, fmt.Errorf("failed to pull image %s: %w", s.config.Image, err) + } + + // Create container + createResp, err := s.dockerClient.ContainerCreate(runCtx, client.ContainerCreateOptions{ + Config: s.containerConfig(cmdParts, stdin != nil), + HostConfig: s.hostConfig(), + }) + if err != nil { + return nil, fmt.Errorf("failed to create container: %w", err) + } + + containerID := createResp.ID + + // Always clean up the container, which isn't auto-removed so that its + // logs can be read + defer func() { + _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) + }() + + // Attach stdin before the start, as docker run -i does, so it is + // connected for the whole run + if stdin != nil { + stopStdin, err := s.attachStdin(runCtx, containerID, stdin) + if err != nil { + return nil, err + } + + defer stopStdin() + } + + // Start container + _, err = s.dockerClient.ContainerStart(runCtx, containerID, client.ContainerStartOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to start container: %w", err) + } + + // Wait for container to finish + waitRes := s.dockerClient.ContainerWait(runCtx, containerID, client.ContainerWaitOptions{ + Condition: container.WaitConditionNotRunning, + }) + + select { + case err := <-waitRes.Error: + return nil, fmt.Errorf("container wait error: %w", err) + case result := <-waitRes.Result: + return s.collectResult(runCtx, containerID, command, result.StatusCode) + } +} + +// containerConfig returns the configuration of a container that runs cmd. +func (s *SandboxDocker) containerConfig(cmd []string, withStdin bool) *container.Config { + // Prepare environment variables + env := make([]string, 0, len(s.config.Environment)) + for key, value := range s.config.Environment { + env = append(env, key+"="+value) + } + + return &container.Config{ + Image: s.config.Image, + Cmd: cmd, + WorkingDir: s.config.WorkingDir, + Env: env, + User: s.config.User, + AttachStdout: true, + AttachStderr: true, + // Stdin stays open until the attached client closes it + AttachStdin: withStdin, + OpenStdin: withStdin, + StdinOnce: withStdin, + } +} + +// hostConfig returns the host configuration of a container, with the network +// mode and memory limit of the sandbox. +func (s *SandboxDocker) hostConfig() *container.HostConfig { + hostConfig := &container.HostConfig{ + AutoRemove: false, // Don't auto-remove so we can get logs + } + + // Set network configuration + if s.config.NetworkDisabled { + hostConfig.NetworkMode = "none" + } else if s.config.NetworkMode != "" { + hostConfig.NetworkMode = container.NetworkMode(s.config.NetworkMode) + } + + // Set resource limits if specified + if s.config.Memory != "" { + hostConfig.Memory = parseMemoryLimit(s.config.Memory) + } + + return hostConfig +} + +// attachStdin copies stdin to the standard input of a created container. The +// returned stop function closes the connection, which ends the copy at its +// next write; call it after the container exits. Like docker run -i, stop +// doesn't wait for a pending read from stdin, which may never return. +func (s *SandboxDocker) attachStdin(ctx context.Context, containerID string, stdin io.Reader) (func(), error) { + attach, err := s.dockerClient.ContainerAttach(ctx, containerID, client.ContainerAttachOptions{ + Stream: true, + Stdin: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to attach container stdin: %w", err) + } + + go func() { + _, _ = io.Copy(attach.Conn, stdin) + // Closing the write side closes the container's stdin + _ = attach.CloseWrite() + }() + + return attach.Close, nil +} + +// logBufferSize is the size of the buffer that collectResult reads container +// logs into. +const logBufferSize = 1024 * 1024 // 1MB + +// collectResult reads the output of an exited container into a Result. +func (s *SandboxDocker) collectResult( + ctx context.Context, containerID, command string, statusCode int64, +) (*types.Result, error) { + // Get container logs + logs, err := s.dockerClient.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to get container logs: %w", err) + } + + defer func() { + _ = logs.Close() + }() + + // Read logs + logData := make([]byte, logBufferSize) + + size, err := logs.Read(logData) + if err != nil && err.Error() != "EOF" { + return nil, fmt.Errorf("failed to read container logs: %w", err) + } + + // Create result + cmdResult := &types.Result{ + Command: command, + } + cmdResult.Stdout.Write(logData[:size]) + + // Set exit code + if statusCode != 0 { + cmdResult.SetExitError(&exec.ExitError{}) + } + + return cmdResult, nil +} diff --git a/virtual_env_test.go b/virtual_env_test.go new file mode 100644 index 0000000..a3324d5 --- /dev/null +++ b/virtual_env_test.go @@ -0,0 +1,339 @@ +//nolint:depguard,exhaustruct,exhaustruct_v5 // no depguard allow list exists; zero values are intended +package gozero_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + "github.com/projectdiscovery/gozero" + "github.com/projectdiscovery/gozero/sandbox" + "github.com/projectdiscovery/gozero/types" + osutils "github.com/projectdiscovery/utils/os" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEvalWithVirtualEnvDockerMatchesEval checks that the Docker container +// runs a source as Eval does: the engine gets Options.Args, the source gets +// the call arguments, the input is stdin, and its variables are environment +// variables. +// +//nolint:paralleltest // newFakeDockerd sets process-wide environment variables +func TestEvalWithVirtualEnvDockerMatchesEval(t *testing.T) { + skipOnWindows(t) + + engine := newEngine(t) + args := []string{"hello", "two words"} + + // With Options.Args -e, the engine stops at false. + const script = `echo "$# args: $*"; echo "FOO: $FOO"; read line; echo "stdin: $line"; false; echo "no -e"` + + want, err := engine.Eval(t.Context(), newSource(t, script), newInput(t), args...) + require.Error(t, err, "Eval of a source that exits 1") + require.Equal(t, "2 args: hello two words\nFOO: bar\nstdin: from-stdin\n", want.Stdout.String(), "Eval output") + + dockerd := newFakeDockerd(t) + _, err = engine.EvalWithVirtualEnv(t.Context(), gozero.VirtualEnvDocker, + newSource(t, script), newInput(t), dockerConfig(), args...) + require.NoError(t, err) + + created, stdin := dockerd.recorded() + assert.True(t, created.AttachStdin && created.OpenStdin && created.StdinOnce, + "container stdin is not attached, open and closed after the input") + + output, exitCode := runLikeContainer(t, created, stdin) + assert.Equal(t, want.Stdout.String(), output, "container output of command %q", created.Cmd) + assert.Equal(t, want.GetExitCode(), exitCode, "container exit code") + assert.True(t, dockerd.removed(), "container is not removed") +} + +// TestEvalWithVirtualEnvDockerShebang checks that, unlike Eval, Docker runs a +// source with a shebang directly, without the engine and Options.Args, and +// still passes the call arguments and stdin. +// +//nolint:paralleltest // newFakeDockerd sets process-wide environment variables +func TestEvalWithVirtualEnvDockerShebang(t *testing.T) { + skipOnWindows(t) + + const script = "#!/bin/sh\n" + `echo "$# args: $*"; read line; echo "stdin: $line"; false; echo "no -e"` + + dockerd := newFakeDockerd(t) + _, err := newEngine(t).EvalWithVirtualEnv(t.Context(), gozero.VirtualEnvDocker, + newSource(t, script), newInput(t), dockerConfig(), "hello", "two words") + require.NoError(t, err) + + created, stdin := dockerd.recorded() + output, _ := runLikeContainer(t, created, stdin) + assert.Equal(t, "2 args: hello two words\nstdin: from-stdin\nno -e\n", output, + "container output of command %q", created.Cmd) +} + +// TestDockerRunSourceInterpreterArgs checks that RunSource still accepts an +// interpreter with arguments, and runs the source without stdin. +// +//nolint:paralleltest // newFakeDockerd sets process-wide environment variables +func TestDockerRunSourceInterpreterArgs(t *testing.T) { + skipOnWindows(t) + + dockerd := newFakeDockerd(t) + + docker, err := sandbox.NewDockerSandbox(t.Context(), dockerConfig()) + require.NoError(t, err) + t.Cleanup(func() { _ = docker.Clear() }) + + _, err = docker.RunSource(t.Context(), `echo start; false; echo "no -e"`, "sh -e") + require.NoError(t, err) + + created, _ := dockerd.recorded() + assert.False(t, created.OpenStdin, "container stdin is open") + + output, _ := runLikeContainer(t, created, "") + assert.Equal(t, "start\n", output, "container output of command %q", created.Cmd) +} + +func skipOnWindows(t *testing.T) { + t.Helper() + + if osutils.IsWindows() { + t.Skip("the fake docker CLI is a POSIX shell script") + } +} + +// newEngine returns an executor that runs sources with sh -e. +func newEngine(t *testing.T) *gozero.Gozero { + t.Helper() + + engine, err := gozero.New(&gozero.Options{Engines: []string{"sh"}, Args: []string{"-e"}}) + require.NoError(t, err) + + return engine +} + +// newSource returns a source with content that is removed when the test ends. +func newSource(t *testing.T, content string) *gozero.Source { + t.Helper() + + src, err := gozero.NewSourceWithString(content, "", "") + require.NoError(t, err) + t.Cleanup(func() { _ = src.Cleanup() }) + + return src +} + +// newInput returns an input source with one line of stdin and the variable +// FOO=bar. +func newInput(t *testing.T) *gozero.Source { + t.Helper() + + input := newSource(t, "from-stdin\n") + input.AddVariable(types.Variable{Name: "FOO", Value: "bar"}) + + return input +} + +// dockerConfig returns a sandbox configuration for the fake Docker API. +// EvalWithVirtualEnv changes the configuration, so each call needs its own. +func dockerConfig() *sandbox.DockerConfiguration { + return &sandbox.DockerConfiguration{Image: "alpine:3", WorkingDir: "/tmp", Timeout: 10 * time.Second} +} + +var containerScriptPath = regexp.MustCompile(`/tmp/gozero_script_\d+`) + +// runLikeContainer runs the command of the created container with the host's +// /bin/sh, its environment and stdin, and returns the standard output and the +// exit code. +func runLikeContainer(t *testing.T, created container.Config, stdin string) (string, int) { + t.Helper() + require.NotEmpty(t, created.Cmd, "container command") + + for _, arg := range created.Cmd { + for _, path := range containerScriptPath.FindAllString(arg, -1) { + t.Cleanup(func() { _ = os.Remove(path) }) + } + } + + var stdout bytes.Buffer + + //nolint:gosec // runs the command that the sandbox sent to the fake Docker API + cmd := exec.CommandContext(t.Context(), created.Cmd[0], created.Cmd[1:]...) + cmd.Env = append(os.Environ(), created.Env...) + cmd.Stdin = strings.NewReader(stdin) + cmd.Stdout = &stdout + + err := cmd.Run() + if err != nil { + require.ErrorAs(t, err, new(*exec.ExitError), "run container command") + } + + return stdout.String(), cmd.ProcessState.ExitCode() +} + +// fakeDockerd is a minimal Docker Engine API for one container. It records +// the container-create request and the bytes sent to the container's stdin. +// Like a process that reads all of its input, the container exits only after +// stdin is closed, when a client is attached to it. +type fakeDockerd struct { + mu sync.Mutex + created container.Config + attached bool + stdin []byte + stdinDone chan struct{} + isRemoved bool +} + +const fakeContainerID = "gozero-test" + +var apiVersionPrefix = regexp.MustCompile(`^/v[0-9.]+`) + +// newFakeDockerd starts a fakeDockerd and points the Docker client and the +// docker CLI check in sandbox.NewDockerSandbox at it. +func newFakeDockerd(t *testing.T) *fakeDockerd { + t.Helper() + + dockerd := &fakeDockerd{stdinDone: make(chan struct{})} + srv := httptest.NewServer(dockerd.handler()) + t.Cleanup(srv.Close) + t.Setenv("DOCKER_HOST", "tcp://"+srv.Listener.Addr().String()) + t.Setenv("DOCKER_CERT_PATH", "") + t.Setenv("DOCKER_API_VERSION", "") + + // NewDockerSandbox runs "docker info" before it connects to the API. + bin := t.TempDir() + //nolint:gosec // the fake docker CLI must be executable + err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0o755) + require.NoError(t, err) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + return dockerd +} + +// handler routes the Docker Engine API requests that the sandbox sends. +func (d *fakeDockerd) handler() http.Handler { + const containerPath = "/containers/" + fakeContainerID + + mux := http.NewServeMux() + mux.HandleFunc("/_ping", func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Api-Version", client.MaxAPIVersion) + _, _ = io.WriteString(writer, "OK") + }) + mux.HandleFunc("GET /images/alpine:3/json", func(writer http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(writer, `{"Id":"sha256:gozero-test"}`) + }) + mux.HandleFunc("POST /containers/create", d.create) + mux.HandleFunc("POST "+containerPath+"/attach", d.attach) + mux.HandleFunc("POST "+containerPath+"/start", noContent) + mux.HandleFunc("POST "+containerPath+"/wait", d.wait) + // No output: the test runs the command itself. + mux.HandleFunc("GET "+containerPath+"/logs", func(http.ResponseWriter, *http.Request) {}) + mux.HandleFunc("DELETE "+containerPath, d.remove) + + // The client prefixes request paths with the API version, such as /v1.54. + return http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + req.URL.Path = apiVersionPrefix.ReplaceAllString(req.URL.Path, "") + req.URL.RawPath = "" + mux.ServeHTTP(writer, req) + }) +} + +// recorded returns the container-create request and the stdin bytes. +func (d *fakeDockerd) recorded() (container.Config, string) { + d.mu.Lock() + defer d.mu.Unlock() + + return d.created, string(d.stdin) +} + +// removed reports whether the container was removed. +func (d *fakeDockerd) removed() bool { + d.mu.Lock() + defer d.mu.Unlock() + + return d.isRemoved +} + +func (d *fakeDockerd) create(writer http.ResponseWriter, req *http.Request) { + d.mu.Lock() + err := json.NewDecoder(req.Body).Decode(&d.created) + d.mu.Unlock() + + if err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + + return + } + + writer.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(writer, `{"Id":"`+fakeContainerID+`"}`) +} + +// attach upgrades the connection to a raw stream and reads the container's +// stdin until the client closes its write side. +func (d *fakeDockerd) attach(writer http.ResponseWriter, _ *http.Request) { + conn, stream, err := http.NewResponseController(writer).Hijack() + if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + + return + } + + defer func() { _ = conn.Close() }() + + d.mu.Lock() + d.attached = true + d.mu.Unlock() + + _, _ = stream.WriteString("HTTP/1.1 101 UPGRADED\r\n" + + "Content-Type: application/vnd.docker.raw-stream\r\n" + + "Connection: Upgrade\r\nUpgrade: tcp\r\n\r\n") + _ = stream.Flush() + + stdin, _ := io.ReadAll(stream) + + d.mu.Lock() + d.stdin = stdin + d.mu.Unlock() + close(d.stdinDone) +} + +// wait answers when the container exits: at once, or after its stdin is +// closed when a client is attached. +func (d *fakeDockerd) wait(writer http.ResponseWriter, req *http.Request) { + d.mu.Lock() + attached := d.attached + d.mu.Unlock() + + if attached { + select { + case <-d.stdinDone: + case <-req.Context().Done(): + return + } + } + + _, _ = io.WriteString(writer, `{"StatusCode":0}`) +} + +func (d *fakeDockerd) remove(writer http.ResponseWriter, _ *http.Request) { + d.mu.Lock() + d.isRemoved = true + d.mu.Unlock() + + writer.WriteHeader(http.StatusNoContent) +} + +func noContent(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusNoContent) +}