28 Commits
Author SHA1 Message Date
petrbalvin b5fa009ec5 fix(ci): copy manpage before vet/build/test in CI workflows 2026-06-30 21:46:12 +02:00
petrbalvin bff619bf84 chore: prepare release v1.1.0 2026-06-30 21:10:46 +02:00
petrbalvin 21ac1f7487 docs: sync documentation with v1.1.0 protocol and CLI changes 2026-06-30 20:43:00 +02:00
petrbalvin 185e6ec20a test(gemini): verify cancel goroutine exits when Download returns 2026-06-30 20:21:00 +02:00
petrbalvin 3752b3d3eb fix(gemini): reject resume when caller supplies Writer 2026-06-30 20:09:54 +02:00
petrbalvin c1373f0d61 fix(gemini): bound cancel goroutine to Download lifetime 2026-06-30 19:24:45 +02:00
petrbalvin 49012fbf82 fix(gemini-test): use ProgressCallback to make resume tests deterministic 2026-06-30 18:40:56 +02:00
petrbalvin 4268fa5d5f test(gopher): verify cancel goroutine exits when Download returns 2026-06-30 14:54:52 +02:00
petrbalvin 88e210daaf fix(gopher): bound cancel goroutine to Download lifetime 2026-06-30 14:01:11 +02:00
petrbalvin 3384e2b344 test(gopher): add resume coverage for typeInformation 2026-06-30 11:55:52 +02:00
petrbalvin d3b6451472 test(gopher): assert orphan metadata is cleaned up 2026-06-30 11:32:09 +02:00
petrbalvin 9066acc325 chore(gopher): extract dial timeout constant 2026-06-30 11:13:49 +02:00
petrbalvin f549a862f8 chore(gopher): remove unused lineCount variable 2026-06-30 11:12:48 +02:00
petrbalvin e20f221ead chore(gopher): simplify file creation error handling 2026-06-30 11:09:48 +02:00
petrbalvin 30f62d3cc9 chore(gopher): use int() conversion for slice index consistency with Gemini 2026-06-30 11:07:56 +02:00
petrbalvin 535496e6e1 fix(gopher): reject resume when caller supplies Writer 2026-06-30 11:07:46 +02:00
petrbalvin dd46aa10f3 fix(gopher-test): use ProgressCallback to make resume tests deterministic 2026-06-30 11:06:48 +02:00
petrbalvin 2dbbef3e30 fix(gopher): preserve partial state when EOF follows context cancellation 2026-06-30 11:05:27 +02:00
petrbalvin 760593cdb9 feat(gopher): add resume and metadata support 2026-06-30 18:43:01 +02:00
petrbalvin 9867e2817e docs: extract goget manpage to standalone troff source 2026-06-29 21:16:28 +02:00
petrbalvin 9ef62520c7 feat: add resume support to Gemini protocol 2026-06-29 20:06:08 +02:00
petrbalvin fc65ea8340 feat: extend certificate pinning to all TLS protocols and add SSH
host-key pinning
2026-06-29 19:23:10 +02:00
petrbalvin e7ed3a334e feat: implement --show-metadata option 2026-06-29 17:54:14 +02:00
petrbalvin 11aed3771d feat: add unified worker pool for recursive downloads 2026-06-29 15:30:31 +02:00
petrbalvin 1672698936 chore: update BACKLOG with v1.1.0 progress and restated roadmap 2026-06-29 12:30:28 +02:00
petrbalvin f915299fa4 feat: add --backup-converted flag to keep .orig backups 2026-06-29 11:56:09 +02:00
petrbalvin 6e58502598 feat: use ContentType to control HTML extension 2026-06-29 11:55:11 +02:00
petrbalvin 277178776d feat: persist resume metadata on SFTP single-file cancel 2026-06-29 11:53:14 +02:00
46 changed files with 4450 additions and 818 deletions
+3
View File
@@ -35,6 +35,9 @@ jobs:
- name: Download dependencies - name: Download dependencies
run: go mod download run: go mod download
- name: Copy manpage for //go:embed
run: cp man/goget.1 cmd/goget/man-page-data/goget.1
- name: Build - name: Build
id: build id: build
env: env:
+9
View File
@@ -19,6 +19,9 @@ jobs:
- name: Download dependencies - name: Download dependencies
run: go mod download run: go mod download
- name: Copy manpage for //go:embed
run: cp man/goget.1 cmd/goget/man-page-data/goget.1
- name: gofmt - name: gofmt
# `gofmt -l` prints the names of unformatted files. An empty # `gofmt -l` prints the names of unformatted files. An empty
# output is the only acceptable result. # output is the only acceptable result.
@@ -40,6 +43,9 @@ jobs:
- name: Download dependencies - name: Download dependencies
run: go mod download 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 - name: go test -race
run: go test -race -count=1 ./... run: go test -race -count=1 ./...
@@ -65,6 +71,9 @@ jobs:
- name: Download dependencies - name: Download dependencies
run: go mod download 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 - name: go test -fuzz
run: | run: |
for pkg in $(go list ./...); do for pkg in $(go list ./...); do
+7
View File
@@ -3,6 +3,13 @@
# Build output # Build output
bin/ 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 artifacts
*.test *.test
*.out *.out
+149 -80
View File
@@ -9,35 +9,37 @@ move to `CHANGELOG.md` when the version ships.
## v1.1.0 — Protocol completeness ## v1.1.0 — Protocol completeness
Finish the protocol layer so every supported protocol handles 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 ### 🔴 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. SIGINT for FTP single-file and SFTP parallel recursive paths.
- [ ] **WebDAV connection pooling**shared `http.Transport` across WebDAV - [x] **`--pinned-cert` for all TLS protocols** — extend certificate
requests instead of a fresh transport per request (currently defeats HTTP pinning to FTPS, Gemini, and WebDAVS (currently HTTP/HTTPS only).
keep-alive, TLS session resumption, and HTTP/2 multiplexing). 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 ### 🟡 High Priority
- [ ] **WebDAV recursive parallel downloads** — extend `--recursive-parallel` - [x] **Gemini resume & metadata** — persist `.goget.meta` during Gemini
to WebDAV using the shared transport. downloads; `--resume` support. Currently Gemini has `Features: []`
- [ ] **SFTP recursive parallel downloads** — parallel SFTP recursive with it doesn't advertise resume, and the download loop doesn't save
session pooling. progress.
- [ ] **FTP recursive parallel** — connection pool for independent control - [x] **Gopher resume & metadata** — same as Gemini. Gopher `typeHTML`
connections in FTP recursive mode. and `typeTextFile` responses should save `.goget.meta` for resume.
- [ ] **`--dry-run` for WebDAV recursive** — list matched entries without
downloading.
### 🟢 Medium Priority ### 🟢 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. 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. before link conversion.
- [ ] **Parallel recursive across all protocols**unified worker pool - [x] **`--show-metadata`** — display `.goget.meta` contents for debugging
independent of protocol handler. 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. 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 ### 🟢 Medium Priority
- [ ] **`--progress=bar:force:noscroll`** — curl-compatible progress options. - [ ] **`--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. of writing to disk.
- [ ] **Bandwidth throttling per-domain** — global rate limit only at present. - [ ] **Bandwidth throttling per-domain** — global rate limit only at present.
- [ ] **MIME type detection for local files** — determine Content-Type from - [ ] **MIME type detection for local files** — determine Content-Type from
file extension when uploading. file extension when uploading (`mime.TypeByExtension` from stdlib covers this).
- [ ] **`--cut-dirs` for recursive mode** — strip directory components from
output paths.
- [ ] **Notification hooks**`--on-error` with richer environment variables - [ ] **Notification hooks**`--on-error` with richer environment variables
(exit code, error type, checksum). (exit code, error type, checksum).
- [ ] **Retry with backoff visualization** — show retry attempts in progress - [ ] **Retry with backoff visualization** — show retry attempts in progress
bar (attempt 2/3, waiting 4s...). 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.
### 🔵 Lower Priority ### 🔵 Lower Priority
- [ ] **URL glob expansion (`--glob`)** — expand `[1-3]` and `{a,b}` patterns - [ ] **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 ### 🔴 Supreme Priority
- [ ] **PGP replacement**`golang.org/x/crypto/openpgp` is frozen (archived - [ ] **PGP replacement**`golang.org/x/crypto/openpgp` is frozen
by Go team). Evaluate: inline a minimal subset, or wait for a community (archived by Go team). Use `github.com/ProtonMail/go-crypto`
`golang.org/x/` successor. (BSD-3-Clause) as the replacement — the only permitted third-party
dependency exception for the Go codebase.
### 🟡 High Priority ### 🟡 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 - [ ] **SOCKS5 proxy for non-HTTP protocols** — FTP and SFTP through SOCKS5
proxy (currently HTTP/HTTPS only). proxy (currently HTTP/HTTPS and WebDAV only). `golang.org/x/net/proxy`
- [ ] **DNS-over-HTTPS (DoH)** — optional DoH resolver. Must use only provides the SOCKS5 dialer; SFTP is straightforward, FTP needs
`golang.org/x/` libraries. 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 ### 🟢 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 - [ ] **Content-addressable cache (CAS)** — every downloaded file is stored
(covers AWS S3, MinIO, and any S3-compatible storage). under its SHA-256 digest. `goget` before hitting the network consults
- [ ] **Huawei OBS** — Huawei Cloud Object Storage. the cache. Identical content from different URLs, protocols, or
- [ ] **Alibaba OSS** — Alibaba Cloud Object Storage Service. projects hits the cache exactly once.
- [ ] **Tencent COS**Tencent Cloud Object Storage. - [ ] **`crate://` protocol** — `crate://serde@1.0.0` → fetches `.crate`
- [ ] **Rsync** — incremental file transfer. Written from scratch from crates.io API. `crate://serde@latest` → resolves latest version.
(rolling checksum, delta transfer, rsync wire protocol). - [ ] **`gem://` protocol** — `gem://rails@7.2` → fetches `.gem` from
- [ ] **TFTP** — trivial file transfer, useful for embedded devices and PXE. RubyGems.org.
- [ ] **NFS**NFS v3/v4 client. Written from scratch - [ ] **`npm://` protocol** — `npm://react@19` → fetches tarball from
(ONC RPC/XDR, portmapper, mount protocol, NFS protocol). registry.npmjs.org.
- [ ] **goget-as-a-service**daemon mode with REST API for remote download - [ ] **`git://` protocol** — `git://github.com/user/repo@v1.0` → archive
management. 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. - [ ] **Plugin system** — protocol handlers as external binaries.
- [ ] **TUI (Terminal User Interface)** — interactive download manager with Discovered from `~/.config/goget/plugins/`. Line-based protocol
live queue, progress, and log views. Keyboard navigation, color-coded over stdin/stdout or Unix socket.
status, inline log viewer. - [ ] **`plugin://` protocol** — transparent proxy to external handlers.
- [ ] **Web GUI** — browser-based download manager (Vue 3 frontend, REST API,
live progress via SSE). ### 🟡 High Priority
- [ ] **Download speed test / benchmark mode** — measure maximum throughput
against a target server without saving the file. - [ ] **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) ## 💡 Backlog (unscheduled)
Items that need more design, depend on external factors, or are Items needing more design, dependent on external factors, or deferred
lower priority than the versioned roadmap. 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 ### Performance
- [ ] **Memory-mapped I/O for large files**`mmap` for checksum - [ ] **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)` / - [ ] **Zero-copy for `file://` and `data:` protocols**`sendfile(2)` /
`splice(2)` where available on Linux. `splice(2)` where available on Linux.
- [ ] **HTTP/3 (QUIC)** — when Go stdlib gains QUIC support. - [ ] **HTTP/3 (QUIC)** — when Go stdlib gains QUIC support.
### Advanced UX ### Protocols (rare / niche)
- [ ] **Scheduling — pause/resume queued downloads** — ability to pause the - [ ] **Rsync** — rolling checksum, delta transfer, wire protocol from scratch.
queue, reorder items, and resume later. - [ ] **TFTP** — trivial file transfer, embedded devices and PXE.
- [ ] **NFS** — NFS v3/v4 client from scratch (ONC RPC/XDR, portmapper).
### Code Quality & Testing ### Code Quality & Testing
- [ ] **Integration test matrix** — CI against real FTP, SFTP, and WebDAV - [ ] **Integration test matrix** — CI against real FTP, SFTP, WebDAV servers.
servers.
- [ ] **Coverage threshold increase** — raise from 40% to 60%. - [ ] **Coverage threshold increase** — raise from 40% to 60%.
- [ ] **Benchmark suite**`go test -bench` in CI. - [ ] **Benchmark suite**`go test -bench` in CI.
- [ ] **Fuzzing corpus in CI** — store and replay past crashes automatically. - [ ] **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. - [ ] **RPM spec file** — Fedora / RHEL / openEuler packaging.
- [ ] **FreeBSD port** — official FreeBSD ports tree entry. - [ ] **FreeBSD port** — official FreeBSD ports tree entry.
- [ ] **systemd unit** — for daemon / scheduled mode.
### Documentation ### Documentation
- [ ] **Man page packaging** — already generated by `--generate-man-page`, - [ ] **Man page packaging** — already generated, needs installation via packaging.
needs installation via packaging. - [ ] **Architecture decision records** — document design choices.
- [ ] **Architecture decision records** — document why specific choices were
made (no short flags, IPv6-first, long flags only, no global state).
- [ ] **Protocol-specific guides** — one doc per protocol with examples. - [ ] **Protocol-specific guides** — one doc per protocol with examples.
- [ ] **Troubleshooting guide** — common errors and solutions. - [ ] **Troubleshooting guide** — common errors and solutions.
+129 -1
View File
@@ -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/), 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). 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 ## [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. - `CONTRIBUTING.md` with Conventional Commits and release process.
[1.0.0]: https://codeberg.org/petrbalvin/goget/releases/tag/v1.0.0 [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
View File
@@ -121,10 +121,12 @@ goget/
├── cmd/ ├── cmd/
│ └── goget/ # CLI entry point │ └── goget/ # CLI entry point
│ ├── main.go # Core run loop, flag setup, orchestrator │ ├── main.go # Core run loop, flag setup, orchestrator
│ ├── app.go # Application wiring
│ ├── commands.go # Command handlers (download, upload, queue, config, etc.) │ ├── commands.go # Command handlers (download, upload, queue, config, etc.)
│ ├── flags.go # CLI flag definitions (struct + defineFlags) │ ├── flags.go # CLI flag definitions (struct + defineFlags)
│ ├── completion.go # Shell completion generators (bash, zsh, fish) │ ├── 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 │ ├── schedule.go # Cron and datetime scheduling
│ └── verify.go # Checksum and PGP verification │ └── verify.go # Checksum and PGP verification
├── internal/ ├── internal/
@@ -138,11 +140,12 @@ goget/
│ ├── crypto/ # Checksum verification, PGP, certificate handling │ ├── crypto/ # Checksum verification, PGP, certificate handling
│ ├── format/ # Human-readable formatting (bytes, speed) │ ├── format/ # Human-readable formatting (bytes, speed)
│ ├── hsts/ # HSTS cache (RFC 6797) │ ├── hsts/ # HSTS cache (RFC 6797)
│ ├── log/ # Structured logger (text/JSON output, level filtering)
│ ├── linkrewrite/ # HTML link rewriting for offline mirroring │ ├── linkrewrite/ # HTML link rewriting for offline mirroring
│ ├── log/ # Structured logger (text/JSON output, level filtering)
│ ├── metalink/ # Metalink (multi-source downloads) │ ├── metalink/ # Metalink (multi-source downloads)
│ ├── mirror/ # Website mirroring logic │ ├── mirror/ # Website mirroring logic
│ ├── output/ # Output writing (atomic writes, resume metadata) │ ├── output/ # Output writing (atomic writes, resume metadata)
│ ├── pool/ # Unified recursive worker pool (FTP, SFTP, WebDAV)
│ ├── protocol/ # Protocol abstraction layer │ ├── protocol/ # Protocol abstraction layer
│ │ ├── http/ # HTTP/HTTPS client, parallel download, upload │ │ ├── http/ # HTTP/HTTPS client, parallel download, upload
│ │ ├── ftp/ # FTP/FTPS client │ │ ├── ftp/ # FTP/FTPS client
@@ -151,11 +154,12 @@ goget/
│ │ ├── dataurl/ # data: URI protocol │ │ ├── dataurl/ # data: URI protocol
│ │ ├── gopher/ # Gopher protocol │ │ ├── gopher/ # Gopher protocol
│ │ ├── gemini/ # Gemini protocol │ │ ├── gemini/ # Gemini protocol
│ │ ── webdav/ # WebDAV protocol │ │ ── webdav/ # WebDAV protocol
│ │ └── register/ # Single registration entry point
│ ├── queue/ # Download queue management │ ├── queue/ # Download queue management
│ ├── recursive/ # Recursive download (HTML parser, URL filter, crawler) │ ├── 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/ ├── pkg/
│ └── api/ # Public library API (Client, Download, Upload, Queue) │ └── api/ # Public library API (Client, Download, Upload, Queue)
└── docs/ # Architecture and design documentation └── docs/ # Architecture and design documentation
@@ -189,11 +193,14 @@ graph TD
Metalink[internal/metalink/] Metalink[internal/metalink/]
QueueInt[internal/queue/] QueueInt[internal/queue/]
Archive[internal/archive/] Archive[internal/archive/]
Pool[internal/pool/ - Unified worker pool]
Log[internal/log/ - Structured logger]
API[pkg/api/ - Public Library] API[pkg/api/ - Public Library]
CLI --> Flags CLI --> Flags
CLI --> Config CLI --> Config
CLI --> Core CLI --> Core
CLI --> Log
CLI --> Auth CLI --> Auth
CLI --> Crypto CLI --> Crypto
CLI --> Recursive CLI --> Recursive
@@ -214,6 +221,9 @@ graph TD
HTTP --> Cookie HTTP --> Cookie
HTTP --> HSTS HTTP --> HSTS
HTTP --> Output HTTP --> Output
FTP --> Pool
SFTP --> Pool
WebDAV --> Pool
API --> Core API --> Core
API --> Registry API --> Registry
``` ```
+20 -1
View File
@@ -40,7 +40,7 @@ It is designed to be:
- **Parallel chunked downloads** — automatically splits files over 100 MB into - **Parallel chunked downloads** — automatically splits files over 100 MB into
concurrent chunks using byte-range requests. concurrent chunks using byte-range requests.
- **Resume interrupted transfers** — metadata-driven resume with `.goget.meta` - **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 - **Recursive downloading & mirroring** — follow links, filter by pattern or
MIME type, limit depth, stay within domain. Full site mirrors with link MIME type, limit depth, stay within domain. Full site mirrors with link
conversion for offline viewing and `robots.txt` compliance. conversion for offline viewing and `robots.txt` compliance.
@@ -130,6 +130,14 @@ goget --rate-limit 1MB/s --url https://example.com/file.zip
# Proxy # Proxy
goget --proxy http://proxy.example.com:8080 --url https://example.com/file.zip 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 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 ### Recursive & Mirroring
@@ -178,6 +186,10 @@ goget --on-complete "process.sh" --url https://example.com/data.csv
# JSON progress for scripting # JSON progress for scripting
goget --json --no-progress --url https://example.com/file.zip 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 # Shell completion
goget --completion bash > /etc/bash_completion.d/goget goget --completion bash > /etc/bash_completion.d/goget
goget --completion zsh > /usr/share/zsh/site-functions/_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 run # run from source (development)
just build # go vet + gofmt check + build → bin/goget just build # go vet + gofmt check + build → bin/goget
just test # go test -race -count=1 ./... 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 # remove build artifacts
just uninstall-man# remove installed manpage
``` ```
--- ---
## Documentation ## 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/architecture.md`](docs/architecture.md) — components and request flow
- [`docs/configuration.md`](docs/configuration.md) — config schema (TOML) - [`docs/configuration.md`](docs/configuration.md) — config schema (TOML)
- [`docs/api.md`](docs/api.md) — public Go library API - [`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/download-pipeline.md`](docs/download-pipeline.md) — download flow
- [`docs/authentication.md`](docs/authentication.md) — auth mechanisms - [`docs/authentication.md`](docs/authentication.md) — auth mechanisms
- [`docs/security.md`](docs/security.md) — threat model and controls - [`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 ## Contributing
+5
View File
@@ -143,6 +143,9 @@ func (a *app) dispatch() int {
if *f.Spider { if *f.Spider {
return handleSpider(*f.URL, cfg) return handleSpider(*f.URL, cfg)
} }
if *f.ShowMetadata != "" {
return handleShowMetadata(*f.ShowMetadata, *f.JSON)
}
if *f.GenManPage { if *f.GenManPage {
return handleGenManPage() return handleGenManPage()
} }
@@ -603,6 +606,8 @@ func (a *app) execute() int {
Ctx: ctx, Ctx: ctx,
SSHKnownHosts: *f.KnownHosts, SSHKnownHosts: *f.KnownHosts,
SSHInsecure: *f.SSHInsecure, SSHInsecure: *f.SSHInsecure,
PinnedCertHash: *f.PinnedCert,
PinnedHostKey: *f.PinnedHostKey,
} }
// Configure TLS for WebDAV if supported. // Configure TLS for WebDAV if supported.
+101
View File
@@ -24,6 +24,7 @@ import (
"codeberg.org/petrbalvin/goget/internal/core" "codeberg.org/petrbalvin/goget/internal/core"
format "codeberg.org/petrbalvin/goget/internal/format" format "codeberg.org/petrbalvin/goget/internal/format"
"codeberg.org/petrbalvin/goget/internal/metalink" "codeberg.org/petrbalvin/goget/internal/metalink"
"codeberg.org/petrbalvin/goget/internal/output"
"codeberg.org/petrbalvin/goget/internal/protocol" "codeberg.org/petrbalvin/goget/internal/protocol"
httpConfig "codeberg.org/petrbalvin/goget/internal/protocol/http" httpConfig "codeberg.org/petrbalvin/goget/internal/protocol/http"
"codeberg.org/petrbalvin/goget/internal/queue" "codeberg.org/petrbalvin/goget/internal/queue"
@@ -845,6 +846,106 @@ func handleSpider(urlStr string, cfg *config.Config) int {
return 0 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 // handleGenManPage generates a man page for goget
// handleInitConfig generates a default config file // handleInitConfig generates a default config file
+5 -1
View File
@@ -150,6 +150,7 @@ type Flags struct {
Referer *string Referer *string
RetryDelay *time.Duration RetryDelay *time.Duration
PinnedCert *string PinnedCert *string
PinnedHostKey *string
AllowPrivate *bool AllowPrivate *bool
Range *string Range *string
ConnectTimeout *time.Duration ConnectTimeout *time.Duration
@@ -169,6 +170,7 @@ type Flags struct {
ProtoDirs *bool ProtoDirs *bool
HSTSFile *string HSTSFile *string
CreateDirs *bool CreateDirs *bool
ShowMetadata *string
} }
// defineFlags registers all CLI flags on the given FlagSet and returns them. // 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"), Compressed: fs.Bool("compressed", false, "Send Accept-Encoding header for auto-decompression"),
Referer: fs.String("referer", "", "Send Referer header"), Referer: fs.String("referer", "", "Send Referer header"),
RetryDelay: fs.Duration("retry-delay", 0, "Delay between retry attempts"), 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)"), 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)"), Range: fs.String("range", "", "Download only a range of bytes (e.g., 0-1000)"),
ConnectTimeout: fs.Duration("connect-timeout", 0, "Timeout for connection establishment"), 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)"), InputFile: fs.String("input-file", "", "Read URLs from file (use - for stdin)"),
HSTSFile: fs.String("hsts-file", "", "HSTS cache file (default: ~/.config/goget/hsts)"), 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)"), 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.Headers, "header", "Custom HTTP header (repeatable, e.g. --header 'Key: Value')")
fs.Var(&f.Cookies, "cookie", "Set cookie (repeatable, e.g. --cookie 'name=value')") fs.Var(&f.Cookies, "cookie", "Set cookie (repeatable, e.g. --cookie 'name=value')")
+19 -2
View File
@@ -130,9 +130,12 @@ func runPostDownload(
fmt.Fprintf(os.Stderr, "Warning: failed to close writer: %v\n", cerr) 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 *f.AdjustExt && *f.Output != "" {
if filepath.Ext(*f.Output) == "" { if filepath.Ext(*f.Output) == "" && isHTMLContentType(result.ContentType) {
newPath := *f.Output + ".html" newPath := *f.Output + ".html"
if err := os.Rename(*f.Output, newPath); err == nil { if err := os.Rename(*f.Output, newPath); err == nil {
*f.Output = newPath *f.Output = newPath
@@ -289,6 +292,20 @@ func runPostDownload(
return 0 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. // 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. // Used to parse --on-complete and --on-complete-url without invoking a shell.
func splitCommand(raw string) []string { func splitCommand(raw string) []string {
+232
View File
@@ -6,6 +6,7 @@ package main
import ( import (
"context" "context"
"flag" "flag"
"io"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -13,6 +14,7 @@ import (
"time" "time"
"codeberg.org/petrbalvin/goget/internal/config" "codeberg.org/petrbalvin/goget/internal/config"
"codeberg.org/petrbalvin/goget/internal/output"
) )
func TestExtractConfigPath(t *testing.T) { func TestExtractConfigPath(t *testing.T) {
@@ -379,3 +381,233 @@ func TestCreateDirsFlagRegisteredAndDefaults(t *testing.T) {
func newTestFlagSet(name string) *flag.FlagSet { func newTestFlagSet(name string) *flag.FlagSet {
return flag.NewFlagSet(name, flag.ContinueOnError) 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)
}
}
+3
View File
@@ -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
View File
@@ -1,217 +1,24 @@
//go:build linux || freebsd //go:build linux || freebsd
// +build linux freebsd
package main package main
import ( import (
_ "embed"
"fmt" "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 { func handleGenManPage() int {
fmt.Printf(`.TH GOGET 1 "2026-06" "goget %s" "User Commands" fmt.Print(manPage)
.SH NAME
goget \- modern IPv6-first command-line download utility
.SH SYNOPSIS
.B goget
\fB\-\-url\fR \fIURL\fR [\fIOPTIONS\fR]
.SH DESCRIPTION
goget is a modern replacement for curl and wget built on Go stdlib.
Features IPv6-first connections, parallel downloads, resume support,
compression, checksum verification, and recursive mirroring.
.SH OPTIONS
.SS "Target"
.TP
\fB\-\-url\fR=\fIURL\fR
URL to download (can be specified multiple times)
.SS "Basic Options"
.TP
\fB\-\-output\fR=\fIFILE\fR
Output filename (default: auto from URL)
.TP
\fB\-\-verbose\fR
Verbose output with progress bar (default: enabled)
.TP
\fB\-\-no-progress\fR
Hide progress bar (keep other output)
.TP
\fB\-\-quiet\fR
Suppress all output except errors
.TP
\fB\-\-debug\fR
Debug mode with detailed info
.TP
\fB\-\-resume\fR
Resume interrupted download
.TP
\fB\-\-restart\fR
Delete existing file and restart download
.TP
\fB\-\-overwrite\fR
Overwrite existing file
.TP
\fB\-\-no-decompress\fR
Disable automatic decompression
.TP
\fB\-\-parallel\fR=\fIN\fR
Parallel connections (0=auto, 1=sequential)
.TP
\fB\-\-json\fR
Output progress as JSON (for scripting)
.TP
\fB\-\-info\fR
Show file information without downloading
.TP
\fB\-\-benchmark\fR=\fIURL\fR
Benchmark download speed
.SS "Network"
.TP
\fB\-\-timeout\fR=\fIDURATION\fR
Connection timeout (0 = auto based on size)
.TP
\fB\-\-proxy\fR=\fIURL\fR
Proxy server URL (http://, https://, socks5://, socks5h://)
.TP
\fB\-\-dns-servers\fR=\fIIPs\fR
Custom DNS servers (comma-separated)
.TP
\fB\-\-max-retries\fR=\fIN\fR
Maximum number of retries on failure
.TP
\fB\-\-no-ipv4\fR
Disable IPv4 fallback
.TP
\fB\-\-no-redirect\fR
Disable following HTTP redirects
.TP
\fB\-\-show-headers\fR
Print HTTP response headers
.TP
\fB\-\-keep-timestamps\fR
Set file modification time from Last-Modified header
.TP
\fB\-\-content-disposition\fR
Use server-provided filename from Content-Disposition header
.SS "Authentication"
.TP
Credentials are loaded automatically from \fB~/.netrc\fR when no \fB\-\-username\fR is given.
.TP
\fB\-\-username\fR=\fIUSER\fR
Username for HTTP Basic/Digest Auth
.TP
\fB\-\-password\fR=\fIPASS\fR
Password for HTTP Basic/Digest Auth
.TP
\fB\-\-password-file\fR=\fIFILE\fR
Read password from file
.TP
\fB\-\-auth-type\fR=\fITYPE\fR
Auth type: basic, digest, auto
.TP
\fB\-\-cookie-jar\fR=\fIFILE\fR
File for loading/saving cookies
.TP
\fB\-\-cookie\fR=\fINAME=VALUE\fR
Set cookie from CLI (repeatable, curl-compatible)
.TP
\fB\-\-retry-all-errors\fR
Retry on any HTTP 4xx/5xx response
.TP
\fB\-\-ssl-key-log\fR=\fIFILE\fR
Log TLS session keys to file (SSLKEYLOGFILE for Wireshark)
.SS "Config"
.TP
\fB\-\-config\fR=\fIFILE\fR
Path to config file
.TP
\fB\-\-config-get\fR=\fIKEY\fR
Get a config value
.TP
\fB\-\-config-set\fR=\fIKEY\fR \fIVALUE\fR
Set a config value
.TP
\fB\-\-config-list\fR
List all config values
.TP
\fB\-\-config-unset\fR=\fIKEY\fR
Unset a config value
.TP
\fB\-\-init-config\fR
Generate default config file
.SS "Mirror & Recursive"
.TP
\fB\-\-mirror\fR
Mirror entire website (infinite depth)
.TP
\fB\-\-recursive\fR
Enable recursive downloading
.TP
\fB\-\-max-depth\fR=\fIN\fR
Maximum recursion depth
.SS "Scheduling & Hooks"
.TP
\fB\-\-schedule\fR=\fITIME\fR
Schedule download ("YYYY-MM-DD HH:MM" or cron "0 2 * * *")
.TP
\fB\-\-on-complete\fR=\fICMD\fR
Run shell command after successful download
.TP
\fB\-\-bind-interface\fR=\fIIFACE\fR
Bind to specific network interface
.SS "Other"
.TP
\fB\-\-batch-file\fR=\fIFILE\fR
Download multiple URLs from a file
.TP
\fB\-\-checksum-file\fR=\fIFILE\fR
Read expected checksum from SHA256SUMS file
.TP
\fB\-\-generate-man-page\fR
Generate this man page
.TP
\fB\-\-help\fR
Show help
.TP
\fB\-\-version\fR
Show version
.SH EXAMPLES
.TP
Download a file:
goget --url https://example.com/file.zip
.TP
Parallel download:
goget --url https://example.com/large.iso --parallel 4
.TP
Resume interrupted download:
goget --url https://example.com/file.zip --resume
.TP
Mirror website:
goget --url https://example.com --mirror --output ./mirror
.TP
Recursive download:
goget --url https://example.com --recursive --max-depth 2
.TP
JSON progress (for scripting):
goget --url https://example.com/file.zip --json --no-progress > progress.jsonl
.TP
Scheduled download at 2 AM:
goget --url https://example.com/daily.zip --schedule "0 2 * * *"
.TP
Run import script after download:
goget --url https://example.com/data.csv --on-complete "import.sh"
.SH FILES
.TP
~/.config/goget/config.json
Configuration file
.TP
<file>.goget.meta
Resume metadata file
.SH BUGS
Report bugs at https://codeberg.org/petrbalvin/goget/issues
.SH AUTHOR
Petr Balvin
.SH LICENSE
MIT License`, core.Version)
return 0 return 0
} }
+134
View File
@@ -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
}
+3
View File
@@ -236,6 +236,9 @@ func ApplyFlags(c *http.Config, f *Flags, cfg *config.Config, cookieJar *cookie.
if *f.AdjustExt { if *f.AdjustExt {
c.Recursive.AdjustExt = true c.Recursive.AdjustExt = true
} }
if *f.BackupConverted {
c.Recursive.BackupConverted = true
}
// ── Cookies from CLI flags ── // ── Cookies from CLI flags ──
if len(f.Cookies) > 0 { if len(f.Cookies) > 0 {
+38 -40
View File
@@ -6,39 +6,40 @@
```mermaid ```mermaid
graph TD graph TD
User[User / Terminal]:::accent6 User[User / Terminal]
Script[Script / CI]:::accent6 Script[Script / CI]
CLI[cmd/goget/ - Main entry]:::accent0 CLI[cmd/goget/ - Main entry]
API[pkg/api/ - Library interface]:::accent3 API[pkg/api/ - Library interface]
Core[internal/core/ - Types, Protocol Interface, Errors]:::accent1 Core[internal/core/ - Types, Protocol Interface, Errors]
Registry[internal/protocol/ - Protocol Registry]:::accent1 Registry[internal/protocol/ - Protocol Registry]
HTTP[internal/protocol/http/]:::accent1 HTTP[internal/protocol/http/]
FTP[internal/protocol/ftp/]:::accent1 FTP[internal/protocol/ftp/]
SFTP[internal/protocol/sftp/]:::accent1 SFTP[internal/protocol/sftp/]
FILE[internal/protocol/file/]:::accent1 FILE[internal/protocol/file/]
DataURL[internal/protocol/dataurl/]:::accent1 DataURL[internal/protocol/dataurl/]
Gopher[internal/protocol/gopher/]:::accent1 Gopher[internal/protocol/gopher/]
Gemini[internal/protocol/gemini/]:::accent1 Gemini[internal/protocol/gemini/]
WebDAV[internal/protocol/webdav/]:::accent1 WebDAV[internal/protocol/webdav/]
Transport[internal/transport/ - Dialer, Proxy, TLS, Rate Limit]:::accent7 Transport[internal/transport/ - Dialer, Proxy, TLS, Rate Limit]
Config[internal/config/]:::accent7 Config[internal/config/]
Output[internal/output/ - Atomic write, Resume, WARC]:::accent2 Output[internal/output/ - Atomic write, Resume, WARC]
Auth[internal/auth/ - Basic, Digest, OAuth, netrc]:::accent7 Auth[internal/auth/ - Basic, Digest, OAuth, netrc]
Crypto[internal/crypto/ - Checksum, PGP, Certs]:::accent7 Crypto[internal/crypto/ - Checksum, PGP, Certs]
Cookie[internal/cookie/ - Netscape jar]:::accent7 Cookie[internal/cookie/ - Netscape jar]
HSTS[internal/hsts/ - RFC 6797 cache]:::accent7 HSTS[internal/hsts/ - RFC 6797 cache]
Archive[internal/archive/ - Extraction]:::accent7 Archive[internal/archive/ - Extraction]
Recursive[internal/recursive/ - Crawler, Parser, Filter]:::accent2 Recursive[internal/recursive/ - Crawler, Parser, Filter]
Mirror[internal/mirror/]:::accent2 Mirror[internal/mirror/]
Metalink[internal/metalink/]:::accent2 Metalink[internal/metalink/]
Queue[internal/queue/]:::accent2 Queue[internal/queue/]
Compression[internal/compression/ - gzip, bzip2, zlib, flate, lzw]:::accent7 Compression[internal/compression/ - gzip, bzip2, zlib, flate, lzw]
Log[internal/log/ - Structured logging]:::accent7 Log[internal/log/ - Structured logging]
WARC[internal/warc/ - Archiving]:::accent7 WARC[internal/warc/ - Archiving]
Register[internal/protocol/register/ - Single registration]:::accent7 Register[internal/protocol/register/ - Single registration]
Format[internal/format/ - Bytes, Speed formatting]:::accent7 Format[internal/format/ - Bytes, Speed formatting]
LinkRewrite[internal/linkrewrite/ - HTML link conversion]:::accent7 LinkRewrite[internal/linkrewrite/ - HTML link conversion]
CLIUtil[internal/cli/ - Help, Progress bar, Colors]:::accent7 CLIUtil[internal/cli/ - Help, Progress bar, Colors]
Pool[internal/pool/ - Unified worker pool]
User --> CLI User --> CLI
Script --> API Script --> API
@@ -65,7 +66,10 @@ graph TD
HTTP --> HSTS HTTP --> HSTS
HTTP --> Output HTTP --> Output
FTP --> Transport FTP --> Transport
FTP --> Pool
SFTP --> Transport SFTP --> Transport
SFTP --> Pool
WebDAV --> Pool
end end
subgraph "Post-Download" subgraph "Post-Download"
@@ -88,13 +92,6 @@ graph TD
Transport --> Auth Transport --> Auth
Transport --> Config 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 ## 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 | | `log` | Structured logger with text and JSON output modes, level filtering, ANSI colors |
| `warc` | WARC (Web ARChive) format writing for legal and archival compliance | | `warc` | WARC (Web ARChive) format writing for legal and archival compliance |
| `cli` | ANSI color output, progress bar, help text, completion | | `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 ## Download Flow
+6 -4
View File
@@ -22,6 +22,7 @@ goget --url <URL> [OPTIONS]
| `--content-disposition` | — | Use filename from server's `Content-Disposition` header | | `--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 | | `--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 | | `--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 ## 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`) | | `--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) | | `--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) | | `--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 ## HTTP / Network Flags
@@ -76,7 +78,7 @@ goget --url <URL> [OPTIONS]
|---|---|---| |---|---|---|
| `--accept` | `"*.pdf"` | Accept patterns — filename glob or MIME type (comma-separated) | | `--accept` | `"*.pdf"` | Accept patterns — filename glob or MIME type (comma-separated) |
| `--no-parent` | — | Do not ascend to parent directories during recursive download | | `--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 | | `--wait` | `<DURATION>` | Delay between requests in recursive mode |
| `--random-wait` | — | Randomize wait time (0.5x1.5x of `--wait` value) | | `--random-wait` | — | Randomize wait time (0.5x1.5x of `--wait` value) |
| `--page-requisites` | — | Download CSS, JS, and images required to render HTML pages | | `--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) | | `--reject` | `<PATTERN>` | Reject URL patterns (glob) |
| `--cut-dirs` | `<N>` | Ignore N directory components when creating local filenames | | `--cut-dirs` | `<N>` | Ignore N directory components when creating local filenames |
| `--proto-dirs` | — | Create protocol-prefixed directories (http/, ftp/ etc.) | | `--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 | | `--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) | | `--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 | | `--continue` | — | Continue getting partially-downloaded file |
+253
View 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
View File
@@ -6,33 +6,33 @@ This document explains how goget processes a download request from start to fini
```mermaid ```mermaid
flowchart TD flowchart TD
Start([User invokes goget]):::accent0 Start([User invokes goget])
ParseFlags["Parse CLI flags"]:::accent7 ParseFlags["Parse CLI flags"]
LoadConfig["Load config + merge overrides"]:::accent7 LoadConfig["Load config + merge overrides"]
ResolveProtocol["Resolve protocol handler\nfrom URL scheme"]:::accent1 ResolveProtocol["Resolve protocol handler\nfrom URL scheme"]
CreateRequest["Build core.DownloadRequest"]:::accent1 CreateRequest["Build core.DownloadRequest"]
CheckResume{"Resume metadata\nexists?"}:::accent2 CheckResume{"Resume metadata\nexists?"}
LoadResume["Load .goget.meta\n(resume info + chunk map)"]:::accent2 LoadResume["Load .goget.meta\n(resume info + chunk map)"]
SetupOutput["Setup output writer\n(atomic temp file + resume)"]:::accent2 SetupOutput["Setup output writer\n(atomic temp file + resume)"]
CreateDirs{"--create-dirs\n(parent missing)?"}:::accent2 CreateDirs{"--create-dirs\n(parent missing)?"}
MkdirAll["os.MkdirAll → create\nparent directories"]:::accent2 MkdirAll["os.MkdirAll → create\nparent directories"]
CheckParallel{"File > 100 MB\nor --parallel set?"}:::accent1 CheckParallel{"File > 100 MB\nor --parallel set?"}
SequentialDownload["Sequential download\n(single connection)"]:::accent1 SequentialDownload["Sequential download\n(single connection)"]
ParallelDownload["Parallel chunked download\n(multiple Range requests)"]:::accent1 ParallelDownload["Parallel chunked download\n(multiple Range requests)"]
Decompress{"Auto-decompress\nenabled?"}:::accent7 Decompress{"Auto-decompress\nenabled?"}
Decompression["Decompress response\n(gzip, deflate, bzip2, zlib)"]:::accent7 Decompression["Decompress response\n(gzip, deflate, bzip2, zlib)"]
VerifyChecksum{"Checksum\nspecified?"}:::accent7 VerifyChecksum{"Checksum\nspecified?"}
ChecksumVerify["Verify checksum\n(SHA-256, SHA-512, etc.)"]:::accent7 ChecksumVerify["Verify checksum\n(SHA-256, SHA-512, etc.)"]
PGPVerify{"PGP verify/\ndecrypt?"}:::accent7 PGPVerify{"PGP verify/\ndecrypt?"}
PGPProcess["Verify signature /\ndecrypt file"]:::accent7 PGPProcess["Verify signature /\ndecrypt file"]
SaveHSTS["Save HSTS cache\n(RFC 6797)"]:::accent7 SaveHSTS["Save HSTS cache\n(RFC 6797)"]
AtomicRename["Atomic rename\ntemp → final"]:::accent2 AtomicRename["Atomic rename\ntemp → final"]
SigInt{"SIGINT received?"}:::accent4 SigInt{"SIGINT received?"}
SaveSidecar["Save .goget.meta\nfor resume"]:::accent2 SaveSidecar["Save .goget.meta\nfor resume"]
Exit130["Exit 130"]:::accent4 Exit130["Exit 130"]
RunHook{"--on-complete\nset?"}:::accent7 RunHook{"--on-complete\nset?"}
PostHook["Run post-download command\n(GOGET_OUTPUT, GOGET_SIZE, GOGET_URL)"]:::accent7 PostHook["Run post-download command\n(GOGET_OUTPUT, GOGET_SIZE, GOGET_URL)"]
Done([Done]):::accent0 Done([Done])
Start --> ParseFlags --> LoadConfig --> ResolveProtocol Start --> ParseFlags --> LoadConfig --> ResolveProtocol
ResolveProtocol --> CreateRequest --> CheckResume ResolveProtocol --> CreateRequest --> CheckResume
@@ -58,12 +58,6 @@ flowchart TD
SigInt -->|No| RunHook SigInt -->|No| RunHook
RunHook -->|Yes| PostHook --> Done RunHook -->|Yes| PostHook --> Done
RunHook -->|No| 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 ## Phase 1: Request Construction
@@ -94,17 +88,13 @@ The protocol registry resolves the handler by URL scheme:
```mermaid ```mermaid
flowchart LR flowchart LR
URL["https://example.com"]:::accent6 URL["https://example.com"]
Parse["Parse scheme\n→ https"]:::accent7 Parse["Parse scheme\n→ https"]
Normalized["Normalize\nhttps → http"]:::accent7 Normalized["Normalize\nhttps → http"]
Registry["Registry lookup\nprotocols[http]"]:::accent1 Registry["Registry lookup\nprotocols[http]"]
Handler["HTTP protocol\nhandler"]:::accent1 Handler["HTTP protocol\nhandler"]
URL --> Parse --> Normalized --> Registry --> Handler 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: Scheme normalization maps:
+30 -16
View File
@@ -11,10 +11,10 @@ goget supports 9 network protocols through a unified abstraction layer. Each pro
| `sftp://` | SFTP | ✓ | ✓ | — | ✓ | `golang.org/x/crypto/ssh` | | `sftp://` | SFTP | ✓ | ✓ | — | ✓ | `golang.org/x/crypto/ssh` |
| `file://` | Local file | — | — | — | ✓ | Go stdlib `os` | | `file://` | Local file | — | — | — | ✓ | Go stdlib `os` |
| `data:` | Data URI | — | — | — | — | Go stdlib | | `data:` | Data URI | — | — | — | — | Go stdlib |
| `gopher://` | Gopher | | — | — | — | Custom implementation | | `gopher://` | Gopher | | — | — | — | Custom implementation |
| `webdav://` `webdavs://` | WebDAV | ✓ | — | ✓ | ✓ | HTTP (via delegation) | | `webdav://` `webdavs://` | WebDAV | ✓ | — | ✓ | ✓ | HTTP (via delegation) |
| `gemini://` | Gemini | | — | — | — | Custom implementation | | `gemini://` | Gemini | | — | — | — | Custom implementation |
## HTTP/HTTPS (`http://`, `https://`) ## 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 - **Explicit TLS (FTPS)** — AUTH TLS command on port 21
- **Resume** — REST command for partial transfers - **Resume** — REST command for partial transfers
- **Directory listing** — For recursive operations - **Directory listing** — For recursive operations
- **Certificate pinning**`--pinned-cert` enforces a SHA-256 leaf-cert match on FTPS connections
### Limitations ### Limitations
@@ -95,6 +96,9 @@ Custom FTP implementation supporting active and passive modes.
# Anonymous (default) # Anonymous (default)
--url ftp://anonymous@ftp.example.com/file.zip --url ftp://anonymous@ftp.example.com/file.zip
# Certificate pinning (FTPS only)
--url ftps://ftp.example.com/file.zip --pinned-cert a1b2c3d4e5f6...
``` ```
## SFTP (`sftp://`) ## SFTP (`sftp://`)
@@ -108,6 +112,7 @@ SFTP over SSH protocol.
- **Known hosts verification** — Via `~/.ssh/known_hosts` - **Known hosts verification** — Via `~/.ssh/known_hosts`
- **Resume** — Partial transfer support; parallel recursive downloads available via `--recursive-parallel N` with connection pooling - **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` - **`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 ### Configuration
@@ -123,6 +128,9 @@ SFTP over SSH protocol.
# With password # With password
--url sftp://user@host:/path/to/file.zip --password "secret" --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://`) ## 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) - **Type parsing** — Respects Gopher type codes (0 = text, 1 = directory, 9 = binary)
- **Directory listing** — For recursive operations - **Directory listing** — For recursive operations
- **Text and binary downloads** - **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 ### Limitations
- No resume
- No parallel downloads - No parallel downloads
- No encryption (Gopher has no TLS) - No encryption (Gopher has no TLS)
- No upload - No upload
@@ -188,6 +200,9 @@ Implements the Gopher protocol as specified in RFC 1436.
# Download a file from a Gopher server # Download a file from a Gopher server
goget --url gopher://gopher.example.com/9/file.zip 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 # Browse a Gopher directory
goget --url gopher://gopher.example.com/1/ 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 - **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 - **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 - **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 ### Limitations
- No resume
- No parallel downloads - No parallel downloads
- No upload (Gemini is download-only) - No upload (Gemini is download-only)
@@ -217,27 +233,25 @@ goget --url gemini://gemini.example.com/file.zip
# Browse a capsule # Browse a capsule
goget --url gemini://gemini.example.com/ goget --url gemini://gemini.example.com/
# Pin the server certificate
goget --url gemini://gemini.example.com/ --pinned-cert a1b2c3d4e5f6...
``` ```
## Protocol Resolution Flow ## Protocol Resolution Flow
```mermaid ```mermaid
flowchart TD flowchart TD
URL[URL input]:::accent6 URL[URL input]
Parse["Parse URL"]:::accent7 Parse["Parse URL"]
Normalize["Normalize scheme\n(https→http, ftps→ftp)"]:::accent7 Normalize["Normalize scheme\n(https→http, ftps→ftp)"]
Registry["Look up in protocol registry"]:::accent1 Registry["Look up in protocol registry"]
Found{"Protocol found?"}:::accent4 Found{"Protocol found?"}
Error["Unsupported protocol error"]:::accent4 Error["Unsupported protocol error"]
Handle["Delegate to protocol handler"]:::accent1 Handle["Delegate to protocol handler"]
URL --> Parse --> Normalize --> Registry URL --> Parse --> Normalize --> Registry
Registry --> Found Registry --> Found
Found -->|Yes| Handle Found -->|Yes| Handle
Found -->|No| Error 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
``` ```
+9 -13
View File
@@ -31,15 +31,15 @@ goget --recursive --accept "image/*,text/html" --url https://example.com/
```mermaid ```mermaid
flowchart TD flowchart TD
HTML[Parse HTML page]:::accent1 HTML[Parse HTML page]
Extract["Extract all links\n(a, img, link, script)"]:::accent1 Extract["Extract all links\n(a, img, link, script)"]
FilterByDomain{"Domain match?"}:::accent7 FilterByDomain{"Domain match?"}
FilterByPattern{"Accept pattern\nmatch?"}:::accent7 FilterByPattern{"Accept pattern\nmatch?"}
FilterByDepth{"Depth ≤ max?"}:::accent7 FilterByDepth{"Depth ≤ max?"}
FilterByParent{"No-parent\ncheck?"}:::accent7 FilterByParent{"No-parent\ncheck?"}
ExcludeFilter{"Not in exclude\nlist?"}:::accent7 ExcludeFilter{"Not in exclude\nlist?"}
Enqueue["Add to download queue"]:::accent1 Enqueue["Add to download queue"]
Skip["Skip"]:::accent4 Skip["Skip"]
HTML --> Extract --> FilterByDomain HTML --> Extract --> FilterByDomain
FilterByDomain -->|Yes| FilterByPattern FilterByDomain -->|Yes| FilterByPattern
@@ -53,10 +53,6 @@ flowchart TD
FilterByParent -->|Fail| Skip FilterByParent -->|Fail| Skip
ExcludeFilter -->|Pass| Enqueue ExcludeFilter -->|Pass| Enqueue
ExcludeFilter -->|Fail| Skip 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 ### Accept Patterns
+19 -2
View File
@@ -12,13 +12,30 @@ goget implements multiple security features to protect both the client and serve
### Certificate Pinning ### 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 ```bash
goget --pinned-cert "a1b2c3d4e5f6..." --url https://example.com 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) ### Client Certificates (mTLS)
+2 -1
View File
@@ -37,7 +37,8 @@ NETWORK
--dns-servers <IPs> Custom DNS servers (comma-separated) --dns-servers <IPs> Custom DNS servers (comma-separated)
--bind-interface <IFACE> Bind to specific network interface --bind-interface <IFACE> Bind to specific network interface
--allow-private Allow connections to private/internal IPs --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 CURL-COMPATIBLE
--header "K: V" Custom HTTP header (repeatable) --header "K: V" Custom HTTP header (repeatable)
+12
View File
@@ -87,6 +87,17 @@ type DownloadRequest struct {
Ctx context.Context Ctx context.Context
SSHKnownHosts string // path to known_hosts file (SFTP) SSHKnownHosts string // path to known_hosts file (SFTP)
SSHInsecure bool // skip SSH host key verification (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 // DownloadResult represents a download result
@@ -103,6 +114,7 @@ type DownloadResult struct {
Parallel bool Parallel bool
ChunksCount int ChunksCount int
HTTPStatusCode int // HTTP status code from response HTTPStatusCode int // HTTP status code from response
ContentType string // Content-Type from response (if available)
TraceTimings *TraceTimings // HTTP tracing breakdown TraceTimings *TraceTimings // HTTP tracing breakdown
} }
+1 -1
View File
@@ -7,4 +7,4 @@ package core
const Name = "goget" const Name = "goget"
// Version is the goget release version. Follows SemVer. // Version is the goget release version. Follows SemVer.
var Version = "1.0.0" var Version = "1.1.0"
+11
View File
@@ -44,6 +44,7 @@ type MirrorConfig struct {
ConvertLinks bool // Convert links for local viewing ConvertLinks bool // Convert links for local viewing
DownloadAssets bool // Download CSS, JS, images DownloadAssets bool // Download CSS, JS, images
PruneEmpty bool // Remove empty directories after download PruneEmpty bool // Remove empty directories after download
BackupConverted bool // Keep .orig backup before link conversion
RestrictFiles bool // Only download files from original host RestrictFiles bool // Only download files from original host
Timeout time.Duration Timeout time.Duration
RateLimiter *transport.TokenBucket RateLimiter *transport.TokenBucket
@@ -642,6 +643,16 @@ func (m *Mirror) convertLinksInFile(filePath string) {
converted := m.rewriter.RewriteLinks(doc, filePath) converted := m.rewriter.RewriteLinks(doc, filePath)
if converted > 0 { 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 // Write modified HTML
var buf bytes.Buffer var buf bytes.Buffer
if err := html.Render(&buf, doc); err != nil { if err := html.Render(&buf, doc); err != nil {
+125
View File
@@ -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()
}
+210
View File
@@ -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
View File
@@ -6,7 +6,10 @@ package ftp
import ( import (
"bufio" "bufio"
"context" "context"
"crypto/sha256"
"crypto/tls" "crypto/tls"
"crypto/x509"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"net" "net"
@@ -16,10 +19,10 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"codeberg.org/petrbalvin/goget/internal/output" "codeberg.org/petrbalvin/goget/internal/output"
"codeberg.org/petrbalvin/goget/internal/pool"
) )
type Client struct { type Client struct {
@@ -35,6 +38,11 @@ type Client struct {
useTLS bool useTLS bool
passive bool passive bool
timeout time.Duration 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) { 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 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 { func (c *Client) Connect() error {
addr := net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port)) addr := net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port))
conn, err := net.DialTimeout("tcp", addr, c.timeout) conn, err := net.DialTimeout("tcp", addr, c.timeout)
@@ -130,6 +145,23 @@ func (c *Client) Connect() error {
} }
// Wrap connection with TLS // 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) tlsConn := tls.Client(c.conn, c.tlsConfig)
if err := tlsConn.Handshake(); err != nil { if err := tlsConn.Handshake(); err != nil {
return fmt.Errorf("tls handshake failed: %w", err) return fmt.Errorf("tls handshake failed: %w", err)
@@ -647,7 +679,7 @@ type clientPool struct {
// newClientPool opens `size` independent FTP connections to the same // newClientPool opens `size` independent FTP connections to the same
// server. Connections are kept open until close() is called. // 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 { if size < 1 {
size = 1 size = 1
} }
@@ -661,6 +693,9 @@ func newClientPool(rawURL string, timeout time.Duration, size int) (*clientPool,
} }
return nil, err return nil, err
} }
if pinnedCertHash != "" {
c.SetPinnedCert(pinnedCertHash)
}
if err := c.Connect(); err != nil { if err := c.Connect(); err != nil {
for _, existing := range all { for _, existing := range all {
_ = existing.Close() _ = existing.Close()
@@ -698,67 +733,56 @@ func (p *clientPool) close() {
} }
} }
// DownloadDirectoryConcurrent downloads a remote directory to localPath // ftpTask represents a unit of work for the concurrent recursive
// using a pool of N FTP connections. All operations (LIST, RETR) use // download: either a directory to list or a file to download.
// absolute paths so concurrent workers never share or mutate CWD state type ftpTask struct {
// on the server side. The returned error is the first error encountered remotePath string
// by any worker; other workers continue in the background until they localPath string
// hit ctx cancellation or finish their tasks. isDir bool
func DownloadDirectoryConcurrent(ctx context.Context, rawURL string, timeout time.Duration, remotePath, localPath string, parallel int, progressFunc func(file string, current, total int64)) error { name string
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
} }
// processEntry processes one remote path: lists it, then dispatches // ftpTaskHandler processes one ftpTask. For directories it lists the
// its child entries (file → download, subdir → recursive processEntry) // contents and submits child tasks for each entry. For files it
// through the shared semaphore-bounded goroutine pool. A single // acquires a client from the pool and downloads.
// sem is threaded through the recursion so the total in-flight func ftpTaskHandler(cpool *clientPool, progressFunc func(file string, current, total int64)) func(ctx context.Context, t ftpTask) {
// goroutines never exceed `parallel`, regardless of directory depth. return func(ctx context.Context, t ftpTask) {
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 }()
if ctx.Err() != nil { if ctx.Err() != nil {
return return
} }
if err := os.MkdirAll(localPath, 0755); err != nil { if err := os.MkdirAll(t.localPath, 0755); err != nil {
setErr(fmt.Errorf("failed to create local dir %s: %w", localPath, err)) pool.Get[ftpTask](ctx).AddError(fmt.Errorf("failed to create local dir %s: %w", t.localPath, err))
return return
} }
c := pool.acquire() if !t.isDir {
entries, err := c.ListRemoteDir(remotePath) c := cpool.acquire()
pool.release(c) 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 { 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 return
} }
p := pool.Get[ftpTask](ctx)
for _, entry := range entries { for _, entry := range entries {
fields := strings.Fields(entry) fields := strings.Fields(entry)
if len(fields) < 9 { if len(fields) < 9 {
@@ -768,50 +792,42 @@ func processEntry(ctx context.Context, pool *clientPool, sem chan struct{}, wg *
if name == "." || name == ".." { if name == "." || name == ".." {
continue continue
} }
isDir := fields[0][0] == 'd' childIsDir := fields[0][0] == 'd'
remoteFilePath := filepath.Join(remotePath, name) p.Submit(ftpTask{
localFilePath := filepath.Join(localPath, name) remotePath: filepath.Join(t.remotePath, name),
localPath: filepath.Join(t.localPath, name),
if isDir { isDir: childIsDir,
wg.Add(1) name: name,
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
}
} }
} }
} }
// 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
}
+18
View File
@@ -380,3 +380,21 @@ func TestNewClientTLSConfig(t *testing.T) {
t.Errorf("tlsConfig.InsecureSkipVerify should be false by default") 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")
}
}
+4 -1
View File
@@ -79,6 +79,9 @@ func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*co
if err != nil { if err != nil {
return nil, core.NewProtocolError(fmt.Sprintf("failed to create FTP client: %v", err), nil, core.SafeURL(req.URL)) 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 { if err := client.Connect(); err != nil {
return nil, core.NewProtocolError(fmt.Sprintf("failed to connect: %v", err), nil, core.SafeURL(req.URL)) 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 // opens N control connections to avoid stepping on CWD state and
// to keep the existing client-side protocol serialisation. // to keep the existing client-side protocol serialisation.
if req.RecursiveParallel > 1 { 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)) return nil, core.NewProtocolError(fmt.Sprintf("directory download failed: %v", err), nil, core.SafeURL(req.URL))
} }
} else { } else {
+159 -5
View File
@@ -6,17 +6,22 @@ package gemini
import ( import (
"bufio" "bufio"
"context" "context"
"crypto/sha256"
"crypto/tls" "crypto/tls"
"crypto/x509"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"net" "net"
"net/url" "net/url"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"codeberg.org/petrbalvin/goget/internal/core" "codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/output"
"codeberg.org/petrbalvin/goget/internal/protocol" "codeberg.org/petrbalvin/goget/internal/protocol"
) )
@@ -36,7 +41,7 @@ func NewProtocol() *Protocol {
Name: "GEMINI", Name: "GEMINI",
Scheme: "gemini", Scheme: "gemini",
DefaultPort: 1965, DefaultPort: 1965,
Features: []string{}, Features: []string{"resume"},
}), }),
} }
} }
@@ -63,6 +68,27 @@ func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.Download
InsecureSkipVerify: p.TLSInsecure, 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} dialer := &net.Dialer{Timeout: 30 * time.Second}
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsCfg) conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
if err != nil { 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)) 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 // Send request: URL + CRLF
requestURL := req.URL.String() requestURL := req.URL.String()
if _, err := fmt.Fprintf(conn, "%s\r\n", requestURL); err != nil { 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, Output: req.Output,
Verbose: req.Verbose, Verbose: req.Verbose,
Writer: req.Writer, Writer: req.Writer,
PinnedCertHash: req.PinnedCertHash,
Ctx: ctx, Ctx: ctx,
} }
return p.downloadWithRedirects(ctx, redirectReq, redirectCount+1) 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 // 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 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 { if req.Writer != nil {
writer = req.Writer writer = req.Writer
} else { } else {
writer = io.Discard 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) buf := make([]byte, 32*1024)
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
saveGeminiResume(req, totalRead)
return nil, ctx.Err() return nil, ctx.Err()
default: default:
} }
n, err := reader.Read(buf) n, err := reader.Read(buf)
if n > 0 { 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) return nil, core.NewFileError("failed to write gemini data", werr)
} }
totalRead += int64(n) }
if req.ProgressCallback != nil { if req.ProgressCallback != nil {
speed := float64(totalRead) / time.Since(startTime).Seconds() speed := float64(totalRead) / time.Since(startTime).Seconds()
req.ProgressCallback(totalRead, -1, speed) req.ProgressCallback(totalRead, -1, speed)
} }
} }
if err == io.EOF { 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 break
} }
if err != nil { 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)) 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) duration := time.Since(startTime)
speed := float64(totalRead) / duration.Seconds() speed := float64(0)
if duration.Seconds() > 0 {
speed = float64(totalRead) / duration.Seconds()
}
return &core.DownloadResult{ return &core.DownloadResult{
BytesDownloaded: totalRead, BytesDownloaded: totalRead,
@@ -199,5 +338,20 @@ func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.Download
IPVersion: 4, IPVersion: 4,
Speed: speed, Speed: speed,
OutputPath: req.Output, OutputPath: req.Output,
Resumed: resumed,
}, nil }, 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)
}
}
+397
View File
@@ -15,12 +15,15 @@ import (
"math/big" "math/big"
"net" "net"
"net/url" "net/url"
"os"
"runtime"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
"codeberg.org/petrbalvin/goget/internal/core" "codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/output"
) )
type stringWriter struct{ sb *strings.Builder } type stringWriter struct{ sb *strings.Builder }
@@ -132,3 +135,397 @@ func TestGeminiPermanentFailure(t *testing.T) {
t.Error("expected permanent failure error") 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())
}
+175 -19
View File
@@ -9,10 +9,13 @@ import (
"fmt" "fmt"
"io" "io"
"net" "net"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
"codeberg.org/petrbalvin/goget/internal/core" "codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/output"
"codeberg.org/petrbalvin/goget/internal/protocol" "codeberg.org/petrbalvin/goget/internal/protocol"
) )
@@ -31,6 +34,12 @@ const (
typeSearch = '7' // index search 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). // Protocol implements gopher:// downloads (RFC 1436).
type Protocol struct { type Protocol struct {
*protocol.BaseProtocol *protocol.BaseProtocol
@@ -43,7 +52,7 @@ func NewProtocol() *Protocol {
Name: "GOPHER", Name: "GOPHER",
Scheme: "gopher", Scheme: "gopher",
DefaultPort: 70, 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) addr := net.JoinHostPort(host, port)
conn, err := net.DialTimeout("tcp", addr, 30*time.Second) conn, err := net.DialTimeout("tcp", addr, dialTimeout)
if err != nil { if err != nil {
return nil, core.NewNetworkError("failed to connect to gopher server", err, core.SafeURL(req.URL)) return nil, core.NewNetworkError("failed to connect to gopher server", err, core.SafeURL(req.URL))
} }
defer conn.Close() 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 // Send selector + CRLF
if _, err := fmt.Fprintf(conn, "%s\r\n", selector); err != nil { 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. // 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) { 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 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 { if req.Writer != nil {
writer = req.Writer writer = req.Writer
} else { } else {
writer = io.Discard writer = io.Discard
} }
}
var totalRead int64 // bytesWritten tracks output payload actually delivered to the writer
lineCount := 0 // (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 { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
saveGopherResume(req, bytesWritten)
return nil, ctx.Err() return nil, ctx.Err()
default: default:
} }
@@ -159,8 +254,18 @@ func (p *Protocol) downloadText(ctx context.Context, reader *bufio.Reader, req *
line, err := reader.ReadString('\n') line, err := reader.ReadString('\n')
if err != nil { if err != nil {
if err == io.EOF { 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 break
} }
saveGopherResume(req, bytesWritten)
return nil, core.NewNetworkError("failed to read gopher line", err, core.SafeURL(req.URL)) 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) // Strip item-type prefix if present (first char = type, second char is usually tab/space)
displayLine := line displayLine := line
if len(line) > 1 && line[1] != '.' { 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) clean := stripGopherMeta(line)
displayLine = clean + "\n" displayLine = clean + "\n"
} }
}
// For informational lines (type i), don't strip anything — just pass through // For informational lines (type i), don't strip anything — just pass through
if firstType == typeInformation { if firstType == typeInformation {
displayLine = line displayLine = line
} }
n, err := writer.Write([]byte(displayLine)) // On resume, drop the first startOffset bytes of the
if err != nil { // post-transform output so the file appends cleanly.
return nil, core.NewFileError("failed to write gopher text", err) 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 { if req.ProgressCallback != nil {
speed := float64(totalRead) / time.Since(startTime).Seconds() speed := float64(bytesWritten) / time.Since(startTime).Seconds()
req.ProgressCallback(totalRead, -1, speed) req.ProgressCallback(bytesWritten, -1, speed)
} }
} }
// Download complete — remove resume metadata sidecar.
if req.Output != "" && req.Output != "-" {
output.DeleteResumeMetadata(req.Output)
}
duration := time.Since(startTime) duration := time.Since(startTime)
speed := float64(totalRead) / duration.Seconds() speed := float64(0)
if duration.Seconds() > 0 {
speed = float64(bytesWritten) / duration.Seconds()
}
return &core.DownloadResult{ return &core.DownloadResult{
BytesDownloaded: totalRead, BytesDownloaded: bytesWritten,
TotalSize: totalRead, TotalSize: bytesWritten,
Duration: duration, Duration: duration,
Protocol: "GOPHER", Protocol: "GOPHER",
IPVersion: 4, IPVersion: 4,
Speed: speed, Speed: speed,
OutputPath: req.Output, OutputPath: req.Output,
ContentType: gopherContentType(firstType),
Resumed: resumed,
}, nil }, 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. // downloadDirectory formats a directory listing as readable text.
func (p *Protocol) downloadDirectory(reader *bufio.Reader, req *core.DownloadRequest, startTime time.Time) (*core.DownloadResult, error) { func (p *Protocol) downloadDirectory(reader *bufio.Reader, req *core.DownloadRequest, startTime time.Time) (*core.DownloadResult, error) {
var writer io.Writer var writer io.Writer
@@ -261,6 +415,7 @@ func (p *Protocol) downloadDirectory(reader *bufio.Reader, req *core.DownloadReq
IPVersion: 4, IPVersion: 4,
Speed: speed, Speed: speed,
OutputPath: req.Output, OutputPath: req.Output,
ContentType: "text/plain",
}, nil }, nil
} }
@@ -313,6 +468,7 @@ func (p *Protocol) downloadBinary(ctx context.Context, reader *bufio.Reader, req
IPVersion: 4, IPVersion: 4,
Speed: speed, Speed: speed,
OutputPath: req.Output, OutputPath: req.Output,
ContentType: "application/octet-stream",
}, nil }, nil
} }
+563
View File
@@ -6,13 +6,18 @@ package gopher
import ( import (
"bufio" "bufio"
"context" "context"
"fmt"
"net" "net"
"net/url" "net/url"
"os"
"runtime"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"codeberg.org/petrbalvin/goget/internal/core" "codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/output"
) )
func TestFormatGopherMenu(t *testing.T) { func TestFormatGopherMenu(t *testing.T) {
@@ -108,3 +113,561 @@ func TestGopherDownloadMock(t *testing.T) {
t.Errorf("output missing text: %q", buf.String()) 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())
}
+1
View File
@@ -403,6 +403,7 @@ func (c *Client) downloadMirror(ctx context.Context, req *core.DownloadRequest)
ConvertLinks: c.config.Recursive.ConvertLinks, ConvertLinks: c.config.Recursive.ConvertLinks,
DownloadAssets: c.config.Recursive.MirrorAssets, DownloadAssets: c.config.Recursive.MirrorAssets,
PruneEmpty: true, PruneEmpty: true,
BackupConverted: c.config.Recursive.BackupConverted,
RestrictFiles: true, RestrictFiles: true,
Timeout: c.config.Transport.Timeout, Timeout: c.config.Transport.Timeout,
RespectRobots: c.config.Recursive.RespectRobots, RespectRobots: c.config.Recursive.RespectRobots,
+1
View File
@@ -87,6 +87,7 @@ type RecursiveConfig struct {
Mirror bool Mirror bool
MirrorAssets bool MirrorAssets bool
ConvertLinks bool ConvertLinks bool
BackupConverted bool
RespectRobots bool RespectRobots bool
} }
+82 -1
View File
@@ -4,15 +4,39 @@
package sftp package sftp
import ( import (
"crypto/sha256"
"encoding/base64"
"fmt" "fmt"
"net"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts" "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 { if insecure {
return ssh.InsecureIgnoreHostKey(), nil return ssh.InsecureIgnoreHostKey(), nil
} }
@@ -31,3 +55,60 @@ func hostKeyCallback(knownHostsPath string, insecure bool) (ssh.HostKeyCallback,
return knownhosts.New(knownHostsPath) 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
}
+58 -2
View File
@@ -4,11 +4,14 @@
package sftp package sftp
import ( import (
"strings"
"testing" "testing"
"golang.org/x/crypto/ssh"
) )
func TestHostKeyCallbackInsecure(t *testing.T) { func TestHostKeyCallbackInsecure(t *testing.T) {
cb, err := hostKeyCallback("", true) cb, err := hostKeyCallback("", true, "")
if err != nil { if err != nil {
t.Fatalf("hostKeyCallback: %v", err) t.Fatalf("hostKeyCallback: %v", err)
} }
@@ -18,8 +21,61 @@ func TestHostKeyCallbackInsecure(t *testing.T) {
} }
func TestHostKeyCallbackMissingFile(t *testing.T) { func TestHostKeyCallbackMissingFile(t *testing.T) {
_, err := hostKeyCallback("/nonexistent/known_hosts", false) _, err := hostKeyCallback("/nonexistent/known_hosts", false, "")
if err == nil { if err == nil {
t.Fatal("expected error for missing known_hosts") 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
View File
@@ -20,6 +20,7 @@ import (
"codeberg.org/petrbalvin/goget/internal/core" "codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/output" "codeberg.org/petrbalvin/goget/internal/output"
"codeberg.org/petrbalvin/goget/internal/pool"
"codeberg.org/petrbalvin/goget/internal/protocol" "codeberg.org/petrbalvin/goget/internal/protocol"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -112,7 +113,7 @@ type client struct {
maxPacket uint32 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() host := u.Hostname()
port := u.Port() port := u.Port()
if port == "" { if port == "" {
@@ -126,7 +127,7 @@ func dial(ctx context.Context, u *url.URL, knownHostsPath string, insecure bool)
addr := net.JoinHostPort(host, port) addr := net.JoinHostPort(host, port)
hostKeyCB, err := hostKeyCallback(knownHostsPath, insecure) hostKeyCB, err := hostKeyCallback(knownHostsPath, insecure, pinnedHostKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -638,7 +639,7 @@ func download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadRes
return downloadRecursiveSequential(ctx, req) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -725,6 +726,14 @@ func downloadSingleFile(ctx context.Context, c *client, req *core.DownloadReques
for { for {
select { select {
case <-ctx.Done(): 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() return nil, ctx.Err()
default: default:
} }
@@ -807,7 +816,7 @@ func newSFTPClientPool(ctx context.Context, req *core.DownloadRequest, size int)
all := make([]*client, 0, size) all := make([]*client, 0, size)
avail := make(chan *client, size) avail := make(chan *client, size)
for i := 0; i < size; i++ { 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 { if err != nil {
for _, existing := range all { for _, existing := range all {
existing.close() existing.close()
@@ -845,7 +854,7 @@ func (p *sftpClientPool) close() {
// downloadRecursiveParallel and is enabled when req.RecursiveParallel // downloadRecursiveParallel and is enabled when req.RecursiveParallel
// is greater than one. // is greater than one.
func downloadRecursiveSequential(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -854,56 +863,113 @@ func downloadRecursiveSequential(ctx context.Context, req *core.DownloadRequest)
return sftpDownloadDirStandalone(ctx, c, req) 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 // downloadRecursiveParallel downloads a remote directory using a pool
// of N SFTP connections. All workers share a single semaphore so the // of N SFTP connections. All workers share a single pool so the total
// total in-flight goroutines never exceed N, regardless of directory // in-flight goroutines never exceed N, regardless of directory depth.
// depth.
func downloadRecursiveParallel(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) { func downloadRecursiveParallel(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
parallel := req.RecursiveParallel parallel := req.RecursiveParallel
if parallel < 1 { if parallel < 1 {
parallel = 1 parallel = 1
} }
pool, err := newSFTPClientPool(ctx, req, parallel) cpool, err := newSFTPClientPool(ctx, req, parallel)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer pool.close() defer cpool.close()
var ( var (
statsMu sync.Mutex statsMu sync.Mutex
totalBytes int64 totalBytes int64
totalChunks int totalChunks int
firstErr error
) )
sem := make(chan struct{}, parallel) p := pool.New(ctx, parallel, func(taskCtx context.Context, t sftpTask) {
var wg sync.WaitGroup if taskCtx.Err() != nil {
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 {
return return
} }
c := pool.acquire()
defer pool.release(c)
sftpDownloadDirWorker(ctx, c, req, &wg, sem, setErr, &statsMu, &totalBytes, &totalChunks, pool)
}()
wg.Wait() c := cpool.acquire()
if firstErr != nil { defer cpool.release(c)
return nil, firstErr
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{ return &core.DownloadResult{
BytesDownloaded: totalBytes, BytesDownloaded: totalBytes,
Duration: 0, Duration: 0,
@@ -950,115 +1016,6 @@ func sftpDownloadDirStandalone(ctx context.Context, c *client, req *core.Downloa
}, nil }, 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`, // sftpWalkDir recursively walks one remote directory on `c`,
// downloading each file and recursing into subdirectories. Stats are // downloading each file and recursing into subdirectories. Stats are
// accumulated inline. Used by the standalone mode. // 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())
}
}
+1 -1
View File
@@ -490,7 +490,7 @@ func TestIsEOF(t *testing.T) {
func TestDialTimeout(t *testing.T) { func TestDialTimeout(t *testing.T) {
u, _ := url.Parse("sftp://user@127.0.0.1:1/") 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 { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
+68 -76
View File
@@ -21,6 +21,7 @@ import (
"codeberg.org/petrbalvin/goget/internal/core" "codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/output" "codeberg.org/petrbalvin/goget/internal/output"
"codeberg.org/petrbalvin/goget/internal/pool"
"codeberg.org/petrbalvin/goget/internal/protocol" "codeberg.org/petrbalvin/goget/internal/protocol"
"codeberg.org/petrbalvin/goget/internal/transport" "codeberg.org/petrbalvin/goget/internal/transport"
) )
@@ -245,8 +246,21 @@ func (p *Protocol) getHTTPTransport() *http.Transport {
return p.httpTransport 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{ tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: p.insecureSkip}, //nolint:gosec TLSClientConfig: tlsClientCfg,
DisableCompression: true, DisableCompression: true,
Proxy: http.ProxyFromEnvironment, Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 100, MaxIdleConns: 100,
@@ -680,6 +694,7 @@ func (p *Protocol) downloadFile(ctx context.Context, req *core.DownloadRequest,
OutputPath: req.Output, OutputPath: req.Output,
Resumed: resumed, Resumed: resumed,
ChunksCount: 1, ChunksCount: 1,
ContentType: resp.Header.Get("Content-Type"),
}, nil }, nil
} }
@@ -981,9 +996,18 @@ func (p *Protocol) downloadRecursiveSequential(ctx context.Context, req *core.Do
}, nil }, 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 // downloadRecursiveParallel is the worker-pool implementation used when
// req.RecursiveParallel > 1. Top-level entries are processed concurrently, // 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 // downloadRecursive, so the same RecursiveParallel limit applies at every
// level of the tree. Stats are aggregated under a mutex. // level of the tree. Stats are aggregated under a mutex.
func (p *Protocol) downloadRecursiveParallel(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) { 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 statsMu sync.Mutex
totalBytes int64 totalBytes int64
totalChunks int totalChunks int
processedDirs int
) )
processedDirs++ processedDirs := 1
// Pre-build sub-requests and output paths so the goroutine body has wp := pool.New(ctx, req.RecursiveParallel, func(taskCtx context.Context, t webdavTask) {
// no per-entry allocation cost. if taskCtx.Err() != nil {
type task struct { return
entry WebDAVEntry
subReq *core.DownloadRequest
outPath string
isDir bool
} }
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 { for _, entry := range filteredEntries {
// Respect MaxDepth to avoid infinite recursion. // Respect MaxDepth to avoid infinite recursion.
if entry.IsDir && req.MaxDepth == 0 { 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.MaxDepth = req.MaxDepth - 1
subReq.RecursiveParallel = req.RecursiveParallel subReq.RecursiveParallel = req.RecursiveParallel
subReq.DryRun = true 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 { } else {
fmt.Fprintf(os.Stderr, "[webdav] [dry-run] would download: %s (%d bytes)\n", entry.URL.String(), entry.Size) fmt.Fprintf(os.Stderr, "[webdav] [dry-run] would download: %s (%d bytes)\n", entry.URL.String(), entry.Size)
statsMu.Lock() statsMu.Lock()
@@ -1105,7 +1157,7 @@ func (p *Protocol) downloadRecursiveParallel(ctx context.Context, req *core.Down
RejectPatterns: req.RejectPatterns, RejectPatterns: req.RejectPatterns,
Ctx: req.Ctx, 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 { } else {
if req.Verbose { if req.Verbose {
fmt.Fprintf(os.Stderr, "[webdav] Downloading file %s -> %s (depth=%d)\n", entry.URL.String(), itemOutputPath, req.MaxDepth) 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, MaxDepth: req.MaxDepth - 1,
Ctx: req.Ctx, 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 wp.Wait()
// 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()
_ = processedDirs // reserved for future per-directory accounting _ = processedDirs // reserved for future per-directory accounting
+24
View File
@@ -11,14 +11,38 @@ run:
# Build the binary # Build the binary
build: 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 ./... go vet ./...
gofmt -l $(find cmd internal pkg -name '*.go') | grep . && exit 1 || true gofmt -l $(find cmd internal pkg -name '*.go') | grep . && exit 1 || true
go build -ldflags "-s -w" -o bin/goget ./cmd/goget go build -ldflags "-s -w" -o bin/goget ./cmd/goget
# Run tests (race detector + fuzz seed corpus) # Run tests (race detector + fuzz seed corpus)
test: test:
cp man/goget.1 cmd/goget/man-page-data/goget.1
go test -race -count=1 ./... go test -race -count=1 ./...
# Remove build artifacts # Remove build artifacts
uninstall: uninstall:
rm -rf bin/ coverage.out 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
View File
@@ -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.