From 535496e6e1c300ff67de39df15e53213ee643296 Mon Sep 17 00:00:00 2001 From: Petr Date: Tue, 30 Jun 2026 11:07:46 +0200 Subject: [PATCH] fix(gopher): reject resume when caller supplies Writer --- internal/protocol/gopher/gopher.go | 10 +++++ internal/protocol/gopher/gopher_test.go | 50 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/internal/protocol/gopher/gopher.go b/internal/protocol/gopher/gopher.go index 7891bf0..29362d0 100644 --- a/internal/protocol/gopher/gopher.go +++ b/internal/protocol/gopher/gopher.go @@ -165,6 +165,16 @@ func (p *Protocol) downloadText(ctx context.Context, reader *bufio.Reader, req * // fresh downloads (create) and resume (append). When req.Writer is // set (caller manages the file), we use it as-is. if req.Output != "" && req.Output != "-" { + // Resume is meaningful only when we own the output file. If the + // caller already supplied a Writer we cannot append to Output + // behind their back, so disable resume to keep the partial-state + // sidecar consistent with what actually lands on disk. + if req.Resume && req.Writer != nil && req.Verbose { + fmt.Fprintf(os.Stderr, "[gopher] warning: --resume ignored because a Writer is set; output goes to the Writer only\n") + } + if req.Resume && req.Writer != nil { + req.Resume = false + } if req.Resume { canResume, resumeMeta, rerr := output.CanResume(req.Output, req.URL.String()) if rerr != nil { diff --git a/internal/protocol/gopher/gopher_test.go b/internal/protocol/gopher/gopher_test.go index 004b0b9..92d4dae 100644 --- a/internal/protocol/gopher/gopher_test.go +++ b/internal/protocol/gopher/gopher_test.go @@ -438,3 +438,53 @@ func TestGopherResumeStaleMetadata(t *testing.T) { t.Errorf("content = %q, want %q", string(data), "Fresh\n") } } + +// TestGopherResumeIgnoredWhenWriterSet verifies that --resume is silently +// disabled when the caller also supplies a Writer: the resume metadata +// sidecar would otherwise lie about bytes written to Output, since the +// real data ends up in the Writer. +func TestGopherResumeIgnoredWhenWriterSet(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + port := listener.Addr().(*net.TCPAddr).Port + + go func() { + conn, _ := listener.Accept() + if conn == nil { + return + } + defer conn.Close() + r := bufio.NewReader(conn) + r.ReadString('\n') + conn.Write([]byte("0Line one\t/foo\thost\t70\r\n.\r\n")) + }() + + proto := NewProtocol() + u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/foo") + outPath := t.TempDir() + "/ignored-output.txt" + + var buf strings.Builder + res, dlErr := proto.Download(context.Background(), &core.DownloadRequest{ + URL: u, + Output: outPath, + Writer: &stringWriter{&buf}, + Resume: true, + }) + if dlErr != nil { + t.Fatalf("download: %v", dlErr) + } + if res.Resumed { + t.Error("expected Resumed=false when Writer is set (resume must be ignored)") + } + if !strings.Contains(buf.String(), "Line one") { + t.Errorf("writer should receive the body, got %q", buf.String()) + } + // Output is supplied but a Writer takes precedence; the file must + // not be created or touched by resume machinery. + if _, statErr := os.Stat(outPath); statErr == nil { + t.Error("Output file should not be created when Writer is set") + } +}