Skip to content

Commit fa3fa16

Browse files
authored
fix(cli): require overwrite opt-in for downloads (#829)
Protect local output files by default and require --overwrite for replacement across Drive-backed exports and Slides thumbnails. Co-authored-by: WadydX <[email protected]>
1 parent 7712dc6 commit fa3fa16

20 files changed

Lines changed: 184 additions & 50 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
### Fixed
1010

1111
- Calendar: report multi-calendar event truncation on stderr for text output and as per-calendar page tokens in JSON. (#831) — thanks @TurboTheTurtle.
12+
- Downloads: protect Drive downloads, Docs/Sheets/Slides exports, Docs tab exports, and Slides thumbnails from replacing existing files unless `--overwrite` is passed. (#827, #829) — thanks @WadydX.
1213
- Docs: update the Docker authentication example to persist file-keyring tokens with `GOG_HOME`. (#828, #830) — thanks @WadydX.
1314

1415
## 0.28.0 - 2026-06-15

docs/commands/gog-docs-export.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ gog docs (doc) export (download,dl) <docId> [flags]
3434
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
3535
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
3636
| `--out`<br>`--output` | `string` | | Output file path (default: gogcli config dir) |
37+
| `--overwrite` | `bool` | | Overwrite an existing output file |
3738
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
3839
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
3940
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |

docs/commands/gog-download.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ gog download (dl) <fileId> [flags]
3434
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
3535
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
3636
| `--out`<br>`--output` | `string` | | Output file path (default: gogcli config dir) |
37+
| `--overwrite` | `bool` | | Overwrite an existing output file |
3738
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
3839
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
3940
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |

docs/commands/gog-drive-download.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ gog drive (drv) download <fileId> [flags]
3434
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
3535
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
3636
| `--out`<br>`--output` | `string` | | Output file path (default: gogcli config dir) |
37+
| `--overwrite` | `bool` | | Overwrite an existing output file |
3738
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
3839
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
3940
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |

docs/commands/gog-sheets-export.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ gog sheets (sheet) export (download,dl) <spreadsheetId> [flags]
3434
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
3535
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
3636
| `--out`<br>`--output` | `string` | | Output file path (default: gogcli config dir) |
37+
| `--overwrite` | `bool` | | Overwrite an existing output file |
3738
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
3839
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
3940
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |

docs/commands/gog-slides-export.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ gog slides (slide) export (download,dl) <presentationId> [flags]
3434
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
3535
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
3636
| `--out`<br>`--output` | `string` | | Output file path (default: gogcli config dir) |
37+
| `--overwrite` | `bool` | | Overwrite an existing output file |
3738
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
3839
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
3940
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |

docs/commands/gog-slides-thumbnail.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ gog slides (slide) thumbnail (thumb) <presentationId> <slideId> [flags]
3434
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
3535
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
3636
| `--out`<br>`--output` | `string` | | Write the thumbnail image to a local file |
37+
| `--overwrite` | `bool` | | Overwrite an existing output file |
3738
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
3839
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
3940
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |

internal/cmd/docs.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -152,27 +152,29 @@ func projectRawDocumentTab(doc *docs.Document, tab *docs.Tab) (*docs.Document, e
152152
}
153153

154154
type DocsExportCmd struct {
155-
DocID string `arg:"" name:"docId" help:"Doc ID"`
156-
Output OutputPathFlag `embed:""`
157-
Format string `name:"format" help:"Export format: pdf|docx|txt|md|html" default:"pdf"`
158-
Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (see 'gog docs list-tabs')"`
155+
DocID string `arg:"" name:"docId" help:"Doc ID"`
156+
Output OutputPathFlag `embed:""`
157+
Format string `name:"format" help:"Export format: pdf|docx|txt|md|html" default:"pdf"`
158+
Tab string `name:"tab" help:"(experimental) Export a specific tab by title or ID (see 'gog docs list-tabs')"`
159+
Overwrite bool `name:"overwrite" help:"Overwrite an existing output file"`
159160
}
160161

161162
func (c *DocsExportCmd) Run(ctx context.Context, flags *RootFlags) error {
162163
if tab := strings.TrimSpace(c.Tab); tab != "" {
163164
return runDocsTabExport(ctx, flags, tabExportParams{
164-
DocID: c.DocID,
165-
OutFlag: c.Output.Path,
166-
Format: c.Format,
167-
TabQuery: tab,
165+
DocID: c.DocID,
166+
OutFlag: c.Output.Path,
167+
Format: c.Format,
168+
TabQuery: tab,
169+
Overwrite: c.Overwrite,
168170
})
169171
}
170172
return exportViaDrive(ctx, flags, exportViaDriveOptions{
171173
ArgName: "docId",
172174
ExpectedMime: "application/vnd.google-apps.document",
173175
KindLabel: "Google Doc",
174176
DefaultFormat: "pdf",
175-
}, c.DocID, c.Output.Path, c.Format)
177+
}, c.DocID, c.Output.Path, c.Format, c.Overwrite)
176178
}
177179

178180
type DocsInfoCmd struct {

internal/cmd/docs_tab_export.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,11 @@ func isGoogleAuthHost(host string) bool {
6666
}
6767

6868
type tabExportParams struct {
69-
DocID string
70-
OutFlag string
71-
Format string
72-
TabQuery string
69+
DocID string
70+
OutFlag string
71+
Format string
72+
TabQuery string
73+
Overwrite bool
7374
}
7475

7576
// sanitizeFilenameComponent replaces characters unsafe for filenames with
@@ -181,10 +182,11 @@ func runDocsTabExport(ctx context.Context, flags *RootFlags, p tabExportParams)
181182
}
182183

183184
if dryErr := dryRunExit(ctx, flags, "docs.tab-export", map[string]any{
184-
"docID": p.DocID,
185-
"tab": p.TabQuery,
186-
"format": format,
187-
"out": outPath,
185+
"docID": p.DocID,
186+
"tab": p.TabQuery,
187+
"format": format,
188+
"out": outPath,
189+
"overwrite": p.Overwrite,
188190
}); dryErr != nil {
189191
return dryErr
190192
}
@@ -234,7 +236,11 @@ func runDocsTabExport(ctx context.Context, flags *RootFlags, p tabExportParams)
234236
return copyErr
235237
}
236238

237-
f, outPath, writeErr := createUserOutputFile(outPath)
239+
f, outPath, writeErr := openUserOutputFile(outPath, outputFileOptions{
240+
Overwrite: p.Overwrite,
241+
FileMode: 0o600,
242+
DirMode: 0o700,
243+
})
238244
if writeErr != nil {
239245
return writeErr
240246
}

internal/cmd/docs_tab_export_test.go

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"errors"
78
"io"
89
"net/http"
910
"net/http/httptest"
@@ -413,19 +414,56 @@ func TestRunDocsTabExport_JSONOutput(t *testing.T) {
413414
}
414415
}
415416

