Files

811 lines
18 KiB
Go

// Package ftp downloads files and directory trees over FTP, FTPS (implicit
// TLS) and FTPES (explicit TLS, AUTH TLS).
//
// A file is split into segments of at least 1 MiB. Each segment is fetched on
// its own login with REST and RETR, and its data connection is closed once
// the segment's last byte has arrived. A server that refuses a login lowers
// the task's connection ceiling to the logins that worked, and a server that
// refuses REST gets one sequential stream per file.
package ftp
import (
"context"
"errors"
"fmt"
"io"
"os"
"path"
"sync"
"sync/atomic"
"time"
"github.com/jlaffaye/ftp"
"github.com/rs/zerolog"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
pftp "github.com/GopeedLab/gopeed/pkg/protocol/ftp"
)
const (
// Many servers cap logins per user, so FTP starts lower than HTTP.
defaultConnections = 4
maxConnections = 256
minSegmentSize = 1 << 20
maxRetries = 5
copyBufferSize = 256 << 10
)
// retryDelay is the first wait before a retry. Later retries wait longer.
var retryDelay = 500 * time.Millisecond
var (
errSizeChanged = errors.New("ftp: the remote file changed size since the task was resolved")
errShortTransfer = errors.New("ftp: the transfer ended before the segment was complete")
errRestUnsupported = errors.New("ftp: the server does not support REST")
errDiskWrite = errors.New("ftp: cannot write the downloaded data")
)
type config struct {
Connections int `json:"connections"`
}
// segment is a byte range of one file. End is inclusive, so an empty file
// has End == Begin-1 and nothing to fetch.
type segment struct {
File int `json:",omitempty"` // index in Res.Files
Begin int64
End int64
Downloaded int64
}
func (s *segment) remain() int64 {
return s.End - s.Begin + 1 - s.Downloaded
}
// lane is one worker that logged in, shown as one connection in the stats.
type lane struct {
downloaded int64
retryTimes int
failed bool
completed bool
}
// loginError marks a failure to connect or log in, as opposed to a failure
// during a transfer.
type loginError struct{ err error }
func (e *loginError) Error() string { return e.err.Error() }
func (e *loginError) Unwrap() error { return e.err }
type Fetcher struct {
manager *FetcherManager
ctl *controller.Controller
config *config
meta *fetcher.FetcherMeta
doneCh chan error
warnOnce sync.Once
// loginOK is set once these credentials have logged in, after which a
// 530 on another login means the server is full.
loginOK atomic.Bool
// mu guards the download state below.
mu sync.Mutex
segments []*segment
maxConns int
noRest bool
lanes []*lane
done bool
// runMu serializes Start and Pause.
runMu sync.Mutex
cancel context.CancelFunc
runDone chan struct{}
}
func (f *Fetcher) Setup(ctl *controller.Controller) {
f.ctl = ctl
f.doneCh = make(chan error, 1)
if f.meta == nil {
f.meta = &fetcher.FetcherMeta{}
}
if ctl != nil && ctl.GetConfig != nil {
ctl.GetConfig(&f.config)
}
if f.config == nil {
f.config = &config{Connections: defaultConnections}
}
}
func (f *Fetcher) logger() *zerolog.Logger {
if f.ctl == nil {
return nil
}
return f.ctl.Logger
}
func (f *Fetcher) warn(format string, args ...any) {
if l := f.logger(); l != nil {
l.Warn().Msgf(format, args...)
}
}
func (f *Fetcher) initOptions() error {
if f.meta.Opts == nil {
f.meta.Opts = &base.Options{}
}
opts := f.meta.Opts
if err := base.ParseOptExtra[pftp.OptsExtra](opts); err != nil {
return err
}
if opts.Extra == nil {
opts.Extra = &pftp.OptsExtra{}
}
return nil
}
func (f *Fetcher) extra() *pftp.OptsExtra {
if f.meta.Opts != nil {
if e, ok := f.meta.Opts.Extra.(*pftp.OptsExtra); ok && e != nil {
return e
}
}
return &pftp.OptsExtra{}
}
// tlsMode is the request's "tls" field, empty when the request has none.
func (f *Fetcher) tlsMode() string {
if f.meta.Req != nil {
if e, ok := f.meta.Req.Extra.(*pftp.ReqExtra); ok && e != nil {
return e.TLS
}
}
return ""
}
// connections is the number of parallel logins to use. The caller holds mu.
func (f *Fetcher) connections() int {
if f.noRest {
return 1
}
n := f.extra().Connections
if n <= 0 && f.config != nil {
n = f.config.Connections
}
if n <= 0 {
n = defaultConnections
}
n = min(n, maxConnections)
if f.maxConns > 0 {
n = min(n, f.maxConns)
}
return n
}
func (f *Fetcher) Resolve(req *base.Request) error {
if err := base.ParseReqExtra[pftp.ReqExtra](req); err != nil {
return err
}
f.meta.Req = req
t, err := parseTarget(req.URL, f.tlsMode())
if err != nil {
return err
}
s, err := f.dial(context.Background(), t)
if err != nil {
return err
}
// quit, not abort: a server that caps logins must see this session end
// before the download's own logins arrive.
defer s.quit()
f.loginOK.Store(true)
res, err := f.resolveTarget(s, t)
if err != nil {
return err
}
f.meta.Res = res
f.mu.Lock()
f.segments = nil
f.done = false
f.mu.Unlock()
return nil
}
// selected returns the indexes of the files to download, in SelectFiles
// order. The caller holds mu or owns the fetcher.
func (f *Fetcher) selected() []int {
if f.meta == nil || f.meta.Res == nil {
return nil
}
if f.meta.Opts != nil && len(f.meta.Opts.SelectFiles) > 0 {
return f.meta.Opts.SelectFiles
}
all := make([]int, len(f.meta.Res.Files))
for i := range all {
all[i] = i
}
return all
}
// localPath is where a file lands: under the task path, with the file's
// directories below the URL kept.
func (f *Fetcher) localPath(index int) string {
if f.meta.Res.Name == "" {
return f.meta.SingleFilepath()
}
file := f.meta.Res.Files[index]
return path.Join(f.meta.FolderPath(), file.Path, file.Name)
}
// remotePath is the server path of a file. A single file uses the URL's own
// path, which a local rename never touches.
func (f *Fetcher) remotePath(t *target, index int) string {
if f.meta.Res.Name == "" {
return t.path
}
file := f.meta.Res.Files[index]
return path.Join(t.path, file.Path, file.Name)
}
// plan splits every selected file into segments of at least minSegmentSize,
// one per connection at most. The caller holds mu.
func (f *Fetcher) plan() {
conns := f.connections()
f.segments = nil
for _, index := range f.selected() {
size := f.meta.Res.Files[index].Size
n := int64(1)
if !f.noRest {
n = max(1, min(int64(conns), size/minSegmentSize))
}
for i := int64(0); i < n; i++ {
f.segments = append(f.segments, &segment{
File: index,
Begin: i * size / n,
End: (i+1)*size/n - 1,
})
}
}
}
// planSequential gives every unfinished file one segment from byte 0,
// because without REST a transfer cannot start in the middle. The caller
// holds mu.
func (f *Fetcher) planSequential() {
complete := map[int]bool{}
for _, s := range f.segments {
if _, seen := complete[s.File]; !seen {
complete[s.File] = true
}
if s.remain() > 0 {
complete[s.File] = false
}
}
var out []*segment
for _, index := range f.selected() {
size := f.meta.Res.Files[index].Size
if complete[index] {
out = append(out, &segment{File: index, Begin: 0, End: size - 1, Downloaded: size})
continue
}
out = append(out, &segment{File: index, Begin: 0, End: size - 1})
}
f.segments = out
// Bytes counted by the old segments are fetched again.
f.lanes = nil
}
// openFiles opens every selected file for writing. A fresh plan creates and
// sizes them; a resumed one keeps their content, and a file that has gone
// missing starts over.
func (f *Fetcher) openFiles(fresh bool) (map[int]*os.File, error) {
files := map[int]*os.File{}
closeAll := func() {
for _, file := range files {
_ = file.Close()
}
}
for _, index := range f.selected() {
name := f.localPath(index)
var (
file *os.File
err error
)
_, statErr := os.Stat(name)
switch {
case fresh || os.IsNotExist(statErr):
file, err = f.ctl.Touch(name, f.meta.Res.Files[index].Size)
if err == nil && !fresh {
f.mu.Lock()
for _, s := range f.segments {
if s.File == index {
s.Downloaded = 0
}
}
f.mu.Unlock()
}
case statErr != nil:
err = statErr
default:
file, err = os.OpenFile(name, os.O_RDWR, 0)
}
if err != nil {
closeAll()
return nil, err
}
files[index] = file
}
return files, nil
}
func (f *Fetcher) Start() error {
f.runMu.Lock()
defer f.runMu.Unlock()
if f.runDone != nil {
select {
case <-f.runDone:
default:
return nil // already running
}
}
if err := f.initOptions(); err != nil {
return err
}
if err := base.ParseReqExtra[pftp.ReqExtra](f.meta.Req); err != nil {
return err
}
t, err := parseTarget(f.meta.Req.URL, f.tlsMode())
if err != nil {
return err
}
// A paused or failed run may have left a result nobody read.
select {
case <-f.doneCh:
default:
}
f.mu.Lock()
if f.done {
f.mu.Unlock()
f.doneCh <- nil
return nil
}
fresh := len(f.segments) == 0
if fresh {
f.plan()
} else if f.noRest {
f.planSequential()
}
f.mu.Unlock()
files, err := f.openFiles(fresh)
if err != nil {
return err
}
ctx, cancel := context.WithCancel(context.Background())
f.cancel = cancel
f.runDone = make(chan struct{})
go f.run(ctx, t, files, f.runDone)
return nil
}
func (f *Fetcher) run(ctx context.Context, t *target, files map[int]*os.File, done chan struct{}) {
defer close(done)
err := f.download(ctx, t, files)
for _, file := range files {
if cerr := file.Close(); cerr != nil && err == nil {
err = cerr
}
}
f.mu.Lock()
if err == nil {
f.done = true
for _, l := range f.lanes {
l.completed = true
}
}
f.mu.Unlock()
if err != nil && ctx.Err() != nil {
return // paused
}
select {
case f.doneCh <- err:
default:
}
}
func (f *Fetcher) download(ctx context.Context, t *target, files map[int]*os.File) error {
for {
err := f.runWorkers(ctx, t, files)
if !errors.Is(err, errRestUnsupported) {
return err
}
f.mu.Lock()
f.noRest = true
f.planSequential()
f.mu.Unlock()
f.warn("ftp: the server refused REST, so each file is downloaded in one stream")
}
}
// pool hands out the segments of one run to its workers. Every decision to
// leave the run is taken under mu together with the queue it depends on, so
// a segment put back can never be left without a worker.
type pool struct {
mu sync.Mutex
queue []*segment
running int // workers still in the run
}
// take returns the next segment. A worker that finds the queue empty leaves
// the run.
func (p *pool) take() *segment {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.queue) == 0 {
p.running--
return nil
}
s := p.queue[0]
p.queue = p.queue[1:]
return s
}
func (p *pool) putBack(s *segment) {
p.mu.Lock()
defer p.mu.Unlock()
p.queue = append([]*segment{s}, p.queue...)
}
// refused puts back the segment of a worker whose login the server turned
// away. The worker leaves when another worker is still in the run to take
// the segment, and left is the number of workers that remain; the last
// worker stays and retries.
func (p *pool) refused(s *segment) (leave bool, left int) {
p.mu.Lock()
defer p.mu.Unlock()
p.queue = append([]*segment{s}, p.queue...)
if p.running > 1 {
p.running--
return true, p.running
}
return false, p.running
}
// leave takes a worker out of the run when it stops for any other reason.
func (p *pool) leave() {
p.mu.Lock()
defer p.mu.Unlock()
p.running--
}
func (f *Fetcher) runWorkers(ctx context.Context, t *target, files map[int]*os.File) error {
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
f.mu.Lock()
p := &pool{}
for _, s := range f.segments {
if s.remain() > 0 {
p.queue = append(p.queue, s)
}
}
workers := min(f.connections(), len(p.queue))
f.mu.Unlock()
if workers == 0 {
return nil
}
p.running = workers
var (
wg sync.WaitGroup
errMu sync.Mutex
firstErr error
)
for id := 0; id < workers; id++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
if err := f.worker(runCtx, t, files, p, id); err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = err
}
errMu.Unlock()
cancel()
}
}(id)
}
wg.Wait()
if err := ctx.Err(); err != nil {
return err
}
if firstErr != nil {
return firstErr
}
f.mu.Lock()
defer f.mu.Unlock()
for _, s := range f.segments {
if s.remain() > 0 {
return errors.New("ftp: the download stopped with data missing")
}
}
return nil
}
// worker fetches segments from the pool until it is empty. A worker whose
// login the server refuses (421, or 530 once these credentials have worked)
// hands its segment back and leaves while other workers remain, lowering the
// task's ceiling to the workers left. This holds whether or not the worker
// had logged in before: a slot can also go to another client mid-download.
func (f *Fetcher) worker(ctx context.Context, t *target, files map[int]*os.File, p *pool, id int) error {
var ln *lane
f.mu.Lock()
if id < len(f.lanes) {
ln = f.lanes[id]
}
f.mu.Unlock()
onLogin := func() {
f.mu.Lock()
if ln == nil {
ln = &lane{}
f.lanes = append(f.lanes, ln)
}
f.mu.Unlock()
}
failures := 0
pendingRetries := 0
for {
seg := p.take()
if seg == nil {
return nil
}
err := f.fetchSegment(ctx, t, files, seg, &ln, onLogin)
if err == nil {
failures = 0
continue
}
if ctx.Err() != nil {
p.putBack(seg)
p.leave()
return nil
}
var le *loginError
if errors.As(err, &le) && isLoginRefusal(le.err, f.loginOK.Load()) {
if leave, left := p.refused(seg); leave {
f.lowerCeiling(left)
return nil
}
// The last worker: wait for a slot.
} else {
p.putBack(seg)
if errors.Is(err, errRestUnsupported) {
p.leave()
return err
}
if isPermanent(err) || errors.Is(err, errDiskWrite) {
f.markFailed(ln)
p.leave()
return err
}
}
failures++
f.mu.Lock()
if ln != nil {
ln.retryTimes += pendingRetries + 1
pendingRetries = 0
} else {
pendingRetries++
}
f.mu.Unlock()
if failures > maxRetries {
f.markFailed(ln)
p.leave()
return fmt.Errorf("ftp: giving up after %d retries: %w", maxRetries, err)
}
select {
case <-ctx.Done():
p.leave()
return nil
case <-time.After(retryDelay * time.Duration(failures)):
}
}
}
func (f *Fetcher) lowerCeiling(n int) {
f.mu.Lock()
defer f.mu.Unlock()
if f.maxConns == 0 || n < f.maxConns {
f.maxConns = n
}
}
func (f *Fetcher) markFailed(ln *lane) {
if ln == nil {
return
}
f.mu.Lock()
ln.failed = true
f.mu.Unlock()
}
// isRestRefused tells a REST the server does not implement. RETR itself
// fails with 550 and the like, never with these codes.
func isRestRefused(err error) bool {
switch replyCode(err) {
case ftp.StatusBadCommand, ftp.StatusBadArguments, ftp.StatusNotImplemented, ftp.StatusNotImplementedParameter:
return true
}
return false
}
// fetchSegment logs in, retrieves the segment from its first missing byte
// and writes it in place. It stops reading at the segment's end and closes
// the data connection; the server then reports the transfer aborted, which
// is expected.
func (f *Fetcher) fetchSegment(ctx context.Context, t *target, files map[int]*os.File, seg *segment, ln **lane, onLogin func()) error {
s, err := f.dial(ctx, t)
if err != nil {
return &loginError{err}
}
defer s.quit()
f.loginOK.Store(true)
onLogin()
remote := f.remotePath(t, seg.File)
f.mu.Lock()
noRest := f.noRest
wantSize := f.meta.Res.Files[seg.File].Size
if noRest {
seg.Downloaded = 0
}
pos := seg.Begin + seg.Downloaded
end := seg.End
f.mu.Unlock()
// A file that changed on the server would mix old and new bytes.
s.deadline()
if size, err := s.FileSize(remote); err == nil && size != wantSize {
return errSizeChanged
} else if err != nil && replyCode(err) == 0 {
return err
}
s.deadline()
var resp *ftp.Response
if noRest {
resp, err = s.Retr(remote)
} else {
resp, err = s.RetrFrom(remote, uint64(pos))
}
if err != nil {
if !noRest && pos > 0 && isRestRefused(err) {
return errRestUnsupported
}
return err
}
defer resp.Close()
s.clearDeadline()
file := files[seg.File]
buf := make([]byte, copyBufferSize)
for pos <= end {
n := int(min(int64(len(buf)), end-pos+1))
_ = resp.SetDeadline(time.Now().Add(readTimeout))
nr, rerr := resp.Read(buf[:n])
if nr > 0 {
if _, werr := file.WriteAt(buf[:nr], pos); werr != nil {
return fmt.Errorf("%w: %w", errDiskWrite, werr)
}
pos += int64(nr)
f.mu.Lock()
seg.Downloaded += int64(nr)
if *ln != nil {
(*ln).downloaded += int64(nr)
}
f.mu.Unlock()
}
if rerr != nil {
if pos > end {
break
}
if errors.Is(rerr, io.EOF) {
return errShortTransfer
}
return rerr
}
}
return nil
}
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
if req == nil {
return nil
}
if req.URL != "" {
f.meta.Req.URL = req.URL
}
if req.Labels != nil {
if f.meta.Req.Labels == nil {
f.meta.Req.Labels = make(map[string]string)
}
for k, v := range req.Labels {
f.meta.Req.Labels[k] = v
}
}
if req.Proxy != nil {
f.meta.Req.Proxy = req.Proxy
}
return nil
}
func (f *Fetcher) Pause() error {
f.runMu.Lock()
defer f.runMu.Unlock()
if f.cancel != nil {
f.cancel()
}
if f.runDone != nil {
<-f.runDone
}
return nil
}
func (f *Fetcher) Close() error {
return f.Pause()
}
func (f *Fetcher) Meta() *fetcher.FetcherMeta {
return f.meta
}
// Stats reports one connection per worker that logged in, in the HTTP
// protocol's shape.
func (f *Fetcher) Stats() *fetcher.Stats {
f.mu.Lock()
defer f.mu.Unlock()
var total int64
if n := int64(len(f.lanes)); n > 0 && f.meta != nil && f.meta.Res != nil && f.meta.Res.Size > 0 {
total = (f.meta.Res.Size + n - 1) / n
}
conns := make([]*pftp.StatsConnection, 0, len(f.lanes))
for _, l := range f.lanes {
conns = append(conns, &pftp.StatsConnection{
Downloaded: l.downloaded,
Total: total,
Completed: l.completed || (f.done && !l.failed),
Failed: l.failed,
RetryTimes: l.retryTimes,
})
}
return &fetcher.Stats{Snapshot: &pftp.Stats{Connections: conns}}
}
// Progress returns the bytes downloaded of each selected file, in
// SelectFiles order.
func (f *Fetcher) Progress() fetcher.Progress {
f.mu.Lock()
defer f.mu.Unlock()
perFile := map[int]int64{}
for _, s := range f.segments {
perFile[s.File] += s.Downloaded
}
sel := f.selected()
p := make(fetcher.Progress, len(sel))
for i, index := range sel {
p[i] = perFile[index]
}
return p
}
func (f *Fetcher) Wait() error {
return <-f.doneCh
}