Files

1551 lines
42 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build linux || freebsd
// +build linux freebsd
package webdav
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/output"
"codeberg.org/petrbalvin/goget/internal/protocol"
httpproto "codeberg.org/petrbalvin/goget/internal/protocol/http"
)
func TestWebDAVProtocol(t *testing.T) {
if !protocol.GlobalRegistry.Supports("http") {
httpClient, err := httpproto.NewClient(httpproto.DefaultConfig())
if err != nil {
t.Fatalf("failed to create http client: %v", err)
}
_ = protocol.Register(httpClient)
}
// Create a mock WebDAV server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
if r.Method == "PROPFIND" {
depth := r.Header.Get("Depth")
path := r.URL.Path
if depth == "0" {
if path == "/dir" || path == "/dir/" {
// Directory response
xmlResp := `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/dir/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(xmlResp))
return
}
if path == "/file.txt" {
// File response
xmlResp := `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/file.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>12</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(xmlResp))
return
}
}
if depth == "1" {
if path == "/dir" || path == "/dir/" {
// Directory contents response
xmlResp := `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/dir/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dir/file1.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>13</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dir/file2.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>13</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(xmlResp))
return
}
}
w.WriteHeader(http.StatusNotFound)
return
}
if r.Method == "GET" {
if r.URL.Path == "/file.txt" {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("file content"))
return
}
if r.URL.Path == "/dir/file1.txt" {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("file1 content"))
return
}
if r.URL.Path == "/dir/file2.txt" {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("file2 content"))
return
}
}
if r.Method == "PUT" {
if r.URL.Path == "/upload.txt" {
w.WriteHeader(http.StatusCreated)
return
}
}
w.WriteHeader(http.StatusMethodNotAllowed)
}))
defer server.Close()
// Parse server URL to construct test URLs
parsedServerURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("failed to parse mock server URL: %v", err)
}
p := NewProtocol()
// 1. Test CanHandle
t.Run("CanHandle", func(t *testing.T) {
u1, _ := url.Parse("webdav://example.com/file")
u2, _ := url.Parse("webdavs://example.com/file")
u3, _ := url.Parse("http://example.com/file")
if !p.CanHandle(u1) {
t.Error("CanHandle(webdav://) = false, want true")
}
if !p.CanHandle(u2) {
t.Error("CanHandle(webdavs://) = false, want true")
}
if p.CanHandle(u3) {
t.Error("CanHandle(http://) = true, want false")
}
})
// 2. Test File Download
t.Run("Download File", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "webdav-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
fileURL := &url.URL{
Scheme: "webdav",
Host: parsedServerURL.Host,
Path: "/file.txt",
}
outputPath := filepath.Join(tmpDir, "downloaded_file.txt")
req := &core.DownloadRequest{
URL: fileURL,
Output: outputPath,
Recursive: false,
Timeout: 5 * time.Second,
}
result, err := p.Download(context.Background(), req)
if err != nil {
t.Fatalf("download failed: %v", err)
}
if result.BytesDownloaded != 12 {
t.Errorf("BytesDownloaded = %d, want 12", result.BytesDownloaded)
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("failed to read output file: %v", err)
}
if string(data) != "file content" {
t.Errorf("file content = %q, want %q", string(data), "file content")
}
})
// 3. Test Directory Detection and error on non-recursive download of directory
t.Run("Directory Non-Recursive Error", func(t *testing.T) {
dirURL := &url.URL{
Scheme: "webdav",
Host: parsedServerURL.Host,
Path: "/dir/",
}
req := &core.DownloadRequest{
URL: dirURL,
Output: "",
Recursive: false,
Timeout: 5 * time.Second,
}
_, err := p.Download(context.Background(), req)
if err == nil {
t.Fatal("expected error downloading directory non-recursively, got nil")
}
if !strings.Contains(err.Error(), "remote path is a directory") {
t.Errorf("unexpected error: %v", err)
}
})
// 4. Test Recursive Download
t.Run("Download Directory Recursive", func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "webdav-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dirURL := &url.URL{
Scheme: "webdav",
Host: parsedServerURL.Host,
Path: "/dir/",
}
outputPath := filepath.Join(tmpDir, "local_dir")
req := &core.DownloadRequest{
URL: dirURL,
Output: outputPath,
Recursive: true,
Timeout: 5 * time.Second,
}
result, err := p.Download(context.Background(), req)
if err != nil {
t.Fatalf("recursive download failed: %v", err)
}
// Two files: file1.txt (13 bytes) + file2.txt (13 bytes) = 26 bytes
if result.BytesDownloaded != 26 {
t.Errorf("BytesDownloaded = %d, want 26", result.BytesDownloaded)
}
if result.ChunksCount != 2 {
t.Errorf("ChunksCount = %d, want 2", result.ChunksCount)
}
// Verify files exist in directory
f1, err := os.ReadFile(filepath.Join(outputPath, "file1.txt"))
if err != nil {
t.Errorf("file1.txt read failed: %v", err)
} else if string(f1) != "file1 content" {
t.Errorf("file1 content = %q, want %q", string(f1), "file1 content")
}
f2, err := os.ReadFile(filepath.Join(outputPath, "file2.txt"))
if err != nil {
t.Errorf("file2.txt read failed: %v", err)
} else if string(f2) != "file2 content" {
t.Errorf("file2 content = %q, want %q", string(f2), "file2 content")
}
})
// 5. Test Upload
t.Run("Upload File", func(t *testing.T) {
tmpFile, err := os.CreateTemp("", "webdav-upload-*")
if err != nil {
t.Fatalf("failed to create temp file for upload: %v", err)
}
defer os.Remove(tmpFile.Name())
content := "upload test content"
if _, err := tmpFile.Write([]byte(content)); err != nil {
t.Fatalf("failed to write upload file content: %v", err)
}
tmpFile.Close()
uploadURL := &url.URL{
Scheme: "webdav",
Host: parsedServerURL.Host,
Path: "/upload.txt",
}
req := &core.UploadRequest{
URL: uploadURL,
Input: tmpFile.Name(),
Timeout: 5 * time.Second,
}
result, err := p.Upload(context.Background(), req)
if err != nil {
t.Fatalf("upload failed: %v", err)
}
if result.BytesUploaded != int64(len(content)) {
t.Errorf("BytesUploaded = %d, want %d", result.BytesUploaded, len(content))
}
if result.Protocol != "webdav" {
t.Errorf("Protocol = %q, want %q", result.Protocol, "webdav")
}
})
}
func TestToHTTPURL(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"webdav to http", "webdav://example.com/file", "http://example.com/file"},
{"webdavs to https", "webdavs://example.com/file", "https://example.com/file"},
{"already http", "http://example.com/file", "http://example.com/file"},
{"already https", "https://example.com/file", "https://example.com/file"},
{"webdav with port", "webdav://example.com:8080/file", "http://example.com:8080/file"},
{"webdavs with port", "webdavs://example.com:443/file", "https://example.com:443/file"},
{"webdav with user", "webdav://user:pass@example.com/file", "http://user:pass@example.com/file"},
{"empty path", "webdav://example.com", "http://example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, err := url.Parse(tt.input)
if err != nil {
t.Fatalf("failed to parse input url: %v", err)
}
result := toHTTPURL(u)
if result.String() != tt.want {
t.Errorf("toHTTPURL(%q) = %q, want %q", tt.input, result.String(), tt.want)
}
})
}
}
func TestFilteredRecursiveDownload(t *testing.T) {
// Register HTTP protocol handler.
if !protocol.GlobalRegistry.Supports("http") {
httpClient, err := httpproto.NewClient(httpproto.DefaultConfig())
if err != nil {
t.Fatalf("failed to create http client: %v", err)
}
_ = protocol.Register(httpClient)
}
// Create mock WebDAV server with multiple entries.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
if r.Method == "PROPFIND" && (r.URL.Path == "/dir" || r.URL.Path == "/dir/") {
// Return unfiltered listing.
xmlResp := `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/dir/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dir/a.pdf</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>100</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dir/b.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>200</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(xmlResp))
return
}
if r.Method == "GET" {
w.Write([]byte("file content"))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
parsedServerURL, _ := url.Parse(server.URL)
p := NewProtocol()
t.Run("accept filter", func(t *testing.T) {
tmpDir, _ := os.MkdirTemp("", "webdav-filtered-*")
defer os.RemoveAll(tmpDir)
dirURL := &url.URL{
Scheme: "webdav",
Host: parsedServerURL.Host,
Path: "/dir/",
}
req := &core.DownloadRequest{
URL: dirURL,
Output: tmpDir,
Recursive: true,
Timeout: 5 * time.Second,
AcceptPatterns: []string{"*.pdf"},
}
result, err := p.Download(context.Background(), req)
if err != nil {
t.Fatalf("filtered download failed: %v", err)
}
// Only a.pdf should be downloaded (100 bytes).
if result.ChunksCount != 1 {
t.Errorf("ChunksCount = %d, want 1 (only *.pdf matched)", result.ChunksCount)
}
})
t.Run("reject filter", func(t *testing.T) {
tmpDir, _ := os.MkdirTemp("", "webdav-reject-*")
defer os.RemoveAll(tmpDir)
dirURL := &url.URL{
Scheme: "webdav",
Host: parsedServerURL.Host,
Path: "/dir/",
}
req := &core.DownloadRequest{
URL: dirURL,
Output: tmpDir,
Recursive: true,
Timeout: 5 * time.Second,
RejectPatterns: []string{"*.txt"},
}
result, err := p.Download(context.Background(), req)
if err != nil {
t.Fatalf("reject download failed: %v", err)
}
// Only a.pdf should be downloaded (b.txt rejected).
if result.ChunksCount != 1 {
t.Errorf("ChunksCount = %d, want 1 (*.txt rejected)", result.ChunksCount)
}
})
}
func TestAuthRequired(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "admin" || pass != "secret" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.Method == "PROPFIND" {
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
xmlResp := `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/file.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>12</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(xmlResp))
return
}
if r.Method == "GET" {
w.Write([]byte("file content"))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
parsedServerURL, _ := url.Parse(server.URL)
p := NewProtocol()
t.Run("auth success", func(t *testing.T) {
tmpDir, _ := os.MkdirTemp("", "webdav-auth-*")
defer os.RemoveAll(tmpDir)
fileURL := &url.URL{
Scheme: "webdav",
Host: parsedServerURL.Host,
Path: "/file.txt",
User: url.UserPassword("admin", "secret"),
}
outputPath := filepath.Join(tmpDir, "file.txt")
req := &core.DownloadRequest{
URL: fileURL,
Output: outputPath,
Timeout: 5 * time.Second,
}
result, err := p.Download(context.Background(), req)
if err != nil {
t.Fatalf("auth download failed: %v", err)
}
if result.BytesDownloaded != 12 {
t.Errorf("BytesDownloaded = %d, want 12", result.BytesDownloaded)
}
})
t.Run("auth failure", func(t *testing.T) {
tmpDir, _ := os.MkdirTemp("", "webdav-auth-fail-*")
defer os.RemoveAll(tmpDir)
fileURL := &url.URL{
Scheme: "webdav",
Host: parsedServerURL.Host,
Path: "/file.txt",
User: url.UserPassword("wrong", "creds"),
}
outputPath := filepath.Join(tmpDir, "file.txt")
req := &core.DownloadRequest{
URL: fileURL,
Output: outputPath,
Timeout: 5 * time.Second,
}
_, err := p.Download(context.Background(), req)
if err == nil {
t.Fatal("expected auth error, got nil")
}
if !strings.Contains(err.Error(), string(core.ErrAuth)) {
t.Errorf("expected AUTH error, got: %v", err)
}
})
}
// TestProtocolHTTPTransportIsShared verifies that repeated calls to
// getHTTPClient return clients that all wrap the same *http.Transport.
// Connection pooling depends on this: a fresh transport per call would
// disable idle-connection reuse, HTTP/2 multiplexing, and TLS session
// resumption.
func TestProtocolHTTPTransportIsShared(t *testing.T) {
p := NewProtocol()
// First call triggers the lazy init.
tr1 := p.getHTTPTransport()
if tr1 == nil {
t.Fatal("getHTTPTransport returned nil on first call")
}
// Subsequent calls must return the very same pointer.
for i := 0; i < 5; i++ {
tr := p.getHTTPTransport()
if tr != tr1 {
t.Errorf("getHTTPTransport call %d returned a fresh transport; want pointer reuse", i+2)
}
}
// And every client wrapper must point at that same transport.
for i := 0; i < 3; i++ {
client := p.getHTTPClient(15 * time.Second)
if client.Transport != tr1 {
t.Errorf("getHTTPClient call %d wrapped a different transport; want shared", i+1)
}
if client.Timeout != 15*time.Second {
t.Errorf("getHTTPClient call %d timeout = %v, want 15s", i+1, client.Timeout)
}
}
}
// TestGetHTTPClientTimeoutDefault verifies that a zero/negative timeout is
// upgraded to a 30-minute default, matching the previous on-site fallback
// in downloadFile and doUpload.
func TestGetHTTPClientTimeoutDefault(t *testing.T) {
p := NewProtocol()
tr := p.getHTTPTransport()
cases := []struct {
name string
timeout time.Duration
want time.Duration
}{
{"zero becomes default", 0, 30 * time.Minute},
{"negative becomes default", -1 * time.Second, 30 * time.Minute},
{"explicit positive is preserved", 5 * time.Second, 5 * time.Second},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
client := p.getHTTPClient(tc.timeout)
if client.Timeout != tc.want {
t.Errorf("Timeout = %v, want %v", client.Timeout, tc.want)
}
if client.Transport != tr {
t.Errorf("Transport is not the shared one")
}
})
}
}
// TestProtocolHTTPTransportConcurrentInit makes sure that concurrent first
// callers see exactly one transport built (no double-init under the mutex).
// Run with -race to catch any data race in the lazy-init path.
func TestProtocolHTTPTransportConcurrentInit(t *testing.T) {
p := NewProtocol()
const goroutines = 16
results := make([]*http.Transport, goroutines)
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
i := i
go func() {
defer wg.Done()
results[i] = p.getHTTPTransport()
}()
}
wg.Wait()
first := results[0]
for i, tr := range results {
if tr != first {
t.Errorf("goroutine %d saw a different transport; lazy init is not safe", i)
}
}
}
func TestRecursiveDownloadParallel(t *testing.T) {
if !protocol.GlobalRegistry.Supports("http") {
httpClient, err := httpproto.NewClient(httpproto.DefaultConfig())
if err != nil {
t.Fatalf("failed to create http client: %v", err)
}
_ = protocol.Register(httpClient)
}
// Mock WebDAV server with 5 files in a single directory.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
if r.Method == "PROPFIND" && (r.URL.Path == "/dir" || r.URL.Path == "/dir/") {
xmlResp := `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/dir/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dir/a.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>10</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dir/b.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>20</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dir/c.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>30</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dir/d.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>40</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dir/e.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>50</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(xmlResp))
return
}
if r.Method == "GET" {
w.Write([]byte("file content"))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
parsedServerURL, _ := url.Parse(server.URL)
dirURL := &url.URL{
Scheme: "webdav",
Host: parsedServerURL.Host,
Path: "/dir/",
}
t.Run("parallel=3 downloads all files", func(t *testing.T) {
tmpDir, _ := os.MkdirTemp("", "webdav-parallel-*")
defer os.RemoveAll(tmpDir)
p := NewProtocol()
req := &core.DownloadRequest{
URL: dirURL,
Output: tmpDir,
Recursive: true,
RecursiveParallel: 3,
Timeout: 5 * time.Second,
}
result, err := p.Download(context.Background(), req)
if err != nil {
t.Fatalf("parallel download failed: %v", err)
}
if result.ChunksCount != 5 {
t.Errorf("ChunksCount = %d, want 5 (all files)", result.ChunksCount)
}
// 10+20+30+40+50 = 150 bytes of metadata-reported sizes, but our
// mock server returns the same 12-byte body for every file, so
// the actual bytes downloaded is 5 * 12 = 60.
const expectedBytes = int64(5 * len("file content"))
if result.BytesDownloaded != expectedBytes {
t.Errorf("BytesDownloaded = %d, want %d", result.BytesDownloaded, expectedBytes)
}
// Verify all files actually exist on disk.
for _, name := range []string{"a.txt", "b.txt", "c.txt", "d.txt", "e.txt"} {
path := filepath.Join(tmpDir, name)
data, err := os.ReadFile(path)
if err != nil {
t.Errorf("file %s not downloaded: %v", name, err)
continue
}
if string(data) != "file content" {
t.Errorf("file %s has wrong content: %q", name, string(data))
}
}
})
t.Run("parallel=1 falls back to sequential path", func(t *testing.T) {
tmpDir, _ := os.MkdirTemp("", "webdav-seq1-*")
defer os.RemoveAll(tmpDir)
p := NewProtocol()
req := &core.DownloadRequest{
URL: dirURL,
Output: tmpDir,
Recursive: true,
RecursiveParallel: 1,
Timeout: 5 * time.Second,
}
result, err := p.Download(context.Background(), req)
if err != nil {
t.Fatalf("download with parallel=1 failed: %v", err)
}
if result.ChunksCount != 5 {
t.Errorf("ChunksCount = %d, want 5", result.ChunksCount)
}
})
t.Run("parallel=0 falls back to sequential path", func(t *testing.T) {
tmpDir, _ := os.MkdirTemp("", "webdav-seq0-*")
defer os.RemoveAll(tmpDir)
p := NewProtocol()
req := &core.DownloadRequest{
URL: dirURL,
Output: tmpDir,
Recursive: true,
RecursiveParallel: 0,
Timeout: 5 * time.Second,
}
result, err := p.Download(context.Background(), req)
if err != nil {
t.Fatalf("download with parallel=0 failed: %v", err)
}
if result.ChunksCount != 5 {
t.Errorf("ChunksCount = %d, want 5", result.ChunksCount)
}
})
t.Run("parallel=3 propagates to subdirectory recursion", func(t *testing.T) {
// Mock server with one subdirectory containing 2 files.
subServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
if r.Method == "PROPFIND" && (r.URL.Path == "/root" || r.URL.Path == "/root/") {
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/root/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/root/sub/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`))
return
}
if r.Method == "PROPFIND" && (r.URL.Path == "/root/sub" || r.URL.Path == "/root/sub/") {
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/root/sub/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/root/sub/x.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>5</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/root/sub/y.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>5</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`))
return
}
if r.Method == "GET" {
w.Write([]byte("hello"))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer subServer.Close()
parsedSubURL, _ := url.Parse(subServer.URL)
rootURL := &url.URL{
Scheme: "webdav",
Host: parsedSubURL.Host,
Path: "/root/",
}
tmpDir, _ := os.MkdirTemp("", "webdav-subdir-*")
defer os.RemoveAll(tmpDir)
p := NewProtocol()
req := &core.DownloadRequest{
URL: rootURL,
Output: tmpDir,
Recursive: true,
RecursiveParallel: 3,
MaxDepth: 1,
Timeout: 5 * time.Second,
}
result, err := p.Download(context.Background(), req)
if err != nil {
t.Fatalf("recursive subdir download failed: %v", err)
}
if result.ChunksCount != 2 {
t.Errorf("ChunksCount = %d, want 2 (files in subdirectory)", result.ChunksCount)
}
// Verify the subdirectory and its files were created.
for _, name := range []string{"sub/x.txt", "sub/y.txt"} {
path := filepath.Join(tmpDir, name)
if _, err := os.Stat(path); err != nil {
t.Errorf("expected file %s to exist: %v", name, err)
}
}
})
t.Run("context cancellation stops parallel workers", func(t *testing.T) {
// Mock server that blocks GETs until ctx is cancelled, so we can
// observe the parallel pool responding to cancellation.
block := make(chan struct{})
cancelServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
if r.Method == "PROPFIND" && (r.URL.Path == "/slow" || r.URL.Path == "/slow/") {
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/slow/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/slow/a.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>10</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/slow/b.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>10</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/slow/c.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>10</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`))
return
}
if r.Method == "GET" {
// Block until the test closes `block` (i.e. the request
// will only return after the test cancels the context).
select {
case <-block:
w.Write([]byte("data"))
case <-r.Context().Done():
return
}
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer cancelServer.Close()
defer close(block)
parsedCancelURL, _ := url.Parse(cancelServer.URL)
slowURL := &url.URL{
Scheme: "webdav",
Host: parsedCancelURL.Host,
Path: "/slow/",
}
tmpDir, _ := os.MkdirTemp("", "webdav-cancel-*")
defer os.RemoveAll(tmpDir)
ctx, cancel := context.WithCancel(context.Background())
p := NewProtocol()
req := &core.DownloadRequest{
URL: slowURL,
Output: tmpDir,
Recursive: true,
RecursiveParallel: 2,
Timeout: 10 * time.Second,
Ctx: ctx,
}
// Cancel after a short delay; the workers should bail out
// instead of waiting for the blocked GETs.
go func() {
time.Sleep(150 * time.Millisecond)
cancel()
}()
done := make(chan struct{})
go func() {
_, _ = p.Download(ctx, req)
close(done)
}()
select {
case <-done:
// Good — the download returned (likely with errors for the
// cancelled files, but it did return promptly).
case <-time.After(3 * time.Second):
t.Fatal("parallel download did not honour context cancellation within 3s")
}
})
}
// captureStderr redirects os.Stderr for the duration of fn and returns
// whatever was written. Used by the dry-run test to assert on the
// human-readable list of entries that would be fetched.
func captureStderr(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stderr = w
defer func() { os.Stderr = orig }()
done := make(chan struct{})
var buf bytes.Buffer
go func() {
_, _ = io.Copy(&buf, r)
close(done)
}()
fn()
_ = w.Close()
<-done
_ = r.Close()
return buf.String()
}
func TestRecursiveDownloadDryRun(t *testing.T) {
if !protocol.GlobalRegistry.Supports("http") {
httpClient, err := httpproto.NewClient(httpproto.DefaultConfig())
if err != nil {
t.Fatalf("failed to create http client: %v", err)
}
_ = protocol.Register(httpClient)
}
// Mock server with the same layout the other recursive tests use.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
if r.Method == "PROPFIND" && (r.URL.Path == "/dir" || r.URL.Path == "/dir/") {
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/dir/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dir/a.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>100</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dir/b.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>200</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dir/c.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>300</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dir/sub/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`))
return
}
if r.Method == "PROPFIND" && (r.URL.Path == "/dir/sub" || r.URL.Path == "/dir/sub/") {
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/dir/sub/</D:href>
<D:propstat>
<D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/dir/sub/x.txt</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>42</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`))
return
}
// GETs should not be invoked during dry-run; the test asserts
// this by checking the output dir is empty afterwards.
if r.Method == "GET" {
t.Errorf("unexpected GET during dry-run: %s", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
parsedServerURL, _ := url.Parse(server.URL)
dirURL := &url.URL{
Scheme: "webdav",
Host: parsedServerURL.Host,
Path: "/dir/",
}
// runDryRun wraps a dry-run invocation and returns both the result
// and the captured stderr so the assertions stay readable.
runDryRun := func(t *testing.T, parallel int) (*core.DownloadResult, string) {
t.Helper()
tmpDir := t.TempDir()
p := NewProtocol()
req := &core.DownloadRequest{
URL: dirURL,
Output: tmpDir,
Recursive: true,
RecursiveParallel: parallel,
DryRun: true,
MaxDepth: 5,
Timeout: 5 * time.Second,
}
var result *core.DownloadResult
stderr := captureStderr(t, func() {
var err error
result, err = p.Download(context.Background(), req)
if err != nil {
t.Fatalf("dry-run failed: %v", err)
}
})
// Verify that no files or local directories were created.
entries, err := os.ReadDir(tmpDir)
if err != nil {
t.Fatalf("read tmpDir: %v", err)
}
if len(entries) != 0 {
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
t.Errorf("dry-run wrote files to disk: %v", names)
}
return result, stderr
}
t.Run("sequential dry-run lists all matched entries", func(t *testing.T) {
result, stderr := runDryRun(t, 0)
if result.ChunksCount != 4 {
t.Errorf("ChunksCount = %d, want 4 (3 top-level files + 1 subdir file)", result.ChunksCount)
}
// 100 + 200 + 300 + 42 = 642 bytes (sum of getcontentlength).
const wantBytes = int64(100 + 200 + 300 + 42)
if result.BytesDownloaded != wantBytes {
t.Errorf("BytesDownloaded = %d, want %d", result.BytesDownloaded, wantBytes)
}
// Stderr should mention every matched file and the subdir.
for _, want := range []string{
"would download: webdav://" + parsedServerURL.Host + "/dir/a.txt (100 bytes)",
"would download: webdav://" + parsedServerURL.Host + "/dir/b.txt (200 bytes)",
"would download: webdav://" + parsedServerURL.Host + "/dir/c.txt (300 bytes)",
"would recurse into: webdav://" + parsedServerURL.Host + "/dir/sub/",
"would download: webdav://" + parsedServerURL.Host + "/dir/sub/x.txt (42 bytes)",
} {
if !strings.Contains(stderr, want) {
t.Errorf("stderr missing %q\nfull output:\n%s", want, stderr)
}
}
})
t.Run("parallel dry-run lists all matched entries", func(t *testing.T) {
result, stderr := runDryRun(t, 4)
if result.ChunksCount != 4 {
t.Errorf("ChunksCount = %d, want 4", result.ChunksCount)
}
const wantBytes = int64(100 + 200 + 300 + 42)
if result.BytesDownloaded != wantBytes {
t.Errorf("BytesDownloaded = %d, want %d", result.BytesDownloaded, wantBytes)
}
// Order is non-deterministic with a worker pool, so just check
// the set of expected lines is present.
for _, want := range []string{
"/dir/a.txt (100 bytes)",
"/dir/b.txt (200 bytes)",
"/dir/c.txt (300 bytes)",
"/dir/sub/",
"/dir/sub/x.txt (42 bytes)",
} {
if !strings.Contains(stderr, want) {
t.Errorf("stderr missing %q\nfull output:\n%s", want, stderr)
}
}
})
t.Run("dry-run with reject filter only lists matched files", func(t *testing.T) {
tmpDir := t.TempDir()
p := NewProtocol()
req := &core.DownloadRequest{
URL: dirURL,
Output: tmpDir,
Recursive: true,
RecursiveParallel: 0,
DryRun: true,
MaxDepth: 5,
Timeout: 5 * time.Second,
RejectPatterns: []string{"b.txt", "c.txt"},
}
stderr := captureStderr(t, func() {
_, err := p.Download(context.Background(), req)
if err != nil {
t.Fatalf("dry-run with reject failed: %v", err)
}
})
if !strings.Contains(stderr, "/dir/a.txt (100 bytes)") {
t.Errorf("expected a.txt in dry-run list:\n%s", stderr)
}
if strings.Contains(stderr, "/dir/b.txt") {
t.Errorf("b.txt should have been rejected but appeared:\n%s", stderr)
}
if strings.Contains(stderr, "/dir/c.txt") {
t.Errorf("c.txt should have been rejected but appeared:\n%s", stderr)
}
// Filter applies per-directory: sub/ is unaffected so sub/x.txt
// is still listed (it doesn't match b.txt or c.txt).
if !strings.Contains(stderr, "/dir/sub/x.txt (42 bytes)") {
t.Errorf("expected sub/x.txt in dry-run list (filter doesn't apply across dirs):\n%s", stderr)
}
})
}
// TestWebDAVSingleGracefulShutdown covers the graceful-shutdown contract
// for single-file WebDAV downloads: when the context is cancelled
// mid-transfer, the worker must persist a `.goget.meta` sidecar with
// the partial byte count so `--resume` can pick up where the user left
// off. On a clean completion the sidecar is removed.
func TestWebDAVSingleGracefulShutdown(t *testing.T) {
if !protocol.GlobalRegistry.Supports("http") {
httpClient, err := httpproto.NewClient(httpproto.DefaultConfig())
if err != nil {
t.Fatalf("failed to create http client: %v", err)
}
_ = protocol.Register(httpClient)
}
// Mock server: responds to GET with a stream that blocks until the
// request context is cancelled. This lets the test cancel from the
// outside and observe how much was downloaded before the cancel.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PROPFIND" {
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(`<?xml version="1.0"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/file.bin</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>1048576</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`))
return
}
if r.Method == "GET" {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("ETag", `"etag-graceful"`)
w.Header().Set("Last-Modified", "Mon, 01 Jan 2026 00:00:00 GMT")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
// Write a few KB synchronously so the client has something
// to write to disk, then block until the request is
// cancelled. The body is large enough that the client
// will not read to EOF naturally.
chunk := bytes.Repeat([]byte("a"), 4096)
for i := 0; i < 4; i++ {
if _, err := w.Write(chunk); err != nil {
return
}
if flusher != nil {
flusher.Flush()
}
}
// Drain or block until the client cancels.
<-r.Context().Done()
}
}))
defer server.Close()
parsedURL, _ := url.Parse(server.URL)
fileURL := &url.URL{
Scheme: "webdav",
Host: parsedURL.Host,
Path: "/file.bin",
}
t.Run("ctx cancel saves resume metadata", func(t *testing.T) {
outDir := t.TempDir()
outPath := filepath.Join(outDir, "file.bin")
ctx, cancel := context.WithCancel(context.Background())
p := NewProtocol()
req := &core.DownloadRequest{
URL: fileURL,
Output: outPath,
Resume: true,
Timeout: 5 * time.Second,
Ctx: ctx,
Recursive: false,
}
done := make(chan struct{})
go func() {
_, _ = p.Download(ctx, req)
close(done)
}()
// Give the worker a moment to start writing chunks, then
// cancel. The mock holds the connection open until cancelled.
time.Sleep(150 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("download did not honour context cancellation within 3s")
}
// The mock wrote 4 × 4096 = 16384 bytes before blocking, so
// the resume metadata should record roughly that many bytes
// (give or take the transport buffer). The point of the test
// is the sidecar exists and is non-empty; exact byte counts
// are racy.
meta, err := output.LoadResumeMetadata(outPath)
if err != nil {
t.Fatalf("LoadResumeMetadata: %v", err)
}
if meta == nil {
t.Fatal("expected resume metadata after cancellation, got nil")
}
if meta.URL != req.URL.String() {
t.Errorf("metadata URL = %q, want %q", meta.URL, req.URL.String())
}
if meta.ETag != `"etag-graceful"` {
t.Errorf("metadata ETag = %q, want %q", meta.ETag, `"etag-graceful"`)
}
if meta.Downloaded <= 0 {
t.Errorf("metadata Downloaded = %d, want > 0", meta.Downloaded)
}
})
t.Run("successful download removes resume metadata", func(t *testing.T) {
// Pre-create a stale resume sidecar to verify the success
// path cleans it up. The mock for this subtest returns the
// full body immediately.
outDir := t.TempDir()
outPath := filepath.Join(outDir, "file.bin")
stale := output.NewResumeMetadata("stale://url", "stale-etag", "state-mod", 999, 999)
if err := stale.Save(outPath); err != nil {
t.Fatalf("pre-save: %v", err)
}
if _, err := output.LoadResumeMetadata(outPath); err != nil {
t.Fatalf("LoadResumeMetadata after pre-save: %v", err)
}
// Spin up a fresh mock that returns the whole file in one shot.
fullServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PROPFIND" {
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
w.WriteHeader(http.StatusMultiStatus)
w.Write([]byte(`<?xml version="1.0"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/file.bin</D:href>
<D:propstat>
<D:prop>
<D:resourcetype/>
<D:getcontentlength>16</D:getcontentlength>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>`))
return
}
if r.Method == "GET" {
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusOK)
w.Write([]byte("0123456789abcdef"))
}
}))
defer fullServer.Close()
fullParsed, _ := url.Parse(fullServer.URL)
fullURL := &url.URL{
Scheme: "webdav",
Host: fullParsed.Host,
Path: "/file.bin",
}
p := NewProtocol()
req := &core.DownloadRequest{
URL: fullURL,
Output: outPath,
Resume: true,
Timeout: 5 * time.Second,
Recursive: false,
}
if _, err := p.Download(context.Background(), req); err != nil {
t.Fatalf("download failed: %v", err)
}
// The pre-seeded stale sidecar should be gone after a
// successful download.
meta, err := output.LoadResumeMetadata(outPath)
if err != nil {
t.Fatalf("LoadResumeMetadata after success: %v", err)
}
if meta != nil {
t.Errorf("expected resume metadata to be cleared, got %+v", meta)
}
})
}