417+
func TestRunDocsTabExport_RequiresOverwrite(t *testing.T) {
418+
ctx, _ := newTabExportTestContext(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
419+
w.Header().Set("Content-Type", "application/pdf")
420+
_, _ = w.Write([]byte("replacement"))
421+
}), false)
422+
423+
outPath := filepath.Join(t.TempDir(), "output.pdf")
424+
if err := os.WriteFile(outPath, []byte("original"), 0o600); err != nil {
425+
t.Fatalf("WriteFile: %v", err)
426+
}
427+
params := tabExportParams{
428+
DocID: "doc1",
429+
OutFlag: outPath,
430+
Format: "pdf",
431+
TabQuery: "First Tab",
432+
}
433+
434+
if err := runDocsTabExport(ctx, &RootFlags{Account: "[email protected]"}, params); !errors.Is(err, os.ErrExist) {
435+
t.Fatalf("expected existing-file error, got %v", err)
436+
}
437+
if got, err := os.ReadFile(outPath); err != nil || string(got) != "original" {
438+
t.Fatalf("existing file changed: data=%q err=%v", got, err)
439+
}
440+
441+
params.Overwrite = true
442+
if err := runDocsTabExport(ctx, &RootFlags{Account: "[email protected]"}, params); err != nil {
443+
t.Fatalf("runDocsTabExport overwrite: %v", err)
444+
}
445+
if got, err := os.ReadFile(outPath); err != nil || string(got) != "replacement" {
446+
t.Fatalf("overwrite failed: data=%q err=%v", got, err)
447+
}
448+
}
449+
416450
func TestDocsExportCmd_TabRouting(t *testing.T) {
417451
ctx, _ := newTabExportTestContext(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
418452
w.Header().Set("Content-Type", "application/pdf")
419453
_, _ = w.Write([]byte("tab pdf"))
420454
}), false)
421455

422456
outPath := filepath.Join(t.TempDir(), "out.pdf")
457+
if err := os.WriteFile(outPath, []byte("original"), 0o600); err != nil {
458+
t.Fatalf("WriteFile: %v", err)
459+
}
423460

424461
cmd := &DocsExportCmd{
425-
DocID: "doc1",
426-
Format: "pdf",
427-
Tab: "Second Tab",
428-
Output: OutputPathFlag{Path: outPath},
462+
DocID: "doc1",
463+
Format: "pdf",
464+
Tab: "Second Tab",
465+
Output: OutputPathFlag{Path: outPath},
466+
Overwrite: true,
429467
}
430468

431469
if err := cmd.Run(ctx, &RootFlags{Account: "[email protected]"}); err != nil {

0 commit comments

Comments
 (0)