feat: initial goget release — modern IPv6-first download utility
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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) }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 "", ""
|
||||
}
|
||||
@@ -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 = ®istry{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
|
||||
}
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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
@@ -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 "", ""
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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 = ®istry{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"
|
||||
}
|
||||
@@ -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()) }
|
||||
@@ -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()) }
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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, ":")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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...) }
|
||||
@@ -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 μ 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
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
"codeberg.org/petrbalvin/goget/internal/protocol"
|
||||
)
|
||||
|
||||
// Protocol implements gemini:// downloads.
|
||||
type Protocol struct {
|
||||
*protocol.BaseProtocol
|
||||
TLSInsecure bool // allow self-signed certs (testing only)
|
||||
}
|
||||
|
||||
// maxGeminiRedirects limits redirect chain depth to prevent stack overflow.
|
||||
const maxGeminiRedirects = 10
|
||||
|
||||
// NewProtocol creates a new Gemini protocol handler.
|
||||
func NewProtocol() *Protocol {
|
||||
return &Protocol{
|
||||
BaseProtocol: protocol.NewBaseProtocol(protocol.ProtocolInfo{
|
||||
Name: "GEMINI",
|
||||
Scheme: "gemini",
|
||||
DefaultPort: 1965,
|
||||
Features: []string{},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Download starts a Gemini download. It handles redirect limits internally.
|
||||
func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
|
||||
return p.downloadWithRedirects(ctx, req, 0)
|
||||
}
|
||||
|
||||
func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.DownloadRequest, redirectCount int) (*core.DownloadResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
host := req.URL.Hostname()
|
||||
port := req.URL.Port()
|
||||
if port == "" {
|
||||
port = "1965"
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: p.TLSInsecure,
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: 30 * time.Second}
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return nil, core.NewNetworkError("failed to connect to gemini server", err, core.SafeURL(req.URL))
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
|
||||
// Send request: URL + CRLF
|
||||
requestURL := req.URL.String()
|
||||
if _, err := fmt.Fprintf(conn, "%s\r\n", requestURL); err != nil {
|
||||
return nil, core.NewNetworkError("failed to send gemini request", err, core.SafeURL(req.URL))
|
||||
}
|
||||
|
||||
// Read response header
|
||||
reader := bufio.NewReader(conn)
|
||||
header, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, core.NewNetworkError("failed to read gemini response header", err, core.SafeURL(req.URL))
|
||||
}
|
||||
header = strings.TrimSpace(header)
|
||||
|
||||
// Parse status code (first two chars)
|
||||
if len(header) < 2 {
|
||||
return nil, core.NewProtocolError("invalid gemini response header", nil, core.SafeURL(req.URL))
|
||||
}
|
||||
|
||||
statusCode, _ := strconv.Atoi(header[:2])
|
||||
meta := ""
|
||||
if len(header) > 3 {
|
||||
meta = header[3:]
|
||||
}
|
||||
|
||||
switch {
|
||||
case statusCode >= 10 && statusCode < 20:
|
||||
// 1x INPUT — server requests input (prompt user with meta)
|
||||
return nil, core.NewProtocolError(
|
||||
fmt.Sprintf("gemini input required: %s", meta), nil, core.SafeURL(req.URL))
|
||||
|
||||
case statusCode >= 30 && statusCode < 40:
|
||||
// 3x REDIRECT — follow the redirect
|
||||
if meta == "" {
|
||||
return nil, core.NewProtocolError("gemini redirect with no target URL", nil, core.SafeURL(req.URL))
|
||||
}
|
||||
if redirectCount >= maxGeminiRedirects {
|
||||
return nil, core.NewProtocolError(
|
||||
fmt.Sprintf("gemini redirect chain exceeded %d", maxGeminiRedirects), nil, core.SafeURL(req.URL))
|
||||
}
|
||||
redirectURL, err := url.Parse(meta)
|
||||
if err != nil {
|
||||
return nil, core.NewProtocolError(fmt.Sprintf("invalid gemini redirect URL: %s", meta), err, core.SafeURL(req.URL))
|
||||
}
|
||||
// Resolve relative redirects
|
||||
resolved := req.URL.ResolveReference(redirectURL)
|
||||
if req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[gemini] redirecting to: %s\n", resolved.String())
|
||||
}
|
||||
// Follow redirect with incremented depth
|
||||
redirectReq := &core.DownloadRequest{
|
||||
URL: resolved,
|
||||
Output: req.Output,
|
||||
Verbose: req.Verbose,
|
||||
Writer: req.Writer,
|
||||
Ctx: ctx,
|
||||
}
|
||||
return p.downloadWithRedirects(ctx, redirectReq, redirectCount+1)
|
||||
|
||||
case statusCode >= 40 && statusCode < 50:
|
||||
// 4x TEMPORARY FAILURE
|
||||
return nil, core.NewNetworkError(
|
||||
fmt.Sprintf("gemini temporary failure: %d %s", statusCode, meta),
|
||||
nil, core.SafeURL(req.URL))
|
||||
|
||||
case statusCode >= 50 && statusCode < 60:
|
||||
// 5x PERMANENT FAILURE
|
||||
return nil, core.NewProtocolError(
|
||||
fmt.Sprintf("gemini permanent failure: %d %s", statusCode, meta), nil, core.SafeURL(req.URL))
|
||||
|
||||
case statusCode >= 60 && statusCode < 70:
|
||||
// 6x CLIENT CERTIFICATE REQUIRED
|
||||
return nil, core.NewProtocolError(
|
||||
fmt.Sprintf("gemini client certificate required: %d %s", statusCode, meta), nil, core.SafeURL(req.URL))
|
||||
|
||||
case statusCode < 20 || statusCode >= 30:
|
||||
return nil, core.NewProtocolError(
|
||||
fmt.Sprintf("gemini server returned %d %s", statusCode, meta), nil, core.SafeURL(req.URL))
|
||||
}
|
||||
|
||||
// Category 2x: success — continue to read body
|
||||
var totalRead int64
|
||||
var writer io.Writer
|
||||
|
||||
if req.Writer != nil {
|
||||
writer = req.Writer
|
||||
} else {
|
||||
writer = io.Discard
|
||||
}
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
n, err := reader.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := writer.Write(buf[:n]); werr != nil {
|
||||
return nil, core.NewFileError("failed to write gemini data", werr)
|
||||
}
|
||||
totalRead += int64(n)
|
||||
if req.ProgressCallback != nil {
|
||||
speed := float64(totalRead) / time.Since(startTime).Seconds()
|
||||
req.ProgressCallback(totalRead, -1, speed)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, core.NewNetworkError("failed to read gemini response body", err, core.SafeURL(req.URL))
|
||||
}
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
speed := float64(totalRead) / duration.Seconds()
|
||||
|
||||
return &core.DownloadResult{
|
||||
BytesDownloaded: totalRead,
|
||||
TotalSize: totalRead,
|
||||
Duration: duration,
|
||||
Protocol: "GEMINI",
|
||||
IPVersion: 4,
|
||||
Speed: speed,
|
||||
OutputPath: req.Output,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
)
|
||||
|
||||
type stringWriter struct{ sb *strings.Builder }
|
||||
|
||||
func (w *stringWriter) Write(p []byte) (int, error) { return w.sb.Write(p) }
|
||||
|
||||
func testTLSConfig() *tls.Config {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
certDER, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{{
|
||||
Certificate: [][]byte{certDER},
|
||||
PrivateKey: key,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiSuccess(t *testing.T) {
|
||||
cfg := testTLSConfig()
|
||||
listener, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
conn, _ := listener.Accept()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
r.ReadString('\n')
|
||||
conn.Write([]byte("20 text/gemini\r\n# Hello\n\nContent.\n"))
|
||||
}()
|
||||
|
||||
proto := NewProtocol()
|
||||
proto.TLSInsecure = true
|
||||
u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/")
|
||||
var buf strings.Builder
|
||||
_, err = proto.Download(context.Background(), &core.DownloadRequest{
|
||||
URL: u,
|
||||
Writer: &stringWriter{&buf},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "Hello") {
|
||||
t.Errorf("got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiTemporaryFailure(t *testing.T) {
|
||||
cfg := testTLSConfig()
|
||||
listener, _ := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
conn, _ := listener.Accept()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
r.ReadString('\n')
|
||||
conn.Write([]byte("40 overloaded\r\n"))
|
||||
}()
|
||||
|
||||
proto := NewProtocol()
|
||||
proto.TLSInsecure = true
|
||||
u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/")
|
||||
_, err := proto.Download(context.Background(), &core.DownloadRequest{URL: u})
|
||||
if err == nil {
|
||||
t.Error("expected temporary failure error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiPermanentFailure(t *testing.T) {
|
||||
cfg := testTLSConfig()
|
||||
listener, _ := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
conn, _ := listener.Accept()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
r.ReadString('\n')
|
||||
conn.Write([]byte("51 not found\r\n"))
|
||||
}()
|
||||
|
||||
proto := NewProtocol()
|
||||
proto.TLSInsecure = true
|
||||
u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/")
|
||||
_, err := proto.Download(context.Background(), &core.DownloadRequest{URL: u})
|
||||
if err == nil {
|
||||
t.Error("expected permanent failure error")
|
||||
}
|
||||
}
|
||||
@@ -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 "❓"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user