diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index 30bf74afe..d63aa6014 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -16,6 +16,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/pathutil" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -38,13 +39,27 @@ func agentCmd(message, sessionKey, model string, debug bool, return fmt.Errorf("error loading config: %w", err) } - // Apply workspace-local config overrides from config-dir + // Snapshot workspace_root from the base config before any overlay can touch it. + workspaceRoot := cfg.Agents.Defaults.WorkspaceRoot + + // Validate and resolve --workspace and --config-dir against workspace_root. + var resolveErr error + workspace, configDir, resolveErr = validateWorkspacePaths(workspaceRoot, workspace, configDir) + if resolveErr != nil { + return resolveErr + } + + // Apply workspace-local config overrides from config-dir. + // mergeAgentDefaults will re-validate any workspace field from the overlay + // against the snapshotted workspace_root. if configDir != "" { wc, wcErr := config.LoadWorkspaceConfig(configDir) if wcErr != nil { return fmt.Errorf("error loading workspace config from %s: %w", configDir, wcErr) } - cfg.MergeWorkspaceConfig(wc) + if mergeErr := cfg.MergeWorkspaceConfig(wc); mergeErr != nil { + return fmt.Errorf("error merging workspace config from %s: %w", configDir, mergeErr) + } } // CLI flags win over workspace config @@ -52,7 +67,7 @@ func agentCmd(message, sessionKey, model string, debug bool, cfg.Agents.Defaults.ModelName = model } - // Workspace override + // Workspace override (already validated above) if workspace != "" { cfg.Agents.Defaults.Workspace = workspace os.MkdirAll(workspace, 0o755) @@ -162,6 +177,27 @@ func applySkillsFilter(cfg *config.Config, skills []string) { } } +// validateWorkspacePaths resolves --workspace and --config-dir against workspace_root. +// Both must be relative subdirectories of workspace_root. Returns the resolved +// absolute paths or an error if validation fails. +func validateWorkspacePaths(workspaceRoot, workspace, configDir string) (string, string, error) { + if workspace != "" { + resolved, err := pathutil.ResolveWorkspacePath(workspaceRoot, workspace) + if err != nil { + return "", "", fmt.Errorf("invalid --workspace: %w", err) + } + workspace = resolved + } + if configDir != "" { + resolved, err := pathutil.ResolveWorkspacePath(workspaceRoot, configDir) + if err != nil { + return "", "", fmt.Errorf("invalid --config-dir: %w", err) + } + configDir = resolved + } + return workspace, configDir, nil +} + // copyBootstrapFiles copies recognized bootstrap files (AGENTS.md, IDENTITY.md, // SOUL.md, USER.md) from srcDir into the workspace directory. func copyBootstrapFiles(srcDir, workspace string) { diff --git a/cmd/picoclaw/internal/agent/helpers_test.go b/cmd/picoclaw/internal/agent/helpers_test.go new file mode 100644 index 000000000..ed43cad71 --- /dev/null +++ b/cmd/picoclaw/internal/agent/helpers_test.go @@ -0,0 +1,140 @@ +package agent + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mustAbs(t *testing.T, path string) string { + t.Helper() + abs, err := filepath.Abs(path) + require.NoError(t, err) + return abs +} + +func TestValidateWorkspacePaths(t *testing.T) { + root := filepath.Join(t.TempDir(), "workspaces") + + absRoot, err := filepath.Abs(root) + require.NoError(t, err) + + tests := []struct { + name string + root string + workspace string + configDir string + wantWS string // expected resolved workspace; empty if error expected + wantCD string // expected resolved configDir; empty if error expected + wantErr string // substring of expected error; empty means success + }{ + { + name: "valid workspace subdirectory", + root: root, + workspace: "tenant1", + wantWS: filepath.Join(absRoot, "tenant1"), + }, + { + name: "valid config-dir subdirectory", + root: root, + configDir: "tenant1/config", + wantCD: filepath.Join(absRoot, "tenant1", "config"), + }, + { + name: "both valid", + root: root, + workspace: "tenant1", + configDir: "tenant1/config", + wantWS: filepath.Join(absRoot, "tenant1"), + wantCD: filepath.Join(absRoot, "tenant1", "config"), + }, + { + name: "empty flags are fine", + root: root, + workspace: "", + configDir: "", + }, + + // --- workspace traversal --- + { + name: "workspace traversal rejected", + root: root, + workspace: "../escape", + wantErr: "invalid --workspace", + }, + { + name: "workspace bare dotdot rejected", + root: root, + workspace: "..", + wantErr: "invalid --workspace", + }, + { + name: "workspace mid-path traversal rejected", + root: root, + workspace: "a/../../../etc", + wantErr: "invalid --workspace", + }, + + // --- config-dir traversal --- + { + name: "config-dir traversal rejected", + root: root, + configDir: "../escape", + wantErr: "invalid --config-dir", + }, + { + name: "config-dir absolute path rejected", + root: root, + configDir: "/etc/passwd", + wantErr: "invalid --config-dir", + }, + + // --- no workspace_root configured (backward compat: falls back to filepath.Abs) --- + { + name: "workspace without root uses Abs fallback", + root: "", + workspace: "anything", + wantWS: mustAbs(t, "anything"), + }, + { + name: "config-dir without root uses Abs fallback", + root: "", + configDir: "anything", + wantCD: mustAbs(t, "anything"), + }, + { + name: "workspace traversal without root still rejected", + root: "", + workspace: "../escape", + wantErr: "invalid --workspace", + }, + + // --- workspace resolves to root itself --- + { + name: "workspace dot rejected", + root: root, + workspace: ".", + wantErr: "invalid --workspace", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ws, cd, err := validateWorkspacePaths(tt.root, tt.workspace, tt.configDir) + + if tt.wantErr != "" { + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), tt.wantErr), + "error %q should contain %q", err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantWS, ws) + assert.Equal(t, tt.wantCD, cd) + }) + } +} diff --git a/config/config.example.json b/config/config.example.json index 8ca7c6438..3505844b2 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -1,6 +1,7 @@ { "agents": { "defaults": { + "workspace_root": "/data/workspaces", "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true, "model_name": "gpt4", diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 6b0ec7f91..88783c69e 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -21,6 +21,7 @@ import ( "unicode/utf8" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/pathutil" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/config" @@ -637,6 +638,28 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) workspaceOverride := msg.Metadata["workspace_override"] configDir := msg.Metadata["config_dir"] + // Defense-in-depth: validate workspace/configDir overrides against workspace_root + // even though the originating channel should have already validated them. + wsRoot := al.cfg.Agents.Defaults.WorkspaceRoot + if workspaceOverride != "" { + resolved, err := pathutil.ResolveWorkspacePath(wsRoot, workspaceOverride) + if err != nil { + logger.WarnCF("agent", "Rejecting workspace_override from metadata", + map[string]any{"workspace_override": workspaceOverride, "error": err.Error()}) + return "", fmt.Errorf("invalid workspace_override in metadata: %w", err) + } + workspaceOverride = resolved + } + if configDir != "" { + resolved, err := pathutil.ResolveWorkspacePath(wsRoot, configDir) + if err != nil { + logger.WarnCF("agent", "Rejecting config_dir from metadata", + map[string]any{"config_dir": configDir, "error": err.Error()}) + return "", fmt.Errorf("invalid config_dir in metadata: %w", err) + } + configDir = resolved + } + var allowedTools, allowedSkills []string if v := msg.Metadata["allowed_tools"]; v != "" { for _, t := range strings.Split(v, ",") { @@ -812,7 +835,9 @@ func (al *AgentLoop) runAgentLoop( map[string]any{"path": configSource, "error": err.Error()}) } else if wc != nil { tmpCfg := al.cfg.Clone() - tmpCfg.MergeWorkspaceConfig(wc) + if err := tmpCfg.MergeWorkspaceConfig(wc); err != nil { + return "", fmt.Errorf("workspace config overlay rejected: %w", err) + } if tmpCfg.Agents.Defaults.GetModelName() == "" { tmpCfg.Agents.Defaults.ModelName = agent.Model } diff --git a/pkg/channels/magicform/init.go b/pkg/channels/magicform/init.go index 36a39bf9f..ebfae004d 100644 --- a/pkg/channels/magicform/init.go +++ b/pkg/channels/magicform/init.go @@ -8,6 +8,6 @@ import ( func init() { channels.RegisterFactory("magicform", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewMagicFormChannel(cfg.Channels.MagicForm, b) + return NewMagicFormChannel(cfg.Channels.MagicForm, cfg.Agents.Defaults.WorkspaceRoot, b) }) } diff --git a/pkg/channels/magicform/magicform.go b/pkg/channels/magicform/magicform.go index aed1f0613..1409620ca 100644 --- a/pkg/channels/magicform/magicform.go +++ b/pkg/channels/magicform/magicform.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "path/filepath" "strings" "sync" "time" @@ -17,6 +16,7 @@ import ( "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/pathutil" ) // WebhookPayload is the inbound payload from MagicForm. @@ -54,15 +54,20 @@ type requestContext struct { // MagicFormChannel implements the MagicForm channel plugin. type MagicFormChannel struct { *channels.BaseChannel - config config.MagicFormConfig - httpClient *http.Client - requests sync.Map // chatID → *requestContext - ctx context.Context - cancel context.CancelFunc + config config.MagicFormConfig + workspaceRoot string // effective root: channel-level fallback to global + httpClient *http.Client + requests sync.Map // chatID → *requestContext + ctx context.Context + cancel context.CancelFunc } // NewMagicFormChannel creates a new MagicForm channel. -func NewMagicFormChannel(cfg config.MagicFormConfig, msgBus *bus.MessageBus) (*MagicFormChannel, error) { +// globalWorkspaceRoot is the agents.defaults.workspace_root from the base config. +// The channel uses its own config.WorkspaceRoot if set, otherwise falls back to +// globalWorkspaceRoot. If neither is configured, the constructor returns an error +// because workspace overrides cannot be validated without a root boundary. +func NewMagicFormChannel(cfg config.MagicFormConfig, globalWorkspaceRoot string, msgBus *bus.MessageBus) (*MagicFormChannel, error) { base := channels.NewBaseChannel( "magicform", cfg, @@ -70,11 +75,21 @@ func NewMagicFormChannel(cfg config.MagicFormConfig, msgBus *bus.MessageBus) (*M cfg.AllowFrom, ) + effectiveRoot := cfg.WorkspaceRoot + if effectiveRoot == "" { + effectiveRoot = globalWorkspaceRoot + } + if effectiveRoot == "" { + return nil, fmt.Errorf("magicform channel requires workspace_root to be configured " + + "(set channels.magicform.workspace_root or agents.defaults.workspace_root)") + } + ctx, cancel := context.WithCancel(context.Background()) ch := &MagicFormChannel{ - BaseChannel: base, - config: cfg, + BaseChannel: base, + config: cfg, + workspaceRoot: effectiveRoot, httpClient: &http.Client{ Timeout: 30 * time.Second, }, @@ -191,31 +206,11 @@ func (c *MagicFormChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { go c.processWebhook(c.ctx, payload) } -// resolveWorkspace validates and resolves the workspace path. -// If workspace_root is configured, the workspace must be a relative path that -// resolves under the root. If workspace_root is not configured, workspace is -// rejected (no arbitrary path writes allowed). +// resolveWorkspace validates and resolves the workspace path using the shared +// pathutil.ResolveWorkspacePath boundary check. The effective workspace root is +// determined at construction time (channel-level config with global fallback). func (c *MagicFormChannel) resolveWorkspace(workspace string) (string, error) { - root := c.config.WorkspaceRoot - if root == "" { - return "", fmt.Errorf("workspace_root not configured; workspace overrides are not allowed") - } - - absRoot, err := filepath.Abs(root) - if err != nil { - return "", fmt.Errorf("invalid workspace_root: %w", err) - } - - // Join the root with the provided workspace (which may be relative) - resolved := filepath.Join(absRoot, workspace) - resolved = filepath.Clean(resolved) - - // Ensure the resolved path is under the root (prevents ../../../etc traversal) - if !strings.HasPrefix(resolved, absRoot+string(filepath.Separator)) && resolved != absRoot { - return "", fmt.Errorf("workspace path escapes workspace_root") - } - - return resolved, nil + return pathutil.ResolveWorkspacePath(c.workspaceRoot, workspace) } // verifyToken checks the Authorization Bearer token using constant-time comparison. diff --git a/pkg/config/config.go b/pkg/config/config.go index 916a1dd98..79ed9a012 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,6 +10,7 @@ import ( "github.com/caarlos0/env/v11" "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/pathutil" ) // rrCounter is a global counter for round-robin load balancing across models. @@ -181,6 +182,7 @@ type RoutingConfig struct { } type AgentDefaults struct { + WorkspaceRoot string `json:"workspace_root,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE_ROOT"` Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` @@ -986,9 +988,10 @@ func (c *Config) Clone() *Config { // MergeWorkspaceConfig overlays allowed fields from a workspace config onto this config. // Fields NOT honored (infrastructure-level): Gateway, Heartbeat, Devices, Providers. -func (c *Config) MergeWorkspaceConfig(wc *WorkspaceConfig) { +// Returns an error if the overlay contains a workspace path that escapes workspace_root. +func (c *Config) MergeWorkspaceConfig(wc *WorkspaceConfig) error { if wc == nil || wc.Config == nil { - return + return nil } src := wc.Config @@ -998,7 +1001,9 @@ func (c *Config) MergeWorkspaceConfig(wc *WorkspaceConfig) { } // agents.defaults: merge non-zero fields - mergeAgentDefaults(&c.Agents.Defaults, &src.Agents.Defaults) + if err := mergeAgentDefaults(&c.Agents.Defaults, &src.Agents.Defaults); err != nil { + return err + } // agents.list: replace if workspace has entries if len(src.Agents.List) > 0 { @@ -1017,11 +1022,20 @@ func (c *Config) MergeWorkspaceConfig(wc *WorkspaceConfig) { // session: merge non-zero fields (prevents cross-tenant identity leakage) mergeSessionConfig(&c.Session, &src.Session) + return nil } // mergeAgentDefaults copies non-zero fields from src into dst. -func mergeAgentDefaults(dst, src *AgentDefaults) { +// WorkspaceRoot is intentionally NOT copied — it is a security boundary +// set by the base config and must not be overridden by workspace overlays. +// Returns an error if a workspace overlay attempts to escape the root boundary. +func mergeAgentDefaults(dst, src *AgentDefaults) error { if src.Workspace != "" { + if dst.WorkspaceRoot != "" { + if _, err := pathutil.ResolveWorkspacePath(dst.WorkspaceRoot, src.Workspace); err != nil { + return fmt.Errorf("workspace config overlay rejected: %w", err) + } + } dst.Workspace = src.Workspace } if src.RestrictToWorkspace { @@ -1066,6 +1080,7 @@ func mergeAgentDefaults(dst, src *AgentDefaults) { if src.MaxMediaSize > 0 { dst.MaxMediaSize = src.MaxMediaSize } + return nil } // mergeSessionConfig copies non-zero fields from src into dst. diff --git a/pkg/pathutil/resolve.go b/pkg/pathutil/resolve.go new file mode 100644 index 000000000..552bee109 --- /dev/null +++ b/pkg/pathutil/resolve.go @@ -0,0 +1,110 @@ +package pathutil + +import ( + "fmt" + "path/filepath" + "strings" +) + +// ResolveWorkspacePath resolves path against root and returns an absolute path +// that is guaranteed to be a proper subdirectory of root. +// +// When root is set: +// - Empty string and bare "." are rejected (must pick a subdirectory). +// - Absolute paths are rejected (must use relative subdirectory names). +// - ".." traversal is rejected before any path joining occurs. +// - A valid relative path is joined to root and returned as an absolute path. +// - A post-join boundary check confirms the result stays within root. +// +// When root is empty, the function falls back to filepath.Abs(path) for +// backward compatibility with callers that don't have a boundary configured. +func ResolveWorkspacePath(root, path string) (string, error) { + if root == "" { + // No boundary configured — resolve raw path as absolute. + if path == "" { + return "", fmt.Errorf("workspace path is empty") + } + if containsTraversal(path) { + return "", fmt.Errorf("workspace path contains directory traversal") + } + return filepath.Abs(path) + } + + // With a root boundary, path must be a non-empty relative subdirectory. + if path == "" { + return "", fmt.Errorf("workspace path must be a subdirectory of workspace_root, not root itself") + } + + if isAbsoluteOrRooted(path) { + return "", fmt.Errorf("workspace path must be relative, got absolute path") + } + + // Check traversal before Clean — Clean("foo/..") normalises to "." and + // would produce a misleading "not root itself" error instead. + if containsTraversal(path) { + return "", fmt.Errorf("workspace path contains directory traversal") + } + + if filepath.Clean(path) == "." { + return "", fmt.Errorf("workspace path must be a subdirectory of workspace_root, not root itself") + } + + absRoot, err := filepath.Abs(root) + if err != nil { + return "", fmt.Errorf("invalid workspace_root: %w", err) + } + + resolved := filepath.Clean(filepath.Join(absRoot, path)) + + // Belt-and-suspenders: confirm the resolved path is a proper subdirectory + // of root even after Clean normalisation. + if !strings.HasPrefix(resolved, absRoot+string(filepath.Separator)) { + return "", fmt.Errorf("workspace path escapes workspace_root") + } + + return resolved, nil +} + +// containsTraversal detects ".." traversal in all four patterns: +// +// bare ".." — the path IS ".." +// starts with "../" — e.g. ../etc +// ends with "/.." — e.g. foo/.. +// contains "/../" — e.g. foo/../bar +// +// Both forward-slash and backslash variants are checked so that this +// works correctly on Windows where either separator may appear. +func containsTraversal(path string) bool { + if path == ".." { + return true + } + + for _, sep := range []string{"/", `\`} { + if strings.HasPrefix(path, ".."+sep) { + return true + } + if strings.HasSuffix(path, sep+"..") { + return true + } + if strings.Contains(path, sep+".."+sep) { + return true + } + } + + return false +} + +// isAbsoluteOrRooted returns true if the path is absolute (e.g. C:\foo on +// Windows, /foo on Unix) or rooted with a leading separator. On Windows, +// filepath.IsAbs returns false for Unix-style "/foo" paths, but we still +// reject them since they are not relative subdirectory names. +func isAbsoluteOrRooted(path string) bool { + if filepath.IsAbs(path) { + return true + } + // Catch Unix-style rooted paths on Windows (e.g. "/etc/passwd"). + if len(path) > 0 && (path[0] == '/' || path[0] == '\\') { + return true + } + return false +} diff --git a/pkg/pathutil/resolve_test.go b/pkg/pathutil/resolve_test.go new file mode 100644 index 000000000..610b2cf71 --- /dev/null +++ b/pkg/pathutil/resolve_test.go @@ -0,0 +1,227 @@ +package pathutil + +import ( + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResolveWorkspacePath_WithRoot(t *testing.T) { + root := filepath.Join(t.TempDir(), "workspaces") + + tests := []struct { + name string + path string + wantErr string // substring of expected error; empty means success + wantRel string // expected relative suffix under root (checked on success) + }{ + // --- valid paths --- + { + name: "relative subdirectory", + path: "tenant1", + wantRel: "tenant1", + }, + { + name: "nested subdirectory", + path: "tenant1/project/sub", + wantRel: filepath.Join("tenant1", "project", "sub"), + }, + + // --- empty / dot --- + { + name: "empty path rejected", + path: "", + wantErr: "subdirectory of workspace_root", + }, + { + name: "dot path rejected", + path: ".", + wantErr: "subdirectory of workspace_root", + }, + + // --- traversal: bare .. --- + { + name: "bare dotdot rejected", + path: "..", + wantErr: "traversal", + }, + + // --- traversal: starts with ../ --- + { + name: "starts with dotdot slash", + path: "../escape", + wantErr: "traversal", + }, + { + name: "starts with dotdot backslash", + path: `..\\escape`, + wantErr: "traversal", + }, + + // --- traversal: ends with /.. --- + { + name: "ends with slash dotdot", + path: "foo/..", + wantErr: "traversal", + }, + { + name: "ends with backslash dotdot", + path: `foo\..`, + wantErr: "traversal", + }, + + // --- traversal: contains /../ --- + { + name: "contains slash dotdot slash", + path: "a/../escape", + wantErr: "traversal", + }, + { + name: "contains backslash dotdot backslash", + path: `a\..\escape`, + wantErr: "traversal", + }, + { + name: "deep traversal", + path: "a/b/../../escape", + wantErr: "traversal", + }, + + // --- absolute paths --- + { + name: "unix absolute path rejected", + path: "/etc/passwd", + wantErr: "must be relative", + }, + } + + if runtime.GOOS == "windows" { + tests = append(tests, struct { + name string + path string + wantErr string + wantRel string + }{ + name: "windows absolute path rejected", + path: `C:\Windows\System32`, + wantErr: "must be relative", + }) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveWorkspacePath(root, tt.path) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil (result: %s)", tt.wantErr, got) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + absRoot, absErr := filepath.Abs(root) + require.NoError(t, absErr) + want := filepath.Join(absRoot, tt.wantRel) + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } + }) + } +} + +func TestResolveWorkspacePath_NoRoot(t *testing.T) { + tests := []struct { + name string + path string + wantErr string + }{ + { + name: "relative path resolved via Abs", + path: "some/dir", + }, + { + name: "empty path rejected", + path: "", + wantErr: "empty", + }, + { + name: "traversal rejected even without root", + path: "../escape", + wantErr: "traversal", + }, + { + name: "bare dotdot rejected even without root", + path: "..", + wantErr: "traversal", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveWorkspacePath("", tt.path) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil (result: %s)", tt.wantErr, got) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Without root, should return filepath.Abs(path) + want, absErr := filepath.Abs(tt.path) + require.NoError(t, absErr) + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } + }) + } +} + +func TestContainsTraversal(t *testing.T) { + tests := []struct { + path string + want bool + }{ + // positive cases + {"..", true}, + {"../foo", true}, + {`..\\foo`, true}, + {"foo/..", true}, + {`foo\..`, true}, + {"foo/../bar", true}, + {`foo\..\bar`, true}, + {"a/b/../../c", true}, + + // negative cases — dotdot as part of a name is fine + {"..hidden", false}, + {"foo..bar", false}, + {"tenant1", false}, + {"a/b/c", false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := containsTraversal(tt.path) + if got != tt.want { + t.Fatalf("containsTraversal(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +}