Skip to content

Commit 2c34f8f

Browse files
steipetezerone0x
andcommitted
fix(gmail): respect --name for directory --out
Co-authored-by: zerone0x <hi@trine.dev>
1 parent c0dfe76 commit 2c34f8f

3 files changed

Lines changed: 164 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
- Forms: add `forms` command group (create/get forms, list/get responses).
77
- Apps Script: add `appscript` command group (create/get projects, fetch content, run deployed functions).
88

9+
### Fixed
10+
- Gmail: when `gmail attachment --out` points to a directory (or ends with a trailing slash), combine with `--name` and avoid false cache hits on directories. (#248) — thanks @zerone0x.
11+
912
## 0.10.0 - 2026-02-14
1013

1114
### Added

internal/cmd/gmail_attachment.go

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,47 @@ type GmailAttachmentCmd struct {
2020
MessageID string `arg:"" name:"messageId" help:"Message ID"`
2121
AttachmentID string `arg:"" name:"attachmentId" help:"Attachment ID"`
2222
Output OutputPathFlag `embed:""`
23-
Name string `name:"name" help:"Filename (only used when --out is empty)"`
23+
Name string `name:"name" help:"Filename (used with default dir or when --out is a directory)"`
2424
}
2525

2626
const defaultGmailAttachmentFilename = "attachment.bin"
2727

28+
func sanitizeAttachmentFilename(name string) string {
29+
filename := strings.TrimSpace(name)
30+
if filename == "" {
31+
filename = defaultGmailAttachmentFilename
32+
}
33+
34+
// Ensure users can't escape the output dir when `--out` is a directory.
35+
safeFilename := filepath.Base(filename)
36+
if safeFilename == "" || safeFilename == "." || safeFilename == ".." {
37+
safeFilename = defaultGmailAttachmentFilename
38+
}
39+
return safeFilename
40+
}
41+
42+
func resolveAttachmentOutPath(outPathFlag string, name string) (string, error) {
43+
outPath, err := config.ExpandPath(outPathFlag)
44+
if err != nil {
45+
return "", err
46+
}
47+
48+
// Directory intent:
49+
// - existing directory path
50+
// - or explicit trailing slash for a (possibly non-existent) directory
51+
isDir := strings.HasSuffix(outPathFlag, "/") || strings.HasSuffix(outPathFlag, "\\")
52+
if !isDir {
53+
if info, statErr := os.Stat(outPath); statErr == nil && info.IsDir() {
54+
isDir = true
55+
}
56+
}
57+
if isDir {
58+
outPath = filepath.Join(outPath, sanitizeAttachmentFilename(name))
59+
}
60+
61+
return outPath, nil
62+
}
63+
2864
func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error {
2965
u := ui.FromContext(ctx)
3066
messageID := normalizeGmailMessageID(c.MessageID)
@@ -40,21 +76,14 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error {
4076
if dirErr != nil {
4177
return dirErr
4278
}
43-
filename := strings.TrimSpace(c.Name)
44-
if filename == "" {
45-
filename = defaultGmailAttachmentFilename
46-
}
47-
safeFilename := filepath.Base(filename)
48-
if safeFilename == "" || safeFilename == "." || safeFilename == ".." {
49-
safeFilename = defaultGmailAttachmentFilename
50-
}
79+
safeFilename := sanitizeAttachmentFilename(c.Name)
5180
shortID := attachmentID
5281
if len(shortID) > 8 {
5382
shortID = shortID[:8]
5483
}
5584
destPath = filepath.Join(dir, fmt.Sprintf("%s_%s_%s", messageID, shortID, safeFilename))
5685
} else {
57-
outPath, err := config.ExpandPath(outPathFlag)
86+
outPath, err := resolveAttachmentOutPath(outPathFlag, c.Name)
5887
if err != nil {
5988
return err
6089
}
@@ -80,19 +109,12 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error {
80109
return err
81110
}
82111

83-
if strings.TrimSpace(c.Output.Path) == "" {
112+
if outPathFlag == "" {
84113
dir, dirErr := config.EnsureGmailAttachmentsDir()
85114
if dirErr != nil {
86115
return dirErr
87116
}
88-
filename := strings.TrimSpace(c.Name)
89-
if filename == "" {
90-
filename = defaultGmailAttachmentFilename
91-
}
92-
safeFilename := filepath.Base(filename)
93-
if safeFilename == "" || safeFilename == "." || safeFilename == ".." {
94-
safeFilename = defaultGmailAttachmentFilename
95-
}
117+
safeFilename := sanitizeAttachmentFilename(c.Name)
96118
shortID := attachmentID
97119
if len(shortID) > 8 {
98120
shortID = shortID[:8]
@@ -111,7 +133,7 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error {
111133
return nil
112134
}
113135

114-
outPath, err := config.ExpandPath(c.Output.Path)
136+
outPath, err := resolveAttachmentOutPath(outPathFlag, c.Name)
115137
if err != nil {
116138
return err
117139
}
@@ -141,11 +163,11 @@ func downloadAttachmentToPath(
141163
}
142164

143165
if expectedSize > 0 {
144-
if st, err := os.Stat(outPath); err == nil && st.Size() == expectedSize {
166+
if st, err := os.Stat(outPath); err == nil && st.Mode().IsRegular() && st.Size() == expectedSize {
145167
return outPath, true, st.Size(), nil
146168
}
147169
} else if expectedSize == -1 {
148-
if st, err := os.Stat(outPath); err == nil && st.Size() > 0 {
170+
if st, err := os.Stat(outPath); err == nil && st.Mode().IsRegular() && st.Size() > 0 {
149171
return outPath, true, st.Size(), nil
150172
}
151173
}

internal/cmd/gmail_attachment_more_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/base64"
66
"encoding/json"
7+
"errors"
78
"net/http"
89
"net/http/httptest"
910
"os"
@@ -13,6 +14,8 @@ import (
1314

1415
"google.golang.org/api/gmail/v1"
1516
"google.golang.org/api/option"
17+
18+
"github.com/steipete/gogcli/internal/outfmt"
1619
)
1720

1821
func TestDownloadAttachmentToPath_MissingOutPath(t *testing.T) {
@@ -94,6 +97,120 @@ func TestDownloadAttachmentToPath_EmptyData(t *testing.T) {
9497
}
9598
}
9699

100+
func TestDownloadAttachmentToPath_DirectoryNotCached(t *testing.T) {
101+
dir := t.TempDir()
102+
// A directory should not be treated as a cached attachment even though
103+
// os.Stat succeeds and Size() > 0 on directories.
104+
srv := httptestServerForAttachment(t, base64.RawURLEncoding.EncodeToString([]byte("data")))
105+
gsvc, err := gmail.NewService(context.Background(),
106+
option.WithoutAuthentication(),
107+
option.WithHTTPClient(srv.Client()),
108+
option.WithEndpoint(srv.URL+"/"),
109+
)
110+
if err != nil {
111+
t.Fatalf("NewService: %v", err)
112+
}
113+
114+
outPath := filepath.Join(dir, "subdir")
115+
if err := os.Mkdir(outPath, 0o700); err != nil {
116+
t.Fatalf("Mkdir: %v", err)
117+
}
118+
119+
// With expectedSize == -1, a directory should NOT be returned as cached.
120+
// Instead it should attempt download and fail trying to write to the dir path.
121+
_, _, _, dlErr := downloadAttachmentToPath(context.Background(), gsvc, "m1", "a1", outPath, -1)
122+
// We expect an error because outPath is a directory and WriteFile to a dir fails.
123+
if dlErr == nil {
124+
t.Fatalf("expected error when outPath is a directory")
125+
}
126+
}
127+
128+
func TestDownloadAttachmentToPath_DirectoryNotCachedBySize(t *testing.T) {
129+
dir := t.TempDir()
130+
// Even with a positive expectedSize matching the directory's metadata
131+
// size, a directory should not be treated as a cached file.
132+
srv := httptestServerForAttachment(t, base64.RawURLEncoding.EncodeToString([]byte("data")))
133+
gsvc, err := gmail.NewService(context.Background(),
134+
option.WithoutAuthentication(),
135+
option.WithHTTPClient(srv.Client()),
136+
option.WithEndpoint(srv.URL+"/"),
137+
)
138+
if err != nil {
139+
t.Fatalf("NewService: %v", err)
140+
}
141+
142+
// Pass the directory size as expectedSize - should NOT match because
143+
// the path is a directory, not a regular file.
144+
info, _ := os.Stat(dir)
145+
_, cached, _, dlErr := downloadAttachmentToPath(context.Background(), gsvc, "m1", "a1", dir, info.Size())
146+
// It should not return cached=true; it will try to download and fail
147+
// because WriteFile to a directory fails.
148+
if dlErr == nil && cached {
149+
t.Fatalf("directory should not be treated as cached file")
150+
}
151+
}
152+
153+
func TestGmailAttachmentCmd_DryRun_OutDir_UsesName(t *testing.T) {
154+
outDir := t.TempDir()
155+
ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: true})
156+
157+
out := captureStdout(t, func() {
158+
err := runKong(t, &GmailAttachmentCmd{}, []string{"m1", "a1", "--out", outDir, "--name", "invoice.pdf"}, ctx, &RootFlags{DryRun: true})
159+
var exitErr *ExitError
160+
if !errors.As(err, &exitErr) || exitErr.Code != 0 {
161+
t.Fatalf("expected exit code 0, got: %v", err)
162+
}
163+
})
164+
165+
var got map[string]any
166+
if err := json.Unmarshal([]byte(out), &got); err != nil {
167+
t.Fatalf("unmarshal: %v\noutput=%q", err, out)
168+
}
169+
req, ok := got["request"].(map[string]any)
170+
if !ok {
171+
t.Fatalf("expected request object, got=%T", got["request"])
172+
}
173+
path, ok := req["path"].(string)
174+
if !ok {
175+
t.Fatalf("expected request.path string, got=%T", req["path"])
176+
}
177+
want := filepath.Join(outDir, "invoice.pdf")
178+
if path != want {
179+
t.Fatalf("unexpected path: got=%q want=%q", path, want)
180+
}
181+
}
182+
183+
func TestGmailAttachmentCmd_DryRun_OutDirTrailingSlash_UsesNameEvenIfMissing(t *testing.T) {
184+
base := t.TempDir()
185+
outDir := filepath.Join(base, "newdir") + string(os.PathSeparator)
186+
ctx := outfmt.WithMode(context.Background(), outfmt.Mode{JSON: true})
187+
188+
out := captureStdout(t, func() {
189+
err := runKong(t, &GmailAttachmentCmd{}, []string{"m1", "a1", "--out", outDir, "--name", "invoice.pdf"}, ctx, &RootFlags{DryRun: true})
190+
var exitErr *ExitError
191+
if !errors.As(err, &exitErr) || exitErr.Code != 0 {
192+
t.Fatalf("expected exit code 0, got: %v", err)
193+
}
194+
})
195+
196+
var got map[string]any
197+
if err := json.Unmarshal([]byte(out), &got); err != nil {
198+
t.Fatalf("unmarshal: %v\noutput=%q", err, out)
199+
}
200+
req, ok := got["request"].(map[string]any)
201+
if !ok {
202+
t.Fatalf("expected request object, got=%T", got["request"])
203+
}
204+
path, ok := req["path"].(string)
205+
if !ok {
206+
t.Fatalf("expected request.path string, got=%T", req["path"])
207+
}
208+
want := filepath.Join(filepath.Join(base, "newdir"), "invoice.pdf")
209+
if path != want {
210+
t.Fatalf("unexpected path: got=%q want=%q", path, want)
211+
}
212+
}
213+
97214
func httptestServerForAttachment(t *testing.T, data string) *httptest.Server {
98215
t.Helper()
99216
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)