diff --git a/cmd/root/acp.go b/cmd/root/acp.go index 2dca0f7a1..0044fe1da 100644 --- a/cmd/root/acp.go +++ b/cmd/root/acp.go @@ -7,6 +7,7 @@ import ( "github.com/docker/docker-agent/pkg/acp" "github.com/docker/docker-agent/pkg/config" + pathx "github.com/docker/docker-agent/pkg/path" "github.com/docker/docker-agent/pkg/paths" "github.com/docker/docker-agent/pkg/telemetry" ) @@ -46,7 +47,7 @@ func (f *acpFlags) runACPCommand(cmd *cobra.Command, args []string) (commandErr agentFilename := args[0] // Expand tilde in session database path - sessionDB, err := expandTilde(f.sessionDB) + sessionDB, err := pathx.ExpandHomeDir(f.sessionDB) if err != nil { return err } diff --git a/cmd/root/alias.go b/cmd/root/alias.go index d226641e7..a9a7fbee6 100644 --- a/cmd/root/alias.go +++ b/cmd/root/alias.go @@ -1,7 +1,6 @@ package root import ( - "errors" "fmt" "maps" "path/filepath" @@ -13,7 +12,7 @@ import ( "github.com/docker/docker-agent/pkg/cli" "github.com/docker/docker-agent/pkg/config" - "github.com/docker/docker-agent/pkg/paths" + pathx "github.com/docker/docker-agent/pkg/path" "github.com/docker/docker-agent/pkg/telemetry" "github.com/docker/docker-agent/pkg/userconfig" ) @@ -121,14 +120,12 @@ func runAliasAddCommand(cmd *cobra.Command, args []string, flags *aliasAddFlags) name := args[0] agentPath := args[1] - // Load existing config cfg, err := userconfig.Load() if err != nil { return fmt.Errorf("failed to load config: %w", err) } - // Expand tilde in path if present - absAgentPath, err := expandTilde(agentPath) + absAgentPath, err := pathx.ExpandHomeDir(agentPath) if err != nil { return err } @@ -265,17 +262,3 @@ func runAliasRemoveCommand(cmd *cobra.Command, args []string) (commandErr error) out.Printf("Alias '%s' removed successfully\n", name) return nil } - -// expandTilde expands the tilde in a path to the user's home directory -func expandTilde(path string) (string, error) { - if !strings.HasPrefix(path, "~/") { - return path, nil - } - - homeDir := paths.GetHomeDir() - if homeDir == "" { - return "", errors.New("failed to get user home directory") - } - - return filepath.Join(homeDir, strings.TrimPrefix(path, "~/")), nil -} diff --git a/cmd/root/api.go b/cmd/root/api.go index 32eb6e87f..4c898d8b0 100644 --- a/cmd/root/api.go +++ b/cmd/root/api.go @@ -12,6 +12,7 @@ import ( "github.com/docker/docker-agent/pkg/cli" "github.com/docker/docker-agent/pkg/config" + pathx "github.com/docker/docker-agent/pkg/path" "github.com/docker/docker-agent/pkg/server" "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/telemetry" @@ -98,7 +99,7 @@ func (f *apiFlags) runAPICommand(cmd *cobra.Command, args []string) (commandErr slog.DebugContext(ctx, "Starting server", "agents", agentsPath, "addr", ln.Addr().String()) // Expand tilde in session database path - sessionDB, err := expandTilde(f.sessionDB) + sessionDB, err := pathx.ExpandHomeDir(f.sessionDB) if err != nil { return err } diff --git a/cmd/root/run.go b/cmd/root/run.go index 1d60d7f5c..b65ebf4fd 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -19,6 +19,7 @@ import ( "github.com/docker/docker-agent/pkg/app" "github.com/docker/docker-agent/pkg/cli" "github.com/docker/docker-agent/pkg/config" + pathx "github.com/docker/docker-agent/pkg/path" "github.com/docker/docker-agent/pkg/paths" "github.com/docker/docker-agent/pkg/permissions" "github.com/docker/docker-agent/pkg/profiling" @@ -344,8 +345,7 @@ func (f *runExecFlags) createLocalRuntimeAndSession(ctx context.Context, loadRes } agentName := agt.Name() - // Expand tilde in session database path - sessionDB, err := expandTilde(f.sessionDB) + sessionDB, err := pathx.ExpandHomeDir(f.sessionDB) if err != nil { return nil, nil, err } diff --git a/pkg/environment/env_files.go b/pkg/environment/env_files.go index 5c529b201..32d58fce0 100644 --- a/pkg/environment/env_files.go +++ b/pkg/environment/env_files.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/docker/docker-agent/pkg/path" - "github.com/docker/docker-agent/pkg/paths" ) type KeyValuePair struct { @@ -31,9 +30,16 @@ func AbsolutePaths(parentDir string, relOrAbsPaths []string) ([]string, error) { } func AbsolutePath(parentDir, relOrAbsPath string) (string, error) { - p, err := expandTildePath(relOrAbsPath) - if err != nil { - return "", err + p := relOrAbsPath + if strings.HasPrefix(p, "~") { + expanded, err := path.ExpandHomeDir(p) + if err != nil { + return "", err + } + if expanded == p && p != "~" { + return "", fmt.Errorf("unsupported tilde expansion format: %s", p) + } + p = expanded } // For absolute paths (including tilde-expanded ones), validate against directory traversal @@ -52,29 +58,6 @@ func AbsolutePath(parentDir, relOrAbsPath string) (string, error) { return validatedPath, nil } -// expandTildePath expands ~ in file paths to the user's home directory -func expandTildePath(p string) (string, error) { - if !strings.HasPrefix(p, "~") { - return p, nil - } - - homeDir := paths.GetHomeDir() - if homeDir == "" { - return "", errors.New("failed to get user home directory") - } - - if p == "~" { - return homeDir, nil - } - - if strings.HasPrefix(p, "~/") { - return filepath.Join(homeDir, p[2:]), nil - } - - // Handle ~username/ format - not commonly supported in this context - return "", fmt.Errorf("unsupported tilde expansion format: %s", p) -} - func ReadEnvFiles(absolutePaths []string) ([]KeyValuePair, error) { if len(absolutePaths) == 0 { return nil, nil diff --git a/pkg/path/expand.go b/pkg/path/expand.go index c6d4083c2..ce8f1403a 100644 --- a/pkg/path/expand.go +++ b/pkg/path/expand.go @@ -1,10 +1,6 @@ package path -import ( - "os" - "path/filepath" - "strings" -) +import "os" // ExpandPath expands shell-like patterns in a file path: // - ~ or ~/ at the start is replaced with the user's home directory @@ -17,14 +13,8 @@ func ExpandPath(p string) string { // Expand environment variables p = os.ExpandEnv(p) - // Expand tilde to home directory - if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, "~"+string(filepath.Separator)) { - if home, err := os.UserHomeDir(); err == nil { - if p == "~" { - return home - } - return filepath.Join(home, p[2:]) - } + if expanded, err := ExpandHomeDir(p); err == nil { + return expanded } return p diff --git a/pkg/path/home.go b/pkg/path/home.go new file mode 100644 index 000000000..4fefd6948 --- /dev/null +++ b/pkg/path/home.go @@ -0,0 +1,32 @@ +package path + +import ( + "errors" + "os" + "path/filepath" + "strings" +) + +// ExpandHomeDir expands a leading home-directory reference in a path. +// It expands "~", "~/...", and "~\\..." on Windows. Other tilde forms, +// such as "~user/...", are returned unchanged. +func ExpandHomeDir(path string) (string, error) { + if !isHomeDirPath(path) { + return path, nil + } + + homeDir, err := os.UserHomeDir() + if err != nil || homeDir == "" { + return "", errors.New("failed to get user home directory") + } + + if path == "~" { + return filepath.Clean(homeDir), nil + } + + return filepath.Join(homeDir, path[2:]), nil +} + +func isHomeDirPath(path string) bool { + return path == "~" || strings.HasPrefix(path, "~/") || (filepath.Separator == '\\' && strings.HasPrefix(path, `~\`)) +} diff --git a/cmd/root/tilde_test.go b/pkg/path/home_test.go similarity index 60% rename from cmd/root/tilde_test.go rename to pkg/path/home_test.go index 407d3d5f3..f31071dda 100644 --- a/cmd/root/tilde_test.go +++ b/pkg/path/home_test.go @@ -1,4 +1,4 @@ -package root +package path import ( "path/filepath" @@ -6,69 +6,70 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/docker/docker-agent/pkg/paths" ) -func TestExpandTilde(t *testing.T) { - t.Parallel() - - homeDir := paths.GetHomeDir() - require.NotEmpty(t, homeDir, "Home directory should be available for tests") +func TestExpandHomeDir(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) tests := []struct { name string input string expected string - wantErr bool }{ { - name: "expands_tilde_prefix", + name: "tilde only", + input: "~", + expected: homeDir, + }, + { + name: "expands tilde prefix", input: "~/session.db", expected: filepath.Join(homeDir, "session.db"), }, { - name: "expands_tilde_with_nested_path", + name: "expands tilde with nested path", input: "~/.cagent/session.db", expected: filepath.Join(homeDir, ".cagent", "session.db"), }, { - name: "expands_tilde_with_deep_path", + name: "expands tilde with deep path", input: "~/path/to/some/file.db", expected: filepath.Join(homeDir, "path", "to", "some", "file.db"), }, { - name: "absolute_path_unchanged", + name: "absolute path unchanged", input: "/absolute/path/session.db", expected: "/absolute/path/session.db", }, { - name: "relative_path_unchanged", + name: "relative path unchanged", input: "relative/path/session.db", expected: "relative/path/session.db", }, { - name: "tilde_in_middle_unchanged", + name: "tilde in middle unchanged", input: "/some/~/path/session.db", expected: "/some/~/path/session.db", }, { - name: "tilde_without_slash_unchanged", + name: "tilde without separator unchanged", input: "~something", expected: "~something", }, { - name: "just_tilde_slash_expands", + name: "tilde slash expands", input: "~/", expected: homeDir, }, { - name: "empty_string_unchanged", + name: "empty string unchanged", input: "", expected: "", }, { - name: "dot_path_unchanged", + name: "dot path unchanged", input: "./session.db", expected: "./session.db", }, @@ -76,14 +77,7 @@ func TestExpandTilde(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - result, err := expandTilde(tt.input) - - if tt.wantErr { - require.Error(t, err) - return - } + result, err := ExpandHomeDir(tt.input) require.NoError(t, err) assert.Equal(t, tt.expected, result) diff --git a/pkg/tools/builtin/filesystem/filesystem.go b/pkg/tools/builtin/filesystem/filesystem.go index e2c924cc7..2f9608a23 100644 --- a/pkg/tools/builtin/filesystem/filesystem.go +++ b/pkg/tools/builtin/filesystem/filesystem.go @@ -17,6 +17,7 @@ import ( "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/fsx" + pathx "github.com/docker/docker-agent/pkg/path" "github.com/docker/docker-agent/pkg/tools" ) @@ -500,7 +501,9 @@ func (t *Tool) executePostEditCommands(ctx context.Context, filePath string) err // [resolveAndCheckPath] when those checks are required (i.e. for any path // that originates from a tool argument). func (t *Tool) resolvePath(path string) string { - path = expandHomeDir(path) + if expandedPath, err := pathx.ExpandHomeDir(path); err == nil { + path = expandedPath + } if filepath.IsAbs(path) { return filepath.Clean(path) } @@ -508,26 +511,6 @@ func (t *Tool) resolvePath(path string) string { return filepath.Clean(filepath.Join(t.workingDir, path)) } -// expandHomeDir replaces a leading "~" or "~/" (or "~\\" on Windows) with -// the user's home directory. Returns the path unchanged when it isn't a -// home-relative reference or when the home directory cannot be determined; -// the caller will then resolve it as a literal name and likely surface a -// "not found" error, which is preferable to a hard failure inside path -// resolution. -func expandHomeDir(path string) string { - if path != "~" && !strings.HasPrefix(path, "~/") && !strings.HasPrefix(path, "~"+string(filepath.Separator)) { - return path - } - home, err := os.UserHomeDir() - if err != nil { - return path - } - if path == "~" { - return home - } - return filepath.Join(home, path[2:]) -} - // resolveAndCheckPath is the canonical entry point used by every filesystem // handler that operates on a user-supplied path. It resolves the path against // the working directory and validates the result against the allow- and diff --git a/pkg/tools/builtin/filesystem/filesystem_paths.go b/pkg/tools/builtin/filesystem/filesystem_paths.go index 50d8fddd8..7afa44e7a 100644 --- a/pkg/tools/builtin/filesystem/filesystem_paths.go +++ b/pkg/tools/builtin/filesystem/filesystem_paths.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" "strings" + + pathx "github.com/docker/docker-agent/pkg/path" ) // pathRootSet is a set of filesystem roots, used to back the filesystem @@ -131,20 +133,18 @@ func expandPathToken(workingDir, token string) (string, error) { return "", fmt.Errorf("path entry %q expands to an empty string (undefined environment variable?)", original) } + if expandedToken, err := pathx.ExpandHomeDir(token); err != nil { + return "", err + } else if expandedToken != token || token == "~" { + return expandedToken, nil + } + switch { case token == ".": if workingDir == "" { return os.Getwd() } return workingDir, nil - case token == "~": - return os.UserHomeDir() - case strings.HasPrefix(token, "~/") || strings.HasPrefix(token, "~"+string(filepath.Separator)): - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, token[2:]), nil case filepath.IsAbs(token): return token, nil default: