Compare commits
28
Commits
v1.0.0
...
development
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5fa009ec5 | ||
|
|
bff619bf84 | ||
|
|
21ac1f7487 | ||
|
|
185e6ec20a | ||
|
|
3752b3d3eb | ||
|
|
c1373f0d61 | ||
|
|
49012fbf82 | ||
|
|
4268fa5d5f | ||
|
|
88e210daaf | ||
|
|
3384e2b344 | ||
|
|
d3b6451472 | ||
|
|
9066acc325 | ||
|
|
f549a862f8 | ||
|
|
e20f221ead | ||
|
|
30f62d3cc9 | ||
|
|
535496e6e1 | ||
|
|
dd46aa10f3 | ||
|
|
2dbbef3e30 | ||
|
|
760593cdb9 | ||
|
|
9867e2817e | ||
|
|
9ef62520c7 | ||
|
|
fc65ea8340 | ||
|
|
e7ed3a334e | ||
|
|
11aed3771d | ||
|
|
1672698936 | ||
|
|
f915299fa4 | ||
|
|
6e58502598 | ||
|
|
277178776d |
@@ -35,6 +35,9 @@ jobs:
|
|||||||
- name: Download dependencies
|
- 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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Placeholder so the directory is tracked by git. The actual file
|
||||||
|
generated here by `just build` is `goget.1`, copied from
|
||||||
|
`../../../man/goget.1`.
|
||||||
+13
-206
@@ -1,217 +1,24 @@
|
|||||||
//go:build linux || freebsd
|
//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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//go:build linux || freebsd
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"os/exec"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/petrbalvin/goget/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// manPageRequiredSections lists the conventional manpage sections that
|
||||||
|
// goget(1) must include. Every section in this list must be present
|
||||||
|
// in the manpage source as a ".SH <NAME>" header. Adding a new section
|
||||||
|
// here forces the manpage author to document it.
|
||||||
|
var manPageRequiredSections = []string{
|
||||||
|
"NAME",
|
||||||
|
"SYNOPSIS",
|
||||||
|
"DESCRIPTION",
|
||||||
|
"OPTIONS",
|
||||||
|
"EXAMPLES",
|
||||||
|
"EXIT STATUS",
|
||||||
|
"FILES",
|
||||||
|
"ENVIRONMENT",
|
||||||
|
"SEE ALSO",
|
||||||
|
"HISTORY",
|
||||||
|
"AUTHORS",
|
||||||
|
"REPORTING BUGS",
|
||||||
|
"COPYRIGHT",
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManPageEmbedded is the simplest smoke test: the embed succeeded
|
||||||
|
// and the variable is not empty. Any first line beginning with `.`
|
||||||
|
// (either a `.\"` comment or a macro such as `.TH`) is accepted as
|
||||||
|
// proof the embedded blob is troff source.
|
||||||
|
func TestManPageEmbedded(t *testing.T) {
|
||||||
|
if manPage == "" {
|
||||||
|
t.Fatal("embedded manPage is empty — //go:embed failed at build time")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(manPage, ".") {
|
||||||
|
t.Errorf("embedded manPage does not look like troff (got prefix %q)", firstLine(manPage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManPageHasTHHeader verifies the .TH macro is present with the
|
||||||
|
// expected program name, section, and version. The .TH line is the
|
||||||
|
// entry point that `man` uses to identify the page.
|
||||||
|
func TestManPageHasTHHeader(t *testing.T) {
|
||||||
|
if !strings.Contains(manPage, ".TH GOGET 1 ") {
|
||||||
|
t.Error("manpage is missing the .TH GOGET 1 header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManPageHasMandatorySections asserts that every section in
|
||||||
|
// manPageRequiredSections exists as a .SH heading. This keeps the page
|
||||||
|
// discoverable in `man goget` and ensures consistency with project
|
||||||
|
// documentation conventions.
|
||||||
|
//
|
||||||
|
// In troff, multi-word section names must be quoted to keep the
|
||||||
|
// spaces inside a single argument: `.SH "SEE ALSO"`. The matcher
|
||||||
|
// accepts both the bare form (`.SH SEE ALSO`) and the quoted form
|
||||||
|
// (`.SH "SEE ALSO"`), so authors can use either convention.
|
||||||
|
func TestManPageHasMandatorySections(t *testing.T) {
|
||||||
|
for _, sec := range manPageRequiredSections {
|
||||||
|
bare := "\n.SH " + sec + "\n"
|
||||||
|
quoted := "\n.SH \"" + sec + "\"\n"
|
||||||
|
if !strings.Contains(manPage, bare) && !strings.Contains(manPage, quoted) {
|
||||||
|
t.Errorf("manpage is missing required section: .SH %s (or .SH \"%s\")", sec, sec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManPageDocumentsEveryFlag is a guard against drift between the
|
||||||
|
// CLI surface (defineFlags) and the manpage. For every flag
|
||||||
|
// registered on the FlagSet, the manpage must contain the
|
||||||
|
// corresponding option entry. The expected on-page form escapes
|
||||||
|
// every hyphen in the flag name as `\-` (per the convention used
|
||||||
|
// throughout the manpage) and prefixes the entry with `\-\-`.
|
||||||
|
//
|
||||||
|
// Adding a new flag in defineFlags but forgetting the manpage (or
|
||||||
|
// vice versa) makes this test fail with a clear missing-flag list.
|
||||||
|
func TestManPageDocumentsEveryFlag(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
fs := newTestFlagSet("goget-test-man-page")
|
||||||
|
defineFlags(fs, cfg)
|
||||||
|
|
||||||
|
var missing []string
|
||||||
|
fs.VisitAll(func(f *flag.Flag) {
|
||||||
|
escaped := strings.ReplaceAll(f.Name, "-", `\-`)
|
||||||
|
needle := `\-\-` + escaped
|
||||||
|
if !strings.Contains(manPage, needle) {
|
||||||
|
missing = append(missing, f.Name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if len(missing) > 0 {
|
||||||
|
sort.Strings(missing)
|
||||||
|
t.Errorf("manpage does not document %d flag(s):\n --%s",
|
||||||
|
len(missing), strings.Join(missing, "\n --"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManPageRendersWithGroff runs `groff -man -ww -Kutf-8 -z` on the
|
||||||
|
// embedded manpage and fails if it reports any warnings or errors.
|
||||||
|
// The `-z` flag suppresses output; `-ww` enables maximum warning
|
||||||
|
// verbosity; `-Kutf-8` accepts UTF-8 source (em-dash, etc.).
|
||||||
|
//
|
||||||
|
// If `groff` is not installed on the test machine the test is
|
||||||
|
// skipped — we still want it to pass on minimal CI images — but a
|
||||||
|
// CI image with `groff` available will catch regressions in the
|
||||||
|
// troff source.
|
||||||
|
func TestManPageRendersWithGroff(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("groff"); err != nil {
|
||||||
|
t.Skip("groff not installed; skipping manpage render check")
|
||||||
|
}
|
||||||
|
cmd := exec.Command("groff", "-man", "-ww", "-Kutf-8", "-z")
|
||||||
|
cmd.Stdin = strings.NewReader(manPage)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("groff rejected the manpage: %v\n%s", err, out)
|
||||||
|
} else if len(out) > 0 {
|
||||||
|
t.Fatalf("groff produced unexpected output:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstLine returns the first line of s, trimmed of the trailing
|
||||||
|
// newline. Used only for error messages.
|
||||||
|
func firstLine(s string) string {
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -236,6 +236,9 @@ func ApplyFlags(c *http.Config, f *Flags, cfg *config.Config, cookieJar *cookie.
|
|||||||
if *f.AdjustExt {
|
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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.5x–1.5x of `--wait` value) |
|
| `--random-wait` | — | Randomize wait time (0.5x–1.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 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
This guide covers the day-to-day workflow for working on **goget** — building,
|
||||||
|
testing, releasing, and contributing.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Go 1.26+**
|
||||||
|
- **Platform:** Linux (amd64, arm64, riscv64, loong64) or
|
||||||
|
FreeBSD (amd64, arm64, riscv64)
|
||||||
|
- **`just`** — task runner (`brew install just` / `apt install just` /
|
||||||
|
`pacman -S just`)
|
||||||
|
- **`groff`** *(optional)* — for `just man` manpage validation
|
||||||
|
|
||||||
|
All Go source files carry the build constraint `//go:build linux || freebsd`.
|
||||||
|
goget does not build or run on macOS or Windows.
|
||||||
|
|
||||||
|
## First-time Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://codeberg.org/petrbalvin/goget.git
|
||||||
|
cd goget
|
||||||
|
just install # go mod download
|
||||||
|
just test # verify everything works on your machine
|
||||||
|
just build # produce bin/goget
|
||||||
|
```
|
||||||
|
|
||||||
|
A successful `just build` produces a static `bin/goget` binary with no
|
||||||
|
runtime dependencies beyond the Go standard library and `golang.org/x/`
|
||||||
|
packages.
|
||||||
|
|
||||||
|
## Task Reference
|
||||||
|
|
||||||
|
Every task is defined in [`justfile`](../justfile) and is the canonical
|
||||||
|
way to interact with the project.
|
||||||
|
|
||||||
|
| Task | What it does |
|
||||||
|
|------|--------------|
|
||||||
|
| `just` | List all recipes (default) |
|
||||||
|
| `just install` | `go mod download` |
|
||||||
|
| `just run` | `go run ./cmd/goget` (development loop) |
|
||||||
|
| `just build` | `gofmt -l` check + `go vet ./...` + build `bin/goget` |
|
||||||
|
| `just test` | `go test -race -count=1 ./...` |
|
||||||
|
| `just uninstall` | Remove `bin/` and `coverage.out` |
|
||||||
|
| `just man` | Validate `man/goget.1` with `groff` (skips if absent) |
|
||||||
|
| `just install-man` | Install manpage to `~/.local/share/man/man1/` (gzipped, XDG) |
|
||||||
|
| `just uninstall-man` | Remove installed manpage |
|
||||||
|
|
||||||
|
`just build` is **strict**: it runs `gofmt -l` (any output is a failure)
|
||||||
|
plus `go vet ./...`. Both must report zero warnings before the binary
|
||||||
|
compiles. This is exactly what CI checks; running it locally first
|
||||||
|
saves a round-trip.
|
||||||
|
|
||||||
|
## Development Loop
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Edit[Edit source in cmd/, internal/, pkg/]
|
||||||
|
Test[just test]
|
||||||
|
Vet[just build runs gofmt + vet + build]
|
||||||
|
Man[just man - groff validation]
|
||||||
|
Commit[Conventional Commits message]
|
||||||
|
Push[Push to development branch]
|
||||||
|
CI[Forgejo CI: test.yml]
|
||||||
|
|
||||||
|
Edit --> Test
|
||||||
|
Test -->|fail| Edit
|
||||||
|
Test --> Vet
|
||||||
|
Vet -->|fail| Edit
|
||||||
|
Vet --> Man
|
||||||
|
Man -->|fail| Edit
|
||||||
|
Man --> Commit
|
||||||
|
Commit --> Push
|
||||||
|
Push --> CI
|
||||||
|
CI -->|fail| Edit
|
||||||
|
CI -->|pass| Merge[Merge / open PR]
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical iteration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make a focused change.
|
||||||
|
$EDITOR cmd/goget/flags.go
|
||||||
|
|
||||||
|
# Quick check that compile + format + vet pass.
|
||||||
|
just build
|
||||||
|
|
||||||
|
# Run the relevant tests with the race detector.
|
||||||
|
just test
|
||||||
|
|
||||||
|
# Validate any manpage edits.
|
||||||
|
just man
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building from Source
|
||||||
|
|
||||||
|
`just build` does three things in order:
|
||||||
|
|
||||||
|
1. Copies `man/goget.1` into `cmd/goget/man-page-data/` — the manpage is
|
||||||
|
embedded into the binary via `//go:embed`, and `//go:embed` can only
|
||||||
|
reference files within the embedding package's directory.
|
||||||
|
2. Runs `gofmt -l` over `cmd/`, `internal/`, and `pkg/`. Any output is a
|
||||||
|
formatting violation that breaks the build.
|
||||||
|
3. Runs `go vet ./...` — must report zero warnings.
|
||||||
|
4. Compiles `bin/goget` with `-ldflags "-s -w"` (strips DWARF).
|
||||||
|
|
||||||
|
The resulting binary is fully static. `ldd bin/goget` should report
|
||||||
|
`not a dynamic executable` on Linux.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just test # full suite, race detector, count=1
|
||||||
|
go test ./internal/protocol/gemini/... # focused
|
||||||
|
go test -run TestFoo ./internal/core/ # one test
|
||||||
|
```
|
||||||
|
|
||||||
|
Conventions:
|
||||||
|
|
||||||
|
- Tests live next to the code as `*_test.go`.
|
||||||
|
- Use table-driven tests for parsers, validators, and protocol handlers.
|
||||||
|
- Cover both happy path and error path.
|
||||||
|
- Test names follow `TestFunctionName_Scenario`.
|
||||||
|
- Fuzz tests use `testing.F` and live next to the code they exercise.
|
||||||
|
|
||||||
|
The race detector is **always** on in CI. New code that introduces a
|
||||||
|
data race must be fixed, not silenced.
|
||||||
|
|
||||||
|
## Manpage Authoring
|
||||||
|
|
||||||
|
The single source of truth is `man/goget.1` (troff). Editing this file is
|
||||||
|
what most users eventually read via `man goget` and what
|
||||||
|
`--generate-man-page` prints.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$EDITOR man/goget.1
|
||||||
|
just man # validates the troff
|
||||||
|
just build # copies the file into cmd/goget/man-page-data/
|
||||||
|
just test # cmd/goget/man_page_test.go asserts every CLI flag
|
||||||
|
# is documented and the embedded source still parses
|
||||||
|
```
|
||||||
|
|
||||||
|
`cmd/goget/man_page_test.go` enforces documentation coverage — adding a
|
||||||
|
new flag to `defineFlags` without documenting it in `man/goget.1` breaks
|
||||||
|
the build.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
The project uses `internal/log` for structured output. Prefer `log.Infof`,
|
||||||
|
`log.Errorf`, etc. over ad-hoc `fmt.Fprintf(os.Stderr, ...)` or
|
||||||
|
`cli.Print*`. The backlog tracks a migration of the remaining ~140 legacy
|
||||||
|
callsites to `internal/log`.
|
||||||
|
|
||||||
|
## Branching & Releases
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
Feature[Feature branches]
|
||||||
|
Development[development]
|
||||||
|
Main[main]
|
||||||
|
Tag[vX.Y.Z tag]
|
||||||
|
Release[Forgejo release]
|
||||||
|
|
||||||
|
Feature --> Development
|
||||||
|
Development --> Main
|
||||||
|
Main --> Tag
|
||||||
|
Tag --> Release
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`development`** — the only branch that receives day-to-day work.
|
||||||
|
All pull requests target `development`.
|
||||||
|
- **`main`** — only changes when `development` is merged for a release.
|
||||||
|
Never commit directly to `main`.
|
||||||
|
- **Tags** — `vX.Y.Z` tags trigger the release workflow.
|
||||||
|
|
||||||
|
### Cutting a Release
|
||||||
|
|
||||||
|
1. Bump `Version` in [`internal/core/version.go`](../internal/core/version.go).
|
||||||
|
2. Move the top `## [development]` section in
|
||||||
|
[`CHANGELOG.md`](../CHANGELOG.md) to `## [X.Y.Z] — YYYY-MM-DD`,
|
||||||
|
inserting a fresh empty `## [development]` section above it.
|
||||||
|
3. Ensure CI is green on `development`.
|
||||||
|
4. Merge `development` → `main`.
|
||||||
|
5. Tag and push:
|
||||||
|
```bash
|
||||||
|
git tag -a vX.Y.Z -m "goget vX.Y.Z"
|
||||||
|
git push origin main
|
||||||
|
git push origin vX.Y.Z
|
||||||
|
```
|
||||||
|
6. The release workflow builds binaries for all seven platform targets,
|
||||||
|
extracts the matching `CHANGELOG.md` section, and publishes the
|
||||||
|
release on Codeberg.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
Two Forgejo Actions workflows live under
|
||||||
|
[`.forgejo/workflows/`](../.forgejo/workflows):
|
||||||
|
|
||||||
|
### `test.yml`
|
||||||
|
|
||||||
|
Triggered on every push to `development` and on every pull request
|
||||||
|
targeting `development`.
|
||||||
|
|
||||||
|
| Job | What it does |
|
||||||
|
|-----|--------------|
|
||||||
|
| `vet` | `gofmt -l` check + `go vet ./...` (no output = pass) |
|
||||||
|
| `test` | `go test -race -count=1 ./...` + coverage gate |
|
||||||
|
|
||||||
|
Run the same locally before opening a PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go vet ./...
|
||||||
|
just test
|
||||||
|
```
|
||||||
|
|
||||||
|
### `release.yml`
|
||||||
|
|
||||||
|
Triggered by pushing a `v*` tag. Verifies that the tag matches
|
||||||
|
`internal/core.Version`, runs the quality gate, builds static binaries
|
||||||
|
for all seven targets, and publishes the release.
|
||||||
|
|
||||||
|
Requires a `FORGEJO_TOKEN` secret with release-creation permissions on
|
||||||
|
the runner.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- Follow [Effective Go](https://go.dev/doc/effective_go).
|
||||||
|
- Keep lines under 100 characters.
|
||||||
|
- `camelCase` for locals, `PascalCase` for exported symbols.
|
||||||
|
- Prefix every file with a one-line comment describing its purpose.
|
||||||
|
- Use `//go:build linux || freebsd` on every source file.
|
||||||
|
- Use long CLI flags only (`--header`, not `-H`).
|
||||||
|
- Stick to the Go standard library and `golang.org/x/`. External
|
||||||
|
dependencies require an explicit justification.
|
||||||
|
- Error messages: English, lowercase, no trailing period, always with
|
||||||
|
context (e.g. `open config: permission denied`).
|
||||||
|
- Run `gofmt` before committing.
|
||||||
|
|
||||||
|
## Reporting Bugs
|
||||||
|
|
||||||
|
Open an issue at
|
||||||
|
<https://codeberg.org/petrbalvin/goget/issues> with:
|
||||||
|
|
||||||
|
- `goget --version` output.
|
||||||
|
- Operating system and architecture.
|
||||||
|
- The full command used and its output.
|
||||||
|
- Expected vs actual behaviour.
|
||||||
|
|
||||||
|
For **security issues**, email
|
||||||
|
[opensource@petrbalvin.org](mailto:opensource@petrbalvin.org) instead of
|
||||||
|
opening a public issue.
|
||||||
+32
-42
@@ -6,33 +6,33 @@ This document explains how goget processes a download request from start to fini
|
|||||||
|
|
||||||
```mermaid
|
```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
@@ -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
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Package pool provides a protocol-agnostic recursive worker pool.
|
||||||
|
//
|
||||||
|
// It replaces the three near-identical semaphore+WaitGroup
|
||||||
|
// implementations that existed in the FTP, SFTP, and WebDAV packages
|
||||||
|
// with a single generic type. Each protocol supplies its own task
|
||||||
|
// type and handler function; the pool takes care of concurrency
|
||||||
|
// limiting, goroutine lifecycle, and error collection.
|
||||||
|
//
|
||||||
|
// Tasks may submit additional tasks from inside the handler (tree
|
||||||
|
// recursion). Submit never blocks the caller—each task gets its
|
||||||
|
// own goroutine that waits for a semaphore slot—so recursive
|
||||||
|
// submissions cannot deadlock even when all pool slots are occupied.
|
||||||
|
package pool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ctxKey is an unexported type for context value keys in this package.
|
||||||
|
type ctxKey struct{}
|
||||||
|
|
||||||
|
// Pool manages a bounded set of goroutines that execute tasks of
|
||||||
|
// type T. Tasks are submitted via Submit and executed by the
|
||||||
|
// handler function provided to New. The pool is safe for
|
||||||
|
// concurrent use from multiple goroutines.
|
||||||
|
type Pool[T any] struct {
|
||||||
|
handler func(ctx context.Context, task T)
|
||||||
|
|
||||||
|
sem chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
errMu sync.Mutex
|
||||||
|
errs []error
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a pool with max parallelism workers. handler is
|
||||||
|
// called for every submitted task, running in its own goroutine
|
||||||
|
// bounded by the semaphore. The returned pool is ready to use.
|
||||||
|
//
|
||||||
|
// ctx is the parent context for all handler goroutines. When ctx
|
||||||
|
// is cancelled, waiting and running handlers observe the
|
||||||
|
// cancellation via the context passed to them.
|
||||||
|
//
|
||||||
|
// The context passed to handler carries a reference to the pool
|
||||||
|
// itself, retrievable via Get. This allows handlers to submit
|
||||||
|
// child tasks without an explicit closure capture.
|
||||||
|
func New[T any](ctx context.Context, maxParallelism int, handler func(ctx context.Context, task T)) *Pool[T] {
|
||||||
|
if maxParallelism < 1 {
|
||||||
|
maxParallelism = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
return &Pool[T]{
|
||||||
|
handler: handler,
|
||||||
|
sem: make(chan struct{}, maxParallelism),
|
||||||
|
cancel: cancel,
|
||||||
|
ctx: ctx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves the pool of type *Pool[T] from the context.
|
||||||
|
// It returns nil when the context does not carry a pool (for
|
||||||
|
// example, when called outside a handler goroutine).
|
||||||
|
func Get[T any](ctx context.Context) *Pool[T] {
|
||||||
|
v, _ := ctx.Value(ctxKey{}).(*Pool[T])
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit enqueues a task for execution. Each submitted task gets
|
||||||
|
// its own goroutine that will wait for a semaphore slot before
|
||||||
|
// running the handler. Submit may be called from inside a handler
|
||||||
|
// (for recursive tasks).
|
||||||
|
//
|
||||||
|
// Submit is a no-op if the pool has already been waited on.
|
||||||
|
func (p *Pool[T]) Submit(task T) {
|
||||||
|
// After Wait returns the context is cancelled; skip work that
|
||||||
|
// can never run.
|
||||||
|
select {
|
||||||
|
case <-p.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
p.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer p.wg.Done()
|
||||||
|
select {
|
||||||
|
case p.sem <- struct{}{}:
|
||||||
|
defer func() { <-p.sem }()
|
||||||
|
handlerCtx := context.WithValue(p.ctx, ctxKey{}, p)
|
||||||
|
p.handler(handlerCtx, task)
|
||||||
|
case <-p.ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait blocks until all submitted tasks (including tasks submitted
|
||||||
|
// by handlers) have completed. It returns all errors collected
|
||||||
|
// during execution. After Wait returns the pool must not be used.
|
||||||
|
func (p *Pool[T]) Wait() []error {
|
||||||
|
p.wg.Wait()
|
||||||
|
p.cancel()
|
||||||
|
|
||||||
|
p.errMu.Lock()
|
||||||
|
defer p.errMu.Unlock()
|
||||||
|
if len(p.errs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddError records a task-level error. It is safe to call from
|
||||||
|
// any goroutine, typically from inside a handler.
|
||||||
|
func (p *Pool[T]) AddError(err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.errMu.Lock()
|
||||||
|
p.errs = append(p.errs, err)
|
||||||
|
p.errMu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
//go:build linux || freebsd
|
||||||
|
// +build linux freebsd
|
||||||
|
|
||||||
|
package pool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPoolBasic(t *testing.T) {
|
||||||
|
var count atomic.Int64
|
||||||
|
p := New(context.Background(), 4, func(_ context.Context, task int) {
|
||||||
|
count.Add(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
p.Submit(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
errs := p.Wait()
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("unexpected errors: %v", errs)
|
||||||
|
}
|
||||||
|
if got := count.Load(); got != 10 {
|
||||||
|
t.Fatalf("processed %d tasks, want 10", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolConcurrencyLimit(t *testing.T) {
|
||||||
|
const parallel = 3
|
||||||
|
var running atomic.Int64
|
||||||
|
var maxRunning atomic.Int64
|
||||||
|
|
||||||
|
p := New(context.Background(), parallel, func(_ context.Context, task int) {
|
||||||
|
cur := running.Add(1)
|
||||||
|
for {
|
||||||
|
old := maxRunning.Load()
|
||||||
|
if cur <= old || maxRunning.CompareAndSwap(old, cur) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Simulate work so concurrent goroutines overlap.
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
}
|
||||||
|
running.Add(-1)
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
p.Submit(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
errs := p.Wait()
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("unexpected errors: %v", errs)
|
||||||
|
}
|
||||||
|
if got := maxRunning.Load(); got > parallel {
|
||||||
|
t.Fatalf("max concurrent goroutines %d, want <= %d", got, parallel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolRecursive(t *testing.T) {
|
||||||
|
// Simulate a tree: each task submits two children until depth 3.
|
||||||
|
// Expected total tasks: 1 + 2 + 4 + 8 = 15.
|
||||||
|
var count atomic.Int64
|
||||||
|
|
||||||
|
type task struct {
|
||||||
|
depth int
|
||||||
|
}
|
||||||
|
|
||||||
|
p := New(context.Background(), 4, func(ctx context.Context, tk task) {
|
||||||
|
count.Add(1)
|
||||||
|
if tk.depth > 0 {
|
||||||
|
Get[task](ctx).Submit(task{depth: tk.depth - 1})
|
||||||
|
Get[task](ctx).Submit(task{depth: tk.depth - 1})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
p.Submit(task{depth: 3})
|
||||||
|
|
||||||
|
errs := p.Wait()
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("unexpected errors: %v", errs)
|
||||||
|
}
|
||||||
|
if got := count.Load(); got != 15 {
|
||||||
|
t.Fatalf("processed %d tasks, want 15", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolErrorCollection(t *testing.T) {
|
||||||
|
p := New(context.Background(), 2, func(ctx context.Context, task int) {
|
||||||
|
if task%2 == 0 {
|
||||||
|
Get[int](ctx).AddError(&testError{task})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
p.Submit(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
errs := p.Wait()
|
||||||
|
if len(errs) != 3 {
|
||||||
|
t.Fatalf("collected %d errors, want 3", len(errs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOutsideHandler(t *testing.T) {
|
||||||
|
// Get returns nil when called outside a handler goroutine.
|
||||||
|
if got := Get[int](context.Background()); got != nil {
|
||||||
|
t.Fatalf("Get outside handler returned %v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolContextCancel(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
p := New(ctx, 4, func(taskCtx context.Context, task int) {
|
||||||
|
// Each task blocks until context is cancelled.
|
||||||
|
<-taskCtx.Done()
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
p.Submit(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel after a short delay so the tasks start.
|
||||||
|
go func() {
|
||||||
|
// Give goroutines time to start and acquire semaphore.
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
errs := p.Wait()
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("unexpected errors: %v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolDeterministic(t *testing.T) {
|
||||||
|
// Verify that all submitted tasks are processed exactly once.
|
||||||
|
const n = 100
|
||||||
|
var mu sync.Mutex
|
||||||
|
seen := make(map[int]int)
|
||||||
|
|
||||||
|
p := New(context.Background(), 8, func(_ context.Context, task int) {
|
||||||
|
mu.Lock()
|
||||||
|
seen[task]++
|
||||||
|
mu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
p.Submit(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
errs := p.Wait()
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("unexpected errors: %v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(seen) != n {
|
||||||
|
t.Fatalf("saw %d unique tasks, want %d", len(seen), n)
|
||||||
|
}
|
||||||
|
for k, v := range seen {
|
||||||
|
if v != 1 {
|
||||||
|
t.Errorf("task %d processed %d times, want 1", k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolSortedResults(t *testing.T) {
|
||||||
|
// Collect results from concurrent workers and verify we get them all.
|
||||||
|
const n = 50
|
||||||
|
var mu sync.Mutex
|
||||||
|
results := make([]int, 0, n)
|
||||||
|
|
||||||
|
p := New(context.Background(), 4, func(_ context.Context, task int) {
|
||||||
|
mu.Lock()
|
||||||
|
results = append(results, task)
|
||||||
|
mu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
p.Submit(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
errs := p.Wait()
|
||||||
|
if len(errs) != 0 {
|
||||||
|
t.Fatalf("unexpected errors: %v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Ints(results)
|
||||||
|
if len(results) != n {
|
||||||
|
t.Fatalf("got %d results, want %d", len(results), n)
|
||||||
|
}
|
||||||
|
for i, v := range results {
|
||||||
|
if v != i {
|
||||||
|
t.Fatalf("results[%d] = %d, want %d", i, v, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type testError struct {
|
||||||
|
task int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *testError) Error() string {
|
||||||
|
return "error in task"
|
||||||
|
}
|
||||||
+108
-92
@@ -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)
|
// ftpTaskHandler processes one ftpTask. For directories it lists the
|
||||||
|
// contents and submits child tasks for each entry. For files it
|
||||||
wg.Wait()
|
// acquires a client from the pool and downloads.
|
||||||
return firstErr
|
func ftpTaskHandler(cpool *clientPool, progressFunc func(file string, current, total int64)) func(ctx context.Context, t ftpTask) {
|
||||||
}
|
return func(ctx context.Context, t ftpTask) {
|
||||||
|
|
||||||
// processEntry processes one remote path: lists it, then dispatches
|
|
||||||
// its child entries (file → download, subdir → recursive processEntry)
|
|
||||||
// through the shared semaphore-bounded goroutine pool. A single
|
|
||||||
// sem is threaded through the recursion so the total in-flight
|
|
||||||
// goroutines never exceed `parallel`, regardless of directory depth.
|
|
||||||
func processEntry(ctx context.Context, pool *clientPool, sem chan struct{}, wg *sync.WaitGroup, setErr func(error), remotePath, localPath string, progressFunc func(file string, current, total int64)) {
|
|
||||||
defer wg.Done()
|
|
||||||
defer func() { <-sem }()
|
|
||||||
|
|
||||||
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),
|
||||||
|
isDir: childIsDir,
|
||||||
|
name: name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if isDir {
|
// DownloadDirectoryConcurrent downloads a remote directory to localPath
|
||||||
wg.Add(1)
|
// using a pool of N FTP connections. All operations (LIST, RETR) use
|
||||||
select {
|
// absolute paths so concurrent workers never share or mutate CWD state
|
||||||
case sem <- struct{}{}:
|
// on the server side. The returned error is the first error encountered
|
||||||
go processEntry(ctx, pool, sem, wg, setErr, remoteFilePath, localFilePath, progressFunc)
|
// by any worker; other workers continue in the background until they
|
||||||
case <-ctx.Done():
|
// hit ctx cancellation or finish their tasks.
|
||||||
return
|
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 {
|
||||||
} else {
|
parallel = 1
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
cpool, err := newClientPool(rawURL, timeout, parallel, pinnedCertHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
defer cpool.close()
|
||||||
|
|
||||||
|
p := pool.New(ctx, parallel, ftpTaskHandler(cpool, progressFunc))
|
||||||
|
p.Submit(ftpTask{
|
||||||
|
remotePath: remotePath,
|
||||||
|
localPath: localPath,
|
||||||
|
isDir: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if errs := p.Wait(); len(errs) > 0 {
|
||||||
|
return errs[0]
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -380,3 +380,21 @@ func TestNewClientTLSConfig(t *testing.T) {
|
|||||||
t.Errorf("tlsConfig.InsecureSkipVerify should be false by default")
|
t.Errorf("tlsConfig.InsecureSkipVerify should be false by default")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetPinnedCert(t *testing.T) {
|
||||||
|
client, err := NewClient("ftps://secure.example.com/file.txt", 30*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewClient() returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinning is off by default.
|
||||||
|
if client.pinnedCertHash != "" {
|
||||||
|
t.Errorf("pinnedCertHash should default to empty, got %q", client.pinnedCertHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPinnedCert normalises to lowercase and trims whitespace.
|
||||||
|
client.SetPinnedCert(" ABCDEF0123456789 ")
|
||||||
|
if client.pinnedCertHash != "abcdef0123456789" {
|
||||||
|
t.Errorf("pinnedCertHash = %q, want %q", client.pinnedCertHash, "abcdef0123456789")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*co
|
|||||||
if err != nil {
|
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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,950 @@
|
|||||||
|
.\" Manpage for goget.
|
||||||
|
.\" Source of truth — kept in sync with cmd/goget/man-page-data/goget.1
|
||||||
|
.\" by the `cp` step in `just build`. The duplicate is required because
|
||||||
|
.\" //go:embed cannot reference files outside the embedding package.
|
||||||
|
.\"
|
||||||
|
.\" Conventions used in this file:
|
||||||
|
.\" .TP tag-paragraph (one option per entry)
|
||||||
|
.\" \fB...\fR bold (the flag itself)
|
||||||
|
.\" \fI...\fR italic (the argument placeholder)
|
||||||
|
.\" \- literal hyphen (escapes groff's dash-to-endash conversion)
|
||||||
|
.\" \\- alternative escape for the same purpose
|
||||||
|
.\"
|
||||||
|
.\" Validate with: groff -man -ww man/goget.1 > /dev/null
|
||||||
|
.\" Render with: groff -man -Tutf8 man/goget.1 | less
|
||||||
|
.\"
|
||||||
|
.TH GOGET 1 "2026-06-26" "goget 1.0.0" "User Commands"
|
||||||
|
.SH NAME
|
||||||
|
goget \- modern IPv6-first command-line download utility
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B goget
|
||||||
|
[\fIOPTION\fR]... \fIURL\fR...
|
||||||
|
.SH DESCRIPTION
|
||||||
|
.B goget
|
||||||
|
is a modern replacement for
|
||||||
|
.BR curl (1)
|
||||||
|
and
|
||||||
|
.BR wget (1)
|
||||||
|
built on the Go standard library.
|
||||||
|
It provides IPv6-first connections (with automatic IPv4 fallback), parallel
|
||||||
|
chunked downloads, resume support, checksum verification, recursive website
|
||||||
|
mirroring, and support for nine network protocols
|
||||||
|
.RB ( HTTP / HTTPS ", " FTP / FTPS ", " SFTP ", " WebDAV / WebDAVS ", "
|
||||||
|
.BR file:// ", " data: ", " Gopher ", and " Gemini ") \(em all in a
|
||||||
|
single static binary with zero runtime dependencies.
|
||||||
|
.PP
|
||||||
|
Safety features include TLS 1.2 minimum, certificate verification, SSRF
|
||||||
|
protection (private/internal addresses are blocked by default), SSH host-key
|
||||||
|
verification for SFTP, HSTS cache (RFC 6797), and certificate or host-key
|
||||||
|
pinning.
|
||||||
|
.PP
|
||||||
|
URLs are specified positionally after the flags; the
|
||||||
|
.B \-\-url
|
||||||
|
flag is an alias for the first positional URL and may be repeated to download
|
||||||
|
several files in one invocation.
|
||||||
|
.PP
|
||||||
|
Configuration is loaded from
|
||||||
|
.IR ~/.config/goget/config.toml
|
||||||
|
(overridable with
|
||||||
|
.BR \-\-config " or the " GOGET_CONFIG
|
||||||
|
environment variable) and merged on top of compiled-in defaults. Command-line
|
||||||
|
flags always take precedence over configuration values.
|
||||||
|
.SH OPTIONS
|
||||||
|
.SS "Target"
|
||||||
|
.TP
|
||||||
|
\fB\-\-url\fR=\fIURL\fR
|
||||||
|
URL to download. May be repeated for multiple URLs, or supplied as positional
|
||||||
|
arguments after the flags.
|
||||||
|
.SS "Output"
|
||||||
|
.TP
|
||||||
|
\fB\-\-output\fR=\fIFILE\fR
|
||||||
|
Output filename or directory. When omitted, the filename is derived from the
|
||||||
|
URL.
|
||||||
|
.TP
|
||||||
|
\fB\-\-restart\fR
|
||||||
|
Delete any existing output file and restart the download from byte zero.
|
||||||
|
.TP
|
||||||
|
\fB\-\-overwrite\fR
|
||||||
|
Overwrite an existing output file without using atomic rename.
|
||||||
|
.TP
|
||||||
|
\fB\-\-content\-disposition\fR
|
||||||
|
Use the filename from the server's
|
||||||
|
.B Content\-Disposition
|
||||||
|
header instead of the one derived from the URL.
|
||||||
|
.TP
|
||||||
|
\fB\-\-create\-dirs\fR
|
||||||
|
Create missing parent directories of
|
||||||
|
.BR \-\-output
|
||||||
|
(enabled by default; disable with
|
||||||
|
.B \-\-create\-dirs=false
|
||||||
|
for
|
||||||
|
.BR curl (1) " /" wget (1)
|
||||||
|
compatibility). Ignored for stdout output.
|
||||||
|
.TP
|
||||||
|
\fB\-\-keep\-timestamps\fR
|
||||||
|
Set the output file's modification time to the server's
|
||||||
|
.B Last\-Modified
|
||||||
|
value.
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-clobber\fR
|
||||||
|
Skip the download if the output file already exists.
|
||||||
|
.TP
|
||||||
|
\fB\-\-continue\fR
|
||||||
|
Auto-resume the download if a partial output file is present.
|
||||||
|
.TP
|
||||||
|
\fB\-\-timestamping\fR
|
||||||
|
Download only when the remote file is newer than the local copy.
|
||||||
|
.SS "Progress and output"
|
||||||
|
.TP
|
||||||
|
\fB\-\-verbose\fR
|
||||||
|
Verbose output (default: enabled when set in the config file).
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-progress\fR
|
||||||
|
Hide the progress bar; keep every other piece of output.
|
||||||
|
.TP
|
||||||
|
\fB\-\-progress\fR=\fISTYLE\fR
|
||||||
|
Progress bar style. One of
|
||||||
|
.B bar
|
||||||
|
(default) or
|
||||||
|
.BR dot .
|
||||||
|
.TP
|
||||||
|
\fB\-\-quiet\fR
|
||||||
|
Suppress all output except errors.
|
||||||
|
.TP
|
||||||
|
\fB\-\-json\fR
|
||||||
|
Emit progress and completion events as newline-delimited JSON on stdout.
|
||||||
|
Each event is a single JSON object with
|
||||||
|
.BR type " (" progress " or " complete "), " downloaded ", " total ", " speed ,
|
||||||
|
and (for the
|
||||||
|
.B progress
|
||||||
|
event) an
|
||||||
|
.B eta_ms
|
||||||
|
field when the total size and speed are known.
|
||||||
|
.TP
|
||||||
|
\fB\-\-debug\fR
|
||||||
|
Enable debug output with detailed connection and protocol information.
|
||||||
|
.TP
|
||||||
|
\fB\-\-show\-headers\fR
|
||||||
|
Print HTTP response headers during the download.
|
||||||
|
.TP
|
||||||
|
\fB\-\-write\-out\fR=\fIFORMAT\fR
|
||||||
|
Print metadata after the transfer, using a
|
||||||
|
.BR curl (1)\-compatible
|
||||||
|
format string. Supported directives:
|
||||||
|
.BR %\{http_code\} ", " %\{size_download\} ", " %\{time_total\} ", "
|
||||||
|
.BR %\{speed_download\} .
|
||||||
|
.TP
|
||||||
|
\fB\-\-trace\-time\fR
|
||||||
|
Print an HTTP timing breakdown after the download:
|
||||||
|
.BR DNS ", " TCP ", " TLS ", " TTFB ", and " Total .
|
||||||
|
.SS "Network"
|
||||||
|
.TP
|
||||||
|
\fB\-\-timeout\fR=\fIDURATION\fR
|
||||||
|
Overall connection timeout.
|
||||||
|
.B 0
|
||||||
|
selects a smart auto-calculation based on the file size.
|
||||||
|
.TP
|
||||||
|
\fB\-\-connect\-timeout\fR=\fIDURATION\fR
|
||||||
|
Timeout for the TCP (or SSH) connection establishment phase.
|
||||||
|
.TP
|
||||||
|
\fB\-\-max\-time\fR=\fIDURATION\fR
|
||||||
|
Maximum total transfer time; abort if exceeded.
|
||||||
|
.B 0
|
||||||
|
disables the limit.
|
||||||
|
.TP
|
||||||
|
\fB\-\-max\-retries\fR=\fIN\fR
|
||||||
|
Maximum number of retry attempts on failure.
|
||||||
|
.B 0
|
||||||
|
selects the built-in default.
|
||||||
|
.TP
|
||||||
|
\fB\-\-retry\-delay\fR=\fIDURATION\fR
|
||||||
|
Delay inserted between retry attempts.
|
||||||
|
.TP
|
||||||
|
\fB\-\-retry\-all\-errors\fR
|
||||||
|
Retry on any HTTP 4xx or 5xx response (in addition to network errors).
|
||||||
|
.TP
|
||||||
|
\fB\-\-retry\-on\-http\-error\fR=\fICODES\fR
|
||||||
|
Retry on the given HTTP status codes (comma-separated; e.g.
|
||||||
|
.BR 503,429 ).
|
||||||
|
.TP
|
||||||
|
\fB\-\-max\-filesize\fR=\fISIZE\fR
|
||||||
|
Abort the transfer if the remote file exceeds this size (e.g.
|
||||||
|
.BR 100MB ", " 1GB ).
|
||||||
|
.TP
|
||||||
|
\fB\-\-proxy\fR=\fIURL\fR
|
||||||
|
Proxy URL.
|
||||||
|
.B http://
|
||||||
|
and
|
||||||
|
.B https://
|
||||||
|
trigger HTTP CONNECT;
|
||||||
|
.B socks5://
|
||||||
|
uses local DNS,
|
||||||
|
.B socks5h://
|
||||||
|
uses remote DNS.
|
||||||
|
Optional
|
||||||
|
.B user:pass@
|
||||||
|
credentials are supported.
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-ipv4\fR
|
||||||
|
Disable IPv4 fallback; connect over IPv6 only.
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-redirect\fR
|
||||||
|
Do not follow HTTP 3xx redirects.
|
||||||
|
.TP
|
||||||
|
\fB\-\-dns\-servers\fR=\fIIPS\fR
|
||||||
|
Custom DNS servers, comma-separated (e.g.
|
||||||
|
.BR 1.1.1.1,8.8.8.8 ).
|
||||||
|
.TP
|
||||||
|
\fB\-\-bind\-interface\fR=\fIIFACE\fR
|
||||||
|
Bind outgoing connections to a specific network interface (Linux only).
|
||||||
|
.TP
|
||||||
|
\fB\-\-allow\-private\fR
|
||||||
|
Allow connections to private or internal IP addresses. By default these
|
||||||
|
are blocked as an SSRF safeguard.
|
||||||
|
.TP
|
||||||
|
\fB\-\-pinned\-cert\fR=\fISHA256\fR
|
||||||
|
Pin the server certificate by its leaf SHA-256 fingerprint. Applies to all
|
||||||
|
TLS-bearing protocols (HTTPS, FTPS, Gemini, WebDAVS); abort on mismatch.
|
||||||
|
.TP
|
||||||
|
\fB\-\-pinned\-host\-key\fR=\fIFINGERPRINT\fR
|
||||||
|
Pin the SSH host key (SFTP). Accepts
|
||||||
|
.BR SHA256:<base64>
|
||||||
|
(as emitted by
|
||||||
|
.BR "ssh\-keygen \-lf" )
|
||||||
|
or a raw lowercase-hex SHA-256 of the key wire format. Pinning takes
|
||||||
|
precedence over
|
||||||
|
.B \-\-ssh\-insecure
|
||||||
|
and any
|
||||||
|
.I known_hosts
|
||||||
|
entry.
|
||||||
|
.TP
|
||||||
|
\fB\-\-rate\-limit\fR=\fIRATE\fR
|
||||||
|
Maximum download speed (e.g.
|
||||||
|
.BR 1MB/s ", " 500KB/s ).
|
||||||
|
.B 0
|
||||||
|
disables the limit.
|
||||||
|
.TP
|
||||||
|
\fB\-\-max\-speed\fR=\fIRATE\fR
|
||||||
|
Alias for
|
||||||
|
.B \-\-rate\-limit
|
||||||
|
in the legacy "bytes per second" form.
|
||||||
|
.TP
|
||||||
|
\fB\-\-range\fR=\fIN\fB\-\fIN\fR
|
||||||
|
Download only a byte range (e.g.
|
||||||
|
.BR 0\-999 ).
|
||||||
|
.SS "HTTP and request shaping"
|
||||||
|
.TP
|
||||||
|
\fB\-\-header\fR=\fI"Key: Value"\fR
|
||||||
|
Custom HTTP header. May be repeated.
|
||||||
|
.TP
|
||||||
|
\fB\-\-insecure\fR
|
||||||
|
Skip TLS certificate verification. Use only for testing.
|
||||||
|
.TP
|
||||||
|
\fB\-\-cert\fR=\fIFILE\fR
|
||||||
|
Client certificate in PEM format (for mTLS).
|
||||||
|
.TP
|
||||||
|
\fB\-\-key\fR=\fIFILE\fR
|
||||||
|
Client private key in PEM format (for mTLS).
|
||||||
|
.TP
|
||||||
|
\fB\-\-ca\-certificate\fR=\fIFILE\fR
|
||||||
|
Path to a custom CA certificate bundle.
|
||||||
|
.TP
|
||||||
|
\fB\-\-ssl\-key\-log\fR=\fIFILE\fR
|
||||||
|
Log TLS session keys to
|
||||||
|
.I FILE
|
||||||
|
in the NSS Key Log format used by Wireshark
|
||||||
|
.RB ( SSLKEYLOGFILE ).
|
||||||
|
.TP
|
||||||
|
\fB\-\-data\fR=\fI"key=value"\fR
|
||||||
|
Send
|
||||||
|
.I key=value
|
||||||
|
as the request body. Implies HTTP POST.
|
||||||
|
.TP
|
||||||
|
\fB\-\-form\fR=\fI"field=value"\fR
|
||||||
|
Multipart form field; repeatable. A value of
|
||||||
|
.B field=@/path
|
||||||
|
attaches a file upload.
|
||||||
|
.TP
|
||||||
|
\fB\-\-post\-file\fR=\fIFILE\fR
|
||||||
|
POST the contents of
|
||||||
|
.I FILE
|
||||||
|
as the request body.
|
||||||
|
.TP
|
||||||
|
\fB\-\-referer\fR=\fIURL\fR
|
||||||
|
Set the
|
||||||
|
.B Referer
|
||||||
|
request header.
|
||||||
|
.TP
|
||||||
|
\fB\-\-compressed\fR
|
||||||
|
Send an
|
||||||
|
.B Accept\-Encoding
|
||||||
|
header so the server returns a compressed response. By default
|
||||||
|
.B goget
|
||||||
|
already handles compressed responses transparently; this flag is provided
|
||||||
|
for
|
||||||
|
.BR curl (1)
|
||||||
|
compatibility.
|
||||||
|
.TP
|
||||||
|
\fB\-\-spider\fR
|
||||||
|
Check whether the URL exists via a HEAD request; do not download the body.
|
||||||
|
.TP
|
||||||
|
\fB\-\-fail\fR
|
||||||
|
Exit with a non-zero status on HTTP 4xx or 5xx responses.
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-decompress\fR
|
||||||
|
Disable automatic decompression of compressed response bodies.
|
||||||
|
.SS "Recursive downloading and mirroring"
|
||||||
|
.TP
|
||||||
|
\fB\-\-recursive\fR
|
||||||
|
Follow links discovered in HTML pages.
|
||||||
|
.TP
|
||||||
|
\fB\-\-mirror\fR
|
||||||
|
Mirror an entire website. Shorthand for
|
||||||
|
.BR "\-\-recursive \-\-convert\-links \-\-page\-requisites" .
|
||||||
|
.TP
|
||||||
|
\fB\-\-max\-depth\fR=\fIN\fR
|
||||||
|
Maximum recursion depth.
|
||||||
|
.TP
|
||||||
|
\fB\-\-recursive\-parallel\fR=\fIN\fR
|
||||||
|
Number of concurrent file downloads in recursive mode across all protocols
|
||||||
|
(HTTP, WebDAV, FTP, SFTP).
|
||||||
|
.B 0
|
||||||
|
runs sequentially (default).
|
||||||
|
.TP
|
||||||
|
\fB\-\-follow\-external\fR
|
||||||
|
Follow links to domains other than the starting domain.
|
||||||
|
.TP
|
||||||
|
\fB\-\-span\-hosts\fR
|
||||||
|
Alias for
|
||||||
|
.B \-\-follow\-external
|
||||||
|
in the
|
||||||
|
.BR wget (1)
|
||||||
|
style.
|
||||||
|
.TP
|
||||||
|
\fB\-\-domains\fR=\fILIST\fR
|
||||||
|
Restrict recursive crawling to the listed comma-separated domains.
|
||||||
|
.TP
|
||||||
|
\fB\-\-accept\fR=\fIPATTERN\fR
|
||||||
|
Accept patterns (filename glob or MIME type), comma-separated. Only
|
||||||
|
matching resources are downloaded.
|
||||||
|
.TP
|
||||||
|
\fB\-\-reject\fR=\fIPATTERN\fR
|
||||||
|
Reject patterns (glob), comma-separated. Matching resources are skipped.
|
||||||
|
.TP
|
||||||
|
\fB\-\-exclude\-pattern\fR=\fIPATTERN\fR
|
||||||
|
Alias for
|
||||||
|
.B \-\-reject
|
||||||
|
used by the recursive crawler.
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-parent\fR
|
||||||
|
Do not ascend to parent directories during recursive download.
|
||||||
|
.TP
|
||||||
|
\fB\-\-page\-requisites\fR
|
||||||
|
Download CSS, JavaScript, and images required to render the HTML pages.
|
||||||
|
.TP
|
||||||
|
\fB\-\-convert\-links\fR
|
||||||
|
Rewrite links in downloaded HTML for local offline viewing.
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-convert\-links\fR
|
||||||
|
Skip HTML link rewriting (faster; output is not offline-ready).
|
||||||
|
.TP
|
||||||
|
\fB\-\-backup\-converted\fR
|
||||||
|
Keep a
|
||||||
|
.B .orig
|
||||||
|
backup of each file before link conversion.
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-mirror\-assets\fR
|
||||||
|
Do not download CSS, JavaScript, and images during a mirror.
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-robots\fR
|
||||||
|
Do not honour
|
||||||
|
.I robots.txt
|
||||||
|
during mirroring.
|
||||||
|
.TP
|
||||||
|
\fB\-\-wait\fR=\fIDURATION\fR
|
||||||
|
Delay between requests in recursive or mirror mode (e.g.
|
||||||
|
.BR 1s ", " 500ms ).
|
||||||
|
.TP
|
||||||
|
\fB\-\-random\-wait\fR
|
||||||
|
Randomize the wait time between 0.5x and 1.5x of
|
||||||
|
.BR \-\-wait .
|
||||||
|
.TP
|
||||||
|
\fB\-\-adjust\-extension\fR
|
||||||
|
Append a
|
||||||
|
.B .html
|
||||||
|
extension to text/html files that lack one.
|
||||||
|
.TP
|
||||||
|
\fB\-\-cut\-dirs\fR=\fIN\fR
|
||||||
|
Ignore the first
|
||||||
|
.I N
|
||||||
|
path components when constructing local filenames.
|
||||||
|
.TP
|
||||||
|
\fB\-\-protocol\-directories\fR
|
||||||
|
Create protocol-prefixed subdirectories
|
||||||
|
.RB ( .http/ ", " .ftp/ )
|
||||||
|
in recursive mode.
|
||||||
|
.TP
|
||||||
|
\fB\-\-dry\-run\fR
|
||||||
|
List the URLs that would be downloaded in recursive or mirror mode without
|
||||||
|
writing any files to disk.
|
||||||
|
.TP
|
||||||
|
\fB\-\-warc\-file\fR=\fIFILE\fR
|
||||||
|
Write a WARC (Web ARChive) record of the transfer to
|
||||||
|
.IR FILE .
|
||||||
|
.SS "Download management"
|
||||||
|
.TP
|
||||||
|
\fB\-\-batch\-file\fR=\fIFILE\fR
|
||||||
|
Read URLs from
|
||||||
|
.IR FILE ,
|
||||||
|
one per line. Lines beginning with
|
||||||
|
.B #
|
||||||
|
are treated as comments and skipped.
|
||||||
|
.TP
|
||||||
|
\fB\-\-input\-file\fR=\fIFILE\fR
|
||||||
|
Alias for
|
||||||
|
.BR \-\-batch\-file .
|
||||||
|
A value of
|
||||||
|
.B \-
|
||||||
|
reads from standard input.
|
||||||
|
.TP
|
||||||
|
\fB\-\-glob\fR
|
||||||
|
Expand
|
||||||
|
.B [1-3]
|
||||||
|
and
|
||||||
|
.B {a,b,c}
|
||||||
|
patterns in the URL before issuing requests.
|
||||||
|
.TP
|
||||||
|
\fB\-\-queue\fR
|
||||||
|
Add the URL to the persistent download queue instead of downloading it
|
||||||
|
immediately.
|
||||||
|
.TP
|
||||||
|
\fB\-\-queue\-list\fR
|
||||||
|
List all items in the download queue and exit.
|
||||||
|
.TP
|
||||||
|
\fB\-\-queue\-process\fR
|
||||||
|
Process (download) every pending item in the queue and exit.
|
||||||
|
.TP
|
||||||
|
\fB\-\-queue\-clear\fR
|
||||||
|
Remove completed items from the queue.
|
||||||
|
.TP
|
||||||
|
\fB\-\-queue\-file\fR=\fIPATH\fR
|
||||||
|
Use a custom queue file (default
|
||||||
|
.IR ~/.config/goget/queue.json ).
|
||||||
|
.TP
|
||||||
|
\fB\-\-queue\-priority\fR=\fIN\fR
|
||||||
|
Priority for the queued item (1\(en10; higher values are processed first).
|
||||||
|
Default: 5.
|
||||||
|
.TP
|
||||||
|
\fB\-\-schedule\fR=\fITIME\fR
|
||||||
|
Schedule the download. Accepts an absolute timestamp
|
||||||
|
.RB ( " YYYY\-MM\-DD HH:MM ")
|
||||||
|
or a cron expression in the standard
|
||||||
|
.B MIN HOUR DOM MON DOW
|
||||||
|
format (e.g.
|
||||||
|
.BR "0 2 * * *" ).
|
||||||
|
.TP
|
||||||
|
\fB\-\-on\-complete\fR=\fICMD\fR
|
||||||
|
Run
|
||||||
|
.I CMD
|
||||||
|
after a successful download. The following environment variables are
|
||||||
|
exported into the hook:
|
||||||
|
.RS
|
||||||
|
.TP
|
||||||
|
.B GOGET_OUTPUT
|
||||||
|
Absolute path of the downloaded file.
|
||||||
|
.TP
|
||||||
|
.B GOGET_SIZE
|
||||||
|
Downloaded size in bytes.
|
||||||
|
.TP
|
||||||
|
.B GOGET_URL
|
||||||
|
The (credential-stripped) source URL.
|
||||||
|
.RE
|
||||||
|
.IP
|
||||||
|
The command is split into tokens with shell-like quoting rules but is
|
||||||
|
executed directly via
|
||||||
|
.BR exec (3)
|
||||||
|
\(em no shell is invoked, so metacharacters in arguments are not
|
||||||
|
re-interpreted.
|
||||||
|
.SS "Authentication"
|
||||||
|
.TP
|
||||||
|
\fB\-\-username\fR=\fIUSER\fR
|
||||||
|
Username for HTTP Basic or Digest authentication. When omitted, credentials
|
||||||
|
are auto-loaded from
|
||||||
|
.I ~/.netrc
|
||||||
|
if a matching machine entry is present.
|
||||||
|
.TP
|
||||||
|
\fB\-\-password\fR=\fIPASS\fR
|
||||||
|
Password for HTTP Basic or Digest authentication. Prefer
|
||||||
|
.B \-\-password\-file
|
||||||
|
to avoid exposing the secret on the command line.
|
||||||
|
.TP
|
||||||
|
\fB\-\-password\-file\fR=\fIFILE\fR
|
||||||
|
Read the password from
|
||||||
|
.I FILE
|
||||||
|
(the first line, with the trailing newline stripped).
|
||||||
|
.TP
|
||||||
|
\fB\-\-auth\-type\fR=\fITYPE\fR
|
||||||
|
Authentication scheme:
|
||||||
|
.BR basic ", " digest ", or " auto
|
||||||
|
(default).
|
||||||
|
.TP
|
||||||
|
\fB\-\-cookie\-jar\fR=\fIFILE\fR
|
||||||
|
Load cookies from and save cookies to
|
||||||
|
.I FILE
|
||||||
|
in Netscape format.
|
||||||
|
.TP
|
||||||
|
\fB\-\-cookie\fR=\fI"name=value"\fR
|
||||||
|
Set a cookie on the request. May be repeated.
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-private\fR
|
||||||
|
Disable
|
||||||
|
.I .netrc
|
||||||
|
and other credential-file auto-loading for this invocation.
|
||||||
|
.TP
|
||||||
|
\fB\-\-oauth\-client\-id\fR=\fIID\fR
|
||||||
|
OAuth 2.0 client ID.
|
||||||
|
.TP
|
||||||
|
\fB\-\-oauth\-client\-secret\fR=\fISECRET\fR
|
||||||
|
OAuth 2.0 client secret.
|
||||||
|
.TP
|
||||||
|
\fB\-\-oauth\-token\-url\fR=\fIURL\fR
|
||||||
|
OAuth 2.0 token endpoint.
|
||||||
|
.TP
|
||||||
|
\fB\-\-oauth\-auth\-url\fR=\fIURL\fR
|
||||||
|
OAuth 2.0 authorization endpoint (used by the
|
||||||
|
.B authorization_code
|
||||||
|
grant).
|
||||||
|
.TP
|
||||||
|
\fB\-\-oauth\-redirect\-uri\fR=\fIURI\fR
|
||||||
|
OAuth 2.0 redirect URI.
|
||||||
|
.TP
|
||||||
|
\fB\-\-oauth\-scopes\fR=\fISCOPES\fR
|
||||||
|
OAuth 2.0 scopes, comma-separated.
|
||||||
|
.TP
|
||||||
|
\fB\-\-oauth\-grant\-type\fR=\fITYPE\fR
|
||||||
|
OAuth 2.0 grant type:
|
||||||
|
.BR client_credentials ", " authorization_code ", " password ", or "
|
||||||
|
.BR refresh_token .
|
||||||
|
.TP
|
||||||
|
\fB\-\-oauth\-access\-token\fR=\fITOKEN\fR
|
||||||
|
OAuth 2.0 access token supplied directly.
|
||||||
|
.TP
|
||||||
|
\fB\-\-oauth\-refresh\-token\fR=\fITOKEN\fR
|
||||||
|
OAuth 2.0 refresh token used to obtain a new access token.
|
||||||
|
.SS "Upload"
|
||||||
|
.TP
|
||||||
|
\fB\-\-upload\fR
|
||||||
|
Upload the file in
|
||||||
|
.B \-\-upload\-file
|
||||||
|
instead of downloading.
|
||||||
|
.TP
|
||||||
|
\fB\-\-upload\-method\fR=\fIMETHOD\fR
|
||||||
|
HTTP method for the upload:
|
||||||
|
.B PUT
|
||||||
|
(default) or
|
||||||
|
.BR POST .
|
||||||
|
.TP
|
||||||
|
\fB\-\-upload\-file\fR=\fIFILE\fR
|
||||||
|
Path to the file to upload.
|
||||||
|
.TP
|
||||||
|
\fB\-\-form\-data\fR=\fI"k=v"\fR
|
||||||
|
Additional multipart form data, comma-separated.
|
||||||
|
.TP
|
||||||
|
\fB\-\-file\-field\fR=\fINAME\fR
|
||||||
|
Form field name used for the file part. Default:
|
||||||
|
.BR file .
|
||||||
|
.SS "Verification"
|
||||||
|
.TP
|
||||||
|
\fB\-\-checksum\fR=\fIHASH\fR
|
||||||
|
Expected checksum, as a hex string.
|
||||||
|
.TP
|
||||||
|
\fB\-\-checksum\-algo\fR=\fIALG\fR
|
||||||
|
Checksum algorithm:
|
||||||
|
.BR sha256 ", " sha512 ", " blake2b ", " sha3\-256 ", " sha3\-512 ", or " md5 .
|
||||||
|
Default:
|
||||||
|
.BR sha256 .
|
||||||
|
.TP
|
||||||
|
\fB\-\-checksum\-file\fR=\fIFILE\fR
|
||||||
|
Read expected checksums from
|
||||||
|
.I FILE
|
||||||
|
in the standard
|
||||||
|
.B SHA256SUMS
|
||||||
|
format.
|
||||||
|
.SS "PGP / OpenPGP"
|
||||||
|
.TP
|
||||||
|
\fB\-\-pgp\-verify\fR
|
||||||
|
Verify a PGP detached or inline signature after download.
|
||||||
|
.TP
|
||||||
|
\fB\-\-pgp\-decrypt\fR
|
||||||
|
Decrypt a PGP-encrypted file after download.
|
||||||
|
.TP
|
||||||
|
\fB\-\-pgp\-sig\fR=\fIFILE\fR
|
||||||
|
Path to a detached signature file (e.g.
|
||||||
|
.BR .asc ", " .sig ).
|
||||||
|
.TP
|
||||||
|
\fB\-\-pgp\-key\fR=\fIFILE\fR
|
||||||
|
Path to a PGP key (public or private, depending on the operation).
|
||||||
|
.TP
|
||||||
|
\fB\-\-pgp\-passphrase\fR=\fIPASS\fR
|
||||||
|
Passphrase for the private key.
|
||||||
|
.TP
|
||||||
|
\fB\-\-pgp\-passphrase\-file\fR=\fIFILE\fR
|
||||||
|
Read the private-key passphrase from
|
||||||
|
.IR FILE .
|
||||||
|
.SS "Metalink"
|
||||||
|
.TP
|
||||||
|
\fB\-\-metalink\fR
|
||||||
|
Treat the URL as a Metalink file (multi-source download).
|
||||||
|
.TP
|
||||||
|
\fB\-\-metalink\-file\fR=\fIPATH\fR
|
||||||
|
Read a Metalink description from
|
||||||
|
.IR PATH .
|
||||||
|
Accepts
|
||||||
|
.B .meta4
|
||||||
|
and
|
||||||
|
.B .metalink
|
||||||
|
formats.
|
||||||
|
.SS "TLS and SSH"
|
||||||
|
.TP
|
||||||
|
\fB\-\-known\-hosts\fR=\fIFILE\fR
|
||||||
|
Use a custom
|
||||||
|
.I known_hosts
|
||||||
|
file for SFTP (default
|
||||||
|
.IR ~/.ssh/known_hosts ).
|
||||||
|
.TP
|
||||||
|
\fB\-\-ssh\-insecure\fR
|
||||||
|
Skip SSH host key verification for SFTP (accept any key). Mutually
|
||||||
|
incompatible with
|
||||||
|
.BR \-\-pinned\-host\-key .
|
||||||
|
.TP
|
||||||
|
\fB\-\-hsts\-file\fR=\fIFILE\fR
|
||||||
|
Use a custom HSTS cache file (default
|
||||||
|
.IR ~/.config/goget/hsts ).
|
||||||
|
.SS "Archive extraction"
|
||||||
|
.TP
|
||||||
|
\fB\-\-extract\fR
|
||||||
|
Automatically extract recognized archives (TAR, TAR.GZ, TAR.BZ2, ZIP)
|
||||||
|
after a successful download.
|
||||||
|
.TP
|
||||||
|
\fB\-\-extract\-dir\fR=\fIDIR\fR
|
||||||
|
Directory to extract into. Default: alongside the downloaded archive.
|
||||||
|
.TP
|
||||||
|
\fB\-\-strip\-components\fR=\fIN\fR
|
||||||
|
Strip the first
|
||||||
|
.I N
|
||||||
|
path components during archive extraction.
|
||||||
|
.SS "Configuration"
|
||||||
|
.TP
|
||||||
|
\fB\-\-config\fR=\fIFILE\fR
|
||||||
|
Path to the TOML configuration file.
|
||||||
|
.TP
|
||||||
|
\fB\-\-init\-config\fR
|
||||||
|
Write a default configuration file to
|
||||||
|
.I ~/.config/goget/config.toml
|
||||||
|
and exit.
|
||||||
|
.TP
|
||||||
|
\fB\-\-config\-get\fR=\fIKEY\fR
|
||||||
|
Print the value of a single configuration key (e.g.
|
||||||
|
.BR \-\-config\-get timeout )
|
||||||
|
and exit.
|
||||||
|
.TP
|
||||||
|
\fB\-\-config\-set\fR=\fIKEY\fR \fIVALUE\fR
|
||||||
|
Persist
|
||||||
|
.I VALUE
|
||||||
|
under
|
||||||
|
.I KEY
|
||||||
|
in the configuration file and exit.
|
||||||
|
.TP
|
||||||
|
\fB\-\-config\-list\fR
|
||||||
|
Print every configuration key and value and exit.
|
||||||
|
.TP
|
||||||
|
\fB\-\-config\-unset\fR=\fIKEY\fR
|
||||||
|
Remove
|
||||||
|
.I KEY
|
||||||
|
from the configuration file and exit.
|
||||||
|
.SS "Information and shell integration"
|
||||||
|
.TP
|
||||||
|
\fB\-\-info\fR=\fIURL\fR
|
||||||
|
Show file metadata (size, content type, server, last-modified) without
|
||||||
|
downloading the body.
|
||||||
|
.TP
|
||||||
|
\fB\-\-benchmark\fR=\fIURL\fR
|
||||||
|
Benchmark the download speed by transferring the first 10 MB and reporting
|
||||||
|
the average throughput.
|
||||||
|
.TP
|
||||||
|
\fB\-\-show\-metadata\fR=\fIPATH\fR
|
||||||
|
Display the contents of a
|
||||||
|
.B .goget.meta
|
||||||
|
sidecar for debugging interrupted or resumed downloads. Accepts either a
|
||||||
|
path to the downloaded file (the sidecar is located next to it) or a direct
|
||||||
|
path to the sidecar. Renders a human-readable summary by default; combines
|
||||||
|
with
|
||||||
|
.B \-\-json
|
||||||
|
for raw output. Credentials embedded in the stored URL are masked.
|
||||||
|
.TP
|
||||||
|
\fB\-\-generate\-man\-page\fR
|
||||||
|
Print this manual page in troff format to standard output and exit.
|
||||||
|
.TP
|
||||||
|
\fB\-\-completion\fR=\fISHELL\fR
|
||||||
|
Print a shell completion script for
|
||||||
|
.IR SHELL \(lqbbash\(rqq, \(rqqzsh\(rqq, or \(rqqfish\(rqq\(en to standard output and exit.
|
||||||
|
.TP
|
||||||
|
\fB\-\-help\fR
|
||||||
|
Print the short help summary and exit.
|
||||||
|
.TP
|
||||||
|
\fB\-\-version\fR
|
||||||
|
Print the program name and version and exit.
|
||||||
|
.SH EXAMPLES
|
||||||
|
.SS "Simple download"
|
||||||
|
.PP
|
||||||
|
Download a single file to the current directory:
|
||||||
|
.PP
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
goget \-\-url https://example.com/file.zip
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.SS "Parallel chunks and resume"
|
||||||
|
.PP
|
||||||
|
Files larger than 100 MB are automatically split into parallel chunks. To
|
||||||
|
force four connections explicitly and resume an interrupted transfer:
|
||||||
|
.PP
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
goget \-\-url https://example.com/large.iso \-\-parallel 4
|
||||||
|
goget \-\-url https://example.com/file.zip \-\-resume
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.SS "Verification"
|
||||||
|
.PP
|
||||||
|
Verify a download against a known checksum and PGP signature:
|
||||||
|
.PP
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
goget \-\-url https://example.com/release.tar.gz \\
|
||||||
|
\-\-checksum\-algo sha256 \-\-checksum 5b9d...e2 \\
|
||||||
|
\-\-pgp\-verify \-\-pgp\-sig release.tar.gz.asc \\
|
||||||
|
\-\-pgp\-key maintainer.asc
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.SS "Recursive mirror"
|
||||||
|
.PP
|
||||||
|
Mirror a documentation site, convert links for offline use, and respect
|
||||||
|
.IR robots.txt :
|
||||||
|
.PP
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
goget \-\-url https://example.com/docs/ \\
|
||||||
|
\-\-mirror \-\-convert\-links \-\-output ./mirror
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.SS "Scripting with JSON progress"
|
||||||
|
.PP
|
||||||
|
Stream machine-readable progress to a file for a downstream tool:
|
||||||
|
.PP
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
goget \-\-url https://example.com/data.csv \\
|
||||||
|
\-\-json \-\-no\-progress > progress.jsonl
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.SS "Scheduled download with a post-hook"
|
||||||
|
.PP
|
||||||
|
Fetch a daily report at 02:00 and feed it into a processor:
|
||||||
|
.PP
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
goget \-\-url https://example.com/daily.zip \\
|
||||||
|
\-\-schedule "0 2 * * *" \\
|
||||||
|
\-\-on\-complete "import.sh"
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.SS "Batch file"
|
||||||
|
.PP
|
||||||
|
Read URLs from a file (one per line; lines starting with
|
||||||
|
.B #
|
||||||
|
are comments):
|
||||||
|
.PP
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
goget \-\-batch\-file urls.txt
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.SH EXIT STATUS
|
||||||
|
.TP
|
||||||
|
.B 0
|
||||||
|
Success.
|
||||||
|
.TP
|
||||||
|
.B 1
|
||||||
|
Generic failure (network error, protocol error, invalid input).
|
||||||
|
.TP
|
||||||
|
.B 2
|
||||||
|
Invalid command-line arguments or configuration.
|
||||||
|
.TP
|
||||||
|
.B 3
|
||||||
|
File I/O error (cannot write output, cannot read source).
|
||||||
|
.TP
|
||||||
|
.B 4
|
||||||
|
Authentication failure.
|
||||||
|
.TP
|
||||||
|
.B 5
|
||||||
|
TLS or certificate error (including pinning mismatch).
|
||||||
|
.TP
|
||||||
|
.B 6
|
||||||
|
Checksum or PGP verification failed.
|
||||||
|
.TP
|
||||||
|
.B 7
|
||||||
|
Operation cancelled (SIGINT, SIGTERM, context cancellation).
|
||||||
|
.TP
|
||||||
|
.B 8
|
||||||
|
Resource limit exceeded
|
||||||
|
.RB ( \-\-max\-filesize ", " \-\-max\-time ", etc.).
|
||||||
|
.PP
|
||||||
|
Only the generic
|
||||||
|
.B 0
|
||||||
|
and
|
||||||
|
.B 1
|
||||||
|
codes are guaranteed; the project reserves the other values and their
|
||||||
|
meaning is part of the stable interface.
|
||||||
|
.SH FILES
|
||||||
|
.TP
|
||||||
|
.I ~/.config/goget/config.toml
|
||||||
|
Default configuration file. Overridden by
|
||||||
|
.B \-\-config
|
||||||
|
or the
|
||||||
|
.B GOGET_CONFIG
|
||||||
|
environment variable.
|
||||||
|
.TP
|
||||||
|
.I ~/.config/goget/queue.json
|
||||||
|
Persistent download queue. Path can be overridden with
|
||||||
|
.BR \-\-queue\-file .
|
||||||
|
.TP
|
||||||
|
.I ~/.config/goget/hsts
|
||||||
|
HSTS cache (RFC 6797). Path can be overridden with
|
||||||
|
.BR \-\-hsts\-file .
|
||||||
|
.TP
|
||||||
|
.I ~/.ssh/known_hosts
|
||||||
|
Default SFTP host-key database. Path can be overridden with
|
||||||
|
.BR \-\-known\-hosts .
|
||||||
|
.TP
|
||||||
|
.I <output>.goget.meta
|
||||||
|
Resume metadata sidecar written next to a partial output file. Deleted on
|
||||||
|
successful completion; consulted by
|
||||||
|
.B \-\-resume
|
||||||
|
or
|
||||||
|
.BR \-\-continue .
|
||||||
|
.TP
|
||||||
|
.I ~/.netrc
|
||||||
|
Auto-loaded credentials. Disabled for the current invocation by
|
||||||
|
.BR \-\-no\-private .
|
||||||
|
.SH ENVIRONMENT
|
||||||
|
.TP
|
||||||
|
.B GOGET_CONFIG
|
||||||
|
Path to the configuration file. Takes precedence over
|
||||||
|
.IR ~/.config/goget/config.toml
|
||||||
|
and
|
||||||
|
.BR \-\-config .
|
||||||
|
.TP
|
||||||
|
.B NO_COLOR
|
||||||
|
When set to a non-empty value, colour output is disabled regardless of the
|
||||||
|
.BR color " setting in the config file (see " https://no\-color.org )."
|
||||||
|
.TP
|
||||||
|
.B SSLKEYLOGFILE
|
||||||
|
Equivalent to passing
|
||||||
|
.B \-\-ssl\-key\-log
|
||||||
|
with the same value. Used by Wireshark to decrypt TLS traffic.
|
||||||
|
.TP
|
||||||
|
.B GOGET_OUTPUT
|
||||||
|
Exported into the environment of
|
||||||
|
.B \-\-on\-complete
|
||||||
|
hooks; contains the absolute path of the downloaded file.
|
||||||
|
.TP
|
||||||
|
.B GOGET_SIZE
|
||||||
|
Exported into
|
||||||
|
.B \-\-on\-complete
|
||||||
|
hooks; contains the downloaded size in bytes.
|
||||||
|
.TP
|
||||||
|
.B GOGET_URL
|
||||||
|
Exported into
|
||||||
|
.B \-\-on\-complete
|
||||||
|
hooks; contains the source URL with credentials stripped
|
||||||
|
.RB ( core.SafeURL ).
|
||||||
|
.TP
|
||||||
|
.B HOME
|
||||||
|
Used to locate
|
||||||
|
.I ~/.config/goget/
|
||||||
|
and
|
||||||
|
.IR ~/.ssh/known_hosts .
|
||||||
|
.TP
|
||||||
|
.B HTTP_PROXY
|
||||||
|
.IR "https_proxy" ", " ALL_PROXY ", " NO_PROXY
|
||||||
|
Standard proxy environment variables; respected unless
|
||||||
|
.B \-\-proxy
|
||||||
|
is given explicitly.
|
||||||
|
.SH "SEE ALSO"
|
||||||
|
.BR curl (1),
|
||||||
|
.BR wget (1),
|
||||||
|
.BR sftp (1),
|
||||||
|
.BR ssh\-keygen (1),
|
||||||
|
.BR groff (1),
|
||||||
|
.BR man (1)
|
||||||
|
.PP
|
||||||
|
Project home page and issue tracker:
|
||||||
|
.RI < https://codeberg.org/petrbalvin/goget >
|
||||||
|
.PP
|
||||||
|
Full documentation set:
|
||||||
|
.RS
|
||||||
|
.TP
|
||||||
|
.I docs/configuration.md
|
||||||
|
Configuration schema.
|
||||||
|
.TP
|
||||||
|
.I docs/cli-reference.md
|
||||||
|
Full flag reference.
|
||||||
|
.TP
|
||||||
|
.I docs/protocols.md
|
||||||
|
Protocol handler details.
|
||||||
|
.TP
|
||||||
|
.I docs/recursive-mirror.md
|
||||||
|
Recursive and mirror behaviour.
|
||||||
|
.TP
|
||||||
|
.I docs/security.md
|
||||||
|
Threat model and security controls.
|
||||||
|
.TP
|
||||||
|
.I docs/api.md
|
||||||
|
Public Go library API.
|
||||||
|
.RE
|
||||||
|
.SH HISTORY
|
||||||
|
goget 1.0.0 (2026-06-26) is the first public release.
|
||||||
|
The project was created as a modern, IPv6-first, Go-stdlib-only replacement
|
||||||
|
for
|
||||||
|
.BR curl (1)
|
||||||
|
and
|
||||||
|
.BR wget (1).
|
||||||
|
.SH AUTHORS
|
||||||
|
Petr Balvín
|
||||||
|
.RI < opensource@petrbalvin.org >
|
||||||
|
\(em upstream maintainer.
|
||||||
|
.PP
|
||||||
|
See
|
||||||
|
.RI < https://codeberg.org/petrbalvin/goget >
|
||||||
|
for the full contributor list.
|
||||||
|
.SH "REPORTING BUGS"
|
||||||
|
Report bugs to the issue tracker at
|
||||||
|
.RI < https://codeberg.org/petrbalvin/goget/issues >.
|
||||||
|
.PP
|
||||||
|
For security issues, do not open a public issue; email
|
||||||
|
.RI < opensource@petrbalvin.org >
|
||||||
|
instead.
|
||||||
|
.SH COPYRIGHT
|
||||||
|
Copyright \(co 2026 Petr Balvín.
|
||||||
|
.PP
|
||||||
|
Licensed under the MIT License.
|
||||||
|
Permission is granted to use, copy, modify, and distribute this software
|
||||||
|
without restriction, provided that the above copyright notice and this
|
||||||
|
permission notice appear in all copies.
|
||||||
|
.PP
|
||||||
|
.B goget
|
||||||
|
is provided
|
||||||
|
.B "AS IS"
|
||||||
|
without any warranty of any kind. See the MIT License text distributed with
|
||||||
|
the source for the full disclaimer.
|
||||||
Reference in New Issue
Block a user