diff --git a/internal/workspace/launchdir.go b/internal/workspace/launchdir.go new file mode 100644 index 0000000..9c732c0 --- /dev/null +++ b/internal/workspace/launchdir.go @@ -0,0 +1,67 @@ +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// ResolveLaunch converts a relative launch_dir from task.yaml into the +// absolute directory the child process should cd into. +// +// Return contract: +// - launchDir == "" : (wsDir, "", nil). No subdir configured. +// - launchDir is absolute : ("", "", error). Security violation. +// - launchDir escapes wsDir : ("", "", error). Security violation. +// - stat returns os.IsNotExist: (wsDir, warningMsg, nil). Caller prints warning. +// - target exists but isn't a directory: +// (wsDir, warningMsg, nil). Caller prints warning. +// - other stat error (permission, ENOTDIR, I/O): +// ("", "", error). Caller must surface. +// - target is a directory : (, "", nil). +// +// The caller is responsible for printing the warning and for exiting on +// error (security violations and non-NotExist stat errors must not proceed). +func ResolveLaunch(wsDir, launchDir string) (string, string, error) { + if launchDir == "" { + return wsDir, "", nil + } + if filepath.IsAbs(launchDir) { + return "", "", fmt.Errorf("launch_dir must be relative, got absolute path %q", launchDir) + } + + absWs, err := filepath.Abs(wsDir) + if err != nil { + return "", "", fmt.Errorf("resolving workspace root: %w", err) + } + joined := filepath.Join(absWs, launchDir) + absJoined, err := filepath.Abs(joined) + if err != nil { + return "", "", fmt.Errorf("resolving launch_dir: %w", err) + } + + rel, err := filepath.Rel(absWs, absJoined) + if err != nil { + return "", "", fmt.Errorf("computing launch_dir relative path: %w", err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", "", fmt.Errorf("launch_dir %q escapes workspace root", launchDir) + } + + info, err := os.Stat(absJoined) + if err != nil { + if os.IsNotExist(err) { + warning := fmt.Sprintf("[ctask] Warning: configured launch directory %q not found, using workspace root", launchDir) + return wsDir, warning, nil + } + // Permission, I/O, ENOTDIR (intermediate component is a file), etc. + // Don't mask these as a warning — the caller should decide. + return "", "", fmt.Errorf("cannot access launch directory %q: %w", launchDir, err) + } + if !info.IsDir() { + warning := fmt.Sprintf("[ctask] Warning: configured launch directory %q is not a directory, using workspace root", launchDir) + return wsDir, warning, nil + } + return absJoined, "", nil +} diff --git a/internal/workspace/launchdir_test.go b/internal/workspace/launchdir_test.go new file mode 100644 index 0000000..ea74427 --- /dev/null +++ b/internal/workspace/launchdir_test.go @@ -0,0 +1,170 @@ +package workspace + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveLaunchEmptyReturnsWsDir(t *testing.T) { + ws := t.TempDir() + abs, warn, err := ResolveLaunch(ws, "") + if err != nil { + t.Fatalf("err: %v", err) + } + if warn != "" { + t.Errorf("expected no warning, got %q", warn) + } + if abs != ws { + t.Errorf("abs: got %q, want %q", abs, ws) + } +} + +func TestResolveLaunchRelativeDirExists(t *testing.T) { + ws := t.TempDir() + os.MkdirAll(filepath.Join(ws, "sub"), 0755) + + abs, warn, err := ResolveLaunch(ws, "sub") + if err != nil { + t.Fatalf("err: %v", err) + } + if warn != "" { + t.Errorf("expected no warning, got %q", warn) + } + want := filepath.Join(ws, "sub") + absWant, _ := filepath.Abs(want) + absGot, _ := filepath.Abs(abs) + if absGot != absWant { + t.Errorf("abs: got %q, want %q", absGot, absWant) + } +} + +func TestResolveLaunchNestedRelativeDirExists(t *testing.T) { + ws := t.TempDir() + os.MkdirAll(filepath.Join(ws, "a", "b"), 0755) + + abs, warn, err := ResolveLaunch(ws, filepath.Join("a", "b")) + if err != nil { + t.Fatalf("err: %v", err) + } + if warn != "" { + t.Errorf("expected no warning, got %q", warn) + } + absWant, _ := filepath.Abs(filepath.Join(ws, "a", "b")) + absGot, _ := filepath.Abs(abs) + if absGot != absWant { + t.Errorf("abs: got %q, want %q", absGot, absWant) + } +} + +func TestResolveLaunchMissingFallsBackWithWarning(t *testing.T) { + // os.IsNotExist case → soft fallback with warning. + ws := t.TempDir() + + abs, warn, err := ResolveLaunch(ws, "no-such-dir") + if err != nil { + t.Fatalf("err: %v", err) + } + if warn == "" { + t.Error("expected warning for missing dir") + } + if !strings.Contains(warn, "no-such-dir") { + t.Errorf("warning should mention the missing name: %q", warn) + } + if !strings.Contains(warn, "not found") { + t.Errorf("warning should say 'not found' for missing: %q", warn) + } + if abs != ws { + t.Errorf("abs should fall back to wsDir: got %q, want %q", abs, ws) + } +} + +func TestResolveLaunchIsFileFallsBackWithWarning(t *testing.T) { + // Stat succeeds but target is a file, not a directory → soft fallback. + ws := t.TempDir() + os.WriteFile(filepath.Join(ws, "not-a-dir"), []byte("x"), 0644) + + abs, warn, err := ResolveLaunch(ws, "not-a-dir") + if err != nil { + t.Fatalf("err: %v", err) + } + if warn == "" { + t.Error("expected warning for is-a-file") + } + if !strings.Contains(warn, "not a directory") { + t.Errorf("warning should say 'not a directory' for file target: %q", warn) + } + if abs != ws { + t.Errorf("abs should fall back to wsDir: got %q, want %q", abs, ws) + } +} + +func TestResolveLaunchOtherStatErrorReturnsError(t *testing.T) { + // A stat error that's not os.IsNotExist (permission, ENOTDIR, invalid + // name, I/O, etc.) must propagate as a real error rather than being + // masked by a warning fallback. Portably trigger a non-NotExist stat + // error by embedding a NUL byte in the relative path — both POSIX and + // Windows reject this with an "invalid argument" / "invalid name" class + // of error, which is distinct from os.IsNotExist. + ws := t.TempDir() + + abs, warn, err := ResolveLaunch(ws, "bad\x00name") + if err == nil { + t.Fatal("expected error when stat fails with a non-NotExist error, got silent fallback") + } + // The amendment's core guarantee: non-NotExist errors must NOT be masked + // by returning (wsDir, warning, nil). Whether the error surfaces from + // filepath.Abs or os.Stat is an implementation detail — both are real + // errors that a caller can act on. + if abs != "" { + t.Errorf("abs path should be empty on error, got %q", abs) + } + if warn != "" { + t.Errorf("warning should be empty on error, got %q", warn) + } +} + +func TestResolveLaunchAbsoluteIsError(t *testing.T) { + ws := t.TempDir() + other := t.TempDir() + + _, _, err := ResolveLaunch(ws, other) + if err == nil { + t.Fatal("expected error for absolute launch_dir") + } + if !strings.Contains(err.Error(), "absolute") { + t.Errorf("error should mention 'absolute': %v", err) + } +} + +func TestResolveLaunchDotDotEscapeIsError(t *testing.T) { + ws := t.TempDir() + + _, _, err := ResolveLaunch(ws, filepath.Join("..", "escape")) + if err == nil { + t.Fatal("expected error for .. escape") + } + if !strings.Contains(err.Error(), "escape") { + t.Errorf("error should mention 'escape': %v", err) + } +} + +func TestResolveLaunchInnerDotDotIsOkIfStaysInside(t *testing.T) { + // "sub/../other" normalizes to "other" which stays inside ws — accept it. + ws := t.TempDir() + os.MkdirAll(filepath.Join(ws, "other"), 0755) + + abs, warn, err := ResolveLaunch(ws, filepath.Join("sub", "..", "other")) + if err != nil { + t.Fatalf("err: %v", err) + } + if warn != "" { + t.Errorf("expected no warning for well-formed inner traversal: %q", warn) + } + absWant, _ := filepath.Abs(filepath.Join(ws, "other")) + absGot, _ := filepath.Abs(abs) + if absGot != absWant { + t.Errorf("abs: got %q, want %q", absGot, absWant) + } +}