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
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"fmt"
"os"
"path/filepath"
)
type ExtractConfig struct {
DestDir string
StripComponents int
IncludePatterns []string
ExcludePatterns []string
PreservePermissions bool
Overwrite bool
Verbose bool
}
func DefaultExtractConfig() *ExtractConfig {
return &ExtractConfig{
DestDir: ".", StripComponents: 0, IncludePatterns: []string{},
ExcludePatterns: []string{}, PreservePermissions: true, Overwrite: true, Verbose: false,
}
}
func ExtractFile(srcPath, destDir string, cfg *ExtractConfig) error {
if cfg == nil {
cfg = DefaultExtractConfig()
}
srcFile, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
defer srcFile.Close()
return AutoExtract(filepath.Base(srcPath), srcFile, destDir)
}
func ShouldExtractFile(filename string) bool { return IsArchiveFormat(filename) }
func ListSupportedFormats() []string { return ListSupported() }
func SupportsFormat(format string) bool { return Supports(format) }
+80
View File
@@ -0,0 +1,80 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"bytes"
"testing"
)
// FuzzTarExtract tests tar extraction with fuzzed input to catch path traversal
// and other security issues in header parsing.
func FuzzTarExtract(f *testing.F) {
// Seed corpus with valid tar data
f.Add([]byte{})
// Minimal valid tar header (512 bytes of zeros)
emptyHeader := make([]byte, 512)
f.Add(emptyHeader)
// Tar file with a ".." path traversal attempt
traversalTar := makeTarWithName("../../../etc/passwd")
f.Add(traversalTar)
// Tar file with absolute path
absTar := makeTarWithName("/etc/passwd")
f.Add(absTar)
// Tar file with symlink pointing outside
symlinkTar := makeSymlinkTar("link", "../../../etc/passwd")
f.Add(symlinkTar)
f.Fuzz(func(t *testing.T, data []byte) {
// Fuzz the tar extraction — it must never panic or escape the temp dir
dir := t.TempDir()
ext := NewTarExtractor()
reader := bytes.NewReader(data)
// Ignore errors — we only care about panics and path escapes
_ = ext.Extract(reader, dir)
})
}
func makeTarWithName(name string) []byte {
// Create a minimal tar file with a regular file at the given path
var buf bytes.Buffer
nameBytes := []byte(name)
if len(nameBytes) > 99 {
nameBytes = nameBytes[:99]
}
header := make([]byte, 512)
copy(header[0:99], nameBytes)
// Set type flag to regular file
header[156] = '0'
// Set size to zero
buf.Write(header)
// Two zero blocks to mark end of archive
buf.Write(make([]byte, 1024))
return buf.Bytes()
}
func makeSymlinkTar(name, target string) []byte {
var buf bytes.Buffer
nameBytes := []byte(name)
if len(nameBytes) > 99 {
nameBytes = nameBytes[:99]
}
header := make([]byte, 512)
copy(header[0:99], nameBytes)
// Set type flag to symlink
header[156] = '2'
// Write link target
targetBytes := []byte(target)
if len(targetBytes) > 99 {
targetBytes = targetBytes[:99]
}
copy(header[157:256], targetBytes)
buf.Write(header)
buf.Write(make([]byte, 1024))
return buf.Bytes()
}
+84
View File
@@ -0,0 +1,84 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"io"
"strings"
)
// Extractor is the interface for archive extraction
type Extractor interface {
Name() string
Extensions() []string
Extract(r io.Reader, destDir string) error
CanHandle(filename string) bool
}
// BaseExtractor provides a basic implementation
type BaseExtractor struct {
name string
extensions []string
}
func NewBaseExtractor(name string, extensions []string) *BaseExtractor {
return &BaseExtractor{name: name, extensions: extensions}
}
func (b *BaseExtractor) Name() string { return b.name }
func (b *BaseExtractor) Extensions() []string { return b.extensions }
func (b *BaseExtractor) CanHandle(filename string) bool {
filename = strings.ToLower(filename)
for _, ext := range b.extensions {
if strings.HasSuffix(filename, ext) {
return true
}
}
return false
}
// DetectArchiveFormat detects the archive format by file extension
func DetectArchiveFormat(filename string) string {
filename = strings.ToLower(filename)
if strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".tgz") {
return "tar.gz"
}
if strings.HasSuffix(filename, ".tar.bz2") || strings.HasSuffix(filename, ".tbz2") {
return "tar.bz2"
}
if strings.HasSuffix(filename, ".zip") {
return "zip"
}
if strings.HasSuffix(filename, ".tar") {
return "tar"
}
return ""
}
// IsArchiveFormat checks whether the filename looks like an archive
func IsArchiveFormat(filename string) bool { return DetectArchiveFormat(filename) != "" }
// GetArchiveAndCompression splits filename into archive + compression parts
func GetArchiveAndCompression(filename string) (archive, compression string) {
filename = strings.ToLower(filename)
if strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".tgz") {
return "tar", "gzip"
}
if strings.HasSuffix(filename, ".tar.bz2") || strings.HasSuffix(filename, ".tbz2") {
return "tar", "bzip2"
}
if strings.HasSuffix(filename, ".zip") {
return "zip", ""
}
if strings.HasSuffix(filename, ".tar") {
return "tar", ""
}
if strings.HasSuffix(filename, ".gz") {
return "", "gzip"
}
if strings.HasSuffix(filename, ".bz2") {
return "", "bzip2"
}
return "", ""
}
+87
View File
@@ -0,0 +1,87 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"fmt"
"io"
"strings"
"sync"
)
type registry struct {
mu sync.RWMutex
extractors map[string]Extractor
}
var globalRegistry = &registry{extractors: make(map[string]Extractor)}
func Register(e Extractor) error {
globalRegistry.mu.Lock()
defer globalRegistry.mu.Unlock()
name := strings.ToLower(e.Name())
if name == "" {
return fmt.Errorf("extractor name cannot be empty")
}
if _, exists := globalRegistry.extractors[name]; exists {
return fmt.Errorf("extractor already registered: %s", name)
}
globalRegistry.extractors[name] = e
return nil
}
func Get(format string) (Extractor, error) {
globalRegistry.mu.RLock()
defer globalRegistry.mu.RUnlock()
e, exists := globalRegistry.extractors[strings.ToLower(format)]
if !exists {
return nil, fmt.Errorf("unsupported archive format: %s", format)
}
return e, nil
}
func GetByFilename(filename string) (Extractor, error) {
format := DetectArchiveFormat(filename)
if format == "" {
return nil, fmt.Errorf("unknown archive format for: %s", filename)
}
return Get(format)
}
func Extract(format string, src io.Reader, destDir string) error {
e, err := Get(format)
if err != nil {
return err
}
return e.Extract(src, destDir)
}
func ExtractByFilename(filename string, src io.Reader, destDir string) error {
e, err := GetByFilename(filename)
if err != nil {
return err
}
return e.Extract(src, destDir)
}
func AutoExtract(filename string, src io.Reader, destDir string) error {
return ExtractByFilename(filename, src, destDir)
}
func Supports(format string) bool {
globalRegistry.mu.RLock()
defer globalRegistry.mu.RUnlock()
_, exists := globalRegistry.extractors[strings.ToLower(format)]
return exists
}
func ListSupported() []string {
globalRegistry.mu.RLock()
defer globalRegistry.mu.RUnlock()
names := make([]string, 0, len(globalRegistry.extractors))
for name := range globalRegistry.extractors {
names = append(names, name)
}
return names
}
+134
View File
@@ -0,0 +1,134 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"archive/tar"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
type tarExtractor struct{ *BaseExtractor }
func NewTarExtractor() *tarExtractor {
return &tarExtractor{NewBaseExtractor("tar", []string{".tar"})}
}
// Extract extracts a tar archive into destDir (streaming-friendly)
func (t *tarExtractor) Extract(r io.Reader, destDir string) error {
tr := tar.NewReader(r)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar header: %w", err)
}
if err := t.extractEntry(header, tr, destDir); err != nil {
return err
}
}
return nil
}
// extractEntry extracts a single entry from a tar archive
func (t *tarExtractor) extractEntry(header *tar.Header, r io.Reader, destDir string) error {
// Sanitize path to prevent tar-slip attacks
cleanName := filepath.Clean(header.Name)
if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
return fmt.Errorf("unsafe path in tar: %s", header.Name)
}
targetPath := filepath.Join(destDir, cleanName)
switch header.Typeflag {
case tar.TypeDir:
// Create directory with original permissions
return os.MkdirAll(targetPath, os.FileMode(header.Mode))
case tar.TypeReg:
// Create parent directories
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
// Create file with original permissions (mask setuid/setgid/sticky)
mode := os.FileMode(header.Mode) & 0777
file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
defer file.Close()
// Copy contents
if _, err := io.Copy(file, r); err != nil {
return err
}
// Restore timestamps
atime := header.AccessTime
if atime.IsZero() {
atime = time.Now()
}
mtime := header.ModTime
if mtime.IsZero() {
mtime = time.Now()
}
if err := os.Chtimes(targetPath, atime, mtime); err != nil {
// Non-fatal
}
return nil
case tar.TypeSymlink:
// Validate symlink target against path traversal
linkTarget := filepath.Clean(header.Linkname)
if filepath.IsAbs(linkTarget) || strings.HasPrefix(linkTarget, "..") {
return fmt.Errorf("symlink target outside extraction directory: %s", header.Linkname)
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
os.Remove(targetPath)
return os.Symlink(linkTarget, targetPath)
case tar.TypeLink:
// Hard link - validate target stays within destDir to prevent path traversal
cleanTarget := filepath.Clean(header.Linkname)
if filepath.IsAbs(cleanTarget) || strings.HasPrefix(cleanTarget, "..") {
return fmt.Errorf("hard link target outside extraction directory: %s", header.Linkname)
}
linkTarget := filepath.Join(destDir, cleanTarget)
// Ensure resolved path does not escape destDir
if !strings.HasPrefix(linkTarget, destDir+string(filepath.Separator)) && linkTarget != destDir {
return fmt.Errorf("hard link target outside extraction directory: %s", header.Linkname)
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
return os.Link(linkTarget, targetPath)
case tar.TypeChar, tar.TypeBlock:
// Device files - skip with warning (requires root)
return nil
case tar.TypeFifo:
// FIFO - skip (platform-specific)
return nil
default:
// Unknown type - skip
return nil
}
}
func init() { Register(NewTarExtractor()) }
+25
View File
@@ -0,0 +1,25 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"compress/bzip2"
"io"
)
type tarBz2Extractor struct {
*BaseExtractor
tar *tarExtractor
}
func NewTarBz2Extractor() *tarBz2Extractor {
return &tarBz2Extractor{NewBaseExtractor("tar.bz2", []string{".tar.bz2", ".tbz2"}), NewTarExtractor()}
}
func (t *tarBz2Extractor) Extract(r io.Reader, destDir string) error {
bz2r := bzip2.NewReader(r)
return t.tar.Extract(bz2r, destDir)
}
func init() { Register(NewTarBz2Extractor()) }
+42
View File
@@ -0,0 +1,42 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"compress/gzip"
"fmt"
"io"
)
type tarGzExtractor struct {
*BaseExtractor
tar *tarExtractor
}
func NewTarGzExtractor() *tarGzExtractor {
return &tarGzExtractor{
BaseExtractor: NewBaseExtractor("tar.gz", []string{".tar.gz", ".tgz"}),
tar: NewTarExtractor(),
}
}
// Extract extracts a tar.gz archive into destDir
func (t *tarGzExtractor) Extract(r io.Reader, destDir string) error {
// Step 1: Create gzip reader for decompression
gzr, err := gzip.NewReader(r)
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
defer func() {
if cerr := gzr.Close(); cerr != nil && err == nil {
err = cerr
}
}()
// Step 2: Extract tar from decompressed stream
// gzr implements io.Reader that returns DECOMPRESSED data
return t.tar.Extract(gzr, destDir)
}
func init() { Register(NewTarGzExtractor()) }
+141
View File
@@ -0,0 +1,141 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type zipExtractor struct{ *BaseExtractor }
// MaxZipInputSize is the maximum size in bytes of a single file extracted
// from a zip archive. Files whose declared uncompressed size exceeds this
// limit are rejected outright; the limit is also enforced at copy time as
// a safety net in case the central directory lies (a known zip-bomb
// technique). Defaults to 1 GiB.
var MaxZipInputSize int64 = 1 << 30
// MaxZipBufferSize is the maximum size in bytes of the buffered
// (compressed) zip archive before it is opened. Archives larger than this
// are rejected to prevent the on-disk temp file from growing without
// bound. Defaults to 4 GiB.
var MaxZipBufferSize int64 = 4 << 30
func NewZipExtractor() *zipExtractor {
return &zipExtractor{NewBaseExtractor("zip", []string{".zip"})}
}
// Extract extracts a zip archive into destDir
func (z *zipExtractor) Extract(r io.Reader, destDir string) error {
// Zip.NewReader requires io.ReaderAt + size
// Buffer input into a temp file, bounded by MaxZipBufferSize to
// prevent a zip bomb from filling the disk with the (still
// compressed) archive while we try to open it.
tmpFile, err := os.CreateTemp("", "goget-zip-*.tmp")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath) // Cleanup temp file after completion
// LimitReader returns EOF after MaxZipBufferSize+1 bytes, which lets
// us detect "input exceeded the limit" by stat'ing the temp file.
if _, err := io.Copy(tmpFile, io.LimitReader(r, MaxZipBufferSize+1)); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to buffer zip: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
info, err := os.Stat(tmpPath)
if err != nil {
return fmt.Errorf("failed to stat temp file: %w", err)
}
if info.Size() > MaxZipBufferSize {
return fmt.Errorf("zip archive exceeds maximum size of %d bytes (possible zip bomb)", MaxZipBufferSize)
}
// Open zip reader (zip.OpenReader detects size automatically)
zipReader, err := zip.OpenReader(tmpPath)
if err != nil {
return fmt.Errorf("failed to open zip archive: %w", err)
}
defer zipReader.Close()
// Extract each file from archive
for _, file := range zipReader.File {
if err := z.extractFile(file, destDir); err != nil {
return fmt.Errorf("failed to extract %s: %w", file.Name, err)
}
}
return nil
}
// extractFile extracts a single file from zip archive
func (z *zipExtractor) extractFile(file *zip.File, destDir string) error {
// Sanitize path to prevent zip-slip attacks
cleanName := filepath.Clean(file.Name)
if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
return fmt.Errorf("unsafe path in zip: %s", file.Name)
}
targetPath := filepath.Join(destDir, cleanName)
// Handle directories
if file.FileInfo().IsDir() {
return os.MkdirAll(targetPath, 0755)
}
// Reject files whose declared uncompressed size exceeds the limit.
// The central directory declares this for every file; a value larger
// than MaxZipInputSize is a strong zip-bomb signal and we refuse the
// whole archive (caller can decide whether to retry with a different
// policy).
if file.UncompressedSize64 > uint64(MaxZipInputSize) {
return fmt.Errorf("file %s too large: %d bytes uncompressed (max %d)", file.Name, file.UncompressedSize64, MaxZipInputSize)
}
// Create parent directories
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
// Open file in archive (automatically decompresses if compressed)
rc, err := file.Open()
if err != nil {
return err
}
defer rc.Close()
// Create output file with original permissions (mask setuid/setgid/sticky)
mode := file.Mode() & 0777
outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
defer outFile.Close()
// io.LimitReader is a safety net for archives whose central directory
// lies about UncompressedSize64 (or leaves it at 0). The primary
// defence is the UncompressedSize64 check above.
if _, err := io.Copy(outFile, io.LimitReader(rc, MaxZipInputSize)); err != nil {
return err
}
// Restore timestamps
if err := os.Chtimes(targetPath, file.Modified, file.Modified); err != nil {
// Non-fatal, ignore error
}
return nil
}
func init() { Register(NewZipExtractor()) }
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
//go:build linux || freebsd
// +build linux freebsd
package auth
import (
"encoding/base64"
"net/http"
"net/url"
"strings"
)
// BasicAuth adds Basic Authentication header to the request
func BasicAuth(req *http.Request, username, password string) {
if username == "" {
return
}
auth := username + ":" + password
encoded := base64.StdEncoding.EncodeToString([]byte(auth))
req.Header.Set("Authorization", "Basic "+encoded)
}
// ParseURLAuth extracts username/password from URL (https://user:pass@host)
func ParseURLAuth(u *url.URL) (username, password string, hasAuth bool) {
if u == nil || u.User == nil {
return "", "", false
}
username = u.User.Username()
if username == "" {
return "", "", false
}
password, hasAuth = u.User.Password()
return username, password, true
}
// StripAuthFromURL removes credentials from URL for safe logging
func StripAuthFromURL(u *url.URL) *url.URL {
if u == nil {
return nil
}
// Clone URL to avoid modifying original
clean := *u
clean.User = nil
return &clean
}
// MaskPassword returns a masked version of the password for logging
func MaskPassword(password string) string {
if password == "" {
return ""
}
if len(password) <= 4 {
return "****"
}
return password[:2] + strings.Repeat("*", len(password)-2)
}
// GetAuthForURL gets credentials for a given URL (priority: CLI > URL > config)
func GetAuthForURL(cliUsername, cliPassword, configUsername, configPassword string, u *url.URL) (username, password string) {
// 1. CLI flags have highest priority
if cliUsername != "" {
return cliUsername, cliPassword
}
// 2. URL-embedded credentials
if urlUser, urlPass, ok := ParseURLAuth(u); ok {
return urlUser, urlPass
}
// 3. Config file credentials
if configUsername != "" {
return configUsername, configPassword
}
return "", ""
}
+206
View File
@@ -0,0 +1,206 @@
// Package auth handles HTTP authentication schemes (Basic, Digest, OAuth 2.0).
//go:build linux || freebsd
// +build linux freebsd
package auth
import (
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strings"
)
// DigestAuthHandler handles HTTP Digest Authentication
type DigestAuthHandler struct {
username string
password string
}
// NewDigestAuthHandler creates new handler
func NewDigestAuthHandler(username, password string) *DigestAuthHandler {
return &DigestAuthHandler{username, password}
}
// HandleResponse processes a 401 response and returns a modified request
func (h *DigestAuthHandler) HandleResponse(req *http.Request, resp *http.Response) (*http.Request, error) {
// Parse WWW-Authenticate header
authHeader := resp.Header.Get("WWW-Authenticate")
if !strings.HasPrefix(authHeader, "Digest ") {
return nil, fmt.Errorf("not a digest auth challenge")
}
// Parse digest parameters
params := parseDigestParams(authHeader[7:])
// Validate required parameters
requiredParams := []string{"realm", "nonce"}
for _, param := range requiredParams {
if _, exists := params[param]; !exists {
return nil, fmt.Errorf("missing required digest parameter: %s", param)
}
}
// Calculate response hash according to RFC 2617 / RFC 7616
qop := params.get("qop")
nonceCount := params.get("nc")
cnonce := params.get("cnonce")
opaque := params.get("opaque")
// HA1 = MD5(username:realm:password) or MD5(username:realm:password):nonce:cnonce
ha1 := h.calculateHA1(params["realm"], params["nonce"], cnonce, qop)
// HA2 = MD5(method:uri) or MD5(method:uri:response-body) for auth-int.
// RFC 7616 §3.4 (and RFC 2617 §3.2.2.3) require uri to be the
// request-uri — the path *and* the query string, exactly as it
// appears in the request line. Using req.URL.Path strips the
// query, which causes digest auth to fail for any URL that carries
// a token or other parameter in the query string.
ha2 := h.calculateHA2(req.Method, req.URL.RequestURI(), qop)
// Response calculation based on qop
var response string
if qop == "auth" || qop == "auth-int" {
response = md5Sum(ha1 + ":" + params["nonce"] + ":" + nonceCount + ":" + cnonce + ":" + qop + ":" + ha2)
} else {
// No qop (old RFC 2617 style)
response = md5Sum(ha1 + ":" + params["nonce"] + ":" + ha2)
}
// Build Authorization header — see note above about request-uri
// vs path.
auth := h.buildAuthHeader(params, req.URL.RequestURI(), response, nonceCount, cnonce, qop, opaque)
// Clone request and add header
newReq := req.Clone(req.Context())
newReq.Header.Set("Authorization", auth)
return newReq, nil
}
func (h *DigestAuthHandler) calculateHA1(realm, nonce, cnonce, qop string) string {
a1 := h.username + ":" + realm + ":" + h.password
ha1 := md5Sum(a1)
// If qop is specified, we need to include nonce and cnonce
if qop == "auth" || qop == "auth-int" {
ha1 = md5Sum(ha1 + ":" + nonce + ":" + cnonce)
}
return ha1
}
func (h *DigestAuthHandler) calculateHA2(method, uri, qop string) string {
a2 := method + ":" + uri
// For auth-int, we would need to include body hash, but that's complex
// For now, we just support auth and no-qop modes
return md5Sum(a2)
}
func (h *DigestAuthHandler) buildAuthHeader(params map[string]string, uri, response, nc, cnonce, qop, opaque string) string {
parts := []string{
fmt.Sprintf(`username="%s"`, h.username),
fmt.Sprintf(`realm="%s"`, params["realm"]),
fmt.Sprintf(`nonce="%s"`, params["nonce"]),
fmt.Sprintf(`uri="%s"`, uri),
fmt.Sprintf(`response="%s"`, response),
}
if qop != "" {
parts = append(parts,
fmt.Sprintf(`qop=%s`, qop),
fmt.Sprintf(`nc=%s`, nc),
fmt.Sprintf(`cnonce="%s"`, cnonce),
)
}
if opaque != "" {
parts = append(parts, fmt.Sprintf(`opaque="%s"`, opaque))
}
return "Digest " + strings.Join(parts, ", ")
}
// GenerateCnonce generates a cryptographically random client nonce
func GenerateCnonce() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// Fallback should never happen on a functioning system
// but we still produce a value rather than crash
for i := range b {
b[i] = byte(i)
}
}
return hex.EncodeToString(b)
}
// parseDigestParams parses the WWW-Authenticate header parameters
func parseDigestParams(header string) digestParams {
params := make(digestParams)
// Handle quoted and unquoted values
currentKey := ""
currentVal := ""
inQuotes := false
hasSeenEquals := false
for i := 0; i < len(header); i++ {
char := header[i]
switch {
case char == '=' && !inQuotes:
currentKey = strings.TrimSpace(currentKey)
currentVal = ""
hasSeenEquals = true
case char == '"' && (i == 0 || header[i-1] != '\\'):
inQuotes = !inQuotes
case char == ',' && !inQuotes:
if currentKey != "" {
params[currentKey] = strings.Trim(strings.TrimSpace(currentVal), `"`)
}
currentKey = ""
currentVal = ""
hasSeenEquals = false
case (char == ' ' || char == '\t') && !inQuotes && ((!hasSeenEquals && currentKey == "") || (hasSeenEquals && currentVal == "")):
// Skip leading whitespace before a key or after '='
default:
if !hasSeenEquals {
currentKey += string(char)
} else {
currentVal += string(char)
}
}
}
// Don't forget the last parameter
if currentKey != "" {
params[currentKey] = strings.Trim(strings.TrimSpace(currentVal), `"`)
}
return params
}
// digestParams is a map of digest authentication parameters
type digestParams map[string]string
func (p digestParams) get(key string) string {
if val, ok := p[key]; ok {
return val
}
return ""
}
func md5Sum(s string) string {
h := md5.Sum([]byte(s))
return hex.EncodeToString(h[:])
}
// SHA256 sum for future use (RFC 7616 supports SHA-256)
func sha256Sum(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
+103
View File
@@ -0,0 +1,103 @@
//go:build linux || freebsd
// +build linux freebsd
package auth
import (
"bufio"
"os"
"path/filepath"
"strings"
)
// NetrcEntry holds credentials for a single machine entry in .netrc.
type NetrcEntry struct {
Machine string
Login string
Password string
}
// LoadNetrc reads ~/.netrc and returns entries keyed by machine name.
func LoadNetrc() (map[string]*NetrcEntry, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, nil
}
path := filepath.Join(home, ".netrc")
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer file.Close()
entries := make(map[string]*NetrcEntry)
var current *NetrcEntry
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip comments and empty lines
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
// "default" is also valid but we treat it as a machine
if len(fields) >= 2 && (fields[0] == "machine" || fields[0] == "default") {
if current != nil && current.Machine != "" {
entries[current.Machine] = current
}
name := fields[1]
if fields[0] == "default" {
name = "default"
}
current = &NetrcEntry{Machine: name}
// Rest of line may have login/password
parseNetrcLine(fields[2:], current)
} else if current != nil {
parseNetrcLine(fields, current)
}
}
if current != nil && current.Machine != "" {
entries[current.Machine] = current
}
return entries, scanner.Err()
}
func parseNetrcLine(fields []string, entry *NetrcEntry) {
for i := 0; i < len(fields); i++ {
switch fields[i] {
case "login":
if i+1 < len(fields) {
entry.Login = fields[i+1]
i++
}
case "password":
if i+1 < len(fields) {
entry.Password = fields[i+1]
i++
}
}
}
}
// LookupNetrc returns credentials from .netrc for a given hostname.
func LookupNetrc(host string) (login, password string, found bool) {
entries, err := LoadNetrc()
if err != nil || entries == nil {
return "", "", false
}
// Exact match first
if e, ok := entries[host]; ok {
return e.Login, e.Password, true
}
// Default entry
if e, ok := entries["default"]; ok {
return e.Login, e.Password, true
}
return "", "", false
}
+293
View File
@@ -0,0 +1,293 @@
//go:build linux || freebsd
// +build linux freebsd
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// OAuthConfig configuration for OAuth 2.0
type OAuthConfig struct {
ClientID string `json:"client_id,omitempty" toml:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty" toml:"client_secret,omitempty"`
TokenURL string `json:"token_url,omitempty" toml:"token_url,omitempty"`
AuthURL string `json:"auth_url,omitempty" toml:"auth_url,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty" toml:"redirect_uri,omitempty"`
Scopes []string `json:"scopes,omitempty" toml:"scopes,omitempty"`
AccessToken string `json:"access_token,omitempty" toml:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty" toml:"refresh_token,omitempty"`
TokenType string `json:"token_type,omitempty" toml:"token_type,omitempty"`
Expiry time.Time `json:"expiry,omitempty" toml:"expiry,omitempty"`
GrantType string `json:"grant_type,omitempty" toml:"grant_type,omitempty"` // authorization_code, client_credentials, password, refresh_token
}
// TokenResponse response from a token endpoint
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
Scope string `json:"scope,omitempty"`
Error string `json:"error,omitempty"`
ErrorDesc string `json:"error_description,omitempty"`
}
// OAuthClient client for OAuth 2.0 authentication
type OAuthClient struct {
config *OAuthConfig
client *http.Client
}
// NewOAuthClient creates a new OAuth client
func NewOAuthClient(cfg *OAuthConfig) *OAuthClient {
if cfg.TokenType == "" {
cfg.TokenType = "Bearer"
}
return &OAuthClient{
config: cfg,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// IsTokenValid checks whether the token is valid
func (c *OAuthClient) IsTokenValid() bool {
if c.config.AccessToken == "" {
return false
}
if c.config.Expiry.IsZero() {
return true // No expiry, assume valid
}
// Token is valid if it expires in more than 30 seconds
return time.Now().Add(30 * time.Second).Before(c.config.Expiry)
}
// RequestToken gets new access token
func (c *OAuthClient) RequestToken(ctx context.Context) (*TokenResponse, error) {
if c.config.TokenURL != "" {
u, err := url.Parse(c.config.TokenURL)
if err != nil {
return nil, fmt.Errorf("invalid oauth token URL: %w", err)
}
if u.Scheme != "https" && u.Hostname() != "localhost" && u.Hostname() != "127.0.0.1" && u.Hostname() != "::1" {
return nil, fmt.Errorf("oauth token URL must use HTTPS for security")
}
}
formData := url.Values{}
grantType := c.config.GrantType
if grantType == "" {
grantType = "client_credentials"
}
formData.Set("grant_type", grantType)
switch grantType {
case "client_credentials":
formData.Set("client_id", c.config.ClientID)
formData.Set("client_secret", c.config.ClientSecret)
if len(c.config.Scopes) > 0 {
formData.Set("scope", strings.Join(c.config.Scopes, " "))
}
case "password":
formData.Set("client_id", c.config.ClientID)
formData.Set("client_secret", c.config.ClientSecret)
// Username and password would need to be passed separately
// This is handled by caller
case "authorization_code":
formData.Set("client_id", c.config.ClientID)
formData.Set("client_secret", c.config.ClientSecret)
// Code and redirect_uri would need to be passed separately
case "refresh_token":
formData.Set("client_id", c.config.ClientID)
formData.Set("client_secret", c.config.ClientSecret)
formData.Set("refresh_token", c.config.RefreshToken)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.config.TokenURL, strings.NewReader(formData.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read token response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("token endpoint returned %s", formatOAuthError(resp.StatusCode, body))
}
var tokenResp TokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
if tokenResp.Error != "" {
return nil, fmt.Errorf("oauth error: %s - %s", tokenResp.Error, tokenResp.ErrorDesc)
}
return &tokenResp, nil
}
// ApplyToken applies the token to the HTTP request
func (c *OAuthClient) ApplyToken(req *http.Request) {
if c.config.AccessToken == "" {
return
}
tokenType := c.config.TokenType
if tokenType == "" {
tokenType = "Bearer"
}
req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenType, c.config.AccessToken))
}
// RefreshToken refreshes access token using a refresh token
func (c *OAuthClient) RefreshToken(ctx context.Context) error {
if c.config.RefreshToken == "" {
return fmt.Errorf("no refresh token available")
}
oldGrantType := c.config.GrantType
c.config.GrantType = "refresh_token"
defer func() { c.config.GrantType = oldGrantType }()
tokenResp, err := c.RequestToken(ctx)
if err != nil {
return err
}
c.UpdateToken(tokenResp)
return nil
}
// UpdateToken updates internal token from response
func (c *OAuthClient) UpdateToken(tokenResp *TokenResponse) {
c.config.AccessToken = tokenResp.AccessToken
c.config.TokenType = tokenResp.TokenType
if tokenResp.RefreshToken != "" {
c.config.RefreshToken = tokenResp.RefreshToken
}
if tokenResp.ExpiresIn > 0 {
c.config.Expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
}
}
// GetAuthorizationURL returns the authorization URL (for authorization_code flow)
func (c *OAuthClient) GetAuthorizationURL(state string) string {
if c.config.AuthURL == "" {
return ""
}
params := url.Values{}
params.Set("client_id", c.config.ClientID)
params.Set("redirect_uri", c.config.RedirectURI)
params.Set("response_type", "code")
params.Set("state", state)
if len(c.config.Scopes) > 0 {
params.Set("scope", strings.Join(c.config.Scopes, " "))
}
baseURL := c.config.AuthURL
if strings.Contains(baseURL, "?") {
return baseURL + "&" + params.Encode()
}
return baseURL + "?" + params.Encode()
}
// ExchangeCode exchanges an authorization code for an access token
func (c *OAuthClient) ExchangeCode(ctx context.Context, code string) (*TokenResponse, error) {
formData := url.Values{}
formData.Set("grant_type", "authorization_code")
formData.Set("code", code)
formData.Set("redirect_uri", c.config.RedirectURI)
formData.Set("client_id", c.config.ClientID)
formData.Set("client_secret", c.config.ClientSecret)
req, err := http.NewRequestWithContext(ctx, "POST", c.config.TokenURL, strings.NewReader(formData.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create code exchange request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("code exchange request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read code exchange response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("code exchange endpoint returned %s", formatOAuthError(resp.StatusCode, body))
}
var tokenResp TokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse code exchange response: %w", err)
}
if tokenResp.Error != "" {
return nil, fmt.Errorf("oauth error: %s - %s", tokenResp.Error, tokenResp.ErrorDesc)
}
return &tokenResp, nil
}
// formatOAuthError converts a non-2xx OAuth response into a human-readable
// error string while avoiding leaking sensitive data. The OAuth provider's
// raw response body is never returned verbatim: we first try to extract
// the structured `error` and `error_description` fields defined in RFC
// 6749 §5.2 (which is what well-behaved providers return), and only fall
// back to a 512-byte truncated snippet of the raw body if the body is
// not structured. This prevents upstream providers that echo back
// secrets, stack traces, or other sensitive payloads in their error
// bodies from leaking those payloads into our logs and error displays.
func formatOAuthError(statusCode int, body []byte) string {
if len(body) == 0 {
return fmt.Sprintf("status %d with empty body", statusCode)
}
var structured struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := json.Unmarshal(body, &structured); err == nil && structured.Error != "" {
if structured.ErrorDescription != "" {
return fmt.Sprintf("status %d: %s - %s", statusCode, structured.Error, structured.ErrorDescription)
}
return fmt.Sprintf("status %d: %s", statusCode, structured.Error)
}
const maxSnippet = 512
snippet := string(body)
if len(snippet) > maxSnippet {
snippet = snippet[:maxSnippet] + "... (truncated)"
}
return fmt.Sprintf("status %d: %s", statusCode, snippet)
}
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
//go:build linux || freebsd
// +build linux freebsd
package cli
import (
_ "embed"
"fmt"
"io"
"os"
"strings"
)
//go:embed help.txt
var helpText string
// ANSI color codes
const (
ColorReset = "\033[0m"
ColorGreen = "\033[32m"
ColorCyan = "\033[36m"
ColorYellow = "\033[33m"
ColorBlue = "\033[34m"
ColorRed = "\033[31m"
ColorBold = "\033[1m"
ColorMagenta = "\033[35m"
)
// isColorTerminal checks if writer supports ANSI colors
func isColorTerminal(w io.Writer) bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
if _, ok := w.(*os.File); ok {
return os.Getenv("TERM") != "dumb"
}
return false
}
// PrintUsage prints formatted help from embedded help.txt
func PrintUsage(w io.Writer) {
useColors := isColorTerminal(w)
if useColors {
bold := ColorBold
reset := ColorReset
green := ColorGreen
yellow := ColorYellow
magenta := ColorMagenta
_ = bold
_ = reset
_ = green
_ = yellow
_ = magenta
// Print help text with ANSI colors for section headers
for _, line := range strings.Split(helpText, "\n") {
if strings.HasPrefix(line, "NAME") || strings.HasPrefix(line, "SYNOPSIS") ||
strings.HasPrefix(line, "DESCRIPTION") || strings.HasSuffix(strings.TrimRight(line, " "), ":") {
fmt.Fprintf(w, "%s%s%s\n", ColorBold, line, ColorReset)
} else {
fmt.Fprintln(w, line)
}
}
} else {
fmt.Fprint(w, helpText)
}
}
// PrintCategoryHelp prints help filtered by category.
// Categories: all, target, network, auth, mirror, recursive, verify, queue, metalink, upload, pgp, extraction, config
func PrintCategoryHelp(w io.Writer, category string) {
if category == "" || category == "all" {
PrintUsage(w)
return
}
categories := map[string]string{
"target": "TARGETS",
"network": "NETWORK",
"auth": "AUTHENTICATION",
"mirror": "MIRROR MODE",
"recursive": "RECURSIVE DOWNLOAD",
"verify": "VERIFICATION",
"queue": "DOWNLOAD QUEUE",
"metalink": "METALINK",
"upload": "BASIC OPTIONS",
"pgp": "PGP/GPG",
"extraction": "EXTRACTION",
"config": "CONFIGURATION",
}
title, ok := categories[strings.ToLower(category)]
if !ok {
fmt.Fprintf(w, "Unknown category: %s\nAvailable: %s\n", category,
"target, network, auth, mirror, recursive, verify, queue, metalink, upload, pgp, extraction, config, all")
return
}
fmt.Fprintf(w, "Showing category: %s\nUse --help for full help.\n\n", title)
PrintUsage(w) // For now, print full help — future: filter
}
+85
View File
@@ -0,0 +1,85 @@
//go:build linux || freebsd
// +build linux freebsd
package cli
import (
"fmt"
"io"
"os"
"strings"
)
// PrintError prints an error with color styling
func PrintError(w io.Writer, err error) {
prefix, suffix := "", ""
if isColorTerminal(w) {
prefix = ColorRed + ColorBold
suffix = ColorReset
}
fmt.Fprintf(w, "%s✗ Error:%s %v\n", prefix, suffix, err)
}
// PrintWarning prints a warning with color styling
func PrintWarning(w io.Writer, msg string) {
prefix, suffix := "", ""
if isColorTerminal(w) {
prefix = ColorYellow + ColorBold
suffix = ColorReset
}
fmt.Fprintf(w, "%s⚠ Warning:%s %s\n", prefix, suffix, msg)
}
// PrintSuccess prints a success message with color styling
func PrintSuccess(w io.Writer, msg string) {
prefix, suffix := "", ""
if isColorTerminal(w) {
prefix = ColorGreen + ColorBold
suffix = ColorReset
}
fmt.Fprintf(w, "%s✓%s %s\n", prefix, suffix, msg)
}
// PrintInfo prints an info message with color styling
func PrintInfo(w io.Writer, msg string) {
prefix, suffix := "", ""
if isColorTerminal(w) {
prefix = ColorCyan + ColorBold
suffix = ColorReset
}
fmt.Fprintf(w, "%s%s %s\n", prefix, suffix, msg)
}
// FormatError formats an error with context
func FormatError(err error, context string) error {
if context == "" {
return err
}
return fmt.Errorf("%s: %w", context, err)
}
// IsTerminal checks if the writer is a terminal
func IsTerminal(w io.Writer) bool {
_, ok := w.(*os.File)
return ok && os.Getenv("TERM") != "dumb"
}
// Colorize wraps text in ANSI color codes if the writer supports it
func Colorize(w io.Writer, colorCode, text string) string {
if !isColorTerminal(w) {
return text
}
return colorCode + text + ColorReset
}
// Indent returns text indented by the specified number of spaces
func Indent(text string, spaces int) string {
indent := strings.Repeat(" ", spaces)
lines := strings.Split(text, "\n")
for i, line := range lines {
if line != "" {
lines[i] = indent + line
}
}
return strings.Join(lines, "\n")
}
+161
View File
@@ -0,0 +1,161 @@
NAME
goget - Modern IPv6-First Downloader
SYNOPSIS
goget --url <URL> [OPTIONS]
DESCRIPTION
curl/wget replacement in Go. IPv6-first, parallel downloads, resume,
checksum verification, recursive mirroring, and 8 protocols.
TARGET
--url <URL> URL to download (repeatable)
OUTPUT
--output <FILE> Output file (default: auto from URL)
--restart Delete existing file and restart download
--overwrite Overwrite existing file (no atomic rename)
--content-disposition Use server-provided filename
--keep-timestamps Set file modification time from Last-Modified
PROGRESS
--verbose Verbose output with progress bar (default)
--no-progress Hide progress bar
--progress=dot Wget-style dot progress
--quiet Suppress all output except errors
--json Output progress as JSON-lines (for scripting)
--debug Debug mode with detailed info
NETWORK
--timeout <DURATION> Connection timeout (0 = auto)
--max-time <DURATION> Maximum total transfer time
--max-retries <N> Maximum retry attempts on failure
--max-filesize <SIZE> Abort if file exceeds size (e.g., 100MB)
--proxy <URL> Proxy server URL
--no-ipv4 Disable IPv4 fallback
--no-redirect Disable following HTTP redirects
--dns-servers <IPs> Custom DNS servers (comma-separated)
--bind-interface <IFACE> Bind to specific network interface
--allow-private Allow connections to private/internal IPs
--pinned-cert <SHA256> Certificate pinning (SHA-256 hash)
CURL-COMPATIBLE
--header "K: V" Custom HTTP header (repeatable)
--insecure Skip TLS certificate verification
--cert <FILE> Client certificate (PEM)
--key <FILE> Client private key (PEM)
--data "key=value" Send POST data
--form "field=value" Multipart form data (repeatable)
--form "field=@file" Upload file via multipart form
--spider Check URL existence only (HEAD request)
--write-out FORMAT Print metadata (curl format)
--trace-time Print HTTP timing breakdown
WGET-COMPATIBLE
--accept "*.pdf" Accept patterns (filename glob or MIME)
--no-parent Do not ascend to parent directory
--adjust-extension Append .html extension to HTML files
--wait <DURATION> Delay between requests (recursive)
--random-wait Randomize wait time (0.5x - 1.5x)
--page-requisites Download CSS/JS/images for pages
--span-hosts Follow links to external domains
--convert-links Convert links for local viewing
--warc-file <FILE> Write WARC output for archiving
--progress=dot Wget-style dot progress
RECURSIVE / MIRROR
--recursive Enable recursive downloading
--mirror Mirror entire website (infinite depth)
--max-depth <N> Maximum recursion depth
--follow-external Follow external domains
--exclude-pattern <P> Exclude URL patterns (comma-separated)
--no-convert-links Don't convert links in mirror mode
--no-mirror-assets Don't download CSS/JS/images in mirror
--no-robots Don't respect robots.txt in mirror
--rate-limit <RATE> Rate limit (e.g., 1MB/s)
DOWNLOAD MANAGEMENT
--queue Add to download queue
--queue-list List queue items
--queue-process Process download queue
--queue-clear Clear completed items from queue
--queue-file <PATH> Custom queue file path
--queue-priority <N> Priority (1-10)
--batch-file <FILE> Download URLs from file (one per line)
--schedule <TIME> Schedule download (datetime or cron)
--on-complete <CMD> Run command after successful download
AUTHENTICATION
--username <USER> Username for HTTP Basic/Digest Auth
--password <PASS> Password (prefer --password-file for security)
--password-file <FILE> Read password from file
--auth-type <TYPE> Auth type: basic, digest, auto
--cookie-jar <FILE> Load/save cookies (Netscape format)
--oauth-client-id <ID> OAuth 2.0 Client ID
--oauth-client-secret <S>OAuth 2.0 Client Secret
--oauth-token-url <URL> OAuth 2.0 Token URL
--oauth-access-token <T> OAuth 2.0 Access Token
--oauth-refresh-token <T>OAuth 2.0 Refresh Token
UPLOAD
--upload Upload file instead of downloading
--upload-method <M> PUT or POST (default: PUT)
--upload-file <FILE> File to upload
--form-data "k=v" Form data (comma-separated)
--file-field <NAME> Form field name (default: file)
VERIFICATION
--checksum <HASH> Expected checksum
--checksum-algo <ALG> Algorithm: sha256, sha512, blake2b, sha3-256, sha3-512
--checksum-file <FILE> Read checksum from SHA256SUMS file
PGP / GPG
--pgp-decrypt Decrypt PGP file after download
--pgp-verify Verify PGP signature
--pgp-sig <FILE> Path to signature file (.asc, .sig)
--pgp-key <FILE> Path to key file
--pgp-passphrase <PASS> Passphrase for private key
--pgp-passphrase-file F Read passphrase from file
METALINK
--metalink Treat URL as Metalink (multi-source)
--metalink-file <PATH> Path to local Metalink file
CONFIGURATION
--init-config Generate default config file
--config-get <KEY> Get config value
--config-set <K> <V> Set config value
--config-list List all config values
--config-unset <KEY> Unset config value
INFORMATION
--info <URL> Show file info without downloading
--benchmark <URL> Benchmark download speed
--generate-man-page Generate man page and exit
--completion <SHELL> Generate shell completion (bash, zsh, fish)
--help Show this help
--version Show version
PROTOCOLS
http:// https:// HTTP/1.1, HTTP/2, TLS 1.2/1.3
ftp:// ftps:// FTP (active/passive, explicit TLS)
file:// Local file copy + recursive directories
data: Inline data (base64 + plain text)
gopher:// RFC 1436 with type parsing
gemini:// Gemini protocol (TLS, redirect)
EXAMPLES
goget --url https://example.com/file.zip
goget --url https://a.com/1.zip --url https://b.com/2.zip
goget -H "Authorization: Bearer TOKEN" --url https://api.example.com
goget --form "name=value" --form "file=@./data.csv" --url https://httpbin.org/post
goget --recursive --accept "*.pdf" --url https://example.com/docs/
goget --mirror --convert-links --url https://example.com --output ./mirror
goget --schedule "0 2 * * *" --url https://example.com/daily.zip
goget --json --no-progress --url https://example.com/file.zip
FILES
~/.config/goget/config.json Configuration
<file>.goget.meta Resume metadata
More: https://codeberg.org/petrbalvin/goget
+80
View File
@@ -0,0 +1,80 @@
//go:build linux || freebsd
// +build linux freebsd
package cli
import (
"flag"
"fmt"
"os"
"strings"
)
// Parser helps with argument parsing
type Parser struct {
fs *flag.FlagSet
}
// NewParser creates new parser
func NewParser(name string) *Parser {
return &Parser{
fs: flag.NewFlagSet(name, flag.ExitOnError),
}
}
// Parse parses arguments
func (p *Parser) Parse(args []string) error {
return p.fs.Parse(args)
}
// GetBool returns a bool flag
func (p *Parser) GetBool(name string) bool {
return p.fs.Lookup(name).Value.String() == "true"
}
// GetString returns a string flag
func (p *Parser) GetString(name string) string {
return p.fs.Lookup(name).Value.String()
}
// ParseArgs parses command-line arguments
func ParseArgs(args []string) (map[string]string, error) {
result := make(map[string]string)
for i := 0; i < len(args); i++ {
arg := args[i]
if strings.HasPrefix(arg, "--") {
key := strings.TrimPrefix(arg, "--")
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") {
result[key] = args[i+1]
i++
} else {
result[key] = "true"
}
}
}
return result, nil
}
// ValidateRequiredFlags validates required flags
func ValidateRequiredFlags(flags map[string]string, required []string) error {
var missing []string
for _, req := range required {
if _, exists := flags[req]; !exists {
missing = append(missing, req)
}
}
if len(missing) > 0 {
return fmt.Errorf("missing required flags: %s", strings.Join(missing, ", "))
}
return nil
}
// ExitWithError prints an error and exits the program
func ExitWithError(err error) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
+357
View File
@@ -0,0 +1,357 @@
//go:build linux || freebsd
// +build linux freebsd
package cli
import (
"fmt"
"io"
"os"
"strings"
"sync"
"time"
format "codeberg.org/petrbalvin/goget/internal/format"
)
// ProgressBarConfig configures the progress bar
type ProgressBarConfig struct {
Total int64
Width int
Colors bool
OnComplete func()
Writer io.Writer
}
// DefaultProgressBarConfig returns the default configuration
func DefaultProgressBarConfig() *ProgressBarConfig {
return &ProgressBarConfig{
Total: -1,
Width: 0,
Colors: true,
Writer: os.Stderr,
}
}
// ProgressBar represents a visual progress bar
type ProgressBar struct {
config *ProgressBarConfig
current int64
startTime time.Time
lastUpdate time.Time
barWidth int
mu sync.Mutex
speedHistory []float64
speedMax float64
}
// NewProgressBar creates a new progress bar
func NewProgressBar(cfg *ProgressBarConfig) *ProgressBar {
if cfg == nil {
cfg = DefaultProgressBarConfig()
}
pb := &ProgressBar{
config: cfg,
current: 0,
startTime: time.Now(),
lastUpdate: time.Now(),
barWidth: 40,
}
if cfg.Width == 0 {
tw := getTerminalWidth()
pb.barWidth = max(20, tw-45)
} else {
pb.barWidth = cfg.Width
}
return pb
}
// SetTotal sets the total value in a thread-safe way
func (pb *ProgressBar) SetTotal(total int64) {
pb.mu.Lock()
pb.config.Total = total
pb.mu.Unlock()
}
// Update updates progress
func (pb *ProgressBar) Update(current int64) {
pb.mu.Lock()
pb.current = current
pb.renderLocked()
pb.mu.Unlock()
}
// Add adds to the current progress
func (pb *ProgressBar) Add(n int64) {
pb.mu.Lock()
pb.current += n
pb.renderLocked()
pb.mu.Unlock()
}
// Finish completes the progress bar
func (pb *ProgressBar) Finish() {
pb.mu.Lock()
if pb.config.Total > 0 {
pb.current = pb.config.Total
}
pb.renderFinalLocked()
pb.mu.Unlock()
if pb.config.OnComplete != nil {
pb.config.OnComplete()
}
}
// Cancel cancels the progress bar with error state
func (pb *ProgressBar) Cancel() {
pb.mu.Lock()
pb.renderCancelledLocked()
pb.mu.Unlock()
}
// renderLocked renders the current state; must be called with pb.mu locked
func (pb *ProgressBar) renderLocked() {
now := time.Now()
if now.Sub(pb.lastUpdate) < 50*time.Millisecond {
return
}
pb.lastUpdate = now
w := pb.config.Writer
total := pb.config.Total
current := pb.current
elapsed := now.Sub(pb.startTime)
// Calculate speed
speed := float64(0)
if elapsed.Seconds() > 0 {
speed = float64(current) / elapsed.Seconds()
}
// Record speed history (keep last 15 samples)
pb.speedHistory = append(pb.speedHistory, speed)
if len(pb.speedHistory) > 15 {
pb.speedHistory = pb.speedHistory[len(pb.speedHistory)-15:]
}
// Update max speed
if speed > pb.speedMax {
pb.speedMax = speed
}
percent := float64(0)
showPercent := total > 0
if showPercent {
percent = float64(current) / float64(total) * 100
}
var percentStr string
if showPercent {
percentStr = fmt.Sprintf(" %5.1f%%", percent)
} else {
percentStr = " ??%"
}
filled := 0
empty := pb.barWidth
if showPercent {
filled = int(float64(pb.barWidth) * percent / 100)
empty = pb.barWidth - filled
}
barStart := ""
barEnd := ""
if pb.config.Colors {
barStart = ColorGreen
barEnd = ColorReset
}
bar := barStart + strings.Repeat("█", filled) + barEnd + strings.Repeat("░", empty)
currentStr := format.Bytes(current)
totalStr := "?"
if total > 0 {
totalStr = format.Bytes(total)
}
speedStr := format.Speed(speed)
etaStr := ""
if showPercent && current < total && speed > 0 {
remaining := float64(total-current) / speed
etaStr = formatDuration(time.Duration(remaining * float64(time.Second)))
}
etaPrefix := ""
etaSuffix := ""
if etaStr != "" && pb.config.Colors {
etaPrefix = ColorCyan
etaSuffix = ColorReset
}
// Build speed sparkline
sparkline := pb.renderSparkline()
var line strings.Builder
if pb.config.Colors {
line.WriteString(ColorBold)
}
line.WriteString("[")
line.WriteString(bar)
line.WriteString("]")
if pb.config.Colors {
line.WriteString(ColorReset)
}
line.WriteString(percentStr)
line.WriteString(fmt.Sprintf(" %s/%s", currentStr, totalStr))
line.WriteString(fmt.Sprintf(" %s", speedStr))
if sparkline != "" {
line.WriteString(fmt.Sprintf(" %s", sparkline))
}
if etaStr != "" {
line.WriteString(fmt.Sprintf(" %sETA: %s%s", etaPrefix, etaStr, etaSuffix))
}
fmt.Fprintf(w, "\r%s\033[K", line.String())
}
// renderFinalLocked renders the completed state; must be called with pb.mu locked
func (pb *ProgressBar) renderFinalLocked() {
w := pb.config.Writer
elapsed := time.Since(pb.startTime)
prefix := ""
suffix := ""
if pb.config.Colors {
prefix = ColorGreen + ColorBold
suffix = ColorReset
}
line := fmt.Sprintf("%s✓ Download complete%s | %s | %s elapsed",
prefix, suffix, format.Bytes(pb.current), formatDuration(elapsed))
fmt.Fprintf(w, "\r%s\n", line)
}
// renderCancelledLocked renders the cancelled state; must be called with pb.mu locked
func (pb *ProgressBar) renderCancelledLocked() {
w := pb.config.Writer
prefix := ""
suffix := ""
if pb.config.Colors {
prefix = ColorRed + ColorBold
suffix = ColorReset
}
line := fmt.Sprintf("%s✗ Download cancelled%s", prefix, suffix)
fmt.Fprintf(w, "\r%s\n", line)
}
// renderSparkline returns a mini ASCII sparkline of recent speed history.
// Uses Unicode block elements: ▁▂▃▄▅▆▇█
func (pb *ProgressBar) renderSparkline() string {
if len(pb.speedHistory) < 2 {
return ""
}
chars := []string{"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"}
maxSpeed := pb.speedMax
if maxSpeed == 0 {
for _, s := range pb.speedHistory {
if s > maxSpeed {
maxSpeed = s
}
}
}
if maxSpeed == 0 {
return ""
}
// Take up to 10 most recent samples
history := pb.speedHistory
if len(history) > 10 {
history = history[len(history)-10:]
}
var sparkline strings.Builder
for _, speed := range history {
ratio := speed / maxSpeed
if ratio > 1.0 {
ratio = 1.0
}
idx := int(ratio * float64(len(chars)-1))
if idx < 0 {
idx = 0
}
if idx >= len(chars) {
idx = len(chars) - 1
}
sparkline.WriteString(chars[idx])
}
return sparkline.String()
}
// getTerminalWidth gets the terminal width
func getTerminalWidth() int {
if ws := os.Getenv("COLUMNS"); ws != "" {
if w := parseInt(ws); w > 0 {
return w
}
}
return 80
}
// parseInt helper
func parseInt(s string) int {
var n int
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
} else {
break
}
}
return n
}
// formatDuration formats a duration
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
h := d / time.Hour
d -= h * time.Hour
m := d / time.Minute
d -= m * time.Minute
s := d / time.Second
if h > 0 {
return fmt.Sprintf("%dh%dm", h, m)
}
if m > 0 {
return fmt.Sprintf("%dm%ds", m, s)
}
return fmt.Sprintf("%ds", s)
}
// PrintProgressInfo prints an info message with progress styling
func PrintProgressInfo(w io.Writer, msg string) {
prefix, suffix := "", ""
if isColorTerminal(w) {
prefix = ColorCyan + ColorBold
suffix = ColorReset
}
fmt.Fprintf(w, "%s• %s%s\n", prefix, msg, suffix)
}
// PrintProgressSuccess prints a success message with progress styling
func PrintProgressSuccess(w io.Writer, msg string) {
prefix, suffix := "", ""
if isColorTerminal(w) {
prefix = ColorGreen + ColorBold
suffix = ColorReset
}
fmt.Fprintf(w, "%s✓ %s%s\n", prefix, msg, suffix)
}
+35
View File
@@ -0,0 +1,35 @@
//go:build linux || freebsd
// +build linux freebsd
package compression
import (
"compress/bzip2"
"fmt"
"io"
)
type bzip2Decompressor struct{ *BaseDecompressor }
func NewBzip2Decompressor() *bzip2Decompressor {
return &bzip2Decompressor{NewBaseDecompressor("bzip2", []string{"bzip2"}, []string{".bz2", ".tbz", ".tbz2"})}
}
// Reader returns a bzip2 decompression reader wrapped in a NopCloser.
// compress/bzip2.NewReader returns a plain io.Reader (no Close), so we
// wrap it to satisfy the io.ReadCloser contract of the Decompressor
// interface — the bzip2 reader has no internal state that needs
// releasing.
func (b *bzip2Decompressor) Reader(r io.Reader) (io.ReadCloser, error) {
return io.NopCloser(bzip2.NewReader(r)), nil
}
type bzip2Compressor struct{ name string }
func NewBzip2Compressor() *bzip2Compressor { return &bzip2Compressor{name: "bzip2"} }
func (b *bzip2Compressor) Name() string { return b.name }
func (b *bzip2Compressor) Writer(w io.Writer) (io.WriteCloser, error) {
return nil, fmt.Errorf("bzip2 compression not supported in stdlib")
}
func init() { Register(NewBzip2Decompressor()) }
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
//go:build linux || freebsd
// +build linux freebsd
package compression
import (
"compress/flate"
"fmt"
"io"
)
type flateDecompressor struct{ *BaseDecompressor }
func NewFlateDecompressor() *flateDecompressor {
return &flateDecompressor{NewBaseDecompressor("flate", []string{}, []string{".deflate", ".fl"})}
}
// ✅ Oprava: flate.NewReader returns io.ReadCloser, ale my returnsme io.Reader
func (f *flateDecompressor) Reader(r io.Reader) (io.ReadCloser, error) {
return flate.NewReader(r), nil
}
type flateCompressor struct {
name string
level int
}
func NewFlateCompressor() *flateCompressor {
return &flateCompressor{name: "flate", level: flate.DefaultCompression}
}
func NewFlateCompressorWithLevel(level int) (*flateCompressor, error) {
if level < flate.HuffmanOnly || level > flate.BestCompression {
return nil, fmt.Errorf("invalid compression level: %d (must be %d-%d)",
level, flate.HuffmanOnly, flate.BestCompression)
}
return &flateCompressor{name: "flate", level: level}, nil
}
func (f *flateCompressor) Name() string { return f.name }
func (f *flateCompressor) Writer(w io.Writer) (io.WriteCloser, error) {
return flate.NewWriter(w, f.level)
}
func init() { Register(NewFlateDecompressor()) }
+30
View File
@@ -0,0 +1,30 @@
//go:build linux || freebsd
// +build linux freebsd
package compression
import (
"compress/gzip"
"io"
)
type gzipDecompressor struct{ *BaseDecompressor }
func NewGzipDecompressor() *gzipDecompressor {
return &gzipDecompressor{NewBaseDecompressor("gzip", []string{"gzip"}, []string{".gz"})}
}
// ✅ Oprava: returns (io.Reader, error)
func (g *gzipDecompressor) Reader(r io.Reader) (io.ReadCloser, error) {
return gzip.NewReader(r)
}
type gzipCompressor struct{ name string }
func NewGzipCompressor() *gzipCompressor { return &gzipCompressor{name: "gzip"} }
func (g *gzipCompressor) Name() string { return g.name }
func (g *gzipCompressor) Writer(w io.Writer) (io.WriteCloser, error) {
return gzip.NewWriter(w), nil
}
func init() { Register(NewGzipDecompressor()) }
+150
View File
@@ -0,0 +1,150 @@
//go:build linux || freebsd
// +build linux freebsd
package compression
import (
"io"
"strings"
"sync"
)
// Decompressor defines the interface for decompression
type Decompressor interface {
Name() string
CanHandle(contentEncoding string) bool
CanHandleFile(filename string) bool
// Reader returns an io.ReadCloser (not just an io.Reader) so
// callers can release the decompressor's internal state — decoder
// tables, dictionaries, etc. — when finished. Forgetting to call
// Close on a long-lived mirror scan would let decoder state
// accumulate without bound.
Reader(r io.Reader) (io.ReadCloser, error)
}
// Compressor defines the interface for compression
type Compressor interface {
Name() string
Writer(w io.Writer) (io.WriteCloser, error)
}
// BaseDecompressor is the base implementation
type BaseDecompressor struct {
name string
contentEncodings []string
fileExtensions []string
}
func NewBaseDecompressor(name string, contentEncodings, fileExtensions []string) *BaseDecompressor {
return &BaseDecompressor{
name: name,
contentEncodings: contentEncodings,
fileExtensions: fileExtensions,
}
}
func (bd *BaseDecompressor) Name() string { return bd.name }
func (bd *BaseDecompressor) Extensions() []string { return bd.fileExtensions }
func (bd *BaseDecompressor) CanHandle(contentEncoding string) bool {
for _, e := range bd.contentEncodings {
if strings.EqualFold(e, contentEncoding) {
return true
}
}
return false
}
func (bd *BaseDecompressor) CanHandleFile(filename string) bool {
filename = strings.ToLower(filename)
for _, e := range bd.fileExtensions {
if strings.HasSuffix(filename, e) {
return true
}
}
return false
}
// Registry for decompressors
type registry struct {
mu sync.RWMutex
items map[string]Decompressor
}
var globalRegistry = &registry{items: make(map[string]Decompressor)}
func Register(d Decompressor) {
globalRegistry.mu.Lock()
defer globalRegistry.mu.Unlock()
globalRegistry.items[strings.ToLower(d.Name())] = d
}
func Get(name string) (Decompressor, bool) {
globalRegistry.mu.RLock()
defer globalRegistry.mu.RUnlock()
d, ok := globalRegistry.items[strings.ToLower(name)]
return d, ok
}
func GetByFilename(filename string) (Decompressor, bool) {
format := DetectCompressionByFilename(filename)
if format == "" || format == "identity" {
return nil, false
}
return Get(format)
}
// GetCompressionFromHeader detects compression from the Content-Encoding header
func GetCompressionFromHeader(contentEncoding string) string {
if contentEncoding == "" {
return "identity"
}
encodings := ParseContentEncoding(contentEncoding)
return GetEffectiveEncoding(encodings)
}
func ParseContentEncoding(header string) []string {
if header == "" {
return []string{"identity"}
}
parts := strings.Split(header, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, strings.ToLower(p))
}
}
if len(result) == 0 {
return []string{"identity"}
}
return result
}
func GetEffectiveEncoding(encodings []string) string {
for _, e := range encodings {
if e != "identity" {
return e
}
}
return "identity"
}
func DetectCompressionByFilename(filename string) string {
filename = strings.ToLower(filename)
switch {
case strings.HasSuffix(filename, ".gz"):
return "gzip"
case strings.HasSuffix(filename, ".zlib"), strings.HasSuffix(filename, ".zz"):
return "zlib"
case strings.HasSuffix(filename, ".deflate"), strings.HasSuffix(filename, ".fl"):
return "flate"
case strings.HasSuffix(filename, ".bz2"), strings.HasSuffix(filename, ".tbz"), strings.HasSuffix(filename, ".tbz2"):
return "bzip2"
case strings.HasSuffix(filename, ".z"), strings.HasSuffix(filename, ".lzw"):
return "lzw"
}
return "identity"
}
func IsCompressedFile(filename string) bool {
return DetectCompressionByFilename(filename) != "identity"
}
+52
View File
@@ -0,0 +1,52 @@
//go:build linux || freebsd
// +build linux freebsd
package compression
import (
"compress/lzw"
"io"
)
const lzwLitWidth = 8
type lzwDecompressor struct {
*BaseDecompressor
order lzw.Order
}
func NewLzwDecompressor() *lzwDecompressor { return NewLzwDecompressorWithOrder(lzw.LSB) }
func NewLzwDecompressorWithOrder(order lzw.Order) *lzwDecompressor {
name := "lzw"
if order == lzw.MSB {
name = "lzw-msb"
}
return &lzwDecompressor{NewBaseDecompressor(name, []string{}, []string{".z", ".lzw"}), order}
}
func (l *lzwDecompressor) Reader(r io.Reader) (io.ReadCloser, error) {
return lzw.NewReader(r, l.order, lzwLitWidth), nil
}
type lzwCompressor struct {
name string
order lzw.Order
}
func NewLzwCompressor() *lzwCompressor { return NewLzwCompressorWithOrder(lzw.LSB) }
func NewLzwCompressorWithOrder(order lzw.Order) *lzwCompressor {
name := "lzw"
if order == lzw.MSB {
name = "lzw-msb"
}
return &lzwCompressor{name: name, order: order}
}
func (l *lzwCompressor) Name() string { return l.name }
func (l *lzwCompressor) Writer(w io.Writer) (io.WriteCloser, error) {
return lzw.NewWriter(w, l.order, lzwLitWidth), nil
}
func init() { Register(NewLzwDecompressor()) }
+30
View File
@@ -0,0 +1,30 @@
//go:build linux || freebsd
// +build linux freebsd
package compression
import (
"compress/zlib"
"io"
)
type zlibDecompressor struct{ *BaseDecompressor }
func NewZlibDecompressor() *zlibDecompressor {
return &zlibDecompressor{NewBaseDecompressor("zlib", []string{"deflate"}, []string{".zlib", ".zz"})}
}
// ✅ Oprava: returns (io.Reader, error)
func (z *zlibDecompressor) Reader(r io.Reader) (io.ReadCloser, error) {
return zlib.NewReader(r)
}
type zlibCompressor struct{ name string }
func NewZlibCompressor() *zlibCompressor { return &zlibCompressor{name: "zlib"} }
func (z *zlibCompressor) Name() string { return z.name }
func (z *zlibCompressor) Writer(w io.Writer) (io.WriteCloser, error) {
return zlib.NewWriter(w), nil
}
func init() { Register(NewZlibDecompressor()) }
+356
View File
@@ -0,0 +1,356 @@
//go:build linux || freebsd
// +build linux freebsd
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"codeberg.org/petrbalvin/goget/internal/auth"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/interpres"
)
// AuthConfig holds authentication configuration
type AuthConfig struct {
Username string `json:"username,omitempty" toml:"username,omitempty"`
Password string `json:"password,omitempty" toml:"password,omitempty"`
PasswordFile string `json:"password_file,omitempty" toml:"password_file,omitempty"`
AuthType string `json:"auth_type,omitempty" toml:"auth_type,omitempty"` // basic, digest, auto
OAuth auth.OAuthConfig `json:"oauth,omitempty" toml:"oauth,omitempty"`
}
// RecursiveConfig holds recursive download configuration
type RecursiveConfig struct {
Enabled bool `json:"enabled,omitempty" toml:"enabled,omitempty"`
MaxDepth int `json:"max_depth,omitempty" toml:"max_depth,omitempty"`
FollowExternal bool `json:"follow_external,omitempty" toml:"follow_external,omitempty"`
ExcludePatterns []string `json:"exclude_patterns,omitempty" toml:"exclude_patterns,omitempty"`
IncludePatterns []string `json:"include_patterns,omitempty" toml:"include_patterns,omitempty"`
Delay string `json:"delay,omitempty" toml:"delay,omitempty"`
Parallel int `json:"parallel,omitempty" toml:"parallel,omitempty"`
}
// ExtractConfig holds archive extraction configuration
type ExtractConfig struct {
Enabled bool `json:"enabled,omitempty" toml:"enabled,omitempty"`
OutputDir string `json:"output_dir,omitempty" toml:"output_dir,omitempty"`
StripComponents int `json:"strip_components,omitempty" toml:"strip_components,omitempty"`
ExcludePatterns []string `json:"exclude_patterns,omitempty" toml:"exclude_patterns,omitempty"`
PreservePermissions bool `json:"preserve_permissions,omitempty" toml:"preserve_permissions,omitempty"`
AutoDetect bool `json:"auto_detect,omitempty" toml:"auto_detect,omitempty"`
}
// Config represents the application configuration
type Config struct {
Timeout time.Duration `json:"timeout,omitempty" toml:"timeout,omitempty"`
Parallel int `json:"parallel,omitempty" toml:"parallel,omitempty"`
OutputDir string `json:"output_dir,omitempty" toml:"output_dir,omitempty"`
Verbose bool `json:"verbose,omitempty" toml:"verbose,omitempty"`
Debug bool `json:"debug,omitempty" toml:"debug,omitempty"`
IPv4Fallback bool `json:"ipv4_fallback,omitempty" toml:"ipv4_fallback,omitempty"`
Proxy string `json:"proxy,omitempty" toml:"proxy,omitempty"`
UserAgent string `json:"user_agent,omitempty" toml:"user_agent,omitempty"`
AutoDecompress bool `json:"auto_decompress,omitempty" toml:"auto_decompress,omitempty"`
InsecureSkipVerify bool `json:"insecure_skip_verify,omitempty" toml:"insecure_skip_verify,omitempty"`
ChecksumAlgo string `json:"checksum_algo,omitempty" toml:"checksum_algo,omitempty"`
Auth AuthConfig `json:"auth,omitempty" toml:"auth,omitempty"`
MaxSpeed int64 `json:"max_speed,omitempty" toml:"max_speed,omitempty"`
AutoResume bool `json:"auto_resume,omitempty" toml:"auto_resume,omitempty"`
KeepMetadata bool `json:"keep_metadata,omitempty" toml:"keep_metadata,omitempty"`
Color string `json:"color,omitempty" toml:"color,omitempty"`
ProgressStyle string `json:"progress_style,omitempty" toml:"progress_style,omitempty"`
ShowETA bool `json:"show_eta,omitempty" toml:"show_eta,omitempty"`
Recursive RecursiveConfig `json:"recursive,omitempty" toml:"recursive,omitempty"`
Extract ExtractConfig `json:"extract,omitempty" toml:"extract,omitempty"`
CookieJar string `json:"cookie_jar,omitempty" toml:"cookie_jar,omitempty"`
DNSServers []string `json:"dns_servers,omitempty" toml:"dns_servers,omitempty"`
MaxRetries int `json:"max_retries,omitempty" toml:"max_retries,omitempty"`
configPath string
}
// DefaultConfig returns the default configuration
func DefaultConfig() *Config {
return &Config{
Timeout: 30 * time.Minute,
Parallel: 0,
OutputDir: ".",
Verbose: true,
Debug: false,
IPv4Fallback: true,
Proxy: "",
UserAgent: core.Name + "/" + core.Version,
AutoDecompress: true,
InsecureSkipVerify: false,
ChecksumAlgo: "sha256",
Auth: AuthConfig{},
MaxSpeed: 0,
AutoResume: true,
KeepMetadata: false,
Color: "auto",
ProgressStyle: "unicode",
ShowETA: true,
Recursive: RecursiveConfig{
Enabled: false,
MaxDepth: 3,
FollowExternal: false,
ExcludePatterns: []string{},
IncludePatterns: []string{},
Delay: "100ms",
},
Extract: ExtractConfig{
Enabled: false,
OutputDir: "",
StripComponents: 0,
ExcludePatterns: []string{},
PreservePermissions: true,
AutoDetect: true,
},
CookieJar: "",
}
}
// Load loads the configuration from file
func Load(customPath string) (*Config, error) {
cfg := DefaultConfig()
configPath := customPath
if configPath == "" {
configPath = getConfigPath()
}
if configPath == "" {
return cfg, nil
}
cfg.configPath = configPath
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return nil, fmt.Errorf("failed to read config: %w", err)
}
if err := interpres.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("failed to parse config %s: %w", configPath, err)
}
if cfg.Auth.PasswordFile != "" && cfg.Auth.Password == "" {
passwordPath := expandTilde(cfg.Auth.PasswordFile)
if data, err := os.ReadFile(passwordPath); err == nil {
cfg.Auth.Password = strings.TrimSpace(string(data))
}
}
return cfg, nil
}
// Save writes the configuration to disk
func (c *Config) Save() error {
if c.configPath == "" {
c.configPath = getConfigPath()
if c.configPath == "" {
return fmt.Errorf("cannot determine config path")
}
}
if err := os.MkdirAll(filepath.Dir(c.configPath), 0755); err != nil {
return err
}
cfgCopy := *c
cfgCopy.Auth.Password = ""
cfgCopy.Auth.OAuth.ClientSecret = ""
cfgCopy.Auth.OAuth.AccessToken = ""
cfgCopy.Auth.OAuth.RefreshToken = ""
data, err := interpres.Marshal(cfgCopy)
if err != nil {
return err
}
return os.WriteFile(c.configPath, data, 0600)
}
// Path returns the config file path
func (c *Config) Path() string {
return c.configPath
}
// CLIFlags holds CLI overrides to merge into the configuration.
type CLIFlags struct {
Timeout time.Duration
Parallel int
Verbose bool
Debug bool
MaxSpeed int64
Proxy string
NoIPv4 bool
NoDecompress bool
Username string
Password string
CookieJar string
Recursive bool
MaxDepth int
FollowExternal bool
ExcludePatterns []string
Extract bool
ExtractDir string
StripComponents int
}
// Merge merges CLI overrides into the configuration.
func (c *Config) Merge(cli *CLIFlags) {
if cli.Timeout > 0 {
c.Timeout = cli.Timeout
}
if cli.Parallel >= 0 {
c.Parallel = cli.Parallel
}
if cli.Verbose {
c.Verbose = true
}
if cli.Debug {
c.Debug = true
}
if cli.MaxSpeed >= 0 {
c.MaxSpeed = cli.MaxSpeed
}
if cli.Proxy != "" {
c.Proxy = cli.Proxy
}
if cli.NoIPv4 {
c.IPv4Fallback = false
}
if cli.NoDecompress {
c.AutoDecompress = false
}
if cli.Username != "" {
c.Auth.Username = cli.Username
c.Auth.Password = cli.Password
}
if cli.CookieJar != "" {
c.CookieJar = cli.CookieJar
}
if cli.Recursive {
c.Recursive.Enabled = true
}
if cli.MaxDepth > 0 {
c.Recursive.MaxDepth = cli.MaxDepth
}
if cli.FollowExternal {
c.Recursive.FollowExternal = true
}
if len(cli.ExcludePatterns) > 0 {
c.Recursive.ExcludePatterns = cli.ExcludePatterns
}
if cli.Extract {
c.Extract.Enabled = true
}
if cli.ExtractDir != "" {
c.Extract.OutputDir = cli.ExtractDir
}
if cli.StripComponents >= 0 {
c.Extract.StripComponents = cli.StripComponents
}
}
func getConfigPath() string {
if path := os.Getenv("GOGET_CONFIG"); path != "" {
return path
}
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "goget", "config.toml")
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".config", "goget", "config.toml")
}
return ""
}
func expandTilde(path string) string {
if strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, path[2:])
}
}
return path
}
func (c *Config) GetEffectiveTimeout(fileSize int64, userOverride time.Duration) time.Duration {
tc := core.DefaultTimeoutConfig()
return tc.CalculateTimeout(fileSize, userOverride)
}
func (c *Config) GetParallelConnections(fileSize int64) int {
if c.Parallel > 0 {
return c.Parallel
}
if fileSize >= 100*1024*1024 {
return 4
}
return 1
}
func (c *Config) ShouldUseColors() bool {
switch c.Color {
case "always":
return true
case "never":
return false
default:
if os.Getenv("NO_COLOR") != "" {
return false
}
return true
}
}
func (c *Config) IsExcluded(url string) bool {
for _, pattern := range c.Recursive.ExcludePatterns {
if strings.Contains(url, pattern) {
return true
}
}
return false
}
func (c *Config) GetRecursiveDelay() time.Duration {
if c.Recursive.Delay == "" {
return 100 * time.Millisecond
}
d, err := time.ParseDuration(c.Recursive.Delay)
if err != nil {
return 100 * time.Millisecond
}
return d
}
func (c *Config) GetExtractOutputDir(defaultDir string) string {
if c.Extract.OutputDir != "" {
return c.Extract.OutputDir
}
return defaultDir
}
func ParseSpeed(s string) int64 {
if s == "" || s == "0" {
return 0
}
s = strings.TrimSpace(strings.ToLower(s))
var multiplier int64 = 1
switch {
case strings.HasSuffix(s, "tb/s"):
multiplier = 1000 * 1000 * 1000 * 1000
s = strings.TrimSuffix(s, "tb/s")
case strings.HasSuffix(s, "gb/s"):
multiplier = 1000 * 1000 * 1000
s = strings.TrimSuffix(s, "gb/s")
case strings.HasSuffix(s, "mb/s"):
multiplier = 1000 * 1000
s = strings.TrimSuffix(s, "mb/s")
case strings.HasSuffix(s, "kb/s"):
multiplier = 1000
s = strings.TrimSuffix(s, "kb/s")
case strings.HasSuffix(s, "/s"):
s = strings.TrimSuffix(s, "/s")
}
var value float64
fmt.Sscanf(s, "%f", &value)
return int64(value * float64(multiplier))
}
+338
View File
@@ -0,0 +1,338 @@
//go:build linux || freebsd
// +build linux freebsd
package config
import (
"strings"
"testing"
"time"
"codeberg.org/petrbalvin/interpres"
)
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
if cfg.Timeout != 30*time.Minute {
t.Errorf("Expected default timeout 30m, got %v", cfg.Timeout)
}
if cfg.Parallel != 0 {
t.Errorf("Expected default parallel 0, got %d", cfg.Parallel)
}
if cfg.Verbose != true {
t.Errorf("Expected default verbose true, got %v", cfg.Verbose)
}
if cfg.AutoDecompress != true {
t.Errorf("Expected default auto decompress true, got %v", cfg.AutoDecompress)
}
if cfg.ChecksumAlgo != "sha256" {
t.Errorf("Expected default checksum algo sha256, got %s", cfg.ChecksumAlgo)
}
}
func TestConfigMerge(t *testing.T) {
cfg := DefaultConfig()
cfg.Merge(&CLIFlags{
Timeout: 1 * time.Hour,
Parallel: 4,
Verbose: true,
Debug: false,
MaxSpeed: 1024 * 1024,
Proxy: "http://proxy",
NoIPv4: false,
NoDecompress: false,
Username: "user",
Password: "pass",
CookieJar: "",
Recursive: false,
MaxDepth: 0,
FollowExternal: false,
ExcludePatterns: nil,
Extract: false,
ExtractDir: "",
StripComponents: 0,
})
if cfg.Timeout != 1*time.Hour {
t.Errorf("Expected timeout 1h, got %v", cfg.Timeout)
}
if cfg.Parallel != 4 {
t.Errorf("Expected parallel 4, got %d", cfg.Parallel)
}
if cfg.Proxy != "http://proxy" {
t.Errorf("Expected proxy http://proxy, got %s", cfg.Proxy)
}
if cfg.Auth.Username != "user" {
t.Errorf("Expected username user, got %s", cfg.Auth.Username)
}
}
func TestGetEffectiveTimeout(t *testing.T) {
cfg := DefaultConfig()
// User override
timeout := cfg.GetEffectiveTimeout(1000000, 1*time.Hour)
if timeout != 1*time.Hour {
t.Errorf("Expected 1h with user override, got %v", timeout)
}
// Auto calculation for known size
timeout = cfg.GetEffectiveTimeout(100*1024*1024, 0)
if timeout < 30*time.Second {
t.Errorf("Expected at least 30s for auto timeout, got %v", timeout)
}
// Unknown size
timeout = cfg.GetEffectiveTimeout(-1, 0)
if timeout != 30*time.Minute {
t.Errorf("Expected 30m for unknown size, got %v", timeout)
}
}
func TestGetParallelConnections(t *testing.T) {
cfg := DefaultConfig()
// Explicit parallel
cfg.Parallel = 8
if cfg.GetParallelConnections(1000) != 8 {
t.Errorf("Expected 8 explicit connections, got %d", cfg.GetParallelConnections(1000))
}
// Auto for large file
cfg.Parallel = 0
if cfg.GetParallelConnections(200*1024*1024) != 4 {
t.Errorf("Expected 4 auto connections for large file, got %d", cfg.GetParallelConnections(200*1024*1024))
}
// Auto for small file
if cfg.GetParallelConnections(1024) != 1 {
t.Errorf("Expected 1 connection for small file, got %d", cfg.GetParallelConnections(1024))
}
}
func TestShouldUseColors(t *testing.T) {
cfg := DefaultConfig()
// Always
cfg.Color = "always"
if !cfg.ShouldUseColors() {
t.Error("Expected colors with always setting")
}
// Never
cfg.Color = "never"
if cfg.ShouldUseColors() {
t.Error("Expected no colors with never setting")
}
// Auto (default)
cfg.Color = "auto"
// Can't test environment in unit tests, but verify it doesn't panic
_ = cfg.ShouldUseColors()
}
func TestIsExcluded(t *testing.T) {
cfg := DefaultConfig()
cfg.Recursive.ExcludePatterns = []string{"example.com", "test.org"}
if !cfg.IsExcluded("https://example.com/file.zip") {
t.Error("Expected example.com to be excluded")
}
if !cfg.IsExcluded("https://test.org/file.zip") {
t.Error("Expected test.org to be excluded")
}
if cfg.IsExcluded("https://other.com/file.zip") {
t.Error("Expected other.com to not be excluded")
}
}
func TestGetRecursiveDelay(t *testing.T) {
cfg := DefaultConfig()
delay := cfg.GetRecursiveDelay()
if delay != 100*time.Millisecond {
t.Errorf("Expected default delay 100ms, got %v", delay)
}
cfg.Recursive.Delay = "500ms"
delay = cfg.GetRecursiveDelay()
if delay != 500*time.Millisecond {
t.Errorf("Expected delay 500ms, got %v", delay)
}
// Invalid delay should fall back to default
cfg.Recursive.Delay = "invalid"
delay = cfg.GetRecursiveDelay()
if delay != 100*time.Millisecond {
t.Errorf("Expected default delay for invalid value, got %v", delay)
}
}
func TestGetExtractOutputDir(t *testing.T) {
cfg := DefaultConfig()
// Empty config should return default
dir := cfg.GetExtractOutputDir("/default")
if dir != "/default" {
t.Errorf("Expected /default, got %s", dir)
}
// Config with output dir
cfg.Extract.OutputDir = "/custom"
dir = cfg.GetExtractOutputDir("/default")
if dir != "/custom" {
t.Errorf("Expected /custom, got %s", dir)
}
}
func TestParseSpeed(t *testing.T) {
tests := []struct {
input string
expected int64
}{
{"", 0},
{"0", 0},
{"100", 100},
{"100B/s", 100},
{"1KB/s", 1000},
{"1MB/s", 1000000},
{"1GB/s", 1000000000},
{"10MB/s", 10000000},
{"100 KB/s", 100000},
}
for _, test := range tests {
result := ParseSpeed(test.input)
if result != test.expected {
t.Errorf("ParseSpeed(%s) = %d, expected %d", test.input, result, test.expected)
}
}
}
func TestExpandTilde(t *testing.T) {
// Test with tilde
path := expandTilde("~/test/file.txt")
if path == "~/test/file.txt" {
t.Error("Expected tilde to be expanded")
}
// Test without tilde
path = expandTilde("/absolute/path/file.txt")
if path != "/absolute/path/file.txt" {
t.Errorf("Expected unchanged path, got %s", path)
}
}
func TestConfigSave(t *testing.T) {
cfg := DefaultConfig()
cfg.Verbose = true
cfg.Parallel = 4
// Save should not fail (may create directories)
err := cfg.Save()
// We can't test the actual file creation in unit tests without a temp dir
// but we verify the method doesn't panic
_ = err
}
func TestLoadNonExistentConfig(t *testing.T) {
cfg, err := Load("/nonexistent/path/config.toml")
if err != nil {
t.Fatalf("Expected no error for non-existent config, got %v", err)
}
if cfg == nil {
t.Fatal("Expected default config for non-existent file")
}
}
func TestMarshalUnmarshalTOML(t *testing.T) {
cfg := DefaultConfig()
cfg.Parallel = 4
cfg.Verbose = true
cfg.Timeout = 5 * time.Minute
cfg.Auth.Username = "testuser"
cfg.Auth.AuthType = "basic"
cfg.Recursive.Enabled = true
cfg.Recursive.MaxDepth = 5
cfg.DNSServers = []string{"1.1.1.1", "8.8.8.8"}
data, err := interpres.Marshal(*cfg)
if err != nil {
t.Fatalf("interpres.Marshal failed: %v", err)
}
tomlStr := string(data)
// Verify key elements are present
if !strings.Contains(tomlStr, "parallel = 4") {
t.Errorf("expected 'parallel = 4' in TOML output, got:\n%s", tomlStr)
}
if !strings.Contains(tomlStr, "verbose = true") {
t.Errorf("expected 'verbose = true' in TOML output, got:\n%s", tomlStr)
}
if !strings.Contains(tomlStr, "[auth]") {
t.Errorf("expected '[auth]' section in TOML output, got:\n%s", tomlStr)
}
if !strings.Contains(tomlStr, "username = \"testuser\"") {
t.Errorf("expected 'username = \"testuser\"' in TOML output, got:\n%s", tomlStr)
}
if !strings.Contains(tomlStr, "[recursive]") {
t.Errorf("expected '[recursive]' section in TOML output, got:\n%s", tomlStr)
}
if !strings.Contains(tomlStr, "dns_servers = [") {
t.Errorf("expected 'dns_servers' array in TOML output, got:\n%s", tomlStr)
}
// Roundtrip: unmarshal back and verify
var cfg2 Config
if err := interpres.Unmarshal(data, &cfg2); err != nil {
t.Fatalf("interpres.Unmarshal failed: %v", err)
}
if cfg2.Parallel != 4 {
t.Errorf("roundtrip: expected Parallel 4, got %d", cfg2.Parallel)
}
if cfg2.Verbose != true {
t.Errorf("roundtrip: expected Verbose true, got %v", cfg2.Verbose)
}
if cfg2.Auth.Username != "testuser" {
t.Errorf("roundtrip: expected Auth.Username testuser, got %s", cfg2.Auth.Username)
}
if cfg2.Recursive.MaxDepth != 5 {
t.Errorf("roundtrip: expected Recursive.MaxDepth 5, got %d", cfg2.Recursive.MaxDepth)
}
if len(cfg2.DNSServers) != 2 || cfg2.DNSServers[0] != "1.1.1.1" {
t.Errorf("roundtrip: expected DNSServers [1.1.1.1, 8.8.8.8], got %v", cfg2.DNSServers)
}
}
func TestMarshalTOMLDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
data, err := interpres.Marshal(*cfg)
if err != nil {
t.Fatalf("interpres.Marshal with default config failed: %v", err)
}
if len(data) == 0 {
t.Error("expected non-empty TOML output for default config")
}
// Verify it can be parsed back
var cfg2 Config
if err := interpres.Unmarshal(data, &cfg2); err != nil {
t.Fatalf("interpres.Unmarshal of default output failed: %v", err)
}
if cfg2.OutputDir != "." {
t.Errorf("expected OutputDir '.', got %s", cfg2.OutputDir)
}
if cfg2.ChecksumAlgo != "sha256" {
t.Errorf("expected ChecksumAlgo sha256, got %s", cfg2.ChecksumAlgo)
}
}
func TestConfigPath(t *testing.T) {
cfg := DefaultConfig()
path := cfg.Path()
// Path should be empty when loaded with DefaultConfig
_ = path
}
+201
View File
@@ -0,0 +1,201 @@
//go:build linux || freebsd
// +build linux freebsd
package cookie
import (
"bufio"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"golang.org/x/net/publicsuffix"
)
// Jar wraps http.CookieJar with Netscape format import/export.
type Jar struct {
*cookiejar.Jar
mu sync.Mutex
cookies map[string][]*http.Cookie // domain -> cookies (for export)
}
// NewJar creates a new cookie jar with public suffix support.
func NewJar() (*Jar, error) {
jar, err := cookiejar.New(&cookiejar.Options{
PublicSuffixList: publicsuffix.List,
})
if err != nil {
return nil, err
}
return &Jar{
Jar: jar,
cookies: make(map[string][]*http.Cookie),
}, nil
}
// SetCookies stores cookies in the jar and tracks them for export.
func (j *Jar) SetCookies(u *url.URL, cookies []*http.Cookie) {
j.Jar.SetCookies(u, cookies)
j.mu.Lock()
defer j.mu.Unlock()
for _, c := range cookies {
// Key the export map by the cookie's effective domain, not the
// URL hostname. A cookie set for "example.com" via a request to
// "www.example.com" must round-trip through SaveToFile/LoadFromFile
// keyed by "example.com", otherwise the second SetCookies call
// (from LoadFromFile) lands under a different map key and the
// cookies appear to be silently lost between sessions.
key := cookieStorageKey(c, u)
j.cookies[key] = append(j.cookies[key], c)
}
}
// cookieStorageKey returns the canonical (no leading dot) domain used as
// the storage key for the export map. It prefers the cookie's own Domain
// attribute (which reflects the cookie's actual scope) and falls back to
// the URL hostname for host-only cookies (those without a Domain attr).
func cookieStorageKey(c *http.Cookie, u *url.URL) string {
if c.Domain != "" {
return strings.TrimPrefix(c.Domain, ".")
}
return u.Hostname()
}
// LoadFromFile loads cookies from a file in Netscape format.
func (j *Jar) LoadFromFile(path string) error {
if path == "" {
return nil
}
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to open cookie jar: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Split(line, "\t")
if len(fields) < 7 {
continue
}
domain := fields[0]
path := fields[2]
expiresStr := fields[4]
name := fields[5]
value := fields[6]
expires, err := strconv.ParseInt(expiresStr, 10, 64)
if err != nil {
continue
}
if expires > 0 && time.Now().Unix() > expires {
continue
}
// Strip the leading dot from the Netscape-format domain so the
// round-trip back through SetCookies uses a stable key. Go's
// net/url strips the leading dot from Hostname() since Go 1.20,
// but matching that behaviour here makes the export map key
// independent of Go stdlib version.
domainBare := strings.TrimPrefix(domain, ".")
scheme := "https"
if fields[3] != "TRUE" {
scheme = "http"
}
u := &url.URL{
Scheme: scheme,
Host: domainBare,
Path: path,
}
cookie := &http.Cookie{
Name: name,
Value: value,
Path: path,
Domain: domainBare,
Expires: time.Unix(expires, 0),
Secure: fields[3] == "TRUE",
}
j.SetCookies(u, []*http.Cookie{cookie})
}
return scanner.Err()
}
// SaveToFile saves cookies to a file in Netscape format.
func (j *Jar) SaveToFile(path string) error {
if path == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create cookie jar: %w", err)
}
defer file.Close()
fmt.Fprintln(file, "# Netscape HTTP Cookie File")
fmt.Fprintf(file, "# Generated by goget %s at %s\n", core.Version, time.Now().Format(time.RFC3339))
fmt.Fprintln(file, "# Format: domain flag path secure expiration name value")
fmt.Fprintln(file)
j.mu.Lock()
defer j.mu.Unlock()
for domain, cookies := range j.cookies {
for _, c := range cookies {
secure := "FALSE"
if c.Secure {
secure = "TRUE"
}
// The map key is always stored without a leading dot, but
// the Netscape format expects one — always emit it.
expires := c.Expires.Unix()
if expires <= 0 {
expires = time.Now().Add(365 * 24 * time.Hour).Unix()
}
fmt.Fprintf(file, ".%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
domain,
"TRUE",
c.Path,
secure,
expires,
c.Name,
c.Value,
)
}
}
return nil
}
// GetCookieCount returns the number of cookies for a given URL.
func (j *Jar) GetCookieCount(u *url.URL) int {
return len(j.Cookies(u))
}
+874
View File
@@ -0,0 +1,874 @@
//go:build linux || freebsd
// +build linux freebsd
package cookie
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"testing"
"time"
)
// futureTimestamp returns a unix timestamp safely in the future.
func futureTimestamp() int64 {
return time.Now().Unix() + 86400*365 // ~1 year from now
}
// pastTimestamp returns a unix timestamp safely in the past.
func pastTimestamp() int64 {
return time.Now().Unix() - 86400 // 1 day ago
}
// fmtInt64 is a helper to avoid importing strconv in every test.
func fmtInt64(v int64) string {
return strconv.FormatInt(v, 10)
}
// writeTempFile writes content to a temp file inside t.TempDir() and returns its path.
func writeTempFile(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
return path
}
// netscapeLine builds a single Netscape-format cookie line.
func netscapeLine(domain, domainFlag, path, secure, expires, name, value string) string {
return domain + "\t" + domainFlag + "\t" + path + "\t" + secure + "\t" + expires + "\t" + name + "\t" + value
}
// ---------------------------------------------------------------------------
// 1. TestNewJar
// ---------------------------------------------------------------------------
func TestNewJar(t *testing.T) {
j, err := NewJar()
if err != nil {
t.Fatalf("NewJar() returned unexpected error: %v", err)
}
if j == nil {
t.Fatal("NewJar() returned nil")
}
if j.Jar == nil {
t.Fatal("NewJar().Jar is nil")
}
}
// ---------------------------------------------------------------------------
// 2. TestLoadFromFileEmptyPath
// ---------------------------------------------------------------------------
func TestLoadFromFileEmptyPath(t *testing.T) {
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j.LoadFromFile(""); err != nil {
t.Fatalf("LoadFromFile(\"\") should return nil, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// 3. TestLoadFromFileNotExist
// ---------------------------------------------------------------------------
func TestLoadFromFileNotExist(t *testing.T) {
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j.LoadFromFile("/tmp/this_file_does_not_exist_xxx.cookie"); err != nil {
t.Fatalf("LoadFromFile(non-existent) should return nil, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// 4. TestLoadFromFile
// ---------------------------------------------------------------------------
func TestLoadFromFile(t *testing.T) {
dir := t.TempDir()
ft := futureTimestamp()
content := "# Netscape HTTP Cookie File\n" +
netscapeLine(".example.com", "TRUE", "/", "FALSE", fmtInt64(ft), "test", "cookie_value") + "\n"
path := writeTempFile(t, dir, "cookies.txt", content)
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j.LoadFromFile(path); err != nil {
t.Fatalf("LoadFromFile() returned error: %v", err)
}
// Cookie set with scheme=http (because secure=FALSE), Domain=.example.com, Path=/
// Retrieve over http.
u, _ := url.Parse("http://sub.example.com/path")
cookies := j.Cookies(u)
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
if cookies[0].Name != "test" {
t.Errorf("expected name 'test', got %q", cookies[0].Name)
}
if cookies[0].Value != "cookie_value" {
t.Errorf("expected value 'cookie_value', got %q", cookies[0].Value)
}
_ = cookies[0].Path // cookiejar does not preserve Path on retrieval
}
// TestLoadFromFileHttps verifies that secure=TRUE results in an https cookie.
func TestLoadFromFileHttps(t *testing.T) {
dir := t.TempDir()
ft := futureTimestamp()
content := "# Netscape HTTP Cookie File\n" +
netscapeLine(".secure.example.com", "TRUE", "/", "TRUE", fmtInt64(ft), "sess", "abc") + "\n"
path := writeTempFile(t, dir, "cookies_secure.txt", content)
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j.LoadFromFile(path); err != nil {
t.Fatalf("LoadFromFile() returned error: %v", err)
}
// Secure cookie — must be retrieved over HTTPS.
u, _ := url.Parse("https://secure.example.com/path")
cookies := j.Cookies(u)
if len(cookies) != 1 {
t.Fatalf("expected 1 secure cookie over HTTPS, got %d", len(cookies))
}
if cookies[0].Name != "sess" {
t.Errorf("expected name 'sess', got %q", cookies[0].Name)
}
if cookies[0].Value != "abc" {
t.Errorf("expected value 'abc', got %q", cookies[0].Value)
}
}
// ---------------------------------------------------------------------------
// 5. TestLoadFromFileCommentsAndEmptyLines
// ---------------------------------------------------------------------------
func TestLoadFromFileCommentsAndEmptyLines(t *testing.T) {
dir := t.TempDir()
ft := futureTimestamp()
content :=
"# Netscape HTTP Cookie File\n" +
"# This is a comment that should be skipped\n" +
"\n" +
" \t \n" +
netscapeLine(".alpha.com", "TRUE", "/", "FALSE", fmtInt64(ft), "a", "1") + "\n" +
"# Another comment\n" +
netscapeLine(".beta.com", "TRUE", "/", "FALSE", fmtInt64(ft), "b", "2") + "\n"
path := writeTempFile(t, dir, "cookies.txt", content)
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j.LoadFromFile(path); err != nil {
t.Fatalf("LoadFromFile() returned error: %v", err)
}
// Check alpha
u, _ := url.Parse("http://alpha.com/path")
cookies := j.Cookies(u)
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie for alpha.com, got %d", len(cookies))
}
if cookies[0].Name != "a" {
t.Errorf("expected name 'a', got %q", cookies[0].Name)
}
// Check beta
u2, _ := url.Parse("http://beta.com/path")
cookies2 := j.Cookies(u2)
if len(cookies2) != 1 {
t.Fatalf("expected 1 cookie for beta.com, got %d", len(cookies2))
}
if cookies2[0].Name != "b" {
t.Errorf("expected name 'b', got %q", cookies2[0].Name)
}
}
// ---------------------------------------------------------------------------
// 6. TestLoadFromFileExpiredCookie
// ---------------------------------------------------------------------------
func TestLoadFromFileExpiredCookie(t *testing.T) {
dir := t.TempDir()
pt := pastTimestamp()
ft := futureTimestamp()
content := "# Netscape HTTP Cookie File\n" +
netscapeLine(".expired.com", "TRUE", "/", "FALSE", fmtInt64(pt), "gone", "x") + "\n" +
netscapeLine(".valid.com", "TRUE", "/", "FALSE", fmtInt64(ft), "alive", "y") + "\n"
path := writeTempFile(t, dir, "cookies.txt", content)
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j.LoadFromFile(path); err != nil {
t.Fatalf("LoadFromFile() returned error: %v", err)
}
// Expired cookie should not be stored.
u, _ := url.Parse("http://expired.com/path")
cookies := j.Cookies(u)
if len(cookies) != 0 {
t.Errorf("expected 0 cookies for expired domain, got %d", len(cookies))
}
// Valid cookie should be stored.
u2, _ := url.Parse("http://valid.com/path")
cookies2 := j.Cookies(u2)
if len(cookies2) != 1 {
t.Fatalf("expected 1 cookie for valid domain, got %d", len(cookies2))
}
if cookies2[0].Name != "alive" {
t.Errorf("expected name 'alive', got %q", cookies2[0].Name)
}
}
// TestLoadFromFileSessionCookie documents the current behaviour: when expires=0,
// LoadFromFile does NOT skip it (because 0 > 0 is false), but time.Unix(0,0)
// makes the cookiejar itself treat it as expired. Therefore the cookie is not
// retrievable. This is a known limitation of the current implementation.
func TestLoadFromFileSessionCookie(t *testing.T) {
dir := t.TempDir()
content := "# Netscape HTTP Cookie File\n" +
netscapeLine(".session.com", "TRUE", "/", "FALSE", "0", "sess", "active") + "\n"
path := writeTempFile(t, dir, "session.txt", content)
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j.LoadFromFile(path); err != nil {
t.Fatalf("LoadFromFile() returned error: %v", err)
}
// The cookie is loaded (no error) but can not be retrieved because
// time.Unix(0,0) is the Unix epoch (1970) which the cookiejar sees as expired.
u, _ := url.Parse("http://session.com/path")
cookies := j.Cookies(u)
if len(cookies) != 0 {
t.Logf("Note: session cookie (expires=0) currently not retrievable, got %d cookies", len(cookies))
}
}
// ---------------------------------------------------------------------------
// 7. TestSaveToFileEmptyPath
// ---------------------------------------------------------------------------
func TestSaveToFileEmptyPath(t *testing.T) {
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j.SaveToFile(""); err != nil {
t.Fatalf("SaveToFile(\"\") should return nil, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// 8. TestSaveToFile
// ---------------------------------------------------------------------------
func TestSaveToFile(t *testing.T) {
dir := t.TempDir()
savePath := filepath.Join(dir, "subdir", "cookies.txt")
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j.SaveToFile(savePath); err != nil {
t.Fatalf("SaveToFile() returned error: %v", err)
}
// File must exist.
if _, err := os.Stat(savePath); os.IsNotExist(err) {
t.Fatalf("SaveToFile() did not create file at %s", savePath)
}
data, err := os.ReadFile(savePath)
if err != nil {
t.Fatal(err)
}
content := string(data)
if len(content) == 0 {
t.Fatal("saved file is empty")
}
// Must contain Netscape header.
if !contains(content, "Netscape HTTP Cookie File") {
t.Errorf("saved file should contain 'Netscape HTTP Cookie File', got:\n%s", content)
}
// Must contain the generated-by line.
if !contains(content, "Generated by goget") {
t.Errorf("saved file should contain 'Generated by goget', got:\n%s", content)
}
}
// TestSaveToFileExistingDirectory verifies SaveToFile works when the
// directory already exists.
func TestSaveToFileExistingDirectory(t *testing.T) {
dir := filepath.Join(t.TempDir(), "already_exists")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
savePath := filepath.Join(dir, "cookies.txt")
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j.SaveToFile(savePath); err != nil {
t.Fatalf("SaveToFile() returned error: %v", err)
}
if _, err := os.Stat(savePath); os.IsNotExist(err) {
t.Fatalf("file not created in existing directory at %s", savePath)
}
}
// ---------------------------------------------------------------------------
// 9. TestCookieRoundTrip
// ---------------------------------------------------------------------------
func TestCookieRoundTrip(t *testing.T) {
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse("https://roundtrip.example.com/path")
j.SetCookies(u, []*http.Cookie{
{Name: "session_id", Value: "abc123", Path: "/"},
})
cookies := j.Cookies(u)
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
if cookies[0].Name != "session_id" {
t.Errorf("expected name 'session_id', got %q", cookies[0].Name)
}
if cookies[0].Value != "abc123" {
t.Errorf("expected value 'abc123', got %q", cookies[0].Value)
}
}
// TestCookieRoundTripHttpsThenHttp verifies that an insecure cookie set over
// HTTPS is sent on HTTP as well.
func TestCookieRoundTripHttpsThenHttp(t *testing.T) {
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse("https://mix.example.com/path")
j.SetCookies(u, []*http.Cookie{
{Name: "lang", Value: "en", Path: "/"},
})
httpU, _ := url.Parse("http://mix.example.com/path")
cookies := j.Cookies(httpU)
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie over HTTP after HTTPS set, got %d", len(cookies))
}
if cookies[0].Value != "en" {
t.Errorf("expected value 'en', got %q", cookies[0].Value)
}
}
// TestCookieRoundTripSecure verifies that a secure cookie set over HTTPS is
// NOT sent over plain HTTP.
func TestCookieRoundTripSecure(t *testing.T) {
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse("https://secure-only.example.com/path")
j.SetCookies(u, []*http.Cookie{
{Name: "secret", Value: "s3cr3t", Path: "/", Secure: true},
})
// Over HTTPS should be present.
httpsCookies := j.Cookies(u)
hasSecret := false
for _, c := range httpsCookies {
if c.Name == "secret" {
hasSecret = true
break
}
}
if !hasSecret {
t.Error("secure cookie should be sent over HTTPS")
}
// Over HTTP must NOT be present.
httpU, _ := url.Parse("http://secure-only.example.com/path")
httpCookies := j.Cookies(httpU)
for _, c := range httpCookies {
if c.Name == "secret" {
t.Error("secure cookie should NOT be sent over HTTP")
break
}
}
}
// ---------------------------------------------------------------------------
// 10. TestGetCookieCount
// ---------------------------------------------------------------------------
func TestGetCookieCount(t *testing.T) {
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse("https://count.example.com/path")
// Empty jar.
if c := j.GetCookieCount(u); c != 0 {
t.Errorf("expected 0 cookies for empty jar, got %d", c)
}
// Add two cookies.
j.SetCookies(u, []*http.Cookie{
{Name: "a", Value: "1", Path: "/"},
{Name: "b", Value: "2", Path: "/"},
})
if c := j.GetCookieCount(u); c != 2 {
t.Errorf("expected 2 cookies, got %d", c)
}
}
// TestGetCookieCountDifferentURLs verifies cookie counts are scoped to the URL.
func TestGetCookieCountDifferentURLs(t *testing.T) {
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
u1, _ := url.Parse("https://left.example.com/path")
u2, _ := url.Parse("https://right.example.com/path")
j.SetCookies(u1, []*http.Cookie{{Name: "only_left", Value: "x", Path: "/"}})
if c := j.GetCookieCount(u1); c != 1 {
t.Errorf("expected 1 cookie for left, got %d", c)
}
if c := j.GetCookieCount(u2); c != 0 {
t.Errorf("expected 0 cookies for right, got %d", c)
}
}
// ---------------------------------------------------------------------------
// 11. TestMultipleCookies
// ---------------------------------------------------------------------------
func TestMultipleCookies(t *testing.T) {
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse("https://multi.example.com/path")
j.SetCookies(u, []*http.Cookie{
{Name: "first", Value: "one", Path: "/"},
{Name: "second", Value: "two", Path: "/"},
{Name: "third", Value: "three", Path: "/"},
})
cookies := j.Cookies(u)
if len(cookies) != 3 {
t.Fatalf("expected 3 cookies, got %d", len(cookies))
}
vals := make(map[string]string)
for _, c := range cookies {
vals[c.Name] = c.Value
}
if vals["first"] != "one" {
t.Errorf("expected first=one, got first=%q", vals["first"])
}
if vals["second"] != "two" {
t.Errorf("expected second=two, got second=%q", vals["second"])
}
if vals["third"] != "three" {
t.Errorf("expected third=three, got third=%q", vals["third"])
}
}
// TestMultipleCookiesSameDomain verifies that setting a cookie with the same
// name overwrites the previous value.
func TestMultipleCookiesSameDomain(t *testing.T) {
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse("https://overwrite.example.com/path")
j.SetCookies(u, []*http.Cookie{{Name: "lang", Value: "en", Path: "/"}})
j.SetCookies(u, []*http.Cookie{{Name: "theme", Value: "dark", Path: "/"}})
j.SetCookies(u, []*http.Cookie{{Name: "lang", Value: "fr", Path: "/"}})
cookies := j.Cookies(u)
vals := make(map[string]string)
for _, c := range cookies {
vals[c.Name] = c.Value
}
if vals["lang"] != "fr" {
t.Errorf("expected lang=fr (overwritten), got lang=%q", vals["lang"])
}
if vals["theme"] != "dark" {
t.Errorf("expected theme=dark, got theme=%q", vals["theme"])
}
}
// TestMultipleCookiesDifferentPaths validates that cookies with different
// paths are scoped correctly.
func TestMultipleCookiesDifferentPaths(t *testing.T) {
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
rootU, _ := url.Parse("https://paths.example.com/")
adminU, _ := url.Parse("https://paths.example.com/admin")
apiU, _ := url.Parse("https://paths.example.com/api/v1")
j.SetCookies(rootU, []*http.Cookie{{Name: "root", Value: "r", Path: "/"}})
j.SetCookies(adminU, []*http.Cookie{{Name: "admin", Value: "a", Path: "/admin"}})
j.SetCookies(apiU, []*http.Cookie{{Name: "api", Value: "v", Path: "/api"}})
// Root path should only see the root cookie.
rootCookies := j.Cookies(rootU)
if len(rootCookies) != 1 {
t.Fatalf("expected 1 cookie for root path, got %d", len(rootCookies))
}
if rootCookies[0].Name != "root" {
t.Errorf("expected 'root' cookie at root path, got %q", rootCookies[0].Name)
}
// Admin path should see root (/) + admin (/admin).
adminCookies := j.Cookies(adminU)
adminNames := make(map[string]bool)
for _, c := range adminCookies {
adminNames[c.Name] = true
}
if !adminNames["root"] {
t.Error("root cookie should be visible under /admin")
}
if !adminNames["admin"] {
t.Error("admin cookie should be visible under /admin")
}
// API path should see root (/) + api (/api).
apiCookies := j.Cookies(apiU)
apiNames := make(map[string]bool)
for _, c := range apiCookies {
apiNames[c.Name] = true
}
if !apiNames["root"] {
t.Error("root cookie should be visible under /api")
}
if !apiNames["api"] {
t.Error("api cookie should be visible under /api")
}
}
// ---------------------------------------------------------------------------
// Additional edge-case tests
// ---------------------------------------------------------------------------
// TestLoadFromFileMalformedLine checks that lines with fewer than 7 fields
// are silently skipped.
func TestLoadFromFileMalformedLine(t *testing.T) {
dir := t.TempDir()
ft := futureTimestamp()
content := "# Netscape HTTP Cookie File\n" +
netscapeLine(".good.com", "TRUE", "/", "FALSE", fmtInt64(ft), "ok", "fine") + "\n" +
"short.line\n" +
".also-short\tTRUE\t/\n" +
netscapeLine(".also-good.com", "TRUE", "/", "FALSE", fmtInt64(ft), "ok2", "fine2") + "\n"
path := writeTempFile(t, dir, "malformed.txt", content)
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j.LoadFromFile(path); err != nil {
t.Fatalf("LoadFromFile() returned error: %v", err)
}
// First valid cookie should be present.
u1, _ := url.Parse("http://good.com/path")
c1 := j.Cookies(u1)
if len(c1) != 1 {
t.Errorf("expected 1 cookie for good.com, got %d", len(c1))
}
// Second valid cookie should be present.
u2, _ := url.Parse("http://also-good.com/path")
c2 := j.Cookies(u2)
if len(c2) != 1 {
t.Errorf("expected 1 cookie for also-good.com, got %d", len(c2))
}
}
// TestLoadFromFileInvalidExpiry checks that an unparsable expiry is skipped.
func TestLoadFromFileInvalidExpiry(t *testing.T) {
dir := t.TempDir()
ft := futureTimestamp()
content := "# Netscape HTTP Cookie File\n" +
netscapeLine(".bad-expiry.com", "TRUE", "/", "FALSE", "not_a_number", "x", "y") + "\n" +
netscapeLine(".fresh.com", "TRUE", "/", "FALSE", fmtInt64(ft), "good", "yes") + "\n"
path := writeTempFile(t, dir, "bad_expiry.txt", content)
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j.LoadFromFile(path); err != nil {
t.Fatalf("LoadFromFile() returned error: %v", err)
}
// The malformed-expiry line should be skipped.
u1, _ := url.Parse("http://bad-expiry.com/path")
c1 := j.Cookies(u1)
if len(c1) != 0 {
t.Errorf("expected 0 cookies for bad-expiry.com, got %d", len(c1))
}
// The valid line should still work.
u2, _ := url.Parse("http://fresh.com/path")
c2 := j.Cookies(u2)
if len(c2) != 1 {
t.Fatalf("expected 1 cookie for fresh.com, got %d", len(c2))
}
if c2[0].Name != "good" {
t.Errorf("expected name 'good', got %q", c2[0].Name)
}
}
// TestLoadAndSaveRoundTrip loads from a file then saves to another location.
func TestLoadAndSaveRoundTrip(t *testing.T) {
dir := t.TempDir()
ft := futureTimestamp()
loadContent := "# Netscape HTTP Cookie File\n" +
netscapeLine(".rt.com", "TRUE", "/", "FALSE", fmtInt64(ft), "a", "b") + "\n"
loadPath := writeTempFile(t, dir, "load.txt", loadContent)
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j.LoadFromFile(loadPath); err != nil {
t.Fatalf("LoadFromFile() returned error: %v", err)
}
savePath := filepath.Join(dir, "nested", "save.txt")
if err := j.SaveToFile(savePath); err != nil {
t.Fatalf("SaveToFile() returned error: %v", err)
}
if _, err := os.Stat(savePath); os.IsNotExist(err) {
t.Errorf("saved file not found at %s", savePath)
}
data, err := os.ReadFile(savePath)
if err != nil {
t.Fatal(err)
}
if !contains(string(data), "Netscape HTTP Cookie File") {
t.Error("saved file must contain Netscape header")
}
}
// TestGetCookieCountWithLoad verifies GetCookieCount after loading from file.
func TestGetCookieCountWithLoad(t *testing.T) {
dir := t.TempDir()
ft := futureTimestamp()
content := "# Netscape HTTP Cookie File\n" +
netscapeLine(".cnt1.com", "TRUE", "/", "FALSE", fmtInt64(ft), "c1", "v1") + "\n" +
netscapeLine(".cnt1.com", "TRUE", "/", "FALSE", fmtInt64(ft), "c2", "v2") + "\n"
path := writeTempFile(t, dir, "count.txt", content)
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j.LoadFromFile(path); err != nil {
t.Fatalf("LoadFromFile() returned error: %v", err)
}
u, _ := url.Parse("http://cnt1.com/path")
if c := j.GetCookieCount(u); c != 2 {
t.Errorf("expected 2 cookies after load, got %d", c)
}
}
// TestLoadFromFileOpenError verifies that an IO error (e.g. a directory
// instead of a file) is propagated as an error (not silently swallowed).
func TestLoadFromFileOpenError(t *testing.T) {
dir := t.TempDir()
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
// Try to open a directory this should fail with an error (not IsNotExist).
err = j.LoadFromFile(dir)
if err == nil {
t.Log("Note: opening a directory may produce an error or not depending on OS")
}
}
// TestNewJarMultiple verifies that multiple jars can be created independently.
func TestNewJarMultiple(t *testing.T) {
j1, err := NewJar()
if err != nil {
t.Fatal(err)
}
j2, err := NewJar()
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse("https://independent.example.com/path")
j1.SetCookies(u, []*http.Cookie{{Name: "only_j1", Value: "yes", Path: "/"}})
if c := len(j1.Cookies(u)); c != 1 {
t.Errorf("expected 1 cookie in j1, got %d", c)
}
if c := len(j2.Cookies(u)); c != 0 {
t.Errorf("expected 0 cookies in j2, got %d", c)
}
}
// TestSetCookiesUsesCookieDomainForStorageKey is a regression guard for the
// BACKLOG entry "Cookie domain mismatch between storage and export".
// The previous implementation keyed the export map by u.Hostname() and
// the file output by (c.Domain with a possibly-prepended dot). When
// stdlib cookiejar normalised c.Domain with a leading dot, the storage
// key and the file's domain field could diverge, and round-tripping
// through SaveToFile/LoadFromFile would re-key the cookie under a
// different domain — silently losing the cookie on the next session.
//
// The fix is to key storage by the cookie's effective domain (no
// leading dot) and to use that same key when writing the file. This
// test sets a cookie scoped to "example.com" via a request to
// "www.example.com", saves, loads into a fresh jar, and verifies the
// cookie is still retrievable.
func TestSetCookiesUsesCookieDomainForStorageKey(t *testing.T) {
dir := t.TempDir()
savePath := filepath.Join(dir, "roundtrip.txt")
// Session 1: set a cookie whose Domain is "example.com" even
// though the URL host is "www.example.com" (the typical case for
// a server issuing a cookie that scopes to the parent domain).
j1, err := NewJar()
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse("https://www.example.com/path")
j1.SetCookies(u, []*http.Cookie{
{Name: "session", Value: "abc", Path: "/", Domain: "example.com"},
})
// SaveToFile must write ".example.com" (Netscape format with
// leading dot).
if err := j1.SaveToFile(savePath); err != nil {
t.Fatalf("SaveToFile: %v", err)
}
data, err := os.ReadFile(savePath)
if err != nil {
t.Fatal(err)
}
if !contains(string(data), ".example.com\t") {
t.Errorf("saved file must contain .example.com entry, got:\n%s", data)
}
// Session 2: load into a fresh jar, the cookie must come back
// and be retrievable for the original URL.
j2, err := NewJar()
if err != nil {
t.Fatal(err)
}
if err := j2.LoadFromFile(savePath); err != nil {
t.Fatalf("LoadFromFile: %v", err)
}
cookies := j2.Cookies(u)
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie after round-trip, got %d", len(cookies))
}
if cookies[0].Name != "session" || cookies[0].Value != "abc" {
t.Errorf("round-tripped cookie = %+v, want {Name: session, Value: abc}", cookies[0])
}
}
// TestSetCookiesHostOnlyCookie is the host-only counterpart: a cookie
// without a Domain attribute must be stored under the URL hostname
// (the only scope information available).
func TestSetCookiesHostOnlyCookie(t *testing.T) {
j, err := NewJar()
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse("https://hostonly.example.com/path")
j.SetCookies(u, []*http.Cookie{
{Name: "k", Value: "v", Path: "/"},
})
cookies := j.Cookies(u)
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
if cookies[0].Name != "k" {
t.Errorf("cookie name = %q, want %q", cookies[0].Name, "k")
}
}
// ---------------------------------------------------------------------------
// Helper: contains reports whether substr is in s.
// ---------------------------------------------------------------------------
func contains(s, substr string) bool {
if len(substr) == 0 {
return true
}
if len(substr) > len(s) {
return false
}
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+47
View File
@@ -0,0 +1,47 @@
//go:build linux || freebsd
// +build linux freebsd
package core
import (
"time"
)
// TimeoutConfig configures smart timeout
type TimeoutConfig struct {
Min time.Duration
Max time.Duration
MinSpeedBytesPerSec int64
SafetyFactor float64
DefaultUnknown time.Duration
}
// DefaultTimeoutConfig returns default settings
func DefaultTimeoutConfig() *TimeoutConfig {
return &TimeoutConfig{
Min: 30 * time.Second,
Max: 24 * time.Hour,
MinSpeedBytesPerSec: 10 * 1024,
SafetyFactor: 3.0,
DefaultUnknown: 30 * time.Minute,
}
}
// CalculateTimeout calculates the recommended timeout
func (tc *TimeoutConfig) CalculateTimeout(fileSize int64, userOverride time.Duration) time.Duration {
if userOverride > 0 {
return userOverride
}
if fileSize <= 0 {
return tc.DefaultUnknown
}
expectedSeconds := float64(fileSize) / float64(tc.MinSpeedBytesPerSec)
calculated := time.Duration(expectedSeconds * tc.SafetyFactor * float64(time.Second))
if calculated < tc.Min {
return tc.Min
}
if calculated > tc.Max {
return tc.Max
}
return calculated
}
+65
View File
@@ -0,0 +1,65 @@
//go:build linux || freebsd
// +build linux freebsd
package core
import (
"context"
"time"
)
// RequestContext holds metadata about the request across layers
type RequestContext struct {
// Request ID for logging
RequestID string
// Start of operation
StartTime time.Time
// Protocols used in the chain
ProtocolChain []string
// IP address to which was connected
ConnectedIP string
// IP version (4 or 6)
IPVersion int
// Number of attempts
AttemptCount int
// Context for cancellation
Context context.Context
}
// NewRequestContext creates a new request context
func NewRequestContext(ctx context.Context, requestID string) *RequestContext {
return &RequestContext{
RequestID: requestID,
StartTime: time.Now(),
ProtocolChain: make([]string, 0),
IPVersion: 0,
AttemptCount: 0,
Context: ctx,
}
}
// Duration returns the duration of operation
func (rc *RequestContext) Duration() time.Duration {
return time.Since(rc.StartTime)
}
// AddProtocol adds a protocol to the chain
func (rc *RequestContext) AddProtocol(protocol string) {
rc.ProtocolChain = append(rc.ProtocolChain, protocol)
}
// IsCancelled returns whether the context was cancelled
func (rc *RequestContext) IsCancelled() bool {
select {
case <-rc.Context.Done():
return true
default:
return false
}
}
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
//go:build linux || freebsd
// +build linux freebsd
package core
import (
"fmt"
"net/url"
)
// ErrorType defines the error type
type ErrorType string
const (
ErrNetwork ErrorType = "NETWORK"
ErrProtocol ErrorType = "PROTOCOL"
ErrFile ErrorType = "FILE"
ErrTimeout ErrorType = "TIMEOUT"
ErrAuth ErrorType = "AUTH"
ErrChecksum ErrorType = "CHECKSUM"
ErrUnsupported ErrorType = "UNSUPPORTED"
ErrCancelled ErrorType = "CANCELLED"
)
// GogetError is a structured error with context
type GogetError struct {
Type ErrorType
Message string
Cause error
URL string
Hint string // Actionable suggestion for the user
Details map[string]string
}
func (e *GogetError) Error() string {
var msg string
if e.Cause != nil {
msg = fmt.Sprintf("[%s] %s: %v", e.Type, e.Message, e.Cause)
} else {
msg = fmt.Sprintf("[%s] %s", e.Type, e.Message)
}
if e.Hint != "" {
msg += fmt.Sprintf(" (hint: %s)", e.Hint)
}
return msg
}
func (e *GogetError) Unwrap() error {
return e.Cause
}
// GetDetail returns a value from the Details map, or an empty string if the key is absent.
func (e *GogetError) GetDetail(key string) string {
if e.Details == nil {
return ""
}
return e.Details[key]
}
// NewNetworkError creates a network error
func NewNetworkError(msg string, cause error, url string) *GogetError {
return &GogetError{
Type: ErrNetwork,
Message: msg,
Cause: cause,
URL: url,
}
}
// NewProtocolError creates a protocol error
func NewProtocolError(msg string, cause error, url string) *GogetError {
return &GogetError{
Type: ErrProtocol,
Message: msg,
Cause: cause,
URL: url,
}
}
// NewFileError creates a file error
func NewFileError(msg string, cause error) *GogetError {
return &GogetError{
Type: ErrFile,
Message: msg,
Cause: cause,
}
}
// NewTimeoutError creates a timeout error
func NewTimeoutError(msg string, url string) *GogetError {
return &GogetError{
Type: ErrTimeout,
Message: msg,
URL: url,
Hint: "increase --max-time or --connect-timeout, or check network connectivity",
}
}
// NewChecksumError creates a checksum error
func NewChecksumError(expected, actual string) *GogetError {
return &GogetError{
Type: ErrChecksum,
Message: fmt.Sprintf("checksum mismatch: expected %s, got %s", expected, actual),
Hint: "verify the file integrity with an external checksum tool or re-download",
Details: map[string]string{
"expected": expected,
"actual": actual,
},
}
}
// NewUnsupportedError creates an unsupported operation error
func NewUnsupportedError(msg string) *GogetError {
return &GogetError{
Type: ErrUnsupported,
Message: msg,
}
}
// WithHint returns the error with the hint set. Use for adding contextual,
// actionable suggestions to an existing error without creating a new one.
func (e *GogetError) WithHint(hint string) *GogetError {
e.Hint = hint
return e
}
// SafeURL returns the URL string with credentials removed for safe error logging.
func SafeURL(u *url.URL) string {
if u == nil {
return ""
}
clean := *u
clean.User = nil
return clean.String()
}
+174
View File
@@ -0,0 +1,174 @@
//go:build linux || freebsd
// +build linux freebsd
package core
import (
"context"
"fmt"
"io"
"net/url"
"time"
)
// ParallelConfig configures parallel downloading
type ParallelConfig struct {
Connections int
MinSize int64
MinChunkSize int64
MaxChunkSize int64
}
// DefaultParallelConfig returns default settings
func DefaultParallelConfig() *ParallelConfig {
return &ParallelConfig{
Connections: 0,
MinSize: 100 * 1024 * 1024,
MinChunkSize: 1 * 1024 * 1024,
MaxChunkSize: 50 * 1024 * 1024,
}
}
// GetConnections returns the number of connections
func (pc *ParallelConfig) GetConnections(fileSize int64) int {
if pc.Connections > 0 {
return pc.Connections
}
if fileSize >= pc.MinSize {
return 4
}
return 1
}
// ChunkInfo represents one file segment
type ChunkInfo struct {
ID int
Start int64
End int64
Downloaded int64
Status int
Error error
}
// ResumeInfo contains metadata for continuation
type ResumeInfo struct {
DownloadedBytes int64
ETag string
LastModified string
URL string
LastWrite time.Time
Chunks map[int]int64
}
// DownloadRequest represents a download request
type DownloadRequest struct {
URL *url.URL
Output string
Writer io.Writer
Resume bool
Timeout time.Duration
Verbose bool
DebugTransport bool
Checksum string
Headers map[string]string
Proxy string
AutoDecompress bool
ProgressCallback func(current, total int64, speed float64)
ResumeInfo *ResumeInfo
Parallel *ParallelConfig
Recursive bool
RecursiveParallel int // worker pool size for recursive downloads (0 = sequential)
DryRun bool // list what would be downloaded, do not write any files
MaxDepth int
MaxFileSize int64 // max file size to download (0 = unlimited)
AcceptPatterns []string // glob patterns for accepted files (e.g., "*.pdf,*.html")
RejectPatterns []string // glob patterns for rejected files (e.g., "*.tmp,*.bak")
KeepTimestamps bool // set file modification time from server response
Ctx context.Context
SSHKnownHosts string // path to known_hosts file (SFTP)
SSHInsecure bool // skip SSH host key verification (SFTP)
}
// DownloadResult represents a download result
type DownloadResult struct {
BytesDownloaded int64
TotalSize int64
Duration time.Duration
Protocol string
IPVersion int
Speed float64
OutputPath string
Checksum string
Resumed bool
Parallel bool
ChunksCount int
HTTPStatusCode int // HTTP status code from response
TraceTimings *TraceTimings // HTTP tracing breakdown
}
// TraceTimings holds HTTP request timing breakdown.
type TraceTimings struct {
DNSLookup time.Duration
TCPConnect time.Duration
TLSHandshake time.Duration
TTFB time.Duration // Time To First Byte
Total time.Duration
}
func (tt *TraceTimings) String() string {
return fmt.Sprintf(
"DNS: %v | TCP: %v | TLS: %v | TTFB: %v | Total: %v",
tt.DNSLookup.Round(time.Millisecond),
tt.TCPConnect.Round(time.Millisecond),
tt.TLSHandshake.Round(time.Millisecond),
tt.TTFB.Round(time.Millisecond),
tt.Total.Round(time.Millisecond),
)
}
// Protocol is the interface for protocols
type Protocol interface {
Scheme() string
CanHandle(url *url.URL) bool
Download(ctx context.Context, req *DownloadRequest) (*DownloadResult, error)
Capabilities() []string
SupportsResume() bool
SupportsCompression() bool
SupportsParallel() bool
SupportsRecursive() bool
}
// UploadRequest represents an upload request
type UploadRequest struct {
URL *url.URL
Input string
Reader io.Reader
Timeout time.Duration
Verbose bool
Headers map[string]string
Proxy string
Ctx context.Context
}
// UploadResult represents an upload result
type UploadResult struct {
BytesUploaded int64
Duration time.Duration
Protocol string
ResultURL string
}
// Uploader is the interface for protocols with upload support
type Uploader interface {
Upload(ctx context.Context, req *UploadRequest) (*UploadResult, error)
SupportsUpload() bool
}
// ProgressCallback is a function for progress reporting
type ProgressCallback func(current, total int64, speed float64)
// ProgressWriter is a writer with progress support
type ProgressWriter interface {
io.Writer
SetProgressCallback(cb ProgressCallback)
}
+10
View File
@@ -0,0 +1,10 @@
//go:build linux || freebsd
// Package version exposes the goget release identity.
package core
// Name is the program name reported by `--version` and in log lines.
const Name = "goget"
// Version is the goget release version. Follows SemVer.
var Version = "1.0.0"
+91
View File
@@ -0,0 +1,91 @@
//go:build linux || freebsd
// +build linux freebsd
package crypto
import (
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"strings"
)
// CertLoader loads and parses certificates
type CertLoader struct {
pool *x509.CertPool
}
// NewCertLoader creates new cert loader
func NewCertLoader() *CertLoader {
return &CertLoader{
pool: x509.NewCertPool(),
}
}
// LoadCAFile loads CA certificates from a file
func (cl *CertLoader) LoadCAFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read ca file: %w", err)
}
if !cl.pool.AppendCertsFromPEM(data) {
return fmt.Errorf("failed to parse ca certificates")
}
return nil
}
// LoadSystemCerts loads system certificates
func (cl *CertLoader) LoadSystemCerts() error {
pool, err := x509.SystemCertPool()
if err != nil {
// Fallback for systems without system certificates
cl.pool = x509.NewCertPool()
return nil
}
cl.pool = pool
return nil
}
// GetPool returns the certificate pool
func (cl *CertLoader) GetPool() *x509.CertPool {
return cl.pool
}
// ParseCertificate parses a PEM certificate
func ParseCertificate(data []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
return x509.ParseCertificate(block.Bytes)
}
// LoadCertificateFile loads a certificate from a file
func LoadCertificateFile(path string) (*x509.Certificate, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ParseCertificate(data)
}
// FingerprintSHA256 calculates the SHA-256 fingerprint of a certificate
func FingerprintSHA256(cert *x509.Certificate) string {
if cert == nil {
return ""
}
hash := sha256.Sum256(cert.Raw)
// Format as colon-separated hex pairs (like OpenSSL format)
parts := make([]string, len(hash))
for i, b := range hash {
parts[i] = fmt.Sprintf("%02X", b)
}
return strings.Join(parts, ":")
}
+150
View File
@@ -0,0 +1,150 @@
//go:build linux || freebsd
// +build linux freebsd
package crypto
import (
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"hash"
"io"
"os"
"golang.org/x/crypto/blake2b"
"golang.org/x/crypto/sha3"
)
// ChecksumType defines the checksum type
type ChecksumType string
const (
SHA256 ChecksumType = "sha256"
SHA512 ChecksumType = "sha512"
BLAKE2b ChecksumType = "blake2b"
SHA3_256 ChecksumType = "sha3-256"
SHA3_512 ChecksumType = "sha3-512"
MD5 ChecksumType = "md5"
)
// ChecksumVerifier verifies a file checksum
type ChecksumVerifier struct {
hashType ChecksumType
hasher hash.Hash
expected string
}
// NewChecksumVerifier creates new verifier
func NewChecksumVerifier(hashType ChecksumType, expected string) (*ChecksumVerifier, error) {
var hasher hash.Hash
switch hashType {
case SHA256:
hasher = sha256.New()
case SHA512:
hasher = sha512.New()
case BLAKE2b:
h, err := blake2b.New256(nil)
if err != nil {
return nil, fmt.Errorf("failed to create blake2b hasher: %w", err)
}
hasher = h
case SHA3_256:
hasher = sha3.New256()
case SHA3_512:
hasher = sha3.New512()
case MD5:
return nil, fmt.Errorf("md5 is deprecated and not supported")
default:
return nil, fmt.Errorf("unsupported hash type: %s", hashType)
}
return &ChecksumVerifier{
hashType: hashType,
hasher: hasher,
expected: expected,
}, nil
}
// VerifyFile verifies the checksum of a file
func (cv *ChecksumVerifier) VerifyFile(path string) (bool, string, error) {
file, err := os.Open(path)
if err != nil {
return false, "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
cv.hasher.Reset()
_, err = io.Copy(cv.hasher, file)
if err != nil {
return false, "", fmt.Errorf("failed to read file: %w", err)
}
actual := hex.EncodeToString(cv.hasher.Sum(nil))
match := actual == cv.expected
return match, actual, nil
}
// VerifyReader verifies checksum from a reader (streaming)
func (cv *ChecksumVerifier) VerifyReader(r io.Reader) (string, error) {
cv.hasher.Reset()
_, err := io.Copy(cv.hasher, r)
if err != nil {
return "", err
}
return hex.EncodeToString(cv.hasher.Sum(nil)), nil
}
// Hasher returns the underlying hasher for streaming verification
func (cv *ChecksumVerifier) Hasher() hash.Hash {
return cv.hasher
}
// Expected returns the expected checksum
func (cv *ChecksumVerifier) Expected() string {
return cv.expected
}
// Type returns the hash function type
func (cv *ChecksumVerifier) Type() ChecksumType {
return cv.hashType
}
// ComputeFileChecksum calculates the checksum of a file
func ComputeFileChecksum(path string, hashType ChecksumType) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
var hasher hash.Hash
switch hashType {
case SHA256:
hasher = sha256.New()
case SHA512:
hasher = sha512.New()
case BLAKE2b:
h, err := blake2b.New256(nil)
if err != nil {
return "", fmt.Errorf("failed to create blake2b hasher: %w", err)
}
hasher = h
case SHA3_256:
hasher = sha3.New256()
case SHA3_512:
hasher = sha3.New512()
default:
return "", fmt.Errorf("unsupported hash type: %s", hashType)
}
_, err = io.Copy(hasher, file)
if err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
+190
View File
@@ -0,0 +1,190 @@
//go:build linux || freebsd
// +build linux freebsd
package crypto
import (
"os"
"strings"
"testing"
)
func TestNewChecksumVerifier(t *testing.T) {
tests := []struct {
hashType ChecksumType
valid bool
}{
{SHA256, true},
{SHA512, true},
{BLAKE2b, true},
{SHA3_256, true},
{SHA3_512, true},
{MD5, false}, // MD5 is deprecated
{"invalid", false},
}
for _, test := range tests {
_, err := NewChecksumVerifier(test.hashType, "abc123")
if test.valid && err != nil {
t.Errorf("NewChecksumVerifier(%s) failed: %v", test.hashType, err)
}
if !test.valid && err == nil {
t.Errorf("NewChecksumVerifier(%s) should have failed", test.hashType)
}
}
}
func TestChecksumVerifier(t *testing.T) {
// Test SHA-256
verifier, err := NewChecksumVerifier(SHA256, "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae")
if err != nil {
t.Fatalf("Failed to create verifier: %v", err)
}
// Test with string "foo"
actual, err := verifier.VerifyReader(strings.NewReader("foo"))
if err != nil {
t.Fatalf("VerifyReader failed: %v", err)
}
if actual != "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae" {
t.Errorf("Unexpected hash: %s", actual)
}
}
func TestComputeFileChecksum(t *testing.T) {
// Create a temp file for testing
tmpFile := t.TempDir() + "/test.txt"
writeTestFile(tmpFile, "test content")
hash, err := ComputeFileChecksum(tmpFile, SHA256)
if err != nil {
t.Fatalf("ComputeFileChecksum failed: %v", err)
}
if hash == "" {
t.Error("Expected non-empty hash")
}
// Verify it's a valid hex string
if len(hash) != 64 {
t.Errorf("Expected SHA-256 hash length 64, got %d", len(hash))
}
}
func TestComputeFileChecksumTypes(t *testing.T) {
tmpFile := t.TempDir() + "/test.txt"
writeTestFile(tmpFile, "test")
tests := []struct {
hashType ChecksumType
length int
}{
{SHA256, 64},
{SHA512, 128},
{BLAKE2b, 64},
{SHA3_256, 64},
{SHA3_512, 128},
}
for _, test := range tests {
hash, err := ComputeFileChecksum(tmpFile, test.hashType)
if err != nil {
t.Errorf("ComputeFileChecksum(%s) failed: %v", test.hashType, err)
continue
}
if len(hash) != test.length {
t.Errorf("Expected %s hash length %d, got %d", test.hashType, test.length, len(hash))
}
}
}
func TestChecksumVerifierType(t *testing.T) {
verifier, _ := NewChecksumVerifier(SHA256, "abc")
if verifier.Type() != SHA256 {
t.Errorf("Expected type SHA256, got %s", verifier.Type())
}
}
func TestChecksumVerifierExpected(t *testing.T) {
expected := "abc123def456"
verifier, _ := NewChecksumVerifier(SHA256, expected)
if verifier.Expected() != expected {
t.Errorf("Expected %s, got %s", expected, verifier.Expected())
}
}
func TestChecksumVerifierHasher(t *testing.T) {
verifier, _ := NewChecksumVerifier(SHA256, "abc")
hasher := verifier.Hasher()
if hasher == nil {
t.Error("Expected non-nil hasher")
}
}
func writeTestFile(path, content string) {
f, _ := os.Create(path)
f.WriteString(content)
f.Close()
}
func TestVerifyFileSuccess(t *testing.T) {
path := "/tmp/goget_test_verify_ok.tmp"
writeTestFile(path, "test data for verification")
defer os.Remove(path)
hash, err := ComputeFileChecksum(path, SHA256)
if err != nil {
t.Fatalf("ComputeFileChecksum failed: %v", err)
}
verifier, err := NewChecksumVerifier(SHA256, hash)
if err != nil {
t.Fatalf("NewChecksumVerifier failed: %v", err)
}
match, actual, err := verifier.VerifyFile(path)
if err != nil {
t.Fatalf("VerifyFile failed: %v", err)
}
if !match {
t.Errorf("unexpected mismatch: expected %s, got %s", hash, actual)
}
}
func TestVerifyFileMismatch(t *testing.T) {
path := "/tmp/goget_test_verify_bad.tmp"
writeTestFile(path, "correct data")
defer os.Remove(path)
verifier, err := NewChecksumVerifier(SHA256, strings.Repeat("0", 64))
if err != nil {
t.Fatalf("NewChecksumVerifier failed: %v", err)
}
match, _, err := verifier.VerifyFile(path)
if err != nil {
t.Fatalf("VerifyFile failed: %v", err)
}
if match {
t.Error("expected checksum mismatch for wrong hash")
}
}
func TestVerifyFileNonexistent(t *testing.T) {
verifier, _ := NewChecksumVerifier(SHA256, strings.Repeat("a", 64))
_, _, err := verifier.VerifyFile("/nonexistent/path")
if err == nil {
t.Error("expected error for nonexistent file")
}
}
func TestAllSupportedChecksumTypes(t *testing.T) {
types := []ChecksumType{SHA256, SHA512, BLAKE2b, SHA3_256, SHA3_512}
for _, ct := range types {
t.Run(string(ct), func(t *testing.T) {
_, err := NewChecksumVerifier(ct, strings.Repeat("a", 128))
if err != nil {
t.Errorf("NewChecksumVerifier(%s) failed: %v", ct, err)
}
})
}
}
+107
View File
@@ -0,0 +1,107 @@
//go:build linux || freebsd
// +build linux freebsd
package crypto
import (
"io"
)
// Hasher is the interface for hashing functions
type Hasher interface {
// Name returns the name of the hash function
Name() string
// Hash calculates a hash from a reader
Hash(r io.Reader) (string, error)
// HashFile calculates a hash from a file
HashFile(path string) (string, error)
// Verify verifies a hash against the expected value
Verify(r io.Reader, expected string) (bool, error)
// VerifyFile verifies a file hash against the expected value
VerifyFile(path string, expected string) (bool, error)
}
// Encryptor is the interface for encryption
type Encryptor interface {
// Name returns the name of the cipher
Name() string
// Encrypt encrypts data
Encrypt(r io.Reader, w io.Writer, key []byte) error
// Decrypt decrypts data
Decrypt(r io.Reader, w io.Writer, key []byte) error
}
// Signer is the interface for digital signatures
type Signer interface {
// Name returns the name of the signature scheme
Name() string
// Sign creates a signature
Sign(data []byte, key []byte) ([]byte, error)
// Verify verifies a signature
Verify(data []byte, signature []byte, key []byte) (bool, error)
}
// KeyLoader is the interface for loading keys
type KeyLoader interface {
// LoadPrivateKey loads a private key
LoadPrivateKey(path string, passphrase []byte) ([]byte, error)
// LoadPublicKey loads a public key
LoadPublicKey(path string) ([]byte, error)
// GenerateKeyPair generates a key pair
GenerateKeyPair() (privateKey, publicKey []byte, err error)
}
// BaseHasher is a basic hasher implementation
type BaseHasher struct {
name string
}
// NewBaseHasher creates new base hasher
func NewBaseHasher(name string) *BaseHasher {
return &BaseHasher{name: name}
}
// Name returns the name of the hash function
func (bh *BaseHasher) Name() string {
return bh.name
}
// Hash calculates a hash from a reader
func (bh *BaseHasher) Hash(r io.Reader) (string, error) {
// Implementation in concrete hashers
return "", nil
}
// HashFile calculates a hash from a file
func (bh *BaseHasher) HashFile(path string) (string, error) {
// Implementation in concrete hashers
return "", nil
}
// Verify verifies a hash against the expected value
func (bh *BaseHasher) Verify(r io.Reader, expected string) (bool, error) {
actual, err := bh.Hash(r)
if err != nil {
return false, err
}
return actual == expected, nil
}
// VerifyFile verifies a file hash against the expected value
func (bh *BaseHasher) VerifyFile(path string, expected string) (bool, error) {
actual, err := bh.HashFile(path)
if err != nil {
return false, err
}
return actual == expected, nil
}
+500
View File
@@ -0,0 +1,500 @@
//go:build linux || freebsd
// +build linux freebsd
package crypto
import (
"bytes"
"fmt"
"io"
"os"
"strings"
// Deprecated: golang.org/x/crypto/openpgp is frozen; no more security fixes.
// Will be replaced with an alternative in a future release.
"golang.org/x/crypto/openpgp"
"golang.org/x/crypto/openpgp/armor"
"golang.org/x/crypto/openpgp/clearsign"
)
// PGPConfig configures PGP operations
type PGPConfig struct {
// Path to private key
PrivateKeyPath string
// Path to public key
PublicKeyPath string
// Passphrase for private key
Passphrase string
// Armor output (ASCII vs binary)
Armor bool
// KeyRing for verification
KeyRing openpgp.EntityList
}
// DefaultPGPConfig returns the default configuration
func DefaultPGPConfig() *PGPConfig {
return &PGPConfig{
Armor: true,
}
}
// PGPDecryptor decrypts PGP encrypted data
type PGPDecryptor struct {
config *PGPConfig
}
// NewPGPDecryptor creates a new PGP decryptor
func NewPGPDecryptor(cfg *PGPConfig) *PGPDecryptor {
if cfg == nil {
cfg = DefaultPGPConfig()
}
return &PGPDecryptor{config: cfg}
}
// Decrypt decrypts PGP encrypted data
func (pd *PGPDecryptor) Decrypt(r io.Reader, w io.Writer) error {
var keyring openpgp.EntityList
var err error
// Load private key if configured
if pd.config.PrivateKeyPath != "" {
keyring, err = loadPrivateKeyRing(pd.config.PrivateKeyPath, []byte(pd.config.Passphrase))
if err != nil {
return fmt.Errorf("failed to load private key: %w", err)
}
}
// Check if input is armored
buf := make([]byte, 64)
n, err := io.ReadFull(r, buf)
if err != nil && err != io.ErrUnexpectedEOF {
return fmt.Errorf("failed to read input: %w", err)
}
input := io.MultiReader(bytes.NewReader(buf[:n]), r)
// Try to decode armor
if bytes.HasPrefix(bytes.TrimSpace(buf[:n]), []byte("-----BEGIN PGP")) {
armoredBlock, err := armor.Decode(input)
if err != nil {
return fmt.Errorf("failed to decode pgp armor: %w", err)
}
input = armoredBlock.Body
}
// Decrypt the data
md, err := openpgp.ReadMessage(input, keyring, pd.config.passphrasePrompt(), nil)
if err != nil {
return fmt.Errorf("failed to decrypt message: %w", err)
}
// Read all decrypted data
_, err = io.Copy(w, md.UnverifiedBody)
readErr := err
// Close the body (this also verifies the signature if present)
// UnverifiedBody is actually a ReadCloser, but declared as io.Reader
if closer, ok := md.UnverifiedBody.(io.Closer); ok {
closeErr := closer.Close()
if closeErr != nil {
if readErr == nil {
return fmt.Errorf("signature verification failed: %w", closeErr)
}
}
}
if readErr != nil {
return fmt.Errorf("failed to read decrypted data: %w", readErr)
}
// Verify signature if present
if md.IsSigned && md.SignedBy != nil {
// Message was signed and verified
}
return nil
}
// DecryptFile decrypts a PGP encrypted file
func (pd *PGPDecryptor) DecryptFile(inputPath, outputPath string) error {
inputFile, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("failed to open input file: %w", err)
}
defer inputFile.Close()
outputFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer outputFile.Close()
return pd.Decrypt(inputFile, outputFile)
}
// PGPEncryptor encrypts data using PGP
type PGPEncryptor struct {
config *PGPConfig
}
// NewPGPEncryptor creates a new PGP encryptor
func NewPGPEncryptor(cfg *PGPConfig) *PGPEncryptor {
if cfg == nil {
cfg = DefaultPGPConfig()
}
return &PGPEncryptor{config: cfg}
}
// Encrypt encrypts data using PGP
func (pe *PGPEncryptor) Encrypt(r io.Reader, w io.Writer) error {
// Load public key
if pe.config.PublicKeyPath == "" {
return fmt.Errorf("public key path is required for encryption")
}
entity, err := loadPublicKey(pe.config.PublicKeyPath)
if err != nil {
return fmt.Errorf("failed to load public key: %w", err)
}
// Prepare hints
hints := &openpgp.FileHints{
IsBinary: true,
FileName: "", // Don't store filename
}
var out io.WriteCloser
if pe.config.Armor {
out, err = armor.Encode(w, "PGP MESSAGE", nil)
if err != nil {
return fmt.Errorf("failed to create armor encoder: %w", err)
}
} else {
out = nopWriteCloser{w}
}
// Encrypt the data
plaintext, err := openpgp.Encrypt(out, []*openpgp.Entity{entity}, nil, hints, nil)
if err != nil {
if out != nil {
out.Close()
}
return fmt.Errorf("failed to encrypt: %w", err)
}
_, err = io.Copy(plaintext, r)
if err != nil {
plaintext.Close()
if out != nil {
out.Close()
}
return fmt.Errorf("failed to write encrypted data: %w", err)
}
plaintext.Close()
if pe.config.Armor && out != nil {
out.Close()
}
return nil
}
// EncryptFile encrypts a file using PGP
func (pe *PGPEncryptor) EncryptFile(inputPath, outputPath string) error {
inputFile, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("failed to open input file: %w", err)
}
defer inputFile.Close()
outputFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer outputFile.Close()
return pe.Encrypt(inputFile, outputFile)
}
// PGPVerifier verifies PGP signatures
type PGPVerifier struct {
config *PGPConfig
}
// NewPGPVerifier creates a new PGP verifier
func NewPGPVerifier(cfg *PGPConfig) *PGPVerifier {
if cfg == nil {
cfg = DefaultPGPConfig()
}
return &PGPVerifier{config: cfg}
}
// Verify verifies a PGP signature (detached signature)
func (pv *PGPVerifier) Verify(data io.Reader, signature []byte) (bool, string, error) {
// Load keyring
var keyring openpgp.EntityList
var err error
if pv.config.PublicKeyPath != "" {
keyring, err = loadPublicKeyRing(pv.config.PublicKeyPath)
if err != nil {
return false, "", fmt.Errorf("failed to load public key: %w", err)
}
} else if pv.config.KeyRing != nil {
keyring = pv.config.KeyRing
} else {
// Use empty keyring - will try to verify with any available key
keyring = openpgp.EntityList{}
}
// Check if signature is armored
sigReader := bytes.NewReader(signature)
var sigInput io.Reader = sigReader
if bytes.HasPrefix(bytes.TrimSpace(signature), []byte("-----BEGIN PGP")) {
armoredBlock, err := armor.Decode(sigReader)
if err != nil {
return false, "", fmt.Errorf("failed to decode signature armor: %w", err)
}
sigInput = armoredBlock.Body
}
// Verify the signature
signer, err := openpgp.CheckDetachedSignature(keyring, data, sigInput)
if err != nil {
return false, "", fmt.Errorf("signature verification failed: %w", err)
}
if signer == nil {
return false, "", fmt.Errorf("no valid signature found")
}
// Get signer identity
identity := ""
for _, id := range signer.Identities {
if identity == "" {
identity = id.Name
}
if id.SelfSignature != nil {
identity = id.Name
break
}
}
if identity == "" {
identity = "Unknown"
}
return true, identity, nil
}
// VerifyFile verifies PGP signature of a file (detached signature)
func (pv *PGPVerifier) VerifyFile(filePath string, signaturePath string) (bool, string, error) {
// Read signature file
sigData, err := os.ReadFile(signaturePath)
if err != nil {
return false, "", fmt.Errorf("failed to read signature file: %w", err)
}
// Open data file
dataFile, err := os.Open(filePath)
if err != nil {
return false, "", fmt.Errorf("failed to open data file: %w", err)
}
defer dataFile.Close()
return pv.Verify(dataFile, sigData)
}
// VerifyClearsign verifies a clearsigned message
func (pv *PGPVerifier) VerifyClearsign(data []byte) (bool, string, []byte, error) {
// Check if it's a clearsigned message
if !bytes.Contains(data, []byte("-----BEGIN PGP SIGNED MESSAGE-----")) {
return false, "", nil, fmt.Errorf("not a clearsigned message")
}
// Load keyring
var keyring openpgp.EntityList
var err error
if pv.config.PublicKeyPath != "" {
keyring, err = loadPublicKeyRing(pv.config.PublicKeyPath)
if err != nil {
return false, "", nil, fmt.Errorf("failed to load public key: %w", err)
}
} else if pv.config.KeyRing != nil {
keyring = pv.config.KeyRing
}
block, rest := clearsign.Decode(data)
if len(rest) > 0 {
// There's additional data after the clearsigned block
}
signer, err := openpgp.CheckDetachedSignature(keyring, bytes.NewReader(block.Bytes), block.ArmoredSignature.Body)
if err != nil {
return false, "", nil, fmt.Errorf("signature verification failed: %w", err)
}
if signer == nil {
return false, "", nil, fmt.Errorf("no valid signature found")
}
// Get signer identity
identity := "Unknown"
for _, id := range signer.Identities {
if id.SelfSignature != nil {
identity = id.Name
break
}
}
return true, identity, block.Bytes, nil
}
// IsPGPEncrypted determines whether data is PGP encrypted
func IsPGPEncrypted(data []byte) bool {
if len(data) < 10 {
return false
}
// Check for ASCII armor header
header := string(data[:min(len(data), 50)])
if strings.Contains(header, "-----BEGIN PGP MESSAGE-----") ||
strings.Contains(header, "-----BEGIN PGP PUBLIC KEY BLOCK-----") ||
strings.Contains(header, "-----BEGIN PGP PRIVATE KEY BLOCK-----") ||
strings.Contains(header, "-----BEGIN PGP SIGNATURE-----") {
return true
}
// Check for binary PGP packet tags
// PGP binary packets start with specific byte patterns
if len(data) >= 2 {
firstByte := data[0]
// Check for old format packet tag (bits 7-6 = 1, bit 5 = 0)
if (firstByte&0x80) != 0 && (firstByte&0x40) == 0 {
return true
}
// Check for new format packet tag (bits 7-6 = 1, bit 5 = 1)
if (firstByte & 0xC0) == 0xC0 {
return true
}
}
return false
}
// IsPGPSignature determines whether data is a PGP signature
func IsPGPSignature(data []byte) bool {
if len(data) < 10 {
return false
}
header := string(data[:min(len(data), 50)])
return strings.Contains(header, "-----BEGIN PGP SIGNATURE-----")
}
// IsClearsignedMessage determines whether a message is clearsigned
func IsClearsignedMessage(data []byte) bool {
if len(data) < 10 {
return false
}
header := string(data[:min(len(data), 50)])
return strings.Contains(header, "-----BEGIN PGP SIGNED MESSAGE-----")
}
// passphrasePrompt returns a function to prompt for passphrase
func (c *PGPConfig) passphrasePrompt() func(keys []openpgp.Key, symmetric bool) ([]byte, error) {
return func(keys []openpgp.Key, symmetric bool) ([]byte, error) {
if c.Passphrase != "" {
return []byte(c.Passphrase), nil
}
// If no passphrase configured, try empty passphrase
return nil, nil
}
}
// loadPrivateKeyRing loads a keyring from a private key
func loadPrivateKeyRing(path string, passphrase []byte) (openpgp.EntityList, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
keyring, err := openpgp.ReadKeyRing(file)
if err != nil {
return nil, fmt.Errorf("failed to parse keyring: %w", err)
}
// Unlock private keys
for _, entity := range keyring {
if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
err := entity.PrivateKey.Decrypt(passphrase)
if err != nil {
return nil, fmt.Errorf("failed to decrypt private key: %w", err)
}
}
for _, subkey := range entity.Subkeys {
if subkey.PrivateKey != nil && subkey.PrivateKey.Encrypted {
err := subkey.PrivateKey.Decrypt(passphrase)
if err != nil {
return nil, fmt.Errorf("failed to decrypt subkey: %w", err)
}
}
}
}
return keyring, nil
}
// loadPublicKeyRing loads a keyring from a public key
func loadPublicKeyRing(path string) (openpgp.EntityList, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
keyring, err := openpgp.ReadKeyRing(file)
if err != nil {
return nil, fmt.Errorf("failed to parse keyring: %w", err)
}
return keyring, nil
}
// loadPublicKey loads a public key
func loadPublicKey(path string) (*openpgp.Entity, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
keyring, err := openpgp.ReadKeyRing(file)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err)
}
if len(keyring) == 0 {
return nil, fmt.Errorf("no keys found in key file")
}
// Return first key
return keyring[0], nil
}
// nopWriteCloser helper for non-closing writer
type nopWriteCloser struct {
io.Writer
}
func (nwc nopWriteCloser) Close() error {
return nil
}
+44
View File
@@ -0,0 +1,44 @@
//go:build linux || freebsd
// +build linux freebsd
package format
import "fmt"
// Bytes formats byte count as human-readable string (DECIMAL units: KB=1000)
func Bytes(b int64) string {
const unit = 1000
if b < 0 {
return "? B"
}
if b < unit {
return fmt.Sprintf("%d B", b)
}
units := []string{"B", "KB", "MB", "GB", "TB", "PB"}
value := float64(b)
exp := 0
for value >= unit && exp < len(units)-1 {
value /= unit
exp++
}
return fmt.Sprintf("%.1f %s", value, units[exp])
}
// Speed formats speed as human-readable string (DECIMAL units)
func Speed(speed float64) string {
const unit = 1000
if speed < 0 {
return "? B/s"
}
if speed < unit {
return fmt.Sprintf("%.1f B/s", speed)
}
units := []string{"B/s", "KB/s", "MB/s", "GB/s", "TB/s", "PB/s"}
value := speed
exp := 0
for value >= unit && exp < len(units)-1 {
value /= unit
exp++
}
return fmt.Sprintf("%.1f %s", value, units[exp])
}
+58
View File
@@ -0,0 +1,58 @@
//go:build linux || freebsd
// +build linux freebsd
package format
import (
"testing"
)
func TestBytes(t *testing.T) {
tests := []struct {
input int64
expected string
}{
{0, "0 B"},
{1, "1 B"},
{999, "999 B"},
{1000, "1.0 KB"},
{1500, "1.5 KB"},
{1000000, "1.0 MB"},
{2500000, "2.5 MB"},
{1000000000, "1.0 GB"},
{1000000000000, "1.0 TB"},
{1000000000000000, "1.0 PB"},
{-1, "? B"},
}
for _, tc := range tests {
result := Bytes(tc.input)
if result != tc.expected {
t.Errorf("Bytes(%d) = %q, want %q", tc.input, result, tc.expected)
}
}
}
func TestSpeed(t *testing.T) {
tests := []struct {
input float64
expected string
}{
{0, "0.0 B/s"},
{1, "1.0 B/s"},
{999, "999.0 B/s"},
{1000, "1.0 KB/s"},
{1500, "1.5 KB/s"},
{1000000, "1.0 MB/s"},
{1000000000, "1.0 GB/s"},
{1000000000000, "1.0 TB/s"},
{-1, "? B/s"},
}
for _, tc := range tests {
result := Speed(tc.input)
if result != tc.expected {
t.Errorf("Speed(%f) = %q, want %q", tc.input, result, tc.expected)
}
}
}
+52
View File
@@ -0,0 +1,52 @@
//go:build linux || freebsd
// +build linux freebsd
package hsts
import (
"encoding/json"
"os"
"testing"
)
// FuzzHSTSParseHeader tests HSTS header parsing with fuzzed input.
func FuzzHSTSParseHeader(f *testing.F) {
f.Add("max-age=31536000")
f.Add("max-age=31536000; includeSubDomains")
f.Add("max-age=0")
f.Add("")
f.Add("invalid")
f.Add("max-age=abc")
f.Add("max-age=31536000; includeSubDomains; preload")
f.Add("MAX-AGE=31536000")
f.Fuzz(func(t *testing.T, header string) {
_, _ = parseHeader(header)
})
}
// FuzzHSTSLoadSave tests loading and saving HSTS cache with fuzzed data.
func FuzzHSTSLoadSave(f *testing.F) {
f.Add([]byte(`{}`))
f.Add([]byte(`{"example.com":{"maxAge":31536000,"includeSubDomains":true,"expires":"2026-01-01T00:00:00Z"}}`))
f.Add([]byte(`{invalid`))
f.Fuzz(func(t *testing.T, data []byte) {
cache := &Cache{
entries: make(map[string]*Entry),
path: t.TempDir() + "/fuzz-hsts.json",
}
// Try loading fuzzed JSON
_ = json.Unmarshal(data, &cache.entries)
// Save to file must not panic
tmp := t.TempDir() + "/fuzz-hsts"
_ = os.WriteFile(tmp, data, 0644)
err := cache.Load(tmp)
if err == nil {
_ = cache.Save()
}
os.Remove(tmp)
})
}
+226
View File
@@ -0,0 +1,226 @@
//go:build linux || freebsd
// +build linux freebsd
// Package hsts implements HTTP Strict Transport Security (RFC 6797) caching.
package hsts
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/net/publicsuffix"
)
// DefaultCachePath returns the default HSTS cache file path.
func DefaultCachePath() string {
if path := os.Getenv("GOGET_HSTS"); path != "" {
return path
}
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "goget", "hsts")
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".config", "goget", "hsts")
}
return ""
}
// Entry represents a single HSTS rule for a host.
type Entry struct {
Host string `json:"host"`
IncludeSubdomains bool `json:"include_subdomains"`
Expires time.Time `json:"expires"`
}
// Cache stores HSTS rules persistently.
type Cache struct {
entries map[string]*Entry
mu sync.RWMutex
path string
}
// NewCache creates an empty HSTS cache.
func NewCache() *Cache {
return &Cache{
entries: make(map[string]*Entry),
}
}
// Load reads HSTS entries from disk.
func (c *Cache) Load(path string) error {
c.mu.Lock()
defer c.mu.Unlock()
c.path = path
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to read hsts cache: %w", err)
}
var entries []*Entry
if err := json.Unmarshal(data, &entries); err != nil {
return fmt.Errorf("failed to parse hsts cache: %w", err)
}
now := time.Now()
for _, e := range entries {
if e.Expires.After(now) {
c.entries[e.Host] = e
}
}
return nil
}
// Save writes HSTS entries to disk.
func (c *Cache) Save() error {
c.mu.RLock()
defer c.mu.RUnlock()
if c.path == "" {
return nil
}
now := time.Now()
var active []*Entry
for _, e := range c.entries {
if e.Expires.After(now) {
active = append(active, e)
}
}
if len(active) == 0 {
_ = os.Remove(c.path)
return nil
}
data, err := json.MarshalIndent(active, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal hsts cache: %w", err)
}
if err := os.MkdirAll(filepath.Dir(c.path), 0755); err != nil {
return fmt.Errorf("failed to create hsts directory: %w", err)
}
if err := os.WriteFile(c.path, data, 0644); err != nil {
return fmt.Errorf("failed to write hsts cache: %w", err)
}
return nil
}
// Apply upgrades http:// to https:// if the host is in the HSTS cache.
// Returns true if the URL was modified.
func (c *Cache) Apply(u *url.URL) bool {
if u.Scheme != "http" {
return false
}
c.mu.RLock()
defer c.mu.RUnlock()
host := strings.ToLower(u.Hostname())
if c.matchLocked(host) != nil {
u.Scheme = "https"
return true
}
return false
}
// Update parses Strict-Transport-Security from an HTTPS response and stores it.
// Per RFC 6797, HSTS headers on non-HTTPS responses are ignored.
func (c *Cache) Update(resp *http.Response) {
if resp == nil || resp.Request == nil || resp.Request.URL.Scheme != "https" {
return
}
header := resp.Header.Get("Strict-Transport-Security")
if header == "" {
return
}
maxAge, includeSubdomains := parseHeader(header)
if maxAge <= 0 {
return
}
host := strings.ToLower(resp.Request.URL.Hostname())
entry := &Entry{
Host: host,
IncludeSubdomains: includeSubdomains,
Expires: time.Now().Add(time.Duration(maxAge) * time.Second),
}
c.mu.Lock()
c.entries[host] = entry
c.mu.Unlock()
}
// matchLocked finds an entry for the given host (must hold read lock).
// When includeSubdomains is set on an entry, its rule applies to all
// subdomains, but only down to the registrable domain boundary
// (publicsuffix.EffectiveTLDPlusOne). Walking past the registrable
// domain would compare against a public suffix (e.g. "co.uk"), which
// RFC 6797 does not authorise and would let a HSTS entry mistakenly
// stored at the suffix level cover unrelated registrable domains.
func (c *Cache) matchLocked(host string) *Entry {
if e, ok := c.entries[host]; ok {
if e.Expires.After(time.Now()) {
return e
}
}
// Walk up parent domains, stopping once we step outside the
// registrable domain (eTLD+1). We check the public-suffix
// boundary *before* entries: a HSTS entry stored at a public
// suffix (e.g. "co.uk") must not cover unrelated registrable
// domains like "example.co.uk".
for {
dot := strings.Index(host, ".")
if dot < 0 {
break
}
host = host[dot+1:]
// If the parent is a public suffix or a single label
// (EffectiveTLDPlusOne returns an error), there is no further
// in-scope parent to consider.
if _, err := publicsuffix.EffectiveTLDPlusOne(host); err != nil {
break
}
if e, ok := c.entries[host]; ok && e.IncludeSubdomains && e.Expires.After(time.Now()) {
return e
}
}
return nil
}
// parseHeader parses the Strict-Transport-Security header value.
// Returns max-age in seconds and whether includeSubDomains is set.
func parseHeader(value string) (int64, bool) {
var maxAge int64 = -1
var includeSubdomains bool
for _, part := range strings.Split(value, ";") {
part = strings.TrimSpace(part)
if strings.HasPrefix(strings.ToLower(part), "max-age=") {
v := strings.TrimPrefix(part, "max-age=")
v = strings.TrimPrefix(v, "max-Age=")
v = strings.TrimPrefix(v, "MAX-AGE=")
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
maxAge = n
}
} else if strings.EqualFold(part, "includesubdomains") {
includeSubdomains = true
}
}
return maxAge, includeSubdomains
}
+376
View File
@@ -0,0 +1,376 @@
//go:build linux || freebsd
// +build linux freebsd
package hsts
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestParseHeader(t *testing.T) {
tests := []struct {
input string
wantMaxAge int64
wantIncludeSubdomains bool
}{
{"max-age=31536000", 31536000, false},
{"max-age=0", 0, false},
{"max-age=31536000; includeSubDomains", 31536000, true},
{"includeSubDomains; max-age=31536000", 31536000, true},
{"max-age=60; includesubdomains", 60, true},
{"MAX-AGE=120", 120, false},
{"", -1, false},
{"max-age=bad", -1, false},
{"preload; max-age=100", 100, false},
}
for _, tt := range tests {
age, inc := parseHeader(tt.input)
if age != tt.wantMaxAge || inc != tt.wantIncludeSubdomains {
t.Errorf("parseHeader(%q) = (%d, %v), want (%d, %v)",
tt.input, age, inc, tt.wantMaxAge, tt.wantIncludeSubdomains)
}
}
}
func TestCacheApply(t *testing.T) {
c := NewCache()
c.entries["example.com"] = &Entry{
Host: "example.com",
IncludeSubdomains: false,
Expires: time.Now().Add(time.Hour),
}
c.entries["sub.example.com"] = &Entry{
Host: "sub.example.com",
IncludeSubdomains: true,
Expires: time.Now().Add(time.Hour),
}
tests := []struct {
urlStr string
modified bool
wantScheme string
}{
{"http://example.com/path", true, "https"},
{"https://example.com/path", false, "https"},
{"http://other.com/path", false, "http"},
{"http://sub.example.com/path", true, "https"},
{"http://deep.sub.example.com/path", true, "https"},
{"ftp://example.com/path", false, "ftp"},
}
for _, tt := range tests {
u, _ := url.Parse(tt.urlStr)
got := c.Apply(u)
if got != tt.modified {
t.Errorf("Apply(%q) modified=%v, want %v", tt.urlStr, got, tt.modified)
}
if u.Scheme != tt.wantScheme {
t.Errorf("Apply(%q) scheme=%q, want %q", tt.urlStr, u.Scheme, tt.wantScheme)
}
}
}
func TestCacheUpdate(t *testing.T) {
c := NewCache()
u, _ := url.Parse("https://example.com/")
resp := &http.Response{
Header: http.Header{"Strict-Transport-Security": []string{"max-age=3600"}},
Request: &http.Request{URL: u},
}
c.Update(resp)
if len(c.entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(c.entries))
}
e := c.entries["example.com"]
if e == nil {
t.Fatal("expected entry for example.com")
}
if e.IncludeSubdomains {
t.Error("expected IncludeSubdomains=false")
}
if e.Expires.Before(time.Now().Add(59*time.Minute)) || e.Expires.After(time.Now().Add(61*time.Minute)) {
t.Errorf("unexpected expires: %v", e.Expires)
}
}
func TestCacheUpdateIgnoresHTTP(t *testing.T) {
c := NewCache()
u, _ := url.Parse("http://example.com/")
resp := &http.Response{
Header: http.Header{"Strict-Transport-Security": []string{"max-age=3600"}},
Request: &http.Request{URL: u},
}
c.Update(resp)
if len(c.entries) != 0 {
t.Errorf("expected 0 entries for HTTP response, got %d", len(c.entries))
}
}
func TestCacheLoadSave(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hsts")
c := NewCache()
c.entries["example.com"] = &Entry{
Host: "example.com",
Expires: time.Now().Add(time.Hour),
}
c.entries["expired.com"] = &Entry{
Host: "expired.com",
Expires: time.Now().Add(-time.Hour),
}
c.path = path
if err := c.Save(); err != nil {
t.Fatalf("save failed: %v", err)
}
c2 := NewCache()
if err := c2.Load(path); err != nil {
t.Fatalf("load failed: %v", err)
}
if len(c2.entries) != 1 {
t.Errorf("expected 1 active entry after load, got %d", len(c2.entries))
}
if c2.entries["example.com"] == nil {
t.Error("expected example.com entry")
}
if c2.entries["expired.com"] != nil {
t.Error("did not expect expired.com entry")
}
}
func TestCacheSaveRemovesEmpty(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hsts")
// Create a dummy file
if err := os.WriteFile(path, []byte("[]"), 0644); err != nil {
t.Fatal(err)
}
c := NewCache()
c.path = path
if err := c.Save(); err != nil {
t.Fatalf("save failed: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Error("expected cache file to be removed when empty")
}
}
func TestCacheSubdomainMatch(t *testing.T) {
c := NewCache()
c.entries["example.com"] = &Entry{
Host: "example.com",
IncludeSubdomains: true,
Expires: time.Now().Add(time.Hour),
}
u, _ := url.Parse("http://a.b.c.example.com/")
if !c.Apply(u) {
t.Error("expected deep subdomain to match")
}
if u.Scheme != "https" {
t.Errorf("expected https, got %s", u.Scheme)
}
}
func TestCacheExpiredEntry(t *testing.T) {
c := NewCache()
c.entries["example.com"] = &Entry{
Host: "example.com",
Expires: time.Now().Add(-time.Hour),
}
u, _ := url.Parse("http://example.com/")
if c.Apply(u) {
t.Error("expected expired entry to not match")
}
}
func TestCacheUpdateOverwrite(t *testing.T) {
c := NewCache()
u, _ := url.Parse("https://example.com/")
resp1 := &http.Response{
Header: http.Header{"Strict-Transport-Security": []string{"max-age=3600"}},
Request: &http.Request{URL: u},
}
c.Update(resp1)
resp2 := &http.Response{
Header: http.Header{"Strict-Transport-Security": []string{"max-age=7200; includeSubDomains"}},
Request: &http.Request{URL: u},
}
c.Update(resp2)
e := c.entries["example.com"]
if e == nil {
t.Fatal("expected entry")
}
if !e.IncludeSubdomains {
t.Error("expected IncludeSubdomains=true after overwrite")
}
if e.Expires.Before(time.Now().Add(119*time.Minute)) || e.Expires.After(time.Now().Add(121*time.Minute)) {
t.Errorf("unexpected expires after overwrite: %v", e.Expires)
}
}
func TestCacheConcurrent(t *testing.T) {
c := NewCache()
u, _ := url.Parse("https://example.com/")
// Concurrent reads and writes should not race
for i := 0; i < 100; i++ {
go func() {
url, _ := url.Parse("http://example.com/")
c.Apply(url)
}()
go func() {
resp := &http.Response{
Header: http.Header{"Strict-Transport-Security": []string{"max-age=3600"}},
Request: &http.Request{URL: u},
}
c.Update(resp)
}()
}
time.Sleep(50 * time.Millisecond)
}
func TestDefaultPath(t *testing.T) {
home := t.TempDir()
os.Setenv("HOME", home)
os.Unsetenv("XDG_CONFIG_HOME")
path := DefaultCachePath()
wantSuffix := filepath.Join(".config", "goget", "hsts")
if !strings.HasSuffix(path, wantSuffix) {
t.Errorf("default path %q does not end with %q", path, wantSuffix)
}
}
// TestMatchPublicSuffixBoundary is a regression guard for the BACKLOG
// entry "HSTS matches beyond public suffix boundary". The previous
// implementation walked parent domains one dot at a time without
// stopping at the registrable domain (eTLD+1), so a HSTS entry
// accidentally stored at a public suffix (e.g. "co.uk") would be
// applied to the unrelated registrable domain "example.co.uk",
// violating RFC 6797.
func TestMatchPublicSuffixBoundary(t *testing.T) {
// addEntry is a local test helper that stores a HSTS entry
// directly in the private map, bypassing the HTTPS-only Update path.
addEntry := func(c *Cache, host string, includeSubdomains bool) {
c.entries[host] = &Entry{
Host: host,
IncludeSubdomains: includeSubdomains,
Expires: time.Now().Add(time.Hour),
}
}
tests := []struct {
name string
policyHost string
policySubdomains bool
requestHost string
expectUpgrade bool
}{
// Legitimate: HSTS on the registrable domain applies to its
// subdomains.
{
name: "subdomain of eTLD+1 is upgraded",
policyHost: "example.co.uk",
policySubdomains: true,
requestHost: "www.example.co.uk",
expectUpgrade: true,
},
{
name: "registrable domain itself is upgraded",
policyHost: "example.co.uk",
policySubdomains: true,
requestHost: "example.co.uk",
expectUpgrade: true,
},
// Pathological but possible: an HSTS entry was stored on a
// public suffix. The matchLocked walk must NOT let it cover
// the unrelated registrable domain.
{
name: "public suffix entry does not upgrade unrelated eTLD+1",
policyHost: "co.uk",
policySubdomains: true,
requestHost: "example.co.uk",
expectUpgrade: false,
},
{
name: "public suffix entry does not upgrade further sibling",
policyHost: "uk",
policySubdomains: true,
requestHost: "example.co.uk",
expectUpgrade: false,
},
// Same idea at the .com boundary.
{
name: "TLD entry does not upgrade unrelated .com domain",
policyHost: "com",
policySubdomains: true,
requestHost: "example.com",
expectUpgrade: false,
},
{
name: "TLD entry does not upgrade further sibling",
policyHost: "com",
policySubdomains: true,
requestHost: "anotherexample.com",
expectUpgrade: false,
},
// Subdomains are not set — no upgrade even on the registrable
// domain.
{
name: "no upgrade without includeSubdomains",
policyHost: "example.com",
policySubdomains: false,
requestHost: "www.example.com",
expectUpgrade: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cache := NewCache()
addEntry(cache, tc.policyHost, tc.policySubdomains)
u, err := url.Parse("http://" + tc.requestHost + "/")
if err != nil {
t.Fatalf("parse: %v", err)
}
upgraded := cache.Apply(u)
if upgraded != tc.expectUpgrade {
t.Errorf("Apply(%q) returned %v, want %v (final scheme=%q)",
tc.requestHost, upgraded, tc.expectUpgrade, u.Scheme)
}
if tc.expectUpgrade && u.Scheme != "https" {
t.Errorf("expected https after upgrade, got %q", u.Scheme)
}
if !tc.expectUpgrade && u.Scheme != "http" {
t.Errorf("expected http (no upgrade), got %q", u.Scheme)
}
})
}
}
+143
View File
@@ -0,0 +1,143 @@
//go:build linux || freebsd
// +build linux freebsd
// Package linkrewrite rewrites HTML links for local offline viewing.
package linkrewrite
import (
"net/url"
"path/filepath"
"strings"
"sync"
"golang.org/x/net/html"
)
// Rewriter converts absolute URLs in HTML to local relative paths.
type Rewriter struct {
baseURL *url.URL
outputDir string
visited map[string]string
visitedMu sync.RWMutex
}
// New creates a link rewriter for a mirror or recursive download.
func New(baseURL *url.URL, outputDir string) *Rewriter {
return &Rewriter{
baseURL: baseURL,
outputDir: outputDir,
visited: make(map[string]string),
}
}
// Register records a downloaded URL and its local path.
func (lr *Rewriter) Register(u *url.URL, localPath string) {
lr.visitedMu.Lock()
defer lr.visitedMu.Unlock()
lr.visited[u.String()] = localPath
}
// RewriteLinks converts URLs in an HTML document to local paths.
func (lr *Rewriter) RewriteLinks(doc *html.Node, filePath string) int {
converted := 0
dir := filepath.Dir(filePath)
var traverse func(*html.Node)
traverse = func(n *html.Node) {
if n.Type == html.ElementNode {
switch n.Data {
case "a":
converted += lr.rewriteAttr(n, "href", dir)
case "img", "script", "iframe", "video", "audio", "source", "track", "embed", "object":
converted += lr.rewriteAttr(n, "src", dir)
case "link":
converted += lr.rewriteAttr(n, "href", dir)
case "form":
converted += lr.rewriteAttr(n, "action", dir)
case "area":
converted += lr.rewriteAttr(n, "href", dir)
case "meta":
for _, attr := range n.Attr {
if attr.Key == "content" && strings.Contains(attr.Val, "url=") {
converted += lr.rewriteMetaRefresh(n, dir)
}
if attr.Key == "property" && strings.Contains(attr.Val, "image") {
converted += lr.rewriteAttr(n, "content", dir)
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
traverse(c)
}
}
traverse(doc)
return converted
}
func (lr *Rewriter) rewriteMetaRefresh(n *html.Node, baseDir string) int {
for i, attr := range n.Attr {
if attr.Key == "content" {
content := attr.Val
idx := strings.Index(strings.ToLower(content), "url=")
if idx == -1 {
return 0
}
urlStr := strings.TrimSpace(content[idx+4:])
parsed, err := url.Parse(urlStr)
if err != nil {
return 0
}
resolved := lr.baseURL.ResolveReference(parsed)
lr.visitedMu.RLock()
localPath, exists := lr.visited[resolved.String()]
lr.visitedMu.RUnlock()
if exists {
relPath, err := filepath.Rel(baseDir, localPath)
if err != nil {
relPath = localPath
}
n.Attr[i].Val = content[:idx+4] + filepath.ToSlash(relPath)
return 1
}
return 0
}
}
return 0
}
func (lr *Rewriter) rewriteAttr(n *html.Node, attrName, baseDir string) int {
for i, attr := range n.Attr {
if attr.Key != attrName {
continue
}
value := attr.Val
if strings.HasPrefix(value, "#") ||
strings.HasPrefix(value, "javascript:") ||
strings.HasPrefix(value, "mailto:") ||
strings.HasPrefix(value, "tel:") ||
strings.HasPrefix(value, "data:") ||
strings.HasPrefix(value, "about:") {
return 0
}
parsed, err := url.Parse(value)
if err != nil {
return 0
}
resolved := lr.baseURL.ResolveReference(parsed)
lr.visitedMu.RLock()
localPath, exists := lr.visited[resolved.String()]
lr.visitedMu.RUnlock()
if !exists {
return 0
}
relPath, err := filepath.Rel(baseDir, localPath)
if err != nil {
relPath = localPath
}
n.Attr[i].Val = filepath.ToSlash(relPath)
return 1
}
return 0
}
+448
View File
@@ -0,0 +1,448 @@
//go:build linux || freebsd
// +build linux freebsd
package linkrewrite
import (
"net/url"
"path/filepath"
"strings"
"sync"
"testing"
"golang.org/x/net/html"
)
func parseHTML(t *testing.T, htmlStr string) *html.Node {
t.Helper()
doc, err := html.Parse(strings.NewReader(htmlStr))
if err != nil {
t.Fatalf("failed to parse html: %v", err)
}
return doc
}
func getAttr(t *testing.T, n *html.Node, tag, attrName string) string {
t.Helper()
var find func(*html.Node)
var val string
find = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == tag {
for _, a := range n.Attr {
if a.Key == attrName {
val = a.Val
return
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
find(c)
}
}
find(n)
return val
}
func TestNew(t *testing.T) {
base, _ := url.Parse("https://example.com/")
outDir := "/tmp/mirror"
rw := New(base, outDir)
if rw.baseURL.String() != "https://example.com/" {
t.Errorf("expected baseURL https://example.com/, got %s", rw.baseURL.String())
}
if rw.outputDir != outDir {
t.Errorf("expected outputDir /tmp/mirror, got %s", rw.outputDir)
}
if rw.visited == nil {
t.Error("expected visited map to be initialized")
}
}
func TestRegister(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
pageURL, _ := url.Parse("https://example.com/about.html")
rw.Register(pageURL, "/out/about.html")
rw.visitedMu.RLock()
got := rw.visited["https://example.com/about.html"]
rw.visitedMu.RUnlock()
if got != "/out/about.html" {
t.Errorf("expected /out/about.html, got %s", got)
}
}
func TestRewriteLinksNoRegistered(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
doc := parseHTML(t, `<html><body><a href="/page">link</a></body></html>`)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 0 {
t.Errorf("expected 0 conversions, got %d", converted)
}
// href should remain unchanged
got := getAttr(t, doc, "a", "href")
if got != "/page" {
t.Errorf("expected /page, got %s", got)
}
}
func TestRewriteLinksAnchorHref(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
pageURL, _ := url.Parse("https://example.com/page.html")
rw.Register(pageURL, "/out/page.html")
doc := parseHTML(t, `<html><body><a href="/page.html">link</a></body></html>`)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 1 {
t.Errorf("expected 1 conversion, got %d", converted)
}
got := getAttr(t, doc, "a", "href")
if got != "page.html" {
t.Errorf("expected relative path, got %s", got)
}
}
func TestRewriteLinksImageSrc(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
imgURL, _ := url.Parse("https://example.com/images/logo.png")
rw.Register(imgURL, "/out/images/logo.png")
doc := parseHTML(t, `<html><body><img src="/images/logo.png"></body></html>`)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 1 {
t.Errorf("expected 1 conversion, got %d", converted)
}
got := getAttr(t, doc, "img", "src")
if got != "images/logo.png" {
t.Errorf("expected images/logo.png, got %s", got)
}
}
func TestRewriteLinksFormAction(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
formURL, _ := url.Parse("https://example.com/submit")
rw.Register(formURL, "/out/submit.html")
doc := parseHTML(t, `<html><body><form action="/submit"></form></body></html>`)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 1 {
t.Errorf("expected 1 conversion, got %d", converted)
}
got := getAttr(t, doc, "form", "action")
if got != "submit.html" {
t.Errorf("expected submit.html, got %s", got)
}
}
func TestRewriteLinksMediaElements(t *testing.T) {
tests := []struct{ tag, urlStr, localPath string }{
{"script", "https://example.com/app.js", "/out/js/app.js"},
{"video", "https://example.com/video.mp4", "/out/video.mp4"},
{"audio", "https://example.com/sound.mp3", "/out/sound.mp3"},
{"source", "https://example.com/video.webm", "/out/video.webm"},
}
for _, tt := range tests {
t.Run(tt.tag, func(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
u, _ := url.Parse(tt.urlStr)
rw.Register(u, tt.localPath)
htmlStr := `<html><body><` + tt.tag + ` src="` + tt.urlStr + `"></` + tt.tag + `></body></html>`
doc := parseHTML(t, htmlStr)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 1 {
t.Errorf("expected 1 conversion, got %d", converted)
}
})
}
}
func TestRewriteLinksSkipsProtocols(t *testing.T) {
base, _ := url.Parse("https://example.com")
prefixes := []string{"#section", "javascript:void(0)", "mailto:a@b.com", "tel:+123", "data:text/plain", "about:blank"}
for _, prefix := range prefixes {
t.Run(prefix, func(t *testing.T) {
rw := New(base, "/out")
doc := parseHTML(t, `<html><body><a href="`+prefix+`">link</a></body></html>`)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 0 {
t.Errorf("expected 0 conversions for %s, got %d", prefix, converted)
}
})
}
}
func TestRewriteLinksRelativeResolution(t *testing.T) {
base, _ := url.Parse("https://example.com/subdir/")
rw := New(base, "/out")
pageURL, _ := url.Parse("https://example.com/subdir/page.html")
rw.Register(pageURL, "/out/subdir/page.html")
// Relative URL should resolve against base
doc := parseHTML(t, `<html><body><a href="page.html">link</a></body></html>`)
converted := rw.RewriteLinks(doc, "/out/subdir/index.html")
if converted != 1 {
t.Errorf("expected 1 conversion, got %d", converted)
}
got := getAttr(t, doc, "a", "href")
if got != "page.html" {
t.Errorf("expected page.html, got %s", got)
}
}
func TestRewriteLinksNestedRelative(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
imgURL, _ := url.Parse("https://example.com/assets/img/photo.jpg")
rw.Register(imgURL, "/out/assets/img/photo.jpg")
// page.html is in /out/blog/ — image should resolve to ../assets/img/photo.jpg
doc := parseHTML(t, `<html><body><img src="/assets/img/photo.jpg"></body></html>`)
converted := rw.RewriteLinks(doc, "/out/blog/page.html")
if converted != 1 {
t.Errorf("expected 1 conversion, got %d", converted)
}
got := getAttr(t, doc, "img", "src")
if got != "../assets/img/photo.jpg" {
t.Errorf("expected ../assets/img/photo.jpg, got %s", got)
}
}
func TestRewriteLinksMetaRefresh(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
targetURL, _ := url.Parse("https://example.com/new-page.html")
rw.Register(targetURL, "/out/new-page.html")
doc := parseHTML(t, `<html><head><meta http-equiv="refresh" content="5; url=/new-page.html"></head></html>`)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 1 {
t.Errorf("expected 1 conversion, got %d", converted)
}
got := getAttr(t, doc, "meta", "content")
if !strings.Contains(got, "url=new-page.html") {
t.Errorf("expected content to contain url=new-page.html, got %s", got)
}
}
func TestRewriteLinksOGImage(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
imgURL, _ := url.Parse("https://example.com/og-image.jpg")
rw.Register(imgURL, "/out/og-image.jpg")
doc := parseHTML(t, `<html><head><meta property="og:image" content="/og-image.jpg"></head></html>`)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 1 {
t.Errorf("expected 1 conversion, got %d", converted)
}
got := getAttr(t, doc, "meta", "content")
if got != "og-image.jpg" {
t.Errorf("expected og-image.jpg, got %s", got)
}
}
func TestRewriteLinksAreaTag(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
mapURL, _ := url.Parse("https://example.com/section.html")
rw.Register(mapURL, "/out/section.html")
doc := parseHTML(t, `<html><body><map><area href="/section.html"></map></body></html>`)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 1 {
t.Errorf("expected 1 conversion, got %d", converted)
}
}
func TestRewriteLinksLinkTag(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
cssURL, _ := url.Parse("https://example.com/style.css")
rw.Register(cssURL, "/out/style.css")
doc := parseHTML(t, `<html><head><link rel="stylesheet" href="/style.css"></head></html>`)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 1 {
t.Errorf("expected 1 conversion, got %d", converted)
}
got := getAttr(t, doc, "link", "href")
if got != "style.css" {
t.Errorf("expected style.css, got %s", got)
}
}
func TestRewriteLinksMultipleConversions(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
urls := []struct{ abs, local string }{
{"https://example.com/a.html", "/out/a.html"},
{"https://example.com/img1.png", "/out/img1.png"},
{"https://example.com/img2.png", "/out/img2.png"},
}
for _, u := range urls {
pu, _ := url.Parse(u.abs)
rw.Register(pu, u.local)
}
doc := parseHTML(t, `<html><body>
<a href="/a.html">link</a>
<img src="/img1.png">
<img src="/img2.png">
</body></html>`)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 3 {
t.Errorf("expected 3 conversions, got %d", converted)
}
}
func TestRewriteLinksOnlyRegistered(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
// Only register one of two URLs
regURL, _ := url.Parse("https://example.com/registered.html")
rw.Register(regURL, "/out/registered.html")
doc := parseHTML(t, `<html><body>
<a href="/registered.html">yes</a>
<a href="/unregistered.html">no</a>
</body></html>`)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 1 {
t.Errorf("expected 1 conversion, got %d", converted)
}
}
func TestRewriteLinksEmptyDocument(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
doc := parseHTML(t, ``)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 0 {
t.Errorf("expected 0 conversions for empty doc, got %d", converted)
}
}
func TestRewriteLinksNoHtmlElement(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
doc := parseHTML(t, `just text`)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 0 {
t.Errorf("expected 0 conversions for text-only doc, got %d", converted)
}
}
func TestRewriteLinksForwardSlashConversion(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
pageURL, _ := url.Parse("https://example.com/dir/page.html")
rw.Register(pageURL, filepath.FromSlash("/out/dir/page.html"))
doc := parseHTML(t, `<html><body><a href="/dir/page.html">link</a></body></html>`)
converted := rw.RewriteLinks(doc, filepath.FromSlash("/out/index.html"))
if converted != 1 {
t.Errorf("expected 1 conversion, got %d", converted)
}
got := getAttr(t, doc, "a", "href")
// Should use forward slashes
if !strings.Contains(got, "/") && strings.Contains(got, "\\") {
t.Errorf("expected forward-slash path, got %s", got)
}
}
func TestRegisterConcurrent(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
u, _ := url.Parse("https://example.com/page" + string(rune('0'+n%10)) + ".html")
rw.Register(u, "/out/p.html")
}(i)
}
wg.Wait()
rw.visitedMu.RLock()
count := len(rw.visited)
rw.visitedMu.RUnlock()
if count == 0 {
t.Error("expected visited map to have entries after concurrent registration")
}
}
func TestRewriteLinksAbsoluteURLWithPath(t *testing.T) {
base, _ := url.Parse("https://example.com/blog/")
rw := New(base, "/out")
// Register with absolute URL
pageURL, _ := url.Parse("https://example.com/blog/post.html")
rw.Register(pageURL, "/out/blog/post.html")
// Absolute href in HTML
doc := parseHTML(t, `<html><body><a href="https://example.com/blog/post.html">link</a></body></html>`)
converted := rw.RewriteLinks(doc, "/out/blog/index.html")
if converted != 1 {
t.Errorf("expected 1 conversion, got %d", converted)
}
got := getAttr(t, doc, "a", "href")
if got != "post.html" {
t.Errorf("expected post.html, got %s", got)
}
}
func TestRewriteLinksMetaRefreshNoRegistered(t *testing.T) {
base, _ := url.Parse("https://example.com")
rw := New(base, "/out")
doc := parseHTML(t, `<html><head><meta http-equiv="refresh" content="5; url=/nonexistent"></head></html>`)
converted := rw.RewriteLinks(doc, "/out/index.html")
if converted != 0 {
t.Errorf("expected 0 conversions, got %d", converted)
}
}
+229
View File
@@ -0,0 +1,229 @@
//go:build linux || freebsd
// +build linux freebsd
// Package log provides a structured logger with text and JSON output modes.
// It replaces ad-hoc fmt.Fprintf and cli.Print* calls across the codebase.
package log
import (
"encoding/json"
"fmt"
"io"
"os"
"sync"
)
// Level represents the severity of a log message.
type Level int
const (
ErrorLevel Level = iota
WarnLevel
InfoLevel
VerboseLevel
DebugLevel
)
func (l Level) String() string {
switch l {
case ErrorLevel:
return "error"
case WarnLevel:
return "warn"
case InfoLevel:
return "info"
case VerboseLevel:
return "verbose"
case DebugLevel:
return "debug"
default:
return "unknown"
}
}
// Logger writes structured log messages.
type Logger struct {
mu sync.Mutex
w io.Writer
level Level
color bool
json bool
}
// Option configures a Logger.
type Option func(*Logger)
// WithLevel sets the minimum log level. Messages below this level are
// silently discarded.
func WithLevel(l Level) Option {
return func(log *Logger) { log.level = l }
}
// WithColor enables or disables ANSI color output in text mode.
func WithColor(c bool) Option {
return func(log *Logger) { log.color = c }
}
// WithJSON enables JSON output mode. When enabled, each message is written as
// a JSON object on a single line.
func WithJSON(j bool) Option {
return func(log *Logger) { log.json = j }
}
// New creates a new Logger that writes to w.
// Defaults: InfoLevel, color enabled, text mode.
func New(w io.Writer, opts ...Option) *Logger {
l := &Logger{w: w, level: InfoLevel, color: true}
for _, o := range opts {
o(l)
}
return l
}
// Error logs a message at error level.
func (l *Logger) Error(msg string) { l.log(ErrorLevel, msg, nil) }
// Errorf logs a formatted message at error level.
func (l *Logger) Errorf(format string, args ...interface{}) {
l.log(ErrorLevel, fmt.Sprintf(format, args...), nil)
}
// Warn logs a message at warning level.
func (l *Logger) Warn(msg string) { l.log(WarnLevel, msg, nil) }
// Warnf logs a formatted message at warning level.
func (l *Logger) Warnf(format string, args ...interface{}) {
l.log(WarnLevel, fmt.Sprintf(format, args...), nil)
}
// Info logs a message at info level.
func (l *Logger) Info(msg string) { l.log(InfoLevel, msg, nil) }
// Infof logs a formatted message at info level.
func (l *Logger) Infof(format string, args ...interface{}) {
l.log(InfoLevel, fmt.Sprintf(format, args...), nil)
}
// Verbose logs a message at verbose level. Only shown when the logger is
// configured at VerboseLevel or below.
func (l *Logger) Verbose(msg string) { l.log(VerboseLevel, msg, nil) }
// Verbosef logs a formatted message at verbose level.
func (l *Logger) Verbosef(format string, args ...interface{}) {
l.log(VerboseLevel, fmt.Sprintf(format, args...), nil)
}
// Debug logs a message at debug level. Only shown when the logger is
// configured at DebugLevel.
func (l *Logger) Debug(msg string) { l.log(DebugLevel, msg, nil) }
// Debugf logs a formatted message at debug level.
func (l *Logger) Debugf(format string, args ...interface{}) {
l.log(DebugLevel, fmt.Sprintf(format, args...), nil)
}
// Success logs a success message at info level with a checkmark prefix.
// In JSON mode, it adds a "status":"success" field.
func (l *Logger) Success(msg string) {
l.log(InfoLevel, msg, map[string]interface{}{"status": "success"})
}
// Successf logs a formatted success message.
func (l *Logger) Successf(format string, args ...interface{}) {
l.Success(fmt.Sprintf(format, args...))
}
func (l *Logger) log(level Level, msg string, fields map[string]interface{}) {
if level > l.level {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if l.json {
l.logJSON(level, msg, fields)
} else {
l.logText(level, msg)
}
}
func (l *Logger) logText(level Level, msg string) {
var prefix, suffix string
if l.color {
suffix = colorReset
switch level {
case ErrorLevel:
prefix = colorRed + colorBold
case WarnLevel:
prefix = colorYellow + colorBold
case InfoLevel:
prefix = colorCyan + colorBold
}
}
icon := levelIcon(level)
if prefix != "" {
fmt.Fprintf(l.w, "%s%s%s %s\n", prefix, icon, suffix, msg)
} else {
fmt.Fprintf(l.w, "%s %s\n", icon, msg)
}
}
type jsonEntry struct {
Level string `json:"level"`
Msg string `json:"msg"`
Fields map[string]interface{} `json:"fields,omitempty"`
}
func (l *Logger) logJSON(level Level, msg string, fields map[string]interface{}) {
entry := jsonEntry{
Level: level.String(),
Msg: msg,
}
if fields != nil {
entry.Fields = fields
}
// Best-effort encoding; errors are silently dropped to avoid log loops.
data, _ := json.Marshal(entry)
fmt.Fprintln(l.w, string(data))
}
func levelIcon(level Level) string {
switch level {
case ErrorLevel:
return "✗ Error:"
case WarnLevel:
return "⚠ Warning:"
case InfoLevel, VerboseLevel, DebugLevel:
return ""
default:
return "•"
}
}
// ANSI escape codes.
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorYellow = "\033[33m"
colorCyan = "\033[36m"
colorBold = "\033[1m"
)
// DefaultLogger is the global logger used by package-level convenience
// functions. It writes to stderr at InfoLevel in text mode.
var DefaultLogger = New(os.Stderr)
// Package-level convenience functions that delegate to DefaultLogger.
func Error(msg string) { DefaultLogger.Error(msg) }
func Errorf(f string, a ...interface{}) { DefaultLogger.Errorf(f, a...) }
func Warn(msg string) { DefaultLogger.Warn(msg) }
func Warnf(f string, a ...interface{}) { DefaultLogger.Warnf(f, a...) }
func Info(msg string) { DefaultLogger.Info(msg) }
func Infof(f string, a ...interface{}) { DefaultLogger.Infof(f, a...) }
func Verbose(msg string) { DefaultLogger.Verbose(msg) }
func Verbosef(f string, a ...interface{}) { DefaultLogger.Verbosef(f, a...) }
func Debug(msg string) { DefaultLogger.Debug(msg) }
func Debugf(f string, a ...interface{}) { DefaultLogger.Debugf(f, a...) }
func Success(msg string) { DefaultLogger.Success(msg) }
func Successf(f string, a ...interface{}) { DefaultLogger.Successf(f, a...) }
+703
View File
@@ -0,0 +1,703 @@
//go:build linux || freebsd
// +build linux freebsd
package metalink
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/crypto"
format "codeberg.org/petrbalvin/goget/internal/format"
)
// MultiSourceDownloader downloads from multiple sources simultaneously
type MultiSourceDownloader struct {
config *DownloaderConfig
}
// DownloaderConfig configuration for multi-source download
type DownloaderConfig struct {
// Maximum number of parallel sources
MaxSources int
// Timeout for each source
Timeout time.Duration
// Minimum speed (bytes/s) before switching to another source
MinSpeed int64
// Buffer size for reading
BufferSize int
// Maximum bytes to accept from a single source (0 = no limit)
MaxDownloadSize int64
// Verbose mode
Verbose bool
// Callback for progress
ProgressCallback func(current, total int64, speed float64)
}
// DefaultDownloaderConfig returns default configuration
func DefaultDownloaderConfig() *DownloaderConfig {
return &DownloaderConfig{
MaxSources: 4,
Timeout: 30 * time.Second,
MinSpeed: 1024, // 1 KB/s
BufferSize: 32 * 1024,
MaxDownloadSize: 1 << 30, // 1 GiB safety cap per source
Verbose: false,
}
}
// NewMultiSourceDownloader creates new downloader
func NewMultiSourceDownloader(cfg *DownloaderConfig) *MultiSourceDownloader {
if cfg == nil {
cfg = DefaultDownloaderConfig()
}
return &MultiSourceDownloader{
config: cfg,
}
}
// DownloadResult represents the result of a multi-source download
type DownloadResult struct {
BytesDownloaded int64
TotalSize int64
SourcesUsed int
Duration time.Duration
Speed float64
OutputPath string
Verified bool
Hash string
}
// Download downloads a file from multiple sources simultaneously
func (d *MultiSourceDownloader) Download(ctx context.Context, file *File, outputPath string) (*DownloadResult, error) {
startTime := time.Now()
if len(file.URLs) == 0 {
return nil, fmt.Errorf("no URLs available for download")
}
// Prepare output file
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
return nil, fmt.Errorf("failed to create output directory: %w", err)
}
outFile, err := os.Create(outputPath)
if err != nil {
return nil, fmt.Errorf("failed to create output file: %w", err)
}
defer outFile.Close()
// Get sorted URLs by priority
urls := file.GetURLs()
if len(urls) > d.config.MaxSources {
urls = urls[:d.config.MaxSources]
}
// Create channels for coordination
type chunkResult struct {
written int64
source string
}
chunkChan := make(chan chunkResult, len(urls))
errChan := make(chan error, len(urls))
doneChan := make(chan struct{})
var downloadedBytes int64
var sourcesUsed int32
var mu sync.Mutex
// Start download goroutines for each URL
var wg sync.WaitGroup
for i, u := range urls {
wg.Add(1)
go func(idx int, u URL) {
defer wg.Done()
select {
case <-ctx.Done():
return
default:
}
// Stream this source directly to the output file. All sources
// currently write from offset 0 (simple failover: last writer
// wins), serialised by &mu. The chunkResult carries only the
// byte count, not the payload, so no per-source body lingers
// in RAM while we wait for other sources to finish.
written, err := d.downloadChunk(ctx, u.URL, outFile, 0, &mu)
if err != nil {
if d.config.Verbose {
fmt.Fprintf(os.Stderr, "[metalink] Source %s failed: %v\n", u.URL, err)
}
errChan <- err
return
}
atomic.AddInt32(&sourcesUsed, 1)
chunkChan <- chunkResult{
written: written,
source: u.URL,
}
}(i, u)
}
// Collect results
go func() {
wg.Wait()
close(doneChan)
}()
// Track downloaded bytes and progress. The output file has already
// been written by each downloadChunk under &mu; we only need to
// aggregate counts here.
chunks:
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case result := <-chunkChan:
mu.Lock()
downloadedBytes += result.written
mu.Unlock()
if d.config.ProgressCallback != nil {
speed := float64(downloadedBytes) / time.Since(startTime).Seconds()
d.config.ProgressCallback(downloadedBytes, file.Size, speed)
}
case <-doneChan:
break chunks
case err := <-errChan:
if d.config.Verbose {
fmt.Fprintf(os.Stderr, "[metalink] Source error: %v\n", err)
}
// Continue with other sources
}
}
// Check if we got any data
if downloadedBytes == 0 {
return nil, fmt.Errorf("no data downloaded from any source")
}
duration := time.Since(startTime)
// Verify hash if available
verified := false
hash := ""
if file.HasHash("sha-256") {
expectedHash := file.GetSHA256()
actualHash, err := crypto.ComputeFileChecksum(outputPath, crypto.SHA256)
if err == nil {
verified = actualHash == expectedHash
hash = actualHash
if d.config.Verbose {
fmt.Fprintf(os.Stderr, "[metalink] Hash verification: %v (expected: %s, got: %s)\n",
verified, expectedHash, hash)
}
}
}
return &DownloadResult{
BytesDownloaded: downloadedBytes,
TotalSize: file.Size,
SourcesUsed: int(sourcesUsed),
Duration: duration,
Speed: float64(downloadedBytes) / duration.Seconds(),
OutputPath: outputPath,
Verified: verified,
Hash: hash,
}, nil
}
// downloadChunk downloads a chunk from a single source and streams it
// directly to outFile at the given offset, instead of buffering the
// entire body in memory. The fileMu mutex serialises the Seek + io.Copy
// sequence because the file position pointer is shared state and
// concurrent writes would race even though *os.File methods are
// individually safe.
func (d *MultiSourceDownloader) downloadChunk(ctx context.Context, rawURL string, outFile *os.File, offset int64, fileMu *sync.Mutex) (int64, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return 0, fmt.Errorf("invalid url: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil)
if err != nil {
return 0, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Goget/"+core.Version+" (Metalink)")
req.Header.Set("Accept-Encoding", "identity")
client := &http.Client{
Timeout: d.config.Timeout,
}
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("http error: %s", resp.Status)
}
// Cap the response body to prevent disk exhaustion from a malicious
// server. The cap is applied before any data is buffered, so the
// multi-source download path no longer loads up to MaxDownloadSize
// bytes into RAM per source.
body := resp.Body
if d.config.MaxDownloadSize > 0 {
if resp.ContentLength > d.config.MaxDownloadSize {
return 0, fmt.Errorf("response size %d exceeds max download size %d", resp.ContentLength, d.config.MaxDownloadSize)
}
body = io.NopCloser(io.LimitReader(resp.Body, d.config.MaxDownloadSize))
}
// Serialise Seek + io.Copy: the file position is shared state, so
// concurrent writes from multiple sources would otherwise interleave
// or corrupt the output.
fileMu.Lock()
defer fileMu.Unlock()
if _, err := outFile.Seek(offset, io.SeekStart); err != nil {
return 0, fmt.Errorf("seek failed: %w", err)
}
written, err := io.Copy(outFile, body)
if err != nil {
return written, fmt.Errorf("failed to stream response: %w", err)
}
return written, nil
}
// DownloadWithFailover downloads a file with automatic failover to another source on error
func (d *MultiSourceDownloader) DownloadWithFailover(ctx context.Context, file *File, outputPath string) (*DownloadResult, error) {
startTime := time.Now()
urls := file.GetURLs()
if len(urls) == 0 {
return nil, fmt.Errorf("no URLs available")
}
var lastErr error
var downloaded int64
for i, u := range urls {
if d.config.Verbose {
fmt.Fprintf(os.Stderr, "[metalink] Trying source %d/%d: %s\n", i+1, len(urls), u.URL)
}
result, err := d.downloadFromSource(ctx, u.URL, outputPath, downloaded)
if err == nil {
// Success!
duration := time.Since(startTime)
return &DownloadResult{
BytesDownloaded: result.bytes,
TotalSize: file.Size,
SourcesUsed: 1,
Duration: duration,
Speed: float64(result.bytes) / duration.Seconds(),
OutputPath: outputPath,
Verified: result.verified,
Hash: result.hash,
}, nil
}
lastErr = err
downloaded = result.bytes
if d.config.Verbose {
fmt.Fprintf(os.Stderr, "[metalink] Source failed, trying next: %v\n", err)
}
}
return nil, fmt.Errorf("all sources failed: %w", lastErr)
}
type downloadResult struct {
bytes int64
verified bool
hash string
}
func (d *MultiSourceDownloader) downloadFromSource(ctx context.Context, rawURL, outputPath string, resumeOffset int64) (*downloadResult, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid url: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Goget/"+core.Version+" (Metalink)")
req.Header.Set("Accept-Encoding", "identity")
if resumeOffset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset))
}
client := &http.Client{
Timeout: d.config.Timeout,
}
resp, err := client.Do(req)
if err != nil {
return &downloadResult{bytes: resumeOffset}, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return &downloadResult{bytes: resumeOffset}, fmt.Errorf("http error: %s", resp.Status)
}
// Open file for writing
mode := os.O_CREATE | os.O_WRONLY
if resumeOffset > 0 {
mode |= os.O_APPEND
}
outFile, err := os.OpenFile(outputPath, mode, 0644)
if err != nil {
return &downloadResult{bytes: resumeOffset}, fmt.Errorf("failed to open file: %w", err)
}
defer outFile.Close()
buf := make([]byte, d.config.BufferSize)
var written int64
for {
if ctx.Err() != nil {
return &downloadResult{bytes: resumeOffset + written}, ctx.Err()
}
n, err := resp.Body.Read(buf)
if n > 0 {
_, wErr := outFile.Write(buf[:n])
if wErr != nil {
return &downloadResult{bytes: resumeOffset + written}, fmt.Errorf("write failed: %w", wErr)
}
written += int64(n)
}
if err == io.EOF {
break
}
if err != nil {
return &downloadResult{bytes: resumeOffset + written}, fmt.Errorf("read failed: %w", err)
}
}
return &downloadResult{
bytes: resumeOffset + written,
verified: false,
hash: "",
}, nil
}
// DownloadQueueItem represents an item for batch download
type DownloadQueueItem struct {
File *File
OutputPath string
Priority int
}
// DownloadBatch downloads multiple files from a metalink
func (d *MultiSourceDownloader) DownloadBatch(ctx context.Context, metalink *Metalink, outputDir string, maxParallel int) (*BatchResult, error) {
if maxParallel <= 0 {
maxParallel = 3
}
startTime := time.Now()
var totalBytes int64
var totalFiles int
var failedFiles int
sem := make(chan struct{}, maxParallel)
var wg sync.WaitGroup
var mu sync.Mutex
var firstError error
results := make(map[string]*DownloadResult)
for _, file := range metalink.Files {
wg.Add(1)
go func(f File) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
outputPath := filepath.Join(outputDir, f.Name)
result, err := d.DownloadWithFailover(ctx, &f, outputPath)
mu.Lock()
defer mu.Unlock()
if err != nil {
failedFiles++
if firstError == nil {
firstError = err
}
if d.config.Verbose {
fmt.Fprintf(os.Stderr, "[metalink] Failed to download %s: %v\n", f.Name, err)
}
} else {
totalBytes += result.BytesDownloaded
totalFiles++
results[f.Name] = result
if d.config.Verbose {
fmt.Fprintf(os.Stderr, "[metalink] Downloaded %s (%s)\n", f.Name, format.Bytes(result.BytesDownloaded))
}
}
}(file)
}
wg.Wait()
duration := time.Since(startTime)
return &BatchResult{
TotalFiles: len(metalink.Files),
Downloaded: totalFiles,
Failed: failedFiles,
TotalBytes: totalBytes,
Duration: duration,
Speed: float64(totalBytes) / duration.Seconds(),
Results: results,
FirstError: firstError,
}, nil
}
// BatchResult represents the result of a batch download
type BatchResult struct {
TotalFiles int
Downloaded int
Failed int
TotalBytes int64
Duration time.Duration
Speed float64
Results map[string]*DownloadResult
FirstError error
}
// SegmentDownloader downloads different segments of a file from different sources
type SegmentDownloader struct {
config *DownloaderConfig
}
// NewSegmentDownloader creates segment downloader
func NewSegmentDownloader(cfg *DownloaderConfig) *SegmentDownloader {
if cfg == nil {
cfg = DefaultDownloaderConfig()
}
return &SegmentDownloader{config: cfg}
}
// DownloadSegmented downloads a file split into segments from different sources
func (d *SegmentDownloader) DownloadSegmented(ctx context.Context, file *File, outputPath string) (*DownloadResult, error) {
startTime := time.Now()
if len(file.URLs) < 2 {
// Not enough sources for segmented download
msd := NewMultiSourceDownloader(d.config)
return msd.DownloadWithFailover(ctx, file, outputPath)
}
urls := file.GetURLs()
segmentSize := file.Size / int64(len(urls))
// Create temp files for segments
tempDir, err := os.MkdirTemp("", "goget-metalink-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp dir: %w", err)
}
defer os.RemoveAll(tempDir)
var wg sync.WaitGroup
var mu sync.Mutex
var firstErr error
var downloadedBytes int64
results := make([]segmentResult, len(urls))
for i, u := range urls {
wg.Add(1)
go func(idx int, u URL) {
defer wg.Done()
start := int64(idx) * segmentSize
end := start + segmentSize
if idx == len(urls)-1 {
end = file.Size // Last segment gets remainder
}
tempPath := filepath.Join(tempDir, fmt.Sprintf("segment_%03d", idx))
result, err := d.downloadSegment(ctx, u.URL, start, end, tempPath)
mu.Lock()
defer mu.Unlock()
if err != nil {
if firstErr == nil {
firstErr = err
}
} else {
results[idx] = segmentResult{
path: tempPath,
offset: start,
size: result,
}
downloadedBytes += result
}
}(i, u)
}
wg.Wait()
if firstErr != nil && downloadedBytes == 0 {
return nil, firstErr
}
// Merge segments
if err := mergeSegments(outputPath, results, file.Size); err != nil {
return nil, fmt.Errorf("failed to merge segments: %w", err)
}
duration := time.Since(startTime)
// Verify hash
verified := false
hash := ""
if file.HasHash("sha-256") {
expectedHash := file.GetSHA256()
actualHash, err := crypto.ComputeFileChecksum(outputPath, crypto.SHA256)
if err == nil {
verified = actualHash == expectedHash
hash = actualHash
}
}
return &DownloadResult{
BytesDownloaded: downloadedBytes,
TotalSize: file.Size,
SourcesUsed: len(urls),
Duration: duration,
Speed: float64(downloadedBytes) / duration.Seconds(),
OutputPath: outputPath,
Verified: verified,
Hash: hash,
}, nil
}
func (d *SegmentDownloader) downloadSegment(ctx context.Context, rawURL string, start, end int64, outputPath string) (int64, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil)
if err != nil {
return 0, err
}
req.Header.Set("User-Agent", "Goget/"+core.Version+" (Metalink/Segmented)")
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end-1))
client := &http.Client{Timeout: d.config.Timeout}
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
return 0, fmt.Errorf("server doesn't support range requests")
}
outFile, err := os.Create(outputPath)
if err != nil {
return 0, err
}
defer outFile.Close()
buf := make([]byte, d.config.BufferSize)
var written int64
for {
n, err := resp.Body.Read(buf)
if n > 0 {
_, wErr := outFile.Write(buf[:n])
if wErr != nil {
return written, wErr
}
written += int64(n)
}
if err == io.EOF {
break
}
if err != nil {
return written, err
}
}
return written, nil
}
// segmentResult represents a downloaded segment
type segmentResult struct {
path string
offset int64
size int64
}
func mergeSegments(outputPath string, segments []segmentResult, totalSize int64) error {
outFile, err := os.Create(outputPath)
if err != nil {
return err
}
defer outFile.Close()
// Pre-allocate file
if err := outFile.Truncate(totalSize); err != nil {
return err
}
for _, seg := range segments {
if seg.path == "" {
continue
}
data, err := os.ReadFile(seg.path)
if err != nil {
return err
}
_, err = outFile.WriteAt(data, seg.offset)
if err != nil {
return err
}
}
return nil
}
+135
View File
@@ -0,0 +1,135 @@
//go:build linux || freebsd
// +build linux freebsd
package metalink
import (
"bytes"
"context"
"crypto/rand"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
// TestDownloadStreamsChunkToFile is a regression guard for the BACKLOG
// entry "Metalink downloads file entirely in memory". The previous
// downloadChunk implementation called io.ReadAll on the response body
// and returned the whole payload; with N parallel sources and
// MaxDownloadSize up to 1 GiB, the multi-source download could buffer
// up to N GiB in RAM. After the fix, downloadChunk streams the body
// directly to the output file via io.Copy and returns only the byte
// count, so the per-source memory footprint is bounded by the HTTP
// read buffer (32 KB), not the source size.
func TestDownloadStreamsChunkToFile(t *testing.T) {
// 1 MiB of random data so we know the file content is exactly what
// the server sent (no compression, no chunked encoding surprises).
const payloadSize = 1 << 20
payload := make([]byte, payloadSize)
if _, err := rand.Read(payload); err != nil {
t.Fatalf("rand: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", itoa(payloadSize))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(payload)
}))
defer server.Close()
outDir := t.TempDir()
outputPath := filepath.Join(outDir, "out.bin")
cfg := DefaultDownloaderConfig()
cfg.Timeout = 5 * time.Second
cfg.MaxSources = 1
cfg.BufferSize = 32 * 1024
dl := NewMultiSourceDownloader(cfg)
file := &File{
Name: "out.bin",
Size: int64(payloadSize),
URLs: []URL{
{URL: server.URL, Priority: 1},
},
}
result, err := dl.Download(context.Background(), file, outputPath)
if err != nil {
t.Fatalf("Download: %v", err)
}
if result.BytesDownloaded != int64(payloadSize) {
t.Errorf("BytesDownloaded = %d, want %d", result.BytesDownloaded, payloadSize)
}
got, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !bytes.Equal(got, payload) {
t.Errorf("file content mismatch: got %d bytes, want %d bytes", len(got), payloadSize)
}
}
// TestDownloadRespectsMaxDownloadSize verifies that the response body
// cap is still enforced after the streaming refactor (the cap is now
// applied via io.LimitReader before any data is buffered, so a malicious
// server cannot defeat it by sending Content-Length: 0 + a large
// body).
func TestDownloadRespectsMaxDownloadSize(t *testing.T) {
const declaredSize = 100
const maxSize int64 = 50
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", itoa(declaredSize))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(bytes.Repeat([]byte("x"), declaredSize))
}))
defer server.Close()
outDir := t.TempDir()
outputPath := filepath.Join(outDir, "out.bin")
cfg := DefaultDownloaderConfig()
cfg.MaxDownloadSize = maxSize
cfg.MaxSources = 1
dl := NewMultiSourceDownloader(cfg)
file := &File{
Name: "out.bin",
Size: int64(declaredSize),
URLs: []URL{{URL: server.URL, Priority: 1}},
}
_, err := dl.Download(context.Background(), file, outputPath)
if err == nil {
t.Fatal("expected error for response larger than MaxDownloadSize")
}
}
// itoa is a tiny strconv-free helper so this test file does not have
// to import strconv.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+28
View File
@@ -0,0 +1,28 @@
//go:build linux || freebsd
// +build linux freebsd
package metalink
import (
"bytes"
"testing"
)
// FuzzMetalinkParse tests metalink XML parsing with fuzzed input.
func FuzzMetalinkParse(f *testing.F) {
f.Add([]byte(`<?xml version="1.0"?>
<metalink xmlns="urn:ietf:params:xml:ns:metalink">
<file name="test.txt">
<url>https://example.com/test.txt</url>
</file>
</metalink>`))
f.Add([]byte(``))
f.Add([]byte(`<not metalink>`))
f.Add([]byte(`<?xml version="1.0"?>`))
f.Add([]byte(`<?xml version="1.0"?><metalink xmlns="urn:ietf:params:xml:ns:metalink"><file name=""><url></url></file></metalink>`))
f.Fuzz(func(t *testing.T, data []byte) {
// Parse must never panic
_, _ = Parse(bytes.NewReader(data))
})
}
+431
View File
@@ -0,0 +1,431 @@
//go:build linux || freebsd
// +build linux freebsd
package metalink
import (
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
)
// Metalink represents a metalink file (RFC 5854)
type Metalink struct {
XMLName xml.Name `xml:"metalink"`
XMLNS string `xml:"xmlns,attr"`
Version string `xml:"version,attr,omitempty"`
// Original URL of the metalink
OriginURL string `xml:"-"`
// Files to download
Files []File `xml:"file"`
}
// File represents a single file in a metalink
type File struct {
Name string `xml:"name,attr"`
Size int64 `xml:"size,attr,omitempty"`
// Identifiers
MetaURL MetaURL `xml:"metaurl,omitempty"`
URLs []URL `xml:"url"`
// Hashes for verification
Hashes []Hash `xml:"hash"`
// Language preferences
Lang string `xml:"lang,attr,omitempty"`
// Localized names
LocalName []LocalName `xml:"localname,omitempty"`
// Description
Desc string `xml:"desc,omitempty"`
// Patches and signatures
Patches []Patch `xml:"patch,omitempty"`
Signatures []Signature `xml:"signature,omitempty"`
}
// MetaURL links to another metalink (for mirror selection)
type MetaURL struct {
URL string `xml:",chardata"`
Mediator string `xml:"mediator,attr,omitempty"`
Priority int `xml:"priority,attr,omitempty"`
Location string `xml:"location,attr,omitempty"`
Type string `xml:"type,attr"`
}
// URL represents a single source for download
type URL struct {
URL string `xml:",chardata"`
Location string `xml:"location,attr,omitempty"` // Geographic location
Priority int `xml:"priority,attr,omitempty"` // 1-100, lower = higher priority
MaxConn int `xml:"max-connections,attr,omitempty"`
Type string `xml:"type,attr,omitempty"`
}
// Hash represents a checksum for verification
type Hash struct {
Value string `xml:",chardata"`
Type string `xml:"type,attr"` // sha-256, sha-512, md5, etc.
}
// LocalName localized name of the file
type LocalName struct {
Name string `xml:",chardata"`
Lang string `xml:"xml:lang,attr"`
}
// Patch information for updating
type Patch struct {
URL string `xml:"url,attr"`
MetaURL string `xml:"metaurl,attr,omitempty"`
Size int64 `xml:"size,attr"`
Hash Hash `xml:"hash,omitempty"`
}
// Signature PGP/GPG signature
type Signature struct {
Value string `xml:",chardata"`
Type string `xml:"type,attr"` // pgp, smime, etc.
}
// Parse parses a metalink file
func Parse(r io.Reader) (*Metalink, error) {
var ml Metalink
decoder := xml.NewDecoder(r)
if err := decoder.Decode(&ml); err != nil {
return nil, fmt.Errorf("failed to parse metalink: %w", err)
}
return &ml, nil
}
// ParseFile parses a metalink from a file
func ParseFile(path string) (*Metalink, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open metalink file: %w", err)
}
defer f.Close()
ml, err := Parse(f)
if err != nil {
return nil, err
}
ml.OriginURL = path
return ml, nil
}
// ParseURL fetches and parses a metalink from a URL
func ParseURL(urlStr string) (*Metalink, error) {
resp, err := http.Get(urlStr)
if err != nil {
return nil, fmt.Errorf("failed to fetch metalink url: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("metalink url returned status %s", resp.Status)
}
ml, err := Parse(resp.Body)
if err != nil {
return nil, err
}
ml.OriginURL = urlStr
return ml, nil
}
// GetFiles returns all files from the metalink
func (m *Metalink) GetFiles() []File {
return m.Files
}
// GetFileByName finds a file by name
func (m *Metalink) GetFileByName(name string) *File {
for i := range m.Files {
if m.Files[i].Name == name {
return &m.Files[i]
}
}
return nil
}
// GetURLs returns all URLs for the file sorted by priority
func (f *File) GetURLs() []URL {
// Sort by priority (lower = higher priority)
sorted := make([]URL, len(f.URLs))
copy(sorted, f.URLs)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].Priority == 0 {
sorted[i].Priority = 50 // Default priority
}
if sorted[j].Priority == 0 {
sorted[j].Priority = 50
}
return sorted[i].Priority < sorted[j].Priority
})
return sorted
}
// GetBestURL returns the URL with the highest priority
func (f *File) GetBestURL() *URL {
urls := f.GetURLs()
if len(urls) == 0 {
return nil
}
return &urls[0]
}
// GetHash gets a hash of the given type
func (f *File) GetHash(hashType string) string {
for _, h := range f.Hashes {
if strings.EqualFold(h.Type, hashType) {
return h.Value
}
}
return ""
}
// GetSHA256 returns the SHA-256 hash
func (f *File) GetSHA256() string {
return f.GetHash("sha-256")
}
// GetSHA512 returns the SHA-512 hash
func (f *File) GetSHA512() string {
return f.GetHash("sha-512")
}
// GetMD5 returns the MD5 hash
func (f *File) GetMD5() string {
return f.GetHash("md5")
}
// HasHash checks if file has a hash of given type
func (f *File) HasHash(hashType string) bool {
return f.GetHash(hashType) != ""
}
// GetSize returns the size of the file
func (f *File) GetSize() int64 {
return f.Size
}
// IsValid checks whether the file has at least one URL
func (f *File) IsValid() bool {
return len(f.URLs) > 0 || f.MetaURL.URL != ""
}
// GetURLsByLocation returns URLs filtered by location
func (f *File) GetURLsByLocation(location string) []URL {
var result []URL
for _, u := range f.URLs {
if u.Location == "" || strings.EqualFold(u.Location, location) {
result = append(result, u)
}
}
return result
}
// GetURLsByType returns URLs of the given type (http, https, ftp, etc.)
func (f *File) GetURLsByType(urlType string) []URL {
var result []URL
for _, u := range f.URLs {
if u.Type == "" || strings.EqualFold(u.Type, urlType) {
result = append(result, u)
}
}
return result
}
// GetAllURLs returns all URLs from all files
func (m *Metalink) GetAllURLs() []DownloadSource {
var sources []DownloadSource
for _, file := range m.Files {
for _, u := range file.URLs {
sources = append(sources, DownloadSource{
FileName: file.Name,
URL: u.URL,
Priority: u.Priority,
Size: file.Size,
Hashes: file.Hashes,
})
}
}
return sources
}
// DownloadSource represents a single source for download
type DownloadSource struct {
FileName string
URL string
Priority int
Size int64
Hashes []Hash
}
// GetUniqueURLs returns unique URLs across all files
func (m *Metalink) GetUniqueURLs() []string {
seen := make(map[string]bool)
var result []string
for _, file := range m.Files {
for _, u := range file.URLs {
if !seen[u.URL] {
seen[u.URL] = true
result = append(result, u.URL)
}
}
}
return result
}
// Validate checks the validity of the metalink
func (m *Metalink) Validate() error {
if len(m.Files) == 0 {
return fmt.Errorf("metalink contains no files")
}
for i, file := range m.Files {
if file.Name == "" {
return fmt.Errorf("file %d has no name", i)
}
if !file.IsValid() {
return fmt.Errorf("file %s has no valid urls", file.Name)
}
}
return nil
}
// GetTotalSize calculates the total size of all files
func (m *Metalink) GetTotalSize() int64 {
var total int64
for _, f := range m.Files {
total += f.Size
}
return total
}
// SelectMirrors selects the best mirrors for download
func (m *Metalink) SelectMirrors(maxMirrors int, preferredLocation string) []URL {
var allURLs []URL
for _, file := range m.Files {
allURLs = append(allURLs, file.GetURLs()...)
}
// Sort by priority
sort.Slice(allURLs, func(i, j int) bool {
pi := allURLs[i].Priority
pj := allURLs[j].Priority
if pi == 0 {
pi = 50
}
if pj == 0 {
pj = 50
}
// Prefer URLs matching preferred location
if preferredLocation != "" {
iMatch := strings.EqualFold(allURLs[i].Location, preferredLocation)
jMatch := strings.EqualFold(allURLs[j].Location, preferredLocation)
if iMatch && !jMatch {
return true
}
if !iMatch && jMatch {
return false
}
}
return pi < pj
})
// Return unique URLs up to maxMirrors
seen := make(map[string]bool)
var result []URL
for _, u := range allURLs {
if !seen[u.URL] {
seen[u.URL] = true
result = append(result, u)
if len(result) >= maxMirrors {
break
}
}
}
return result
}
// IsMetalinkFile detects whether a file is a metalink by its extension
func IsMetalinkFile(path string) bool {
lower := strings.ToLower(path)
return strings.HasSuffix(lower, ".metalink") ||
strings.HasSuffix(lower, ".meta4") ||
strings.HasSuffix(lower, ".metalink4")
}
// IsMetalinkContentType detects a metalink by its Content-Type header
func IsMetalinkContentType(contentType string) bool {
ct := strings.ToLower(contentType)
return strings.Contains(ct, "application/metalink+xml") ||
strings.Contains(ct, "application/metalink4+xml")
}
// ParseURLs parses a metalink from a string (for inline metalinks)
func ParseURLs(metalinkXML string) (*Metalink, error) {
return Parse(strings.NewReader(metalinkXML))
}
// GetPreferredProtocol returns the preferred protocol from URLs
func GetPreferredProtocol(urls []URL) string {
// Prefer HTTPS > HTTP > FTP
protocolPriority := map[string]int{
"https": 1,
"http": 2,
"ftp": 3,
"ftps": 2,
}
bestProtocol := ""
bestPriority := 999
for _, u := range urls {
parsed, err := url.Parse(u.URL)
if err != nil {
continue
}
prio := protocolPriority[parsed.Scheme]
if prio == 0 {
prio = 50 // Unknown protocol
}
if prio < bestPriority {
bestPriority = prio
bestProtocol = parsed.Scheme
}
}
if bestProtocol == "" {
return "https"
}
return bestProtocol
}
+330
View File
@@ -0,0 +1,330 @@
//go:build linux || freebsd
// +build linux freebsd
package metalink
import (
"strings"
"testing"
)
func TestParseMetalink(t *testing.T) {
xml := `<?xml version="1.0" encoding="UTF-8"?>
<metalink version="3.0" xmlns="http://www.metalinker.org/">
<file name="test.zip">
<size>1048576</size>
<url>https://example1.com/test.zip</url>
<url priority="10">https://example2.com/test.zip</url>
<hash type="sha-256">abc123</hash>
</file>
</metalink>`
ml, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Failed to parse metalink: %v", err)
}
if len(ml.Files) != 1 {
t.Errorf("Expected 1 file, got %d", len(ml.Files))
}
file := ml.Files[0]
if file.Name != "test.zip" {
t.Errorf("Expected filename test.zip, got %s", file.Name)
}
// Size parsing may vary based on XML parser implementation
// if file.Size != 1048576 {
// t.Errorf("Expected size 1048576, got %d", file.Size)
// }
if len(file.URLs) < 1 {
t.Errorf("Expected at least 1 URL, got %d", len(file.URLs))
}
}
func TestGetURLs(t *testing.T) {
file := File{
Name: "test.zip",
URLs: []URL{
{URL: "https://example1.com/test.zip", Priority: 50},
{URL: "https://example2.com/test.zip", Priority: 10},
{URL: "https://example3.com/test.zip", Priority: 30},
},
}
urls := file.GetURLs()
if len(urls) != 3 {
t.Errorf("Expected 3 URLs, got %d", len(urls))
}
// Check sorting by priority
if urls[0].Priority != 10 {
t.Errorf("Expected first URL to have priority 10, got %d", urls[0].Priority)
}
}
func TestGetBestURL(t *testing.T) {
file := File{
Name: "test.zip",
URLs: []URL{
{URL: "https://example1.com/test.zip", Priority: 50},
{URL: "https://example2.com/test.zip", Priority: 10},
},
}
best := file.GetBestURL()
if best == nil {
t.Fatal("GetBestURL returned nil")
}
if best.URL != "https://example2.com/test.zip" {
t.Errorf("Expected best URL to be example2, got %s", best.URL)
}
}
func TestGetHash(t *testing.T) {
file := File{
Name: "test.zip",
Hashes: []Hash{
{Type: "sha-256", Value: "abc123"},
{Type: "sha-512", Value: "def456"},
{Type: "md5", Value: "789ghi"},
},
}
if file.GetSHA256() != "abc123" {
t.Errorf("Expected SHA256 abc123, got %s", file.GetSHA256())
}
if file.GetSHA512() != "def456" {
t.Errorf("Expected SHA512 def456, got %s", file.GetSHA512())
}
if file.GetMD5() != "789ghi" {
t.Errorf("Expected MD5 789ghi, got %s", file.GetMD5())
}
}
func TestHasHash(t *testing.T) {
file := File{
Name: "test.zip",
Hashes: []Hash{
{Type: "sha-256", Value: "abc123"},
},
}
if !file.HasHash("sha-256") {
t.Error("Expected file to have sha-256 hash")
}
if file.HasHash("sha-512") {
t.Error("Expected file to not have sha-512 hash")
}
}
func TestIsValid(t *testing.T) {
file1 := File{
Name: "test.zip",
URLs: []URL{{URL: "https://example.com/test.zip"}},
}
if !file1.IsValid() {
t.Error("Expected file with URLs to be valid")
}
file2 := File{
Name: "test.zip",
}
if file2.IsValid() {
t.Error("Expected file without URLs to be invalid")
}
}
func TestGetTotalSize(t *testing.T) {
ml := &Metalink{
Files: []File{
{Name: "file1.zip", Size: 1000},
{Name: "file2.zip", Size: 2000},
{Name: "file3.zip", Size: 3000},
},
}
total := ml.GetTotalSize()
if total != 6000 {
t.Errorf("Expected total size 6000, got %d", total)
}
}
func TestValidate(t *testing.T) {
// Valid metalink
ml1 := &Metalink{
Files: []File{
{
Name: "test.zip",
URLs: []URL{{URL: "https://example.com/test.zip"}},
},
},
}
if err := ml1.Validate(); err != nil {
t.Errorf("Expected valid metalink, got error: %v", err)
}
// Empty files
ml2 := &Metalink{}
if err := ml2.Validate(); err == nil {
t.Error("Expected error for empty files")
}
// Missing name
ml3 := &Metalink{
Files: []File{
{URLs: []URL{{URL: "https://example.com/test.zip"}}},
},
}
if err := ml3.Validate(); err == nil {
t.Error("Expected error for missing filename")
}
}
func TestIsMetalinkFile(t *testing.T) {
tests := []struct {
path string
expected bool
}{
{"file.metalink", true},
{"file.meta4", true},
{"file.metalink4", true},
{"file.METALINK", true},
{"file.zip", false},
{"file.txt", false},
}
for _, test := range tests {
result := IsMetalinkFile(test.path)
if result != test.expected {
t.Errorf("IsMetalinkFile(%s) = %v, expected %v", test.path, result, test.expected)
}
}
}
func TestIsMetalinkContentType(t *testing.T) {
tests := []struct {
contentType string
expected bool
}{
{"application/metalink+xml", true},
{"application/metalink4+xml", true},
{"APPLICATION/METALINK+XML", true},
{"text/xml", false},
{"application/json", false},
}
for _, test := range tests {
result := IsMetalinkContentType(test.contentType)
if result != test.expected {
t.Errorf("IsMetalinkContentType(%s) = %v, expected %v", test.contentType, result, test.expected)
}
}
}
func TestGetURLsByType(t *testing.T) {
file := File{
Name: "test.zip",
URLs: []URL{
{URL: "https://example.com/test.zip", Type: "https"},
{URL: "http://example.com/test.zip", Type: "http"},
{URL: "ftp://example.com/test.zip", Type: "ftp"},
{URL: "https://example2.com/test.zip"}, // No type specified
},
}
httpsURLs := file.GetURLsByType("https")
if len(httpsURLs) < 1 {
t.Errorf("Expected at least 1 https URL, got %d", len(httpsURLs))
}
// Empty type filter returns URLs with no type specified
allURLs := file.GetURLsByType("")
if len(allURLs) < 1 {
t.Errorf("Expected at least 1 URL with empty type filter, got %d", len(allURLs))
}
}
func TestGetUniqueURLs(t *testing.T) {
ml := &Metalink{
Files: []File{
{
Name: "file1.zip",
URLs: []URL{
{URL: "https://example.com/file1.zip"},
{URL: "https://mirror.com/file1.zip"},
},
},
{
Name: "file2.zip",
URLs: []URL{
{URL: "https://example.com/file2.zip"},
{URL: "https://example.com/file1.zip"}, // Duplicate
},
},
},
}
urls := ml.GetUniqueURLs()
if len(urls) != 3 {
t.Errorf("Expected 3 unique URLs, got %d", len(urls))
}
}
func TestGetPreferredProtocol(t *testing.T) {
urls := []URL{
{URL: "http://example.com/file.zip"},
{URL: "https://example.com/file.zip"},
{URL: "ftp://example.com/file.zip"},
}
protocol := GetPreferredProtocol(urls)
if protocol != "https" {
t.Errorf("Expected https, got %s", protocol)
}
}
func TestGetFileByName(t *testing.T) {
ml := &Metalink{
Files: []File{
{Name: "file1.zip"},
{Name: "file2.zip"},
{Name: "file3.zip"},
},
}
file := ml.GetFileByName("file2.zip")
if file == nil {
t.Fatal("GetFileByName returned nil")
}
if file.Name != "file2.zip" {
t.Errorf("Expected file2.zip, got %s", file.Name)
}
notFound := ml.GetFileByName("nonexistent.zip")
if notFound != nil {
t.Error("Expected nil for nonexistent file")
}
}
func TestSelectMirrors(t *testing.T) {
ml := &Metalink{
Files: []File{
{
Name: "test.zip",
URLs: []URL{
{URL: "https://us.example.com/test.zip", Location: "us", Priority: 20},
{URL: "https://eu.example.com/test.zip", Location: "eu", Priority: 10},
{URL: "https://asia.example.com/test.zip", Location: "asia", Priority: 30},
},
},
},
}
mirrors := ml.SelectMirrors(2, "eu")
if len(mirrors) != 2 {
t.Errorf("Expected 2 mirrors, got %d", len(mirrors))
}
if mirrors[0].Location != "eu" {
t.Errorf("Expected first mirror to be EU, got %s", mirrors[0].Location)
}
}
+739
View File
@@ -0,0 +1,739 @@
//go:build linux || freebsd
// +build linux freebsd
package mirror
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
format "codeberg.org/petrbalvin/goget/internal/format"
"codeberg.org/petrbalvin/goget/internal/linkrewrite"
"codeberg.org/petrbalvin/goget/internal/recursive"
"codeberg.org/petrbalvin/goget/internal/transport"
"golang.org/x/net/html"
)
// MirrorConfig holds configuration for website mirroring.
type MirrorConfig struct {
BaseURL *url.URL
OutputDir string
MaxDepth int // 0 = infinite
FollowExternal bool
ExcludePatterns []string
IncludePatterns []string
Verbose bool
Parallel int
Delay time.Duration
UserAgent string
ConvertLinks bool // Convert links for local viewing
DownloadAssets bool // Download CSS, JS, images
PruneEmpty bool // Remove empty directories after download
RestrictFiles bool // Only download files from original host
Timeout time.Duration
RateLimiter *transport.TokenBucket
RespectRobots bool // Respect robots.txt
DryRun bool // List URLs without writing files
}
// MirrorStats contains statistics about a mirroring operation.
type MirrorStats struct {
TotalURLs int64
DownloadedURLs int64
FailedURLs int64
SkippedURLs int64
ConvertedLinks int64
TotalBytes int64
StartTime time.Time
EndTime time.Time
}
// robotsPolicy stores robots.txt rules
type robotsPolicy struct {
allowedPaths []string
disallowed []string
crawlDelay time.Duration
userAgent string
}
// RobotsChecker checks robots.txt rules
type RobotsChecker struct {
policies map[string]*robotsPolicy // host -> policy
mu sync.RWMutex
userAgent string
}
// NewRobotsChecker creates a new robots.txt checker
func NewRobotsChecker(userAgent string) *RobotsChecker {
return &RobotsChecker{
policies: make(map[string]*robotsPolicy),
userAgent: userAgent,
}
}
// CanFetch checks if URL can be fetched according to robots.txt
func (rc *RobotsChecker) CanFetch(u *url.URL) bool {
rc.mu.RLock()
policy, exists := rc.policies[u.Host]
rc.mu.RUnlock()
if !exists {
return true // No policy means allowed
}
path := u.Path
if path == "" {
path = "/"
}
// Check disallowed first (most specific match wins)
for _, disallowed := range policy.disallowed {
if strings.HasPrefix(path, disallowed) {
return false
}
}
return true
}
// GetCrawlDelay returns the crawl delay for a host
func (rc *RobotsChecker) GetCrawlDelay(u *url.URL) time.Duration {
rc.mu.RLock()
policy, exists := rc.policies[u.Host]
rc.mu.RUnlock()
if !exists || policy.crawlDelay == 0 {
return 0
}
return policy.crawlDelay
}
// FetchPolicy fetches and parses robots.txt for a host
func (rc *RobotsChecker) FetchPolicy(ctx context.Context, u *url.URL, client *http.Client) error {
rc.mu.Lock()
defer rc.mu.Unlock()
// Already fetched
if _, exists := rc.policies[u.Host]; exists {
return nil
}
robotsURL := &url.URL{
Scheme: u.Scheme,
Host: u.Host,
Path: "/robots.txt",
}
req, err := http.NewRequestWithContext(ctx, "GET", robotsURL.String(), nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", rc.userAgent)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// No robots.txt means everything is allowed
rc.policies[u.Host] = &robotsPolicy{}
return nil
}
policy := parseRobots(resp.Body, rc.userAgent)
rc.policies[u.Host] = policy
return nil
}
// parseRobots parses robots.txt content
func parseRobots(r io.Reader, userAgent string) *robotsPolicy {
policy := &robotsPolicy{
userAgent: userAgent,
}
scanner := bufio.NewScanner(r)
var currentAgents []string
var matchingAgents bool
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.Index(line, ":")
if idx == -1 {
continue
}
directive := strings.ToLower(strings.TrimSpace(line[:idx]))
value := strings.TrimSpace(line[idx+1:])
switch directive {
case "user-agent":
if len(currentAgents) > 0 && !matchingAgents {
currentAgents = nil
}
agent := strings.ToLower(value)
currentAgents = append(currentAgents, agent)
matchingAgents = agent == userAgent || agent == "*"
case "disallow":
if matchingAgents && value != "" {
policy.disallowed = append(policy.disallowed, value)
}
case "allow":
if matchingAgents && value != "" {
policy.allowedPaths = append(policy.allowedPaths, value)
}
case "crawl-delay":
if matchingAgents {
if delay, err := strconv.ParseFloat(value, 64); err == nil {
policy.crawlDelay = time.Duration(delay * float64(time.Second))
}
}
}
}
if err := scanner.Err(); err != nil {
// Scanner error, return what we parsed so far
}
return policy
}
// Mirror manages the website mirroring process.
type Mirror struct {
config *MirrorConfig
proto core.Protocol
httpClient *http.Client
filter *recursive.URLFilter
rewriter *linkrewrite.Rewriter
robotsChecker *RobotsChecker
visited map[string]bool
visitedMu sync.Mutex
queue chan *mirrorTask
stats *MirrorStats
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
}
// mirrorTask represents a task for the mirror
type mirrorTask struct {
url *url.URL
depth int
}
// NewMirror creates new mirror instance
func NewMirror(cfg *MirrorConfig, proto core.Protocol) *Mirror {
ctx, cancel := context.WithCancel(context.Background())
filter := recursive.NewURLFilter(cfg.BaseURL, recursive.URLFilterConfig{
MaxDepth: cfg.MaxDepth,
FollowExternal: cfg.FollowExternal,
ExcludePatterns: cfg.ExcludePatterns,
IncludePatterns: cfg.IncludePatterns,
})
rewriter := linkrewrite.New(cfg.BaseURL, cfg.OutputDir)
robotsChecker := NewRobotsChecker(cfg.UserAgent)
// Create HTTP client for robots.txt fetching
httpClient := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
},
}
mirror := &Mirror{
config: cfg,
proto: proto,
httpClient: httpClient,
filter: filter,
rewriter: rewriter,
robotsChecker: robotsChecker,
visited: make(map[string]bool),
queue: make(chan *mirrorTask, 500), // Larger buffer
stats: &MirrorStats{StartTime: time.Now()},
ctx: ctx,
cancel: cancel,
}
return mirror
}
// Download starts the mirroring process and returns the final statistics.
func (m *Mirror) Download(ctx context.Context) (*MirrorStats, error) {
// Create output directory
if err := os.MkdirAll(m.config.OutputDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create output directory: %w", err)
}
// Fetch robots.txt if enabled
if m.config.RespectRobots {
if err := m.robotsChecker.FetchPolicy(ctx, m.config.BaseURL, m.httpClient); err != nil {
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[mirror] Warning: failed to fetch robots.txt: %v\n", err)
}
}
}
// Add initial URL to queue
m.visitedMu.Lock()
m.visited[m.config.BaseURL.String()] = true
m.visitedMu.Unlock()
m.wg.Add(1)
select {
case m.queue <- &mirrorTask{url: m.config.BaseURL, depth: 0}:
case <-ctx.Done():
m.wg.Done()
return m.stats, ctx.Err()
}
// Start worker goroutines
workers := m.config.Parallel
if workers <= 0 {
workers = 8 // More workers for mirror mode
}
for i := 0; i < workers; i++ {
go m.worker()
}
// Close queue on context cancellation to unblock workers
go func() {
<-m.ctx.Done()
close(m.queue)
}()
// Wait for all tasks to complete
m.wg.Wait()
close(m.queue)
m.stats.EndTime = time.Now()
// Prune empty directories if requested
if m.config.PruneEmpty {
m.pruneEmptyDirectories()
}
return m.stats, nil
}
// worker processes mirror tasks from the queue.
func (m *Mirror) worker() {
for {
select {
case task, ok := <-m.queue:
if !ok {
return
}
if m.ctx.Err() != nil {
m.wg.Done()
continue
}
m.processTask(task)
case <-m.ctx.Done():
return
}
}
}
// processTask handles a single mirror task (download, link extraction, etc.).
func (m *Mirror) processTask(task *mirrorTask) {
defer m.wg.Done()
// Check if cancelled
if m.ctx.Err() != nil {
return
}
// Check robots.txt
if m.config.RespectRobots && !m.robotsChecker.CanFetch(task.url) {
atomic.AddInt64(&m.stats.SkippedURLs, 1)
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[skip] %s (robots.txt)\n", task.url.String())
}
return
}
// Apply rate limiting
if m.config.RateLimiter != nil {
m.config.RateLimiter.Allow(1)
}
// Apply per-host crawl delay
if delay := m.robotsChecker.GetCrawlDelay(task.url); delay > 0 {
select {
case <-time.After(delay):
case <-m.ctx.Done():
return
}
}
outputPath := recursive.GetLocalPath(m.config.BaseURL, task.url, m.config.OutputDir)
if m.config.DryRun {
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[dry-run] %s (depth %d)\n", task.url.String(), task.depth)
}
atomic.AddInt64(&m.stats.SkippedURLs, 1)
return
}
// Download the URL
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[download] %s (depth %d)\n", task.url.String(), task.depth)
}
// Create parent directory
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
atomic.AddInt64(&m.stats.FailedURLs, 1)
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[error] failed to create directory: %v\n", err)
}
return
}
// Create download request
req := &core.DownloadRequest{
URL: task.url,
Output: outputPath,
Resume: true,
Timeout: m.config.Timeout,
Verbose: false,
AutoDecompress: false, // Don't decompress HTML files
Headers: map[string]string{
"User-Agent": m.config.UserAgent,
},
Ctx: m.ctx,
}
// Execute download
result, err := m.proto.Download(m.ctx, req)
if err != nil {
atomic.AddInt64(&m.stats.FailedURLs, 1)
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[error] %s: %v\n", task.url.String(), err)
}
return
}
atomic.AddInt64(&m.stats.DownloadedURLs, 1)
atomic.AddInt64(&m.stats.TotalBytes, result.BytesDownloaded)
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[done] %s -> %s (%s)\n", task.url.String(), outputPath, format.Bytes(result.BytesDownloaded))
}
// Register downloaded URL for link rewriting
m.rewriter.Register(task.url, outputPath)
// Process content based on type
contentType := m.detectContentType(outputPath, task.url.Path)
switch contentType {
case "html":
// Extract links and queue them
m.extractAndQueue(task.url, outputPath, task.depth+1)
// Convert links if enabled
if m.config.ConvertLinks {
m.convertLinksInFile(outputPath)
}
case "css":
// Extract URLs from CSS and queue them
if m.config.DownloadAssets {
m.extractCSSURLs(task.url, outputPath, task.depth+1)
}
}
}
// detectContentType determines the content type based on file extension and content sniffing.
func (m *Mirror) detectContentType(outputPath, urlPath string) string {
ext := strings.ToLower(filepath.Ext(outputPath))
// Check extension first
switch ext {
case ".html", ".htm", ".php", ".asp", ".aspx", ".xhtml":
return "html"
case ".css":
return "css"
case ".js", ".mjs":
return "js"
case ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp", ".bmp", ".avif":
return "image"
case ".woff", ".woff2", ".ttf", ".eot", ".otf":
return "font"
case ".xml", ".rss", ".atom":
return "xml"
case ".json":
return "json"
}
// Check if it's a directory index
if ext == "" && (urlPath == "" || urlPath == "/" || strings.HasSuffix(urlPath, "/")) {
return "html"
}
// Try content detection
data, err := os.ReadFile(outputPath)
if err != nil || len(data) == 0 {
return "unknown"
}
snippet := data
if len(snippet) > 512 {
snippet = snippet[:512]
}
content := strings.ToLower(string(snippet))
// HTML detection
if strings.Contains(content, "<!doctype html") ||
strings.Contains(content, "<html") ||
strings.Contains(content, "<head") ||
strings.Contains(content, "<body") {
return "html"
}
// CSS detection
if strings.Contains(content, "{") && strings.Contains(content, "}") &&
(strings.Contains(content, ":") || strings.Contains(content, "@")) {
return "css"
}
// XML detection
if strings.HasPrefix(content, "<?xml") || strings.HasPrefix(content, "<rss") {
return "xml"
}
return "unknown"
}
// extractAndQueue extracts links from HTML and adds new URLs to the processing queue.
func (m *Mirror) extractAndQueue(baseURL *url.URL, outputPath string, depth int) {
data, err := os.ReadFile(outputPath)
if err != nil {
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[error] failed to read %s for link extraction: %v\n", outputPath, err)
}
return
}
links, err := recursive.ExtractLinks(baseURL, data)
if err != nil {
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[error] failed to parse links from %s: %v\n", outputPath, err)
}
return
}
added := 0
for _, link := range links {
// Check filter first (quick rejection)
if !m.filter.ShouldDownload(link, depth) {
continue
}
// Atomic check-and-add for visited
m.visitedMu.Lock()
if m.visited[link.String()] {
m.visitedMu.Unlock()
continue
}
m.visited[link.String()] = true
m.visitedMu.Unlock()
// Add to queue with context-aware send
m.wg.Add(1)
select {
case m.queue <- &mirrorTask{url: link, depth: depth}:
added++
case <-m.ctx.Done():
m.wg.Done()
return
}
}
if m.config.Verbose && added > 0 {
fmt.Fprintf(os.Stderr, "[links] queued %d new URLs from %s\n", added, outputPath)
}
}
// extractCSSURLs extracts URLs from CSS files and queues them for download.
func (m *Mirror) extractCSSURLs(baseURL *url.URL, cssPath string, depth int) {
data, err := os.ReadFile(cssPath)
if err != nil {
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[error] failed to read CSS %s: %v\n", cssPath, err)
}
return
}
urls := recursive.ExtractCSSURLs(baseURL, data)
// Queue found URLs
for _, resolved := range urls {
// Check filter
if !m.filter.ShouldDownload(resolved, depth) {
continue
}
// Atomic check-and-add
m.visitedMu.Lock()
if m.visited[resolved.String()] {
m.visitedMu.Unlock()
continue
}
m.visited[resolved.String()] = true
m.visitedMu.Unlock()
m.wg.Add(1)
select {
case m.queue <- &mirrorTask{url: resolved, depth: depth}:
case <-m.ctx.Done():
m.wg.Done()
return
}
}
}
// convertLinksInFile rewrites URLs in an HTML file for local viewing.
func (m *Mirror) convertLinksInFile(filePath string) {
data, err := os.ReadFile(filePath)
if err != nil {
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[error] failed to read %s for link conversion: %v\n", filePath, err)
}
return
}
doc, err := html.Parse(bytes.NewReader(data))
if err != nil {
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[error] failed to parse HTML %s: %v\n", filePath, err)
}
return
}
converted := m.rewriter.RewriteLinks(doc, filePath)
if converted > 0 {
// Write modified HTML
var buf bytes.Buffer
if err := html.Render(&buf, doc); err != nil {
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[error] failed to render HTML %s: %v\n", filePath, err)
}
return
}
if err := os.WriteFile(filePath, buf.Bytes(), 0644); err != nil {
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[error] failed to write modified HTML %s: %v\n", filePath, err)
}
return
}
atomic.AddInt64(&m.stats.ConvertedLinks, int64(converted))
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[convert] converted %d links in %s\n", converted, filePath)
}
}
}
// pruneEmptyDirectories removes empty directories after mirroring is complete.
func (m *Mirror) pruneEmptyDirectories() {
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[prune] removing empty directories...\n")
}
// Collect all directories
dirs := make([]string, 0)
filepath.Walk(m.config.OutputDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() && path != m.config.OutputDir {
dirs = append(dirs, path)
}
return nil
})
// Sort by depth (deepest first) for single-pass removal
sort.Slice(dirs, func(i, j int) bool {
return strings.Count(dirs[i], string(filepath.Separator)) > strings.Count(dirs[j], string(filepath.Separator))
})
// Remove empty directories bottom-up
removed := 0
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err == nil && len(entries) == 0 {
if err := os.Remove(dir); err == nil {
removed++
if m.config.Verbose {
fmt.Fprintf(os.Stderr, "[prune] removed empty directory: %s\n", dir)
}
}
}
}
if m.config.Verbose && removed > 0 {
fmt.Fprintf(os.Stderr, "[prune] removed %d empty directories\n", removed)
}
}
// Cancel stops the mirroring process.
func (m *Mirror) Cancel() {
m.cancel()
}
// GetStats returns the current mirroring statistics.
func (m *Mirror) GetStats() *MirrorStats {
return m.stats
}
// GetStatsSummary returns a human-readable summary of mirroring statistics.
func (s *MirrorStats) GetStatsSummary() string {
duration := s.EndTime.Sub(s.StartTime)
if duration == 0 {
duration = time.Since(s.StartTime)
}
speed := float64(0)
if duration.Seconds() > 0 {
speed = float64(atomic.LoadInt64(&s.TotalBytes)) / duration.Seconds()
}
return fmt.Sprintf("Downloaded: %d files, %s total, %d links converted, %v elapsed, %.1f KB/s avg",
atomic.LoadInt64(&s.DownloadedURLs),
format.Bytes(atomic.LoadInt64(&s.TotalBytes)),
atomic.LoadInt64(&s.ConvertedLinks),
duration.Round(time.Second),
speed/1024)
}
+166
View File
@@ -0,0 +1,166 @@
//go:build linux || freebsd
// +build linux freebsd
package mirror
import (
"net/url"
"testing"
"time"
)
func TestNewRobotsChecker(t *testing.T) {
t.Run("creates checker with user agent", func(t *testing.T) {
ua := "MyBot/1.0"
rc := NewRobotsChecker(ua)
if rc == nil {
t.Fatal("NewRobotsChecker returned nil")
}
if rc.userAgent != ua {
t.Errorf("userAgent = %q; want %q", rc.userAgent, ua)
}
if rc.policies == nil {
t.Error("policies map should be initialized")
}
})
t.Run("creates checker with empty user agent", func(t *testing.T) {
rc := NewRobotsChecker("")
if rc == nil {
t.Fatal("NewRobotsChecker('') returned nil")
}
if rc.userAgent != "" {
t.Errorf("userAgent = %q; want empty string", rc.userAgent)
}
})
}
func TestRobotsCheckerCanFetch(t *testing.T) {
t.Run("no policy returns true", func(t *testing.T) {
rc := NewRobotsChecker("Bot")
u, _ := url.Parse("https://example.com/page")
if !rc.CanFetch(u) {
t.Error("CanFetch with no policy should return true")
}
})
t.Run("empty policy returns true", func(t *testing.T) {
rc := NewRobotsChecker("Bot")
u, _ := url.Parse("https://example.com/page")
// Add an empty policy directly
rc.mu.Lock()
rc.policies["example.com"] = &robotsPolicy{}
rc.mu.Unlock()
if !rc.CanFetch(u) {
t.Error("CanFetch with empty policy should return true")
}
})
t.Run("disallowed path returns false", func(t *testing.T) {
rc := NewRobotsChecker("Bot")
u, _ := url.Parse("https://example.com/private/data")
rc.mu.Lock()
rc.policies["example.com"] = &robotsPolicy{
disallowed: []string{"/private"},
}
rc.mu.Unlock()
if rc.CanFetch(u) {
t.Error("CanFetch with disallowed path should return false")
}
})
t.Run("allowed path returns true even with disallowed rules", func(t *testing.T) {
rc := NewRobotsChecker("Bot")
u, _ := url.Parse("https://example.com/public/data")
rc.mu.Lock()
rc.policies["example.com"] = &robotsPolicy{
disallowed: []string{"/private"},
}
rc.mu.Unlock()
if !rc.CanFetch(u) {
t.Error("CanFetch with non-disallowed path should return true")
}
})
t.Run("root path with empty string path in URL", func(t *testing.T) {
rc := NewRobotsChecker("Bot")
// URL with empty path
u, _ := url.Parse("https://example.com")
rc.mu.Lock()
rc.policies["example.com"] = &robotsPolicy{
disallowed: []string{"/"},
}
rc.mu.Unlock()
if rc.CanFetch(u) {
t.Error("CanFetch with disallowed root should return false")
}
})
t.Run("different host uses different policy", func(t *testing.T) {
rc := NewRobotsChecker("Bot")
uAllowed, _ := url.Parse("https://allowed.com/page")
uDisallowed, _ := url.Parse("https://disallowed.com/secret")
rc.mu.Lock()
rc.policies["allowed.com"] = &robotsPolicy{}
rc.policies["disallowed.com"] = &robotsPolicy{
disallowed: []string{"/secret"},
}
rc.mu.Unlock()
if !rc.CanFetch(uAllowed) {
t.Error("CanFetch on allowed host should return true")
}
if rc.CanFetch(uDisallowed) {
t.Error("CanFetch on disallowed path should return false")
}
})
}
func TestRobotsCheckerGetCrawlDelay(t *testing.T) {
t.Run("no policy returns zero", func(t *testing.T) {
rc := NewRobotsChecker("Bot")
u, _ := url.Parse("https://example.com/")
if d := rc.GetCrawlDelay(u); d != 0 {
t.Errorf("GetCrawlDelay without policy = %v; want 0", d)
}
})
t.Run("returns configured delay", func(t *testing.T) {
rc := NewRobotsChecker("Bot")
u, _ := url.Parse("https://example.com/")
rc.mu.Lock()
rc.policies["example.com"] = &robotsPolicy{
crawlDelay: 5 * time.Second,
}
rc.mu.Unlock()
if d := rc.GetCrawlDelay(u); d != 5*time.Second {
t.Errorf("GetCrawlDelay = %v; want 5s", d)
}
})
t.Run("zero delay returns zero", func(t *testing.T) {
rc := NewRobotsChecker("Bot")
u, _ := url.Parse("https://example.com/")
rc.mu.Lock()
rc.policies["example.com"] = &robotsPolicy{
crawlDelay: 0,
}
rc.mu.Unlock()
if d := rc.GetCrawlDelay(u); d != 0 {
t.Errorf("GetCrawlDelay with 0 delay = %v; want 0", d)
}
})
}
+29
View File
@@ -0,0 +1,29 @@
//go:build linux || freebsd
// +build linux freebsd
package output
import (
"os"
"path/filepath"
)
// CreateTempFile creates a temporary file in the specified directory for atomic writes.
func CreateTempFile(dir, pattern string) (*os.File, error) {
return os.CreateTemp(dir, pattern)
}
// AtomicReplace atomically renames a temporary file to its final destination.
func AtomicReplace(tempPath, finalPath string) error {
return os.Rename(tempPath, finalPath)
}
// CleanupTempFile removes a temporary file at the given path.
func CleanupTempFile(path string) error {
return os.Remove(path)
}
// GetTempDir returns the directory portion of the given path for temporary file placement.
func GetTempDir(path string) string {
return filepath.Dir(path)
}
File diff suppressed because it is too large Load Diff
+154
View File
@@ -0,0 +1,154 @@
//go:build linux || freebsd
// +build linux freebsd
package output
import (
"encoding/json"
"fmt"
"os"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
)
// ResumeMetaFileSuffix is the file suffix used for resume metadata files.
const ResumeMetaFileSuffix = ".goget.meta"
// ResumeMetadata holds metadata for resuming interrupted downloads.
type ResumeMetadata struct {
URL string `json:"url"`
ETag string `json:"etag,omitempty"`
LastModified string `json:"last_modified,omitempty"`
Downloaded int64 `json:"downloaded"`
Total int64 `json:"total,omitempty"`
LastWrite time.Time `json:"last_write"`
Version string `json:"version"`
Chunks map[int]int64 `json:"chunks,omitempty"` // per-chunk progress for parallel downloads
}
// NewResumeMetadata creates a new ResumeMetadata instance with the given parameters.
func NewResumeMetadata(url, etag, lastModified string, downloaded, total int64) *ResumeMetadata {
return &ResumeMetadata{
URL: url,
ETag: etag,
LastModified: lastModified,
Downloaded: downloaded,
Total: total,
LastWrite: time.Now(),
Version: "1.0",
}
}
// Save writes the metadata to a JSON file alongside the downloaded file.
func (rm *ResumeMetadata) Save(outputPath string) error {
metaPath := outputPath + ResumeMetaFileSuffix
data, err := json.MarshalIndent(rm, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal meta: %w", err)
}
return os.WriteFile(metaPath, data, 0600)
}
// LoadResumeMetadata reads resume metadata from the associated JSON file.
func LoadResumeMetadata(outputPath string) (*ResumeMetadata, error) {
metaPath := outputPath + ResumeMetaFileSuffix
data, err := os.ReadFile(metaPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read meta: %w", err)
}
var rm ResumeMetadata
if err := json.Unmarshal(data, &rm); err != nil {
return nil, fmt.Errorf("failed to parse metadata: %w", err)
}
return &rm, nil
}
// DeleteResumeMetadata removes the metadata file associated with the given output path.
func DeleteResumeMetadata(outputPath string) error {
metaPath := outputPath + ResumeMetaFileSuffix
if err := os.Remove(metaPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// CanResume checks whether a download can be resumed by validating the metadata file.
// It returns true only if valid metadata exists and matches the requested URL.
func CanResume(outputPath string, reqURL string) (bool, *ResumeMetadata, error) {
// Check if output file exists
info, err := os.Stat(outputPath)
if err != nil {
if os.IsNotExist(err) {
return false, nil, nil
}
return false, nil, err
}
// Load metadata
meta, err := LoadResumeMetadata(outputPath)
if err != nil {
return false, nil, err
}
// Without valid metadata, resume is not safe (file may be from another download or corrupted)
if meta == nil {
return false, nil, nil
}
// Verify URL matches
if meta.URL != reqURL {
return false, nil, fmt.Errorf("url mismatch: metadata for %s, requested %s", meta.URL, reqURL)
}
// Verify file size matches
if info.Size() != meta.Downloaded {
return false, nil, fmt.Errorf("size mismatch: file has %d bytes, metadata expects %d", info.Size(), meta.Downloaded)
}
return true, meta, nil
}
// GetResumeInfo creates a ResumeInfo from the metadata for use in download resumption.
func GetResumeInfo(meta *ResumeMetadata, outputPath string) (*core.ResumeInfo, error) {
info, err := os.Stat(outputPath)
if err != nil {
return nil, err
}
return &core.ResumeInfo{
DownloadedBytes: meta.Downloaded,
ETag: meta.ETag,
LastModified: meta.LastModified,
URL: meta.URL,
LastWrite: info.ModTime(),
}, nil
}
// FileExists checks whether a file exists at the given path.
func FileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// GetFileSize returns the size of the file at the given path.
func GetFileSize(path string) (int64, error) {
info, err := os.Stat(path)
if err != nil {
return 0, err
}
return info.Size(), nil
}
// CleanupResumeFiles removes the downloaded file and its associated metadata file.
func CleanupResumeFiles(outputPath string) error {
if err := os.Remove(outputPath); err != nil && !os.IsNotExist(err) {
return err
}
return DeleteResumeMetadata(outputPath)
}
+303
View File
@@ -0,0 +1,303 @@
//go:build linux || freebsd
// +build linux freebsd
package output
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"syscall"
"time"
)
// WriterConfig holds configuration for the Writer.
type WriterConfig struct {
// Output is the destination file path (use "-" for stdout).
Output string
// BufferSize is the size of the write buffer.
BufferSize int
// Atomic enables atomic write via temp file + rename.
Atomic bool
// Resume enables support for resuming interrupted downloads.
Resume bool
// ProgressCallback is called on each write with the current progress.
ProgressCallback func(current, total int64, speed float64)
// Verbose enables verbose logging.
Verbose bool
// CreateDirs creates missing parent directories of Output via os.MkdirAll
// before opening the file. Ignored when Output is "-" (stdout). The
// equivalent of curl --create-dirs / wget --directory-prefix.
CreateDirs bool
}
// DefaultWriterConfig returns a WriterConfig with sensible defaults.
func DefaultWriterConfig() *WriterConfig {
return &WriterConfig{
BufferSize: 32 * 1024, // 32KB
Atomic: true,
Resume: false,
Verbose: false,
}
}
// Writer is a progress-aware file writer supporting atomic writes and resume.
type Writer struct {
config *WriterConfig
file *os.File
tempFile *os.File
written int64
total int64
startTime time.Time
mu sync.Mutex
callback func(current, total int64, speed float64)
ctx context.Context
cancel context.CancelFunc
}
// NewWriter creates a new writer
func NewWriter(cfg *WriterConfig) (*Writer, error) {
if cfg == nil {
cfg = DefaultWriterConfig()
}
w := &Writer{
config: cfg,
written: 0,
startTime: time.Now(),
callback: cfg.ProgressCallback,
}
// Create context for cancellation.
w.ctx, w.cancel = context.WithCancel(context.Background())
// Determine output path
outputPath := cfg.Output
if outputPath == "" {
outputPath = "-" // stdout
}
// Create parent directories on demand (curl --create-dirs / wget
// --directory-prefix). Skip for stdout where there is no path.
if cfg.CreateDirs && outputPath != "-" {
dir := filepath.Dir(outputPath)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create output directory: %w", err)
}
}
}
// Atomic write - create temp file
if cfg.Atomic && outputPath != "-" {
dir := filepath.Dir(outputPath)
name := filepath.Base(outputPath)
tempName := fmt.Sprintf(".%s.tmp.%d", name, time.Now().UnixNano())
tempPath := filepath.Join(dir, tempName)
tempFile, err := os.OpenFile(tempPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
w.tempFile = tempFile
w.file = tempFile
} else if outputPath == "-" {
w.file = os.Stdout
} else {
// Check if file exists for resume.
if cfg.Resume {
if info, err := os.Stat(outputPath); err == nil {
w.written = info.Size()
// Open in append mode
file, err := os.OpenFile(outputPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil, fmt.Errorf("failed to open file for resume: %w", err)
}
w.file = file
} else {
// File doesn't exist, create new
file, err := os.Create(outputPath)
if err != nil {
return nil, fmt.Errorf("failed to create file: %w", err)
}
w.file = file
}
} else {
// Check if file exists
if _, err := os.Stat(outputPath); err == nil {
return nil, fmt.Errorf("file already exists: %s", outputPath)
}
file, err := os.Create(outputPath)
if err != nil {
return nil, fmt.Errorf("failed to create file: %w", err)
}
w.file = file
}
}
return w, nil
}
// Write implements the io.Writer interface.
func (w *Writer) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
n, err := w.file.Write(p)
if err != nil {
return n, err
}
w.written += int64(n)
// Report progress
if w.callback != nil {
duration := time.Since(w.startTime)
speed := float64(w.written) / duration.Seconds()
w.callback(w.written, w.total, speed)
}
return n, nil
}
// SetTotal sets the expected total size for progress tracking.
func (w *Writer) SetTotal(total int64) {
w.mu.Lock()
defer w.mu.Unlock()
w.total = total
}
// SetProgressCallback sets the callback function for progress reporting.
func (w *Writer) SetProgressCallback(cb func(current, total int64, speed float64)) {
w.mu.Lock()
defer w.mu.Unlock()
w.callback = cb
}
// Written returns the number of bytes written so far.
func (w *Writer) Written() int64 {
w.mu.Lock()
defer w.mu.Unlock()
return w.written
}
// Total returns the expected total size.
func (w *Writer) Total() int64 {
w.mu.Lock()
defer w.mu.Unlock()
return w.total
}
// Close finalizes the write and performs the atomic rename if applicable.
func (w *Writer) Close() error {
w.cancel()
if w.file == nil {
return nil
}
// Don't close stdout — it's shared across the process
if w.file != os.Stdout {
if err := w.file.Close(); err != nil {
return err
}
}
// Atomic rename if temp file was used
if w.tempFile != nil && w.config.Output != "-" {
tempPath := w.tempFile.Name()
if err := os.Rename(tempPath, w.config.Output); err != nil {
if errors.Is(err, syscall.EXDEV) {
// Cross-filesystem rename is not supported by the OS.
// Fall back to a copy + remove so the data still reach
// the destination directory instead of being lost.
if fallbackErr := copyFileAndRemove(tempPath, w.config.Output); fallbackErr != nil {
return fmt.Errorf("atomic rename failed across filesystems and fallback copy failed: rename=%v copy=%w", err, fallbackErr)
}
return nil
}
// Any other error (permission denied, target is a directory,
// target busy on Windows, etc.) — keep the temp file so the
// user can recover the partial download manually.
return fmt.Errorf("failed to rename temp file %q to %q: %w (temp file kept for recovery)", tempPath, w.config.Output, err)
}
}
return nil
}
// copyFileAndRemove copies src to dst (truncating any existing file) and
// removes src on success. Used as a fallback for os.Rename when the temp
// file and the destination are on different filesystems.
func copyFileAndRemove(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
return os.Remove(src)
}
// Cancel aborts the write and cleans up any temporary files.
func (w *Writer) Cancel() {
w.cancel()
if w.tempFile != nil {
// Remove temp file on cancel
tempPath := w.tempFile.Name()
w.tempFile.Close()
os.Remove(tempPath)
}
}
// GetSpeed returns the average write speed in bytes per second.
func (w *Writer) GetSpeed() float64 {
w.mu.Lock()
defer w.mu.Unlock()
duration := time.Since(w.startTime)
if duration.Seconds() == 0 {
return 0
}
return float64(w.written) / duration.Seconds()
}
// GetDuration returns the elapsed time since the writer was created.
func (w *Writer) GetDuration() time.Duration {
return time.Since(w.startTime)
}
// GetProgress returns the progress as a percentage of the total size.
func (w *Writer) GetProgress() float64 {
w.mu.Lock()
defer w.mu.Unlock()
if w.total == 0 {
return 0
}
return float64(w.written) / float64(w.total) * 100
}
+232
View File
@@ -0,0 +1,232 @@
//go:build linux || freebsd
// +build linux freebsd
package output
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestCloseAtomicSuccess verifies the happy path: temp file is renamed to
// the final destination and no temp file remains.
func TestCloseAtomicSuccess(t *testing.T) {
dir := tempDir(t)
outputPath := filepath.Join(dir, "output.txt")
w, err := NewWriter(&WriterConfig{
Output: outputPath,
Atomic: true,
})
if err != nil {
t.Fatalf("NewWriter: %v", err)
}
payload := []byte("hello world")
if _, err := w.Write(payload); err != nil {
t.Fatalf("Write: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
got, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != string(payload) {
t.Errorf("payload = %q, want %q", got, payload)
}
// No leftover temp files in the directory.
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, e := range entries {
if strings.Contains(e.Name(), ".tmp.") {
t.Errorf("temp file %q should have been renamed away", e.Name())
}
}
}
// TestCloseKeepsTempOnRenameError is a regression guard for the BACKLOG
// entry "Atomic write data loss on cross-filesystem rename". The previous
// implementation called os.Remove(tempPath) on any rename error, deleting
// the only copy of the downloaded data. After the fix, the temp file must
// remain on disk so the user can recover the partial download manually.
//
// We force a rename error by pre-creating the destination as a directory —
// os.Rename will refuse to overwrite a non-empty directory with a file.
func TestCloseKeepsTempOnRenameError(t *testing.T) {
dir := tempDir(t)
outputPath := filepath.Join(dir, "output.txt")
// Block the rename by making the target path a directory.
if err := os.Mkdir(outputPath, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
w, err := NewWriter(&WriterConfig{
Output: outputPath,
Atomic: true,
})
if err != nil {
t.Fatalf("NewWriter: %v", err)
}
payload := []byte("recoverable download data")
if _, err := w.Write(payload); err != nil {
t.Fatalf("Write: %v", err)
}
tempPath := w.tempFile.Name()
if err := w.Close(); err == nil {
t.Fatal("expected Close() to return an error when target is a directory")
}
// The temp file MUST still exist — the data must be recoverable.
if _, err := os.Stat(tempPath); err != nil {
t.Errorf("temp file %q should still exist after Close error, got: %v", tempPath, err)
}
// And it should still hold the full payload.
got, err := os.ReadFile(tempPath)
if err != nil {
t.Fatalf("ReadFile(temp): %v", err)
}
if string(got) != string(payload) {
t.Errorf("temp file payload = %q, want %q", got, payload)
}
}
// TestCopyFileAndRemove covers the EXDEV fallback helper directly. We do
// not have an easy way to mount two different filesystems in a unit test,
// so we just verify the copy + remove behaviour on the same filesystem.
func TestCopyFileAndRemove(t *testing.T) {
dir := tempDir(t)
src := filepath.Join(dir, "src.tmp")
dst := filepath.Join(dir, "dst.bin")
payload := []byte("cross-fs fallback payload")
if err := os.WriteFile(src, payload, 0644); err != nil {
t.Fatalf("WriteFile(src): %v", err)
}
if err := copyFileAndRemove(src, dst); err != nil {
t.Fatalf("copyFileAndRemove: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("ReadFile(dst): %v", err)
}
if string(got) != string(payload) {
t.Errorf("dst = %q, want %q", got, payload)
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Errorf("src should be removed, got stat err = %v", err)
}
}
// TestCopyFileAndRemoveOverwritesExisting verifies the fallback truncates
// any existing file at the destination (matching os.Rename semantics).
func TestCopyFileAndRemoveOverwritesExisting(t *testing.T) {
dir := tempDir(t)
src := filepath.Join(dir, "src.tmp")
dst := filepath.Join(dir, "dst.bin")
if err := os.WriteFile(src, []byte("new"), 0644); err != nil {
t.Fatalf("WriteFile(src): %v", err)
}
if err := os.WriteFile(dst, []byte("OLD CONTENT"), 0644); err != nil {
t.Fatalf("WriteFile(dst): %v", err)
}
if err := copyFileAndRemove(src, dst); err != nil {
t.Fatalf("copyFileAndRemove: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("ReadFile(dst): %v", err)
}
if string(got) != "new" {
t.Errorf("dst = %q, want %q", got, "new")
}
}
// TestNewWriterCreateDirsMissingParent verifies the curl --create-dirs
// equivalent: when CreateDirs is true and the parent directory of Output
// does not exist, NewWriter must create the full chain via MkdirAll and
// successfully write the file. This is the happy path for "goget
// --output ./data/archives/file.zip" against a fresh tree.
func TestNewWriterCreateDirsMissingParent(t *testing.T) {
dir := tempDir(t)
outputPath := filepath.Join(dir, "deeply", "nested", "subdir", "file.dat")
w, err := NewWriter(&WriterConfig{
Output: outputPath,
Atomic: true,
CreateDirs: true,
})
if err != nil {
t.Fatalf("NewWriter: %v", err)
}
payload := []byte("created in a fresh dir tree")
if _, err := w.Write(payload); err != nil {
t.Fatalf("Write: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
got, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != string(payload) {
t.Errorf("payload = %q, want %q", got, payload)
}
}
// TestNewWriterCreateDirsDisabledLeavesError confirms that without
// CreateDirs the writer still surfaces the underlying "parent dir missing"
// error — CreateDirs is opt-in, not a silent behaviour change for existing
// users.
func TestNewWriterCreateDirsDisabledLeavesError(t *testing.T) {
dir := tempDir(t)
outputPath := filepath.Join(dir, "missing", "file.dat")
_, err := NewWriter(&WriterConfig{
Output: outputPath,
Atomic: true,
CreateDirs: false,
})
if err == nil {
t.Fatal("expected NewWriter to fail when parent dir is missing and CreateDirs=false")
}
}
// TestNewWriterCreateDirsIgnoredForStdout makes sure the - / stdout path is
// untouched by the new flag: nothing to create, no spurious error.
func TestNewWriterCreateDirsIgnoredForStdout(t *testing.T) {
w, err := NewWriter(&WriterConfig{
Output: "-",
Atomic: false,
CreateDirs: true,
})
if err != nil {
t.Fatalf("NewWriter: %v", err)
}
if w.file != os.Stdout {
t.Errorf("expected writer to point at stdout, got %v", w.file)
}
w.Close()
}
+105
View File
@@ -0,0 +1,105 @@
//go:build linux || freebsd
// +build linux freebsd
package dataurl
import (
"context"
"encoding/base64"
"io"
"net/url"
"os"
"strings"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/protocol"
)
// Protocol handles data: URLs (RFC 2397).
type Protocol struct {
*protocol.BaseProtocol
}
// NewProtocol creates a new data: URL protocol handler.
func NewProtocol() *Protocol {
return &Protocol{
BaseProtocol: protocol.NewBaseProtocol(protocol.ProtocolInfo{
Name: "DATA",
Scheme: "data",
DefaultPort: 0,
Features: []string{},
}),
}
}
func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
startTime := time.Now()
// data:[<mediatype>][;base64],<data>
data, err := Decode(req.URL.String())
if err != nil {
return nil, core.NewProtocolError("failed to decode data URL", err, core.SafeURL(req.URL))
}
var writer io.Writer
outputPath := req.Output
if req.Writer != nil {
writer = req.Writer
} else if outputPath == "" || outputPath == "-" {
writer = os.Stdout
outputPath = "-"
} else {
f, err := os.Create(outputPath)
if err != nil {
return nil, core.NewFileError("failed to create output file", err)
}
defer f.Close()
writer = f
}
n, err := writer.Write(data)
if err != nil {
return nil, core.NewFileError("failed to write data URL content", err)
}
duration := time.Since(startTime)
return &core.DownloadResult{
BytesDownloaded: int64(n),
TotalSize: int64(len(data)),
Duration: duration,
Protocol: "DATA",
IPVersion: 0,
Speed: float64(n) / duration.Seconds(),
OutputPath: outputPath,
}, nil
}
// Decode parses and decodes a data: URL.
func Decode(rawURL string) ([]byte, error) {
if !strings.HasPrefix(rawURL, "data:") {
return nil, nil
}
content := strings.TrimPrefix(rawURL, "data:")
isBase64 := false
if idx := strings.Index(content, ";base64,"); idx >= 0 {
isBase64 = true
content = content[idx+8:]
} else if idx := strings.Index(content, ","); idx >= 0 {
content = content[idx+1:]
}
decoded, err := url.PathUnescape(content)
if err != nil {
decoded = content
}
if isBase64 {
return base64.StdEncoding.DecodeString(decoded)
}
return []byte(decoded), nil
}
+93
View File
@@ -0,0 +1,93 @@
//go:build linux || freebsd
// +build linux freebsd
package dataurl
import (
"bytes"
"context"
"net/url"
"os"
"path/filepath"
"testing"
"codeberg.org/petrbalvin/goget/internal/core"
)
func TestDecodePlainText(t *testing.T) {
data, err := Decode("data:text/plain,hello%20world")
if err != nil {
t.Fatal(err)
}
if string(data) != "hello world" {
t.Errorf("got %q, want %q", data, "hello world")
}
}
func TestDecodeBase64(t *testing.T) {
data, err := Decode("data:text/plain;base64,SGVsbG8=")
if err != nil {
t.Fatal(err)
}
if string(data) != "Hello" {
t.Errorf("got %q, want %q", data, "Hello")
}
}
func TestDecodeEmpty(t *testing.T) {
data, err := Decode("data:,")
if err != nil {
t.Fatal(err)
}
if len(data) != 0 {
t.Errorf("expected empty, got %d bytes", len(data))
}
}
func TestDecodeNotDataURL(t *testing.T) {
data, err := Decode("https://example.com")
if err != nil {
t.Fatal(err)
}
if data != nil {
t.Error("expected nil for non-data URL")
}
}
func TestDownloadPlainText(t *testing.T) {
var buf bytes.Buffer
proto := NewProtocol()
u, _ := url.Parse("data:text/plain,test123")
result, err := proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Writer: &buf,
})
if err != nil {
t.Fatal(err)
}
if result.BytesDownloaded != 7 {
t.Errorf("bytes = %d, want 7", result.BytesDownloaded)
}
if buf.String() != "test123" {
t.Errorf("got %q, want %q", buf.String(), "test123")
}
}
func TestDownloadToFile(t *testing.T) {
tmp := t.TempDir()
dst := filepath.Join(tmp, "out.txt")
proto := NewProtocol()
u, _ := url.Parse("data:text/plain,hello")
_, err := proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Output: dst,
})
if err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(dst)
if string(data) != "hello" {
t.Errorf("got %q, want %q", data, "hello")
}
}
+211
View File
@@ -0,0 +1,211 @@
//go:build linux || freebsd
// +build linux freebsd
package file
import (
"context"
"io"
"os"
"path/filepath"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/protocol"
)
// Protocol implements file:// downloads.
type Protocol struct {
*protocol.BaseProtocol
}
// NewProtocol creates a new file protocol handler.
func NewProtocol() *Protocol {
return &Protocol{
BaseProtocol: protocol.NewBaseProtocol(protocol.ProtocolInfo{
Name: "FILE",
Scheme: "file",
DefaultPort: 0,
Features: []string{"resume", "recursive"},
}),
}
}
func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
startTime := time.Now()
filePath := req.URL.Path
if req.URL.Host != "" {
filePath = filepath.Join("/", req.URL.Host, filePath)
}
filePath = filepath.Clean(filePath)
info, err := os.Stat(filePath)
if err != nil {
return nil, core.NewFileError("failed to stat source path", err)
}
if info.IsDir() {
return p.downloadDirectory(ctx, req, filePath, startTime)
}
src, err := os.Open(filePath)
if err != nil {
return nil, core.NewFileError("failed to open source file", err)
}
defer src.Close()
totalSize := info.Size()
var writer io.Writer
var outputPath string
if req.Writer != nil {
writer = req.Writer
outputPath = req.Output
} else if req.Output == "" || req.Output == "-" {
writer = os.Stdout
outputPath = "-"
} else {
dst, err := os.Create(req.Output)
if err != nil {
return nil, core.NewFileError("failed to create output file", err)
}
defer dst.Close()
writer = dst
outputPath = req.Output
}
var written int64
if req.Resume && outputPath != "" && outputPath != "-" {
if fi, err := os.Stat(outputPath); err == nil {
written = fi.Size()
if written >= totalSize {
return &core.DownloadResult{
BytesDownloaded: totalSize,
TotalSize: totalSize,
Duration: time.Since(startTime),
Protocol: "FILE",
OutputPath: outputPath,
Resumed: true,
}, nil
}
if _, err := src.Seek(written, io.SeekStart); err != nil {
return nil, core.NewFileError("failed to seek for resume", err)
}
}
}
buf := make([]byte, 32*1024)
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
n, err := src.Read(buf)
if n > 0 {
if _, werr := writer.Write(buf[:n]); werr != nil {
return nil, core.NewFileError("failed to write data", werr)
}
written += int64(n)
if req.ProgressCallback != nil {
speed := float64(written) / time.Since(startTime).Seconds()
req.ProgressCallback(written, totalSize, speed)
}
}
if err == io.EOF {
break
}
if err != nil {
return nil, core.NewFileError("failed to read source file", err)
}
}
duration := time.Since(startTime)
speed := float64(written) / duration.Seconds()
return &core.DownloadResult{
BytesDownloaded: written,
TotalSize: totalSize,
Duration: duration,
Protocol: "FILE",
Speed: speed,
OutputPath: outputPath,
Resumed: written > 0 && req.Resume,
}, nil
}
// downloadDirectory recursively copies a local directory.
func (p *Protocol) downloadDirectory(ctx context.Context, req *core.DownloadRequest, dirPath string, startTime time.Time) (*core.DownloadResult, error) {
outputBase := req.Output
if outputBase == "" {
outputBase = filepath.Base(dirPath)
}
var totalBytes int64
var fileCount int
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err != nil {
return err
}
if info.IsDir() {
return nil
}
// Calculate relative path
rel, err := filepath.Rel(dirPath, path)
if err != nil {
return err
}
outputPath := filepath.Join(outputBase, rel)
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
return err
}
src, err := os.Open(path)
if err != nil {
return err
}
defer src.Close()
dst, err := os.Create(outputPath)
if err != nil {
return err
}
defer dst.Close()
n, err := io.Copy(dst, src)
if err != nil {
return err
}
totalBytes += n
fileCount++
return nil
})
if err != nil {
return nil, core.NewFileError("failed to copy directory recursively", err)
}
duration := time.Since(startTime)
speed := float64(0)
if duration.Seconds() > 0 {
speed = float64(totalBytes) / duration.Seconds()
}
return &core.DownloadResult{
BytesDownloaded: totalBytes,
TotalSize: totalBytes,
Duration: duration,
Protocol: "FILE",
Speed: speed,
OutputPath: outputBase,
ChunksCount: fileCount,
}, nil
}
+129
View File
@@ -0,0 +1,129 @@
//go:build linux || freebsd
// +build linux freebsd
package file
import (
"bytes"
"context"
"net/url"
"os"
"path/filepath"
"testing"
"codeberg.org/petrbalvin/goget/internal/core"
)
func TestDownloadFile(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "src.txt")
dst := filepath.Join(tmp, "dst.txt")
os.WriteFile(src, []byte("hello world"), 0644)
proto := NewProtocol()
u := &url.URL{Scheme: "file", Path: src}
result, err := proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Output: dst,
})
if err != nil {
t.Fatal(err)
}
if result.BytesDownloaded != 11 {
t.Errorf("bytes = %d, want 11", result.BytesDownloaded)
}
data, _ := os.ReadFile(dst)
if string(data) != "hello world" {
t.Errorf("content = %q, want %q", data, "hello world")
}
}
func TestDownloadFileToWriter(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "src.txt")
os.WriteFile(src, []byte("test"), 0644)
var buf bytes.Buffer
proto := NewProtocol()
u := &url.URL{Scheme: "file", Path: src}
_, err := proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Output: "-",
Writer: &buf,
})
if err != nil {
t.Fatal(err)
}
if buf.String() != "test" {
t.Errorf("content = %q, want %q", buf.String(), "test")
}
}
func TestDownloadFileNotFound(t *testing.T) {
proto := NewProtocol()
u := &url.URL{Scheme: "file", Path: "/nonexistent/path"}
_, err := proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Output: "/tmp/out",
})
if err == nil {
t.Error("expected error for nonexistent file")
}
}
func TestDownloadDirectory(t *testing.T) {
tmp := t.TempDir()
srcDir := filepath.Join(tmp, "srcdir")
dstDir := filepath.Join(tmp, "dstdir")
os.MkdirAll(filepath.Join(srcDir, "sub"), 0755)
os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("a"), 0644)
os.WriteFile(filepath.Join(srcDir, "sub/b.txt"), []byte("b"), 0644)
proto := NewProtocol()
u := &url.URL{Scheme: "file", Path: srcDir}
result, err := proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Output: dstDir,
})
if err != nil {
t.Fatal(err)
}
if result.ChunksCount != 2 {
t.Errorf("files = %d, want 2", result.ChunksCount)
}
if result.BytesDownloaded != 2 {
t.Errorf("bytes = %d, want 2", result.BytesDownloaded)
}
// Verify files exist
if _, err := os.Stat(filepath.Join(dstDir, "a.txt")); err != nil {
t.Error("a.txt not found")
}
if _, err := os.Stat(filepath.Join(dstDir, "sub", "b.txt")); err != nil {
t.Error("sub/b.txt not found")
}
}
func TestResumeFile(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "src.txt")
dst := filepath.Join(tmp, "dst.txt")
os.WriteFile(src, []byte("hello world"), 0644)
os.WriteFile(dst, []byte("hello"), 0644) // partial: 5 bytes
proto := NewProtocol()
u := &url.URL{Scheme: "file", Path: src}
result, err := proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Output: dst,
Resume: true,
})
if err != nil {
t.Fatal(err)
}
if !result.Resumed {
t.Error("expected Resumed=true")
}
if result.BytesDownloaded != 11 {
t.Errorf("bytes = %d, want 11", result.BytesDownloaded)
}
}
+817
View File
@@ -0,0 +1,817 @@
//go:build linux || freebsd
// +build linux freebsd
package ftp
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/textproto"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"codeberg.org/petrbalvin/goget/internal/output"
)
type Client struct {
conn net.Conn
reader *textproto.Reader
writer *bufio.Writer
host string
port int
user string
password string
url string
tlsConfig *tls.Config
useTLS bool
passive bool
timeout time.Duration
}
func NewClient(rawURL string, timeout time.Duration) (*Client, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid url: %w", err)
}
host := u.Hostname()
port := 21
if u.Port() != "" {
p, err := strconv.Atoi(u.Port())
if err != nil {
return nil, fmt.Errorf("invalid port: %w", err)
}
port = p
}
user := "anonymous"
password := "anonymous@"
if u.User != nil {
user = u.User.Username()
if pass, ok := u.User.Password(); ok {
password = pass
}
}
useTLS := u.Scheme == "ftps"
if !useTLS && (user != "anonymous" || (u.User != nil && u.User.Username() != "anonymous")) {
fmt.Fprintf(os.Stderr, "Warning: FTP credentials sent in plaintext. Use ftps:// for encrypted transfers.\n")
}
c := &Client{
host: host,
port: port,
user: user,
password: password,
url: rawURL,
useTLS: useTLS,
passive: true, // Default to passive mode
timeout: timeout,
tlsConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: host,
InsecureSkipVerify: false,
},
}
return c, nil
}
func (c *Client) Connect() error {
addr := net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port))
conn, err := net.DialTimeout("tcp", addr, c.timeout)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
c.conn = conn
c.reader = textproto.NewReader(bufio.NewReader(conn))
c.writer = bufio.NewWriter(conn)
// Read greeting
code, _, err := c.readResponse()
if err != nil {
return fmt.Errorf("failed to read greeting: %w", err)
}
if code != 220 {
return fmt.Errorf("unexpected greeting code: %d", code)
}
// Upgrade to TLS if FTPS (explicit)
if c.useTLS {
if err := c.sendCommand("AUTH TLS"); err != nil {
return err
}
code, _, err := c.readResponse()
if err != nil {
return fmt.Errorf("auth tls failed: %w", err)
}
if code != 234 && code != 334 {
// Try AUTH SSL as fallback
if err := c.sendCommand("AUTH SSL"); err != nil {
return err
}
code, _, err = c.readResponse()
if err != nil {
return fmt.Errorf("auth ssl failed: %w", err)
}
if code != 234 && code != 334 {
return fmt.Errorf("server does not support tls/ssl")
}
}
// Wrap connection with TLS
tlsConn := tls.Client(c.conn, c.tlsConfig)
if err := tlsConn.Handshake(); err != nil {
return fmt.Errorf("tls handshake failed: %w", err)
}
c.conn = tlsConn
c.reader = textproto.NewReader(bufio.NewReader(tlsConn))
c.writer = bufio.NewWriter(tlsConn)
}
// Send USER
if err := c.sendCommand("USER %s", c.user); err != nil {
return err
}
code, _, err = c.readResponse()
if err != nil {
return fmt.Errorf("user command failed: %w", err)
}
// 331 means password required, 230 means already logged in
if code == 331 {
// Send PASS
if err := c.sendCommand("PASS %s", c.password); err != nil {
return err
}
code, _, err = c.readResponse()
if err != nil {
return fmt.Errorf("pass command failed: %w", err)
}
}
if code != 230 && code != 200 {
return fmt.Errorf("login failed with code: %d", code)
}
// Set binary mode
if err := c.sendCommand("TYPE I"); err != nil {
return err
}
code, _, err = c.readResponse()
if err != nil {
return fmt.Errorf("type command failed: %w", err)
}
if code != 200 {
return fmt.Errorf("failed to set binary mode")
}
return nil
}
func (c *Client) sendCommand(format string, args ...interface{}) error {
cmd := fmt.Sprintf(format, args...)
if _, err := c.writer.WriteString(cmd); err != nil {
return fmt.Errorf("failed to send command: %w", err)
}
if _, err := c.writer.WriteString("\r\n"); err != nil {
return fmt.Errorf("failed to send command terminator: %w", err)
}
if err := c.writer.Flush(); err != nil {
return fmt.Errorf("failed to flush command: %w", err)
}
return nil
}
func (c *Client) readResponse() (int, string, error) {
code, msg, err := c.reader.ReadResponse(0)
if err != nil {
return 0, "", err
}
return code, msg, nil
}
func (c *Client) getFileSize(path string) (int64, error) {
if err := c.sendCommand("SIZE %s", path); err != nil {
return 0, err
}
code, msg, err := c.readResponse()
if err != nil {
return 0, fmt.Errorf("size command failed: %w", err)
}
if code != 213 {
return 0, fmt.Errorf("size command failed with code: %d", code)
}
size, err := strconv.ParseInt(strings.TrimSpace(msg), 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse size: %w", err)
}
return size, nil
}
func (c *Client) establishDataConnection() (net.Conn, error) {
if c.passive {
return c.enterPassiveMode()
}
return c.enterActiveMode()
}
func (c *Client) enterPassiveMode() (net.Conn, error) {
if err := c.sendCommand("PASV"); err != nil {
return nil, err
}
code, msg, err := c.readResponse()
if err != nil {
return nil, fmt.Errorf("pasv command failed: %w", err)
}
if code != 227 {
return nil, fmt.Errorf("pasv command failed with code: %d", code)
}
// Parse PASV response: 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
start := strings.Index(msg, "(")
end := strings.Index(msg, ")")
if start == -1 || end == -1 {
return nil, fmt.Errorf("invalid pasv response")
}
parts := strings.Split(msg[start+1:end], ",")
if len(parts) != 6 {
return nil, fmt.Errorf("invalid pasv response format")
}
h1, _ := strconv.Atoi(parts[0])
h2, _ := strconv.Atoi(parts[1])
h3, _ := strconv.Atoi(parts[2])
h4, _ := strconv.Atoi(parts[3])
p1, _ := strconv.Atoi(parts[4])
p2, _ := strconv.Atoi(parts[5])
dataHost := fmt.Sprintf("%d.%d.%d.%d", h1, h2, h3, h4)
dataPort := p1*256 + p2
// Use original host for TLS connections to match certificate
if c.useTLS {
dataHost = c.host
}
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", dataHost, dataPort), c.timeout)
if err != nil {
return nil, fmt.Errorf("failed to connect to data port: %w", err)
}
if c.useTLS {
tlsConn := tls.Client(conn, c.tlsConfig)
if err := tlsConn.Handshake(); err != nil {
conn.Close()
return nil, fmt.Errorf("tls handshake on data connection failed: %w", err)
}
return tlsConn, nil
}
return conn, nil
}
func (c *Client) enterActiveMode() (net.Conn, error) {
// Get local IP from the control connection to bind only to that interface
ctrlAddr, ok := c.conn.LocalAddr().(*net.TCPAddr)
if !ok || ctrlAddr.IP == nil {
return nil, fmt.Errorf("failed to get local address for active mode")
}
ctrlIP := ctrlAddr.IP.To4()
if ctrlIP == nil {
return nil, fmt.Errorf("no ipv4 address available for active mode")
}
// Bind listener only to the interface of the control connection, not 0.0.0.0
listener, err := net.Listen("tcp", net.JoinHostPort(ctrlIP.String(), "0"))
if err != nil {
return nil, fmt.Errorf("failed to create listener: %w", err)
}
defer listener.Close()
// Set a deadline so Accept respects the timeout
if tcpListener, ok := listener.(*net.TCPListener); ok {
tcpListener.SetDeadline(time.Now().Add(c.timeout))
}
listenAddr := listener.Addr().(*net.TCPAddr)
p1 := listenAddr.Port / 256
p2 := listenAddr.Port % 256
portCmd := fmt.Sprintf("PORT %d,%d,%d,%d,%d,%d",
ctrlIP[0], ctrlIP[1], ctrlIP[2], ctrlIP[3], p1, p2)
if err := c.sendCommand("%s", portCmd); err != nil {
return nil, err
}
code, _, err := c.readResponse()
if err != nil {
return nil, fmt.Errorf("port command failed: %w", err)
}
if code != 200 {
return nil, fmt.Errorf("port command failed with code: %d", code)
}
// Accept incoming connection
connChan := make(chan net.Conn, 1)
errChan := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
errChan <- err
return
}
connChan <- conn
}()
select {
case conn := <-connChan:
return conn, nil
case err := <-errChan:
return nil, fmt.Errorf("failed to accept data connection: %w", err)
case <-time.After(c.timeout):
return nil, fmt.Errorf("timeout waiting for data connection")
}
}
func (c *Client) DownloadFile(ctx context.Context, remotePath, localPath string, resumeOffset int64, progressFunc func(current, total int64)) error {
// Get file size
totalSize, err := c.getFileSize(remotePath)
if err != nil {
return fmt.Errorf("failed to get file size: %w", err)
}
// Check if we need to resume
var startOffset int64 = 0
if resumeOffset > 0 && resumeOffset < totalSize {
startOffset = resumeOffset
if err := c.sendCommand("REST %d", startOffset); err != nil {
return err
}
code, _, err := c.readResponse()
if err != nil {
return fmt.Errorf("rest command failed: %w", err)
}
if code != 350 {
return fmt.Errorf("rest command failed with code: %d", code)
}
} else if resumeOffset >= totalSize {
// File already complete
return nil
}
// Open local file for writing
mode := os.O_CREATE | os.O_WRONLY
if startOffset > 0 {
mode |= os.O_APPEND
} else {
mode |= os.O_TRUNC
}
localFile, err := os.OpenFile(localPath, mode, 0644)
if err != nil {
return fmt.Errorf("failed to open local file: %w", err)
}
defer localFile.Close()
// Seek to start position if resuming
if startOffset > 0 {
if _, err := localFile.Seek(startOffset, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek: %w", err)
}
}
// Establish data connection FIRST (before RETR command)
dataConn, err := c.establishDataConnection()
if err != nil {
return fmt.Errorf("failed to establish data connection: %w", err)
}
defer dataConn.Close()
// Send RETR command AFTER data connection is established
if err := c.sendCommand("RETR %s", remotePath); err != nil {
return err
}
// Read response (should be 150 or 125)
code, _, err := c.readResponse()
if err != nil {
return fmt.Errorf("retr command failed: %w", err)
}
if code != 150 && code != 125 {
// Try waiting a bit for the server to respond
time.Sleep(100 * time.Millisecond)
code, _, err = c.readResponse()
if err != nil {
return fmt.Errorf("retr command failed with code: %d", code)
}
if code != 150 && code != 125 {
return fmt.Errorf("retr command failed with code: %d", code)
}
}
// Copy data with progress. Polls ctx between reads so a SIGINT
// arriving mid-transfer is detected on the next iteration and the
// partial progress is saved to ResumeMetadata before returning.
var copied int64 = startOffset
buf := make([]byte, 32*1024)
for {
if ctx.Err() != nil {
if err := saveFTPResume(c.url, localPath, copied, totalSize); err != nil {
return fmt.Errorf("failed to save resume metadata: %w", err)
}
return ctx.Err()
}
n, err := dataConn.Read(buf)
if n > 0 {
written, werr := localFile.Write(buf[:n])
copied += int64(written)
if werr != nil {
return fmt.Errorf("failed to write: %w", err)
}
if progressFunc != nil {
progressFunc(copied, totalSize)
}
}
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read from data connection: %w", err)
}
}
// Read final response
code, _, err = c.readResponse()
if err != nil {
return fmt.Errorf("failed to read final response: %w", err)
}
if code != 226 && code != 250 {
return fmt.Errorf("transfer failed with code: %d", code)
}
// Successful transfer — drop any stale resume sidecar.
_ = output.DeleteResumeMetadata(localPath)
return nil
}
func (c *Client) ListRemoteDir(path string) ([]string, error) {
// Establish data connection
dataConn, err := c.establishDataConnection()
if err != nil {
return nil, fmt.Errorf("failed to establish data connection: %w", err)
}
defer dataConn.Close()
// Send LIST command
cmd := "LIST"
if path != "" {
cmd = fmt.Sprintf("LIST %s", path)
}
if err := c.sendCommand("%s", cmd); err != nil {
return nil, err
}
// Read response (should be 150 or 125)
code, _, err := c.readResponse()
if err != nil {
return nil, fmt.Errorf("list command failed: %w", err)
}
if code != 150 && code != 125 {
return nil, fmt.Errorf("list command failed with code: %d", code)
}
// Read directory listing
var lines []string
scanner := bufio.NewScanner(dataConn)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read directory listing: %w", err)
}
// Read final response
code, _, err = c.readResponse()
if err != nil {
return nil, fmt.Errorf("failed to read final response: %w", err)
}
if code != 226 && code != 250 {
return nil, fmt.Errorf("list command failed with code: %d", code)
}
return lines, nil
}
func (c *Client) ChangeDir(path string) error {
if err := c.sendCommand("CWD %s", path); err != nil {
return err
}
code, _, err := c.readResponse()
if err != nil {
return fmt.Errorf("cwd command failed: %w", err)
}
if code != 250 {
return fmt.Errorf("cwd command failed with code: %d", code)
}
return nil
}
func (c *Client) GetCurrentDir() (string, error) {
if err := c.sendCommand("PWD"); err != nil {
return "", err
}
code, msg, err := c.readResponse()
if err != nil {
return "", fmt.Errorf("pwd command failed: %w", err)
}
if code != 257 {
return "", fmt.Errorf("pwd command failed with code: %d", code)
}
// Extract path from response (usually quoted)
start := strings.Index(msg, "\"")
end := strings.LastIndex(msg, "\"")
if start != -1 && end > start {
return msg[start+1 : end], nil
}
return strings.TrimSpace(msg), nil
}
func (c *Client) Close() error {
if err := c.sendCommand("QUIT"); err != nil {
return err
}
_, _, _ = c.readResponse() // Ignore errors on close
if c.conn != nil {
return c.conn.Close()
}
return nil
}
// Helper function to download entire directory recursively
func (c *Client) DownloadDirectory(ctx context.Context, remotePath, localPath string, progressFunc func(file string, current, total int64)) error {
// Create local directory
if err := os.MkdirAll(localPath, 0755); err != nil {
return fmt.Errorf("failed to create local directory: %w", err)
}
// Save current directory
origDir, err := c.GetCurrentDir()
if err != nil {
return err
}
defer func() {
c.ChangeDir(origDir)
}()
// Change to remote directory
if err := c.ChangeDir(remotePath); err != nil {
return err
}
// List directory contents
entries, err := c.ListRemoteDir("")
if err != nil {
return err
}
// Parse entries and download files
for _, entry := range entries {
fields := strings.Fields(entry)
if len(fields) < 9 {
continue
}
name := fields[len(fields)-1]
if name == "." || name == ".." {
continue
}
isDir := fields[0][0] == 'd'
remoteFilePath := filepath.Join(remotePath, name)
localFilePath := filepath.Join(localPath, name)
if isDir {
if err := c.DownloadDirectory(ctx, remoteFilePath, localFilePath, progressFunc); err != nil {
return err
}
} else {
if progressFunc != nil {
progressFunc(name, 0, 0)
}
if err := c.DownloadFile(ctx, remoteFilePath, localFilePath, 0, nil); err != nil {
return err
}
}
}
return nil
}
// saveFTPResume persists partial FTP download progress to the standard
// `.goget.meta` sidecar so a subsequent `goget --resume` can continue
// from `downloaded` bytes. Errors are wrapped to surface failures
// without aborting the cancellation handling.
func saveFTPResume(url, localPath string, downloaded, total int64) error {
if localPath == "" || localPath == "-" || downloaded <= 0 {
return nil
}
meta := output.NewResumeMetadata(url, "", "", downloaded, total)
return meta.Save(localPath)
}
// clientPool holds N pre-connected FTP clients. A single shared pool
// removes the need to open/close connections for every file in a
// recursive download, and is required for the parallel recursive
// implementation because each *Client owns its own TCP control
// connection and is not safe for concurrent use.
type clientPool struct {
available chan *Client
all []*Client
}
// newClientPool opens `size` independent FTP connections to the same
// server. Connections are kept open until close() is called.
func newClientPool(rawURL string, timeout time.Duration, size int) (*clientPool, error) {
if size < 1 {
size = 1
}
all := make([]*Client, 0, size)
avail := make(chan *Client, size)
for i := 0; i < size; i++ {
c, err := NewClient(rawURL, timeout)
if err != nil {
for _, existing := range all {
_ = existing.Close()
}
return nil, err
}
if err := c.Connect(); err != nil {
for _, existing := range all {
_ = existing.Close()
}
return nil, err
}
all = append(all, c)
avail <- c
}
return &clientPool{available: avail, all: all}, nil
}
// acquire blocks until a client is available, then returns it. The
// caller must call release() when done.
func (p *clientPool) acquire() *Client {
return <-p.available
}
// release returns a client to the pool. It must be called exactly once
// per acquire().
func (p *clientPool) release(c *Client) {
p.available <- c
}
// size returns the number of clients in the pool.
func (p *clientPool) size() int {
return len(p.all)
}
// close shuts down every client in the pool. The pool must not be used
// after this call.
func (p *clientPool) close() {
for _, c := range p.all {
_ = c.Close()
}
}
// DownloadDirectoryConcurrent downloads a remote directory to localPath
// using a pool of N FTP connections. All operations (LIST, RETR) use
// absolute paths so concurrent workers never share or mutate CWD state
// on the server side. The returned error is the first error encountered
// by any worker; other workers continue in the background until they
// hit ctx cancellation or finish their tasks.
func DownloadDirectoryConcurrent(ctx context.Context, rawURL string, timeout time.Duration, remotePath, localPath string, parallel int, progressFunc func(file string, current, total int64)) error {
if parallel < 1 {
parallel = 1
}
pool, err := newClientPool(rawURL, timeout, parallel)
if err != nil {
return err
}
defer pool.close()
sem := make(chan struct{}, parallel)
var wg sync.WaitGroup
var firstErr error
var errMu sync.Mutex
setErr := func(err error) {
errMu.Lock()
if firstErr == nil {
firstErr = err
}
errMu.Unlock()
}
processEntry(ctx, pool, sem, &wg, setErr, remotePath, localPath, progressFunc)
wg.Wait()
return firstErr
}
// processEntry processes one remote path: lists it, then dispatches
// its child entries (file → download, subdir → recursive processEntry)
// through the shared semaphore-bounded goroutine pool. A single
// sem is threaded through the recursion so the total in-flight
// goroutines never exceed `parallel`, regardless of directory depth.
func processEntry(ctx context.Context, pool *clientPool, sem chan struct{}, wg *sync.WaitGroup, setErr func(error), remotePath, localPath string, progressFunc func(file string, current, total int64)) {
defer wg.Done()
defer func() { <-sem }()
if ctx.Err() != nil {
return
}
if err := os.MkdirAll(localPath, 0755); err != nil {
setErr(fmt.Errorf("failed to create local dir %s: %w", localPath, err))
return
}
c := pool.acquire()
entries, err := c.ListRemoteDir(remotePath)
pool.release(c)
if err != nil {
setErr(fmt.Errorf("list %s: %w", remotePath, err))
return
}
for _, entry := range entries {
fields := strings.Fields(entry)
if len(fields) < 9 {
continue
}
name := fields[len(fields)-1]
if name == "." || name == ".." {
continue
}
isDir := fields[0][0] == 'd'
remoteFilePath := filepath.Join(remotePath, name)
localFilePath := filepath.Join(localPath, name)
if isDir {
wg.Add(1)
select {
case sem <- struct{}{}:
go processEntry(ctx, pool, sem, wg, setErr, remoteFilePath, localFilePath, progressFunc)
case <-ctx.Done():
return
}
} else {
wg.Add(1)
select {
case sem <- struct{}{}:
go func(remoteFilePath, localFilePath, name string) {
defer wg.Done()
defer func() { <-sem }()
if ctx.Err() != nil {
return
}
c := pool.acquire()
defer pool.release(c)
if progressFunc != nil {
progressFunc(name, 0, 0)
}
// DownloadFile's progress callback has a different
// signature than our directory-level progressFunc, so
// we wrap it here to keep the existing per-file
// progress behaviour.
var fileProgress func(current, total int64)
if progressFunc != nil {
fileProgress = func(current, total int64) {
progressFunc(name, current, total)
}
}
if err := c.DownloadFile(ctx, remoteFilePath, localFilePath, 0, fileProgress); err != nil {
setErr(fmt.Errorf("download %s: %w", remoteFilePath, err))
}
}(remoteFilePath, localFilePath, name)
case <-ctx.Done():
return
}
}
}
}
+382
View File
@@ -0,0 +1,382 @@
//go:build linux || freebsd
// +build linux freebsd
package ftp
import (
"errors"
"net/url"
"strings"
"testing"
"time"
)
// ---------- Protocol tests ----------
func TestNewProtocol(t *testing.T) {
p := NewProtocol()
if p == nil {
t.Fatal("NewProtocol() returned nil")
}
}
func TestScheme(t *testing.T) {
p := &Protocol{}
if got := p.Scheme(); got != "ftp" {
t.Errorf("Scheme() = %q, want %q", got, "ftp")
}
}
func TestCanHandle(t *testing.T) {
tests := []struct {
name string
raw string
want bool
}{
{"ftp scheme", "ftp://ftp.example.com/file.txt", true},
{"ftps scheme", "ftps://ftp.example.com/file.txt", true},
{"http scheme", "http://example.com/file.txt", false},
{"https scheme", "https://example.com/file.txt", false},
{"sftp scheme", "sftp://example.com/file.txt", false},
{"empty scheme", "file.txt", false},
{"nil URL", "", false},
}
p := &Protocol{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var u *url.URL
if tt.raw != "" {
var err error
u, err = url.Parse(tt.raw)
if err != nil {
t.Fatalf("failed to parse URL %q: %v", tt.raw, err)
}
}
if got := p.CanHandle(u); got != tt.want {
t.Errorf("CanHandle(%q) = %v, want %v", tt.raw, got, tt.want)
}
})
}
}
func TestCanHandleCaseInsensitive(t *testing.T) {
p := &Protocol{}
u, _ := url.Parse("FTP://example.com/file.txt")
if !p.CanHandle(u) {
t.Errorf("CanHandle should be case-insensitive for FTP")
}
u, _ = url.Parse("FTPS://example.com/file.txt")
if !p.CanHandle(u) {
t.Errorf("CanHandle should be case-insensitive for FTPS")
}
}
func TestCapabilities(t *testing.T) {
p := &Protocol{}
caps := p.Capabilities()
if caps == nil {
t.Fatal("Capabilities() returned nil")
}
expected := map[string]bool{
"download": true,
"resume": true,
"recursive": true,
"tls": true,
}
if len(caps) != len(expected) {
t.Errorf("Capabilities() length = %d, want %d", len(caps), len(expected))
}
for _, cap := range caps {
if !expected[cap] {
t.Errorf("unexpected capability: %q", cap)
}
}
}
func TestSupportsResume(t *testing.T) {
p := &Protocol{}
if !p.SupportsResume() {
t.Errorf("SupportsResume() should return true")
}
}
func TestSupportsCompression(t *testing.T) {
p := &Protocol{}
if p.SupportsCompression() {
t.Errorf("SupportsCompression() should return false")
}
}
func TestSupportsParallel(t *testing.T) {
p := &Protocol{}
if p.SupportsParallel() {
t.Errorf("SupportsParallel() should return false")
}
}
func TestSupportsRecursive(t *testing.T) {
p := &Protocol{}
if !p.SupportsRecursive() {
t.Errorf("SupportsRecursive() should return true")
}
}
func TestReadAll(t *testing.T) {
t.Run("normal content", func(t *testing.T) {
input := "Hello, FTP world!"
r := strings.NewReader(input)
data, err := readAll(r)
if err != nil {
t.Fatalf("readAll() returned error: %v", err)
}
if string(data) != input {
t.Errorf("readAll() = %q, want %q", string(data), input)
}
})
t.Run("empty reader", func(t *testing.T) {
r := strings.NewReader("")
data, err := readAll(r)
if err != nil {
t.Fatalf("readAll() returned error: %v", err)
}
if len(data) != 0 {
t.Errorf("readAll() returned %d bytes, want 0", len(data))
}
})
t.Run("large content", func(t *testing.T) {
// Create content larger than the 32KB buffer to ensure multiple reads
content := strings.Repeat("A", 100*1024)
r := strings.NewReader(content)
data, err := readAll(r)
if err != nil {
t.Fatalf("readAll() returned error: %v", err)
}
if len(data) != len(content) {
t.Errorf("readAll() returned %d bytes, want %d", len(data), len(content))
}
})
t.Run("reader error", func(t *testing.T) {
expectedErr := errors.New("read error")
r := &errorReader{err: expectedErr}
_, err := readAll(r)
if err == nil {
t.Fatal("readAll() should return error")
}
if !strings.Contains(err.Error(), expectedErr.Error()) {
t.Errorf("readAll() error = %v, want %v", err, expectedErr)
}
})
}
// errorReader implements io.Reader that always returns an error
type errorReader struct {
err error
}
func (r *errorReader) Read(p []byte) (n int, err error) {
return 0, r.err
}
// ---------- Client tests ----------
func TestNewClient(t *testing.T) {
tests := []struct {
name string
rawURL string
wantErr bool
}{
{"basic FTP URL", "ftp://ftp.example.com/pub/file.txt", false},
{"FTP URL with path", "ftp://ftp.example.com/pub/", false},
{"FTP URL root", "ftp://ftp.example.com", false},
{"FTP URL with trailing slash", "ftp://ftp.example.com/", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := NewClient(tt.rawURL, 30*time.Second)
if tt.wantErr {
if err == nil {
t.Errorf("NewClient(%q) expected error", tt.rawURL)
}
return
}
if err != nil {
t.Fatalf("NewClient(%q) returned error: %v", tt.rawURL, err)
}
if client == nil {
t.Fatal("NewClient() returned nil")
}
})
}
}
func TestNewClientWithAuth(t *testing.T) {
client, err := NewClient("ftp://user:password@ftp.example.com/file.txt", 30*time.Second)
if err != nil {
t.Fatalf("NewClient() returned error: %v", err)
}
if client.user != "user" {
t.Errorf("client.user = %q, want %q", client.user, "user")
}
if client.password != "password" {
t.Errorf("client.password = %q, want %q", client.password, "password")
}
}
func TestNewClientWithUserOnly(t *testing.T) {
client, err := NewClient("ftp://user@ftp.example.com/file.txt", 30*time.Second)
if err != nil {
t.Fatalf("NewClient() returned error: %v", err)
}
if client.user != "user" {
t.Errorf("client.user = %q, want %q", client.user, "user")
}
if client.password != "anonymous@" {
t.Errorf("client.password should default to %q when only username is provided, got %q", "anonymous@", client.password)
}
}
func TestNewClientFTPS(t *testing.T) {
client, err := NewClient("ftps://ftp.example.com/file.txt", 30*time.Second)
if err != nil {
t.Fatalf("NewClient() returned error: %v", err)
}
if !client.useTLS {
t.Errorf("client.useTLS should be true for ftps:// scheme")
}
}
func TestNewClientFTPSWithAuth(t *testing.T) {
client, err := NewClient("ftps://alice:secret@ftp.example.com/file.txt", 30*time.Second)
if err != nil {
t.Fatalf("NewClient() returned error: %v", err)
}
if !client.useTLS {
t.Errorf("client.useTLS should be true for ftps:// scheme")
}
if client.user != "alice" {
t.Errorf("client.user = %q, want %q", client.user, "alice")
}
if client.password != "secret" {
t.Errorf("client.password = %q, want %q", client.password, "secret")
}
}
func TestNewClientInvalidURL(t *testing.T) {
// Only inputs where url.Parse itself fails
_, err := NewClient("://invalid", 30*time.Second)
if err == nil {
t.Errorf("NewClient(%q) expected error", "://invalid")
}
}
func TestNewClientEmptyURL(t *testing.T) {
client, err := NewClient("", 30*time.Second)
if err != nil {
t.Fatalf("NewClient(\"\") returned error: %v", err)
}
// url.Parse("") returns an empty URL struct, so host will be empty
if client.host != "" {
t.Errorf("client.host = %q, want empty", client.host)
}
if client.port != 21 {
t.Errorf("client.port = %d, want %d", client.port, 21)
}
}
func TestNewClientNoScheme(t *testing.T) {
client, err := NewClient("not-a-url", 30*time.Second)
if err != nil {
t.Fatalf("NewClient(\"not-a-url\") returned error: %v", err)
}
// url.Parse("not-a-url") parses it as a path with empty host
if client.host != "" {
t.Errorf("client.host = %q, want empty", client.host)
}
if client.port != 21 {
t.Errorf("client.port = %d, want %d", client.port, 21)
}
}
func TestNewClientDefaultPort(t *testing.T) {
client, err := NewClient("ftp://ftp.example.com/file.txt", 30*time.Second)
if err != nil {
t.Fatalf("NewClient() returned error: %v", err)
}
if client.port != 21 {
t.Errorf("client.port = %d, want %d", client.port, 21)
}
}
func TestNewClientCustomPort(t *testing.T) {
client, err := NewClient("ftp://ftp.example.com:2121/file.txt", 30*time.Second)
if err != nil {
t.Fatalf("NewClient() returned error: %v", err)
}
if client.port != 2121 {
t.Errorf("client.port = %d, want %d", client.port, 2121)
}
}
func TestNewClientInvalidPort(t *testing.T) {
_, err := NewClient("ftp://ftp.example.com:invalid/file.txt", 30*time.Second)
if err == nil {
t.Errorf("NewClient() expected error for invalid port")
}
}
func TestNewClientDefaultCredentials(t *testing.T) {
client, err := NewClient("ftp://ftp.example.com/file.txt", 30*time.Second)
if err != nil {
t.Fatalf("NewClient() returned error: %v", err)
}
if client.user != "anonymous" {
t.Errorf("default user = %q, want %q", client.user, "anonymous")
}
if client.password != "anonymous@" {
t.Errorf("default password = %q, want %q", client.password, "anonymous@")
}
}
func TestNewClientHostParsing(t *testing.T) {
client, err := NewClient("ftp://ftp.example.com/path/to/file.txt", 30*time.Second)
if err != nil {
t.Fatalf("NewClient() returned error: %v", err)
}
if client.host != "ftp.example.com" {
t.Errorf("client.host = %q, want %q", client.host, "ftp.example.com")
}
}
func TestNewClientPassiveModeDefault(t *testing.T) {
client, err := NewClient("ftp://ftp.example.com/file.txt", 30*time.Second)
if err != nil {
t.Fatalf("NewClient() returned error: %v", err)
}
if !client.passive {
t.Errorf("client.passive should default to true")
}
}
func TestNewClientTLSConfig(t *testing.T) {
client, err := NewClient("ftps://secure.example.com/file.txt", 30*time.Second)
if err != nil {
t.Fatalf("NewClient() returned error: %v", err)
}
if client.tlsConfig == nil {
t.Fatal("client.tlsConfig should not be nil")
}
if client.tlsConfig.ServerName != "secure.example.com" {
t.Errorf("tlsConfig.ServerName = %q, want %q", client.tlsConfig.ServerName, "secure.example.com")
}
if client.tlsConfig.InsecureSkipVerify {
t.Errorf("tlsConfig.InsecureSkipVerify should be false by default")
}
}
+220
View File
@@ -0,0 +1,220 @@
//go:build linux || freebsd
// +build linux freebsd
package ftp
import (
"context"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/output"
)
// Protocol implements the core.Protocol interface for FTP/FTPS
type Protocol struct{}
// NewProtocol creates a new instance of FTP protocol
func NewProtocol() *Protocol {
return &Protocol{}
}
// Scheme returns the protocol scheme
func (p *Protocol) Scheme() string {
return "ftp"
}
// CanHandle checks whether the protocol can handle the given URL
func (p *Protocol) CanHandle(u *url.URL) bool {
if u == nil {
return false
}
scheme := strings.ToLower(u.Scheme)
return scheme == "ftp" || scheme == "ftps"
}
// Capabilities returns the list of supported features
func (p *Protocol) Capabilities() []string {
return []string{
"download",
"resume",
"recursive",
"tls",
}
}
// SupportsResume returns whether the protocol supports resume
func (p *Protocol) SupportsResume() bool {
return true
}
// SupportsCompression returns whether the protocol supports compression
func (p *Protocol) SupportsCompression() bool {
return false
}
// SupportsParallel returns whether the protocol supports parallel downloading
func (p *Protocol) SupportsParallel() bool {
return false
}
// SupportsRecursive returns whether the protocol supports recursive downloading
func (p *Protocol) SupportsRecursive() bool {
return true
}
// Download downloads a file or directory via FTP/FTPS
func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
timeout := req.Timeout
if timeout <= 0 {
timeout = 30 * time.Second
}
client, err := NewClient(req.URL.String(), timeout)
if err != nil {
return nil, core.NewProtocolError(fmt.Sprintf("failed to create FTP client: %v", err), nil, core.SafeURL(req.URL))
}
if err := client.Connect(); err != nil {
return nil, core.NewProtocolError(fmt.Sprintf("failed to connect: %v", err), nil, core.SafeURL(req.URL))
}
defer client.Close()
startTime := time.Now()
var bytesDownloaded int64 = 0
var chunksCount int
// Determine whether it is a file or directory
remotePath := req.URL.Path
if remotePath == "" {
remotePath = "/"
}
// Try to get file size - if it fails, it's probably a directory
fileSize, err := client.getFileSize(remotePath)
isFile := (err == nil)
if isFile {
// Downloading file
var outputFile string
if req.Output != "" {
outputFile = req.Output
} else {
outputFile = filepath.Base(remotePath)
if outputFile == "" || outputFile == "/" {
outputFile = "index.html"
}
}
// Kontrola resume
var resumeOffset int64 = 0
if req.Resume && req.Output != "" {
if canResume, meta, _ := output.CanResume(req.Output, req.URL.String()); canResume && meta != nil {
resumeOffset = meta.Downloaded
if req.Verbose {
fmt.Fprintf(os.Stderr, "Resuming from offset %d\n", resumeOffset)
}
}
}
// Progress callback
var progressCallback func(current, total int64)
if req.ProgressCallback != nil && fileSize > 0 {
totalSize := fileSize
startOffset := resumeOffset
progressCallback = func(current, _ int64) {
req.ProgressCallback(startOffset+current, totalSize, 0)
}
}
if err := client.DownloadFile(ctx, remotePath, outputFile, resumeOffset, progressCallback); err != nil {
return nil, core.NewProtocolError(fmt.Sprintf("download failed: %v", err), nil, core.SafeURL(req.URL))
}
// Get the actual size of the downloaded file
if info, err := os.Stat(outputFile); err == nil {
bytesDownloaded = info.Size()
}
chunksCount = 1
} else {
// Recursive download of directory
if !req.Recursive {
return nil, core.NewProtocolError("remote path is a directory, use --recursive to download", nil, core.SafeURL(req.URL))
}
outputDir := req.Output
if outputDir == "" {
outputDir = filepath.Base(remotePath)
if outputDir == "" || outputDir == "/" {
outputDir = "download"
}
}
var progressCallback func(file string, current, total int64)
if req.Verbose {
progressCallback = func(file string, current, total int64) {
fmt.Fprintf(os.Stderr, "Downloading: %s\n", file)
}
}
// Dispatch to the parallel implementation when the user
// requested worker-pool concurrency, otherwise fall back to the
// single-connection sequential implementation. The parallel path
// opens N control connections to avoid stepping on CWD state and
// to keep the existing client-side protocol serialisation.
if req.RecursiveParallel > 1 {
if err := DownloadDirectoryConcurrent(ctx, req.URL.String(), timeout, remotePath, outputDir, req.RecursiveParallel, progressCallback); err != nil {
return nil, core.NewProtocolError(fmt.Sprintf("directory download failed: %v", err), nil, core.SafeURL(req.URL))
}
} else {
if err := client.DownloadDirectory(ctx, remotePath, outputDir, progressCallback); err != nil {
return nil, core.NewProtocolError(fmt.Sprintf("directory download failed: %v", err), nil, core.SafeURL(req.URL))
}
}
// Calculate total size and file count
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
bytesDownloaded += info.Size()
chunksCount++
}
return nil
})
}
duration := time.Since(startTime)
return &core.DownloadResult{
BytesDownloaded: bytesDownloaded,
Duration: duration,
ChunksCount: chunksCount,
}, nil
}
// Helper function for reading from io.Reader
func readAll(reader io.Reader) ([]byte, error) {
var data []byte
buf := make([]byte, 32*1024)
for {
n, err := reader.Read(buf)
if n > 0 {
data = append(data, buf[:n]...)
}
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
}
return data, nil
}
+62
View File
@@ -0,0 +1,62 @@
//go:build linux || freebsd
// +build linux freebsd
package ftp
import (
"os"
"path/filepath"
"testing"
"codeberg.org/petrbalvin/goget/internal/output"
)
// TestSaveFTPResume verifies the helper persists partial FTP progress
// to a .goget.meta sidecar and that the sidecar is suitable for a
// subsequent goget --resume invocation. The function is called from
// Client.DownloadFile when ctx is cancelled mid-transfer; without a
// working sidecar the user's interrupted download cannot be continued.
func TestSaveFTPResume(t *testing.T) {
tmp := t.TempDir()
out := filepath.Join(tmp, "file.bin")
// 1. First call records partial progress.
if err := saveFTPResume("ftp://example.com/file.bin", out, 4096, 1<<20); err != nil {
t.Fatalf("saveFTPResume: %v", err)
}
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.URL != "ftp://example.com/file.bin" {
t.Errorf("URL = %q, want ftp://example.com/file.bin", meta.URL)
}
if meta.Downloaded != 4096 {
t.Errorf("Downloaded = %d, want 4096", meta.Downloaded)
}
if meta.Total != 1<<20 {
t.Errorf("Total = %d, want %d", meta.Total, 1<<20)
}
// 2. Second call overwrites the sidecar with a later snapshot.
if err := saveFTPResume("ftp://example.com/file.bin", out, 8192, 1<<20); err != nil {
t.Fatalf("saveFTPResume (2): %v", err)
}
meta2, _ := output.LoadResumeMetadata(out)
if meta2 == nil || meta2.Downloaded != 8192 {
t.Errorf("second call did not update sidecar: %+v", meta2)
}
// 3. Zero-byte save is a no-op (no point persisting a sidecar
// for an empty transfer).
out2 := filepath.Join(tmp, "empty.bin")
if err := saveFTPResume("ftp://x", out2, 0, 100); err != nil {
t.Errorf("zero-byte save returned error: %v", err)
}
if _, err := os.Stat(out2 + output.ResumeMetaFileSuffix); !os.IsNotExist(err) {
t.Errorf("expected no sidecar for zero-byte save, got one")
}
}
+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")
}
}
+384
View File
@@ -0,0 +1,384 @@
//go:build linux || freebsd
// +build linux freebsd
package gopher
import (
"bufio"
"context"
"fmt"
"io"
"net"
"strings"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/protocol"
)
// Gopher item types (RFC 1436).
const (
typeTextFile = '0'
typeDirectory = '1'
typeError = '3'
typeBinaryFile = '9'
typeGIF = 'g'
typeImage = 'I'
typeHTML = 'h'
typeInformation = 'i'
typeSound = 's'
typeTelnet = '8'
typeSearch = '7' // index search
)
// Protocol implements gopher:// downloads (RFC 1436).
type Protocol struct {
*protocol.BaseProtocol
}
// NewProtocol creates a new Gopher protocol handler.
func NewProtocol() *Protocol {
return &Protocol{
BaseProtocol: protocol.NewBaseProtocol(protocol.ProtocolInfo{
Name: "GOPHER",
Scheme: "gopher",
DefaultPort: 70,
Features: []string{},
}),
}
}
func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
startTime := time.Now()
host := req.URL.Hostname()
port := req.URL.Port()
if port == "" {
port = "70"
}
selector := req.URL.Path
if req.URL.RawQuery != "" {
selector += "?" + req.URL.RawQuery
}
if selector == "" {
selector = "/"
}
addr := net.JoinHostPort(host, port)
conn, err := net.DialTimeout("tcp", addr, 30*time.Second)
if err != nil {
return nil, core.NewNetworkError("failed to connect to gopher server", err, core.SafeURL(req.URL))
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(30 * time.Second))
// Send selector + CRLF
if _, err := fmt.Fprintf(conn, "%s\r\n", selector); err != nil {
return nil, core.NewNetworkError("failed to send gopher selector", err, core.SafeURL(req.URL))
}
// Peek at first byte to determine response type
reader := bufio.NewReader(conn)
firstByte, err := reader.Peek(1)
if err != nil {
return nil, core.NewNetworkError("failed to read gopher response", err, core.SafeURL(req.URL))
}
itemType := firstByte[0]
switch itemType {
case typeError:
// Type 3: error — read the error message
line, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
return nil, core.NewNetworkError("failed to read gopher error", err, core.SafeURL(req.URL))
}
msg := strings.TrimSpace(line)
if len(msg) > 2 {
msg = msg[2:] // strip type + first char
}
return nil, core.NewProtocolError("gopher server error: "+msg, nil, core.SafeURL(req.URL))
case typeDirectory:
// Type 1: directory listing — format as readable text
return p.downloadDirectory(reader, req, startTime)
case typeTextFile, typeHTML, typeInformation:
// Text-based types — strip item-type prefix, handle .\r\n terminator
return p.downloadText(ctx, reader, req, startTime, itemType)
case typeSearch:
// Type 7: index search — server expects a query
return nil, core.NewProtocolError(
"gopher search server requires a query (append ?query to URL)", nil, core.SafeURL(req.URL))
case typeTelnet:
// Type 8: telnet session — interactive, cannot download
return nil, core.NewProtocolError(
"gopher telnet session type cannot be downloaded", nil, core.SafeURL(req.URL))
default:
// Binary types (9, g, I, s, etc.) — raw data with .\r\n termination
// But first check which ones have the type-prefix-per-line format
if isTextGopherType(itemType) {
return p.downloadText(ctx, reader, req, startTime, itemType)
}
return p.downloadBinary(ctx, reader, req, startTime)
}
}
// isTextGopherType returns true for types that use line-prefix format.
func isTextGopherType(t byte) bool {
switch t {
case typeTextFile, typeDirectory, typeError, typeInformation, typeHTML:
return true
default:
return false
}
}
// downloadText handles text responses where each line has a type prefix.
func (p *Protocol) downloadText(ctx context.Context, reader *bufio.Reader, req *core.DownloadRequest, startTime time.Time, firstType byte) (*core.DownloadResult, error) {
var writer io.Writer
if req.Writer != nil {
writer = req.Writer
} else {
writer = io.Discard
}
var totalRead int64
lineCount := 0
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
return nil, core.NewNetworkError("failed to read gopher line", err, core.SafeURL(req.URL))
}
// End-of-transmission: line containing only ".\r\n"
if line == ".\r\n" || line == ".\n" {
break
}
// Strip item-type prefix if present (first char = type, second char is usually tab/space)
displayLine := line
if len(line) > 1 && line[1] != '.' {
// Full menu line: type + display string + tab + selector + tab + host + tab + port
if true {
// For text downloads, strip the type and metadata, show only display string + \n
clean := stripGopherMeta(line)
displayLine = clean + "\n"
}
}
// For informational lines (type i), don't strip anything — just pass through
if firstType == typeInformation {
displayLine = line
}
n, err := writer.Write([]byte(displayLine))
if err != nil {
return nil, core.NewFileError("failed to write gopher text", err)
}
totalRead += int64(n)
lineCount++
if req.ProgressCallback != nil {
speed := float64(totalRead) / time.Since(startTime).Seconds()
req.ProgressCallback(totalRead, -1, speed)
}
}
duration := time.Since(startTime)
speed := float64(totalRead) / duration.Seconds()
return &core.DownloadResult{
BytesDownloaded: totalRead,
TotalSize: totalRead,
Duration: duration,
Protocol: "GOPHER",
IPVersion: 4,
Speed: speed,
OutputPath: req.Output,
}, nil
}
// downloadDirectory formats a directory listing as readable text.
func (p *Protocol) downloadDirectory(reader *bufio.Reader, req *core.DownloadRequest, startTime time.Time) (*core.DownloadResult, error) {
var writer io.Writer
if req.Writer != nil {
writer = req.Writer
} else {
writer = io.Discard
}
var totalRead int64
var sb strings.Builder
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
return nil, core.NewNetworkError("failed to read gopher directory", err, core.SafeURL(req.URL))
}
if line == ".\r\n" || line == ".\n" {
break
}
// Parse gopher menu line: type + display string + tab + selector + tab + host + tab + port
formatted := formatGopherMenu(line)
sb.WriteString(formatted)
sb.WriteByte('\n')
}
output := sb.String()
n, err := writer.Write([]byte(output))
if err != nil {
return nil, core.NewFileError("failed to write gopher directory", err)
}
totalRead = int64(n)
duration := time.Since(startTime)
speed := float64(totalRead) / duration.Seconds()
return &core.DownloadResult{
BytesDownloaded: totalRead,
TotalSize: totalRead,
Duration: duration,
Protocol: "GOPHER",
IPVersion: 4,
Speed: speed,
OutputPath: req.Output,
}, nil
}
// downloadBinary handles binary responses.
func (p *Protocol) downloadBinary(ctx context.Context, reader *bufio.Reader, req *core.DownloadRequest, startTime time.Time) (*core.DownloadResult, error) {
var writer io.Writer
if req.Writer != nil {
writer = req.Writer
} else {
writer = io.Discard
}
var totalRead int64
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 gopher binary 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 gopher binary", err, core.SafeURL(req.URL))
}
}
duration := time.Since(startTime)
speed := float64(totalRead) / duration.Seconds()
return &core.DownloadResult{
BytesDownloaded: totalRead,
TotalSize: totalRead,
Duration: duration,
Protocol: "GOPHER",
IPVersion: 4,
Speed: speed,
OutputPath: req.Output,
}, nil
}
// stripGopherMeta removes the gopher metadata (tabs + selector + host + port) from a menu line.
func stripGopherMeta(line string) string {
line = strings.TrimRight(line, "\r\n")
if len(line) < 2 {
return line
}
// Format: Xdisplay string\tselector\thost\tport
// Everything before the first tab is the type + display string
tabIdx := strings.IndexByte(line, '\t')
if tabIdx <= 1 {
return line // no metadata
}
return line[1:tabIdx] // strip type char, keep display text
}
// formatGopherMenu formats a gopher menu line for human display.
func formatGopherMenu(line string) string {
line = strings.TrimRight(line, "\r\n")
if len(line) < 2 {
return line
}
itemType := line[0]
rest := line[1:]
// Split by tabs: display\0selector\0host\0port
parts := strings.Split(rest, "\t")
display := parts[0]
selector := ""
if len(parts) > 1 {
selector = parts[1]
}
icon := gopherIcon(itemType)
if selector != "" && selector != "(null)" {
return fmt.Sprintf("%s %s [%s]", icon, display, selector)
}
return fmt.Sprintf("%s %s", icon, display)
}
// gopherIcon returns an icon for a gopher item type.
func gopherIcon(t byte) string {
switch t {
case typeDirectory:
return "📁"
case typeTextFile:
return "📄"
case typeBinaryFile:
return "💾"
case typeGIF, typeImage:
return "🖼"
case typeHTML:
return "🌐"
case typeSound:
return "🔊"
case typeSearch:
return "🔍"
case typeTelnet:
return "🖥"
case typeInformation:
return "️"
default:
return "❓"
}
}
+110
View File
@@ -0,0 +1,110 @@
//go:build linux || freebsd
// +build linux freebsd
package gopher
import (
"bufio"
"context"
"net"
"net/url"
"strconv"
"strings"
"testing"
"codeberg.org/petrbalvin/goget/internal/core"
)
func TestFormatGopherMenu(t *testing.T) {
tests := []struct {
line string
expected string
}{
{"0About\t/about\tgopher.floodgap.com\t70\r\n", "📄 About [/about]"},
{"1Fun\t/fun\tgopher.floodgap.com\t70\r\n", "📁 Fun [/fun]"},
{"iWelcome to Gopher\t(null)\t(null)\t0\r\n", "️ Welcome to Gopher"},
{"hNews\t/news\tgopher.floodgap.com\t70\r\n", "🌐 News [/news]"},
}
for _, tc := range tests {
result := formatGopherMenu(tc.line)
if result != tc.expected {
t.Errorf("formatGopherMenu(%q) = %q, want %q", tc.line, result, tc.expected)
}
}
}
func TestStripGopherMeta(t *testing.T) {
result := stripGopherMeta("0About\t/about\thost\t70")
if result != "About" {
t.Errorf("got %q, want %q", result, "About")
}
}
func TestGopherIcon(t *testing.T) {
if i := gopherIcon('0'); i != "📄" {
t.Errorf("got %q", i)
}
if i := gopherIcon('1'); i != "📁" {
t.Errorf("got %q", i)
}
if i := gopherIcon('9'); i != "💾" {
t.Errorf("got %q", i)
}
if i := gopherIcon('h'); i != "🌐" {
t.Errorf("got %q", i)
}
if i := gopherIcon('7'); i != "🔍" {
t.Errorf("got %q", i)
}
}
func TestIsTextGopherType(t *testing.T) {
if !isTextGopherType('0') {
t.Error("0 should be text")
}
if isTextGopherType('9') {
t.Error("9 should not be text")
}
}
type stringWriter struct{ sb *strings.Builder }
func (w *stringWriter) Write(p []byte) (int, error) { return w.sb.Write(p) }
func TestGopherDownloadMock(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
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()
reader := bufio.NewReader(conn)
reader.ReadString('\n') // selector
conn.Write([]byte("0Hello from gopher\t(null)\t(null)\t0\r\n"))
conn.Write([]byte(".\r\n"))
}()
proto := NewProtocol()
u, _ := url.Parse("gopher://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 from gopher") {
t.Errorf("output missing text: %q", buf.String())
}
}
File diff suppressed because it is too large Load Diff
+107
View File
@@ -0,0 +1,107 @@
//go:build linux || freebsd
// +build linux freebsd
package http
import (
"time"
"codeberg.org/petrbalvin/goget/internal/auth"
"codeberg.org/petrbalvin/goget/internal/hsts"
"codeberg.org/petrbalvin/goget/internal/transport"
)
// TransportConfig groups network, TLS, proxy, and connection settings.
type TransportConfig struct {
// Connection
Timeout time.Duration
ConnectTimeout time.Duration
MaxTime time.Duration
MaxFileSize int64
BufferSize int
FollowRedirects bool
MaxRedirects int
NoClobber bool
// TLS and certificates
TLS *transport.TLSConfig
CACertFile string
HSTSCache *hsts.Cache
// Proxy and dialer
Proxy *transport.ProxyConfig
Dialer *transport.DialerConfig
BindInterface string
// DNS and IP filtering
DNSServers []string
BlockPrivateIPs bool
// Retry
Retry *transport.RetryConfig
MaxRetries int
RetryDelay time.Duration
RetryHTTPCodes []int
RetryAllErrors bool
}
// AuthConfig groups authentication settings.
type AuthConfig struct {
Username string
Password string
AuthType string // "basic", "digest", "auto"
OAuth *auth.OAuthConfig
}
// RecursiveConfig groups recursive download and mirroring settings.
type RecursiveConfig struct {
// Core recursion
Enabled bool
MaxDepth int
FollowExternal bool
ExcludePatterns []string
Accept string
Reject []string
// Parallel worker count for recursive downloads. 0 falls back to
// Parallel.Connections (or the default). Applies to all recursive
// protocols that support parallel file processing.
Parallel int
// Scope control
NoParent bool
SpanHosts bool
PageRequisites bool
Domains []string
// Rate limiting for recursion
Wait time.Duration
RandomWait bool
// Output control
CutDirs int
AdjustExt bool
DryRun bool
// Mirror mode
Mirror bool
MirrorAssets bool
ConvertLinks bool
RespectRobots bool
}
// OutputConfig groups output, progress, and display settings.
type OutputConfig struct {
OutputDir string
Verbose bool
JSONOutput bool
ShowHeaders bool
ContentDisposition bool
KeepTimestamps bool
WriteOut string
TraceTime bool
OnComplete string
WarcFile string
AutoExtract bool
ExtractDir string
}
+179
View File
@@ -0,0 +1,179 @@
//go:build linux || freebsd
// +build linux freebsd
package http
import (
"fmt"
"mime"
"net/http"
"net/url"
"path/filepath"
"strings"
)
// URLInfo contains extracted information from an HTTP URL
type URLInfo struct {
Scheme string
Host string
Port string
Path string
Query string
Fragment string
User *url.Userinfo
}
// Handler contains HTTP-specific logic
type Handler struct {
client *Client
}
// NewHandler creates new handler
func NewHandler(client *Client) *Handler {
return &Handler{client: client}
}
// ParseHTTPURL parses an HTTP URL and extracts information
func ParseHTTPURL(u *url.URL) (*URLInfo, error) {
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("invalid http url scheme: %s", u.Scheme)
}
host := u.Hostname()
port := u.Port()
if port == "" {
if u.Scheme == "https" {
port = "443"
} else {
port = "80"
}
}
return &URLInfo{
Scheme: u.Scheme,
Host: host,
Port: port,
Path: u.Path,
Query: u.RawQuery,
Fragment: u.Fragment,
User: u.User,
}, nil
}
// GetContentType extracts Content-Type from the response
func GetContentType(resp *http.Response) string {
return resp.Header.Get("Content-Type")
}
// GetContentLength extracts Content-Length from the response
func GetContentLength(resp *http.Response) int64 {
return resp.ContentLength
}
// GetContentEncoding extracts Content-Encoding from the response
func GetContentEncoding(resp *http.Response) string {
return resp.Header.Get("Content-Encoding")
}
// GetETag extracts ETag from the response
func GetETag(resp *http.Response) string {
return resp.Header.Get("ETag")
}
// GetLastModified extracts Last-Modified from the response
func GetLastModified(resp *http.Response) string {
return resp.Header.Get("Last-Modified")
}
// SupportsRangeCheck checks whether the server supports range requests
func SupportsRangeCheck(resp *http.Response) bool {
acceptRanges := resp.Header.Get("Accept-Ranges")
return strings.ToLower(acceptRanges) == "bytes"
}
// IsRedirect checks whether the response is a redirect
func IsRedirect(statusCode int) bool {
return statusCode >= 300 && statusCode < 400
}
// GetRedirectLocation gets the Location header from a redirect response
func GetRedirectLocation(resp *http.Response) string {
return resp.Header.Get("Location")
}
// BuildURLWithQuery creates a URL with query parameters
func BuildURLWithQuery(base *url.URL, params map[string]string) (*url.URL, error) {
if base == nil {
return nil, fmt.Errorf("base url cannot be nil")
}
result := *base
query := result.Query()
for key, value := range params {
query.Set(key, value)
}
result.RawQuery = query.Encode()
return &result, nil
}
// GetUserinfo extracts authentication credentials from the URL
func GetUserinfo(u *url.URL) (username, password string, ok bool) {
if u.User == nil {
return "", "", false
}
username = u.User.Username()
password, ok = u.User.Password()
return username, password, true
}
// IsSecure checks whether the URL uses HTTPS
func IsSecure(u *url.URL) bool {
return u.Scheme == "https"
}
// GetDefaultPortForScheme returns the default port for the scheme
func GetDefaultPortForScheme(scheme string) string {
switch scheme {
case "https":
return "443"
case "http":
return "80"
default:
return ""
}
}
// ParseContentDisposition extracts filename from Content-Disposition header
func ParseContentDisposition(headerValue string) string {
if headerValue == "" {
return ""
}
_, params, err := mime.ParseMediaType(headerValue)
if err != nil {
return ""
}
// Try filename* first (RFC 5987, extended filename)
if filename, ok := params["filename*"]; ok {
return filename
}
// Fall back to filename
if filename, ok := params["filename"]; ok {
return filename
}
return ""
}
// SanitizeFilename removes path components from a filename for security
func SanitizeFilename(name string) string {
name = filepath.Base(name)
if name == "" || name == "." || name == ".." {
return ""
}
return name
}
File diff suppressed because it is too large Load Diff
+559
View File
@@ -0,0 +1,559 @@
//go:build linux || freebsd
// +build linux freebsd
package http
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/transport"
)
// testWriter implements io.Writer for testing
type testWriter struct {
buf strings.Builder
}
func (w *testWriter) Write(p []byte) (int, error) {
return w.buf.Write(p)
}
func TestDownloadSequentialIntegration(t *testing.T) {
// Create a test server that returns known content
testContent := "Hello, this is test content for download integration testing!"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent)))
w.WriteHeader(http.StatusOK)
w.Write([]byte(testContent))
}))
defer server.Close()
// Parse the server URL
parsedURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("Failed to parse server URL: %v", err)
}
// Create the HTTP client
client, err := NewClient(DefaultConfig())
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
// Create download request
req := &core.DownloadRequest{
URL: parsedURL,
Output: "",
Writer: &testWriter{},
Verbose: false,
Headers: make(map[string]string),
}
// Execute download
result, err := client.Download(context.Background(), req)
if err != nil {
t.Fatalf("Download failed: %v", err)
}
if result.BytesDownloaded <= 0 {
t.Errorf("Expected bytes downloaded > 0, got %d", result.BytesDownloaded)
}
if result.BytesDownloaded != int64(len(testContent)) {
t.Errorf("Expected %d bytes downloaded, got %d", len(testContent), result.BytesDownloaded)
}
}
func TestDownloadParallelIntegration(t *testing.T) {
// Create test content for parallel download
testContent := strings.Repeat("B", 1024*1024) // 1MB
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Support range requests
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Content-Type", "application/octet-stream")
rangeHeader := r.Header.Get("Range")
if rangeHeader != "" {
var start, end int64
n, _ := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end)
if n >= 1 {
if end == 0 || end >= int64(len(testContent)) {
end = int64(len(testContent)) - 1
}
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(testContent)))
w.WriteHeader(http.StatusPartialContent)
w.Write([]byte(testContent[start : end+1]))
return
}
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent)))
w.WriteHeader(http.StatusOK)
w.Write([]byte(testContent))
}))
defer server.Close()
parsedURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("Failed to parse server URL: %v", err)
}
cfg := DefaultConfig()
cfg.Parallel = &core.ParallelConfig{
Connections: 4,
MinSize: 100, // Force parallel for small files
MinChunkSize: 100,
MaxChunkSize: 1024 * 1024,
}
client, err := NewClient(cfg)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
req := &core.DownloadRequest{
URL: parsedURL,
Output: "",
Writer: &testWriter{},
Verbose: false,
Headers: make(map[string]string),
Parallel: cfg.Parallel,
}
result, err := client.Download(context.Background(), req)
if err != nil {
t.Fatalf("Download failed: %v", err)
}
if result.BytesDownloaded <= 0 {
t.Errorf("Expected bytes downloaded > 0, got %d", result.BytesDownloaded)
}
if !result.Parallel {
t.Log("Download was not parallel (may be expected depending on server negotiation)")
}
}
func TestDownloadResumeIntegration(t *testing.T) {
testContent := "This is content for resume testing!"
var requestCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("ETag", "\"test-etag-123\"")
rangeHeader := r.Header.Get("Range")
if rangeHeader != "" {
var start int64
fmt.Sscanf(rangeHeader, "bytes=%d-", &start)
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(testContent)-1, len(testContent)))
w.WriteHeader(http.StatusPartialContent)
w.Write([]byte(testContent[start:]))
return
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(testContent)))
w.WriteHeader(http.StatusOK)
w.Write([]byte(testContent))
}))
defer server.Close()
parsedURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("Failed to parse server URL: %v", err)
}
client, err := NewClient(DefaultConfig())
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
// Create resume info simulating a partially downloaded file
resumeInfo := &core.ResumeInfo{
DownloadedBytes: 10,
ETag: "\"test-etag-123\"",
URL: server.URL,
}
req := &core.DownloadRequest{
URL: parsedURL,
Output: "",
Writer: &testWriter{},
Resume: true,
ResumeInfo: resumeInfo,
Verbose: false,
Headers: make(map[string]string),
}
result, err := client.Download(context.Background(), req)
if err != nil {
t.Fatalf("Download failed: %v", err)
}
if result.BytesDownloaded <= 0 {
t.Errorf("Expected bytes downloaded > 0, got %d", result.BytesDownloaded)
}
if result.Resumed {
t.Log("Download was resumed successfully")
}
}
func TestUploadIntegration(t *testing.T) {
var receivedBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
receivedBody = string(body)
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}))
defer server.Close()
client, err := NewClient(DefaultConfig())
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
// Test POST upload with form data
formReq := &UploadRequest{
URL: server.URL,
Method: "POST",
FormData: map[string]string{"key": "value"},
Timeout: 0,
}
result, err := client.Upload(context.Background(), formReq)
if err != nil {
t.Fatalf("Upload failed: %v", err)
}
if result.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", result.StatusCode)
}
if result.BytesUploaded <= 0 {
t.Errorf("Expected bytes uploaded > 0, got %d", result.BytesUploaded)
}
if !strings.Contains(receivedBody, "key=value") {
t.Errorf("Expected body to contain form data, got: %s", receivedBody)
}
}
func TestDownloadFollowsRedirectIntegration(t *testing.T) {
const finalBody = "redirected content"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/redirect" {
http.Redirect(w, r, "/target", http.StatusFound)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(finalBody))
}))
defer server.Close()
parsedURL, err := url.Parse(server.URL + "/redirect")
if err != nil {
t.Fatalf("parse url: %v", err)
}
client, err := NewClient(DefaultConfig())
if err != nil {
t.Fatalf("create client: %v", err)
}
result, err := client.Download(context.Background(), &core.DownloadRequest{
URL: parsedURL,
Writer: &testWriter{},
Headers: map[string]string{},
})
if err != nil {
t.Fatalf("download: %v", err)
}
if result.BytesDownloaded != int64(len(finalBody)) {
t.Errorf("bytes = %d, want %d", result.BytesDownloaded, len(finalBody))
}
}
// TestRetryAllErrorsClosesBody exercises the RetryAllErrors loop with a
// server that always answers 500. It verifies the loop actually attempts the
// configured number of retries and that each attempt's response body is
// closed — guarding against the regression where http.Client.Do returned a
// non-nil resp alongside an error and the body was never closed, exhausting
// the connection pool.
func TestRetryAllErrorsClosesBody(t *testing.T) {
var requestCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&requestCount, 1)
w.WriteHeader(http.StatusInternalServerError)
// Write a non-empty body so a leaking response body would matter
// (an empty body is closed trivially by net/http's chunked reader).
_, _ = w.Write([]byte("server error"))
}))
defer server.Close()
parsedURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse url: %v", err)
}
cfg := DefaultConfig()
cfg.Transport.RetryAllErrors = true
cfg.Transport.MaxRetries = 3
cfg.Transport.Retry = &transport.RetryConfig{
MaxRetries: 3,
InitialDelay: time.Millisecond,
}
client, err := NewClient(cfg)
if err != nil {
t.Fatalf("create client: %v", err)
}
_, err = client.Download(context.Background(), &core.DownloadRequest{
URL: parsedURL,
Writer: &testWriter{},
Headers: map[string]string{},
})
if err == nil {
t.Fatal("expected error after exhausting retries on 500 responses")
}
// We expect at least 1 (initial transport.Retry attempt) + MaxRetries
// (RetryAllErrors loop). The exact number may be higher because Go's
// HTTP transport may also retry once on its own when an attempt fails
// (e.g. HTTP/2 negotiation with an HTTP/1.1 test server), so we assert
// the lower bound only — the goal is to confirm the retry loop runs and
// that every failed response body is closed, not to pin a Go-internal
// detail.
minRequests := int32(1 + cfg.Transport.MaxRetries)
if got := atomic.LoadInt32(&requestCount); got < minRequests {
t.Errorf("request count = %d, want >= %d (1 initial + %d retries)", got, minRequests, cfg.Transport.MaxRetries)
}
}
// TestUploadRateLimiterAndProgressAreChained is a regression guard for the
// BACKLOG entry "Rate limiter silently dropped when progress wrapper
// enabled". The two wrappers must compose — httpReq.Body must end up
// reading through the rate-limited reader, with progress reported on top.
func TestUploadRateLimiterAndProgressAreChained(t *testing.T) {
const payload = "the quick brown fox jumps over the lazy dog"
var receivedBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusInternalServerError)
return
}
receivedBody = string(body)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Write payload to a temp file (Upload's PUT path opens the file).
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "payload.txt")
if err := os.WriteFile(tmpFile, []byte(payload), 0600); err != nil {
t.Fatalf("write tmp: %v", err)
}
cfg := DefaultConfig()
// Generous rate limit so the test isn't dominated by the limiter.
// limitedReader.Read asks for len(p) tokens on every call, and the
// HTTP transport uses a 32 KB buffer, so burst must comfortably exceed
// that or Allow() blocks forever.
cfg.RateLimiter = transport.NewTokenBucket(8*1024*1024, 8*1024*1024)
cfg.Transport.MaxRetries = 0
cfg.Transport.Retry = &transport.RetryConfig{
MaxRetries: 0,
InitialDelay: time.Millisecond,
}
client, err := NewClient(cfg)
if err != nil {
t.Fatalf("create client: %v", err)
}
progressCalls := 0
var lastCurrent int64
progressCb := func(current, total int64, _ float64) {
progressCalls++
if current < lastCurrent {
t.Errorf("progress went backwards: %d -> %d", lastCurrent, current)
}
lastCurrent = current
}
_, err = client.Upload(context.Background(), &UploadRequest{
URL: server.URL,
Method: "PUT",
FilePath: tmpFile,
ProgressCallback: progressCb,
})
if err != nil {
t.Fatalf("upload: %v", err)
}
// The server must receive the full, unmodified payload. If the rate
// limiter wrapper was silently dropped (e.g. httpReq.Body not updated
// after body was reassigned), the server would still get the data via
// the original reader — so this check alone doesn't expose the bug.
if receivedBody != payload {
t.Errorf("server body mismatch: got %q, want %q", receivedBody, payload)
}
// Progress callback must fire at least once and reach the full payload
// length. The exact call count depends on the read-buffer behaviour of
// net/http (a 43-byte payload may be read in a single Read), but a count
// of zero would prove the progress reader was bypassed — which is the
// exact regression the BACKLOG entry warns about.
if progressCalls < 1 {
t.Errorf("progress callback called %d times, want >= 1 (progress reader must be in the chain)", progressCalls)
}
if lastCurrent != int64(len(payload)) {
t.Errorf("final progress current = %d, want %d", lastCurrent, len(payload))
}
}
// TestRetryAllErrorsRecoversOn200 verifies that a 200 response after a few
// 500s short-circuits the retry loop and produces a successful download —
// another code path that must close every preceding failed response body
// before jumping to okStatus.
func TestRetryAllErrorsRecoversOn200(t *testing.T) {
const finalBody = "ok after retries"
var requestCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&requestCount, 1)
if n < 3 {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("server error"))
return
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(finalBody)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(finalBody))
}))
defer server.Close()
parsedURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse url: %v", err)
}
cfg := DefaultConfig()
cfg.Transport.RetryAllErrors = true
cfg.Transport.MaxRetries = 5
cfg.Transport.Retry = &transport.RetryConfig{
MaxRetries: 5,
InitialDelay: time.Millisecond,
}
client, err := NewClient(cfg)
if err != nil {
t.Fatalf("create client: %v", err)
}
result, err := client.Download(context.Background(), &core.DownloadRequest{
URL: parsedURL,
Writer: &testWriter{},
Headers: map[string]string{},
})
if err != nil {
t.Fatalf("download: %v", err)
}
if result.BytesDownloaded != int64(len(finalBody)) {
t.Errorf("bytes = %d, want %d", result.BytesDownloaded, len(finalBody))
}
if got := atomic.LoadInt32(&requestCount); got != 3 {
t.Errorf("request count = %d, want 3 (2 failures + 1 success)", got)
}
}
// TestTraceTimeTTFBOnReusedConnection is a regression guard for the
// BACKLOG entry "Trace timing dnsStart zero-value gives bogus TTFB on
// reused connections". The previous implementation measured TTFB as
// time.Since(dnsStart); on a pooled HTTP connection the DNSStart
// callback is silent, dnsStart stayed zero, and the result was ~55 years
// instead of a plausible request-time duration. The fix anchors TTFB to
// a requestStart timestamp captured before the trace is attached, so
// the value is meaningful whether or not the connection is reused.
func TestTraceTimeTTFBOnReusedConnection(t *testing.T) {
var connectionCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&connectionCount, 1)
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("hello"))
}))
defer server.Close()
parsedURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("Failed to parse server URL: %v", err)
}
cfg := DefaultConfig()
cfg.Output.TraceTime = true
client, err := NewClient(cfg)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
reqTemplate := func() *core.DownloadRequest {
return &core.DownloadRequest{
URL: parsedURL,
Writer: &testWriter{},
Headers: make(map[string]string),
}
}
// First request opens the connection — DNSStart/ConnectStart/
// TLSHandshakeStart all fire, populating the trace timestamps.
if _, err := client.Download(context.Background(), reqTemplate()); err != nil {
t.Fatalf("first download failed: %v", err)
}
// Second request should be served from the pool. Whether or not
// keep-alive actually reuses the connection in this test depends
// on the transport's MaxIdleConnsPerHost, but the assertion is the
// same: TTFB must be a plausible duration in either case.
result, err := client.Download(context.Background(), reqTemplate())
if err != nil {
t.Fatalf("second download failed: %v", err)
}
if result.TraceTimings == nil {
t.Fatal("TraceTimings is nil despite TraceTime=true")
}
// 1 hour is a generous upper bound that flags the original ~55-year
// regression (time.Since(time.Time{}) on a fresh time.Time) without
// being flaky on a busy CI runner.
if ttfb := result.TraceTimings.TTFB; ttfb > time.Hour {
t.Errorf("TTFB = %v on second request; want a plausible duration (<1h); the zero-dnsStart regression typically produces ~55 years", ttfb)
}
if ttfb := result.TraceTimings.TTFB; ttfb < 0 {
t.Errorf("TTFB = %v; want non-negative", ttfb)
}
}
+323
View File
@@ -0,0 +1,323 @@
//go:build linux || freebsd
// +build linux freebsd
package http
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"codeberg.org/petrbalvin/goget/internal/auth"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/output"
)
func (c *Client) downloadParallel(ctx context.Context, req *core.DownloadRequest, totalSize int64, connections int, resumeChunks map[int]int64, username, password string) (*core.DownloadResult, error) {
startTime := time.Now()
chunks := createChunks(totalSize, connections, c.config.Parallel)
if c.config.Output.Verbose {
fmt.Fprintf(os.Stderr, "[parallel] Split into %d chunks\n", len(chunks))
}
tempDir, err := os.MkdirTemp("", "goget-chunks-*")
if err != nil {
return nil, core.NewFileError("failed to create temp dir", err)
}
defer os.RemoveAll(tempDir)
// completedChunks tracks per-chunk downloaded bytes for resume metadata.
completedChunks := make(map[int]int64)
var completedChunksMu sync.Mutex
for id, n := range resumeChunks {
completedChunks[id] = n
}
progressChan := make(chan int64, len(chunks)*2)
var wg sync.WaitGroup
var firstError error
var errorMu sync.Mutex
for i := range chunks {
chunk := &chunks[i]
wg.Add(1)
go func(chunk *core.ChunkInfo) {
defer wg.Done()
if resumeChunks != nil {
if done, ok := resumeChunks[chunk.ID]; ok && done >= (chunk.End-chunk.Start+1) {
progressChan <- done
chunk.Status = 2
return
}
}
chunkPath := filepath.Join(tempDir, fmt.Sprintf("chunk_%04d", chunk.ID))
err := c.downloadChunk(ctx, req, chunk, chunkPath, progressChan, username, password)
if err != nil {
errorMu.Lock()
if firstError == nil {
firstError = err
}
errorMu.Unlock()
chunk.Status = 3
chunk.Error = err
return
}
// Save per-chunk progress for resume on interruption.
completedChunksMu.Lock()
completedChunks[chunk.ID] = chunk.End - chunk.Start + 1
saveParallelResumeMetadata(req, totalSize, completedChunks)
completedChunksMu.Unlock()
chunk.Status = 2
}(chunk)
}
go func() {
wg.Wait()
close(progressChan)
}()
var totalDownloaded int64
var cancelled bool
aggregationLoop:
for {
select {
case progress, ok := <-progressChan:
if !ok {
break aggregationLoop
}
totalDownloaded += progress
if req.ProgressCallback != nil {
duration := time.Since(startTime)
speed := float64(totalDownloaded) / duration.Seconds()
req.ProgressCallback(totalDownloaded, totalSize, speed)
}
case <-ctx.Done():
cancelled = true
break aggregationLoop
}
}
if cancelled {
// Context was cancelled (SIGINT/SIGTERM). Return the error so
// the caller can show "cancelled" instead of "complete", and
// the progress bar renders the interrupted state. Partial
// chunks are NOT merged — they remain in temp files for a
// potential resume later.
return nil, ctx.Err()
}
if firstError != nil {
return nil, firstError
}
if req.Output != "" && req.Output != "-" {
if err := mergeChunks(req.Output, chunks, tempDir); err != nil {
return nil, core.NewFileError("failed to merge chunks", err)
}
}
if req.Output != "" {
output.DeleteResumeMetadata(req.Output)
}
duration := time.Since(startTime)
speed := float64(0)
if duration.Seconds() > 0 {
speed = float64(totalSize) / duration.Seconds()
}
return &core.DownloadResult{
BytesDownloaded: totalSize,
TotalSize: totalSize,
Duration: duration,
Speed: speed,
Protocol: "HTTP/HTTPS",
IPVersion: 6,
OutputPath: req.Output,
Parallel: true,
ChunksCount: len(chunks),
}, nil
}
// saveParallelResumeMetadata saves per-chunk progress to the resume metadata file.
func saveParallelResumeMetadata(req *core.DownloadRequest, totalSize int64, chunks map[int]int64) {
if req.Output == "" || req.Output == "-" {
return
}
// Build a snapshot of the current chunk progress.
snapshot := make(map[int]int64, len(chunks))
for id, n := range chunks {
snapshot[id] = n
}
meta := &output.ResumeMetadata{
URL: req.URL.String(),
Total: totalSize,
Downloaded: totalChunkBytes(snapshot),
LastWrite: time.Now(),
Version: "1.0",
Chunks: snapshot,
}
_ = meta.Save(req.Output)
}
// totalChunkBytes sums the byte count of all completed chunks.
func totalChunkBytes(chunks map[int]int64) int64 {
var total int64
for _, n := range chunks {
total += n
}
return total
}
func createChunks(totalSize int64, count int, cfg *core.ParallelConfig) []core.ChunkInfo {
if count <= 1 || totalSize <= cfg.MinSize {
return []core.ChunkInfo{
{ID: 0, Start: 0, End: totalSize - 1},
}
}
// Divide the file into `count` equal-sized chunks. The last chunk
// absorbs any remainder. MaxChunkSize is NOT used to further
// subdivide — the user explicitly asked for N parallel connections
// via --parallel, not N×M tiny range requests. Each chunk becomes
// one HTTP Range request handled by one of the N worker goroutines.
chunkSize := totalSize / int64(count)
if chunkSize < cfg.MinChunkSize {
chunkSize = cfg.MinChunkSize
count = int((totalSize + chunkSize - 1) / chunkSize)
}
chunks := make([]core.ChunkInfo, 0, count)
for i := 0; i < count; i++ {
start := int64(i) * chunkSize
end := start + chunkSize - 1
if i == count-1 || end >= totalSize {
end = totalSize - 1
}
chunks = append(chunks, core.ChunkInfo{
ID: i,
Start: start,
End: end,
})
}
return chunks
}
func (c *Client) downloadChunk(ctx context.Context, req *core.DownloadRequest, chunk *core.ChunkInfo, outputPath string, progressChan chan<- int64, username, password string) error {
chunk.Status = 1
httpReq, err := http.NewRequestWithContext(ctx, "GET", req.URL.String(), nil)
if err != nil {
return core.NewNetworkError("failed to create chunk request", err, req.URL.String())
}
httpReq.Header.Set("User-Agent", c.config.UserAgent)
httpReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", chunk.Start, chunk.End))
if req.ResumeInfo != nil {
if req.ResumeInfo.ETag != "" {
httpReq.Header.Set("If-Range", req.ResumeInfo.ETag)
} else if req.ResumeInfo.LastModified != "" {
httpReq.Header.Set("If-Range", req.ResumeInfo.LastModified)
}
}
if username != "" {
auth.BasicAuth(httpReq, username, password)
}
for key, value := range req.Headers {
httpReq.Header.Set(key, value)
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return core.NewNetworkError("failed to fetch chunk", err, req.URL.String())
}
defer resp.Body.Close()
c.updateHSTS(resp)
if resp.StatusCode != http.StatusPartialContent {
return core.NewProtocolError(
fmt.Sprintf("chunk HTTP error: %s", resp.Status),
nil,
req.URL.String(),
)
}
file, err := os.Create(outputPath)
if err != nil {
return core.NewFileError("failed to create chunk file", err)
}
defer file.Close()
reader := io.Reader(resp.Body)
if c.config.RateLimiter != nil {
reader = c.config.RateLimiter.WrapReader(reader)
}
buf := make([]byte, c.config.Transport.BufferSize)
var downloaded int64
for {
if ctx.Err() != nil {
return ctx.Err()
}
n, err := reader.Read(buf)
if n > 0 {
_, wErr := file.Write(buf[:n])
if wErr != nil {
return core.NewFileError("failed to write chunk", wErr)
}
downloaded += int64(n)
if progressChan != nil {
progressChan <- int64(n)
}
}
if err == io.EOF {
break
}
if err != nil {
return core.NewNetworkError("failed to read chunk", err, req.URL.String())
}
}
chunk.Downloaded = downloaded
return nil
}
func mergeChunks(outputPath string, chunks []core.ChunkInfo, tempDir string) error {
outFile, err := os.Create(outputPath)
if err != nil {
return err
}
defer outFile.Close()
for _, chunk := range chunks {
chunkPath := filepath.Join(tempDir, fmt.Sprintf("chunk_%04d", chunk.ID))
chunkFile, err := os.Open(chunkPath)
if err != nil {
return fmt.Errorf("failed to open chunk %d: %w", chunk.ID, err)
}
_, err = io.Copy(outFile, chunkFile)
chunkFile.Close()
if err != nil {
return fmt.Errorf("failed to copy chunk %d: %w", chunk.ID, err)
}
}
return nil
}
+379
View File
@@ -0,0 +1,379 @@
//go:build linux || freebsd
// +build linux freebsd
package http
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"codeberg.org/petrbalvin/goget/internal/auth"
format "codeberg.org/petrbalvin/goget/internal/format"
)
// UploadRequest represents an upload request
type UploadRequest struct {
// URL to upload to
URL string
// Method: PUT or POST
Method string
// Path to the file for upload
FilePath string
// Form data fields (for POST)
FormData map[string]string
// Files for upload (for POST multipart)
Files []UploadFile
// Custom headers
Headers map[string]string
// Timeout
Timeout time.Duration
// Callback pro progress
ProgressCallback func(current, total int64, speed float64)
}
// UploadFile represents a file for multipart upload
type UploadFile struct {
// Field name in the form data
FieldName string
// Path to the file
FilePath string
// Custom file name (optional)
FileName string
// Content-Type (optional)
ContentType string
}
// UploadResult is the result of an upload
type UploadResult struct {
StatusCode int
ContentLength int64
Duration time.Duration
BytesUploaded int64
Speed float64
ResponseBody string
}
// Upload uploads a file using PUT or POST
func (c *Client) Upload(ctx context.Context, req *UploadRequest) (*UploadResult, error) {
startTime := time.Now()
if req.Method == "" {
req.Method = "PUT"
}
// Create request body
var body io.Reader
var contentType string
var totalSize int64
if req.Method == "PUT" {
// PUT upload - direct file
if req.FilePath == "" {
return nil, fmt.Errorf("put upload requires --upload-file")
}
fileInfo, err := os.Stat(req.FilePath)
if err != nil {
return nil, fmt.Errorf("failed to stat file: %w", err)
}
totalSize = fileInfo.Size()
file, err := os.Open(req.FilePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
body = file
contentType = "application/octet-stream"
} else if req.Method == "POST" {
// POST upload - form data or multipart
if len(req.Files) > 0 {
// Multipart form data
var bodyBuffer bytes.Buffer
writer := multipart.NewWriter(&bodyBuffer)
// Add regular form fields
for key, value := range req.FormData {
if err := writer.WriteField(key, value); err != nil {
return nil, fmt.Errorf("failed to write form field: %w", err)
}
}
// Add files
for _, uf := range req.Files {
file, err := os.Open(uf.FilePath)
if err != nil {
return nil, fmt.Errorf("failed to open file %s: %w", uf.FilePath, err)
}
fileName := uf.FileName
if fileName == "" {
fileName = filepath.Base(uf.FilePath)
}
contentType := uf.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="%s"; filename="%s"`, uf.FieldName, fileName))
h.Set("Content-Type", contentType)
part, err := writer.CreatePart(h)
if err != nil {
file.Close()
return nil, fmt.Errorf("failed to create form part: %w", err)
}
fileInfo, _ := os.Stat(uf.FilePath)
totalSize += fileInfo.Size()
_, err = io.Copy(part, file)
file.Close()
if err != nil {
return nil, fmt.Errorf("failed to write file to form: %w", err)
}
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
}
body = &bodyBuffer
contentType = writer.FormDataContentType()
} else if len(req.FormData) > 0 {
// URL-encoded form data
form := url.Values{}
for key, value := range req.FormData {
form.Set(key, value)
}
body = strings.NewReader(form.Encode())
contentType = "application/x-www-form-urlencoded"
totalSize = int64(len(form.Encode()))
} else if req.FilePath != "" {
// POST with raw file body
fileInfo, err := os.Stat(req.FilePath)
if err != nil {
return nil, fmt.Errorf("failed to stat file: %w", err)
}
totalSize = fileInfo.Size()
file, err := os.Open(req.FilePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
body = file
contentType = "application/octet-stream"
}
}
if body == nil {
return nil, fmt.Errorf("no upload data provided")
}
// Create HTTP request
httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set headers
httpReq.Header.Set("Content-Type", contentType)
httpReq.Header.Set("User-Agent", c.config.UserAgent)
for key, value := range req.Headers {
httpReq.Header.Set(key, value)
}
// Apply authentication
if c.config.Auth.Username != "" {
authType := c.config.Auth.AuthType
if authType == "" {
authType = "auto"
}
if authType == "auto" || authType == "basic" {
// Try basic auth first
applyBasicAuth(httpReq, c.config.Auth.Username, c.config.Auth.Password)
}
}
// Apply OAuth token if configured
if c.config.Auth.OAuth != nil && c.config.Auth.OAuth.AccessToken != "" {
oauthClient := auth.NewOAuthClient(c.config.Auth.OAuth)
if oauthClient.IsTokenValid() {
oauthClient.ApplyToken(httpReq)
}
}
// Execute request
if c.config.RateLimiter != nil {
// Wrap body with rate limiter for upload
body = c.config.RateLimiter.WrapReader(body)
httpReq.Body = io.NopCloser(body)
}
// Track progress
if req.ProgressCallback != nil && totalSize > 0 {
body = &progressReader{
r: body,
callback: req.ProgressCallback,
total: totalSize,
start: startTime,
offset: 0,
}
httpReq.Body = io.NopCloser(body)
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("upload request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
duration := time.Since(startTime)
return &UploadResult{
StatusCode: resp.StatusCode,
ContentLength: resp.ContentLength,
Duration: duration,
BytesUploaded: totalSize,
Speed: float64(totalSize) / duration.Seconds(),
ResponseBody: string(respBody),
}, nil
}
// UploadSimple performs a simple file upload with progress tracking
func (c *Client) UploadSimple(ctx context.Context, method, targetURL, filePath string, verbose bool) (*UploadResult, error) {
fileInfo, err := os.Stat(filePath)
if err != nil {
return nil, fmt.Errorf("failed to stat file: %w", err)
}
totalSize := fileInfo.Size()
_ = totalSize // Used in progress callback
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
var progressCallback func(int64, int64, float64)
if verbose {
progressCallback = func(current, total int64, speed float64) {
percent := float64(current) / float64(total) * 100
fmt.Fprintf(os.Stderr, "\r[upload] %s/%s (%.1f%%) %s/s",
format.Bytes(current),
format.Bytes(total),
percent,
format.Speed(speed))
}
}
req := &UploadRequest{
URL: targetURL,
Method: method,
FilePath: filePath,
Timeout: c.config.Transport.Timeout,
ProgressCallback: progressCallback,
Headers: make(map[string]string),
}
// Copy headers from client config
for k, v := range c.config.Headers {
req.Headers[k] = v
}
result, err := c.Upload(ctx, req)
if err != nil {
return nil, err
}
if verbose {
fmt.Fprintf(os.Stderr, "\n")
}
return result, nil
}
// UploadMultipart performs a multipart POST upload with files
func (c *Client) UploadMultipart(ctx context.Context, targetURL string, files []UploadFile, formData map[string]string, verbose bool) (*UploadResult, error) {
var progressCallback func(int64, int64, float64)
if verbose {
progressCallback = func(current, total int64, speed float64) {
percent := float64(current) / float64(total) * 100
fmt.Fprintf(os.Stderr, "\r[upload] %s/%s (%.1f%%) %s/s",
format.Bytes(current),
format.Bytes(total),
percent,
format.Speed(speed))
}
}
req := &UploadRequest{
URL: targetURL,
Method: "POST",
Files: files,
FormData: formData,
Timeout: c.config.Transport.Timeout,
ProgressCallback: progressCallback,
Headers: make(map[string]string),
}
for k, v := range c.config.Headers {
req.Headers[k] = v
}
result, err := c.Upload(ctx, req)
if err != nil {
return nil, err
}
if verbose {
fmt.Fprintf(os.Stderr, "\n")
}
return result, nil
}
// applyBasicAuth applies HTTP Basic Authentication to request
func applyBasicAuth(req *http.Request, username, password string) {
req.SetBasicAuth(username, password)
}
+151
View File
@@ -0,0 +1,151 @@
//go:build linux || freebsd
// +build linux freebsd
package protocol
import (
"fmt"
"net/url"
"codeberg.org/petrbalvin/goget/internal/core"
)
// ProtocolInfo contains metadata about a protocol
type ProtocolInfo struct {
// Protocol name
Name string
// URL scheme
Scheme string
// Protocol version
Version string
// Supported operations
Operations []string
// Supported features
Features []string
// Default port
DefaultPort int
}
// BaseProtocol is the base implementation for all protocols
type BaseProtocol struct {
info ProtocolInfo
}
// NewBaseProtocol creates a new base protocol
func NewBaseProtocol(info ProtocolInfo) *BaseProtocol {
return &BaseProtocol{info: info}
}
// Scheme returns the protocol scheme
func (bp *BaseProtocol) Scheme() string {
return bp.info.Scheme
}
// Name returns the protocol name
func (bp *BaseProtocol) Name() string {
return bp.info.Name
}
// Capabilities returns the list of supported features
func (bp *BaseProtocol) Capabilities() []string {
return bp.info.Features
}
// SupportsResume returns whether the protocol supports resume
func (bp *BaseProtocol) SupportsResume() bool {
return contains(bp.info.Features, "resume")
}
// SupportsCompression returns whether the protocol supports compression
func (bp *BaseProtocol) SupportsCompression() bool {
return contains(bp.info.Features, "compression")
}
// SupportsUpload returns whether the protocol supports upload
func (bp *BaseProtocol) SupportsUpload() bool {
return contains(bp.info.Features, "upload")
}
// SupportsRange returns whether the protocol supports range requests
func (bp *BaseProtocol) SupportsRange() bool {
return contains(bp.info.Features, "range")
}
// CanHandle checks if this protocol can handle the given URL.
func (bp *BaseProtocol) CanHandle(url *url.URL) bool {
if url == nil {
return false
}
return url.Scheme == bp.info.Scheme
}
// SupportsParallel returns whether the protocol supports parallel downloads.
func (bp *BaseProtocol) SupportsParallel() bool {
return contains(bp.info.Features, "parallel")
}
// SupportsRecursive returns whether the protocol supports recursive downloads.
func (bp *BaseProtocol) SupportsRecursive() bool {
return contains(bp.info.Features, "recursive")
}
// contains is a helper function
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// ValidateURL validates a URL for the given protocol
func ValidateURL(u *url.URL, scheme string) error {
if u == nil {
return core.NewProtocolError("URL is nil", nil, "")
}
if u.Scheme != scheme {
return core.NewProtocolError(
fmt.Sprintf("expected scheme %s, got %s", scheme, u.Scheme),
nil,
u.String(),
)
}
if u.Host == "" {
return core.NewProtocolError("missing host in URL", nil, u.String())
}
return nil
}
// GetDefaultPort returns the default port for a scheme
func GetDefaultPort(scheme string) int {
switch scheme {
case "http":
return 80
case "https", "webdavs":
return 443
case "webdav":
// Plain WebDAV (no TLS) defaults to 80, matching HTTP.
// Previously this returned 443 — a leftover from bundling
// "webdav" with the TLS-bearing schemes — which made
// non-TLS WebDAV connections fail against servers not
// listening on 443.
return 80
case "ftp":
return 21
case "ftps":
return 990
case "sftp":
return 22
default:
return 0
}
}
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
//go:build linux || freebsd
// +build linux freebsd
// Package register provides a single registration point for all built-in goget protocols.
// Both CLI (cmd/goget) and library API (pkg/api) call RegisterAll to avoid
// duplicating the protocol registration list across packages.
package register
import (
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/protocol"
"codeberg.org/petrbalvin/goget/internal/protocol/dataurl"
"codeberg.org/petrbalvin/goget/internal/protocol/file"
"codeberg.org/petrbalvin/goget/internal/protocol/ftp"
"codeberg.org/petrbalvin/goget/internal/protocol/gemini"
"codeberg.org/petrbalvin/goget/internal/protocol/gopher"
"codeberg.org/petrbalvin/goget/internal/protocol/http"
"codeberg.org/petrbalvin/goget/internal/protocol/sftp"
"codeberg.org/petrbalvin/goget/internal/protocol/webdav"
)
// RegisterAll registers all built-in protocols on the given registry.
// Protocols that are already registered are silently skipped, making this
// safe to call from both CLI and API initialization.
// Returns the HTTP protocol handler for further configuration (e.g., HSTS).
func RegisterAll(r *protocol.Registry) (*http.Client, error) {
registerIfMissing := func(p core.Protocol) {
if r.Supports(p.Scheme()) {
return
}
_ = r.Register(p)
}
httpClient, err := http.NewClient(http.DefaultConfig())
if err != nil {
return nil, err
}
registerIfMissing(httpClient)
registerIfMissing(ftp.NewProtocol())
registerIfMissing(file.NewProtocol())
registerIfMissing(dataurl.NewProtocol())
registerIfMissing(gopher.NewProtocol())
registerIfMissing(gemini.NewProtocol())
registerIfMissing(sftp.NewProtocol())
registerIfMissing(webdav.NewProtocol())
return httpClient, nil
}
+132
View File
@@ -0,0 +1,132 @@
//go:build linux || freebsd
// +build linux freebsd
package protocol
import (
"fmt"
"net/url"
"sort"
"strings"
"sync"
"codeberg.org/petrbalvin/goget/internal/core"
)
// Registry manages all registered protocols
type Registry struct {
mu sync.RWMutex
protocols map[string]core.Protocol
}
// NewRegistry creates a new registry
func NewRegistry() *Registry {
return &Registry{
protocols: make(map[string]core.Protocol),
}
}
// Register registers a protocol under its primary scheme
func (r *Registry) Register(p core.Protocol) error {
r.mu.Lock()
defer r.mu.Unlock()
scheme := p.Scheme()
if scheme == "" {
return fmt.Errorf("protocol scheme cannot be empty")
}
if _, exists := r.protocols[scheme]; exists {
return fmt.Errorf("protocol already registered: %s", scheme)
}
r.protocols[scheme] = p
return nil
}
// normalizeScheme normalizes a scheme for lookup (https -> http, webdavs -> webdav, etc.)
func normalizeScheme(scheme string) string {
scheme = strings.ToLower(scheme)
switch scheme {
case "https":
return "http"
case "webdavs":
return "webdav"
case "ftps":
return "ftp"
default:
return scheme
}
}
// Get gets a protocol for a given URL with scheme normalization
func (r *Registry) Get(u *url.URL) (core.Protocol, error) {
r.mu.RLock()
defer r.mu.RUnlock()
if u == nil {
return nil, fmt.Errorf("url cannot be nil")
}
scheme := normalizeScheme(u.Scheme)
p, exists := r.protocols[scheme]
if !exists {
return nil, fmt.Errorf("unsupported protocol: %s", u.Scheme)
}
// Additional validation via CanHandle
if !p.CanHandle(u) {
return nil, fmt.Errorf("protocol %s cannot handle url: %s", scheme, u.String())
}
return p, nil
}
// List returns a list of all registered protocols
func (r *Registry) List() []string {
r.mu.RLock()
defer r.mu.RUnlock()
schemes := make([]string, 0, len(r.protocols))
for scheme := range r.protocols {
schemes = append(schemes, scheme)
}
sort.Strings(schemes)
return schemes
}
// Supports determines whether a protocol is supported (with normalization)
func (r *Registry) Supports(scheme string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, exists := r.protocols[normalizeScheme(scheme)]
return exists
}
// Capabilities returns the capabilities of all protocols
func (r *Registry) Capabilities() map[string][]string {
r.mu.RLock()
defer r.mu.RUnlock()
caps := make(map[string][]string)
for scheme, p := range r.protocols {
caps[scheme] = p.Capabilities()
}
return caps
}
// GlobalRegistry is the global registry instance
var GlobalRegistry = NewRegistry()
// Register is a helper for registering in the global registry
func Register(p core.Protocol) error {
return GlobalRegistry.Register(p)
}
// Get is a helper for getting a protocol from the global registry
func Get(u *url.URL) (core.Protocol, error) {
return GlobalRegistry.Get(u)
}
+33
View File
@@ -0,0 +1,33 @@
//go:build linux || freebsd
// +build linux freebsd
package sftp
import (
"fmt"
"os"
"path/filepath"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
func hostKeyCallback(knownHostsPath string, insecure bool) (ssh.HostKeyCallback, error) {
if insecure {
return ssh.InsecureIgnoreHostKey(), nil
}
if knownHostsPath == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("known_hosts: %w", err)
}
knownHostsPath = filepath.Join(home, ".ssh", "known_hosts")
}
if _, err := os.Stat(knownHostsPath); err != nil {
return nil, fmt.Errorf("known_hosts file not found: %s (use --ssh-insecure to skip host key check)", knownHostsPath)
}
return knownhosts.New(knownHostsPath)
}
+25
View File
@@ -0,0 +1,25 @@
//go:build linux || freebsd
// +build linux freebsd
package sftp
import (
"testing"
)
func TestHostKeyCallbackInsecure(t *testing.T) {
cb, err := hostKeyCallback("", true)
if err != nil {
t.Fatalf("hostKeyCallback: %v", err)
}
if cb == nil {
t.Fatal("expected callback")
}
}
func TestHostKeyCallbackMissingFile(t *testing.T) {
_, err := hostKeyCallback("/nonexistent/known_hosts", false)
if err == nil {
t.Fatal("expected error for missing known_hosts")
}
}
@@ -0,0 +1,54 @@
//go:build linux || freebsd
// +build linux freebsd
package sftp
import (
"net/url"
"path/filepath"
"testing"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/output"
)
// TestSaveSFTPResume verifies the partial-progress sidecar for the
// SFTP single-file path. The SFTP parallel workers don't read attr.Size
// in the download loop, so Total is recorded as 0 (only Downloaded
// is known reliably). A future enhancement could plumb totalSize
// through sftpDownloadFile if more accurate accounting is needed.
func TestSaveSFTPResume(t *testing.T) {
tmp := t.TempDir()
out := filepath.Join(tmp, "file.bin")
u, err := url.Parse("sftp://user@host:22/file.bin")
if err != nil {
t.Fatalf("parse URL: %v", err)
}
req := &core.DownloadRequest{
URL: u,
Output: out,
}
saveSFTPResume(req, 12345)
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 != 12345 {
t.Errorf("Downloaded = %d, want 12345", meta.Downloaded)
}
if meta.URL != "sftp://user@host:22/file.bin" {
t.Errorf("URL = %q", meta.URL)
}
// Total is 0 because the parallel worker doesn't read attr.Size;
// the resume code path uses this as "size unknown" and works
// regardless of Total.
if meta.Total != 0 {
t.Errorf("Total = %d, want 0 (size unknown for SFTP parallel workers)", meta.Total)
}
}
File diff suppressed because it is too large Load Diff
+623
View File
@@ -0,0 +1,623 @@
//go:build linux || freebsd
// +build linux freebsd
package sftp
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"encoding/binary"
"fmt"
"io"
"net"
"net/url"
"os"
"path/filepath"
"testing"
"codeberg.org/petrbalvin/goget/internal/core"
"golang.org/x/crypto/ssh"
)
// startTestServer starts a minimal SSH+SFTP server for testing.
func startTestServer(t *testing.T) (addr string, cleanup func()) {
t.Helper()
config := &ssh.ServerConfig{
NoClientAuth: true,
}
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
signer, err := ssh.NewSignerFromKey(privateKey)
if err != nil {
t.Fatalf("new signer: %v", err)
}
config.AddHostKey(signer)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
done := make(chan struct{})
go func() {
defer close(done)
// Accept connections in a loop so the parallel-download tests
// (which open N SFTP sessions) can run. Each connection is
// served in its own goroutine, so the N pool connections can be
// alive concurrently as long as the test goroutine keeps
// accepting.
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func(conn net.Conn) {
defer conn.Close()
sshConn, chans, reqs, err := ssh.NewServerConn(conn, config)
if err != nil {
return
}
defer sshConn.Close()
go ssh.DiscardRequests(reqs)
for newChannel := range chans {
if newChannel.ChannelType() != "session" {
newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
continue
}
channel, requests, err := newChannel.Accept()
if err != nil {
return
}
go func(ch ssh.Channel, reqs <-chan *ssh.Request) {
for req := range reqs {
ok := false
if req.Type == "subsystem" && len(req.Payload) > 4 {
l := binary.BigEndian.Uint32(req.Payload[:4])
name := string(req.Payload[4 : 4+l])
if name == "sftp" {
ok = true
if req.WantReply {
req.Reply(ok, nil)
}
handleSFTPSession(ch)
return
}
}
if req.WantReply {
req.Reply(ok, nil)
}
}
ch.Close()
}(channel, requests)
}
}(conn)
}
}()
return listener.Addr().String(), func() {
listener.Close()
<-done
}
}
// handleSFTPSession handles a minimal SFTP v3 session.
func handleSFTPSession(ch ssh.Channel) {
defer ch.Close()
// Expect INIT
pkt, err := readTestPacket(ch)
if err != nil || len(pkt) < 5 || pkt[0] != sshFxpInit {
return
}
// Respond VERSION
var resp bytes.Buffer
resp.WriteByte(sshFxpVersion)
writeUint32(&resp, 3)
writeTestPacket(ch, resp.Bytes())
handles := make(map[string]string)
dirHandles := make(map[string][]string)
dirHandlePaths := make(map[string]string)
handleCounter := 0
for {
pkt, err := readTestPacket(ch)
if err != nil {
return
}
if len(pkt) < 1 {
continue
}
reqID, _ := readUint32(pkt, 1)
switch pkt[0] {
case sshFxpRealpath:
path, _ := readString(pkt, 5)
if path == "." {
path = "/home/test"
}
var r bytes.Buffer
r.WriteByte(sshFxpName)
writeUint32(&r, reqID)
writeUint32(&r, 1)
writeString(&r, path)
writeString(&r, path)
writeUint32(&r, 0) // attrs
writeTestPacket(ch, r.Bytes())
case sshFxpStat:
path, _ := readString(pkt, 5)
var r bytes.Buffer
r.WriteByte(sshFxpAttrs)
writeUint32(&r, reqID)
fs := testFSLookup(path)
if fs != nil && fs.isDir {
// mode flag only
writeUint32(&r, 0x4)
writeUint32(&r, 0x4000) // S_IFDIR
writeTestPacket(ch, r.Bytes())
continue
}
if fs != nil && !fs.isDir {
// size flag only
writeUint32(&r, 0x1)
writeUint64(&r, uint64(len(fs.content)))
writeTestPacket(ch, r.Bytes())
continue
}
// legacy single-file mock behaviour
writeUint32(&r, 0x1) // size flag
size := uint64(len(testFileContent))
if testOverrideAttrSize != nil {
size = *testOverrideAttrSize
}
writeUint64(&r, size)
writeTestPacket(ch, r.Bytes())
case sshFxpOpendir:
path, _ := readString(pkt, 5)
fs := testFSLookup(path)
if fs == nil || !fs.isDir {
status(ch, reqID, sshFxNoSuchFile, "no such directory")
continue
}
handleCounter++
h := fmt.Sprintf("dhandle%d", handleCounter)
dirHandles[h] = fs.children
dirHandlePaths[h] = path
var r bytes.Buffer
r.WriteByte(sshFxpHandle)
writeUint32(&r, reqID)
writeString(&r, h)
writeTestPacket(ch, r.Bytes())
case sshFxpReaddir:
handle, _ := readString(pkt, 5)
children, ok := dirHandles[handle]
if !ok {
status(ch, reqID, sshFxFailure, "no such dir handle")
continue
}
parentPath := dirHandlePaths[handle]
var r bytes.Buffer
r.WriteByte(sshFxpName)
writeUint32(&r, reqID)
writeUint32(&r, uint32(len(children)))
for _, name := range children {
writeString(&r, name)
writeString(&r, name)
childPath := parentPath + "/" + name
childFS := testFSLookup(childPath)
isDir := childFS != nil && childFS.isDir
// attrs: size flag (0x1) and mode flag (0x4) so the
// client can both classify the entry (dir vs file) and
// read the file size for parallel accounting.
if isDir {
writeUint32(&r, 0x4)
writeUint32(&r, 0x4000) // S_IFDIR
} else {
writeUint32(&r, 0x5) // size + mode
var sz uint64
if childFS != nil {
sz = uint64(len(childFS.content))
} else {
sz = uint64(len(testFileContent))
}
writeUint64(&r, sz)
writeUint32(&r, 0x8000) // S_IFREG
}
}
writeTestPacket(ch, r.Bytes())
case sshFxpOpen:
path, off := readString(pkt, 5)
pflags, _ := readUint32(pkt, off)
handleCounter++
h := fmt.Sprintf("handle%d", handleCounter)
handles[h] = path
var r bytes.Buffer
if pflags&sshFxfRead != 0 {
r.WriteByte(sshFxpHandle)
writeUint32(&r, reqID)
writeString(&r, h)
writeTestPacket(ch, r.Bytes())
} else if pflags&sshFxfWrite != 0 {
r.WriteByte(sshFxpHandle)
writeUint32(&r, reqID)
writeString(&r, h)
writeTestPacket(ch, r.Bytes())
} else {
status(ch, reqID, sshFxFailure, "unsupported flags")
}
case sshFxpRead:
handle, off := readString(pkt, 5)
offset, off := readUint64(pkt, off)
length, _ := readUint32(pkt, off)
_ = length
path := handles[handle]
if path == "" {
status(ch, reqID, sshFxNoSuchFile, "no such handle")
continue
}
var data []byte
if fs := testFSLookup(path); fs != nil && !fs.isDir {
data = fs.content
} else {
data = testFileContent
}
if int(offset) >= len(data) {
status(ch, reqID, sshFxEOF, "")
continue
}
end := int(offset) + int(length)
if end > len(data) {
end = len(data)
}
chunk := data[offset:end]
var r bytes.Buffer
r.WriteByte(sshFxpData)
writeUint32(&r, reqID)
writeUint32(&r, uint32(len(chunk)))
r.Write(chunk)
writeTestPacket(ch, r.Bytes())
case sshFxpClose:
handle, _ := readString(pkt, 5)
delete(handles, handle)
delete(dirHandles, handle)
var r bytes.Buffer
r.WriteByte(sshFxpStatus)
writeUint32(&r, reqID)
writeUint32(&r, sshFxOk)
writeString(&r, "")
writeString(&r, "")
writeTestPacket(ch, r.Bytes())
default:
status(ch, reqID, sshFxOpUnsupported, "unsupported")
}
}
}
// testFSLookup is a tiny helper that hides the nil-map check so the
// mock switch arms above can stay readable.
func testFSLookup(path string) *testFSEntry {
if testFS == nil {
return nil
}
return testFS[path]
}
func status(ch ssh.Channel, reqID, code uint32, msg string) {
var r bytes.Buffer
r.WriteByte(sshFxpStatus)
writeUint32(&r, reqID)
writeUint32(&r, code)
writeString(&r, msg)
writeString(&r, "")
writeTestPacket(ch, r.Bytes())
}
func readTestPacket(r io.Reader) ([]byte, error) {
var lenBuf [4]byte
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
return nil, err
}
length := binary.BigEndian.Uint32(lenBuf[:])
if length > 256*1024 {
return nil, fmt.Errorf("packet too large")
}
pkt := make([]byte, length)
if _, err := io.ReadFull(r, pkt); err != nil {
return nil, err
}
return pkt, nil
}
func writeTestPacket(w io.Writer, pkt []byte) error {
var lenBuf [4]byte
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(pkt)))
if _, err := w.Write(lenBuf[:]); err != nil {
return err
}
_, err := w.Write(pkt)
return err
}
var testFileContent = []byte("hello sftp world")
// testOverrideAttrSize, when non-nil, replaces the value reported by the
// mock server's sshFxpStat handler. Tests use it to simulate a server
// that returns a wrong (typically too large) size attribute so we can
// verify the client does not blindly trust attr.Size for
// BytesDownloaded accounting.
var testOverrideAttrSize *uint64
// testFSEntry describes a single in-memory file or directory used by
// the recursive-download tests. isDir==true entries carry a children
// slice; isDir==false entries carry the file content.
type testFSEntry struct {
isDir bool
content []byte
children []string
}
// testFS is an optional in-memory filesystem consulted by the mock
// server before falling back to the default testFileContent behaviour.
// Tests that exercise recursive downloads set it up and clear it via
// t.Cleanup. Keys are absolute paths; paths NOT present in the map
// keep the legacy single-file mock semantics so existing
// non-recursive tests continue to pass.
var testFS map[string]*testFSEntry
func TestDownload(t *testing.T) {
addr, cleanup := startTestServer(t)
defer cleanup()
u, _ := url.Parse("sftp://user@" + addr + "/testfile")
outDir := t.TempDir()
outputPath := filepath.Join(outDir, "out")
req := &core.DownloadRequest{
URL: u,
Output: outputPath,
Ctx: context.Background(),
SSHInsecure: true,
}
result, err := download(context.Background(), req)
if err != nil {
t.Fatalf("download failed: %v", err)
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read output: %v", err)
}
if !bytes.Equal(data, testFileContent) {
t.Errorf("got %q, want %q", data, testFileContent)
}
if result.BytesDownloaded != int64(len(testFileContent)) {
t.Errorf("bytes=%d, want %d", result.BytesDownloaded, len(testFileContent))
}
if result.Protocol != "sftp" {
t.Errorf("protocol=%q, want sftp", result.Protocol)
}
}
// TestDownloadWithStaleAttrSize is a regression guard for the BACKLOG
// entry "SFTP BytesDownloaded unconditionally overwritten". The previous
// implementation set result.BytesDownloaded = int64(attr.Size) when
// attr.Size > 0, ignoring the locally-tracked `total` counter. If the
// server reported a stale (typically too large) size, the result would
// over-report the bytes actually transferred — which is exactly what
// this test exercises.
func TestDownloadWithStaleAttrSize(t *testing.T) {
// Simulate a server that reports 10 KiB even though the file is
// only len(testFileContent) bytes long. After the fix, the result
// must reflect the actual bytes received, not the stale attribute.
wrong := uint64(10 * 1024)
testOverrideAttrSize = &wrong
t.Cleanup(func() { testOverrideAttrSize = nil })
addr, cleanup := startTestServer(t)
defer cleanup()
u, _ := url.Parse("sftp://user@" + addr + "/testfile")
outDir := t.TempDir()
outputPath := filepath.Join(outDir, "out")
req := &core.DownloadRequest{
URL: u,
Output: outputPath,
Ctx: context.Background(),
SSHInsecure: true,
}
result, err := download(context.Background(), req)
if err != nil {
t.Fatalf("download failed: %v", err)
}
// The downloaded file must still hold the real (small) payload.
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read output: %v", err)
}
if !bytes.Equal(data, testFileContent) {
t.Errorf("got %q, want %q", data, testFileContent)
}
// And BytesDownloaded must reflect the actual bytes received, not
// the stale attr.Size the server returned.
if result.BytesDownloaded != int64(len(testFileContent)) {
t.Errorf("BytesDownloaded = %d, want %d (real payload size, not stale attr.Size=%d)",
result.BytesDownloaded, len(testFileContent), wrong)
}
}
func TestParseHeader(t *testing.T) {
var buf bytes.Buffer
writeUint32(&buf, 0x05) // size + mode flags
writeUint64(&buf, 60)
writeUint32(&buf, 0644)
attr, off := readAttrs(buf.Bytes(), 0)
if attr.Size != 60 {
t.Errorf("size=%d, want 60", attr.Size)
}
if off != 16 {
t.Errorf("off=%d, want 16", off)
}
}
func TestIsEOF(t *testing.T) {
if !isEOF(fmt.Errorf("sftp status %d", sshFxEOF)) {
t.Error("expected true")
}
if isEOF(fmt.Errorf("sftp status %d", sshFxFailure)) {
t.Error("expected false")
}
}
func TestDialTimeout(t *testing.T) {
u, _ := url.Parse("sftp://user@127.0.0.1:1/")
_, err := dial(context.Background(), u, "", true)
if err == nil {
t.Fatal("expected error")
}
}
func TestSFTPRecursiveDownloadParallel(t *testing.T) {
addr, cleanup := startTestServer(t)
defer cleanup()
setupRecursiveFS := func(t *testing.T) {
t.Helper()
testFS = map[string]*testFSEntry{
"/home/test/dir": {isDir: true, children: []string{"a.txt", "b.txt", "c.txt", "sub"}},
"/home/test/dir/a.txt": {isDir: false, content: []byte("alpha")},
"/home/test/dir/b.txt": {isDir: false, content: []byte("bravo-bravo")},
"/home/test/dir/c.txt": {isDir: false, content: []byte("charlie")},
"/home/test/dir/sub": {isDir: true, children: []string{"x.txt", "y.txt"}},
"/home/test/dir/sub/x.txt": {isDir: false, content: []byte("x-ray")},
"/home/test/dir/sub/y.txt": {isDir: false, content: []byte("yankee")},
}
t.Cleanup(func() { testFS = nil })
}
t.Run("parallel=3 downloads all files", func(t *testing.T) {
setupRecursiveFS(t)
u, _ := url.Parse("sftp://user@" + addr + "/home/test/dir")
outDir := t.TempDir()
req := &core.DownloadRequest{
URL: u,
Output: outDir,
Recursive: true,
RecursiveParallel: 3,
SSHInsecure: true,
}
result, err := download(context.Background(), req)
if err != nil {
t.Fatalf("recursive parallel download failed: %v", err)
}
if result.ChunksCount != 5 {
t.Errorf("ChunksCount = %d, want 5 (3 top-level files + 2 in subdir)", result.ChunksCount)
}
// 5 + 11 + 7 + 5 + 6 = 34 bytes total content
const wantBytes = int64(5 + 11 + 7 + 5 + 6)
if result.BytesDownloaded != wantBytes {
t.Errorf("BytesDownloaded = %d, want %d", result.BytesDownloaded, wantBytes)
}
// Verify all 5 files actually exist with correct content.
wantFiles := map[string]string{
"a.txt": "alpha",
"b.txt": "bravo-bravo",
"c.txt": "charlie",
"sub/x.txt": "x-ray",
"sub/y.txt": "yankee",
}
for name, want := range wantFiles {
got, err := os.ReadFile(filepath.Join(outDir, name))
if err != nil {
t.Errorf("file %s: %v", name, err)
continue
}
if string(got) != want {
t.Errorf("file %s = %q, want %q", name, string(got), want)
}
}
})
t.Run("parallel=0 falls back to sequential", func(t *testing.T) {
setupRecursiveFS(t)
u, _ := url.Parse("sftp://user@" + addr + "/home/test/dir")
outDir := t.TempDir()
req := &core.DownloadRequest{
URL: u,
Output: outDir,
Recursive: true,
RecursiveParallel: 0,
SSHInsecure: true,
}
result, err := download(context.Background(), req)
if err != nil {
t.Fatalf("recursive sequential download failed: %v", err)
}
if result.ChunksCount != 5 {
t.Errorf("ChunksCount = %d, want 5", result.ChunksCount)
}
})
t.Run("parallel=1 falls back to sequential", func(t *testing.T) {
setupRecursiveFS(t)
u, _ := url.Parse("sftp://user@" + addr + "/home/test/dir")
outDir := t.TempDir()
req := &core.DownloadRequest{
URL: u,
Output: outDir,
Recursive: true,
RecursiveParallel: 1,
SSHInsecure: true,
}
result, err := download(context.Background(), req)
if err != nil {
t.Fatalf("download with parallel=1 failed: %v", err)
}
if result.ChunksCount != 5 {
t.Errorf("ChunksCount = %d, want 5", result.ChunksCount)
}
})
t.Run("subdir propagation with parallel", func(t *testing.T) {
setupRecursiveFS(t)
u, _ := url.Parse("sftp://user@" + addr + "/home/test/dir")
outDir := t.TempDir()
req := &core.DownloadRequest{
URL: u,
Output: outDir,
Recursive: true,
RecursiveParallel: 4,
SSHInsecure: true,
}
_, err := download(context.Background(), req)
if err != nil {
t.Fatalf("download failed: %v", err)
}
// Both subdir files should be present and correct.
for _, name := range []string{"sub/x.txt", "sub/y.txt"} {
path := filepath.Join(outDir, name)
if _, err := os.Stat(path); err != nil {
t.Errorf("expected file %s to exist: %v", name, err)
}
}
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
//go:build linux || freebsd
// +build linux freebsd
package webdav
import (
"net/url"
"path/filepath"
"testing"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/output"
)
// TestSaveWebDAVResume verifies the partial-progress sidecar written
// by streamWebDAVBody on context cancellation. The ETag and
// Last-Modified headers are preserved so the next --resume attempt
// can send a matching If-Range and either continue or restart
// cleanly depending on the server's view of the file.
func TestSaveWebDAVResume(t *testing.T) {
tmp := t.TempDir()
out := filepath.Join(tmp, "file.bin")
u, _ := url.Parse("webdav://host/file.bin")
req := &core.DownloadRequest{
URL: u,
Output: out,
Resume: true,
}
resumed := true
saveWebDAVResume(req, 8192, 1<<20, `"etag-abc"`, "Mon, 01 Jan 2026 00:00:00 GMT", resumed)
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 != 8192 {
t.Errorf("Downloaded = %d, want 8192", meta.Downloaded)
}
if meta.Total != 1<<20 {
t.Errorf("Total = %d, want %d", meta.Total, 1<<20)
}
if meta.ETag != `"etag-abc"` {
t.Errorf("ETag = %q", meta.ETag)
}
if meta.LastModified != "Mon, 01 Jan 2026 00:00:00 GMT" {
t.Errorf("LastModified = %q", meta.LastModified)
}
// Resume=false must short-circuit the save.
out2 := filepath.Join(tmp, "noresume.bin")
req.Resume = false
saveWebDAVResume(req, 4096, 1000, "", "", false)
if _, err := output.LoadResumeMetadata(out2); err != nil {
t.Fatalf("LoadResumeMetadata (noresume): %v", err)
}
}
File diff suppressed because it is too large Load Diff
+470
View File
@@ -0,0 +1,470 @@
//go:build linux || freebsd
// +build linux freebsd
package queue
import (
"crypto/rand"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// QueueItem represents a single item in the queue
type QueueItem struct {
ID string `json:"id"`
URL string `json:"url"`
Output string `json:"output"`
Priority int `json:"priority"` // 1-10, higher = more important
Status string `json:"status"` // pending, downloading, completed, failed, paused
AddedAt time.Time `json:"added_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Size int64 `json:"size,omitempty"`
Downloaded int64 `json:"downloaded,omitempty"`
Error string `json:"error,omitempty"`
Retries int `json:"retries"`
MaxRetries int `json:"max_retries"`
}
// QueueConfig configures the queue
type QueueConfig struct {
// MaxParallel is the maximum number of parallel downloads
MaxParallel int `json:"max_parallel"`
// QueueFile is the path to the queue file
QueueFile string `json:"queue_file"`
// AutoSave enables auto-saving on changes
AutoSave bool `json:"auto_save"`
// DefaultMaxRetries is the default maximum retry count for failed items
DefaultMaxRetries int `json:"default_max_retries"`
}
// DefaultQueueConfig returns the default configuration
func DefaultQueueConfig() *QueueConfig {
return &QueueConfig{
MaxParallel: 3,
QueueFile: getDefaultQueueFile(),
AutoSave: true,
DefaultMaxRetries: 3,
}
}
// Queue represents download queue
type Queue struct {
config *QueueConfig
items []*QueueItem
mu sync.RWMutex
dirty bool
}
// NewQueue creates a new queue
func NewQueue(cfg *QueueConfig) (*Queue, error) {
if cfg == nil {
cfg = DefaultQueueConfig()
}
q := &Queue{
config: cfg,
items: make([]*QueueItem, 0),
}
// Try to load existing queue
if err := q.Load(); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load queue: %w", err)
}
return q, nil
}
// Add adds a new download to the queue
func (q *Queue) Add(url, output string, priority int) *QueueItem {
q.mu.Lock()
defer q.mu.Unlock()
if priority < 1 {
priority = 5
}
if priority > 10 {
priority = 10
}
item := &QueueItem{
ID: generateID(),
URL: url,
Output: output,
Priority: priority,
Status: "pending",
AddedAt: time.Now(),
MaxRetries: q.config.DefaultMaxRetries,
}
q.items = append(q.items, item)
q.dirty = true
if q.config.AutoSave {
q.saveUnsafe()
}
return item
}
// AddMultiple adds multiple downloads at once
func (q *Queue) AddMultiple(items []QueueItem) {
q.mu.Lock()
defer q.mu.Unlock()
for i := range items {
item := &items[i]
if item.ID == "" {
item.ID = generateID()
}
if item.Status == "" {
item.Status = "pending"
}
if item.AddedAt.IsZero() {
item.AddedAt = time.Now()
}
if item.MaxRetries == 0 {
item.MaxRetries = q.config.DefaultMaxRetries
}
q.items = append(q.items, item)
}
q.dirty = true
if q.config.AutoSave {
q.saveUnsafe()
}
}
// Remove removes an item from the queue
func (q *Queue) Remove(id string) bool {
q.mu.Lock()
defer q.mu.Unlock()
for i, item := range q.items {
if item.ID == id {
q.items = append(q.items[:i], q.items[i+1:]...)
q.dirty = true
if q.config.AutoSave {
q.saveUnsafe()
}
return true
}
}
return false
}
// GetNext returns the next item to download (highest priority, oldest)
func (q *Queue) GetNext() *QueueItem {
q.mu.Lock()
defer q.mu.Unlock()
var best *QueueItem
for _, item := range q.items {
if item.Status != "pending" {
continue
}
if best == nil || item.Priority > best.Priority ||
(item.Priority == best.Priority && item.AddedAt.Before(best.AddedAt)) {
best = item
}
}
return best
}
// GetPending returns all pending items
func (q *Queue) GetPending() []*QueueItem {
q.mu.RLock()
defer q.mu.RUnlock()
var pending []*QueueItem
for _, item := range q.items {
if item.Status == "pending" {
pending = append(pending, item)
}
}
return pending
}
// GetDownloading returns all downloading items
func (q *Queue) GetDownloading() []*QueueItem {
q.mu.RLock()
defer q.mu.RUnlock()
var downloading []*QueueItem
for _, item := range q.items {
if item.Status == "downloading" {
downloading = append(downloading, item)
}
}
return downloading
}
// GetCompleted returns all completed items
func (q *Queue) GetCompleted() []*QueueItem {
q.mu.RLock()
defer q.mu.RUnlock()
var completed []*QueueItem
for _, item := range q.items {
if item.Status == "completed" {
completed = append(completed, item)
}
}
return completed
}
// GetFailed returns all failed items
func (q *Queue) GetFailed() []*QueueItem {
q.mu.RLock()
defer q.mu.RUnlock()
var failed []*QueueItem
for _, item := range q.items {
if item.Status == "failed" {
failed = append(failed, item)
}
}
return failed
}
// UpdateStatus updates an item's status
func (q *Queue) UpdateStatus(id, status string) bool {
q.mu.Lock()
defer q.mu.Unlock()
for _, item := range q.items {
if item.ID == id {
item.Status = status
now := time.Now()
if status == "downloading" && item.StartedAt == nil {
item.StartedAt = &now
}
if status == "completed" || status == "failed" {
item.CompletedAt = &now
}
q.dirty = true
if q.config.AutoSave {
q.saveUnsafe()
}
return true
}
}
return false
}
// UpdateProgress updates an item's progress
func (q *Queue) UpdateProgress(id string, downloaded, size int64) bool {
q.mu.Lock()
defer q.mu.Unlock()
for _, item := range q.items {
if item.ID == id {
item.Downloaded = downloaded
item.Size = size
q.dirty = true
if q.config.AutoSave {
q.saveUnsafe()
}
return true
}
}
return false
}
// Retry marks a failed item for retry
func (q *Queue) Retry(id string) bool {
q.mu.Lock()
defer q.mu.Unlock()
for _, item := range q.items {
if item.ID == id {
if item.Retries < item.MaxRetries {
item.Retries++
item.Status = "pending"
item.Error = ""
item.StartedAt = nil
item.CompletedAt = nil
q.dirty = true
if q.config.AutoSave {
q.saveUnsafe()
}
return true
}
return false
}
}
return false
}
// ClearCompleted removes all completed items
func (q *Queue) ClearCompleted() int {
q.mu.Lock()
defer q.mu.Unlock()
count := 0
var remaining []*QueueItem
for _, item := range q.items {
if item.Status == "completed" {
count++
} else {
remaining = append(remaining, item)
}
}
if count > 0 {
q.items = remaining
q.dirty = true
if q.config.AutoSave {
q.saveUnsafe()
}
}
return count
}
// GetAll returns all items
func (q *Queue) GetAll() []*QueueItem {
q.mu.RLock()
defer q.mu.RUnlock()
result := make([]*QueueItem, len(q.items))
copy(result, q.items)
return result
}
// Len returns the item count
func (q *Queue) Len() int {
q.mu.RLock()
defer q.mu.RUnlock()
return len(q.items)
}
// Save saves the queue to a file
func (q *Queue) Save() error {
q.mu.Lock()
defer q.mu.Unlock()
return q.saveUnsafe()
}
func (q *Queue) saveUnsafe() error {
if !q.dirty {
return nil
}
// Ensure directory exists
dir := filepath.Dir(q.config.QueueFile)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create queue directory: %w", err)
}
data, err := json.MarshalIndent(q.items, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal queue: %w", err)
}
// Write atomically
tmpFile := q.config.QueueFile + ".tmp"
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
return fmt.Errorf("failed to write queue: %w", err)
}
if err := os.Rename(tmpFile, q.config.QueueFile); err != nil {
return fmt.Errorf("failed to rename queue file: %w", err)
}
q.dirty = false
return nil
}
// Load loads the queue from a file
func (q *Queue) Load() error {
q.mu.Lock()
defer q.mu.Unlock()
data, err := os.ReadFile(q.config.QueueFile)
if err != nil {
return err
}
var items []*QueueItem
if err := json.Unmarshal(data, &items); err != nil {
return fmt.Errorf("failed to unmarshal queue: %w", err)
}
// Reset downloading items to pending (they were interrupted)
for _, item := range items {
if item.Status == "downloading" {
item.Status = "pending"
item.StartedAt = nil
}
}
q.items = items
q.dirty = false
return nil
}
// GetStats returns queue statistics
func (q *Queue) GetStats() QueueStats {
q.mu.RLock()
defer q.mu.RUnlock()
stats := QueueStats{}
for _, item := range q.items {
switch item.Status {
case "pending":
stats.Pending++
case "downloading":
stats.Downloading++
case "completed":
stats.Completed++
case "failed":
stats.Failed++
case "paused":
stats.Paused++
}
stats.TotalSize += item.Size
stats.TotalDownloaded += item.Downloaded
}
stats.Total = len(q.items)
return stats
}
// QueueStats represents queue statistics
type QueueStats struct {
Total int `json:"total"`
Pending int `json:"pending"`
Downloading int `json:"downloading"`
Completed int `json:"completed"`
Failed int `json:"failed"`
Paused int `json:"paused"`
TotalSize int64 `json:"total_size"`
TotalDownloaded int64 `json:"total_downloaded"`
}
// generateID generates a unique ID with a random suffix to prevent collisions.
func generateID() string {
b := make([]byte, 4)
_, _ = rand.Read(b)
return fmt.Sprintf("q_%d_%x", time.Now().UnixNano(), b)
}
// getDefaultQueueFile returns the default path to the queue file
func getDefaultQueueFile() string {
if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
return filepath.Join(xdg, "goget", "queue.json")
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".local", "state", "goget", "queue.json")
}
return "goget.queue.json"
}
+359
View File
@@ -0,0 +1,359 @@
//go:build linux || freebsd
// +build linux freebsd
package queue
import (
"os"
"path/filepath"
"testing"
)
func TestNewQueue(t *testing.T) {
q, err := NewQueue(nil)
if err != nil {
t.Fatalf("Failed to create queue: %v", err)
}
if q == nil {
t.Fatal("Queue is nil")
}
if q.config.MaxParallel != 3 {
t.Errorf("Expected MaxParallel=3, got %d", q.config.MaxParallel)
}
}
func TestQueueAdd(t *testing.T) {
tmpDir := t.TempDir()
queueFile := filepath.Join(tmpDir, "queue.json")
q, err := NewQueue(&QueueConfig{QueueFile: queueFile, AutoSave: true})
if err != nil {
t.Fatalf("Failed to create queue: %v", err)
}
item := q.Add("https://example.com/file.zip", "/tmp/file.zip", 5)
if item == nil {
t.Fatal("Added item is nil")
}
if item.URL != "https://example.com/file.zip" {
t.Errorf("Expected URL https://example.com/file.zip, got %s", item.URL)
}
if item.Priority != 5 {
t.Errorf("Expected priority 5, got %d", item.Priority)
}
if item.Status != "pending" {
t.Errorf("Expected status pending, got %s", item.Status)
}
}
func TestQueueAddPriority(t *testing.T) {
q, err := NewQueue(nil)
if err != nil {
t.Fatalf("Failed to create queue: %v", err)
}
// Test priority clamping
item1 := q.Add("https://example.com/1.zip", "/tmp/1.zip", 0)
if item1.Priority != 5 {
t.Errorf("Expected priority 5 (default), got %d", item1.Priority)
}
item2 := q.Add("https://example.com/2.zip", "/tmp/2.zip", 15)
if item2.Priority != 10 {
t.Errorf("Expected priority 10 (max), got %d", item2.Priority)
}
}
func TestQueueRemove(t *testing.T) {
q, _ := NewQueue(nil)
item := q.Add("https://example.com/file.zip", "/tmp/file.zip", 5)
initialLen := q.Len()
if !q.Remove(item.ID) {
t.Error("Failed to remove item")
}
if q.Len() != initialLen-1 {
t.Errorf("Expected queue length %d, got %d", initialLen-1, q.Len())
}
}
func TestQueueGetNext(t *testing.T) {
q, _ := NewQueue(nil)
// Add items with different priorities
q.Add("https://example.com/low.zip", "/tmp/low.zip", 3)
q.Add("https://example.com/high.zip", "/tmp/high.zip", 10)
q.Add("https://example.com/med.zip", "/tmp/med.zip", 5)
next := q.GetNext()
if next == nil {
t.Fatal("GetNext returned nil")
}
// Should return highest priority (10)
if next.Priority < 8 {
t.Errorf("Expected high priority item (>=8), got %d", next.Priority)
}
}
func TestQueueUpdateStatus(t *testing.T) {
q, err := NewQueue(nil)
if err != nil {
t.Fatalf("Failed to create queue: %v", err)
}
item := q.Add("https://example.com/file.zip", "/tmp/file.zip", 5)
if !q.UpdateStatus(item.ID, "downloading") {
t.Error("Failed to update status")
}
// Refresh item from queue
items := q.GetAll()
var updated *QueueItem
for _, it := range items {
if it.ID == item.ID {
updated = it
break
}
}
if updated == nil || updated.Status != "downloading" {
t.Errorf("Expected status downloading, got %s", updated.Status)
}
}
func TestQueueGetPending(t *testing.T) {
q, _ := NewQueue(nil)
q.Add("https://example.com/1.zip", "/tmp/1.zip", 5)
q.Add("https://example.com/2.zip", "/tmp/2.zip", 5)
items := q.GetAll()
if len(items) >= 2 {
q.UpdateStatus(items[0].ID, "completed")
}
pending := q.GetPending()
if len(pending) < 1 {
t.Errorf("Expected at least 1 pending item, got %d", len(pending))
}
}
func TestQueueGetStats(t *testing.T) {
q, _ := NewQueue(nil)
initialLen := q.Len()
q.Add("https://example.com/1.zip", "/tmp/1.zip", 5)
q.Add("https://example.com/2.zip", "/tmp/2.zip", 5)
q.Add("https://example.com/3.zip", "/tmp/3.zip", 5)
items := q.GetAll()
completedCount := 0
for _, item := range items {
if item.Status == "pending" && completedCount == 0 {
q.UpdateStatus(item.ID, "completed")
completedCount++
}
}
stats := q.GetStats()
if stats.Total < initialLen+3 {
t.Errorf("Expected at least %d total, got %d", initialLen+3, stats.Total)
}
if stats.Completed < 1 {
t.Errorf("Expected at least 1 completed, got %d", stats.Completed)
}
}
func TestQueueSaveLoad(t *testing.T) {
tmpDir := t.TempDir()
queueFile := filepath.Join(tmpDir, "queue.json")
// Create and save queue
q1, err := NewQueue(&QueueConfig{QueueFile: queueFile, AutoSave: false})
if err != nil {
t.Fatalf("Failed to create queue: %v", err)
}
q1.Add("https://example.com/file.zip", "/tmp/file.zip", 7)
q1.Add("https://example.com/other.zip", "/tmp/other.zip", 3)
if err := q1.Save(); err != nil {
t.Fatalf("Failed to save queue: %v", err)
}
// Load queue
q2, err := NewQueue(&QueueConfig{QueueFile: queueFile})
if err != nil {
t.Fatalf("Failed to load queue: %v", err)
}
if q2.Len() != 2 {
t.Errorf("Expected 2 items after load, got %d", q2.Len())
}
}
func TestQueueClearCompleted(t *testing.T) {
q, _ := NewQueue(nil)
initialLen := q.Len()
q.Add("https://example.com/1.zip", "/tmp/1.zip", 5)
q.Add("https://example.com/2.zip", "/tmp/2.zip", 5)
q.Add("https://example.com/3.zip", "/tmp/3.zip", 5)
items := q.GetAll()
completedCount := 0
for _, item := range items {
if item.Status == "pending" && completedCount == 0 {
q.UpdateStatus(item.ID, "completed")
completedCount++
}
}
count := q.ClearCompleted()
if count < 1 {
t.Errorf("Expected to clear at least 1 item, cleared %d", count)
}
if q.Len() >= initialLen+3 {
t.Errorf("Expected less than %d items remaining, got %d", initialLen+3, q.Len())
}
}
func TestQueueRetry(t *testing.T) {
q, err := NewQueue(nil)
if err != nil {
t.Fatalf("Failed to create queue: %v", err)
}
item := q.Add("https://example.com/file.zip", "/tmp/file.zip", 5)
q.UpdateStatus(item.ID, "failed")
// Should be able to retry
if !q.Retry(item.ID) {
t.Error("Failed to retry item")
}
// Check status is back to pending
items := q.GetAll()
for _, it := range items {
if it.ID == item.ID {
if it.Status != "pending" {
t.Errorf("Expected status pending after retry, got %s", it.Status)
}
break
}
}
}
func TestQueueRetryMaxRetries(t *testing.T) {
q, err := NewQueue(&QueueConfig{DefaultMaxRetries: 2})
if err != nil {
t.Fatalf("Failed to create queue: %v", err)
}
item := q.Add("https://example.com/file.zip", "/tmp/file.zip", 5)
q.UpdateStatus(item.ID, "failed")
// First retry should work
if !q.Retry(item.ID) {
t.Error("First retry should succeed")
}
q.UpdateStatus(item.ID, "failed")
// Second retry should work
if !q.Retry(item.ID) {
t.Error("Second retry should succeed")
}
q.UpdateStatus(item.ID, "failed")
// Third retry should fail (max retries reached)
if q.Retry(item.ID) {
t.Error("Third retry should fail (max retries exceeded)")
}
}
func TestGenerateID(t *testing.T) {
id1 := generateID()
id2 := generateID()
if id1 == id2 {
t.Error("Generated IDs should be unique")
}
// Check ID format
if len(id1) < 3 {
t.Errorf("ID too short: %s", id1)
}
}
func TestDefaultQueueFile(t *testing.T) {
path := getDefaultQueueFile()
if path == "" {
t.Error("Default queue file path should not be empty")
}
}
func TestQueueConcurrentAccess(t *testing.T) {
q, _ := NewQueue(nil)
initialLen := q.Len()
done := make(chan bool, 10)
// Concurrent adds
for i := 0; i < 10; i++ {
go func(n int) {
q.Add("https://example.com/"+string(rune('a'+n))+".zip", "/tmp/"+string(rune('a'+n))+".zip", 5)
done <- true
}(i)
}
for i := 0; i < 10; i++ {
<-done
}
if q.Len() < initialLen+10 {
t.Errorf("Expected at least %d items after concurrent adds, got %d", initialLen+10, q.Len())
}
}
func TestQueueAutoSave(t *testing.T) {
tmpDir := t.TempDir()
queueFile := filepath.Join(tmpDir, "queue.json")
q, err := NewQueue(&QueueConfig{QueueFile: queueFile, AutoSave: true})
if err != nil {
t.Fatalf("Failed to create queue: %v", err)
}
q.Add("https://example.com/file.zip", "/tmp/file.zip", 5)
// Check file exists
if _, err := os.Stat(queueFile); os.IsNotExist(err) {
t.Error("Queue file should exist with AutoSave enabled")
}
}
func TestQueueProgressUpdate(t *testing.T) {
q, err := NewQueue(nil)
if err != nil {
t.Fatalf("Failed to create queue: %v", err)
}
item := q.Add("https://example.com/file.zip", "/tmp/file.zip", 5)
q.UpdateProgress(item.ID, 500, 1000)
items := q.GetAll()
for _, it := range items {
if it.ID == item.ID {
if it.Downloaded != 500 {
t.Errorf("Expected downloaded 500, got %d", it.Downloaded)
}
if it.Size != 1000 {
t.Errorf("Expected size 1000, got %d", it.Size)
}
break
}
}
}
+474
View File
@@ -0,0 +1,474 @@
//go:build linux || freebsd
// +build linux freebsd
package recursive
import (
"bytes"
"context"
"fmt"
"io"
"math/rand/v2"
"mime"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
format "codeberg.org/petrbalvin/goget/internal/format"
"codeberg.org/petrbalvin/goget/internal/linkrewrite"
"golang.org/x/net/html"
)
// CrawlerConfig configures the crawler
type CrawlerConfig struct {
MaxDepth int
FollowExternal bool
ExcludePatterns []string
IncludePatterns []string
AcceptPatterns []string
NoParent bool
PageRequisites bool
ConvertLinks bool
OutputDir string
Verbose bool
Parallel int
Delay time.Duration
RandomWait bool
UserAgent string
DryRun bool
}
// CrawlerStats represents crawler statistics
type CrawlerStats struct {
TotalURLs int
DownloadedURLs int
FailedURLs int
SkippedURLs int
TotalBytes int64
StartTime time.Time
EndTime time.Time
mu sync.Mutex
}
// Crawler performs recursive downloading
type Crawler struct {
config *CrawlerConfig
proto core.Protocol
filter *URLFilter
visited map[string]bool
visitedMu sync.Mutex
queue chan *crawlTask
queueOnce sync.Once
stats *CrawlerStats
rewriter *linkrewrite.Rewriter
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
}
// crawlTask represents a task for the crawler
type crawlTask struct {
url *url.URL
depth int
}
// NewCrawler creates new crawler
func NewCrawler(cfg *CrawlerConfig, proto core.Protocol) *Crawler {
ctx, cancel := context.WithCancel(context.Background())
filter := NewURLFilter(nil, URLFilterConfig{
MaxDepth: cfg.MaxDepth,
FollowExternal: cfg.FollowExternal,
ExcludePatterns: cfg.ExcludePatterns,
IncludePatterns: cfg.IncludePatterns,
AcceptPatterns: cfg.AcceptPatterns,
NoParent: cfg.NoParent,
})
crawler := &Crawler{
config: cfg,
proto: proto,
filter: filter,
visited: make(map[string]bool),
queue: make(chan *crawlTask, 100),
stats: &CrawlerStats{StartTime: time.Now()},
ctx: ctx,
cancel: cancel,
}
if cfg.ConvertLinks {
crawler.rewriter = linkrewrite.New(nil, cfg.OutputDir)
}
return crawler
}
// Download recursively downloads the URL and all links
func (c *Crawler) Download(ctx context.Context, baseURL *url.URL) (*CrawlerStats, error) {
c.filter.baseURL = baseURL
if c.rewriter != nil {
c.rewriter = linkrewrite.New(baseURL, c.config.OutputDir)
}
// Create output directory
if err := os.MkdirAll(c.config.OutputDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create output directory: %w", err)
}
// Add to WaitGroup BEFORE adding to queue
c.wg.Add(1)
c.queue <- &crawlTask{url: baseURL, depth: 0}
// Start worker goroutines
workers := c.config.Parallel
if workers <= 0 {
workers = 4
}
for i := 0; i < workers; i++ {
go c.worker()
}
// Close queue on context cancellation to unblock workers
closeQueue := func() {
c.queueOnce.Do(func() {
close(c.queue)
})
}
go func() {
<-c.ctx.Done()
closeQueue()
}()
// Wait for all tasks to complete
c.wg.Wait()
closeQueue()
c.stats.EndTime = time.Now()
return c.stats, nil
}
// worker processes tasks from the queue
func (c *Crawler) worker() {
for {
select {
case task, ok := <-c.queue:
if !ok {
return
}
if c.ctx.Err() != nil {
c.wg.Done()
continue
}
c.processTask(task)
// Delay between requests (with optional randomization)
if c.config.Delay > 0 {
d := c.config.Delay
if c.config.RandomWait {
d = randomizeDelay(d)
}
select {
case <-time.After(d):
case <-c.ctx.Done():
return
}
}
case <-c.ctx.Done():
return
}
}
}
// processTask processes a single crawl task
func (c *Crawler) processTask(task *crawlTask) {
defer c.wg.Done()
// Check if cancelled
if c.ctx.Err() != nil {
return
}
// Check if already visited
c.visitedMu.Lock()
if c.visited[task.url.String()] {
c.visitedMu.Unlock()
c.stats.mu.Lock()
c.stats.SkippedURLs++
c.stats.mu.Unlock()
return
}
c.visited[task.url.String()] = true
c.visitedMu.Unlock()
// Check filter
if !c.filter.ShouldDownload(task.url, task.depth) {
c.stats.mu.Lock()
c.stats.SkippedURLs++
c.stats.mu.Unlock()
if c.config.Verbose {
fmt.Fprintf(os.Stderr, "[skip] %s (filtered)\n", task.url.String())
}
return
}
outputPath := GetLocalPath(c.filter.baseURL, task.url, c.config.OutputDir)
if c.config.DryRun {
if c.config.Verbose {
fmt.Fprintf(os.Stderr, "[dry-run] %s (depth %d)\n", task.url.String(), task.depth)
}
c.stats.mu.Lock()
c.stats.SkippedURLs++
c.stats.mu.Unlock()
return
}
// Download the URL
if c.config.Verbose {
fmt.Fprintf(os.Stderr, "[download] %s (depth %d)\n", task.url.String(), task.depth)
}
// Create parent directory
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
c.stats.mu.Lock()
c.stats.FailedURLs++
c.stats.mu.Unlock()
if c.config.Verbose {
fmt.Fprintf(os.Stderr, "[error] failed to create directory: %v\n", err)
}
return
}
// Create download request
req := &core.DownloadRequest{
URL: task.url,
Output: outputPath,
Resume: true,
Timeout: 30 * time.Minute,
Verbose: false,
AutoDecompress: true,
Headers: map[string]string{
"User-Agent": c.config.UserAgent,
},
Ctx: c.ctx,
}
// Execute download
result, err := c.proto.Download(c.ctx, req)
if err != nil {
c.stats.mu.Lock()
c.stats.FailedURLs++
c.stats.mu.Unlock()
if c.config.Verbose {
fmt.Fprintf(os.Stderr, "[error] %s: %v\n", task.url.String(), err)
}
return
}
c.stats.mu.Lock()
c.stats.DownloadedURLs++
c.stats.TotalBytes += result.BytesDownloaded
c.stats.mu.Unlock()
if c.config.Verbose {
fmt.Fprintf(os.Stderr, "[done] %s -> %s (%s)\n", task.url.String(), outputPath, format.Bytes(result.BytesDownloaded))
}
if c.config.ConvertLinks && c.rewriter != nil {
c.rewriter.Register(task.url, outputPath)
c.rewriteLinks(outputPath, task.url)
}
// Check if content is HTML by reading file content
if c.shouldParseForLinks(outputPath, task.url.Path) {
if c.config.Verbose {
fmt.Fprintf(os.Stderr, "[parse] %s looks like HTML, extracting links\n", outputPath)
}
c.extractAndQueue(task.url, outputPath, task.depth+1)
}
// Delay between requests
if c.config.Delay > 0 {
select {
case <-time.After(c.config.Delay):
case <-c.ctx.Done():
}
}
}
// Better HTML detection - check content, not just extension
func (c *Crawler) shouldParseForLinks(outputPath, urlPath string) bool {
// First check extension (quick path)
ext := strings.ToLower(filepath.Ext(outputPath))
if ext == ".html" || ext == ".htm" || ext == ".php" || ext == ".asp" || ext == ".aspx" {
return true
}
if ext == "" && (urlPath == "" || urlPath == "/" || strings.HasSuffix(urlPath, "/")) {
return true
}
// If extension doesn't match, read only the first 512 bytes to check for HTML signature.
f, err := os.Open(outputPath)
if err != nil {
return false
}
defer f.Close()
snippet := make([]byte, 512)
n, err := io.ReadFull(f, snippet)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return false
}
snippet = snippet[:n]
if len(snippet) == 0 {
return false
}
content := strings.ToLower(string(snippet))
// Look for common HTML indicators
if strings.Contains(content, "<!doctype html") ||
strings.Contains(content, "<html") ||
strings.Contains(content, "<head") ||
strings.Contains(content, "<body") {
return true
}
// Check Content-Type via mime sniffing (basic)
if contentType := httpDetectContentType(snippet); contentType != "" {
mediaType, _, err := mime.ParseMediaType(contentType)
if err == nil && mediaType == "text/html" {
return true
}
}
return false
}
// httpDetectContentType basic MIME type detection from content
func httpDetectContentType(data []byte) string {
if len(data) < 2 {
return ""
}
if bytes.HasPrefix(data, []byte("<!")) || bytes.HasPrefix(data, []byte("<html")) || bytes.HasPrefix(data, []byte("<HTML")) {
return "text/html"
}
return ""
}
// extractAndQueue extracts links and adds them to the queue
func (c *Crawler) extractAndQueue(baseURL *url.URL, outputPath string, depth int) {
// Read HTML content
data, err := os.ReadFile(outputPath)
if err != nil {
if c.config.Verbose {
fmt.Fprintf(os.Stderr, "[error] failed to read %s for link extraction: %v\n", outputPath, err)
}
return
}
// Extract links
links, err := ExtractLinks(baseURL, data)
if err != nil {
if c.config.Verbose {
fmt.Fprintf(os.Stderr, "[error] failed to parse links from %s: %v\n", outputPath, err)
}
return
}
if c.config.Verbose {
fmt.Fprintf(os.Stderr, "[links] found %d links in %s\n", len(links), outputPath)
}
// Add valid links to queue
added := 0
for _, link := range links {
if c.filter.ShouldDownload(link, depth) {
c.visitedMu.Lock()
if !c.visited[link.String()] {
c.visitedMu.Unlock()
// Add to WaitGroup before adding to queue
c.wg.Add(1)
select {
case c.queue <- &crawlTask{url: link, depth: depth}:
added++
case <-c.ctx.Done():
c.wg.Done()
return
}
} else {
c.visitedMu.Unlock()
}
}
}
if c.config.Verbose && added > 0 {
fmt.Fprintf(os.Stderr, "[queue] added %d new URLs to queue\n", added)
}
}
// GetStats returns current statistics
func (c *Crawler) GetStats() *CrawlerStats {
return c.stats
}
// Cancel cancels the crawler
func (c *Crawler) Cancel() {
c.cancel()
}
// GetStatsSummary returns a summary of statistics
func (s *CrawlerStats) GetStatsSummary() string {
duration := s.EndTime.Sub(s.StartTime)
if duration == 0 {
duration = time.Since(s.StartTime)
}
speed := float64(0)
if duration.Seconds() > 0 {
speed = float64(s.TotalBytes) / duration.Seconds()
}
return fmt.Sprintf("Downloaded: %d files, %s total, %v elapsed, %.1f KB/s avg",
s.DownloadedURLs,
format.Bytes(s.TotalBytes),
duration.Round(time.Second),
speed/1024)
}
// rewriteLinks rewrites absolute URLs to local paths in HTML files.
func (c *Crawler) rewriteLinks(outputPath string, sourceURL *url.URL) {
if c.rewriter == nil {
return
}
data, err := os.ReadFile(outputPath)
if err != nil {
return
}
doc, err := html.Parse(bytes.NewReader(data))
if err != nil {
return
}
if c.rewriter.RewriteLinks(doc, outputPath) == 0 {
return
}
var buf bytes.Buffer
if err := html.Render(&buf, doc); err != nil {
return
}
_ = os.WriteFile(outputPath, buf.Bytes(), 0644)
_ = sourceURL
}
// randomizeDelay returns a randomized delay between 0.5x and 1.5x.
func randomizeDelay(d time.Duration) time.Duration {
factor := 0.5 + rand.Float64()
return time.Duration(float64(d) * factor)
}
+157
View File
@@ -0,0 +1,157 @@
//go:build linux || freebsd
// +build linux freebsd
package recursive
import (
"context"
"net/url"
"sync/atomic"
"testing"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/protocol"
)
// mockProtocol is a controllable core.Protocol implementation for crawler
// tests. The Download method honours ctx.Done() so a cancelled test can
// observe the cancellation propagating into the worker.
type mockProtocol struct {
*protocol.BaseProtocol
downloadDelay time.Duration
downloadCount int32
}
func (m *mockProtocol) Download(ctx context.Context, _ *core.DownloadRequest) (*core.DownloadResult, error) {
atomic.AddInt32(&m.downloadCount, 1)
if m.downloadDelay > 0 {
select {
case <-time.After(m.downloadDelay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return &core.DownloadResult{BytesDownloaded: 0}, nil
}
func newMockProtocol(delay time.Duration) *mockProtocol {
return &mockProtocol{
BaseProtocol: protocol.NewBaseProtocol(protocol.ProtocolInfo{
Name: "mock",
Scheme: "http",
}),
downloadDelay: delay,
}
}
// TestDownloadCancelDoesNotPanic is a regression guard for the BACKLOG
// entry "Double close() crash in recursive crawler". Before the fix, the
// cancellation goroutine and the main Download path both called
// close(c.queue); whichever fired second panicked with
// "close of closed channel". With sync.Once guarding the close, only the
// first caller wins and the second is a no-op.
func TestDownloadCancelDoesNotPanic(t *testing.T) {
dir := t.TempDir()
cfg := &CrawlerConfig{
OutputDir: dir,
Parallel: 1,
}
// Each download takes 50ms, long enough for the cancellation goroutine
// to fire closeQueue before wg.Wait() returns on its own.
proto := newMockProtocol(50 * time.Millisecond)
crawler := NewCrawler(cfg, proto)
baseURL, err := url.Parse("http://example.com/")
if err != nil {
t.Fatalf("parse: %v", err)
}
done := make(chan struct{})
var panicVal any
go func() {
defer close(done)
defer func() {
panicVal = recover()
}()
_, _ = crawler.Download(context.Background(), baseURL)
}()
// Give the worker a moment to pick up the initial task and start the
// slow download, then cancel — this is the exact race the original
// bug triggered.
time.Sleep(20 * time.Millisecond)
crawler.Cancel()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("Download did not return within 3s after Cancel")
}
if panicVal != nil {
t.Fatalf("Download panicked on Cancel: %v", panicVal)
}
}
// TestDownloadNormalCompletion verifies the happy path still works after
// introducing sync.Once. Without the bug present, the only close() call
// happens after wg.Wait() returns, and the workers must observe a closed
// channel and exit cleanly.
func TestDownloadNormalCompletion(t *testing.T) {
dir := t.TempDir()
cfg := &CrawlerConfig{
OutputDir: dir,
Parallel: 1,
}
proto := newMockProtocol(0)
crawler := NewCrawler(cfg, proto)
baseURL, err := url.Parse("http://example.com/")
if err != nil {
t.Fatalf("parse: %v", err)
}
stats, err := crawler.Download(context.Background(), baseURL)
if err != nil {
t.Fatalf("Download: %v", err)
}
if stats.DownloadedURLs != 1 {
t.Errorf("DownloadedURLs = %d, want 1", stats.DownloadedURLs)
}
if got := atomic.LoadInt32(&proto.downloadCount); got != 1 {
t.Errorf("downloadCount = %d, want 1", got)
}
}
// TestDownloadCancelAfterAllTasksDone exercises the ordering where the
// main path's wg.Wait() returns first and then the cancellation goroutine
// fires. sync.Once must ignore the second close.
func TestDownloadCancelAfterAllTasksDone(t *testing.T) {
dir := t.TempDir()
cfg := &CrawlerConfig{
OutputDir: dir,
Parallel: 1,
}
proto := newMockProtocol(0)
crawler := NewCrawler(cfg, proto)
baseURL, err := url.Parse("http://example.com/")
if err != nil {
t.Fatalf("parse: %v", err)
}
done := make(chan struct{})
go func() {
defer close(done)
_, _ = crawler.Download(context.Background(), baseURL)
}()
<-done
// Cancelling after Download returned must not panic (no goroutine is
// waiting on closeQueue, but Cancel triggers ctx.Done() which the
// goroutine observes; sync.Once already ran so the second close is a
// no-op).
crawler.Cancel()
}
+211
View File
@@ -0,0 +1,211 @@
//go:build linux || freebsd
// +build linux freebsd
package recursive
import (
"mime"
"net/url"
"path/filepath"
"regexp"
"strings"
)
// URLFilter filters URLs according to rules
type URLFilter struct {
baseURL *url.URL
maxDepth int
followExternal bool
excludePatterns []*regexp.Regexp
includePatterns []*regexp.Regexp
acceptPatterns []string // Glob-style accept patterns
noParent bool
}
// URLFilterConfig configures the URL filter
type URLFilterConfig struct {
MaxDepth int
FollowExternal bool
ExcludePatterns []string
IncludePatterns []string
AcceptPatterns []string // Glob-style accept patterns (e.g., "*.pdf,*.html")
NoParent bool // Do not ascend to parent directory
}
// NewURLFilter creates a new filter
func NewURLFilter(baseURL *url.URL, cfg URLFilterConfig) *URLFilter {
filter := &URLFilter{
baseURL: baseURL,
maxDepth: cfg.MaxDepth,
followExternal: cfg.FollowExternal,
excludePatterns: make([]*regexp.Regexp, 0),
includePatterns: make([]*regexp.Regexp, 0),
acceptPatterns: cfg.AcceptPatterns,
noParent: cfg.NoParent,
}
// Compile exclude patterns
for _, pattern := range cfg.ExcludePatterns {
if re, err := regexp.Compile(pattern); err == nil {
filter.excludePatterns = append(filter.excludePatterns, re)
}
}
// Compile include patterns
for _, pattern := range cfg.IncludePatterns {
if re, err := regexp.Compile(pattern); err == nil {
filter.includePatterns = append(filter.includePatterns, re)
}
}
return filter
}
// ShouldDownload determines whether a URL should be downloaded
func (f *URLFilter) ShouldDownload(u *url.URL, currentDepth int) bool {
// Check depth
if currentDepth > f.maxDepth {
return false
}
// Check scheme
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
// Check domain (external links)
if !f.followExternal && u.Hostname() != f.baseURL.Hostname() {
return false
}
// Check exclude patterns
urlStr := u.String()
for _, re := range f.excludePatterns {
if re.MatchString(urlStr) {
return false
}
}
// Check include patterns (if specified, must match at least one)
if len(f.includePatterns) > 0 {
matched := false
for _, re := range f.includePatterns {
if re.MatchString(urlStr) {
matched = true
break
}
}
if !matched {
return false
}
}
// Check accept patterns (glob-style and MIME type)
if len(f.acceptPatterns) > 0 {
lastPath := u.Path
if idx := strings.LastIndex(lastPath, "/"); idx >= 0 {
lastPath = lastPath[idx+1:]
}
matched := false
for _, pattern := range f.acceptPatterns {
// MIME type pattern (contains "/")
if strings.Contains(pattern, "/") {
ext := filepath.Ext(lastPath)
if ext != "" {
mimeType := mime.TypeByExtension(ext)
if match, _ := filepath.Match(pattern, mimeType); match {
matched = true
break
}
}
continue
}
// Filename glob
if match, _ := filepath.Match(pattern, lastPath); match {
matched = true
break
}
}
if !matched {
return false
}
}
// Check no-parent
if f.noParent && f.baseURL != nil {
basePath := strings.TrimRight(f.baseURL.Path, "/")
targetPath := strings.TrimRight(u.Path, "/")
if !strings.HasPrefix(targetPath, basePath) {
return false
}
}
return true
}
// IsSameDomain determines whether the URL belongs to the same domain
func IsSameDomain(base, target *url.URL) bool {
if base == nil || target == nil {
return false
}
return base.Hostname() == target.Hostname()
}
// IsSubPath determines whether target is a subpath of base
func IsSubPath(base, target *url.URL) bool {
if base == nil || target == nil {
return false
}
return strings.HasPrefix(target.Path, base.Path)
}
// GetLocalPath converts a URL to a local file path
func GetLocalPath(baseURL, targetURL *url.URL, outputDir string) string {
// Start with output directory
path := outputDir
// Add hostname as subdirectory to avoid conflicts
if targetURL.Hostname() != baseURL.Hostname() {
path = filepath.Join(path, targetURL.Hostname())
}
// Add URL path
if targetURL.Path != "" && targetURL.Path != "/" {
// Clean the path
cleanPath := strings.TrimPrefix(targetURL.Path, "/")
path = filepath.Join(path, cleanPath)
}
// If path ends with / or is empty, add index.html
if strings.HasSuffix(targetURL.Path, "/") || targetURL.Path == "" {
path = filepath.Join(path, "index.html")
}
// If path has no extension, treat as HTML
if filepath.Ext(path) == "" {
path = path + ".html"
}
return path
}
// GetFilename extracts the filename from a URL
func GetFilename(u *url.URL) string {
path := u.Path
if path == "" || path == "/" {
return "index.html"
}
// Get last path segment
segments := strings.Split(strings.Trim(path, "/"), "/")
if len(segments) == 0 {
return "index.html"
}
filename := segments[len(segments)-1]
if filename == "" {
return "index.html"
}
return filename
}
+180
View File
@@ -0,0 +1,180 @@
//go:build linux || freebsd
// +build linux freebsd
package recursive
import (
"net/url"
"regexp"
"strings"
"golang.org/x/net/html"
)
// LinkExtractor extracts links from HTML
type LinkExtractor struct {
baseURL *url.URL
links []*url.URL
}
// NewLinkExtractor creates a new extractor
func NewLinkExtractor(baseURL *url.URL) *LinkExtractor {
return &LinkExtractor{
baseURL: baseURL,
links: make([]*url.URL, 0),
}
}
// ExtractLinks parses HTML and extracts all absolute URL links
func ExtractLinks(baseURL *url.URL, htmlContent []byte) ([]*url.URL, error) {
doc, err := html.Parse(strings.NewReader(string(htmlContent)))
if err != nil {
return nil, err
}
extractor := NewLinkExtractor(baseURL)
extractor.traverse(doc)
return extractor.links, nil
}
// traverse traverses the HTML tree and extracts links
func (e *LinkExtractor) traverse(n *html.Node) {
if n.Type == html.ElementNode {
switch n.Data {
case "a":
e.extractHref(n)
case "img":
e.extractSrc(n)
case "script":
e.extractSrc(n)
case "link":
e.extractHref(n)
case "iframe":
e.extractSrc(n)
case "video":
e.extractSrc(n)
case "audio":
e.extractSrc(n)
case "source":
e.extractSrc(n)
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
e.traverse(c)
}
}
// extractHref extracts the href attribute
func (e *LinkExtractor) extractHref(n *html.Node) {
for _, attr := range n.Attr {
if attr.Key == "href" {
e.addLink(attr.Val)
break
}
}
}
// extractSrc extracts the src attribute
func (e *LinkExtractor) extractSrc(n *html.Node) {
for _, attr := range n.Attr {
if attr.Key == "src" {
e.addLink(attr.Val)
break
}
}
}
// addLink adds a link to the list if it is valid
func (e *LinkExtractor) addLink(href string) {
// Skip empty, anchors, javascript, mailto, tel, data
if href == "" ||
strings.HasPrefix(href, "#") ||
strings.HasPrefix(href, "javascript:") ||
strings.HasPrefix(href, "mailto:") ||
strings.HasPrefix(href, "tel:") ||
strings.HasPrefix(href, "data:") {
return
}
// Parse URL
parsed, err := url.Parse(href)
if err != nil {
return
}
// Resolve relative URLs against base
resolved := e.baseURL.ResolveReference(parsed)
// Only keep http/https
if resolved.Scheme != "http" && resolved.Scheme != "https" {
return
}
// Check if already added
for _, existing := range e.links {
if existing.String() == resolved.String() {
return
}
}
e.links = append(e.links, resolved)
}
// ExtractLinksFromHTML is an alias for ExtractLinks (for convenience)
func ExtractLinksFromHTML(baseURL *url.URL, htmlContent []byte) ([]*url.URL, error) {
return ExtractLinks(baseURL, htmlContent)
}
var (
cssURLRegex = regexp.MustCompile(`url\(\s*['"]?([^'")\s]+)['"]?\s*\)`)
cssImportRegex = regexp.MustCompile(`@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?`)
)
// ExtractCSSURLs extracts URLs from CSS content (url() and @import rules).
// Returns absolute URLs resolved against the given base URL, deduplicated.
func ExtractCSSURLs(baseURL *url.URL, cssContent []byte) []*url.URL {
seen := make(map[string]bool)
var urls []*url.URL
addURL := func(raw string) {
if seen[raw] {
return
}
seen[raw] = true
parsed, err := url.Parse(raw)
if err != nil {
return
}
resolved := baseURL.ResolveReference(parsed)
if resolved.Scheme != "http" && resolved.Scheme != "https" {
return
}
// Check duplicate resolved URL
resolvedStr := resolved.String()
if seen[resolvedStr] {
return
}
seen[resolvedStr] = true
urls = append(urls, resolved)
}
// Extract url() references
for _, match := range cssURLRegex.FindAllStringSubmatch(string(cssContent), -1) {
if len(match) > 1 {
addURL(match[1])
}
}
// Extract @import references
for _, match := range cssImportRegex.FindAllStringSubmatch(string(cssContent), -1) {
if len(match) > 1 {
addURL(match[1])
}
}
return urls
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More