532 lines
14 KiB
Go
532 lines
14 KiB
Go
//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"
|
|
"runtime"
|
|
"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())
|
|
// 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)
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
_, err := proto.Download(ctx, &core.DownloadRequest{
|
|
URL: u,
|
|
Output: outPath,
|
|
ProgressCallback: func(current, total int64, speed float64) {
|
|
select {
|
|
case progress <- current:
|
|
default:
|
|
}
|
|
},
|
|
})
|
|
errCh <- err
|
|
}()
|
|
|
|
// Wait until the first byte has been written to disk, then cancel.
|
|
// No fixed sleep — this is robust against slow CI schedulers.
|
|
<-progress
|
|
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. progress
|
|
// signals after the first writer.Write so waiting on it replaces
|
|
// the previous fixed sleep with a deterministic barrier.
|
|
ctx1, cancel1 := context.WithCancel(context.Background())
|
|
progress := make(chan int64, 1)
|
|
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{}{} // 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.
|
|
})
|
|
}
|
|
|
|
// TestGeminiCancelGoroutineExits verifies that the ctx-cancellation
|
|
// goroutine spawned by Gemini downloadWithRedirects 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
|
|
// must let every iteration return to the runtime goroutine pool.
|
|
func TestGeminiCancelGoroutineExits(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() {
|
|
for {
|
|
conn, lerr := listener.Accept()
|
|
if lerr != nil {
|
|
return
|
|
}
|
|
go func(c net.Conn) {
|
|
defer c.Close()
|
|
r := bufio.NewReader(c)
|
|
r.ReadString('\n')
|
|
// Brief response that Gemini Download can chew
|
|
// through quickly, spawning the cancellation
|
|
// goroutine and returning. The goroutine must
|
|
// exit via the done channel on return.
|
|
c.Write([]byte("20 application/octet-stream\r\n"))
|
|
c.Write([]byte("hi"))
|
|
}(conn)
|
|
}
|
|
}()
|
|
|
|
// Warm up to settle the runtime goroutine pool (timer, GC, etc.).
|
|
proto := NewProtocol()
|
|
proto.TLSInsecure = true
|
|
u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/warmup")
|
|
if _, dlErr := proto.Download(context.Background(), &core.DownloadRequest{
|
|
URL: u,
|
|
Writer: &stringWriter{&strings.Builder{}},
|
|
}); dlErr != nil {
|
|
t.Fatalf("warmup: %v", dlErr)
|
|
}
|
|
|
|
runtime.GC()
|
|
time.Sleep(50 * time.Millisecond)
|
|
runtime.GC()
|
|
baseline := runtime.NumGoroutine()
|
|
|
|
const iterations = 50
|
|
for i := 0; i < iterations; i++ {
|
|
u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/x")
|
|
if _, dlErr := proto.Download(context.Background(), &core.DownloadRequest{
|
|
URL: u,
|
|
Writer: &stringWriter{&strings.Builder{}},
|
|
}); 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())
|
|
}
|