@@ -43,7 +43,7 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error {
4343 return usage ("messageId/attachmentId required" )
4444 }
4545
46- destPath , err := resolveAttachmentOutputPath (messageID , attachmentID , c .Output .Path , c .Name , false )
46+ dest , err := resolveAttachmentDest (messageID , attachmentID , c .Output .Path , c .Name , false )
4747 if err != nil {
4848 return err
4949 }
@@ -52,7 +52,7 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error {
5252 if dryRunErr := dryRunExit (ctx , flags , "gmail.attachment.download" , map [string ]any {
5353 "message_id" : messageID ,
5454 "attachment_id" : attachmentID ,
55- "path" : destPath ,
55+ "path" : dest . Path ,
5656 }); dryRunErr != nil {
5757 return dryRunErr
5858 }
@@ -67,69 +67,83 @@ func (c *GmailAttachmentCmd) Run(ctx context.Context, flags *RootFlags) error {
6767 return err
6868 }
6969
70- destPath , err = resolveAttachmentOutputPath (messageID , attachmentID , c .Output .Path , c .Name , true )
70+ dest , err = resolveAttachmentDest (messageID , attachmentID , c .Output .Path , c .Name , true )
7171 if err != nil {
7272 return err
7373 }
74+ if dest .EnsureDefaultDir {
75+ // Ensure the config dir exists (so permissions are correct) before we write under it.
76+ if _ , ensureErr := config .EnsureGmailAttachmentsDir (); ensureErr != nil {
77+ return ensureErr
78+ }
79+ }
7480
7581 expectedSize := int64 (- 1 )
76- if st , statErr := os .Stat (destPath ); statErr == nil && st .Mode ().IsRegular () {
82+ if st , statErr := os .Stat (dest . Path ); statErr == nil && st .Mode ().IsRegular () {
7783 // Only hit messages.get when we might have a cache-hit candidate.
7884 expectedSize = lookupAttachmentSizeEstimate (ctx , svc , messageID , attachmentID )
7985 }
80- path , cached , bytes , err := downloadAttachmentToPath (ctx , svc , messageID , attachmentID , destPath , expectedSize )
86+ path , cached , bytes , err := downloadAttachmentToPath (ctx , svc , messageID , attachmentID , dest . Path , expectedSize )
8187 if err != nil {
8288 return err
8389 }
8490 return printAttachmentDownloadResult (ctx , u , path , cached , bytes )
8591}
8692
87- func resolveAttachmentOutputPath (messageID , attachmentID , outPathFlag , name string , ensureDefaultDir bool ) (string , error ) {
93+ type attachmentDest struct {
94+ Path string
95+ EnsureDefaultDir bool
96+ }
97+
98+ func resolveAttachmentDest (messageID , attachmentID , outPathFlag , name string , allowEnsureDefaultDir bool ) (attachmentDest , error ) {
8899 shortID := attachmentID
89100 if len (shortID ) > 8 {
90101 shortID = shortID [:8 ]
91102 }
92103 safeFilename := sanitizeAttachmentFilename (name , defaultGmailAttachmentFilename )
93104
94105 if strings .TrimSpace (outPathFlag ) == "" {
95- var dir string
96- var err error
97- if ensureDefaultDir {
98- dir , err = config .EnsureGmailAttachmentsDir ()
99- } else {
100- dir , err = config .GmailAttachmentsDir ()
101- }
106+ dir , err := config .GmailAttachmentsDir ()
102107 if err != nil {
103- return "" , err
108+ return attachmentDest {} , err
104109 }
105- return filepath .Join (dir , fmt .Sprintf ("%s_%s_%s" , messageID , shortID , safeFilename )), nil
110+ return attachmentDest {
111+ Path : filepath .Join (dir , fmt .Sprintf ("%s_%s_%s" , messageID , shortID , safeFilename )),
112+ EnsureDefaultDir : allowEnsureDefaultDir ,
113+ }, nil
106114 }
107115
108116 outPath , err := config .ExpandPath (outPathFlag )
109117 if err != nil {
110- return "" , err
118+ return attachmentDest {} , err
111119 }
112120
121+ isDir := isDirIntent (outPathFlag , outPath )
122+ if ! isDir {
123+ // file path; keep as-is
124+ return attachmentDest {Path : outPath }, nil
125+ }
126+
127+ filename := safeFilename
128+ if strings .TrimSpace (name ) == "" {
129+ filename = fmt .Sprintf ("%s_%s_attachment.bin" , messageID , shortID )
130+ }
131+
132+ return attachmentDest {Path : filepath .Join (outPath , filename )}, nil
133+ }
134+
135+ func isDirIntent (outPathFlag , expandedOutPath string ) bool {
113136 // Directory intent:
114137 // - existing directory path
115138 // - or explicit trailing slash for a (possibly non-existent) directory
116- isDir := strings .HasSuffix (strings .TrimSpace (outPathFlag ), string (os .PathSeparator )) ||
117- strings .HasSuffix (outPathFlag , "/" ) ||
118- strings .HasSuffix (outPathFlag , "\\ " )
119- if ! isDir {
120- if st , statErr := os .Stat (outPath ); statErr == nil && st .IsDir () {
121- isDir = true
122- }
139+ flag := strings .TrimSpace (outPathFlag )
140+ if strings .HasSuffix (flag , string (os .PathSeparator )) || strings .HasSuffix (flag , "/" ) || strings .HasSuffix (flag , "\\ " ) {
141+ return true
123142 }
124- if isDir {
125- filename := safeFilename
126- if strings .TrimSpace (name ) == "" {
127- filename = fmt .Sprintf ("%s_%s_attachment.bin" , messageID , shortID )
128- }
129- return filepath .Join (outPath , filename ), nil
143+ if st , statErr := os .Stat (expandedOutPath ); statErr == nil && st .IsDir () {
144+ return true
130145 }
131-
132- return outPath , nil
146+ return false
133147}
134148
135149func sanitizeAttachmentFilename (name , fallback string ) string {
@@ -170,40 +184,91 @@ func downloadAttachmentToPath(
170184 return "" , false , 0 , errors .New ("missing outPath" )
171185 }
172186
173- if st , err := os .Stat (outPath ); err == nil {
174- if st .IsDir () {
175- return "" , false , 0 , fmt .Errorf ("outPath is a directory: %s" , outPath )
176- }
177- if st .Mode ().IsRegular () && expectedSize > 0 && st .Size () == expectedSize {
178- return outPath , true , st .Size (), nil
187+ cached , cachedSize , err := cachedRegularFile (outPath , expectedSize )
188+ if err != nil {
189+ return "" , false , 0 , err
190+ }
191+ if cached {
192+ return outPath , true , cachedSize , nil
193+ }
194+
195+ data , err := fetchAttachmentBytes (ctx , svc , messageID , attachmentID )
196+ if err != nil {
197+ return "" , false , 0 , err
198+ }
199+ if err := writeFileAtomic (outPath , data ); err != nil {
200+ return "" , false , 0 , err
201+ }
202+ return outPath , false , int64 (len (data )), nil
203+ }
204+
205+ func cachedRegularFile (outPath string , expectedSize int64 ) (cached bool , size int64 , err error ) {
206+ if expectedSize <= 0 {
207+ return false , 0 , nil
208+ }
209+ st , statErr := os .Stat (outPath )
210+ if statErr != nil {
211+ if os .IsNotExist (statErr ) {
212+ return false , 0 , nil
179213 }
214+ return false , 0 , statErr
215+ }
216+ if st .IsDir () {
217+ return false , 0 , fmt .Errorf ("outPath is a directory: %s" , outPath )
218+ }
219+ if st .Mode ().IsRegular () && st .Size () == expectedSize {
220+ return true , st .Size (), nil
180221 }
222+ return false , 0 , nil
223+ }
181224
225+ func fetchAttachmentBytes (ctx context.Context , svc * gmail.Service , messageID , attachmentID string ) ([]byte , error ) {
182226 if svc == nil {
183- return "" , false , 0 , errors .New ("missing gmail service" )
227+ return nil , errors .New ("missing gmail service" )
184228 }
185229
186230 body , err := svc .Users .Messages .Attachments .Get ("me" , messageID , attachmentID ).Context (ctx ).Do ()
187231 if err != nil {
188- return "" , false , 0 , err
232+ return nil , err
189233 }
190234 if body == nil || body .Data == "" {
191- return "" , false , 0 , errors .New ("empty attachment data" )
235+ return nil , errors .New ("empty attachment data" )
192236 }
237+
193238 data , err := base64 .RawURLEncoding .DecodeString (body .Data )
194239 if err != nil {
195240 // Gmail can return padded base64url; accept both.
196241 data , err = base64 .URLEncoding .DecodeString (body .Data )
197242 if err != nil {
198- return "" , false , 0 , err
243+ return nil , err
199244 }
200245 }
246+ return data , nil
247+ }
201248
202- if err := os .MkdirAll (filepath .Dir (outPath ), 0o700 ); err != nil {
203- return "" , false , 0 , err
249+ func writeFileAtomic (outPath string , data []byte ) error {
250+ dir := filepath .Dir (outPath )
251+ if err := os .MkdirAll (dir , 0o700 ); err != nil {
252+ return err
204253 }
205- if err := os .WriteFile (outPath , data , 0o600 ); err != nil {
206- return "" , false , 0 , err
254+
255+ f , err := os .CreateTemp (dir , ".gog-attachment-*" )
256+ if err != nil {
257+ return err
207258 }
208- return outPath , false , int64 (len (data )), nil
259+ tmp := f .Name ()
260+ defer func () { _ = os .Remove (tmp ) }()
261+
262+ if err := f .Chmod (0o600 ); err != nil {
263+ _ = f .Close ()
264+ return err
265+ }
266+ if _ , err := f .Write (data ); err != nil {
267+ _ = f .Close ()
268+ return err
269+ }
270+ if err := f .Close (); err != nil {
271+ return err
272+ }
273+ return os .Rename (tmp , outPath )
209274}
0 commit comments