feat: initial goget release — modern IPv6-first download utility

This commit is contained in:
2026-06-26 21:21:58 +02:00
commit 53db81e1f7
147 changed files with 45931 additions and 0 deletions
+203
View File
@@ -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
}
+134
View File
@@ -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")
}
}