Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ without starting a subprocess.
or a complete Space trash bin.
- `internal/transfer`: coordinate bounded parallel local and remote traversal
and aggregate byte progress; atomically replace local files after successful
downloads.
downloads. A partial download is only resumed against the entity validator
recorded when its bytes were written; an unverifiable prefix is discarded.
- `internal/versions`: list, stream, and restore historical file versions
through the resource-ID-based WebDAV metadata endpoint.
- `internal/webdav`: implement DAV requests, retries, capability-negotiated TUS
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,9 @@ Expired sessions and changed local files start a new upload automatically.
Zero-byte files and servers without compatible TUS support use WebDAV `PUT`.

Downloads use an atomic `.part` file and resume it with an HTTP byte range when
possible. Use
possible. A resumed range is guarded by the entity validator recorded for that
`.part` file, so a remote file that changed since the interruption restarts from
the beginning instead of mixing old and new content. Use
`--no-clobber` to protect an existing destination, `--interactive` to confirm
an operation, or `--dry-run` to print the plan without changing files:

Expand Down Expand Up @@ -1181,8 +1183,8 @@ classification, message, and operation:

The executable installs a signal-aware root context. Ctrl-C cancels OIDC login,
HTTP requests, search, and transfer workers; loopback listeners and open bodies
are closed. An interrupted resumable download retains its `.part` file for the
next invocation.
are closed. An interrupted resumable download retains its `.part` file, and the
entity validator that produced it, for the next invocation.

## OIDC clients

