From 6f20790b17c91cd4aeab558a58b52eddde17dcf1 Mon Sep 17 00:00:00 2001 From: Ashish Goswami <116054951+Ashish6298@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:58:56 +0530 Subject: [PATCH] feat(protocol/http, mcpserver, download): add checksum verification, MCP speed tool, pause webhook, and CLI flags (#1542) --- cmd/gopeed/flags.go | 10 +- cmd/gopeed/main.go | 19 +- internal/protocol/http/fetcher.go | 119 +++++- .../http/fetcher_checksum_retry_test.go | 388 ++++++++++++++++++ .../protocol/http/fetcher_checksum_test.go | 304 ++++++++++++++ pkg/download/downloader.go | 1 + pkg/download/model.go | 3 + pkg/download/webhook.go | 1 + pkg/download/webhook_test.go | 43 ++ pkg/mcpserver/server.go | 74 +++- pkg/mcpserver/server_test.go | 1 + pkg/mcpserver/speed_test.go | 103 +++++ pkg/protocol/http/model.go | 74 ++++ pkg/protocol/http/model_test.go | 162 ++++++++ ui/flutter/lib/api/model/options.dart | 17 + ui/flutter/lib/api/model/options.g.dart | 14 + .../pages/create_task_window_page.dart | 79 +++- ui/flutter/lib/l10n/app_ar.arb | 5 + ui/flutter/lib/l10n/app_az.arb | 5 + ui/flutter/lib/l10n/app_ca.arb | 5 + ui/flutter/lib/l10n/app_de.arb | 5 + ui/flutter/lib/l10n/app_en.arb | 5 + ui/flutter/lib/l10n/app_es.arb | 5 + ui/flutter/lib/l10n/app_fa.arb | 5 + ui/flutter/lib/l10n/app_fr.arb | 5 + ui/flutter/lib/l10n/app_hu.arb | 5 + ui/flutter/lib/l10n/app_id.arb | 5 + ui/flutter/lib/l10n/app_it.arb | 5 + ui/flutter/lib/l10n/app_ja.arb | 5 + ui/flutter/lib/l10n/app_ko.arb | 5 + ui/flutter/lib/l10n/app_pl.arb | 5 + ui/flutter/lib/l10n/app_pt.arb | 5 + ui/flutter/lib/l10n/app_ru.arb | 5 + ui/flutter/lib/l10n/app_sk.arb | 5 + ui/flutter/lib/l10n/app_ta.arb | 5 + ui/flutter/lib/l10n/app_tr.arb | 5 + ui/flutter/lib/l10n/app_uk.arb | 5 + ui/flutter/lib/l10n/app_vi.arb | 5 + ui/flutter/lib/l10n/app_zh.arb | 5 + ui/flutter/lib/l10n/app_zh_TW.arb | 5 + .../create_task_window_page_test.dart | 350 ++++++++++++++++ 41 files changed, 1852 insertions(+), 25 deletions(-) create mode 100644 internal/protocol/http/fetcher_checksum_retry_test.go create mode 100644 internal/protocol/http/fetcher_checksum_test.go create mode 100644 pkg/mcpserver/speed_test.go create mode 100644 pkg/protocol/http/model_test.go diff --git a/cmd/gopeed/flags.go b/cmd/gopeed/flags.go index 7acaf9dd..d03d90eb 100644 --- a/cmd/gopeed/flags.go +++ b/cmd/gopeed/flags.go @@ -7,9 +7,11 @@ import ( ) type args struct { - url string - connections *int - dir *string + url string + connections *int + dir *string + checksum *string + checksumAlgo *string } func parse() *args { @@ -20,6 +22,8 @@ func parse() *args { var args args args.connections = flag.Int("C", 16, "Concurrent connections.") args.dir = flag.String("D", dir, "Store directory.") + args.checksum = flag.String("checksum", "", "Expected checksum hash (hex encoded).") + args.checksumAlgo = flag.String("checksum-algo", "md5", "Checksum algorithm: md5, sha1, or sha256.") flag.Parse() t := flag.Args() if len(t) > 0 { diff --git a/cmd/gopeed/main.go b/cmd/gopeed/main.go index cd4e0587..c6b96705 100644 --- a/cmd/gopeed/main.go +++ b/cmd/gopeed/main.go @@ -19,6 +19,18 @@ func main() { var wg sync.WaitGroup wg.Add(1) + var checksumOpt *http.ChecksumOption + if args.checksum != nil && *args.checksum != "" { + algo := "md5" + if args.checksumAlgo != nil && *args.checksumAlgo != "" { + algo = *args.checksumAlgo + } + checksumOpt = &http.ChecksumOption{ + Algorithm: algo, + Expected: *args.checksum, + } + } + _, err := download.Boot(). URL(args.url). Listener(func(event *download.Event) { @@ -43,8 +55,11 @@ func main() { } }). Create(&base.Options{ - Path: *args.dir, - Extra: http.OptsExtra{Connections: *args.connections}, + Path: *args.dir, + Extra: http.OptsExtra{ + Connections: *args.connections, + Checksum: checksumOpt, + }, }) if err != nil { panic(err) diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index a693a03f..98f7e0c4 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -2,8 +2,13 @@ package http import ( "context" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" "errors" "fmt" + "hash" "io" "net/http" "net/url" @@ -280,6 +285,12 @@ type Fetcher struct { primaryReadyOnce sync.Once primaryReadyCh chan struct{} + // checksumFailed marks that the last completed download failed + // checksum verification, so the next retry must force a full + // re-download instead of skipping connections that already + // reported Completed. + checksumFailed atomic.Bool + // Start pending mechanism startPending atomic.Bool resolvedCh chan struct{} // Signal when resolve completes @@ -664,6 +675,22 @@ func (f *Fetcher) cleanupPrefetchFile() { f.resolveFallback.Store(false) } +// prepareFullRedownload resets all fetcher connection and prefetch state +// after a checksum mismatch so the subsequent Start takes the fresh-start path. +func (f *Fetcher) prepareFullRedownload() { + f.connMu.Lock() + if f.meta.Res.Range { + f.connections = nil + f.resolveConn = nil + f.rangeReprobeEligible = false + f.rangeValidatorPinned = false + } + f.connMu.Unlock() + + f.cleanupPrefetchFile() + f.resolveDataPos.Store(0) +} + // Options may be replaced between Resolve and Start when creating a resolved task. func (f *Fetcher) initOptions() error { opts := f.meta.Opts @@ -681,6 +708,9 @@ func (f *Fetcher) initOptions() error { extra.Connections = 1 } } + if err := extra.Checksum.Validate(); err != nil { + return err + } return nil } @@ -735,11 +765,26 @@ func (f *Fetcher) doStart() error { // If retrying after error, reset connection states for retry if state == stateError { + fullRedownload := f.checksumFailed.Swap(false) + if fullRedownload { + // Checksum mismatch: the file content itself is wrong even + // though every connection reported Completed. Truncate the + // corrupt file so stale bytes cannot linger if the retry is + // interrupted before every connection re-writes its chunk. + if err := os.Truncate(f.meta.SingleFilepath(), 0); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to reset file after checksum mismatch: %w", err) + } + f.prepareFullRedownload() + } f.connMu.Lock() for _, conn := range f.connections { - // Reset connections that can be retried - if !conn.Completed && conn.State != connCompleted { - if !f.hasSequentialPrefixLocked(conn) { + // On a normal error, only reset connections that didn't + // finish. On a checksum mismatch, reset EVERY connection — + // "Completed" only means the download loop finished, not + // that the bytes were verified correct. + shouldReset := fullRedownload || (!conn.Completed && conn.State != connCompleted) + if shouldReset { + if fullRedownload || !f.hasSequentialPrefixLocked(conn) { f.resetConnectionForRestart(conn) } conn.State = connNotStarted @@ -970,8 +1015,14 @@ func (f *Fetcher) expandConnections() { } f.fileMu.Unlock() - f.setState(stateDone) - f.doneCh <- nil + var err error + if chkErr := f.verifyChecksum(); chkErr != nil { + err = chkErr + f.setState(stateError) + } else { + f.setState(stateDone) + } + f.doneCh <- err return } @@ -2067,7 +2118,7 @@ func (f *Fetcher) resetConnectionForRestart(conn *connection) { // so pause/retry must restart instead of continuing from the old offset. if conn.Chunk == nil { conn.Chunk = newChunk(0, 0) - } else { + } else if !f.meta.Res.Range { conn.Chunk.Begin = 0 conn.Chunk.setEnd(0) conn.Chunk.Downloaded = 0 @@ -2238,10 +2289,15 @@ func (f *Fetcher) onDownloadComplete() { } f.fileMu.Unlock() - if finalErr != nil { - f.setState(stateError) + if finalErr == nil { + if chkErr := f.verifyChecksum(); chkErr != nil { + finalErr = chkErr + f.setState(stateError) + } else { + f.setState(stateDone) + } } else { - f.setState(stateDone) + f.setState(stateError) } select { @@ -2250,6 +2306,51 @@ func (f *Fetcher) onDownloadComplete() { } } +func (f *Fetcher) verifyChecksum() error { + if f.meta == nil || f.meta.Opts == nil || f.meta.Opts.Extra == nil { + return nil + } + extra, ok := f.meta.Opts.Extra.(*fhttp.OptsExtra) + if !ok || extra.Checksum == nil { + return nil + } + chk := extra.Checksum + if chk.Algorithm == "" && chk.Expected == "" { + return nil + } + var h hash.Hash + switch strings.ToLower(strings.TrimSpace(chk.Algorithm)) { + case "md5": + h = md5.New() + case "sha1", "sha-1": + h = sha1.New() + case "sha256", "sha-256": + h = sha256.New() + default: + return fmt.Errorf("unsupported checksum algorithm: %s", chk.Algorithm) + } + + filePath := f.meta.SingleFilepath() + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("open file for checksum verification failed: %w", err) + } + defer file.Close() + + buf := make([]byte, 64*1024) + if _, err := io.CopyBuffer(h, file, buf); err != nil { + return fmt.Errorf("calculate checksum failed: %w", err) + } + + actual := hex.EncodeToString(h.Sum(nil)) + expected := strings.ToLower(strings.TrimSpace(chk.Expected)) + if !strings.EqualFold(actual, expected) { + f.checksumFailed.Store(true) + return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual) + } + return nil +} + func (f *Fetcher) checkCompletion() bool { // Check if all data has been downloaded f.connMu.Lock() diff --git a/internal/protocol/http/fetcher_checksum_retry_test.go b/internal/protocol/http/fetcher_checksum_retry_test.go new file mode 100644 index 00000000..98439fed --- /dev/null +++ b/internal/protocol/http/fetcher_checksum_retry_test.go @@ -0,0 +1,388 @@ +package http + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + gohttp "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/protocol/http" +) + +// serveRangeContent serves byte range requests or full responses from data. +func serveRangeContent(w gohttp.ResponseWriter, r *gohttp.Request, data []byte) { + w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes) + rangeHdr := r.Header.Get(base.HttpHeaderRange) + total := int64(len(data)) + if rangeHdr == "" { + w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", total)) + w.WriteHeader(gohttp.StatusOK) + _, _ = w.Write(data) + return + } + + // Parse bytes=start-end or bytes=start- + if !strings.HasPrefix(rangeHdr, "bytes=") { + w.WriteHeader(gohttp.StatusBadRequest) + return + } + spec := strings.TrimPrefix(rangeHdr, "bytes=") + parts := strings.Split(spec, "-") + start, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || start < 0 || start >= total { + w.WriteHeader(gohttp.StatusRequestedRangeNotSatisfiable) + return + } + end := total - 1 + if len(parts) > 1 && parts[1] != "" { + if parsedEnd, err := strconv.ParseInt(parts[1], 10, 64); err == nil && parsedEnd >= start && parsedEnd < total { + end = parsedEnd + } + } + + contentLength := end - start + 1 + w.Header().Set(base.HttpHeaderContentRange, fmt.Sprintf("bytes %d-%d/%d", start, end, total)) + w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", contentLength)) + w.WriteHeader(gohttp.StatusPartialContent) + _, _ = w.Write(data[start : end+1]) +} + +func TestFetcher_ChecksumMismatch_Retry(t *testing.T) { + corruptContent := []byte("Corrupt Initial Download Data 33B") + correctContent := []byte("Correct Retried Download Data 33B") + + correctSha256Hasher := sha256.New() + correctSha256Hasher.Write(correctContent) + correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil)) + + var phase atomic.Int32 // 0: serve corruptContent, 1: serve correctContent + server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + var served []byte + if phase.Load() == 0 { + served = corruptContent + } else { + served = correctContent + } + serveRangeContent(w, r, served) + })) + defer server.Close() + + downloadDir := t.TempDir() + fileName := "test_checksum_retry.txt" + + f := buildFetcher() + defer f.Close() + + opts := &base.Options{ + Path: downloadDir, + Name: fileName, + Extra: &http.OptsExtra{ + Connections: 1, + Checksum: &http.ChecksumOption{ + Algorithm: "sha256", + Expected: correctSha256, + }, + }, + } + + // 1. Initial attempt -> will download corruptContent, failing checksum verification + if err := f.Resolve(&base.Request{URL: server.URL + "/retry_test.txt"}, opts); err != nil { + t.Fatalf("Resolve error: %v", err) + } + if err := f.Start(); err != nil { + t.Fatalf("Start error: %v", err) + } + err := f.Wait() + if err == nil { + t.Fatal("expected checksum mismatch error on initial attempt, got nil") + } + if !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("expected 'checksum mismatch' in error, got %v", err) + } + if f.getState() != stateError { + t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState()) + } + + // Flip phase to 1 after observing checksum mismatch + phase.Store(1) + + // 2. Retry download -> should trigger forced full reset, truncate file, and re-download correctContent + if err := f.Start(); err != nil { + t.Fatalf("Start error on retry: %v", err) + } + if err := f.Wait(); err != nil { + t.Fatalf("Wait error on retry (expected success): %v", err) + } + if f.getState() != stateDone { + t.Fatalf("expected stateDone after successful retry, got %v", f.getState()) + } + + // 3. Verify on-disk file content matches the re-downloaded bytes + filePath := f.meta.SingleFilepath() + fileBytes, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("failed to read completed file: %v", err) + } + if string(fileBytes) != string(correctContent) { + t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent)) + } + + // 4. Compute hash independently and verify + actualHasher := sha256.New() + actualHasher.Write(fileBytes) + actualHash := hex.EncodeToString(actualHasher.Sum(nil)) + if actualHash != correctSha256 { + t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256) + } +} + +func TestFetcher_ChecksumMismatch_Retry_PartialPrefetch(t *testing.T) { + corruptContent := []byte("Corrupt Initial Download Data 33B") + correctContent := []byte("Correct Retried Download Data 33B") + + correctSha256Hasher := sha256.New() + correctSha256Hasher.Write(correctContent) + correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil)) + + var phase atomic.Int32 // 0: corrupt, 1: correct + var isFirstRequest atomic.Bool + isFirstRequest.Store(true) + + releasePrefetch := make(chan struct{}) + var closeReleaseOnce sync.Once + + var mu sync.Mutex + var phase1Ranges []string + + server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + curPhase := phase.Load() + if curPhase == 1 { + mu.Lock() + phase1Ranges = append(phase1Ranges, r.Header.Get(base.HttpHeaderRange)) + mu.Unlock() + serveRangeContent(w, r, correctContent) + return + } + + // Phase 0: First request is Resolve GET without Range header. + // Write exactly 7 bytes and flush, then block until release channel closes or context is done. + if isFirstRequest.CompareAndSwap(true, false) { + w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes) + w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(corruptContent))) + w.WriteHeader(gohttp.StatusOK) + _, _ = w.Write(corruptContent[:7]) + if flusher, ok := w.(gohttp.Flusher); ok { + flusher.Flush() + } + select { + case <-releasePrefetch: + case <-r.Context().Done(): + } + return + } + + // Subsequent requests in Phase 0 (such as Range request from Start) + serveRangeContent(w, r, corruptContent) + })) + defer server.Close() + defer closeReleaseOnce.Do(func() { close(releasePrefetch) }) + + downloadDir := t.TempDir() + fileName := "test_checksum_prefetch_retry.txt" + + f := buildFetcher() + defer f.Close() + + opts := &base.Options{ + Path: downloadDir, + Name: fileName, + Extra: &http.OptsExtra{ + Connections: 1, + Checksum: &http.ChecksumOption{ + Algorithm: "sha256", + Expected: correctSha256, + }, + }, + } + + // 1. Resolve phase + if err := f.Resolve(&base.Request{URL: server.URL + "/prefetch_retry.txt"}, opts); err != nil { + t.Fatalf("Resolve error: %v", err) + } + + // Wait until prefetch has received exactly 7 bytes + for i := 0; i < 200; i++ { + if f.prefetchSize.Load() >= 7 { + break + } + time.Sleep(10 * time.Millisecond) + } + if f.prefetchSize.Load() < 7 { + t.Fatalf("expected at least 7 prefetched bytes, got %d", f.prefetchSize.Load()) + } + + // Release prefetch handler right before Start + closeReleaseOnce.Do(func() { close(releasePrefetch) }) + + // 2. Start initial download attempt (will complete with corruptContent and fail checksum) + if err := f.Start(); err != nil { + t.Fatalf("Start error: %v", err) + } + err := f.Wait() + if err == nil { + t.Fatal("expected checksum mismatch error on initial attempt, got nil") + } + if !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("expected 'checksum mismatch' in error, got %v", err) + } + if f.getState() != stateError { + t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState()) + } + + // 3. Flip to phase 1 (correctContent) and retry + phase.Store(1) + + if err := f.Start(); err != nil { + t.Fatalf("Start error on retry: %v", err) + } + if err := f.Wait(); err != nil { + t.Fatalf("Wait error on retry (expected success): %v", err) + } + if f.getState() != stateDone { + t.Fatalf("expected stateDone after successful retry, got %v", f.getState()) + } + + // Verify that in Phase 1 at least one Range request started at byte 0 + mu.Lock() + rangesSeen := append([]string(nil), phase1Ranges...) + mu.Unlock() + + hasByteZero := false + for _, r := range rangesSeen { + if strings.HasPrefix(r, "bytes=0-") { + hasByteZero = true + break + } + } + if !hasByteZero { + t.Fatalf("expected at least one Phase 1 Range request starting at byte 0, saw: %v", rangesSeen) + } + + // Verify on-disk content matches correctContent exactly + filePath := f.meta.SingleFilepath() + fileBytes, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("failed to read completed file: %v", err) + } + if string(fileBytes) != string(correctContent) { + t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent)) + } + + // Compute hash independently and verify + actualHasher := sha256.New() + actualHasher.Write(fileBytes) + actualHash := hex.EncodeToString(actualHasher.Sum(nil)) + if actualHash != correctSha256 { + t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256) + } +} + +func TestFetcher_ChecksumMismatch_Retry_NonRange(t *testing.T) { + corruptContent := []byte("Corrupt Non-Range Initial Data 33B") + correctContent := []byte("Correct Non-Range Retried Data 33B") + + correctSha256Hasher := sha256.New() + correctSha256Hasher.Write(correctContent) + correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil)) + + var phase atomic.Int32 // 0: corrupt, 1: correct + server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + // Server does not support Range (always returns full content 200 OK) + var served []byte + if phase.Load() == 0 { + served = corruptContent + } else { + served = correctContent + } + w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(served))) + w.WriteHeader(gohttp.StatusOK) + _, _ = w.Write(served) + })) + defer server.Close() + + downloadDir := t.TempDir() + fileName := "test_checksum_nonrange_retry.txt" + + f := buildFetcher() + defer f.Close() + + opts := &base.Options{ + Path: downloadDir, + Name: fileName, + Extra: &http.OptsExtra{ + Connections: 1, + Checksum: &http.ChecksumOption{ + Algorithm: "sha256", + Expected: correctSha256, + }, + }, + } + + // 1. Resolve and start initial attempt + if err := f.Resolve(&base.Request{URL: server.URL + "/nonrange_retry.txt"}, opts); err != nil { + t.Fatalf("Resolve error: %v", err) + } + if err := f.Start(); err != nil { + t.Fatalf("Start error: %v", err) + } + err := f.Wait() + if err == nil { + t.Fatal("expected checksum mismatch error on initial non-range attempt, got nil") + } + if !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("expected 'checksum mismatch' in error, got %v", err) + } + if f.getState() != stateError { + t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState()) + } + + // 2. Switch phase and retry + phase.Store(1) + + if err := f.Start(); err != nil { + t.Fatalf("Start error on non-range retry: %v", err) + } + if err := f.Wait(); err != nil { + t.Fatalf("Wait error on non-range retry (expected success): %v", err) + } + if f.getState() != stateDone { + t.Fatalf("expected stateDone after successful non-range retry, got %v", f.getState()) + } + + // 3. Verify on-disk file content + filePath := f.meta.SingleFilepath() + fileBytes, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("failed to read completed file: %v", err) + } + if string(fileBytes) != string(correctContent) { + t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent)) + } + + // 4. Compute hash independently and verify + actualHasher := sha256.New() + actualHasher.Write(fileBytes) + actualHash := hex.EncodeToString(actualHasher.Sum(nil)) + if actualHash != correctSha256 { + t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256) + } +} diff --git a/internal/protocol/http/fetcher_checksum_test.go b/internal/protocol/http/fetcher_checksum_test.go new file mode 100644 index 00000000..8298f4eb --- /dev/null +++ b/internal/protocol/http/fetcher_checksum_test.go @@ -0,0 +1,304 @@ +package http + +import ( + "crypto/md5" + "crypto/sha256" + "encoding/hex" + "fmt" + gohttp "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/GopeedLab/gopeed/internal/test" + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/protocol/http" +) + +func TestFetcher_ChecksumVerification(t *testing.T) { + content := []byte("Hello, Gopeed Checksum Verification Test Data!") + md5Hasher := md5.New() + md5Hasher.Write(content) + correctMd5 := hex.EncodeToString(md5Hasher.Sum(nil)) + + sha256Hasher := sha256.New() + sha256Hasher.Write(content) + correctSha256 := hex.EncodeToString(sha256Hasher.Sum(nil)) + + wrongHash := "0123456789abcdef0123456789abcdef" + + server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(content))) + w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes) + w.WriteHeader(gohttp.StatusOK) + _, _ = w.Write(content) + })) + defer server.Close() + + t.Run("Valid MD5", func(t *testing.T) { + f := buildFetcher() + defer f.Close() + + opts := &base.Options{ + Path: t.TempDir(), + Name: "test_valid_md5.txt", + Extra: &http.OptsExtra{ + Connections: 1, + Checksum: &http.ChecksumOption{ + Algorithm: "md5", + Expected: correctMd5, + }, + }, + } + + if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil { + t.Fatalf("Resolve error: %v", err) + } + if err := f.Start(); err != nil { + t.Fatalf("Start error: %v", err) + } + if err := f.Wait(); err != nil { + t.Fatalf("Wait error (expected success): %v", err) + } + }) + + t.Run("Valid SHA256", func(t *testing.T) { + f := buildFetcher() + defer f.Close() + + opts := &base.Options{ + Path: t.TempDir(), + Name: "test_valid_sha256.txt", + Extra: &http.OptsExtra{ + Connections: 1, + Checksum: &http.ChecksumOption{ + Algorithm: "sha256", + Expected: correctSha256, + }, + }, + } + + if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil { + t.Fatalf("Resolve error: %v", err) + } + if err := f.Start(); err != nil { + t.Fatalf("Start error: %v", err) + } + if err := f.Wait(); err != nil { + t.Fatalf("Wait error (expected success): %v", err) + } + }) + + t.Run("Wrong MD5 mismatch", func(t *testing.T) { + f := buildFetcher() + defer f.Close() + + opts := &base.Options{ + Path: t.TempDir(), + Name: "test_wrong_md5.txt", + Extra: &http.OptsExtra{ + Connections: 1, + Checksum: &http.ChecksumOption{ + Algorithm: "md5", + Expected: wrongHash, + }, + }, + } + + if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil { + t.Fatalf("Resolve error: %v", err) + } + if err := f.Start(); err != nil { + t.Fatalf("Start error: %v", err) + } + err := f.Wait() + if err == nil { + t.Fatal("Wait expected checksum mismatch error, got nil") + } + if !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("expected 'checksum mismatch' in error, got %v", err) + } + }) + + t.Run("Unsupported Algorithm", func(t *testing.T) { + f := buildFetcher() + defer f.Close() + + opts := &base.Options{ + Path: t.TempDir(), + Name: "test_unsupported.txt", + Extra: &http.OptsExtra{ + Connections: 1, + Checksum: &http.ChecksumOption{ + Algorithm: "invalid-algo", + Expected: wrongHash, + }, + }, + } + + err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts) + if err == nil { + t.Fatal("Resolve expected error for unsupported algorithm, got nil") + } + if !strings.Contains(err.Error(), "unsupported checksum algorithm") { + t.Fatalf("expected 'unsupported checksum algorithm' in error, got %v", err) + } + if f.checksumFailed.Load() { + t.Fatal("checksumFailed flag should NOT be set on unsupported algorithm error") + } + }) + + t.Run("Case-Insensitive Hash Comparison", func(t *testing.T) { + f := buildFetcher() + defer f.Close() + + opts := &base.Options{ + Path: t.TempDir(), + Name: "test_case_insensitive.txt", + Extra: &http.OptsExtra{ + Connections: 1, + Checksum: &http.ChecksumOption{ + Algorithm: "sha256", + Expected: strings.ToUpper(correctSha256), + }, + }, + } + + if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil { + t.Fatalf("Resolve error: %v", err) + } + if err := f.Start(); err != nil { + t.Fatalf("Start error: %v", err) + } + if err := f.Wait(); err != nil { + t.Fatalf("Wait error (expected success with upper case hash): %v", err) + } + }) + + t.Run("Empty Expected Checksum (Skipped)", func(t *testing.T) { + f := buildFetcher() + defer f.Close() + + opts := &base.Options{ + Path: t.TempDir(), + Name: "test_empty_expected.txt", + Extra: &http.OptsExtra{ + Connections: 1, + Checksum: &http.ChecksumOption{ + Algorithm: "", + Expected: "", + }, + }, + } + + if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil { + t.Fatalf("Resolve error: %v", err) + } + if err := f.Start(); err != nil { + t.Fatalf("Start error: %v", err) + } + if err := f.Wait(); err != nil { + t.Fatalf("Wait error (expected skip and success): %v", err) + } + }) + + t.Run("Large File Multi-Chunk Streaming (>64KB)", func(t *testing.T) { + largeData := make([]byte, 128*1024) + for i := range largeData { + largeData[i] = byte(i % 256) + } + largeSha256Hasher := sha256.New() + largeSha256Hasher.Write(largeData) + largeSha256 := hex.EncodeToString(largeSha256Hasher.Sum(nil)) + + largeServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(largeData))) + w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes) + w.WriteHeader(gohttp.StatusOK) + _, _ = w.Write(largeData) + })) + defer largeServer.Close() + + f := buildFetcher() + defer f.Close() + + opts := &base.Options{ + Path: t.TempDir(), + Name: "test_large_file.bin", + Extra: &http.OptsExtra{ + Connections: 1, + Checksum: &http.ChecksumOption{ + Algorithm: "sha256", + Expected: largeSha256, + }, + }, + } + + if err := f.Resolve(&base.Request{URL: largeServer.URL + "/large.bin"}, opts); err != nil { + t.Fatalf("Resolve error: %v", err) + } + if err := f.Start(); err != nil { + t.Fatalf("Start error: %v", err) + } + if err := f.Wait(); err != nil { + t.Fatalf("Wait error (expected large file checksum success): %v", err) + } + }) + + t.Run("No Checksum (Opt-in backward compatibility)", func(t *testing.T) { + f := buildFetcher() + defer f.Close() + + opts := &base.Options{ + Path: t.TempDir(), + Name: test.DownloadName, + Extra: &http.OptsExtra{ + Connections: 1, + }, + } + + if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil { + t.Fatalf("Resolve error: %v", err) + } + if err := f.Start(); err != nil { + t.Fatalf("Start error: %v", err) + } + if err := f.Wait(); err != nil { + t.Fatalf("Wait error: %v", err) + } + }) + + t.Run("Invalid Checksum In Resolve Makes Zero Requests", func(t *testing.T) { + var reqReceived atomic.Int32 + customServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + reqReceived.Add(1) + w.WriteHeader(gohttp.StatusOK) + _, _ = w.Write([]byte("some data")) + })) + defer customServer.Close() + + f := buildFetcher() + defer f.Close() + + opts := &base.Options{ + Path: t.TempDir(), + Name: "test_invalid_checksum.txt", + Extra: &http.OptsExtra{ + Connections: 1, + Checksum: &http.ChecksumOption{ + Algorithm: "sha256", + Expected: "invalid-hex-chars-not-valid-hex-00000000000000000000000000000000", + }, + }, + } + + err := f.Resolve(&base.Request{URL: customServer.URL + "/test.txt"}, opts) + if err == nil { + t.Fatal("Resolve expected error for invalid checksum hash, got nil") + } + if reqReceived.Load() != 0 { + t.Fatalf("expected 0 requests to server on invalid checksum in Resolve, got %d", reqReceived.Load()) + } + }) +} diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 4d60c7ee..89323144 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -1912,6 +1912,7 @@ func (d *Downloader) doPause(task *Task) (err error) { } if handled { d.emit(EventKeyPause, task) + d.triggerWebhooks(WebhookEventDownloadPause, task, nil) } return nil } diff --git a/pkg/download/model.go b/pkg/download/model.go index bf479a0b..85fa4827 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -23,6 +23,9 @@ type ResolveResult struct { Res *base.Resource `json:"res"` } +// Task represents a download unit. +// Options flow path: +// REST Request (model.CreateTask/ResolveTask) -> pkg/download Downloader -> Task.Meta.Opts (base.Options) -> Fetcher.Resolve/Start (e.g. internal/protocol/http/fetcher.go) type Task struct { ID string `json:"id"` Protocol string `json:"protocol"` diff --git a/pkg/download/webhook.go b/pkg/download/webhook.go index d223d213..d0facb05 100644 --- a/pkg/download/webhook.go +++ b/pkg/download/webhook.go @@ -21,6 +21,7 @@ type WebhookEvent string const ( WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE" WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR" + WebhookEventDownloadPause WebhookEvent = "DOWNLOAD_PAUSE" ) // WebhookData is the data sent to webhook URLs diff --git a/pkg/download/webhook_test.go b/pkg/download/webhook_test.go index b2b28093..f87f678f 100644 --- a/pkg/download/webhook_test.go +++ b/pkg/download/webhook_test.go @@ -814,6 +814,49 @@ func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) { }) } +func TestWebhook_TriggerOnPause(t *testing.T) { + receivedData := make(chan *WebhookData, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var data WebhookData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + t.Errorf("Failed to decode data: %v", err) + return + } + receivedData <- &data + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + setupWebhookTest(t, func(downloader *Downloader) { + cfg, _ := downloader.GetConfig() + cfg.Webhook = &base.WebhookConfig{ + Enable: true, + URLs: []string{server.URL}, + } + downloader.PutConfig(cfg) + + task := NewTask() + task.Protocol = "http" + task.Meta = &mockFetcherMeta + + downloader.triggerWebhooks(WebhookEventDownloadPause, task, nil) + + select { + case data := <-receivedData: + if data.Event != WebhookEventDownloadPause { + t.Errorf("Expected event 'DOWNLOAD_PAUSE', got '%s'", data.Event) + } + if data.Payload == nil || data.Payload.Task == nil { + t.Error("Expected payload.task to be present") + } else if data.Payload.Task.ID != task.ID { + t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID) + } + case <-time.After(2 * time.Second): + t.Error("Timeout waiting for webhook") + } + }) +} + func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) { defaultDownloader.Setup() defaultDownloader.cfg.StorageDir = ".test_storage" diff --git a/pkg/mcpserver/server.go b/pkg/mcpserver/server.go index 1c6a954e..10c6ec53 100644 --- a/pkg/mcpserver/server.go +++ b/pkg/mcpserver/server.go @@ -12,6 +12,7 @@ import ( "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/download" + phttp "github.com/GopeedLab/gopeed/pkg/protocol/http" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -65,12 +66,13 @@ type btRequestExtra struct { } type httpTaskExtra struct { - Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"` - AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"` - DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"` - AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"` - ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"` - DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"` + Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"` + AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"` + DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"` + AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"` + ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"` + DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"` + Checksum *checksumOption `json:"checksum,omitempty" jsonschema:"Optional checksum verification settings"` } // downloadRequest is the MCP-facing form of base.Request. The REST model uses @@ -129,6 +131,11 @@ func isBitTorrentRequest(rawURL string) bool { strings.HasPrefix(upperURL, "DATA:APPLICATION/X-BITTORRENT;BASE64,") } +type checksumOption struct { + Algorithm string `json:"algorithm" jsonschema:"Checksum algorithm: md5, sha1, or sha256"` + Expected string `json:"expected" jsonschema:"Expected hex-encoded checksum hash"` +} + // downloadOptions is the MCP-facing form of base.Options. Only HTTP currently // defines protocol-specific task options, so Extra has one concrete schema. type downloadOptions struct { @@ -186,12 +193,31 @@ func (o *downloadOptions) baseOptions() *base.Options { if o == nil { return nil } + var extra any + if o.Extra != nil { + var checksum *phttp.ChecksumOption + if o.Extra.Checksum != nil { + checksum = &phttp.ChecksumOption{ + Algorithm: o.Extra.Checksum.Algorithm, + Expected: o.Extra.Checksum.Expected, + } + } + extra = &phttp.OptsExtra{ + Connections: o.Extra.Connections, + AutoTorrent: o.Extra.AutoTorrent, + DeleteTorrentAfterDownload: o.Extra.DeleteTorrentAfterDownload, + AutoExtract: o.Extra.AutoExtract, + ArchivePassword: o.Extra.ArchivePassword, + DeleteAfterExtract: o.Extra.DeleteAfterExtract, + Checksum: checksum, + } + } return &base.Options{ Name: o.Name, Path: o.Path, AsDefaultPath: o.AsDefaultPath, SelectFiles: o.SelectFiles, - Extra: o.Extra, + Extra: extra, } } @@ -270,6 +296,13 @@ type getTaskStatsOutput struct { Stats any `json:"stats"` } +type getDownloadSpeedInput struct{} + +type getDownloadSpeedOutput struct { + TotalSpeed int64 `json:"totalSpeed"` + ActiveTaskCount int `json:"activeTaskCount"` +} + type taskActionOutput struct { ID string `json:"id"` Success bool `json:"success"` @@ -416,6 +449,33 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) { return nil, &getTaskStatsOutput{Stats: stats}, nil }) + mcp.AddTool(server, &mcp.Tool{ + Name: "get_download_speed", + Title: "Get download speed", + Description: "Returns the combined download speed across all currently active tasks and how many tasks are actively downloading. Use get_task_status instead if you need the speed, progress, or ETA of one specific task.", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld}, + }, func(_ context.Context, _ *mcp.CallToolRequest, _ *getDownloadSpeedInput) (*mcp.CallToolResult, *getDownloadSpeedOutput, error) { + allTasks := downloader.GetTasks() + var totalSpeed int64 + activeCount := 0 + + for _, task := range allTasks { + status, err := downloader.RuntimeStatus(task.ID) + if err != nil { + continue + } + if status.Status == base.DownloadStatusRunning { + activeCount++ + totalSpeed += status.Speed + } + } + + return nil, &getDownloadSpeedOutput{ + TotalSpeed: totalSpeed, + ActiveTaskCount: activeCount, + }, nil + }) + registerTaskAction(server, downloader, "pause_task", "Pause task", "Pause one Gopeed task.", &nondestructive, &closedWorld, downloader.Pause) registerTaskAction(server, downloader, "continue_task", "Continue task", "Continue a paused or failed Gopeed task.", &nondestructive, &openWorld, downloader.Continue) diff --git a/pkg/mcpserver/server_test.go b/pkg/mcpserver/server_test.go index 56d208e1..225d6ed8 100644 --- a/pkg/mcpserver/server_test.go +++ b/pkg/mcpserver/server_test.go @@ -64,6 +64,7 @@ func TestStreamableHTTPTools(t *testing.T) { "continue_task", "create_task", "delete_task", + "get_download_speed", "get_task", "get_task_stats", "get_task_status", diff --git a/pkg/mcpserver/speed_test.go b/pkg/mcpserver/speed_test.go new file mode 100644 index 00000000..28df1eed --- /dev/null +++ b/pkg/mcpserver/speed_test.go @@ -0,0 +1,103 @@ +package mcpserver + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/download" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestGetDownloadSpeedTool(t *testing.T) { + // Serve a test payload that takes a little bit of time or streams bytes + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "1048576") + w.WriteHeader(http.StatusOK) + chunk := make([]byte, 8192) + for i := 0; i < 128; i++ { + _, _ = w.Write(chunk) + time.Sleep(10 * time.Millisecond) + } + })) + defer testServer.Close() + + downloader := download.NewDownloader(&download.DownloaderConfig{ + Storage: download.NewMemStorage(), + StorageDir: t.TempDir(), + }) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer downloader.Close() + + httpServer := httptest.NewServer(NewHandler(downloader)) + defer httpServer.Close() + + client := mcp.NewClient(&mcp.Implementation{Name: "gopeed-test", Version: "1.0.0"}, nil) + session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatal(err) + } + defer session.Close() + + // 1. Zero tasks test (returns 0 totalSpeed and 0 activeTaskCount) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_download_speed", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatalf("get_download_speed returned error: %+v", result.Content) + } + structured, ok := result.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("structured content type = %T", result.StructuredContent) + } + if activeCount, ok := structured["activeTaskCount"].(float64); !ok || activeCount != 0 { + t.Fatalf("activeTaskCount = %v, want 0", structured["activeTaskCount"]) + } + if totalSpeed, ok := structured["totalSpeed"].(float64); !ok || totalSpeed != 0 { + t.Fatalf("totalSpeed = %v, want 0", structured["totalSpeed"]) + } + + // 2. Start a real download task + taskID, err := downloader.CreateDirect(&base.Request{ + URL: testServer.URL, + }, &base.Options{ + Path: t.TempDir(), + }) + if err != nil { + t.Fatal(err) + } + defer func() { + _ = downloader.Delete(&download.TaskFilter{IDs: []string{taskID}}, true) + }() + + // Query get_download_speed while downloading + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_download_speed", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatal(err) + } + if result.IsError { + t.Fatalf("get_download_speed returned error: %+v", result.Content) + } + structured, ok = result.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("structured content type = %T", result.StructuredContent) + } + if _, ok := structured["activeTaskCount"].(float64); !ok { + t.Fatalf("activeTaskCount missing or wrong type: %v", structured["activeTaskCount"]) + } + if _, ok := structured["totalSpeed"].(float64); !ok { + t.Fatalf("totalSpeed missing or wrong type: %v", structured["totalSpeed"]) + } +} diff --git a/pkg/protocol/http/model.go b/pkg/protocol/http/model.go index 9e96ffe2..1db4c18e 100644 --- a/pkg/protocol/http/model.go +++ b/pkg/protocol/http/model.go @@ -1,11 +1,83 @@ package http +import ( + "encoding/hex" + "fmt" + "strings" +) + type ReqExtra struct { Method string `json:"method"` Header map[string]string `json:"header"` Body string `json:"body"` } +// ChecksumOption specifies an expected checksum to verify against +// the completed download. Only supported for HTTP downloads. +type ChecksumOption struct { + Algorithm string `json:"algorithm"` // "md5" | "sha1" | "sha256" + Expected string `json:"expected"` // hex-encoded expected hash +} + +// IsEmpty returns true when chk is nil, or when both Algorithm and Expected +// are blank after trimming whitespace. +func (chk *ChecksumOption) IsEmpty() bool { + if chk == nil { + return true + } + return strings.TrimSpace(chk.Algorithm) == "" && strings.TrimSpace(chk.Expected) == "" +} + +// NormalizedAlgorithm returns the canonical lower-cased algorithm ("md5", "sha1", "sha256") +// and true if recognized, or ("", false) if unsupported. +func (chk *ChecksumOption) NormalizedAlgorithm() (string, bool) { + if chk == nil { + return "", false + } + switch strings.ToLower(strings.TrimSpace(chk.Algorithm)) { + case "md5": + return "md5", true + case "sha1", "sha-1": + return "sha1", true + case "sha256", "sha-256": + return "sha256", true + default: + return "", false + } +} + +// Validate checks whether the checksum option is valid. +// Empty options return nil (no verification). +func (chk *ChecksumOption) Validate() error { + if chk.IsEmpty() { + return nil + } + algo, ok := chk.NormalizedAlgorithm() + if !ok { + return fmt.Errorf("unsupported checksum algorithm: %s", chk.Algorithm) + } + expected := strings.TrimSpace(chk.Expected) + if expected == "" { + return fmt.Errorf("checksum expected hash is required when algorithm is set") + } + var expectedLen int + switch algo { + case "md5": + expectedLen = 32 + case "sha1": + expectedLen = 40 + case "sha256": + expectedLen = 64 + } + if len(expected) != expectedLen { + return fmt.Errorf("invalid checksum hash length for %s: expected %d characters, got %d", algo, expectedLen, len(expected)) + } + if _, err := hex.DecodeString(expected); err != nil { + return fmt.Errorf("invalid checksum hash for %s: expected valid hexadecimal string", algo) + } + return nil +} + type OptsExtra struct { Connections int `json:"connections"` // AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file @@ -21,6 +93,8 @@ type OptsExtra struct { ArchivePassword string `json:"archivePassword"` // DeleteAfterExtract when true, deletes the archive file after successful extraction DeleteAfterExtract bool `json:"deleteAfterExtract"` + // Checksum verifies the completed file against an expected hash after download + Checksum *ChecksumOption `json:"checksum,omitempty"` } // Stats for download diff --git a/pkg/protocol/http/model_test.go b/pkg/protocol/http/model_test.go new file mode 100644 index 00000000..f68d2066 --- /dev/null +++ b/pkg/protocol/http/model_test.go @@ -0,0 +1,162 @@ +package http + +import ( + "strings" + "testing" +) + +func TestOptsExtra_Checksum(t *testing.T) { + extra := &OptsExtra{ + Checksum: &ChecksumOption{ + Algorithm: "sha256", + Expected: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + } + if extra.Checksum.Algorithm != "sha256" || extra.Checksum.Expected != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" { + t.Fatalf("unexpected checksum: %+v", extra.Checksum) + } +} + +func TestChecksumOption_Validate(t *testing.T) { + validMd5 := "0123456789abcdef0123456789abcdef" + validSha1 := "0123456789abcdef0123456789abcdef01234567" + validSha256 := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + tests := []struct { + name string + option *ChecksumOption + wantErr bool + errSubstr string + }{ + { + name: "nil option", + option: nil, + wantErr: false, + }, + { + name: "empty option (empty strings)", + option: &ChecksumOption{Algorithm: "", Expected: ""}, + wantErr: false, + }, + { + name: "empty option (whitespace strings)", + option: &ChecksumOption{Algorithm: " ", Expected: " "}, + wantErr: false, + }, + { + name: "valid md5 lowercase", + option: &ChecksumOption{Algorithm: "md5", Expected: validMd5}, + wantErr: false, + }, + { + name: "valid md5 uppercase", + option: &ChecksumOption{Algorithm: "MD5", Expected: strings.ToUpper(validMd5)}, + wantErr: false, + }, + { + name: "valid sha1 lowercase", + option: &ChecksumOption{Algorithm: "sha1", Expected: validSha1}, + wantErr: false, + }, + { + name: "valid sha-1 alias", + option: &ChecksumOption{Algorithm: "SHA-1", Expected: strings.ToUpper(validSha1)}, + wantErr: false, + }, + { + name: "valid sha256 lowercase", + option: &ChecksumOption{Algorithm: "sha256", Expected: validSha256}, + wantErr: false, + }, + { + name: "valid sha-256 alias", + option: &ChecksumOption{Algorithm: "sha-256", Expected: validSha256}, + wantErr: false, + }, + { + name: "valid sha256 with whitespace around hash and algorithm", + option: &ChecksumOption{Algorithm: " sha256 ", Expected: " " + validSha256 + " "}, + wantErr: false, + }, + { + name: "unknown algorithm", + option: &ChecksumOption{Algorithm: "crc32", Expected: "12345678"}, + wantErr: true, + errSubstr: "unsupported checksum algorithm", + }, + { + name: "algorithm set but hash blank", + option: &ChecksumOption{Algorithm: "sha256", Expected: " "}, + wantErr: true, + errSubstr: "checksum expected hash is required", + }, + { + name: "sha256 with abc (short length)", + option: &ChecksumOption{Algorithm: "sha256", Expected: "abc"}, + wantErr: true, + errSubstr: "expected 64 characters, got 3", + }, + { + name: "sha256 with 64 x g (non-hex)", + option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("g", 64)}, + wantErr: true, + errSubstr: "expected valid hexadecimal string", + }, + { + name: "sha256 with 63 x a (off-by-one short)", + option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("a", 63)}, + wantErr: true, + errSubstr: "expected 64 characters, got 63", + }, + { + name: "sha256 with 65 x a (off-by-one long)", + option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("a", 65)}, + wantErr: true, + errSubstr: "expected 64 characters, got 65", + }, + { + name: "md5 with 31 chars", + option: &ChecksumOption{Algorithm: "md5", Expected: strings.Repeat("a", 31)}, + wantErr: true, + errSubstr: "expected 32 characters, got 31", + }, + { + name: "md5 with 33 chars", + option: &ChecksumOption{Algorithm: "md5", Expected: strings.Repeat("a", 33)}, + wantErr: true, + errSubstr: "expected 32 characters, got 33", + }, + { + name: "md5 with sha256-length hash (64 chars)", + option: &ChecksumOption{Algorithm: "md5", Expected: validSha256}, + wantErr: true, + errSubstr: "expected 32 characters, got 64", + }, + { + name: "sha1 with 39 chars", + option: &ChecksumOption{Algorithm: "sha1", Expected: strings.Repeat("a", 39)}, + wantErr: true, + errSubstr: "expected 40 characters, got 39", + }, + { + name: "sha1 with 41 chars", + option: &ChecksumOption{Algorithm: "sha1", Expected: strings.Repeat("a", 41)}, + wantErr: true, + errSubstr: "expected 40 characters, got 41", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.option.Validate() + if (err != nil) != tt.wantErr { + t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && tt.errSubstr != "" { + if err == nil || !strings.Contains(err.Error(), tt.errSubstr) { + t.Fatalf("Validate() error = %v, want substring %q", err, tt.errSubstr) + } + } + }) + } +} diff --git a/ui/flutter/lib/api/model/options.dart b/ui/flutter/lib/api/model/options.dart index 17aadd0d..35a50ac2 100644 --- a/ui/flutter/lib/api/model/options.dart +++ b/ui/flutter/lib/api/model/options.dart @@ -17,6 +17,21 @@ class Options { Map toJson() => _$OptionsToJson(this); } +@JsonSerializable() +class ChecksumOption { + String algorithm; + String expected; + + ChecksumOption({ + this.algorithm = '', + this.expected = '', + }); + + factory ChecksumOption.fromJson(Map json) => _$ChecksumOptionFromJson(json); + + Map toJson() => _$ChecksumOptionToJson(this); +} + @JsonSerializable() class OptsExtraHttp { int connections; @@ -25,6 +40,7 @@ class OptsExtraHttp { bool? autoExtract; String archivePassword; bool deleteAfterExtract; + ChecksumOption? checksum; OptsExtraHttp({ this.connections = 0, @@ -33,6 +49,7 @@ class OptsExtraHttp { this.autoExtract, this.archivePassword = '', this.deleteAfterExtract = false, + this.checksum, }); factory OptsExtraHttp.fromJson(Map json) => _$OptsExtraHttpFromJson(json); diff --git a/ui/flutter/lib/api/model/options.g.dart b/ui/flutter/lib/api/model/options.g.dart index 52b86453..557ea04f 100644 --- a/ui/flutter/lib/api/model/options.g.dart +++ b/ui/flutter/lib/api/model/options.g.dart @@ -22,6 +22,16 @@ Map _$OptionsToJson(Options instance) => { 'extra': ?instance.extra, }; +ChecksumOption _$ChecksumOptionFromJson(Map json) => ChecksumOption( + algorithm: json['algorithm'] as String? ?? '', + expected: json['expected'] as String? ?? '', +); + +Map _$ChecksumOptionToJson(ChecksumOption instance) => { + 'algorithm': instance.algorithm, + 'expected': instance.expected, +}; + OptsExtraHttp _$OptsExtraHttpFromJson(Map json) => OptsExtraHttp( connections: (json['connections'] as num?)?.toInt() ?? 0, autoTorrent: json['autoTorrent'] as bool?, @@ -29,6 +39,9 @@ OptsExtraHttp _$OptsExtraHttpFromJson(Map json) => OptsExtraHtt autoExtract: json['autoExtract'] as bool?, archivePassword: json['archivePassword'] as String? ?? '', deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false, + checksum: json['checksum'] == null + ? null + : ChecksumOption.fromJson(json['checksum'] as Map), ); Map _$OptsExtraHttpToJson(OptsExtraHttp instance) => { @@ -38,4 +51,5 @@ Map _$OptsExtraHttpToJson(OptsExtraHttp instance) => { final _httpHeadersController = AppHttpHeadersController(defaultNames: const ['User-Agent', 'Cookie', 'Referer']); final _trackersController = TextEditingController(); final _archivePasswordController = TextEditingController(); + final _checksumHashController = TextEditingController(); final _formScrollController = ScrollController(); String _httpMethod = 'GET'; @@ -74,6 +75,7 @@ class _CreateTaskWindowPageState extends ConsumerState { Map? _initialLabels; RequestProxyMode _proxyMode = RequestProxyMode.follow; String _proxyScheme = 'http'; + String _checksumAlgorithm = 'md5'; bool _skipVerifyCert = false; bool? _autoTorrent; bool? _deleteTorrentAfterDownload; @@ -134,6 +136,7 @@ class _CreateTaskWindowPageState extends ConsumerState { _httpHeadersController.dispose(); _trackersController.dispose(); _archivePasswordController.dispose(); + _checksumHashController.dispose(); _formScrollController.dispose(); super.dispose(); } @@ -184,6 +187,10 @@ class _CreateTaskWindowPageState extends ConsumerState { if (!_programmaticUrlChange && _fileDataUri.isNotEmpty) { _fileDataUri = ''; } + if (_inputUrls().length > 1) { + _checksumHashController.clear(); + _checksumAlgorithm = 'md5'; + } } _recognizeMagnetUri(urlText.trim()); } @@ -193,6 +200,8 @@ class _CreateTaskWindowPageState extends ConsumerState { final req = task.req; final opts = task.opts; setState(() { + _checksumAlgorithm = 'md5'; + _checksumHashController.clear(); if (req?.url.isNotEmpty == true) { _fileDataUri = ''; _renameController.clear(); @@ -243,6 +252,12 @@ class _CreateTaskWindowPageState extends ConsumerState { _autoExtract = extra.autoExtract; _archivePasswordController.text = extra.archivePassword; _deleteAfterExtract = extra.deleteAfterExtract; + if (extra.checksum != null) { + if (extra.checksum!.algorithm.isNotEmpty) { + _checksumAlgorithm = extra.checksum!.algorithm; + } + _checksumHashController.text = extra.checksum!.expected; + } } } }); @@ -668,6 +683,34 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ), ], + if (_inputUrls().length <= 1) ...[ + const SizedBox(height: 14), + AppFormPair( + direction: stacked ? Axis.vertical : Axis.horizontal, + firstLabel: context.l10n.checksumAlgorithm, + secondLabel: context.l10n.checksumHash, + firstFlex: 382, + secondFlex: 618, + first: AppChoiceSegmentedControl( + key: const ValueKey('create-task-checksum-algorithm'), + showIcons: false, + value: _checksumAlgorithm, + buttonKeyPrefix: 'create-task-checksum-algorithm', + alignment: WrapAlignment.start, + options: const [ + AppChoiceOption(value: 'md5', label: 'MD5', icon: Icons.tag), + AppChoiceOption(value: 'sha1', label: 'SHA-1', icon: Icons.tag), + AppChoiceOption(value: 'sha256', label: 'SHA-256', icon: Icons.tag), + ], + onChanged: (value) => setState(() => _checksumAlgorithm = value), + ), + second: _WindowTextField( + key: const ValueKey('create-task-checksum-expected'), + controller: _checksumHashController, + hintText: context.l10n.checksumExpectedHint, + ), + ), + ], ] else ...[ AppFormRow( direction: stacked ? Axis.vertical : Axis.horizontal, @@ -950,6 +993,25 @@ class _CreateTaskWindowPageState extends ConsumerState { } bool _validateAdvancedOptions() { + if (_protocolTab == 0 && _inputUrls().length <= 1) { + final checksumExpected = _checksumHashController.text.trim(); + if (checksumExpected.isNotEmpty) { + final expectedLen = switch (_checksumAlgorithm.toLowerCase()) { + 'md5' => 32, + 'sha1' || 'sha-1' => 40, + 'sha256' || 'sha-256' => 64, + _ => 0, + }; + if (expectedLen > 0 && checksumExpected.length != expectedLen) { + _showToast(context.l10n.checksumInvalidLength(_checksumAlgorithm.toUpperCase(), expectedLen.toString())); + return false; + } + if (!RegExp(r'^[0-9a-fA-F]+$').hasMatch(checksumExpected)) { + _showToast(context.l10n.checksumInvalidHex); + return false; + } + } + } if (_proxyMode != RequestProxyMode.custom) { return true; } @@ -1022,8 +1084,15 @@ class _CreateTaskWindowPageState extends ConsumerState { return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text); } - api_options.Options _buildOptions({String? name, List selectFiles = const []}) { + api_options.Options _buildOptions({String? name, List selectFiles = const [], bool omitChecksum = false}) { final connections = int.tryParse(_connectionsController.text.trim()) ?? 0; + final checksumExpected = _checksumHashController.text.trim(); + final checksum = (!omitChecksum && _inputUrls().length <= 1 && checksumExpected.isNotEmpty) + ? api_options.ChecksumOption( + algorithm: _checksumAlgorithm, + expected: checksumExpected, + ) + : null; return api_options.Options( name: name ?? _renameController.text.trim(), path: _directoryController.text.trim(), @@ -1036,6 +1105,7 @@ class _CreateTaskWindowPageState extends ConsumerState { autoExtract: _autoExtract, archivePassword: _archivePasswordController.text, deleteAfterExtract: _deleteAfterExtract, + checksum: checksum, ).toJson(), ); } @@ -1050,13 +1120,14 @@ class _CreateTaskWindowPageState extends ConsumerState { Future _submitResolved(Request request, ResolveResult result, List selectedIndexes) async { final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList()) ..sort(); + final isMultiFile = selected.length > 1; if (result.id.isNotEmpty) { await ref .read(gopeedServiceProvider) .createTask( CreateTask( rid: result.id, - opts: _buildOptions(selectFiles: selected), + opts: _buildOptions(selectFiles: selected, omitChecksum: isMultiFile), ), ); return; @@ -1070,11 +1141,11 @@ class _CreateTaskWindowPageState extends ConsumerState { opts: api_options.Options( name: file.name, path: path.join(_directoryController.text.trim(), result.res.name, file.path), - extra: _buildOptions().extra, + extra: _buildOptions(omitChecksum: isMultiFile).extra, ), ); }).toList(); - await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions())); + await ref.read(gopeedServiceProvider).createTaskBatch(CreateTaskBatch(reqs: reqs, opts: _buildOptions(omitChecksum: isMultiFile))); return; } diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index 1fcbf1ab..c4f1f1ed 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -164,6 +164,11 @@ "autoExtract": "فك الضغط تلقائياً", "archivePassword": "كلمة مرور الملف المضغوط", "archivePasswordHint": "اتركه فارغاً إذا لم يكن محمياً بكلمة مرور", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "حذف الملف المضغوط بعد فك الضغط", "extracting": "جاري فك الضغط", "extractDone": "تم فك الضغط بنجاح", diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index f1f09732..c8d875f9 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -171,6 +171,11 @@ "autoExtract": "Arxivləri Avtomatik Çıxar", "archivePassword": "Arxiv Parolu", "archivePasswordHint": "Parolla qorunmayıbsa boş buraxın", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Çıxarıldıqdan sonra arxivi sil", "extracting": "Çıxarılır", "extractDone": "Çıxarma tamamlandı", diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index 0bad8028..2d05abe1 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -164,6 +164,11 @@ "autoExtract": "Extreu els comprimits automàticament", "archivePassword": "Contrasenya del comprimit", "archivePasswordHint": "Deixeu-lo en blanc si no està protegit amb contrasenya", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Suprimeix el comprimit després de l'extracció", "extracting": "S'està extraient", "extractDone": "Extracció completada", diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index ac742216..57ec757d 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -164,6 +164,11 @@ "autoExtract": "Archive automatisch entpacken", "archivePassword": "Archiv-Passwort", "archivePasswordHint": "Leer lassen, wenn nicht passwortgeschützt", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Archiv nach Entpacken löschen", "extracting": "Wird entpackt", "extractDone": "Entpacken abgeschlossen", diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index 8eac5fdd..b8d6f5de 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -178,6 +178,11 @@ "autoExtract": "Extract Archives Automatically", "archivePassword": "Archive Password", "archivePasswordHint": "Leave empty if not password protected", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Delete Archives After Extraction", "extracting": "Extracting", "extractDone": "Extraction completed", diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index 9c6cd25e..b38c551c 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -164,6 +164,11 @@ "autoExtract": "Extraer archivos automáticamente", "archivePassword": "Contraseña del archivo", "archivePasswordHint": "Dejar vacío si no tiene contraseña", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Eliminar archivo después de extraer", "extracting": "Extrayendo", "extractDone": "Extracción completada", diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index 31db8a2c..bdd1463d 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -164,6 +164,11 @@ "autoExtract": "استخراج خودکار بایگانی‌ها", "archivePassword": "رمز عبور بایگانی", "archivePasswordHint": "اگر رمز ندارد خالی بگذارید", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "حذف آرشیو پس از استخراج", "extracting": "در حال استخراج", "extractDone": "استخراج کامل شد", diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index 6d609842..575a7509 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -164,6 +164,11 @@ "autoExtract": "Extraire automatiquement les archives", "archivePassword": "Mot de passe de l'archive", "archivePasswordHint": "Laisser vide si non protégé par mot de passe", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Supprimer l'archive après extraction", "extracting": "Extraction en cours", "extractDone": "Extraction terminée", diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index ca5382cc..34b49690 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -164,6 +164,11 @@ "autoExtract": "Archívumok automatikus kicsomagolása", "archivePassword": "Archívum jelszó", "archivePasswordHint": "Hagyja üresen, ha nincs jelszóval védve", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Archívum törlése kicsomagolás után", "extracting": "Kicsomagolás", "extractDone": "Kicsomagolás befejezve", diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 8c1fa043..765da637 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -164,6 +164,11 @@ "autoExtract": "Ekstrak Arsip Otomatis", "archivePassword": "Kata Sandi Arsip", "archivePasswordHint": "Kosongkan jika tidak dilindungi kata sandi", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Hapus arsip setelah ekstraksi", "extracting": "Mengekstrak", "extractDone": "Ekstraksi selesai", diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index cc655095..47447887 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -164,6 +164,11 @@ "autoExtract": "Estrai automaticamente gli archivi", "archivePassword": "Password archivio", "archivePasswordHint": "Lasciare vuoto se non protetto da password", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Elimina l'archivio dopo l'estrazione", "extracting": "Estrazione in corso", "extractDone": "Estrazione completata", diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index f51400c4..3338bcf5 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -164,6 +164,11 @@ "autoExtract": "アーカイブを自動展開", "archivePassword": "アーカイブパスワード", "archivePasswordHint": "パスワードなしの場合は空のままにしてください", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "展開後にアーカイブを削除", "extracting": "展開中", "extractDone": "展開完了", diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index fc5db70a..a4cc72de 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -164,6 +164,11 @@ "autoExtract": "압축 파일 자동 압축 해제", "archivePassword": "압축 파일 비밀번호", "archivePasswordHint": "비밀번호가 없으면 비워 두세요", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "압축 해제 후 압축 파일 삭제", "extracting": "압축 해제 중", "extractDone": "압축 해제 완료", diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index d5f513ee..451d8285 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -164,6 +164,11 @@ "autoExtract": "Automatycznie wyodrębnij archiwa", "archivePassword": "Hasło archiwum", "archivePasswordHint": "Pozostaw puste, jeśli nie ma hasła", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Usuń archiwum po rozpakowaniu", "extracting": "Rozpakowywanie", "extractDone": "Rozpakowywanie zakończone", diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index a307d075..8b67dea4 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -164,6 +164,11 @@ "autoExtract": "Extrair arquivos automaticamente", "archivePassword": "Senha do arquivo", "archivePasswordHint": "Deixar vazio se não estiver protegido por senha", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Excluir arquivo após extração", "extracting": "Extraindo", "extractDone": "Extração concluída", diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index 57035b2b..9bb79910 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -164,6 +164,11 @@ "autoExtract": "Автоматически распаковать архивы", "archivePassword": "Пароль архива", "archivePasswordHint": "Оставить пустым, если архив не защищён паролем", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Удалить архив после распаковки", "extracting": "Распаковка", "extractDone": "Распаковка завершена", diff --git a/ui/flutter/lib/l10n/app_sk.arb b/ui/flutter/lib/l10n/app_sk.arb index 1a7b5cae..0932539b 100644 --- a/ui/flutter/lib/l10n/app_sk.arb +++ b/ui/flutter/lib/l10n/app_sk.arb @@ -178,6 +178,11 @@ "autoExtract": "Automaticky rozbaliť archívy", "archivePassword": "Heslo archívu", "archivePasswordHint": "Nechajte prázdne, ak nie je chránený heslom", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Po rozbalení odstrániť archívy", "extracting": "Rozbaľovanie", "extractDone": "Rozbalenie dokončené", diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index 80eb3201..6de8c219 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -164,6 +164,11 @@ "autoExtract": "காப்பகங்களை தானாக பிரித்தெடு", "archivePassword": "காப்பக கடவுச்சொல்", "archivePasswordHint": "கடவுச்சொல் இல்லையென்றால் காலியாக விடவும்", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "பிரித்தெடுத்த பிறகு காப்பகத்தை நீக்கு", "extracting": "பிரித்தெடுக்கிறது", "extractDone": "பிரித்தெடுத்தல் முடிந்தது", diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index 2a0e4df5..f69ee45b 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -164,6 +164,11 @@ "autoExtract": "Arşivleri Otomatik Çıkar", "archivePassword": "Arşiv Şifresi", "archivePasswordHint": "Şifre korumalı değilse boş bırakın", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Çıkardıktan sonra arşivi sil", "extracting": "Çıkarılıyor", "extractDone": "Çıkarma tamamlandı", diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index eac67523..a0605ae7 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -164,6 +164,11 @@ "autoExtract": "Автоматично розпаковувати архіви", "archivePassword": "Пароль архіву", "archivePasswordHint": "Залишити порожнім, якщо архів не захищений паролем", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Видалити архів після розпакування", "extracting": "Розпакування", "extractDone": "Розпакування завершено", diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index 81224d99..34a176ec 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -164,6 +164,11 @@ "autoExtract": "Tự động giải nén tập tin nén", "archivePassword": "Mật khẩu tập tin nén", "archivePasswordHint": "Để trống nếu không có mật khẩu", + "checksumAlgorithm": "Checksum Algorithm", + "checksumHash": "Checksum Hash", + "checksumExpectedHint": "Expected hash (optional)", + "checksumInvalidLength": "Invalid checksum hash length for {algorithm}: expected {length} characters", + "checksumInvalidHex": "Invalid checksum hash: expected hexadecimal characters (0-9, a-f)", "deleteAfterExtract": "Xóa tập tin nén sau khi giải nén", "extracting": "Đang giải nén", "extractDone": "Giải nén hoàn tất", diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index 4d586e2f..d3aaba61 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -164,6 +164,11 @@ "autoExtract": "自动解压压缩包", "archivePassword": "压缩包密码", "archivePasswordHint": "如无密码请留空", + "checksumAlgorithm": "校验和算法", + "checksumHash": "校验和", + "checksumExpectedHint": "预期哈希值(可选)", + "checksumInvalidLength": "{algorithm} 校验和哈希长度无效:应为 {length} 个字符", + "checksumInvalidHex": "校验和哈希无效:应为十六进制字符(0-9,a-f)", "deleteAfterExtract": "解压后删除压缩包", "extracting": "正在解压", "extractDone": "解压完成", diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index b903a205..4915b44d 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -164,6 +164,11 @@ "autoExtract": "自動解壓縮壓縮包", "archivePassword": "壓縮包密碼", "archivePasswordHint": "如無密碼請留空", + "checksumAlgorithm": "校驗和演算法", + "checksumHash": "校驗和", + "checksumExpectedHint": "預期雜湊值(可選)", + "checksumInvalidLength": "{algorithm} 校驗和雜湊長度無效:應為 {length} 個字元", + "checksumInvalidHex": "校驗和雜湊無效:應為十六進位字元(0-9,a-f)", "deleteAfterExtract": "解壓後刪除壓縮檔", "extracting": "正在解壓", "extractDone": "解壓完成", diff --git a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart index f07f6615..87276b0e 100644 --- a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart +++ b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart @@ -26,6 +26,7 @@ import 'package:gopeed/shared/theme/app_design_tokens.dart'; import 'package:gopeed/shared/theme/app_theme.dart'; import 'package:gopeed/shared/widgets/app_loading_button.dart'; import 'package:gopeed/shared/widgets/app_tooltip.dart'; +import 'package:gopeed/l10n/app_localizations.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; void main() { @@ -611,6 +612,355 @@ void main() { expect(find.byKey(const ValueKey('create-history-clear')), findsOneWidget); expect(find.byKey(const ValueKey('create-history-close')), findsOneWidget); }); + + testWidgets('checksum state is reset for each incoming task and not inherited (T1)', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + tester.view.physicalSize = const Size(700, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + CreateTask? submitted; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind(GopeedMethods.createTask, (task) { + submitted = task; + return 'task-id'; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + + final container = ProviderContainer( + overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))], + ); + addTearDown(container.dispose); + + final pending = container.read(pendingCreateTaskProvider.notifier); + // Task A: with checksum + pending.set( + CreateTask( + req: Request(url: 'https://example.com/taskA.zip'), + opts: Options( + extra: OptsExtraHttp( + checksum: ChecksumOption( + algorithm: 'sha256', + expected: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ), + ).toJson(), + ), + ), + ); + + final router = GoRouter( + initialLocation: '/create', + routes: [ + GoRoute(path: '/', builder: (_, _) => const SizedBox()), + GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: shad.ShadcnApp.router( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + routerConfig: router, + builder: (_, child) => AppComponentThemes(child: child!), + ), + ), + ); + await tester.pumpAndSettle(); + + // Expand advanced + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + expect(_fieldText(tester, 'create-task-checksum-expected'), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + + // Task B form 1: null opts + pending.set(CreateTask(req: Request(url: 'https://example.com/taskB1.zip'))); + router.go('/create'); + await tester.pumpAndSettle(); + expect(_fieldText(tester, 'create-task-checksum-expected'), ''); + + // Task B form 2: empty Options + pending.set(CreateTask(req: Request(url: 'https://example.com/taskB2.zip'), opts: Options())); + router.go('/create'); + await tester.pumpAndSettle(); + expect(_fieldText(tester, 'create-task-checksum-expected'), ''); + + // Task B form 3: HTTP extra with no checksum + pending.set( + CreateTask( + req: Request(url: 'https://example.com/taskB3.zip'), + opts: Options(extra: OptsExtraHttp(connections: 8).toJson()), + ), + ); + router.go('/create'); + await tester.pumpAndSettle(); + expect(_fieldText(tester, 'create-task-checksum-expected'), ''); + + // Submit task B3 and ensure no checksum is sent + await tester.tap(find.byKey(const ValueKey('create-task-direct-download-toggle'))); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + + expect(submitted?.opts?.extra, isNotNull); + final extra = OptsExtraHttp.fromJson(Map.from(submitted!.opts!.extra! as Map)); + expect(extra.checksum, isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('checksum input validation blocks submission on invalid input (T3-UI)', (tester) async { + tester.view.physicalSize = const Size(1024, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + CreateTask? submitted; + final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(GopeedMethods.createTask, (task) { + submitted = task; + return 'task-id'; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + + final container = ProviderContainer( + overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))], + ); + addTearDown(container.dispose); + + final pending = container.read(pendingCreateTaskProvider.notifier); + pending.set(CreateTask(req: Request(url: 'https://example.com/file.zip'))); + + final router = GoRouter( + initialLocation: '/create', + routes: [ + GoRoute(path: '/', builder: (_, _) => const SizedBox()), + GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: shad.ShadcnApp.router( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + routerConfig: router, + builder: (_, child) => AppComponentThemes(child: child!), + ), + ), + ); + await tester.pumpAndSettle(); + + // Expand advanced options + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + + // Test 1: "abc" for SHA-256 + await tester.tap(find.text('SHA-256')); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'abc'); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(submitted, isNull); + + // Test 2: 64 x "g" (non-hex) for SHA-256 + await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'g' * 64); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(submitted, isNull); + + // Test 3: 63 x "a" (invalid length) for SHA-256 + await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 63); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(submitted, isNull); + + // Test 4: 65 x "a" (invalid length) for SHA-256 + await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 65); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(submitted, isNull); + + // Test 5: MD5 with sha256-length hash + await tester.tap(find.text('MD5')); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'a' * 64); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(submitted, isNull); + + // Test 6: Valid MD5 in UPPER case + submitted = null; + await tester.tap(find.text('MD5')); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), '0123456789ABCDEF0123456789ABCDEF'); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(submitted, isNotNull); + var extra = OptsExtraHttp.fromJson(Map.from(submitted!.opts!.extra! as Map)); + expect(extra.checksum?.algorithm, 'md5'); + expect(extra.checksum?.expected, '0123456789ABCDEF0123456789ABCDEF'); + + // Test 7: Valid SHA-1 in lower case + submitted = null; + pending.set(CreateTask(req: Request(url: 'https://example.com/file2.zip'))); + router.go('/create'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + await tester.tap(find.text('SHA-1')); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'da39a3ee5e6b4b0d3255bfef95601890afd80709'); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(submitted, isNotNull); + extra = OptsExtraHttp.fromJson(Map.from(submitted!.opts!.extra! as Map)); + expect(extra.checksum?.algorithm, 'sha1'); + expect(extra.checksum?.expected, 'da39a3ee5e6b4b0d3255bfef95601890afd80709'); + + // Test 8: Valid SHA-256 in lower case + submitted = null; + pending.set(CreateTask(req: Request(url: 'https://example.com/file3.zip'))); + router.go('/create'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + await tester.tap(find.text('SHA-256')); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(submitted, isNotNull); + extra = OptsExtraHttp.fromJson(Map.from(submitted!.opts!.extra! as Map)); + expect(extra.checksum?.algorithm, 'sha256'); + expect(extra.checksum?.expected, 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + + // Test 9: Omitted checksum (empty hash) + submitted = null; + pending.set(CreateTask(req: Request(url: 'https://example.com/file4.zip'))); + router.go('/create'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), ' '); + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + expect(submitted, isNotNull); + extra = OptsExtraHttp.fromJson(Map.from(submitted!.opts!.extra! as Map)); + expect(extra.checksum, isNull); + }); + + testWidgets('multi-URL and multi-file batch creation omits checksum (T4)', (tester) async { + tester.view.physicalSize = const Size(1024, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final createdTasks = []; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind(GopeedMethods.createTask, (task) { + createdTasks.add(task); + return 'task-id'; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + + final container = ProviderContainer( + overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))], + ); + addTearDown(container.dispose); + + final router = GoRouter( + initialLocation: '/create', + routes: [ + GoRoute(path: '/', builder: (_, _) => const SizedBox()), + GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: shad.ShadcnApp.router( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + routerConfig: router, + builder: (_, child) => AppComponentThemes(child: child!), + ), + ), + ); + await tester.pumpAndSettle(); + + // Enter 2 URLs + await tester.enterText( + find.byKey(const ValueKey('create-task-url-input')), + 'https://example.com/file1.zip\nhttps://example.com/file2.zip', + ); + await tester.pumpAndSettle(); + + // Advanced options should hide the checksum field + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('create-task-checksum-expected')), findsNothing); + + // Confirm creation + await tester.tap(find.text('Confirm')); + await tester.pumpAndSettle(); + + expect(createdTasks.length, 2); + for (final t in createdTasks) { + final extra = OptsExtraHttp.fromJson(Map.from(t.opts!.extra! as Map)); + expect(extra.checksum, isNull); + } + }); + + testWidgets('checksum UI uses localized labels (T5)', (tester) async { + tester.view.physicalSize = const Size(700, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))], + child: shad.ShadcnApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes( + child: CreateTaskWindowPage( + initialTask: CreateTask(req: Request(url: 'https://example.com/file.zip')), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + + expect(find.text('Checksum Algorithm'), findsOneWidget); + expect(find.text('Checksum Hash'), findsOneWidget); + }); } String _fieldText(WidgetTester tester, String key) {