diff --git a/cli/tui/docker.go b/cli/tui/docker.go index 843b8005..9fc6e5dd 100644 --- a/cli/tui/docker.go +++ b/cli/tui/docker.go @@ -43,6 +43,9 @@ const ( dockerNodeModeArg = "--run-node=true" dockerNodeLogMount = "/spt-node-logs" dockerNodeLogResultsDirName = ".node-home" + itemFileCreateMode = 0o600 + itemFileMountMode = 0o444 + itemFileStagingDirPattern = "spt-items-*" nodeLogDirPattern = "spt-node-logs-*" ) @@ -80,6 +83,7 @@ type DockerManager struct { diagnosticsRec *diagnosticsRecord diagnosticsStopDone bool fileMounts []scenario.FileMount + itemFileStagingDir string } func skipImagePull(image string) bool { @@ -111,10 +115,74 @@ func (dm *DockerManager) SetFileMounts(mounts []scenario.FileMount) error { if dm.remote != nil { return dm.remote.SetFileMounts(mounts) } - dm.fileMounts = append([]scenario.FileMount(nil), mounts...) + dm.cleanupItemFileStagingDir() + if len(mounts) == 0 { + return nil + } + + stagingDir, err := os.MkdirTemp("", itemFileStagingDirPattern) + if err != nil { + return fmt.Errorf("create local item file staging directory: %w", err) + } + stagingRoot, err := os.OpenRoot(stagingDir) + if err != nil { + _ = os.RemoveAll(stagingDir) + return fmt.Errorf("open local item file staging directory: %w", err) + } + defer func() { _ = stagingRoot.Close() }() + stagedMounts := make([]scenario.FileMount, 0, len(mounts)) + for _, mount := range mounts { + stagedName := filepath.Base(mount.ContainerPath) + stagedPath := filepath.Join(stagingDir, stagedName) + if err := stageItemFile(mount.HostPath, stagingRoot, stagedName); err != nil { + _ = os.RemoveAll(stagingDir) + return fmt.Errorf("stage item file %q: %w", mount.HostPath, err) + } + stagedMounts = append(stagedMounts, scenario.FileMount{ + HostPath: stagedPath, + ContainerPath: mount.ContainerPath, + }) + } + dm.fileMounts = stagedMounts + dm.itemFileStagingDir = stagingDir return nil } +func stageItemFile(sourcePath string, stagingRoot *os.Root, stagedName string) error { + source, err := os.Open(sourcePath) // #nosec G304 -- user-selected item input file + if err != nil { + return fmt.Errorf("open source: %w", err) + } + defer func() { _ = source.Close() }() + + staged, err := stagingRoot.OpenFile(stagedName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, itemFileCreateMode) + if err != nil { + return fmt.Errorf("create staged copy: %w", err) + } + if _, err = io.Copy(staged, source); err != nil { + _ = staged.Close() + return fmt.Errorf("copy staged file: %w", err) + } + if err = staged.Close(); err != nil { + return fmt.Errorf("close staged file: %w", err) + } + if err = stagingRoot.Chmod(stagedName, itemFileMountMode); err != nil { + return fmt.Errorf("make staged file container-readable: %w", err) + } + return nil +} + +func (dm *DockerManager) cleanupItemFileStagingDir() { + dm.fileMounts = nil + if dm.itemFileStagingDir == "" { + return + } + if err := os.RemoveAll(dm.itemFileStagingDir); err != nil { + logging.LogWarn("docker", "failed to remove item file staging directory", "path", dm.itemFileStagingDir, "error", err.Error()) + } + dm.itemFileStagingDir = "" +} + func (dm *DockerManager) itemFileBindSpecs() []string { if len(dm.fileMounts) == 0 { return nil @@ -1094,6 +1162,7 @@ func (dm *DockerManager) Cleanup() error { } defer dm.cleanupNodeLogDir() defer dm.cleanupDiagnosticsDir() + defer dm.cleanupItemFileStagingDir() if dm.containerID == "" { return nil } @@ -1178,6 +1247,7 @@ func isNoSuchContainer(err error) bool { // Close cleans up resources func (dm *DockerManager) Close() { + dm.cleanupItemFileStagingDir() if dm.cancel != nil { dm.cancel() } diff --git a/cli/tui/docker_client_shim_test.go b/cli/tui/docker_client_shim_test.go index 87bc57d4..9be004c7 100644 --- a/cli/tui/docker_client_shim_test.go +++ b/cli/tui/docker_client_shim_test.go @@ -109,10 +109,86 @@ func TestEnsureImageAvailable_DevImagePresent(t *testing.T) { } } -func TestStartContainerInNodeModeMountsItemFiles(t *testing.T) { +func TestSetFileMountsStagesContainerReadableCopy(t *testing.T) { + sourcePath := filepath.Join(t.TempDir(), "items.csv") + content := []byte("item-1\nitem-2\n") + if err := os.WriteFile(sourcePath, content, 0o600); err != nil { + t.Fatalf("write source items file: %v", err) + } + + dm := &DockerManager{client: &fakeDockerClient{}, ctx: context.Background()} + mount := scenario.FileMount{HostPath: sourcePath, ContainerPath: "/spt-input/items/read-items.csv"} + if err := dm.SetFileMounts([]scenario.FileMount{mount}); err != nil { + t.Fatalf("SetFileMounts() error = %v", err) + } + if len(dm.fileMounts) != 1 { + t.Fatalf("staged mounts = %d, want 1", len(dm.fileMounts)) + } + stagedPath := dm.fileMounts[0].HostPath + if stagedPath == sourcePath { + t.Fatal("SetFileMounts() retained restrictive source path instead of staging a copy") + } + if dm.fileMounts[0].ContainerPath != mount.ContainerPath { + t.Fatalf("container path = %q, want %q", dm.fileMounts[0].ContainerPath, mount.ContainerPath) + } + got, err := os.ReadFile(stagedPath) + if err != nil { + t.Fatalf("read staged items file: %v", err) + } + if !bytes.Equal(got, content) { + t.Fatalf("staged content = %q, want %q", got, content) + } + stagedInfo, err := os.Stat(stagedPath) + if err != nil { + t.Fatalf("stat staged items file: %v", err) + } + if gotMode := stagedInfo.Mode().Perm(); gotMode != itemFileMountMode { + t.Fatalf("staged mode = %04o, want %04o", gotMode, itemFileMountMode) + } + sourceInfo, err := os.Stat(sourcePath) + if err != nil { + t.Fatalf("stat source items file: %v", err) + } + if gotMode := sourceInfo.Mode().Perm(); gotMode != 0o600 { + t.Fatalf("source mode changed to %04o, want 0600", gotMode) + } + + stagingDir := dm.itemFileStagingDir + if err := dm.Cleanup(); err != nil { + t.Fatalf("Cleanup() error = %v", err) + } + if _, err := os.Stat(stagingDir); !os.IsNotExist(err) { + t.Fatalf("staging directory still exists after cleanup: %v", err) + } +} + +func TestSetFileMountsFailureDoesNotRetainPartialState(t *testing.T) { + sourcePath := filepath.Join(t.TempDir(), "items.csv") + if err := os.WriteFile(sourcePath, []byte("item-1\n"), 0o600); err != nil { + t.Fatalf("write source items file: %v", err) + } + dm := &DockerManager{client: &fakeDockerClient{}, ctx: context.Background()} + mounts := []scenario.FileMount{ + {HostPath: sourcePath, ContainerPath: "/spt-input/items/read-items.csv"}, + {HostPath: filepath.Join(t.TempDir(), "missing.csv"), ContainerPath: "/spt-input/items/mixed-read-items.csv"}, + } + if err := dm.SetFileMounts(mounts); err == nil { + t.Fatal("SetFileMounts() error = nil, want staging failure") + } + if dm.itemFileStagingDir != "" || len(dm.fileMounts) != 0 { + t.Fatalf("partial staging state retained: dir=%q mounts=%v", dm.itemFileStagingDir, dm.fileMounts) + } +} + +func TestStartContainerInNodeModeMountsStagedItemFile(t *testing.T) { + sourcePath := filepath.Join(t.TempDir(), "items.csv") + if err := os.WriteFile(sourcePath, []byte("item-1\n"), 0o600); err != nil { + t.Fatalf("write source items file: %v", err) + } f := &fakeDockerClient{} dm := &DockerManager{client: f, ctx: context.Background()} - mounts := []scenario.FileMount{{HostPath: "/host/items.csv", ContainerPath: "/spt-input/items/read-items.csv"}} + t.Cleanup(dm.Close) + mounts := []scenario.FileMount{{HostPath: sourcePath, ContainerPath: "/spt-input/items/read-items.csv"}} if err := dm.SetFileMounts(mounts); err != nil { t.Fatalf("SetFileMounts() error = %v", err) } @@ -122,10 +198,29 @@ func TestStartContainerInNodeModeMountsItemFiles(t *testing.T) { if f.createHostConfig == nil { t.Fatal("ContainerCreate host config was nil") } - want := "/host/items.csv:/spt-input/items/read-items.csv:ro" + want := dm.fileMounts[0].HostPath + ":/spt-input/items/read-items.csv:ro" if !containsString(f.createHostConfig.Binds, want) { t.Fatalf("host binds = %v, want %q", f.createHostConfig.Binds, want) } + if strings.Contains(want, sourcePath+":") { + t.Fatalf("container bind used restrictive source directly: %q", want) + } +} + +func TestCloseCleansItemFileStagingDir(t *testing.T) { + sourcePath := filepath.Join(t.TempDir(), "items.csv") + if err := os.WriteFile(sourcePath, []byte("item-1\n"), 0o600); err != nil { + t.Fatalf("write source items file: %v", err) + } + dm := &DockerManager{client: &fakeDockerClient{}, ctx: context.Background()} + if err := dm.SetFileMounts([]scenario.FileMount{{HostPath: sourcePath, ContainerPath: "/spt-input/items/read-items.csv"}}); err != nil { + t.Fatalf("SetFileMounts() error = %v", err) + } + stagingDir := dm.itemFileStagingDir + dm.Close() + if _, err := os.Stat(stagingDir); !os.IsNotExist(err) { + t.Fatalf("staging directory still exists after close: %v", err) + } } func TestEnsureImageAvailable_DevImageMissing(t *testing.T) {