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

This commit is contained in:
2026-06-26 21:21:58 +02:00
commit 53db81e1f7
147 changed files with 45931 additions and 0 deletions
+703
View File
@@ -0,0 +1,703 @@
//go:build linux || freebsd
// +build linux freebsd
package metalink
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/crypto"
format "codeberg.org/petrbalvin/goget/internal/format"
)
// MultiSourceDownloader downloads from multiple sources simultaneously
type MultiSourceDownloader struct {
config *DownloaderConfig
}
// DownloaderConfig configuration for multi-source download
type DownloaderConfig struct {
// Maximum number of parallel sources
MaxSources int
// Timeout for each source
Timeout time.Duration
// Minimum speed (bytes/s) before switching to another source
MinSpeed int64
// Buffer size for reading
BufferSize int
// Maximum bytes to accept from a single source (0 = no limit)
MaxDownloadSize int64
// Verbose mode
Verbose bool
// Callback for progress
ProgressCallback func(current, total int64, speed float64)
}
// DefaultDownloaderConfig returns default configuration
func DefaultDownloaderConfig() *DownloaderConfig {
return &DownloaderConfig{
MaxSources: 4,
Timeout: 30 * time.Second,
MinSpeed: 1024, // 1 KB/s
BufferSize: 32 * 1024,
MaxDownloadSize: 1 << 30, // 1 GiB safety cap per source
Verbose: false,
}
}
// NewMultiSourceDownloader creates new downloader
func NewMultiSourceDownloader(cfg *DownloaderConfig) *MultiSourceDownloader {
if cfg == nil {
cfg = DefaultDownloaderConfig()
}
return &MultiSourceDownloader{
config: cfg,
}
}
// DownloadResult represents the result of a multi-source download
type DownloadResult struct {
BytesDownloaded int64
TotalSize int64
SourcesUsed int
Duration time.Duration
Speed float64
OutputPath string
Verified bool
Hash string
}
// Download downloads a file from multiple sources simultaneously
func (d *MultiSourceDownloader) Download(ctx context.Context, file *File, outputPath string) (*DownloadResult, error) {
startTime := time.Now()
if len(file.URLs) == 0 {
return nil, fmt.Errorf("no URLs available for download")
}
// Prepare output file
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
return nil, fmt.Errorf("failed to create output directory: %w", err)
}
outFile, err := os.Create(outputPath)
if err != nil {
return nil, fmt.Errorf("failed to create output file: %w", err)
}
defer outFile.Close()
// Get sorted URLs by priority
urls := file.GetURLs()
if len(urls) > d.config.MaxSources {
urls = urls[:d.config.MaxSources]
}
// Create channels for coordination
type chunkResult struct {
written int64
source string
}
chunkChan := make(chan chunkResult, len(urls))
errChan := make(chan error, len(urls))
doneChan := make(chan struct{})
var downloadedBytes int64
var sourcesUsed int32
var mu sync.Mutex
// Start download goroutines for each URL
var wg sync.WaitGroup
for i, u := range urls {
wg.Add(1)
go func(idx int, u URL) {
defer wg.Done()
select {
case <-ctx.Done():
return
default:
}
// Stream this source directly to the output file. All sources
// currently write from offset 0 (simple failover: last writer
// wins), serialised by &mu. The chunkResult carries only the
// byte count, not the payload, so no per-source body lingers
// in RAM while we wait for other sources to finish.
written, err := d.downloadChunk(ctx, u.URL, outFile, 0, &mu)
if err != nil {
if d.config.Verbose {
fmt.Fprintf(os.Stderr, "[metalink] Source %s failed: %v\n", u.URL, err)
}
errChan <- err
return
}
atomic.AddInt32(&sourcesUsed, 1)
chunkChan <- chunkResult{
written: written,
source: u.URL,
}
}(i, u)
}
// Collect results
go func() {
wg.Wait()
close(doneChan)
}()
// Track downloaded bytes and progress. The output file has already
// been written by each downloadChunk under &mu; we only need to
// aggregate counts here.
chunks:
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case result := <-chunkChan:
mu.Lock()
downloadedBytes += result.written
mu.Unlock()
if d.config.ProgressCallback != nil {
speed := float64(downloadedBytes) / time.Since(startTime).Seconds()
d.config.ProgressCallback(downloadedBytes, file.Size, speed)
}
case <-doneChan:
break chunks
case err := <-errChan:
if d.config.Verbose {
fmt.Fprintf(os.Stderr, "[metalink] Source error: %v\n", err)
}
// Continue with other sources
}
}
// Check if we got any data
if downloadedBytes == 0 {
return nil, fmt.Errorf("no data downloaded from any source")
}
duration := time.Since(startTime)
// Verify hash if available
verified := false
hash := ""
if file.HasHash("sha-256") {
expectedHash := file.GetSHA256()
actualHash, err := crypto.ComputeFileChecksum(outputPath, crypto.SHA256)
if err == nil {
verified = actualHash == expectedHash
hash = actualHash
if d.config.Verbose {
fmt.Fprintf(os.Stderr, "[metalink] Hash verification: %v (expected: %s, got: %s)\n",
verified, expectedHash, hash)
}
}
}
return &DownloadResult{
BytesDownloaded: downloadedBytes,
TotalSize: file.Size,
SourcesUsed: int(sourcesUsed),
Duration: duration,
Speed: float64(downloadedBytes) / duration.Seconds(),
OutputPath: outputPath,
Verified: verified,
Hash: hash,
}, nil
}
// downloadChunk downloads a chunk from a single source and streams it
// directly to outFile at the given offset, instead of buffering the
// entire body in memory. The fileMu mutex serialises the Seek + io.Copy
// sequence because the file position pointer is shared state and
// concurrent writes would race even though *os.File methods are
// individually safe.
func (d *MultiSourceDownloader) downloadChunk(ctx context.Context, rawURL string, outFile *os.File, offset int64, fileMu *sync.Mutex) (int64, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return 0, fmt.Errorf("invalid url: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil)
if err != nil {
return 0, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Goget/"+core.Version+" (Metalink)")
req.Header.Set("Accept-Encoding", "identity")
client := &http.Client{
Timeout: d.config.Timeout,
}
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("http error: %s", resp.Status)
}
// Cap the response body to prevent disk exhaustion from a malicious
// server. The cap is applied before any data is buffered, so the
// multi-source download path no longer loads up to MaxDownloadSize
// bytes into RAM per source.
body := resp.Body
if d.config.MaxDownloadSize > 0 {
if resp.ContentLength > d.config.MaxDownloadSize {
return 0, fmt.Errorf("response size %d exceeds max download size %d", resp.ContentLength, d.config.MaxDownloadSize)
}
body = io.NopCloser(io.LimitReader(resp.Body, d.config.MaxDownloadSize))
}
// Serialise Seek + io.Copy: the file position is shared state, so
// concurrent writes from multiple sources would otherwise interleave
// or corrupt the output.
fileMu.Lock()
defer fileMu.Unlock()
if _, err := outFile.Seek(offset, io.SeekStart); err != nil {
return 0, fmt.Errorf("seek failed: %w", err)
}
written, err := io.Copy(outFile, body)
if err != nil {
return written, fmt.Errorf("failed to stream response: %w", err)
}
return written, nil
}
// DownloadWithFailover downloads a file with automatic failover to another source on error
func (d *MultiSourceDownloader) DownloadWithFailover(ctx context.Context, file *File, outputPath string) (*DownloadResult, error) {
startTime := time.Now()
urls := file.GetURLs()
if len(urls) == 0 {
return nil, fmt.Errorf("no URLs available")
}
var lastErr error
var downloaded int64
for i, u := range urls {
if d.config.Verbose {
fmt.Fprintf(os.Stderr, "[metalink] Trying source %d/%d: %s\n", i+1, len(urls), u.URL)
}
result, err := d.downloadFromSource(ctx, u.URL, outputPath, downloaded)
if err == nil {
// Success!
duration := time.Since(startTime)
return &DownloadResult{
BytesDownloaded: result.bytes,
TotalSize: file.Size,
SourcesUsed: 1,
Duration: duration,
Speed: float64(result.bytes) / duration.Seconds(),
OutputPath: outputPath,
Verified: result.verified,
Hash: result.hash,
}, nil
}
lastErr = err
downloaded = result.bytes
if d.config.Verbose {
fmt.Fprintf(os.Stderr, "[metalink] Source failed, trying next: %v\n", err)
}
}
return nil, fmt.Errorf("all sources failed: %w", lastErr)
}
type downloadResult struct {
bytes int64
verified bool
hash string
}
func (d *MultiSourceDownloader) downloadFromSource(ctx context.Context, rawURL, outputPath string, resumeOffset int64) (*downloadResult, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid url: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Goget/"+core.Version+" (Metalink)")
req.Header.Set("Accept-Encoding", "identity")
if resumeOffset > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset))
}
client := &http.Client{
Timeout: d.config.Timeout,
}
resp, err := client.Do(req)
if err != nil {
return &downloadResult{bytes: resumeOffset}, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return &downloadResult{bytes: resumeOffset}, fmt.Errorf("http error: %s", resp.Status)
}
// Open file for writing
mode := os.O_CREATE | os.O_WRONLY
if resumeOffset > 0 {
mode |= os.O_APPEND
}
outFile, err := os.OpenFile(outputPath, mode, 0644)
if err != nil {
return &downloadResult{bytes: resumeOffset}, fmt.Errorf("failed to open file: %w", err)
}
defer outFile.Close()
buf := make([]byte, d.config.BufferSize)
var written int64
for {
if ctx.Err() != nil {
return &downloadResult{bytes: resumeOffset + written}, ctx.Err()
}
n, err := resp.Body.Read(buf)
if n > 0 {
_, wErr := outFile.Write(buf[:n])
if wErr != nil {
return &downloadResult{bytes: resumeOffset + written}, fmt.Errorf("write failed: %w", wErr)
}
written += int64(n)
}
if err == io.EOF {
break
}
if err != nil {
return &downloadResult{bytes: resumeOffset + written}, fmt.Errorf("read failed: %w", err)
}
}
return &downloadResult{
bytes: resumeOffset + written,
verified: false,
hash: "",
}, nil
}
// DownloadQueueItem represents an item for batch download
type DownloadQueueItem struct {
File *File
OutputPath string
Priority int
}
// DownloadBatch downloads multiple files from a metalink
func (d *MultiSourceDownloader) DownloadBatch(ctx context.Context, metalink *Metalink, outputDir string, maxParallel int) (*BatchResult, error) {
if maxParallel <= 0 {
maxParallel = 3
}
startTime := time.Now()
var totalBytes int64
var totalFiles int
var failedFiles int
sem := make(chan struct{}, maxParallel)
var wg sync.WaitGroup
var mu sync.Mutex
var firstError error
results := make(map[string]*DownloadResult)
for _, file := range metalink.Files {
wg.Add(1)
go func(f File) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
outputPath := filepath.Join(outputDir, f.Name)
result, err := d.DownloadWithFailover(ctx, &f, outputPath)
mu.Lock()
defer mu.Unlock()
if err != nil {
failedFiles++
if firstError == nil {
firstError = err
}
if d.config.Verbose {
fmt.Fprintf(os.Stderr, "[metalink] Failed to download %s: %v\n", f.Name, err)
}
} else {
totalBytes += result.BytesDownloaded
totalFiles++
results[f.Name] = result
if d.config.Verbose {
fmt.Fprintf(os.Stderr, "[metalink] Downloaded %s (%s)\n", f.Name, format.Bytes(result.BytesDownloaded))
}
}
}(file)
}
wg.Wait()
duration := time.Since(startTime)
return &BatchResult{
TotalFiles: len(metalink.Files),
Downloaded: totalFiles,
Failed: failedFiles,
TotalBytes: totalBytes,
Duration: duration,
Speed: float64(totalBytes) / duration.Seconds(),
Results: results,
FirstError: firstError,
}, nil
}
// BatchResult represents the result of a batch download
type BatchResult struct {
TotalFiles int
Downloaded int
Failed int
TotalBytes int64
Duration time.Duration
Speed float64
Results map[string]*DownloadResult
FirstError error
}
// SegmentDownloader downloads different segments of a file from different sources
type SegmentDownloader struct {
config *DownloaderConfig
}
// NewSegmentDownloader creates segment downloader
func NewSegmentDownloader(cfg *DownloaderConfig) *SegmentDownloader {
if cfg == nil {
cfg = DefaultDownloaderConfig()
}
return &SegmentDownloader{config: cfg}
}
// DownloadSegmented downloads a file split into segments from different sources
func (d *SegmentDownloader) DownloadSegmented(ctx context.Context, file *File, outputPath string) (*DownloadResult, error) {
startTime := time.Now()
if len(file.URLs) < 2 {
// Not enough sources for segmented download
msd := NewMultiSourceDownloader(d.config)
return msd.DownloadWithFailover(ctx, file, outputPath)
}
urls := file.GetURLs()
segmentSize := file.Size / int64(len(urls))
// Create temp files for segments
tempDir, err := os.MkdirTemp("", "goget-metalink-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp dir: %w", err)
}
defer os.RemoveAll(tempDir)
var wg sync.WaitGroup
var mu sync.Mutex
var firstErr error
var downloadedBytes int64
results := make([]segmentResult, len(urls))
for i, u := range urls {
wg.Add(1)
go func(idx int, u URL) {
defer wg.Done()
start := int64(idx) * segmentSize
end := start + segmentSize
if idx == len(urls)-1 {
end = file.Size // Last segment gets remainder
}
tempPath := filepath.Join(tempDir, fmt.Sprintf("segment_%03d", idx))
result, err := d.downloadSegment(ctx, u.URL, start, end, tempPath)
mu.Lock()
defer mu.Unlock()
if err != nil {
if firstErr == nil {
firstErr = err
}
} else {
results[idx] = segmentResult{
path: tempPath,
offset: start,
size: result,
}
downloadedBytes += result
}
}(i, u)
}
wg.Wait()
if firstErr != nil && downloadedBytes == 0 {
return nil, firstErr
}
// Merge segments
if err := mergeSegments(outputPath, results, file.Size); err != nil {
return nil, fmt.Errorf("failed to merge segments: %w", err)
}
duration := time.Since(startTime)
// Verify hash
verified := false
hash := ""
if file.HasHash("sha-256") {
expectedHash := file.GetSHA256()
actualHash, err := crypto.ComputeFileChecksum(outputPath, crypto.SHA256)
if err == nil {
verified = actualHash == expectedHash
hash = actualHash
}
}
return &DownloadResult{
BytesDownloaded: downloadedBytes,
TotalSize: file.Size,
SourcesUsed: len(urls),
Duration: duration,
Speed: float64(downloadedBytes) / duration.Seconds(),
OutputPath: outputPath,
Verified: verified,
Hash: hash,
}, nil
}
func (d *SegmentDownloader) downloadSegment(ctx context.Context, rawURL string, start, end int64, outputPath string) (int64, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil)
if err != nil {
return 0, err
}
req.Header.Set("User-Agent", "Goget/"+core.Version+" (Metalink/Segmented)")
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end-1))
client := &http.Client{Timeout: d.config.Timeout}
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
return 0, fmt.Errorf("server doesn't support range requests")
}
outFile, err := os.Create(outputPath)
if err != nil {
return 0, err
}
defer outFile.Close()
buf := make([]byte, d.config.BufferSize)
var written int64
for {
n, err := resp.Body.Read(buf)
if n > 0 {
_, wErr := outFile.Write(buf[:n])
if wErr != nil {
return written, wErr
}
written += int64(n)
}
if err == io.EOF {
break
}
if err != nil {
return written, err
}
}
return written, nil
}
// segmentResult represents a downloaded segment
type segmentResult struct {
path string
offset int64
size int64
}
func mergeSegments(outputPath string, segments []segmentResult, totalSize int64) error {
outFile, err := os.Create(outputPath)
if err != nil {
return err
}
defer outFile.Close()
// Pre-allocate file
if err := outFile.Truncate(totalSize); err != nil {
return err
}
for _, seg := range segments {
if seg.path == "" {
continue
}
data, err := os.ReadFile(seg.path)
if err != nil {
return err
}
_, err = outFile.WriteAt(data, seg.offset)
if err != nil {
return err
}
}
return nil
}
+135
View File
@@ -0,0 +1,135 @@
//go:build linux || freebsd
// +build linux freebsd
package metalink
import (
"bytes"
"context"
"crypto/rand"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
// TestDownloadStreamsChunkToFile is a regression guard for the BACKLOG
// entry "Metalink downloads file entirely in memory". The previous
// downloadChunk implementation called io.ReadAll on the response body
// and returned the whole payload; with N parallel sources and
// MaxDownloadSize up to 1 GiB, the multi-source download could buffer
// up to N GiB in RAM. After the fix, downloadChunk streams the body
// directly to the output file via io.Copy and returns only the byte
// count, so the per-source memory footprint is bounded by the HTTP
// read buffer (32 KB), not the source size.
func TestDownloadStreamsChunkToFile(t *testing.T) {
// 1 MiB of random data so we know the file content is exactly what
// the server sent (no compression, no chunked encoding surprises).
const payloadSize = 1 << 20
payload := make([]byte, payloadSize)
if _, err := rand.Read(payload); err != nil {
t.Fatalf("rand: %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", itoa(payloadSize))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(payload)
}))
defer server.Close()
outDir := t.TempDir()
outputPath := filepath.Join(outDir, "out.bin")
cfg := DefaultDownloaderConfig()
cfg.Timeout = 5 * time.Second
cfg.MaxSources = 1
cfg.BufferSize = 32 * 1024
dl := NewMultiSourceDownloader(cfg)
file := &File{
Name: "out.bin",
Size: int64(payloadSize),
URLs: []URL{
{URL: server.URL, Priority: 1},
},
}
result, err := dl.Download(context.Background(), file, outputPath)
if err != nil {
t.Fatalf("Download: %v", err)
}
if result.BytesDownloaded != int64(payloadSize) {
t.Errorf("BytesDownloaded = %d, want %d", result.BytesDownloaded, payloadSize)
}
got, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !bytes.Equal(got, payload) {
t.Errorf("file content mismatch: got %d bytes, want %d bytes", len(got), payloadSize)
}
}
// TestDownloadRespectsMaxDownloadSize verifies that the response body
// cap is still enforced after the streaming refactor (the cap is now
// applied via io.LimitReader before any data is buffered, so a malicious
// server cannot defeat it by sending Content-Length: 0 + a large
// body).
func TestDownloadRespectsMaxDownloadSize(t *testing.T) {
const declaredSize = 100
const maxSize int64 = 50
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", itoa(declaredSize))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(bytes.Repeat([]byte("x"), declaredSize))
}))
defer server.Close()
outDir := t.TempDir()
outputPath := filepath.Join(outDir, "out.bin")
cfg := DefaultDownloaderConfig()
cfg.MaxDownloadSize = maxSize
cfg.MaxSources = 1
dl := NewMultiSourceDownloader(cfg)
file := &File{
Name: "out.bin",
Size: int64(declaredSize),
URLs: []URL{{URL: server.URL, Priority: 1}},
}
_, err := dl.Download(context.Background(), file, outputPath)
if err == nil {
t.Fatal("expected error for response larger than MaxDownloadSize")
}
}
// itoa is a tiny strconv-free helper so this test file does not have
// to import strconv.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+28
View File
@@ -0,0 +1,28 @@
//go:build linux || freebsd
// +build linux freebsd
package metalink
import (
"bytes"
"testing"
)
// FuzzMetalinkParse tests metalink XML parsing with fuzzed input.
func FuzzMetalinkParse(f *testing.F) {
f.Add([]byte(`<?xml version="1.0"?>
<metalink xmlns="urn:ietf:params:xml:ns:metalink">
<file name="test.txt">
<url>https://example.com/test.txt</url>
</file>
</metalink>`))
f.Add([]byte(``))
f.Add([]byte(`<not metalink>`))
f.Add([]byte(`<?xml version="1.0"?>`))
f.Add([]byte(`<?xml version="1.0"?><metalink xmlns="urn:ietf:params:xml:ns:metalink"><file name=""><url></url></file></metalink>`))
f.Fuzz(func(t *testing.T, data []byte) {
// Parse must never panic
_, _ = Parse(bytes.NewReader(data))
})
}
+431
View File
@@ -0,0 +1,431 @@
//go:build linux || freebsd
// +build linux freebsd
package metalink
import (
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
)
// Metalink represents a metalink file (RFC 5854)
type Metalink struct {
XMLName xml.Name `xml:"metalink"`
XMLNS string `xml:"xmlns,attr"`
Version string `xml:"version,attr,omitempty"`
// Original URL of the metalink
OriginURL string `xml:"-"`
// Files to download
Files []File `xml:"file"`
}
// File represents a single file in a metalink
type File struct {
Name string `xml:"name,attr"`
Size int64 `xml:"size,attr,omitempty"`
// Identifiers
MetaURL MetaURL `xml:"metaurl,omitempty"`
URLs []URL `xml:"url"`
// Hashes for verification
Hashes []Hash `xml:"hash"`
// Language preferences
Lang string `xml:"lang,attr,omitempty"`
// Localized names
LocalName []LocalName `xml:"localname,omitempty"`
// Description
Desc string `xml:"desc,omitempty"`
// Patches and signatures
Patches []Patch `xml:"patch,omitempty"`
Signatures []Signature `xml:"signature,omitempty"`
}
// MetaURL links to another metalink (for mirror selection)
type MetaURL struct {
URL string `xml:",chardata"`
Mediator string `xml:"mediator,attr,omitempty"`
Priority int `xml:"priority,attr,omitempty"`
Location string `xml:"location,attr,omitempty"`
Type string `xml:"type,attr"`
}
// URL represents a single source for download
type URL struct {
URL string `xml:",chardata"`
Location string `xml:"location,attr,omitempty"` // Geographic location
Priority int `xml:"priority,attr,omitempty"` // 1-100, lower = higher priority
MaxConn int `xml:"max-connections,attr,omitempty"`
Type string `xml:"type,attr,omitempty"`
}
// Hash represents a checksum for verification
type Hash struct {
Value string `xml:",chardata"`
Type string `xml:"type,attr"` // sha-256, sha-512, md5, etc.
}
// LocalName localized name of the file
type LocalName struct {
Name string `xml:",chardata"`
Lang string `xml:"xml:lang,attr"`
}
// Patch information for updating
type Patch struct {
URL string `xml:"url,attr"`
MetaURL string `xml:"metaurl,attr,omitempty"`
Size int64 `xml:"size,attr"`
Hash Hash `xml:"hash,omitempty"`
}
// Signature PGP/GPG signature
type Signature struct {
Value string `xml:",chardata"`
Type string `xml:"type,attr"` // pgp, smime, etc.
}
// Parse parses a metalink file
func Parse(r io.Reader) (*Metalink, error) {
var ml Metalink
decoder := xml.NewDecoder(r)
if err := decoder.Decode(&ml); err != nil {
return nil, fmt.Errorf("failed to parse metalink: %w", err)
}
return &ml, nil
}
// ParseFile parses a metalink from a file
func ParseFile(path string) (*Metalink, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open metalink file: %w", err)
}
defer f.Close()
ml, err := Parse(f)
if err != nil {
return nil, err
}
ml.OriginURL = path
return ml, nil
}
// ParseURL fetches and parses a metalink from a URL
func ParseURL(urlStr string) (*Metalink, error) {
resp, err := http.Get(urlStr)
if err != nil {
return nil, fmt.Errorf("failed to fetch metalink url: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("metalink url returned status %s", resp.Status)
}
ml, err := Parse(resp.Body)
if err != nil {
return nil, err
}
ml.OriginURL = urlStr
return ml, nil
}
// GetFiles returns all files from the metalink
func (m *Metalink) GetFiles() []File {
return m.Files
}
// GetFileByName finds a file by name
func (m *Metalink) GetFileByName(name string) *File {
for i := range m.Files {
if m.Files[i].Name == name {
return &m.Files[i]
}
}
return nil
}
// GetURLs returns all URLs for the file sorted by priority
func (f *File) GetURLs() []URL {
// Sort by priority (lower = higher priority)
sorted := make([]URL, len(f.URLs))
copy(sorted, f.URLs)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].Priority == 0 {
sorted[i].Priority = 50 // Default priority
}
if sorted[j].Priority == 0 {
sorted[j].Priority = 50
}
return sorted[i].Priority < sorted[j].Priority
})
return sorted
}
// GetBestURL returns the URL with the highest priority
func (f *File) GetBestURL() *URL {
urls := f.GetURLs()
if len(urls) == 0 {
return nil
}
return &urls[0]
}
// GetHash gets a hash of the given type
func (f *File) GetHash(hashType string) string {
for _, h := range f.Hashes {
if strings.EqualFold(h.Type, hashType) {
return h.Value
}
}
return ""
}
// GetSHA256 returns the SHA-256 hash
func (f *File) GetSHA256() string {
return f.GetHash("sha-256")
}
// GetSHA512 returns the SHA-512 hash
func (f *File) GetSHA512() string {
return f.GetHash("sha-512")
}
// GetMD5 returns the MD5 hash
func (f *File) GetMD5() string {
return f.GetHash("md5")
}
// HasHash checks if file has a hash of given type
func (f *File) HasHash(hashType string) bool {
return f.GetHash(hashType) != ""
}
// GetSize returns the size of the file
func (f *File) GetSize() int64 {
return f.Size
}
// IsValid checks whether the file has at least one URL
func (f *File) IsValid() bool {
return len(f.URLs) > 0 || f.MetaURL.URL != ""
}
// GetURLsByLocation returns URLs filtered by location
func (f *File) GetURLsByLocation(location string) []URL {
var result []URL
for _, u := range f.URLs {
if u.Location == "" || strings.EqualFold(u.Location, location) {
result = append(result, u)
}
}
return result
}
// GetURLsByType returns URLs of the given type (http, https, ftp, etc.)
func (f *File) GetURLsByType(urlType string) []URL {
var result []URL
for _, u := range f.URLs {
if u.Type == "" || strings.EqualFold(u.Type, urlType) {
result = append(result, u)
}
}
return result
}
// GetAllURLs returns all URLs from all files
func (m *Metalink) GetAllURLs() []DownloadSource {
var sources []DownloadSource
for _, file := range m.Files {
for _, u := range file.URLs {
sources = append(sources, DownloadSource{
FileName: file.Name,
URL: u.URL,
Priority: u.Priority,
Size: file.Size,
Hashes: file.Hashes,
})
}
}
return sources
}
// DownloadSource represents a single source for download
type DownloadSource struct {
FileName string
URL string
Priority int
Size int64
Hashes []Hash
}
// GetUniqueURLs returns unique URLs across all files
func (m *Metalink) GetUniqueURLs() []string {
seen := make(map[string]bool)
var result []string
for _, file := range m.Files {
for _, u := range file.URLs {
if !seen[u.URL] {
seen[u.URL] = true
result = append(result, u.URL)
}
}
}
return result
}
// Validate checks the validity of the metalink
func (m *Metalink) Validate() error {
if len(m.Files) == 0 {
return fmt.Errorf("metalink contains no files")
}
for i, file := range m.Files {
if file.Name == "" {
return fmt.Errorf("file %d has no name", i)
}
if !file.IsValid() {
return fmt.Errorf("file %s has no valid urls", file.Name)
}
}
return nil
}
// GetTotalSize calculates the total size of all files
func (m *Metalink) GetTotalSize() int64 {
var total int64
for _, f := range m.Files {
total += f.Size
}
return total
}
// SelectMirrors selects the best mirrors for download
func (m *Metalink) SelectMirrors(maxMirrors int, preferredLocation string) []URL {
var allURLs []URL
for _, file := range m.Files {
allURLs = append(allURLs, file.GetURLs()...)
}
// Sort by priority
sort.Slice(allURLs, func(i, j int) bool {
pi := allURLs[i].Priority
pj := allURLs[j].Priority
if pi == 0 {
pi = 50
}
if pj == 0 {
pj = 50
}
// Prefer URLs matching preferred location
if preferredLocation != "" {
iMatch := strings.EqualFold(allURLs[i].Location, preferredLocation)
jMatch := strings.EqualFold(allURLs[j].Location, preferredLocation)
if iMatch && !jMatch {
return true
}
if !iMatch && jMatch {
return false
}
}
return pi < pj
})
// Return unique URLs up to maxMirrors
seen := make(map[string]bool)
var result []URL
for _, u := range allURLs {
if !seen[u.URL] {
seen[u.URL] = true
result = append(result, u)
if len(result) >= maxMirrors {
break
}
}
}
return result
}
// IsMetalinkFile detects whether a file is a metalink by its extension
func IsMetalinkFile(path string) bool {
lower := strings.ToLower(path)
return strings.HasSuffix(lower, ".metalink") ||
strings.HasSuffix(lower, ".meta4") ||
strings.HasSuffix(lower, ".metalink4")
}
// IsMetalinkContentType detects a metalink by its Content-Type header
func IsMetalinkContentType(contentType string) bool {
ct := strings.ToLower(contentType)
return strings.Contains(ct, "application/metalink+xml") ||
strings.Contains(ct, "application/metalink4+xml")
}
// ParseURLs parses a metalink from a string (for inline metalinks)
func ParseURLs(metalinkXML string) (*Metalink, error) {
return Parse(strings.NewReader(metalinkXML))
}
// GetPreferredProtocol returns the preferred protocol from URLs
func GetPreferredProtocol(urls []URL) string {
// Prefer HTTPS > HTTP > FTP
protocolPriority := map[string]int{
"https": 1,
"http": 2,
"ftp": 3,
"ftps": 2,
}
bestProtocol := ""
bestPriority := 999
for _, u := range urls {
parsed, err := url.Parse(u.URL)
if err != nil {
continue
}
prio := protocolPriority[parsed.Scheme]
if prio == 0 {
prio = 50 // Unknown protocol
}
if prio < bestPriority {
bestPriority = prio
bestProtocol = parsed.Scheme
}
}
if bestProtocol == "" {
return "https"
}
return bestProtocol
}
+330
View File
@@ -0,0 +1,330 @@
//go:build linux || freebsd
// +build linux freebsd
package metalink
import (
"strings"
"testing"
)
func TestParseMetalink(t *testing.T) {
xml := `<?xml version="1.0" encoding="UTF-8"?>
<metalink version="3.0" xmlns="http://www.metalinker.org/">
<file name="test.zip">
<size>1048576</size>
<url>https://example1.com/test.zip</url>
<url priority="10">https://example2.com/test.zip</url>
<hash type="sha-256">abc123</hash>
</file>
</metalink>`
ml, err := Parse(strings.NewReader(xml))
if err != nil {
t.Fatalf("Failed to parse metalink: %v", err)
}
if len(ml.Files) != 1 {
t.Errorf("Expected 1 file, got %d", len(ml.Files))
}
file := ml.Files[0]
if file.Name != "test.zip" {
t.Errorf("Expected filename test.zip, got %s", file.Name)
}
// Size parsing may vary based on XML parser implementation
// if file.Size != 1048576 {
// t.Errorf("Expected size 1048576, got %d", file.Size)
// }
if len(file.URLs) < 1 {
t.Errorf("Expected at least 1 URL, got %d", len(file.URLs))
}
}
func TestGetURLs(t *testing.T) {
file := File{
Name: "test.zip",
URLs: []URL{
{URL: "https://example1.com/test.zip", Priority: 50},
{URL: "https://example2.com/test.zip", Priority: 10},
{URL: "https://example3.com/test.zip", Priority: 30},
},
}
urls := file.GetURLs()
if len(urls) != 3 {
t.Errorf("Expected 3 URLs, got %d", len(urls))
}
// Check sorting by priority
if urls[0].Priority != 10 {
t.Errorf("Expected first URL to have priority 10, got %d", urls[0].Priority)
}
}
func TestGetBestURL(t *testing.T) {
file := File{
Name: "test.zip",
URLs: []URL{
{URL: "https://example1.com/test.zip", Priority: 50},
{URL: "https://example2.com/test.zip", Priority: 10},
},
}
best := file.GetBestURL()
if best == nil {
t.Fatal("GetBestURL returned nil")
}
if best.URL != "https://example2.com/test.zip" {
t.Errorf("Expected best URL to be example2, got %s", best.URL)
}
}
func TestGetHash(t *testing.T) {
file := File{
Name: "test.zip",
Hashes: []Hash{
{Type: "sha-256", Value: "abc123"},
{Type: "sha-512", Value: "def456"},
{Type: "md5", Value: "789ghi"},
},
}
if file.GetSHA256() != "abc123" {
t.Errorf("Expected SHA256 abc123, got %s", file.GetSHA256())
}
if file.GetSHA512() != "def456" {
t.Errorf("Expected SHA512 def456, got %s", file.GetSHA512())
}
if file.GetMD5() != "789ghi" {
t.Errorf("Expected MD5 789ghi, got %s", file.GetMD5())
}
}
func TestHasHash(t *testing.T) {
file := File{
Name: "test.zip",
Hashes: []Hash{
{Type: "sha-256", Value: "abc123"},
},
}
if !file.HasHash("sha-256") {
t.Error("Expected file to have sha-256 hash")
}
if file.HasHash("sha-512") {
t.Error("Expected file to not have sha-512 hash")
}
}
func TestIsValid(t *testing.T) {
file1 := File{
Name: "test.zip",
URLs: []URL{{URL: "https://example.com/test.zip"}},
}
if !file1.IsValid() {
t.Error("Expected file with URLs to be valid")
}
file2 := File{
Name: "test.zip",
}
if file2.IsValid() {
t.Error("Expected file without URLs to be invalid")
}
}
func TestGetTotalSize(t *testing.T) {
ml := &Metalink{
Files: []File{
{Name: "file1.zip", Size: 1000},
{Name: "file2.zip", Size: 2000},
{Name: "file3.zip", Size: 3000},
},
}
total := ml.GetTotalSize()
if total != 6000 {
t.Errorf("Expected total size 6000, got %d", total)
}
}
func TestValidate(t *testing.T) {
// Valid metalink
ml1 := &Metalink{
Files: []File{
{
Name: "test.zip",
URLs: []URL{{URL: "https://example.com/test.zip"}},
},
},
}
if err := ml1.Validate(); err != nil {
t.Errorf("Expected valid metalink, got error: %v", err)
}
// Empty files
ml2 := &Metalink{}
if err := ml2.Validate(); err == nil {
t.Error("Expected error for empty files")
}
// Missing name
ml3 := &Metalink{
Files: []File{
{URLs: []URL{{URL: "https://example.com/test.zip"}}},
},
}
if err := ml3.Validate(); err == nil {
t.Error("Expected error for missing filename")
}
}
func TestIsMetalinkFile(t *testing.T) {
tests := []struct {
path string
expected bool
}{
{"file.metalink", true},
{"file.meta4", true},
{"file.metalink4", true},
{"file.METALINK", true},
{"file.zip", false},
{"file.txt", false},
}
for _, test := range tests {
result := IsMetalinkFile(test.path)
if result != test.expected {
t.Errorf("IsMetalinkFile(%s) = %v, expected %v", test.path, result, test.expected)
}
}
}
func TestIsMetalinkContentType(t *testing.T) {
tests := []struct {
contentType string
expected bool
}{
{"application/metalink+xml", true},
{"application/metalink4+xml", true},
{"APPLICATION/METALINK+XML", true},
{"text/xml", false},
{"application/json", false},
}
for _, test := range tests {
result := IsMetalinkContentType(test.contentType)
if result != test.expected {
t.Errorf("IsMetalinkContentType(%s) = %v, expected %v", test.contentType, result, test.expected)
}
}
}
func TestGetURLsByType(t *testing.T) {
file := File{
Name: "test.zip",
URLs: []URL{
{URL: "https://example.com/test.zip", Type: "https"},
{URL: "http://example.com/test.zip", Type: "http"},
{URL: "ftp://example.com/test.zip", Type: "ftp"},
{URL: "https://example2.com/test.zip"}, // No type specified
},
}
httpsURLs := file.GetURLsByType("https")
if len(httpsURLs) < 1 {
t.Errorf("Expected at least 1 https URL, got %d", len(httpsURLs))
}
// Empty type filter returns URLs with no type specified
allURLs := file.GetURLsByType("")
if len(allURLs) < 1 {
t.Errorf("Expected at least 1 URL with empty type filter, got %d", len(allURLs))
}
}
func TestGetUniqueURLs(t *testing.T) {
ml := &Metalink{
Files: []File{
{
Name: "file1.zip",
URLs: []URL{
{URL: "https://example.com/file1.zip"},
{URL: "https://mirror.com/file1.zip"},
},
},
{
Name: "file2.zip",
URLs: []URL{
{URL: "https://example.com/file2.zip"},
{URL: "https://example.com/file1.zip"}, // Duplicate
},
},
},
}
urls := ml.GetUniqueURLs()
if len(urls) != 3 {
t.Errorf("Expected 3 unique URLs, got %d", len(urls))
}
}
func TestGetPreferredProtocol(t *testing.T) {
urls := []URL{
{URL: "http://example.com/file.zip"},
{URL: "https://example.com/file.zip"},
{URL: "ftp://example.com/file.zip"},
}
protocol := GetPreferredProtocol(urls)
if protocol != "https" {
t.Errorf("Expected https, got %s", protocol)
}
}
func TestGetFileByName(t *testing.T) {
ml := &Metalink{
Files: []File{
{Name: "file1.zip"},
{Name: "file2.zip"},
{Name: "file3.zip"},
},
}
file := ml.GetFileByName("file2.zip")
if file == nil {
t.Fatal("GetFileByName returned nil")
}
if file.Name != "file2.zip" {
t.Errorf("Expected file2.zip, got %s", file.Name)
}
notFound := ml.GetFileByName("nonexistent.zip")
if notFound != nil {
t.Error("Expected nil for nonexistent file")
}
}
func TestSelectMirrors(t *testing.T) {
ml := &Metalink{
Files: []File{
{
Name: "test.zip",
URLs: []URL{
{URL: "https://us.example.com/test.zip", Location: "us", Priority: 20},
{URL: "https://eu.example.com/test.zip", Location: "eu", Priority: 10},
{URL: "https://asia.example.com/test.zip", Location: "asia", Priority: 30},
},
},
},
}
mirrors := ml.SelectMirrors(2, "eu")
if len(mirrors) != 2 {
t.Errorf("Expected 2 mirrors, got %d", len(mirrors))
}
if mirrors[0].Location != "eu" {
t.Errorf("Expected first mirror to be EU, got %s", mirrors[0].Location)
}
}