//go:build linux || freebsd // +build linux freebsd package gopher import ( "bufio" "context" "fmt" "net" "net/url" "os" "runtime" "strconv" "strings" "testing" "time" "codeberg.org/petrbalvin/goget/internal/core" "codeberg.org/petrbalvin/goget/internal/output" ) func TestFormatGopherMenu(t *testing.T) { tests := []struct { line string expected string }{ {"0About\t/about\tgopher.floodgap.com\t70\r\n", "📄 About [/about]"}, {"1Fun\t/fun\tgopher.floodgap.com\t70\r\n", "📁 Fun [/fun]"}, {"iWelcome to Gopher\t(null)\t(null)\t0\r\n", "â„šī¸ Welcome to Gopher"}, {"hNews\t/news\tgopher.floodgap.com\t70\r\n", "🌐 News [/news]"}, } for _, tc := range tests { result := formatGopherMenu(tc.line) if result != tc.expected { t.Errorf("formatGopherMenu(%q) = %q, want %q", tc.line, result, tc.expected) } } } func TestStripGopherMeta(t *testing.T) { result := stripGopherMeta("0About\t/about\thost\t70") if result != "About" { t.Errorf("got %q, want %q", result, "About") } } func TestGopherIcon(t *testing.T) { if i := gopherIcon('0'); i != "📄" { t.Errorf("got %q", i) } if i := gopherIcon('1'); i != "📁" { t.Errorf("got %q", i) } if i := gopherIcon('9'); i != "💾" { t.Errorf("got %q", i) } if i := gopherIcon('h'); i != "🌐" { t.Errorf("got %q", i) } if i := gopherIcon('7'); i != "🔍" { t.Errorf("got %q", i) } } func TestIsTextGopherType(t *testing.T) { if !isTextGopherType('0') { t.Error("0 should be text") } if isTextGopherType('9') { t.Error("9 should not be text") } } type stringWriter struct{ sb *strings.Builder } func (w *stringWriter) Write(p []byte) (int, error) { return w.sb.Write(p) } func TestGopherDownloadMock(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() reader := bufio.NewReader(conn) reader.ReadString('\n') // selector conn.Write([]byte("0Hello from gopher\t(null)\t(null)\t0\r\n")) conn.Write([]byte(".\r\n")) }() proto := NewProtocol() u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/") var buf strings.Builder _, err = proto.Download(context.Background(), &core.DownloadRequest{ URL: u, Writer: &stringWriter{&buf}, }) if err != nil { t.Fatal(err) } if !strings.Contains(buf.String(), "Hello from gopher") { t.Errorf("output missing text: %q", buf.String()) } } // TestGopherResumeSaveMetadata verifies that interrupting a Gopher text // download persists a .goget.meta sidecar describing the partial state. func TestGopherResumeSaveMetadata(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() reader := bufio.NewReader(conn) reader.ReadString('\n') // Send the first display line, sleep so the client has a chance // to land in the read loop and receive the cancellation, then // send the second line and the terminator. conn.Write([]byte("hFirst\t/first\thost\t70\r\n")) time.Sleep(150 * time.Millisecond) conn.Write([]byte("hSecond\t/second\thost\t70\r\n")) conn.Write([]byte(".\r\n")) }() tmp := t.TempDir() outPath := tmp + "/page.html" proto := NewProtocol() u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/page") // progress is signalled every time the download loop calls // req.ProgressCallback — which happens *after* a successful // writer.Write. Reading from it is therefore a deterministic // barrier waiting for the first byte to land on disk. progress := make(chan int64, 1) ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { _, dlErr := proto.Download(ctx, &core.DownloadRequest{ URL: u, Output: outPath, ProgressCallback: func(current, total int64, speed float64) { select { case progress <- current: default: } }, }) errCh <- dlErr }() // Wait until the first byte has been written to disk, then cancel. // No fixed sleep — this is robust against slow CI schedulers. <-progress cancel() dlErr := <-errCh if dlErr == nil { t.Fatal("expected error from cancelled context") } metaPath := outPath + output.ResumeMetaFileSuffix if _, statErr := os.Stat(metaPath); statErr != nil { t.Fatalf("expected .goget.meta sidecar to exist: %v", statErr) } meta, metaErr := output.LoadResumeMetadata(outPath) if metaErr != nil { t.Fatalf("LoadResumeMetadata: %v", metaErr) } if meta == nil { t.Fatal("expected resume metadata, got nil") } if meta.Downloaded <= 0 { t.Errorf("Downloaded = %d, want > 0", meta.Downloaded) } if meta.URL != u.String() { t.Errorf("URL = %q, want %q", meta.URL, u.String()) } // Gopher does not expose Content-Length, so Total stays 0. if meta.Total != 0 { t.Errorf("Total = %d, want 0", meta.Total) } // Sanity-check: the saved Downloaded should match what's on disk. info, statErr := os.Stat(outPath) if statErr != nil { t.Fatalf("partial file missing: %v", statErr) } if info.Size() != meta.Downloaded { t.Errorf("file size = %d, metadata.Downloaded = %d (must match)", info.Size(), meta.Downloaded) } } // TestGopherResumeDownload verifies end-to-end resume: an interrupted // download completes on the second attempt with --resume=true, and the // metadata sidecar is removed after the successful completion. func TestGopherResumeDownload(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 cancelReady := make(chan struct{}) connReady := make(chan struct{}) go func() { for i := 0; i < 2; i++ { conn, lerr := listener.Accept() if lerr != nil { return } r := bufio.NewReader(conn) r.ReadString('\n') if i == 0 { // First attempt: send the first line, then wait // for the client to cancel before closing. conn.Write([]byte("0Line one\t/foo\thost\t70\r\n")) <-cancelReady conn.Close() connReady <- struct{}{} } else { // Second attempt: send the full body. conn.Write([]byte("0Line one\t/foo\thost\t70\r\n")) conn.Write([]byte("0Line two\t/bar\thost\t70\r\n")) conn.Write([]byte(".\r\n")) conn.Close() } } }() tmp := t.TempDir() outPath := tmp + "/resume.txt" proto := NewProtocol() u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/foo") // First attempt: send only the first line, then cancel. // progress signals after the first writer.Write, so waiting on // it replaces the previous fixed sleep with a deterministic barrier. progress := make(chan int64, 1) ctx1, cancel1 := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { _, dlErr := proto.Download(ctx1, &core.DownloadRequest{ URL: u, Output: outPath, ProgressCallback: func(current, total int64, speed float64) { select { case progress <- current: default: } }, }) errCh <- dlErr }() <-progress cancel1() cancelReady <- struct{}{} <-connReady dlErr := <-errCh if dlErr == nil { t.Fatal("expected error from cancelled context") } // Sanity-check: partial file + metadata sidecar present. info, statErr := os.Stat(outPath) if statErr != nil { t.Fatalf("partial file missing: %v", statErr) } if info.Size() == 0 { t.Fatal("partial file is empty") } meta, metaErr := output.LoadResumeMetadata(outPath) if metaErr != nil || meta == nil { t.Fatalf("expected resume metadata: err=%v meta=%v", metaErr, meta) } // Second attempt: resume should pick up where we left off. result, resErr := proto.Download(context.Background(), &core.DownloadRequest{ URL: u, Output: outPath, Resume: true, }) if resErr != nil { t.Fatalf("resume download failed: %v", resErr) } if !result.Resumed { t.Error("expected Resumed=true") } // The file should contain the full body, with the resume logic // skipping already-written bytes via the post-transform offset. data, readErr := os.ReadFile(outPath) if readErr != nil { t.Fatalf("read output: %v", readErr) } want := "Line one\nLine two\n" if string(data) != want { t.Errorf("file content = %q, want %q", string(data), want) } // Metadata sidecar should be deleted after a successful completion. if output.FileExists(outPath + output.ResumeMetaFileSuffix) { t.Error("expected .goget.meta to be deleted after successful resume") } } // TestSaveGopherResumeMetadata exercises the helper directly. func TestSaveGopherResumeMetadata(t *testing.T) { tmp := t.TempDir() out := tmp + "/file.txt" u, err := url.Parse("gopher://example.com/foo.txt") if err != nil { t.Fatalf("parse URL: %v", err) } req := &core.DownloadRequest{ URL: u, Output: out, } saveGopherResume(req, 4096) meta, err := output.LoadResumeMetadata(out) if err != nil { t.Fatalf("LoadResumeMetadata: %v", err) } if meta == nil { t.Fatal("expected resume metadata, got nil") } if meta.Downloaded != 4096 { t.Errorf("Downloaded = %d, want 4096", meta.Downloaded) } if meta.URL != "gopher://example.com/foo.txt" { t.Errorf("URL = %q, want %q", meta.URL, "gopher://example.com/foo.txt") } if meta.Total != 0 { t.Errorf("Total = %d, want 0", meta.Total) } } // TestSaveGopherResumeNoop verifies that the helper is a no-op for // stdout writes, empty output paths, zero-byte transfers, and nil // requests. func TestSaveGopherResumeNoop(t *testing.T) { u, _ := url.Parse("gopher://example.com/") t.Run("stdout", func(t *testing.T) { saveGopherResume(&core.DownloadRequest{URL: u, Output: "-"}, 100) }) t.Run("empty output", func(t *testing.T) { saveGopherResume(&core.DownloadRequest{URL: u, Output: ""}, 100) }) t.Run("zero bytes", func(t *testing.T) { tmp := t.TempDir() saveGopherResume(&core.DownloadRequest{URL: u, Output: tmp + "/f"}, 0) if output.FileExists(tmp + "/f" + output.ResumeMetaFileSuffix) { t.Error("metadata created for zero-byte transfer") } }) t.Run("nil request", func(t *testing.T) { saveGopherResume(nil, 100) }) } // TestGopherResumeStaleMetadata exercises the path where the .goget.meta // sidecar exists but the partial file doesn't (or has been truncated), // forcing the resume code to clean up and start fresh. func TestGopherResumeStaleMetadata(t *testing.T) { tmp := t.TempDir() outPath := tmp + "/stale.txt" // Pre-create an orphan .goget.meta for a URL we will not use. orphanMeta := output.NewResumeMetadata("gopher://other.example/foo", "", "", 1234, 0) if err := orphanMeta.Save(outPath); err != nil { t.Fatalf("seed meta: %v", err) } // Do not pre-create the partial file; CanResume requires file size // to match metadata Downloaded. 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("0Fresh\t/fresh\thost\t70\r\n.\r\n")) }() proto := NewProtocol() u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/fresh") res, err := proto.Download(context.Background(), &core.DownloadRequest{ URL: u, Output: outPath, Resume: true, }) if err != nil { t.Fatalf("download: %v", err) } if res.Resumed { t.Error("expected Resumed=false after stale-metadata cleanup") } data, _ := os.ReadFile(outPath) if string(data) != "Fresh\n" { t.Errorf("content = %q, want %q", string(data), "Fresh\n") } // The orphan sidecar from a different URL must be removed once the // successful fresh download completes — otherwise stale metadata // could be picked up by a future --resume run. if output.FileExists(outPath + output.ResumeMetaFileSuffix) { t.Error("expected orphan .goget.meta to be deleted after fresh download") } } // 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") } } // TestGopherResumeInformation verifies that resume machinery works for the // informational (type i) response path, which is also routed through // downloadText. Informational lines are passed through verbatim (no // metadata strip), so the resume offset matches the raw line length // including trailing CR/LF. func TestGopherResumeInformation(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 cancelReady := make(chan struct{}) connReady := make(chan struct{}) go func() { for i := 0; i < 2; i++ { conn, lerr := listener.Accept() if lerr != nil { return } r := bufio.NewReader(conn) r.ReadString('\n') if i == 0 { // First half: send two info lines, then wait // for the client to cancel before closing. conn.Write([]byte("iFirst line.\r\n")) conn.Write([]byte("iSecond line.\r\n")) <-cancelReady conn.Close() connReady <- struct{}{} } else { // Second half: full body. conn.Write([]byte("iFirst line.\r\n")) conn.Write([]byte("iSecond line.\r\n")) conn.Write([]byte(".\r\n")) conn.Close() } } }() tmp := t.TempDir() outPath := tmp + "/info.txt" proto := NewProtocol() u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/info") progress := make(chan int64, 1) ctx1, cancel1 := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { _, dlErr := proto.Download(ctx1, &core.DownloadRequest{ URL: u, Output: outPath, ProgressCallback: func(current, total int64, speed float64) { select { case progress <- current: default: } }, }) errCh <- dlErr }() <-progress cancel1() cancelReady <- struct{}{} <-connReady if dlErr := <-errCh; dlErr == nil { t.Fatal("expected error from cancelled context") } // Resume should append the missing bytes. For informational lines // we do not strip metadata, so the output preserves the server CR/LF. result, resErr := proto.Download(context.Background(), &core.DownloadRequest{ URL: u, Output: outPath, Resume: true, }) if resErr != nil { t.Fatalf("resume download failed: %v", resErr) } if !result.Resumed { t.Error("expected Resumed=true") } if result.ContentType != "text/plain" { t.Errorf("ContentType = %q, want text/plain", result.ContentType) } data, _ := os.ReadFile(outPath) // Informational lines are passed through verbatim: the 'i' item-type // prefix and CR/LF framing from the server survive into the output. want := "iFirst line.\r\niSecond line.\r\n" if string(data) != want { t.Errorf("content = %q, want %q", string(data), want) } if output.FileExists(outPath + output.ResumeMetaFileSuffix) { t.Error("expected .goget.meta to be deleted after successful resume") } } // TestGopherCancelGoroutineExits verifies that the ctx-cancellation // goroutine spawned by Download does not outlive Download itself. // The goroutine used to wait forever on <-ctx.Done() when the parent // context was never cancelled; the done-channel bound added with M3 // must let every iteration return to the runtime goroutine pool. func TestGopherCancelGoroutineExits(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() { // Drain connections so the listener does not backpressure // the test loop. We do not read or write — a quick accept // and close is enough for Download to complete. for { conn, lerr := listener.Accept() if lerr != nil { return } go func(c net.Conn) { // Reply with the response header so the client // reaches downloadText and spawns its goroutine. defer c.Close() fmt.Fprintf(c, "0hi\t/x\th\t70\r\n.\r\n") // Block until the peer closes — this lets the // client-side goroutine sit on ReadString until // it's raced by done, exercising the bound. buf := make([]byte, 1) c.Read(buf) }(conn) } }() // Warm up: a single download is enough to settle the runtime // goroutine pool (timer, GC, poll, etc.) before we measure. proto := NewProtocol() u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/x") req := core.DownloadRequest{URL: u, Writer: &stringWriter{&strings.Builder{}}} if _, dlErr := proto.Download(context.Background(), &req); dlErr != nil { t.Fatalf("warmup: %v", dlErr) } // Settle baseline after warmup. runtime.GC() time.Sleep(50 * time.Millisecond) runtime.GC() baseline := runtime.NumGoroutine() const iterations = 50 for i := 0; i < iterations; i++ { if _, dlErr := proto.Download(context.Background(), &req); dlErr != nil { t.Fatalf("download %d: %v", i, dlErr) } } // After every successful Download returns, its cancellation // goroutine must have exited. Allow a brief settle window for // the runtime to reclaim them. deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { runtime.GC() time.Sleep(50 * time.Millisecond) // One tolerance for any incidental stdlib goroutine that // the Go runtime occasionally spins up between calls. if runtime.NumGoroutine() <= baseline+1 { return } } t.Fatalf("goroutine leak: baseline=%d, after %d downloads = %d", baseline, iterations, runtime.NumGoroutine()) }