Skip to content

Commit 30aa506

Browse files
fix(paths): expand ~ in user-provided file paths
When users specify paths with ~ (e.g., --out ~/Downloads/file.pdf) and the path is quoted in the shell command, the tilde is not expanded by the shell. This caused files to be written to a literal ~/Downloads directory instead of the user's home directory. Add config.ExpandPath() function that expands ~ at the beginning of paths to the user's home directory. Apply this fix to all user-provided file paths across: - gmail attachment download (--out) - drive download/export (--out) - drive upload (localPath argument) - auth token export (--out) - auth credentials/import/keep (input paths) - gmail thread attachments (--out-dir) - gmail send/drafts (--attach) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 810e0c5 commit 30aa506

9 files changed

Lines changed: 132 additions & 5 deletions

File tree

internal/cmd/auth.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ func (c *AuthCredentialsCmd) Run(ctx context.Context) error {
6767
if inPath == "-" {
6868
b, err = io.ReadAll(os.Stdin)
6969
} else {
70+
inPath, err = config.ExpandPath(inPath)
71+
if err != nil {
72+
return err
73+
}
7074
b, err = os.ReadFile(inPath) //nolint:gosec // user-provided path
7175
}
7276
if err != nil {
@@ -184,6 +188,10 @@ func (c *AuthTokensExportCmd) Run(ctx context.Context) error {
184188
if outPath == "" {
185189
return usage("empty outPath")
186190
}
191+
outPath, err := config.ExpandPath(outPath)
192+
if err != nil {
193+
return err
194+
}
187195

188196
store, err := openSecretsStore()
189197
if err != nil {
@@ -259,6 +267,10 @@ func (c *AuthTokensImportCmd) Run(ctx context.Context) error {
259267
if inPath == "-" {
260268
b, err = io.ReadAll(os.Stdin)
261269
} else {
270+
inPath, err = config.ExpandPath(inPath)
271+
if err != nil {
272+
return err
273+
}
262274
b, err = os.ReadFile(inPath) //nolint:gosec // user-provided path
263275
}
264276
if err != nil {
@@ -608,6 +620,10 @@ func (c *AuthKeepCmd) Run(ctx context.Context) error {
608620
if keyPath == "" {
609621
return usage("empty key path")
610622
}
623+
keyPath, err := config.ExpandPath(keyPath)
624+
if err != nil {
625+
return err
626+
}
611627

612628
data, err := os.ReadFile(keyPath) //nolint:gosec // user-provided path
613629
if err != nil {

internal/cmd/drive.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"google.golang.org/api/drive/v3"
1414
gapi "google.golang.org/api/googleapi"
1515

16+
"github.com/steipete/gogcli/internal/config"
1617
"github.com/steipete/gogcli/internal/googleapi"
1718
"github.com/steipete/gogcli/internal/outfmt"
1819
"github.com/steipete/gogcli/internal/ui"
@@ -330,6 +331,10 @@ func (c *DriveUploadCmd) Run(ctx context.Context, flags *RootFlags) error {
330331
if localPath == "" {
331332
return usage("empty localPath")
332333
}
334+
localPath, err = config.ExpandPath(localPath)
335+
if err != nil {
336+
return err
337+
}
333338

334339
f, err := os.Open(localPath) //nolint:gosec // user-provided path
335340
if err != nil {

internal/cmd/drive_download_helpers.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ func resolveDriveDownloadDestPath(meta *drive.File, outPathFlag string) (string,
2424
}
2525

2626
destPath := strings.TrimSpace(outPathFlag)
27+
// Expand ~ to home directory (shell doesn't expand when path is quoted).
28+
if destPath != "" {
29+
expanded, err := config.ExpandPath(destPath)
30+
if err != nil {
31+
return "", err
32+
}
33+
destPath = expanded
34+
}
2735
// Sanitize filename to prevent path traversal.
2836
safeName := filepath.Base(meta.Name)
2937
if safeName == "" || safeName == "." || safeName == ".." {

internal/cmd/gmail_attachment.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error {
7171
return nil
7272
}
7373

74-
path, cached, bytes, err := downloadAttachmentToPath(ctx, svc, messageID, attachmentID, c.Output.Path, -1)
74+
outPath, err := config.ExpandPath(c.Output.Path)
75+
if err != nil {
76+
return err
77+
}
78+
path, cached, bytes, err := downloadAttachmentToPath(ctx, svc, messageID, attachmentID, outPath, -1)
7579
if err != nil {
7680
return err
7781
}

internal/cmd/gmail_drafts.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,11 @@ func buildDraftMessage(ctx context.Context, svc *gmail.Service, account string,
331331

332332
atts := make([]mailAttachment, 0, len(input.Attach))
333333
for _, p := range input.Attach {
334-
atts = append(atts, mailAttachment{Path: p})
334+
expanded, expandErr := config.ExpandPath(p)
335+
if expandErr != nil {
336+
return nil, "", expandErr
337+
}
338+
atts = append(atts, mailAttachment{Path: expanded})
335339
}
336340

337341
raw, err := buildRFC822(mailOptions{

internal/cmd/gmail_send.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"google.golang.org/api/gmail/v1"
1212

13+
"github.com/steipete/gogcli/internal/config"
1314
"github.com/steipete/gogcli/internal/outfmt"
1415
"github.com/steipete/gogcli/internal/tracking"
1516
"github.com/steipete/gogcli/internal/ui"
@@ -147,7 +148,11 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error {
147148

148149
atts := make([]mailAttachment, 0, len(c.Attach))
149150
for _, p := range c.Attach {
150-
atts = append(atts, mailAttachment{Path: p})
151+
expanded, expandErr := config.ExpandPath(p)
152+
if expandErr != nil {
153+
return expandErr
154+
}
155+
atts = append(atts, mailAttachment{Path: expanded})
151156
}
152157

153158
var trackingCfg *tracking.Config

internal/cmd/gmail_thread.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"google.golang.org/api/gmail/v1"
1616

17+
"github.com/steipete/gogcli/internal/config"
1718
"github.com/steipete/gogcli/internal/outfmt"
1819
"github.com/steipete/gogcli/internal/ui"
1920
)
@@ -81,7 +82,11 @@ func (c *GmailThreadGetCmd) Run(ctx context.Context, flags *RootFlags) error {
8182
// Default: current directory, not gogcli config dir.
8283
attachDir = "."
8384
} else {
84-
attachDir = filepath.Clean(c.OutputDir.Dir)
85+
expanded, err := config.ExpandPath(c.OutputDir.Dir)
86+
if err != nil {
87+
return err
88+
}
89+
attachDir = filepath.Clean(expanded)
8590
}
8691
}
8792

@@ -292,7 +297,11 @@ func (c *GmailThreadAttachmentsCmd) Run(ctx context.Context, flags *RootFlags) e
292297
if strings.TrimSpace(c.OutputDir.Dir) == "" {
293298
attachDir = "."
294299
} else {
295-
attachDir = filepath.Clean(c.OutputDir.Dir)
300+
expanded, err := config.ExpandPath(c.OutputDir.Dir)
301+
if err != nil {
302+
return err
303+
}
304+
attachDir = filepath.Clean(expanded)
296305
}
297306
}
298307

internal/config/paths.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,23 @@ func EnsureGmailWatchDir() (string, error) {
153153

154154
return dir, nil
155155
}
156+
157+
// ExpandPath expands ~ at the beginning of a path to the user's home directory.
158+
// This is needed because ~ is a shell feature and is not expanded when paths
159+
// are quoted (e.g., --out "~/Downloads/file.pdf").
160+
func ExpandPath(path string) (string, error) {
161+
if path == "" {
162+
return "", nil
163+
}
164+
if path == "~" {
165+
return os.UserHomeDir()
166+
}
167+
if strings.HasPrefix(path, "~/") {
168+
home, err := os.UserHomeDir()
169+
if err != nil {
170+
return "", fmt.Errorf("expand home dir: %w", err)
171+
}
172+
return filepath.Join(home, path[2:]), nil
173+
}
174+
return path, nil
175+
}

internal/config/paths_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,62 @@ func TestPaths_CreateDirs(t *testing.T) {
7171
}
7272
}
7373

74+
func TestExpandPath(t *testing.T) {
75+
home := t.TempDir()
76+
t.Setenv("HOME", home)
77+
78+
tests := []struct {
79+
name string
80+
input string
81+
want string
82+
wantErr bool
83+
}{
84+
{
85+
name: "empty path",
86+
input: "",
87+
want: "",
88+
},
89+
{
90+
name: "tilde only",
91+
input: "~",
92+
want: home,
93+
},
94+
{
95+
name: "tilde with subpath",
96+
input: "~/Downloads/file.txt",
97+
want: filepath.Join(home, "Downloads/file.txt"),
98+
},
99+
{
100+
name: "absolute path unchanged",
101+
input: "/usr/local/bin",
102+
want: "/usr/local/bin",
103+
},
104+
{
105+
name: "relative path unchanged",
106+
input: "relative/path/file.txt",
107+
want: "relative/path/file.txt",
108+
},
109+
{
110+
name: "tilde in middle unchanged",
111+
input: "/some/~/path",
112+
want: "/some/~/path",
113+
},
114+
}
115+
116+
for _, tt := range tests {
117+
t.Run(tt.name, func(t *testing.T) {
118+
got, err := ExpandPath(tt.input)
119+
if (err != nil) != tt.wantErr {
120+
t.Errorf("ExpandPath() error = %v, wantErr %v", err, tt.wantErr)
121+
return
122+
}
123+
if got != tt.want {
124+
t.Errorf("ExpandPath() = %q, want %q", got, tt.want)
125+
}
126+
})
127+
}
128+
}
129+
74130
func TestKeepServiceAccountPath_SafeFilename(t *testing.T) {
75131
home := t.TempDir()
76132
t.Setenv("HOME", home)

0 commit comments

Comments
 (0)