Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5fa009ec5 | ||
|
|
bff619bf84 | ||
|
|
21ac1f7487 | ||
|
|
185e6ec20a | ||
|
|
3752b3d3eb | ||
|
|
c1373f0d61 | ||
|
|
49012fbf82 | ||
|
|
4268fa5d5f | ||
|
|
88e210daaf | ||
|
|
3384e2b344 | ||
|
|
d3b6451472 | ||
|
|
9066acc325 | ||
|
|
f549a862f8 | ||
|
|
e20f221ead | ||
|
|
30f62d3cc9 | ||
|
|
535496e6e1 | ||
|
|
dd46aa10f3 | ||
|
|
2dbbef3e30 | ||
|
|
760593cdb9 | ||
|
|
9867e2817e | ||
|
|
9ef62520c7 | ||
|
|
fc65ea8340 | ||
|
|
e7ed3a334e | ||
|
|
11aed3771d | ||
|
|
1672698936 | ||
|
|
f915299fa4 | ||
|
|
6e58502598 | ||
|
|
277178776d |
@@ -35,6 +35,9 @@ jobs:
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Copy manpage for //go:embed
|
||||
run: cp man/goget.1 cmd/goget/man-page-data/goget.1
|
||||
|
||||
- name: Build
|
||||
id: build
|
||||
env:
|
||||
|
||||
@@ -19,6 +19,9 @@ jobs:
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Copy manpage for //go:embed
|
||||
run: cp man/goget.1 cmd/goget/man-page-data/goget.1
|
||||
|
||||
- name: gofmt
|
||||
# `gofmt -l` prints the names of unformatted files. An empty
|
||||
# output is the only acceptable result.
|
||||
@@ -40,6 +43,9 @@ jobs:
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Copy manpage for //go:embed
|
||||
run: cp man/goget.1 cmd/goget/man-page-data/goget.1
|
||||
|
||||
- name: go test -race
|
||||
run: go test -race -count=1 ./...
|
||||
|
||||
@@ -65,6 +71,9 @@ jobs:
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Copy manpage for //go:embed
|
||||
run: cp man/goget.1 cmd/goget/man-page-data/goget.1
|
||||
|
||||
- name: go test -fuzz
|
||||
run: |
|
||||
for pkg in $(go list ./...); do
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
# Build output
|
||||
bin/
|
||||
|
||||
# Generated copy of man/goget.1 (regenerated by `just build`).
|
||||
# Required because //go:embed cannot reference files outside the
|
||||
# embedding package, so man/goget.1 is copied into the package
|
||||
# tree at build time and embedded into the binary.
|
||||
cmd/goget/man-page-data/*.1
|
||||
!cmd/goget/man-page-data/.gitkeep
|
||||
|
||||
# Test artifacts
|
||||
*.test
|
||||
*.out
|
||||
|
||||
+149
-80
@@ -9,35 +9,37 @@ move to `CHANGELOG.md` when the version ships.
|
||||
## v1.1.0 — Protocol completeness
|
||||
|
||||
Finish the protocol layer so every supported protocol handles
|
||||
recursive, parallel, resume, and shutdown correctly.
|
||||
recursive, parallel, resume, and shutdown correctly. Also bring
|
||||
certificate pinning and metadata debugging to all TLS protocols.
|
||||
|
||||
### 🔴 Supreme Priority
|
||||
|
||||
- [ ] **Graceful shutdown for all protocols** — persist `.goget.meta` on
|
||||
- [x] **Graceful shutdown for all protocols** — persist `.goget.meta` on
|
||||
SIGINT for FTP single-file and SFTP parallel recursive paths.
|
||||
- [ ] **WebDAV connection pooling** — shared `http.Transport` across WebDAV
|
||||
requests instead of a fresh transport per request (currently defeats HTTP
|
||||
keep-alive, TLS session resumption, and HTTP/2 multiplexing).
|
||||
- [x] **`--pinned-cert` for all TLS protocols** — extend certificate
|
||||
pinning to FTPS, Gemini, and WebDAVS (currently HTTP/HTTPS only).
|
||||
Also add SSH host-key pinning for SFTP as `--pinned-host-key`.
|
||||
- [x] **Unified worker pool** — a single protocol-agnostic worker pool
|
||||
used by FTP, SFTP, and WebDAV recursive-parallel paths. Removes
|
||||
the three near-identical semaphore+WaitGroup implementations.
|
||||
|
||||
### 🟡 High Priority
|
||||
|
||||
- [ ] **WebDAV recursive parallel downloads** — extend `--recursive-parallel`
|
||||
to WebDAV using the shared transport.
|
||||
- [ ] **SFTP recursive parallel downloads** — parallel SFTP recursive with
|
||||
session pooling.
|
||||
- [ ] **FTP recursive parallel** — connection pool for independent control
|
||||
connections in FTP recursive mode.
|
||||
- [ ] **`--dry-run` for WebDAV recursive** — list matched entries without
|
||||
downloading.
|
||||
- [x] **Gemini resume & metadata** — persist `.goget.meta` during Gemini
|
||||
downloads; `--resume` support. Currently Gemini has `Features: []` —
|
||||
it doesn't advertise resume, and the download loop doesn't save
|
||||
progress.
|
||||
- [x] **Gopher resume & metadata** — same as Gemini. Gopher `typeHTML`
|
||||
and `typeTextFile` responses should save `.goget.meta` for resume.
|
||||
|
||||
### 🟢 Medium Priority
|
||||
|
||||
- [ ] **`--adjust-extension` for non-HTML protocols** — apply `.html`
|
||||
- [x] **`--adjust-extension` for non-HTML protocols** — apply `.html`
|
||||
extension to text/html files from WebDAV, Gopher.
|
||||
- [ ] **`--backup-converted` for WebDAV mirrors** — keep `.orig` backups
|
||||
- [x] **`--backup-converted` for WebDAV mirrors** — keep `.orig` backups
|
||||
before link conversion.
|
||||
- [ ] **Parallel recursive across all protocols** — unified worker pool
|
||||
independent of protocol handler.
|
||||
- [x] **`--show-metadata`** — display `.goget.meta` contents for debugging
|
||||
interrupted/resumed downloads.
|
||||
|
||||
---
|
||||
|
||||
@@ -45,15 +47,6 @@ recursive, parallel, resume, and shutdown correctly.
|
||||
|
||||
Close the gap with curl and wget, and improve scripting/automation UX.
|
||||
|
||||
### 🟡 High Priority
|
||||
|
||||
- [ ] **`--create-dirs`** — auto-create parent directories for `--output`
|
||||
paths (curl `--create-dirs` / wget `--directory-prefix` equivalent).
|
||||
- [ ] **Short flags** — `-O`, `-o`, `-s`, `-v` aliases for curl/wget
|
||||
muscle memory. `--output-document` alias for `--output`.
|
||||
- [ ] **Brotli decompression** — RFC 7932, written from scratch.
|
||||
Currently only gzip/deflate/bzip2/LZW.
|
||||
|
||||
### 🟢 Medium Priority
|
||||
|
||||
- [ ] **`--progress=bar:force:noscroll`** — curl-compatible progress options.
|
||||
@@ -61,21 +54,16 @@ Close the gap with curl and wget, and improve scripting/automation UX.
|
||||
of writing to disk.
|
||||
- [ ] **Bandwidth throttling per-domain** — global rate limit only at present.
|
||||
- [ ] **MIME type detection for local files** — determine Content-Type from
|
||||
file extension when uploading.
|
||||
- [ ] **`--cut-dirs` for recursive mode** — strip directory components from
|
||||
output paths.
|
||||
file extension when uploading (`mime.TypeByExtension` from stdlib covers this).
|
||||
- [ ] **Notification hooks** — `--on-error` with richer environment variables
|
||||
(exit code, error type, checksum).
|
||||
- [ ] **Retry with backoff visualization** — show retry attempts in progress
|
||||
bar (attempt 2/3, waiting 4s...).
|
||||
- [ ] **Idempotent `--resume`** — auto-detect whether resume is possible
|
||||
without manual flag.
|
||||
- [ ] **`--show-metadata`** — display `.goget.meta` contents for debugging.
|
||||
bar (attempt 2/3, waiting 4s…).
|
||||
|
||||
### 🔵 Lower Priority
|
||||
|
||||
- [ ] **URL glob expansion (`--glob`)** — expand `[1-3]` and `{a,b}` patterns
|
||||
in URLs.
|
||||
in URLs (flag exists, logic not implemented).
|
||||
|
||||
---
|
||||
|
||||
@@ -85,18 +73,20 @@ TLS, authentication, and verification improvements.
|
||||
|
||||
### 🔴 Supreme Priority
|
||||
|
||||
- [ ] **PGP replacement** — `golang.org/x/crypto/openpgp` is frozen (archived
|
||||
by Go team). Evaluate: inline a minimal subset, or wait for a community
|
||||
`golang.org/x/` successor.
|
||||
- [ ] **PGP replacement** — `golang.org/x/crypto/openpgp` is frozen
|
||||
(archived by Go team). Use `github.com/ProtonMail/go-crypto`
|
||||
(BSD-3-Clause) as the replacement — the only permitted third-party
|
||||
dependency exception for the Go codebase.
|
||||
|
||||
### 🟡 High Priority
|
||||
|
||||
- [ ] **`--pinned-cert` for all TLS protocols** — extend certificate pinning
|
||||
to FTPS, SFTP, Gemini, and WebDAVS (currently HTTP/HTTPS only).
|
||||
- [ ] **SOCKS5 proxy for non-HTTP protocols** — FTP and SFTP through SOCKS5
|
||||
proxy (currently HTTP/HTTPS only).
|
||||
- [ ] **DNS-over-HTTPS (DoH)** — optional DoH resolver. Must use only
|
||||
`golang.org/x/` libraries.
|
||||
proxy (currently HTTP/HTTPS and WebDAV only). `golang.org/x/net/proxy`
|
||||
provides the SOCKS5 dialer; SFTP is straightforward, FTP needs
|
||||
dual-connection handling.
|
||||
- [ ] **DNS-over-HTTPS (DoH)** — optional DoH resolver. Uses
|
||||
`golang.org/x/net/dns/dnsmessage` for DNS wire format and `net/http`
|
||||
for the HTTPS transport.
|
||||
|
||||
### 🟢 Medium Priority
|
||||
|
||||
@@ -105,64 +95,146 @@ TLS, authentication, and verification improvements.
|
||||
|
||||
---
|
||||
|
||||
## v2.0.0 — New protocols
|
||||
## v2.0.0 — Package registries & CAS cache
|
||||
|
||||
Cloud providers, additional network protocols, and long-lived services.
|
||||
Turn goget from a file-in-URL-out downloader into a language-aware
|
||||
package fetcher. The content-addressable cache deduplicates downloads
|
||||
across protocols and projects — a file is never downloaded twice.
|
||||
|
||||
### 🔵 Lower Priority
|
||||
### 🔴 Supreme Priority
|
||||
|
||||
- [ ] **S3** — generic S3-compatible protocol with signature v4 auth
|
||||
(covers AWS S3, MinIO, and any S3-compatible storage).
|
||||
- [ ] **Huawei OBS** — Huawei Cloud Object Storage.
|
||||
- [ ] **Alibaba OSS** — Alibaba Cloud Object Storage Service.
|
||||
- [ ] **Tencent COS** — Tencent Cloud Object Storage.
|
||||
- [ ] **Rsync** — incremental file transfer. Written from scratch
|
||||
(rolling checksum, delta transfer, rsync wire protocol).
|
||||
- [ ] **TFTP** — trivial file transfer, useful for embedded devices and PXE.
|
||||
- [ ] **NFS** — NFS v3/v4 client. Written from scratch
|
||||
(ONC RPC/XDR, portmapper, mount protocol, NFS protocol).
|
||||
- [ ] **goget-as-a-service** — daemon mode with REST API for remote download
|
||||
management.
|
||||
- [ ] **Content-addressable cache (CAS)** — every downloaded file is stored
|
||||
under its SHA-256 digest. `goget` before hitting the network consults
|
||||
the cache. Identical content from different URLs, protocols, or
|
||||
projects hits the cache exactly once.
|
||||
- [ ] **`crate://` protocol** — `crate://serde@1.0.0` → fetches `.crate`
|
||||
from crates.io API. `crate://serde@latest` → resolves latest version.
|
||||
- [ ] **`gem://` protocol** — `gem://rails@7.2` → fetches `.gem` from
|
||||
RubyGems.org.
|
||||
- [ ] **`npm://` protocol** — `npm://react@19` → fetches tarball from
|
||||
registry.npmjs.org.
|
||||
- [ ] **`git://` protocol** — `git://github.com/user/repo@v1.0` → archive
|
||||
download via provider API. `@tag`, `@branch`, `@commit` resolution.
|
||||
|
||||
### 🟡 High Priority
|
||||
|
||||
- [ ] **`oci://` protocol** — `oci://alpine:3.20` → pulls OCI
|
||||
image layers via registry v2 API, stores in CAS. No daemon required —
|
||||
goget acts as a standalone OCI image fetcher. Works against any
|
||||
OCI-compatible registry (Docker Hub, Quay.io, GHCR, …). Complex
|
||||
protocol, but purely HTTP + JSON — stdlib covers it.
|
||||
- [ ] **Lockfile import** — `goget crate:// --from-lock Cargo.lock`
|
||||
downloads and caches every dependency in one shot. Same for
|
||||
`Gemfile.lock` and `package-lock.json`.
|
||||
|
||||
---
|
||||
|
||||
## v2.1.0 — Extensibility & UI
|
||||
## v2.1.0 — Plugin system & extensibility
|
||||
|
||||
### 🔵 Lower Priority
|
||||
### 🔴 Supreme Priority
|
||||
|
||||
- [ ] **Plugin system** — protocol handlers as external plugins.
|
||||
- [ ] **TUI (Terminal User Interface)** — interactive download manager with
|
||||
live queue, progress, and log views. Keyboard navigation, color-coded
|
||||
status, inline log viewer.
|
||||
- [ ] **Web GUI** — browser-based download manager (Vue 3 frontend, REST API,
|
||||
live progress via SSE).
|
||||
- [ ] **Download speed test / benchmark mode** — measure maximum throughput
|
||||
against a target server without saving the file.
|
||||
- [ ] **Plugin system** — protocol handlers as external binaries.
|
||||
Discovered from `~/.config/goget/plugins/`. Line-based protocol
|
||||
over stdin/stdout or Unix socket.
|
||||
- [ ] **`plugin://` protocol** — transparent proxy to external handlers.
|
||||
|
||||
### 🟡 High Priority
|
||||
|
||||
- [ ] **Plugin SDK** — Go library (`golang.org/x/` only) for writing plugins.
|
||||
- [ ] **Plugin registry** — community-maintained index of known plugins.
|
||||
|
||||
---
|
||||
|
||||
## v2.2.0 — Distributed download mesh
|
||||
|
||||
Goget instances on the same LAN discover each other and share cached files.
|
||||
mDNS/DNS-SD written from scratch on top of `golang.org/x/net/dns/dnsmessage`
|
||||
and stdlib multicast UDP.
|
||||
|
||||
### 🔴 Supreme Priority
|
||||
|
||||
- [ ] **`ggtp://` protocol (goget transfer protocol)** — simple
|
||||
single-round-trip request for a file by SHA-256 from a peer.
|
||||
- [ ] **LAN discovery (mDNS / DNS-SD)** — `_goget._tcp` announcements.
|
||||
Written from scratch using `golang.org/x/net/dns/dnsmessage` for
|
||||
message encoding and stdlib `net` for multicast UDP.
|
||||
|
||||
### 🟡 High Priority
|
||||
|
||||
- [ ] **Mesh-aware CAS** — query LAN peers for SHA-256 before downloading
|
||||
from the internet.
|
||||
- [ ] **Peer authentication** — optional shared secret or mTLS between
|
||||
trusted nodes.
|
||||
|
||||
---
|
||||
|
||||
## v3.0.0 — goget-as-a-service
|
||||
|
||||
Long-lived daemon with REST API, TUI, and Web GUI.
|
||||
|
||||
### 🔴 Supreme Priority
|
||||
|
||||
- [ ] **Daemon mode** — `goget daemon` + REST API (localhost-only).
|
||||
- [ ] **REST API** — `POST /downloads`, `GET /downloads/:id`, SSE progress.
|
||||
|
||||
### 🟡 High Priority
|
||||
|
||||
- [ ] **Web GUI** — Vue 3 + Vite + Tailwind CSS, daemon REST API + SSE.
|
||||
- [ ] **Queue pause/resume/reorder** — full download queue management.
|
||||
|
||||
### 🟢 Medium Priority
|
||||
|
||||
- [ ] **S3 protocol** — as a plugin (see v2.1.0). Signature v4 auth
|
||||
(HMAC-SHA256, all in stdlib).
|
||||
- [ ] **Huawei OBS, Alibaba OSS, Tencent COS** — cloud storage plugins.
|
||||
- [ ] **systemd unit** — for daemon / scheduled mode.
|
||||
- [ ] **Download speed test / benchmark mode**.
|
||||
|
||||
---
|
||||
|
||||
## v3.1.0 — Streaming & real-time protocols
|
||||
|
||||
### 🟢 Medium Priority
|
||||
|
||||
- [ ] **HLS / MPEG-DASH** — download streamed video.
|
||||
- [ ] **Icecast / SHOUTcast** — capture internet radio.
|
||||
- [ ] **WebSocket capture** — record WebSocket messages (RFC 6455 framing
|
||||
from scratch on top of `net/http` hijack).
|
||||
|
||||
---
|
||||
|
||||
## 💡 Backlog (unscheduled)
|
||||
|
||||
Items that need more design, depend on external factors, or are
|
||||
lower priority than the versioned roadmap.
|
||||
Items needing more design, dependent on external factors, or deferred
|
||||
until foundational libraries are written.
|
||||
|
||||
### Deferred — waiting for custom libraries
|
||||
|
||||
- [ ] **Brotli decompression** — RFC 7932. Will be built as a standalone
|
||||
Go library first, then integrated into goget. No third-party
|
||||
dependency — pure Go from scratch.
|
||||
- [ ] **TUI (Terminal User Interface)** — interactive download manager.
|
||||
Will be built as a standalone Go TUI toolkit on top of `golang.org/x/term`,
|
||||
then integrated. Layout engine, widgets, and event loop written from
|
||||
scratch.
|
||||
|
||||
### Performance
|
||||
|
||||
- [ ] **Memory-mapped I/O for large files** — `mmap` for checksum
|
||||
verification of multi-gigabyte downloads.
|
||||
verification.
|
||||
- [ ] **Zero-copy for `file://` and `data:` protocols** — `sendfile(2)` /
|
||||
`splice(2)` where available on Linux.
|
||||
- [ ] **HTTP/3 (QUIC)** — when Go stdlib gains QUIC support.
|
||||
|
||||
### Advanced UX
|
||||
### Protocols (rare / niche)
|
||||
|
||||
- [ ] **Scheduling — pause/resume queued downloads** — ability to pause the
|
||||
queue, reorder items, and resume later.
|
||||
- [ ] **Rsync** — rolling checksum, delta transfer, wire protocol from scratch.
|
||||
- [ ] **TFTP** — trivial file transfer, embedded devices and PXE.
|
||||
- [ ] **NFS** — NFS v3/v4 client from scratch (ONC RPC/XDR, portmapper).
|
||||
|
||||
### Code Quality & Testing
|
||||
|
||||
- [ ] **Integration test matrix** — CI against real FTP, SFTP, and WebDAV
|
||||
servers.
|
||||
- [ ] **Integration test matrix** — CI against real FTP, SFTP, WebDAV servers.
|
||||
- [ ] **Coverage threshold increase** — raise from 40% to 60%.
|
||||
- [ ] **Benchmark suite** — `go test -bench` in CI.
|
||||
- [ ] **Fuzzing corpus in CI** — store and replay past crashes automatically.
|
||||
@@ -175,13 +247,10 @@ lower priority than the versioned roadmap.
|
||||
|
||||
- [ ] **RPM spec file** — Fedora / RHEL / openEuler packaging.
|
||||
- [ ] **FreeBSD port** — official FreeBSD ports tree entry.
|
||||
- [ ] **systemd unit** — for daemon / scheduled mode.
|
||||
|
||||
### Documentation
|
||||
|
||||
- [ ] **Man page packaging** — already generated by `--generate-man-page`,
|
||||
needs installation via packaging.
|
||||
- [ ] **Architecture decision records** — document why specific choices were
|
||||
made (no short flags, IPv6-first, long flags only, no global state).
|
||||
- [ ] **Man page packaging** — already generated, needs installation via packaging.
|
||||
- [ ] **Architecture decision records** — document design choices.
|
||||
- [ ] **Protocol-specific guides** — one doc per protocol with examples.
|
||||
- [ ] **Troubleshooting guide** — common errors and solutions.
|
||||
|
||||
+129
-1
@@ -5,7 +5,134 @@ All notable changes to **goget** are documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [development]
|
||||
## [1.1.0] — 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
**Documentation**
|
||||
|
||||
- **Standalone troff manpage** — `man/goget.1` is now the single source
|
||||
of truth for the goget(1) manual. The previous embedded manpage listed
|
||||
only ~30 of the 138 CLI flags and was missing every standard section;
|
||||
the new file documents every flag with a dedicated entry, the
|
||||
full set of conventional `.SH` sections (NAME, SYNOPSIS, DESCRIPTION,
|
||||
OPTIONS, EXAMPLES, EXIT STATUS, FILES, ENVIRONMENT, SEE ALSO, HISTORY,
|
||||
AUTHORS, REPORTING BUGS, COPYRIGHT), curl/wget compatibility notes, and
|
||||
a release-history entry. The manpage is embedded into the binary via
|
||||
`//go:embed`, so `--generate-man-page` and `man goget` now agree.
|
||||
- **`just man`** — new task that runs `groff -man -ww -Kutf-8 -z` over
|
||||
`man/goget.1` to validate the troff source. Skips cleanly when `groff`
|
||||
is not installed (CI on minimal images still passes).
|
||||
- **`just install-man`** and **`just uninstall-man`** — install the
|
||||
manpage to `$HOME/.local/share/man/man1/goget.1.gz` (XDG rootless,
|
||||
gzipped, reinstall-safe). After `just install-man`, `man goget`
|
||||
resolves the page automatically via the user's `MANPATH`.
|
||||
|
||||
**Core engine**
|
||||
|
||||
- **Unified worker pool** — single protocol-agnostic worker pool (`internal/pool`)
|
||||
used by FTP, SFTP, and WebDAV recursive-parallel downloads. Replaces three
|
||||
near-identical semaphore+WaitGroup implementations with a shared, tested
|
||||
concurrency primitive.
|
||||
- **Graceful shutdown on SIGINT** — persist `.goget.meta` sidecar on interrupt
|
||||
for SFTP single-file downloads, ensuring progress is saved for resume.
|
||||
- **Gemini resume & metadata** — Gemini downloads now persist `.goget.meta`
|
||||
sidecar on interrupt and delete it on successful completion. `--resume`
|
||||
continues interrupted Gemini transfers from the saved byte offset.
|
||||
- **Gopher resume & metadata** — Gopher `typeHTML` and `typeTextFile`
|
||||
responses now persist `.goget.meta` sidecar on interrupt and delete it on
|
||||
successful completion. `--resume` continues interrupted Gopher text
|
||||
transfers, skipping already-written output bytes (post-transform length
|
||||
because Gopher line metadata is stripped before writing).
|
||||
|
||||
**Security**
|
||||
|
||||
- **Certificate pinning across all TLS protocols** — `--pinned-cert` now
|
||||
applies to FTPS, Gemini, and WebDAVS in addition to HTTP/HTTPS. The leaf
|
||||
certificate SHA-256 hash is verified via `tls.Config.VerifyPeerCertificate`
|
||||
on every TLS-bearing protocol.
|
||||
- **SSH host-key pinning for SFTP** — new `--pinned-host-key` flag pins the
|
||||
SSH host key by its OpenSSH fingerprint (`SHA256:<base64>`, as produced by
|
||||
`ssh-keygen -lf`). Raw lowercase-hex SHA-256 of the key wire format is also
|
||||
accepted. Pinning takes precedence over `--ssh-insecure` and `known_hosts`.
|
||||
- **WebDAV TLS config fix** — `webdav.Protocol.getHTTPTransport` now builds
|
||||
its `tls.Config` from the caller-provided `transport.TLSConfig` instead of
|
||||
a fresh config that silently dropped CA certs, mTLS, keylog, and pinned-cert
|
||||
settings.
|
||||
|
||||
**Recursive downloading & mirroring**
|
||||
|
||||
- `--adjust-extension` extended to non-HTTP protocols — applies `.html`
|
||||
extension to `text/html` files downloaded via Gopher and WebDAV.
|
||||
- `--backup-converted` — keep `.orig` backups of files before link conversion
|
||||
during recursive mirroring. Previously only available for HTTP/HTTPS;
|
||||
now works for WebDAV mirrors too.
|
||||
|
||||
**Download management**
|
||||
|
||||
- `--show-metadata` — displays `.goget.meta` sidecar contents for debugging
|
||||
interrupted / resumed downloads. Accepts a path to the downloaded output
|
||||
(the sidecar is located next to it) or a direct path to the sidecar file.
|
||||
Renders a human-readable summary by default and switches to raw JSON
|
||||
output when combined with `--json`. Credentials embedded in the stored
|
||||
URL are masked before display (the password placeholder is `xxxxx`).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Gopher: `ctx.Done()` cancellation goroutine leak** — the goroutine
|
||||
spawned in `Download` to close the socket on `ctx.Done()` previously
|
||||
waited indefinitely on the parent context, leaking every time the
|
||||
context outlived the function. Now bounded by a `defer`-closed
|
||||
`done` channel so the goroutine exits on every return path.
|
||||
|
||||
- **Gemini: `ctx.Done()` cancellation goroutine leak** — same bug,
|
||||
same fix in `downloadWithRedirects`.
|
||||
|
||||
- **Gopher: partial state lost on cancel-then-close** — when the server
|
||||
closed the connection after `ctx` was cancelled, `downloadText`
|
||||
treated the resulting EOF as a successful end-of-stream and never
|
||||
persisted `.goget.meta`. Mirrors the equivalent Gemini behavior
|
||||
(already present from the Gemini resume feature): if `ctx.Err() != nil`,
|
||||
save partial state and return the context error.
|
||||
|
||||
- **Gopher: `--resume` ignored when caller supplies `Writer`** — when
|
||||
`--resume` was set together with a caller-supplied `Writer`, the
|
||||
partial-state sidecar would track bytes that landed in the `Writer`
|
||||
rather than `Output`. `--resume` is now silently disabled in that
|
||||
case (with a `--verbose` warning), keeping metadata consistent with
|
||||
the actual on-disk state.
|
||||
|
||||
- **Gemini: `--resume` ignored when caller supplies `Writer`** — same
|
||||
fix applied to `downloadWithRedirects`.
|
||||
|
||||
### Testing
|
||||
|
||||
- `cmd/goget/man_page_test.go` — new test file. Asserts the embedded
|
||||
manpage is non-empty, carries a `.TH GOGET 1` header, contains every
|
||||
mandatory `.SH` section, and documents every flag registered by
|
||||
`defineFlags` (so adding a new flag without documenting it now breaks
|
||||
the build). When `groff` is installed, also runs `groff -man -ww
|
||||
-Kutf-8 -z` over the embedded source and fails on any warning.
|
||||
- Resume tests deterministic across the protocol stack
|
||||
(`TestGeminiResume{SaveMetadata,Download}` and
|
||||
`TestGopherResume{SaveMetadata,Download}`) — the four tests previously
|
||||
relied on a fixed `time.Sleep` before calling `cancel()`, making them
|
||||
flaky on slow CI schedulers. They now wait for a `ProgressCallback`
|
||||
signal, which fires synchronously after the first `writer.Write`
|
||||
lands on disk.
|
||||
- Goroutine leak regression suite
|
||||
(`TestGeminiCancelGoroutineExits`, `TestGopherCancelGoroutineExits`)
|
||||
— runs 50 downloads back-to-back, settles the runtime via
|
||||
`runtime.GC()`, and asserts `runtime.NumGoroutine()` returns to
|
||||
baseline + 1 within a 2-second window.
|
||||
- `TestGopherResumeInformation` — new regression test covering the
|
||||
`typeInformation` text-mode path through resume, which differs from
|
||||
`typeHTML`/`typeTextFile` because lines are passed through verbatim
|
||||
(no metadata strip).
|
||||
- `TestGopherResumeStaleMetadata` now asserts that an orphan
|
||||
`.goget.meta` carried over from a different URL is deleted once a
|
||||
successful fresh download completes, preventing it from being
|
||||
picked up by a future `--resume` run.
|
||||
|
||||
## [1.0.0] — 2026-06-26
|
||||
|
||||
@@ -175,3 +302,4 @@ network protocols — all in a single static binary with zero runtime dependenci
|
||||
- `CONTRIBUTING.md` with Conventional Commits and release process.
|
||||
|
||||
[1.0.0]: https://codeberg.org/petrbalvin/goget/releases/tag/v1.0.0
|
||||
[1.1.0]: https://codeberg.org/petrbalvin/goget/releases/tag/v1.1.0
|
||||
|
||||
+15
-5
@@ -121,10 +121,12 @@ goget/
|
||||
├── cmd/
|
||||
│ └── goget/ # CLI entry point
|
||||
│ ├── main.go # Core run loop, flag setup, orchestrator
|
||||
│ ├── app.go # Application wiring
|
||||
│ ├── commands.go # Command handlers (download, upload, queue, config, etc.)
|
||||
│ ├── flags.go # CLI flag definitions (struct + defineFlags)
|
||||
│ ├── completion.go # Shell completion generators (bash, zsh, fish)
|
||||
│ ├── man_page.go # Man page generation
|
||||
│ ├── man_page.go # Embedded manpage source and --generate-man-page
|
||||
│ ├── setup.go # One-time CLI setup helpers
|
||||
│ ├── schedule.go # Cron and datetime scheduling
|
||||
│ └── verify.go # Checksum and PGP verification
|
||||
├── internal/
|
||||
@@ -138,11 +140,12 @@ goget/
|
||||
│ ├── crypto/ # Checksum verification, PGP, certificate handling
|
||||
│ ├── format/ # Human-readable formatting (bytes, speed)
|
||||
│ ├── hsts/ # HSTS cache (RFC 6797)
|
||||
│ ├── log/ # Structured logger (text/JSON output, level filtering)
|
||||
│ ├── linkrewrite/ # HTML link rewriting for offline mirroring
|
||||
│ ├── log/ # Structured logger (text/JSON output, level filtering)
|
||||
│ ├── metalink/ # Metalink (multi-source downloads)
|
||||
│ ├── mirror/ # Website mirroring logic
|
||||
│ ├── output/ # Output writing (atomic writes, resume metadata)
|
||||
│ ├── pool/ # Unified recursive worker pool (FTP, SFTP, WebDAV)
|
||||
│ ├── protocol/ # Protocol abstraction layer
|
||||
│ │ ├── http/ # HTTP/HTTPS client, parallel download, upload
|
||||
│ │ ├── ftp/ # FTP/FTPS client
|
||||
@@ -151,11 +154,12 @@ goget/
|
||||
│ │ ├── dataurl/ # data: URI protocol
|
||||
│ │ ├── gopher/ # Gopher protocol
|
||||
│ │ ├── gemini/ # Gemini protocol
|
||||
│ │ └── webdav/ # WebDAV protocol
|
||||
│ │ ├── webdav/ # WebDAV protocol
|
||||
│ │ └── register/ # Single registration entry point
|
||||
│ ├── queue/ # Download queue management
|
||||
│ ├── recursive/ # Recursive download (HTML parser, URL filter, crawler)
|
||||
│ ├── warc/ # WARC archiving writer
|
||||
│ └── transport/ # HTTP transport (dialer, proxy, TLS, retry, rate limiting)
|
||||
│ ├── transport/ # HTTP transport (dialer, proxy, TLS, retry, rate limiting)
|
||||
│ └── warc/ # WARC archiving writer
|
||||
├── pkg/
|
||||
│ └── api/ # Public library API (Client, Download, Upload, Queue)
|
||||
└── docs/ # Architecture and design documentation
|
||||
@@ -189,11 +193,14 @@ graph TD
|
||||
Metalink[internal/metalink/]
|
||||
QueueInt[internal/queue/]
|
||||
Archive[internal/archive/]
|
||||
Pool[internal/pool/ - Unified worker pool]
|
||||
Log[internal/log/ - Structured logger]
|
||||
API[pkg/api/ - Public Library]
|
||||
|
||||
CLI --> Flags
|
||||
CLI --> Config
|
||||
CLI --> Core
|
||||
CLI --> Log
|
||||
CLI --> Auth
|
||||
CLI --> Crypto
|
||||
CLI --> Recursive
|
||||
@@ -214,6 +221,9 @@ graph TD
|
||||
HTTP --> Cookie
|
||||
HTTP --> HSTS
|
||||
HTTP --> Output
|
||||
FTP --> Pool
|
||||
SFTP --> Pool
|
||||
WebDAV --> Pool
|
||||
API --> Core
|
||||
API --> Registry
|
||||
```
|
||||
|
||||
@@ -40,7 +40,7 @@ It is designed to be:
|
||||
- **Parallel chunked downloads** — automatically splits files over 100 MB into
|
||||
concurrent chunks using byte-range requests.
|
||||
- **Resume interrupted transfers** — metadata-driven resume with `.goget.meta`
|
||||
tracking across HTTP, FTP, SFTP, and WebDAV.
|
||||
tracking across HTTP, FTP, SFTP, WebDAV, and Gemini.
|
||||
- **Recursive downloading & mirroring** — follow links, filter by pattern or
|
||||
MIME type, limit depth, stay within domain. Full site mirrors with link
|
||||
conversion for offline viewing and `robots.txt` compliance.
|
||||
@@ -130,6 +130,14 @@ goget --rate-limit 1MB/s --url https://example.com/file.zip
|
||||
# Proxy
|
||||
goget --proxy http://proxy.example.com:8080 --url https://example.com/file.zip
|
||||
goget --proxy socks5h://127.0.0.1:1080 --url https://example.com/file.zip
|
||||
|
||||
# Pin server certificate (applies to HTTPS, FTPS, Gemini, WebDAVS)
|
||||
goget --pinned-cert a1b2c3d4e5f6... --url https://example.com/file
|
||||
goget --pinned-cert a1b2c3d4e5f6... --url gemini://gemini.example.com/
|
||||
|
||||
# Pin SSH host key for SFTP (OpenSSH fingerprint from `ssh-keygen -lf`)
|
||||
goget --pinned-host-key "SHA256:AAAAC3NzaC1lZDI1NTE5AAAAIE..." \
|
||||
--url sftp://example.com/file
|
||||
```
|
||||
|
||||
### Recursive & Mirroring
|
||||
@@ -178,6 +186,10 @@ goget --on-complete "process.sh" --url https://example.com/data.csv
|
||||
# JSON progress for scripting
|
||||
goget --json --no-progress --url https://example.com/file.zip
|
||||
|
||||
# Inspect a partially-downloaded transfer's resume metadata
|
||||
goget --show-metadata ./file.zip
|
||||
goget --show-metadata ./file.zip.goget.meta --json
|
||||
|
||||
# Shell completion
|
||||
goget --completion bash > /etc/bash_completion.d/goget
|
||||
goget --completion zsh > /usr/share/zsh/site-functions/_goget
|
||||
@@ -271,13 +283,18 @@ just install # go mod download
|
||||
just run # run from source (development)
|
||||
just build # go vet + gofmt check + build → bin/goget
|
||||
just test # go test -race -count=1 ./...
|
||||
just man # validate man/goget.1 with groff
|
||||
just install-man # install manpage to ~/.local/share/man/man1/
|
||||
just uninstall # remove build artifacts
|
||||
just uninstall-man# remove installed manpage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`man/goget.1`](man/goget.1) — troff manual page (`man goget` after
|
||||
`just install-man`)
|
||||
- [`docs/architecture.md`](docs/architecture.md) — components and request flow
|
||||
- [`docs/configuration.md`](docs/configuration.md) — config schema (TOML)
|
||||
- [`docs/api.md`](docs/api.md) — public Go library API
|
||||
@@ -288,6 +305,8 @@ just uninstall # remove build artifacts
|
||||
- [`docs/download-pipeline.md`](docs/download-pipeline.md) — download flow
|
||||
- [`docs/authentication.md`](docs/authentication.md) — auth mechanisms
|
||||
- [`docs/security.md`](docs/security.md) — threat model and controls
|
||||
- [`docs/migration.md`](docs/migration.md) — curl/wget → goget flag mapping
|
||||
- [`docs/development.md`](docs/development.md) — dev workflow, releases, CI
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -143,6 +143,9 @@ func (a *app) dispatch() int {
|
||||
if *f.Spider {
|
||||
return handleSpider(*f.URL, cfg)
|
||||
}
|
||||
if *f.ShowMetadata != "" {
|
||||
return handleShowMetadata(*f.ShowMetadata, *f.JSON)
|
||||
}
|
||||
if *f.GenManPage {
|
||||
return handleGenManPage()
|
||||
}
|
||||
@@ -603,6 +606,8 @@ func (a *app) execute() int {
|
||||
Ctx: ctx,
|
||||
SSHKnownHosts: *f.KnownHosts,
|
||||
SSHInsecure: *f.SSHInsecure,
|
||||
PinnedCertHash: *f.PinnedCert,
|
||||
PinnedHostKey: *f.PinnedHostKey,
|
||||
}
|
||||
|
||||
// Configure TLS for WebDAV if supported.
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
format "codeberg.org/petrbalvin/goget/internal/format"
|
||||
"codeberg.org/petrbalvin/goget/internal/metalink"
|
||||
"codeberg.org/petrbalvin/goget/internal/output"
|
||||
"codeberg.org/petrbalvin/goget/internal/protocol"
|
||||
httpConfig "codeberg.org/petrbalvin/goget/internal/protocol/http"
|
||||
"codeberg.org/petrbalvin/goget/internal/queue"
|
||||
@@ -845,6 +846,106 @@ func handleSpider(urlStr string, cfg *config.Config) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// handleShowMetadata prints the contents of the .goget.meta sidecar for
|
||||
// debugging interrupted / resumed downloads. The argument accepts either the
|
||||
// path to the downloaded file (the .goget.meta sidecar is located next to it)
|
||||
// or a direct path to the sidecar file itself (anything ending in the
|
||||
// .goget.meta suffix). When --json is set the metadata is printed as raw
|
||||
// JSON, otherwise a human-readable summary is emitted on stdout and the
|
||||
// corresponding JSON is reproduced verbatim from the on-disk file so the user
|
||||
// sees exactly what the resume logic would consume.
|
||||
func handleShowMetadata(pathArg string, asJSON bool) int {
|
||||
if pathArg == "" {
|
||||
cli.PrintError(os.Stderr, fmt.Errorf("--show-metadata requires a path (output file or .goget.meta sidecar)"))
|
||||
return 1
|
||||
}
|
||||
|
||||
var metaPath string
|
||||
if strings.HasSuffix(pathArg, output.ResumeMetaFileSuffix) {
|
||||
metaPath = pathArg
|
||||
} else {
|
||||
metaPath = pathArg + output.ResumeMetaFileSuffix
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
cli.PrintError(os.Stderr, fmt.Errorf("no resume metadata found at %s", metaPath))
|
||||
return 1
|
||||
}
|
||||
cli.PrintError(os.Stderr, fmt.Errorf("failed to read metadata: %w", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
if asJSON {
|
||||
fmt.Println(string(data))
|
||||
return 0
|
||||
}
|
||||
|
||||
// Parse for the human-readable summary. If parsing fails we still print
|
||||
// the raw bytes so the user sees what is on disk — better than silent
|
||||
// success followed by a confusing --resume later.
|
||||
var rm output.ResumeMetadata
|
||||
parseErr := json.Unmarshal(data, &rm)
|
||||
if parseErr != nil {
|
||||
cli.PrintWarning(os.Stderr, fmt.Sprintf("metadata file is not valid JSON, printing raw contents from %s", metaPath))
|
||||
fmt.Println(string(data))
|
||||
return 0
|
||||
}
|
||||
|
||||
fmt.Printf("Path: %s\n", metaPath)
|
||||
fmt.Printf("URL: %s\n", safeMetadataURL(rm.URL))
|
||||
fmt.Printf("Downloaded: %s (%d bytes)\n", format.Bytes(rm.Downloaded), rm.Downloaded)
|
||||
if rm.Total > 0 {
|
||||
fmt.Printf("Total: %s (%d bytes)\n", format.Bytes(rm.Total), rm.Total)
|
||||
fmt.Printf("Progress: %.1f%%\n", percent(rm.Downloaded, rm.Total))
|
||||
} else {
|
||||
fmt.Printf("Total: unknown\n")
|
||||
}
|
||||
if rm.ETag != "" {
|
||||
fmt.Printf("ETag: %s\n", rm.ETag)
|
||||
}
|
||||
if rm.LastModified != "" {
|
||||
fmt.Printf("Last-Modified: %s\n", rm.LastModified)
|
||||
}
|
||||
if !rm.LastWrite.IsZero() {
|
||||
fmt.Printf("Last-Write: %s\n", rm.LastWrite.Format(time.RFC3339))
|
||||
}
|
||||
fmt.Printf("Version: %s\n", rm.Version)
|
||||
if len(rm.Chunks) > 0 {
|
||||
fmt.Printf("Chunks: %d parallel ranges with progress\n", len(rm.Chunks))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// safeMetadataURL masks any credentials embedded in the metadata URL so the
|
||||
// displayed value can be pasted into chats or issue trackers without leaking
|
||||
// tokens. The on-disk metadata is still printed verbatim when --json is set
|
||||
// — the file is owned by the user and chmod 0600, so the operator already
|
||||
// consented to its contents on the filesystem.
|
||||
func safeMetadataURL(raw string) string {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.User == nil {
|
||||
return raw
|
||||
}
|
||||
if _, hasPass := parsed.User.Password(); hasPass {
|
||||
parsed.User = url.UserPassword(parsed.User.Username(), "xxxxx")
|
||||
} else {
|
||||
parsed.User = url.User(parsed.User.Username())
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
// percent returns value/total*100 rounded to one decimal place. When total
|
||||
// is zero or negative the result is 0 — callers gate the printed percentage
|
||||
// on a non-empty total themselves.
|
||||
func percent(value, total int64) float64 {
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(value) * 100 / float64(total)
|
||||
}
|
||||
|
||||
// handleGenManPage generates a man page for goget
|
||||
|
||||
// handleInitConfig generates a default config file
|
||||
|
||||
+5
-1
@@ -150,6 +150,7 @@ type Flags struct {
|
||||
Referer *string
|
||||
RetryDelay *time.Duration
|
||||
PinnedCert *string
|
||||
PinnedHostKey *string
|
||||
AllowPrivate *bool
|
||||
Range *string
|
||||
ConnectTimeout *time.Duration
|
||||
@@ -169,6 +170,7 @@ type Flags struct {
|
||||
ProtoDirs *bool
|
||||
HSTSFile *string
|
||||
CreateDirs *bool
|
||||
ShowMetadata *string
|
||||
}
|
||||
|
||||
// defineFlags registers all CLI flags on the given FlagSet and returns them.
|
||||
@@ -288,7 +290,8 @@ func defineFlags(fs *flag.FlagSet, cfg *config.Config) *Flags {
|
||||
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"),
|
||||
PinnedCert: fs.String("pinned-cert", "", "SHA-256 hash of server certificate for pinning (TLS protocols: HTTPS, FTPS, Gemini, WebDAVS)"),
|
||||
PinnedHostKey: fs.String("pinned-host-key", "", "SSH host key fingerprint for pinning (SFTP). Accepts 'SHA256:<base64>' (ssh-keygen -lf) or raw hex SHA-256."),
|
||||
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"),
|
||||
@@ -308,6 +311,7 @@ func defineFlags(fs *flag.FlagSet, cfg *config.Config) *Flags {
|
||||
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)"),
|
||||
ShowMetadata: fs.String("show-metadata", "", "Display .goget.meta contents for debugging interrupted/resumed downloads (path to output file, or to the .goget.meta sidecar itself)"),
|
||||
}
|
||||
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')")
|
||||
|
||||
+19
-2
@@ -130,9 +130,12 @@ func runPostDownload(
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to close writer: %v\n", cerr)
|
||||
}
|
||||
|
||||
// Adjust extension: append .html to HTML files without extension
|
||||
// Adjust extension: append .html to HTML files without extension.
|
||||
// When ContentType is available (WebDAV, Gopher), only apply to
|
||||
// text/html responses. When ContentType is empty (HTTP legacy
|
||||
// path), preserve the existing wget-compatible behaviour.
|
||||
if *f.AdjustExt && *f.Output != "" {
|
||||
if filepath.Ext(*f.Output) == "" {
|
||||
if filepath.Ext(*f.Output) == "" && isHTMLContentType(result.ContentType) {
|
||||
newPath := *f.Output + ".html"
|
||||
if err := os.Rename(*f.Output, newPath); err == nil {
|
||||
*f.Output = newPath
|
||||
@@ -289,6 +292,20 @@ func runPostDownload(
|
||||
return 0
|
||||
}
|
||||
|
||||
// isHTMLContentType returns true when ct indicates HTML content.
|
||||
// An empty string is treated as HTML for backward compatibility with
|
||||
// protocol handlers that do not populate ContentType (e.g. HTTP).
|
||||
func isHTMLContentType(ct string) bool {
|
||||
if ct == "" {
|
||||
return true
|
||||
}
|
||||
// Strip parameters (charset, boundary, …).
|
||||
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||
ct = ct[:i]
|
||||
}
|
||||
return strings.TrimSpace(ct) == "text/html"
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/config"
|
||||
"codeberg.org/petrbalvin/goget/internal/output"
|
||||
)
|
||||
|
||||
func TestExtractConfigPath(t *testing.T) {
|
||||
@@ -379,3 +381,233 @@ func TestCreateDirsFlagRegisteredAndDefaults(t *testing.T) {
|
||||
func newTestFlagSet(name string) *flag.FlagSet {
|
||||
return flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
}
|
||||
|
||||
// TestShowMetadataFlagRegistered is a regression guard for the BACKLOG entry
|
||||
// "`--show-metadata` — display .goget.meta contents for debugging
|
||||
// interrupted/resumed downloads". It verifies that the flag is wired up on
|
||||
// the FlagSet, defaults to an empty string (the handler short-circuits on the
|
||||
// empty value, so the flag is opt-in), and can be set via the standard
|
||||
// flag parser so the long name reaches the dispatch layer.
|
||||
func TestShowMetadataFlagRegistered(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
fs := newTestFlagSet("goget-test-show-metadata")
|
||||
flags := defineFlags(fs, cfg)
|
||||
|
||||
if flags.ShowMetadata == nil {
|
||||
t.Fatal("ShowMetadata flag was not registered on the FlagSet")
|
||||
}
|
||||
if *flags.ShowMetadata != "" {
|
||||
t.Errorf("ShowMetadata default = %q, want empty (opt-in)", *flags.ShowMetadata)
|
||||
}
|
||||
|
||||
fs2 := newTestFlagSet("goget-test-show-metadata-2")
|
||||
flags2 := defineFlags(fs2, cfg)
|
||||
if err := fs2.Parse([]string{"--show-metadata=/tmp/file.zip"}); err != nil {
|
||||
t.Fatalf("Parse --show-metadata: %v", err)
|
||||
}
|
||||
if want := "/tmp/file.zip"; *flags2.ShowMetadata != want {
|
||||
t.Errorf("ShowMetadata after parse = %q, want %q", *flags2.ShowMetadata, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeMetadataURL ensures credentials embedded in resume metadata URLs
|
||||
// are masked before the value is shown on the terminal. The .goget.meta
|
||||
// sidecar may contain auth tokens in the query string; printing them raw
|
||||
// during a debug session would defeat the 0600 file permissions.
|
||||
func TestSafeMetadataURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"no credentials", "https://example.com/file.zip", "https://example.com/file.zip"},
|
||||
{"with password", "https://user:secret@example.com/file.zip", "https://user:xxxxx@example.com/file.zip"},
|
||||
{"with token query", "https://example.com/file.zip?sig=abc", "https://example.com/file.zip?sig=abc"},
|
||||
{"unparsable passthrough", "://broken", "://broken"},
|
||||
{"username only", "https://user@example.com/file.zip", "https://user@example.com/file.zip"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := safeMetadataURL(tc.in); got != tc.want {
|
||||
t.Errorf("safeMetadataURL(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPercent guards the displayed progress math in --show-metadata: a zero
|
||||
// or negative total (e.g. server did not advertise Content-Length) must not
|
||||
// produce a confusing "Inf%" or "NaN%" reading.
|
||||
func TestPercent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value int64
|
||||
total int64
|
||||
wantMin float64
|
||||
wantMax float64
|
||||
}{
|
||||
{"zero total", 100, 0, 0, 0},
|
||||
{"negative total", 100, -1, 0, 0},
|
||||
{"full", 1000, 1000, 100, 100},
|
||||
{"half", 500, 1000, 50, 50},
|
||||
{"quarter", 250, 1000, 25, 25},
|
||||
{"empty value", 0, 1000, 0, 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := percent(tc.value, tc.total)
|
||||
if got < tc.wantMin || got > tc.wantMax {
|
||||
t.Errorf("percent(%d, %d) = %v, want in [%v, %v]", tc.value, tc.total, got, tc.wantMin, tc.wantMax)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// captureStdoutStderr swaps os.Stdout and os.Stderr for pipes and returns a
|
||||
// drain function that closes the write ends, copies everything written
|
||||
// through the pipes into a buffer, and restores the original os.Stdout /
|
||||
// os.Stderr. Callers run the code under test, then invoke drain (typically
|
||||
// via defer) and read the returned string to assert against the rendered
|
||||
// output. Synchronous drain is intentional — every entry point we exercise
|
||||
// is well-bounded and we want deterministic test output.
|
||||
func captureStdoutStderr(t *testing.T) func() string {
|
||||
t.Helper()
|
||||
|
||||
origStdout, origStderr := os.Stdout, os.Stderr
|
||||
stdoutR, stdoutW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stdout pipe: %v", err)
|
||||
}
|
||||
stderrR, stderrW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("stderr pipe: %v", err)
|
||||
}
|
||||
os.Stdout = stdoutW
|
||||
os.Stderr = stderrW
|
||||
|
||||
var buf strings.Builder
|
||||
return func() string {
|
||||
_ = stdoutW.Close()
|
||||
_ = stderrW.Close()
|
||||
_, _ = io.Copy(&buf, stdoutR)
|
||||
_, _ = io.Copy(&buf, stderrR)
|
||||
_ = stdoutR.Close()
|
||||
_ = stderrR.Close()
|
||||
os.Stdout = origStdout
|
||||
os.Stderr = origStderr
|
||||
return buf.String()
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleShowMetadata covers the four code paths of --show-metadata: a
|
||||
// path pointing at the output file (the handler appends .goget.meta), a path
|
||||
// pointing directly at the sidecar, a missing file (exit 1), and the empty
|
||||
// argument guard. Each subtest also captures stdout + stderr via a pipe so
|
||||
// the rendered summary is checked for the fields the user actually sees.
|
||||
func TestHandleShowMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
meta := output.NewResumeMetadata(
|
||||
"https://user:secret@example.com/large.bin",
|
||||
"etag-abc",
|
||||
"Thu, 01 Jan 2026 00:00:00 GMT",
|
||||
4096, 16384,
|
||||
)
|
||||
outputPath := filepath.Join(dir, "large.bin")
|
||||
if err := meta.Save(outputPath); err != nil {
|
||||
t.Fatalf("seed save: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pathArg string
|
||||
asJSON bool
|
||||
wantExit int
|
||||
wantInOut []string
|
||||
wantNotOut []string
|
||||
}{
|
||||
{
|
||||
name: "output path appends suffix",
|
||||
pathArg: outputPath,
|
||||
asJSON: false,
|
||||
wantExit: 0,
|
||||
wantInOut: []string{"URL: https://user:xxxxx@example.com/large.bin", "ETag: etag-abc", "Last-Modified: Thu, 01 Jan 2026", "Progress: 25.0%", "Version: 1.0"},
|
||||
// Raw secret must not leak through the password-masking helper.
|
||||
wantNotOut: []string{"secret"},
|
||||
},
|
||||
{
|
||||
name: "direct sidecar path",
|
||||
pathArg: outputPath + output.ResumeMetaFileSuffix,
|
||||
asJSON: false,
|
||||
wantExit: 0,
|
||||
wantInOut: []string{"Path: " + outputPath + output.ResumeMetaFileSuffix, "Downloaded: 4.1 KB (4096 bytes)"},
|
||||
},
|
||||
{
|
||||
name: "json mode prints raw bytes",
|
||||
pathArg: outputPath,
|
||||
asJSON: true,
|
||||
wantExit: 0,
|
||||
wantInOut: []string{`"etag": "etag-abc"`, `"downloaded": 4096`, `"total": 16384`},
|
||||
// Raw bytes contain the password — that's intentional, the user
|
||||
// opted into JSON mode and the file is chmod 0600.
|
||||
wantNotOut: nil,
|
||||
},
|
||||
{
|
||||
name: "missing file returns 1",
|
||||
pathArg: filepath.Join(dir, "never-downloaded.bin"),
|
||||
asJSON: false,
|
||||
wantExit: 1,
|
||||
wantInOut: []string{"no resume metadata found at"},
|
||||
},
|
||||
{
|
||||
name: "empty argument returns 1",
|
||||
pathArg: "",
|
||||
asJSON: false,
|
||||
wantExit: 1,
|
||||
wantInOut: []string{"--show-metadata requires a path"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
drain := captureStdoutStderr(t)
|
||||
if got := handleShowMetadata(tc.pathArg, tc.asJSON); got != tc.wantExit {
|
||||
t.Errorf("handleShowMetadata exit = %d, want %d", got, tc.wantExit)
|
||||
}
|
||||
got := drain()
|
||||
for _, want := range tc.wantInOut {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q\nfull output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, banned := range tc.wantNotOut {
|
||||
if strings.Contains(got, banned) {
|
||||
t.Errorf("output must not contain %q\nfull output:\n%s", banned, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleShowMetadataCorruptJSON ensures a malformed .goget.meta sidecar
|
||||
// does not produce a silent success. The user must see the raw bytes so they
|
||||
// can diagnose why --resume later fails to parse the same file.
|
||||
func TestHandleShowMetadataCorruptJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outputPath := filepath.Join(dir, "broken.bin")
|
||||
metaPath := outputPath + output.ResumeMetaFileSuffix
|
||||
if err := os.WriteFile(metaPath, []byte("{not valid json"), 0600); err != nil {
|
||||
t.Fatalf("seed write: %v", err)
|
||||
}
|
||||
|
||||
drain := captureStdoutStderr(t)
|
||||
if got := handleShowMetadata(outputPath, false); got != 0 {
|
||||
t.Errorf("handleShowMetadata exit = %d, want 0 (still attempt to read)", got)
|
||||
}
|
||||
out := drain()
|
||||
if !strings.Contains(out, "{not valid json") {
|
||||
t.Errorf("expected raw metadata in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "not valid JSON") {
|
||||
t.Errorf("expected warning about corrupt JSON, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Placeholder so the directory is tracked by git. The actual file
|
||||
generated here by `just build` is `goget.1`, copied from
|
||||
`../../../man/goget.1`.
|
||||
+13
-206
@@ -1,217 +1,24 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
)
|
||||
|
||||
// manPage holds the canonical goget(1) manual page in troff/groff format.
|
||||
// The source of truth is ../../man/goget.1; a copy is kept here so it can
|
||||
// be embedded into the binary at build time (//go:embed cannot reference
|
||||
// files outside the embedding package). The copy is refreshed by the
|
||||
// `cp` step in `just build`.
|
||||
//
|
||||
//go:embed man-page-data/goget.1
|
||||
var manPage string
|
||||
|
||||
// handleGenManPage implements the --generate-man-page flag. It writes the
|
||||
// embedded manpage to standard output and exits 0.
|
||||
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)
|
||||
fmt.Print(manPage)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//go:build linux || freebsd
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/config"
|
||||
)
|
||||
|
||||
// manPageRequiredSections lists the conventional manpage sections that
|
||||
// goget(1) must include. Every section in this list must be present
|
||||
// in the manpage source as a ".SH <NAME>" header. Adding a new section
|
||||
// here forces the manpage author to document it.
|
||||
var manPageRequiredSections = []string{
|
||||
"NAME",
|
||||
"SYNOPSIS",
|
||||
"DESCRIPTION",
|
||||
"OPTIONS",
|
||||
"EXAMPLES",
|
||||
"EXIT STATUS",
|
||||
"FILES",
|
||||
"ENVIRONMENT",
|
||||
"SEE ALSO",
|
||||
"HISTORY",
|
||||
"AUTHORS",
|
||||
"REPORTING BUGS",
|
||||
"COPYRIGHT",
|
||||
}
|
||||
|
||||
// TestManPageEmbedded is the simplest smoke test: the embed succeeded
|
||||
// and the variable is not empty. Any first line beginning with `.`
|
||||
// (either a `.\"` comment or a macro such as `.TH`) is accepted as
|
||||
// proof the embedded blob is troff source.
|
||||
func TestManPageEmbedded(t *testing.T) {
|
||||
if manPage == "" {
|
||||
t.Fatal("embedded manPage is empty — //go:embed failed at build time")
|
||||
}
|
||||
if !strings.HasPrefix(manPage, ".") {
|
||||
t.Errorf("embedded manPage does not look like troff (got prefix %q)", firstLine(manPage))
|
||||
}
|
||||
}
|
||||
|
||||
// TestManPageHasTHHeader verifies the .TH macro is present with the
|
||||
// expected program name, section, and version. The .TH line is the
|
||||
// entry point that `man` uses to identify the page.
|
||||
func TestManPageHasTHHeader(t *testing.T) {
|
||||
if !strings.Contains(manPage, ".TH GOGET 1 ") {
|
||||
t.Error("manpage is missing the .TH GOGET 1 header")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManPageHasMandatorySections asserts that every section in
|
||||
// manPageRequiredSections exists as a .SH heading. This keeps the page
|
||||
// discoverable in `man goget` and ensures consistency with project
|
||||
// documentation conventions.
|
||||
//
|
||||
// In troff, multi-word section names must be quoted to keep the
|
||||
// spaces inside a single argument: `.SH "SEE ALSO"`. The matcher
|
||||
// accepts both the bare form (`.SH SEE ALSO`) and the quoted form
|
||||
// (`.SH "SEE ALSO"`), so authors can use either convention.
|
||||
func TestManPageHasMandatorySections(t *testing.T) {
|
||||
for _, sec := range manPageRequiredSections {
|
||||
bare := "\n.SH " + sec + "\n"
|
||||
quoted := "\n.SH \"" + sec + "\"\n"
|
||||
if !strings.Contains(manPage, bare) && !strings.Contains(manPage, quoted) {
|
||||
t.Errorf("manpage is missing required section: .SH %s (or .SH \"%s\")", sec, sec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestManPageDocumentsEveryFlag is a guard against drift between the
|
||||
// CLI surface (defineFlags) and the manpage. For every flag
|
||||
// registered on the FlagSet, the manpage must contain the
|
||||
// corresponding option entry. The expected on-page form escapes
|
||||
// every hyphen in the flag name as `\-` (per the convention used
|
||||
// throughout the manpage) and prefixes the entry with `\-\-`.
|
||||
//
|
||||
// Adding a new flag in defineFlags but forgetting the manpage (or
|
||||
// vice versa) makes this test fail with a clear missing-flag list.
|
||||
func TestManPageDocumentsEveryFlag(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
fs := newTestFlagSet("goget-test-man-page")
|
||||
defineFlags(fs, cfg)
|
||||
|
||||
var missing []string
|
||||
fs.VisitAll(func(f *flag.Flag) {
|
||||
escaped := strings.ReplaceAll(f.Name, "-", `\-`)
|
||||
needle := `\-\-` + escaped
|
||||
if !strings.Contains(manPage, needle) {
|
||||
missing = append(missing, f.Name)
|
||||
}
|
||||
})
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
t.Errorf("manpage does not document %d flag(s):\n --%s",
|
||||
len(missing), strings.Join(missing, "\n --"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestManPageRendersWithGroff runs `groff -man -ww -Kutf-8 -z` on the
|
||||
// embedded manpage and fails if it reports any warnings or errors.
|
||||
// The `-z` flag suppresses output; `-ww` enables maximum warning
|
||||
// verbosity; `-Kutf-8` accepts UTF-8 source (em-dash, etc.).
|
||||
//
|
||||
// If `groff` is not installed on the test machine the test is
|
||||
// skipped — we still want it to pass on minimal CI images — but a
|
||||
// CI image with `groff` available will catch regressions in the
|
||||
// troff source.
|
||||
func TestManPageRendersWithGroff(t *testing.T) {
|
||||
if _, err := exec.LookPath("groff"); err != nil {
|
||||
t.Skip("groff not installed; skipping manpage render check")
|
||||
}
|
||||
cmd := exec.Command("groff", "-man", "-ww", "-Kutf-8", "-z")
|
||||
cmd.Stdin = strings.NewReader(manPage)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("groff rejected the manpage: %v\n%s", err, out)
|
||||
} else if len(out) > 0 {
|
||||
t.Fatalf("groff produced unexpected output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// firstLine returns the first line of s, trimmed of the trailing
|
||||
// newline. Used only for error messages.
|
||||
func firstLine(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -236,6 +236,9 @@ func ApplyFlags(c *http.Config, f *Flags, cfg *config.Config, cookieJar *cookie.
|
||||
if *f.AdjustExt {
|
||||
c.Recursive.AdjustExt = true
|
||||
}
|
||||
if *f.BackupConverted {
|
||||
c.Recursive.BackupConverted = true
|
||||
}
|
||||
|
||||
// ── Cookies from CLI flags ──
|
||||
if len(f.Cookies) > 0 {
|
||||
|
||||
+38
-40
@@ -6,39 +6,40 @@
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User[User / Terminal]:::accent6
|
||||
Script[Script / CI]:::accent6
|
||||
CLI[cmd/goget/ - Main entry]:::accent0
|
||||
API[pkg/api/ - Library interface]:::accent3
|
||||
Core[internal/core/ - Types, Protocol Interface, Errors]:::accent1
|
||||
Registry[internal/protocol/ - Protocol Registry]:::accent1
|
||||
HTTP[internal/protocol/http/]:::accent1
|
||||
FTP[internal/protocol/ftp/]:::accent1
|
||||
SFTP[internal/protocol/sftp/]:::accent1
|
||||
FILE[internal/protocol/file/]:::accent1
|
||||
DataURL[internal/protocol/dataurl/]:::accent1
|
||||
Gopher[internal/protocol/gopher/]:::accent1
|
||||
Gemini[internal/protocol/gemini/]:::accent1
|
||||
WebDAV[internal/protocol/webdav/]:::accent1
|
||||
Transport[internal/transport/ - Dialer, Proxy, TLS, Rate Limit]:::accent7
|
||||
Config[internal/config/]:::accent7
|
||||
Output[internal/output/ - Atomic write, Resume, WARC]:::accent2
|
||||
Auth[internal/auth/ - Basic, Digest, OAuth, netrc]:::accent7
|
||||
Crypto[internal/crypto/ - Checksum, PGP, Certs]:::accent7
|
||||
Cookie[internal/cookie/ - Netscape jar]:::accent7
|
||||
HSTS[internal/hsts/ - RFC 6797 cache]:::accent7
|
||||
Archive[internal/archive/ - Extraction]:::accent7
|
||||
Recursive[internal/recursive/ - Crawler, Parser, Filter]:::accent2
|
||||
Mirror[internal/mirror/]:::accent2
|
||||
Metalink[internal/metalink/]:::accent2
|
||||
Queue[internal/queue/]:::accent2
|
||||
Compression[internal/compression/ - gzip, bzip2, zlib, flate, lzw]:::accent7
|
||||
Log[internal/log/ - Structured logging]:::accent7
|
||||
WARC[internal/warc/ - Archiving]:::accent7
|
||||
Register[internal/protocol/register/ - Single registration]:::accent7
|
||||
Format[internal/format/ - Bytes, Speed formatting]:::accent7
|
||||
LinkRewrite[internal/linkrewrite/ - HTML link conversion]:::accent7
|
||||
CLIUtil[internal/cli/ - Help, Progress bar, Colors]:::accent7
|
||||
User[User / Terminal]
|
||||
Script[Script / CI]
|
||||
CLI[cmd/goget/ - Main entry]
|
||||
API[pkg/api/ - Library interface]
|
||||
Core[internal/core/ - Types, Protocol Interface, Errors]
|
||||
Registry[internal/protocol/ - Protocol Registry]
|
||||
HTTP[internal/protocol/http/]
|
||||
FTP[internal/protocol/ftp/]
|
||||
SFTP[internal/protocol/sftp/]
|
||||
FILE[internal/protocol/file/]
|
||||
DataURL[internal/protocol/dataurl/]
|
||||
Gopher[internal/protocol/gopher/]
|
||||
Gemini[internal/protocol/gemini/]
|
||||
WebDAV[internal/protocol/webdav/]
|
||||
Transport[internal/transport/ - Dialer, Proxy, TLS, Rate Limit]
|
||||
Config[internal/config/]
|
||||
Output[internal/output/ - Atomic write, Resume, WARC]
|
||||
Auth[internal/auth/ - Basic, Digest, OAuth, netrc]
|
||||
Crypto[internal/crypto/ - Checksum, PGP, Certs]
|
||||
Cookie[internal/cookie/ - Netscape jar]
|
||||
HSTS[internal/hsts/ - RFC 6797 cache]
|
||||
Archive[internal/archive/ - Extraction]
|
||||
Recursive[internal/recursive/ - Crawler, Parser, Filter]
|
||||
Mirror[internal/mirror/]
|
||||
Metalink[internal/metalink/]
|
||||
Queue[internal/queue/]
|
||||
Compression[internal/compression/ - gzip, bzip2, zlib, flate, lzw]
|
||||
Log[internal/log/ - Structured logging]
|
||||
WARC[internal/warc/ - Archiving]
|
||||
Register[internal/protocol/register/ - Single registration]
|
||||
Format[internal/format/ - Bytes, Speed formatting]
|
||||
LinkRewrite[internal/linkrewrite/ - HTML link conversion]
|
||||
CLIUtil[internal/cli/ - Help, Progress bar, Colors]
|
||||
Pool[internal/pool/ - Unified worker pool]
|
||||
|
||||
User --> CLI
|
||||
Script --> API
|
||||
@@ -65,7 +66,10 @@ graph TD
|
||||
HTTP --> HSTS
|
||||
HTTP --> Output
|
||||
FTP --> Transport
|
||||
FTP --> Pool
|
||||
SFTP --> Transport
|
||||
SFTP --> Pool
|
||||
WebDAV --> Pool
|
||||
end
|
||||
|
||||
subgraph "Post-Download"
|
||||
@@ -88,13 +92,6 @@ graph TD
|
||||
|
||||
Transport --> Auth
|
||||
Transport --> Config
|
||||
|
||||
classDef accent0 fill:#3B82F6,stroke:#2563EB,color:#fff
|
||||
classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff
|
||||
classDef accent2 fill:#F59E0B,stroke:#D97706,color:#000
|
||||
classDef accent3 fill:#A855F7,stroke:#7C3AED,color:#fff
|
||||
classDef accent6 fill:#6366F1,stroke:#4F46E5,color:#fff
|
||||
classDef accent7 fill:#64748B,stroke:#475569,color:#fff
|
||||
```
|
||||
|
||||
## Layers
|
||||
@@ -168,6 +165,7 @@ Protocols register themselves by scheme in a global `Registry`. Scheme normaliza
|
||||
| `log` | Structured logger with text and JSON output modes, level filtering, ANSI colors |
|
||||
| `warc` | WARC (Web ARChive) format writing for legal and archival compliance |
|
||||
| `cli` | ANSI color output, progress bar, help text, completion |
|
||||
| `pool` | Protocol-agnostic recursive worker pool — bounded goroutine execution with recursive task submission. Replaces per-protocol semaphore+WaitGroup code in FTP, SFTP, and WebDAV recursive-parallel downloads |
|
||||
|
||||
## Download Flow
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ goget --url <URL> [OPTIONS]
|
||||
| `--content-disposition` | — | Use filename from server's `Content-Disposition` header |
|
||||
| `--create-dirs` | — | Create missing parent directories of `--output` (default: true). Use `--create-dirs=false` to disable (curl `--create-dirs` / wget behavior). Ignored for stdout output |
|
||||
| `--keep-timestamps` | — | Set file modification time to server's `Last-Modified` value |
|
||||
| `--show-metadata` | `<PATH>` | Display `.goget.meta` sidecar contents for debugging interrupted or resumed downloads. `<PATH>` is either the downloaded output file (sidecar is located next to it) or the sidecar file itself. Renders a human-readable summary; combine with `--json` for raw JSON. Credentials embedded in the stored URL are masked |
|
||||
|
||||
## Progress
|
||||
|
||||
@@ -49,7 +50,8 @@ goget --url <URL> [OPTIONS]
|
||||
| `--dns-servers` | `<IPs>` | Custom DNS servers, comma-separated (e.g. `1.1.1.1,8.8.8.8`) |
|
||||
| `--bind-interface` | `<IFACE>` | Bind outgoing connections to a specific network interface (Linux only) |
|
||||
| `--allow-private` | — | Allow connections to private/internal IPs (disabled by default for SSRF protection) |
|
||||
| `--pinned-cert` | `<SHA256>` | Certificate pinning — abort if server cert SHA-256 doesn't match |
|
||||
| `--pinned-cert` | `<SHA256>` | Certificate pinning (TLS protocols: HTTPS, FTPS, Gemini, WebDAVS) — abort if leaf cert SHA-256 doesn't match |
|
||||
| `--pinned-host-key` | `<FINGERPRINT>` | SSH host-key pinning (SFTP) — accepts `SHA256:<base64>` (`ssh-keygen -lf`) or raw hex SHA-256 |
|
||||
|
||||
## HTTP / Network Flags
|
||||
|
||||
@@ -76,7 +78,7 @@ goget --url <URL> [OPTIONS]
|
||||
|---|---|---|
|
||||
| `--accept` | `"*.pdf"` | Accept patterns — filename glob or MIME type (comma-separated) |
|
||||
| `--no-parent` | — | Do not ascend to parent directories during recursive download |
|
||||
| `--adjust-extension` | — | Append `.html` extension to text/html files without one |
|
||||
| `--adjust-extension` | — | Append `.html` extension to `text/html` files missing one. Applies across HTTP, Gopher, and WebDAV |
|
||||
| `--wait` | `<DURATION>` | Delay between requests in recursive mode |
|
||||
| `--random-wait` | — | Randomize wait time (0.5x–1.5x of `--wait` value) |
|
||||
| `--page-requisites` | — | Download CSS, JS, and images required to render HTML pages |
|
||||
@@ -104,9 +106,9 @@ goget --url <URL> [OPTIONS]
|
||||
| `--reject` | `<PATTERN>` | Reject URL patterns (glob) |
|
||||
| `--cut-dirs` | `<N>` | Ignore N directory components when creating local filenames |
|
||||
| `--proto-dirs` | — | Create protocol-prefixed directories (http/, ftp/ etc.) |
|
||||
| `--adjust-extension` | — | Append `.html` to text/html files missing an extension |
|
||||
| `--adjust-extension` | — | Append `.html` to `text/html` files missing an extension. Applies across HTTP, Gopher, and WebDAV |
|
||||
| `--timestamping` | — | Only download files newer than the local copy |
|
||||
| `--backup-converted` | — | Back up original `.orig` files before link conversion |
|
||||
| `--backup-converted` | — | Back up original `.orig` files before link conversion. Applies to HTTP and WebDAV mirrors |
|
||||
| `--recursive-parallel` | `<N>` | Number of concurrent file downloads in recursive mode across all protocols (HTTP, WebDAV, FTP, SFTP). `0` = sequential (default) |
|
||||
| `--continue` | — | Continue getting partially-downloaded file |
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# Development
|
||||
|
||||
This guide covers the day-to-day workflow for working on **goget** — building,
|
||||
testing, releasing, and contributing.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Go 1.26+**
|
||||
- **Platform:** Linux (amd64, arm64, riscv64, loong64) or
|
||||
FreeBSD (amd64, arm64, riscv64)
|
||||
- **`just`** — task runner (`brew install just` / `apt install just` /
|
||||
`pacman -S just`)
|
||||
- **`groff`** *(optional)* — for `just man` manpage validation
|
||||
|
||||
All Go source files carry the build constraint `//go:build linux || freebsd`.
|
||||
goget does not build or run on macOS or Windows.
|
||||
|
||||
## First-time Setup
|
||||
|
||||
```bash
|
||||
git clone https://codeberg.org/petrbalvin/goget.git
|
||||
cd goget
|
||||
just install # go mod download
|
||||
just test # verify everything works on your machine
|
||||
just build # produce bin/goget
|
||||
```
|
||||
|
||||
A successful `just build` produces a static `bin/goget` binary with no
|
||||
runtime dependencies beyond the Go standard library and `golang.org/x/`
|
||||
packages.
|
||||
|
||||
## Task Reference
|
||||
|
||||
Every task is defined in [`justfile`](../justfile) and is the canonical
|
||||
way to interact with the project.
|
||||
|
||||
| Task | What it does |
|
||||
|------|--------------|
|
||||
| `just` | List all recipes (default) |
|
||||
| `just install` | `go mod download` |
|
||||
| `just run` | `go run ./cmd/goget` (development loop) |
|
||||
| `just build` | `gofmt -l` check + `go vet ./...` + build `bin/goget` |
|
||||
| `just test` | `go test -race -count=1 ./...` |
|
||||
| `just uninstall` | Remove `bin/` and `coverage.out` |
|
||||
| `just man` | Validate `man/goget.1` with `groff` (skips if absent) |
|
||||
| `just install-man` | Install manpage to `~/.local/share/man/man1/` (gzipped, XDG) |
|
||||
| `just uninstall-man` | Remove installed manpage |
|
||||
|
||||
`just build` is **strict**: it runs `gofmt -l` (any output is a failure)
|
||||
plus `go vet ./...`. Both must report zero warnings before the binary
|
||||
compiles. This is exactly what CI checks; running it locally first
|
||||
saves a round-trip.
|
||||
|
||||
## Development Loop
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Edit[Edit source in cmd/, internal/, pkg/]
|
||||
Test[just test]
|
||||
Vet[just build runs gofmt + vet + build]
|
||||
Man[just man - groff validation]
|
||||
Commit[Conventional Commits message]
|
||||
Push[Push to development branch]
|
||||
CI[Forgejo CI: test.yml]
|
||||
|
||||
Edit --> Test
|
||||
Test -->|fail| Edit
|
||||
Test --> Vet
|
||||
Vet -->|fail| Edit
|
||||
Vet --> Man
|
||||
Man -->|fail| Edit
|
||||
Man --> Commit
|
||||
Commit --> Push
|
||||
Push --> CI
|
||||
CI -->|fail| Edit
|
||||
CI -->|pass| Merge[Merge / open PR]
|
||||
```
|
||||
|
||||
Typical iteration:
|
||||
|
||||
```bash
|
||||
# Make a focused change.
|
||||
$EDITOR cmd/goget/flags.go
|
||||
|
||||
# Quick check that compile + format + vet pass.
|
||||
just build
|
||||
|
||||
# Run the relevant tests with the race detector.
|
||||
just test
|
||||
|
||||
# Validate any manpage edits.
|
||||
just man
|
||||
```
|
||||
|
||||
## Building from Source
|
||||
|
||||
`just build` does three things in order:
|
||||
|
||||
1. Copies `man/goget.1` into `cmd/goget/man-page-data/` — the manpage is
|
||||
embedded into the binary via `//go:embed`, and `//go:embed` can only
|
||||
reference files within the embedding package's directory.
|
||||
2. Runs `gofmt -l` over `cmd/`, `internal/`, and `pkg/`. Any output is a
|
||||
formatting violation that breaks the build.
|
||||
3. Runs `go vet ./...` — must report zero warnings.
|
||||
4. Compiles `bin/goget` with `-ldflags "-s -w"` (strips DWARF).
|
||||
|
||||
The resulting binary is fully static. `ldd bin/goget` should report
|
||||
`not a dynamic executable` on Linux.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
just test # full suite, race detector, count=1
|
||||
go test ./internal/protocol/gemini/... # focused
|
||||
go test -run TestFoo ./internal/core/ # one test
|
||||
```
|
||||
|
||||
Conventions:
|
||||
|
||||
- Tests live next to the code as `*_test.go`.
|
||||
- Use table-driven tests for parsers, validators, and protocol handlers.
|
||||
- Cover both happy path and error path.
|
||||
- Test names follow `TestFunctionName_Scenario`.
|
||||
- Fuzz tests use `testing.F` and live next to the code they exercise.
|
||||
|
||||
The race detector is **always** on in CI. New code that introduces a
|
||||
data race must be fixed, not silenced.
|
||||
|
||||
## Manpage Authoring
|
||||
|
||||
The single source of truth is `man/goget.1` (troff). Editing this file is
|
||||
what most users eventually read via `man goget` and what
|
||||
`--generate-man-page` prints.
|
||||
|
||||
Workflow:
|
||||
|
||||
```bash
|
||||
$EDITOR man/goget.1
|
||||
just man # validates the troff
|
||||
just build # copies the file into cmd/goget/man-page-data/
|
||||
just test # cmd/goget/man_page_test.go asserts every CLI flag
|
||||
# is documented and the embedded source still parses
|
||||
```
|
||||
|
||||
`cmd/goget/man_page_test.go` enforces documentation coverage — adding a
|
||||
new flag to `defineFlags` without documenting it in `man/goget.1` breaks
|
||||
the build.
|
||||
|
||||
## Logging
|
||||
|
||||
The project uses `internal/log` for structured output. Prefer `log.Infof`,
|
||||
`log.Errorf`, etc. over ad-hoc `fmt.Fprintf(os.Stderr, ...)` or
|
||||
`cli.Print*`. The backlog tracks a migration of the remaining ~140 legacy
|
||||
callsites to `internal/log`.
|
||||
|
||||
## Branching & Releases
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Feature[Feature branches]
|
||||
Development[development]
|
||||
Main[main]
|
||||
Tag[vX.Y.Z tag]
|
||||
Release[Forgejo release]
|
||||
|
||||
Feature --> Development
|
||||
Development --> Main
|
||||
Main --> Tag
|
||||
Tag --> Release
|
||||
```
|
||||
|
||||
- **`development`** — the only branch that receives day-to-day work.
|
||||
All pull requests target `development`.
|
||||
- **`main`** — only changes when `development` is merged for a release.
|
||||
Never commit directly to `main`.
|
||||
- **Tags** — `vX.Y.Z` tags trigger the release workflow.
|
||||
|
||||
### Cutting a Release
|
||||
|
||||
1. Bump `Version` in [`internal/core/version.go`](../internal/core/version.go).
|
||||
2. Move the top `## [development]` section in
|
||||
[`CHANGELOG.md`](../CHANGELOG.md) to `## [X.Y.Z] — YYYY-MM-DD`,
|
||||
inserting a fresh empty `## [development]` section above it.
|
||||
3. Ensure CI is green on `development`.
|
||||
4. Merge `development` → `main`.
|
||||
5. Tag and push:
|
||||
```bash
|
||||
git tag -a vX.Y.Z -m "goget vX.Y.Z"
|
||||
git push origin main
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
6. The release workflow builds binaries for all seven platform targets,
|
||||
extracts the matching `CHANGELOG.md` section, and publishes the
|
||||
release on Codeberg.
|
||||
|
||||
## CI
|
||||
|
||||
Two Forgejo Actions workflows live under
|
||||
[`.forgejo/workflows/`](../.forgejo/workflows):
|
||||
|
||||
### `test.yml`
|
||||
|
||||
Triggered on every push to `development` and on every pull request
|
||||
targeting `development`.
|
||||
|
||||
| Job | What it does |
|
||||
|-----|--------------|
|
||||
| `vet` | `gofmt -l` check + `go vet ./...` (no output = pass) |
|
||||
| `test` | `go test -race -count=1 ./...` + coverage gate |
|
||||
|
||||
Run the same locally before opening a PR:
|
||||
|
||||
```bash
|
||||
go vet ./...
|
||||
just test
|
||||
```
|
||||
|
||||
### `release.yml`
|
||||
|
||||
Triggered by pushing a `v*` tag. Verifies that the tag matches
|
||||
`internal/core.Version`, runs the quality gate, builds static binaries
|
||||
for all seven targets, and publishes the release.
|
||||
|
||||
Requires a `FORGEJO_TOKEN` secret with release-creation permissions on
|
||||
the runner.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow [Effective Go](https://go.dev/doc/effective_go).
|
||||
- Keep lines under 100 characters.
|
||||
- `camelCase` for locals, `PascalCase` for exported symbols.
|
||||
- Prefix every file with a one-line comment describing its purpose.
|
||||
- Use `//go:build linux || freebsd` on every source file.
|
||||
- Use long CLI flags only (`--header`, not `-H`).
|
||||
- Stick to the Go standard library and `golang.org/x/`. External
|
||||
dependencies require an explicit justification.
|
||||
- Error messages: English, lowercase, no trailing period, always with
|
||||
context (e.g. `open config: permission denied`).
|
||||
- Run `gofmt` before committing.
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
Open an issue at
|
||||
<https://codeberg.org/petrbalvin/goget/issues> with:
|
||||
|
||||
- `goget --version` output.
|
||||
- Operating system and architecture.
|
||||
- The full command used and its output.
|
||||
- Expected vs actual behaviour.
|
||||
|
||||
For **security issues**, email
|
||||
[opensource@petrbalvin.org](mailto:opensource@petrbalvin.org) instead of
|
||||
opening a public issue.
|
||||
+32
-42
@@ -6,33 +6,33 @@ This document explains how goget processes a download request from start to fini
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([User invokes goget]):::accent0
|
||||
ParseFlags["Parse CLI flags"]:::accent7
|
||||
LoadConfig["Load config + merge overrides"]:::accent7
|
||||
ResolveProtocol["Resolve protocol handler\nfrom URL scheme"]:::accent1
|
||||
CreateRequest["Build core.DownloadRequest"]:::accent1
|
||||
CheckResume{"Resume metadata\nexists?"}:::accent2
|
||||
LoadResume["Load .goget.meta\n(resume info + chunk map)"]:::accent2
|
||||
SetupOutput["Setup output writer\n(atomic temp file + resume)"]:::accent2
|
||||
CreateDirs{"--create-dirs\n(parent missing)?"}:::accent2
|
||||
MkdirAll["os.MkdirAll → create\nparent directories"]:::accent2
|
||||
CheckParallel{"File > 100 MB\nor --parallel set?"}:::accent1
|
||||
SequentialDownload["Sequential download\n(single connection)"]:::accent1
|
||||
ParallelDownload["Parallel chunked download\n(multiple Range requests)"]:::accent1
|
||||
Decompress{"Auto-decompress\nenabled?"}:::accent7
|
||||
Decompression["Decompress response\n(gzip, deflate, bzip2, zlib)"]:::accent7
|
||||
VerifyChecksum{"Checksum\nspecified?"}:::accent7
|
||||
ChecksumVerify["Verify checksum\n(SHA-256, SHA-512, etc.)"]:::accent7
|
||||
PGPVerify{"PGP verify/\ndecrypt?"}:::accent7
|
||||
PGPProcess["Verify signature /\ndecrypt file"]:::accent7
|
||||
SaveHSTS["Save HSTS cache\n(RFC 6797)"]:::accent7
|
||||
AtomicRename["Atomic rename\ntemp → final"]:::accent2
|
||||
SigInt{"SIGINT received?"}:::accent4
|
||||
SaveSidecar["Save .goget.meta\nfor resume"]:::accent2
|
||||
Exit130["Exit 130"]:::accent4
|
||||
RunHook{"--on-complete\nset?"}:::accent7
|
||||
PostHook["Run post-download command\n(GOGET_OUTPUT, GOGET_SIZE, GOGET_URL)"]:::accent7
|
||||
Done([Done]):::accent0
|
||||
Start([User invokes goget])
|
||||
ParseFlags["Parse CLI flags"]
|
||||
LoadConfig["Load config + merge overrides"]
|
||||
ResolveProtocol["Resolve protocol handler\nfrom URL scheme"]
|
||||
CreateRequest["Build core.DownloadRequest"]
|
||||
CheckResume{"Resume metadata\nexists?"}
|
||||
LoadResume["Load .goget.meta\n(resume info + chunk map)"]
|
||||
SetupOutput["Setup output writer\n(atomic temp file + resume)"]
|
||||
CreateDirs{"--create-dirs\n(parent missing)?"}
|
||||
MkdirAll["os.MkdirAll → create\nparent directories"]
|
||||
CheckParallel{"File > 100 MB\nor --parallel set?"}
|
||||
SequentialDownload["Sequential download\n(single connection)"]
|
||||
ParallelDownload["Parallel chunked download\n(multiple Range requests)"]
|
||||
Decompress{"Auto-decompress\nenabled?"}
|
||||
Decompression["Decompress response\n(gzip, deflate, bzip2, zlib)"]
|
||||
VerifyChecksum{"Checksum\nspecified?"}
|
||||
ChecksumVerify["Verify checksum\n(SHA-256, SHA-512, etc.)"]
|
||||
PGPVerify{"PGP verify/\ndecrypt?"}
|
||||
PGPProcess["Verify signature /\ndecrypt file"]
|
||||
SaveHSTS["Save HSTS cache\n(RFC 6797)"]
|
||||
AtomicRename["Atomic rename\ntemp → final"]
|
||||
SigInt{"SIGINT received?"}
|
||||
SaveSidecar["Save .goget.meta\nfor resume"]
|
||||
Exit130["Exit 130"]
|
||||
RunHook{"--on-complete\nset?"}
|
||||
PostHook["Run post-download command\n(GOGET_OUTPUT, GOGET_SIZE, GOGET_URL)"]
|
||||
Done([Done])
|
||||
|
||||
Start --> ParseFlags --> LoadConfig --> ResolveProtocol
|
||||
ResolveProtocol --> CreateRequest --> CheckResume
|
||||
@@ -58,12 +58,6 @@ flowchart TD
|
||||
SigInt -->|No| RunHook
|
||||
RunHook -->|Yes| PostHook --> Done
|
||||
RunHook -->|No| Done
|
||||
|
||||
classDef accent0 fill:#3B82F6,stroke:#2563EB,color:#fff
|
||||
classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff
|
||||
classDef accent2 fill:#F59E0B,stroke:#D97706,color:#000
|
||||
classDef accent4 fill:#EF4444,stroke:#DC2626,color:#fff
|
||||
classDef accent7 fill:#64748B,stroke:#475569,color:#fff
|
||||
```
|
||||
|
||||
## Phase 1: Request Construction
|
||||
@@ -94,17 +88,13 @@ The protocol registry resolves the handler by URL scheme:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
URL["https://example.com"]:::accent6
|
||||
Parse["Parse scheme\n→ https"]:::accent7
|
||||
Normalized["Normalize\nhttps → http"]:::accent7
|
||||
Registry["Registry lookup\nprotocols[http]"]:::accent1
|
||||
Handler["HTTP protocol\nhandler"]:::accent1
|
||||
URL["https://example.com"]
|
||||
Parse["Parse scheme\n→ https"]
|
||||
Normalized["Normalize\nhttps → http"]
|
||||
Registry["Registry lookup\nprotocols[http]"]
|
||||
Handler["HTTP protocol\nhandler"]
|
||||
|
||||
URL --> Parse --> Normalized --> Registry --> Handler
|
||||
|
||||
classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff
|
||||
classDef accent6 fill:#6366F1,stroke:#4F46E5,color:#fff
|
||||
classDef accent7 fill:#64748B,stroke:#475569,color:#fff
|
||||
```
|
||||
|
||||
Scheme normalization maps:
|
||||
|
||||
+30
-16
@@ -11,10 +11,10 @@ goget supports 9 network protocols through a unified abstraction layer. Each pro
|
||||
| `sftp://` | SFTP | ✓ | ✓ | — | ✓ | `golang.org/x/crypto/ssh` |
|
||||
| `file://` | Local file | — | — | — | ✓ | Go stdlib `os` |
|
||||
| `data:` | Data URI | — | — | — | — | Go stdlib |
|
||||
| `gopher://` | Gopher | — | — | — | — | Custom implementation |
|
||||
| `gopher://` | Gopher | ✓ | — | — | — | Custom implementation |
|
||||
| `webdav://` `webdavs://` | WebDAV | ✓ | — | ✓ | ✓ | HTTP (via delegation) |
|
||||
|
||||
| `gemini://` | Gemini | — | — | — | — | Custom implementation |
|
||||
| `gemini://` | Gemini | ✓ | — | — | — | Custom implementation |
|
||||
|
||||
## HTTP/HTTPS (`http://`, `https://`)
|
||||
|
||||
@@ -78,6 +78,7 @@ Custom FTP implementation supporting active and passive modes.
|
||||
- **Explicit TLS (FTPS)** — AUTH TLS command on port 21
|
||||
- **Resume** — REST command for partial transfers
|
||||
- **Directory listing** — For recursive operations
|
||||
- **Certificate pinning** — `--pinned-cert` enforces a SHA-256 leaf-cert match on FTPS connections
|
||||
|
||||
### Limitations
|
||||
|
||||
@@ -95,6 +96,9 @@ Custom FTP implementation supporting active and passive modes.
|
||||
|
||||
# Anonymous (default)
|
||||
--url ftp://anonymous@ftp.example.com/file.zip
|
||||
|
||||
# Certificate pinning (FTPS only)
|
||||
--url ftps://ftp.example.com/file.zip --pinned-cert a1b2c3d4e5f6...
|
||||
```
|
||||
|
||||
## SFTP (`sftp://`)
|
||||
@@ -108,6 +112,7 @@ SFTP over SSH protocol.
|
||||
- **Known hosts verification** — Via `~/.ssh/known_hosts`
|
||||
- **Resume** — Partial transfer support; parallel recursive downloads available via `--recursive-parallel N` with connection pooling
|
||||
- **`known_hosts` verification** — Reads `~/.ssh/known_hosts`; does not write to it. First-connection accept via `--ssh-insecure`
|
||||
- **Host-key pinning** — `--pinned-host-key` enforces an exact SSH host-key match (takes precedence over `--ssh-insecure` and `known_hosts`)
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -123,6 +128,9 @@ SFTP over SSH protocol.
|
||||
|
||||
# With password
|
||||
--url sftp://user@host:/path/to/file.zip --password "secret"
|
||||
|
||||
# Host-key pinning (OpenSSH fingerprint from ssh-keygen -lf)
|
||||
--url sftp://user@host:/path/to/file.zip --pinned-host-key SHA256:AAAAC3NzaC1lZDI1NTE5AAAAIE...
|
||||
```
|
||||
|
||||
## Local File (`file://`)
|
||||
@@ -174,10 +182,14 @@ Implements the Gopher protocol as specified in RFC 1436.
|
||||
- **Type parsing** — Respects Gopher type codes (0 = text, 1 = directory, 9 = binary)
|
||||
- **Directory listing** — For recursive operations
|
||||
- **Text and binary downloads**
|
||||
- **Resume** — `.goget.meta` sidecar on interrupt for `typeHTML`,
|
||||
`typeTextFile`, and `typeInformation` responses; `--resume` continues
|
||||
interrupted Gopher transfers (skips already-written output bytes
|
||||
— post-transform length because Gopher line metadata is stripped
|
||||
before writing)
|
||||
|
||||
### Limitations
|
||||
|
||||
- No resume
|
||||
- No parallel downloads
|
||||
- No encryption (Gopher has no TLS)
|
||||
- No upload
|
||||
@@ -188,6 +200,9 @@ Implements the Gopher protocol as specified in RFC 1436.
|
||||
# Download a file from a Gopher server
|
||||
goget --url gopher://gopher.example.com/9/file.zip
|
||||
|
||||
# Resume an interrupted Gopher text download
|
||||
goget --url gopher://gopher.example.com/0/article --output article.txt --resume
|
||||
|
||||
# Browse a Gopher directory
|
||||
goget --url gopher://gopher.example.com/1/
|
||||
```
|
||||
@@ -202,10 +217,11 @@ Implements the Gemini protocol (a lightweight alternative to HTTP/HTTPS).
|
||||
- **Redirect following** — Respects Gemini redirect status codes (3x), up to 10 redirects
|
||||
- **Error handling** — Input prompts (1x) return an error with the prompt text; temporary (4x) and permanent (5x) failures are handled
|
||||
- **Text and binary downloads** — 2x success responses stream content directly
|
||||
- **Certificate pinning** — `--pinned-cert` enforces a SHA-256 leaf-cert match
|
||||
- **Resume** — `.goget.meta` sidecar tracks partial progress; `--resume` continues interrupted downloads
|
||||
|
||||
### Limitations
|
||||
|
||||
- No resume
|
||||
- No parallel downloads
|
||||
- No upload (Gemini is download-only)
|
||||
|
||||
@@ -217,27 +233,25 @@ goget --url gemini://gemini.example.com/file.zip
|
||||
|
||||
# Browse a capsule
|
||||
goget --url gemini://gemini.example.com/
|
||||
|
||||
# Pin the server certificate
|
||||
goget --url gemini://gemini.example.com/ --pinned-cert a1b2c3d4e5f6...
|
||||
```
|
||||
|
||||
## Protocol Resolution Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
URL[URL input]:::accent6
|
||||
Parse["Parse URL"]:::accent7
|
||||
Normalize["Normalize scheme\n(https→http, ftps→ftp)"]:::accent7
|
||||
Registry["Look up in protocol registry"]:::accent1
|
||||
Found{"Protocol found?"}:::accent4
|
||||
Error["Unsupported protocol error"]:::accent4
|
||||
Handle["Delegate to protocol handler"]:::accent1
|
||||
URL[URL input]
|
||||
Parse["Parse URL"]
|
||||
Normalize["Normalize scheme\n(https→http, ftps→ftp)"]
|
||||
Registry["Look up in protocol registry"]
|
||||
Found{"Protocol found?"}
|
||||
Error["Unsupported protocol error"]
|
||||
Handle["Delegate to protocol handler"]
|
||||
|
||||
URL --> Parse --> Normalize --> Registry
|
||||
Registry --> Found
|
||||
Found -->|Yes| Handle
|
||||
Found -->|No| Error
|
||||
|
||||
classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff
|
||||
classDef accent4 fill:#EF4444,stroke:#DC2626,color:#fff
|
||||
classDef accent6 fill:#6366F1,stroke:#4F46E5,color:#fff
|
||||
classDef accent7 fill:#64748B,stroke:#475569,color:#fff
|
||||
```
|
||||
|
||||
@@ -31,15 +31,15 @@ goget --recursive --accept "image/*,text/html" --url https://example.com/
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
HTML[Parse HTML page]:::accent1
|
||||
Extract["Extract all links\n(a, img, link, script)"]:::accent1
|
||||
FilterByDomain{"Domain match?"}:::accent7
|
||||
FilterByPattern{"Accept pattern\nmatch?"}:::accent7
|
||||
FilterByDepth{"Depth ≤ max?"}:::accent7
|
||||
FilterByParent{"No-parent\ncheck?"}:::accent7
|
||||
ExcludeFilter{"Not in exclude\nlist?"}:::accent7
|
||||
Enqueue["Add to download queue"]:::accent1
|
||||
Skip["Skip"]:::accent4
|
||||
HTML[Parse HTML page]
|
||||
Extract["Extract all links\n(a, img, link, script)"]
|
||||
FilterByDomain{"Domain match?"}
|
||||
FilterByPattern{"Accept pattern\nmatch?"}
|
||||
FilterByDepth{"Depth ≤ max?"}
|
||||
FilterByParent{"No-parent\ncheck?"}
|
||||
ExcludeFilter{"Not in exclude\nlist?"}
|
||||
Enqueue["Add to download queue"]
|
||||
Skip["Skip"]
|
||||
|
||||
HTML --> Extract --> FilterByDomain
|
||||
FilterByDomain -->|Yes| FilterByPattern
|
||||
@@ -53,10 +53,6 @@ flowchart TD
|
||||
FilterByParent -->|Fail| Skip
|
||||
ExcludeFilter -->|Pass| Enqueue
|
||||
ExcludeFilter -->|Fail| Skip
|
||||
|
||||
classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff
|
||||
classDef accent4 fill:#EF4444,stroke:#DC2626,color:#fff
|
||||
classDef accent7 fill:#64748B,stroke:#475569,color:#fff
|
||||
```
|
||||
|
||||
### Accept Patterns
|
||||
|
||||
+19
-2
@@ -12,13 +12,30 @@ goget implements multiple security features to protect both the client and serve
|
||||
|
||||
### Certificate Pinning
|
||||
|
||||
Pin a specific certificate by its SHA-256 hash:
|
||||
Pin a specific certificate by its SHA-256 hash. This works across every
|
||||
TLS-bearing protocol goget supports: HTTPS, FTPS, Gemini, and WebDAVS.
|
||||
|
||||
```bash
|
||||
goget --pinned-cert "a1b2c3d4e5f6..." --url https://example.com
|
||||
goget --pinned-cert "a1b2c3d4e5f6..." --url ftps://ftp.example.com/file
|
||||
goget --pinned-cert "a1b2c3d4e5f6..." --url gemini://geminiprotocol.net
|
||||
goget --pinned-cert "a1b2c3d4e5f6..." --url webdavs://dav.example.com/file
|
||||
```
|
||||
|
||||
If the server certificate's SHA-256 doesn't match, the connection is aborted.
|
||||
If the leaf certificate's SHA-256 doesn't match, the connection is aborted.
|
||||
|
||||
### SSH Host-Key Pinning (SFTP)
|
||||
|
||||
Pin the SSH host key for SFTP by its OpenSSH fingerprint (the same value
|
||||
printed by `ssh-keygen -lf ~/.ssh/known_hosts`). Pinning takes precedence
|
||||
over `--ssh-insecure` and the `known_hosts` file.
|
||||
|
||||
```bash
|
||||
goget --pinned-host-key "SHA256:AAAAC3NzaC1lZDI1NTE5AAAAIE+HHi0MtRj4VPl8mdP8gniGZDRb0SZTdI2TPxRyCm1H" \
|
||||
--url sftp://example.com/file
|
||||
```
|
||||
|
||||
The raw lowercase-hex SHA-256 of the key wire format is also accepted.
|
||||
|
||||
### Client Certificates (mTLS)
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ NETWORK
|
||||
--dns-servers <IPs> Custom DNS servers (comma-separated)
|
||||
--bind-interface <IFACE> Bind to specific network interface
|
||||
--allow-private Allow connections to private/internal IPs
|
||||
--pinned-cert <SHA256> Certificate pinning (SHA-256 hash)
|
||||
--pinned-cert <SHA256> Certificate pinning (TLS: HTTPS, FTPS, Gemini, WebDAVS)
|
||||
--pinned-host-key <FP> SSH host-key pinning (SFTP): SHA256:<base64> or hex
|
||||
|
||||
CURL-COMPATIBLE
|
||||
--header "K: V" Custom HTTP header (repeatable)
|
||||
|
||||
@@ -87,6 +87,17 @@ type DownloadRequest struct {
|
||||
Ctx context.Context
|
||||
SSHKnownHosts string // path to known_hosts file (SFTP)
|
||||
SSHInsecure bool // skip SSH host key verification (SFTP)
|
||||
|
||||
// PinnedCertHash pins a TLS leaf certificate by its SHA-256 hash
|
||||
// (lowercase hex). Applied to every TLS-bearing protocol: HTTPS,
|
||||
// FTPS, Gemini, WebDAVS. Empty disables pinning.
|
||||
PinnedCertHash string
|
||||
|
||||
// PinnedHostKey pins an SSH host key. Accepted formats:
|
||||
// "SHA256:<base64>" — OpenSSH fingerprint (ssh-keygen -lf)
|
||||
// "<hex>" — raw SHA-256 hex of the key wire format
|
||||
// Applied to SFTP only. Empty disables pinning.
|
||||
PinnedHostKey string
|
||||
}
|
||||
|
||||
// DownloadResult represents a download result
|
||||
@@ -103,6 +114,7 @@ type DownloadResult struct {
|
||||
Parallel bool
|
||||
ChunksCount int
|
||||
HTTPStatusCode int // HTTP status code from response
|
||||
ContentType string // Content-Type from response (if available)
|
||||
TraceTimings *TraceTimings // HTTP tracing breakdown
|
||||
}
|
||||
|
||||
|
||||
@@ -7,4 +7,4 @@ package core
|
||||
const Name = "goget"
|
||||
|
||||
// Version is the goget release version. Follows SemVer.
|
||||
var Version = "1.0.0"
|
||||
var Version = "1.1.0"
|
||||
|
||||
@@ -44,6 +44,7 @@ type MirrorConfig struct {
|
||||
ConvertLinks bool // Convert links for local viewing
|
||||
DownloadAssets bool // Download CSS, JS, images
|
||||
PruneEmpty bool // Remove empty directories after download
|
||||
BackupConverted bool // Keep .orig backup before link conversion
|
||||
RestrictFiles bool // Only download files from original host
|
||||
Timeout time.Duration
|
||||
RateLimiter *transport.TokenBucket
|
||||
@@ -642,6 +643,16 @@ func (m *Mirror) convertLinksInFile(filePath string) {
|
||||
converted := m.rewriter.RewriteLinks(doc, filePath)
|
||||
|
||||
if converted > 0 {
|
||||
// Backup original file before link conversion.
|
||||
if m.config.BackupConverted {
|
||||
backupPath := filePath + ".orig"
|
||||
if err := os.WriteFile(backupPath, data, 0644); err != nil {
|
||||
if m.config.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[error] failed to backup %s: %v\n", filePath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write modified HTML
|
||||
var buf bytes.Buffer
|
||||
if err := html.Render(&buf, doc); err != nil {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Package pool provides a protocol-agnostic recursive worker pool.
|
||||
//
|
||||
// It replaces the three near-identical semaphore+WaitGroup
|
||||
// implementations that existed in the FTP, SFTP, and WebDAV packages
|
||||
// with a single generic type. Each protocol supplies its own task
|
||||
// type and handler function; the pool takes care of concurrency
|
||||
// limiting, goroutine lifecycle, and error collection.
|
||||
//
|
||||
// Tasks may submit additional tasks from inside the handler (tree
|
||||
// recursion). Submit never blocks the caller—each task gets its
|
||||
// own goroutine that waits for a semaphore slot—so recursive
|
||||
// submissions cannot deadlock even when all pool slots are occupied.
|
||||
package pool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ctxKey is an unexported type for context value keys in this package.
|
||||
type ctxKey struct{}
|
||||
|
||||
// Pool manages a bounded set of goroutines that execute tasks of
|
||||
// type T. Tasks are submitted via Submit and executed by the
|
||||
// handler function provided to New. The pool is safe for
|
||||
// concurrent use from multiple goroutines.
|
||||
type Pool[T any] struct {
|
||||
handler func(ctx context.Context, task T)
|
||||
|
||||
sem chan struct{}
|
||||
wg sync.WaitGroup
|
||||
errMu sync.Mutex
|
||||
errs []error
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// New creates a pool with max parallelism workers. handler is
|
||||
// called for every submitted task, running in its own goroutine
|
||||
// bounded by the semaphore. The returned pool is ready to use.
|
||||
//
|
||||
// ctx is the parent context for all handler goroutines. When ctx
|
||||
// is cancelled, waiting and running handlers observe the
|
||||
// cancellation via the context passed to them.
|
||||
//
|
||||
// The context passed to handler carries a reference to the pool
|
||||
// itself, retrievable via Get. This allows handlers to submit
|
||||
// child tasks without an explicit closure capture.
|
||||
func New[T any](ctx context.Context, maxParallelism int, handler func(ctx context.Context, task T)) *Pool[T] {
|
||||
if maxParallelism < 1 {
|
||||
maxParallelism = 1
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
return &Pool[T]{
|
||||
handler: handler,
|
||||
sem: make(chan struct{}, maxParallelism),
|
||||
cancel: cancel,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves the pool of type *Pool[T] from the context.
|
||||
// It returns nil when the context does not carry a pool (for
|
||||
// example, when called outside a handler goroutine).
|
||||
func Get[T any](ctx context.Context) *Pool[T] {
|
||||
v, _ := ctx.Value(ctxKey{}).(*Pool[T])
|
||||
return v
|
||||
}
|
||||
|
||||
// Submit enqueues a task for execution. Each submitted task gets
|
||||
// its own goroutine that will wait for a semaphore slot before
|
||||
// running the handler. Submit may be called from inside a handler
|
||||
// (for recursive tasks).
|
||||
//
|
||||
// Submit is a no-op if the pool has already been waited on.
|
||||
func (p *Pool[T]) Submit(task T) {
|
||||
// After Wait returns the context is cancelled; skip work that
|
||||
// can never run.
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
p.wg.Add(1)
|
||||
go func() {
|
||||
defer p.wg.Done()
|
||||
select {
|
||||
case p.sem <- struct{}{}:
|
||||
defer func() { <-p.sem }()
|
||||
handlerCtx := context.WithValue(p.ctx, ctxKey{}, p)
|
||||
p.handler(handlerCtx, task)
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait blocks until all submitted tasks (including tasks submitted
|
||||
// by handlers) have completed. It returns all errors collected
|
||||
// during execution. After Wait returns the pool must not be used.
|
||||
func (p *Pool[T]) Wait() []error {
|
||||
p.wg.Wait()
|
||||
p.cancel()
|
||||
|
||||
p.errMu.Lock()
|
||||
defer p.errMu.Unlock()
|
||||
if len(p.errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return p.errs
|
||||
}
|
||||
|
||||
// AddError records a task-level error. It is safe to call from
|
||||
// any goroutine, typically from inside a handler.
|
||||
func (p *Pool[T]) AddError(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
p.errMu.Lock()
|
||||
p.errs = append(p.errs, err)
|
||||
p.errMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package pool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPoolBasic(t *testing.T) {
|
||||
var count atomic.Int64
|
||||
p := New(context.Background(), 4, func(_ context.Context, task int) {
|
||||
count.Add(1)
|
||||
})
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
p.Submit(i)
|
||||
}
|
||||
|
||||
errs := p.Wait()
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("unexpected errors: %v", errs)
|
||||
}
|
||||
if got := count.Load(); got != 10 {
|
||||
t.Fatalf("processed %d tasks, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolConcurrencyLimit(t *testing.T) {
|
||||
const parallel = 3
|
||||
var running atomic.Int64
|
||||
var maxRunning atomic.Int64
|
||||
|
||||
p := New(context.Background(), parallel, func(_ context.Context, task int) {
|
||||
cur := running.Add(1)
|
||||
for {
|
||||
old := maxRunning.Load()
|
||||
if cur <= old || maxRunning.CompareAndSwap(old, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Simulate work so concurrent goroutines overlap.
|
||||
for i := 0; i < 1000; i++ {
|
||||
}
|
||||
running.Add(-1)
|
||||
})
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
p.Submit(i)
|
||||
}
|
||||
|
||||
errs := p.Wait()
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("unexpected errors: %v", errs)
|
||||
}
|
||||
if got := maxRunning.Load(); got > parallel {
|
||||
t.Fatalf("max concurrent goroutines %d, want <= %d", got, parallel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolRecursive(t *testing.T) {
|
||||
// Simulate a tree: each task submits two children until depth 3.
|
||||
// Expected total tasks: 1 + 2 + 4 + 8 = 15.
|
||||
var count atomic.Int64
|
||||
|
||||
type task struct {
|
||||
depth int
|
||||
}
|
||||
|
||||
p := New(context.Background(), 4, func(ctx context.Context, tk task) {
|
||||
count.Add(1)
|
||||
if tk.depth > 0 {
|
||||
Get[task](ctx).Submit(task{depth: tk.depth - 1})
|
||||
Get[task](ctx).Submit(task{depth: tk.depth - 1})
|
||||
}
|
||||
})
|
||||
|
||||
p.Submit(task{depth: 3})
|
||||
|
||||
errs := p.Wait()
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("unexpected errors: %v", errs)
|
||||
}
|
||||
if got := count.Load(); got != 15 {
|
||||
t.Fatalf("processed %d tasks, want 15", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolErrorCollection(t *testing.T) {
|
||||
p := New(context.Background(), 2, func(ctx context.Context, task int) {
|
||||
if task%2 == 0 {
|
||||
Get[int](ctx).AddError(&testError{task})
|
||||
}
|
||||
})
|
||||
|
||||
for i := 0; i < 6; i++ {
|
||||
p.Submit(i)
|
||||
}
|
||||
|
||||
errs := p.Wait()
|
||||
if len(errs) != 3 {
|
||||
t.Fatalf("collected %d errors, want 3", len(errs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOutsideHandler(t *testing.T) {
|
||||
// Get returns nil when called outside a handler goroutine.
|
||||
if got := Get[int](context.Background()); got != nil {
|
||||
t.Fatalf("Get outside handler returned %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolContextCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
p := New(ctx, 4, func(taskCtx context.Context, task int) {
|
||||
// Each task blocks until context is cancelled.
|
||||
<-taskCtx.Done()
|
||||
})
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
p.Submit(i)
|
||||
}
|
||||
|
||||
// Cancel after a short delay so the tasks start.
|
||||
go func() {
|
||||
// Give goroutines time to start and acquire semaphore.
|
||||
cancel()
|
||||
}()
|
||||
|
||||
errs := p.Wait()
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("unexpected errors: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolDeterministic(t *testing.T) {
|
||||
// Verify that all submitted tasks are processed exactly once.
|
||||
const n = 100
|
||||
var mu sync.Mutex
|
||||
seen := make(map[int]int)
|
||||
|
||||
p := New(context.Background(), 8, func(_ context.Context, task int) {
|
||||
mu.Lock()
|
||||
seen[task]++
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
p.Submit(i)
|
||||
}
|
||||
|
||||
errs := p.Wait()
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("unexpected errors: %v", errs)
|
||||
}
|
||||
|
||||
if len(seen) != n {
|
||||
t.Fatalf("saw %d unique tasks, want %d", len(seen), n)
|
||||
}
|
||||
for k, v := range seen {
|
||||
if v != 1 {
|
||||
t.Errorf("task %d processed %d times, want 1", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolSortedResults(t *testing.T) {
|
||||
// Collect results from concurrent workers and verify we get them all.
|
||||
const n = 50
|
||||
var mu sync.Mutex
|
||||
results := make([]int, 0, n)
|
||||
|
||||
p := New(context.Background(), 4, func(_ context.Context, task int) {
|
||||
mu.Lock()
|
||||
results = append(results, task)
|
||||
mu.Unlock()
|
||||
})
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
p.Submit(i)
|
||||
}
|
||||
|
||||
errs := p.Wait()
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("unexpected errors: %v", errs)
|
||||
}
|
||||
|
||||
sort.Ints(results)
|
||||
if len(results) != n {
|
||||
t.Fatalf("got %d results, want %d", len(results), n)
|
||||
}
|
||||
for i, v := range results {
|
||||
if v != i {
|
||||
t.Fatalf("results[%d] = %d, want %d", i, v, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type testError struct {
|
||||
task int
|
||||
}
|
||||
|
||||
func (e *testError) Error() string {
|
||||
return "error in task"
|
||||
}
|
||||
+110
-94
@@ -6,7 +6,10 @@ package ftp
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -16,10 +19,10 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/output"
|
||||
"codeberg.org/petrbalvin/goget/internal/pool"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
@@ -35,6 +38,11 @@ type Client struct {
|
||||
useTLS bool
|
||||
passive bool
|
||||
timeout time.Duration
|
||||
|
||||
// pinnedCertHash, when non-empty, requires the server's leaf
|
||||
// certificate to match this SHA-256 hash (lowercase hex). Only
|
||||
// meaningful for FTPS.
|
||||
pinnedCertHash string
|
||||
}
|
||||
|
||||
func NewClient(rawURL string, timeout time.Duration) (*Client, error) {
|
||||
@@ -86,6 +94,13 @@ func NewClient(rawURL string, timeout time.Duration) (*Client, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SetPinnedCert configures certificate pinning for FTPS connections.
|
||||
// The expectedHash is a SHA-256 hash of the leaf certificate, expressed
|
||||
// as lowercase hex. Calling this on a plain-FTP client is a no-op.
|
||||
func (c *Client) SetPinnedCert(expectedHash string) {
|
||||
c.pinnedCertHash = strings.ToLower(strings.TrimSpace(expectedHash))
|
||||
}
|
||||
|
||||
func (c *Client) Connect() error {
|
||||
addr := net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port))
|
||||
conn, err := net.DialTimeout("tcp", addr, c.timeout)
|
||||
@@ -130,6 +145,23 @@ func (c *Client) Connect() error {
|
||||
}
|
||||
|
||||
// Wrap connection with TLS
|
||||
if c.pinnedCertHash != "" {
|
||||
expected := c.pinnedCertHash
|
||||
c.tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||
for _, rawCert := range rawCerts {
|
||||
cert, err := x509.ParseCertificate(rawCert)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hash := sha256.Sum256(cert.Raw)
|
||||
if hex.EncodeToString(hash[:]) == expected {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("certificate pinning failed: no certificate matches sha-256 %s", expected)
|
||||
}
|
||||
}
|
||||
|
||||
tlsConn := tls.Client(c.conn, c.tlsConfig)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return fmt.Errorf("tls handshake failed: %w", err)
|
||||
@@ -647,7 +679,7 @@ type clientPool struct {
|
||||
|
||||
// newClientPool opens `size` independent FTP connections to the same
|
||||
// server. Connections are kept open until close() is called.
|
||||
func newClientPool(rawURL string, timeout time.Duration, size int) (*clientPool, error) {
|
||||
func newClientPool(rawURL string, timeout time.Duration, size int, pinnedCertHash string) (*clientPool, error) {
|
||||
if size < 1 {
|
||||
size = 1
|
||||
}
|
||||
@@ -661,6 +693,9 @@ func newClientPool(rawURL string, timeout time.Duration, size int) (*clientPool,
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if pinnedCertHash != "" {
|
||||
c.SetPinnedCert(pinnedCertHash)
|
||||
}
|
||||
if err := c.Connect(); err != nil {
|
||||
for _, existing := range all {
|
||||
_ = existing.Close()
|
||||
@@ -698,67 +733,56 @@ func (p *clientPool) close() {
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadDirectoryConcurrent downloads a remote directory to localPath
|
||||
// using a pool of N FTP connections. All operations (LIST, RETR) use
|
||||
// absolute paths so concurrent workers never share or mutate CWD state
|
||||
// on the server side. The returned error is the first error encountered
|
||||
// by any worker; other workers continue in the background until they
|
||||
// hit ctx cancellation or finish their tasks.
|
||||
func DownloadDirectoryConcurrent(ctx context.Context, rawURL string, timeout time.Duration, remotePath, localPath string, parallel int, progressFunc func(file string, current, total int64)) error {
|
||||
if parallel < 1 {
|
||||
parallel = 1
|
||||
}
|
||||
pool, err := newClientPool(rawURL, timeout, parallel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pool.close()
|
||||
|
||||
sem := make(chan struct{}, parallel)
|
||||
var wg sync.WaitGroup
|
||||
var firstErr error
|
||||
var errMu sync.Mutex
|
||||
|
||||
setErr := func(err error) {
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
errMu.Unlock()
|
||||
}
|
||||
|
||||
processEntry(ctx, pool, sem, &wg, setErr, remotePath, localPath, progressFunc)
|
||||
|
||||
wg.Wait()
|
||||
return firstErr
|
||||
// ftpTask represents a unit of work for the concurrent recursive
|
||||
// download: either a directory to list or a file to download.
|
||||
type ftpTask struct {
|
||||
remotePath string
|
||||
localPath string
|
||||
isDir bool
|
||||
name string
|
||||
}
|
||||
|
||||
// processEntry processes one remote path: lists it, then dispatches
|
||||
// its child entries (file → download, subdir → recursive processEntry)
|
||||
// through the shared semaphore-bounded goroutine pool. A single
|
||||
// sem is threaded through the recursion so the total in-flight
|
||||
// goroutines never exceed `parallel`, regardless of directory depth.
|
||||
func processEntry(ctx context.Context, pool *clientPool, sem chan struct{}, wg *sync.WaitGroup, setErr func(error), remotePath, localPath string, progressFunc func(file string, current, total int64)) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
// ftpTaskHandler processes one ftpTask. For directories it lists the
|
||||
// contents and submits child tasks for each entry. For files it
|
||||
// acquires a client from the pool and downloads.
|
||||
func ftpTaskHandler(cpool *clientPool, progressFunc func(file string, current, total int64)) func(ctx context.Context, t ftpTask) {
|
||||
return func(ctx context.Context, t ftpTask) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(localPath, 0755); err != nil {
|
||||
setErr(fmt.Errorf("failed to create local dir %s: %w", localPath, err))
|
||||
if err := os.MkdirAll(t.localPath, 0755); err != nil {
|
||||
pool.Get[ftpTask](ctx).AddError(fmt.Errorf("failed to create local dir %s: %w", t.localPath, err))
|
||||
return
|
||||
}
|
||||
|
||||
c := pool.acquire()
|
||||
entries, err := c.ListRemoteDir(remotePath)
|
||||
pool.release(c)
|
||||
if !t.isDir {
|
||||
c := cpool.acquire()
|
||||
defer cpool.release(c)
|
||||
if progressFunc != nil {
|
||||
progressFunc(t.name, 0, 0)
|
||||
}
|
||||
var fileProgress func(current, total int64)
|
||||
if progressFunc != nil {
|
||||
fileProgress = func(current, total int64) {
|
||||
progressFunc(t.name, current, total)
|
||||
}
|
||||
}
|
||||
if err := c.DownloadFile(ctx, t.remotePath, t.localPath, 0, fileProgress); err != nil {
|
||||
pool.Get[ftpTask](ctx).AddError(fmt.Errorf("download %s: %w", t.remotePath, err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c := cpool.acquire()
|
||||
entries, err := c.ListRemoteDir(t.remotePath)
|
||||
cpool.release(c)
|
||||
if err != nil {
|
||||
setErr(fmt.Errorf("list %s: %w", remotePath, err))
|
||||
pool.Get[ftpTask](ctx).AddError(fmt.Errorf("list %s: %w", t.remotePath, err))
|
||||
return
|
||||
}
|
||||
|
||||
p := pool.Get[ftpTask](ctx)
|
||||
for _, entry := range entries {
|
||||
fields := strings.Fields(entry)
|
||||
if len(fields) < 9 {
|
||||
@@ -768,50 +792,42 @@ func processEntry(ctx context.Context, pool *clientPool, sem chan struct{}, wg *
|
||||
if name == "." || name == ".." {
|
||||
continue
|
||||
}
|
||||
isDir := fields[0][0] == 'd'
|
||||
remoteFilePath := filepath.Join(remotePath, name)
|
||||
localFilePath := filepath.Join(localPath, name)
|
||||
|
||||
if isDir {
|
||||
wg.Add(1)
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
go processEntry(ctx, pool, sem, wg, setErr, remoteFilePath, localFilePath, progressFunc)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
} else {
|
||||
wg.Add(1)
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
go func(remoteFilePath, localFilePath, name string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
c := pool.acquire()
|
||||
defer pool.release(c)
|
||||
if progressFunc != nil {
|
||||
progressFunc(name, 0, 0)
|
||||
}
|
||||
// DownloadFile's progress callback has a different
|
||||
// signature than our directory-level progressFunc, so
|
||||
// we wrap it here to keep the existing per-file
|
||||
// progress behaviour.
|
||||
var fileProgress func(current, total int64)
|
||||
if progressFunc != nil {
|
||||
fileProgress = func(current, total int64) {
|
||||
progressFunc(name, current, total)
|
||||
}
|
||||
}
|
||||
if err := c.DownloadFile(ctx, remoteFilePath, localFilePath, 0, fileProgress); err != nil {
|
||||
setErr(fmt.Errorf("download %s: %w", remoteFilePath, err))
|
||||
}
|
||||
}(remoteFilePath, localFilePath, name)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
childIsDir := fields[0][0] == 'd'
|
||||
p.Submit(ftpTask{
|
||||
remotePath: filepath.Join(t.remotePath, name),
|
||||
localPath: filepath.Join(t.localPath, name),
|
||||
isDir: childIsDir,
|
||||
name: name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DownloadDirectoryConcurrent downloads a remote directory to localPath
|
||||
// using a pool of N FTP connections. All operations (LIST, RETR) use
|
||||
// absolute paths so concurrent workers never share or mutate CWD state
|
||||
// on the server side. The returned error is the first error encountered
|
||||
// by any worker; other workers continue in the background until they
|
||||
// hit ctx cancellation or finish their tasks.
|
||||
func DownloadDirectoryConcurrent(ctx context.Context, rawURL string, timeout time.Duration, remotePath, localPath string, parallel int, pinnedCertHash string, progressFunc func(file string, current, total int64)) error {
|
||||
if parallel < 1 {
|
||||
parallel = 1
|
||||
}
|
||||
cpool, err := newClientPool(rawURL, timeout, parallel, pinnedCertHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cpool.close()
|
||||
|
||||
p := pool.New(ctx, parallel, ftpTaskHandler(cpool, progressFunc))
|
||||
p.Submit(ftpTask{
|
||||
remotePath: remotePath,
|
||||
localPath: localPath,
|
||||
isDir: true,
|
||||
})
|
||||
|
||||
if errs := p.Wait(); len(errs) > 0 {
|
||||
return errs[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -380,3 +380,21 @@ func TestNewClientTLSConfig(t *testing.T) {
|
||||
t.Errorf("tlsConfig.InsecureSkipVerify should be false by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPinnedCert(t *testing.T) {
|
||||
client, err := NewClient("ftps://secure.example.com/file.txt", 30*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Pinning is off by default.
|
||||
if client.pinnedCertHash != "" {
|
||||
t.Errorf("pinnedCertHash should default to empty, got %q", client.pinnedCertHash)
|
||||
}
|
||||
|
||||
// SetPinnedCert normalises to lowercase and trims whitespace.
|
||||
client.SetPinnedCert(" ABCDEF0123456789 ")
|
||||
if client.pinnedCertHash != "abcdef0123456789" {
|
||||
t.Errorf("pinnedCertHash = %q, want %q", client.pinnedCertHash, "abcdef0123456789")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*co
|
||||
if err != nil {
|
||||
return nil, core.NewProtocolError(fmt.Sprintf("failed to create FTP client: %v", err), nil, core.SafeURL(req.URL))
|
||||
}
|
||||
if req.PinnedCertHash != "" {
|
||||
client.SetPinnedCert(req.PinnedCertHash)
|
||||
}
|
||||
|
||||
if err := client.Connect(); err != nil {
|
||||
return nil, core.NewProtocolError(fmt.Sprintf("failed to connect: %v", err), nil, core.SafeURL(req.URL))
|
||||
@@ -169,7 +172,7 @@ func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*co
|
||||
// opens N control connections to avoid stepping on CWD state and
|
||||
// to keep the existing client-side protocol serialisation.
|
||||
if req.RecursiveParallel > 1 {
|
||||
if err := DownloadDirectoryConcurrent(ctx, req.URL.String(), timeout, remotePath, outputDir, req.RecursiveParallel, progressCallback); err != nil {
|
||||
if err := DownloadDirectoryConcurrent(ctx, req.URL.String(), timeout, remotePath, outputDir, req.RecursiveParallel, req.PinnedCertHash, progressCallback); err != nil {
|
||||
return nil, core.NewProtocolError(fmt.Sprintf("directory download failed: %v", err), nil, core.SafeURL(req.URL))
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -6,17 +6,22 @@ package gemini
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
"codeberg.org/petrbalvin/goget/internal/output"
|
||||
"codeberg.org/petrbalvin/goget/internal/protocol"
|
||||
)
|
||||
|
||||
@@ -36,7 +41,7 @@ func NewProtocol() *Protocol {
|
||||
Name: "GEMINI",
|
||||
Scheme: "gemini",
|
||||
DefaultPort: 1965,
|
||||
Features: []string{},
|
||||
Features: []string{"resume"},
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -63,6 +68,27 @@ func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.Download
|
||||
InsecureSkipVerify: p.TLSInsecure,
|
||||
}
|
||||
|
||||
// Certificate pinning: require the leaf certificate to match the
|
||||
// caller-provided SHA-256 hash. The check runs after the standard
|
||||
// verification chain so a pinned self-signed cert still needs
|
||||
// InsecureSkipVerify (or a matching CA) to clear the normal path.
|
||||
if req != nil && req.PinnedCertHash != "" {
|
||||
expected := strings.ToLower(strings.TrimSpace(req.PinnedCertHash))
|
||||
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
||||
for _, rawCert := range rawCerts {
|
||||
cert, err := x509.ParseCertificate(rawCert)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hash := sha256.Sum256(cert.Raw)
|
||||
if hex.EncodeToString(hash[:]) == expected {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("certificate pinning failed: no certificate matches sha-256 %s", expected)
|
||||
}
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: 30 * time.Second}
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
@@ -72,6 +98,22 @@ func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.Download
|
||||
|
||||
conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
|
||||
// Close the connection when the context is cancelled so any
|
||||
// blocking Read returns immediately instead of waiting for the
|
||||
// 30-second deadline. The done channel bounds the goroutine to
|
||||
// the lifetime of this call: when Download returns, defer
|
||||
// close(done) lets the goroutine exit even if the parent context
|
||||
// was never cancelled.
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
conn.Close()
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
|
||||
// Send request: URL + CRLF
|
||||
requestURL := req.URL.String()
|
||||
if _, err := fmt.Fprintf(conn, "%s\r\n", requestURL); err != nil {
|
||||
@@ -127,6 +169,7 @@ func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.Download
|
||||
Output: req.Output,
|
||||
Verbose: req.Verbose,
|
||||
Writer: req.Writer,
|
||||
PinnedCertHash: req.PinnedCertHash,
|
||||
Ctx: ctx,
|
||||
}
|
||||
return p.downloadWithRedirects(ctx, redirectReq, redirectCount+1)
|
||||
@@ -153,43 +196,139 @@ func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.Download
|
||||
}
|
||||
|
||||
// Category 2x: success — continue to read body
|
||||
var totalRead int64
|
||||
var totalRead int64 // total bytes received from server (including skipped)
|
||||
var writer io.Writer
|
||||
var file *os.File
|
||||
resumed := false
|
||||
var startOffset int64 // bytes to skip on resume
|
||||
|
||||
// Set up output destination. When req.Output is set and no external
|
||||
// Writer was provided, we open the file directly. This covers both
|
||||
// fresh downloads (create) and resume (append). When req.Writer is
|
||||
// set (caller manages the file), we use it as-is.
|
||||
if req.Output != "" && req.Output != "-" {
|
||||
// Resume is meaningful only when we own the output file. If the
|
||||
// caller already supplied a Writer we cannot append to Output
|
||||
// behind their back, so disable resume to keep the partial-state
|
||||
// sidecar consistent with what actually lands on disk.
|
||||
if req.Resume && req.Writer != nil && req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[gemini] warning: --resume ignored because a Writer is set; output goes to the Writer only\n")
|
||||
}
|
||||
if req.Resume && req.Writer != nil {
|
||||
req.Resume = false
|
||||
}
|
||||
if req.Resume {
|
||||
canResume, resumeMeta, rerr := output.CanResume(req.Output, req.URL.String())
|
||||
if rerr != nil {
|
||||
if req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[gemini] Resume check failed: %v\n", rerr)
|
||||
}
|
||||
output.CleanupResumeFiles(req.Output)
|
||||
} else if canResume && resumeMeta != nil {
|
||||
file, rerr = os.OpenFile(req.Output, os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if rerr != nil {
|
||||
return nil, core.NewFileError("failed to open file for resume", rerr)
|
||||
}
|
||||
defer file.Close()
|
||||
writer = file
|
||||
startOffset = resumeMeta.Downloaded
|
||||
resumed = true
|
||||
if req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[gemini] Resuming from byte %d\n", startOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
if writer == nil && req.Writer == nil {
|
||||
// Fresh download — create the output file.
|
||||
dir := filepath.Dir(req.Output)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, core.NewFileError("failed to create output directory", err)
|
||||
}
|
||||
}
|
||||
file, err = os.Create(req.Output)
|
||||
if err != nil {
|
||||
return nil, core.NewFileError("failed to create output file", err)
|
||||
}
|
||||
defer file.Close()
|
||||
writer = file
|
||||
}
|
||||
}
|
||||
if writer == nil {
|
||||
if req.Writer != nil {
|
||||
writer = req.Writer
|
||||
} else {
|
||||
writer = io.Discard
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini does not support range requests, so resume re-downloads
|
||||
// the full content and skips the bytes already saved to disk.
|
||||
var skipped int64
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
saveGeminiResume(req, totalRead)
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
n, err := reader.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := writer.Write(buf[:n]); werr != nil {
|
||||
totalRead += int64(n)
|
||||
chunk := buf[:n]
|
||||
// On resume, discard bytes we already have on disk.
|
||||
if skipped < startOffset {
|
||||
remain := startOffset - skipped
|
||||
if int64(n) <= remain {
|
||||
skipped += int64(n)
|
||||
chunk = nil
|
||||
} else {
|
||||
skipped += remain
|
||||
chunk = chunk[int(remain):]
|
||||
}
|
||||
}
|
||||
if len(chunk) > 0 {
|
||||
if _, werr := writer.Write(chunk); werr != nil {
|
||||
saveGeminiResume(req, totalRead)
|
||||
return nil, core.NewFileError("failed to write gemini data", werr)
|
||||
}
|
||||
totalRead += int64(n)
|
||||
}
|
||||
if req.ProgressCallback != nil {
|
||||
speed := float64(totalRead) / time.Since(startTime).Seconds()
|
||||
req.ProgressCallback(totalRead, -1, speed)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
// The connection may have been closed by our context
|
||||
// cancellation goroutine. If so, the EOF is not a genuine
|
||||
// end-of-data but a side-effect of the cancel.
|
||||
if ctx.Err() != nil {
|
||||
saveGeminiResume(req, totalRead)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
saveGeminiResume(req, totalRead)
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, core.NewNetworkError("failed to read gemini response body", err, core.SafeURL(req.URL))
|
||||
}
|
||||
}
|
||||
|
||||
// Download complete — remove resume metadata sidecar.
|
||||
if req.Output != "" && req.Output != "-" {
|
||||
output.DeleteResumeMetadata(req.Output)
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
speed := float64(totalRead) / duration.Seconds()
|
||||
speed := float64(0)
|
||||
if duration.Seconds() > 0 {
|
||||
speed = float64(totalRead) / duration.Seconds()
|
||||
}
|
||||
|
||||
return &core.DownloadResult{
|
||||
BytesDownloaded: totalRead,
|
||||
@@ -199,5 +338,20 @@ func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.Download
|
||||
IPVersion: 4,
|
||||
Speed: speed,
|
||||
OutputPath: req.Output,
|
||||
Resumed: resumed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// saveGeminiResume persists partial Gemini download progress to the
|
||||
// standard `.goget.meta` sidecar. No-op for stdout writes or zero-byte
|
||||
// transfers. Gemini does not expose Content-Length in the response
|
||||
// header, so Total is recorded as 0 (size unknown).
|
||||
func saveGeminiResume(req *core.DownloadRequest, downloaded int64) {
|
||||
if req == nil || req.Output == "" || req.Output == "-" || downloaded <= 0 {
|
||||
return
|
||||
}
|
||||
meta := output.NewResumeMetadata(req.URL.String(), "", "", downloaded, 0)
|
||||
if err := meta.Save(req.Output); err != nil && req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[gemini] Warning: failed to save resume metadata: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,15 @@ import (
|
||||
"math/big"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
"codeberg.org/petrbalvin/goget/internal/output"
|
||||
)
|
||||
|
||||
type stringWriter struct{ sb *strings.Builder }
|
||||
@@ -132,3 +135,397 @@ func TestGeminiPermanentFailure(t *testing.T) {
|
||||
t.Error("expected permanent failure error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiPinnedCertMismatch exercises the certificate-pinning path.
|
||||
// The server presents a self-signed cert whose hash does NOT match the
|
||||
// pinned value, so the download must fail with a pinning error.
|
||||
func TestGeminiPinnedCertMismatch(t *testing.T) {
|
||||
cfg := testTLSConfig()
|
||||
listener, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
conn, _ := listener.Accept()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
r.ReadString('\n')
|
||||
conn.Write([]byte("20 text/gemini\r\nhello\n"))
|
||||
}()
|
||||
|
||||
proto := NewProtocol()
|
||||
proto.TLSInsecure = true // accept self-signed, then let pinning reject
|
||||
u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/")
|
||||
_, err = proto.Download(context.Background(), &core.DownloadRequest{
|
||||
URL: u,
|
||||
PinnedCertHash: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected pinning failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pinning failed") {
|
||||
t.Errorf("expected pinning error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiResumeSaveMetadata verifies that partial download progress is
|
||||
// persisted to a .goget.meta sidecar when the context is cancelled.
|
||||
func TestGeminiResumeSaveMetadata(t *testing.T) {
|
||||
cfg := testTLSConfig()
|
||||
listener, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
// Send data in small pieces with delays so the download is still in
|
||||
// progress when the context is cancelled.
|
||||
body := make([]byte, 2000)
|
||||
for i := range body {
|
||||
body[i] = 'A'
|
||||
}
|
||||
go func() {
|
||||
conn, _ := listener.Accept()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
r.ReadString('\n')
|
||||
conn.Write([]byte("20 application/octet-stream\r\n"))
|
||||
// Send 500 bytes per chunk with 100ms delays.
|
||||
for off := 0; off < len(body); off += 500 {
|
||||
end := off + 500
|
||||
if end > len(body) {
|
||||
end = len(body)
|
||||
}
|
||||
conn.Write(body[off:end])
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
tmp := t.TempDir()
|
||||
outPath := tmp + "/partial.bin"
|
||||
|
||||
proto := NewProtocol()
|
||||
proto.TLSInsecure = true
|
||||
u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// progress is signalled every time the download loop calls
|
||||
// req.ProgressCallback — which happens *after* a successful
|
||||
// writer.Write. Reading from it is therefore a deterministic
|
||||
// barrier waiting for the first byte to land on disk.
|
||||
progress := make(chan int64, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := proto.Download(ctx, &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outPath,
|
||||
ProgressCallback: func(current, total int64, speed float64) {
|
||||
select {
|
||||
case progress <- current:
|
||||
default:
|
||||
}
|
||||
},
|
||||
})
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
// Wait until the first byte has been written to disk, then cancel.
|
||||
// No fixed sleep — this is robust against slow CI schedulers.
|
||||
<-progress
|
||||
cancel()
|
||||
|
||||
err = <-errCh
|
||||
if err == nil {
|
||||
t.Fatal("expected error from cancelled context")
|
||||
}
|
||||
|
||||
// Verify resume metadata was saved.
|
||||
metaPath := outPath + output.ResumeMetaFileSuffix
|
||||
if _, err := os.Stat(metaPath); os.IsNotExist(err) {
|
||||
t.Fatal("expected .goget.meta sidecar to exist")
|
||||
}
|
||||
|
||||
meta, err := output.LoadResumeMetadata(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadResumeMetadata: %v", err)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatal("expected resume metadata, got nil")
|
||||
}
|
||||
if meta.Downloaded <= 0 {
|
||||
t.Errorf("Downloaded = %d, want > 0", meta.Downloaded)
|
||||
}
|
||||
if meta.URL != u.String() {
|
||||
t.Errorf("URL = %q, want %q", meta.URL, u.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeminiResumeDownload verifies end-to-end resume: a first download is
|
||||
// interrupted, and the second download picks up from the saved offset.
|
||||
func TestGeminiResumeDownload(t *testing.T) {
|
||||
cfg := testTLSConfig()
|
||||
listener, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
fullBody := make([]byte, 1000)
|
||||
for i := range fullBody {
|
||||
fullBody[i] = byte('A' + i%26)
|
||||
}
|
||||
cancelReady := make(chan struct{}) // client signals it has cancelled
|
||||
connReady := make(chan struct{}) // server signals connection is done
|
||||
go func() {
|
||||
for i := 0; i < 2; i++ {
|
||||
conn, lerr := listener.Accept()
|
||||
if lerr != nil {
|
||||
return
|
||||
}
|
||||
r := bufio.NewReader(conn)
|
||||
r.ReadString('\n')
|
||||
conn.Write([]byte("20 application/octet-stream\r\n"))
|
||||
if i == 0 {
|
||||
// First connection: send 500 bytes, then wait
|
||||
// for the client to cancel before closing.
|
||||
conn.Write(fullBody[:500])
|
||||
<-cancelReady
|
||||
conn.Close()
|
||||
connReady <- struct{}{}
|
||||
} else {
|
||||
// Second connection: send the full body.
|
||||
conn.Write(fullBody)
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
tmp := t.TempDir()
|
||||
outPath := tmp + "/resume.bin"
|
||||
|
||||
proto := NewProtocol()
|
||||
proto.TLSInsecure = true
|
||||
u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/data")
|
||||
|
||||
// First attempt: the server sends 500 bytes and blocks. We cancel
|
||||
// the context, then the server closes the connection. progress
|
||||
// signals after the first writer.Write so waiting on it replaces
|
||||
// the previous fixed sleep with a deterministic barrier.
|
||||
ctx1, cancel1 := context.WithCancel(context.Background())
|
||||
progress := make(chan int64, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, dlErr := proto.Download(ctx1, &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outPath,
|
||||
ProgressCallback: func(current, total int64, speed float64) {
|
||||
select {
|
||||
case progress <- current:
|
||||
default:
|
||||
}
|
||||
},
|
||||
})
|
||||
errCh <- dlErr
|
||||
}()
|
||||
<-progress
|
||||
cancel1()
|
||||
cancelReady <- struct{}{} // let server close connection
|
||||
<-connReady
|
||||
dlErr := <-errCh
|
||||
if dlErr == nil {
|
||||
t.Fatal("expected error from cancelled context")
|
||||
}
|
||||
|
||||
// Verify partial file and metadata exist.
|
||||
info, statErr := os.Stat(outPath)
|
||||
if statErr != nil {
|
||||
t.Fatalf("partial file missing: %v", statErr)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatal("partial file is empty")
|
||||
}
|
||||
|
||||
meta, metaErr := output.LoadResumeMetadata(outPath)
|
||||
if metaErr != nil || meta == nil {
|
||||
t.Fatalf("expected resume metadata: err=%v meta=%v", metaErr, meta)
|
||||
}
|
||||
|
||||
// Second attempt: resume.
|
||||
result, resErr := proto.Download(context.Background(), &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outPath,
|
||||
Resume: true,
|
||||
})
|
||||
if resErr != nil {
|
||||
t.Fatalf("resume download failed: %v", resErr)
|
||||
}
|
||||
if !result.Resumed {
|
||||
t.Error("expected Resumed=true")
|
||||
}
|
||||
|
||||
// The file should now contain the full body.
|
||||
data, readErr := os.ReadFile(outPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read output: %v", readErr)
|
||||
}
|
||||
if string(data) != string(fullBody) {
|
||||
t.Errorf("file content length = %d, want %d", len(data), len(fullBody))
|
||||
}
|
||||
|
||||
// Resume metadata should be deleted after successful completion.
|
||||
if output.FileExists(outPath + output.ResumeMetaFileSuffix) {
|
||||
t.Error("expected .goget.meta to be deleted after successful resume")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveGeminiResumeMetadata verifies the helper function directly.
|
||||
func TestSaveGeminiResumeMetadata(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
out := tmp + "/file.bin"
|
||||
|
||||
u, err := url.Parse("gemini://example.com/file.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("parse URL: %v", err)
|
||||
}
|
||||
req := &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: out,
|
||||
}
|
||||
|
||||
saveGeminiResume(req, 4096)
|
||||
|
||||
meta, err := output.LoadResumeMetadata(out)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadResumeMetadata: %v", err)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatal("expected resume metadata, got nil")
|
||||
}
|
||||
if meta.Downloaded != 4096 {
|
||||
t.Errorf("Downloaded = %d, want 4096", meta.Downloaded)
|
||||
}
|
||||
if meta.URL != "gemini://example.com/file.bin" {
|
||||
t.Errorf("URL = %q, want %q", meta.URL, "gemini://example.com/file.bin")
|
||||
}
|
||||
// Gemini doesn't expose Content-Length, so Total should be 0.
|
||||
if meta.Total != 0 {
|
||||
t.Errorf("Total = %d, want 0", meta.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveGeminiResumeNoop verifies that the helper is a no-op for
|
||||
// stdout writes, empty output, and zero-byte transfers.
|
||||
func TestSaveGeminiResumeNoop(t *testing.T) {
|
||||
u, _ := url.Parse("gemini://example.com/")
|
||||
|
||||
t.Run("stdout", func(t *testing.T) {
|
||||
saveGeminiResume(&core.DownloadRequest{URL: u, Output: "-"}, 100)
|
||||
// Should not panic or create any file.
|
||||
})
|
||||
|
||||
t.Run("empty output", func(t *testing.T) {
|
||||
saveGeminiResume(&core.DownloadRequest{URL: u, Output: ""}, 100)
|
||||
})
|
||||
|
||||
t.Run("zero bytes", func(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
saveGeminiResume(&core.DownloadRequest{URL: u, Output: tmp + "/f"}, 0)
|
||||
if output.FileExists(tmp + "/f" + output.ResumeMetaFileSuffix) {
|
||||
t.Error("metadata created for zero-byte transfer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil request", func(t *testing.T) {
|
||||
saveGeminiResume(nil, 100)
|
||||
// Should not panic.
|
||||
})
|
||||
}
|
||||
|
||||
// TestGeminiCancelGoroutineExits verifies that the ctx-cancellation
|
||||
// goroutine spawned by Gemini downloadWithRedirects does not outlive
|
||||
// Download itself. The goroutine used to wait forever on <-ctx.Done()
|
||||
// when the parent context was never cancelled; the done-channel bound
|
||||
// must let every iteration return to the runtime goroutine pool.
|
||||
func TestGeminiCancelGoroutineExits(t *testing.T) {
|
||||
cfg := testTLSConfig()
|
||||
listener, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, lerr := listener.Accept()
|
||||
if lerr != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
r := bufio.NewReader(c)
|
||||
r.ReadString('\n')
|
||||
// Brief response that Gemini Download can chew
|
||||
// through quickly, spawning the cancellation
|
||||
// goroutine and returning. The goroutine must
|
||||
// exit via the done channel on return.
|
||||
c.Write([]byte("20 application/octet-stream\r\n"))
|
||||
c.Write([]byte("hi"))
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
// Warm up to settle the runtime goroutine pool (timer, GC, etc.).
|
||||
proto := NewProtocol()
|
||||
proto.TLSInsecure = true
|
||||
u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/warmup")
|
||||
if _, dlErr := proto.Download(context.Background(), &core.DownloadRequest{
|
||||
URL: u,
|
||||
Writer: &stringWriter{&strings.Builder{}},
|
||||
}); dlErr != nil {
|
||||
t.Fatalf("warmup: %v", dlErr)
|
||||
}
|
||||
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
runtime.GC()
|
||||
baseline := runtime.NumGoroutine()
|
||||
|
||||
const iterations = 50
|
||||
for i := 0; i < iterations; i++ {
|
||||
u, _ := url.Parse("gemini://127.0.0.1:" + strconv.Itoa(port) + "/x")
|
||||
if _, dlErr := proto.Download(context.Background(), &core.DownloadRequest{
|
||||
URL: u,
|
||||
Writer: &stringWriter{&strings.Builder{}},
|
||||
}); dlErr != nil {
|
||||
t.Fatalf("download %d: %v", i, dlErr)
|
||||
}
|
||||
}
|
||||
|
||||
// After every successful Download returns, its cancellation
|
||||
// goroutine must have exited. Allow a brief settle window for
|
||||
// the runtime to reclaim them.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
// One tolerance for any incidental stdlib goroutine that
|
||||
// the Go runtime occasionally spins up between calls.
|
||||
if runtime.NumGoroutine() <= baseline+1 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("goroutine leak: baseline=%d, after %d downloads = %d",
|
||||
baseline, iterations, runtime.NumGoroutine())
|
||||
}
|
||||
|
||||
@@ -9,10 +9,13 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
"codeberg.org/petrbalvin/goget/internal/output"
|
||||
"codeberg.org/petrbalvin/goget/internal/protocol"
|
||||
)
|
||||
|
||||
@@ -31,6 +34,12 @@ const (
|
||||
typeSearch = '7' // index search
|
||||
)
|
||||
|
||||
// dialTimeout caps the time spent establishing a TCP connection and
|
||||
// waiting for the first response byte. Generous enough for slow Gopher
|
||||
// servers, short enough that a stalled connection does not block the
|
||||
// caller indefinitely.
|
||||
const dialTimeout = 30 * time.Second
|
||||
|
||||
// Protocol implements gopher:// downloads (RFC 1436).
|
||||
type Protocol struct {
|
||||
*protocol.BaseProtocol
|
||||
@@ -43,7 +52,7 @@ func NewProtocol() *Protocol {
|
||||
Name: "GOPHER",
|
||||
Scheme: "gopher",
|
||||
DefaultPort: 70,
|
||||
Features: []string{},
|
||||
Features: []string{"resume"},
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -65,12 +74,28 @@ func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*co
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
conn, err := net.DialTimeout("tcp", addr, 30*time.Second)
|
||||
conn, err := net.DialTimeout("tcp", addr, dialTimeout)
|
||||
if err != nil {
|
||||
return nil, core.NewNetworkError("failed to connect to gopher server", err, core.SafeURL(req.URL))
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
conn.SetDeadline(time.Now().Add(dialTimeout))
|
||||
|
||||
// Close the connection when the context is cancelled so any
|
||||
// blocking Read returns immediately instead of waiting for the
|
||||
// dial deadline. The done channel bounds the goroutine to the
|
||||
// lifetime of Download: the goroutine exits either when the
|
||||
// context fires (we close the conn) or when Download returns
|
||||
// successfully and defer close(done) signals shutdown.
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
conn.Close()
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
|
||||
// Send selector + CRLF
|
||||
if _, err := fmt.Fprintf(conn, "%s\r\n", selector); err != nil {
|
||||
@@ -138,20 +163,90 @@ func isTextGopherType(t byte) bool {
|
||||
}
|
||||
|
||||
// downloadText handles text responses where each line has a type prefix.
|
||||
// Supports resume for typeHTML and typeTextFile responses: when interrupted,
|
||||
// writes a `.goget.meta` sidecar; on resume with valid metadata, skips
|
||||
// already-written output bytes by length. Gopher does not expose
|
||||
// Content-Length, so the resume metadata records only the bytes
|
||||
// actually delivered to the writer.
|
||||
func (p *Protocol) downloadText(ctx context.Context, reader *bufio.Reader, req *core.DownloadRequest, startTime time.Time, firstType byte) (*core.DownloadResult, error) {
|
||||
var writer io.Writer
|
||||
var file *os.File
|
||||
resumed := false
|
||||
var startOffset int64
|
||||
|
||||
// Set up output destination. When req.Output is set and no external
|
||||
// Writer was provided, we open the file directly. This covers both
|
||||
// fresh downloads (create) and resume (append). When req.Writer is
|
||||
// set (caller manages the file), we use it as-is.
|
||||
if req.Output != "" && req.Output != "-" {
|
||||
// Resume is meaningful only when we own the output file. If the
|
||||
// caller already supplied a Writer we cannot append to Output
|
||||
// behind their back, so disable resume to keep the partial-state
|
||||
// sidecar consistent with what actually lands on disk.
|
||||
if req.Resume && req.Writer != nil && req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[gopher] warning: --resume ignored because a Writer is set; output goes to the Writer only\n")
|
||||
}
|
||||
if req.Resume && req.Writer != nil {
|
||||
req.Resume = false
|
||||
}
|
||||
if req.Resume {
|
||||
canResume, resumeMeta, rerr := output.CanResume(req.Output, req.URL.String())
|
||||
if rerr != nil {
|
||||
if req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[gopher] Resume check failed: %v\n", rerr)
|
||||
}
|
||||
output.CleanupResumeFiles(req.Output)
|
||||
} else if canResume && resumeMeta != nil {
|
||||
file, rerr = os.OpenFile(req.Output, os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if rerr != nil {
|
||||
return nil, core.NewFileError("failed to open file for resume", rerr)
|
||||
}
|
||||
defer file.Close()
|
||||
writer = file
|
||||
startOffset = resumeMeta.Downloaded
|
||||
resumed = true
|
||||
if req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[gopher] Resuming from byte %d\n", startOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
if writer == nil && req.Writer == nil {
|
||||
// Fresh download — create the output file.
|
||||
dir := filepath.Dir(req.Output)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, core.NewFileError("failed to create output directory", err)
|
||||
}
|
||||
}
|
||||
f, err := os.Create(req.Output)
|
||||
if err != nil {
|
||||
return nil, core.NewFileError("failed to create output file", err)
|
||||
}
|
||||
file = f
|
||||
defer file.Close()
|
||||
writer = file
|
||||
}
|
||||
}
|
||||
if writer == nil {
|
||||
if req.Writer != nil {
|
||||
writer = req.Writer
|
||||
} else {
|
||||
writer = io.Discard
|
||||
}
|
||||
}
|
||||
|
||||
var totalRead int64
|
||||
lineCount := 0
|
||||
// bytesWritten tracks output payload actually delivered to the writer
|
||||
// (after the per-line metadata strip). On resume we skip ahead by
|
||||
// startOffset bytes of this post-transform stream so the file on
|
||||
// disk ends up as the concatenation of the previous run and the
|
||||
// new one — same convention as Gemini.
|
||||
var bytesWritten int64
|
||||
var skipped int64
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
saveGopherResume(req, bytesWritten)
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
@@ -159,8 +254,18 @@ func (p *Protocol) downloadText(ctx context.Context, reader *bufio.Reader, req *
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
// The connection may have been closed by our context
|
||||
// cancellation goroutine (or by the server after the
|
||||
// client cancelled). If so, the EOF is not a genuine
|
||||
// end-of-data but a side-effect of the cancel and we
|
||||
// must persist the partial state before bailing.
|
||||
if ctx.Err() != nil {
|
||||
saveGopherResume(req, bytesWritten)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
break
|
||||
}
|
||||
saveGopherResume(req, bytesWritten)
|
||||
return nil, core.NewNetworkError("failed to read gopher line", err, core.SafeURL(req.URL))
|
||||
}
|
||||
|
||||
@@ -172,46 +277,95 @@ func (p *Protocol) downloadText(ctx context.Context, reader *bufio.Reader, req *
|
||||
// Strip item-type prefix if present (first char = type, second char is usually tab/space)
|
||||
displayLine := line
|
||||
if len(line) > 1 && line[1] != '.' {
|
||||
// Full menu line: type + display string + tab + selector + tab + host + tab + port
|
||||
if true {
|
||||
// For text downloads, strip the type and metadata, show only display string + \n
|
||||
clean := stripGopherMeta(line)
|
||||
displayLine = clean + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// For informational lines (type i), don't strip anything — just pass through
|
||||
if firstType == typeInformation {
|
||||
displayLine = line
|
||||
}
|
||||
|
||||
n, err := writer.Write([]byte(displayLine))
|
||||
if err != nil {
|
||||
return nil, core.NewFileError("failed to write gopher text", err)
|
||||
// On resume, drop the first startOffset bytes of the
|
||||
// post-transform output so the file appends cleanly.
|
||||
payload := []byte(displayLine)
|
||||
if skipped < startOffset {
|
||||
remain := startOffset - skipped
|
||||
if int64(len(payload)) <= remain {
|
||||
skipped += int64(len(payload))
|
||||
payload = nil
|
||||
} else {
|
||||
skipped += remain
|
||||
payload = payload[int(remain):]
|
||||
}
|
||||
}
|
||||
|
||||
if len(payload) > 0 {
|
||||
n, werr := writer.Write(payload)
|
||||
if werr != nil {
|
||||
saveGopherResume(req, bytesWritten)
|
||||
return nil, core.NewFileError("failed to write gopher text", werr)
|
||||
}
|
||||
bytesWritten += int64(n)
|
||||
}
|
||||
totalRead += int64(n)
|
||||
lineCount++
|
||||
|
||||
if req.ProgressCallback != nil {
|
||||
speed := float64(totalRead) / time.Since(startTime).Seconds()
|
||||
req.ProgressCallback(totalRead, -1, speed)
|
||||
speed := float64(bytesWritten) / time.Since(startTime).Seconds()
|
||||
req.ProgressCallback(bytesWritten, -1, speed)
|
||||
}
|
||||
}
|
||||
|
||||
// Download complete — remove resume metadata sidecar.
|
||||
if req.Output != "" && req.Output != "-" {
|
||||
output.DeleteResumeMetadata(req.Output)
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
speed := float64(totalRead) / duration.Seconds()
|
||||
speed := float64(0)
|
||||
if duration.Seconds() > 0 {
|
||||
speed = float64(bytesWritten) / duration.Seconds()
|
||||
}
|
||||
|
||||
return &core.DownloadResult{
|
||||
BytesDownloaded: totalRead,
|
||||
TotalSize: totalRead,
|
||||
BytesDownloaded: bytesWritten,
|
||||
TotalSize: bytesWritten,
|
||||
Duration: duration,
|
||||
Protocol: "GOPHER",
|
||||
IPVersion: 4,
|
||||
Speed: speed,
|
||||
OutputPath: req.Output,
|
||||
ContentType: gopherContentType(firstType),
|
||||
Resumed: resumed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// saveGopherResume persists partial Gopher text download progress to the
|
||||
// standard `.goget.meta` sidecar. No-op for stdout writes, nil requests,
|
||||
// or zero-byte transfers. Gopher does not expose Content-Length, so the
|
||||
// total field stays 0; the resume offset is taken from the number of
|
||||
// bytes already delivered to the writer.
|
||||
func saveGopherResume(req *core.DownloadRequest, downloaded int64) {
|
||||
if req == nil || req.Output == "" || req.Output == "-" || downloaded <= 0 {
|
||||
return
|
||||
}
|
||||
meta := output.NewResumeMetadata(req.URL.String(), "", "", downloaded, 0)
|
||||
if err := meta.Save(req.Output); err != nil && req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[gopher] Warning: failed to save resume metadata: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// gopherContentType returns the MIME content type for a gopher item type.
|
||||
func gopherContentType(t byte) string {
|
||||
switch t {
|
||||
case typeHTML:
|
||||
return "text/html"
|
||||
case typeTextFile, typeInformation:
|
||||
return "text/plain"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// downloadDirectory formats a directory listing as readable text.
|
||||
func (p *Protocol) downloadDirectory(reader *bufio.Reader, req *core.DownloadRequest, startTime time.Time) (*core.DownloadResult, error) {
|
||||
var writer io.Writer
|
||||
@@ -261,6 +415,7 @@ func (p *Protocol) downloadDirectory(reader *bufio.Reader, req *core.DownloadReq
|
||||
IPVersion: 4,
|
||||
Speed: speed,
|
||||
OutputPath: req.Output,
|
||||
ContentType: "text/plain",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -313,6 +468,7 @@ func (p *Protocol) downloadBinary(ctx context.Context, reader *bufio.Reader, req
|
||||
IPVersion: 4,
|
||||
Speed: speed,
|
||||
OutputPath: req.Output,
|
||||
ContentType: "application/octet-stream",
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,18 @@ package gopher
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
"codeberg.org/petrbalvin/goget/internal/output"
|
||||
)
|
||||
|
||||
func TestFormatGopherMenu(t *testing.T) {
|
||||
@@ -108,3 +113,561 @@ func TestGopherDownloadMock(t *testing.T) {
|
||||
t.Errorf("output missing text: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGopherResumeSaveMetadata verifies that interrupting a Gopher text
|
||||
// download persists a .goget.meta sidecar describing the partial state.
|
||||
func TestGopherResumeSaveMetadata(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
conn, _ := listener.Accept()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
reader := bufio.NewReader(conn)
|
||||
reader.ReadString('\n')
|
||||
// Send the first display line, sleep so the client has a chance
|
||||
// to land in the read loop and receive the cancellation, then
|
||||
// send the second line and the terminator.
|
||||
conn.Write([]byte("hFirst\t/first\thost\t70\r\n"))
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
conn.Write([]byte("hSecond\t/second\thost\t70\r\n"))
|
||||
conn.Write([]byte(".\r\n"))
|
||||
}()
|
||||
|
||||
tmp := t.TempDir()
|
||||
outPath := tmp + "/page.html"
|
||||
|
||||
proto := NewProtocol()
|
||||
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/page")
|
||||
|
||||
// progress is signalled every time the download loop calls
|
||||
// req.ProgressCallback — which happens *after* a successful
|
||||
// writer.Write. Reading from it is therefore a deterministic
|
||||
// barrier waiting for the first byte to land on disk.
|
||||
progress := make(chan int64, 1)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, dlErr := proto.Download(ctx, &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outPath,
|
||||
ProgressCallback: func(current, total int64, speed float64) {
|
||||
select {
|
||||
case progress <- current:
|
||||
default:
|
||||
}
|
||||
},
|
||||
})
|
||||
errCh <- dlErr
|
||||
}()
|
||||
|
||||
// Wait until the first byte has been written to disk, then cancel.
|
||||
// No fixed sleep — this is robust against slow CI schedulers.
|
||||
<-progress
|
||||
cancel()
|
||||
|
||||
dlErr := <-errCh
|
||||
if dlErr == nil {
|
||||
t.Fatal("expected error from cancelled context")
|
||||
}
|
||||
|
||||
metaPath := outPath + output.ResumeMetaFileSuffix
|
||||
if _, statErr := os.Stat(metaPath); statErr != nil {
|
||||
t.Fatalf("expected .goget.meta sidecar to exist: %v", statErr)
|
||||
}
|
||||
|
||||
meta, metaErr := output.LoadResumeMetadata(outPath)
|
||||
if metaErr != nil {
|
||||
t.Fatalf("LoadResumeMetadata: %v", metaErr)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatal("expected resume metadata, got nil")
|
||||
}
|
||||
if meta.Downloaded <= 0 {
|
||||
t.Errorf("Downloaded = %d, want > 0", meta.Downloaded)
|
||||
}
|
||||
if meta.URL != u.String() {
|
||||
t.Errorf("URL = %q, want %q", meta.URL, u.String())
|
||||
}
|
||||
// Gopher does not expose Content-Length, so Total stays 0.
|
||||
if meta.Total != 0 {
|
||||
t.Errorf("Total = %d, want 0", meta.Total)
|
||||
}
|
||||
// Sanity-check: the saved Downloaded should match what's on disk.
|
||||
info, statErr := os.Stat(outPath)
|
||||
if statErr != nil {
|
||||
t.Fatalf("partial file missing: %v", statErr)
|
||||
}
|
||||
if info.Size() != meta.Downloaded {
|
||||
t.Errorf("file size = %d, metadata.Downloaded = %d (must match)",
|
||||
info.Size(), meta.Downloaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGopherResumeDownload verifies end-to-end resume: an interrupted
|
||||
// download completes on the second attempt with --resume=true, and the
|
||||
// metadata sidecar is removed after the successful completion.
|
||||
func TestGopherResumeDownload(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
cancelReady := make(chan struct{})
|
||||
connReady := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < 2; i++ {
|
||||
conn, lerr := listener.Accept()
|
||||
if lerr != nil {
|
||||
return
|
||||
}
|
||||
r := bufio.NewReader(conn)
|
||||
r.ReadString('\n')
|
||||
if i == 0 {
|
||||
// First attempt: send the first line, then wait
|
||||
// for the client to cancel before closing.
|
||||
conn.Write([]byte("0Line one\t/foo\thost\t70\r\n"))
|
||||
<-cancelReady
|
||||
conn.Close()
|
||||
connReady <- struct{}{}
|
||||
} else {
|
||||
// Second attempt: send the full body.
|
||||
conn.Write([]byte("0Line one\t/foo\thost\t70\r\n"))
|
||||
conn.Write([]byte("0Line two\t/bar\thost\t70\r\n"))
|
||||
conn.Write([]byte(".\r\n"))
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
tmp := t.TempDir()
|
||||
outPath := tmp + "/resume.txt"
|
||||
|
||||
proto := NewProtocol()
|
||||
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/foo")
|
||||
|
||||
// First attempt: send only the first line, then cancel.
|
||||
// progress signals after the first writer.Write, so waiting on
|
||||
// it replaces the previous fixed sleep with a deterministic barrier.
|
||||
progress := make(chan int64, 1)
|
||||
ctx1, cancel1 := context.WithCancel(context.Background())
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, dlErr := proto.Download(ctx1, &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outPath,
|
||||
ProgressCallback: func(current, total int64, speed float64) {
|
||||
select {
|
||||
case progress <- current:
|
||||
default:
|
||||
}
|
||||
},
|
||||
})
|
||||
errCh <- dlErr
|
||||
}()
|
||||
<-progress
|
||||
cancel1()
|
||||
cancelReady <- struct{}{}
|
||||
<-connReady
|
||||
dlErr := <-errCh
|
||||
if dlErr == nil {
|
||||
t.Fatal("expected error from cancelled context")
|
||||
}
|
||||
|
||||
// Sanity-check: partial file + metadata sidecar present.
|
||||
info, statErr := os.Stat(outPath)
|
||||
if statErr != nil {
|
||||
t.Fatalf("partial file missing: %v", statErr)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatal("partial file is empty")
|
||||
}
|
||||
meta, metaErr := output.LoadResumeMetadata(outPath)
|
||||
if metaErr != nil || meta == nil {
|
||||
t.Fatalf("expected resume metadata: err=%v meta=%v", metaErr, meta)
|
||||
}
|
||||
|
||||
// Second attempt: resume should pick up where we left off.
|
||||
result, resErr := proto.Download(context.Background(), &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outPath,
|
||||
Resume: true,
|
||||
})
|
||||
if resErr != nil {
|
||||
t.Fatalf("resume download failed: %v", resErr)
|
||||
}
|
||||
if !result.Resumed {
|
||||
t.Error("expected Resumed=true")
|
||||
}
|
||||
|
||||
// The file should contain the full body, with the resume logic
|
||||
// skipping already-written bytes via the post-transform offset.
|
||||
data, readErr := os.ReadFile(outPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read output: %v", readErr)
|
||||
}
|
||||
want := "Line one\nLine two\n"
|
||||
if string(data) != want {
|
||||
t.Errorf("file content = %q, want %q", string(data), want)
|
||||
}
|
||||
|
||||
// Metadata sidecar should be deleted after a successful completion.
|
||||
if output.FileExists(outPath + output.ResumeMetaFileSuffix) {
|
||||
t.Error("expected .goget.meta to be deleted after successful resume")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveGopherResumeMetadata exercises the helper directly.
|
||||
func TestSaveGopherResumeMetadata(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
out := tmp + "/file.txt"
|
||||
|
||||
u, err := url.Parse("gopher://example.com/foo.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("parse URL: %v", err)
|
||||
}
|
||||
req := &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: out,
|
||||
}
|
||||
|
||||
saveGopherResume(req, 4096)
|
||||
|
||||
meta, err := output.LoadResumeMetadata(out)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadResumeMetadata: %v", err)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatal("expected resume metadata, got nil")
|
||||
}
|
||||
if meta.Downloaded != 4096 {
|
||||
t.Errorf("Downloaded = %d, want 4096", meta.Downloaded)
|
||||
}
|
||||
if meta.URL != "gopher://example.com/foo.txt" {
|
||||
t.Errorf("URL = %q, want %q", meta.URL, "gopher://example.com/foo.txt")
|
||||
}
|
||||
if meta.Total != 0 {
|
||||
t.Errorf("Total = %d, want 0", meta.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveGopherResumeNoop verifies that the helper is a no-op for
|
||||
// stdout writes, empty output paths, zero-byte transfers, and nil
|
||||
// requests.
|
||||
func TestSaveGopherResumeNoop(t *testing.T) {
|
||||
u, _ := url.Parse("gopher://example.com/")
|
||||
|
||||
t.Run("stdout", func(t *testing.T) {
|
||||
saveGopherResume(&core.DownloadRequest{URL: u, Output: "-"}, 100)
|
||||
})
|
||||
|
||||
t.Run("empty output", func(t *testing.T) {
|
||||
saveGopherResume(&core.DownloadRequest{URL: u, Output: ""}, 100)
|
||||
})
|
||||
|
||||
t.Run("zero bytes", func(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
saveGopherResume(&core.DownloadRequest{URL: u, Output: tmp + "/f"}, 0)
|
||||
if output.FileExists(tmp + "/f" + output.ResumeMetaFileSuffix) {
|
||||
t.Error("metadata created for zero-byte transfer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil request", func(t *testing.T) {
|
||||
saveGopherResume(nil, 100)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGopherResumeStaleMetadata exercises the path where the .goget.meta
|
||||
// sidecar exists but the partial file doesn't (or has been truncated),
|
||||
// forcing the resume code to clean up and start fresh.
|
||||
func TestGopherResumeStaleMetadata(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
outPath := tmp + "/stale.txt"
|
||||
|
||||
// Pre-create an orphan .goget.meta for a URL we will not use.
|
||||
orphanMeta := output.NewResumeMetadata("gopher://other.example/foo", "", "", 1234, 0)
|
||||
if err := orphanMeta.Save(outPath); err != nil {
|
||||
t.Fatalf("seed meta: %v", err)
|
||||
}
|
||||
// Do not pre-create the partial file; CanResume requires file size
|
||||
// to match metadata Downloaded.
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
conn, _ := listener.Accept()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
r.ReadString('\n')
|
||||
conn.Write([]byte("0Fresh\t/fresh\thost\t70\r\n.\r\n"))
|
||||
}()
|
||||
|
||||
proto := NewProtocol()
|
||||
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/fresh")
|
||||
|
||||
res, err := proto.Download(context.Background(), &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outPath,
|
||||
Resume: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
if res.Resumed {
|
||||
t.Error("expected Resumed=false after stale-metadata cleanup")
|
||||
}
|
||||
data, _ := os.ReadFile(outPath)
|
||||
if string(data) != "Fresh\n" {
|
||||
t.Errorf("content = %q, want %q", string(data), "Fresh\n")
|
||||
}
|
||||
// The orphan sidecar from a different URL must be removed once the
|
||||
// successful fresh download completes — otherwise stale metadata
|
||||
// could be picked up by a future --resume run.
|
||||
if output.FileExists(outPath + output.ResumeMetaFileSuffix) {
|
||||
t.Error("expected orphan .goget.meta to be deleted after fresh download")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGopherResumeIgnoredWhenWriterSet verifies that --resume is silently
|
||||
// disabled when the caller also supplies a Writer: the resume metadata
|
||||
// sidecar would otherwise lie about bytes written to Output, since the
|
||||
// real data ends up in the Writer.
|
||||
func TestGopherResumeIgnoredWhenWriterSet(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
conn, _ := listener.Accept()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
r.ReadString('\n')
|
||||
conn.Write([]byte("0Line one\t/foo\thost\t70\r\n.\r\n"))
|
||||
}()
|
||||
|
||||
proto := NewProtocol()
|
||||
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/foo")
|
||||
outPath := t.TempDir() + "/ignored-output.txt"
|
||||
|
||||
var buf strings.Builder
|
||||
res, dlErr := proto.Download(context.Background(), &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outPath,
|
||||
Writer: &stringWriter{&buf},
|
||||
Resume: true,
|
||||
})
|
||||
if dlErr != nil {
|
||||
t.Fatalf("download: %v", dlErr)
|
||||
}
|
||||
if res.Resumed {
|
||||
t.Error("expected Resumed=false when Writer is set (resume must be ignored)")
|
||||
}
|
||||
if !strings.Contains(buf.String(), "Line one") {
|
||||
t.Errorf("writer should receive the body, got %q", buf.String())
|
||||
}
|
||||
// Output is supplied but a Writer takes precedence; the file must
|
||||
// not be created or touched by resume machinery.
|
||||
if _, statErr := os.Stat(outPath); statErr == nil {
|
||||
t.Error("Output file should not be created when Writer is set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGopherResumeInformation verifies that resume machinery works for the
|
||||
// informational (type i) response path, which is also routed through
|
||||
// downloadText. Informational lines are passed through verbatim (no
|
||||
// metadata strip), so the resume offset matches the raw line length
|
||||
// including trailing CR/LF.
|
||||
func TestGopherResumeInformation(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
cancelReady := make(chan struct{})
|
||||
connReady := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < 2; i++ {
|
||||
conn, lerr := listener.Accept()
|
||||
if lerr != nil {
|
||||
return
|
||||
}
|
||||
r := bufio.NewReader(conn)
|
||||
r.ReadString('\n')
|
||||
if i == 0 {
|
||||
// First half: send two info lines, then wait
|
||||
// for the client to cancel before closing.
|
||||
conn.Write([]byte("iFirst line.\r\n"))
|
||||
conn.Write([]byte("iSecond line.\r\n"))
|
||||
<-cancelReady
|
||||
conn.Close()
|
||||
connReady <- struct{}{}
|
||||
} else {
|
||||
// Second half: full body.
|
||||
conn.Write([]byte("iFirst line.\r\n"))
|
||||
conn.Write([]byte("iSecond line.\r\n"))
|
||||
conn.Write([]byte(".\r\n"))
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
tmp := t.TempDir()
|
||||
outPath := tmp + "/info.txt"
|
||||
|
||||
proto := NewProtocol()
|
||||
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/info")
|
||||
|
||||
progress := make(chan int64, 1)
|
||||
ctx1, cancel1 := context.WithCancel(context.Background())
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, dlErr := proto.Download(ctx1, &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outPath,
|
||||
ProgressCallback: func(current, total int64, speed float64) {
|
||||
select {
|
||||
case progress <- current:
|
||||
default:
|
||||
}
|
||||
},
|
||||
})
|
||||
errCh <- dlErr
|
||||
}()
|
||||
<-progress
|
||||
cancel1()
|
||||
cancelReady <- struct{}{}
|
||||
<-connReady
|
||||
if dlErr := <-errCh; dlErr == nil {
|
||||
t.Fatal("expected error from cancelled context")
|
||||
}
|
||||
|
||||
// Resume should append the missing bytes. For informational lines
|
||||
// we do not strip metadata, so the output preserves the server CR/LF.
|
||||
result, resErr := proto.Download(context.Background(), &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outPath,
|
||||
Resume: true,
|
||||
})
|
||||
if resErr != nil {
|
||||
t.Fatalf("resume download failed: %v", resErr)
|
||||
}
|
||||
if !result.Resumed {
|
||||
t.Error("expected Resumed=true")
|
||||
}
|
||||
if result.ContentType != "text/plain" {
|
||||
t.Errorf("ContentType = %q, want text/plain", result.ContentType)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(outPath)
|
||||
// Informational lines are passed through verbatim: the 'i' item-type
|
||||
// prefix and CR/LF framing from the server survive into the output.
|
||||
want := "iFirst line.\r\niSecond line.\r\n"
|
||||
if string(data) != want {
|
||||
t.Errorf("content = %q, want %q", string(data), want)
|
||||
}
|
||||
if output.FileExists(outPath + output.ResumeMetaFileSuffix) {
|
||||
t.Error("expected .goget.meta to be deleted after successful resume")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGopherCancelGoroutineExits verifies that the ctx-cancellation
|
||||
// goroutine spawned by Download does not outlive Download itself.
|
||||
// The goroutine used to wait forever on <-ctx.Done() when the parent
|
||||
// context was never cancelled; the done-channel bound added with M3
|
||||
// must let every iteration return to the runtime goroutine pool.
|
||||
func TestGopherCancelGoroutineExits(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
// Drain connections so the listener does not backpressure
|
||||
// the test loop. We do not read or write — a quick accept
|
||||
// and close is enough for Download to complete.
|
||||
for {
|
||||
conn, lerr := listener.Accept()
|
||||
if lerr != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
// Reply with the response header so the client
|
||||
// reaches downloadText and spawns its goroutine.
|
||||
defer c.Close()
|
||||
fmt.Fprintf(c, "0hi\t/x\th\t70\r\n.\r\n")
|
||||
// Block until the peer closes — this lets the
|
||||
// client-side goroutine sit on ReadString until
|
||||
// it's raced by done, exercising the bound.
|
||||
buf := make([]byte, 1)
|
||||
c.Read(buf)
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
// Warm up: a single download is enough to settle the runtime
|
||||
// goroutine pool (timer, GC, poll, etc.) before we measure.
|
||||
proto := NewProtocol()
|
||||
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/x")
|
||||
req := core.DownloadRequest{URL: u, Writer: &stringWriter{&strings.Builder{}}}
|
||||
if _, dlErr := proto.Download(context.Background(), &req); dlErr != nil {
|
||||
t.Fatalf("warmup: %v", dlErr)
|
||||
}
|
||||
|
||||
// Settle baseline after warmup.
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
runtime.GC()
|
||||
baseline := runtime.NumGoroutine()
|
||||
|
||||
const iterations = 50
|
||||
for i := 0; i < iterations; i++ {
|
||||
if _, dlErr := proto.Download(context.Background(), &req); dlErr != nil {
|
||||
t.Fatalf("download %d: %v", i, dlErr)
|
||||
}
|
||||
}
|
||||
|
||||
// After every successful Download returns, its cancellation
|
||||
// goroutine must have exited. Allow a brief settle window for
|
||||
// the runtime to reclaim them.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
// One tolerance for any incidental stdlib goroutine that
|
||||
// the Go runtime occasionally spins up between calls.
|
||||
if runtime.NumGoroutine() <= baseline+1 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("goroutine leak: baseline=%d, after %d downloads = %d",
|
||||
baseline, iterations, runtime.NumGoroutine())
|
||||
}
|
||||
|
||||
@@ -403,6 +403,7 @@ func (c *Client) downloadMirror(ctx context.Context, req *core.DownloadRequest)
|
||||
ConvertLinks: c.config.Recursive.ConvertLinks,
|
||||
DownloadAssets: c.config.Recursive.MirrorAssets,
|
||||
PruneEmpty: true,
|
||||
BackupConverted: c.config.Recursive.BackupConverted,
|
||||
RestrictFiles: true,
|
||||
Timeout: c.config.Transport.Timeout,
|
||||
RespectRobots: c.config.Recursive.RespectRobots,
|
||||
|
||||
@@ -87,6 +87,7 @@ type RecursiveConfig struct {
|
||||
Mirror bool
|
||||
MirrorAssets bool
|
||||
ConvertLinks bool
|
||||
BackupConverted bool
|
||||
RespectRobots bool
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,39 @@
|
||||
package sftp
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
func hostKeyCallback(knownHostsPath string, insecure bool) (ssh.HostKeyCallback, error) {
|
||||
// hostKeyCallback builds the ssh.HostKeyCallback used for dialing.
|
||||
//
|
||||
// Precedence:
|
||||
//
|
||||
// 1. pinnedHostKey — when set, only a host whose key matches the
|
||||
// given fingerprint is accepted. known_hosts and --ssh-insecure
|
||||
// are both ignored because pinning is a stricter guarantee.
|
||||
// 2. insecure --ssh-insecure skips verification entirely.
|
||||
// 3. known_hosts file (default ~/.ssh/known_hosts).
|
||||
func hostKeyCallback(knownHostsPath string, insecure bool, pinnedHostKey string) (ssh.HostKeyCallback, error) {
|
||||
pinned := normalizePinnedHostKey(pinnedHostKey)
|
||||
if pinned != "" {
|
||||
return func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
||||
actual := sshFingerprintSHA256(key)
|
||||
if actual == pinned {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("host key pinning failed: server key %s does not match pinned %s", actual, pinned)
|
||||
}, nil
|
||||
}
|
||||
|
||||
if insecure {
|
||||
return ssh.InsecureIgnoreHostKey(), nil
|
||||
}
|
||||
@@ -31,3 +55,60 @@ func hostKeyCallback(knownHostsPath string, insecure bool) (ssh.HostKeyCallback,
|
||||
|
||||
return knownhosts.New(knownHostsPath)
|
||||
}
|
||||
|
||||
// sshFingerprintSHA256 returns the OpenSSH-style SHA-256 fingerprint
|
||||
// of an SSH public key, e.g. "SHA256:abc123...". This matches the
|
||||
// output of `ssh-keygen -lf <keyfile>`.
|
||||
func sshFingerprintSHA256(key ssh.PublicKey) string {
|
||||
hash := sha256.Sum256(key.Marshal())
|
||||
return "SHA256:" + base64.StdEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// normalizePinnedHostKey accepts either the OpenSSH fingerprint form
|
||||
// ("SHA256:<base64>") or a raw lowercase-hex SHA-256 of the key wire
|
||||
// format, and returns the canonical OpenSSH fingerprint form. An empty
|
||||
// input returns an empty string.
|
||||
func normalizePinnedHostKey(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(s, "SHA256:") {
|
||||
return s
|
||||
}
|
||||
// Raw hex form — convert to base64 to match the OpenSSH fingerprint.
|
||||
if decoded := tryHexDecode(s); decoded != nil {
|
||||
return "SHA256:" + base64.StdEncoding.EncodeToString(decoded)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// tryHexDecode decodes a lowercase-or-uppercase hex string. Returns nil
|
||||
// on any error or odd length.
|
||||
func tryHexDecode(s string) []byte {
|
||||
if len(s)%2 != 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]byte, len(s)/2)
|
||||
for i := 0; i < len(s); i += 2 {
|
||||
hi, ok1 := hexNibble(s[i])
|
||||
lo, ok2 := hexNibble(s[i+1])
|
||||
if !ok1 || !ok2 {
|
||||
return nil
|
||||
}
|
||||
out[i/2] = hi<<4 | lo
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hexNibble(c byte) (byte, bool) {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return c - '0', true
|
||||
case c >= 'a' && c <= 'f':
|
||||
return c - 'a' + 10, true
|
||||
case c >= 'A' && c <= 'F':
|
||||
return c - 'A' + 10, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
package sftp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestHostKeyCallbackInsecure(t *testing.T) {
|
||||
cb, err := hostKeyCallback("", true)
|
||||
cb, err := hostKeyCallback("", true, "")
|
||||
if err != nil {
|
||||
t.Fatalf("hostKeyCallback: %v", err)
|
||||
}
|
||||
@@ -18,8 +21,61 @@ func TestHostKeyCallbackInsecure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHostKeyCallbackMissingFile(t *testing.T) {
|
||||
_, err := hostKeyCallback("/nonexistent/known_hosts", false)
|
||||
_, err := hostKeyCallback("/nonexistent/known_hosts", false, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing known_hosts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostKeyCallbackPinned(t *testing.T) {
|
||||
// Pinning takes precedence over insecure and known_hosts: the
|
||||
// callback is returned without touching the filesystem.
|
||||
cb, err := hostKeyCallback("/nonexistent/known_hosts", false, "SHA256:abcdefghijklmnopqrstuvwxyz0123456789+/=")
|
||||
if err != nil {
|
||||
t.Fatalf("hostKeyCallback with pin: %v", err)
|
||||
}
|
||||
if cb == nil {
|
||||
t.Fatal("expected callback for pinned host key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePinnedHostKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"whitespace only", " ", ""},
|
||||
{"openssh form preserved", "SHA256:abc123", "SHA256:abc123"},
|
||||
{"hex converted to base64", "00", "SHA256:AA=="},
|
||||
{"invalid hex falls through", "nothex", "nothex"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := normalizePinnedHostKey(c.in); got != c.want {
|
||||
t.Errorf("normalizePinnedHostKey(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHFingerprintSHA256(t *testing.T) {
|
||||
// Use a fixed test key so the fingerprint is deterministic.
|
||||
// Generated with ssh-keygen -t ed25519 -f /tmp/test_key -N ""
|
||||
// and parsed here. We use ssh.ParseAuthorizedKey on a known-good
|
||||
// public key line.
|
||||
pubLine := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE+HHi0MtRj4VPl8mdP8gniGZDRb0SZTdI2TPxRyCm1H test@example.com"
|
||||
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubLine))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAuthorizedKey: %v", err)
|
||||
}
|
||||
fp := sshFingerprintSHA256(key)
|
||||
if !strings.HasPrefix(fp, "SHA256:") {
|
||||
t.Errorf("fingerprint should start with SHA256:, got %q", fp)
|
||||
}
|
||||
// The fingerprint must be deterministic for the same key.
|
||||
if fp != sshFingerprintSHA256(key) {
|
||||
t.Error("fingerprint should be deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
+101
-144
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
"codeberg.org/petrbalvin/goget/internal/output"
|
||||
"codeberg.org/petrbalvin/goget/internal/pool"
|
||||
"codeberg.org/petrbalvin/goget/internal/protocol"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
@@ -112,7 +113,7 @@ type client struct {
|
||||
maxPacket uint32
|
||||
}
|
||||
|
||||
func dial(ctx context.Context, u *url.URL, knownHostsPath string, insecure bool) (*client, error) {
|
||||
func dial(ctx context.Context, u *url.URL, knownHostsPath string, insecure bool, pinnedHostKey string) (*client, error) {
|
||||
host := u.Hostname()
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
@@ -126,7 +127,7 @@ func dial(ctx context.Context, u *url.URL, knownHostsPath string, insecure bool)
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
|
||||
hostKeyCB, err := hostKeyCallback(knownHostsPath, insecure)
|
||||
hostKeyCB, err := hostKeyCallback(knownHostsPath, insecure, pinnedHostKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -638,7 +639,7 @@ func download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadRes
|
||||
return downloadRecursiveSequential(ctx, req)
|
||||
}
|
||||
|
||||
c, err := dial(ctx, req.URL, req.SSHKnownHosts, req.SSHInsecure)
|
||||
c, err := dial(ctx, req.URL, req.SSHKnownHosts, req.SSHInsecure, req.PinnedHostKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -725,6 +726,14 @@ func downloadSingleFile(ctx context.Context, c *client, req *core.DownloadReques
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Persist partial progress so the user can resume later
|
||||
// with --resume. Unconditional — mirrors the FTP and SFTP
|
||||
// parallel paths that always save a .goget.meta sidecar on
|
||||
// cancellation.
|
||||
if outputPath != "" && outputPath != "-" && total > 0 {
|
||||
meta := output.NewResumeMetadata(req.URL.String(), "", "", int64(total), totalSize)
|
||||
_ = meta.Save(outputPath)
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
@@ -807,7 +816,7 @@ func newSFTPClientPool(ctx context.Context, req *core.DownloadRequest, size int)
|
||||
all := make([]*client, 0, size)
|
||||
avail := make(chan *client, size)
|
||||
for i := 0; i < size; i++ {
|
||||
c, err := dial(ctx, req.URL, req.SSHKnownHosts, req.SSHInsecure)
|
||||
c, err := dial(ctx, req.URL, req.SSHKnownHosts, req.SSHInsecure, req.PinnedHostKey)
|
||||
if err != nil {
|
||||
for _, existing := range all {
|
||||
existing.close()
|
||||
@@ -845,7 +854,7 @@ func (p *sftpClientPool) close() {
|
||||
// downloadRecursiveParallel and is enabled when req.RecursiveParallel
|
||||
// is greater than one.
|
||||
func downloadRecursiveSequential(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
|
||||
c, err := dial(ctx, req.URL, req.SSHKnownHosts, req.SSHInsecure)
|
||||
c, err := dial(ctx, req.URL, req.SSHKnownHosts, req.SSHInsecure, req.PinnedHostKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -854,56 +863,113 @@ func downloadRecursiveSequential(ctx context.Context, req *core.DownloadRequest)
|
||||
return sftpDownloadDirStandalone(ctx, c, req)
|
||||
}
|
||||
|
||||
// sftpTask represents a unit of work for the concurrent recursive
|
||||
// download: either a directory to list or a file to download.
|
||||
type sftpTask struct {
|
||||
req *core.DownloadRequest
|
||||
isDir bool
|
||||
size uint64
|
||||
}
|
||||
|
||||
// downloadRecursiveParallel downloads a remote directory using a pool
|
||||
// of N SFTP connections. All workers share a single semaphore so the
|
||||
// total in-flight goroutines never exceed N, regardless of directory
|
||||
// depth.
|
||||
// of N SFTP connections. All workers share a single pool so the total
|
||||
// in-flight goroutines never exceed N, regardless of directory depth.
|
||||
func downloadRecursiveParallel(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
|
||||
parallel := req.RecursiveParallel
|
||||
if parallel < 1 {
|
||||
parallel = 1
|
||||
}
|
||||
pool, err := newSFTPClientPool(ctx, req, parallel)
|
||||
cpool, err := newSFTPClientPool(ctx, req, parallel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer pool.close()
|
||||
defer cpool.close()
|
||||
|
||||
var (
|
||||
statsMu sync.Mutex
|
||||
totalBytes int64
|
||||
totalChunks int
|
||||
firstErr error
|
||||
)
|
||||
|
||||
sem := make(chan struct{}, parallel)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
setErr := func(err error) {
|
||||
statsMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
statsMu.Unlock()
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if ctx.Err() != nil {
|
||||
p := pool.New(ctx, parallel, func(taskCtx context.Context, t sftpTask) {
|
||||
if taskCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
c := pool.acquire()
|
||||
defer pool.release(c)
|
||||
sftpDownloadDirWorker(ctx, c, req, &wg, sem, setErr, &statsMu, &totalBytes, &totalChunks, pool)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
if firstErr != nil {
|
||||
return nil, firstErr
|
||||
c := cpool.acquire()
|
||||
defer cpool.release(c)
|
||||
|
||||
if !t.isDir {
|
||||
if err := sftpDownloadFile(taskCtx, c, t.req, t.size); err != nil {
|
||||
pool.Get[sftpTask](taskCtx).AddError(fmt.Errorf("sftp download %s: %w", t.req.URL.Path, err))
|
||||
return
|
||||
}
|
||||
statsMu.Lock()
|
||||
totalBytes += int64(t.size)
|
||||
totalChunks++
|
||||
statsMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Directory: list and submit child tasks.
|
||||
remotePath := t.req.URL.Path
|
||||
if remotePath == "" {
|
||||
remotePath = "."
|
||||
}
|
||||
|
||||
absPath, err := c.realpath(remotePath)
|
||||
if err != nil {
|
||||
absPath = remotePath
|
||||
}
|
||||
|
||||
outputDir := t.req.Output
|
||||
if outputDir == "" {
|
||||
outputDir = filepath.Base(absPath)
|
||||
if outputDir == "" || outputDir == "/" {
|
||||
outputDir = "download"
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := sftpListDir(taskCtx, c, absPath)
|
||||
if err != nil {
|
||||
pool.Get[sftpTask](taskCtx).AddError(fmt.Errorf("sftp list %s: %w", absPath, err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
pool.Get[sftpTask](taskCtx).AddError(fmt.Errorf("mkdir %s: %w", outputDir, err))
|
||||
return
|
||||
}
|
||||
|
||||
pp := pool.Get[sftpTask](taskCtx)
|
||||
for _, e := range entries {
|
||||
name := e.Name
|
||||
if name == "." || name == ".." {
|
||||
continue
|
||||
}
|
||||
entryIsDir := e.Attr != nil && sftpIsDir(e.Attr.Mode)
|
||||
remoteChild := absPath + "/" + name
|
||||
localChild := filepath.Join(outputDir, name)
|
||||
subReq := *t.req
|
||||
subReq.URL = &url.URL{Scheme: t.req.URL.Scheme, User: t.req.URL.User, Host: t.req.URL.Host, Path: remoteChild}
|
||||
subReq.Output = localChild
|
||||
if entryIsDir {
|
||||
pp.Submit(sftpTask{req: &subReq, isDir: true})
|
||||
} else {
|
||||
subReq.Recursive = false
|
||||
subReq.RecursiveParallel = 0
|
||||
pp.Submit(sftpTask{req: &subReq, isDir: false, size: e.Attr.Size})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
p.Submit(sftpTask{req: req, isDir: true})
|
||||
|
||||
if errs := p.Wait(); len(errs) > 0 {
|
||||
return nil, errs[0]
|
||||
}
|
||||
statsMu.Lock()
|
||||
defer statsMu.Unlock()
|
||||
return &core.DownloadResult{
|
||||
BytesDownloaded: totalBytes,
|
||||
Duration: 0,
|
||||
@@ -950,115 +1016,6 @@ func sftpDownloadDirStandalone(ctx context.Context, c *client, req *core.Downloa
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sftpDownloadDirWorker is the worker-pool variant. It lists the
|
||||
// directory, then dispatches file downloads and subdirectory recursion
|
||||
// through the shared semaphore. Subdirectory workers reuse the same
|
||||
// pool to acquire clients. The return value is always nil; results
|
||||
// and errors are routed through the shared fields.
|
||||
func sftpDownloadDirWorker(
|
||||
ctx context.Context,
|
||||
c *client,
|
||||
req *core.DownloadRequest,
|
||||
wg *sync.WaitGroup,
|
||||
sem chan struct{},
|
||||
setErr func(error),
|
||||
statsMu *sync.Mutex,
|
||||
totalBytes *int64,
|
||||
totalChunks *int,
|
||||
pool *sftpClientPool,
|
||||
) {
|
||||
remotePath := req.URL.Path
|
||||
if remotePath == "" {
|
||||
remotePath = "."
|
||||
}
|
||||
|
||||
absPath, err := c.realpath(remotePath)
|
||||
if err != nil {
|
||||
absPath = remotePath
|
||||
}
|
||||
|
||||
outputDir := req.Output
|
||||
if outputDir == "" {
|
||||
outputDir = filepath.Base(absPath)
|
||||
if outputDir == "" || outputDir == "/" {
|
||||
outputDir = "download"
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := sftpListDir(ctx, c, absPath)
|
||||
if err != nil {
|
||||
setErr(fmt.Errorf("sftp list %s: %w", absPath, err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
setErr(fmt.Errorf("mkdir %s: %w", outputDir, err))
|
||||
return
|
||||
}
|
||||
|
||||
for i, e := range entries {
|
||||
name := e.Name
|
||||
if name == "." || name == ".." {
|
||||
continue
|
||||
}
|
||||
isDir := e.Attr != nil && sftpIsDir(e.Attr.Mode)
|
||||
remoteChild := absPath + "/" + name
|
||||
localChild := filepath.Join(outputDir, name)
|
||||
_ = i
|
||||
|
||||
if isDir {
|
||||
wg.Add(1)
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
go func(remoteChild, localChild string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
cc := pool.acquire()
|
||||
defer pool.release(cc)
|
||||
subReq := *req
|
||||
subReq.URL = &url.URL{Scheme: req.URL.Scheme, User: req.URL.User, Host: req.URL.Host, Path: remoteChild}
|
||||
subReq.Output = localChild
|
||||
sftpDownloadDirWorker(ctx, cc, &subReq, wg, sem, setErr, statsMu, totalBytes, totalChunks, pool)
|
||||
}(remoteChild, localChild)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
} else {
|
||||
wg.Add(1)
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
go func(remoteChild, localChild string, size uint64) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
cc := pool.acquire()
|
||||
defer pool.release(cc)
|
||||
subReq := *req
|
||||
subReq.URL = &url.URL{Scheme: req.URL.Scheme, User: req.URL.User, Host: req.URL.Host, Path: remoteChild}
|
||||
subReq.Output = localChild
|
||||
subReq.Recursive = false
|
||||
subReq.RecursiveParallel = 0
|
||||
if err := sftpDownloadFile(ctx, cc, &subReq, size); err != nil {
|
||||
setErr(fmt.Errorf("sftp download %s: %w", remoteChild, err))
|
||||
return
|
||||
}
|
||||
statsMu.Lock()
|
||||
*totalBytes += int64(size)
|
||||
*totalChunks++
|
||||
statsMu.Unlock()
|
||||
}(remoteChild, localChild, e.Attr.Size)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sftpWalkDir recursively walks one remote directory on `c`,
|
||||
// downloading each file and recursing into subdirectories. Stats are
|
||||
// accumulated inline. Used by the standalone mode.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package sftp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
"codeberg.org/petrbalvin/goget/internal/output"
|
||||
)
|
||||
|
||||
// TestDownloadSingleFileGracefulShutdown verifies that downloadSingleFile
|
||||
// persists a .goget.meta sidecar when the context is cancelled mid-transfer.
|
||||
// Without this, a user hitting Ctrl-C during an SFTP single-file download
|
||||
// would lose all progress and be unable to --resume.
|
||||
func TestDownloadSingleFileGracefulShutdown(t *testing.T) {
|
||||
addr, cleanup := startTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
// Use a large file so the download takes multiple read iterations,
|
||||
// giving the context cancellation a chance to land between reads.
|
||||
bigContent := make([]byte, 1<<20) // 1 MiB
|
||||
if _, err := rand.Read(bigContent); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
origContent := testFileContent
|
||||
testFileContent = bigContent
|
||||
t.Cleanup(func() { testFileContent = origContent })
|
||||
|
||||
u, _ := url.Parse("sftp://user@" + addr + "/testfile")
|
||||
outDir := t.TempDir()
|
||||
outputPath := filepath.Join(outDir, "out")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Cancel the context on the first progress callback — at that point
|
||||
// at least one chunk has been written, so total > 0 and the
|
||||
// .goget.meta sidecar will be persisted.
|
||||
var once sync.Once
|
||||
progressCallback := func(current, total int64, speed float64) {
|
||||
once.Do(cancel)
|
||||
}
|
||||
|
||||
req := &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outputPath,
|
||||
Ctx: ctx,
|
||||
SSHInsecure: true,
|
||||
ProgressCallback: progressCallback,
|
||||
}
|
||||
|
||||
_, err := download(ctx, req)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from cancelled context, got nil")
|
||||
}
|
||||
|
||||
// The output file must exist (partial data was written).
|
||||
if _, statErr := os.Stat(outputPath); statErr != nil {
|
||||
t.Fatalf("output file should exist: %v", statErr)
|
||||
}
|
||||
|
||||
// Verify .goget.meta was saved with partial progress.
|
||||
meta, err := output.LoadResumeMetadata(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadResumeMetadata: %v", err)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatal("expected resume metadata after SIGINT, got nil")
|
||||
}
|
||||
if meta.Downloaded <= 0 {
|
||||
t.Errorf("Downloaded = %d, want > 0", meta.Downloaded)
|
||||
}
|
||||
if meta.URL != u.String() {
|
||||
t.Errorf("URL = %q, want %q", meta.URL, u.String())
|
||||
}
|
||||
}
|
||||
@@ -490,7 +490,7 @@ func TestIsEOF(t *testing.T) {
|
||||
|
||||
func TestDialTimeout(t *testing.T) {
|
||||
u, _ := url.Parse("sftp://user@127.0.0.1:1/")
|
||||
_, err := dial(context.Background(), u, "", true)
|
||||
_, err := dial(context.Background(), u, "", true, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
"codeberg.org/petrbalvin/goget/internal/output"
|
||||
"codeberg.org/petrbalvin/goget/internal/pool"
|
||||
"codeberg.org/petrbalvin/goget/internal/protocol"
|
||||
"codeberg.org/petrbalvin/goget/internal/transport"
|
||||
)
|
||||
@@ -245,8 +246,21 @@ func (p *Protocol) getHTTPTransport() *http.Transport {
|
||||
return p.httpTransport
|
||||
}
|
||||
|
||||
// Build the TLS config from the caller-provided transport.TLSConfig
|
||||
// (which carries CA certs, mTLS, pinned-cert hash, keylog, etc.)
|
||||
// rather than a fresh tls.Config that would silently drop pinning.
|
||||
var tlsClientCfg *tls.Config
|
||||
if p.tlsConfig != nil {
|
||||
if cfg, err := p.tlsConfig.ToTLSConfig(); err == nil {
|
||||
tlsClientCfg = cfg
|
||||
}
|
||||
}
|
||||
if tlsClientCfg == nil {
|
||||
tlsClientCfg = &tls.Config{InsecureSkipVerify: p.insecureSkip} //nolint:gosec
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: p.insecureSkip}, //nolint:gosec
|
||||
TLSClientConfig: tlsClientCfg,
|
||||
DisableCompression: true,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
MaxIdleConns: 100,
|
||||
@@ -680,6 +694,7 @@ func (p *Protocol) downloadFile(ctx context.Context, req *core.DownloadRequest,
|
||||
OutputPath: req.Output,
|
||||
Resumed: resumed,
|
||||
ChunksCount: 1,
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -981,9 +996,18 @@ func (p *Protocol) downloadRecursiveSequential(ctx context.Context, req *core.Do
|
||||
}, nil
|
||||
}
|
||||
|
||||
// webdavTask represents a unit of work for the concurrent recursive
|
||||
// download: a file or subdirectory entry with its pre-built request.
|
||||
type webdavTask struct {
|
||||
entry WebDAVEntry
|
||||
subReq *core.DownloadRequest
|
||||
outPath string
|
||||
isDir bool
|
||||
}
|
||||
|
||||
// downloadRecursiveParallel is the worker-pool implementation used when
|
||||
// req.RecursiveParallel > 1. Top-level entries are processed concurrently,
|
||||
// bounded by a semaphore. Subdirectory recursion is delegated back to
|
||||
// bounded by the pool. Subdirectory recursion is delegated back to
|
||||
// downloadRecursive, so the same RecursiveParallel limit applies at every
|
||||
// level of the tree. Stats are aggregated under a mutex.
|
||||
func (p *Protocol) downloadRecursiveParallel(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
|
||||
@@ -1023,21 +1047,49 @@ func (p *Protocol) downloadRecursiveParallel(ctx context.Context, req *core.Down
|
||||
statsMu sync.Mutex
|
||||
totalBytes int64
|
||||
totalChunks int
|
||||
processedDirs int
|
||||
)
|
||||
|
||||
processedDirs++
|
||||
processedDirs := 1
|
||||
|
||||
// Pre-build sub-requests and output paths so the goroutine body has
|
||||
// no per-entry allocation cost.
|
||||
type task struct {
|
||||
entry WebDAVEntry
|
||||
subReq *core.DownloadRequest
|
||||
outPath string
|
||||
isDir bool
|
||||
wp := pool.New(ctx, req.RecursiveParallel, func(taskCtx context.Context, t webdavTask) {
|
||||
if taskCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tasks := make([]task, 0, len(filteredEntries))
|
||||
if t.isDir {
|
||||
subResult, err := p.downloadRecursive(taskCtx, t.subReq)
|
||||
if err != nil {
|
||||
if req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[webdav] Warning: failed to %s sub-directory %s: %v\n", dryRunVerb(req.DryRun), t.entry.URL.String(), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
statsMu.Lock()
|
||||
totalBytes += subResult.BytesDownloaded
|
||||
totalChunks += subResult.ChunksCount
|
||||
statsMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
subResult, err := p.downloadFile(taskCtx, t.subReq, t.entry.Size, t.entry.LastModified)
|
||||
if err != nil {
|
||||
if req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[webdav] Warning: failed to download file %s: %v\n", t.entry.URL.String(), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Apply keep-timestamps if requested.
|
||||
if req.KeepTimestamps {
|
||||
setFileTimestamp(t.outPath, t.entry.LastModified)
|
||||
}
|
||||
|
||||
statsMu.Lock()
|
||||
totalBytes += subResult.BytesDownloaded
|
||||
totalChunks++
|
||||
statsMu.Unlock()
|
||||
})
|
||||
|
||||
for _, entry := range filteredEntries {
|
||||
// Respect MaxDepth to avoid infinite recursion.
|
||||
if entry.IsDir && req.MaxDepth == 0 {
|
||||
@@ -1064,7 +1116,7 @@ func (p *Protocol) downloadRecursiveParallel(ctx context.Context, req *core.Down
|
||||
subReq.MaxDepth = req.MaxDepth - 1
|
||||
subReq.RecursiveParallel = req.RecursiveParallel
|
||||
subReq.DryRun = true
|
||||
tasks = append(tasks, task{entry: entry, subReq: &subReq, outPath: itemOutputPath, isDir: true})
|
||||
wp.Submit(webdavTask{entry: entry, subReq: &subReq, outPath: itemOutputPath, isDir: true})
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[webdav] [dry-run] would download: %s (%d bytes)\n", entry.URL.String(), entry.Size)
|
||||
statsMu.Lock()
|
||||
@@ -1105,7 +1157,7 @@ func (p *Protocol) downloadRecursiveParallel(ctx context.Context, req *core.Down
|
||||
RejectPatterns: req.RejectPatterns,
|
||||
Ctx: req.Ctx,
|
||||
}
|
||||
tasks = append(tasks, task{entry: entry, subReq: subReq, outPath: itemOutputPath, isDir: true})
|
||||
wp.Submit(webdavTask{entry: entry, subReq: subReq, outPath: itemOutputPath, isDir: true})
|
||||
} else {
|
||||
if req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[webdav] Downloading file %s -> %s (depth=%d)\n", entry.URL.String(), itemOutputPath, req.MaxDepth)
|
||||
@@ -1125,71 +1177,11 @@ func (p *Protocol) downloadRecursiveParallel(ctx context.Context, req *core.Down
|
||||
MaxDepth: req.MaxDepth - 1,
|
||||
Ctx: req.Ctx,
|
||||
}
|
||||
tasks = append(tasks, task{entry: entry, subReq: subReq, outPath: itemOutputPath, isDir: false})
|
||||
wp.Submit(webdavTask{entry: entry, subReq: subReq, outPath: itemOutputPath, isDir: false})
|
||||
}
|
||||
}
|
||||
|
||||
// Worker pool: bounded by req.RecursiveParallel. We use a semaphore
|
||||
// channel + WaitGroup so total in-flight workers never exceed the
|
||||
// requested count, regardless of how many tasks are enqueued.
|
||||
sem := make(chan struct{}, req.RecursiveParallel)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, t := range tasks {
|
||||
// Honour context cancellation before launching a new task.
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
|
||||
go func(t task) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
// If the context was cancelled while we were waiting for
|
||||
// the semaphore, just exit cleanly.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if t.isDir {
|
||||
subResult, err := p.downloadRecursive(ctx, t.subReq)
|
||||
if err != nil {
|
||||
if req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[webdav] Warning: failed to %s sub-directory %s: %v\n", dryRunVerb(req.DryRun), t.entry.URL.String(), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
statsMu.Lock()
|
||||
totalBytes += subResult.BytesDownloaded
|
||||
totalChunks += subResult.ChunksCount
|
||||
statsMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
subResult, err := p.downloadFile(ctx, t.subReq, t.entry.Size, t.entry.LastModified)
|
||||
if err != nil {
|
||||
if req.Verbose {
|
||||
fmt.Fprintf(os.Stderr, "[webdav] Warning: failed to download file %s: %v\n", t.entry.URL.String(), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Apply keep-timestamps if requested.
|
||||
if req.KeepTimestamps {
|
||||
setFileTimestamp(t.outPath, t.entry.LastModified)
|
||||
}
|
||||
|
||||
statsMu.Lock()
|
||||
totalBytes += subResult.BytesDownloaded
|
||||
totalChunks++
|
||||
statsMu.Unlock()
|
||||
}(t)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
wp.Wait()
|
||||
|
||||
_ = processedDirs // reserved for future per-directory accounting
|
||||
|
||||
|
||||
@@ -11,14 +11,38 @@ run:
|
||||
|
||||
# Build the binary
|
||||
build:
|
||||
# Keep the embedded manpage in sync with man/goget.1. //go:embed
|
||||
# cannot reference files outside the embedding package, so we copy
|
||||
# the source-of-truth manpage into the package tree before compiling.
|
||||
cp man/goget.1 cmd/goget/man-page-data/goget.1
|
||||
go vet ./...
|
||||
gofmt -l $(find cmd internal pkg -name '*.go') | grep . && exit 1 || true
|
||||
go build -ldflags "-s -w" -o bin/goget ./cmd/goget
|
||||
|
||||
# Run tests (race detector + fuzz seed corpus)
|
||||
test:
|
||||
cp man/goget.1 cmd/goget/man-page-data/goget.1
|
||||
go test -race -count=1 ./...
|
||||
|
||||
# Remove build artifacts
|
||||
uninstall:
|
||||
rm -rf bin/ coverage.out
|
||||
|
||||
# Validate the manpage with groff (zero warnings on success). Requires
|
||||
# `groff` to be installed — skipped silently otherwise.
|
||||
man:
|
||||
@command -v groff >/dev/null || { echo "groff not installed, skipping"; exit 0; }
|
||||
groff -man -ww -Kutf-8 -z man/goget.1
|
||||
|
||||
# Install the manpage to ~/.local/share/man/man1/ (XDG rootless).
|
||||
# After install, `man goget` resolves it automatically. Re-run safely to
|
||||
# pick up edits.
|
||||
install-man: man
|
||||
@mkdir -p "$HOME/.local/share/man/man1"
|
||||
gzip -c -f man/goget.1 > "$HOME/.local/share/man/man1/goget.1.gz"
|
||||
@echo "Installed: $HOME/.local/share/man/man1/goget.1.gz"
|
||||
|
||||
# Remove the manpage installed by `just install-man`.
|
||||
uninstall-man:
|
||||
rm -f "$HOME/.local/share/man/man1/goget.1.gz"
|
||||
@echo "Removed: $HOME/.local/share/man/man1/goget.1.gz"
|
||||
|
||||
+950
@@ -0,0 +1,950 @@
|
||||
.\" Manpage for goget.
|
||||
.\" Source of truth — kept in sync with cmd/goget/man-page-data/goget.1
|
||||
.\" by the `cp` step in `just build`. The duplicate is required because
|
||||
.\" //go:embed cannot reference files outside the embedding package.
|
||||
.\"
|
||||
.\" Conventions used in this file:
|
||||
.\" .TP tag-paragraph (one option per entry)
|
||||
.\" \fB...\fR bold (the flag itself)
|
||||
.\" \fI...\fR italic (the argument placeholder)
|
||||
.\" \- literal hyphen (escapes groff's dash-to-endash conversion)
|
||||
.\" \\- alternative escape for the same purpose
|
||||
.\"
|
||||
.\" Validate with: groff -man -ww man/goget.1 > /dev/null
|
||||
.\" Render with: groff -man -Tutf8 man/goget.1 | less
|
||||
.\"
|
||||
.TH GOGET 1 "2026-06-26" "goget 1.0.0" "User Commands"
|
||||
.SH NAME
|
||||
goget \- modern IPv6-first command-line download utility
|
||||
.SH SYNOPSIS
|
||||
.B goget
|
||||
[\fIOPTION\fR]... \fIURL\fR...
|
||||
.SH DESCRIPTION
|
||||
.B goget
|
||||
is a modern replacement for
|
||||
.BR curl (1)
|
||||
and
|
||||
.BR wget (1)
|
||||
built on the Go standard library.
|
||||
It provides IPv6-first connections (with automatic IPv4 fallback), parallel
|
||||
chunked downloads, resume support, checksum verification, recursive website
|
||||
mirroring, and support for nine network protocols
|
||||
.RB ( HTTP / HTTPS ", " FTP / FTPS ", " SFTP ", " WebDAV / WebDAVS ", "
|
||||
.BR file:// ", " data: ", " Gopher ", and " Gemini ") \(em all in a
|
||||
single static binary with zero runtime dependencies.
|
||||
.PP
|
||||
Safety features include TLS 1.2 minimum, certificate verification, SSRF
|
||||
protection (private/internal addresses are blocked by default), SSH host-key
|
||||
verification for SFTP, HSTS cache (RFC 6797), and certificate or host-key
|
||||
pinning.
|
||||
.PP
|
||||
URLs are specified positionally after the flags; the
|
||||
.B \-\-url
|
||||
flag is an alias for the first positional URL and may be repeated to download
|
||||
several files in one invocation.
|
||||
.PP
|
||||
Configuration is loaded from
|
||||
.IR ~/.config/goget/config.toml
|
||||
(overridable with
|
||||
.BR \-\-config " or the " GOGET_CONFIG
|
||||
environment variable) and merged on top of compiled-in defaults. Command-line
|
||||
flags always take precedence over configuration values.
|
||||
.SH OPTIONS
|
||||
.SS "Target"
|
||||
.TP
|
||||
\fB\-\-url\fR=\fIURL\fR
|
||||
URL to download. May be repeated for multiple URLs, or supplied as positional
|
||||
arguments after the flags.
|
||||
.SS "Output"
|
||||
.TP
|
||||
\fB\-\-output\fR=\fIFILE\fR
|
||||
Output filename or directory. When omitted, the filename is derived from the
|
||||
URL.
|
||||
.TP
|
||||
\fB\-\-restart\fR
|
||||
Delete any existing output file and restart the download from byte zero.
|
||||
.TP
|
||||
\fB\-\-overwrite\fR
|
||||
Overwrite an existing output file without using atomic rename.
|
||||
.TP
|
||||
\fB\-\-content\-disposition\fR
|
||||
Use the filename from the server's
|
||||
.B Content\-Disposition
|
||||
header instead of the one derived from the URL.
|
||||
.TP
|
||||
\fB\-\-create\-dirs\fR
|
||||
Create missing parent directories of
|
||||
.BR \-\-output
|
||||
(enabled by default; disable with
|
||||
.B \-\-create\-dirs=false
|
||||
for
|
||||
.BR curl (1) " /" wget (1)
|
||||
compatibility). Ignored for stdout output.
|
||||
.TP
|
||||
\fB\-\-keep\-timestamps\fR
|
||||
Set the output file's modification time to the server's
|
||||
.B Last\-Modified
|
||||
value.
|
||||
.TP
|
||||
\fB\-\-no\-clobber\fR
|
||||
Skip the download if the output file already exists.
|
||||
.TP
|
||||
\fB\-\-continue\fR
|
||||
Auto-resume the download if a partial output file is present.
|
||||
.TP
|
||||
\fB\-\-timestamping\fR
|
||||
Download only when the remote file is newer than the local copy.
|
||||
.SS "Progress and output"
|
||||
.TP
|
||||
\fB\-\-verbose\fR
|
||||
Verbose output (default: enabled when set in the config file).
|
||||
.TP
|
||||
\fB\-\-no\-progress\fR
|
||||
Hide the progress bar; keep every other piece of output.
|
||||
.TP
|
||||
\fB\-\-progress\fR=\fISTYLE\fR
|
||||
Progress bar style. One of
|
||||
.B bar
|
||||
(default) or
|
||||
.BR dot .
|
||||
.TP
|
||||
\fB\-\-quiet\fR
|
||||
Suppress all output except errors.
|
||||
.TP
|
||||
\fB\-\-json\fR
|
||||
Emit progress and completion events as newline-delimited JSON on stdout.
|
||||
Each event is a single JSON object with
|
||||
.BR type " (" progress " or " complete "), " downloaded ", " total ", " speed ,
|
||||
and (for the
|
||||
.B progress
|
||||
event) an
|
||||
.B eta_ms
|
||||
field when the total size and speed are known.
|
||||
.TP
|
||||
\fB\-\-debug\fR
|
||||
Enable debug output with detailed connection and protocol information.
|
||||
.TP
|
||||
\fB\-\-show\-headers\fR
|
||||
Print HTTP response headers during the download.
|
||||
.TP
|
||||
\fB\-\-write\-out\fR=\fIFORMAT\fR
|
||||
Print metadata after the transfer, using a
|
||||
.BR curl (1)\-compatible
|
||||
format string. Supported directives:
|
||||
.BR %\{http_code\} ", " %\{size_download\} ", " %\{time_total\} ", "
|
||||
.BR %\{speed_download\} .
|
||||
.TP
|
||||
\fB\-\-trace\-time\fR
|
||||
Print an HTTP timing breakdown after the download:
|
||||
.BR DNS ", " TCP ", " TLS ", " TTFB ", and " Total .
|
||||
.SS "Network"
|
||||
.TP
|
||||
\fB\-\-timeout\fR=\fIDURATION\fR
|
||||
Overall connection timeout.
|
||||
.B 0
|
||||
selects a smart auto-calculation based on the file size.
|
||||
.TP
|
||||
\fB\-\-connect\-timeout\fR=\fIDURATION\fR
|
||||
Timeout for the TCP (or SSH) connection establishment phase.
|
||||
.TP
|
||||
\fB\-\-max\-time\fR=\fIDURATION\fR
|
||||
Maximum total transfer time; abort if exceeded.
|
||||
.B 0
|
||||
disables the limit.
|
||||
.TP
|
||||
\fB\-\-max\-retries\fR=\fIN\fR
|
||||
Maximum number of retry attempts on failure.
|
||||
.B 0
|
||||
selects the built-in default.
|
||||
.TP
|
||||
\fB\-\-retry\-delay\fR=\fIDURATION\fR
|
||||
Delay inserted between retry attempts.
|
||||
.TP
|
||||
\fB\-\-retry\-all\-errors\fR
|
||||
Retry on any HTTP 4xx or 5xx response (in addition to network errors).
|
||||
.TP
|
||||
\fB\-\-retry\-on\-http\-error\fR=\fICODES\fR
|
||||
Retry on the given HTTP status codes (comma-separated; e.g.
|
||||
.BR 503,429 ).
|
||||
.TP
|
||||
\fB\-\-max\-filesize\fR=\fISIZE\fR
|
||||
Abort the transfer if the remote file exceeds this size (e.g.
|
||||
.BR 100MB ", " 1GB ).
|
||||
.TP
|
||||
\fB\-\-proxy\fR=\fIURL\fR
|
||||
Proxy URL.
|
||||
.B http://
|
||||
and
|
||||
.B https://
|
||||
trigger HTTP CONNECT;
|
||||
.B socks5://
|
||||
uses local DNS,
|
||||
.B socks5h://
|
||||
uses remote DNS.
|
||||
Optional
|
||||
.B user:pass@
|
||||
credentials are supported.
|
||||
.TP
|
||||
\fB\-\-no\-ipv4\fR
|
||||
Disable IPv4 fallback; connect over IPv6 only.
|
||||
.TP
|
||||
\fB\-\-no\-redirect\fR
|
||||
Do not follow HTTP 3xx redirects.
|
||||
.TP
|
||||
\fB\-\-dns\-servers\fR=\fIIPS\fR
|
||||
Custom DNS servers, comma-separated (e.g.
|
||||
.BR 1.1.1.1,8.8.8.8 ).
|
||||
.TP
|
||||
\fB\-\-bind\-interface\fR=\fIIFACE\fR
|
||||
Bind outgoing connections to a specific network interface (Linux only).
|
||||
.TP
|
||||
\fB\-\-allow\-private\fR
|
||||
Allow connections to private or internal IP addresses. By default these
|
||||
are blocked as an SSRF safeguard.
|
||||
.TP
|
||||
\fB\-\-pinned\-cert\fR=\fISHA256\fR
|
||||
Pin the server certificate by its leaf SHA-256 fingerprint. Applies to all
|
||||
TLS-bearing protocols (HTTPS, FTPS, Gemini, WebDAVS); abort on mismatch.
|
||||
.TP
|
||||
\fB\-\-pinned\-host\-key\fR=\fIFINGERPRINT\fR
|
||||
Pin the SSH host key (SFTP). Accepts
|
||||
.BR SHA256:<base64>
|
||||
(as emitted by
|
||||
.BR "ssh\-keygen \-lf" )
|
||||
or a raw lowercase-hex SHA-256 of the key wire format. Pinning takes
|
||||
precedence over
|
||||
.B \-\-ssh\-insecure
|
||||
and any
|
||||
.I known_hosts
|
||||
entry.
|
||||
.TP
|
||||
\fB\-\-rate\-limit\fR=\fIRATE\fR
|
||||
Maximum download speed (e.g.
|
||||
.BR 1MB/s ", " 500KB/s ).
|
||||
.B 0
|
||||
disables the limit.
|
||||
.TP
|
||||
\fB\-\-max\-speed\fR=\fIRATE\fR
|
||||
Alias for
|
||||
.B \-\-rate\-limit
|
||||
in the legacy "bytes per second" form.
|
||||
.TP
|
||||
\fB\-\-range\fR=\fIN\fB\-\fIN\fR
|
||||
Download only a byte range (e.g.
|
||||
.BR 0\-999 ).
|
||||
.SS "HTTP and request shaping"
|
||||
.TP
|
||||
\fB\-\-header\fR=\fI"Key: Value"\fR
|
||||
Custom HTTP header. May be repeated.
|
||||
.TP
|
||||
\fB\-\-insecure\fR
|
||||
Skip TLS certificate verification. Use only for testing.
|
||||
.TP
|
||||
\fB\-\-cert\fR=\fIFILE\fR
|
||||
Client certificate in PEM format (for mTLS).
|
||||
.TP
|
||||
\fB\-\-key\fR=\fIFILE\fR
|
||||
Client private key in PEM format (for mTLS).
|
||||
.TP
|
||||
\fB\-\-ca\-certificate\fR=\fIFILE\fR
|
||||
Path to a custom CA certificate bundle.
|
||||
.TP
|
||||
\fB\-\-ssl\-key\-log\fR=\fIFILE\fR
|
||||
Log TLS session keys to
|
||||
.I FILE
|
||||
in the NSS Key Log format used by Wireshark
|
||||
.RB ( SSLKEYLOGFILE ).
|
||||
.TP
|
||||
\fB\-\-data\fR=\fI"key=value"\fR
|
||||
Send
|
||||
.I key=value
|
||||
as the request body. Implies HTTP POST.
|
||||
.TP
|
||||
\fB\-\-form\fR=\fI"field=value"\fR
|
||||
Multipart form field; repeatable. A value of
|
||||
.B field=@/path
|
||||
attaches a file upload.
|
||||
.TP
|
||||
\fB\-\-post\-file\fR=\fIFILE\fR
|
||||
POST the contents of
|
||||
.I FILE
|
||||
as the request body.
|
||||
.TP
|
||||
\fB\-\-referer\fR=\fIURL\fR
|
||||
Set the
|
||||
.B Referer
|
||||
request header.
|
||||
.TP
|
||||
\fB\-\-compressed\fR
|
||||
Send an
|
||||
.B Accept\-Encoding
|
||||
header so the server returns a compressed response. By default
|
||||
.B goget
|
||||
already handles compressed responses transparently; this flag is provided
|
||||
for
|
||||
.BR curl (1)
|
||||
compatibility.
|
||||
.TP
|
||||
\fB\-\-spider\fR
|
||||
Check whether the URL exists via a HEAD request; do not download the body.
|
||||
.TP
|
||||
\fB\-\-fail\fR
|
||||
Exit with a non-zero status on HTTP 4xx or 5xx responses.
|
||||
.TP
|
||||
\fB\-\-no\-decompress\fR
|
||||
Disable automatic decompression of compressed response bodies.
|
||||
.SS "Recursive downloading and mirroring"
|
||||
.TP
|
||||
\fB\-\-recursive\fR
|
||||
Follow links discovered in HTML pages.
|
||||
.TP
|
||||
\fB\-\-mirror\fR
|
||||
Mirror an entire website. Shorthand for
|
||||
.BR "\-\-recursive \-\-convert\-links \-\-page\-requisites" .
|
||||
.TP
|
||||
\fB\-\-max\-depth\fR=\fIN\fR
|
||||
Maximum recursion depth.
|
||||
.TP
|
||||
\fB\-\-recursive\-parallel\fR=\fIN\fR
|
||||
Number of concurrent file downloads in recursive mode across all protocols
|
||||
(HTTP, WebDAV, FTP, SFTP).
|
||||
.B 0
|
||||
runs sequentially (default).
|
||||
.TP
|
||||
\fB\-\-follow\-external\fR
|
||||
Follow links to domains other than the starting domain.
|
||||
.TP
|
||||
\fB\-\-span\-hosts\fR
|
||||
Alias for
|
||||
.B \-\-follow\-external
|
||||
in the
|
||||
.BR wget (1)
|
||||
style.
|
||||
.TP
|
||||
\fB\-\-domains\fR=\fILIST\fR
|
||||
Restrict recursive crawling to the listed comma-separated domains.
|
||||
.TP
|
||||
\fB\-\-accept\fR=\fIPATTERN\fR
|
||||
Accept patterns (filename glob or MIME type), comma-separated. Only
|
||||
matching resources are downloaded.
|
||||
.TP
|
||||
\fB\-\-reject\fR=\fIPATTERN\fR
|
||||
Reject patterns (glob), comma-separated. Matching resources are skipped.
|
||||
.TP
|
||||
\fB\-\-exclude\-pattern\fR=\fIPATTERN\fR
|
||||
Alias for
|
||||
.B \-\-reject
|
||||
used by the recursive crawler.
|
||||
.TP
|
||||
\fB\-\-no\-parent\fR
|
||||
Do not ascend to parent directories during recursive download.
|
||||
.TP
|
||||
\fB\-\-page\-requisites\fR
|
||||
Download CSS, JavaScript, and images required to render the HTML pages.
|
||||
.TP
|
||||
\fB\-\-convert\-links\fR
|
||||
Rewrite links in downloaded HTML for local offline viewing.
|
||||
.TP
|
||||
\fB\-\-no\-convert\-links\fR
|
||||
Skip HTML link rewriting (faster; output is not offline-ready).
|
||||
.TP
|
||||
\fB\-\-backup\-converted\fR
|
||||
Keep a
|
||||
.B .orig
|
||||
backup of each file before link conversion.
|
||||
.TP
|
||||
\fB\-\-no\-mirror\-assets\fR
|
||||
Do not download CSS, JavaScript, and images during a mirror.
|
||||
.TP
|
||||
\fB\-\-no\-robots\fR
|
||||
Do not honour
|
||||
.I robots.txt
|
||||
during mirroring.
|
||||
.TP
|
||||
\fB\-\-wait\fR=\fIDURATION\fR
|
||||
Delay between requests in recursive or mirror mode (e.g.
|
||||
.BR 1s ", " 500ms ).
|
||||
.TP
|
||||
\fB\-\-random\-wait\fR
|
||||
Randomize the wait time between 0.5x and 1.5x of
|
||||
.BR \-\-wait .
|
||||
.TP
|
||||
\fB\-\-adjust\-extension\fR
|
||||
Append a
|
||||
.B .html
|
||||
extension to text/html files that lack one.
|
||||
.TP
|
||||
\fB\-\-cut\-dirs\fR=\fIN\fR
|
||||
Ignore the first
|
||||
.I N
|
||||
path components when constructing local filenames.
|
||||
.TP
|
||||
\fB\-\-protocol\-directories\fR
|
||||
Create protocol-prefixed subdirectories
|
||||
.RB ( .http/ ", " .ftp/ )
|
||||
in recursive mode.
|
||||
.TP
|
||||
\fB\-\-dry\-run\fR
|
||||
List the URLs that would be downloaded in recursive or mirror mode without
|
||||
writing any files to disk.
|
||||
.TP
|
||||
\fB\-\-warc\-file\fR=\fIFILE\fR
|
||||
Write a WARC (Web ARChive) record of the transfer to
|
||||
.IR FILE .
|
||||
.SS "Download management"
|
||||
.TP
|
||||
\fB\-\-batch\-file\fR=\fIFILE\fR
|
||||
Read URLs from
|
||||
.IR FILE ,
|
||||
one per line. Lines beginning with
|
||||
.B #
|
||||
are treated as comments and skipped.
|
||||
.TP
|
||||
\fB\-\-input\-file\fR=\fIFILE\fR
|
||||
Alias for
|
||||
.BR \-\-batch\-file .
|
||||
A value of
|
||||
.B \-
|
||||
reads from standard input.
|
||||
.TP
|
||||
\fB\-\-glob\fR
|
||||
Expand
|
||||
.B [1-3]
|
||||
and
|
||||
.B {a,b,c}
|
||||
patterns in the URL before issuing requests.
|
||||
.TP
|
||||
\fB\-\-queue\fR
|
||||
Add the URL to the persistent download queue instead of downloading it
|
||||
immediately.
|
||||
.TP
|
||||
\fB\-\-queue\-list\fR
|
||||
List all items in the download queue and exit.
|
||||
.TP
|
||||
\fB\-\-queue\-process\fR
|
||||
Process (download) every pending item in the queue and exit.
|
||||
.TP
|
||||
\fB\-\-queue\-clear\fR
|
||||
Remove completed items from the queue.
|
||||
.TP
|
||||
\fB\-\-queue\-file\fR=\fIPATH\fR
|
||||
Use a custom queue file (default
|
||||
.IR ~/.config/goget/queue.json ).
|
||||
.TP
|
||||
\fB\-\-queue\-priority\fR=\fIN\fR
|
||||
Priority for the queued item (1\(en10; higher values are processed first).
|
||||
Default: 5.
|
||||
.TP
|
||||
\fB\-\-schedule\fR=\fITIME\fR
|
||||
Schedule the download. Accepts an absolute timestamp
|
||||
.RB ( " YYYY\-MM\-DD HH:MM ")
|
||||
or a cron expression in the standard
|
||||
.B MIN HOUR DOM MON DOW
|
||||
format (e.g.
|
||||
.BR "0 2 * * *" ).
|
||||
.TP
|
||||
\fB\-\-on\-complete\fR=\fICMD\fR
|
||||
Run
|
||||
.I CMD
|
||||
after a successful download. The following environment variables are
|
||||
exported into the hook:
|
||||
.RS
|
||||
.TP
|
||||
.B GOGET_OUTPUT
|
||||
Absolute path of the downloaded file.
|
||||
.TP
|
||||
.B GOGET_SIZE
|
||||
Downloaded size in bytes.
|
||||
.TP
|
||||
.B GOGET_URL
|
||||
The (credential-stripped) source URL.
|
||||
.RE
|
||||
.IP
|
||||
The command is split into tokens with shell-like quoting rules but is
|
||||
executed directly via
|
||||
.BR exec (3)
|
||||
\(em no shell is invoked, so metacharacters in arguments are not
|
||||
re-interpreted.
|
||||
.SS "Authentication"
|
||||
.TP
|
||||
\fB\-\-username\fR=\fIUSER\fR
|
||||
Username for HTTP Basic or Digest authentication. When omitted, credentials
|
||||
are auto-loaded from
|
||||
.I ~/.netrc
|
||||
if a matching machine entry is present.
|
||||
.TP
|
||||
\fB\-\-password\fR=\fIPASS\fR
|
||||
Password for HTTP Basic or Digest authentication. Prefer
|
||||
.B \-\-password\-file
|
||||
to avoid exposing the secret on the command line.
|
||||
.TP
|
||||
\fB\-\-password\-file\fR=\fIFILE\fR
|
||||
Read the password from
|
||||
.I FILE
|
||||
(the first line, with the trailing newline stripped).
|
||||
.TP
|
||||
\fB\-\-auth\-type\fR=\fITYPE\fR
|
||||
Authentication scheme:
|
||||
.BR basic ", " digest ", or " auto
|
||||
(default).
|
||||
.TP
|
||||
\fB\-\-cookie\-jar\fR=\fIFILE\fR
|
||||
Load cookies from and save cookies to
|
||||
.I FILE
|
||||
in Netscape format.
|
||||
.TP
|
||||
\fB\-\-cookie\fR=\fI"name=value"\fR
|
||||
Set a cookie on the request. May be repeated.
|
||||
.TP
|
||||
\fB\-\-no\-private\fR
|
||||
Disable
|
||||
.I .netrc
|
||||
and other credential-file auto-loading for this invocation.
|
||||
.TP
|
||||
\fB\-\-oauth\-client\-id\fR=\fIID\fR
|
||||
OAuth 2.0 client ID.
|
||||
.TP
|
||||
\fB\-\-oauth\-client\-secret\fR=\fISECRET\fR
|
||||
OAuth 2.0 client secret.
|
||||
.TP
|
||||
\fB\-\-oauth\-token\-url\fR=\fIURL\fR
|
||||
OAuth 2.0 token endpoint.
|
||||
.TP
|
||||
\fB\-\-oauth\-auth\-url\fR=\fIURL\fR
|
||||
OAuth 2.0 authorization endpoint (used by the
|
||||
.B authorization_code
|
||||
grant).
|
||||
.TP
|
||||
\fB\-\-oauth\-redirect\-uri\fR=\fIURI\fR
|
||||
OAuth 2.0 redirect URI.
|
||||
.TP
|
||||
\fB\-\-oauth\-scopes\fR=\fISCOPES\fR
|
||||
OAuth 2.0 scopes, comma-separated.
|
||||
.TP
|
||||
\fB\-\-oauth\-grant\-type\fR=\fITYPE\fR
|
||||
OAuth 2.0 grant type:
|
||||
.BR client_credentials ", " authorization_code ", " password ", or "
|
||||
.BR refresh_token .
|
||||
.TP
|
||||
\fB\-\-oauth\-access\-token\fR=\fITOKEN\fR
|
||||
OAuth 2.0 access token supplied directly.
|
||||
.TP
|
||||
\fB\-\-oauth\-refresh\-token\fR=\fITOKEN\fR
|
||||
OAuth 2.0 refresh token used to obtain a new access token.
|
||||
.SS "Upload"
|
||||
.TP
|
||||
\fB\-\-upload\fR
|
||||
Upload the file in
|
||||
.B \-\-upload\-file
|
||||
instead of downloading.
|
||||
.TP
|
||||
\fB\-\-upload\-method\fR=\fIMETHOD\fR
|
||||
HTTP method for the upload:
|
||||
.B PUT
|
||||
(default) or
|
||||
.BR POST .
|
||||
.TP
|
||||
\fB\-\-upload\-file\fR=\fIFILE\fR
|
||||
Path to the file to upload.
|
||||
.TP
|
||||
\fB\-\-form\-data\fR=\fI"k=v"\fR
|
||||
Additional multipart form data, comma-separated.
|
||||
.TP
|
||||
\fB\-\-file\-field\fR=\fINAME\fR
|
||||
Form field name used for the file part. Default:
|
||||
.BR file .
|
||||
.SS "Verification"
|
||||
.TP
|
||||
\fB\-\-checksum\fR=\fIHASH\fR
|
||||
Expected checksum, as a hex string.
|
||||
.TP
|
||||
\fB\-\-checksum\-algo\fR=\fIALG\fR
|
||||
Checksum algorithm:
|
||||
.BR sha256 ", " sha512 ", " blake2b ", " sha3\-256 ", " sha3\-512 ", or " md5 .
|
||||
Default:
|
||||
.BR sha256 .
|
||||
.TP
|
||||
\fB\-\-checksum\-file\fR=\fIFILE\fR
|
||||
Read expected checksums from
|
||||
.I FILE
|
||||
in the standard
|
||||
.B SHA256SUMS
|
||||
format.
|
||||
.SS "PGP / OpenPGP"
|
||||
.TP
|
||||
\fB\-\-pgp\-verify\fR
|
||||
Verify a PGP detached or inline signature after download.
|
||||
.TP
|
||||
\fB\-\-pgp\-decrypt\fR
|
||||
Decrypt a PGP-encrypted file after download.
|
||||
.TP
|
||||
\fB\-\-pgp\-sig\fR=\fIFILE\fR
|
||||
Path to a detached signature file (e.g.
|
||||
.BR .asc ", " .sig ).
|
||||
.TP
|
||||
\fB\-\-pgp\-key\fR=\fIFILE\fR
|
||||
Path to a PGP key (public or private, depending on the operation).
|
||||
.TP
|
||||
\fB\-\-pgp\-passphrase\fR=\fIPASS\fR
|
||||
Passphrase for the private key.
|
||||
.TP
|
||||
\fB\-\-pgp\-passphrase\-file\fR=\fIFILE\fR
|
||||
Read the private-key passphrase from
|
||||
.IR FILE .
|
||||
.SS "Metalink"
|
||||
.TP
|
||||
\fB\-\-metalink\fR
|
||||
Treat the URL as a Metalink file (multi-source download).
|
||||
.TP
|
||||
\fB\-\-metalink\-file\fR=\fIPATH\fR
|
||||
Read a Metalink description from
|
||||
.IR PATH .
|
||||
Accepts
|
||||
.B .meta4
|
||||
and
|
||||
.B .metalink
|
||||
formats.
|
||||
.SS "TLS and SSH"
|
||||
.TP
|
||||
\fB\-\-known\-hosts\fR=\fIFILE\fR
|
||||
Use a custom
|
||||
.I known_hosts
|
||||
file for SFTP (default
|
||||
.IR ~/.ssh/known_hosts ).
|
||||
.TP
|
||||
\fB\-\-ssh\-insecure\fR
|
||||
Skip SSH host key verification for SFTP (accept any key). Mutually
|
||||
incompatible with
|
||||
.BR \-\-pinned\-host\-key .
|
||||
.TP
|
||||
\fB\-\-hsts\-file\fR=\fIFILE\fR
|
||||
Use a custom HSTS cache file (default
|
||||
.IR ~/.config/goget/hsts ).
|
||||
.SS "Archive extraction"
|
||||
.TP
|
||||
\fB\-\-extract\fR
|
||||
Automatically extract recognized archives (TAR, TAR.GZ, TAR.BZ2, ZIP)
|
||||
after a successful download.
|
||||
.TP
|
||||
\fB\-\-extract\-dir\fR=\fIDIR\fR
|
||||
Directory to extract into. Default: alongside the downloaded archive.
|
||||
.TP
|
||||
\fB\-\-strip\-components\fR=\fIN\fR
|
||||
Strip the first
|
||||
.I N
|
||||
path components during archive extraction.
|
||||
.SS "Configuration"
|
||||
.TP
|
||||
\fB\-\-config\fR=\fIFILE\fR
|
||||
Path to the TOML configuration file.
|
||||
.TP
|
||||
\fB\-\-init\-config\fR
|
||||
Write a default configuration file to
|
||||
.I ~/.config/goget/config.toml
|
||||
and exit.
|
||||
.TP
|
||||
\fB\-\-config\-get\fR=\fIKEY\fR
|
||||
Print the value of a single configuration key (e.g.
|
||||
.BR \-\-config\-get timeout )
|
||||
and exit.
|
||||
.TP
|
||||
\fB\-\-config\-set\fR=\fIKEY\fR \fIVALUE\fR
|
||||
Persist
|
||||
.I VALUE
|
||||
under
|
||||
.I KEY
|
||||
in the configuration file and exit.
|
||||
.TP
|
||||
\fB\-\-config\-list\fR
|
||||
Print every configuration key and value and exit.
|
||||
.TP
|
||||
\fB\-\-config\-unset\fR=\fIKEY\fR
|
||||
Remove
|
||||
.I KEY
|
||||
from the configuration file and exit.
|
||||
.SS "Information and shell integration"
|
||||
.TP
|
||||
\fB\-\-info\fR=\fIURL\fR
|
||||
Show file metadata (size, content type, server, last-modified) without
|
||||
downloading the body.
|
||||
.TP
|
||||
\fB\-\-benchmark\fR=\fIURL\fR
|
||||
Benchmark the download speed by transferring the first 10 MB and reporting
|
||||
the average throughput.
|
||||
.TP
|
||||
\fB\-\-show\-metadata\fR=\fIPATH\fR
|
||||
Display the contents of a
|
||||
.B .goget.meta
|
||||
sidecar for debugging interrupted or resumed downloads. Accepts either a
|
||||
path to the downloaded file (the sidecar is located next to it) or a direct
|
||||
path to the sidecar. Renders a human-readable summary by default; combines
|
||||
with
|
||||
.B \-\-json
|
||||
for raw output. Credentials embedded in the stored URL are masked.
|
||||
.TP
|
||||
\fB\-\-generate\-man\-page\fR
|
||||
Print this manual page in troff format to standard output and exit.
|
||||
.TP
|
||||
\fB\-\-completion\fR=\fISHELL\fR
|
||||
Print a shell completion script for
|
||||
.IR SHELL \(lqbbash\(rqq, \(rqqzsh\(rqq, or \(rqqfish\(rqq\(en to standard output and exit.
|
||||
.TP
|
||||
\fB\-\-help\fR
|
||||
Print the short help summary and exit.
|
||||
.TP
|
||||
\fB\-\-version\fR
|
||||
Print the program name and version and exit.
|
||||
.SH EXAMPLES
|
||||
.SS "Simple download"
|
||||
.PP
|
||||
Download a single file to the current directory:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
goget \-\-url https://example.com/file.zip
|
||||
.fi
|
||||
.RE
|
||||
.SS "Parallel chunks and resume"
|
||||
.PP
|
||||
Files larger than 100 MB are automatically split into parallel chunks. To
|
||||
force four connections explicitly and resume an interrupted transfer:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
goget \-\-url https://example.com/large.iso \-\-parallel 4
|
||||
goget \-\-url https://example.com/file.zip \-\-resume
|
||||
.fi
|
||||
.RE
|
||||
.SS "Verification"
|
||||
.PP
|
||||
Verify a download against a known checksum and PGP signature:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
goget \-\-url https://example.com/release.tar.gz \\
|
||||
\-\-checksum\-algo sha256 \-\-checksum 5b9d...e2 \\
|
||||
\-\-pgp\-verify \-\-pgp\-sig release.tar.gz.asc \\
|
||||
\-\-pgp\-key maintainer.asc
|
||||
.fi
|
||||
.RE
|
||||
.SS "Recursive mirror"
|
||||
.PP
|
||||
Mirror a documentation site, convert links for offline use, and respect
|
||||
.IR robots.txt :
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
goget \-\-url https://example.com/docs/ \\
|
||||
\-\-mirror \-\-convert\-links \-\-output ./mirror
|
||||
.fi
|
||||
.RE
|
||||
.SS "Scripting with JSON progress"
|
||||
.PP
|
||||
Stream machine-readable progress to a file for a downstream tool:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
goget \-\-url https://example.com/data.csv \\
|
||||
\-\-json \-\-no\-progress > progress.jsonl
|
||||
.fi
|
||||
.RE
|
||||
.SS "Scheduled download with a post-hook"
|
||||
.PP
|
||||
Fetch a daily report at 02:00 and feed it into a processor:
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
goget \-\-url https://example.com/daily.zip \\
|
||||
\-\-schedule "0 2 * * *" \\
|
||||
\-\-on\-complete "import.sh"
|
||||
.fi
|
||||
.RE
|
||||
.SS "Batch file"
|
||||
.PP
|
||||
Read URLs from a file (one per line; lines starting with
|
||||
.B #
|
||||
are comments):
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
goget \-\-batch\-file urls.txt
|
||||
.fi
|
||||
.RE
|
||||
.SH EXIT STATUS
|
||||
.TP
|
||||
.B 0
|
||||
Success.
|
||||
.TP
|
||||
.B 1
|
||||
Generic failure (network error, protocol error, invalid input).
|
||||
.TP
|
||||
.B 2
|
||||
Invalid command-line arguments or configuration.
|
||||
.TP
|
||||
.B 3
|
||||
File I/O error (cannot write output, cannot read source).
|
||||
.TP
|
||||
.B 4
|
||||
Authentication failure.
|
||||
.TP
|
||||
.B 5
|
||||
TLS or certificate error (including pinning mismatch).
|
||||
.TP
|
||||
.B 6
|
||||
Checksum or PGP verification failed.
|
||||
.TP
|
||||
.B 7
|
||||
Operation cancelled (SIGINT, SIGTERM, context cancellation).
|
||||
.TP
|
||||
.B 8
|
||||
Resource limit exceeded
|
||||
.RB ( \-\-max\-filesize ", " \-\-max\-time ", etc.).
|
||||
.PP
|
||||
Only the generic
|
||||
.B 0
|
||||
and
|
||||
.B 1
|
||||
codes are guaranteed; the project reserves the other values and their
|
||||
meaning is part of the stable interface.
|
||||
.SH FILES
|
||||
.TP
|
||||
.I ~/.config/goget/config.toml
|
||||
Default configuration file. Overridden by
|
||||
.B \-\-config
|
||||
or the
|
||||
.B GOGET_CONFIG
|
||||
environment variable.
|
||||
.TP
|
||||
.I ~/.config/goget/queue.json
|
||||
Persistent download queue. Path can be overridden with
|
||||
.BR \-\-queue\-file .
|
||||
.TP
|
||||
.I ~/.config/goget/hsts
|
||||
HSTS cache (RFC 6797). Path can be overridden with
|
||||
.BR \-\-hsts\-file .
|
||||
.TP
|
||||
.I ~/.ssh/known_hosts
|
||||
Default SFTP host-key database. Path can be overridden with
|
||||
.BR \-\-known\-hosts .
|
||||
.TP
|
||||
.I <output>.goget.meta
|
||||
Resume metadata sidecar written next to a partial output file. Deleted on
|
||||
successful completion; consulted by
|
||||
.B \-\-resume
|
||||
or
|
||||
.BR \-\-continue .
|
||||
.TP
|
||||
.I ~/.netrc
|
||||
Auto-loaded credentials. Disabled for the current invocation by
|
||||
.BR \-\-no\-private .
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
.B GOGET_CONFIG
|
||||
Path to the configuration file. Takes precedence over
|
||||
.IR ~/.config/goget/config.toml
|
||||
and
|
||||
.BR \-\-config .
|
||||
.TP
|
||||
.B NO_COLOR
|
||||
When set to a non-empty value, colour output is disabled regardless of the
|
||||
.BR color " setting in the config file (see " https://no\-color.org )."
|
||||
.TP
|
||||
.B SSLKEYLOGFILE
|
||||
Equivalent to passing
|
||||
.B \-\-ssl\-key\-log
|
||||
with the same value. Used by Wireshark to decrypt TLS traffic.
|
||||
.TP
|
||||
.B GOGET_OUTPUT
|
||||
Exported into the environment of
|
||||
.B \-\-on\-complete
|
||||
hooks; contains the absolute path of the downloaded file.
|
||||
.TP
|
||||
.B GOGET_SIZE
|
||||
Exported into
|
||||
.B \-\-on\-complete
|
||||
hooks; contains the downloaded size in bytes.
|
||||
.TP
|
||||
.B GOGET_URL
|
||||
Exported into
|
||||
.B \-\-on\-complete
|
||||
hooks; contains the source URL with credentials stripped
|
||||
.RB ( core.SafeURL ).
|
||||
.TP
|
||||
.B HOME
|
||||
Used to locate
|
||||
.I ~/.config/goget/
|
||||
and
|
||||
.IR ~/.ssh/known_hosts .
|
||||
.TP
|
||||
.B HTTP_PROXY
|
||||
.IR "https_proxy" ", " ALL_PROXY ", " NO_PROXY
|
||||
Standard proxy environment variables; respected unless
|
||||
.B \-\-proxy
|
||||
is given explicitly.
|
||||
.SH "SEE ALSO"
|
||||
.BR curl (1),
|
||||
.BR wget (1),
|
||||
.BR sftp (1),
|
||||
.BR ssh\-keygen (1),
|
||||
.BR groff (1),
|
||||
.BR man (1)
|
||||
.PP
|
||||
Project home page and issue tracker:
|
||||
.RI < https://codeberg.org/petrbalvin/goget >
|
||||
.PP
|
||||
Full documentation set:
|
||||
.RS
|
||||
.TP
|
||||
.I docs/configuration.md
|
||||
Configuration schema.
|
||||
.TP
|
||||
.I docs/cli-reference.md
|
||||
Full flag reference.
|
||||
.TP
|
||||
.I docs/protocols.md
|
||||
Protocol handler details.
|
||||
.TP
|
||||
.I docs/recursive-mirror.md
|
||||
Recursive and mirror behaviour.
|
||||
.TP
|
||||
.I docs/security.md
|
||||
Threat model and security controls.
|
||||
.TP
|
||||
.I docs/api.md
|
||||
Public Go library API.
|
||||
.RE
|
||||
.SH HISTORY
|
||||
goget 1.0.0 (2026-06-26) is the first public release.
|
||||
The project was created as a modern, IPv6-first, Go-stdlib-only replacement
|
||||
for
|
||||
.BR curl (1)
|
||||
and
|
||||
.BR wget (1).
|
||||
.SH AUTHORS
|
||||
Petr Balvín
|
||||
.RI < opensource@petrbalvin.org >
|
||||
\(em upstream maintainer.
|
||||
.PP
|
||||
See
|
||||
.RI < https://codeberg.org/petrbalvin/goget >
|
||||
for the full contributor list.
|
||||
.SH "REPORTING BUGS"
|
||||
Report bugs to the issue tracker at
|
||||
.RI < https://codeberg.org/petrbalvin/goget/issues >.
|
||||
.PP
|
||||
For security issues, do not open a public issue; email
|
||||
.RI < opensource@petrbalvin.org >
|
||||
instead.
|
||||
.SH COPYRIGHT
|
||||
Copyright \(co 2026 Petr Balvín.
|
||||
.PP
|
||||
Licensed under the MIT License.
|
||||
Permission is granted to use, copy, modify, and distribute this software
|
||||
without restriction, provided that the above copyright notice and this
|
||||
permission notice appear in all copies.
|
||||
.PP
|
||||
.B goget
|
||||
is provided
|
||||
.B "AS IS"
|
||||
without any warranty of any kind. See the MIT License text distributed with
|
||||
the source for the full disclaimer.
|
||||
Reference in New Issue
Block a user