Expand Down
122 changes: 119 additions & 3 deletions internal/webdav/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,17 @@ func (client *Client) DownloadToWriter(

// DownloadWithOptions downloads one remote file atomically. A retained .part
// file is resumed with a byte range on the next attempt when enabled.
//
// Resuming is only safe while the remote entity that produced the .part file is
// unchanged, because the retained prefix is never re-read. Every ranged request
// therefore carries an If-Range validator, and a 200 response to that request
// means the validator did not match: the .part prefix is discarded and the
// current entity is written from offset zero.
//
// The recorded validator must never describe bytes it did not produce, so it is
// invalidated before a restart truncates the prefix and recorded again only
// once the file is empty; an interruption anywhere in between leaves an
// unlabelled prefix, which the next attempt discards rather than trusts.
func (client *Client) DownloadWithOptions(ctx context.Context, remote, local string, options TransferOptions) error {
if info, err := os.Stat(local); err == nil && info.IsDir() {
local = filepath.Join(local, path.Base(cleanRemote(remote)))
Expand All @@ -372,17 +383,31 @@ func (client *Client) DownloadWithOptions(ctx context.Context, remote, local str
}
temporary := local + ".part"
if !options.Resume {
if err := os.Remove(temporary); err != nil && !errors.Is(err, os.ErrNotExist) {
if err := discardPartial(temporary); err != nil {
return err
}
}
var expectedSize int64 = -1
var downloadedETag string
for attempt := 0; attempt <= client.config.Retries; attempt++ {
offset := int64(0)
validator := ""
if options.Resume {
if info, err := os.Stat(temporary); err == nil {
offset = info.Size()
// A retained prefix may only be reused when its originating
// entity validator is known; otherwise it is not provably the
// same remote content and must be discarded.
saved, err := readPartValidator(temporary)
if err != nil {
return err
}
if saved == "" {
if err := discardPartial(temporary); err != nil {
return err
}
} else {
offset, validator = info.Size(), saved
}
}
}
request, err := client.newRequest(ctx, http.MethodGet, remote, nil)
Expand All @@ -391,6 +416,7 @@ func (client *Client) DownloadWithOptions(ctx context.Context, remote, local str
}
if offset > 0 {
request.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
request.Header.Set("If-Range", validator)
}
if options.ExpectedETag != "" {
request.Header.Set("If-Match", options.ExpectedETag)
Expand All @@ -417,7 +443,9 @@ func (client *Client) DownloadWithOptions(ctx context.Context, remote, local str
defer func() { _ = response.Body.Close() }()
return responseError(response)
}
responseETag := response.Header.Get("ETag")
flags := os.O_CREATE | os.O_WRONLY
restart := true
if response.StatusCode == http.StatusPartialContent && offset > 0 {
rangeStart, rangeSize, ok := parseContentRange(response.Header.Get("Content-Range"))
if !ok || rangeStart != offset {
Expand All @@ -427,19 +455,52 @@ func (client *Client) DownloadWithOptions(ctx context.Context, remote, local str
response.Header.Get("Content-Range"), offset,
)
}
// The continuation was granted for the saved validator, so an ETag
// naming a different entity would splice two entities together. The
// saved validator is kept: it still describes the retained prefix.
if responseETag != "" && responseETag != validator {
_ = response.Body.Close()
return fmt.Errorf(
"resume download: server returned validator %s for a range "+
"requested with validator %s",
responseETag, validator,
)
}
restart = false
flags |= os.O_APPEND
expectedSize = rangeSize
downloadedETag = validator
} else {
flags |= os.O_TRUNC
offset = 0
expectedSize = response.ContentLength
downloadedETag = responseETag
}
// A restart replaces the retained prefix, so the validator describing it
// is invalidated before the stale bytes are removed and only recorded
// again once the file is empty. Publishing it earlier would let an
// interruption in between leave stale bytes labelled with the new
// entity, which is exactly the pairing a later resume must never trust.
if restart {
if err := discardPartValidator(temporary); err != nil {
_ = response.Body.Close()
return err
}
}
downloadedETag = response.Header.Get("ETag")
file, openErr := os.OpenFile(temporary, flags, 0600) //nolint:gosec // temporary is derived from the user-selected download destination
if openErr != nil {
_ = response.Body.Close()
return openErr
}
if restart {
// Recorded after truncation, so the validator can only ever describe
// bytes that came from this response.
if err := writePartValidator(temporary, downloadedETag); err != nil {
_ = file.Close()
_ = response.Body.Close()
return err
}
}
if options.Progress != nil {
options.Progress(offset)
}
Expand Down Expand Up @@ -482,9 +543,64 @@ func (client *Client) DownloadWithOptions(ctx context.Context, remote, local str
return fmt.Errorf("verify download: remote ETag changed during transfer")
}
}
// The sidecar is dropped before the destination is committed, so the only
// fallible step left is the commit itself. Once the destination has changed
// the transfer has succeeded, and reporting a later tidy-up failure as a
// transfer failure would make a sync caller skip its baseline update for a
// file that is already in place.
if err := discardPartValidator(temporary); err != nil {
client.config.Logger.Debug(
"download sidecar cleanup failed", "path", partValidatorPath(temporary),
"reason", err,
)
}
return transfer.ReplaceFile(temporary, local)
}

// partValidatorPath returns the sidecar holding the entity validator for a
// retained partial download.
func partValidatorPath(temporary string) string {
return temporary + ".etag"
}

// readPartValidator returns the validator recorded for a retained partial
// download, or an empty value when none is known.
func readPartValidator(temporary string) (string, error) {
data, err := os.ReadFile(partValidatorPath(temporary)) //nolint:gosec // derived from the user-selected download destination
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}

// writePartValidator records the validator of the entity currently being
// written, removing any stale value when the server supplies none.
func writePartValidator(temporary, validator string) error {
if strings.TrimSpace(validator) == "" {
return discardPartValidator(temporary)
}
return os.WriteFile(partValidatorPath(temporary), []byte(validator), 0600)
}

func discardPartValidator(temporary string) error {
if err := os.Remove(partValidatorPath(temporary)); err != nil &&
!errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}

// discardPartial removes a partial download and its recorded validator.
func discardPartial(temporary string) error {
if err := os.Remove(temporary); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return discardPartValidator(temporary)
}

type progressReader struct {
reader io.ReadCloser
completed int64
Expand Down
Loading