feat: initial goget release — modern IPv6-first download utility
This commit is contained in:
@@ -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
@@ -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"
|
||||
`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user