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
+660
View File
@@ -0,0 +1,660 @@
//go:build linux || freebsd
// +build linux freebsd
package main
import (
"context"
"flag"
"fmt"
"net/url"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"codeberg.org/petrbalvin/goget/internal/auth"
"codeberg.org/petrbalvin/goget/internal/cli"
"codeberg.org/petrbalvin/goget/internal/config"
"codeberg.org/petrbalvin/goget/internal/cookie"
"codeberg.org/petrbalvin/goget/internal/core"
format "codeberg.org/petrbalvin/goget/internal/format"
"codeberg.org/petrbalvin/goget/internal/hsts"
"codeberg.org/petrbalvin/goget/internal/log"
"codeberg.org/petrbalvin/goget/internal/output"
"codeberg.org/petrbalvin/goget/internal/protocol"
protocolreg "codeberg.org/petrbalvin/goget/internal/protocol/register"
"codeberg.org/petrbalvin/goget/internal/transport"
)
// app holds the runtime state of a goget command execution.
type app struct {
cfg *config.Config
flags *Flags
fs *flag.FlagSet
configPath string
allURLs []string
sigCtx context.Context
sigCancel context.CancelFunc
}
// run is the main entry point. It is kept small — the work is delegated to
// init, dispatch, setup, and execute.
func (a *app) run() int {
// init() assigns a.sigCancel; a no-op here makes the early
// `return` paths (e.g. "missing required flag" on a fresh
// invocation) safe to clean up. Once init() runs, the real
// cancel function replaces this no-op.
defer func() {
if a.sigCancel != nil {
a.sigCancel()
}
}()
if code := a.init(); code != 0 {
return code
}
if code := a.dispatch(); code >= 0 {
return code
}
return exitCodeForRun(a.sigCtx, a.execute())
}
// exitCodeForRun maps the inner exit code to the final process exit
// code. If the signal context was cancelled (SIGINT/SIGTERM) but
// execute() returned 0 — because the download propagated the cancel
// cleanly — surface 130 so shell scripts can detect the interruption.
// On a normal completion (no signal) the inner code is returned
// unchanged; non-zero inner codes are preserved regardless of signal
// state so genuine errors are not masked.
func exitCodeForRun(sigCtx context.Context, innerCode int) int {
if innerCode != 0 {
return innerCode
}
if sigCtx != nil && sigCtx.Err() != nil {
return 130
}
return 0
}
// init loads config, sets up the signal context, and parses CLI flags.
func (a *app) init() int {
a.configPath = extractConfigPath(os.Args[1:])
cfg, err := config.Load(a.configPath)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to load config: %w", err))
return 1
}
a.cfg = cfg
sigCtx, stop := signalContext()
a.sigCtx = sigCtx
a.sigCancel = stop
a.fs = flag.NewFlagSet("goget", flag.ExitOnError)
a.fs.Usage = func() { cli.PrintUsage(os.Stderr) }
a.flags = defineFlags(a.fs, a.cfg)
if err := a.fs.Parse(os.Args[1:]); err != nil {
cli.PrintError(os.Stderr, err)
return 1
}
a.allURLs = extractURLs(os.Args[1:])
return 0
}
// dispatch handles all standalone subcommands (help, version, config-*, info,
// benchmark, etc.). Returns the exit code, or -1 if no subcommand matched
// (meaning the caller should proceed to the download pipeline).
func (a *app) dispatch() int {
f := a.flags
cfg := a.cfg
if *f.Help {
cli.PrintUsage(os.Stdout)
return 0
}
if *f.Version {
fmt.Printf("goget version %s\n", core.Version)
return 0
}
if *f.ConfigList {
return handleConfigList(a.configPath)
}
if *f.ConfigGet != "" {
return handleConfigGet(a.configPath, *f.ConfigGet)
}
if *f.ConfigSet != "" {
return handleConfigSet(a.configPath, *f.ConfigSet, os.Args)
}
if *f.ConfigUnset != "" {
return handleConfigUnset(a.configPath, *f.ConfigUnset)
}
if *f.Info != "" {
return handleInfo(*f.Info, cfg)
}
if *f.Benchmark != "" {
return handleBenchmark(*f.Benchmark, cfg)
}
if *f.Spider {
return handleSpider(*f.URL, cfg)
}
if *f.GenManPage {
return handleGenManPage()
}
if *f.Completion != "" {
return handleCompletion(*f.Completion)
}
if *f.InitConfig {
return handleInitConfig(a.configPath)
}
if *f.BatchFile != "" {
return handleBatchFile(*f.BatchFile, *f.Output, cfg, a.sigCtx)
}
if *f.QueueList {
return handleQueueList(*f.QueueFile)
}
if *f.QueueProcess {
return handleQueueProcess(*f.QueueFile, cfg.Verbose)
}
if *f.QueueClear {
return handleQueueClear(*f.QueueFile, cfg.Verbose)
}
if *f.MetalinkFile != "" {
return handleMetalinkFile(*f.MetalinkFile, *f.Output, cfg.Verbose, *f.Queue, *f.QueuePriority, *f.QueueFile)
}
if *f.Metalink {
return handleMetalinkURL(*f.URL, *f.Output, cfg.Verbose, *f.Queue, *f.QueuePriority, *f.QueueFile)
}
if *f.Upload {
return handleUpload(*f.URL, *f.UploadMethod, *f.UploadFile, *f.UploadFormData, *f.UploadFileField, cfg.Verbose, cfg)
}
if len(a.allURLs) > 1 {
return handleMultipleURLs(a.allURLs, *f.Output, cfg, a.sigCtx)
}
return -1 // proceed to download pipeline
}
// execute runs the download pipeline after dispatch determined that a
// download should be performed. It handles URL validation, config merging,
// authentication, transport setup, and the actual download via the protocol
// handler.
func (a *app) execute() int {
f := a.flags
cfg := a.cfg
if *f.URL == "" {
cli.PrintError(os.Stderr, fmt.Errorf("--url is a required parameter"))
a.fs.Usage()
return 1
}
parsedURL, err := url.Parse(*f.URL)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("invalid url: %w", err))
return 1
}
// Convert internationalized domain name to Punycode
parsedURL = idnaURL(parsedURL)
excludePatterns := []string{}
if *f.ExcludePattern != "" {
for _, p := range strings.Split(*f.ExcludePattern, ",") {
p = strings.TrimSpace(p)
if p != "" {
excludePatterns = append(excludePatterns, p)
}
}
}
oauthScopes := []string{}
if *f.OAuthScopes != "" {
for _, s := range strings.Split(*f.OAuthScopes, ",") {
s = strings.TrimSpace(s)
if s != "" {
oauthScopes = append(oauthScopes, s)
}
}
}
if cfg.IsExcluded(parsedURL.String()) {
cli.PrintError(os.Stderr, fmt.Errorf("url matches exclude pattern: %s", parsedURL.String()))
return 1
}
maxSpeed := config.ParseSpeed(*f.MaxSpeed)
cfg.Merge(&config.CLIFlags{
Timeout: *f.Timeout,
Parallel: *f.Parallel,
Verbose: *f.Verbose,
Debug: *f.Debug,
MaxSpeed: maxSpeed,
Proxy: *f.Proxy,
NoIPv4: *f.NoIPv4,
NoDecompress: *f.NoDecompress,
Username: *f.Username,
Password: *f.Password,
CookieJar: *f.CookieJar,
Recursive: *f.Recursive,
MaxDepth: *f.MaxDepth,
FollowExternal: *f.FollowExternal,
ExcludePatterns: excludePatterns,
Extract: *f.Extract,
ExtractDir: *f.ExtractDir,
StripComponents: *f.StripComponents,
})
// Authentication settings
if *f.AuthType != "" {
cfg.Auth.AuthType = *f.AuthType
}
// OAuth 2.0 settings
if *f.OAuthClientID != "" || *f.OAuthClientSecret != "" || *f.OAuthTokenURL != "" ||
*f.OAuthAccessToken != "" || *f.OAuthRefreshToken != "" {
if *f.OAuthClientID != "" {
cfg.Auth.OAuth.ClientID = *f.OAuthClientID
}
if *f.OAuthClientSecret != "" {
cfg.Auth.OAuth.ClientSecret = *f.OAuthClientSecret
}
if *f.OAuthTokenURL != "" {
cfg.Auth.OAuth.TokenURL = *f.OAuthTokenURL
}
if *f.OAuthAuthURL != "" {
cfg.Auth.OAuth.AuthURL = *f.OAuthAuthURL
}
if *f.OAuthRedirectURI != "" {
cfg.Auth.OAuth.RedirectURI = *f.OAuthRedirectURI
}
if len(oauthScopes) > 0 {
cfg.Auth.OAuth.Scopes = oauthScopes
}
if *f.OAuthGrantType != "" {
cfg.Auth.OAuth.GrantType = *f.OAuthGrantType
}
if *f.OAuthAccessToken != "" {
cfg.Auth.OAuth.AccessToken = *f.OAuthAccessToken
}
if *f.OAuthRefreshToken != "" {
cfg.Auth.OAuth.RefreshToken = *f.OAuthRefreshToken
}
}
// Checksum algorithm settings
if *f.ChecksumAlgo != "" {
cfg.ChecksumAlgo = *f.ChecksumAlgo
}
// Password file handling
if *f.PasswordFile != "" {
data, err := os.ReadFile(*f.PasswordFile)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to read password file: %w", err))
return 1
}
cfg.Auth.Password = strings.TrimSpace(string(data))
} else if passwordEnv := os.Getenv("GOGET_PASSWORD"); passwordEnv != "" {
cfg.Auth.Password = passwordEnv
}
// Quiet mode: suppress verbose output
if *f.Quiet {
cfg.Verbose = false
}
// Configure structured logger based on CLI flags.
logLevel := log.InfoLevel
if cfg.Verbose {
logLevel = log.VerboseLevel
}
if cfg.Debug {
logLevel = log.DebugLevel
}
if *f.Quiet {
logLevel = log.WarnLevel
}
log.DefaultLogger = log.New(os.Stderr,
log.WithLevel(logLevel),
log.WithColor(cfg.ShouldUseColors()),
log.WithJSON(*f.JSON),
)
// Restart: delete existing file before download
if *f.Restart && *f.Output != "" {
os.Remove(*f.Output)
output.CleanupResumeFiles(*f.Output)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
log.Warn("Interrupted by user.")
cancel()
// Also cancel the app-level signal context so run() can
// observe the interruption and return 130.
a.sigCancel()
}()
// DNS servers
if *f.DNSServers != "" {
dnsServers := transport.ParseDNSServers(*f.DNSServers)
if len(dnsServers) > 0 {
cfg.DNSServers = dnsServers
}
}
if *f.Schedule != "" {
if code := waitForSchedule(*f.Schedule, cfg, sigChan); code != 0 {
a.sigCancel()
return code
}
}
// Checksum file handling
if *f.ChecksumFile != "" && *f.Checksum == "" {
expectedChecksum, err := parseChecksumFile(*f.ChecksumFile, *f.Output)
if err != nil {
cli.PrintWarning(os.Stderr, fmt.Sprintf("Failed to read checksum from file: %v", err))
} else if expectedChecksum != "" {
*f.Checksum = expectedChecksum
if cfg.Verbose {
cli.PrintInfo(os.Stderr, fmt.Sprintf("Using checksum from file: %s", expectedChecksum))
}
}
}
httpClient, err := protocolreg.RegisterAll(protocol.GlobalRegistry)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to register protocols: %w", err))
return 1
}
// HSTS cache
hstsCache := hsts.NewCache()
hstsPath := *f.HSTSFile
if hstsPath == "" {
hstsPath = hsts.DefaultCachePath()
}
if hstsPath != "" {
if err := hstsCache.Load(hstsPath); err != nil && cfg.Verbose {
cli.PrintWarning(os.Stderr, fmt.Sprintf("failed to load hsts cache: %v", err))
}
httpClient.SetHSTSCache(hstsCache)
}
var cookieJar *cookie.Jar
cookieJarPath := cfg.CookieJar
if cookieJarPath != "" {
cookieJar, err = cookie.NewJar()
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to create cookie jar: %w", err))
return 1
}
if err := cookieJar.LoadFromFile(cookieJarPath); err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to load cookies: %w", err))
return 1
}
if cfg.Verbose {
cli.PrintProgressInfo(os.Stderr, fmt.Sprintf("Loaded cookies from %s", cookieJarPath))
}
}
if cfg.Verbose {
safeURL := core.SafeURL(parsedURL)
log.Infof("Goget %s", core.Version)
log.Infof("URL: %s", safeURL)
if cfg.Auth.Username != "" {
log.Infof("Auth: %s:%s", cfg.Auth.Username, auth.MaskPassword(cfg.Auth.Password))
}
if cookieJar != nil {
log.Infof("Cookies: %d loaded", cookieJar.GetCookieCount(parsedURL))
}
if *f.Mirror {
log.Info("Mirror: enabled (infinite depth, convert links)")
}
if *f.Recursive {
log.Infof("Recursive: enabled (max-depth=%d)", cfg.Recursive.MaxDepth)
}
if *f.Extract {
extractInfo := "enabled"
if cfg.Extract.OutputDir != "" {
extractInfo = fmt.Sprintf("enabled → %s", cfg.Extract.OutputDir)
}
log.Infof("Extract: %s", extractInfo)
}
if *f.Timeout > 0 {
log.Infof("Timeout: %v (explicit)", *f.Timeout)
} else {
log.Infof("Timeout: %v (auto)", cfg.Timeout)
}
if cfg.Parallel > 0 {
log.Infof("Parallel: %d connections", cfg.Parallel)
} else {
log.Info("Parallel: auto")
}
if cfg.MaxSpeed > 0 {
log.Infof("Max speed: %s/s", format.Bytes(cfg.MaxSpeed))
}
// DNS server information
if len(cfg.DNSServers) > 0 {
log.Infof("DNS: %v", cfg.DNSServers)
}
}
if !*f.Recursive && *f.Resume && *f.Output != "" {
canResume, meta, err := output.CanResume(*f.Output, parsedURL.String())
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("resume check failed: %w", err))
if !*f.Overwrite {
return 1
}
output.CleanupResumeFiles(*f.Output)
if cfg.Verbose {
cli.PrintProgressInfo(os.Stderr, "Starting fresh download (overwrite mode)")
}
} else if canResume && meta != nil {
if cfg.Verbose {
cli.PrintProgressInfo(os.Stderr, fmt.Sprintf("Resuming download: %s already downloaded", format.Bytes(meta.Downloaded)))
}
}
}
// --no-clobber: skip if output file exists
if !*f.Recursive && *f.NoClobber && *f.Output != "" && *f.Output != "-" {
if _, err := os.Stat(*f.Output); err == nil {
if cfg.Verbose {
cli.PrintInfo(os.Stderr, fmt.Sprintf("File exists, skipping: %s", *f.Output))
}
return 0
}
}
// Default output filename: derive from the URL path when no
// --output flag is given. Matches curl/wget behaviour and prevents
// binary data being dumped to stdout. Recursive downloads, stdout
// mode (--output -), and content-disposition mode are exempt.
if *f.Output == "" && !*f.Recursive && !*f.ContentDisp {
name := filepath.Base(parsedURL.Path)
if name == "" || name == "/" || name == "." {
name = "index.html"
}
*f.Output = name
}
writer, progressBar := setupOutputWriter(f, cfg)
if writer == nil && !*f.Recursive {
// Error already printed by setupOutputWriter
return 1
}
if writer != nil {
defer writer.Close()
}
httpCfg := setupHTTPConfig(f, cfg, cookieJar, parsedURL)
if *f.Recursive {
httpCfg.Recursive.Enabled = true
httpCfg.Recursive.MaxDepth = cfg.Recursive.MaxDepth
httpCfg.Recursive.FollowExternal = cfg.Recursive.FollowExternal
httpCfg.Recursive.ExcludePatterns = cfg.Recursive.ExcludePatterns
httpCfg.Output.OutputDir = *f.Output
if *f.Accept != "" {
httpCfg.Recursive.Accept = *f.Accept
}
if *f.NoParent {
httpCfg.Recursive.NoParent = true
}
if *f.SpanHosts {
httpCfg.Recursive.SpanHosts = true
httpCfg.Recursive.FollowExternal = true
}
if *f.PageRequisites {
httpCfg.Recursive.PageRequisites = true
}
if *f.Wait != "" {
if d, err := time.ParseDuration(*f.Wait); err == nil {
httpCfg.Recursive.Wait = d
}
}
if *f.RandomWait {
httpCfg.Recursive.RandomWait = true
}
if *f.ConvertLinks {
httpCfg.Recursive.ConvertLinks = true
}
}
if *f.DryRun {
httpCfg.Recursive.DryRun = true
}
if *f.Mirror {
httpCfg.Recursive.Mirror = true
httpCfg.Recursive.ConvertLinks = !*f.NoConvertLinks
httpCfg.Recursive.MirrorAssets = !*f.NoMirrorAssets
httpCfg.Recursive.RespectRobots = !*f.NoRobots
httpCfg.Output.OutputDir = *f.Output
httpCfg.Recursive.Enabled = false // Mirror takes precedence
// Parse rate limit
if *f.RateLimit != "" {
rate, perr := parseRateLimit(*f.RateLimit)
if perr != nil {
cli.PrintWarning(os.Stderr, fmt.Sprintf("ignoring --max-speed: %v; download will be unthrottled", perr))
}
httpCfg.RateLimit = rate
}
}
if *f.Extract {
httpCfg.Output.AutoExtract = true
httpCfg.Output.ExtractDir = cfg.Extract.OutputDir
}
if *f.Proxy != "" {
proxyCfg, perr := transport.ParseProxyConfig(*f.Proxy)
if perr == nil {
httpCfg.Transport.Proxy = proxyCfg
}
}
proto, err := protocol.Get(parsedURL)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("protocol error: %w", err))
return 1
}
var resumeInfo *core.ResumeInfo
if !*f.Recursive && *f.Resume && *f.Output != "" {
meta, _ := output.LoadResumeMetadata(*f.Output)
if meta != nil {
resumeInfo, _ = output.GetResumeInfo(meta, *f.Output)
}
}
req := &core.DownloadRequest{
URL: parsedURL,
Output: *f.Output,
Writer: writer,
Resume: *f.Resume,
Timeout: cfg.GetEffectiveTimeout(-1, *f.Timeout),
Verbose: cfg.Verbose,
DebugTransport: cfg.Debug,
Checksum: *f.Checksum,
Headers: make(map[string]string),
Proxy: cfg.Proxy,
AutoDecompress: cfg.AutoDecompress,
ProgressCallback: nil,
ResumeInfo: resumeInfo,
Parallel: httpCfg.Parallel,
Recursive: *f.Recursive,
RecursiveParallel: httpCfg.Recursive.Parallel,
DryRun: httpCfg.Recursive.DryRun,
MaxDepth: cfg.Recursive.MaxDepth,
AcceptPatterns: parseStringSlice(*f.Accept),
RejectPatterns: httpCfg.Recursive.Reject,
KeepTimestamps: *f.KeepTimestamps,
Ctx: ctx,
SSHKnownHosts: *f.KnownHosts,
SSHInsecure: *f.SSHInsecure,
}
// Configure TLS for WebDAV if supported.
if webdavProto, ok := proto.(*webdavProtocol); ok {
tlsCfg := httpCfg.Transport.TLS
insecure := httpCfg.Transport.TLS != nil && httpCfg.Transport.TLS.InsecureSkipVerify
webdavProto.ConfigureTLS(tlsCfg, insecure)
if cfg.Proxy != "" {
webdavProto.ConfigureProxy(cfg.Proxy)
}
}
if cfg.Verbose {
if *f.Recursive {
cli.PrintProgressInfo(os.Stderr, fmt.Sprintf("Starting recursive download: %s", core.SafeURL(parsedURL)))
} else {
cli.PrintProgressInfo(os.Stderr, fmt.Sprintf("Starting download: %s", core.SafeURL(parsedURL)))
}
}
var result *core.DownloadResult
var downloadErr error
// Wire the progress bar to the download via a callback so the
// user sees live speed and ETA during the transfer, not just a
// static "Starting download" line. The parallel downloader
// aggregates per-chunk progress; the sequential path ignores
// the callback when the bar was not created.
if progressBar != nil && !*f.Recursive {
req.ProgressCallback = func(current, total int64, speed float64) {
progressBar.SetTotal(total)
progressBar.Update(current)
}
}
startTime := time.Now()
result, downloadErr = proto.Download(ctx, req)
if progressBar != nil {
if downloadErr != nil {
progressBar.Cancel()
} else {
progressBar.Finish()
}
}
if downloadErr != nil {
cli.PrintError(os.Stderr, downloadErr)
return 1
}
return runPostDownload(f, cfg, result, cookieJar, cookieJarPath, hstsCache, writer, parsedURL, startTime)
}
File diff suppressed because it is too large Load Diff
+185
View File
@@ -0,0 +1,185 @@
//go:build linux || freebsd
// +build linux freebsd
package main
func generateBashCompletion() string {
return `# goget bash completion
_goget() {
local cur prev opts
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
opts="--url --output --resume --overwrite --verbose --debug
--timeout --proxy --no-ipv4 --checksum --checksum-algo
--no-decompress --parallel --max-speed --username --password
--password-file --auth-type --cookie-jar --cookie --recursive --recursive-parallel --max-depth
--follow-external --mirror --no-convert-links --no-mirror-assets
--no-robots --rate-limit --exclude-pattern --extract --extract-dir
--strip-components --upload --upload-method --upload-file
--form-data --file-field --pgp-decrypt --pgp-verify --pgp-sig
--pgp-key --pgp-passphrase --oauth-client-id --oauth-client-secret
--oauth-token-url --oauth-auth-url --oauth-redirect-uri
--oauth-scopes --oauth-grant-type --oauth-access-token
--oauth-refresh-token --metalink --metalink-file --queue
--queue-list --queue-process --queue-clear --queue-file
--queue-priority --help --version --batch-file --quiet
--init-config --content-disposition --restart --json --info
--show-headers --keep-timestamps --dns-servers --max-retries
--no-redirect --retry-all-errors --ssl-key-log --completion --config"
case "${prev}" in
--url|--output|--proxy|--checksum|--password|--password-file|--cookie-jar)
return 0
;;
--timeout|--max-speed|--rate-limit)
return 0
;;
--batch-file|--metalink-file|--pgp-sig|--pgp-key|--pgp-passphrase)
return 0
;;
--dns-servers|--max-retries|--parallel|--recursive-parallel|--max-depth|--queue-priority)
return 0
;;
--completion)
COMPREPLY=($(compgen -W "bash zsh fish" -- ${cur}))
return 0
;;
--auth-type)
COMPREPLY=($(compgen -W "basic digest auto" -- ${cur}))
return 0
;;
--checksum-algo)
COMPREPLY=($(compgen -W "sha256 sha512 blake2b sha3-256 sha3-512 md5" -- ${cur}))
return 0
;;
--upload-method)
COMPREPLY=($(compgen -W "PUT POST" -- ${cur}))
return 0
;;
--exclude-pattern|--form-data|--oauth-scopes)
return 0
;;
esac
if [[ ${cur} == -* ]]; then
COMPREPLY=($(compgen -W "${opts}" -- ${cur}))
fi
}
complete -F _goget goget
`
}
func generateZshCompletion() string {
return `#compdef goget
_goget() {
local -a opts
opts=(
'--url[URL to download]:url:_urls'
'--output[Output file]:file:_files'
'--verbose[Verbose output]'
'--debug[Debug mode]'
'--resume[Resume interrupted download]'
'--quiet[Suppress all output except errors]'
'--json[JSON progress output]'
'--info[Show file information without downloading]'
'--show-headers[Print HTTP response headers]'
'--keep-timestamps[Set file modification time from server]'
'--restart[Delete existing file and restart download]'
'--timeout[Timeout (0 = auto)]:timeout'
'--proxy[Proxy server URL]:proxy:_urls'
'--no-ipv4[Disable IPv4 fallback]'
'--checksum[Expected checksum]:checksum'
'--checksum-algo[Checksum algorithm]:algorithm:(sha256 sha512 blake2b sha3-256 sha3-512 md5)'
'--no-decompress[Disable automatic decompression]'
'--parallel[Number of parallel connections]:number'
'--max-speed[Max speed]:speed'
'--username[Username]:username'
'--password[Password]:password'
'--password-file[Read password from file]:file:_files'
'--auth-type[Auth type]:(basic digest auto)'
'--batch-file[Download URLs from file]:file:_files'
'--cookie[Cookie name=value]'
'--retry-all-errors[Retry on any HTTP error]'
'--ssl-key-log[TLS key log file]:file:_files'
'--max-retries[Maximum retries]:number'
'--dns-servers[Custom DNS servers]:dns'
'--no-redirect[Disable following redirects]'
'--completion[Generate completion]:shell:(bash zsh fish)'
'--content-disposition[Use server-provided filename]'
'--init-config[Generate default config file]'
'--help[Show help]'
'--version[Show version]'
)
_describe 'goget' opts
}
compdef _goget goget
`
}
func generateFishCompletion() string {
return `# goget fish completion
function __fish_goget_no_subcommand
for i in (commandline -opc)
if contains -- $i (__fish_goget_commands)
return 1
end
end
return 0
end
function __fish_goget_commands
echo url output resume overwrite verbose debug
echo timeout proxy no-ipv4 checksum checksum-algo
echo no-decompress parallel max-speed username password
echo password-file auth-type cookie-jar cookie recursive recursive-parallel max-depth
echo retry-all-errors ssl-key-log
echo follow-external mirror no-convert-links no-mirror-assets
echo no-robots rate-limit exclude-pattern extract extract-dir
echo strip-components upload upload-method upload-file
echo form-data file-field pgp-decrypt pgp-verify pgp-sig
echo pgp-key pgp-passphrase help version batch-file quiet
echo init-config content-disposition restart json info
echo show-headers keep-timestamps dns-servers max-retries
echo no-redirect completion
end
# Flags
complete -c goget -l url -d "URL to download" -r
complete -c goget -l output -d "Output file" -r
complete -c goget -l verbose -d "Verbose output"
complete -c goget -l debug -d "Debug mode"
complete -c goget -l resume -d "Resume interrupted download"
complete -c goget -l quiet -d "Suppress all output except errors"
complete -c goget -l json -d "JSON progress output"
complete -c goget -l info -d "Show file information without downloading"
complete -c goget -l show-headers -d "Print HTTP response headers"
complete -c goget -l keep-timestamps -d "Set file modification time from server"
complete -c goget -l restart -d "Delete existing file and restart download"
complete -c goget -l init-config -d "Generate default config file"
complete -c goget -l content-disposition -d "Use server-provided filename"
complete -c goget -l no-redirect -d "Disable following redirects"
complete -c goget -l batch-file -d "Download URLs from file" -r
complete -c goget -l max-retries -d "Maximum retries" -r
complete -c goget -l dns-servers -d "Custom DNS servers" -r
complete -c goget -l completion -d "Generate completion" -r -xa "bash zsh fish"
complete -c goget -l timeout -d "Timeout (0 = auto)" -r
complete -c goget -l proxy -d "Proxy server URL" -r
complete -c goget -l checksum -d "Expected checksum" -r
complete -c goget -l checksum-algo -d "Algorithm" -r -xa "sha256 sha512 blake2b sha3-256 sha3-512 md5"
complete -c goget -l parallel -d "Parallel connections" -r
complete -c goget -l max-speed -d "Max speed" -r
complete -c goget -l username -d "Username" -r
complete -c goget -l password -d "Password" -r
complete -c goget -l password-file -d "Read password from file" -r
complete -c goget -l auth-type -d "Auth type" -r -xa "basic digest auto"
complete -c goget -l cookie -d "Cookie name=value" -r
complete -c goget -l retry-all-errors -d "Retry on any HTTP error"
complete -c goget -l ssl-key-log -d "TLS key log file" -r
complete -c goget -l help -d "Show help"
complete -c goget -l version -d "Show version"
`
}
+316
View File
@@ -0,0 +1,316 @@
//go:build linux || freebsd
// +build linux freebsd
package main
import (
"flag"
"strings"
"time"
"codeberg.org/petrbalvin/goget/internal/config"
)
// headerFlags accumulates multiple --header values.
type headerFlags []string
func (h *headerFlags) String() string { return strings.Join(*h, ", ") }
func (h *headerFlags) Set(v string) error {
*h = append(*h, v)
return nil
}
// formFlags accumulates multiple --form values.
type formFlags []string
func (f *formFlags) String() string { return strings.Join(*f, ", ") }
func (f *formFlags) Set(v string) error {
*f = append(*f, v)
return nil
}
// Flags holds all CLI flag values.
type Flags struct {
URL *string
Output *string
Resume *bool
Overwrite *bool
Restart *bool
Verbose *bool
Quiet *bool
NoProgress *bool
JSON *bool
Debug *bool
Timeout *time.Duration
Proxy *string
NoIPv4 *bool
NoRedirect *bool
MaxRetries *int
DNSServers *string
Checksum *string
ChecksumAlgo *string
ChecksumFile *string
NoDecompress *bool
Parallel *int
MaxSpeed *string
Username *string
Password *string
PasswordFile *string
AuthType *string
CookieJar *string
ContentDisp *bool
ShowHeaders *bool
KeepTimestamps *bool
Recursive *bool
RecursiveParallel *int
MaxDepth *int
FollowExternal *bool
Mirror *bool
NoConvertLinks *bool
NoMirrorAssets *bool
NoRobots *bool
RateLimit *string
ExcludePattern *string
Extract *bool
ExtractDir *string
StripComponents *int
Upload *bool
UploadMethod *string
UploadFile *string
UploadFormData *string
UploadFileField *string
PGPDecrypt *bool
PGPVerify *bool
PGPSignature *string
PGPKey *string
PGPPassphrase *string
PGPPassphraseFile *string
OAuthClientID *string
OAuthClientSecret *string
OAuthTokenURL *string
OAuthAuthURL *string
OAuthRedirectURI *string
OAuthScopes *string
OAuthGrantType *string
OAuthAccessToken *string
OAuthRefreshToken *string
Metalink *bool
MetalinkFile *string
Queue *bool
QueueList *bool
QueueProcess *bool
QueueClear *bool
QueueFile *string
QueuePriority *int
BatchFile *string
InitConfig *bool
ConfigGet *string
ConfigSet *string
ConfigList *bool
ConfigUnset *string
Completion *string
Info *string
Benchmark *string
Schedule *string
BindInterface *string
KnownHosts *string
SSHInsecure *bool
DryRun *bool
OnComplete *string
GenManPage *bool
Help *bool
Version *bool
// curl/wget compatible flags
Headers headerFlags
Cookies headerFlags // --cookie, repeatable
Insecure *bool
CertFile *string
KeyFile *string
SSLKeyLogFile *string
Data *string
MaxTime *time.Duration
MaxFileSize *string
Accept *string
NoParent *bool
Spider *bool
WriteOut *string
ProgressStyle *string
WarcFile *string
TraceTime *bool
Form formFlags
PageRequisites *bool
SpanHosts *bool
AdjustExt *bool
NoPrivate *bool // deprecated, use AllowPrivate inversely
Wait *string
RandomWait *bool
ConvertLinks *bool
Fail *bool
Compressed *bool
Referer *string
RetryDelay *time.Duration
PinnedCert *string
AllowPrivate *bool
Range *string
ConnectTimeout *time.Duration
Domains *string
Reject *string
CutDirs *int
PostFile *string
CACertificate *string
NoClobber *bool
RetryHTTPError *string
RetryAllErrors *bool
Glob *bool
InputFile *string
Timestamping *bool
BackupConverted *bool
Continue *bool
ProtoDirs *bool
HSTSFile *string
CreateDirs *bool
}
// defineFlags registers all CLI flags on the given FlagSet and returns them.
func defineFlags(fs *flag.FlagSet, cfg *config.Config) *Flags {
f := &Flags{
URL: fs.String("url", "", "URL to download (can be specified multiple times)"),
Output: fs.String("output", "", "Output file path"),
Resume: fs.Bool("resume", cfg.AutoResume, "Resume interrupted download"),
Overwrite: fs.Bool("overwrite", false, "Overwrite existing file"),
Restart: fs.Bool("restart", false, "Delete existing file and restart download from scratch"),
Verbose: fs.Bool("verbose", cfg.Verbose, "Verbose output"),
Quiet: fs.Bool("quiet", false, "Suppress all output except errors"),
NoProgress: fs.Bool("no-progress", false, "Hide progress bar (keep other output)"),
JSON: fs.Bool("json", false, "Output machine-readable progress as JSON lines"),
Debug: fs.Bool("debug", cfg.Debug, "Debug mode"),
Timeout: fs.Duration("timeout", 0, "Timeout (0 = auto)"),
Proxy: fs.String("proxy", cfg.Proxy, "Proxy server URL (http://, https://, socks5://, socks5h://)"),
NoIPv4: fs.Bool("no-ipv4", !cfg.IPv4Fallback, "Disable IPv4 fallback"),
NoRedirect: fs.Bool("no-redirect", false, "Disable following HTTP redirects"),
MaxRetries: fs.Int("max-retries", 0, "Maximum number of retries on failure (0 = default)"),
DNSServers: fs.String("dns-servers", "", "Custom DNS servers (comma-separated, e.g. 1.1.1.1,8.8.8.8)"),
Checksum: fs.String("checksum", "", "Expected checksum value"),
ChecksumAlgo: fs.String("checksum-algo", cfg.ChecksumAlgo, "Checksum algorithm (sha256, sha512, blake2b, sha3-256, sha3-512, md5)"),
ChecksumFile: fs.String("checksum-file", "", "Read checksum from file (SHA256SUMS format)"),
NoDecompress: fs.Bool("no-decompress", !cfg.AutoDecompress, "Disable automatic decompression"),
Parallel: fs.Int("parallel", cfg.Parallel, "Number of parallel connections (0 = auto)"),
MaxSpeed: fs.String("max-speed", "", "Max speed (e.g., 10MB/s, 0 = unlimited)"),
Username: fs.String("username", cfg.Auth.Username, "Username for HTTP Basic/Digest Auth"),
Password: fs.String("password", "", "Password for HTTP Basic/Digest Auth"),
PasswordFile: fs.String("password-file", "", "Read password from file"),
AuthType: fs.String("auth-type", cfg.Auth.AuthType, "Auth type: basic, digest, auto"),
CookieJar: fs.String("cookie-jar", cfg.CookieJar, "File for loading/saving cookies"),
ContentDisp: fs.Bool("content-disposition", false, "Use server-provided filename from Content-Disposition header"),
ShowHeaders: fs.Bool("show-headers", false, "Print HTTP response headers during download"),
KeepTimestamps: fs.Bool("keep-timestamps", false, "Set file modification time from Last-Modified header"),
Recursive: fs.Bool("recursive", cfg.Recursive.Enabled, "Recursive downloading"),
RecursiveParallel: fs.Int("recursive-parallel", cfg.Recursive.Parallel, "Number of concurrent file downloads in recursive mode (0 = sequential)"),
MaxDepth: fs.Int("max-depth", cfg.Recursive.MaxDepth, "Maximum recursion depth"),
FollowExternal: fs.Bool("follow-external", cfg.Recursive.FollowExternal, "Follow external domains"),
Mirror: fs.Bool("mirror", false, "Mirror entire website (infinite depth, convert links)"),
NoConvertLinks: fs.Bool("no-convert-links", false, "Don't convert links for local viewing in mirror mode"),
NoMirrorAssets: fs.Bool("no-mirror-assets", false, "Don't download assets (CSS, JS, images) in mirror mode"),
NoRobots: fs.Bool("no-robots", false, "Don't respect robots.txt in mirror mode"),
RateLimit: fs.String("rate-limit", "", "Rate limit (e.g., 100KB/s, 1MB/s, 0 = unlimited)"),
ExcludePattern: fs.String("exclude-pattern", strings.Join(cfg.Recursive.ExcludePatterns, ","), "Exclude URL patterns (comma-separated)"),
Extract: fs.Bool("extract", cfg.Extract.Enabled, "Auto-extract archive after download"),
ExtractDir: fs.String("extract-dir", cfg.Extract.OutputDir, "Directory for extracted files"),
StripComponents: fs.Int("strip-components", cfg.Extract.StripComponents, "Strip N path components during extraction"),
Upload: fs.Bool("upload", false, "Upload file instead of downloading"),
UploadMethod: fs.String("upload-method", "PUT", "Upload method: PUT or POST"),
UploadFile: fs.String("upload-file", "", "File to upload"),
UploadFormData: fs.String("form-data", "", "Form data (key=value, comma-separated)"),
UploadFileField: fs.String("file-field", "file", "Form field name for file upload"),
PGPDecrypt: fs.Bool("pgp-decrypt", false, "Decrypt PGP encrypted file after download"),
PGPVerify: fs.Bool("pgp-verify", false, "Verify PGP signature after download"),
PGPSignature: fs.String("pgp-sig", "", "Path to PGP signature file (.asc, .sig)"),
PGPKey: fs.String("pgp-key", "", "Path to PGP key file (public or private)"),
PGPPassphrase: fs.String("pgp-passphrase", "", "Passphrase for PGP private key"),
PGPPassphraseFile: fs.String("pgp-passphrase-file", "", "Read PGP passphrase from file"),
OAuthClientID: fs.String("oauth-client-id", cfg.Auth.OAuth.ClientID, "OAuth 2.0 Client ID"),
OAuthClientSecret: fs.String("oauth-client-secret", cfg.Auth.OAuth.ClientSecret, "OAuth 2.0 Client Secret"),
OAuthTokenURL: fs.String("oauth-token-url", cfg.Auth.OAuth.TokenURL, "OAuth 2.0 Token URL"),
OAuthAuthURL: fs.String("oauth-auth-url", cfg.Auth.OAuth.AuthURL, "OAuth 2.0 Authorization URL"),
OAuthRedirectURI: fs.String("oauth-redirect-uri", cfg.Auth.OAuth.RedirectURI, "OAuth 2.0 Redirect URI"),
OAuthScopes: fs.String("oauth-scopes", strings.Join(cfg.Auth.OAuth.Scopes, ","), "OAuth 2.0 Scopes (comma-separated)"),
OAuthGrantType: fs.String("oauth-grant-type", cfg.Auth.OAuth.GrantType, "OAuth 2.0 Grant Type (client_credentials, authorization_code, password, refresh_token)"),
OAuthAccessToken: fs.String("oauth-access-token", cfg.Auth.OAuth.AccessToken, "OAuth 2.0 Access Token (direct)"),
OAuthRefreshToken: fs.String("oauth-refresh-token", cfg.Auth.OAuth.RefreshToken, "OAuth 2.0 Refresh Token"),
Metalink: fs.Bool("metalink", false, "Treat URL as Metalink (multi-source download)"),
MetalinkFile: fs.String("metalink-file", "", "Path to local Metalink file"),
Queue: fs.Bool("queue", false, "Add to download queue instead of downloading immediately"),
QueueList: fs.Bool("queue-list", false, "List items in download queue"),
QueueProcess: fs.Bool("queue-process", false, "Process download queue"),
QueueClear: fs.Bool("queue-clear", false, "Clear completed items from queue"),
QueueFile: fs.String("queue-file", "", "Custom queue file path"),
QueuePriority: fs.Int("queue-priority", 5, "Priority for queue item (1-10)"),
BatchFile: fs.String("batch-file", "", "Download multiple URLs from a file (one per line)"),
InitConfig: fs.Bool("init-config", false, "Generate default config file"),
ConfigGet: fs.String("config-get", "", "Get a config value (e.g. --config-get timeout)"),
ConfigSet: fs.String("config-set", "", "Set a config value (e.g. --config-set timeout 5m)"),
ConfigList: fs.Bool("config-list", false, "List all config values"),
ConfigUnset: fs.String("config-unset", "", "Unset a config value (e.g. --config-unset timeout)"),
Completion: fs.String("completion", "", "Generate shell completion script (bash, zsh, fish)"),
Info: fs.String("info", "", "Show file information without downloading"),
Benchmark: fs.String("benchmark", "", "Benchmark download speed from URL (downloads first 10MB)"),
Schedule: fs.String("schedule", "", "Schedule download at specified time (e.g. '2026-06-01 02:00' or cron '0 2 * * *')"),
BindInterface: fs.String("bind-interface", "", "Bind to specific network interface"),
KnownHosts: fs.String("known-hosts", "", "Path to SSH known_hosts file for SFTP (default: ~/.ssh/known_hosts)"),
SSHInsecure: fs.Bool("ssh-insecure", false, "Skip SSH host key verification for SFTP"),
DryRun: fs.Bool("dry-run", false, "List URLs without saving files in recursive/mirror mode"),
OnComplete: fs.String("on-complete", "", "Run command after successful download"),
GenManPage: fs.Bool("generate-man-page", false, "Generate man page and exit"),
Help: fs.Bool("help", false, "Show help"),
Version: fs.Bool("version", false, "Show version"),
// curl/wget compatible flags
Insecure: fs.Bool("insecure", false, "Skip TLS certificate verification"),
CertFile: fs.String("cert", "", "Client certificate file (PEM, for mTLS)"),
KeyFile: fs.String("key", "", "Client private key file (PEM)"),
SSLKeyLogFile: fs.String("ssl-key-log", "", "File to log TLS session keys to (SSLKEYLOGFILE for Wireshark)"),
Data: fs.String("data", "", "Send POST data (e.g., --data 'key=value')"),
MaxTime: fs.Duration("max-time", 0, "Maximum total transfer time (0 = unlimited)"),
MaxFileSize: fs.String("max-filesize", "", "Maximum file size to download (e.g., 100MB)"),
Accept: fs.String("accept", "", "Accept patterns for recursive download (e.g., '*.pdf,*.html')"),
NoParent: fs.Bool("no-parent", false, "Do not ascend to parent directory in recursive download"),
Spider: fs.Bool("spider", false, "Check URL existence only (HEAD request, no download)"),
WriteOut: fs.String("write-out", "", "Output metadata after download (curl format: %{http_code}, %{size_download}, %{time_total})"),
ProgressStyle: fs.String("progress", "", "Progress display style: bar (default) or dot"),
WarcFile: fs.String("warc-file", "", "Write WARC output file for web archiving"),
TraceTime: fs.Bool("trace-time", false, "Print HTTP timing breakdown after download"),
PageRequisites: fs.Bool("page-requisites", false, "Download all assets (CSS, JS, images) needed to display the page"),
SpanHosts: fs.Bool("span-hosts", false, "Follow links to external domains in recursive download"),
AdjustExt: fs.Bool("adjust-extension", false, "Append .html extension to downloaded HTML files"),
Wait: fs.String("wait", "", "Wait between requests in recursive/mirror mode (e.g., 1s, 500ms)"),
RandomWait: fs.Bool("random-wait", false, "Randomize wait time (0.5x to 1.5x) in recursive/mirror mode"),
ConvertLinks: fs.Bool("convert-links", false, "Convert links for local viewing in recursive mode"),
Fail: fs.Bool("fail", false, "Exit with error on HTTP 4xx/5xx status codes"),
Compressed: fs.Bool("compressed", false, "Send Accept-Encoding header for auto-decompression"),
Referer: fs.String("referer", "", "Send Referer header"),
RetryDelay: fs.Duration("retry-delay", 0, "Delay between retry attempts"),
PinnedCert: fs.String("pinned-cert", "", "SHA-256 hash of server certificate for pinning"),
AllowPrivate: fs.Bool("allow-private", false, "Allow connections to private/internal IPs (disables SSRF protection)"),
Range: fs.String("range", "", "Download only a range of bytes (e.g., 0-1000)"),
ConnectTimeout: fs.Duration("connect-timeout", 0, "Timeout for connection establishment"),
Domains: fs.String("domains", "", "Only follow these domains in recursive mode (comma-separated)"),
Reject: fs.String("reject", "", "Reject URL patterns in recursive mode (glob, comma-separated)"),
CutDirs: fs.Int("cut-dirs", 0, "Strip N directory levels when saving files"),
PostFile: fs.String("post-file", "", "Send file contents as POST body"),
CACertificate: fs.String("ca-certificate", "", "Path to custom CA certificate"),
NoClobber: fs.Bool("no-clobber", false, "Skip download if output file already exists"),
RetryHTTPError: fs.String("retry-on-http-error", "", "Retry on these HTTP status codes (comma-separated, e.g. 503,429)"),
RetryAllErrors: fs.Bool("retry-all-errors", false, "Retry on any HTTP error (4xx/5xx) instead of just selected codes"),
Glob: fs.Bool("glob", false, "Expand [1-3], {a,b} patterns in URL"),
Timestamping: fs.Bool("timestamping", false, "Download only if remote file is newer"),
BackupConverted: fs.Bool("backup-converted", false, "Backup original file before link conversion"),
Continue: fs.Bool("continue", false, "Auto-resume download if output file exists"),
ProtoDirs: fs.Bool("protocol-directories", false, "Create .http/, .ftp/ subdirectories in recursive mode"),
InputFile: fs.String("input-file", "", "Read URLs from file (use - for stdin)"),
HSTSFile: fs.String("hsts-file", "", "HSTS cache file (default: ~/.config/goget/hsts)"),
CreateDirs: fs.Bool("create-dirs", true, "Create missing parent directories of --output (curl --create-dirs)"),
}
fs.Var(&f.Headers, "header", "Custom HTTP header (repeatable, e.g. --header 'Key: Value')")
fs.Var(&f.Cookies, "cookie", "Set cookie (repeatable, e.g. --cookie 'name=value')")
fs.Var(&f.Form, "form", "Multipart form data (repeatable, e.g. --form 'field=value' or --form 'field=@file')")
return f
}
+356
View File
@@ -0,0 +1,356 @@
//go:build linux || freebsd
// +build linux freebsd
package main
import (
"context"
"fmt"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"codeberg.org/petrbalvin/goget/internal/archive"
"codeberg.org/petrbalvin/goget/internal/cli"
"codeberg.org/petrbalvin/goget/internal/config"
"codeberg.org/petrbalvin/goget/internal/cookie"
"codeberg.org/petrbalvin/goget/internal/core"
format "codeberg.org/petrbalvin/goget/internal/format"
"codeberg.org/petrbalvin/goget/internal/hsts"
"codeberg.org/petrbalvin/goget/internal/output"
"codeberg.org/petrbalvin/goget/internal/protocol/http"
"codeberg.org/petrbalvin/goget/internal/protocol/webdav"
"codeberg.org/petrbalvin/goget/internal/warc"
"golang.org/x/net/idna"
)
func main() {
os.Exit(run())
}
// signalContext returns a context that is cancelled when SIGINT or SIGTERM
// is received. Extracted as a helper so it can be unit-tested in
// isolation (testing the real signal flow inside run() would race with
// other tests in the same package).
func signalContext() (context.Context, context.CancelFunc) {
return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
}
// run is a thin wrapper that delegates to the app struct's run method.
func run() int {
var a app
return a.run()
}
// setupOutputWriter creates the output writer and progress bar for non-recursive downloads.
func setupOutputWriter(f *Flags, cfg *config.Config) (*output.Writer, *cli.ProgressBar) {
var progressBar *cli.ProgressBar
if cfg.Verbose && !*f.Recursive && !*f.NoProgress {
progressCfg := cli.DefaultProgressBarConfig()
progressCfg.Total = -1
progressCfg.Colors = cfg.ShouldUseColors()
progressBar = cli.NewProgressBar(progressCfg)
}
var writer *output.Writer
if !*f.Recursive {
writerCfg := output.DefaultWriterConfig()
writerCfg.Output = *f.Output
writerCfg.Resume = *f.Resume
writerCfg.Verbose = cfg.Verbose
writerCfg.Atomic = !*f.Overwrite
writerCfg.CreateDirs = *f.CreateDirs
if progressBar != nil || *f.JSON || *f.ProgressStyle == "dot" {
writerCfg.ProgressCallback = func(current, total int64, speed float64) {
if *f.ProgressStyle == "dot" {
// Wget-style dot progress: print dot per 100KB
if current > 0 && current%(100*1024) < 32*1024 {
fmt.Fprintf(os.Stderr, ".")
}
}
if *f.JSON {
// JSON-lines progress output
eta := ""
if total > 0 && current < total && speed > 0 {
remaining := float64(total-current) / speed
eta = fmt.Sprintf(`,"eta_ms":%d`, int64(remaining*1000))
}
totalJSON := "null"
if total > 0 {
totalJSON = fmt.Sprintf("%d", total)
}
fmt.Printf(`{"type":"progress","downloaded":%d,"total":%s,"speed":%d%s}`+"\n",
current, totalJSON, int64(speed), eta)
}
if progressBar != nil && total > 0 {
progressBar.Update(current)
}
}
}
var err error
writer, err = output.NewWriter(writerCfg)
if err != nil {
cli.PrintError(os.Stderr, err)
return nil, progressBar
}
}
return writer, progressBar
}
// setupHTTPConfig populates an http.Config from CLI flags, parsed config, and cookie jar.
func setupHTTPConfig(f *Flags, cfg *config.Config, cookieJar *cookie.Jar, parsedURL *url.URL) *http.Config {
httpCfg := http.DefaultConfig()
ApplyFlags(httpCfg, f, cfg, cookieJar, parsedURL)
return httpCfg
}
// runPostDownload handles WARC, cookie saving, checksum, PGP, write-out, trace-time,
// on-complete hook, verbose output, and HSTS save. Returns the exit code.
func runPostDownload(
f *Flags,
cfg *config.Config,
result *core.DownloadResult,
cookieJar *cookie.Jar,
cookieJarPath string,
hstsCache *hsts.Cache,
writer *output.Writer,
parsedURL *url.URL,
startTime time.Time,
) int {
if !*f.Recursive {
if cerr := writer.Close(); cerr != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to close writer: %v\n", cerr)
}
// Adjust extension: append .html to HTML files without extension
if *f.AdjustExt && *f.Output != "" {
if filepath.Ext(*f.Output) == "" {
newPath := *f.Output + ".html"
if err := os.Rename(*f.Output, newPath); err == nil {
*f.Output = newPath
result.OutputPath = newPath
if cfg.Verbose {
cli.PrintInfo(os.Stderr, fmt.Sprintf("Adjusted extension: %s", newPath))
}
}
}
}
}
// Dot progress newline
if *f.ProgressStyle == "dot" {
fmt.Fprintf(os.Stderr, "\n")
}
// WARC writing
if *f.WarcFile != "" && !*f.Recursive && *f.Output != "" {
w := warc.NewWriter(*f.WarcFile)
if err := w.WriteRecordFile(core.SafeURL(parsedURL), *f.Output); err != nil {
cli.PrintWarning(os.Stderr, fmt.Sprintf("Failed to write WARC: %v", err))
} else if cfg.Verbose {
cli.PrintInfo(os.Stderr, fmt.Sprintf("WARC record saved to %s", *f.WarcFile))
}
}
if cookieJar != nil && cookieJarPath != "" {
if err := cookieJar.SaveToFile(cookieJarPath); err != nil {
if cfg.Verbose {
fmt.Fprintf(os.Stderr, "Warning: failed to save cookies: %v\n", err)
}
} else if cfg.Verbose {
cli.PrintProgressInfo(os.Stderr, fmt.Sprintf("Saved cookies to %s", cookieJarPath))
}
}
if !*f.Recursive && *f.Checksum != "" && *f.Output != "" {
ok, err := verifyChecksum(*f.Output, *f.Checksum, cfg.ChecksumAlgo, cfg.Verbose)
if err != nil {
cli.PrintError(os.Stderr, err)
return 1
}
if !ok {
return 1
}
}
// PGP passphrase from file (more secure than CLI flag)
if *f.PGPPassphraseFile != "" && *f.PGPPassphrase == "" {
data, err := os.ReadFile(*f.PGPPassphraseFile)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to read pgp passphrase file: %w", err))
return 1
}
pass := strings.TrimSpace(string(data))
f.PGPPassphrase = &pass
}
// PGP verification and/or decryption
if !*f.Recursive && (*f.PGPVerify || *f.PGPDecrypt) && *f.Output != "" {
ok, err := verifyPGP(*f.Output, *f.PGPVerify, *f.PGPDecrypt, f.PGPKey, f.PGPPassphrase, f.PGPSignature, cfg.Verbose)
if err != nil {
cli.PrintError(os.Stderr, err)
return 1
}
if !ok {
return 1
}
}
duration := time.Since(startTime)
// JSON completion event
if *f.JSON && !*f.Recursive {
fmt.Printf(`{"type":"complete","downloaded":%d,"total":%d,"speed":%d,"elapsed_ms":%d,"output":"%s"}`+"\n",
result.BytesDownloaded, result.TotalSize, int64(result.Speed), duration.Milliseconds(), result.OutputPath)
}
// On-complete hook — run command directly without shell to avoid injection
if *f.OnComplete != "" && !*f.Recursive {
if cfg.Verbose {
cli.PrintInfo(os.Stderr, fmt.Sprintf("Running: %s", *f.OnComplete))
}
// Split command into executable and arguments (basic shell-like tokenization)
tokens := splitCommand(*f.OnComplete)
if len(tokens) == 0 {
cli.PrintWarning(os.Stderr, "On-complete hook: empty command")
} else {
cmd := exec.Command(tokens[0], tokens[1:]...)
cmd.Env = append(os.Environ(),
fmt.Sprintf("GOGET_OUTPUT=%s", result.OutputPath),
fmt.Sprintf("GOGET_SIZE=%d", result.BytesDownloaded),
fmt.Sprintf("GOGET_URL=%s", core.SafeURL(parsedURL)),
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
cli.PrintWarning(os.Stderr, fmt.Sprintf("On-complete hook failed: %v", err))
}
}
}
if cfg.Verbose {
fmt.Fprintf(os.Stderr, "\n")
if *f.Recursive {
cli.PrintProgressSuccess(os.Stderr, fmt.Sprintf("Recursive download complete in %v", duration.Round(time.Second)))
fmt.Fprintf(os.Stderr, " Files downloaded: %d\n", result.ChunksCount)
fmt.Fprintf(os.Stderr, " Total size: %s\n", format.Bytes(result.BytesDownloaded))
fmt.Fprintf(os.Stderr, " Average speed: %s/s\n", format.Speed(result.Speed))
fmt.Fprintf(os.Stderr, " Output directory: %s\n", result.OutputPath)
} else {
resumeNote := ""
if result.Resumed {
resumeNote = " [resumed]"
}
parallelNote := ""
if result.Parallel {
parallelNote = fmt.Sprintf(" [%d parallel chunks]", result.ChunksCount)
}
extractNote := ""
if *f.Extract && archive.IsArchiveFormat(*f.Output) {
extractNote = " [auto-extracted]"
}
cli.PrintProgressSuccess(os.Stderr, fmt.Sprintf("Downloaded %s in %v (%s)%s%s%s",
format.Bytes(result.BytesDownloaded),
duration.Round(time.Millisecond),
format.Speed(result.Speed),
resumeNote,
parallelNote,
extractNote))
fmt.Fprintf(os.Stderr, " Protocol: %s | IPv%d | Saved: %s\n",
result.Protocol, result.IPVersion, result.OutputPath)
}
}
// Write-out (curl-compatible metadata output)
if *f.WriteOut != "" && !*f.Recursive {
handleWriteOut(*f.WriteOut, result, result.BytesDownloaded, duration, result.HTTPStatusCode)
}
// Trace timing output
if *f.TraceTime && result.TraceTimings != nil {
fmt.Fprintf(os.Stderr, "Timing breakdown: %s\n", result.TraceTimings.String())
}
// Save HSTS cache
if hstsCache != nil {
if err := hstsCache.Save(); err != nil && cfg.Verbose {
cli.PrintWarning(os.Stderr, fmt.Sprintf("failed to save hsts cache: %v", err))
}
}
return 0
}
// splitCommand splits a shell-like command string into tokens respecting double quotes.
// Used to parse --on-complete and --on-complete-url without invoking a shell.
func splitCommand(raw string) []string {
var tokens []string
current := ""
inQuote := false
for i := 0; i < len(raw); i++ {
c := raw[i]
switch {
case c == '"':
inQuote = !inQuote
case c == ' ' || c == '\t':
if inQuote {
current += string(c)
} else if current != "" {
tokens = append(tokens, current)
current = ""
}
default:
current += string(c)
}
}
if current != "" {
tokens = append(tokens, current)
}
return tokens
}
// idnaURL converts internationalized domain names to Punycode.
func idnaURL(u *url.URL) *url.URL {
if u == nil || u.Hostname() == "" {
return u
}
host := u.Hostname()
ascii, err := idna.Lookup.ToASCII(host)
if err != nil || ascii == host {
return u
}
// Replace hostname with Punycode version
if u.Port() != "" {
ascii = ascii + ":" + u.Port()
}
result := *u
result.Host = ascii
return &result
}
// webdavProtocol is a type alias for WebDAV protocol handler.
type webdavProtocol = webdav.Protocol
// parseStringSlice parses comma-separated string into a slice of strings.
func parseStringSlice(s string) []string {
if s == "" {
return nil
}
var parts []string
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p != "" {
parts = append(parts, p)
}
}
return parts
}
+381
View File
@@ -0,0 +1,381 @@
//go:build linux || freebsd
// +build linux freebsd
package main
import (
"context"
"flag"
"os"
"path/filepath"
"strings"
"testing"
"time"
"codeberg.org/petrbalvin/goget/internal/config"
)
func TestExtractConfigPath(t *testing.T) {
tests := []struct {
name string
args []string
expected string
}{
{"no config flag", []string{"--url", "https://example.com"}, ""},
{"--config with value", []string{"--config", "/path/to/config.json"}, "/path/to/config.json"},
{"--config=value", []string{"--config=/path/to/config.json"}, "/path/to/config.json"},
{"config after url", []string{"--url", "https://example.com", "--config", "/custom/config.json"}, "/custom/config.json"},
{"empty args", []string{}, ""},
{"--config without value", []string{"--config"}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := extractConfigPath(tc.args)
if result != tc.expected {
t.Errorf("extractConfigPath(%v) = %q, want %q", tc.args, result, tc.expected)
}
})
}
}
func TestParseRateLimit(t *testing.T) {
tests := []struct {
input string
expected int64
}{
{"", 0},
{"0", 0},
{"100", 100},
{"1KB/s", 1000},
{"1MB/s", 1000000},
{"1GB/s", 1000000000},
{"500KB/s", 500000},
{"10MB/s", 10000000},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
result, err := parseRateLimit(tc.input)
if err != nil {
t.Errorf("parseRateLimit(%q) returned unexpected error: %v", tc.input, err)
}
if result != tc.expected {
t.Errorf("parseRateLimit(%q) = %d, want %d", tc.input, result, tc.expected)
}
})
}
}
// TestParseRateLimitInvalid is a regression guard for the BACKLOG entry
// "`parseRateLimit` silently returns 0 on parse error". The previous
// implementation fell back to 0 without telling the caller, so a typo
// like "1oMB/s" (letter 'o' instead of digit '0') silently disabled rate
// limiting. The fixed implementation returns a non-nil error so the caller
// can warn the user.
func TestParseRateLimitInvalid(t *testing.T) {
tests := []struct {
name string
input string
}{
{"letter_o_instead_of_zero", "1oMB/s"},
{"pure_garbage", "abc"},
{"trailing_unit_only", "MB"},
{"only_unit_letter", "B"},
{"whitespace_around_digits", " abc "},
{"multiple_dots", "1.2.3MB"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := parseRateLimit(tc.input)
if err == nil {
t.Errorf("parseRateLimit(%q) = %d, nil err; want non-nil error", tc.input, result)
}
if result != 0 {
t.Errorf("parseRateLimit(%q) = %d on error, want 0", tc.input, result)
}
})
}
}
func TestExtractURLs(t *testing.T) {
tests := []struct {
name string
args []string
expected int
}{
{"single url", []string{"--url", "https://example.com/file.zip"}, 1},
{"multiple urls", []string{"--url", "https://a.com", "--url", "https://b.com"}, 2},
{"with other flags", []string{"--url", "https://a.com", "--verbose", "--url", "https://b.com"}, 2},
{"url= format", []string{"--url=https://a.com", "--url=https://b.com"}, 2},
{"no url", []string{"--verbose", "--debug"}, 0},
{"empty args", []string{}, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := extractURLs(tc.args)
if len(result) != tc.expected {
t.Errorf("extractURLs(%v) returned %d URLs, want %d", tc.args, len(result), tc.expected)
}
})
}
}
func TestBatchFileURLParsing(t *testing.T) {
// Test that the URL parsing logic in handleBatchFile would work
// by testing the individual steps
content := `https://example.com/file1.zip
https://example.com/file2.zip
# This is a comment
// This is also a comment
https://example.com/file3.zip`
lines := strings.Split(strings.TrimSpace(content), "\n")
var urls []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
continue
}
urls = append(urls, line)
}
if len(urls) != 3 {
t.Errorf("Expected 3 URLs, got %d: %v", len(urls), urls)
}
}
// TestConfigSetFilePermissions is a regression guard for the BACKLOG
// entry "os.WriteFile with 0644 on config containing OAuth tokens".
// The previous implementation used 0644, which leaves the config file
// (and any embedded access_token / refresh_token) world-readable on
// multi-user systems. After the fix, both handleConfigSet and
// handleConfigUnset must write the file with mode 0600.
func TestConfigFilePermissions(t *testing.T) {
tests := []struct {
name string
run func(t *testing.T, configPath string)
}{
{
name: "handleConfigSet writes 0600",
run: func(t *testing.T, configPath string) {
if rc := handleConfigSet(configPath, "timeout", []string{
"--config-set", "timeout", "30",
}); rc != 0 {
t.Fatalf("handleConfigSet returned %d", rc)
}
},
},
{
name: "handleConfigUnset writes 0600",
run: func(t *testing.T, configPath string) {
if rc := handleConfigSet(configPath, "timeout", []string{
"--config-set", "timeout", "30",
}); rc != 0 {
t.Fatalf("seed handleConfigSet returned %d", rc)
}
if rc := handleConfigUnset(configPath, "timeout"); rc != 0 {
t.Fatalf("handleConfigUnset returned %d", rc)
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.toml")
// Seed a minimal config so config.Load succeeds.
seed := []byte("proxy = \"\"\nuser_agent = \"\"\ntimeout = 0\n")
if err := os.WriteFile(configPath, seed, 0600); err != nil {
t.Fatalf("seed write: %v", err)
}
tc.run(t, configPath)
info, err := os.Stat(configPath)
if err != nil {
t.Fatalf("stat: %v", err)
}
perm := info.Mode().Perm()
// Mask out the umask bits by checking against 0600; the file
// must not be group- or world-readable.
if perm != 0600 {
t.Errorf("config file mode = %04o, want 0600", perm)
}
})
}
}
func TestNextCronTime(t *testing.T) {
// Use a fixed reference time for deterministic tests (UTC)
ref, _ := time.Parse("2006-01-02 15:04", "2026-06-01 10:00")
ref = ref.UTC()
t.Run("every minute", func(t *testing.T) {
next, err := nextCronTime("* * * * *", ref)
if err != nil {
t.Fatal(err)
}
expected := ref.Truncate(time.Minute).Add(time.Minute)
if !next.Equal(expected) {
t.Errorf("got %v, want %v", next, expected)
}
})
t.Run("specific hour and minute", func(t *testing.T) {
next, err := nextCronTime("30 14 * * *", ref)
if err != nil {
t.Fatal(err)
}
expected := time.Date(2026, 6, 1, 14, 30, 0, 0, time.UTC)
if !next.Equal(expected) {
t.Errorf("got %v, want %v", next, expected)
}
})
t.Run("daily at midnight", func(t *testing.T) {
next, err := nextCronTime("0 0 * * *", ref)
if err != nil {
t.Fatal(err)
}
expected := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)
if !next.Equal(expected) {
t.Errorf("got %v, want %v", next, expected)
}
})
t.Run("specific day of week", func(t *testing.T) {
// June 1, 2026 is Monday (1)
next, err := nextCronTime("0 9 * * 3", ref) // Wednesday
if err != nil {
t.Fatal(err)
}
if next.Weekday() != time.Wednesday {
t.Errorf("expected Wednesday, got %v", next.Weekday())
}
})
t.Run("comma separated hours", func(t *testing.T) {
next, err := nextCronTime("0 8,16 * * *", ref)
if err != nil {
t.Fatal(err)
}
expected := time.Date(2026, 6, 1, 16, 0, 0, 0, time.UTC)
if !next.Equal(expected) {
t.Errorf("got %v, want %v", next, expected)
}
})
t.Run("step values", func(t *testing.T) {
next, err := nextCronTime("*/15 * * * *", ref)
if err != nil {
t.Fatal(err)
}
if next.Minute()%15 != 0 {
t.Errorf("minute not a multiple of 15: %d", next.Minute())
}
})
t.Run("invalid fields", func(t *testing.T) {
_, err := nextCronTime("invalid", ref)
if err == nil {
t.Error("expected error for invalid cron expression")
}
})
t.Run("too many fields", func(t *testing.T) {
_, err := nextCronTime("1 2 3 4 5 6", ref)
if err == nil {
t.Error("expected error for 6 fields")
}
})
}
// TestExitCodeForRun covers the contract that graceful shutdown
// surfaces exit code 130 to the shell when the user interrupts the
// process. A clean exit (no signal) returns the inner code unchanged;
// a non-zero inner code (real error) is preserved even if the signal
// context was also cancelled.
func TestExitCodeForRun(t *testing.T) {
t.Run("clean exit on no signal returns inner code 0", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if got := exitCodeForRun(ctx, 0); got != 0 {
t.Errorf("got %d, want 0", got)
}
})
t.Run("cancelled signal context maps clean exit to 130", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // simulate SIGINT/SIGTERM propagation
if got := exitCodeForRun(ctx, 0); got != 130 {
t.Errorf("got %d, want 130", got)
}
})
t.Run("non-zero inner code is preserved when signal is also cancelled", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
// Even with the signal context cancelled, a real error (e.g.
// download failure) should not be masked by the 130 mapping.
if got := exitCodeForRun(ctx, 7); got != 7 {
t.Errorf("got %d, want 7 (inner code preserved)", got)
}
})
t.Run("non-zero inner code is returned unchanged with no signal", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if got := exitCodeForRun(ctx, 42); got != 42 {
t.Errorf("got %d, want 42", got)
}
})
t.Run("nil context returns inner code unchanged", func(t *testing.T) {
if got := exitCodeForRun(context.TODO(), 5); got != 5 {
t.Errorf("got %d, want 5 (nil context safe)", got)
}
})
}
// TestCreateDirsFlagRegisteredAndDefaults is a regression guard for the
// BACKLOG entry "Add --create-dirs flag (curl / wget compatibility)". It
// verifies the flag is registered on the command-line FlagSet, defaults to
// true (matching curl / wget behaviour so common `goget --output
// ./downloads/file.zip` invocations just work), and can be flipped to
// false via the standard flag parser. We do not exercise the full
// download path here — that is covered by TestNewWriterCreateDirsMissingParent
// in internal/output/writer_test.go.
func TestCreateDirsFlagRegisteredAndDefaults(t *testing.T) {
cfg := config.DefaultConfig()
fs := newTestFlagSet("goget-test")
flags := defineFlags(fs, cfg)
if flags.CreateDirs == nil {
t.Fatal("CreateDirs flag was not registered on the FlagSet")
}
if !*flags.CreateDirs {
t.Errorf("CreateDirs default = false, want true (curl / wget parity)")
}
// Flip the flag through the standard flag parser to make sure the
// long name is wired up correctly and the value reaches the struct.
fs2 := newTestFlagSet("goget-test-2")
flags2 := defineFlags(fs2, cfg)
if err := fs2.Parse([]string{"--create-dirs=false"}); err != nil {
t.Fatalf("Parse --create-dirs=false: %v", err)
}
if *flags2.CreateDirs {
t.Errorf("CreateDirs after --create-dirs=false = true, want false")
}
}
// newTestFlagSet builds an isolated flag.FlagSet for CLI tests. We use
// flag.ContinueOnError so a bad flag in a test fails the test instead of
// calling os.Exit and tearing down the rest of the test binary.
func newTestFlagSet(name string) *flag.FlagSet {
return flag.NewFlagSet(name, flag.ContinueOnError)
}
+217
View File
@@ -0,0 +1,217 @@
//go:build linux || freebsd
// +build linux freebsd
package main
import (
"fmt"
"codeberg.org/petrbalvin/goget/internal/core"
)
func handleGenManPage() int {
fmt.Printf(`.TH GOGET 1 "2026-06" "goget %s" "User Commands"
.SH NAME
goget \- modern IPv6-first command-line download utility
.SH SYNOPSIS
.B goget
\fB\-\-url\fR \fIURL\fR [\fIOPTIONS\fR]
.SH DESCRIPTION
goget is a modern replacement for curl and wget built on Go stdlib.
Features IPv6-first connections, parallel downloads, resume support,
compression, checksum verification, and recursive mirroring.
.SH OPTIONS
.SS "Target"
.TP
\fB\-\-url\fR=\fIURL\fR
URL to download (can be specified multiple times)
.SS "Basic Options"
.TP
\fB\-\-output\fR=\fIFILE\fR
Output filename (default: auto from URL)
.TP
\fB\-\-verbose\fR
Verbose output with progress bar (default: enabled)
.TP
\fB\-\-no-progress\fR
Hide progress bar (keep other output)
.TP
\fB\-\-quiet\fR
Suppress all output except errors
.TP
\fB\-\-debug\fR
Debug mode with detailed info
.TP
\fB\-\-resume\fR
Resume interrupted download
.TP
\fB\-\-restart\fR
Delete existing file and restart download
.TP
\fB\-\-overwrite\fR
Overwrite existing file
.TP
\fB\-\-no-decompress\fR
Disable automatic decompression
.TP
\fB\-\-parallel\fR=\fIN\fR
Parallel connections (0=auto, 1=sequential)
.TP
\fB\-\-json\fR
Output progress as JSON (for scripting)
.TP
\fB\-\-info\fR
Show file information without downloading
.TP
\fB\-\-benchmark\fR=\fIURL\fR
Benchmark download speed
.SS "Network"
.TP
\fB\-\-timeout\fR=\fIDURATION\fR
Connection timeout (0 = auto based on size)
.TP
\fB\-\-proxy\fR=\fIURL\fR
Proxy server URL (http://, https://, socks5://, socks5h://)
.TP
\fB\-\-dns-servers\fR=\fIIPs\fR
Custom DNS servers (comma-separated)
.TP
\fB\-\-max-retries\fR=\fIN\fR
Maximum number of retries on failure
.TP
\fB\-\-no-ipv4\fR
Disable IPv4 fallback
.TP
\fB\-\-no-redirect\fR
Disable following HTTP redirects
.TP
\fB\-\-show-headers\fR
Print HTTP response headers
.TP
\fB\-\-keep-timestamps\fR
Set file modification time from Last-Modified header
.TP
\fB\-\-content-disposition\fR
Use server-provided filename from Content-Disposition header
.SS "Authentication"
.TP
Credentials are loaded automatically from \fB~/.netrc\fR when no \fB\-\-username\fR is given.
.TP
\fB\-\-username\fR=\fIUSER\fR
Username for HTTP Basic/Digest Auth
.TP
\fB\-\-password\fR=\fIPASS\fR
Password for HTTP Basic/Digest Auth
.TP
\fB\-\-password-file\fR=\fIFILE\fR
Read password from file
.TP
\fB\-\-auth-type\fR=\fITYPE\fR
Auth type: basic, digest, auto
.TP
\fB\-\-cookie-jar\fR=\fIFILE\fR
File for loading/saving cookies
.TP
\fB\-\-cookie\fR=\fINAME=VALUE\fR
Set cookie from CLI (repeatable, curl-compatible)
.TP
\fB\-\-retry-all-errors\fR
Retry on any HTTP 4xx/5xx response
.TP
\fB\-\-ssl-key-log\fR=\fIFILE\fR
Log TLS session keys to file (SSLKEYLOGFILE for Wireshark)
.SS "Config"
.TP
\fB\-\-config\fR=\fIFILE\fR
Path to config file
.TP
\fB\-\-config-get\fR=\fIKEY\fR
Get a config value
.TP
\fB\-\-config-set\fR=\fIKEY\fR \fIVALUE\fR
Set a config value
.TP
\fB\-\-config-list\fR
List all config values
.TP
\fB\-\-config-unset\fR=\fIKEY\fR
Unset a config value
.TP
\fB\-\-init-config\fR
Generate default config file
.SS "Mirror & Recursive"
.TP
\fB\-\-mirror\fR
Mirror entire website (infinite depth)
.TP
\fB\-\-recursive\fR
Enable recursive downloading
.TP
\fB\-\-max-depth\fR=\fIN\fR
Maximum recursion depth
.SS "Scheduling & Hooks"
.TP
\fB\-\-schedule\fR=\fITIME\fR
Schedule download ("YYYY-MM-DD HH:MM" or cron "0 2 * * *")
.TP
\fB\-\-on-complete\fR=\fICMD\fR
Run shell command after successful download
.TP
\fB\-\-bind-interface\fR=\fIIFACE\fR
Bind to specific network interface
.SS "Other"
.TP
\fB\-\-batch-file\fR=\fIFILE\fR
Download multiple URLs from a file
.TP
\fB\-\-checksum-file\fR=\fIFILE\fR
Read expected checksum from SHA256SUMS file
.TP
\fB\-\-generate-man-page\fR
Generate this man page
.TP
\fB\-\-help\fR
Show help
.TP
\fB\-\-version\fR
Show version
.SH EXAMPLES
.TP
Download a file:
goget --url https://example.com/file.zip
.TP
Parallel download:
goget --url https://example.com/large.iso --parallel 4
.TP
Resume interrupted download:
goget --url https://example.com/file.zip --resume
.TP
Mirror website:
goget --url https://example.com --mirror --output ./mirror
.TP
Recursive download:
goget --url https://example.com --recursive --max-depth 2
.TP
JSON progress (for scripting):
goget --url https://example.com/file.zip --json --no-progress > progress.jsonl
.TP
Scheduled download at 2 AM:
goget --url https://example.com/daily.zip --schedule "0 2 * * *"
.TP
Run import script after download:
goget --url https://example.com/data.csv --on-complete "import.sh"
.SH FILES
.TP
~/.config/goget/config.json
Configuration file
.TP
<file>.goget.meta
Resume metadata file
.SH BUGS
Report bugs at https://codeberg.org/petrbalvin/goget/issues
.SH AUTHOR
Petr Balvin
.SH LICENSE
MIT License`, core.Version)
return 0
}
+145
View File
@@ -0,0 +1,145 @@
//go:build linux || freebsd
// +build linux freebsd
package main
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"codeberg.org/petrbalvin/goget/internal/cli"
"codeberg.org/petrbalvin/goget/internal/config"
)
// waitForSchedule blocks until the scheduled time or returns an error code.
func waitForSchedule(schedule string, cfg *config.Config, sigChan <-chan os.Signal) int {
var scheduledTime time.Time
var isCron bool
var cronSchedule string
parsedTime, parseErr := time.Parse("2006-01-02 15:04", schedule)
if parseErr == nil {
scheduledTime = parsedTime
} else {
fields := strings.Fields(schedule)
if len(fields) == 5 {
isCron = true
cronSchedule = schedule
next, cerr := nextCronTime(cronSchedule, time.Now())
if cerr != nil {
cli.PrintError(os.Stderr, fmt.Errorf("invalid schedule: %w (use 'YYYY-MM-DD HH:MM' or cron 'MIN HOUR DOM MON DOW')", cerr))
return 1
}
scheduledTime = next
} else {
cli.PrintError(os.Stderr, fmt.Errorf("invalid schedule format: %w (use 'YYYY-MM-DD HH:MM' or cron 'MIN HOUR DOM MON DOW')", parseErr))
return 1
}
}
delay := time.Until(scheduledTime)
if delay <= 0 {
cli.PrintError(os.Stderr, fmt.Errorf("scheduled time is in the past"))
return 1
}
if cfg.Verbose {
label := "Scheduled"
if isCron {
label = fmt.Sprintf("Cron (%s) scheduled", cronSchedule)
}
cli.PrintInfo(os.Stderr, fmt.Sprintf("%s download in %v (at %s)", label, delay.Round(time.Second), scheduledTime.Format(time.RFC3339)))
}
select {
case <-time.After(delay):
case <-sigChan:
return 1
}
return 0
}
// nextCronTime calculates the next scheduled time from a cron expression.
func nextCronTime(expr string, from time.Time) (time.Time, error) {
fields := strings.Fields(expr)
if len(fields) != 5 {
return time.Time{}, fmt.Errorf("cron expression must have 5 fields, got %d", len(fields))
}
minute := fields[0]
hour := fields[1]
dom := fields[2]
month := fields[3]
dow := fields[4]
t := from.Truncate(time.Minute).Add(time.Minute)
deadline := from.AddDate(2, 0, 0)
for t.Before(deadline) {
if matchCronField(minute, t.Minute(), 0, 59) &&
matchCronField(hour, t.Hour(), 0, 23) &&
matchCronField(dom, t.Day(), 1, 31) &&
matchCronField(month, int(t.Month()), 1, 12) &&
matchCronField(dow, int(t.Weekday()), 0, 6) {
return t, nil
}
t = t.Add(time.Minute)
}
return time.Time{}, fmt.Errorf("no matching time found within 2 years")
}
func matchCronField(pattern string, value, min, max int) bool {
if pattern == "*" {
return true
}
for _, part := range strings.Split(pattern, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.Contains(part, "/") {
parts := strings.SplitN(part, "/", 2)
base := parts[0]
step, err := strconv.Atoi(parts[1])
if err != nil || step <= 0 {
continue
}
if base == "*" {
if (value-min)%step == 0 {
return true
}
continue
}
start, err := strconv.Atoi(base)
if err != nil {
continue
}
if value >= start && (value-start)%step == 0 {
return true
}
continue
}
if strings.Contains(part, "-") {
parts := strings.SplitN(part, "-", 2)
start, err1 := strconv.Atoi(parts[0])
end, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil {
continue
}
if value >= start && value <= end {
return true
}
continue
}
if v, err := strconv.Atoi(part); err == nil && v == value {
return true
}
}
return false
}
+262
View File
@@ -0,0 +1,262 @@
//go:build linux || freebsd
// +build linux freebsd
package main
import (
"fmt"
stdhttp "net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"codeberg.org/petrbalvin/goget/internal/auth"
"codeberg.org/petrbalvin/goget/internal/cli"
"codeberg.org/petrbalvin/goget/internal/config"
"codeberg.org/petrbalvin/goget/internal/cookie"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/protocol/http"
"codeberg.org/petrbalvin/goget/internal/transport"
)
// ApplyFlags populates the Config from CLI flags, global config, and cookie jar.
// It is the single function that maps all CLI flags to config fields — adding a
// new flag only requires adding one block here instead of hunting through
// scattered setup code.
func ApplyFlags(c *http.Config, f *Flags, cfg *config.Config, cookieJar *cookie.Jar, parsedURL *url.URL) {
// ── Transport defaults from global config ──
c.Transport.Timeout = cfg.GetEffectiveTimeout(-1, *f.Timeout)
c.Transport.Dialer.IPv4Fallback = cfg.IPv4Fallback
c.Transport.Dialer.Debug = cfg.Debug
c.UserAgent = cfg.UserAgent
c.Output.Verbose = cfg.Verbose
c.Parallel = &core.ParallelConfig{
Connections: cfg.GetParallelConnections(-1),
MinSize: 100 * 1024 * 1024,
MinChunkSize: 5 * 1024 * 1024,
MaxChunkSize: 50 * 1024 * 1024,
}
if cfg.MaxSpeed > 0 {
c.RateLimiter = transport.NewTokenBucket(cfg.MaxSpeed, cfg.MaxSpeed/10)
}
// ── DNS / interface ──
if len(cfg.DNSServers) > 0 {
c.Transport.DNSServers = cfg.DNSServers
}
if *f.BindInterface != "" {
c.Transport.BindInterface = *f.BindInterface
}
c.Transport.BlockPrivateIPs = !*f.AllowPrivate
// ── Authentication ──
if cfg.Auth.Username == "" && cfg.Auth.OAuth.AccessToken == "" {
if user, pass, ok := auth.LookupNetrc(parsedURL.Hostname()); ok {
cfg.Auth.Username = user
cfg.Auth.Password = pass
}
}
if cfg.Auth.Username != "" || cfg.Auth.OAuth.AccessToken != "" {
c.Auth.Username = cfg.Auth.Username
c.Auth.Password = cfg.Auth.Password
c.Auth.AuthType = cfg.Auth.AuthType
c.Auth.OAuth = &cfg.Auth.OAuth
}
if cookieJar != nil {
c.CookieJar = cookieJar
}
// ── TLS and certificates ──
if *f.Insecure && c.Transport.TLS != nil {
c.Transport.TLS.InsecureSkipVerify = true
}
if *f.CertFile != "" && c.Transport.TLS != nil {
c.Transport.TLS.CertFile = *f.CertFile
}
if *f.KeyFile != "" && c.Transport.TLS != nil {
c.Transport.TLS.KeyFile = *f.KeyFile
}
if *f.PinnedCert != "" && c.Transport.TLS != nil {
c.Transport.TLS.PinnedCertHash = *f.PinnedCert
}
if *f.CACertificate != "" && c.Transport.TLS != nil {
c.Transport.TLS.CACertFile = *f.CACertificate
}
if *f.SSLKeyLogFile != "" && c.Transport.TLS != nil {
c.Transport.TLS.KeyLogFile = *f.SSLKeyLogFile
}
if *f.ConnectTimeout > 0 && c.Transport.Dialer != nil {
c.Transport.Dialer.Timeout = *f.ConnectTimeout
}
// ── Retry and redirects ──
if *f.NoRedirect {
c.Transport.FollowRedirects = false
}
if *f.MaxRetries > 0 {
c.Transport.MaxRetries = *f.MaxRetries
if c.Transport.Retry != nil {
c.Transport.Retry.MaxRetries = *f.MaxRetries
}
}
if *f.RetryDelay > 0 {
c.Transport.RetryDelay = *f.RetryDelay
}
if *f.RetryHTTPError != "" {
for _, code := range strings.Split(*f.RetryHTTPError, ",") {
if code, err := strconv.Atoi(strings.TrimSpace(code)); err == nil {
c.Transport.RetryHTTPCodes = append(c.Transport.RetryHTTPCodes, code)
}
}
}
if *f.RetryAllErrors {
c.Transport.RetryAllErrors = true
}
// ── Timeouts and limits ──
if *f.ConnectTimeout > 0 {
c.Transport.ConnectTimeout = *f.ConnectTimeout
}
if *f.MaxTime > 0 {
c.Transport.MaxTime = *f.MaxTime
}
if *f.MaxFileSize != "" {
size, perr := parseRateLimit(*f.MaxFileSize)
if perr != nil {
cli.PrintWarning(os.Stderr, fmt.Sprintf("ignoring --max-filesize: %v; size limit disabled", perr))
}
c.Transport.MaxFileSize = size
}
// ── Output flags ──
if *f.ContentDisp {
c.Output.ContentDisposition = true
}
if *f.ShowHeaders {
c.Output.ShowHeaders = true
}
if *f.KeepTimestamps {
c.Output.KeepTimestamps = true
}
if *f.JSON {
c.Output.JSONOutput = true
}
if *f.OnComplete != "" {
c.Output.OnComplete = *f.OnComplete
}
if *f.WarcFile != "" {
c.Output.WarcFile = *f.WarcFile
}
if *f.TraceTime {
c.Output.TraceTime = true
}
if *f.WriteOut != "" {
c.Output.WriteOut = *f.WriteOut
}
// ── Request flags ──
if len(f.Headers) > 0 {
if c.CustomHeaders == nil {
c.CustomHeaders = make(map[string]string)
}
for _, h := range f.Headers {
parts := strings.SplitN(h, ":", 2)
key := strings.TrimSpace(parts[0])
val := ""
if len(parts) > 1 {
val = strings.TrimSpace(parts[1])
}
if strings.ContainsAny(key, "\r\n") || strings.ContainsAny(val, "\r\n") {
cli.PrintWarning(os.Stderr, fmt.Sprintf("Skipping header with CRLF: %s", key))
continue
}
c.CustomHeaders[key] = val
}
}
if *f.Data != "" {
c.PostData = *f.Data
}
if *f.PostFile != "" {
c.PostFile = *f.PostFile
}
if *f.Range != "" {
c.Range = *f.Range
}
if *f.Compressed {
c.Compressed = true
}
if *f.Referer != "" {
c.Referer = *f.Referer
}
if *f.Fail {
c.FailOnHTTPError = true
}
if *f.NoClobber {
c.Transport.NoClobber = true
}
if len(f.Form) > 0 {
c.FormData = f.Form
}
// ── Recursive flags ──
if *f.RecursiveParallel > 0 {
c.Recursive.Parallel = *f.RecursiveParallel
}
if *f.Wait != "" {
if d, err := time.ParseDuration(*f.Wait); err == nil {
c.Recursive.Wait = d
}
}
if *f.RandomWait {
c.Recursive.RandomWait = true
}
if *f.ConvertLinks {
c.Recursive.ConvertLinks = true
}
if *f.Accept != "" {
c.Recursive.Accept = *f.Accept
}
if *f.Domains != "" {
c.Recursive.Domains = strings.Split(*f.Domains, ",")
}
if *f.Reject != "" {
c.Recursive.Reject = strings.Split(*f.Reject, ",")
}
if *f.CutDirs > 0 {
c.Recursive.CutDirs = *f.CutDirs
}
if *f.PageRequisites {
c.Recursive.PageRequisites = true
}
if *f.SpanHosts {
c.Recursive.SpanHosts = true
}
if *f.AdjustExt {
c.Recursive.AdjustExt = true
}
// ── Cookies from CLI flags ──
if len(f.Cookies) > 0 {
if cookieJar == nil {
var err error
cookieJar, err = cookie.NewJar()
if err != nil {
cli.PrintWarning(os.Stderr, fmt.Sprintf("failed to create cookie jar: %v", err))
}
}
if cookieJar != nil {
for _, cstr := range f.Cookies {
parts := strings.SplitN(cstr, "=", 2)
if len(parts) < 2 {
cli.PrintWarning(os.Stderr, fmt.Sprintf("invalid cookie format (use name=value): %s", cstr))
continue
}
ck := &stdhttp.Cookie{Name: parts[0], Value: parts[1]}
cookieJar.SetCookies(parsedURL, []*stdhttp.Cookie{ck})
}
c.CookieJar = cookieJar
}
}
}
+200
View File
@@ -0,0 +1,200 @@
//go:build linux || freebsd
// +build linux freebsd
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"codeberg.org/petrbalvin/goget/internal/crypto"
)
// parseChecksumFile reads a checksum file (SHA256SUMS format) and finds
// the expected checksum for the given filename.
func parseChecksumFile(checksumFile, outputFile string) (string, error) {
data, err := os.ReadFile(checksumFile)
if err != nil {
return "", fmt.Errorf("failed to read checksum file: %w", err)
}
targetName := filepath.Base(outputFile)
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Format: <checksum> <filename> or <checksum> *<filename>
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
checksum := parts[0]
filename := strings.TrimPrefix(parts[1], "*")
if filename == targetName {
return checksum, nil
}
// Also check if the output file matches
if filename == outputFile {
return checksum, nil
}
}
return "", fmt.Errorf("no checksum found for %s in %s", targetName, checksumFile)
}
// verifyChecksum verifies the checksum of a downloaded file.
// Returns true if the checksum matches, false otherwise.
// Returns an error if verification could not be performed.
func verifyChecksum(outputFile, checksumFlag, checksumAlgo string, verbose bool) (bool, error) {
if verbose {
fmt.Fprintf(os.Stderr, "\nVerifying checksum...\n")
}
algo := crypto.SHA256
switch strings.ToLower(checksumAlgo) {
case "sha256":
algo = crypto.SHA256
case "sha512":
algo = crypto.SHA512
case "blake2b":
algo = crypto.BLAKE2b
case "sha3-256", "sha3_256":
algo = crypto.SHA3_256
case "sha3-512", "sha3_512":
algo = crypto.SHA3_512
case "md5":
algo = crypto.MD5
default:
return false, fmt.Errorf("unsupported checksum algorithm: %s", checksumAlgo)
}
verifier, verr := crypto.NewChecksumVerifier(algo, checksumFlag)
if verr != nil {
return false, verr
}
match, actual, verr := verifier.VerifyFile(outputFile)
if verr != nil {
return false, verr
}
if !match {
return false, fmt.Errorf("checksum mismatch\n Expected: %s\n Actual: %s", checksumFlag, actual)
}
if verbose {
fmt.Fprintf(os.Stderr, "Checksum OK: %s\n", actual)
}
return true, nil
}
// verifyPGP handles PGP signature verification and PGP decryption.
// Returns true on success, false on failure, and any error encountered.
func verifyPGP(outputFile string, pgpVerify, pgpDecrypt bool, pgpKeyFlag, pgpPassphraseFlag, pgpSignatureFlag *string, verbose bool) (bool, error) {
// PGP signature verification
if pgpVerify && outputFile != "" {
if verbose {
fmt.Fprintf(os.Stderr, "\nVerifying PGP signature...\n")
}
pgpCfg := &crypto.PGPConfig{
PublicKeyPath: *pgpKeyFlag,
Passphrase: *pgpPassphraseFlag,
}
verifier := crypto.NewPGPVerifier(pgpCfg)
var valid bool
var identity string
var err error
if *pgpSignatureFlag != "" {
// Detached signature
valid, identity, err = verifier.VerifyFile(outputFile, *pgpSignatureFlag)
} else {
// Try to find signature file automatically
sigFiles := []string{
outputFile + ".asc",
outputFile + ".sig",
outputFile + ".gpg",
}
for _, sigFile := range sigFiles {
if _, statErr := os.Stat(sigFile); statErr == nil {
valid, identity, err = verifier.VerifyFile(outputFile, sigFile)
if valid {
break
}
}
}
if !valid {
err = fmt.Errorf("signature file not found or invalid (tried: %s)", strings.Join(sigFiles, ", "))
}
}
if err != nil {
return false, fmt.Errorf("pgp verification failed: %w", err)
}
if !valid {
return false, fmt.Errorf("pgp signature verification failed")
}
if verbose {
fmt.Fprintf(os.Stderr, "PGP signature verified OK (signed by: %s)\n", identity)
}
}
// PGP decryption
if pgpDecrypt && outputFile != "" {
if verbose {
fmt.Fprintf(os.Stderr, "\nDecrypting PGP file...\n")
}
// Read file to check if it's PGP encrypted
data, err := os.ReadFile(outputFile)
if err != nil {
return false, fmt.Errorf("failed to read file for pgp check: %w", err)
}
if !crypto.IsPGPEncrypted(data) {
if verbose {
fmt.Fprintf(os.Stderr, "File is not PGP encrypted, skipping decryption\n")
}
return true, nil
}
pgpCfg := &crypto.PGPConfig{
PrivateKeyPath: *pgpKeyFlag,
Passphrase: *pgpPassphraseFlag,
}
decryptor := crypto.NewPGPDecryptor(pgpCfg)
// Determine output path
decryptedPath := outputFile
if strings.HasSuffix(outputFile, ".gpg") {
decryptedPath = strings.TrimSuffix(outputFile, ".gpg")
} else if strings.HasSuffix(outputFile, ".pgp") {
decryptedPath = strings.TrimSuffix(outputFile, ".pgp")
} else if strings.HasSuffix(outputFile, ".asc") {
decryptedPath = strings.TrimSuffix(outputFile, ".asc")
} else {
decryptedPath = outputFile + ".decrypted"
}
if err := decryptor.DecryptFile(outputFile, decryptedPath); err != nil {
return false, fmt.Errorf("pgp decryption failed: %w", err)
}
if verbose {
fmt.Fprintf(os.Stderr, "PGP decryption successful: %s\n", decryptedPath)
}
// Remove encrypted file
if err := os.Remove(outputFile); err != nil {
if verbose {
fmt.Fprintf(os.Stderr, "Warning: failed to remove encrypted file: %v\n", err)
}
}
}
return true, nil
}