//go:build linux || freebsd // +build linux freebsd package gemini import ( "bufio" "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "math/big" "net" "net/url" "os" "strconv" "strings" "testing" "time" "codeberg.org/petrbalvin/goget/internal/core" "codeberg.org/petrbalvin/goget/internal/output" ) type stringWriter struct{ sb *strings.Builder } func (w *stringWriter) Write(p []byte) (int, error) { return w.sb.Write(p) } func testTLSConfig() *tls.Config { key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) tmpl := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, NotBefore: time.Now(), NotAfter: time.Now().Add(time.Hour), IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, } certDER, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) return &tls.Config{ Certificates: []tls.Certificate{{ Certificate: [][]byte{certDER}, PrivateKey: key, }}, } } func TestGeminiSuccess(t *testing.T) { cfg := testTLSConfig() listener, err := tls.Listen("tcp", "127.0.0.1:0", cfg) 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("20 text/gemini\r\n# Hello\n\nContent.\n")) }() proto := NewProtocol() proto.TLSInsecure = true u, _ := url.Parse("gemini://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") { t.Errorf("got %q", buf.String()) } } func TestGeminiTemporaryFailure(t *testing.T) { cfg := testTLSConfig() listener, _ := tls.Listen("tcp", "127.0.0.1:0", cfg) 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("40 overloaded\r\n")) }() proto := NewProtocol() proto.TLSInsecure = true u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/") _, err := proto.Download(context.Background(), &core.DownloadRequest{URL: u}) if err == nil { t.Error("expected temporary failure error") } } func TestGeminiPermanentFailure(t *testing.T) { cfg := testTLSConfig() listener, _ := tls.Listen("tcp", "127.0.0.1:0", cfg) 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("51 not found\r\n")) }() proto := NewProtocol() proto.TLSInsecure = true u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/") _, err := proto.Download(context.Background(), &core.DownloadRequest{URL: u}) if err == nil { t.Error("expected permanent failure error") } } // TestGeminiPinnedCertMismatch exercises the certificate-pinning path. // The server presents a self-signed cert whose hash does NOT match the // pinned value, so the download must fail with a pinning error. func TestGeminiPinnedCertMismatch(t *testing.T) { cfg := testTLSConfig() listener, err := tls.Listen("tcp", "127.0.0.1:0", cfg) 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("20 text/gemini\r\nhello\n")) }() proto := NewProtocol() proto.TLSInsecure = true // accept self-signed, then let pinning reject u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/") _, err = proto.Download(context.Background(), &core.DownloadRequest{ URL: u, PinnedCertHash: "0000000000000000000000000000000000000000000000000000000000000000", }) if err == nil { t.Fatal("expected pinning failure") } if !strings.Contains(err.Error(), "pinning failed") { t.Errorf("expected pinning error, got: %v", err) } } // TestGeminiResumeSaveMetadata verifies that partial download progress is // persisted to a .goget.meta sidecar when the context is cancelled. func TestGeminiResumeSaveMetadata(t *testing.T) { cfg := testTLSConfig() listener, err := tls.Listen("tcp", "127.0.0.1:0", cfg) if err != nil { t.Fatal(err) } defer listener.Close() port := listener.Addr().(*net.TCPAddr).Port // Send data in small pieces with delays so the download is still in // progress when the context is cancelled. body := make([]byte, 2000) for i := range body { body[i] = 'A' } go func() { conn, _ := listener.Accept() if conn == nil { return } defer conn.Close() r := bufio.NewReader(conn) r.ReadString('\n') conn.Write([]byte("20 application/octet-stream\r\n")) // Send 500 bytes per chunk with 100ms delays. for off := 0; off < len(body); off += 500 { end := off + 500 if end > len(body) { end = len(body) } conn.Write(body[off:end]) time.Sleep(100 * time.Millisecond) } }() tmp := t.TempDir() outPath := tmp + "/partial.bin" proto := NewProtocol() proto.TLSInsecure = true u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/") ctx, cancel := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { _, err := proto.Download(ctx, &core.DownloadRequest{ URL: u, Output: outPath, }) errCh <- err }() // Cancel mid-download — the slow server keeps the connection open // long enough for the cancellation to fire between reads. time.Sleep(150 * time.Millisecond) cancel() err = <-errCh if err == nil { t.Fatal("expected error from cancelled context") } // Verify resume metadata was saved. metaPath := outPath + output.ResumeMetaFileSuffix if _, err := os.Stat(metaPath); os.IsNotExist(err) { t.Fatal("expected .goget.meta sidecar to exist") } meta, err := output.LoadResumeMetadata(outPath) if err != nil { t.Fatalf("LoadResumeMetadata: %v", err) } 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()) } } // TestGeminiResumeDownload verifies end-to-end resume: a first download is // interrupted, and the second download picks up from the saved offset. func TestGeminiResumeDownload(t *testing.T) { cfg := testTLSConfig() listener, err := tls.Listen("tcp", "127.0.0.1:0", cfg) if err != nil { t.Fatal(err) } defer listener.Close() port := listener.Addr().(*net.TCPAddr).Port fullBody := make([]byte, 1000) for i := range fullBody { fullBody[i] = byte('A' + i%26) } cancelReady := make(chan struct{}) // client signals it has cancelled connReady := make(chan struct{}) // server signals connection is done go func() { for i := 0; i < 2; i++ { conn, lerr := listener.Accept() if lerr != nil { return } r := bufio.NewReader(conn) r.ReadString('\n') conn.Write([]byte("20 application/octet-stream\r\n")) if i == 0 { // First connection: send 500 bytes, then wait // for the client to cancel before closing. conn.Write(fullBody[:500]) <-cancelReady conn.Close() connReady <- struct{}{} } else { // Second connection: send the full body. conn.Write(fullBody) conn.Close() } } }() tmp := t.TempDir() outPath := tmp + "/resume.bin" proto := NewProtocol() proto.TLSInsecure = true u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/data") // First attempt: the server sends 500 bytes and blocks. We cancel // the context, then the server closes the connection. ctx1, cancel1 := context.WithCancel(context.Background()) errCh := make(chan error, 1) go func() { _, dlErr := proto.Download(ctx1, &core.DownloadRequest{ URL: u, Output: outPath, }) errCh <- dlErr }() time.Sleep(100 * time.Millisecond) cancel1() cancelReady <- struct{}{} // let server close connection <-connReady dlErr := <-errCh if dlErr == nil { t.Fatal("expected error from cancelled context") } // Verify partial file and metadata exist. 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. 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 now contain the full body. data, readErr := os.ReadFile(outPath) if readErr != nil { t.Fatalf("read output: %v", readErr) } if string(data) != string(fullBody) { t.Errorf("file content length = %d, want %d", len(data), len(fullBody)) } // Resume metadata should be deleted after successful completion. if output.FileExists(outPath + output.ResumeMetaFileSuffix) { t.Error("expected .goget.meta to be deleted after successful resume") } } // TestSaveGeminiResumeMetadata verifies the helper function directly. func TestSaveGeminiResumeMetadata(t *testing.T) { tmp := t.TempDir() out := tmp + "/file.bin" u, err := url.Parse("gemini://example.com/file.bin") if err != nil { t.Fatalf("parse URL: %v", err) } req := &core.DownloadRequest{ URL: u, Output: out, } saveGeminiResume(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 != "gemini://example.com/file.bin" { t.Errorf("URL = %q, want %q", meta.URL, "gemini://example.com/file.bin") } // Gemini doesn't expose Content-Length, so Total should be 0. if meta.Total != 0 { t.Errorf("Total = %d, want 0", meta.Total) } } // TestSaveGeminiResumeNoop verifies that the helper is a no-op for // stdout writes, empty output, and zero-byte transfers. func TestSaveGeminiResumeNoop(t *testing.T) { u, _ := url.Parse("gemini://example.com/") t.Run("stdout", func(t *testing.T) { saveGeminiResume(&core.DownloadRequest{URL: u, Output: "-"}, 100) // Should not panic or create any file. }) t.Run("empty output", func(t *testing.T) { saveGeminiResume(&core.DownloadRequest{URL: u, Output: ""}, 100) }) t.Run("zero bytes", func(t *testing.T) { tmp := t.TempDir() saveGeminiResume(&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) { saveGeminiResume(nil, 100) // Should not panic. }) }