Implement resumable download queue (T-301)

This commit is contained in:
ila
2026-07-16 19:49:06 +08:00
parent 2d302b731a
commit 8dad40f934
27 changed files with 5498 additions and 18 deletions
+79
View File
@@ -0,0 +1,79 @@
package downloader
import (
"context"
"softbox.local/core/application"
)
// ApplicationPublisher is implemented by application.Runtime.
type ApplicationPublisher interface {
Publish(context.Context, application.Event) error
}
// ApplicationObserver maps downloader events to the documented application
// event envelope and concrete payloads.
type ApplicationObserver struct {
Publisher ApplicationPublisher
}
// PublishDownload implements Observer.
func (observer ApplicationObserver) PublishDownload(
ctx context.Context,
event Event,
) error {
if observer.Publisher == nil {
return nil
}
envelope := application.Event{
RequestID: event.RequestID,
AppID: event.AppID,
}
switch event.Type {
case EventStarted:
envelope.Type = application.EventDownloadStarted
envelope.Payload = application.DownloadStartedPayload{
Attempt: event.Attempt,
Done: event.Done,
TotalKnown: event.TotalKnown,
Total: event.Total,
}
case EventProgress:
envelope.Type = application.EventDownloadProgress
envelope.Payload = application.DownloadProgressPayload{
Attempt: event.Attempt,
Done: event.Done,
TotalKnown: event.TotalKnown,
Total: event.Total,
SpeedBytesSec: event.SpeedBytesSec,
}
case EventPaused:
envelope.Type = application.EventDownloadPaused
envelope.Payload = application.DownloadPausedPayload{
Attempt: event.Attempt,
Done: event.Done,
}
case EventCompleted:
envelope.Type = application.EventDownloadCompleted
envelope.Payload = application.DownloadCompletedPayload{
Attempt: event.Attempt,
Done: event.Done,
Path: event.CompletedPath,
}
case EventFailed:
envelope.Type = application.EventDownloadFailed
envelope.Payload = application.DownloadFailedPayload{
Attempt: event.Attempt,
Done: event.Done,
ErrorCode: event.ErrorCode,
}
case EventCanceled:
envelope.Type = application.EventDownloadCanceled
envelope.Payload = application.DownloadCanceledPayload{
Attempt: event.Attempt,
}
default:
return nil
}
return observer.Publisher.Publish(ctx, envelope)
}
@@ -0,0 +1,102 @@
package downloader
import (
"context"
"reflect"
"testing"
"softbox.local/core/application"
)
func TestApplicationObserverUsesConcretePayloads(t *testing.T) {
publisher := &recordingApplicationPublisher{}
observer := ApplicationObserver{Publisher: publisher}
tests := []struct {
event Event
wantType application.EventType
wantPayload any
}{
{
event: Event{
Type: EventStarted, RequestID: "request-1", AppID: "app-1",
Attempt: 1, Done: 2, TotalKnown: true, Total: 10,
},
wantType: application.EventDownloadStarted,
wantPayload: application.DownloadStartedPayload{
Attempt: 1, Done: 2, TotalKnown: true, Total: 10,
},
},
{
event: Event{
Type: EventProgress, RequestID: "request-1", AppID: "app-1",
Attempt: 1, Done: 4, TotalKnown: true, Total: 10, SpeedBytesSec: 2,
},
wantType: application.EventDownloadProgress,
wantPayload: application.DownloadProgressPayload{
Attempt: 1, Done: 4, TotalKnown: true, Total: 10, SpeedBytesSec: 2,
},
},
{
event: Event{
Type: EventPaused, RequestID: "request-1", AppID: "app-1",
Attempt: 1, Done: 4,
},
wantType: application.EventDownloadPaused,
wantPayload: application.DownloadPausedPayload{Attempt: 1, Done: 4},
},
{
event: Event{
Type: EventCompleted, RequestID: "request-1", AppID: "app-1",
Attempt: 1, Done: 10, CompletedPath: "download",
},
wantType: application.EventDownloadCompleted,
wantPayload: application.DownloadCompletedPayload{
Attempt: 1, Done: 10, Path: "download",
},
},
{
event: Event{
Type: EventFailed, RequestID: "request-1", AppID: "app-1",
Attempt: 1, Done: 4, ErrorCode: "http_status",
},
wantType: application.EventDownloadFailed,
wantPayload: application.DownloadFailedPayload{
Attempt: 1, Done: 4, ErrorCode: "http_status",
},
},
{
event: Event{
Type: EventCanceled, RequestID: "request-1", AppID: "app-1",
Attempt: 1,
},
wantType: application.EventDownloadCanceled,
wantPayload: application.DownloadCanceledPayload{Attempt: 1},
},
}
for _, test := range tests {
if err := observer.PublishDownload(context.Background(), test.event); err != nil {
t.Fatalf("PublishDownload(%s) error = %v", test.event.Type, err)
}
got := publisher.events[len(publisher.events)-1]
if got.Type != test.wantType ||
got.RequestID != test.event.RequestID ||
got.AppID != test.event.AppID {
t.Fatalf("event = %#v", got)
}
if !reflect.DeepEqual(got.Payload, test.wantPayload) {
t.Fatalf("payload = %#v, want %#v", got.Payload, test.wantPayload)
}
}
}
type recordingApplicationPublisher struct {
events []application.Event
}
func (publisher *recordingApplicationPublisher) Publish(
_ context.Context,
event application.Event,
) error {
publisher.events = append(publisher.events, event)
return nil
}
+6
View File
@@ -0,0 +1,6 @@
// Package downloader provides resumable byte-transfer queues.
//
// A completed download is still untrusted input. Callers must verify the
// signed Catalog identity, exact size, SHA-256 and package signature before
// handing the file to the installer.
package downloader
+43
View File
@@ -0,0 +1,43 @@
package downloader
import "context"
// EventType identifies an observer event emitted by Queue.
type EventType string
const (
EventStarted EventType = "started"
EventProgress EventType = "progress"
EventPaused EventType = "paused"
EventCompleted EventType = "completed"
EventFailed EventType = "failed"
EventCanceled EventType = "canceled"
)
// Event reports one generation of a download task. Done is monotonic within a
// RequestID+Attempt pair. A new Attempt permits a reset to zero when a server
// ignores Range or an entity cannot be safely resumed.
type Event struct {
Type EventType
RequestID string
AppID string
Attempt uint64
Done int64
TotalKnown bool
Total int64
SpeedBytesSec int64
CompletedPath string
ErrorCode string
}
// Observer receives queue events. Implementations must honor context
// cancellation so pause/cancel can wait for the old generation to exit.
type Observer interface {
PublishDownload(context.Context, Event) error
}
type discardObserver struct{}
func (discardObserver) PublishDownload(context.Context, Event) error {
return nil
}
+234
View File
@@ -0,0 +1,234 @@
package downloader
import (
"fmt"
"io"
"os"
)
func ensureTransferLayout(downloadsRoot, requestID string) (TaskPaths, error) {
paths, err := DeriveTaskPaths(downloadsRoot, requestID)
if err != nil {
return TaskPaths{}, err
}
if err := os.MkdirAll(paths.Root, 0o700); err != nil {
return TaskPaths{}, fmt.Errorf("create downloads root: %w", err)
}
for _, directory := range []string{paths.Root, paths.TasksDir, paths.FilesDir} {
if err := os.Mkdir(directory, 0o700); err != nil && !os.IsExist(err) {
return TaskPaths{}, fmt.Errorf("create download directory: %w", err)
}
info, err := os.Lstat(directory)
if err != nil {
return TaskPaths{}, fmt.Errorf("inspect download directory: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return TaskPaths{}, fmt.Errorf("%w: managed directory is unsafe", ErrTaskCorrupt)
}
}
return paths, nil
}
func regularFileSize(filePath string) (size int64, exists bool, err error) {
info, err := os.Lstat(filePath)
if os.IsNotExist(err) {
return 0, false, nil
}
if err != nil {
return 0, false, fmt.Errorf("inspect download file: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return 0, false, fmt.Errorf("%w: managed file is not regular", ErrTaskCorrupt)
}
return info.Size(), true, nil
}
func regularFileExists(filePath string) (bool, error) {
_, exists, err := regularFileSize(filePath)
return exists, err
}
func truncatePart(filePath string) error {
file, _, err := openVerifiedRegular(filePath, os.O_RDWR)
if err != nil {
return err
}
if err := file.Truncate(0); err != nil {
file.Close()
return fmt.Errorf("truncate part: %w", err)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
file.Close()
return fmt.Errorf("seek truncated part: %w", err)
}
if err := file.Sync(); err != nil {
file.Close()
return fmt.Errorf("sync truncated part: %w", err)
}
return file.Close()
}
func openPart(paths TaskPaths, offset int64, restart bool) (*os.File, error) {
if restart {
if _, exists, err := regularFileSize(paths.Part); err != nil {
return nil, err
} else if exists {
file, _, err := openVerifiedRegular(paths.Part, os.O_RDWR)
if err != nil {
return nil, err
}
if err := file.Truncate(0); err != nil {
file.Close()
return nil, fmt.Errorf("truncate restarted part: %w", err)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
file.Close()
return nil, fmt.Errorf("seek restarted part: %w", err)
}
return file, nil
}
return os.OpenFile(paths.Part, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
}
size, exists, err := regularFileSize(paths.Part)
if err != nil {
return nil, err
}
if !exists {
if offset != 0 {
return nil, fmt.Errorf("%w: missing part for non-zero offset", ErrTaskCorrupt)
}
return os.OpenFile(paths.Part, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
}
if size != offset {
return nil, fmt.Errorf("%w: part size changed", ErrTaskCorrupt)
}
file, _, err := openVerifiedRegular(paths.Part, os.O_WRONLY)
if err != nil {
return nil, err
}
if _, err := file.Seek(offset, io.SeekStart); err != nil {
file.Close()
return nil, fmt.Errorf("seek part: %w", err)
}
return file, nil
}
func syncRegularFile(filePath string) error {
file, _, err := openVerifiedRegular(filePath, os.O_RDWR)
if err != nil {
return err
}
if err := file.Sync(); err != nil {
file.Close()
return err
}
return file.Close()
}
func activateCompleted(paths TaskPaths, expected os.FileInfo) error {
if exists, err := regularFileExists(paths.Completed); err != nil {
return err
} else if exists {
return fmt.Errorf("%w: completed path already exists", ErrTaskCorrupt)
}
if expected == nil {
part, partInfo, err := openVerifiedRegular(paths.Part, os.O_RDONLY)
if err != nil {
return err
}
if err := part.Close(); err != nil {
return fmt.Errorf("close part before activation: %w", err)
}
expected = partInfo
}
if err := verifyRegularIdentity(paths.Part, expected); err != nil {
return err
}
if err := os.Rename(paths.Part, paths.Completed); err != nil {
return fmt.Errorf("activate completed download: %w", err)
}
completedInfo, err := os.Lstat(paths.Completed)
if err != nil {
return fmt.Errorf("inspect activated download: %w", err)
}
if completedInfo.Mode()&os.ModeSymlink != 0 ||
!completedInfo.Mode().IsRegular() ||
!os.SameFile(expected, completedInfo) {
_ = removeExactRegularFile(paths.Completed)
return fmt.Errorf("%w: activated file identity changed", ErrTaskCorrupt)
}
return nil
}
func verifyRegularIdentity(filePath string, expected os.FileInfo) error {
current, err := os.Lstat(filePath)
if err != nil {
return fmt.Errorf("inspect managed file identity: %w", err)
}
if current.Mode()&os.ModeSymlink != 0 ||
!current.Mode().IsRegular() ||
!os.SameFile(expected, current) {
return fmt.Errorf("%w: managed file identity changed", ErrTaskCorrupt)
}
return nil
}
func removeExactRegularFile(filePath string) error {
info, err := os.Lstat(filePath)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("%w: refuse removal of non-regular file", ErrTaskCorrupt)
}
return os.Remove(filePath)
}
func syncAndClose(file *os.File) error {
if file == nil {
return nil
}
if err := file.Sync(); err != nil {
file.Close()
return err
}
return file.Close()
}
func openVerifiedRegular(
filePath string,
flags int,
) (*os.File, os.FileInfo, error) {
before, err := os.Lstat(filePath)
if err != nil {
return nil, nil, err
}
if before.Mode()&os.ModeSymlink != 0 || !before.Mode().IsRegular() {
return nil, nil, fmt.Errorf("%w: managed file is not regular", ErrTaskCorrupt)
}
file, err := os.OpenFile(filePath, flags, 0o600)
if err != nil {
return nil, nil, fmt.Errorf("open managed file: %w", err)
}
opened, err := file.Stat()
if err != nil {
file.Close()
return nil, nil, fmt.Errorf("stat managed file: %w", err)
}
after, err := os.Lstat(filePath)
if err != nil {
file.Close()
return nil, nil, fmt.Errorf("restat managed file: %w", err)
}
if after.Mode()&os.ModeSymlink != 0 ||
!after.Mode().IsRegular() ||
!os.SameFile(before, opened) ||
!os.SameFile(opened, after) {
file.Close()
return nil, nil, fmt.Errorf("%w: managed file changed while opening", ErrTaskCorrupt)
}
return file, opened, nil
}
+45
View File
@@ -0,0 +1,45 @@
package downloader
import (
"errors"
"os"
"testing"
)
func TestActivateCompletedRejectsReplacementOfWrittenPart(t *testing.T) {
paths, err := ensureTransferLayout(t.TempDir(), "request-identity")
if err != nil {
t.Fatalf("ensureTransferLayout() error = %v", err)
}
if err := os.WriteFile(paths.Part, []byte("trusted"), 0o600); err != nil {
t.Fatalf("WriteFile(original) error = %v", err)
}
original, expected, err := openVerifiedRegular(paths.Part, os.O_RDONLY)
if err != nil {
t.Fatalf("openVerifiedRegular() error = %v", err)
}
if err := original.Close(); err != nil {
t.Fatalf("Close(original) error = %v", err)
}
if err := os.Remove(paths.Part); err != nil {
t.Fatalf("Remove(original) error = %v", err)
}
if err := os.WriteFile(paths.Part, []byte("replacement"), 0o600); err != nil {
t.Fatalf("WriteFile(replacement) error = %v", err)
}
err = activateCompleted(paths, expected)
if !errors.Is(err, ErrTaskCorrupt) {
t.Fatalf("activateCompleted() error = %v, want %v", err, ErrTaskCorrupt)
}
if _, err := os.Stat(paths.Completed); !os.IsNotExist(err) {
t.Fatalf("completed path exists after identity mismatch: %v", err)
}
document, err := os.ReadFile(paths.Part)
if err != nil {
t.Fatalf("ReadFile(replacement) error = %v", err)
}
if string(document) != "replacement" {
t.Fatalf("replacement bytes = %q", document)
}
}
+280
View File
@@ -0,0 +1,280 @@
package downloader
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
)
var (
ErrHTTPStatus = errors.New("download HTTP status is not successful")
ErrRangeMismatch = errors.New("download Content-Range does not match request")
ErrRangeEntityChanged = errors.New("download range entity validator changed")
ErrResponseEncoding = errors.New("download response encoding is not identity")
ErrInsecureRedirect = errors.New("download redirect target is not secure")
ErrTransferTooLarge = errors.New("download response exceeds byte limit")
ErrTransferIncomplete = errors.New("download response ended before expected length")
)
// OpenRequest describes one HTTP attempt.
type OpenRequest struct {
URL string
Offset int64
Validator EntityValidator
}
// OpenResponse owns Body until the caller closes it.
type OpenResponse struct {
Body io.ReadCloser
Restart bool
TotalKnown bool
Total int64
ResponseLengthKnown bool
ResponseLength int64
Validator EntityValidator
}
// Transport opens a remote byte stream at a requested offset.
type Transport interface {
Open(context.Context, OpenRequest) (OpenResponse, error)
}
// HTTPTransport implements strict HTTPS and Range semantics with net/http.
type HTTPTransport struct {
client *http.Client
}
// NewHTTPTransport creates a standard-library transport.
func NewHTTPTransport(client *http.Client) *HTTPTransport {
if client == nil {
client = http.DefaultClient
}
clientCopy := *client
previousRedirectCheck := client.CheckRedirect
clientCopy.CheckRedirect = func(request *http.Request, via []*http.Request) error {
if request.URL == nil || ValidateHTTPSURL(request.URL.String()) != nil {
return ErrInsecureRedirect
}
if previousRedirectCheck != nil {
return previousRedirectCheck(request, via)
}
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
return nil
}
return &HTTPTransport{client: &clientCopy}
}
// Open starts a transfer. A 200 response to a Range request is returned as
// Restart=true so the queue truncates the old part before consuming bytes.
func (transport *HTTPTransport) Open(
ctx context.Context,
request OpenRequest,
) (OpenResponse, error) {
if err := ValidateHTTPSURL(request.URL); err != nil {
return OpenResponse{}, err
}
if request.Offset < 0 {
return OpenResponse{}, fmt.Errorf("%w: negative offset", ErrRangeMismatch)
}
if err := request.Validator.Validate(); err != nil {
return OpenResponse{}, fmt.Errorf("%w: invalid validator", ErrRangeMismatch)
}
if request.Offset > 0 && request.Validator.Empty() {
return OpenResponse{}, fmt.Errorf("%w: resume needs an entity validator", ErrRangeMismatch)
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, request.URL, nil)
if err != nil {
return OpenResponse{}, fmt.Errorf("create download request: %w", err)
}
httpRequest.Header.Set("Accept-Encoding", "identity")
if request.Offset > 0 {
httpRequest.Header.Set("Range", fmt.Sprintf("bytes=%d-", request.Offset))
httpRequest.Header.Set("If-Range", request.Validator.HeaderValue())
}
response, err := transport.client.Do(httpRequest)
if err != nil {
return OpenResponse{}, err
}
closeWithError := func(openErr error) (OpenResponse, error) {
_ = response.Body.Close()
return OpenResponse{}, openErr
}
if response.Request == nil || response.Request.URL == nil ||
ValidateHTTPSURL(response.Request.URL.String()) != nil {
return closeWithError(ErrInsecureRedirect)
}
encoding := strings.TrimSpace(response.Header.Get("Content-Encoding"))
if encoding != "" && !strings.EqualFold(encoding, "identity") {
return closeWithError(ErrResponseEncoding)
}
validator := responseValidator(response.Header)
switch response.StatusCode {
case http.StatusOK:
if response.Header.Get("Content-Range") != "" {
return closeWithError(fmt.Errorf("%w: 200 includes Content-Range", ErrRangeMismatch))
}
totalKnown := response.ContentLength >= 0
total := response.ContentLength
if !totalKnown {
total = 0
}
return OpenResponse{
Body: response.Body,
Restart: request.Offset > 0,
TotalKnown: totalKnown,
Total: total,
ResponseLengthKnown: totalKnown,
ResponseLength: total,
Validator: validator,
}, nil
case http.StatusPartialContent:
if request.Offset == 0 {
return closeWithError(fmt.Errorf("%w: unsolicited partial response", ErrRangeMismatch))
}
start, end, totalKnown, total, parseErr := parseContentRange(
response.Header.Get("Content-Range"),
)
if parseErr != nil || start != request.Offset {
return closeWithError(ErrRangeMismatch)
}
rangeLength := end - start + 1
if rangeLength <= 0 ||
(response.ContentLength >= 0 && response.ContentLength != rangeLength) {
return closeWithError(ErrRangeMismatch)
}
if !validatorsMatch(request.Validator, validator) {
return closeWithError(ErrRangeEntityChanged)
}
return OpenResponse{
Body: response.Body,
TotalKnown: totalKnown,
Total: total,
ResponseLengthKnown: true,
ResponseLength: rangeLength,
Validator: request.Validator,
}, nil
default:
return closeWithError(fmt.Errorf("%w: %d", ErrHTTPStatus, response.StatusCode))
}
}
func parseContentRange(value string) (
start int64,
end int64,
totalKnown bool,
total int64,
err error,
) {
if !strings.HasPrefix(value, "bytes ") {
return 0, 0, false, 0, ErrRangeMismatch
}
rangeAndTotal := strings.Split(strings.TrimPrefix(value, "bytes "), "/")
if len(rangeAndTotal) != 2 {
return 0, 0, false, 0, ErrRangeMismatch
}
bounds := strings.Split(rangeAndTotal[0], "-")
if len(bounds) != 2 {
return 0, 0, false, 0, ErrRangeMismatch
}
start, err = strconv.ParseInt(bounds[0], 10, 64)
if err != nil || start < 0 {
return 0, 0, false, 0, ErrRangeMismatch
}
end, err = strconv.ParseInt(bounds[1], 10, 64)
if err != nil || end < start || end == int64(^uint64(0)>>1) {
return 0, 0, false, 0, ErrRangeMismatch
}
if rangeAndTotal[1] == "*" {
return start, end, false, 0, nil
}
total, err = strconv.ParseInt(rangeAndTotal[1], 10, 64)
if err != nil || total <= end {
return 0, 0, false, 0, ErrRangeMismatch
}
return start, end, true, total, nil
}
// CopyOptions bounds one response copy and tags progress with the generation
// that owns it.
type CopyOptions struct {
MaxBytes int64
ExpectedBytesKnown bool
ExpectedBytes int64
Generation uint64
Progress func(CopyProgress) error
}
// CopyProgress reports bytes written during this response only.
type CopyProgress struct {
Generation uint64
Written int64
}
// CopyResponse copies a response with a hard byte cap. It reads at most
// MaxBytes+1 bytes so an oversized body is detected without unbounded IO.
func CopyResponse(
ctx context.Context,
dst io.Writer,
src io.Reader,
options CopyOptions,
) (int64, error) {
if options.MaxBytes < 0 || options.MaxBytes == int64(^uint64(0)>>1) {
return 0, fmt.Errorf("%w: invalid byte limit", ErrTransferTooLarge)
}
if options.ExpectedBytesKnown &&
(options.ExpectedBytes < 0 || options.ExpectedBytes > options.MaxBytes) {
return 0, fmt.Errorf("%w: invalid expected response length", ErrTransferIncomplete)
}
limited := &io.LimitedReader{R: src, N: options.MaxBytes + 1}
buffer := make([]byte, 32*1024)
var written int64
for {
if err := ctx.Err(); err != nil {
return written, err
}
readCount, readErr := limited.Read(buffer)
if readCount > 0 {
if int64(readCount) > options.MaxBytes-written {
return written, ErrTransferTooLarge
}
writeCount, writeErr := dst.Write(buffer[:readCount])
written += int64(writeCount)
if writeErr != nil {
return written, writeErr
}
if writeCount != readCount {
return written, io.ErrShortWrite
}
if options.Progress != nil {
if err := options.Progress(CopyProgress{
Generation: options.Generation,
Written: written,
}); err != nil {
return written, err
}
}
}
if readErr != nil {
if readErr != io.EOF {
return written, readErr
}
break
}
}
if options.ExpectedBytesKnown && written != options.ExpectedBytes {
return written, ErrTransferIncomplete
}
return written, nil
}
+295
View File
@@ -0,0 +1,295 @@
package downloader
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestHTTPTransportFreshAndResume(t *testing.T) {
content := []byte("abcdefghij")
server := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
if request.Header.Get("Accept-Encoding") != "identity" {
t.Errorf("Accept-Encoding = %q", request.Header.Get("Accept-Encoding"))
}
switch request.Header.Get("Range") {
case "":
writer.Header().Set("ETag", `"v1"`)
writer.Header().Set("Content-Length", "10")
_, _ = writer.Write(content)
case "bytes=4-":
if request.Header.Get("If-Range") != `"v1"` {
t.Errorf("If-Range = %q", request.Header.Get("If-Range"))
}
writer.Header().Set("ETag", `"v1"`)
writer.Header().Set("Content-Range", "bytes 4-9/10")
writer.Header().Set("Content-Length", "6")
writer.WriteHeader(http.StatusPartialContent)
_, _ = writer.Write(content[4:])
default:
t.Errorf("unexpected Range %q", request.Header.Get("Range"))
}
}))
defer server.Close()
transport := NewHTTPTransport(server.Client())
fresh, err := transport.Open(context.Background(), OpenRequest{URL: server.URL})
if err != nil {
t.Fatalf("Open(fresh) error = %v", err)
}
freshBytes, err := io.ReadAll(fresh.Body)
fresh.Body.Close()
if err != nil || !bytes.Equal(freshBytes, content) {
t.Fatalf("fresh bytes = %q, error=%v", freshBytes, err)
}
if fresh.Restart || !fresh.TotalKnown || fresh.Total != 10 ||
fresh.Validator.ETag != `"v1"` {
t.Fatalf("fresh response = %#v", fresh)
}
resumed, err := transport.Open(context.Background(), OpenRequest{
URL: server.URL,
Offset: 4,
Validator: EntityValidator{ETag: `"v1"`},
})
if err != nil {
t.Fatalf("Open(resume) error = %v", err)
}
resumedBytes, err := io.ReadAll(resumed.Body)
resumed.Body.Close()
if err != nil || !bytes.Equal(resumedBytes, content[4:]) {
t.Fatalf("resume bytes = %q, error=%v", resumedBytes, err)
}
if resumed.Restart || resumed.ResponseLength != 6 || resumed.Total != 10 {
t.Fatalf("resume response = %#v", resumed)
}
}
func TestHTTPTransportRestartsWhenRangeIgnored(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.Header().Set("ETag", `"v2"`)
writer.Header().Set("Content-Length", "4")
_, _ = writer.Write([]byte("new!"))
}))
defer server.Close()
response, err := NewHTTPTransport(server.Client()).Open(
context.Background(),
OpenRequest{
URL: server.URL,
Offset: 2,
Validator: EntityValidator{ETag: `"v1"`},
},
)
if err != nil {
t.Fatalf("Open() error = %v", err)
}
defer response.Body.Close()
if !response.Restart || response.Validator.ETag != `"v2"` {
t.Fatalf("response = %#v", response)
}
}
func TestHTTPTransportRejectsInvalidRangeResponses(t *testing.T) {
tests := []struct {
name string
contentRange string
etag string
want error
}{
{name: "wrong start", contentRange: "bytes 3-9/10", etag: `"v1"`, want: ErrRangeMismatch},
{name: "malformed", contentRange: "invalid", etag: `"v1"`, want: ErrRangeMismatch},
{name: "entity changed", contentRange: "bytes 4-9/10", etag: `"v2"`, want: ErrRangeEntityChanged},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.Header().Set("Content-Range", test.contentRange)
writer.Header().Set("Content-Length", "6")
writer.Header().Set("ETag", test.etag)
writer.WriteHeader(http.StatusPartialContent)
_, _ = writer.Write([]byte("efghij"))
}))
defer server.Close()
_, err := NewHTTPTransport(server.Client()).Open(
context.Background(),
OpenRequest{
URL: server.URL,
Offset: 4,
Validator: EntityValidator{ETag: `"v1"`},
},
)
if !errors.Is(err, test.want) {
t.Fatalf("Open() error = %v, want %v", err, test.want)
}
})
}
}
func TestHTTPTransportRejectsNonSuccessStatuses(t *testing.T) {
for _, status := range []int{
http.StatusNotFound,
http.StatusRequestedRangeNotSatisfiable,
http.StatusInternalServerError,
} {
t.Run(http.StatusText(status), func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.WriteHeader(status)
}))
defer server.Close()
_, err := NewHTTPTransport(server.Client()).Open(
context.Background(),
OpenRequest{URL: server.URL},
)
if !errors.Is(err, ErrHTTPStatus) {
t.Fatalf("Open() error = %v, want %v", err, ErrHTTPStatus)
}
})
}
}
func TestHTTPTransportRejectsUnsafeSuccessMetadata(t *testing.T) {
tests := []struct {
name string
offset int64
headers map[string]string
status int
want error
}{
{
name: "encoded body",
status: http.StatusOK,
headers: map[string]string{
"Content-Encoding": "gzip",
},
want: ErrResponseEncoding,
},
{
name: "200 with content range",
status: http.StatusOK,
headers: map[string]string{
"Content-Range": "bytes 0-3/4",
},
want: ErrRangeMismatch,
},
{
name: "206 missing content range",
offset: 2,
status: http.StatusPartialContent,
headers: map[string]string{
"ETag": `"v1"`,
},
want: ErrRangeMismatch,
},
{
name: "206 content length mismatch",
offset: 2,
status: http.StatusPartialContent,
headers: map[string]string{
"Content-Range": "bytes 2-3/4",
"Content-Length": "1",
"ETag": `"v1"`,
},
want: ErrRangeMismatch,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
for name, value := range test.headers {
writer.Header().Set(name, value)
}
writer.WriteHeader(test.status)
_, _ = writer.Write([]byte("data"))
}))
defer server.Close()
request := OpenRequest{URL: server.URL, Offset: test.offset}
if test.offset > 0 {
request.Validator = EntityValidator{ETag: `"v1"`}
}
_, err := NewHTTPTransport(server.Client()).Open(
context.Background(),
request,
)
if !errors.Is(err, test.want) {
t.Fatalf("Open() error = %v, want %v", err, test.want)
}
})
}
}
func TestHTTPTransportRejectsDowngradeRedirect(t *testing.T) {
insecure := httptest.NewServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
_, _ = writer.Write([]byte("unsafe"))
}))
defer insecure.Close()
secure := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
http.Redirect(writer, request, insecure.URL, http.StatusFound)
}))
defer secure.Close()
_, err := NewHTTPTransport(secure.Client()).Open(
context.Background(),
OpenRequest{URL: secure.URL},
)
if !errors.Is(err, ErrInsecureRedirect) {
t.Fatalf("Open() error = %v, want %v", err, ErrInsecureRedirect)
}
}
func TestCopyResponseBoundsAndCompleteness(t *testing.T) {
var destination bytes.Buffer
written, err := CopyResponse(
context.Background(),
&destination,
bytes.NewReader([]byte("1234")),
CopyOptions{MaxBytes: 4, ExpectedBytesKnown: true, ExpectedBytes: 4},
)
if err != nil || written != 4 || destination.String() != "1234" {
t.Fatalf("CopyResponse() = %d, %q, %v", written, destination.String(), err)
}
_, err = CopyResponse(
context.Background(),
io.Discard,
bytes.NewReader([]byte("12345")),
CopyOptions{MaxBytes: 4},
)
if !errors.Is(err, ErrTransferTooLarge) {
t.Fatalf("oversize error = %v, want %v", err, ErrTransferTooLarge)
}
_, err = CopyResponse(
context.Background(),
io.Discard,
bytes.NewReader([]byte("123")),
CopyOptions{MaxBytes: 4, ExpectedBytesKnown: true, ExpectedBytes: 4},
)
if !errors.Is(err, ErrTransferIncomplete) {
t.Fatalf("short error = %v, want %v", err, ErrTransferIncomplete)
}
}
+60
View File
@@ -0,0 +1,60 @@
package downloader
import (
"fmt"
"path/filepath"
"strings"
)
// TaskPaths are fixed local names derived only from RequestID.
type TaskPaths struct {
Root string
TasksDir string
FilesDir string
Metadata string
Backup string
Part string
Completed string
}
// DeriveTaskPaths derives all local paths without accepting remote filenames
// or Content-Disposition values.
func DeriveTaskPaths(downloadsRoot, requestID string) (TaskPaths, error) {
if downloadsRoot == "" {
return TaskPaths{}, fmt.Errorf("%w: empty downloads root", ErrInvalidTask)
}
if !ValidRequestID(requestID) {
return TaskPaths{}, fmt.Errorf("%w: invalid request_id", ErrInvalidTask)
}
absoluteRoot, err := filepath.Abs(downloadsRoot)
if err != nil {
return TaskPaths{}, fmt.Errorf("%w: resolve downloads root", ErrInvalidTask)
}
tasksDir := filepath.Join(absoluteRoot, "tasks")
filesDir := filepath.Join(absoluteRoot, "files")
paths := TaskPaths{
Root: absoluteRoot,
TasksDir: tasksDir,
FilesDir: filesDir,
Metadata: filepath.Join(tasksDir, requestID+".json"),
Backup: filepath.Join(tasksDir, requestID+".json.backup"),
Part: filepath.Join(filesDir, requestID+".part"),
Completed: filepath.Join(filesDir, requestID+".download"),
}
for _, candidate := range []string{
paths.TasksDir,
paths.FilesDir,
paths.Metadata,
paths.Backup,
paths.Part,
paths.Completed,
} {
relative, relativeErr := filepath.Rel(absoluteRoot, candidate)
if relativeErr != nil ||
relative == ".." ||
strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return TaskPaths{}, fmt.Errorf("%w: derived path escapes root", ErrInvalidTask)
}
}
return paths, nil
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+239
View File
@@ -0,0 +1,239 @@
package downloader
import (
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
const (
TaskSchemaVersion = 1
MaxRequestIDBytes = 128
MaxAppIDBytes = 128
MaxURLBytes = 4096
MaxErrorCodeBytes = 64
MaxValidatorBytes = 512
)
var (
ErrInvalidTask = errors.New("download task is invalid")
ErrTaskConflict = errors.New("download task conflicts with an existing task")
ErrTaskNotFound = errors.New("download task not found")
ErrInvalidCommand = errors.New("download command is invalid for task state")
ErrTaskBusy = errors.New("download task is busy")
ErrQueueClosed = errors.New("download queue is closed")
ErrTaskCorrupt = errors.New("download task state is corrupt")
requestIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,127}$`)
appIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,127}$`)
errorCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_]{0,63}$`)
strongETagPattern = regexp.MustCompile(`^"[\x21\x23-\x7E\x80-\xFF]*"$`)
)
// TaskStatus is the durable lifecycle of one byte transfer.
type TaskStatus string
const (
StatusQueued TaskStatus = "queued"
StatusDownloading TaskStatus = "downloading"
StatusPaused TaskStatus = "paused"
StatusFailed TaskStatus = "failed"
StatusCompleted TaskStatus = "completed"
)
// EntityValidator binds a resumed range to the same remote representation.
// ETag must be strong. LastModified must be an HTTP-date.
type EntityValidator struct {
ETag string `json:"etag,omitempty"`
LastModified string `json:"last_modified,omitempty"`
}
// Task is the persisted download-task.json v1 protocol.
//
// Local paths are deliberately absent. They are derived from RequestID below
// the configured downloads root so remote metadata cannot choose filesystem
// destinations.
type Task struct {
SchemaVersion int `json:"schema_version"`
RequestID string `json:"request_id"`
AppID string `json:"app_id"`
URL string `json:"url"`
Status TaskStatus `json:"status"`
Attempt uint64 `json:"attempt"`
Done int64 `json:"done"`
TotalKnown bool `json:"total_known"`
Total int64 `json:"total"`
Validator EntityValidator `json:"validator"`
ErrorCode string `json:"error_code"`
CreatedAt string `json:"created_at"`
}
// Terminal reports whether the task no longer occupies the per-app active
// task slot. Completed transfers remain queryable until a later workflow
// consumes or removes them.
func (task Task) Terminal() bool {
return task.Status == StatusCompleted
}
// Validate enforces the durable protocol independently of JSON Schema.
func (task Task) Validate() error {
if task.SchemaVersion != TaskSchemaVersion {
return fmt.Errorf("%w: schema_version=%d", ErrInvalidTask, task.SchemaVersion)
}
if !ValidRequestID(task.RequestID) {
return fmt.Errorf("%w: invalid request_id", ErrInvalidTask)
}
if !appIDPattern.MatchString(task.AppID) || len(task.AppID) > MaxAppIDBytes {
return fmt.Errorf("%w: invalid app_id", ErrInvalidTask)
}
if err := ValidateHTTPSURL(task.URL); err != nil {
return fmt.Errorf("%w: url: %v", ErrInvalidTask, err)
}
switch task.Status {
case StatusQueued, StatusDownloading, StatusPaused, StatusFailed, StatusCompleted:
default:
return fmt.Errorf("%w: invalid status %q", ErrInvalidTask, task.Status)
}
if task.Attempt > uint64(^uint64(0)>>1) {
return fmt.Errorf("%w: attempt is too large", ErrInvalidTask)
}
if task.Done < 0 {
return fmt.Errorf("%w: done=%d", ErrInvalidTask, task.Done)
}
if task.TotalKnown {
if task.Total <= 0 || task.Done > task.Total {
return fmt.Errorf(
"%w: invalid known total done=%d total=%d",
ErrInvalidTask,
task.Done,
task.Total,
)
}
if task.Status == StatusCompleted && task.Done != task.Total {
return fmt.Errorf(
"%w: completed done=%d total=%d",
ErrInvalidTask,
task.Done,
task.Total,
)
}
} else if task.Total != 0 {
return fmt.Errorf("%w: unknown total must be zero", ErrInvalidTask)
}
if err := task.Validator.Validate(); err != nil {
return fmt.Errorf("%w: validator: %v", ErrInvalidTask, err)
}
if task.Status == StatusFailed {
if !errorCodePattern.MatchString(task.ErrorCode) ||
len(task.ErrorCode) > MaxErrorCodeBytes {
return fmt.Errorf("%w: failed task needs a stable error_code", ErrInvalidTask)
}
} else if task.ErrorCode != "" {
return fmt.Errorf("%w: error_code is only valid for failed tasks", ErrInvalidTask)
}
createdAt, err := time.Parse(time.RFC3339Nano, task.CreatedAt)
if err != nil {
return fmt.Errorf("%w: created_at: %v", ErrInvalidTask, err)
}
_, offset := createdAt.Zone()
if offset != 0 {
return fmt.Errorf("%w: created_at must be UTC", ErrInvalidTask)
}
return nil
}
// Validate validates an entity validator.
func (validator EntityValidator) Validate() error {
if validator.ETag != "" && validator.LastModified != "" {
return errors.New("etag and last_modified are mutually exclusive")
}
if validator.ETag != "" {
if len(validator.ETag) > MaxValidatorBytes ||
strings.HasPrefix(strings.ToUpper(validator.ETag), "W/") ||
!strongETagPattern.MatchString(validator.ETag) {
return errors.New("etag is not a strong ETag")
}
}
if validator.LastModified != "" {
if len(validator.LastModified) > MaxValidatorBytes {
return errors.New("last_modified is too long")
}
if _, err := http.ParseTime(validator.LastModified); err != nil {
return errors.New("last_modified is not an HTTP-date")
}
}
return nil
}
// Empty reports whether no reliable resume validator is available.
func (validator EntityValidator) Empty() bool {
return validator.ETag == "" && validator.LastModified == ""
}
// HeaderValue returns the If-Range value.
func (validator EntityValidator) HeaderValue() string {
if validator.ETag != "" {
return validator.ETag
}
return validator.LastModified
}
// ValidRequestID reports whether a request ID can safely derive local names.
func ValidRequestID(requestID string) bool {
return len(requestID) <= MaxRequestIDBytes && requestIDPattern.MatchString(requestID)
}
// ValidateHTTPSURL applies the same transport restrictions as the Catalog.
func ValidateHTTPSURL(value string) error {
if value == "" || len(value) > MaxURLBytes {
return errors.New("URL length is invalid")
}
parsed, err := url.Parse(value)
if err != nil {
return errors.New("URL is malformed")
}
if parsed.Scheme != "https" ||
parsed.Host == "" ||
parsed.User != nil ||
parsed.Fragment != "" ||
!parsed.IsAbs() {
return errors.New("URL must be absolute HTTPS without user info or fragment")
}
return nil
}
func validatorsMatch(expected, actual EntityValidator) bool {
if expected.ETag != "" {
if actual.Empty() {
return true
}
return expected.ETag == actual.ETag
}
if expected.LastModified != "" {
if actual.Empty() {
return true
}
return expected.LastModified == actual.LastModified
}
return true
}
func responseValidator(header http.Header) EntityValidator {
etag := strings.TrimSpace(header.Get("ETag"))
if etag != "" &&
!strings.HasPrefix(strings.ToUpper(etag), "W/") &&
strongETagPattern.MatchString(etag) &&
len(etag) <= MaxValidatorBytes {
return EntityValidator{ETag: etag}
}
lastModified := strings.TrimSpace(header.Get("Last-Modified"))
if lastModified != "" && len(lastModified) <= MaxValidatorBytes {
if _, err := http.ParseTime(lastModified); err == nil {
return EntityValidator{LastModified: lastModified}
}
}
return EntityValidator{}
}
+89
View File
@@ -0,0 +1,89 @@
package downloader
import (
"errors"
"path/filepath"
"testing"
)
func TestTaskValidateAndDerivedPaths(t *testing.T) {
task := validTaskForTest()
if err := task.Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
paths, err := DeriveTaskPaths(filepath.Join(t.TempDir(), "downloads"), task.RequestID)
if err != nil {
t.Fatalf("DeriveTaskPaths() error = %v", err)
}
for _, path := range []string{
paths.Metadata,
paths.Backup,
paths.Part,
paths.Completed,
} {
relative, err := filepath.Rel(paths.Root, path)
if err != nil || relative == ".." {
t.Fatalf("derived path escapes root: %s", path)
}
}
}
func TestTaskRejectsInvalidFields(t *testing.T) {
tests := []struct {
name string
mutate func(*Task)
}{
{name: "request id", mutate: func(task *Task) { task.RequestID = "../escape" }},
{name: "app id", mutate: func(task *Task) { task.AppID = "Bad App" }},
{name: "URL", mutate: func(task *Task) { task.URL = "http://example.invalid/app.zip" }},
{name: "state", mutate: func(task *Task) { task.Status = "unknown" }},
{name: "negative done", mutate: func(task *Task) { task.Done = -1 }},
{name: "done exceeds total", mutate: func(task *Task) { task.Done = task.Total + 1 }},
{name: "unknown total value", mutate: func(task *Task) {
task.TotalKnown = false
task.Total = 10
}},
{name: "weak etag", mutate: func(task *Task) {
task.Validator = EntityValidator{ETag: `W/"weak"`}
}},
{name: "failed without code", mutate: func(task *Task) {
task.Status = StatusFailed
task.ErrorCode = ""
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
task := validTaskForTest()
test.mutate(&task)
if err := task.Validate(); !errors.Is(err, ErrInvalidTask) {
t.Fatalf("Validate() error = %v, want %v", err, ErrInvalidTask)
}
})
}
}
func TestValidatorsMatchRejectsDifferentKinds(t *testing.T) {
expected := EntityValidator{ETag: `"v1"`}
if validatorsMatch(expected, EntityValidator{LastModified: "Wed, 16 Jul 2026 10:00:00 GMT"}) {
t.Fatal("ETag unexpectedly matched Last-Modified")
}
if validatorsMatch(expected, EntityValidator{ETag: `"v2"`}) {
t.Fatal("different ETags matched")
}
if !validatorsMatch(expected, EntityValidator{}) {
t.Fatal("absent response validator should not contradict If-Range")
}
}
func validTaskForTest() Task {
return Task{
SchemaVersion: TaskSchemaVersion,
RequestID: "request-001",
AppID: "json-parser",
URL: "https://download.invalid/json-parser.zip",
Status: StatusQueued,
TotalKnown: true,
Total: 42,
CreatedAt: "2026-07-16T00:00:00Z",
}
}
+335
View File
@@ -0,0 +1,335 @@
package downloader
import (
"context"
"fmt"
"os"
"time"
)
func (queue *Queue) transfer(
runtime *runtimeTask,
ctx context.Context,
attempt uint64,
) (completedPath string, currentAttempt uint64, transferErr error) {
currentAttempt = attempt
task, valid := queue.taskForAttempt(runtime, currentAttempt)
if !valid {
return "", currentAttempt, context.Canceled
}
paths, err := ensureTransferLayout(queue.root, task.RequestID)
if err != nil {
return "", currentAttempt, err
}
offset, _, err := regularFileSize(paths.Part)
if err != nil {
return "", currentAttempt, err
}
response, err := queue.transport.Open(ctx, OpenRequest{
URL: task.URL,
Offset: offset,
Validator: task.Validator,
})
if err != nil {
return "", currentAttempt, err
}
body := response.Body
defer func() {
if body != nil {
_ = body.Close()
}
}()
if task.TotalKnown && response.TotalKnown && response.Total != task.Total {
return "", currentAttempt, fmt.Errorf(
"%w: response total %d, expected %d",
ErrTransferIncomplete,
response.Total,
task.Total,
)
}
if !task.TotalKnown && response.TotalKnown && response.Total > queue.maxUnknown {
return "", currentAttempt, ErrTransferTooLarge
}
if !task.TotalKnown && offset > 0 && !response.Restart && !response.TotalKnown {
return "", currentAttempt, fmt.Errorf(
"%w: resumed response has unknown complete length",
ErrTransferIncomplete,
)
}
var part *os.File
if response.Restart {
part, err = openPart(paths, offset, true)
if err != nil {
return "", currentAttempt, err
}
if err := part.Sync(); err != nil {
_ = part.Close()
return "", currentAttempt, fmt.Errorf("sync restarted part: %w", err)
}
currentAttempt, task, err = queue.restartAttempt(
runtime,
currentAttempt,
response,
)
if err != nil {
_ = part.Close()
return "", currentAttempt, err
}
offset = 0
} else {
task, err = queue.applyResponseFacts(runtime, currentAttempt, response)
if err != nil {
return "", currentAttempt, err
}
}
var maximum int64
var expectedKnown bool
var expected int64
if task.TotalKnown {
if offset > task.Total {
return "", currentAttempt, fmt.Errorf(
"%w: offset exceeds total",
ErrTaskCorrupt,
)
}
maximum = task.Total - offset
expectedKnown = true
expected = maximum
} else {
if offset >= queue.maxUnknown {
return "", currentAttempt, ErrTransferTooLarge
}
maximum = queue.maxUnknown - offset
if response.ResponseLengthKnown {
expectedKnown = true
expected = response.ResponseLength
}
}
if !task.TotalKnown && response.TotalKnown {
if response.Total > queue.maxUnknown ||
offset > response.Total ||
!response.ResponseLengthKnown ||
response.ResponseLength != response.Total-offset {
if part != nil {
_ = part.Close()
}
return "", currentAttempt, ErrTransferIncomplete
}
}
if response.ResponseLengthKnown && response.ResponseLength > maximum {
if part != nil {
_ = part.Close()
}
return "", currentAttempt, ErrTransferTooLarge
}
if expectedKnown &&
response.ResponseLengthKnown &&
response.ResponseLength != expected {
if part != nil {
_ = part.Close()
}
return "", currentAttempt, ErrTransferIncomplete
}
if part == nil {
part, err = openPart(paths, offset, false)
if err != nil {
return "", currentAttempt, err
}
}
lastEventAt := queue.clock.Now()
lastEventDone := offset
lastPublishedDone := offset
copyOptions := CopyOptions{
MaxBytes: maximum,
ExpectedBytesKnown: expectedKnown,
ExpectedBytes: expected,
Generation: currentAttempt,
Progress: func(progress CopyProgress) error {
absoluteDone := offset + progress.Written
now := queue.clock.Now()
emit := queue.progressEvery == 0 ||
now.Sub(lastEventAt) >= queue.progressEvery
if err := queue.updateAttemptDone(
runtime,
currentAttempt,
absoluteDone,
); err != nil {
return err
}
if !emit {
return nil
}
speed := progressSpeed(lastEventAt, now, lastEventDone, absoluteDone)
if err := queue.persistAndPublishProgress(
runtime,
currentAttempt,
speed,
); err != nil {
return err
}
lastEventAt = now
lastEventDone = absoluteDone
lastPublishedDone = absoluteDone
return nil
},
}
written, copyErr := CopyResponse(ctx, part, body, copyOptions)
writtenInfo, statErr := part.Stat()
bodyCloseErr := body.Close()
body = nil
syncErr := syncAndClose(part)
if syncErr != nil {
return "", currentAttempt, syncErr
}
if bodyCloseErr != nil {
return "", currentAttempt, bodyCloseErr
}
if statErr != nil {
return "", currentAttempt, fmt.Errorf("stat written part: %w", statErr)
}
if err := verifyRegularIdentity(paths.Part, writtenInfo); err != nil {
return "", currentAttempt, err
}
if copyErr != nil {
return "", currentAttempt, copyErr
}
finalDone := offset + written
if err := queue.updateAttemptDone(runtime, currentAttempt, finalDone); err != nil {
return "", currentAttempt, err
}
if finalDone != lastPublishedDone {
now := queue.clock.Now()
speed := progressSpeed(lastEventAt, now, lastEventDone, finalDone)
if err := queue.persistAndPublishProgress(
runtime,
currentAttempt,
speed,
); err != nil {
return "", currentAttempt, err
}
}
if task.TotalKnown && finalDone != task.Total {
return "", currentAttempt, ErrTransferIncomplete
}
if err := activateCompleted(paths, writtenInfo); err != nil {
return "", currentAttempt, err
}
return paths.Completed, currentAttempt, nil
}
func (queue *Queue) restartAttempt(
runtime *runtimeTask,
attempt uint64,
response OpenResponse,
) (uint64, Task, error) {
queue.mu.Lock()
if !queue.attemptCurrent(runtime, attempt) || runtime.stop != stopNone {
queue.mu.Unlock()
return attempt, Task{}, context.Canceled
}
runtime.task.Attempt++
runtime.task.Done = 0
runtime.task.Validator = response.Validator
task := runtime.task
newAttempt := task.Attempt
queue.mu.Unlock()
if err := queue.store.Write(task); err != nil {
return newAttempt, task, err
}
queue.publishBestEffort(Event{
Type: EventStarted,
RequestID: task.RequestID,
AppID: task.AppID,
Attempt: task.Attempt,
Done: 0,
TotalKnown: task.TotalKnown,
Total: task.Total,
})
return newAttempt, task, nil
}
func (queue *Queue) applyResponseFacts(
runtime *runtimeTask,
attempt uint64,
response OpenResponse,
) (Task, error) {
queue.mu.Lock()
if !queue.attemptCurrent(runtime, attempt) || runtime.stop != stopNone {
queue.mu.Unlock()
return Task{}, context.Canceled
}
if runtime.task.Validator.Empty() {
runtime.task.Validator = response.Validator
}
task := runtime.task
queue.mu.Unlock()
if err := queue.store.Write(task); err != nil {
return Task{}, err
}
return task, nil
}
func (queue *Queue) updateAttemptDone(
runtime *runtimeTask,
attempt uint64,
done int64,
) error {
queue.mu.Lock()
defer queue.mu.Unlock()
if !queue.attemptCurrent(runtime, attempt) ||
runtime.stop != stopNone ||
done < runtime.task.Done {
return context.Canceled
}
if runtime.task.TotalKnown && done > runtime.task.Total {
return ErrTransferTooLarge
}
runtime.task.Done = done
return nil
}
func (queue *Queue) persistAndPublishProgress(
runtime *runtimeTask,
attempt uint64,
speed int64,
) error {
queue.mu.Lock()
if !queue.attemptCurrent(runtime, attempt) || runtime.stop != stopNone {
queue.mu.Unlock()
return context.Canceled
}
task := runtime.task
queue.mu.Unlock()
if err := queue.store.Write(task); err != nil {
return err
}
queue.publishBestEffort(Event{
Type: EventProgress,
RequestID: task.RequestID,
AppID: task.AppID,
Attempt: task.Attempt,
Done: task.Done,
TotalKnown: task.TotalKnown,
Total: task.Total,
SpeedBytesSec: speed,
})
return nil
}
func progressSpeed(start, end time.Time, startDone, endDone int64) int64 {
elapsed := end.Sub(start)
if elapsed <= 0 || endDone <= startDone {
return 0
}
bytesPerSecond := float64(endDone-startDone) / elapsed.Seconds()
if bytesPerSecond <= 0 {
return 0
}
return int64(bytesPerSecond)
}