feat: initial goget release — modern IPv6-first download utility
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
"codeberg.org/petrbalvin/goget/internal/protocol"
|
||||
)
|
||||
|
||||
// Protocol implements gemini:// downloads.
|
||||
type Protocol struct {
|
||||
*protocol.BaseProtocol
|
||||
TLSInsecure bool // allow self-signed certs (testing only)
|
||||
}
|
||||
|
||||
// maxGeminiRedirects limits redirect chain depth to prevent stack overflow.
|
||||
const maxGeminiRedirects = 10
|
||||
|
||||
// NewProtocol creates a new Gemini protocol handler.
|
||||
func NewProtocol() *Protocol {
|
||||
return &Protocol{
|
||||
BaseProtocol: protocol.NewBaseProtocol(protocol.ProtocolInfo{
|
||||
Name: "GEMINI",
|
||||
Scheme: "gemini",
|
||||
DefaultPort: 1965,
|
||||
Features: []string{},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Download starts a Gemini download. It handles redirect limits internally.
|
||||
func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
|
||||
return p.downloadWithRedirects(ctx, req, 0)
|
||||
}
|
||||
|
||||
func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.DownloadRequest, redirectCount int) (*core.DownloadResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
host := req.URL.Hostname()
|
||||
port := req.URL.Port()
|
||||
if port == "" {
|
||||
port = "1965"
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: p.TLSInsecure,
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: 30 * time.Second}
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return nil, core.NewNetworkError("failed to connect to gemini server", err, core.SafeURL(req.URL))
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
|
||||
// Send request: URL + CRLF
|
||||
requestURL := req.URL.String()
|
||||
if _, err := fmt.Fprintf(conn, "%s\r\n", requestURL); err != nil {
|
||||
return nil, core.NewNetworkError("failed to send gemini request", err, core.SafeURL(req.URL))
|
||||
}
|
||||
|
||||
// Read response header
|
||||
reader := bufio.NewReader(conn)
|
||||
header, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, core.NewNetworkError("failed to read gemini response header", err, core.SafeURL(req.URL))
|
||||
}
|
||||
header = strings.TrimSpace(header)
|
||||
|
||||
// Parse status code (first two chars)
|
||||
if len(header) < 2 {
|
||||
return nil, core.NewProtocolError("invalid gemini response header", nil, core.SafeURL(req.URL))
|
||||
}
|
||||
|
||||
statusCode, _ := strconv.Atoi(header[:2])
|
||||
meta := ""
|
||||
if len(header) > 3 {
|
||||
meta = header[3:]
|
||||
}
|
||||
|
||||
switch {
|
||||
case statusCode >= 10 && statusCode < 20:
|
||||
// 1x INPUT — server requests input (prompt user with meta)
|
||||
return nil, core.NewProtocolError(
|
||||
fmt.Sprintf("gemini input required: %s", meta), nil, core.SafeURL(req.URL))
|
||||
|
||||
case statusCode >= 30 && statusCode < 40:
|
||||
// 3x REDIRECT — follow the redirect
|
||||
if meta == "" {
|
||||
return nil, core.NewProtocolError("gemini redirect with no target URL", nil, core.SafeURL(req.URL))
|
||||
}
|
||||
if redirectCount >= maxGeminiRedirects {
|
||||
return nil, core.NewProtocolError(
|
||||
fmt.Sprintf("gemini redirect chain exceeded %d", maxGeminiRedirects), nil, core.SafeURL(req.URL))
|
||||
}
|
||||
redirectURL, err := url.Parse(meta)
|
||||
if err != nil {
|
||||
return nil, core.NewProtocolError(fmt.Sprintf("invalid gemini redirect URL: %s", meta), err, core.SafeURL(req.URL))
|
||||
}
|
||||
// Resolve relative redirects
|
||||
resolved := req.URL.ResolveReference(redirectURL)
|
||||
if req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[gemini] redirecting to: %s\n", resolved.String())
|
||||
}
|
||||
// Follow redirect with incremented depth
|
||||
redirectReq := &core.DownloadRequest{
|
||||
URL: resolved,
|
||||
Output: req.Output,
|
||||
Verbose: req.Verbose,
|
||||
Writer: req.Writer,
|
||||
Ctx: ctx,
|
||||
}
|
||||
return p.downloadWithRedirects(ctx, redirectReq, redirectCount+1)
|
||||
|
||||
case statusCode >= 40 && statusCode < 50:
|
||||
// 4x TEMPORARY FAILURE
|
||||
return nil, core.NewNetworkError(
|
||||
fmt.Sprintf("gemini temporary failure: %d %s", statusCode, meta),
|
||||
nil, core.SafeURL(req.URL))
|
||||
|
||||
case statusCode >= 50 && statusCode < 60:
|
||||
// 5x PERMANENT FAILURE
|
||||
return nil, core.NewProtocolError(
|
||||
fmt.Sprintf("gemini permanent failure: %d %s", statusCode, meta), nil, core.SafeURL(req.URL))
|
||||
|
||||
case statusCode >= 60 && statusCode < 70:
|
||||
// 6x CLIENT CERTIFICATE REQUIRED
|
||||
return nil, core.NewProtocolError(
|
||||
fmt.Sprintf("gemini client certificate required: %d %s", statusCode, meta), nil, core.SafeURL(req.URL))
|
||||
|
||||
case statusCode < 20 || statusCode >= 30:
|
||||
return nil, core.NewProtocolError(
|
||||
fmt.Sprintf("gemini server returned %d %s", statusCode, meta), nil, core.SafeURL(req.URL))
|
||||
}
|
||||
|
||||
// Category 2x: success — continue to read body
|
||||
var totalRead int64
|
||||
var writer io.Writer
|
||||
|
||||
if req.Writer != nil {
|
||||
writer = req.Writer
|
||||
} else {
|
||||
writer = io.Discard
|
||||
}
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
n, err := reader.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := writer.Write(buf[:n]); werr != nil {
|
||||
return nil, core.NewFileError("failed to write gemini data", werr)
|
||||
}
|
||||
totalRead += int64(n)
|
||||
if req.ProgressCallback != nil {
|
||||
speed := float64(totalRead) / time.Since(startTime).Seconds()
|
||||
req.ProgressCallback(totalRead, -1, speed)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, core.NewNetworkError("failed to read gemini response body", err, core.SafeURL(req.URL))
|
||||
}
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
speed := float64(totalRead) / duration.Seconds()
|
||||
|
||||
return &core.DownloadResult{
|
||||
BytesDownloaded: totalRead,
|
||||
TotalSize: totalRead,
|
||||
Duration: duration,
|
||||
Protocol: "GEMINI",
|
||||
IPVersion: 4,
|
||||
Speed: speed,
|
||||
OutputPath: req.Output,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//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"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user