From ceaa2c57ab72bd9566ddf3d772d504389a74b77a Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 24 Oct 2026 03:39:11 -0400 Subject: [PATCH] fix: bound native container execution and owned cleanup --- README.md | 45 +++ container/adapters.go | 99 +++++- container/docker_compatible_runtime.go | 151 ++++++--- container/docker_compatible_runtime_test.go | 12 +- container/native_accounting_context_test.go | 120 +++++++ container/native_bounds_test.go | 272 ++++++++++++++++ container/native_cli_error_cleanup_test.go | 94 ++++++ container/native_optional_volumes_test.go | 57 ++++ container/native_policy_test.go | 332 ++++++++++++++++++++ container/native_swap_test.go | 40 +++ container/process_output.go | 216 +++++++++++++ container/readonly_workspace.go | 133 ++++++++ container/runtime_probe.go | 50 ++- container/runtime_probe_test.go | 14 +- container/sandbox_ownership.go | 101 ++++++ container/sandbox_ownership_test.go | 103 ++++++ go.mod | 4 +- go.sum | 4 + 18 files changed, 1758 insertions(+), 89 deletions(-) create mode 100644 container/native_accounting_context_test.go create mode 100644 container/native_bounds_test.go create mode 100644 container/native_cli_error_cleanup_test.go create mode 100644 container/native_optional_volumes_test.go create mode 100644 container/native_policy_test.go create mode 100644 container/native_swap_test.go create mode 100644 container/process_output.go create mode 100644 container/readonly_workspace.go create mode 100644 container/sandbox_ownership.go create mode 100644 container/sandbox_ownership_test.go diff --git a/README.md b/README.md index 00c4a24..715f406 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,51 @@ network, env, proof, and cleanup behavior under those constraints. ## Verification +### Native execution bounds + +Protected workload and conformance runs explicitly use `--pull never`. Approved +images must already be cached; execution never falls back to pulling an image. +Availability has a ten-second ceiling, conformance has thirty seconds, and direct +command execution defaults to one minute. Explicit request/runtime limits and +earlier caller deadlines take precedence. Owned cancellation cleanup gets a +separate ten-second opportunity shared by ownership inspection and removal. +Cleanup requires the independent per-attempt owner label and a full verified +container ID before removing that ID in the same runtime scope. Name collisions, +missing/malformed inspection and unknown creation remain unresolved errors; no +foreign name is removed and generic inspection failure never proves absence. + +`ExecDockerCommandRunner.MaxOutputBytes` bounds aggregate stdout/stderr. A positive +limit is used exactly; the implementation default is 1 MiB when no positive limit +is supplied. Writers continue draining through EOF and retain each stream's final +rolling tail before deterministic aggregate rebalancing. `OutputLimitError` +implements `OutputCaptureMetadataProvider`, carrying original per-stream counts +and final truncation flags without putting output into its error text. Adapters +preserve that typed error across timeout wrapping. Consumers must copy this +metadata into their run-log provenance before preserving bounded streams. +`CaptureIncompleteError` has `Complete:false`; its partial streams are withheld. +Observed bytes from forced pipe closure must never be labeled complete original +stream sizes. Control-process output is not exposed as workload provenance. + +`ReadOnlyWorkspace` is an opt-in policy on `DockerSandboxRuntime`, +`SandboxRuntimeCommandAdapter` and `SandboxRunRequest`; defaults remain false. +It keeps the same workspace bind mounted read-only, requires a successful strict +initial byte-limit walk, rejects writable/shadowing mounts, special files, +symlinks, writable rootfs/tmpfs/capability overrides and artifact output requests. +The shared `ValidateReadOnlyPolicy` preflight also rejects image-declared writable +volumes and checks Docker's advertised memory/swap/CPU/PID controls. The opt-in +command policy caps memory plus swap at the declared memory limit. Unsupported runtime +evidence fails closed; read-only control verification currently supports Docker +with a Linux daemon. A native smoke test must still demonstrate actual configured +limits and write denial on the selected runtime. No daemon or cgroup settings are +changed by this policy. + +An exclusively owned empty workspace then has zero workload growth; read-only +mounting cannot stop concurrent host writes. `/tmp` is separately bounded to +64 MiB. Read-only commands and all conformance containers use `--log-driver none`; +bounded attached output is distinct from daemon log storage. Legacy writable +workspace/artifact behavior remains available. Its `WorkspaceBytes` describes +measurement/reservation, not a portable hard quota on a writable bind mount. + ```sh GOWORK=off go test ./... -count=1 GOWORK=off go vet ./... diff --git a/container/adapters.go b/container/adapters.go index eec32e2..115e8df 100644 --- a/container/adapters.go +++ b/container/adapters.go @@ -62,6 +62,7 @@ type SandboxRunRequest struct { DataMounts []SandboxMount Timeout time.Duration Limits core.ResourceLimits + ReadOnlyWorkspace bool } type SandboxRunResult struct { @@ -70,6 +71,7 @@ type SandboxRunResult struct { Stderr []byte ArtifactHash string ResourceUsage core.ResourceUsage + OutputCapture OutputCaptureMetadata } type SandboxRuntime interface { @@ -104,7 +106,8 @@ type SandboxedCommandRuntime interface { } type SandboxRuntimeCommandAdapter struct { - Runtime SandboxRuntime + ReadOnlyWorkspace bool + Runtime SandboxRuntime } type SandboxedContainerBuildRuntimeInvocation struct { @@ -363,24 +366,38 @@ func (a SandboxRuntimeCommandAdapter) RunSandboxedCommand(ctx context.Context, i if len(workload.Args) == 0 { return core.RuntimeExecutionResult{}, errors.New("command args are required") } - if err := a.Runtime.Available(ctx); err != nil { - return core.RuntimeExecutionResult{}, fmt.Errorf("sandbox runtime unavailable: %w", err) - } timeout := defaultDuration(invocation.Timeout, time.Minute) + if invocation.Request.Limits.RuntimeSeconds > 0 && time.Duration(invocation.Request.Limits.RuntimeSeconds)*time.Second < timeout { + timeout = time.Duration(invocation.Request.Limits.RuntimeSeconds) * time.Second + } timeoutLimitHit := firstNonEmpty(invocation.TimeoutLimitHit, "timeout") runCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + readOnly := a.ReadOnlyWorkspace + switch native := a.Runtime.(type) { + case DockerSandboxRuntime: + readOnly = readOnly || native.ReadOnlyWorkspace + case *DockerSandboxRuntime: + readOnly = readOnly || (native != nil && native.ReadOnlyWorkspace) + } + if readOnly && len(workload.ArtifactAllowlist) > 0 { + return core.RuntimeExecutionResult{}, errors.New("read-only workspace cannot produce artifacts") + } + if err := a.Runtime.Available(runCtx); err != nil { + return core.RuntimeExecutionResult{}, fmt.Errorf("sandbox runtime unavailable: %w", controlProcessError(err, "runtime availability output capture failed")) + } started := time.Now().UTC() result, err := a.Runtime.Run(runCtx, SandboxRunRequest{ - Image: invocation.Image, - Command: slices.Clone(workload.Args), - Workspace: invocation.Workspace, - WorkingDir: workload.WorkingDirectory, - Env: cloneStringMap(invocation.Request.Env), - Network: firstNonEmpty(invocation.Network, SandboxNetworkNone), - Timeout: timeout, - Limits: invocation.Request.Limits, + Image: invocation.Image, + Command: slices.Clone(workload.Args), + Workspace: invocation.Workspace, + WorkingDir: workload.WorkingDirectory, + Env: cloneStringMap(invocation.Request.Env), + Network: firstNonEmpty(invocation.Network, SandboxNetworkNone), + Timeout: timeout, + Limits: invocation.Request.Limits, + ReadOnlyWorkspace: readOnly, }) finished := time.Now().UTC() runResult := core.RuntimeExecutionResult{ @@ -394,7 +411,7 @@ func (a SandboxRuntimeCommandAdapter) RunSandboxedCommand(ctx context.Context, i } if runCtx.Err() != nil { runResult.ResourceUsage.LimitHit = timeoutLimitHit - return runResult, fmt.Errorf("sandboxed command timed out after %s: %w", timeout, runCtx.Err()) + return runResult, errors.Join(fmt.Errorf("sandboxed command timed out after %s: %w", timeout, runCtx.Err()), err) } if err != nil { return runResult, err @@ -412,7 +429,14 @@ func (a SandboxRuntimeCommandAdapter) RunSandboxedCommand(ctx context.Context, i runResult.Artifacts = slices.Clone(workload.ArtifactAllowlist) } if runResult.ResourceUsage.WorkspaceBytes == 0 && invocation.Workspace != "" { - runResult.ResourceUsage.WorkspaceBytes = DirectorySize(invocation.Workspace) + size, measureErr := directorySizeContext(runCtx, invocation.Workspace) + if measureErr != nil { + if runCtx.Err() != nil { + runResult.ResourceUsage.LimitHit = timeoutLimitHit + } + return runResult, fmt.Errorf("sandbox workspace accounting incomplete: %w", measureErr) + } + runResult.ResourceUsage.WorkspaceBytes = size } return runResult, nil } @@ -482,7 +506,7 @@ func (a SandboxRuntimeContainerBuildAdapter) BuildSandboxedContainer(ctx context return core.RuntimeExecutionResult{}, err } if err := a.Runtime.Available(ctx); err != nil { - return core.RuntimeExecutionResult{}, fmt.Errorf("sandbox runtime unavailable: %w", err) + return core.RuntimeExecutionResult{}, fmt.Errorf("sandbox runtime unavailable: %w", controlProcessError(err, "runtime availability output capture failed")) } timeout := defaultDuration(invocation.Timeout, time.Hour) timeoutLimitHit := firstNonEmpty(invocation.TimeoutLimitHit, "timeout") @@ -537,7 +561,7 @@ func (a SandboxRuntimeContainerBuildAdapter) BuildSandboxedContainer(ctx context } if runCtx.Err() != nil { runResult.ResourceUsage.LimitHit = timeoutLimitHit - return runResult, fmt.Errorf("sandboxed container build timed out after %s: %w", timeout, runCtx.Err()) + return runResult, errors.Join(fmt.Errorf("sandboxed container build timed out after %s: %w", timeout, runCtx.Err()), err) } if err != nil { return runResult, err @@ -556,7 +580,14 @@ func (a SandboxRuntimeContainerBuildAdapter) BuildSandboxedContainer(ctx context return runResult, errors.New("sandboxed container build did not produce a sha256 artifact digest") } if runResult.ResourceUsage.WorkspaceBytes == 0 && invocation.Workspace != "" { - runResult.ResourceUsage.WorkspaceBytes = DirectorySize(invocation.Workspace) + size, measureErr := directorySizeContext(runCtx, invocation.Workspace) + if measureErr != nil { + if runCtx.Err() != nil { + runResult.ResourceUsage.LimitHit = timeoutLimitHit + } + return runResult, fmt.Errorf("sandbox workspace accounting incomplete: %w", measureErr) + } + runResult.ResourceUsage.WorkspaceBytes = size } return runResult, nil } @@ -693,6 +724,40 @@ func ReadSandboxedContainerBuildDigest(path string) (string, error) { return digest, nil } +// directorySizeContext discards partial accounting on cancellation or I/O +// failure. Cancellation is cooperative between filesystem operations; it cannot +// preempt a kernel-blocked filesystem call. Legacy DirectorySize stays unchanged. +func directorySizeContext(ctx context.Context, root string) (int64, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + var total int64 + err := filepath.WalkDir(root, func(_ string, entry os.DirEntry, walkErr error) error { + if err := ctx.Err(); err != nil { + return err + } + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + total += info.Size() + return nil + }) + if err != nil { + return 0, err + } + if err := ctx.Err(); err != nil { + return 0, err + } + return total, nil +} + func DirectorySize(root string) int64 { var total int64 _ = filepath.WalkDir(root, func(_ string, entry os.DirEntry, err error) error { diff --git a/container/docker_compatible_runtime.go b/container/docker_compatible_runtime.go index c8a3c2c..569d990 100644 --- a/container/docker_compatible_runtime.go +++ b/container/docker_compatible_runtime.go @@ -2,14 +2,12 @@ package container import ( "bufio" - "bytes" "context" "crypto/rand" "encoding/hex" "errors" "fmt" "os" - "os/exec" "path" "path/filepath" "slices" @@ -21,19 +19,20 @@ import ( ) type DockerSandboxRuntime struct { - Tool string - RuntimeName string - Runner DockerCommandRunner + Tool string + RuntimeName string + Runner DockerCommandRunner + ReadOnlyWorkspace bool } type DockerCommandRunner interface { CombinedOutput(ctx context.Context, stdin []byte, name string, args ...string) ([]byte, []byte, error) } -type ExecDockerCommandRunner struct{} +type ExecDockerCommandRunner struct{ MaxOutputBytes int64 } -func (ExecDockerCommandRunner) CombinedOutput(ctx context.Context, stdin []byte, name string, args ...string) ([]byte, []byte, error) { - return splitOutputContextWithStdin(ctx, stdin, name, args...) +func (r ExecDockerCommandRunner) CombinedOutput(ctx context.Context, stdin []byte, name string, args ...string) ([]byte, []byte, error) { + return runBoundedProcess(ctx, stdin, r.MaxOutputBytes, name, args...) } type dockerRunSpec struct { @@ -42,11 +41,19 @@ type dockerRunSpec struct { Args []string EnvFile string ContainerName string + Owner string } func (r DockerSandboxRuntime) Available(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() tool := firstNonEmpty(r.Tool, "docker") out, stderr, err := r.commandRunner().CombinedOutput(ctx, nil, tool, "version") + out, stderr, err = boundRunnerOutput(out, stderr, err, 0) + var capture OutputCaptureMetadataProvider + if errors.As(err, &capture) { + return controlProcessError(err, "runtime availability output capture failed") + } if err != nil { if message := strings.TrimSpace(string(stderr)); message != "" { return fmt.Errorf("%w: %s", err, message) @@ -59,40 +66,63 @@ func (r DockerSandboxRuntime) Available(ctx context.Context) error { } func (r DockerSandboxRuntime) Run(ctx context.Context, req SandboxRunRequest) (SandboxRunResult, error) { - spec, cleanup, err := r.prepareRun(req) + timeout := defaultDuration(req.Timeout, time.Minute) + if req.Limits.RuntimeSeconds > 0 && time.Duration(req.Limits.RuntimeSeconds)*time.Second < timeout { + timeout = time.Duration(req.Limits.RuntimeSeconds) * time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + req.ReadOnlyWorkspace = req.ReadOnlyWorkspace || r.ReadOnlyWorkspace + if err := r.ValidateReadOnlyPolicy(ctx, req); err != nil { + return SandboxRunResult{}, err + } + spec, cleanup, err := r.prepareRunContext(ctx, req) if err != nil { return SandboxRunResult{}, err } defer cleanup() - if len(spec.RuntimeArgs) > 0 { - runtimeScope, err := validateRuntimeScope(spec.Tool, ContainerRuntimeScope{Args: spec.RuntimeArgs}) - if err != nil { - return SandboxRunResult{}, err - } - spec.RuntimeArgs = runtimeScope.Args - } - stdout, stderr, err := r.commandRunner().CombinedOutput(ctx, req.Stdin, spec.Tool, spec.CommandArgs()...) - if ctx.Err() != nil { - r.removeCanceledContainer(spec) + runner := r.Runner + if runner == nil { + runner = ExecDockerCommandRunner{MaxOutputBytes: req.Limits.OutputBytes} + } + out, stderr, err := runner.CombinedOutput(ctx, req.Stdin, spec.Tool, spec.CommandArgs()...) + out, stderr, err = boundRunnerOutput(out, stderr, err, req.Limits.OutputBytes) + var taskCapture OutputCaptureMetadataProvider + if ctx.Err() != nil && !errors.As(err, &taskCapture) { + err = &CaptureIncompleteError{Metadata: OutputCaptureMetadata{Complete: false}, Cause: errors.Join(err, ctx.Err())} + out, stderr = nil, nil + } + // Any CLI error may leave a created container running, even with complete + // stream EOF. Require the same exact ownership proof as canceled runs. + if err != nil || ctx.Err() != nil { + err = errors.Join(err, ctx.Err(), r.removeCanceledContainer(spec)) + } + var capture OutputCaptureMetadataProvider + if errors.As(err, &capture) { + // Capture errors carry nonsecret provenance, never output in error text. + } else if err != nil && len(stderr) > 0 { + err = fmt.Errorf("%w: %s", err, strings.TrimSpace(string(stderr))) + } else if err != nil && len(out) > 0 { + err = fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) } - exitCode := 0 - if err != nil { - exitCode = commandExitCode(err) + // Measurement failures must not reclassify successful process output as + // execution-error text; retain output only in its existing result fields. + workspaceBytes, measureErr := directorySizeContext(ctx, req.Workspace) + if measureErr != nil { + err = errors.Join(err, fmt.Errorf("sandbox workspace accounting incomplete: %w", measureErr)) } - if err != nil && len(stderr) > 0 { - err = fmt.Errorf("%w: %s", err, strings.TrimSpace(string(stderr))) - } else if err != nil && len(stdout) > 0 { - err = fmt.Errorf("%w: %s", err, strings.TrimSpace(string(stdout))) + result := SandboxRunResult{ExitCode: commandExitCode(err), Stdout: out, Stderr: stderr, ResourceUsage: core.ResourceUsage{OutputBytes: int64(len(out) + len(stderr)), WorkspaceBytes: workspaceBytes}} + result.OutputCapture = OutputCaptureMetadata{Complete: true, StdoutOriginalSizeBytes: int64(len(out)), StderrOriginalSizeBytes: int64(len(stderr))} + if capture != nil { + result.OutputCapture = capture.CaptureMetadata() + if !result.OutputCapture.Complete { + result.ResourceUsage = core.ResourceUsage{} + } else { + result.ResourceUsage.LimitHit = "output" + result.ResourceUsage.OutputBytes = result.OutputCapture.StdoutOriginalSizeBytes + result.OutputCapture.StderrOriginalSizeBytes + } } - return SandboxRunResult{ - ExitCode: exitCode, - Stdout: stdout, - Stderr: stderr, - ResourceUsage: core.ResourceUsage{ - OutputBytes: int64(len(stdout) + len(stderr)), - WorkspaceBytes: DirectorySize(req.Workspace), - }, - }, err + return result, err } func (r DockerSandboxRuntime) commandRunner() DockerCommandRunner { @@ -103,6 +133,10 @@ func (r DockerSandboxRuntime) commandRunner() DockerCommandRunner { } func (r DockerSandboxRuntime) prepareRun(req SandboxRunRequest) (dockerRunSpec, func(), error) { + return r.prepareRunContext(context.Background(), req) +} + +func (r DockerSandboxRuntime) prepareRunContext(ctx context.Context, req SandboxRunRequest) (dockerRunSpec, func(), error) { cleanup := func() {} tool := firstNonEmpty(r.Tool, "docker") if strings.TrimSpace(req.Workspace) == "" { @@ -113,13 +147,19 @@ func (r DockerSandboxRuntime) prepareRun(req SandboxRunRequest) (dockerRunSpec, return dockerRunSpec{}, cleanup, fmt.Errorf("resolve workspace: %w", err) } workspace = filepath.Clean(workspace) + req.ReadOnlyWorkspace = req.ReadOnlyWorkspace || r.ReadOnlyWorkspace + if req.ReadOnlyWorkspace { + if err := validateReadOnlyWorkspaceContext(ctx, req); err != nil { + return dockerRunSpec{}, cleanup, err + } + } if req.RunAsRoot { restore, err := makeWorkspaceSandboxReadable(workspace) if err != nil { return dockerRunSpec{}, cleanup, err } cleanup = restore - } else if tool == "nerdctl" { + } else if tool == "nerdctl" && !req.ReadOnlyWorkspace { restore, err := makeWorkspaceSandboxWritable(workspace) if err != nil { return dockerRunSpec{}, cleanup, err @@ -133,18 +173,27 @@ func (r DockerSandboxRuntime) prepareRun(req SandboxRunRequest) (dockerRunSpec, args := []string{ "run", "--rm", + "--pull", "never", "--network", network, "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "256", "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=64m", } + if req.ReadOnlyWorkspace { + args = append(args, "--log-driver", "none") + } containerName, err := newSandboxContainerName() if err != nil { cleanup() return dockerRunSpec{}, cleanup, err } - args = append(args, "--name", containerName) + owner, err := newSandboxOwner() + if err != nil { + cleanup() + return dockerRunSpec{}, cleanup, err + } + args = append(args, "--name", containerName, "--label", sandboxOwnerLabel+"="+owner) if len(req.Stdin) > 0 { args = append(args, "-i") } @@ -178,6 +227,9 @@ func (r DockerSandboxRuntime) prepareRun(req SandboxRunRequest) (dockerRunSpec, } if req.Limits.MemoryBytes > 0 { args = append(args, "--memory", fmt.Sprintf("%d", req.Limits.MemoryBytes)) + if req.ReadOnlyWorkspace { + args = append(args, "--memory-swap", fmt.Sprintf("%d", req.Limits.MemoryBytes)) + } } if req.Limits.CPUPercent > 0 { args = append(args, "--cpus", fmt.Sprintf("%.2f", float64(req.Limits.CPUPercent)/100)) @@ -187,7 +239,11 @@ func (r DockerSandboxRuntime) prepareRun(req SandboxRunRequest) (dockerRunSpec, cleanup() return dockerRunSpec{}, cleanup, err } - args = append(args, "-v", workspace+":/workspace:rw", "-w", workingDir) + mode := "rw" + if req.ReadOnlyWorkspace { + mode = "ro" + } + args = append(args, "-v", workspace+":/workspace:"+mode, "-w", workingDir) for _, mount := range req.DataMounts { hostPath, containerPath, err := validateSandboxMount(mount) if err != nil { @@ -223,17 +279,16 @@ func (r DockerSandboxRuntime) prepareRun(req SandboxRunRequest) (dockerRunSpec, cleanup() return dockerRunSpec{}, cleanup, err } - return dockerRunSpec{Tool: tool, RuntimeArgs: runtimeScope.Args, Args: args, EnvFile: envFile, ContainerName: containerName}, cleanup, nil + return dockerRunSpec{Tool: tool, RuntimeArgs: runtimeScope.Args, Args: args, EnvFile: envFile, ContainerName: containerName, Owner: owner}, cleanup, nil } -func (r DockerSandboxRuntime) removeCanceledContainer(spec dockerRunSpec) { +func (r DockerSandboxRuntime) removeCanceledContainer(spec dockerRunSpec) error { if spec.ContainerName == "" { - return + return nil } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - args := appendRuntimeScopeArgs(ContainerRuntimeScope{Args: spec.RuntimeArgs}, "rm", "-f", spec.ContainerName) - _, _, _ = r.commandRunner().CombinedOutput(ctx, nil, spec.Tool, args...) + return removeOwnedSandbox(ctx, r.commandRunner(), spec.Tool, ContainerRuntimeScope{Args: spec.RuntimeArgs}, spec.ContainerName, spec.Owner) } func newSandboxContainerName() (string, error) { @@ -674,15 +729,7 @@ func combinedOutputContextWithStdin(ctx context.Context, stdin []byte, name stri } func splitOutputContextWithStdin(ctx context.Context, stdin []byte, name string, args ...string) ([]byte, []byte, error) { - cmd := exec.CommandContext(ctx, name, args...) - if len(stdin) > 0 { - cmd.Stdin = bytes.NewReader(stdin) - } - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - return stdout.Bytes(), stderr.Bytes(), err + return runBoundedProcess(ctx, stdin, 0, name, args...) } func commandExitCode(err error) int { diff --git a/container/docker_compatible_runtime_test.go b/container/docker_compatible_runtime_test.go index de915ea..94f346e 100644 --- a/container/docker_compatible_runtime_test.go +++ b/container/docker_compatible_runtime_test.go @@ -867,15 +867,15 @@ func TestDockerCompatibleRuntimeRemovesNamedContainerOnContextCancel(t *testing. if err == nil { t.Fatal("expected canceled runtime error") } - if len(runner.calls) != 2 { - t.Fatalf("runtime calls = %d want run plus cleanup: %#v", len(runner.calls), runner.calls) + if len(runner.calls) != 3 { + t.Fatalf("runtime calls = %d want run plus ownership inspection and exact-ID cleanup: %#v", len(runner.calls), runner.calls) } name := dockerArgValue(runner.calls[0], "--name") if !strings.HasPrefix(name, "wfcompute-sandbox-") { t.Fatalf("run args missing sandbox container name: %#v", runner.calls[0]) } - if !slices.Equal(runner.calls[1], []string{"rm", "-f", name}) { - t.Fatalf("cleanup args = %#v want rm -f %s", runner.calls[1], name) + if !slices.Equal(runner.calls[2], []string{"rm", "-f", strings.Repeat("a", 64)}) { + t.Fatalf("cleanup args = %#v want exact verified ID", runner.calls[2]) } } @@ -942,6 +942,10 @@ func (r *cancelingDockerCommandRunner) CombinedOutput(ctx context.Context, _ []b <-ctx.Done() return nil, nil, ctx.Err() } + if len(args) > 0 && args[0] == "inspect" { + owner := strings.TrimPrefix(dockerArgValue(r.calls[0], "--label"), sandboxOwnerLabel+"=") + return []byte(`{"id":"` + strings.Repeat("a", 64) + `","owner":"` + owner + `"}`), nil, nil + } return nil, nil, nil } diff --git a/container/native_accounting_context_test.go b/container/native_accounting_context_test.go new file mode 100644 index 0000000..553546c --- /dev/null +++ b/container/native_accounting_context_test.go @@ -0,0 +1,120 @@ +package container + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + core "github.com/GoCodeAlone/workflow-plugin-compute-core/protocol" +) + +type disappearingWorkspaceRuntime struct{} + +func (disappearingWorkspaceRuntime) Available(context.Context) error { return nil } +func (disappearingWorkspaceRuntime) Run(_ context.Context, req SandboxRunRequest) (SandboxRunResult, error) { + if err := os.RemoveAll(req.Workspace); err != nil { + return SandboxRunResult{}, err + } + return SandboxRunResult{Stdout: []byte("known-output"), ArtifactHash: "sha256:" + strings.Repeat("a", 64)}, nil +} + +type disappearingWorkspaceRunner struct{ workspace string } + +func (r disappearingWorkspaceRunner) CombinedOutput(context.Context, []byte, string, ...string) ([]byte, []byte, error) { + if err := os.RemoveAll(r.workspace); err != nil { + return nil, nil, err + } + return []byte("known-output"), nil, nil +} + +// The real runtime and both adapters must not turn an incomplete accounting +// walk into a successfully measured zero-byte workspace. +func TestNativeAccountingIncompleteWalkIsNotSuccessfulZero(t *testing.T) { + for _, kind := range []string{"direct", "command", "build"} { + t.Run(kind, func(t *testing.T) { + workspace := t.TempDir() + var size int64 + var err error + switch kind { + case "direct": + result, runErr := (DockerSandboxRuntime{Runner: disappearingWorkspaceRunner{workspace}}).Run(t.Context(), SandboxRunRequest{Image: "fixture", Command: []string{"true"}, Workspace: workspace, Timeout: time.Second}) + size, err = result.ResourceUsage.WorkspaceBytes, runErr + case "command": + invocation, makeErr := NewSandboxedCommandInvocation(SandboxedCommandInvocationOptions{TaskID: "task", LeaseID: "lease", Image: "fixture", Workspace: workspace, Workload: core.CommandWorkload{Args: []string{"true"}}}) + if makeErr != nil { + t.Fatal(makeErr) + } + result, runErr := (SandboxRuntimeCommandAdapter{Runtime: disappearingWorkspaceRuntime{}}).RunSandboxedCommand(t.Context(), invocation) + size, err = result.ResourceUsage.WorkspaceBytes, runErr + case "build": + if err := os.WriteFile(filepath.Join(workspace, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { + t.Fatal(err) + } + invocation, makeErr := NewSandboxedContainerBuildInvocation(SandboxedContainerBuildInvocationOptions{TaskID: "task", LeaseID: "lease", Image: "fixture", Workspace: workspace, Workload: core.ContainerBuildWorkload{ContextDirectory: ".", Dockerfile: "Dockerfile", Tags: []string{"fixture:local"}}}) + if makeErr != nil { + t.Fatal(makeErr) + } + result, runErr := (SandboxRuntimeContainerBuildAdapter{Runtime: disappearingWorkspaceRuntime{}}).BuildSandboxedContainer(t.Context(), invocation) + size, err = result.ResourceUsage.WorkspaceBytes, runErr + } + if !errors.Is(err, os.ErrNotExist) || size != 0 { + t.Fatalf("incomplete workspace walk reported success or partial usage: size=%d err=%v", size, err) + } + if strings.Contains(err.Error(), "known-output") { + t.Fatal("accounting error echoed successful process output") + } + }) + } +} + +// Cancellation is triggered on a real context at deterministic accounting +// checkpoints, avoiding timing-sensitive sleeps or a large filesystem fixture. +type accountingCheckpointContext struct { + context.Context + cancel context.CancelFunc + cancelAt, checks int +} + +func (c *accountingCheckpointContext) Err() error { + c.checks++ + if c.checks == c.cancelAt { + c.cancel() + } + return c.Context.Err() +} +func TestNativeAccountingContextCancellationDiscardsPartialMeasurement(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "a"), []byte("abc"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "b"), []byte("1234567"), 0600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + at int + }{{"before_io", 1}, {"after_first_file", 4}, {"after_last_file", 5}} { + t.Run(tc.name, func(t *testing.T) { + base, cancel := context.WithCancel(t.Context()) + defer cancel() + ctx := &accountingCheckpointContext{Context: base, cancel: cancel, cancelAt: tc.at} + size, err := directorySizeContext(ctx, root) + if !errors.Is(err, context.Canceled) || size != 0 || ctx.checks != tc.at { + t.Fatalf("canceled walk retained bytes or continued: size=%d err=%v checks=%d", size, err, ctx.checks) + } + }) + } + size, err := directorySizeContext(t.Context(), root) + if err != nil || size != 10 || DirectorySize(root) != 10 { + t.Fatalf("complete accounting changed: size=%d err=%v", size, err) + } + expired, cancel := context.WithDeadline(t.Context(), time.Unix(1, 0)) + defer cancel() + if size, err := directorySizeContext(expired, filepath.Join(root, "missing")); !errors.Is(err, context.DeadlineExceeded) || size != 0 { + t.Fatalf("expired accounting began I/O: size=%d err=%v", size, err) + } +} diff --git a/container/native_bounds_test.go b/container/native_bounds_test.go new file mode 100644 index 0000000..e18e1f7 --- /dev/null +++ b/container/native_bounds_test.go @@ -0,0 +1,272 @@ +package container + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" + + core "github.com/GoCodeAlone/workflow-plugin-compute-core/protocol" +) + +func TestNativeProcessFixture(t *testing.T) { + if os.Getenv("WFC_PLUGIN_NATIVE_FIXTURE") != "1" { + return + } + mode := os.Args[len(os.Args)-1] + if mode == "flood" { + _, _ = io.WriteString(os.Stdout, strings.Repeat("o", 1100000)+"stdout-final-tail") + _, _ = io.WriteString(os.Stderr, strings.Repeat("e", 1100000)+"stderr-final-tail") + } else if mode == "sleep" { + time.Sleep(10 * time.Second) + } else if mode == "inherited-pipe" || mode == "inherited-pipe-exit" { + cmd := exec.Command(os.Args[0], "-test.run=^TestNativeProcessFixture$", "--", "sleep") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + os.Exit(3) + } + if err := os.WriteFile(os.Getenv("WFC_PLUGIN_PID_FILE"), []byte(strconv.Itoa(cmd.Process.Pid)), 0600); err != nil { + os.Exit(4) + } + if err := cmd.Process.Release(); err != nil { + os.Exit(5) + } + if mode == "inherited-pipe-exit" { + os.Exit(7) + } + } + os.Exit(0) +} + +func TestNativeExactPositiveOutputLimitAndTruthfulProvenance(t *testing.T) { + t.Setenv("WFC_PLUGIN_NATIVE_FIXTURE", "1") + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, limit := range []int64{8, 3 << 20} { + r := ExecDockerCommandRunner{MaxOutputBytes: limit} + out, stderr, err := r.CombinedOutput(t.Context(), nil, binary, "-test.run=^TestNativeProcessFixture$", "--", "flood") + if limit == 8 { + var capture *OutputLimitError + if !errors.As(err, &capture) { + t.Fatalf("missing typed output provenance: %v", err) + } + m := capture.Metadata + if !m.Complete || m.StdoutOriginalSizeBytes != 1100017 || m.StderrOriginalSizeBytes != 1100017 || !m.StdoutTruncated || !m.StderrTruncated { + t.Fatalf("false original metadata: %+v", m) + } + if string(out) != "tail" || string(stderr) != "tail" { + t.Fatalf("wrong aggregate final tails %q/%q", out, stderr) + } + if strings.Contains(err.Error(), "tail") { + t.Fatal("error copied output") + } + } else if err != nil || len(out)+len(stderr) != 2200034 { + t.Fatalf("positive limit replaced by smaller default: %d %v", len(out)+len(stderr), err) + } + } +} + +func TestNativeIncompleteCaptureDoesNotInventCompleteOriginalSizes(t *testing.T) { + for _, mode := range []string{"inherited-pipe", "inherited-pipe-exit"} { + t.Run(mode, func(t *testing.T) { testNativeIncompleteCapture(t, mode) }) + } +} + +func testNativeIncompleteCapture(t *testing.T, mode string) { + t.Setenv("WFC_PLUGIN_NATIVE_FIXTURE", "1") + pidFile := filepath.Join(t.TempDir(), "child.pid") + t.Setenv("WFC_PLUGIN_PID_FILE", pidFile) + t.Cleanup(func() { + if data, err := os.ReadFile(pidFile); err == nil { + if pid, err := strconv.Atoi(string(data)); err == nil { + if p, err := os.FindProcess(pid); err == nil { + _ = p.Kill() + } + } + } + }) + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + start := time.Now() + out, stderr, err := (ExecDockerCommandRunner{}).CombinedOutput(t.Context(), nil, binary, "-test.run=^TestNativeProcessFixture$", "--", mode) + var capture *CaptureIncompleteError + if !errors.As(err, &capture) || capture.Metadata.Complete || len(out)+len(stderr) != 0 { + t.Fatalf("incomplete capture represented as complete: %v out=%d stderr=%d", err, len(out), len(stderr)) + } + if time.Since(start) > 3*time.Second { + t.Fatal("pipe wait was unbounded") + } +} + +func TestNativeDefaultOutputContinuesDrainingAndRetainsAggregateTail(t *testing.T) { + t.Setenv("WFC_PLUGIN_NATIVE_FIXTURE", "1") + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + out, stderr, err := splitOutputContextWithStdin(ctx, nil, binary, "-test.run=^TestNativeProcessFixture$", "--", "flood") + if len(out)+len(stderr) > 1<<20 { + t.Fatalf("unbounded aggregate output: %d", len(out)+len(stderr)) + } + if !bytes.HasSuffix(out, []byte("stdout-final-tail")) || !bytes.HasSuffix(stderr, []byte("stderr-final-tail")) { + t.Fatal("did not drain final stream tails") + } + if err == nil { + t.Fatal("effective default output limit violation not reported") + } +} + +func TestNativeProtectedRunNeverPulls(t *testing.T) { + spec, cleanup, err := (DockerSandboxRuntime{}).prepareRun(SandboxRunRequest{Image: "local-pinned-image", Workspace: t.TempDir()}) + defer cleanup() + if err != nil { + t.Fatal(err) + } + if dockerArgValue(spec.Args, "--pull") != "never" { + t.Fatalf("implicit image pull remains possible: %v", spec.Args) + } +} + +type nativeDeadlineRunner struct{ bounded bool } + +func (r *nativeDeadlineRunner) CombinedOutput(ctx context.Context, _ []byte, _ string, args ...string) ([]byte, []byte, error) { + if len(args) > 0 && args[0] == "run" { + deadline, ok := ctx.Deadline() + r.bounded = ok && time.Until(deadline) <= 100*time.Millisecond + return nil, nil, errors.New("fixture completed") + } + return nil, nil, nil +} +func TestNativeDirectRunHonorsRequestTimeout(t *testing.T) { + r := &nativeDeadlineRunner{} + _, _ = (DockerSandboxRuntime{Runner: r}).Run(t.Context(), SandboxRunRequest{Image: "local-image", Workspace: t.TempDir(), Timeout: 25 * time.Millisecond}) + if !r.bounded { + t.Fatal("direct runtime ignored requested timeout") + } +} + +func TestNativeAvailabilityHasFiniteDefaultDeadline(t *testing.T) { + r := nativeAvailabilityRunner{} + if err := (DockerSandboxRuntime{Runner: r}).Available(t.Context()); err != nil { + t.Fatal(err) + } +} + +type nativeAvailabilityRunner struct{} + +func (nativeAvailabilityRunner) CombinedOutput(ctx context.Context, _ []byte, _ string, _ ...string) ([]byte, []byte, error) { + if _, ok := ctx.Deadline(); !ok { + return nil, nil, fmt.Errorf("availability deadline missing") + } + return nil, nil, nil +} + +func TestNativeReadOnlyWorkspacePolicyAndLegacyMount(t *testing.T) { + r := DockerSandboxRuntime{} + field := reflect.ValueOf(&r).Elem().FieldByName("ReadOnlyWorkspace") + if !field.IsValid() { + t.Fatal("read-only workspace policy missing") + } + field.SetBool(true) + workspace := t.TempDir() + req := SandboxRunRequest{Workspace: workspace, Image: "local", Limits: core.ResourceLimits{WorkspaceBytes: 16, MemoryBytes: 64 << 20, CPUPercent: 25}} + spec, cleanup, err := r.prepareRun(req) + defer cleanup() + if err != nil { + t.Fatal(err) + } + args := strings.Join(spec.Args, "\x00") + if !strings.Contains(args, workspace+":/workspace:ro") || dockerArgValue(spec.Args, "--log-driver") != "none" { + t.Fatalf("missing enforced read-only/log policy: %v", spec.Args) + } + for _, mode := range []string{"root", "tmpfs", "writable_mount", "shadow_mount", "oversize", "symlink"} { + t.Run(mode, func(t *testing.T) { + bad := req + switch mode { + case "root": + bad.WritableRootFS = true + case "tmpfs": + bad.ExtraTmpfs = []string{"/wfcompute-build:rw,noexec,nosuid,nodev,size=512m"} + case "writable_mount": + bad.DataMounts = []SandboxMount{{HostPath: t.TempDir(), ContainerPath: "/data"}} + case "shadow_mount": + bad.DataMounts = []SandboxMount{{HostPath: t.TempDir(), ContainerPath: "/workspace/nested", ReadOnly: true}} + case "oversize": + bad.Workspace = t.TempDir() + if err := os.WriteFile(filepath.Join(bad.Workspace, "big"), make([]byte, 17), 0600); err != nil { + t.Fatal(err) + } + case "symlink": + bad.Workspace = t.TempDir() + if err := os.Symlink(workspace, filepath.Join(bad.Workspace, "link")); err != nil { + t.Skip(err) + } + } + _, c, err := r.prepareRun(bad) + c() + if err == nil { + t.Fatal("unsafe read-only request accepted") + } + }) + } + legacy, c, err := (DockerSandboxRuntime{}).prepareRun(req) + defer c() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(legacy.Args, "\x00"), workspace+":/workspace:rw") { + t.Fatal("legacy writable semantics changed") + } +} + +func TestNativeProbeHasNoPullLogStorageOrUnboundedLifetime(t *testing.T) { + runner := &nativeProbeRunner{} + probe := RuntimeBackendProbe{Options: podmanProbeOptions([]core.RuntimeProfile{core.RuntimeProfileSandboxedOCI}), Runner: runner} + report := probe.Probe(t.Context()) + if report.Status != core.RuntimeBackendSupported { + t.Fatalf("probe: %+v", report) + } + if !runner.bounded { + t.Fatal("probe deadline missing") + } + for _, key := range []string{"--name", "--memory", "--cpus", "--pids-limit"} { + if dockerArgValue(runner.args, key) == "" { + t.Fatalf("probe missing %s", key) + } + } + if dockerArgValue(runner.args, "--pull") != "never" || dockerArgValue(runner.args, "--log-driver") != "none" { + t.Fatalf("probe may pull/store logs: %v", runner.args) + } +} + +type nativeProbeRunner struct { + args []string + bounded bool +} + +func (*nativeProbeRunner) LookPath(name string) (string, error) { return name, nil } +func (r *nativeProbeRunner) Run(ctx context.Context, _ string, args ...string) (RuntimeCommandResult, error) { + _, ok := ctx.Deadline() + r.bounded = ok + if len(args) > 0 && args[0] == "version" { + return RuntimeCommandResult{Stdout: []byte("1")}, nil + } + r.args = append([]string(nil), args...) + return RuntimeCommandResult{Stdout: []byte(validRuntimeEvidenceJSON())}, nil +} diff --git a/container/native_cli_error_cleanup_test.go b/container/native_cli_error_cleanup_test.go new file mode 100644 index 0000000..f600f40 --- /dev/null +++ b/container/native_cli_error_cleanup_test.go @@ -0,0 +1,94 @@ +package container + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +type cliErrorCleanupRunner struct { + runErr, inspectErr, removeErr error + foreign bool + cancel context.CancelFunc + owner, removed string + calls []string + fresh bool +} + +func (r *cliErrorCleanupRunner) CombinedOutput(ctx context.Context, _ []byte, _ string, args ...string) ([]byte, []byte, error) { + r.calls = append(r.calls, args[0]) + switch args[0] { + case "run": + r.owner = strings.TrimPrefix(dockerArgValue(args, "--label"), sandboxOwnerLabel+"=") + if r.cancel != nil { + r.cancel() + } + return nil, nil, r.runErr + case "inspect": + deadline, ok := ctx.Deadline() + r.fresh = ok && ctx.Err() == nil && time.Until(deadline) <= 10*time.Second + owner := r.owner + if r.foreign { + owner = "foreign" + } + return []byte(`{"id":"` + strings.Repeat("a", 64) + `","owner":"` + owner + `"}`), nil, r.inspectErr + case "rm": + r.removed = args[len(args)-1] + return nil, nil, r.removeErr + } + return nil, nil, errors.New("unexpected fixture operation") +} +func TestNativeCLIErrorRequiresOwnedCleanup(t *testing.T) { + runFailure := errors.New("synthetic CLI transport failure") + inspectFailure := errors.New("synthetic uncertain inspection") + removeFailure := errors.New("synthetic uncertain removal") + for _, mode := range []string{"ordinary", "foreign", "inspect_failure", "remove_failure", "canceled", "success"} { + t.Run(mode, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + runner := &cliErrorCleanupRunner{runErr: runFailure} + switch mode { + case "foreign": + runner.foreign = true + case "inspect_failure": + runner.inspectErr = inspectFailure + case "remove_failure": + runner.removeErr = removeFailure + case "canceled": + runner.runErr = context.Canceled + runner.cancel = cancel + case "success": + runner.runErr = nil + } + _, err := (DockerSandboxRuntime{Runner: runner}).Run(ctx, SandboxRunRequest{Image: "fixture", Command: []string{"true"}, Workspace: t.TempDir(), Timeout: time.Second}) + if mode == "success" { + if err != nil || len(runner.calls) != 1 { + t.Fatalf("successful run acquired cleanup: err=%v calls=%v", err, runner.calls) + } + return + } + if !errors.Is(err, runner.runErr) { + t.Fatalf("original execution failure lost: %v", err) + } + if !runner.fresh || len(runner.owner) != 32 { + t.Fatalf("owned inspection missing or cleanup context expired: calls=%v fresh=%t", runner.calls, runner.fresh) + } + wantRemove := mode != "foreign" && mode != "inspect_failure" + if wantRemove { + if runner.removed != strings.Repeat("a", 64) || strings.Join(runner.calls, ",") != "run,inspect,rm" { + t.Fatalf("exact owned removal missing: calls=%v", runner.calls) + } + } else if runner.removed != "" || strings.Join(runner.calls, ",") != "run,inspect" { + t.Fatalf("unproved identity authorized removal: calls=%v", runner.calls) + } + if mode == "inspect_failure" && !errors.Is(err, inspectFailure) { + t.Fatal("inspection uncertainty lost") + } + if mode == "remove_failure" && !errors.Is(err, removeFailure) { + t.Fatal("removal uncertainty lost") + } + }) + } +} diff --git a/container/native_optional_volumes_test.go b/container/native_optional_volumes_test.go new file mode 100644 index 0000000..bdd563a --- /dev/null +++ b/container/native_optional_volumes_test.go @@ -0,0 +1,57 @@ +package container + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + "text/template" + + core "github.com/GoCodeAlone/workflow-plugin-compute-core/protocol" +) + +// Docker29 may omit empty Config.Volumes. Evaluate the actual production +// template with Docker's strict missing-key behavior, not canned template output. +type optionalVolumesTemplateRunner struct{ config map[string]any } + +func (r optionalVolumesTemplateRunner) CombinedOutput(_ context.Context, _ []byte, _ string, args ...string) ([]byte, []byte, error) { + if len(args) == 3 && args[0] == "info" { + return []byte(supportedDockerControls), nil, nil + } + if len(args) != 5 || args[0] != "image" || args[1] != "inspect" || args[2] != "--format" { + return nil, nil, errors.New("unexpected preflight command") + } + render, err := template.New("actual-docker-format").Option("missingkey=error").Funcs(template.FuncMap{ + "json": func(value any) (string, error) { raw, err := json.Marshal(value); return string(raw), err }, + }).Parse(args[3]) + if err != nil { + return nil, nil, err + } + var out bytes.Buffer + err = render.Execute(&out, map[string]any{"Config": r.config}) + return out.Bytes(), nil, err +} + +func TestNativeReadOnlyOptionalImageVolumes(t *testing.T) { + for _, tc := range []struct { + name string + config map[string]any + wantError bool + }{ + {"omitted", map[string]any{}, false}, + {"null", map[string]any{"Volumes": nil}, false}, + {"empty", map[string]any{"Volumes": map[string]any{}}, false}, + {"declared", map[string]any{"Volumes": map[string]any{"/private": map[string]any{}}}, true}, + {"malformed", map[string]any{"Volumes": []any{}}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + req := SandboxRunRequest{Image: "fixed-cached-image", Workspace: t.TempDir(), Limits: core.ResourceLimits{WorkspaceBytes: 4096, MemoryBytes: 64 << 20, CPUPercent: 25}} + runtime := DockerSandboxRuntime{ReadOnlyWorkspace: true, Runner: optionalVolumesTemplateRunner{config: tc.config}} + err := runtime.ValidateReadOnlyPolicy(t.Context(), req) + if (err != nil) != tc.wantError { + t.Fatalf("preflight error=%v, wantError=%t", err, tc.wantError) + } + }) + } +} diff --git a/container/native_policy_test.go b/container/native_policy_test.go new file mode 100644 index 0000000..dad2c07 --- /dev/null +++ b/container/native_policy_test.go @@ -0,0 +1,332 @@ +package container + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + core "github.com/GoCodeAlone/workflow-plugin-compute-core/protocol" +) + +type policyFixtureRunner struct { + info, volumes string + calls [][]string +} + +func (r *policyFixtureRunner) CombinedOutput(ctx context.Context, _ []byte, _ string, args ...string) ([]byte, []byte, error) { + if _, ok := ctx.Deadline(); !ok { + return nil, nil, errors.New("unbounded preflight") + } + r.calls = append(r.calls, append([]string(nil), args...)) + switch args[0] { + case "info": + return []byte(r.info), nil, nil + case "image": + return []byte(r.volumes), nil, nil + case "run": + return []byte("marker"), nil, nil + } + return nil, nil, nil +} + +const supportedDockerControls = `{"OSType":"linux","MemoryLimit":true,"SwapLimit":true,"CPUCfsQuota":true,"CPUCfsPeriod":true,"PidsLimit":true}` + +func TestNativeReadOnlyPreflightRejectsMissingControlEvidenceAndImageVolumes(t *testing.T) { + req := SandboxRunRequest{Image: "cached", Workspace: t.TempDir(), Limits: core.ResourceLimits{WorkspaceBytes: 16, MemoryBytes: 64 << 20, CPUPercent: 25}} + for _, mode := range []string{"valid", "missing_memory", "missing_cpu", "missing_pid", "image_volumes", "malformed_info", "malformed_image", "unsupported_backend"} { + t.Run(mode, func(t *testing.T) { + runner := &policyFixtureRunner{info: supportedDockerControls, volumes: "null"} + r := DockerSandboxRuntime{ReadOnlyWorkspace: true, Runner: runner} + switch mode { + case "missing_memory": + runner.info = strings.Replace(runner.info, `"MemoryLimit":true`, `"MemoryLimit":false`, 1) + case "missing_cpu": + runner.info = strings.Replace(runner.info, `"CPUCfsQuota":true`, `"CPUCfsQuota":false`, 1) + case "missing_pid": + runner.info = strings.Replace(runner.info, `"PidsLimit":true`, `"PidsLimit":false`, 1) + case "image_volumes": + runner.volumes = `{"/unbounded":{}}` + case "malformed_info": + runner.info = `{}` + case "malformed_image": + runner.volumes = `[]` + case "unsupported_backend": + r.Tool = "podman" + } + err := r.ValidateReadOnlyPolicy(t.Context(), req) + if (err == nil) != (mode == "valid") { + t.Fatalf("mode %s err=%v", mode, err) + } + }) + } +} + +type timeoutMetadataRuntime struct{ availableDeadline bool } + +func (r *timeoutMetadataRuntime) Available(ctx context.Context) error { + _, r.availableDeadline = ctx.Deadline() + return nil +} +func (*timeoutMetadataRuntime) Run(ctx context.Context, _ SandboxRunRequest) (SandboxRunResult, error) { + <-ctx.Done() + return SandboxRunResult{Stdout: []byte("tail")}, &OutputLimitError{Metadata: OutputCaptureMetadata{Complete: true, StdoutOriginalSizeBytes: 20, StdoutTruncated: true}} +} +func TestNativeAdapterDeadlineIncludesAvailabilityAndPreservesCaptureError(t *testing.T) { + invocation, err := NewSandboxedCommandInvocation(SandboxedCommandInvocationOptions{TaskID: "task", LeaseID: "lease", Image: "cached", Workspace: t.TempDir(), Timeout: time.Millisecond, Workload: core.CommandWorkload{Args: []string{"true"}}}) + if err != nil { + t.Fatal(err) + } + runtime := &timeoutMetadataRuntime{} + _, err = (SandboxRuntimeCommandAdapter{Runtime: runtime}).RunSandboxedCommand(t.Context(), invocation) + var capture *OutputLimitError + if !runtime.availableDeadline || !errors.As(err, &capture) || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("timeout lost metadata/deadline: %v", err) + } +} + +func TestNativeReadOnlyAdapterRejectsArtifactsBeforeAvailability(t *testing.T) { + invocation, err := NewSandboxedCommandInvocation(SandboxedCommandInvocationOptions{TaskID: "task", LeaseID: "lease", Image: "cached", Workspace: t.TempDir(), Workload: core.CommandWorkload{Args: []string{"true"}, ArtifactAllowlist: []string{"out.txt"}}}) + if err != nil { + t.Fatal(err) + } + runtime := &recordingSandboxRuntime{} + _, err = (SandboxRuntimeCommandAdapter{Runtime: runtime, ReadOnlyWorkspace: true}).RunSandboxedCommand(t.Context(), invocation) + if err == nil || runtime.request.Image != "" { + t.Fatal("read-only artifact task reached execution") + } +} + +func TestNativeConfiguredRuntimeReadOnlyCannotBeDisabledAtAdapter(t *testing.T) { + invocation, err := NewSandboxedCommandInvocation(SandboxedCommandInvocationOptions{TaskID: "task", LeaseID: "lease", Image: "cached", Workspace: t.TempDir(), Workload: core.CommandWorkload{Args: []string{"true"}, ArtifactAllowlist: []string{"out.txt"}}}) + if err != nil { + t.Fatal(err) + } + runner := &policyFixtureRunner{} + for _, runtime := range []SandboxRuntime{DockerSandboxRuntime{ReadOnlyWorkspace: true, Runner: runner}, &DockerSandboxRuntime{ReadOnlyWorkspace: true, Runner: runner}} { + _, err = (SandboxRuntimeCommandAdapter{Runtime: runtime}).RunSandboxedCommand(t.Context(), invocation) + if err == nil || !strings.Contains(err.Error(), "artifacts") || len(runner.calls) != 0 { + t.Fatalf("configured policy lost at adapter: %v calls=%v", err, runner.calls) + } + } +} + +type runtimeLimitProbe struct{ bounded bool } + +func (r *runtimeLimitProbe) Available(ctx context.Context) error { + d, ok := ctx.Deadline() + r.bounded = ok && time.Until(d) < 1500*time.Millisecond + return nil +} +func (*runtimeLimitProbe) Run(context.Context, SandboxRunRequest) (SandboxRunResult, error) { + return SandboxRunResult{}, nil +} +func TestNativeAdapterRuntimeLimitIncludesAvailability(t *testing.T) { + inv, err := NewSandboxedCommandInvocation(SandboxedCommandInvocationOptions{TaskID: "task", LeaseID: "lease", Image: "cached", Workspace: t.TempDir(), Timeout: 10 * time.Second, Limits: core.ResourceLimits{RuntimeSeconds: 1}, Workload: core.CommandWorkload{Args: []string{"true"}}}) + if err != nil { + t.Fatal(err) + } + r := &runtimeLimitProbe{} + _, err = (SandboxRuntimeCommandAdapter{Runtime: r}).RunSandboxedCommand(t.Context(), inv) + if err != nil || !r.bounded { + t.Fatalf("runtime limit omitted availability: %v bounded=%t", err, r.bounded) + } +} + +type controlCaptureRunner struct{} + +func (controlCaptureRunner) CombinedOutput(context.Context, []byte, string, ...string) ([]byte, []byte, error) { + return []byte("tail"), nil, &OutputLimitError{Metadata: OutputCaptureMetadata{Complete: true, StdoutOriginalSizeBytes: 999, StdoutTruncated: true}} +} +func TestNativeControlCaptureCannotMasqueradeAsWorkloadProvenance(t *testing.T) { + r := DockerSandboxRuntime{Runner: controlCaptureRunner{}} + var metadata OutputCaptureMetadataProvider + if err := r.Available(t.Context()); err == nil || errors.As(err, &metadata) { + t.Fatalf("availability output presented as workload: %v", err) + } + r.ReadOnlyWorkspace = true + req := SandboxRunRequest{Image: "cached", Workspace: t.TempDir(), Limits: core.ResourceLimits{WorkspaceBytes: 16, MemoryBytes: 64 << 20, CPUPercent: 25}} + if err := r.ValidateReadOnlyPolicy(t.Context(), req); err == nil || errors.As(err, &metadata) { + t.Fatalf("preflight output presented as workload: %v", err) + } +} + +type cleanupFailureRunner struct { + cancel context.CancelFunc + calls [][]string + fresh bool +} + +func (r *cleanupFailureRunner) CombinedOutput(ctx context.Context, _ []byte, _ string, args ...string) ([]byte, []byte, error) { + r.calls = append(r.calls, append([]string(nil), args...)) + if len(r.calls) == 1 { + r.cancel() + return []byte("partial"), nil, context.Canceled + } + _, r.fresh = ctx.Deadline() + r.fresh = r.fresh && ctx.Err() == nil + return nil, nil, &OutputLimitError{Metadata: OutputCaptureMetadata{Complete: true, StdoutOriginalSizeBytes: 999, StdoutTruncated: true}} +} +func TestNativeCancellationCleanupIsScopedFreshVisibleAndNotTaskOutput(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + runner := &cleanupFailureRunner{cancel: cancel} + result, err := (DockerSandboxRuntime{Tool: "nerdctl", Runner: runner}).Run(ctx, SandboxRunRequest{Image: "cached", Workspace: t.TempDir(), RuntimeScope: ContainerRuntimeScope{Args: []string{"--namespace", "owned"}}}) + if len(runner.calls) != 2 || !runner.fresh || !strings.Contains(err.Error(), "cleanup") { + t.Fatalf("cleanup not fresh/visible: %v %v", runner.calls, err) + } + if got := strings.Join(runner.calls[1][:4], " "); got != "--namespace owned inspect --type" { + t.Fatalf("lost cleanup scope %v", runner.calls[1]) + } + var capture OutputCaptureMetadataProvider + if !errors.As(err, &capture) || capture.CaptureMetadata().Complete || len(result.Stdout) != 0 || result.ResourceUsage.OutputBytes != 0 { + t.Fatalf("partial/control bytes claimed as complete task output: result=%+v err=%v", result, err) + } +} + +func TestNativeReadOnlyCanceledPreflightStopsBeforeEngineCalls(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + runner := &policyFixtureRunner{info: supportedDockerControls, volumes: "null"} + r := DockerSandboxRuntime{ReadOnlyWorkspace: true, Runner: runner} + err := r.ValidateReadOnlyPolicy(ctx, SandboxRunRequest{Image: "cached", Workspace: t.TempDir(), Limits: core.ResourceLimits{WorkspaceBytes: 16, MemoryBytes: 64 << 20, CPUPercent: 25}}) + if !errors.Is(err, context.Canceled) || len(runner.calls) != 0 { + t.Fatalf("canceled preflight kept running: %v calls=%v", err, runner.calls) + } +} + +type accountingRunner struct{} + +func (accountingRunner) CombinedOutput(_ context.Context, _ []byte, _ string, args ...string) ([]byte, []byte, error) { + if len(args) > 0 && args[0] == "version" { + return []byte("1"), nil, nil + } + return []byte("0123456789"), []byte("abcdefghij"), nil +} +func TestNativeRuntimeAdapterCountsCompleteOriginalOutputNotRetainedTails(t *testing.T) { + inv, err := NewSandboxedCommandInvocation(SandboxedCommandInvocationOptions{TaskID: "task", LeaseID: "lease", Image: "cached", Workspace: t.TempDir(), Limits: core.ResourceLimits{OutputBytes: 8}, Workload: core.CommandWorkload{Args: []string{"true"}}}) + if err != nil { + t.Fatal(err) + } + result, err := (SandboxRuntimeCommandAdapter{Runtime: DockerSandboxRuntime{Runner: accountingRunner{}}}).RunSandboxedCommand(t.Context(), inv) + var capture *OutputLimitError + if !errors.As(err, &capture) || result.ResourceUsage.OutputBytes != 20 || len(result.Stdout)+len(result.Stderr) != 8 { + t.Fatalf("original output accounting lost: %+v err=%v", result, err) + } +} + +type collisionRunner struct { + cancel context.CancelFunc + removed bool +} + +func (r *collisionRunner) CombinedOutput(_ context.Context, _ []byte, _ string, args ...string) ([]byte, []byte, error) { + switch args[0] { + case "run": + r.cancel() + return nil, nil, errors.New("name collision") + case "inspect": + return []byte(`{"id":"` + strings.Repeat("b", 64) + `","owner":"foreign"}`), nil, nil + case "rm": + r.removed = true + return nil, nil, nil + } + return nil, nil, nil +} +func TestNativeCommandCollisionNeverRemovesForeignContainer(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + runner := &collisionRunner{cancel: cancel} + _, err := (DockerSandboxRuntime{Runner: runner}).Run(ctx, SandboxRunRequest{Image: "cached", Workspace: t.TempDir()}) + if err == nil || runner.removed { + t.Fatalf("foreign collision target removed: removed=%t err=%v", runner.removed, err) + } +} + +type collisionProbeRunner struct{ removed bool } + +func (*collisionProbeRunner) LookPath(name string) (string, error) { return name, nil } +func (r *collisionProbeRunner) Run(_ context.Context, _ string, args ...string) (RuntimeCommandResult, error) { + switch args[0] { + case "version": + return RuntimeCommandResult{Stdout: []byte("1")}, nil + case "run": + return RuntimeCommandResult{}, errors.New("name collision") + case "inspect": + return RuntimeCommandResult{Stdout: []byte(`{"id":"` + strings.Repeat("b", 64) + `","owner":"foreign"}`)}, nil + case "rm": + r.removed = true + return RuntimeCommandResult{}, nil + } + return RuntimeCommandResult{}, nil +} +func TestNativeProbeCollisionNeverRemovesForeignContainer(t *testing.T) { + runner := &collisionProbeRunner{} + report := (RuntimeBackendProbe{Options: podmanProbeOptions([]core.RuntimeProfile{core.RuntimeProfileSandboxedOCI}), Runner: runner}).Probe(t.Context()) + if report.Status != core.RuntimeBackendDegraded || runner.removed { + t.Fatalf("foreign probe target removed: removed=%t report=%+v", runner.removed, report) + } +} + +type adapterCaptureRuntime struct{ complete, availableError bool } + +func (r adapterCaptureRuntime) Available(context.Context) error { + if r.availableError { + return &OutputLimitError{Metadata: OutputCaptureMetadata{Complete: true, StdoutOriginalSizeBytes: 999}} + } + return nil +} +func (r adapterCaptureRuntime) Run(ctx context.Context, _ SandboxRunRequest) (SandboxRunResult, error) { + <-ctx.Done() + metadata := OutputCaptureMetadata{Complete: r.complete, StdoutOriginalSizeBytes: 20, StdoutTruncated: true} + if !r.complete { + return SandboxRunResult{}, &CaptureIncompleteError{Metadata: metadata, Cause: ctx.Err()} + } + return SandboxRunResult{Stdout: []byte("tail")}, &OutputLimitError{Metadata: metadata, Cause: ctx.Err()} +} +func nativeAdapterCall(t *testing.T, name string, runtime SandboxRuntime) (core.RuntimeExecutionResult, error) { + t.Helper() + workspace := t.TempDir() + if name == "command" { + inv, err := NewSandboxedCommandInvocation(SandboxedCommandInvocationOptions{TaskID: "task", LeaseID: "lease", Image: "cached", Workspace: workspace, Timeout: time.Millisecond, Workload: core.CommandWorkload{Args: []string{"true"}}}) + if err != nil { + t.Fatal(err) + } + return (SandboxRuntimeCommandAdapter{Runtime: runtime}).RunSandboxedCommand(t.Context(), inv) + } + if err := os.WriteFile(filepath.Join(workspace, "Dockerfile"), []byte("FROM scratch\n"), 0600); err != nil { + t.Fatal(err) + } + inv, err := NewSandboxedContainerBuildInvocation(SandboxedContainerBuildInvocationOptions{TaskID: "task", LeaseID: "lease", Image: "builder", Workspace: workspace, Timeout: time.Millisecond, Workload: core.ContainerBuildWorkload{ContextDirectory: ".", Dockerfile: "Dockerfile", Tags: []string{"example:test"}}}) + if err != nil { + t.Fatal(err) + } + return (SandboxRuntimeContainerBuildAdapter{Runtime: runtime}).BuildSandboxedContainer(t.Context(), inv) +} +func TestNativeAllAdaptersPreserveCaptureMetadataAcrossTimeout(t *testing.T) { + for _, name := range []string{"command", "build"} { + for _, complete := range []bool{true, false} { + t.Run(name+map[bool]string{true: "_overflow", false: "_incomplete"}[complete], func(t *testing.T) { + _, err := nativeAdapterCall(t, name, adapterCaptureRuntime{complete: complete}) + var capture OutputCaptureMetadataProvider + if !errors.Is(err, context.DeadlineExceeded) || !errors.As(err, &capture) || capture.CaptureMetadata().Complete != complete { + t.Fatalf("adapter lost capture provenance: %v", err) + } + }) + } + } +} +func TestNativeAllAdaptersDoNotProjectAvailabilityOutputAsTask(t *testing.T) { + for _, name := range []string{"command", "build"} { + t.Run(name, func(t *testing.T) { + _, err := nativeAdapterCall(t, name, adapterCaptureRuntime{availableError: true}) + var capture OutputCaptureMetadataProvider + if err == nil || errors.As(err, &capture) { + t.Fatalf("control output claimed as workload: %v", err) + } + }) + } +} diff --git a/container/native_swap_test.go b/container/native_swap_test.go new file mode 100644 index 0000000..27eedbf --- /dev/null +++ b/container/native_swap_test.go @@ -0,0 +1,40 @@ +package container + +import ( + "strings" + "testing" + + core "github.com/GoCodeAlone/workflow-plugin-compute-core/protocol" +) + +func TestNativeReadOnlyRefusesMissingSwapControlBeforeCreation(t *testing.T) { + for _, field := range []string{"", `"SwapLimit":false,`} { + runner := &policyFixtureRunner{info: strings.Replace(supportedDockerControls, `"SwapLimit":true,`, field, 1), volumes: "null"} + _, err := (DockerSandboxRuntime{ReadOnlyWorkspace: true, Runner: runner}).Run(t.Context(), SandboxRunRequest{Image: "cached", Workspace: t.TempDir(), Limits: core.ResourceLimits{WorkspaceBytes: 4096, MemoryBytes: 64 << 20, CPUPercent: 25}}) + if err == nil { + t.Fatal("unsupported or missing swap limit evidence admitted creation") + } + for _, call := range runner.calls { + if len(call) > 0 && call[0] == "run" { + t.Fatal("unsupported swap control reached runtime execution") + } + } + } +} + +func TestNativeReadOnlyMemoryBoundsIncludeSwap(t *testing.T) { + for _, readonly := range []bool{false, true} { + spec, cleanup, err := (DockerSandboxRuntime{ReadOnlyWorkspace: readonly}).prepareRun(SandboxRunRequest{Image: "cached", Workspace: t.TempDir(), Limits: core.ResourceLimits{WorkspaceBytes: 4096, MemoryBytes: 64 << 20, CPUPercent: 25}}) + defer cleanup() + if err != nil { + t.Fatal(err) + } + got := dockerArgValue(spec.Args, "--memory-swap") + if readonly && got != "67108864" { + t.Fatal("fixed read-only exercise did not bound memory plus swap") + } + if !readonly && got != "" { + t.Fatal("legacy writable command swap behavior changed") + } + } +} diff --git a/container/process_output.go b/container/process_output.go new file mode 100644 index 0000000..facaf10 --- /dev/null +++ b/container/process_output.go @@ -0,0 +1,216 @@ +package container + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "os/exec" + "sync" + "time" +) + +const defaultProcessOutputLimit int64 = 1 << 20 +const processPipeWait = time.Second + +// OutputCaptureMetadata describes the original streams only when Complete is true. +// Complete=false means that a deadline or forced pipe closure prevented proving EOF. +type OutputCaptureMetadata struct { + Complete bool + StdoutOriginalSizeBytes int64 + StderrOriginalSizeBytes int64 + StdoutTruncated bool + StderrTruncated bool +} + +type OutputCaptureMetadataProvider interface{ CaptureMetadata() OutputCaptureMetadata } + +type OutputLimitError struct { + Metadata OutputCaptureMetadata + Cause error +} + +func (e *OutputLimitError) Error() string { return "process output limit exceeded" } +func (e *OutputLimitError) Unwrap() error { return e.Cause } +func (e *OutputLimitError) CaptureMetadata() OutputCaptureMetadata { return e.Metadata } + +type CaptureIncompleteError struct { + Metadata OutputCaptureMetadata + Cause error +} + +func (e *CaptureIncompleteError) Error() string { return "process output capture incomplete" } +func (e *CaptureIncompleteError) Unwrap() error { return e.Cause } +func (e *CaptureIncompleteError) CaptureMetadata() OutputCaptureMetadata { return e.Metadata } + +// Control process output is not workload output. Keep the failure/cancellation, +// but do not let an outer workload consumer mistake its counts for task streams. +func controlProcessError(err error, message string) error { + var metadata OutputCaptureMetadataProvider + if !errors.As(err, &metadata) { + return err + } + var cause error + if errors.Is(err, context.Canceled) { + cause = context.Canceled + } + if errors.Is(err, context.DeadlineExceeded) { + cause = errors.Join(cause, context.DeadlineExceeded) + } + return errors.Join(errors.New(message), cause) +} + +func effectiveOutputLimit(limit int64) int64 { + if limit > 0 { + return limit + } + return defaultProcessOutputLimit +} + +// Each pipe keeps its rolling tail before a deterministic aggregate rebalance. +// Writers always drain: exceeding the limit never changes the child's pipe behavior. +type processCapture struct { + mu sync.Mutex + limit int64 + tails [2][]byte + original [2]int64 +} +type processCaptureWriter struct { + capture *processCapture + stream int +} + +func (w processCaptureWriter) Write(p []byte) (int, error) { + c := w.capture + c.mu.Lock() + defer c.mu.Unlock() + c.original[w.stream] += int64(len(p)) + if int64(len(p)) >= c.limit { + c.tails[w.stream] = append(c.tails[w.stream][:0], p[len(p)-int(c.limit):]...) + } else { + old := c.tails[w.stream] + if int64(len(old)+len(p)) > c.limit { + old = old[len(old)+len(p)-int(c.limit):] + } + c.tails[w.stream] = append(old, p...) + } + return len(p), nil +} +func (c *processCapture) result(complete bool) ([]byte, []byte, OutputCaptureMetadata) { + c.mu.Lock() + defer c.mu.Unlock() + a, b := c.tails[0], c.tails[1] + if int64(len(a))+int64(len(b)) > c.limit { + x := min(int64(len(a)), c.limit/2) + y := min(int64(len(b)), c.limit-c.limit/2) + left := c.limit - x - y + extra := min(left, int64(len(a))-x) + x += extra + left -= extra + y += min(left, int64(len(b))-y) + a = a[len(a)-int(x):] + b = b[len(b)-int(y):] + } + m := OutputCaptureMetadata{Complete: complete, StdoutOriginalSizeBytes: c.original[0], StderrOriginalSizeBytes: c.original[1], + StdoutTruncated: c.original[0] > int64(len(a)), StderrTruncated: c.original[1] > int64(len(b))} + return bytes.Clone(a), bytes.Clone(b), m +} + +func runBoundedProcess(ctx context.Context, stdin []byte, limit int64, name string, args ...string) ([]byte, []byte, error) { + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Minute) + defer cancel() + } + cmd := exec.CommandContext(ctx, name, args...) + cmd.WaitDelay = processPipeWait + if len(stdin) > 0 { + cmd.Stdin = bytes.NewReader(stdin) + } + capture := &processCapture{limit: effectiveOutputLimit(limit)} + // Own the read pipes: exec.Wait can hide ErrWaitDelay behind a nonzero exit. + // Explicit EOF tracking is necessary before calling original byte counts complete. + stdoutRead, stdoutWrite, err := os.Pipe() + if err != nil { + return nil, nil, err + } + defer stdoutRead.Close() + defer stdoutWrite.Close() + stderrRead, stderrWrite, err := os.Pipe() + if err != nil { + return nil, nil, err + } + defer stderrRead.Close() + defer stderrWrite.Close() + cmd.Stdout = stdoutWrite + cmd.Stderr = stderrWrite + if err := cmd.Start(); err != nil { + return nil, nil, err + } + _ = stdoutWrite.Close() + _ = stderrWrite.Close() + drains := make(chan error, 2) + go func() { + _, err := io.Copy(processCaptureWriter{capture: capture, stream: 0}, stdoutRead) + drains <- err + }() + go func() { + _, err := io.Copy(processCaptureWriter{capture: capture, stream: 1}, stderrRead) + drains <- err + }() + err = cmd.Wait() + timer := time.NewTimer(processPipeWait) + defer timer.Stop() + complete := ctx.Err() == nil && !errors.Is(err, exec.ErrWaitDelay) + for pending := 2; pending > 0; { + select { + case drainErr := <-drains: + pending-- + if drainErr != nil { + complete = false + err = errors.Join(err, drainErr) + } + case <-timer.C: + complete = false + err = errors.Join(err, exec.ErrWaitDelay) + _ = stdoutRead.Close() + _ = stderrRead.Close() + } + } + out, stderr, metadata := capture.result(complete) + if !complete { + return nil, nil, &CaptureIncompleteError{Metadata: metadata, Cause: errors.Join(err, ctx.Err())} + } + if metadata.StdoutOriginalSizeBytes+metadata.StderrOriginalSizeBytes > capture.limit { + return out, stderr, &OutputLimitError{Metadata: metadata, Cause: err} + } + return out, stderr, err +} + +// Bound injected runner results too; an existing metadata error remains authoritative. +func boundRunnerOutput(out, stderr []byte, err error, limit int64) ([]byte, []byte, error) { + var prior OutputCaptureMetadataProvider + var original *OutputCaptureMetadata + if errors.As(err, &prior) { + m := prior.CaptureMetadata() + if !m.Complete { + return nil, nil, err + } + original = &m + } + capture := &processCapture{limit: effectiveOutputLimit(limit)} + _, _ = (processCaptureWriter{capture: capture, stream: 0}).Write(out) + _, _ = (processCaptureWriter{capture: capture, stream: 1}).Write(stderr) + out, stderr, metadata := capture.result(true) + if original != nil { + metadata.StdoutOriginalSizeBytes = original.StdoutOriginalSizeBytes + metadata.StderrOriginalSizeBytes = original.StderrOriginalSizeBytes + metadata.StdoutTruncated = metadata.StdoutOriginalSizeBytes > int64(len(out)) + metadata.StderrTruncated = metadata.StderrOriginalSizeBytes > int64(len(stderr)) + } + if metadata.StdoutOriginalSizeBytes+metadata.StderrOriginalSizeBytes > capture.limit { + return out, stderr, &OutputLimitError{Metadata: metadata, Cause: err} + } + return out, stderr, err +} diff --git a/container/readonly_workspace.go b/container/readonly_workspace.go new file mode 100644 index 0000000..6a4b24b --- /dev/null +++ b/container/readonly_workspace.go @@ -0,0 +1,133 @@ +package container + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +// ValidateReadOnlyPolicy is shared by native command and service consumers. It +// does not provision quotas or change engine settings. Unsupported evidence fails. +func (r DockerSandboxRuntime) ValidateReadOnlyPolicy(ctx context.Context, req SandboxRunRequest) error { + if !r.ReadOnlyWorkspace && !req.ReadOnlyWorkspace { + return nil + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if err := validateReadOnlyWorkspaceContext(ctx, req); err != nil { + return err + } + tool := firstNonEmpty(r.Tool, "docker") + if filepath.Base(tool) != "docker" { + return errors.New("read-only resource control evidence unsupported for runtime") + } + scope, err := validateRuntimeScope(tool, req.RuntimeScope) + if err != nil { + return err + } + run := func(args ...string) ([]byte, error) { + out, stderr, err := r.commandRunner().CombinedOutput(ctx, nil, tool, appendRuntimeScopeArgs(scope, args...)...) + out, _, err = boundRunnerOutput(out, stderr, err, 0) + return out, controlProcessError(err, "runtime preflight output capture failed") + } + out, err := run("info", "--format", "{{json .}}") + if err != nil { + return fmt.Errorf("read-only runtime controls unavailable: %w", err) + } + var info struct { + OSType string + MemoryLimit bool + SwapLimit bool + CPUCfsQuota bool + CPUCfsPeriod bool + PidsLimit bool + } + if json.Unmarshal(out, &info) != nil || info.OSType != "linux" || !info.MemoryLimit || !info.SwapLimit || !info.CPUCfsQuota || !info.CPUCfsPeriod || !info.PidsLimit { + return errors.New("required runtime memory/swap/CPU/PID controls unavailable") + } + out, err = run("image", "inspect", "--format", `{{json (index .Config "Volumes")}}`, req.Image) + if err != nil { + return fmt.Errorf("read-only local image inspection failed: %w", err) + } + var volumes map[string]json.RawMessage + if json.Unmarshal(out, &volumes) != nil || len(volumes) > 0 { + return errors.New("read-only image has unsupported writable volumes") + } + return nil +} + +func validateReadOnlyWorkspace(req SandboxRunRequest) error { + return validateReadOnlyWorkspaceContext(context.Background(), req) +} + +func validateReadOnlyWorkspaceContext(ctx context.Context, req SandboxRunRequest) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(req.Image) == "" || strings.HasPrefix(req.Image, "-") { + return errors.New("read-only local image required") + } + if req.Limits.WorkspaceBytes <= 0 || req.Limits.MemoryBytes <= 0 || req.Limits.CPUPercent <= 0 { + return errors.New("read-only workspace requires positive workspace, memory and CPU limits") + } + if req.WritableRootFS || req.RunAsRoot || len(req.ExtraTmpfs) > 0 || len(req.AddCapabilities) > 0 { + return errors.New("read-only workspace rejects writable sandbox overrides") + } + for _, mount := range req.DataMounts { + _, target, err := validateSandboxMount(mount) + if err != nil { + return err + } + target = path.Clean(filepath.ToSlash(target)) + if !mount.ReadOnly || target == "/" || target == "/workspace" || strings.HasPrefix(target, "/workspace/") { + return errors.New("read-only workspace rejects writable or shadowing data mount") + } + } + if strings.TrimSpace(req.Workspace) == "" { + return errors.New("read-only workspace required") + } + if err := rejectSymlinkPathComponents(req.Workspace, req.Workspace); err != nil { + return err + } + info, err := os.Lstat(req.Workspace) + if err != nil { + return err + } + if !info.IsDir() { + return errors.New("read-only workspace must be a directory") + } + var total int64 + return filepath.WalkDir(req.Workspace, func(_ string, entry fs.DirEntry, walkErr error) error { + if err := ctx.Err(); err != nil { + return err + } + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink != 0 { + return errors.New("read-only workspace rejects symlinks") + } + info, err := entry.Info() + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if !info.Mode().IsRegular() { + return errors.New("read-only workspace rejects special files") + } + if info.Size() > req.Limits.WorkspaceBytes-total { + return errors.New("read-only initial workspace exceeds byte limit") + } + total += info.Size() + return nil + }) +} diff --git a/container/runtime_probe.go b/container/runtime_probe.go index 12c99fe..d00fc4d 100644 --- a/container/runtime_probe.go +++ b/container/runtime_probe.go @@ -43,16 +43,8 @@ func (ExecRuntimeCommandRunner) LookPath(name string) (string, error) { } func (ExecRuntimeCommandRunner) Run(ctx context.Context, name string, args ...string) (RuntimeCommandResult, error) { - cmd := exec.CommandContext(ctx, name, args...) - stdout, err := cmd.Output() - result := RuntimeCommandResult{Stdout: stdout} - if err != nil { - if exitErr := (&exec.ExitError{}); errors.As(err, &exitErr) { - result.Stderr = exitErr.Stderr - } - return result, err - } - return result, nil + out, stderr, err := runBoundedProcess(ctx, nil, 0, name, args...) + return RuntimeCommandResult{Stdout: out, Stderr: stderr}, err } type RuntimeBackendProbeOptions struct { @@ -232,6 +224,8 @@ func ManagedRuntimeScopeName(parts ...string) string { } func (p RuntimeBackendProbe) Probe(ctx context.Context) core.RuntimeBackendReport { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() opts := p.Options.withDefaults() runner := p.Runner if runner == nil { @@ -285,6 +279,7 @@ func (p RuntimeBackendProbe) Probe(ctx context.Context) core.RuntimeBackendRepor return report } versionResult, err := runner.Run(ctx, opts.Command, opts.VersionArgs...) + versionResult.Stdout, versionResult.Stderr, err = boundRunnerOutput(versionResult.Stdout, versionResult.Stderr, err, 0) if err != nil { report.Status = core.RuntimeBackendDegraded report.Reason = redactRuntimeProbeDetail(fmt.Sprintf("runtime version probe failed: %v", err)) @@ -300,10 +295,32 @@ func (p RuntimeBackendProbe) Probe(ctx context.Context) core.RuntimeBackendRepor return report } defer cleanup() + name, nameErr := newSandboxContainerName() + if nameErr != nil { + report.Status = core.RuntimeBackendDegraded + report.Reason = "runtime probe ownership unavailable" + return report + } + owner, ownerErr := newSandboxOwner() + if ownerErr != nil { + report.Status = core.RuntimeBackendDegraded + report.Reason = "runtime probe ownership unavailable" + return report + } conformanceArgs := append([]string(nil), opts.RuntimeScopeArgs...) conformanceArgs = append(conformanceArgs, "run", "--rm", + "--pull", "never", + "--log-driver", "none", + "--name", name, + "--label", sandboxOwnerLabel+"="+owner, + "--memory", "67108864", + "--cpus", "0.25", + "--pids-limit", "64", + "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=16m", + "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", "--network", SandboxNetworkNone, "-v", workspace+":/workspace", "-w", "/workspace", @@ -313,9 +330,17 @@ func (p RuntimeBackendProbe) Probe(ctx context.Context) core.RuntimeBackendRepor ) conformanceArgs = append(conformanceArgs, opts.ConformanceCommand...) conformanceResult, err := runner.Run(ctx, opts.Command, conformanceArgs...) + conformanceResult.Stdout, conformanceResult.Stderr, err = boundRunnerOutput(conformanceResult.Stdout, conformanceResult.Stderr, err, 0) + failurePrefix := "runtime conformance probe failed" + if err != nil || ctx.Err() != nil { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second) + cleanupErr := removeOwnedSandbox(cleanupCtx, probeCleanupRunner{runner: runner}, opts.Command, ContainerRuntimeScope{Args: opts.RuntimeScopeArgs}, name, owner) + cleanupCancel() + err = errors.Join(err, ctx.Err(), cleanupErr) + } if err != nil { report.Status = core.RuntimeBackendDegraded - report.Reason = redactRuntimeProbeDetail(runtimeProbeFailureDetail("runtime conformance probe failed", err, conformanceResult.Stderr)) + report.Reason = redactRuntimeProbeDetail(runtimeProbeFailureDetail(failurePrefix, err, conformanceResult.Stderr)) return report } evidence, err := runtimeBackendConformanceEvidence(conformanceResult.Stdout, report) @@ -517,6 +542,9 @@ func redactRuntimeProbeDetail(value string) string { if value == "" { return "runtime probe failed" } + if runes := []rune(value); len(runes) > maxRuntimeProbeStderrRunes { + value = strings.TrimSpace(string(runes[:maxRuntimeProbeStderrRunes])) + " ... truncated" + } return value } diff --git a/container/runtime_probe_test.go b/container/runtime_probe_test.go index 8ee9f3d..e3d2590 100644 --- a/container/runtime_probe_test.go +++ b/container/runtime_probe_test.go @@ -575,7 +575,7 @@ func podmanProbeOptions(profiles []core.RuntimeProfile) RuntimeBackendProbeOptio } func defaultRuntimeConformanceCommandKey(tool string) string { - return tool + " run --rm --network none -v /tmp/wfcompute-runtime-probe-test:/workspace -w /workspace -e WFCOMPUTE_RUNTIME_PROBE=1 --read-only " + defaultConformanceImageRef + " " + defaultConformanceCommand + return tool + " run --rm --pull never --log-driver none --memory 67108864 --cpus 0.25 --pids-limit 64 --tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m --cap-drop ALL --security-opt no-new-privileges --network none -v /tmp/wfcompute-runtime-probe-test:/workspace -w /workspace -e WFCOMPUTE_RUNTIME_PROBE=1 --read-only " + defaultConformanceImageRef + " " + defaultConformanceCommand } func validRuntimeEvidenceJSON() string { @@ -654,7 +654,7 @@ func validManagedRuntimeBundleDescriptor() core.ManagedRuntimeBundleDescriptor { func managedRuntimeConformanceCommandKey(command string) string { digest := digestForRuntimeClaim("managed-containerd-linux-amd64", "distroless-static-v1", "scope") return command + " --namespace wfcompute-" + digest[7:23] + - " run --rm --network none -v /tmp/wfcompute-runtime-probe-test:/workspace -w /workspace -e WFCOMPUTE_RUNTIME_PROBE=1 --read-only " + + " run --rm --pull never --log-driver none --memory 67108864 --cpus 0.25 --pids-limit 64 --tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m --cap-drop ALL --security-opt no-new-privileges --network none -v /tmp/wfcompute-runtime-probe-test:/workspace -w /workspace -e WFCOMPUTE_RUNTIME_PROBE=1 --read-only " + defaultConformanceImageRef + " " + defaultConformanceCommand } @@ -698,7 +698,15 @@ func (r *fakeRuntimeCommandRunner) LookPath(string) (string, error) { } func (r *fakeRuntimeCommandRunner) Run(_ context.Context, name string, args ...string) (RuntimeCommandResult, error) { - key := strings.Join(append([]string{name}, args...), " ") + normalized := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + if (args[i] == "--name" || args[i] == "--label") && i+1 < len(args) { + i++ + continue + } + normalized = append(normalized, args[i]) + } + key := strings.Join(append([]string{name}, normalized...), " ") if r.calls != nil { *r.calls = append(*r.calls, key) } diff --git a/container/sandbox_ownership.go b/container/sandbox_ownership.go new file mode 100644 index 0000000..3939ff1 --- /dev/null +++ b/container/sandbox_ownership.go @@ -0,0 +1,101 @@ +package container + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "io" + "path/filepath" + "strings" +) + +const sandboxOwnerLabel = "com.gocodealone.workflow-compute.sandbox-owner" + +func newSandboxOwner() (string, error) { + var token [16]byte + if _, err := rand.Read(token[:]); err != nil { + return "", errors.New("sandbox ownership unavailable") + } + return hex.EncodeToString(token[:]), nil +} + +// Inspect only two fields, require exact attempt ownership, and remove by full +// immutable ID. Failed/absent/ambiguous inspection never authorizes deletion. +func removeOwnedSandbox(ctx context.Context, runner DockerCommandRunner, tool string, scope ContainerRuntimeScope, name, owner string) error { + if name == "" || owner == "" { + return errors.New("sandbox cleanup ownership unresolved") + } + validated, err := validateRuntimeScope(filepath.Base(tool), scope) + if err != nil { + return errors.New("sandbox cleanup scope unresolved") + } + format := `{"id":{{json .Id}},"owner":{{json (index .Config.Labels "` + sandboxOwnerLabel + `")}}}` + out, stderr, err := runner.CombinedOutput(ctx, nil, tool, appendRuntimeScopeArgs(validated, "inspect", "--type", "container", "--format", format, name)...) + out, _, err = boundRunnerOutput(out, stderr, err, 0) + if err != nil { + return errors.Join(errors.New("sandbox cleanup inspection unresolved"), controlProcessError(err, "sandbox cleanup inspection output unavailable")) + } + id, err := ownedSandboxID(out, owner) + if err != nil { + return err + } + _, _, err = runner.CombinedOutput(ctx, nil, tool, appendRuntimeScopeArgs(validated, "rm", "-f", id)...) + if err != nil { + return errors.Join(errors.New("owned sandbox removal failed"), controlProcessError(err, "sandbox cleanup removal output unavailable")) + } + return nil +} + +func ownedSandboxID(raw []byte, owner string) (string, error) { + invalid := errors.New("sandbox cleanup identity or owner unresolved") + decoder := json.NewDecoder(bytes.NewReader(raw)) + token, err := decoder.Token() + if err != nil || token != json.Delim('{') { + return "", invalid + } + fields := map[string]string{} + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return "", invalid + } + key, ok := token.(string) + if !ok || (key != "id" && key != "owner") { + return "", invalid + } + if _, duplicate := fields[key]; duplicate { + return "", invalid + } + var value string + if decoder.Decode(&value) != nil { + return "", invalid + } + fields[key] = value + } + if token, err = decoder.Token(); err != nil || token != json.Delim('}') { + return "", invalid + } + var extra any + if decoder.Decode(&extra) != io.EOF { + return "", invalid + } + id := fields["id"] + if len(fields) != 2 || fields["owner"] != owner || owner == "" || len(id) != 64 || strings.ToLower(id) != id { + return "", invalid + } + decoded, err := hex.DecodeString(id) + if err != nil || len(decoded) != 32 { + return "", invalid + } + return id, nil +} + +type probeCleanupRunner struct{ runner RuntimeCommandRunner } + +func (r probeCleanupRunner) CombinedOutput(ctx context.Context, _ []byte, tool string, args ...string) ([]byte, []byte, error) { + result, err := r.runner.Run(ctx, tool, args...) + return result.Stdout, result.Stderr, err +} diff --git a/container/sandbox_ownership_test.go b/container/sandbox_ownership_test.go new file mode 100644 index 0000000..9514f8d --- /dev/null +++ b/container/sandbox_ownership_test.go @@ -0,0 +1,103 @@ +package container + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +type ownershipRunner struct { + inspection []byte + inspectErr error + calls [][]string + fresh bool +} + +func (r *ownershipRunner) CombinedOutput(ctx context.Context, _ []byte, _ string, args ...string) ([]byte, []byte, error) { + r.calls = append(r.calls, append([]string(nil), args...)) + _, r.fresh = ctx.Deadline() + r.fresh = r.fresh && ctx.Err() == nil + if len(args) > 2 && args[2] == "inspect" { + return r.inspection, nil, r.inspectErr + } + return nil, nil, nil +} +func TestNativeOwnedCleanupRequiresExactClosedProofAndFullID(t *testing.T) { + id := strings.Repeat("a", 64) + owner := strings.Repeat("c", 32) + for _, row := range []struct { + name, payload string + valid bool + }{ + {"valid", `{"id":"` + id + `","owner":"` + owner + `"}`, true}, + {"foreign", `{"id":"` + id + `","owner":"foreign"}`, false}, + {"short_id", `{"id":"aaaa","owner":"` + owner + `"}`, false}, + {"duplicate", `{"id":"` + id + `","id":"` + id + `","owner":"` + owner + `"}`, false}, + {"unknown", `{"id":"` + id + `","owner":"` + owner + `","extra":true}`, false}, + {"missing", `{"id":"` + id + `"}`, false}, + {"null_owner", `{"id":"` + id + `","owner":null}`, false}, + {"extra_document", `{"id":"` + id + `","owner":"` + owner + `"}{}`, false}, + {"malformed", `[]`, false}, + {"absent", "", false}, + } { + t.Run(row.name, func(t *testing.T) { + r := &ownershipRunner{inspection: []byte(row.payload)} + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + err := removeOwnedSandbox(ctx, r, "/scoped/bin/nerdctl", ContainerRuntimeScope{Args: []string{"--namespace", "owned"}}, "attempt-name", owner) + if (err == nil) != row.valid || !r.fresh { + t.Fatalf("proof err=%v fresh=%t", err, r.fresh) + } + if !row.valid && len(r.calls) != 1 { + t.Fatalf("unverified proof allowed removal: %v", r.calls) + } + if row.valid && (len(r.calls) != 2 || strings.Join(r.calls[1], " ") != "--namespace owned rm -f "+id) { + t.Fatalf("removed by name/wrong scope: %v", r.calls) + } + }) + } + r := &ownershipRunner{inspectErr: errors.New("engine unavailable")} + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if err := removeOwnedSandbox(ctx, r, "nerdctl", ContainerRuntimeScope{Args: []string{"--namespace", "owned"}}, "attempt-name", owner); err == nil || len(r.calls) != 1 { + t.Fatalf("inspection failure inferred absence: err=%v calls=%v", err, r.calls) + } +} + +type ownedProbeCleanupRunner struct { + cancel context.CancelFunc + owner string + removed string + fresh bool +} + +func (*ownedProbeCleanupRunner) LookPath(name string) (string, error) { return name, nil } +func (r *ownedProbeCleanupRunner) Run(ctx context.Context, _ string, args ...string) (RuntimeCommandResult, error) { + switch args[0] { + case "version": + return RuntimeCommandResult{Stdout: []byte("1")}, nil + case "run": + r.owner = strings.TrimPrefix(dockerArgValue(args, "--label"), sandboxOwnerLabel+"=") + r.cancel() + return RuntimeCommandResult{}, context.Canceled + case "inspect": + _, r.fresh = ctx.Deadline() + r.fresh = r.fresh && ctx.Err() == nil + return RuntimeCommandResult{Stdout: []byte(`{"id":"` + strings.Repeat("a", 64) + `","owner":"` + r.owner + `"}`)}, nil + case "rm": + r.removed = args[len(args)-1] + return RuntimeCommandResult{}, nil + } + return RuntimeCommandResult{}, errors.New("unexpected fixture operation") +} +func TestNativeProbeCancellationRemovesOnlyVerifiedOwnedID(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + runner := &ownedProbeCleanupRunner{cancel: cancel} + _ = (RuntimeBackendProbe{Options: podmanProbeOptions(nil), Runner: runner}).Probe(ctx) + if len(runner.owner) != 32 || !runner.fresh || runner.removed != strings.Repeat("a", 64) { + t.Fatalf("owned probe cleanup unproved: owner=%q fresh=%t removed=%q", runner.owner, runner.fresh, runner.removed) + } +} diff --git a/go.mod b/go.mod index ffa14ca..1befd3b 100644 --- a/go.mod +++ b/go.mod @@ -135,10 +135,10 @@ require ( go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/crypto v0.51.0 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect diff --git a/go.sum b/go.sum index 234b731..49e579f 100644 --- a/go.sum +++ b/go.sum @@ -404,6 +404,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -430,6 +432,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=