//go:build linux || freebsd // +build linux freebsd package gemini import ( "bufio" "context" "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/hex" "fmt" "io" "net" "net/url" "os" "path/filepath" "strconv" "strings" "time" "codeberg.org/petrbalvin/goget/internal/core" "codeberg.org/petrbalvin/goget/internal/output" "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{"resume"}, }), } } // 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, } // Certificate pinning: require the leaf certificate to match the // caller-provided SHA-256 hash. The check runs after the standard // verification chain so a pinned self-signed cert still needs // InsecureSkipVerify (or a matching CA) to clear the normal path. if req != nil && req.PinnedCertHash != "" { expected := strings.ToLower(strings.TrimSpace(req.PinnedCertHash)) tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error { for _, rawCert := range rawCerts { cert, err := x509.ParseCertificate(rawCert) if err != nil { continue } hash := sha256.Sum256(cert.Raw) if hex.EncodeToString(hash[:]) == expected { return nil } } return fmt.Errorf("certificate pinning failed: no certificate matches sha-256 %s", expected) } } 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)) // Close the connection when the context is cancelled so any // blocking Read returns immediately instead of waiting for the // 30-second deadline. The done channel bounds the goroutine to // the lifetime of this call: when Download returns, defer // close(done) lets the goroutine exit even if the parent context // was never cancelled. done := make(chan struct{}) defer close(done) go func() { select { case <-ctx.Done(): conn.Close() case <-done: } }() // 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, PinnedCertHash: req.PinnedCertHash, 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 // total bytes received from server (including skipped) var writer io.Writer var file *os.File resumed := false var startOffset int64 // bytes to skip on resume // Set up output destination. When req.Output is set and no external // Writer was provided, we open the file directly. This covers both // fresh downloads (create) and resume (append). When req.Writer is // set (caller manages the file), we use it as-is. if req.Output != "" && req.Output != "-" { // Resume is meaningful only when we own the output file. If the // caller already supplied a Writer we cannot append to Output // behind their back, so disable resume to keep the partial-state // sidecar consistent with what actually lands on disk. if req.Resume && req.Writer != nil && req.Verbose { fmt.Fprintf(os.Stderr, "[gemini] warning: --resume ignored because a Writer is set; output goes to the Writer only\n") } if req.Resume && req.Writer != nil { req.Resume = false } if req.Resume { canResume, resumeMeta, rerr := output.CanResume(req.Output, req.URL.String()) if rerr != nil { if req.Verbose { fmt.Fprintf(os.Stderr, "[gemini] Resume check failed: %v\n", rerr) } output.CleanupResumeFiles(req.Output) } else if canResume && resumeMeta != nil { file, rerr = os.OpenFile(req.Output, os.O_APPEND|os.O_WRONLY, 0644) if rerr != nil { return nil, core.NewFileError("failed to open file for resume", rerr) } defer file.Close() writer = file startOffset = resumeMeta.Downloaded resumed = true if req.Verbose { fmt.Fprintf(os.Stderr, "[gemini] Resuming from byte %d\n", startOffset) } } } if writer == nil && req.Writer == nil { // Fresh download — create the output file. dir := filepath.Dir(req.Output) if dir != "" && dir != "." { if err := os.MkdirAll(dir, 0755); err != nil { return nil, core.NewFileError("failed to create output directory", err) } } file, err = os.Create(req.Output) if err != nil { return nil, core.NewFileError("failed to create output file", err) } defer file.Close() writer = file } } if writer == nil { if req.Writer != nil { writer = req.Writer } else { writer = io.Discard } } // Gemini does not support range requests, so resume re-downloads // the full content and skips the bytes already saved to disk. var skipped int64 buf := make([]byte, 32*1024) for { select { case <-ctx.Done(): saveGeminiResume(req, totalRead) return nil, ctx.Err() default: } n, err := reader.Read(buf) if n > 0 { totalRead += int64(n) chunk := buf[:n] // On resume, discard bytes we already have on disk. if skipped < startOffset { remain := startOffset - skipped if int64(n) <= remain { skipped += int64(n) chunk = nil } else { skipped += remain chunk = chunk[int(remain):] } } if len(chunk) > 0 { if _, werr := writer.Write(chunk); werr != nil { saveGeminiResume(req, totalRead) return nil, core.NewFileError("failed to write gemini data", werr) } } if req.ProgressCallback != nil { speed := float64(totalRead) / time.Since(startTime).Seconds() req.ProgressCallback(totalRead, -1, speed) } } if err == io.EOF { // The connection may have been closed by our context // cancellation goroutine. If so, the EOF is not a genuine // end-of-data but a side-effect of the cancel. if ctx.Err() != nil { saveGeminiResume(req, totalRead) return nil, ctx.Err() } break } if err != nil { saveGeminiResume(req, totalRead) if ctx.Err() != nil { return nil, ctx.Err() } return nil, core.NewNetworkError("failed to read gemini response body", err, core.SafeURL(req.URL)) } } // Download complete — remove resume metadata sidecar. if req.Output != "" && req.Output != "-" { output.DeleteResumeMetadata(req.Output) } duration := time.Since(startTime) speed := float64(0) if duration.Seconds() > 0 { speed = float64(totalRead) / duration.Seconds() } return &core.DownloadResult{ BytesDownloaded: totalRead, TotalSize: totalRead, Duration: duration, Protocol: "GEMINI", IPVersion: 4, Speed: speed, OutputPath: req.Output, Resumed: resumed, }, nil } // saveGeminiResume persists partial Gemini download progress to the // standard `.goget.meta` sidecar. No-op for stdout writes or zero-byte // transfers. Gemini does not expose Content-Length in the response // header, so Total is recorded as 0 (size unknown). func saveGeminiResume(req *core.DownloadRequest, downloaded int64) { if req == nil || req.Output == "" || req.Output == "-" || downloaded <= 0 { return } meta := output.NewResumeMetadata(req.URL.String(), "", "", downloaded, 0) if err := meta.Save(req.Output); err != nil && req.Verbose { fmt.Fprintf(os.Stderr, "[gemini] Warning: failed to save resume metadata: %v\n", err) } }