feat(v0.5): add workspace.ResolveLaunch helper
Resolves a relative launch_dir into the absolute directory the child process should cd into. Returns an error for absolute paths and paths that escape the workspace via .. traversal. Returns (wsDir, warning, nil) on os.IsNotExist or target-is-a-file so the caller prints a warning and falls back. Non-IsNotExist stat errors (permission, invalid name, I/O) propagate as real errors rather than being masked as warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 : (<absolute path>, "", 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user