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
+303
View File
@@ -0,0 +1,303 @@
//go:build linux || freebsd
// +build linux freebsd
package output
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"syscall"
"time"
)
// WriterConfig holds configuration for the Writer.
type WriterConfig struct {
// Output is the destination file path (use "-" for stdout).
Output string
// BufferSize is the size of the write buffer.
BufferSize int
// Atomic enables atomic write via temp file + rename.
Atomic bool
// Resume enables support for resuming interrupted downloads.
Resume bool
// ProgressCallback is called on each write with the current progress.
ProgressCallback func(current, total int64, speed float64)
// Verbose enables verbose logging.
Verbose bool
// CreateDirs creates missing parent directories of Output via os.MkdirAll
// before opening the file. Ignored when Output is "-" (stdout). The
// equivalent of curl --create-dirs / wget --directory-prefix.
CreateDirs bool
}
// DefaultWriterConfig returns a WriterConfig with sensible defaults.
func DefaultWriterConfig() *WriterConfig {
return &WriterConfig{
BufferSize: 32 * 1024, // 32KB
Atomic: true,
Resume: false,
Verbose: false,
}
}
// Writer is a progress-aware file writer supporting atomic writes and resume.
type Writer struct {
config *WriterConfig
file *os.File
tempFile *os.File
written int64
total int64
startTime time.Time
mu sync.Mutex
callback func(current, total int64, speed float64)
ctx context.Context
cancel context.CancelFunc
}
// NewWriter creates a new writer
func NewWriter(cfg *WriterConfig) (*Writer, error) {
if cfg == nil {
cfg = DefaultWriterConfig()
}
w := &Writer{
config: cfg,
written: 0,
startTime: time.Now(),
callback: cfg.ProgressCallback,
}
// Create context for cancellation.
w.ctx, w.cancel = context.WithCancel(context.Background())
// Determine output path
outputPath := cfg.Output
if outputPath == "" {
outputPath = "-" // stdout
}
// Create parent directories on demand (curl --create-dirs / wget
// --directory-prefix). Skip for stdout where there is no path.
if cfg.CreateDirs && outputPath != "-" {
dir := filepath.Dir(outputPath)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create output directory: %w", err)
}
}
}
// Atomic write - create temp file
if cfg.Atomic && outputPath != "-" {
dir := filepath.Dir(outputPath)
name := filepath.Base(outputPath)
tempName := fmt.Sprintf(".%s.tmp.%d", name, time.Now().UnixNano())
tempPath := filepath.Join(dir, tempName)
tempFile, err := os.OpenFile(tempPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
w.tempFile = tempFile
w.file = tempFile
} else if outputPath == "-" {
w.file = os.Stdout
} else {
// Check if file exists for resume.
if cfg.Resume {
if info, err := os.Stat(outputPath); err == nil {
w.written = info.Size()
// Open in append mode
file, err := os.OpenFile(outputPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil, fmt.Errorf("failed to open file for resume: %w", err)
}
w.file = file
} else {
// File doesn't exist, create new
file, err := os.Create(outputPath)
if err != nil {
return nil, fmt.Errorf("failed to create file: %w", err)
}
w.file = file
}
} else {
// Check if file exists
if _, err := os.Stat(outputPath); err == nil {
return nil, fmt.Errorf("file already exists: %s", outputPath)
}
file, err := os.Create(outputPath)
if err != nil {
return nil, fmt.Errorf("failed to create file: %w", err)
}
w.file = file
}
}
return w, nil
}
// Write implements the io.Writer interface.
func (w *Writer) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
n, err := w.file.Write(p)
if err != nil {
return n, err
}
w.written += int64(n)
// Report progress
if w.callback != nil {
duration := time.Since(w.startTime)
speed := float64(w.written) / duration.Seconds()
w.callback(w.written, w.total, speed)
}
return n, nil
}
// SetTotal sets the expected total size for progress tracking.
func (w *Writer) SetTotal(total int64) {
w.mu.Lock()
defer w.mu.Unlock()
w.total = total
}
// SetProgressCallback sets the callback function for progress reporting.
func (w *Writer) SetProgressCallback(cb func(current, total int64, speed float64)) {
w.mu.Lock()
defer w.mu.Unlock()
w.callback = cb
}
// Written returns the number of bytes written so far.
func (w *Writer) Written() int64 {
w.mu.Lock()
defer w.mu.Unlock()
return w.written
}
// Total returns the expected total size.
func (w *Writer) Total() int64 {
w.mu.Lock()
defer w.mu.Unlock()
return w.total
}
// Close finalizes the write and performs the atomic rename if applicable.
func (w *Writer) Close() error {
w.cancel()
if w.file == nil {
return nil
}
// Don't close stdout — it's shared across the process
if w.file != os.Stdout {
if err := w.file.Close(); err != nil {
return err
}
}
// Atomic rename if temp file was used
if w.tempFile != nil && w.config.Output != "-" {
tempPath := w.tempFile.Name()
if err := os.Rename(tempPath, w.config.Output); err != nil {
if errors.Is(err, syscall.EXDEV) {
// Cross-filesystem rename is not supported by the OS.
// Fall back to a copy + remove so the data still reach
// the destination directory instead of being lost.
if fallbackErr := copyFileAndRemove(tempPath, w.config.Output); fallbackErr != nil {
return fmt.Errorf("atomic rename failed across filesystems and fallback copy failed: rename=%v copy=%w", err, fallbackErr)
}
return nil
}
// Any other error (permission denied, target is a directory,
// target busy on Windows, etc.) — keep the temp file so the
// user can recover the partial download manually.
return fmt.Errorf("failed to rename temp file %q to %q: %w (temp file kept for recovery)", tempPath, w.config.Output, err)
}
}
return nil
}
// copyFileAndRemove copies src to dst (truncating any existing file) and
// removes src on success. Used as a fallback for os.Rename when the temp
// file and the destination are on different filesystems.
func copyFileAndRemove(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
return os.Remove(src)
}
// Cancel aborts the write and cleans up any temporary files.
func (w *Writer) Cancel() {
w.cancel()
if w.tempFile != nil {
// Remove temp file on cancel
tempPath := w.tempFile.Name()
w.tempFile.Close()
os.Remove(tempPath)
}
}
// GetSpeed returns the average write speed in bytes per second.
func (w *Writer) GetSpeed() float64 {
w.mu.Lock()
defer w.mu.Unlock()
duration := time.Since(w.startTime)
if duration.Seconds() == 0 {
return 0
}
return float64(w.written) / duration.Seconds()
}
// GetDuration returns the elapsed time since the writer was created.
func (w *Writer) GetDuration() time.Duration {
return time.Since(w.startTime)
}
// GetProgress returns the progress as a percentage of the total size.
func (w *Writer) GetProgress() float64 {
w.mu.Lock()
defer w.mu.Unlock()
if w.total == 0 {
return 0
}
return float64(w.written) / float64(w.total) * 100
}