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

This commit is contained in:
2026-06-26 21:21:58 +02:00
commit 53db81e1f7
147 changed files with 45931 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"fmt"
"os"
"path/filepath"
)
type ExtractConfig struct {
DestDir string
StripComponents int
IncludePatterns []string
ExcludePatterns []string
PreservePermissions bool
Overwrite bool
Verbose bool
}
func DefaultExtractConfig() *ExtractConfig {
return &ExtractConfig{
DestDir: ".", StripComponents: 0, IncludePatterns: []string{},
ExcludePatterns: []string{}, PreservePermissions: true, Overwrite: true, Verbose: false,
}
}
func ExtractFile(srcPath, destDir string, cfg *ExtractConfig) error {
if cfg == nil {
cfg = DefaultExtractConfig()
}
srcFile, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
defer srcFile.Close()
return AutoExtract(filepath.Base(srcPath), srcFile, destDir)
}
func ShouldExtractFile(filename string) bool { return IsArchiveFormat(filename) }
func ListSupportedFormats() []string { return ListSupported() }
func SupportsFormat(format string) bool { return Supports(format) }
+80
View File
@@ -0,0 +1,80 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"bytes"
"testing"
)
// FuzzTarExtract tests tar extraction with fuzzed input to catch path traversal
// and other security issues in header parsing.
func FuzzTarExtract(f *testing.F) {
// Seed corpus with valid tar data
f.Add([]byte{})
// Minimal valid tar header (512 bytes of zeros)
emptyHeader := make([]byte, 512)
f.Add(emptyHeader)
// Tar file with a ".." path traversal attempt
traversalTar := makeTarWithName("../../../etc/passwd")
f.Add(traversalTar)
// Tar file with absolute path
absTar := makeTarWithName("/etc/passwd")
f.Add(absTar)
// Tar file with symlink pointing outside
symlinkTar := makeSymlinkTar("link", "../../../etc/passwd")
f.Add(symlinkTar)
f.Fuzz(func(t *testing.T, data []byte) {
// Fuzz the tar extraction — it must never panic or escape the temp dir
dir := t.TempDir()
ext := NewTarExtractor()
reader := bytes.NewReader(data)
// Ignore errors — we only care about panics and path escapes
_ = ext.Extract(reader, dir)
})
}
func makeTarWithName(name string) []byte {
// Create a minimal tar file with a regular file at the given path
var buf bytes.Buffer
nameBytes := []byte(name)
if len(nameBytes) > 99 {
nameBytes = nameBytes[:99]
}
header := make([]byte, 512)
copy(header[0:99], nameBytes)
// Set type flag to regular file
header[156] = '0'
// Set size to zero
buf.Write(header)
// Two zero blocks to mark end of archive
buf.Write(make([]byte, 1024))
return buf.Bytes()
}
func makeSymlinkTar(name, target string) []byte {
var buf bytes.Buffer
nameBytes := []byte(name)
if len(nameBytes) > 99 {
nameBytes = nameBytes[:99]
}
header := make([]byte, 512)
copy(header[0:99], nameBytes)
// Set type flag to symlink
header[156] = '2'
// Write link target
targetBytes := []byte(target)
if len(targetBytes) > 99 {
targetBytes = targetBytes[:99]
}
copy(header[157:256], targetBytes)
buf.Write(header)
buf.Write(make([]byte, 1024))
return buf.Bytes()
}
+84
View File
@@ -0,0 +1,84 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"io"
"strings"
)
// Extractor is the interface for archive extraction
type Extractor interface {
Name() string
Extensions() []string
Extract(r io.Reader, destDir string) error
CanHandle(filename string) bool
}
// BaseExtractor provides a basic implementation
type BaseExtractor struct {
name string
extensions []string
}
func NewBaseExtractor(name string, extensions []string) *BaseExtractor {
return &BaseExtractor{name: name, extensions: extensions}
}
func (b *BaseExtractor) Name() string { return b.name }
func (b *BaseExtractor) Extensions() []string { return b.extensions }
func (b *BaseExtractor) CanHandle(filename string) bool {
filename = strings.ToLower(filename)
for _, ext := range b.extensions {
if strings.HasSuffix(filename, ext) {
return true
}
}
return false
}
// DetectArchiveFormat detects the archive format by file extension
func DetectArchiveFormat(filename string) string {
filename = strings.ToLower(filename)
if strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".tgz") {
return "tar.gz"
}
if strings.HasSuffix(filename, ".tar.bz2") || strings.HasSuffix(filename, ".tbz2") {
return "tar.bz2"
}
if strings.HasSuffix(filename, ".zip") {
return "zip"
}
if strings.HasSuffix(filename, ".tar") {
return "tar"
}
return ""
}
// IsArchiveFormat checks whether the filename looks like an archive
func IsArchiveFormat(filename string) bool { return DetectArchiveFormat(filename) != "" }
// GetArchiveAndCompression splits filename into archive + compression parts
func GetArchiveAndCompression(filename string) (archive, compression string) {
filename = strings.ToLower(filename)
if strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".tgz") {
return "tar", "gzip"
}
if strings.HasSuffix(filename, ".tar.bz2") || strings.HasSuffix(filename, ".tbz2") {
return "tar", "bzip2"
}
if strings.HasSuffix(filename, ".zip") {
return "zip", ""
}
if strings.HasSuffix(filename, ".tar") {
return "tar", ""
}
if strings.HasSuffix(filename, ".gz") {
return "", "gzip"
}
if strings.HasSuffix(filename, ".bz2") {
return "", "bzip2"
}
return "", ""
}
+87
View File
@@ -0,0 +1,87 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"fmt"
"io"
"strings"
"sync"
)
type registry struct {
mu sync.RWMutex
extractors map[string]Extractor
}
var globalRegistry = &registry{extractors: make(map[string]Extractor)}
func Register(e Extractor) error {
globalRegistry.mu.Lock()
defer globalRegistry.mu.Unlock()
name := strings.ToLower(e.Name())
if name == "" {
return fmt.Errorf("extractor name cannot be empty")
}
if _, exists := globalRegistry.extractors[name]; exists {
return fmt.Errorf("extractor already registered: %s", name)
}
globalRegistry.extractors[name] = e
return nil
}
func Get(format string) (Extractor, error) {
globalRegistry.mu.RLock()
defer globalRegistry.mu.RUnlock()
e, exists := globalRegistry.extractors[strings.ToLower(format)]
if !exists {
return nil, fmt.Errorf("unsupported archive format: %s", format)
}
return e, nil
}
func GetByFilename(filename string) (Extractor, error) {
format := DetectArchiveFormat(filename)
if format == "" {
return nil, fmt.Errorf("unknown archive format for: %s", filename)
}
return Get(format)
}
func Extract(format string, src io.Reader, destDir string) error {
e, err := Get(format)
if err != nil {
return err
}
return e.Extract(src, destDir)
}
func ExtractByFilename(filename string, src io.Reader, destDir string) error {
e, err := GetByFilename(filename)
if err != nil {
return err
}
return e.Extract(src, destDir)
}
func AutoExtract(filename string, src io.Reader, destDir string) error {
return ExtractByFilename(filename, src, destDir)
}
func Supports(format string) bool {
globalRegistry.mu.RLock()
defer globalRegistry.mu.RUnlock()
_, exists := globalRegistry.extractors[strings.ToLower(format)]
return exists
}
func ListSupported() []string {
globalRegistry.mu.RLock()
defer globalRegistry.mu.RUnlock()
names := make([]string, 0, len(globalRegistry.extractors))
for name := range globalRegistry.extractors {
names = append(names, name)
}
return names
}
+134
View File
@@ -0,0 +1,134 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"archive/tar"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
type tarExtractor struct{ *BaseExtractor }
func NewTarExtractor() *tarExtractor {
return &tarExtractor{NewBaseExtractor("tar", []string{".tar"})}
}
// Extract extracts a tar archive into destDir (streaming-friendly)
func (t *tarExtractor) Extract(r io.Reader, destDir string) error {
tr := tar.NewReader(r)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar header: %w", err)
}
if err := t.extractEntry(header, tr, destDir); err != nil {
return err
}
}
return nil
}
// extractEntry extracts a single entry from a tar archive
func (t *tarExtractor) extractEntry(header *tar.Header, r io.Reader, destDir string) error {
// Sanitize path to prevent tar-slip attacks
cleanName := filepath.Clean(header.Name)
if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
return fmt.Errorf("unsafe path in tar: %s", header.Name)
}
targetPath := filepath.Join(destDir, cleanName)
switch header.Typeflag {
case tar.TypeDir:
// Create directory with original permissions
return os.MkdirAll(targetPath, os.FileMode(header.Mode))
case tar.TypeReg:
// Create parent directories
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
// Create file with original permissions (mask setuid/setgid/sticky)
mode := os.FileMode(header.Mode) & 0777
file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
defer file.Close()
// Copy contents
if _, err := io.Copy(file, r); err != nil {
return err
}
// Restore timestamps
atime := header.AccessTime
if atime.IsZero() {
atime = time.Now()
}
mtime := header.ModTime
if mtime.IsZero() {
mtime = time.Now()
}
if err := os.Chtimes(targetPath, atime, mtime); err != nil {
// Non-fatal
}
return nil
case tar.TypeSymlink:
// Validate symlink target against path traversal
linkTarget := filepath.Clean(header.Linkname)
if filepath.IsAbs(linkTarget) || strings.HasPrefix(linkTarget, "..") {
return fmt.Errorf("symlink target outside extraction directory: %s", header.Linkname)
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
os.Remove(targetPath)
return os.Symlink(linkTarget, targetPath)
case tar.TypeLink:
// Hard link - validate target stays within destDir to prevent path traversal
cleanTarget := filepath.Clean(header.Linkname)
if filepath.IsAbs(cleanTarget) || strings.HasPrefix(cleanTarget, "..") {
return fmt.Errorf("hard link target outside extraction directory: %s", header.Linkname)
}
linkTarget := filepath.Join(destDir, cleanTarget)
// Ensure resolved path does not escape destDir
if !strings.HasPrefix(linkTarget, destDir+string(filepath.Separator)) && linkTarget != destDir {
return fmt.Errorf("hard link target outside extraction directory: %s", header.Linkname)
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
return os.Link(linkTarget, targetPath)
case tar.TypeChar, tar.TypeBlock:
// Device files - skip with warning (requires root)
return nil
case tar.TypeFifo:
// FIFO - skip (platform-specific)
return nil
default:
// Unknown type - skip
return nil
}
}
func init() { Register(NewTarExtractor()) }
+25
View File
@@ -0,0 +1,25 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"compress/bzip2"
"io"
)
type tarBz2Extractor struct {
*BaseExtractor
tar *tarExtractor
}
func NewTarBz2Extractor() *tarBz2Extractor {
return &tarBz2Extractor{NewBaseExtractor("tar.bz2", []string{".tar.bz2", ".tbz2"}), NewTarExtractor()}
}
func (t *tarBz2Extractor) Extract(r io.Reader, destDir string) error {
bz2r := bzip2.NewReader(r)
return t.tar.Extract(bz2r, destDir)
}
func init() { Register(NewTarBz2Extractor()) }
+42
View File
@@ -0,0 +1,42 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"compress/gzip"
"fmt"
"io"
)
type tarGzExtractor struct {
*BaseExtractor
tar *tarExtractor
}
func NewTarGzExtractor() *tarGzExtractor {
return &tarGzExtractor{
BaseExtractor: NewBaseExtractor("tar.gz", []string{".tar.gz", ".tgz"}),
tar: NewTarExtractor(),
}
}
// Extract extracts a tar.gz archive into destDir
func (t *tarGzExtractor) Extract(r io.Reader, destDir string) error {
// Step 1: Create gzip reader for decompression
gzr, err := gzip.NewReader(r)
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
defer func() {
if cerr := gzr.Close(); cerr != nil && err == nil {
err = cerr
}
}()
// Step 2: Extract tar from decompressed stream
// gzr implements io.Reader that returns DECOMPRESSED data
return t.tar.Extract(gzr, destDir)
}
func init() { Register(NewTarGzExtractor()) }
+141
View File
@@ -0,0 +1,141 @@
//go:build linux || freebsd
// +build linux freebsd
package archive
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type zipExtractor struct{ *BaseExtractor }
// MaxZipInputSize is the maximum size in bytes of a single file extracted
// from a zip archive. Files whose declared uncompressed size exceeds this
// limit are rejected outright; the limit is also enforced at copy time as
// a safety net in case the central directory lies (a known zip-bomb
// technique). Defaults to 1 GiB.
var MaxZipInputSize int64 = 1 << 30
// MaxZipBufferSize is the maximum size in bytes of the buffered
// (compressed) zip archive before it is opened. Archives larger than this
// are rejected to prevent the on-disk temp file from growing without
// bound. Defaults to 4 GiB.
var MaxZipBufferSize int64 = 4 << 30
func NewZipExtractor() *zipExtractor {
return &zipExtractor{NewBaseExtractor("zip", []string{".zip"})}
}
// Extract extracts a zip archive into destDir
func (z *zipExtractor) Extract(r io.Reader, destDir string) error {
// Zip.NewReader requires io.ReaderAt + size
// Buffer input into a temp file, bounded by MaxZipBufferSize to
// prevent a zip bomb from filling the disk with the (still
// compressed) archive while we try to open it.
tmpFile, err := os.CreateTemp("", "goget-zip-*.tmp")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath) // Cleanup temp file after completion
// LimitReader returns EOF after MaxZipBufferSize+1 bytes, which lets
// us detect "input exceeded the limit" by stat'ing the temp file.
if _, err := io.Copy(tmpFile, io.LimitReader(r, MaxZipBufferSize+1)); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to buffer zip: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
info, err := os.Stat(tmpPath)
if err != nil {
return fmt.Errorf("failed to stat temp file: %w", err)
}
if info.Size() > MaxZipBufferSize {
return fmt.Errorf("zip archive exceeds maximum size of %d bytes (possible zip bomb)", MaxZipBufferSize)
}
// Open zip reader (zip.OpenReader detects size automatically)
zipReader, err := zip.OpenReader(tmpPath)
if err != nil {
return fmt.Errorf("failed to open zip archive: %w", err)
}
defer zipReader.Close()
// Extract each file from archive
for _, file := range zipReader.File {
if err := z.extractFile(file, destDir); err != nil {
return fmt.Errorf("failed to extract %s: %w", file.Name, err)
}
}
return nil
}
// extractFile extracts a single file from zip archive
func (z *zipExtractor) extractFile(file *zip.File, destDir string) error {
// Sanitize path to prevent zip-slip attacks
cleanName := filepath.Clean(file.Name)
if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
return fmt.Errorf("unsafe path in zip: %s", file.Name)
}
targetPath := filepath.Join(destDir, cleanName)
// Handle directories
if file.FileInfo().IsDir() {
return os.MkdirAll(targetPath, 0755)
}
// Reject files whose declared uncompressed size exceeds the limit.
// The central directory declares this for every file; a value larger
// than MaxZipInputSize is a strong zip-bomb signal and we refuse the
// whole archive (caller can decide whether to retry with a different
// policy).
if file.UncompressedSize64 > uint64(MaxZipInputSize) {
return fmt.Errorf("file %s too large: %d bytes uncompressed (max %d)", file.Name, file.UncompressedSize64, MaxZipInputSize)
}
// Create parent directories
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
// Open file in archive (automatically decompresses if compressed)
rc, err := file.Open()
if err != nil {
return err
}
defer rc.Close()
// Create output file with original permissions (mask setuid/setgid/sticky)
mode := file.Mode() & 0777
outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
defer outFile.Close()
// io.LimitReader is a safety net for archives whose central directory
// lies about UncompressedSize64 (or leaves it at 0). The primary
// defence is the UncompressedSize64 check above.
if _, err := io.Copy(outFile, io.LimitReader(rc, MaxZipInputSize)); err != nil {
return err
}
// Restore timestamps
if err := os.Chtimes(targetPath, file.Modified, file.Modified); err != nil {
// Non-fatal, ignore error
}
return nil
}
func init() { Register(NewZipExtractor()) }