From 029c66502907214ecd106c64a455772be745a20e Mon Sep 17 00:00:00 2001 From: wxianfeng Date: Fri, 31 Jul 2026 17:52:44 +0800 Subject: [PATCH 01/15] fix(event): place Unix bus sockets in temp dir --- CHANGELOG.md | 4 ++++ internal/event/bus/meta.go | 3 ++- internal/event/busctl/discover.go | 6 +++--- internal/event/busctl/status.go | 3 +-- internal/event/endpoint.go | 22 ++++++++-------------- internal/event/endpoint_portable_test.go | 3 ++- internal/event/endpoint_test.go | 11 +++++++---- 7 files changed, 27 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 602f9c12..c2bb52b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Fixed + +- **Event bus sockets on shared filesystems** — Unix event buses now place their local IPC socket under the system temporary directory while retaining locks, metadata, logs, and subscription state in the configured Workdir. This prevents `dws event consume` from failing with `bind: errno 524` when `~/.dws` is hosted on NFS, CSI, FUSE, or another filesystem that does not support Unix Domain Sockets. + ## [1.0.56-beta.2] - 2026-07-30 This beta adds PRs #831 and #835 on top of v1.0.56-beta.1. It separates diff --git a/internal/event/bus/meta.go b/internal/event/bus/meta.go index 1e2412b0..f889942f 100644 --- a/internal/event/bus/meta.go +++ b/internal/event/bus/meta.go @@ -25,7 +25,8 @@ import ( ) // MetaFileName is the on-disk name of the bus metadata file. It lives -// alongside bus.lock and bus.sock inside the bus working directory. +// alongside bus.lock inside the bus working directory. Unix bus sockets live +// under os.TempDir so shared config filesystems do not need socket support. const MetaFileName = "bus.meta" // Meta is the JSON document written once at bus startup. Its primary diff --git a/internal/event/busctl/discover.go b/internal/event/busctl/discover.go index d29cd75d..224555f7 100644 --- a/internal/event/busctl/discover.go +++ b/internal/event/busctl/discover.go @@ -30,9 +30,9 @@ import ( type SpawnFunc func(SpawnConfig) (pid int, err error) // DiscoverConfig describes one discover attempt. WorkDir holds bus.lock and -// usually (on Unix) bus.sock — see dwsevent.IPCEndpoint for the short-path -// fallback when WorkDir is too deep; the caller must mkdir it with -// pkg/config.DirPerm beforehand. +// persistent bus metadata; Unix sockets live under os.TempDir so WorkDir may +// reside on a shared filesystem without socket support. The caller must mkdir +// WorkDir with pkg/config.DirPerm beforehand. type DiscoverConfig struct { WorkDir string IPCEndpoint string diff --git a/internal/event/busctl/status.go b/internal/event/busctl/status.go index 6a6ec28c..d4009dc2 100644 --- a/internal/event/busctl/status.go +++ b/internal/event/busctl/status.go @@ -69,8 +69,7 @@ type BusEntry struct { // IPCEndpoint returns the IPC endpoint for this entry. Delegates to // dwsevent.IPCEndpoint so status/stop dial exactly where consume and the -// bus daemon bound (including the short-path fallback when WorkDir is too -// deep for sun_path). +// bus daemon bound (a local temp path on Unix and a named pipe on Windows). func (e BusEntry) IPCEndpoint() string { hash := e.ClientIDHash if e.IdentityHash != "" { diff --git a/internal/event/endpoint.go b/internal/event/endpoint.go index a9dcec26..39600b8d 100644 --- a/internal/event/endpoint.go +++ b/internal/event/endpoint.go @@ -35,17 +35,15 @@ func maxUnixSocketPath(goos string) int { } // IPCEndpoint returns the bus IPC endpoint for one identity: a Named Pipe -// name on Windows, otherwise bus.sock inside workDir. +// name on Windows, otherwise a deterministic Unix socket under os.TempDir. // -// The canonical Unix location is /bus.sock, but workDir derives -// from the config dir, which can be arbitrarily deep (e.g. dwssb sandboxes -// use ~/.dwssb/sandboxes//config/...). When the canonical path would -// exceed the OS sun_path limit, the socket falls back to a short -// deterministic path under os.TempDir keyed by a hash of workDir, so every -// process (consume parent, forked _bus child, status/stop tooling) that -// derives the endpoint from the same workDir agrees on the location. -// bus.lock / bus.meta / bus.log always stay in workDir — only the socket -// moves. +// Unix sockets must live on a local filesystem that supports bind(2). +// Config directories may reside on NFS, CSI, FUSE, or other shared mounts +// that reject Unix socket creation with ENOTSUPP. Keeping the socket under +// os.TempDir also guarantees a short path. The name is keyed by a hash of +// workDir so every process (consume parent, forked _bus child, status/stop +// tooling) that derives the endpoint from the same workDir agrees on the +// location. bus.lock / bus.meta / bus.log always stay in workDir. // // This is the single source of truth for endpoint derivation; the cobra // layer and busctl must not re-implement the shape. @@ -60,9 +58,5 @@ func ipcEndpointForOS(goos, workDir, editionName string, sourceKind SourceKind, if goos == "windows" { return `\\.\pipe\dws-event-` + editionName + "-" + string(sourceKind) + "-" + identityHash } - sock := filepath.Join(workDir, "bus.sock") - if len(sock) <= maxUnixSocketPath(goos) { - return sock - } return filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash(workDir)+".sock") } diff --git a/internal/event/endpoint_portable_test.go b/internal/event/endpoint_portable_test.go index 3968910e..9fb3d674 100644 --- a/internal/event/endpoint_portable_test.go +++ b/internal/event/endpoint_portable_test.go @@ -14,6 +14,7 @@ package event import ( + "os" "path/filepath" "strings" "testing" @@ -33,7 +34,7 @@ func TestCrossPlatformCoverageEndpointPortableCoverageEdges(t *testing.T) { if got := ipcEndpointForOS("windows", "ignored", "open", "", "hash"); got != `\\.\pipe\dws-event-open-app_stream-hash` { t.Fatalf("Windows endpoint = %q", got) } - if got := ipcEndpointForOS("darwin", "short", "open", SourceKindPersonalStream, "hash"); got != filepath.Join("short", "bus.sock") { + if got := ipcEndpointForOS("darwin", "short", "open", SourceKindPersonalStream, "hash"); got != filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash("short")+".sock") { t.Fatalf("short Unix endpoint = %q", got) } if got := ipcEndpointForOS("darwin", strings.Repeat("x", 200), "open", SourceKindAppStream, "hash"); !strings.Contains(got, "dws-evt-") { diff --git a/internal/event/endpoint_test.go b/internal/event/endpoint_test.go index 0d1d1f18..449fa814 100644 --- a/internal/event/endpoint_test.go +++ b/internal/event/endpoint_test.go @@ -31,7 +31,7 @@ func TestEndpointPlatformVariants(t *testing.T) { t.Fatalf("Windows pipe = %q", pipe) } short := ipcEndpointForOS("darwin", "/tmp/events", "open", SourceKindPersonalStream, "hash") - if short != filepath.Join("/tmp/events", "bus.sock") { + if short != filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash("/tmp/events")+".sock") { t.Fatalf("short Unix endpoint = %q", short) } long := ipcEndpointForOS("darwin", "/"+strings.Repeat("deep/", 40), "open", SourceKindAppStream, "hash") @@ -40,16 +40,19 @@ func TestEndpointPlatformVariants(t *testing.T) { } } -func TestIPCEndpointShortWorkDirUsesCanonicalPath(t *testing.T) { +func TestIPCEndpointShortWorkDirUsesLocalTempDir(t *testing.T) { workDir := "/tmp/dws/events/open/app_stream/aabbccdd00112233" got := IPCEndpoint(workDir, "open", SourceKindAppStream, "aabbccdd00112233") - want := filepath.Join(workDir, "bus.sock") + want := filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash(workDir)+".sock") if got != want { t.Fatalf("IPCEndpoint = %q, want %q", got, want) } + if strings.HasPrefix(got, workDir) { + t.Fatalf("IPCEndpoint = %q, want endpoint outside workDir", got) + } } -func TestIPCEndpointLongWorkDirFallsBackUnderTempDir(t *testing.T) { +func TestIPCEndpointLongWorkDirUsesLocalTempDir(t *testing.T) { // Mirrors the dwssb sandbox layout that produced a 111-byte socket // path — over macOS's 103-byte usable sun_path budget. workDir := "/Users/zhengyubai/.dwssb/sandboxes/event-subscribe/config/events/open/personal_stream/3928ce0fb4860a52" From 5459bcc524ab121ae8adf2ace1cefd9555327a26 Mon Sep 17 00:00:00 2001 From: wxianfeng Date: Mon, 3 Aug 2026 11:40:01 +0800 Subject: [PATCH 02/15] fix(event): secure Unix bus runtime directory --- CHANGELOG.md | 2 +- internal/event/bus/meta.go | 3 +- internal/event/busctl/discover.go | 6 +- internal/event/busctl/status.go | 3 +- internal/event/endpoint.go | 34 ++++- internal/event/endpoint_portable_test.go | 3 +- internal/event/endpoint_test.go | 42 ++++- internal/event/transport/transport_unix.go | 92 ++++++++++- .../event/transport/transport_unix_test.go | 143 +++++++++++++++++- 9 files changed, 305 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2bb52b4..b47008ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Fixed -- **Event bus sockets on shared filesystems** — Unix event buses now place their local IPC socket under the system temporary directory while retaining locks, metadata, logs, and subscription state in the configured Workdir. This prevents `dws event consume` from failing with `bind: errno 524` when `~/.dws` is hosted on NFS, CSI, FUSE, or another filesystem that does not support Unix Domain Sockets. +- **Event bus sockets on shared filesystems** — Unix event buses now place their local IPC socket in a private per-user runtime directory (`XDG_RUNTIME_DIR` when available, otherwise a `0700` per-UID directory under the system temporary directory) while retaining locks, metadata, logs, and subscription state in the configured Workdir. Listener and dial paths validate directory ownership and permissions before use. This prevents `dws event consume` from failing with `bind: errno 524` when `~/.dws` is hosted on NFS, CSI, FUSE, or another filesystem that does not support Unix Domain Sockets without exposing a predictable socket in a shared `/tmp` root. ## [1.0.56-beta.2] - 2026-07-30 diff --git a/internal/event/bus/meta.go b/internal/event/bus/meta.go index f889942f..6d2a75ae 100644 --- a/internal/event/bus/meta.go +++ b/internal/event/bus/meta.go @@ -26,7 +26,8 @@ import ( // MetaFileName is the on-disk name of the bus metadata file. It lives // alongside bus.lock inside the bus working directory. Unix bus sockets live -// under os.TempDir so shared config filesystems do not need socket support. +// in a private per-user runtime directory so shared config filesystems do not +// need socket support. const MetaFileName = "bus.meta" // Meta is the JSON document written once at bus startup. Its primary diff --git a/internal/event/busctl/discover.go b/internal/event/busctl/discover.go index 224555f7..2bbbb863 100644 --- a/internal/event/busctl/discover.go +++ b/internal/event/busctl/discover.go @@ -30,9 +30,9 @@ import ( type SpawnFunc func(SpawnConfig) (pid int, err error) // DiscoverConfig describes one discover attempt. WorkDir holds bus.lock and -// persistent bus metadata; Unix sockets live under os.TempDir so WorkDir may -// reside on a shared filesystem without socket support. The caller must mkdir -// WorkDir with pkg/config.DirPerm beforehand. +// persistent bus metadata; Unix sockets live in a private per-user runtime +// directory so WorkDir may reside on a shared filesystem without socket +// support. The caller must mkdir WorkDir with pkg/config.DirPerm beforehand. type DiscoverConfig struct { WorkDir string IPCEndpoint string diff --git a/internal/event/busctl/status.go b/internal/event/busctl/status.go index d4009dc2..abfb5b81 100644 --- a/internal/event/busctl/status.go +++ b/internal/event/busctl/status.go @@ -69,7 +69,8 @@ type BusEntry struct { // IPCEndpoint returns the IPC endpoint for this entry. Delegates to // dwsevent.IPCEndpoint so status/stop dial exactly where consume and the -// bus daemon bound (a local temp path on Unix and a named pipe on Windows). +// bus daemon bound (a private per-user runtime path on Unix and a named pipe +// on Windows). func (e BusEntry) IPCEndpoint() string { hash := e.ClientIDHash if e.IdentityHash != "" { diff --git a/internal/event/endpoint.go b/internal/event/endpoint.go index 39600b8d..64a09e51 100644 --- a/internal/event/endpoint.go +++ b/internal/event/endpoint.go @@ -17,8 +17,16 @@ import ( "os" "path/filepath" "runtime" + "strconv" + "strings" ) +const eventRuntimeDirPrefix = "dws-event-" + +func currentUserID() string { + return strconv.Itoa(os.Geteuid()) +} + // MaxUnixSocketPath returns the longest Unix socket path accepted by // bind/connect on this OS (Go rejects longer names with EINVAL before // the syscall). sockaddr_un.sun_path is 104 bytes on darwin and the @@ -35,15 +43,19 @@ func maxUnixSocketPath(goos string) int { } // IPCEndpoint returns the bus IPC endpoint for one identity: a Named Pipe -// name on Windows, otherwise a deterministic Unix socket under os.TempDir. +// name on Windows, otherwise a deterministic Unix socket under a private +// per-user runtime directory. // // Unix sockets must live on a local filesystem that supports bind(2). // Config directories may reside on NFS, CSI, FUSE, or other shared mounts -// that reject Unix socket creation with ENOTSUPP. Keeping the socket under -// os.TempDir also guarantees a short path. The name is keyed by a hash of -// workDir so every process (consume parent, forked _bus child, status/stop -// tooling) that derives the endpoint from the same workDir agrees on the -// location. bus.lock / bus.meta / bus.log always stay in workDir. +// that reject Unix socket creation with ENOTSUPP. On Unix, the endpoint uses +// XDG_RUNTIME_DIR when it is absolute and short enough; otherwise it falls +// back to a per-UID directory under os.TempDir. The transport creates and +// validates that directory as owner-only before listening or dialing. The +// socket name is keyed by a hash of workDir so every process (consume parent, +// forked _bus child, status/stop tooling) that derives the endpoint from the +// same workDir agrees on the location. bus.lock / bus.meta / bus.log always +// stay in workDir. // // This is the single source of truth for endpoint derivation; the cobra // layer and busctl must not re-implement the shape. @@ -58,5 +70,13 @@ func ipcEndpointForOS(goos, workDir, editionName string, sourceKind SourceKind, if goos == "windows" { return `\\.\pipe\dws-event-` + editionName + "-" + string(sourceKind) + "-" + identityHash } - return filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash(workDir)+".sock") + socketName := "dws-evt-" + IdentityHash(workDir) + ".sock" + userDirName := eventRuntimeDirPrefix + currentUserID() + if runtimeDir := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); filepath.IsAbs(runtimeDir) { + candidate := filepath.Join(runtimeDir, userDirName, socketName) + if len(candidate) <= maxUnixSocketPath(goos) { + return candidate + } + } + return filepath.Join(os.TempDir(), userDirName, socketName) } diff --git a/internal/event/endpoint_portable_test.go b/internal/event/endpoint_portable_test.go index 9fb3d674..9c0fb425 100644 --- a/internal/event/endpoint_portable_test.go +++ b/internal/event/endpoint_portable_test.go @@ -21,6 +21,7 @@ import ( ) func TestCrossPlatformCoverageEndpointPortableCoverageEdges(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "") if got := MaxUnixSocketPath(); got != 103 && got != 107 { t.Fatalf("MaxUnixSocketPath() = %d", got) } @@ -34,7 +35,7 @@ func TestCrossPlatformCoverageEndpointPortableCoverageEdges(t *testing.T) { if got := ipcEndpointForOS("windows", "ignored", "open", "", "hash"); got != `\\.\pipe\dws-event-open-app_stream-hash` { t.Fatalf("Windows endpoint = %q", got) } - if got := ipcEndpointForOS("darwin", "short", "open", SourceKindPersonalStream, "hash"); got != filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash("short")+".sock") { + if got := ipcEndpointForOS("darwin", "short", "open", SourceKindPersonalStream, "hash"); got != filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash("short")+".sock") { t.Fatalf("short Unix endpoint = %q", got) } if got := ipcEndpointForOS("darwin", strings.Repeat("x", 200), "open", SourceKindAppStream, "hash"); !strings.Contains(got, "dws-evt-") { diff --git a/internal/event/endpoint_test.go b/internal/event/endpoint_test.go index 449fa814..81df50a3 100644 --- a/internal/event/endpoint_test.go +++ b/internal/event/endpoint_test.go @@ -23,6 +23,7 @@ import ( ) func TestEndpointPlatformVariants(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "") if maxUnixSocketPath("linux") != 107 || maxUnixSocketPath("darwin") != 103 { t.Fatal("Unix socket limits changed") } @@ -31,7 +32,7 @@ func TestEndpointPlatformVariants(t *testing.T) { t.Fatalf("Windows pipe = %q", pipe) } short := ipcEndpointForOS("darwin", "/tmp/events", "open", SourceKindPersonalStream, "hash") - if short != filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash("/tmp/events")+".sock") { + if short != filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash("/tmp/events")+".sock") { t.Fatalf("short Unix endpoint = %q", short) } long := ipcEndpointForOS("darwin", "/"+strings.Repeat("deep/", 40), "open", SourceKindAppStream, "hash") @@ -40,10 +41,30 @@ func TestEndpointPlatformVariants(t *testing.T) { } } -func TestIPCEndpointShortWorkDirUsesLocalTempDir(t *testing.T) { +func TestIPCEndpointUsesXDGUserRuntimeDir(t *testing.T) { + tempRoot, err := filepath.EvalSymlinks("/tmp") + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + runtimeDir, err := os.MkdirTemp(tempRoot, "dws-xdg-") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(runtimeDir) }) + t.Setenv("XDG_RUNTIME_DIR", runtimeDir) + workDir := "/shared/events/open/app_stream/aabbccdd00112233" + got := IPCEndpoint(workDir, "open", SourceKindAppStream, "aabbccdd00112233") + want := filepath.Join(runtimeDir, eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock") + if got != want { + t.Fatalf("IPCEndpoint = %q, want %q", got, want) + } +} + +func TestIPCEndpointWithoutXDGUsesPerUserLocalTempDir(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "") workDir := "/tmp/dws/events/open/app_stream/aabbccdd00112233" got := IPCEndpoint(workDir, "open", SourceKindAppStream, "aabbccdd00112233") - want := filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash(workDir)+".sock") + want := filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock") if got != want { t.Fatalf("IPCEndpoint = %q, want %q", got, want) } @@ -53,6 +74,7 @@ func TestIPCEndpointShortWorkDirUsesLocalTempDir(t *testing.T) { } func TestIPCEndpointLongWorkDirUsesLocalTempDir(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "") // Mirrors the dwssb sandbox layout that produced a 111-byte socket // path — over macOS's 103-byte usable sun_path budget. workDir := "/Users/zhengyubai/.dwssb/sandboxes/event-subscribe/config/events/open/personal_stream/3928ce0fb4860a52" @@ -69,6 +91,7 @@ func TestIPCEndpointLongWorkDirUsesLocalTempDir(t *testing.T) { } func TestIPCEndpointFallbackIsDeterministicPerWorkDir(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "") long := strings.Repeat("x", 120) a := IPCEndpoint("/base/"+long+"/one", "open", SourceKindPersonalStream, "hash") b := IPCEndpoint("/base/"+long+"/one", "open", SourceKindPersonalStream, "hash") @@ -80,3 +103,16 @@ func TestIPCEndpointFallbackIsDeterministicPerWorkDir(t *testing.T) { t.Fatalf("different workDirs collided on endpoint %q", a) } } + +func TestIPCEndpointLongXDGPathFallsBackToTempDir(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "/"+strings.Repeat("runtime/", 30)) + workDir := "/shared/events/open/personal_stream/aabbccdd00112233" + got := ipcEndpointForOS("linux", workDir, "open", SourceKindPersonalStream, "hash") + wantPrefix := filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID()) + string(filepath.Separator) + if !strings.HasPrefix(got, wantPrefix) { + t.Fatalf("IPCEndpoint = %q, want fallback under %q", got, wantPrefix) + } + if len(got) > maxUnixSocketPath("linux") { + t.Fatalf("fallback path still too long: %d > %d (%q)", len(got), maxUnixSocketPath("linux"), got) + } +} diff --git a/internal/event/transport/transport_unix.go b/internal/event/transport/transport_unix.go index d14f69dd..4f61cecf 100644 --- a/internal/event/transport/transport_unix.go +++ b/internal/event/transport/transport_unix.go @@ -16,9 +16,12 @@ package transport import ( + "errors" "fmt" "net" "os" + "path/filepath" + "syscall" dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" @@ -56,11 +59,95 @@ func checkSocketPath(path string) error { return nil } +// ensureSocketDir makes the socket's immediate parent an owner-only +// directory and rejects unsafe pre-existing paths. The parent of that +// directory must itself either be private to the effective user (for +// XDG_RUNTIME_DIR and macOS temporary roots) or sticky (for Linux /tmp), so +// another user cannot rename the private directory out from under us. +func ensureSocketDir(path string, create bool) error { + if !filepath.IsAbs(path) { + return fmt.Errorf("transport: unix socket path must be absolute: %s", path) + } + dir := filepath.Dir(path) + root := filepath.Dir(dir) + rootInfo, err := os.Lstat(root) + if err != nil { + return fmt.Errorf("transport: inspect socket runtime root %s: %w", root, err) + } + // macOS exposes the system /tmp as a root-owned symlink to /private/tmp. + // Follow only that well-known alias, then apply the same ownership/sticky + // validation to its target. Arbitrary runtime-root symlinks remain rejected. + if rootInfo.Mode()&os.ModeSymlink != 0 && filepath.Clean(root) == "/tmp" { + rootInfo, err = os.Stat(root) + if err != nil { + return fmt.Errorf("transport: resolve socket runtime root %s: %w", root, err) + } + } + if err := validateSocketRuntimeRoot(root, rootInfo, uint32(os.Geteuid())); err != nil { + return err + } + if create { + if err := os.Mkdir(dir, config.DirPerm); err != nil && !errors.Is(err, os.ErrExist) { + return fmt.Errorf("transport: create socket directory %s: %w", dir, err) + } + } + dirInfo, err := os.Lstat(dir) + if err != nil { + return fmt.Errorf("transport: inspect socket directory %s: %w", dir, err) + } + return validatePrivateSocketDir(dir, dirInfo, uint32(os.Geteuid())) +} + +func validateSocketRuntimeRoot(path string, info os.FileInfo, effectiveUID uint32) error { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("transport: socket runtime root is not a directory: %s", path) + } + owner, err := fileOwnerUID(info) + if err != nil { + return fmt.Errorf("transport: inspect socket runtime root owner %s: %w", path, err) + } + privateOwnerRoot := owner == effectiveUID && info.Mode().Perm()&0o022 == 0 + stickyRoot := info.Mode()&os.ModeSticky != 0 + if !privateOwnerRoot && !stickyRoot { + return fmt.Errorf("transport: socket runtime root is neither private nor sticky: %s", path) + } + return nil +} + +func validatePrivateSocketDir(path string, info os.FileInfo, effectiveUID uint32) error { + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("transport: socket directory is not a directory: %s", path) + } + owner, err := fileOwnerUID(info) + if err != nil { + return fmt.Errorf("transport: inspect socket directory owner %s: %w", path, err) + } + if owner != effectiveUID { + return fmt.Errorf("transport: socket directory %s is owned by uid %d, want %d", path, owner, effectiveUID) + } + if perm := info.Mode().Perm(); perm != config.DirPerm { + return fmt.Errorf("transport: socket directory %s has permissions %04o, want %04o", path, perm, config.DirPerm) + } + return nil +} + +func fileOwnerUID(info os.FileInfo) (uint32, error) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, errors.New("stat result does not expose an owner uid") + } + return stat.Uid, nil +} + func listen(path string) (Listener, error) { if err := checkSocketPath(path); err != nil { return nil, err } - // Stale socket cleanup. Caller holds bus.lock so this is race-safe. + if err := ensureSocketDir(path, true); err != nil { + return nil, err + } + // The private per-user parent excludes other users. The caller's bus.lock + // serializes stale-socket cleanup for processes using the same WorkDir. if _, err := statSocket(path); err == nil { if err := removeSocket(path); err != nil { return nil, fmt.Errorf("transport: remove stale socket %s: %w", path, err) @@ -82,5 +169,8 @@ func dial(path string) (net.Conn, error) { if err := checkSocketPath(path); err != nil { return nil, err } + if err := ensureSocketDir(path, false); err != nil { + return nil, err + } return net.Dial("unix", path) } diff --git a/internal/event/transport/transport_unix_test.go b/internal/event/transport/transport_unix_test.go index 6de5af4a..1928eec7 100644 --- a/internal/event/transport/transport_unix_test.go +++ b/internal/event/transport/transport_unix_test.go @@ -21,12 +21,32 @@ import ( "net" "os" "path/filepath" + "strings" "sync" "testing" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" ) +func shortSecureTempDir(t *testing.T) string { + t.Helper() + tempRoot, err := filepath.EvalSymlinks("/tmp") + if err != nil { + t.Fatalf("EvalSymlinks: %v", err) + } + dir, err := os.MkdirTemp(tempRoot, "dws-et-") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatalf("chmod temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + func TestListen_DialRoundtrip(t *testing.T) { - path := filepath.Join(t.TempDir(), "bus.sock") + path := filepath.Join(shortSecureTempDir(t), "bus.sock") l, err := Listen(path) if err != nil { t.Fatalf("Listen: %v", err) @@ -87,7 +107,7 @@ func TestListen_DialRoundtrip(t *testing.T) { } func TestListen_StaleSocketCleanup(t *testing.T) { - path := filepath.Join(t.TempDir(), "bus.sock") + path := filepath.Join(shortSecureTempDir(t), "bus.sock") // Pre-create a stale file at path (not a valid socket). if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil { t.Fatalf("pre-create: %v", err) @@ -99,8 +119,121 @@ func TestListen_StaleSocketCleanup(t *testing.T) { defer l.Close() } +func TestListen_CreatesPrivateSocketDirectory(t *testing.T) { + dir := filepath.Join(shortSecureTempDir(t), "dws-event-test") + path := filepath.Join(dir, "bus.sock") + l, err := Listen(path) + if err != nil { + t.Fatalf("Listen: %v", err) + } + defer l.Close() + + st, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat socket directory: %v", err) + } + if mode := st.Mode().Perm(); mode != 0o700 { + t.Fatalf("socket directory mode = %04o, want 0700", mode) + } +} + +func TestListen_RejectsWorldAccessibleSocketDirectory(t *testing.T) { + dir := filepath.Join(shortSecureTempDir(t), "dws-event-test") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.Chmod(dir, 0o777); err != nil { + t.Fatalf("chmod: %v", err) + } + if _, err := Listen(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "want 0700") { + t.Fatalf("Listen error = %v, want 0700 directory rejection", err) + } +} + +func TestDial_RejectsWorldAccessibleSocketDirectory(t *testing.T) { + dir := filepath.Join(shortSecureTempDir(t), "dws-event-test") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.Chmod(dir, 0o777); err != nil { + t.Fatalf("chmod: %v", err) + } + if _, err := Dial(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "want 0700") { + t.Fatalf("Dial error = %v, want 0700 directory rejection", err) + } +} + +func TestListen_RejectsSymlinkSocketDirectory(t *testing.T) { + root := shortSecureTempDir(t) + target := filepath.Join(root, "target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatalf("mkdir target: %v", err) + } + link := filepath.Join(root, "dws-event-test") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink: %v", err) + } + if _, err := Listen(filepath.Join(link, "bus.sock")); err == nil || !strings.Contains(err.Error(), "not a directory") { + t.Fatalf("Listen error = %v, want symlink directory rejection", err) + } +} + +func TestValidatePrivateSocketDirRejectsDifferentOwner(t *testing.T) { + dir := shortSecureTempDir(t) + st, err := os.Lstat(dir) + if err != nil { + t.Fatalf("lstat: %v", err) + } + otherUID := uint32(os.Geteuid() + 1) + if err := validatePrivateSocketDir(dir, st, otherUID); err == nil || !strings.Contains(err.Error(), "is owned by uid") { + t.Fatalf("validatePrivateSocketDir error = %v, want owner mismatch", err) + } +} + +func TestListen_RejectsUntrustedRuntimeRoot(t *testing.T) { + root := filepath.Join(shortSecureTempDir(t), "untrusted") + if err := os.Mkdir(root, 0o700); err != nil { + t.Fatalf("mkdir root: %v", err) + } + if err := os.Chmod(root, 0o777); err != nil { + t.Fatalf("chmod root: %v", err) + } + dir := filepath.Join(root, "dws-event-test") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatalf("mkdir socket dir: %v", err) + } + if _, err := Listen(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "neither private nor sticky") { + t.Fatalf("Listen error = %v, want untrusted runtime root rejection", err) + } +} + +func TestListen_SharedWorkDirUsesLocalSecureRuntimeEndpoint(t *testing.T) { + root := shortSecureTempDir(t) + runtimeDir := filepath.Join(root, "runtime") + if err := os.Mkdir(runtimeDir, 0o700); err != nil { + t.Fatalf("mkdir runtime: %v", err) + } + t.Setenv("XDG_RUNTIME_DIR", runtimeDir) + sharedWorkDir := filepath.Join(root, "simulated-nfs", "events", "open", "personal_stream", "identity") + endpoint := dwsevent.IPCEndpoint(sharedWorkDir, "open", dwsevent.SourceKindPersonalStream, "identity") + if strings.HasPrefix(endpoint, sharedWorkDir) { + t.Fatalf("endpoint = %q, want socket outside shared WorkDir %q", endpoint, sharedWorkDir) + } + + l, err := Listen(endpoint) + if err != nil { + t.Fatalf("Listen on local runtime endpoint: %v", err) + } + defer l.Close() + if mode, err := os.Stat(filepath.Dir(endpoint)); err != nil { + t.Fatalf("stat runtime socket directory: %v", err) + } else if mode.Mode().Perm() != 0o700 { + t.Fatalf("runtime socket directory mode = %04o, want 0700", mode.Mode().Perm()) + } +} + func TestListen_CloseUnlinksSocket(t *testing.T) { - path := filepath.Join(t.TempDir(), "bus.sock") + path := filepath.Join(shortSecureTempDir(t), "bus.sock") l, err := Listen(path) if err != nil { t.Fatalf("Listen: %v", err) @@ -114,7 +247,7 @@ func TestListen_CloseUnlinksSocket(t *testing.T) { } func TestDial_NoServerReturnsError(t *testing.T) { - path := filepath.Join(t.TempDir(), "nonexistent.sock") + path := filepath.Join(shortSecureTempDir(t), "nonexistent.sock") if _, err := Dial(path); err == nil { t.Fatal("Dial to nonexistent socket should error") } @@ -124,7 +257,7 @@ func TestDial_NoServerReturnsError(t *testing.T) { // surfaces as io.EOF to the server's Reader — the EOF signal is what bus // uses to unregister dead consumers (plan invariant #5). func TestReader_HandlesPeerCloseEOF(t *testing.T) { - path := filepath.Join(t.TempDir(), "bus.sock") + path := filepath.Join(shortSecureTempDir(t), "bus.sock") l, err := Listen(path) if err != nil { t.Fatalf("Listen: %v", err) From 22862508b82212eb20582e7fe7525c92b01eea83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=95=9C=E7=8E=84?= Date: Mon, 3 Aug 2026 14:47:59 +0800 Subject: [PATCH 03/15] feat: add aitable workflow edit example command --- CHANGELOG.md | 4 + .../cli/schema_agent_metadata/aitable.json | 259 ++++++++++++++ internal/cli/schema_agent_metadata/index.json | 18 +- internal/cli/schema_agent_metadata_audit.json | 20 +- internal/cli/schema_catalog/catalog.json | 45 ++- .../cli/schema_catalog/tools/aitable.json | 320 ++++++++++++++++++ .../products/aitable.json | 4 + .../cli/schema_hints/metadata/aitable.json | 13 + .../runtime-surface-completeness.json | 2 +- .../cli/schema_hints/selection/aitable.json | 20 ++ internal/helpers/aitable.go | 14 +- .../helpers/aitable_workflow_write_test.go | 17 + skills/mono/references/products/aitable.md | 1 + .../products/aitable/aitable-workflow.md | 13 +- .../dingtalk-aitable/references/aitable.md | 1 + .../references/aitable/aitable-workflow.md | 15 +- 16 files changed, 731 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 602f9c12..a36125f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Added + +- **Aitable workflow editing reference** — adds `dws aitable workflow edit-example`, a parameter-free read command that returns the service-provided workflow editing documentation and `workflow-dsl/v1` examples through `aitable/edit_workflow_example`. + ## [1.0.56-beta.2] - 2026-07-30 This beta adds PRs #831 and #835 on top of v1.0.56-beta.1. It separates diff --git a/internal/cli/schema_agent_metadata/aitable.json b/internal/cli/schema_agent_metadata/aitable.json index cfc2f451..bc31ee71 100644 --- a/internal/cli/schema_agent_metadata/aitable.json +++ b/internal/cli/schema_agent_metadata/aitable.json @@ -37075,6 +37075,265 @@ "用户明确要求停止某自动化工作流时" ] }, + "aitable workflow edit-example": { + "agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。", + "agent_summary_source": "dws-agent-selection/aitable", + "availability": "available", + "avoid_when": [ + "实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get" + ], + "confirmation": "not_required", + "effect": "read", + "effect_source": "agent-hint", + "examples": [ + "dws aitable workflow edit-example" + ], + "field_provenance": { + "agent_summary": { + "value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。", + "source": "internal/cli/schema_hints/selection/aitable.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。", + "candidates": [ + { + "value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。", + "source": "internal/cli/schema_hints/selection/aitable.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。" + } + ] + }, + "availability": { + "value": "available", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "candidates": [ + { + "value": "available", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry." + } + ] + }, + "avoid_when": { + "value": [ + "实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get" + ], + "source": "internal/cli/schema_hints/selection/aitable.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。", + "candidates": [ + { + "value": [ + "实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get" + ], + "source": "internal/cli/schema_hints/selection/aitable.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。" + } + ] + }, + "confirmation": { + "value": "not_required", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "candidates": [ + { + "value": "not_required", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry." + } + ] + }, + "effect": { + "value": "read", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "candidates": [ + { + "value": "read", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry." + } + ] + }, + "examples": { + "value": [ + "dws aitable workflow edit-example" + ], + "source": "internal/cli/schema_hints/selection/aitable.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。", + "candidates": [ + { + "value": [ + "dws aitable workflow edit-example" + ], + "source": "internal/cli/schema_hints/selection/aitable.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。" + } + ] + }, + "idempotency": { + "value": "idempotent", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "candidates": [ + { + "value": "idempotent", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry." + } + ] + }, + "interface_mode": { + "value": "composite", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "candidates": [ + { + "value": "composite", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry." + } + ] + }, + "interface_reason": { + "value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "candidates": [ + { + "value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry." + } + ] + }, + "interface_ref": { + "value": null, + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "resolution": "interface_disposition_matrix", + "review_reason": "final interface mode composite forbids a direct MCP interface_ref", + "candidates": [ + { + "value": null, + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "final interface mode composite forbids a direct MCP interface_ref" + } + ] + }, + "reviewed": { + "value": true, + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "candidates": [ + { + "value": true, + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry." + }, + { + "value": true, + "source": "internal/cli/schema_hints/selection/aitable.json", + "precedence": "reviewed_explicit", + "selected": false, + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。" + } + ] + }, + "risk": { + "value": "low", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "candidates": [ + { + "value": "low", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry." + } + ] + }, + "use_when": { + "value": [ + "创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时" + ], + "source": "internal/cli/schema_hints/selection/aitable.json", + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。", + "candidates": [ + { + "value": [ + "创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时" + ], + "source": "internal/cli/schema_hints/selection/aitable.json", + "precedence": "reviewed_explicit", + "selected": true, + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。" + } + ] + } + }, + "idempotency": "idempotent", + "interface_mode": "composite", + "interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", + "reviewed": true, + "risk": "low", + "source_refs": [ + "cobra-help:dws aitable workflow edit-example --help", + "internal/cli/schema_command_registry.json#aitable.workflow_edit_example", + "internal/cli/schema_hints/metadata/aitable.json", + "internal/cli/schema_hints/selection/aitable.json", + "mcp-contract:aitable/edit_workflow_example", + "skills/mono/references/products/aitable/aitable-workflow.md" + ], + "use_when": [ + "创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时" + ] + }, "aitable workflow enable": { "agent_summary": "启用工作流。", "agent_summary_source": "dws-agent-selection/aitable", diff --git a/internal/cli/schema_agent_metadata/index.json b/internal/cli/schema_agent_metadata/index.json index f4bb0712..45d82c88 100644 --- a/internal/cli/schema_agent_metadata/index.json +++ b/internal/cli/schema_agent_metadata/index.json @@ -1,17 +1,17 @@ { "version": 1, - "source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613", - "surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16", + "source_hash": "sha256:b513a679eb51b64afa3f31364b45c29bc4590f0568908650f426ac8f4b041b2b", + "surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894", "coverage": { "surface_products": 26, "products_with_metadata": 26, - "surface_tools": 845, - "tools_with_metadata": 845, - "tools_with_agent_summary": 845, - "tools_with_use_when": 845, - "tools_with_avoid_when": 845, - "tools_with_examples": 845, - "tools_with_interface_mode": 845, + "surface_tools": 846, + "tools_with_metadata": 846, + "tools_with_agent_summary": 846, + "tools_with_use_when": 846, + "tools_with_avoid_when": 846, + "tools_with_examples": 846, + "tools_with_interface_mode": 846, "unmatched_skill_tools": 122, "unreviewed_skill_tools": 11 }, diff --git a/internal/cli/schema_agent_metadata_audit.json b/internal/cli/schema_agent_metadata_audit.json index 5cb7f08c..8c1b99fd 100644 --- a/internal/cli/schema_agent_metadata_audit.json +++ b/internal/cli/schema_agent_metadata_audit.json @@ -1,11 +1,11 @@ { "version": 1, - "source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613", - "surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16", + "source_hash": "sha256:b513a679eb51b64afa3f31364b45c29bc4590f0568908650f426ac8f4b041b2b", + "surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894", "source_files": 160, "hint_files": 54, "hint_products": 43, - "hint_tools": 1842, + "hint_tools": 1844, "interface_metadata": { "source": "mcp-tools-list+cli-registry", "revision": "4574f7022c32cf4c033e9b7b4156e2fec815fed8", @@ -29,13 +29,13 @@ "coverage": { "surface_products": 26, "products_with_metadata": 26, - "surface_tools": 845, - "tools_with_metadata": 845, - "tools_with_agent_summary": 845, - "tools_with_use_when": 845, - "tools_with_avoid_when": 845, - "tools_with_examples": 845, - "tools_with_interface_mode": 845, + "surface_tools": 846, + "tools_with_metadata": 846, + "tools_with_agent_summary": 846, + "tools_with_use_when": 846, + "tools_with_avoid_when": 846, + "tools_with_examples": 846, + "tools_with_interface_mode": 846, "unmatched_skill_tools": 122, "unreviewed_skill_tools": 11 }, diff --git a/internal/cli/schema_catalog/catalog.json b/internal/cli/schema_catalog/catalog.json index 05b97f39..eb2a389a 100644 --- a/internal/cli/schema_catalog/catalog.json +++ b/internal/cli/schema_catalog/catalog.json @@ -1,17 +1,17 @@ { "version": 1, - "surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16", - "source_hash": "sha256:862734fb976a4c9c78d010b7391c74822f37805f5acc650551f4ac60f701c13a", + "surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894", + "source_hash": "sha256:1b4f0e6e6fe49115137ff81717462374b2fdd05ad4ca1bfb1b2dd8272aa6bad8", "catalog": { "agent_metadata": { "products_with_metadata": 26, "source": "embedded-skill-metadata", - "source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613", - "surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16", + "source_hash": "sha256:b513a679eb51b64afa3f31364b45c29bc4590f0568908650f426ac8f4b041b2b", + "surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894", "surface_products": 26, - "surface_tools": 845, - "tools_with_agent_summary": 845, - "tools_with_metadata": 845, + "surface_tools": 846, + "tools_with_agent_summary": 846, + "tools_with_metadata": 846, "unmatched_skill_tools": 122, "version": 1 }, @@ -288,7 +288,7 @@ "id": "aitable", "name": "AI 表格操作", "runtime": true, - "tool_count": 145, + "tool_count": 146, "tools": [ { "agent_metadata_source": "embedded-skill-metadata", @@ -4449,6 +4449,33 @@ "用户明确要求停止某自动化工作流时" ] }, + { + "agent_metadata_source": "embedded-skill-metadata", + "agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。", + "agent_summary_source": "dws-agent-selection/aitable", + "availability": "available", + "avoid_when": [ + "实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get" + ], + "canonical_path": "aitable.workflow_edit_example", + "cli_name": "edit-example", + "cli_path": "aitable workflow edit-example", + "confirmation": "not_required", + "description": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。", + "effect": "read", + "group": "workflow", + "idempotency": "idempotent", + "interface_mode": "composite", + "interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", + "name": "workflow_edit_example", + "primary_cli_path": "aitable workflow edit-example", + "reviewed": true, + "risk": "low", + "title": "获取工作流编辑文档与示例", + "use_when": [ + "创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时" + ] + }, { "agent_metadata_source": "embedded-skill-metadata", "agent_summary": "启用工作流。", @@ -26644,6 +26671,6 @@ } ], "source": "embedded-command-catalog", - "tool_count": 845 + "tool_count": 846 } } diff --git a/internal/cli/schema_catalog/tools/aitable.json b/internal/cli/schema_catalog/tools/aitable.json index 3f8cf870..bc14d9f5 100644 --- a/internal/cli/schema_catalog/tools/aitable.json +++ b/internal/cli/schema_catalog/tools/aitable.json @@ -97777,6 +97777,326 @@ "用户明确要求停止某自动化工作流时" ] }, + "aitable.workflow_edit_example": { + "agent_metadata_source": "embedded-skill-metadata", + "agent_source_refs": [ + "cobra-help:dws aitable workflow edit-example --help", + "internal/cli/schema_command_registry.json#aitable.workflow_edit_example", + "internal/cli/schema_hints/metadata/aitable.json", + "internal/cli/schema_hints/selection/aitable.json", + "mcp-contract:aitable/edit_workflow_example", + "skills/mono/references/products/aitable/aitable-workflow.md" + ], + "agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。", + "agent_summary_source": "dws-agent-selection/aitable", + "availability": "available", + "avoid_when": [ + "实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get" + ], + "canonical_path": "aitable.workflow_edit_example", + "cli_name": "edit-example", + "cli_path": "aitable workflow edit-example", + "confirmation": "not_required", + "description": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。", + "display": "AI 表格操作", + "effect": "read", + "effect_source": "agent-hint", + "examples": [ + "dws aitable workflow edit-example" + ], + "field_provenance": { + "agent_summary": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。", + "selected": true, + "source": "internal/cli/schema_hints/selection/aitable.json", + "value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。", + "source": "internal/cli/schema_hints/selection/aitable.json", + "value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。" + }, + "availability": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": "available" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": "available" + }, + "avoid_when": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。", + "selected": true, + "source": "internal/cli/schema_hints/selection/aitable.json", + "value": [ + "实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get" + ] + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。", + "source": "internal/cli/schema_hints/selection/aitable.json", + "value": [ + "实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get" + ] + }, + "canonical_path": { + "candidates": [ + { + "precedence": "command_registry", + "selected": true, + "source": "reviewed_command_registry", + "source_ref": "aitable workflow edit-example", + "value": "aitable.workflow_edit_example" + } + ], + "precedence": "command_registry", + "resolution": "registry_identity", + "source": "reviewed_command_registry", + "source_ref": "aitable workflow edit-example", + "value": "aitable.workflow_edit_example" + }, + "confirmation": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": "not_required" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": "not_required" + }, + "description": { + "candidates": [ + { + "precedence": "cobra_help", + "selected": true, + "source": "cobra_help", + "value": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。" + } + ], + "precedence": "cobra_help", + "resolution": "highest_precedence", + "source": "cobra_help", + "value": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。" + }, + "effect": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": "read" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": "read" + }, + "examples": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。", + "selected": true, + "source": "internal/cli/schema_hints/selection/aitable.json", + "value": [ + "dws aitable workflow edit-example" + ] + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。", + "source": "internal/cli/schema_hints/selection/aitable.json", + "value": [ + "dws aitable workflow edit-example" + ] + }, + "idempotency": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": "idempotent" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": "idempotent" + }, + "interface_mode": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": "composite" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": "composite" + }, + "interface_reason": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command." + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command." + }, + "interface_ref": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "final interface mode composite forbids a direct MCP interface_ref", + "selected": true, + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": null + } + ], + "precedence": "reviewed_explicit", + "resolution": "interface_disposition_matrix", + "review_reason": "final interface mode composite forbids a direct MCP interface_ref", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": null + }, + "reviewed": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": true + }, + { + "precedence": "reviewed_explicit", + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。", + "selected": false, + "source": "internal/cli/schema_hints/selection/aitable.json", + "value": true + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": true + }, + "risk": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "selected": true, + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": "low" + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "source": "internal/cli/schema_hints/metadata/aitable.json", + "value": "low" + }, + "title": { + "candidates": [ + { + "precedence": "cobra_help", + "selected": true, + "source": "cobra_help", + "value": "获取工作流编辑文档与示例" + } + ], + "precedence": "cobra_help", + "resolution": "highest_precedence", + "source": "cobra_help", + "value": "获取工作流编辑文档与示例" + }, + "use_when": { + "candidates": [ + { + "precedence": "reviewed_explicit", + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。", + "selected": true, + "source": "internal/cli/schema_hints/selection/aitable.json", + "value": [ + "创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时" + ] + } + ], + "precedence": "reviewed_explicit", + "resolution": "highest_precedence", + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。", + "source": "internal/cli/schema_hints/selection/aitable.json", + "value": [ + "创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时" + ] + } + }, + "group": "workflow", + "has_parameters": false, + "idempotency": "idempotent", + "interface_mode": "composite", + "interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", + "is_alias": false, + "name": "workflow_edit_example", + "parameter_count": 0, + "parameters": {}, + "path": "aitable.workflow_edit_example", + "primary_cli_path": "aitable workflow edit-example", + "product_id": "aitable", + "reviewed": true, + "risk": "low", + "source": "reviewed_command_registry", + "title": "获取工作流编辑文档与示例", + "use_when": [ + "创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时" + ] + }, "aitable.workflow_enable": { "agent_metadata_source": "embedded-skill-metadata", "agent_source_refs": [ diff --git a/internal/cli/schema_command_registry/products/aitable.json b/internal/cli/schema_command_registry/products/aitable.json index c8a5d32e..376e2c34 100644 --- a/internal/cli/schema_command_registry/products/aitable.json +++ b/internal/cli/schema_command_registry/products/aitable.json @@ -452,6 +452,10 @@ "canonical_path": "aitable.workflow_disable", "cli_path": "aitable workflow disable" }, + { + "canonical_path": "aitable.workflow_edit_example", + "cli_path": "aitable workflow edit-example" + }, { "canonical_path": "aitable.workflow_enable", "cli_path": "aitable workflow enable" diff --git a/internal/cli/schema_hints/metadata/aitable.json b/internal/cli/schema_hints/metadata/aitable.json index 7bf29664..e06c60af 100644 --- a/internal/cli/schema_hints/metadata/aitable.json +++ b/internal/cli/schema_hints/metadata/aitable.json @@ -999,6 +999,19 @@ "reviewed": true, "runtime_gate": "confirm_delete" }, + "aitable.workflow_edit_example": { + "effect": "read", + "risk": "low", + "confirmation": "not_required", + "idempotency": "idempotent", + "interface_mode": "composite", + "availability": "available", + "interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", + "reviewed": true, + "review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.", + "cli_path": "aitable workflow edit-example", + "runtime_gate": "none" + }, "aitable.workflow_enable": { "interface_ref": { "product_id": "aitable-helper", diff --git a/internal/cli/schema_hints/runtime-surface-completeness.json b/internal/cli/schema_hints/runtime-surface-completeness.json index e80c4705..2bc9e838 100644 --- a/internal/cli/schema_hints/runtime-surface-completeness.json +++ b/internal/cli/schema_hints/runtime-surface-completeness.json @@ -7,7 +7,7 @@ "channel": "open-source" }, "coverage": { - "source_tools": 845, + "source_tools": 846, "matched_tools": 71 }, "tools": { diff --git a/internal/cli/schema_hints/selection/aitable.json b/internal/cli/schema_hints/selection/aitable.json index ef1f0ae8..cd3f7fc9 100644 --- a/internal/cli/schema_hints/selection/aitable.json +++ b/internal/cli/schema_hints/selection/aitable.json @@ -2250,6 +2250,26 @@ "dws-schema-live:none (helper/composite; Skill+Cobra)" ] }, + "aitable.workflow_edit_example": { + "agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。", + "use_when": [ + "创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时" + ], + "avoid_when": [ + "实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get" + ], + "examples": [ + "dws aitable workflow edit-example" + ], + "reviewed": true, + "review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。", + "source_refs": [ + "internal/cli/schema_command_registry.json#aitable.workflow_edit_example", + "cobra-help:dws aitable workflow edit-example --help", + "skills/mono/references/products/aitable/aitable-workflow.md", + "mcp-contract:aitable/edit_workflow_example" + ] + }, "aitable.workflow_enable": { "agent_summary": "启用工作流。", "use_when": [ diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index 83ff658a..7855ba43 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -853,6 +853,7 @@ func newAitableCommand() *cobra.Command { dws aitable form [list|delete|update] 表单管理 dws aitable form field [list|update|hide] 表单字段管理 dws aitable form share [get|update|notify] 表单分享管理 + dws aitable workflow [edit-example|create|update|enable|disable|get|list] 自动化工作流管理 dws aitable dashboard [get|create|update|delete|config-example] 仪表盘管理 dws aitable chart [get|create|update|delete|widgets-example] 图表管理 dws aitable export data 数据导出 @@ -3260,6 +3261,17 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正 }, } + workflowEditExampleCmd := &cobra.Command{ + Use: "edit-example", + Short: "获取工作流编辑文档与示例", + Long: `返回服务端提供的 AI 表格工作流编辑文档与示例。 +可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。`, + Example: ` dws aitable workflow edit-example`, + RunE: func(cmd *cobra.Command, args []string) error { + return callAitableTool("edit_workflow_example", map[string]any{}) + }, + } + workflowUpdateCmd := &cobra.Command{ Use: "update", Short: "更新并发布已有自动化工作流", @@ -4856,7 +4868,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。 workflowListCmd.Flags().Int("limit", 0, "分页大小 [1, 100],不传走服务端默认 20") workflowListCmd.Flags().Int("offset", 0, "分页偏移量,>= 0,不传走服务端默认 0") workflowCmd.AddCommand( - workflowCreateCmd, workflowUpdateCmd, + workflowEditExampleCmd, workflowCreateCmd, workflowUpdateCmd, workflowEnableCmd, workflowDisableCmd, workflowGetCmd, workflowListCmd, ) diff --git a/internal/helpers/aitable_workflow_write_test.go b/internal/helpers/aitable_workflow_write_test.go index d1bbe995..3546fb83 100644 --- a/internal/helpers/aitable_workflow_write_test.go +++ b/internal/helpers/aitable_workflow_write_test.go @@ -94,6 +94,23 @@ func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) { } } +func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) { + caller, err := runAitableWorkflowCommand(t, nil, "edit-example") + if err != nil { + t.Fatalf("workflow edit-example returned error: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("tool call count = %d, want 1", len(caller.calls)) + } + call := caller.calls[0] + if call.productID != "aitable" || call.toolName != "edit_workflow_example" { + t.Fatalf("tool call = %s/%s, want aitable/edit_workflow_example", call.productID, call.toolName) + } + if len(call.args) != 0 { + t.Fatalf("tool args = %#v, want empty arguments", call.args) + } +} + func TestAitableWorkflowUpdateReadsDSLFile(t *testing.T) { path := t.TempDir() + "/workflow.json" if err := os.WriteFile(path, []byte(`{"version":"workflow-dsl/v1","name":"updated"}`), 0o600); err != nil { diff --git a/skills/mono/references/products/aitable.md b/skills/mono/references/products/aitable.md index 144f8567..53e80a9d 100644 --- a/skills/mono/references/products/aitable.md +++ b/skills/mono/references/products/aitable.md @@ -105,6 +105,7 @@ | 命令 | 用途 | 必填参数 | 路由提醒 | |------|------|----------|----------| +| `workflow edit-example` | 获取编辑文档与 DSL 示例 | 无 | create/update 前优先调用,内容由服务端提供 | | `workflow create` | 创建并发布工作流 | `--base-id` `--dsl` | `--dsl` 为完整 workflow-dsl/v1;非幂等,不自动重试 | | `workflow update` | 更新并发布工作流 | `--base-id` `--workflow-id` `--dsl` | 全量替换,先 get 留底;检查 `data.valid/issues` | | `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` | diff --git a/skills/mono/references/products/aitable/aitable-workflow.md b/skills/mono/references/products/aitable/aitable-workflow.md index 1db8249b..49af4100 100644 --- a/skills/mono/references/products/aitable/aitable-workflow.md +++ b/skills/mono/references/products/aitable/aitable-workflow.md @@ -7,6 +7,7 @@ | 命令 | 用途 | |------|------| +| `workflow edit-example` | 获取工作流编辑文档与 workflow-dsl/v1 示例 | | `workflow create` | 创建并发布自动化工作流 | | `workflow update` | 更新并发布已有自动化工作流 | | `workflow list` | 列出 Base 下所有工作流(含状态/创建人/最后修改时间),支持分页 | @@ -14,11 +15,11 @@ | `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) | | `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) | -> 所有子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。 +> `workflow edit-example` 无参数;其他子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。 ## DSL 入参格式与最小 Demo -`workflow create/update` 的 `--dsl` 接收完整的 `workflow-dsl/v1` JSON object,不是局部 patch。支持内联 JSON、`@文件路径` 或 `-` 从 stdin 读取。 +先运行 `workflow edit-example` 获取服务端提供的最新编辑文档和示例。`workflow create/update` 的 `--dsl` 接收完整的 `workflow-dsl/v1` JSON object,不是局部 patch。支持内联 JSON、`@文件路径` 或 `-` 从 stdin 读取。 复杂工作流应先用 `table get` / `field get` / `view list` 确认真实 `sheetId`、`fieldId`、`viewId`,并检查所有 `next`、`loopEntry`、branch `to` 和 ref。下面是一个不依赖数据表字段的最小定时消息工作流: @@ -53,6 +54,14 @@ create 和 update 都必须同时满足 `status=success`、`data.valid=true`、` ## 命令详情 +### workflow edit-example — 获取编辑文档与示例 + +```bash +dws aitable workflow edit-example --format json +``` + +该命令无业务参数,调用 `aitable/edit_workflow_example` 返回服务端提供的工作流编辑文档和示例。创建或更新复杂工作流前优先调用它,避免依赖可能过期的本地 DSL 结构。 + ### workflow create — 创建并发布工作流 ```bash diff --git a/skills/multi/dingtalk-aitable/references/aitable.md b/skills/multi/dingtalk-aitable/references/aitable.md index 890bea3e..5f210a52 100644 --- a/skills/multi/dingtalk-aitable/references/aitable.md +++ b/skills/multi/dingtalk-aitable/references/aitable.md @@ -134,6 +134,7 @@ Flags: | 命令 | 用途 | 必填参数 | 路由提醒 | |------|------|----------|----------| +| `workflow edit-example` | 获取编辑文档与 DSL 示例 | 无 | create/update 前优先调用,内容由服务端提供 | | `workflow create` | 创建并发布自动化工作流 | `--base-id` `--dsl` | 按子文档 Demo 组装 DSL;必须检查返回的 `data.valid` / `issues`;create 不自动重试 | | `workflow update` | 更新并发布已有自动化工作流 | `--base-id` `--workflow-id` `--dsl` | 先 get 留底;提交完整目标 DSL;必须检查 `data.valid` / `issues` | | `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` | diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-workflow.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-workflow.md index 6df46e2b..eb3640f1 100644 --- a/skills/multi/dingtalk-aitable/references/aitable/aitable-workflow.md +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-workflow.md @@ -7,6 +7,7 @@ | 命令 | 用途 | |------|------| +| `workflow edit-example` | 获取工作流编辑文档与 workflow-dsl/v1 示例 | | `workflow create` | 创建并发布自动化工作流 | | `workflow update` | 更新并发布已有自动化工作流 | | `workflow list` | 列出 Base 下所有工作流(含状态/创建人/最后修改时间),支持分页 | @@ -14,15 +15,15 @@ | `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) | | `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) | -> 所有子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。 +> `workflow edit-example` 无参数;其他子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。 ## DSL 入参格式与最小 Demo -`workflow create/update` 的 `--dsl` 接收钉钉 AI 表格 `workflow-dsl/v1` JSON object。当前同步范围只包含 create/update,没有新增 DSL 文档子命令;其他 Agent 可以直接使用下面的最小 Demo 理解调用格式。 +先运行 `workflow edit-example` 获取服务端提供的最新编辑文档和示例。`workflow create/update` 的 `--dsl` 接收钉钉 AI 表格 `workflow-dsl/v1` JSON object。 复杂工作流还应注意: -1. 如果 Agent 运行环境直接提供 AI 表格 MCP 的 `get_workflow_dsl_docs`,可用它获取最新 DSL Guide、Schema 和示例。 +1. 使用 `workflow edit-example` 获取最新 DSL Guide、结构和示例。 2. 涉及数据表、字段或视图的节点,先用 `table get` / `field get` / `view list` 确认真实 `sheetId`、`fieldId`、`viewId`。 3. create 和 update 都提交完整的 workflow-dsl/v1 JSON object,并检查所有 `next`、`loopEntry`、branch `to` 和 ref。 @@ -82,6 +83,14 @@ create 和 update 都必须同时满足 `status=success`、`data.valid=true`、` ## 命令详情 +### workflow edit-example — 获取编辑文档与示例 + +```bash +dws aitable workflow edit-example --format json +``` + +该命令无业务参数,调用 `aitable/edit_workflow_example` 返回服务端提供的工作流编辑文档和示例。创建或更新复杂工作流前优先调用它,避免依赖可能过期的本地 DSL 结构。 + ### workflow create — 创建并发布工作流 ```bash From 015a1f85caa1b96d1fadc2ae3362613cf90ba92a Mon Sep 17 00:00:00 2001 From: wxianfeng Date: Mon, 3 Aug 2026 15:42:17 +0800 Subject: [PATCH 04/15] fix(event): satisfy platform coverage gate --- CHANGELOG.md | 2 +- internal/event/endpoint.go | 12 ++- internal/event/endpoint_portable_test.go | 18 ++++- internal/event/endpoint_test.go | 13 ++++ .../event/transport/coverage_edges_test.go | 77 +++++++++++++++++++ internal/event/transport/transport_unix.go | 19 +++-- .../event/transport/transport_unix_test.go | 14 ++-- 7 files changed, 136 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b47008ab..061ff0a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Fixed -- **Event bus sockets on shared filesystems** — Unix event buses now place their local IPC socket in a private per-user runtime directory (`XDG_RUNTIME_DIR` when available, otherwise a `0700` per-UID directory under the system temporary directory) while retaining locks, metadata, logs, and subscription state in the configured Workdir. Listener and dial paths validate directory ownership and permissions before use. This prevents `dws event consume` from failing with `bind: errno 524` when `~/.dws` is hosted on NFS, CSI, FUSE, or another filesystem that does not support Unix Domain Sockets without exposing a predictable socket in a shared `/tmp` root. +- **Event bus sockets on shared filesystems** — Unix event buses now place their local IPC socket in a private per-user runtime directory (`XDG_RUNTIME_DIR` when available, otherwise a `0700` per-UID directory under the system temporary directory) while retaining locks, metadata, logs, and subscription state in the configured Workdir. Listener and dial paths validate directory ownership and permissions before use. This prevents `dws event consume` from failing with `bind: errno 524` when `~/.dws` is hosted on NFS, CSI, FUSE, or another filesystem that does not support Unix Domain Sockets without exposing the socket directly in a shared `/tmp` root. When `XDG_RUNTIME_DIR` is unavailable, the per-UID directory name is deterministic: ownership validation prevents endpoint hijacking, but another local user can pre-create the directory to deny service; multi-user deployments should provide a private `XDG_RUNTIME_DIR`. ## [1.0.56-beta.2] - 2026-07-30 diff --git a/internal/event/endpoint.go b/internal/event/endpoint.go index 64a09e51..a242403d 100644 --- a/internal/event/endpoint.go +++ b/internal/event/endpoint.go @@ -70,13 +70,21 @@ func ipcEndpointForOS(goos, workDir, editionName string, sourceKind SourceKind, if goos == "windows" { return `\\.\pipe\dws-event-` + editionName + "-" + string(sourceKind) + "-" + identityHash } + return unixSocketEndpoint(goos, workDir, strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")), os.TempDir()) +} + +func unixSocketEndpoint(goos, workDir, runtimeDir, tempDir string) string { socketName := "dws-evt-" + IdentityHash(workDir) + ".sock" userDirName := eventRuntimeDirPrefix + currentUserID() - if runtimeDir := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); filepath.IsAbs(runtimeDir) { + if filepath.IsAbs(runtimeDir) { candidate := filepath.Join(runtimeDir, userDirName, socketName) if len(candidate) <= maxUnixSocketPath(goos) { return candidate } } - return filepath.Join(os.TempDir(), userDirName, socketName) + fallback := filepath.Join(tempDir, userDirName, socketName) + if len(fallback) <= maxUnixSocketPath(goos) { + return fallback + } + return filepath.Join("/tmp", userDirName, socketName) } diff --git a/internal/event/endpoint_portable_test.go b/internal/event/endpoint_portable_test.go index 9c0fb425..ede9e29a 100644 --- a/internal/event/endpoint_portable_test.go +++ b/internal/event/endpoint_portable_test.go @@ -21,7 +21,6 @@ import ( ) func TestCrossPlatformCoverageEndpointPortableCoverageEdges(t *testing.T) { - t.Setenv("XDG_RUNTIME_DIR", "") if got := MaxUnixSocketPath(); got != 103 && got != 107 { t.Fatalf("MaxUnixSocketPath() = %d", got) } @@ -35,10 +34,27 @@ func TestCrossPlatformCoverageEndpointPortableCoverageEdges(t *testing.T) { if got := ipcEndpointForOS("windows", "ignored", "open", "", "hash"); got != `\\.\pipe\dws-event-open-app_stream-hash` { t.Fatalf("Windows endpoint = %q", got) } + + runtimeRoot := filepath.VolumeName(os.TempDir()) + string(filepath.Separator) + runtimeDir := filepath.Join(runtimeRoot, "dws-xdg-runtime") + t.Setenv("XDG_RUNTIME_DIR", runtimeDir) + workDir := "portable-xdg-workdir" + wantXDG := filepath.Join(runtimeDir, eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock") + if got := ipcEndpointForOS("linux", workDir, "open", SourceKindPersonalStream, "hash"); got != wantXDG { + t.Fatalf("XDG Unix endpoint = %q, want %q", got, wantXDG) + } + + t.Setenv("XDG_RUNTIME_DIR", "") if got := ipcEndpointForOS("darwin", "short", "open", SourceKindPersonalStream, "hash"); got != filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash("short")+".sock") { t.Fatalf("short Unix endpoint = %q", got) } if got := ipcEndpointForOS("darwin", strings.Repeat("x", 200), "open", SourceKindAppStream, "hash"); !strings.Contains(got, "dws-evt-") { t.Fatalf("long Unix endpoint = %q", got) } + + longTempDir := filepath.Join(string(filepath.Separator), strings.Repeat("long-temp-root", 20)) + wantShortFallback := filepath.Join("/tmp", eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock") + if got := unixSocketEndpoint("darwin", workDir, "", longTempDir); got != wantShortFallback { + t.Fatalf("overlong temp endpoint = %q, want %q", got, wantShortFallback) + } } diff --git a/internal/event/endpoint_test.go b/internal/event/endpoint_test.go index 81df50a3..d169fc09 100644 --- a/internal/event/endpoint_test.go +++ b/internal/event/endpoint_test.go @@ -116,3 +116,16 @@ func TestIPCEndpointLongXDGPathFallsBackToTempDir(t *testing.T) { t.Fatalf("fallback path still too long: %d > %d (%q)", len(got), maxUnixSocketPath("linux"), got) } } + +func TestIPCEndpointLongTempDirUsesShortSystemFallback(t *testing.T) { + workDir := "/shared/events/open/personal_stream/aabbccdd00112233" + longTempDir := "/" + strings.Repeat("long-temp-root/", 20) + got := unixSocketEndpoint("linux", workDir, "", longTempDir) + want := filepath.Join("/tmp", eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock") + if got != want { + t.Fatalf("IPCEndpoint = %q, want short fallback %q", got, want) + } + if len(got) > maxUnixSocketPath("linux") { + t.Fatalf("short fallback path too long: %d > %d (%q)", len(got), maxUnixSocketPath("linux"), got) + } +} diff --git a/internal/event/transport/coverage_edges_test.go b/internal/event/transport/coverage_edges_test.go index bfc97e9a..a68994fb 100644 --- a/internal/event/transport/coverage_edges_test.go +++ b/internal/event/transport/coverage_edges_test.go @@ -73,6 +73,83 @@ func TestCrossPlatformCoverageUnixListenErrorCoverage(t *testing.T) { } } +func TestCrossPlatformCoverageUnixSocketDirectoryErrorCoverage(t *testing.T) { + oldLstat, oldRuntimeStat, oldMkdir := lstatSocketPath, statSocketRuntimeRoot, mkdirSocketDir + t.Cleanup(func() { + lstatSocketPath, statSocketRuntimeRoot, mkdirSocketDir = oldLstat, oldRuntimeStat, oldMkdir + }) + + wantErr := errors.New("synthetic socket directory failure") + if err := ensureSocketDir("relative/bus.sock", true); err == nil || !strings.Contains(err.Error(), "must be absolute") { + t.Fatalf("relative socket path error = %v", err) + } + + root := shortSecureTempDir(t) + missingRootPath := filepath.Join(root, "missing-root", "dws-event-test", "bus.sock") + if err := ensureSocketDir(missingRootPath, false); err == nil || !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing runtime root error = %v", err) + } + + rootInfo, err := oldLstat(root) + if err != nil { + t.Fatalf("lstat secure root: %v", err) + } + lstatSocketPath = func(path string) (os.FileInfo, error) { + if path == "/tmp" { + return fileInfoWithMode{FileInfo: rootInfo, mode: os.ModeSymlink | 0o777}, nil + } + return oldLstat(path) + } + statSocketRuntimeRoot = func(path string) (os.FileInfo, error) { + if path == "/tmp" { + return nil, wantErr + } + return oldRuntimeStat(path) + } + if err := ensureSocketDir("/tmp/dws-event-coverage/bus.sock", false); !errors.Is(err, wantErr) { + t.Fatalf("runtime root resolution error = %v", err) + } + lstatSocketPath, statSocketRuntimeRoot = oldLstat, oldRuntimeStat + + rootFile := filepath.Join(root, "runtime-root-file") + if err := os.WriteFile(rootFile, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("write runtime root file: %v", err) + } + if err := ensureSocketDir(filepath.Join(rootFile, "dws-event-test", "bus.sock"), false); err == nil || !strings.Contains(err.Error(), "runtime root is not a directory") { + t.Fatalf("non-directory runtime root error = %v", err) + } + + mkdirSocketDir = func(string, os.FileMode) error { return wantErr } + if err := ensureSocketDir(filepath.Join(root, "mkdir-failure", "bus.sock"), true); !errors.Is(err, wantErr) { + t.Fatalf("socket directory creation error = %v", err) + } + mkdirSocketDir = oldMkdir + + if err := ensureSocketDir(filepath.Join(root, "missing-socket-dir", "bus.sock"), false); err == nil || !errors.Is(err, os.ErrNotExist) { + t.Fatalf("missing socket directory error = %v", err) + } + + withoutOwner := fileInfoWithoutOwner{FileInfo: rootInfo} + if err := validateSocketRuntimeRoot(root, withoutOwner, uint32(os.Geteuid())); err == nil || !strings.Contains(err.Error(), "owner") { + t.Fatalf("runtime root owner error = %v", err) + } + if err := validatePrivateSocketDir(root, withoutOwner, uint32(os.Geteuid())); err == nil || !strings.Contains(err.Error(), "owner") { + t.Fatalf("socket directory owner error = %v", err) + } +} + +type fileInfoWithMode struct { + os.FileInfo + mode os.FileMode +} + +func (f fileInfoWithMode) Mode() os.FileMode { return f.mode } +func (f fileInfoWithMode) IsDir() bool { return f.mode.IsDir() } + +type fileInfoWithoutOwner struct{ os.FileInfo } + +func (fileInfoWithoutOwner) Sys() any { return struct{}{} } + type stubNetListener struct { close func() error } diff --git a/internal/event/transport/transport_unix.go b/internal/event/transport/transport_unix.go index 4f61cecf..79b93138 100644 --- a/internal/event/transport/transport_unix.go +++ b/internal/event/transport/transport_unix.go @@ -33,10 +33,13 @@ type unixListener struct { } var ( - statSocket = os.Stat - removeSocket = os.Remove - listenUnix = net.Listen - chmodSocket = os.Chmod + statSocket = os.Stat + removeSocket = os.Remove + listenUnix = net.Listen + chmodSocket = os.Chmod + lstatSocketPath = os.Lstat + statSocketRuntimeRoot = os.Stat + mkdirSocketDir = os.Mkdir ) func (u *unixListener) Accept() (net.Conn, error) { return u.l.Accept() } @@ -70,7 +73,7 @@ func ensureSocketDir(path string, create bool) error { } dir := filepath.Dir(path) root := filepath.Dir(dir) - rootInfo, err := os.Lstat(root) + rootInfo, err := lstatSocketPath(root) if err != nil { return fmt.Errorf("transport: inspect socket runtime root %s: %w", root, err) } @@ -78,7 +81,7 @@ func ensureSocketDir(path string, create bool) error { // Follow only that well-known alias, then apply the same ownership/sticky // validation to its target. Arbitrary runtime-root symlinks remain rejected. if rootInfo.Mode()&os.ModeSymlink != 0 && filepath.Clean(root) == "/tmp" { - rootInfo, err = os.Stat(root) + rootInfo, err = statSocketRuntimeRoot(root) if err != nil { return fmt.Errorf("transport: resolve socket runtime root %s: %w", root, err) } @@ -87,11 +90,11 @@ func ensureSocketDir(path string, create bool) error { return err } if create { - if err := os.Mkdir(dir, config.DirPerm); err != nil && !errors.Is(err, os.ErrExist) { + if err := mkdirSocketDir(dir, config.DirPerm); err != nil && !errors.Is(err, os.ErrExist) { return fmt.Errorf("transport: create socket directory %s: %w", dir, err) } } - dirInfo, err := os.Lstat(dir) + dirInfo, err := lstatSocketPath(dir) if err != nil { return fmt.Errorf("transport: inspect socket directory %s: %w", dir, err) } diff --git a/internal/event/transport/transport_unix_test.go b/internal/event/transport/transport_unix_test.go index 1928eec7..f896ed06 100644 --- a/internal/event/transport/transport_unix_test.go +++ b/internal/event/transport/transport_unix_test.go @@ -119,7 +119,7 @@ func TestListen_StaleSocketCleanup(t *testing.T) { defer l.Close() } -func TestListen_CreatesPrivateSocketDirectory(t *testing.T) { +func TestCrossPlatformCoverageListenCreatesPrivateSocketDirectory(t *testing.T) { dir := filepath.Join(shortSecureTempDir(t), "dws-event-test") path := filepath.Join(dir, "bus.sock") l, err := Listen(path) @@ -137,7 +137,7 @@ func TestListen_CreatesPrivateSocketDirectory(t *testing.T) { } } -func TestListen_RejectsWorldAccessibleSocketDirectory(t *testing.T) { +func TestCrossPlatformCoverageListenRejectsWorldAccessibleSocketDirectory(t *testing.T) { dir := filepath.Join(shortSecureTempDir(t), "dws-event-test") if err := os.Mkdir(dir, 0o700); err != nil { t.Fatalf("mkdir: %v", err) @@ -150,7 +150,7 @@ func TestListen_RejectsWorldAccessibleSocketDirectory(t *testing.T) { } } -func TestDial_RejectsWorldAccessibleSocketDirectory(t *testing.T) { +func TestCrossPlatformCoverageDialRejectsWorldAccessibleSocketDirectory(t *testing.T) { dir := filepath.Join(shortSecureTempDir(t), "dws-event-test") if err := os.Mkdir(dir, 0o700); err != nil { t.Fatalf("mkdir: %v", err) @@ -163,7 +163,7 @@ func TestDial_RejectsWorldAccessibleSocketDirectory(t *testing.T) { } } -func TestListen_RejectsSymlinkSocketDirectory(t *testing.T) { +func TestCrossPlatformCoverageListenRejectsSymlinkSocketDirectory(t *testing.T) { root := shortSecureTempDir(t) target := filepath.Join(root, "target") if err := os.Mkdir(target, 0o700); err != nil { @@ -178,7 +178,7 @@ func TestListen_RejectsSymlinkSocketDirectory(t *testing.T) { } } -func TestValidatePrivateSocketDirRejectsDifferentOwner(t *testing.T) { +func TestCrossPlatformCoverageValidatePrivateSocketDirRejectsDifferentOwner(t *testing.T) { dir := shortSecureTempDir(t) st, err := os.Lstat(dir) if err != nil { @@ -190,7 +190,7 @@ func TestValidatePrivateSocketDirRejectsDifferentOwner(t *testing.T) { } } -func TestListen_RejectsUntrustedRuntimeRoot(t *testing.T) { +func TestCrossPlatformCoverageListenRejectsUntrustedRuntimeRoot(t *testing.T) { root := filepath.Join(shortSecureTempDir(t), "untrusted") if err := os.Mkdir(root, 0o700); err != nil { t.Fatalf("mkdir root: %v", err) @@ -207,7 +207,7 @@ func TestListen_RejectsUntrustedRuntimeRoot(t *testing.T) { } } -func TestListen_SharedWorkDirUsesLocalSecureRuntimeEndpoint(t *testing.T) { +func TestCrossPlatformCoverageListenSharedWorkDirUsesLocalSecureRuntimeEndpoint(t *testing.T) { root := shortSecureTempDir(t) runtimeDir := filepath.Join(root, "runtime") if err := os.Mkdir(runtimeDir, 0o700); err != nil { From e99c20a0a13a3c4bee07498c816adb9bd88eb22c Mon Sep 17 00:00:00 2001 From: chichuan Date: Mon, 3 Aug 2026 20:34:17 +0800 Subject: [PATCH 05/15] docs: seal v1.0.56-beta.3 changelog --- CHANGELOG.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d631eb86..95265ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,20 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.56-beta.3] - 2026-08-03 + +This beta adds PRs #846 and #851 on top of v1.0.56-beta.2. It adds a +service-provided Aitable workflow-editing reference command and makes local +event-bus IPC reliable on shared filesystems by placing Unix sockets in a +validated private runtime directory. + ### Added -- **Aitable workflow editing reference** — adds `dws aitable workflow edit-example`, a parameter-free read command that returns the service-provided workflow editing documentation and `workflow-dsl/v1` examples through `aitable/edit_workflow_example`. +- **Aitable workflow editing reference** (#851) — adds `dws aitable workflow edit-example`, a parameter-free read command that returns the service-provided workflow editing documentation and `workflow-dsl/v1` examples through `aitable/edit_workflow_example`. ### Fixed -- **Event bus sockets on shared filesystems** — Unix event buses now place their local IPC socket in a private per-user runtime directory (`XDG_RUNTIME_DIR` when available, otherwise a `0700` per-UID directory under the system temporary directory) while retaining locks, metadata, logs, and subscription state in the configured Workdir. Listener and dial paths validate directory ownership and permissions before use. This prevents `dws event consume` from failing with `bind: errno 524` when `~/.dws` is hosted on NFS, CSI, FUSE, or another filesystem that does not support Unix Domain Sockets without exposing the socket directly in a shared `/tmp` root. When `XDG_RUNTIME_DIR` is unavailable, the per-UID directory name is deterministic: ownership validation prevents endpoint hijacking, but another local user can pre-create the directory to deny service; multi-user deployments should provide a private `XDG_RUNTIME_DIR`. +- **Event bus sockets on shared filesystems** (#846) — Unix event buses now place their local IPC socket in a private per-user runtime directory (`XDG_RUNTIME_DIR` when available, otherwise a `0700` per-UID directory under the system temporary directory) while retaining locks, metadata, logs, and subscription state in the configured Workdir. Listener and dial paths validate directory ownership and permissions before use. This prevents `dws event consume` from failing with `bind: errno 524` when `~/.dws` is hosted on NFS, CSI, FUSE, or another filesystem that does not support Unix Domain Sockets without exposing the socket directly in a shared `/tmp` root. When `XDG_RUNTIME_DIR` is unavailable, the per-UID directory name is deterministic: ownership validation prevents endpoint hijacking, but another local user can pre-create the directory to deny service; multi-user deployments should provide a private `XDG_RUNTIME_DIR`. ## [1.0.56-beta.2] - 2026-07-30 From b2cbca27628e184576bd1ecf096539c8a29cb7e6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:55:19 +0000 Subject: [PATCH 06/15] chore: update beta formula for v1.0.56-beta.3 [skip ci] --- Formula/dingtalk-workspace-cli-beta.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index 3cce7fa2..1579349c 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCliBeta < Formula desc "Automate DingTalk workspace tasks from the terminal (beta channel)" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.56-beta.2" + version "1.0.56-beta.3" license "Apache-2.0" keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-darwin-arm64.tar.gz" - sha256 "19b52b5427dbf24acfeb1da16a93e6edfd0443389a778abbbfd381ff8f656139" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-darwin-arm64.tar.gz" + sha256 "5d35bb3fca7883a4ee51e1561aefd6b954b104313359a7c05b30a333ea41e749" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-darwin-amd64.tar.gz" - sha256 "621da52d04f391234d160a0522d70c1fb2e36da59102ef201a9a3a11b706b3e8" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-darwin-amd64.tar.gz" + sha256 "5a94069a3ab2c811d915639bbfd9ff17035b77501d5f9070c0b540ffe525a41b" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-linux-arm64.tar.gz" - sha256 "4ba956463f4b583c1727f58026a6bc1fb23d27537083e3bafd541a05ab15b48b" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-linux-arm64.tar.gz" + sha256 "e9cbc1c647f3ea703e1b93264c0e7d92871cfabe26fef26fbe17b9dff4b80c0f" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-linux-amd64.tar.gz" - sha256 "a8156ec5b89355faf8c08a65d9c088f89a7b411a418fb4b488f3d472efc79670" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-linux-amd64.tar.gz" + sha256 "7c475841ea871d204f9f6efc7d6e5378cf19db4541aeaa8a27d1387fa6f2a1f1" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-skills.zip" - sha256 "92c71fdeade88b3b76a00cb74ebd3223cc4110c7ee151d36d782537613129967" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-skills.zip" + sha256 "b29b35345613bc9260ae37578eb982472591c9dc548b02176b5b9ba0bdc431f2" end def install From bf33ab622fd9081baefcd658a7719147633520db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Mon, 3 Aug 2026 20:27:42 +0800 Subject: [PATCH 07/15] fix(chat): restore download media JSON result --- CHANGELOG.md | 4 + internal/helpers/chat.go | 16 +++- internal/helpers/chat_download_media_test.go | 95 ++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 internal/helpers/chat_download_media_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 95265ad3..9e1a57dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Fixed + +- **Chat media download JSON compatibility** — `dws chat message download-media --format json` once again returns a clean `{success, downloadUrl, output}` result after the file is saved, preserving the temporary URL and resolved local path without progress text corrupting JSON stdout. + ## [1.0.56-beta.3] - 2026-08-03 This beta adds PRs #846 and #851 on top of v1.0.56-beta.2. It adds a diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index f4861bc5..35708870 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -3434,6 +3434,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 conversationID := mustGetFlag(cmd, "open-conversation-id") messageID := mustGetFlag(cmd, "message-id") outputPath := mustGetFlag(cmd, "output") + jsonMode := deps.Caller.Format() == "json" switch resourceType { case "mediaId": @@ -3455,7 +3456,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 ctx := context.Background() // Step 1: 获取下载 URL - deps.Out.PrintInfo("[1/2] 获取资源下载链接...") + if !jsonMode { + deps.Out.PrintInfo("[1/2] 获取资源下载链接...") + } text, err := callMCPToolReturnTextOnServer(ctx, "im", "get_resource_download_url", map[string]any{ "resourceType": resourceType, "resourceId": resourceID, @@ -3488,11 +3491,20 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 } // Step 2: HTTP GET 下载文件 - deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载资源到 %s ...", outputPath)) + if !jsonMode { + deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载资源到 %s ...", outputPath)) + } if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil { return err } + if jsonMode { + return deps.Out.PrintJSONUnescaped(map[string]any{ + "success": true, + "downloadUrl": resourceURL, + "output": outputPath, + }) + } deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath)) return nil }, diff --git a/internal/helpers/chat_download_media_test.go b/internal/helpers/chat_download_media_test.go new file mode 100644 index 00000000..e35244e9 --- /dev/null +++ b/internal/helpers/chat_download_media_test.go @@ -0,0 +1,95 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestChatDownloadMediaJSONPreservesLegacyResult(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + previousHTTPGetFile := httpGetFile + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { + deps = previousDeps + os.Args = previousArgs + httpGetFile = previousHTTPGetFile + }) + + const downloadURL = "https://download.example.test/photo.jpg?token=one&part=two" + caller := &scriptedToolCaller{ + format: "json", + steps: []scriptedToolStep{{ + text: `{"resourceUrl":"` + downloadURL + `"}`, + }}, + } + InitDeps(caller) + var stdout bytes.Buffer + deps.Out = &Formatter{w: &stdout, errW: io.Discard} + + outputDir := t.TempDir() + wantOutput := filepath.Join(outputDir, "photo.jpg") + httpGetFile = func(_ context.Context, gotURL string, _ map[string]string, gotOutput string) error { + if gotURL != downloadURL { + t.Fatalf("download URL = %q, want %q", gotURL, downloadURL) + } + if gotOutput != wantOutput { + t.Fatalf("download output = %q, want %q", gotOutput, wantOutput) + } + return nil + } + + root := newChatCommand() + installExampleGlobalFlags(root) + root.SilenceErrors = true + root.SilenceUsage = true + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs([]string{ + "message", "download-media", + "--type=mediaId", + "--resource-id=resource", + "--message-id=message", + "--open-conversation-id=conversation", + "--output=" + outputDir, + }) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatal(err) + } + + var got struct { + Success bool `json:"success"` + DownloadURL string `json:"downloadUrl"` + Output string `json:"output"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String()) + } + if !got.Success || got.DownloadURL != downloadURL || got.Output != wantOutput { + t.Fatalf("result = %#v, want success=true downloadUrl=%q output=%q", got, downloadURL, wantOutput) + } + if strings.Contains(stdout.String(), "[INFO]") { + t.Fatalf("JSON stdout contains progress text: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), "?token=one&part=two") { + t.Fatalf("downloadUrl was escaped or changed: %s", stdout.String()) + } +} From b1f4a5d62a221178895443cdb02ccf9449d86672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Mon, 3 Aug 2026 21:50:06 +0800 Subject: [PATCH 08/15] test(chat): add download-media CLI integration coverage --- test/mock_mcp/mock_mcp_smoke_test.go | 187 +++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/test/mock_mcp/mock_mcp_smoke_test.go b/test/mock_mcp/mock_mcp_smoke_test.go index a97cb649..0a9afda8 100644 --- a/test/mock_mcp/mock_mcp_smoke_test.go +++ b/test/mock_mcp/mock_mcp_smoke_test.go @@ -163,6 +163,193 @@ func TestMockMCPSmoke_CLIRoutesSerializedArgumentsAndPrintsJSON(t *testing.T) { } } +func TestMockMCPSmoke_ChatDownloadMediaJSONCLI(t *testing.T) { + type mediaRequest struct { + method string + path string + query string + header string + } + + mediaPayload := []byte("synthetic media payload") + var mediaRequestsMu sync.Mutex + var mediaRequests []mediaRequest + mediaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaRequestsMu.Lock() + mediaRequests = append(mediaRequests, mediaRequest{ + method: r.Method, + path: r.URL.Path, + query: r.URL.RawQuery, + header: r.Header.Get("X-Download-Fixture"), + }) + mediaRequestsMu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mediaPayload) + })) + defer mediaServer.Close() + + downloadURL := mediaServer.URL + "/photo.jpg?fixture=one&part=two" + downloadInfo, err := json.Marshal(map[string]any{ + "resourceUrl": downloadURL, + "headers": map[string]string{ + "X-Download-Fixture": "synthetic", + }, + }) + if err != nil { + t.Fatal(err) + } + + var requestsMu sync.Mutex + var requests []recordedToolCall + mcpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call := recordedToolCall{ + path: r.URL.Path, + method: r.Method, + authorization: r.Header.Get("Authorization"), + } + + var envelope struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&envelope); err != nil { + call.err = err + } else { + call.jsonrpc = envelope.JSONRPC + call.tool = envelope.Params.Name + call.arguments = envelope.Params.Arguments + if envelope.Method != "tools/call" { + call.err = fmt.Errorf("JSON-RPC method = %q, want tools/call", envelope.Method) + } + } + requestsMu.Lock() + requests = append(requests, call) + requestsMu.Unlock() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": envelope.ID, + "result": map[string]any{ + "content": []map[string]any{{ + "type": "text", + "text": string(downloadInfo), + }}, + }, + }) + })) + defer mcpServer.Close() + + outputDir := t.TempDir() + env := isolatedCLIEnv(t, map[string]string{ + "DINGTALK_IM_MCP_URL": mcpServer.URL + "/mcp/im", + }) + args := []string{ + "--token", "ci-smoke-token", + "--format", "json", + "chat", "message", "download-media", + "--type", "mediaId", + "--resource-id", "resource-001", + "--message-id", "message-001", + "--open-conversation-id", "conversation-001", + "--output", outputDir, + } + stdout, stderr, err := runCLI(t, env, args...) + if err != nil { + t.Fatalf("dws chat message download-media failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + + requestsMu.Lock() + recorded := append([]recordedToolCall(nil), requests...) + requestsMu.Unlock() + if len(recorded) != 1 { + t.Fatalf("local fake MCP server received %d requests, want exactly one tools/call: %#v", len(recorded), recorded) + } + call := recorded[0] + if call.err != nil { + t.Fatal(call.err) + } + if call.path != "/mcp/im" || call.method != http.MethodPost || call.jsonrpc != "2.0" { + t.Fatalf("MCP request = path %q method %q jsonrpc %q", call.path, call.method, call.jsonrpc) + } + if call.authorization != "Bearer ci-smoke-token" { + t.Fatalf("Authorization = %q, want synthetic smoke token", call.authorization) + } + if call.tool != "get_resource_download_url" { + t.Fatalf("tool = %q, want get_resource_download_url", call.tool) + } + wantArgs := map[string]any{ + "resourceType": "mediaId", + "resourceId": "resource-001", + "openMessageId": "message-001", + "openConversationId": "conversation-001", + } + if !reflect.DeepEqual(call.arguments, wantArgs) { + t.Fatalf("arguments = %#v, want %#v", call.arguments, wantArgs) + } + + mediaRequestsMu.Lock() + recordedMedia := append([]mediaRequest(nil), mediaRequests...) + mediaRequestsMu.Unlock() + if len(recordedMedia) != 1 { + t.Fatalf("media server received %d requests, want exactly one: %#v", len(recordedMedia), recordedMedia) + } + gotMedia := recordedMedia[0] + if gotMedia.method != http.MethodGet || gotMedia.path != "/photo.jpg" { + t.Fatalf("media request = method %q path %q", gotMedia.method, gotMedia.path) + } + if gotMedia.query != "fixture=one&part=two" { + t.Fatalf("media query = %q, want fixture=one&part=two", gotMedia.query) + } + if gotMedia.header != "synthetic" { + t.Fatalf("media header = %q, want synthetic", gotMedia.header) + } + + wantOutput := filepath.Join(outputDir, "photo.jpg") + gotPayload, err := os.ReadFile(wantOutput) + if err != nil { + t.Fatalf("read downloaded file: %v", err) + } + if !bytes.Equal(gotPayload, mediaPayload) { + t.Fatalf("downloaded payload = %q, want %q", gotPayload, mediaPayload) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("CLI returned non-JSON stdout: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + if len(result) != 3 || result["success"] != true || result["downloadUrl"] != downloadURL || result["output"] != wantOutput { + t.Fatalf("CLI result = %#v, want exact success/downloadUrl/output contract", result) + } + if strings.Contains(stdout, "[INFO]") { + t.Fatalf("JSON stdout contains progress text: %s", stdout) + } + if !strings.Contains(stdout, "?fixture=one&part=two") { + t.Fatalf("downloadUrl was escaped or changed: %s", stdout) + } + + publicResult := map[string]any{ + "success": true, + "downloadUrl": "http://127.0.0.1:/photo.jpg?fixture=one&part=two", + "output": "./downloads/photo.jpg", + } + var publicOutput bytes.Buffer + publicEncoder := json.NewEncoder(&publicOutput) + publicEncoder.SetEscapeHTML(false) + publicEncoder.SetIndent("", " ") + if err := publicEncoder.Encode(publicResult); err != nil { + t.Fatal(err) + } + t.Log("CLI (public-safe): dws chat message download-media --type mediaId --resource-id resource-001 --message-id message-001 --open-conversation-id conversation-001 --output ./downloads/ --format json") + t.Logf("stdout (public-safe):\n%s", strings.TrimSpace(publicOutput.String())) + t.Logf("downloaded file: photo.jpg (%d bytes, content verified)", len(gotPayload)) +} + func isolatedCLIEnv(t *testing.T, extra map[string]string) []string { t.Helper() From 34c0c86a59d450c58bb6ca478628a03afbe3c283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=8A=E5=9C=AD?= Date: Mon, 3 Aug 2026 15:50:29 +0800 Subject: [PATCH 09/15] feat: center protocol upload/download refactoring with multipart download - Add multipart download engine (drive_transfer.go) with Range probe, resume support, and credential auto-refresh on 401/403 - Add --part-size, --parallel, --no-resume flags to drive download and download-version commands - Replace httpGetFile with driveTransferDownload for chunked parallel downloads in download and download-version commands - Replace uploadToDrive credential parsing with driveUploadPut (transparent header pass-through, retry on 401/403) - Add typed httpStatusError for non-2xx HTTP responses in doc.go - Add comprehensive unit tests (32 cases) for drive_transfer - Update drive reference documentation with multipart download behavior - Add E2E test for multipart download (auto-test/, gitignored) CR: 28984991 --- internal/helpers/doc.go | 6 +- internal/helpers/drive.go | 111 +- internal/helpers/drive_transfer.go | 796 ++++++++++++ internal/helpers/drive_transfer_test.go | 1089 +++++++++++++++++ skills/mono/references/products/drive.md | 9 + .../multi/dingtalk-drive/references/drive.md | 10 + 6 files changed, 2009 insertions(+), 12 deletions(-) create mode 100644 internal/helpers/drive_transfer.go create mode 100644 internal/helpers/drive_transfer_test.go diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index ab2c0ee3..785cc1ac 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -276,7 +276,8 @@ func defaultHTTPPutFile(ctx context.Context, url string, headers map[string]stri if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("OSS upload failed: HTTP %d: %s", resp.StatusCode, string(body)) + // typed httpStatusError 供上层按 401/403 分支重取凭证 + return fmt.Errorf("OSS upload failed: %w", &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}) } return nil @@ -434,7 +435,8 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + // typed httpStatusError 供上层按 401/403 分支重取凭证 + return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)} } outFile, err := docCreateDestination(destPath) diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index 87211ed1..69b6968a 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -476,7 +476,8 @@ func newDriveCommand() *cobra.Command { --output 指定本地保存路径,可以是文件路径或目录。 如果指定目录,文件名从下载 URL 中自动推断。`, Example: ` dws drive download --node --output ./report.pdf - dws drive download --node --output ~/downloads/`, + dws drive download --node --output ~/downloads/ + dws drive download --node --output ./big.zip --part-size 32MB --parallel 8`, RunE: func(cmd *cobra.Command, args []string) error { fileID := flagOrFallback(cmd, "node", "file-id") if fileID == "" { @@ -492,6 +493,15 @@ func newDriveCommand() *cobra.Command { argsMap["spaceId"] = v } + // fail-fast:分片下载参数校验 + dlOpts, err := driveDownloadOptionsFromFlags(cmd) + if err != nil { + return err + } + dlOpts.logf = func(format string, a ...any) { + deps.Out.PrintInfo(fmt.Sprintf(format, a...)) + } + if deps.Caller.DryRun() { deps.Out.PrintKeyValue("操作", "下载钉盘文件") deps.Out.PrintKeyValue("文件ID", fileID) @@ -523,9 +533,18 @@ func newDriveCommand() *cobra.Command { outputPath = filepath.Join(outputPath, filename) } - // Step 2: HTTP GET 下载文件 + // Step 2: 分片下载(自动分派 + 401/403 凭证刷新重试) deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) - if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil { + dlOpts.knownSize = parseDownloadFileSize(text) + dlOpts.nodeID = fileID + fetchCred := func(fctx context.Context) (string, map[string]string, error) { + t, ferr := callMCPToolReturnText(fctx, "download_file", argsMap) + if ferr != nil { + return "", nil, ferr + } + return parseDownloadInfo(t) + } + if err := driveTransferDownload(ctx, fetchCred, resourceURL, dlHeaders, outputPath, dlOpts); err != nil { return err } @@ -564,6 +583,15 @@ func newDriveCommand() *cobra.Command { return fmt.Errorf("flag --output is required") } + // fail-fast:分片下载参数校验 + dlOpts, err := driveDownloadOptionsFromFlags(cmd) + if err != nil { + return err + } + dlOpts.logf = func(format string, a ...any) { + deps.Out.PrintInfo(fmt.Sprintf(format, a...)) + } + if deps.Caller.DryRun() { deps.Out.PrintKeyValue("操作", "下载文件历史版本") deps.Out.PrintKeyValue("节点ID", fileID) @@ -574,10 +602,11 @@ func newDriveCommand() *cobra.Command { ctx := context.Background() deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...") - text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{ + dlArgsMap := map[string]any{ "nodeId": fileID, "version": versionNum, - }) + } + text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", dlArgsMap) if err != nil { return err } @@ -593,7 +622,17 @@ func newDriveCommand() *cobra.Command { outputPath = filepath.Join(outputPath, filename) } deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) - if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil { + dlOpts.knownSize = parseDownloadFileSize(text) + dlOpts.nodeID = fileID + dlOpts.version = versionNum + fetchCred := func(fctx context.Context) (string, map[string]string, error) { + t, ferr := callMCPToolReturnTextOnServer(fctx, "drive", "download_file_version", dlArgsMap) + if ferr != nil { + return "", nil, ferr + } + return parseDownloadInfo(t) + } + if err := driveTransferDownload(ctx, fetchCred, resourceURL, dlHeaders, outputPath, dlOpts); err != nil { return err } deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath)) @@ -709,10 +748,16 @@ func newDriveCommand() *cobra.Command { driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)") driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)") driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)") + driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)") + driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)") + driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)") driveDownloadVersionCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)") driveDownloadVersionCmd.Flags().Int("version", 0, "历史版本号 (必填,正整数,从 drive list --versions 获取)") driveDownloadVersionCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)") + driveDownloadVersionCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)") + driveDownloadVersionCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)") + driveDownloadVersionCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)") for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} { driveDownloadVersionCmd.Flags().String(alias, "", "") _ = driveDownloadVersionCmd.Flags().MarkHidden(alias) @@ -2006,13 +2051,13 @@ func uploadToDrive(ctx context.Context, filePath, fileName string, fileSize int6 if err != nil { return err } - resourceURL, uploadID, headers, err := parseDriveUploadInfo(text) + // HTTP PUT 上传文件(OSS 与中心协议同路径;headers 透传,401/403 重取凭证重试一次) + uploadID, err := driveUploadPut(ctx, text, func(rctx context.Context) (string, error) { + return callMCPToolReturnTextOnServer(rctx, "drive", "get_upload_info", step1Args) + }, filePath, fileSize) if err != nil { return err } - if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil { - return err - } commitArgs := map[string]any{ "fileName": fileName, @@ -2219,3 +2264,49 @@ func isPermissionCLIError(err error) bool { var patErr *PATError return errors.As(err, &patErr) } + +// parseDriveDownloadInfo 从 drive 的 download_file 返回里取下载 URL 与请求头。 +// drive 返回形如 {"result":{"downloadUrl":"https://..."}};OSS 预签名 URL 自带签名参数、 +// 无额外请求头;中心协议(httpToCenterWithToken)返回的 headers 含 dentry-token, +// 需原样透传。对历史字段名(resourceUrl / resourceUrls[].url)做 fallback。 +func parseDriveDownloadInfo(text string) (string, map[string]string, error) { + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + return "", nil, fmt.Errorf("解析 download_file 返回失败: %w", err) + } + if result, ok := data["result"].(map[string]any); ok { + data = result + } + + headers := make(map[string]string) + if h, ok := data["headers"].(map[string]any); ok { + for k, v := range h { + if s, ok := v.(string); ok { + headers[k] = s + } + } + } + + dlURL, _ := data["downloadUrl"].(string) + if dlURL == "" { + dlURL, _ = data["resourceUrl"].(string) + } + if dlURL == "" { + if arr, ok := data["resourceUrls"].([]any); ok && len(arr) > 0 { + if first, ok := arr[0].(map[string]any); ok { + dlURL, _ = first["url"].(string) + if h, ok := first["headers"].(map[string]any); ok { + for k, v := range h { + if s, ok := v.(string); ok { + headers[k] = s + } + } + } + } + } + } + if dlURL == "" { + return "", nil, fmt.Errorf("download_file 未返回下载链接(downloadUrl 为空)") + } + return dlURL, headers, nil +} diff --git a/internal/helpers/drive_transfer.go b/internal/helpers/drive_transfer.go new file mode 100644 index 00000000..b60306bf --- /dev/null +++ b/internal/helpers/drive_transfer.go @@ -0,0 +1,796 @@ +package helpers + +// ────────────────────────────────────────────────────────── +// drive 传输增强:中心协议(token 化凭证)+ 分片下载(Range) +// +// 下载:文件大小 ≥ 2×part-size 时自动分片并发下载(对齐 aws s3 cp / +// ossutil 惯例),断点续传默认开启(.dwspart 临时文件 + checkpoint +// 元信息),服务端不支持 Range 时自动回退整流;401/403(凭证过期)自动 +// 重新调用 MCP 取新凭证后续传。 +// 上传:中心协议(uploadType=httpToCenterWithToken)与 OSS 走同一 PUT +// 路径(URL 服务端拼好、headers 透传、客户端零追加),401/403 重取凭证 +// 重试一次;服务端超限错误补充可读提示。 +// ────────────────────────────────────────────────────────── + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/spf13/cobra" +) + +const ( + driveDownloadDefaultPartSize = 16 * 1024 * 1024 // --part-size 默认 16MB + driveDownloadMinPartSize = 1 * 1024 * 1024 + driveDownloadMaxPartSize = 1024 * 1024 * 1024 + driveDownloadDefaultParallel = 4 + driveDownloadMaxParallel = 8 + // 单分片常规失败指数退避重试次数(不含 401/403 凭证刷新)。 + driveDownloadPartRetries = 3 + // 单分片 401/403 凭证刷新重试上限(防止无效凭证死循环)。 + driveDownloadPartAuthRetries = 2 + + drivePartFileSuffix = ".dwspart" + drivePartMetaSuffix = ".dwspart.meta" + driveCheckpointVersion = 1 + uploadTypeCenterToken = "httpToCenterWithToken" + driveTransferBodyErrCap = 2048 // 错误响应 body 截断长度 +) + +// driveRangeClient 分片下载/探测专用 HTTP 客户端。 +// 整流路径仍走可注入的 httpGetFile,保持既有测试注入点不变。 +var driveRangeClient = &http.Client{Timeout: 10 * time.Minute} + +// ────────────────────────────────────────────────────────── +// HTTP 状态错误 +// ────────────────────────────────────────────────────────── + +// httpStatusError 表示非 2xx 的 HTTP 响应,供上层按状态码分支(401/403 重取凭证等)。 +type httpStatusError struct { + StatusCode int + Body string +} + +func (e *httpStatusError) Error() string { + return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body) +} + +// isAuthStatusError 判断错误是否为 401/403(凭证过期/无效)。 +// 兼容 typed httpStatusError 与文本形态(测试注入或历史包装的 "HTTP 401/403" 错误)。 +func isAuthStatusError(err error) bool { + if err == nil { + return false + } + var se *httpStatusError + if errors.As(err, &se) { + return se.StatusCode == http.StatusUnauthorized || se.StatusCode == http.StatusForbidden + } + msg := err.Error() + return strings.Contains(msg, "HTTP 401") || strings.Contains(msg, "HTTP 403") +} + +// ────────────────────────────────────────────────────────── +// 参数解析 +// ────────────────────────────────────────────────────────── + +// parsePartSize 解析 --part-size 的人类可读值(16MB、512KB、1GB;纯数字按字节)。 +func parsePartSize(s string) (int64, error) { + v := strings.TrimSpace(strings.ToUpper(s)) + if v == "" { + return 0, fmt.Errorf("--part-size 不能为空(示例: 16MB、512KB、1GB)") + } + unit := int64(1) + switch { + case strings.HasSuffix(v, "GB"): + unit, v = 1<<30, strings.TrimSuffix(v, "GB") + case strings.HasSuffix(v, "MB"): + unit, v = 1<<20, strings.TrimSuffix(v, "MB") + case strings.HasSuffix(v, "KB"): + unit, v = 1<<10, strings.TrimSuffix(v, "KB") + case strings.HasSuffix(v, "G"): + unit, v = 1<<30, strings.TrimSuffix(v, "G") + case strings.HasSuffix(v, "M"): + unit, v = 1<<20, strings.TrimSuffix(v, "M") + case strings.HasSuffix(v, "K"): + unit, v = 1<<10, strings.TrimSuffix(v, "K") + case strings.HasSuffix(v, "B"): + v = strings.TrimSuffix(v, "B") + } + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil || n <= 0 { + return 0, fmt.Errorf("--part-size 格式非法: %q(示例: 16MB、512KB、1GB)", s) + } + size := n * unit + if size < driveDownloadMinPartSize || size > driveDownloadMaxPartSize { + return 0, fmt.Errorf("--part-size 取值范围 %s - %s,当前值: %s", + formatByteSize(driveDownloadMinPartSize), formatByteSize(driveDownloadMaxPartSize), s) + } + return size, nil +} + +func formatByteSize(n int64) string { + switch { + case n >= 1<<30 && n%(1<<30) == 0: + return fmt.Sprintf("%dGB", n/(1<<30)) + case n >= 1<<20 && n%(1<<20) == 0: + return fmt.Sprintf("%dMB", n/(1<<20)) + case n >= 1<<10 && n%(1<<10) == 0: + return fmt.Sprintf("%dKB", n/(1<<10)) + default: + return fmt.Sprintf("%dB", n) + } +} + +// driveDownloadOptions 分片下载选项(由 drive download 的 flag 解析而来)。 +type driveDownloadOptions struct { + partSize int64 + parallel int + resume bool + knownSize int64 // MCP 返回的 fileSize;未知(0)或小于阈值时直接整流 + nodeID string // 节点唯一标识(dentryUuid),用于生成 checkpoint 指纹 + version int // 文件版本号;0 表示最新版 + logf func(format string, args ...any) +} + +// driveDownloadOptionsFromFlags 解析并校验 --part-size / --parallel / --no-resume。 +func driveDownloadOptionsFromFlags(cmd *cobra.Command) (driveDownloadOptions, error) { + raw, _ := cmd.Flags().GetString("part-size") + partSize, err := parsePartSize(raw) + if err != nil { + return driveDownloadOptions{}, err + } + parallel, _ := cmd.Flags().GetInt("parallel") + if parallel < 1 || parallel > driveDownloadMaxParallel { + return driveDownloadOptions{}, fmt.Errorf("--parallel 取值范围 1-%d,当前值: %d", driveDownloadMaxParallel, parallel) + } + noResume, _ := cmd.Flags().GetBool("no-resume") + return driveDownloadOptions{partSize: partSize, parallel: parallel, resume: !noResume}, nil +} + +// parseDownloadFileSize 从 download_file 返回中提取 fileSize(缺失/非法返回 0)。 +func parseDownloadFileSize(text string) int64 { + var data map[string]any + if json.Unmarshal([]byte(text), &data) != nil { + return 0 + } + if r, ok := data["result"].(map[string]any); ok { + data = r + } + switch v := data["fileSize"].(type) { + case float64: + return int64(v) + case string: + n, _ := strconv.ParseInt(v, 10, 64) + return n + } + return 0 +} + +// parseDownloadFileVersion 从 MCP download_file 响应中提取文件当前版本号。 +// 返回 0 表示未获取到(兼容旧版 MCP 不返回 version 的场景)。 +func parseDownloadFileVersion(text string) int { + var data map[string]any + if json.Unmarshal([]byte(text), &data) != nil { + return 0 + } + if r, ok := data["result"].(map[string]any); ok { + data = r + } + switch v := data["version"].(type) { + case float64: + return int(v) + case string: + n, _ := strconv.Atoi(v) + return n + } + return 0 +} + +// ────────────────────────────────────────────────────────── +// 凭证状态(分片过程共享,401/403 时 single-flight 刷新) +// ────────────────────────────────────────────────────────── + +// driveCredentialFetcher 重新调用 MCP 获取下载 URL + headers(含 dentry-token)。 +type driveCredentialFetcher func(ctx context.Context) (string, map[string]string, error) + +type driveCredentialState struct { + mu sync.Mutex + fetch driveCredentialFetcher + url string + headers map[string]string + gen int +} + +func (cs *driveCredentialState) current() (string, map[string]string, int) { + cs.mu.Lock() + defer cs.mu.Unlock() + return cs.url, cs.headers, cs.gen +} + +// refresh 重取凭证。仅当调用方持有的 generation 仍是最新时才真正重取 +// (其他并发分片已刷新过则直接复用新凭证,避免重复 MCP 调用)。 +func (cs *driveCredentialState) refresh(ctx context.Context, gen int) error { + cs.mu.Lock() + defer cs.mu.Unlock() + if cs.gen > gen { + return nil // 已被其他分片刷新 + } + if cs.fetch == nil { + return fmt.Errorf("下载凭证已过期且无法自动刷新") + } + url, headers, err := cs.fetch(ctx) + if err != nil { + return err + } + cs.url, cs.headers = url, headers + cs.gen++ + return nil +} + +// ────────────────────────────────────────────────────────── +// 下载入口:整流 / 分片自动分派 +// ────────────────────────────────────────────────────────── + +// driveTransferDownload 下载入口:按文件大小自动选择整流或分片下载。 +// - 文件大小未知(MCP 未返回 fileSize)或 < 2×partSize → 直接整流 +// (复用可注入的 httpGetFile,保持存量行为与测试注入边界不变, +// 401/403 时重取凭证重试一次); +// - 已知大小 ≥ 2×partSize → 首请求以 Range: bytes=0-0 探测:206 且解析出 +// 总长 → 分片下载;服务端返回 200(不支持 Range)→ 直接消费该响应整流落盘。 +func driveTransferDownload(ctx context.Context, fetch driveCredentialFetcher, rawURL string, headers map[string]string, destPath string, opts driveDownloadOptions) error { + if opts.partSize <= 0 { + opts.partSize = driveDownloadDefaultPartSize + } + if opts.parallel <= 0 { + opts.parallel = driveDownloadDefaultParallel + } + threshold := 2 * opts.partSize + if opts.knownSize < threshold { + // 含 knownSize==0(大小未知):不发起额外探测请求,保持存量整流行为 + return downloadSingleWithAuthRetry(ctx, fetch, rawURL, headers, destPath) + } + + creds := &driveCredentialState{fetch: fetch, url: rawURL, headers: headers} + totalSize, fullResp, err := probeRangeSupport(ctx, creds) + if err != nil { + return err + } + if fullResp != nil { + // 服务端不支持 Range(忽略探测请求头返回 200 全量流):直接消费落盘 + defer fullResp.Body.Close() + return writeStreamToFile(fullResp.Body, destPath) + } + curURL, curHeaders, _ := creds.current() + if totalSize <= 0 || totalSize < threshold { + // 总长未知(Content-Range 异常)或小于阈值:整流下载 + return downloadSingleWithAuthRetry(ctx, func(fctx context.Context) (string, map[string]string, error) { + if fetch == nil { + return "", nil, fmt.Errorf("下载凭证已过期且无法自动刷新") + } + return fetch(fctx) + }, curURL, curHeaders, destPath) + } + return downloadRangedParts(ctx, creds, destPath, totalSize, opts) +} + +// downloadSingleWithAuthRetry 整流下载;401/403(凭证过期)时重新调用 MCP +// 获取新 URL+token 重试一次,仍失败走既有错误路径。 +func downloadSingleWithAuthRetry(ctx context.Context, fetch driveCredentialFetcher, urlStr string, headers map[string]string, destPath string) error { + err := httpGetFile(ctx, urlStr, headers, destPath) + if err == nil || !isAuthStatusError(err) || fetch == nil { + return err + } + newURL, newHeaders, ferr := fetch(ctx) + if ferr != nil { + return err + } + return httpGetFile(ctx, newURL, newHeaders, destPath) +} + +// probeRangeSupport 发送 Range: bytes=0-0 探测请求验证 206/Content-Range。 +// 返回 (totalSize, nil, nil) 表示支持 Range 且已知总长; +// 返回 (0, resp, nil) 表示服务端忽略 Range 返回 200 全量响应(调用方直接消费); +// 401/403 时刷新凭证重试一次。 +func probeRangeSupport(ctx context.Context, creds *driveCredentialState) (int64, *http.Response, error) { + for attempt := 0; attempt < 2; attempt++ { + urlStr, headers, gen := creds.current() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) + if err != nil { + return 0, nil, err + } + for k, v := range headers { + req.Header.Set(k, v) + } + req.Header.Set("Range", "bytes=0-0") + resp, err := driveRangeClient.Do(req) + if err != nil { + return 0, nil, err + } + switch { + case resp.StatusCode == http.StatusPartialContent: + total, perr := parseContentRangeTotal(resp.Header.Get("Content-Range")) + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if perr != nil { + return 0, nil, nil // Content-Range 异常:总长未知,回退整流 + } + return total, nil, nil + case resp.StatusCode == http.StatusOK: + return 0, resp, nil + case (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && attempt == 0: + body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap)) + resp.Body.Close() + if rerr := creds.refresh(ctx, gen); rerr != nil { + return 0, nil, fmt.Errorf("重新获取下载凭证失败: %w (原错误: %v)", + rerr, &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}) + } + default: + body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap)) + resp.Body.Close() + return 0, nil, &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)} + } + } + return 0, nil, fmt.Errorf("下载凭证刷新后仍鉴权失败") +} + +// parseContentRangeTotal 从 "bytes 0-0/12345" 解析总长;"*" 视为未知。 +func parseContentRangeTotal(cr string) (int64, error) { + idx := strings.LastIndex(cr, "/") + if idx < 0 || idx == len(cr)-1 { + return 0, fmt.Errorf("非法 Content-Range: %q", cr) + } + totalStr := cr[idx+1:] + if totalStr == "*" { + return 0, fmt.Errorf("Content-Range 总长未知: %q", cr) + } + total, err := strconv.ParseInt(totalStr, 10, 64) + if err != nil || total <= 0 { + return 0, fmt.Errorf("非法 Content-Range 总长: %q", cr) + } + return total, nil +} + +// parseContentRange 解析 "bytes -/" 格式的 Content-Range 头。 +// 返回值 start、end 为字节偏移(闭区间),total 为文件总长("*" 视为 -1)。 +func parseContentRange(header string) (start, end, total int64, err error) { + if header == "" { + return 0, 0, 0, fmt.Errorf("Content-Range 为空") + } + // 去掉 "bytes " 前缀 + const prefix = "bytes " + if !strings.HasPrefix(header, prefix) { + return 0, 0, 0, fmt.Errorf("非法 Content-Range 前缀: %q", header) + } + rest := header[len(prefix):] // e.g. "0-1048575/104857600" + // 按 "/" 分割范围与总长 + slashIdx := strings.LastIndex(rest, "/") + if slashIdx < 0 || slashIdx == len(rest)-1 { + return 0, 0, 0, fmt.Errorf("非法 Content-Range 格式: %q", header) + } + rangePart := rest[:slashIdx] // "0-1048575" + totalPart := rest[slashIdx+1:] // "104857600" 或 "*" + // 解析 total + if totalPart == "*" { + total = -1 + } else { + total, err = strconv.ParseInt(totalPart, 10, 64) + if err != nil || total <= 0 { + return 0, 0, 0, fmt.Errorf("非法 Content-Range 总长: %q", header) + } + } + // 按 "-" 分割 start 和 end + dashIdx := strings.Index(rangePart, "-") + if dashIdx < 0 { + return 0, 0, 0, fmt.Errorf("非法 Content-Range 区间: %q", header) + } + start, err = strconv.ParseInt(rangePart[:dashIdx], 10, 64) + if err != nil || start < 0 { + return 0, 0, 0, fmt.Errorf("非法 Content-Range start: %q", header) + } + end, err = strconv.ParseInt(rangePart[dashIdx+1:], 10, 64) + if err != nil || end < start { + return 0, 0, 0, fmt.Errorf("非法 Content-Range end: %q", header) + } + return start, end, total, nil +} + +func writeStreamToFile(r io.Reader, destPath string) error { + out, err := os.Create(destPath) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, r); err != nil { + return err + } + return nil +} + +// ────────────────────────────────────────────────────────── +// 分片切分与 checkpoint +// ────────────────────────────────────────────────────────── + +type driveDownloadPart struct { + index int + offset int64 + length int64 +} + +// splitDownloadParts 按 partSize 等长切片,末片为余量。 +func splitDownloadParts(totalSize, partSize int64) []driveDownloadPart { + if totalSize <= 0 || partSize <= 0 { + return nil + } + count := int((totalSize + partSize - 1) / partSize) + parts := make([]driveDownloadPart, 0, count) + for i := 0; i < count; i++ { + offset := int64(i) * partSize + length := partSize + if offset+length > totalSize { + length = totalSize - offset + } + parts = append(parts, driveDownloadPart{index: i, offset: offset, length: length}) + } + return parts +} + +// driveDownloadCheckpoint 断点续传元信息,随每个分片完成原子落盘。 +type driveDownloadCheckpoint struct { + Version int `json:"version"` + Fingerprint string `json:"fingerprint"` + TotalSize int64 `json:"totalSize"` + PartSize int64 `json:"partSize"` + Completed []bool `json:"completed"` +} + +// driveDownloadFingerprint 基于节点 ID + 版本号 + 文件总长 + 首次资源 URL path 计算指纹。 +// resourceURL 的 path 包含文件存储版本信息,覆盖上传后会分配新存储位置导致 path 变化, +// 防止最新版下载(version=0)在文件被同大小内容覆盖后错误复用旧 checkpoint。 +// resourceURL 为空时不影响其他字段的指纹计算(安全降级)。 +func driveDownloadFingerprint(nodeID string, version int, totalSize int64, resourceURL string) string { + urlPath := "" + if resourceURL != "" { + if u, err := url.Parse(resourceURL); err == nil && u != nil { + urlPath = u.Path + } + } + sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%d|%d|%s", nodeID, version, totalSize, urlPath))) + return hex.EncodeToString(sum[:]) +} + +// loadDriveDownloadCheckpoint 读取并校验 checkpoint;任一字段不匹配返回 nil(从头下载)。 +func loadDriveDownloadCheckpoint(metaPath, fingerprint string, totalSize, partSize int64, partCount int) *driveDownloadCheckpoint { + data, err := os.ReadFile(metaPath) + if err != nil { + return nil + } + var cp driveDownloadCheckpoint + if err := json.Unmarshal(data, &cp); err != nil { + return nil + } + if cp.Version != driveCheckpointVersion || cp.Fingerprint != fingerprint || + cp.TotalSize != totalSize || cp.PartSize != partSize || len(cp.Completed) != partCount { + return nil + } + return &cp +} + +// save 原子写入(临时文件 + rename),避免中断产生半截 checkpoint。 +func (cp *driveDownloadCheckpoint) save(metaPath string) error { + data, err := json.Marshal(cp) + if err != nil { + return err + } + tmp := metaPath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, metaPath) +} + +// ────────────────────────────────────────────────────────── +// 分片下载引擎 +// ────────────────────────────────────────────────────────── + +// downloadRangedParts 并发分片下载到 .dwspart,全部完成后校验总长并 +// 原子重命名为 destPath、清理 checkpoint;中途失败保留分片产物供断点续传。 +func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destPath string, totalSize int64, opts driveDownloadOptions) error { + parts := splitDownloadParts(totalSize, opts.partSize) + partPath := destPath + drivePartFileSuffix + metaPath := destPath + drivePartMetaSuffix + // 取首次凭证 URL 用于指纹计算,防止同大小文件覆盖后 checkpoint 错误复用 + initialURL, _, _ := creds.current() + fingerprint := driveDownloadFingerprint(opts.nodeID, opts.version, totalSize, initialURL) + + var cp *driveDownloadCheckpoint + if opts.resume { + cp = loadDriveDownloadCheckpoint(metaPath, fingerprint, totalSize, opts.partSize, len(parts)) + // 分片数据文件缺失或长度不符时 checkpoint 作废,从头下载 + if cp != nil { + if fi, err := os.Stat(partPath); err != nil || fi.Size() != totalSize { + cp = nil + } + } + } else { + // --no-resume:清理历史断点产物,从头下载且不写 checkpoint + _ = os.Remove(partPath) + _ = os.Remove(metaPath) + } + if cp == nil { + cp = &driveDownloadCheckpoint{ + Version: driveCheckpointVersion, + Fingerprint: fingerprint, + TotalSize: totalSize, + PartSize: opts.partSize, + Completed: make([]bool, len(parts)), + } + } + + f, err := os.OpenFile(partPath, os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + return fmt.Errorf("创建分片临时文件失败: %w", err) + } + if err := f.Truncate(totalSize); err != nil { + f.Close() + return fmt.Errorf("预分配分片临时文件失败: %w", err) + } + + remaining := 0 + for _, p := range parts { + if !cp.Completed[p.index] { + remaining++ + } + } + if opts.logf != nil { + if remaining < len(parts) { + opts.logf("断点续传: 共 %d 分片(%s/片,并发 %d),已完成 %d,续传 %d", + len(parts), formatByteSize(opts.partSize), opts.parallel, len(parts)-remaining, remaining) + } else { + opts.logf("分片下载: 共 %d 分片(%s/片,并发 %d)", len(parts), formatByteSize(opts.partSize), opts.parallel) + } + } + + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + var ( + mu sync.Mutex // 保护 cp 与 checkpoint 落盘 + wg sync.WaitGroup + errOnce sync.Once + firstErr error + ) + fail := func(err error) { + errOnce.Do(func() { + firstErr = err + cancel() + }) + } + + jobs := make(chan driveDownloadPart) + workers := opts.parallel + if workers > remaining { + workers = remaining + } + if workers < 1 { + workers = 1 + } + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for part := range jobs { + if runCtx.Err() != nil { + return + } + if err := downloadOnePart(runCtx, creds, f, part); err != nil { + fail(fmt.Errorf("分片 %d/%d 下载失败: %w", part.index+1, len(parts), err)) + return + } + mu.Lock() + cp.Completed[part.index] = true + var saveErr error + if opts.resume { + saveErr = cp.save(metaPath) + } + mu.Unlock() + if saveErr != nil { + fail(fmt.Errorf("写入下载断点信息失败: %w", saveErr)) + return + } + } + }() + } + +dispatch: + for _, part := range parts { + if cp.Completed[part.index] { + continue + } + select { + case jobs <- part: + case <-runCtx.Done(): + break dispatch + } + } + close(jobs) + wg.Wait() + + if firstErr != nil { + f.Close() + return firstErr // 保留 .dwspart 与 checkpoint,重跑同一命令自动续传 + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + fi, statErr := f.Stat() + f.Close() + if statErr != nil { + return statErr + } + if fi.Size() != totalSize { + return fmt.Errorf("下载完成但文件长度不符: got %d, want %d", fi.Size(), totalSize) + } + if err := os.Rename(partPath, destPath); err != nil { + return fmt.Errorf("重命名下载文件失败: %w", err) + } + _ = os.Remove(metaPath) + return nil +} + +// downloadOnePart 下载单个分片:常规失败指数退避重试 driveDownloadPartRetries 次; +// 401/403 触发凭证 single-flight 刷新(不计入常规重试,上限 driveDownloadPartAuthRetries), +// 刷新后用新凭证续传,不重下其他已完成分片。 +func downloadOnePart(ctx context.Context, creds *driveCredentialState, f *os.File, part driveDownloadPart) error { + attempt := 0 + authRetries := 0 + backoff := 500 * time.Millisecond + for { + urlStr, headers, gen := creds.current() + err := fetchRangeInto(ctx, urlStr, headers, f, part) + if err == nil { + return nil + } + if ctx.Err() != nil { + return err + } + if isAuthStatusError(err) && authRetries < driveDownloadPartAuthRetries { + authRetries++ + if rerr := creds.refresh(ctx, gen); rerr != nil { + return fmt.Errorf("重新获取下载凭证失败: %w (原错误: %v)", rerr, err) + } + continue // 凭证刷新不计常规重试、不退避 + } + attempt++ + if attempt > driveDownloadPartRetries { + return err + } + select { + case <-time.After(backoff): + case <-ctx.Done(): + return err + } + backoff *= 2 + } +} + +// fetchRangeInto 拉取 [offset, offset+length) 区间并写入文件对应偏移。 +func fetchRangeInto(ctx context.Context, urlStr string, headers map[string]string, f *os.File, part driveDownloadPart) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) + if err != nil { + return err + } + for k, v := range headers { + req.Header.Set(k, v) + } + req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", part.offset, part.offset+part.length-1)) + resp, err := driveRangeClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusPartialContent { + body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap)) + return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)} + } + // 校验 Content-Range 响应区间与请求分片一致,防止代理/服务端返回错位数据 + if cr := resp.Header.Get("Content-Range"); cr != "" { + crStart, crEnd, _, crErr := parseContentRange(cr) + if crErr != nil { + return fmt.Errorf("Content-Range 解析失败: %w", crErr) + } + wantStart := part.offset + wantEnd := part.offset + part.length - 1 + if crStart != wantStart || crEnd != wantEnd { + return fmt.Errorf("Content-Range 区间不匹配: 响应 %d-%d, 期望 %d-%d", + crStart, crEnd, wantStart, wantEnd) + } + } + // Content-Range 缺失时不阻断(兼容不规范服务端),仅依赖读取长度校验 + n, err := io.Copy(io.NewOffsetWriter(f, part.offset), io.LimitReader(resp.Body, part.length)) + if err != nil { + return err + } + if n != part.length { + return fmt.Errorf("分片长度不符: got %d, want %d", n, part.length) + } + return nil +} + +// ────────────────────────────────────────────────────────── +// 上传:中心协议识别 + 401/403 重试 + 超限可读提示 +// ────────────────────────────────────────────────────────── + +// parseDriveUploadType 提取 get_upload_info 返回中的 uploadType(可选字段)。 +// "httpToCenterWithToken" 表示中心协议;存量 OSS 返回无该字段,返回空串。 +func parseDriveUploadType(text string) string { + var data map[string]any + if json.Unmarshal([]byte(text), &data) != nil { + return "" + } + if r, ok := data["result"].(map[string]any); ok { + data = r + } + t, _ := data["uploadType"].(string) + return t +} + +// decorateUploadSizeError 为服务端上传超限错误补充可读提示。 +// 不做本地文件大小上限校验(上限为服务端动态权益值,本地硬编码会漂移)。 +func decorateUploadSizeError(err error, uploadType string) error { + var se *httpStatusError + if !errors.As(err, &se) { + return err + } + if se.StatusCode == http.StatusRequestEntityTooLarge || + (uploadType == uploadTypeCenterToken && likelySizeLimitBody(se.Body)) { + return fmt.Errorf("%w\n提示: 文件大小可能超出空间容量或上传上限,请确认文件大小、清理空间或联系管理员调整容量后重试", err) + } + return err +} + +func likelySizeLimitBody(body string) bool { + b := strings.ToLower(body) + if strings.Contains(b, "超限") || strings.Contains(b, "超出") || strings.Contains(b, "容量") { + return true + } + return strings.Contains(b, "size") && (strings.Contains(b, "limit") || strings.Contains(b, "exceed") || strings.Contains(b, "over")) +} + +// driveUploadPut 解析上传凭证并执行 HTTP PUT;401/403(token 过期)时通过 refetch +// 重新获取凭证重试一次。返回最终生效凭证的 uploadId(凭证刷新后以新值为准)。 +// 中心协议(uploadType=httpToCenterWithToken)与 OSS 走同一路径:resourceUrl 为 +// 服务端拼好的完整 PUT URL(客户端零追加),headers(含 dentry-token)原样透传。 +func driveUploadPut(ctx context.Context, credText string, refetch func(context.Context) (string, error), filePath string, fileSize int64) (string, error) { + resourceURL, uploadID, headers, err := parseDriveUploadInfo(credText) + if err != nil { + return "", err + } + uploadType := parseDriveUploadType(credText) + putErr := httpPutFile(ctx, resourceURL, headers, filePath, fileSize) + if putErr != nil && isAuthStatusError(putErr) && refetch != nil { + text2, rerr := refetch(ctx) + if rerr == nil { + if url2, id2, headers2, perr := parseDriveUploadInfo(text2); perr == nil { + uploadID = id2 + uploadType = parseDriveUploadType(text2) + putErr = httpPutFile(ctx, url2, headers2, filePath, fileSize) + } + } + } + if putErr != nil { + return "", decorateUploadSizeError(putErr, uploadType) + } + return uploadID, nil +} diff --git a/internal/helpers/drive_transfer_test.go b/internal/helpers/drive_transfer_test.go new file mode 100644 index 00000000..fa913e4d --- /dev/null +++ b/internal/helpers/drive_transfer_test.go @@ -0,0 +1,1089 @@ +package helpers + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +// ────────────────────────────────────────────────────────── +// parsePartSize / formatByteSize +// ────────────────────────────────────────────────────────── + +func TestParsePartSize(t *testing.T) { + cases := []struct { + in string + want int64 + wantErr bool + }{ + {"16MB", 16 << 20, false}, + {"16M", 16 << 20, false}, + {"1GB", 1 << 30, false}, + {"1g", 1 << 30, false}, + {"1024KB", 1 << 20, false}, + {"33554432", 32 << 20, false}, // 纯数字按字节 + {" 8mb ", 8 << 20, false}, + {"", 0, true}, + {"abc", 0, true}, + {"-4MB", 0, true}, + {"0", 0, true}, + {"512KB", 0, true}, // 低于 1MB 下限 + {"2048MB", 0, true}, // 高于 1GB 上限 + {"1.5MB", 0, true}, // 不支持小数 + } + for _, c := range cases { + got, err := parsePartSize(c.in) + if c.wantErr { + if err == nil { + t.Errorf("parsePartSize(%q) 应报错, got %d", c.in, got) + } + continue + } + if err != nil { + t.Errorf("parsePartSize(%q) unexpected error: %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("parsePartSize(%q) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestFormatByteSize(t *testing.T) { + cases := map[int64]string{ + 16 << 20: "16MB", + 1 << 30: "1GB", + 4 << 10: "4KB", + 123: "123B", + } + for in, want := range cases { + if got := formatByteSize(in); got != want { + t.Errorf("formatByteSize(%d) = %q, want %q", in, got, want) + } + } +} + +// ────────────────────────────────────────────────────────── +// splitDownloadParts +// ────────────────────────────────────────────────────────── + +func TestSplitDownloadParts(t *testing.T) { + // 整除 + parts := splitDownloadParts(32, 16) + if len(parts) != 2 || parts[0].offset != 0 || parts[0].length != 16 || parts[1].offset != 16 || parts[1].length != 16 { + t.Errorf("整除切分错误: %+v", parts) + } + // 有余量:末片较短 + parts = splitDownloadParts(33, 16) + if len(parts) != 3 || parts[2].offset != 32 || parts[2].length != 1 { + t.Errorf("余量切分错误: %+v", parts) + } + // 单片 + parts = splitDownloadParts(10, 16) + if len(parts) != 1 || parts[0].length != 10 { + t.Errorf("单片切分错误: %+v", parts) + } + // 非法输入 + if splitDownloadParts(0, 16) != nil || splitDownloadParts(16, 0) != nil { + t.Error("非法输入应返回 nil") + } + // 覆盖完整性 + parts = splitDownloadParts(100, 7) + var sum int64 + for i, p := range parts { + if p.index != i { + t.Errorf("index 不连续: %+v", p) + } + sum += p.length + } + if sum != 100 { + t.Errorf("分片总长 %d != 100", sum) + } +} + +// ────────────────────────────────────────────────────────── +// checkpoint 读写与恢复 +// ────────────────────────────────────────────────────────── + +func TestCheckpointSaveLoadRoundtrip(t *testing.T) { + dir := t.TempDir() + metaPath := filepath.Join(dir, "f.bin.dwspart.meta") + fp := driveDownloadFingerprint("test-node-1", 0, 100, "") + cp := &driveDownloadCheckpoint{ + Version: driveCheckpointVersion, + Fingerprint: fp, + TotalSize: 100, + PartSize: 30, + Completed: []bool{true, false, true, false}, + } + if err := cp.save(metaPath); err != nil { + t.Fatalf("save: %v", err) + } + got := loadDriveDownloadCheckpoint(metaPath, fp, 100, 30, 4) + if got == nil { + t.Fatal("roundtrip 应成功加载") + } + if !got.Completed[0] || got.Completed[1] || !got.Completed[2] { + t.Errorf("Completed 位图不符: %v", got.Completed) + } +} + +func TestCheckpointLoadRejectsMismatch(t *testing.T) { + dir := t.TempDir() + metaPath := filepath.Join(dir, "f.meta") + fp := driveDownloadFingerprint("test-node-2", 0, 100, "") + cp := &driveDownloadCheckpoint{Version: driveCheckpointVersion, Fingerprint: fp, TotalSize: 100, PartSize: 30, Completed: make([]bool, 4)} + if err := cp.save(metaPath); err != nil { + t.Fatalf("save: %v", err) + } + if loadDriveDownloadCheckpoint(metaPath, "other-fp", 100, 30, 4) != nil { + t.Error("指纹不符应作废") + } + if loadDriveDownloadCheckpoint(metaPath, fp, 200, 30, 4) != nil { + t.Error("总长不符应作废") + } + if loadDriveDownloadCheckpoint(metaPath, fp, 100, 40, 4) != nil { + t.Error("分片大小不符应作废") + } + if loadDriveDownloadCheckpoint(metaPath, fp, 100, 30, 5) != nil { + t.Error("分片数不符应作废") + } + if loadDriveDownloadCheckpoint(filepath.Join(dir, "missing"), fp, 100, 30, 4) != nil { + t.Error("文件缺失应返回 nil") + } + // 损坏 JSON + if err := os.WriteFile(metaPath, []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + if loadDriveDownloadCheckpoint(metaPath, fp, 100, 30, 4) != nil { + t.Error("损坏 JSON 应返回 nil") + } +} + +// 指纹基于 nodeID + version + totalSize,不同 nodeID / version / size 产生不同指纹。 +func TestFingerprintNodeIDBased(t *testing.T) { + a := driveDownloadFingerprint("node-aaa", 0, 500, "") + b := driveDownloadFingerprint("node-aaa", 0, 500, "") + if a != b { + t.Error("相同参数应产生相同指纹") + } + c := driveDownloadFingerprint("node-bbb", 0, 500, "") + if a == c { + t.Error("不同 nodeID 应产生不同指纹") + } + d := driveDownloadFingerprint("node-aaa", 0, 501, "") + if a == d { + t.Error("不同 totalSize 应产生不同指纹") + } + e := driveDownloadFingerprint("node-aaa", 2, 500, "") + if a == e { + t.Error("不同 version 应产生不同指纹") + } +} + +// checkpoint 指纹碰撞防护:相同输出路径、相同大小、不同 nodeID 不复用 checkpoint。 +func TestCheckpointNotReusedDifferentNodeID(t *testing.T) { + dir := t.TempDir() + metaPath := filepath.Join(dir, "f.meta") + const totalSize int64 = 1000 + const partSize int64 = 300 + partCount := int((totalSize + partSize - 1) / partSize) + + // 用 nodeA 写入一个 checkpoint + fpA := driveDownloadFingerprint("node-A", 0, totalSize, "") + cp := &driveDownloadCheckpoint{ + Version: driveCheckpointVersion, + Fingerprint: fpA, + TotalSize: totalSize, + PartSize: partSize, + Completed: make([]bool, partCount), + } + cp.Completed[0] = true + if err := cp.save(metaPath); err != nil { + t.Fatal(err) + } + + // 用 nodeB 尝试加载,应返回 nil(不复用) + fpB := driveDownloadFingerprint("node-B", 0, totalSize, "") + if loadDriveDownloadCheckpoint(metaPath, fpB, totalSize, partSize, partCount) != nil { + t.Error("不同 nodeID 同大小不应复用 checkpoint") + } +} + +// checkpoint 指纹碰撞防护:相同 nodeID、不同大小不复用 checkpoint。 +func TestCheckpointNotReusedDifferentSize(t *testing.T) { + dir := t.TempDir() + metaPath := filepath.Join(dir, "f.meta") + const partSize int64 = 300 + + // 用 size=1000 写入 checkpoint + fp1000 := driveDownloadFingerprint("same-node", 0, 1000, "") + pc := int((int64(1000) + partSize - 1) / partSize) + cp := &driveDownloadCheckpoint{ + Version: driveCheckpointVersion, + Fingerprint: fp1000, + TotalSize: 1000, + PartSize: partSize, + Completed: make([]bool, pc), + } + if err := cp.save(metaPath); err != nil { + t.Fatal(err) + } + + // 用 size=2000 尝试加载,应返回 nil + fp2000 := driveDownloadFingerprint("same-node", 0, 2000, "") + pc2 := int((int64(2000) + partSize - 1) / partSize) + if loadDriveDownloadCheckpoint(metaPath, fp2000, 2000, partSize, pc2) != nil { + t.Error("相同 nodeID 不同大小不应复用 checkpoint") + } +} + +// checkpoint 正常续传:相同 nodeID + 相同大小复用 checkpoint。 +func TestCheckpointReusedSameNodeIDAndSize(t *testing.T) { + dir := t.TempDir() + metaPath := filepath.Join(dir, "f.meta") + const totalSize int64 = 1000 + const partSize int64 = 300 + partCount := int((totalSize + partSize - 1) / partSize) + + fp := driveDownloadFingerprint("resume-node", 0, totalSize, "") + cp := &driveDownloadCheckpoint{ + Version: driveCheckpointVersion, + Fingerprint: fp, + TotalSize: totalSize, + PartSize: partSize, + Completed: make([]bool, partCount), + } + cp.Completed[0] = true + cp.Completed[2] = true + if err := cp.save(metaPath); err != nil { + t.Fatal(err) + } + + // 用相同 nodeID + 相同大小加载,应成功复用 + got := loadDriveDownloadCheckpoint(metaPath, fp, totalSize, partSize, partCount) + if got == nil { + t.Fatal("相同 nodeID + 相同大小应复用 checkpoint") + } + if !got.Completed[0] || got.Completed[1] || !got.Completed[2] || got.Completed[3] { + t.Errorf("Completed 位图不符: %v", got.Completed) + } +} + +// checkpoint 指纹碰撞防护:同 nodeID、同大小、version=0 但不同 resourceURL 不复用 checkpoint。 +// 模拟最新版下载场景:文件被相同大小内容覆盖后 URL path 变化,旧 checkpoint 应作废。 +func TestCheckpointNotReusedDifferentResourceURL(t *testing.T) { + dir := t.TempDir() + metaPath := filepath.Join(dir, "f.meta") + const totalSize int64 = 1000 + const partSize int64 = 300 + partCount := int((totalSize + partSize - 1) / partSize) + + // 模拟首次下载时的资源 URL(旧存储位置) + oldURL := "https://storage.example.com/v1/files/abc123/content?token=xxx" + fpOld := driveDownloadFingerprint("same-node", 0, totalSize, oldURL) + cp := &driveDownloadCheckpoint{ + Version: driveCheckpointVersion, + Fingerprint: fpOld, + TotalSize: totalSize, + PartSize: partSize, + Completed: make([]bool, partCount), + } + cp.Completed[0] = true + if err := cp.save(metaPath); err != nil { + t.Fatal(err) + } + + // 文件被覆盖后获得新的资源 URL(新存储位置,path 不同) + newURL := "https://storage.example.com/v1/files/def456/content?token=yyy" + fpNew := driveDownloadFingerprint("same-node", 0, totalSize, newURL) + + // 新旧指纹应不同 + if fpOld == fpNew { + t.Fatal("不同 resourceURL 应产生不同指纹") + } + + // 用新指纹加载旧 checkpoint 应返回 nil(不复用) + if loadDriveDownloadCheckpoint(metaPath, fpNew, totalSize, partSize, partCount) != nil { + t.Error("同 nodeID、同大小、version=0 但不同 resourceURL 不应复用 checkpoint") + } +} + +// 验证 resourceURL 为空时的安全降级:不影响其他字段的指纹计算。 +func TestFingerprintEmptyResourceURLFallback(t *testing.T) { + // 空 URL 应产生确定性指纹 + a := driveDownloadFingerprint("node-x", 0, 500, "") + b := driveDownloadFingerprint("node-x", 0, 500, "") + if a != b { + t.Error("空 resourceURL 时相同参数应产生相同指纹") + } + + // 空 URL 与有 URL 的指纹应不同 + c := driveDownloadFingerprint("node-x", 0, 500, "https://example.com/path/to/file") + if a == c { + t.Error("空 URL 与有 URL 应产生不同指纹") + } + + // 仅 query 不同、path 相同的 URL 应产生相同指纹(只取 path) + d := driveDownloadFingerprint("node-x", 0, 500, "https://example.com/path/to/file?token=aaa") + e := driveDownloadFingerprint("node-x", 0, 500, "https://example.com/path/to/file?token=bbb") + if d != e { + t.Error("仅 query 不同时应产生相同指纹(只取 path)") + } +} + +// ────────────────────────────────────────────────────────── +// parseContentRangeTotal / parseDownloadFileSize / parseDriveUploadType +// ────────────────────────────────────────────────────────── + +func TestParseContentRangeTotal(t *testing.T) { + if n, err := parseContentRangeTotal("bytes 0-0/12345"); err != nil || n != 12345 { + t.Errorf("got %d, %v", n, err) + } + for _, bad := range []string{"", "bytes 0-0/*", "bytes 0-0/", "bytes 0-0/abc", "12345"} { + if _, err := parseContentRangeTotal(bad); err == nil { + t.Errorf("%q 应报错", bad) + } + } +} + +func TestParseContentRange(t *testing.T) { + cases := []struct { + in string + wantStart, wantEnd, wantTotal int64 + wantErr bool + }{ + {"bytes 0-1048575/104857600", 0, 1048575, 104857600, false}, + {"bytes 500-999/2000", 500, 999, 2000, false}, + {"bytes 0-0/1", 0, 0, 1, false}, + {"bytes 100-199/*", 100, 199, -1, false}, // total 未知 + {"", 0, 0, 0, true}, // 空串 + {"0-100/200", 0, 0, 0, true}, // 无 "bytes " 前缀 + {"bytes 0-100", 0, 0, 0, true}, // 缺少 /total + {"bytes abc-100/200", 0, 0, 0, true}, // start 非数字 + {"bytes 0-abc/200", 0, 0, 0, true}, // end 非数字 + {"bytes 0-100/abc", 0, 0, 0, true}, // total 非数字 + {"bytes 100-50/200", 0, 0, 0, true}, // end < start + } + for _, c := range cases { + start, end, total, err := parseContentRange(c.in) + if c.wantErr { + if err == nil { + t.Errorf("parseContentRange(%q) 应报错", c.in) + } + continue + } + if err != nil { + t.Errorf("parseContentRange(%q) unexpected error: %v", c.in, err) + continue + } + if start != c.wantStart || end != c.wantEnd || total != c.wantTotal { + t.Errorf("parseContentRange(%q) = (%d,%d,%d), want (%d,%d,%d)", + c.in, start, end, total, c.wantStart, c.wantEnd, c.wantTotal) + } + } +} + +func TestParseDownloadFileSize(t *testing.T) { + if n := parseDownloadFileSize(`{"result":{"fileSize":1048576,"downloadUrl":"u"}}`); n != 1048576 { + t.Errorf("number: got %d", n) + } + if n := parseDownloadFileSize(`{"fileSize":"2048"}`); n != 2048 { + t.Errorf("string: got %d", n) + } + if n := parseDownloadFileSize(`{"result":{}}`); n != 0 { + t.Errorf("missing: got %d", n) + } + if n := parseDownloadFileSize("not json"); n != 0 { + t.Errorf("invalid: got %d", n) + } +} + +func TestParseDownloadFileVersion(t *testing.T) { + // 数值类型 + if n := parseDownloadFileVersion(`{"result":{"version":3,"downloadUrl":"u"}}`); n != 3 { + t.Errorf("number: got %d, want 3", n) + } + // 字符串类型 + if n := parseDownloadFileVersion(`{"version":"7"}`); n != 7 { + t.Errorf("string: got %d, want 7", n) + } + // 缺失字段 + if n := parseDownloadFileVersion(`{"result":{}}`); n != 0 { + t.Errorf("missing: got %d, want 0", n) + } + // 非法 JSON + if n := parseDownloadFileVersion("not json"); n != 0 { + t.Errorf("invalid json: got %d, want 0", n) + } + // 无 result 包裹的数值 + if n := parseDownloadFileVersion(`{"version":12}`); n != 12 { + t.Errorf("top-level number: got %d, want 12", n) + } +} + +func TestParseDriveUploadType(t *testing.T) { + if got := parseDriveUploadType(`{"result":{"uploadType":"httpToCenterWithToken","resourceUrl":"u"}}`); got != uploadTypeCenterToken { + t.Errorf("got %q", got) + } + if got := parseDriveUploadType(`{"resourceUrl":"u"}`); got != "" { + t.Errorf("存量返回无 uploadType 应为空串, got %q", got) + } + if got := parseDriveUploadType("not json"); got != "" { + t.Errorf("invalid json 应为空串, got %q", got) + } +} + +// ────────────────────────────────────────────────────────── +// isAuthStatusError / decorateUploadSizeError +// ────────────────────────────────────────────────────────── + +func TestIsAuthStatusError(t *testing.T) { + if !isAuthStatusError(&httpStatusError{StatusCode: 401}) || !isAuthStatusError(&httpStatusError{StatusCode: 403}) { + t.Error("typed 401/403 应命中") + } + if isAuthStatusError(&httpStatusError{StatusCode: 500}) { + t.Error("typed 500 不应命中") + } + if !isAuthStatusError(fmt.Errorf("OSS upload failed: %w", &httpStatusError{StatusCode: 403, Body: "x"})) { + t.Error("包装后的 typed 错误应命中") + } + if !isAuthStatusError(fmt.Errorf("HTTP 401: expired")) { + t.Error("字符串形态应命中(测试注入兼容)") + } + if isAuthStatusError(nil) || isAuthStatusError(fmt.Errorf("HTTP 404: not found")) { + t.Error("nil/404 不应命中") + } +} + +func TestDecorateUploadSizeError(t *testing.T) { + // 413 → 补充可读提示 + err := decorateUploadSizeError(&httpStatusError{StatusCode: 413, Body: "too large"}, "") + if !strings.Contains(err.Error(), "提示") { + t.Errorf("413 应补充提示: %v", err) + } + // 中心协议 + 超限语义 body + err = decorateUploadSizeError(&httpStatusError{StatusCode: 400, Body: "file size exceed limit"}, uploadTypeCenterToken) + if !strings.Contains(err.Error(), "提示") { + t.Errorf("中心协议超限应补充提示: %v", err) + } + // 普通错误原样返回 + orig := &httpStatusError{StatusCode: 500, Body: "internal"} + if got := decorateUploadSizeError(orig, uploadTypeCenterToken); got.Error() != orig.Error() { + t.Errorf("普通错误不应装饰: %v", got) + } + nonHTTP := fmt.Errorf("dial timeout") + if got := decorateUploadSizeError(nonHTTP, ""); got != nonHTTP { + t.Errorf("非 HTTP 错误不应装饰: %v", got) + } +} + +// ────────────────────────────────────────────────────────── +// driveTransferDownload:整流 / 分片 / 回退 / 断点续传 +// ────────────────────────────────────────────────────────── + +// rangeTestServer 支持 Range 的测试服务端。 +func rangeTestServer(t *testing.T, content []byte, requireToken string, tokenGen *atomic.Int32) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requireToken != "" { + want := requireToken + if tokenGen != nil { + want = fmt.Sprintf("%s-%d", requireToken, tokenGen.Load()) + } + if r.Header.Get("dentry-token") != want { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "token expired") + return + } + } + rng := r.Header.Get("Range") + if rng == "" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + return + } + var start, end int64 + if _, err := fmt.Sscanf(rng, "bytes=%d-%d", &start, &end); err != nil || start > end || start >= int64(len(content)) { + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + return + } + if end >= int64(len(content)) { + end = int64(len(content)) - 1 + } + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[start : end+1]) + })) +} + +func makeTestContent(n int) []byte { + buf := make([]byte, n) + for i := range buf { + buf[i] = byte(i % 251) + } + return buf +} + +func verifyFile(t *testing.T, path string, want []byte) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读取产物失败: %v", err) + } + if len(got) != len(want) { + t.Fatalf("产物长度 %d != %d", len(got), len(want)) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("产物内容第 %d 字节不符", i) + } + } +} + +// 小分片选项:partSize 用引擎内部值绕过 flag 校验(单测直接构造 options)。 +func smallPartOpts(partSize int64, parallel int) driveDownloadOptions { + return driveDownloadOptions{partSize: partSize, parallel: parallel, resume: true} +} + +func TestDriveTransferDownload_RangedParts(t *testing.T) { + content := makeTestContent(1000) + srv := rangeTestServer(t, content, "", nil) + defer srv.Close() + dest := filepath.Join(t.TempDir(), "out.bin") + + // knownSize=1000 ≥ 2×300 → 分片 + opts := smallPartOpts(300, 3) + opts.knownSize = 1000 + if err := driveTransferDownload(context.Background(), nil, srv.URL, nil, dest, opts); err != nil { + t.Fatalf("分片下载失败: %v", err) + } + verifyFile(t, dest, content) + if _, err := os.Stat(dest + drivePartFileSuffix); !os.IsNotExist(err) { + t.Error("完成后应清理 .dwspart") + } + if _, err := os.Stat(dest + drivePartMetaSuffix); !os.IsNotExist(err) { + t.Error("完成后应清理 checkpoint") + } +} + +// knownSize 小于阈值 → 直接整流(走可注入 httpGetFile)。 +func TestDriveTransferDownload_SmallFileSingleStream(t *testing.T) { + var calls atomic.Int32 + SetHTTPGetFile(func(ctx context.Context, url string, headers map[string]string, destPath string) error { + calls.Add(1) + return os.WriteFile(destPath, []byte("small"), 0o644) + }) + defer SetHTTPGetFile(nil) + + dest := filepath.Join(t.TempDir(), "small.bin") + opts := smallPartOpts(1<<20, 4) + opts.knownSize = 100 // < 2MB 阈值 + if err := driveTransferDownload(context.Background(), nil, "https://x.example.com/f", nil, dest, opts); err != nil { + t.Fatalf("整流下载失败: %v", err) + } + if calls.Load() != 1 { + t.Errorf("应恰好调用一次 httpGetFile, got %d", calls.Load()) + } + verifyFile(t, dest, []byte("small")) +} + +// 整流 401 → 重取凭证重试一次。 +func TestDownloadSingleWithAuthRetry(t *testing.T) { + var calls atomic.Int32 + SetHTTPGetFile(func(ctx context.Context, url string, headers map[string]string, destPath string) error { + if calls.Add(1) == 1 { + return &httpStatusError{StatusCode: 401, Body: "expired"} + } + if url != "https://new.example.com/f" || headers["dentry-token"] != "new-token" { + return fmt.Errorf("重试应使用新凭证, got url=%s headers=%v", url, headers) + } + return os.WriteFile(destPath, []byte("ok"), 0o644) + }) + defer SetHTTPGetFile(nil) + + fetched := false + fetch := func(ctx context.Context) (string, map[string]string, error) { + fetched = true + return "https://new.example.com/f", map[string]string{"dentry-token": "new-token"}, nil + } + dest := filepath.Join(t.TempDir(), "auth.bin") + if err := downloadSingleWithAuthRetry(context.Background(), fetch, "https://old.example.com/f", nil, dest); err != nil { + t.Fatalf("401 重试后应成功: %v", err) + } + if !fetched || calls.Load() != 2 { + t.Errorf("fetched=%v calls=%d", fetched, calls.Load()) + } + verifyFile(t, dest, []byte("ok")) +} + +// 整流重取凭证后仍失败 → 走既有错误路径(返回错误)。 +func TestDownloadSingleWithAuthRetry_StillFails(t *testing.T) { + SetHTTPGetFile(func(ctx context.Context, url string, headers map[string]string, destPath string) error { + return &httpStatusError{StatusCode: 403, Body: "denied"} + }) + defer SetHTTPGetFile(nil) + fetch := func(ctx context.Context) (string, map[string]string, error) { + return "https://new.example.com/f", nil, nil + } + err := downloadSingleWithAuthRetry(context.Background(), fetch, "https://old.example.com/f", nil, filepath.Join(t.TempDir(), "x")) + if err == nil || !isAuthStatusError(err) { + t.Fatalf("应返回原始鉴权错误: %v", err) + } +} + +// 服务端不支持 Range(探测返回 200)→ 自动回退整流。 +func TestDriveTransferDownload_FallbackWhenNoRangeSupport(t *testing.T) { + content := makeTestContent(900) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 忽略 Range 头,始终 200 全量 + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "fallback.bin") + opts := smallPartOpts(300, 4) + opts.knownSize = 900 // ≥ 阈值,进入探测 + if err := driveTransferDownload(context.Background(), nil, srv.URL, nil, dest, opts); err != nil { + t.Fatalf("回退整流失败: %v", err) + } + verifyFile(t, dest, content) +} + +// Content-Range 校验:正常匹配、区间错位、header 缺失 +func TestFetchRangeInto_ContentRangeValidation(t *testing.T) { + content := makeTestContent(1000) + + // 正常情况:Content-Range 区间匹配 + t.Run("match", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var start, end int64 + fmt.Sscanf(r.Header.Get("Range"), "bytes=%d-%d", &start, &end) + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[start : end+1]) + })) + defer srv.Close() + + f, err := os.CreateTemp(t.TempDir(), "cr-match-*") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := f.Truncate(1000); err != nil { + t.Fatal(err) + } + part := driveDownloadPart{index: 1, offset: 300, length: 300} + if err := fetchRangeInto(context.Background(), srv.URL, nil, f, part); err != nil { + t.Fatalf("正常匹配不应报错: %v", err) + } + }) + + // Content-Range 区间错位(start 不匹配)→ 返回错误 + t.Run("mismatch_start", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 请求 300-599,但返回假装的 0-299 + w.Header().Set("Content-Range", fmt.Sprintf("bytes 0-299/%d", len(content))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[0:300]) + })) + defer srv.Close() + + f, err := os.CreateTemp(t.TempDir(), "cr-mismatch-*") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := f.Truncate(1000); err != nil { + t.Fatal(err) + } + part := driveDownloadPart{index: 1, offset: 300, length: 300} + err = fetchRangeInto(context.Background(), srv.URL, nil, f, part) + if err == nil { + t.Fatal("Content-Range 错位应返回错误") + } + if !strings.Contains(err.Error(), "不匹配") { + t.Errorf("错误信息应包含'不匹配': %v", err) + } + }) + + // Content-Range header 缺失 → 不阻断,正常下载 + t.Run("missing_header", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var start, end int64 + fmt.Sscanf(r.Header.Get("Range"), "bytes=%d-%d", &start, &end) + // 不设置 Content-Range header + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[start : end+1]) + })) + defer srv.Close() + + f, err := os.CreateTemp(t.TempDir(), "cr-missing-*") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := f.Truncate(1000); err != nil { + t.Fatal(err) + } + part := driveDownloadPart{index: 0, offset: 0, length: 300} + if err := fetchRangeInto(context.Background(), srv.URL, nil, f, part); err != nil { + t.Fatalf("Content-Range 缺失不应阻断下载: %v", err) + } + }) + + // Content-Range 格式异常 → 返回错误 + t.Run("malformed_header", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Range", "invalid-format") + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[0:300]) + })) + defer srv.Close() + + f, err := os.CreateTemp(t.TempDir(), "cr-malformed-*") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := f.Truncate(1000); err != nil { + t.Fatal(err) + } + part := driveDownloadPart{index: 0, offset: 0, length: 300} + err = fetchRangeInto(context.Background(), srv.URL, nil, f, part) + if err == nil { + t.Fatal("Content-Range 格式异常应返回错误") + } + if !strings.Contains(err.Error(), "解析失败") { + t.Errorf("错误信息应包含'解析失败': %v", err) + } + }) +} + +// 分片过程 401 → single-flight 刷新凭证后续传(不重下已完成分片)。 +func TestDriveTransferDownload_AuthRefreshDuringParts(t *testing.T) { + content := makeTestContent(1200) + var tokenGen atomic.Int32 // 服务端当前有效 token 代数 + srv := rangeTestServer(t, content, "tok", &tokenGen) + defer srv.Close() + + var fetchCalls atomic.Int32 + fetch := func(ctx context.Context) (string, map[string]string, error) { + fetchCalls.Add(1) + return srv.URL, map[string]string{"dentry-token": fmt.Sprintf("tok-%d", tokenGen.Load())}, nil + } + dest := filepath.Join(t.TempDir(), "auth-parts.bin") + opts := smallPartOpts(300, 2) + opts.knownSize = 1200 + + // 初始凭证是第 0 代;探测后服务端轮换到第 1 代,分片请求将收到 401 + initial := map[string]string{"dentry-token": "tok-0"} + go func() { + // 探测完成前不轮换:用探测本身消耗第 0 代,随后轮换 + tokenGen.Store(1) + }() + if err := driveTransferDownload(context.Background(), fetch, srv.URL, initial, dest, opts); err != nil { + t.Fatalf("凭证刷新续传失败: %v", err) + } + verifyFile(t, dest, content) +} + +// 断点续传:模拟部分分片完成后中断,重跑跳过已完成分片。 +func TestDriveTransferDownload_ResumeSkipsCompletedParts(t *testing.T) { + content := makeTestContent(1000) + partSize := int64(300) + dest := filepath.Join(t.TempDir(), "resume.bin") + partPath := dest + drivePartFileSuffix + metaPath := dest + drivePartMetaSuffix + + // 预置:分片 0、2 已完成(写入正确数据),1、3 未完成 + pre := make([]byte, 1000) + copy(pre[0:300], content[0:300]) + copy(pre[600:900], content[600:900]) + if err := os.WriteFile(partPath, pre, 0o644); err != nil { + t.Fatal(err) + } + const resumeNodeID = "resume-test-node" + + // 服务端记录收到的 Range 区间 + var mu struct { + ranges []string + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rng := r.Header.Get("Range") + mu.ranges = append(mu.ranges, rng) + var start, end int64 + fmt.Sscanf(rng, "bytes=%d-%d", &start, &end) + if end >= int64(len(content)) { + end = int64(len(content)) - 1 + } + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[start : end+1]) + })) + defer srv.Close() + + cp := &driveDownloadCheckpoint{ + Version: driveCheckpointVersion, + Fingerprint: driveDownloadFingerprint(resumeNodeID, 0, 1000, srv.URL), + TotalSize: 1000, + PartSize: partSize, + Completed: []bool{true, false, true, false}, + } + if err := cp.save(metaPath); err != nil { + t.Fatal(err) + } + + opts := smallPartOpts(partSize, 1) // 串行便于断言 + opts.knownSize = 1000 + opts.nodeID = resumeNodeID + if err := driveTransferDownload(context.Background(), nil, srv.URL, nil, dest, opts); err != nil { + t.Fatalf("续传失败: %v", err) + } + verifyFile(t, dest, content) + + // 探测请求(bytes=0-0) + 分片1(300-599) + 分片3(900-999),不应重下分片0/2 + for _, rng := range mu.ranges { + if rng == fmt.Sprintf("bytes=%d-%d", 0, partSize-1) || rng == fmt.Sprintf("bytes=%d-%d", 2*partSize, 3*partSize-1) { + t.Errorf("已完成分片被重下: %s", rng) + } + } +} + +// --no-resume:清理历史断点从头下载,且过程中不写 checkpoint。 +func TestDriveTransferDownload_NoResume(t *testing.T) { + content := makeTestContent(1000) + srv := rangeTestServer(t, content, "", nil) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "noresume.bin") + // 预置一个陈旧 checkpoint(若未清理会因 Completed 全 true 跳过下载产出错误内容) + stale := &driveDownloadCheckpoint{ + Version: driveCheckpointVersion, + Fingerprint: driveDownloadFingerprint("stale-node", 0, 1000, ""), + TotalSize: 1000, + PartSize: 300, + Completed: []bool{true, true, true, true}, + } + if err := os.WriteFile(dest+drivePartFileSuffix, make([]byte, 1000), 0o644); err != nil { + t.Fatal(err) + } + if err := stale.save(dest + drivePartMetaSuffix); err != nil { + t.Fatal(err) + } + + opts := driveDownloadOptions{partSize: 300, parallel: 2, resume: false, knownSize: 1000} + if err := driveTransferDownload(context.Background(), nil, srv.URL, nil, dest, opts); err != nil { + t.Fatalf("no-resume 下载失败: %v", err) + } + verifyFile(t, dest, content) // 内容正确说明确实重下而非采信陈旧 checkpoint + if _, err := os.Stat(dest + drivePartMetaSuffix); !os.IsNotExist(err) { + t.Error("no-resume 不应遗留 checkpoint") + } +} + +// 分片失败重试后成功(指数退避路径)。 +func TestDownloadOnePart_RetryOnTransientError(t *testing.T) { + content := makeTestContent(600) + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if hits.Add(1) <= 2 { // 前两次 500 + w.WriteHeader(http.StatusInternalServerError) + return + } + rng := r.Header.Get("Range") + var start, end int64 + fmt.Sscanf(rng, "bytes=%d-%d", &start, &end) + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[start : end+1]) + })) + defer srv.Close() + + dir := t.TempDir() + f, err := os.Create(filepath.Join(dir, "part.tmp")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := f.Truncate(600); err != nil { + t.Fatal(err) + } + creds := &driveCredentialState{url: srv.URL} + if err := downloadOnePart(context.Background(), creds, f, driveDownloadPart{index: 0, offset: 0, length: 600}); err != nil { + t.Fatalf("瞬时错误重试后应成功: %v", err) + } + if hits.Load() != 3 { + t.Errorf("应请求 3 次, got %d", hits.Load()) + } +} + +// 分片持续失败超过重试上限 → 返回错误。 +func TestDownloadOnePart_ExhaustsRetries(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + f, err := os.Create(filepath.Join(t.TempDir(), "part.tmp")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + creds := &driveCredentialState{url: srv.URL} + err = downloadOnePart(context.Background(), creds, f, driveDownloadPart{index: 0, offset: 0, length: 10}) + if err == nil { + t.Fatal("超过重试上限应失败") + } + if hits.Load() != int32(driveDownloadPartRetries)+1 { + t.Errorf("应请求 %d 次, got %d", driveDownloadPartRetries+1, hits.Load()) + } +} + +// ────────────────────────────────────────────────────────── +// driveUploadPut:中心协议 PUT + 401 重取凭证重试 +// ────────────────────────────────────────────────────────── + +func TestDriveUploadPut_Success(t *testing.T) { + var gotURL string + var gotHeaders map[string]string + SetHTTPPutFile(func(ctx context.Context, url string, headers map[string]string, filePath string, fileSize int64) error { + gotURL, gotHeaders = url, headers + return nil + }) + defer SetHTTPPutFile(nil) + + cred := `{"result":{"uploadType":"httpToCenterWithToken","resourceUrl":"https://c.example.com/attachment/token/upload/single?file_size=5&spaceId=s1&upload_key=k1","uploadId":"k1","headers":{"dentry-token":"tk"}}}` + uploadID, err := driveUploadPut(context.Background(), cred, nil, "/tmp/f.bin", 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if uploadID != "k1" { + t.Errorf("uploadID = %q", uploadID) + } + if !strings.Contains(gotURL, "upload_key=k1") { + t.Errorf("应使用服务端拼好的完整 URL: %q", gotURL) + } + if gotHeaders["dentry-token"] != "tk" { + t.Errorf("headers 应透传 dentry-token: %v", gotHeaders) + } +} + +func TestDriveUploadPut_AuthRetryWithNewCredential(t *testing.T) { + var calls atomic.Int32 + SetHTTPPutFile(func(ctx context.Context, url string, headers map[string]string, filePath string, fileSize int64) error { + if calls.Add(1) == 1 { + return &httpStatusError{StatusCode: 401, Body: "token expired"} + } + if headers["dentry-token"] != "tk2" { + return fmt.Errorf("重试应使用新 token, got %v", headers) + } + return nil + }) + defer SetHTTPPutFile(nil) + + cred1 := `{"result":{"uploadType":"httpToCenterWithToken","resourceUrl":"https://c.example.com/u?upload_key=k1","uploadId":"k1","headers":{"dentry-token":"tk1"}}}` + cred2 := `{"result":{"uploadType":"httpToCenterWithToken","resourceUrl":"https://c.example.com/u?upload_key=k2","uploadId":"k2","headers":{"dentry-token":"tk2"}}}` + refetch := func(ctx context.Context) (string, error) { return cred2, nil } + + uploadID, err := driveUploadPut(context.Background(), cred1, refetch, "/tmp/f.bin", 5) + if err != nil { + t.Fatalf("401 重试后应成功: %v", err) + } + if uploadID != "k2" { + t.Errorf("应返回新凭证的 uploadId, got %q", uploadID) + } + if calls.Load() != 2 { + t.Errorf("PUT 应调用 2 次, got %d", calls.Load()) + } +} + +func TestDriveUploadPut_AuthRetryStillFails(t *testing.T) { + SetHTTPPutFile(func(ctx context.Context, url string, headers map[string]string, filePath string, fileSize int64) error { + return &httpStatusError{StatusCode: 403, Body: "denied"} + }) + defer SetHTTPPutFile(nil) + + cred := `{"result":{"resourceUrl":"https://c.example.com/u","uploadId":"k1"}}` + refetch := func(ctx context.Context) (string, error) { return cred, nil } + if _, err := driveUploadPut(context.Background(), cred, refetch, "/tmp/f.bin", 5); err == nil { + t.Fatal("重试仍失败应返回错误") + } +} + +func TestDriveUploadPut_SizeLimitHint(t *testing.T) { + SetHTTPPutFile(func(ctx context.Context, url string, headers map[string]string, filePath string, fileSize int64) error { + return &httpStatusError{StatusCode: 413, Body: "request entity too large"} + }) + defer SetHTTPPutFile(nil) + + cred := `{"result":{"uploadType":"httpToCenterWithToken","resourceUrl":"https://c.example.com/u","uploadId":"k1","headers":{"dentry-token":"tk"}}}` + _, err := driveUploadPut(context.Background(), cred, nil, "/tmp/f.bin", 5) + if err == nil || !strings.Contains(err.Error(), "提示") { + t.Fatalf("超限错误应含可读提示: %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// parseDriveDownloadInfo:中心协议 headers 透传(新增行为) +// ────────────────────────────────────────────────────────── + +func TestParseDriveDownloadInfo_CenterProtocolHeaders(t *testing.T) { + text := `{"result":{"downloadType":"httpToCenterWithToken","downloadUrl":"https://c.example.com/attachment/token/mdown?k=v","headers":{"dentry-token":"tk"},"fileName":"a.bin"},"success":true}` + url, headers, err := parseDriveDownloadInfo(text) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if url != "https://c.example.com/attachment/token/mdown?k=v" { + t.Errorf("url = %q", url) + } + if headers["dentry-token"] != "tk" { + t.Errorf("中心协议应透传 dentry-token, got %v", headers) + } +} + +// ────────────────────────────────────────────────────────── +// driveCredentialState:single-flight 刷新 +// ────────────────────────────────────────────────────────── + +func TestDriveCredentialStateSingleFlightRefresh(t *testing.T) { + var fetches atomic.Int32 + cs := &driveCredentialState{ + url: "u0", + fetch: func(ctx context.Context) (string, map[string]string, error) { + n := fetches.Add(1) + return fmt.Sprintf("u%d", n), nil, nil + }, + } + _, _, gen0 := cs.current() + // 两个并发分片都持第 0 代请求刷新:只应真正 fetch 一次 + if err := cs.refresh(context.Background(), gen0); err != nil { + t.Fatal(err) + } + if err := cs.refresh(context.Background(), gen0); err != nil { + t.Fatal(err) + } + if fetches.Load() != 1 { + t.Errorf("同代刷新应 single-flight, fetch 次数 = %d", fetches.Load()) + } + url, _, gen1 := cs.current() + if url != "u1" || gen1 != gen0+1 { + t.Errorf("刷新后 url=%q gen=%d", url, gen1) + } + // 持新代再刷新 → 再 fetch 一次 + if err := cs.refresh(context.Background(), gen1); err != nil { + t.Fatal(err) + } + if fetches.Load() != 2 { + t.Errorf("新代刷新应真正执行, fetch 次数 = %d", fetches.Load()) + } +} diff --git a/skills/mono/references/products/drive.md b/skills/mono/references/products/drive.md index 3b9ed6fb..80e7e3ed 100644 --- a/skills/mono/references/products/drive.md +++ b/skills/mono/references/products/drive.md @@ -202,14 +202,23 @@ Usage: Example: dws drive download --node --output ./report.pdf dws drive download --node --output ~/downloads/ + dws drive download --node --output ./big.zip --part-size 32MB --parallel 8 Flags: --node string 文件 ID (dentryUuid) (必填) --output string 本地保存路径 (必填),可以是文件路径或目录;如果指定目录,文件名从下载 URL 中自动推断 --space-id string 文件所属空间 ID (可选) + --part-size string 分片下载的分片大小,支持 KB/MB/GB 单位,范围 1MB-1GB (默认 16MB) + --parallel int 分片下载并发数,范围 1-8 (默认 4) + --no-resume 关闭断点续传,忽略历史下载进度从头下载 (默认开启续传) ``` > **注意**:`--output` 是必填参数,不传会报错。 +> **大文件分片下载**: +> - 大文件自动分片并发下载,小文件整流下载,行为对用户透明,无需任何额外操作。 +> - 断点续传默认开启:下载中断后重跑同一命令会自动跳过已完成部分继续下载(`<目标文件>.dwspart` 为临时进度文件,下载完成后自动清理);不需要续传时加 `--no-resume`。 +> - 下载凭证过期会自动刷新并继续下载,已完成的部分不会重下;单个分片失败会自动重试,无需手动处理。 + ### 创建文件夹 ``` diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index b0c3fc20..ce1cc74e 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -170,14 +170,23 @@ Usage: Example: dws drive download --node --output ./report.pdf dws drive download --node --output ~/downloads/ + dws drive download --node --output ./big.zip --part-size 32MB --parallel 8 Flags: --node string 文件 ID (dentryUuid) (必填) --output string 本地保存路径 (文件路径或目录,不传则保存到当前目录);如果指定目录,文件名从下载 URL 中自动推断 (可选) --space-id string 文件所属空间 ID (可选) + --part-size string 分片下载的分片大小,支持 KB/MB/GB 单位,范围 1MB-1GB (默认 16MB) + --parallel int 分片下载并发数,范围 1-8 (默认 4) + --no-resume 关闭断点续传,忽略历史下载进度从头下载 (默认开启续传) ``` > **提示**:`--output` 为可选参数,不传则保存到当前目录,文件名从下载 URL 中自动推断。 +> **大文件分片下载**: +> - 大文件自动分片并发下载,小文件整流下载,行为对用户透明,无需任何额外操作。 +> - 断点续传默认开启:下载中断后重跑同一命令会自动跳过已完成部分继续下载(`<目标文件>.dwspart` 为临时进度文件,下载完成后自动清理);不需要续传时加 `--no-resume`。 +> - 下载凭证过期会自动刷新并继续下载,已完成的部分不会重下;单个分片失败会自动重试,无需手动处理。 + ### 创建文件夹 ``` @@ -526,6 +535,7 @@ Flags: > **两步下载流程**:先调用 MCP 工具获取历史版本下载 URL 和签名头,再 HTTP GET 下载文件内容到本地。 > `--output` 指定目录时,优先从文件信息中获取原始文件名,获取不到时从下载 URL 推断。 +> 历史版本下载同样支持 `--part-size` / `--parallel` / `--no-resume` 分片下载参数,行为与最新版下载一致。 #### 回滚文件到指定历史版本 From fdcd44f9e3e802fc40693e4c4393dfbad0960a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=8A=E5=9C=AD?= Date: Mon, 3 Aug 2026 16:53:17 +0800 Subject: [PATCH 10/15] fix: handle Ctrl+C (SIGINT) gracefully during drive download - Replace context.Background() with cmd.Context() in download and download-version commands so SIGINT propagates to download goroutines - Enables graceful interruption of multipart downloads via Ctrl+C --- internal/helpers/drive.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index 69b6968a..32feda63 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -509,7 +509,7 @@ func newDriveCommand() *cobra.Command { return nil } - ctx := context.Background() + ctx := cmd.Context() // Step 1: 获取下载 URL 和签名请求头 deps.Out.PrintInfo("[1/2] 获取下载链接...") @@ -600,7 +600,7 @@ func newDriveCommand() *cobra.Command { return nil } - ctx := context.Background() + ctx := cmd.Context() deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...") dlArgsMap := map[string]any{ "nodeId": fileID, From 837a96fe3df07c4750c319d714b13b6c426a4c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=8A=E5=9C=AD?= Date: Mon, 3 Aug 2026 17:00:38 +0800 Subject: [PATCH 11/15] fix: show friendly message on Ctrl+C instead of internal error JSON When user interrupts multipart download with Ctrl+C, display a helpful message indicating checkpoint is saved and download can be resumed, instead of returning an internal error with context.Canceled. --- CHANGELOG.md | 1 + internal/cli/schema_agent_metadata/index.json | 2 +- internal/cli/schema_agent_metadata_audit.json | 2 +- internal/cli/schema_catalog/catalog.json | 4 +- internal/cli/schema_catalog/tools/drive.json | 578 +++- internal/cli/schema_parameter_bindings.json | 6 + internal/helpers/drive.go | 47 +- internal/helpers/drive_transfer.go | 121 +- internal/helpers/drive_transfer_test.go | 2396 ++++++++++++++++- 9 files changed, 3053 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95265ad3..a6f1a63d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ validated private runtime directory. ### Added - **Aitable workflow editing reference** (#851) — adds `dws aitable workflow edit-example`, a parameter-free read command that returns the service-provided workflow editing documentation and `workflow-dsl/v1` examples through `aitable/edit_workflow_example`. +- **Multipart download engine** — adds `--part-size`, `--parallel`, and `--no-resume` flags to `drive download` and `drive download-version`. Files exceeding the part-size threshold are downloaded in parallel chunks with automatic Range probe, checkpoint-based resume, and credential refresh on 401/403. Ctrl+C gracefully preserves the checkpoint for later resume. ### Fixed diff --git a/internal/cli/schema_agent_metadata/index.json b/internal/cli/schema_agent_metadata/index.json index 45d82c88..ae474204 100644 --- a/internal/cli/schema_agent_metadata/index.json +++ b/internal/cli/schema_agent_metadata/index.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:b513a679eb51b64afa3f31364b45c29bc4590f0568908650f426ac8f4b041b2b", + "source_hash": "sha256:f3d5e5d6ca3c613b1d004d68eca89679d0f8cffe882d7fed0fbb1bcef078fdc9", "surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894", "coverage": { "surface_products": 26, diff --git a/internal/cli/schema_agent_metadata_audit.json b/internal/cli/schema_agent_metadata_audit.json index 8c1b99fd..128ddd3d 100644 --- a/internal/cli/schema_agent_metadata_audit.json +++ b/internal/cli/schema_agent_metadata_audit.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:b513a679eb51b64afa3f31364b45c29bc4590f0568908650f426ac8f4b041b2b", + "source_hash": "sha256:f3d5e5d6ca3c613b1d004d68eca89679d0f8cffe882d7fed0fbb1bcef078fdc9", "surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894", "source_files": 160, "hint_files": 54, diff --git a/internal/cli/schema_catalog/catalog.json b/internal/cli/schema_catalog/catalog.json index eb2a389a..e406cf73 100644 --- a/internal/cli/schema_catalog/catalog.json +++ b/internal/cli/schema_catalog/catalog.json @@ -1,12 +1,12 @@ { "version": 1, "surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894", - "source_hash": "sha256:1b4f0e6e6fe49115137ff81717462374b2fdd05ad4ca1bfb1b2dd8272aa6bad8", + "source_hash": "sha256:00285e7f05c4ed9e9e3aa7464c7114990187dd7ffdcc9e83e64f3ec33d22d0a8", "catalog": { "agent_metadata": { "products_with_metadata": 26, "source": "embedded-skill-metadata", - "source_hash": "sha256:b513a679eb51b64afa3f31364b45c29bc4590f0568908650f426ac8f4b041b2b", + "source_hash": "sha256:f3d5e5d6ca3c613b1d004d68eca89679d0f8cffe882d7fed0fbb1bcef078fdc9", "surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894", "surface_products": 26, "surface_tools": 846, diff --git a/internal/cli/schema_catalog/tools/drive.json b/internal/cli/schema_catalog/tools/drive.json index 0581d77a..b62dd8b3 100644 --- a/internal/cli/schema_catalog/tools/drive.json +++ b/internal/cli/schema_catalog/tools/drive.json @@ -5312,8 +5312,99 @@ "is_alias": false, "metadata_source": "embedded-mcp-metadata", "name": "download_file", - "parameter_count": 3, + "parameter_count": 6, "parameters": { + "no-resume": { + "description": "关闭断点续传 (可选)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "关闭断点续传 (可选)" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "关闭断点续传 (可选)" + }, + "property": { + "candidates": [ + { + "precedence": "reviewed_mapping_exclusion", + "review_reason": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.", + "selected": true, + "source": "reviewed_mapping_exclusion", + "value": "" + }, + { + "precedence": "inference", + "selected": false, + "source": "flag_name_inference", + "value": "noResume" + } + ], + "precedence": "reviewed_mapping_exclusion", + "resolution": "highest_precedence", + "review_reason": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.", + "source": "reviewed_mapping_exclusion", + "value": "" + }, + "required": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": false + } + ], + "precedence": "default", + "resolution": "fallback", + "source": "default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "boolean" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "boolean" + } + }, + "required": false, + "type": "boolean" + }, "node": { "description": "文件 ID (dentryUuid) (必填)", "field_provenance": { @@ -5520,6 +5611,202 @@ "required": true, "type": "string" }, + "parallel": { + "default": "4", + "description": "分片下载并发数,范围 1-8 (可选)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "分片下载并发数,范围 1-8 (可选)" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "分片下载并发数,范围 1-8 (可选)" + }, + "property": { + "candidates": [ + { + "precedence": "reviewed_mapping_exclusion", + "review_reason": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.", + "selected": true, + "source": "reviewed_mapping_exclusion", + "value": "" + }, + { + "precedence": "inference", + "selected": false, + "source": "flag_name_inference", + "value": "parallel" + } + ], + "precedence": "reviewed_mapping_exclusion", + "resolution": "highest_precedence", + "review_reason": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.", + "source": "reviewed_mapping_exclusion", + "value": "" + }, + "required": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_nonzero_default", + "value": false + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": false + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_nonzero_default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "integer" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "integer" + } + }, + "required": false, + "type": "integer" + }, + "part-size": { + "default": "16MB", + "description": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)" + }, + "property": { + "candidates": [ + { + "precedence": "reviewed_mapping_exclusion", + "review_reason": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.", + "selected": true, + "source": "reviewed_mapping_exclusion", + "value": "" + }, + { + "precedence": "inference", + "selected": false, + "source": "flag_name_inference", + "value": "partSize" + } + ], + "precedence": "reviewed_mapping_exclusion", + "resolution": "highest_precedence", + "review_reason": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.", + "source": "reviewed_mapping_exclusion", + "value": "" + }, + "required": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_nonzero_default", + "value": false + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": false + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_nonzero_default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "string" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "string" + } + }, + "required": false, + "type": "string" + }, "space-id": { "description": "文件所属空间 ID (可选)", "field_provenance": { @@ -5937,8 +6224,99 @@ "interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.", "is_alias": false, "name": "download_file_version", - "parameter_count": 3, + "parameter_count": 6, "parameters": { + "no-resume": { + "description": "关闭断点续传 (可选)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "关闭断点续传 (可选)" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "关闭断点续传 (可选)" + }, + "property": { + "candidates": [ + { + "precedence": "reviewed_mapping_exclusion", + "review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.", + "selected": true, + "source": "reviewed_mapping_exclusion", + "value": "" + }, + { + "precedence": "inference", + "selected": false, + "source": "flag_name_inference", + "value": "noResume" + } + ], + "precedence": "reviewed_mapping_exclusion", + "resolution": "highest_precedence", + "review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.", + "source": "reviewed_mapping_exclusion", + "value": "" + }, + "required": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": false + } + ], + "precedence": "default", + "resolution": "fallback", + "source": "default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "boolean" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "boolean" + } + }, + "required": false, + "type": "boolean" + }, "node": { "description": "文件 ID (dentryUuid) 或 URL (必填)", "field_provenance": { @@ -6133,6 +6511,202 @@ "required": true, "type": "string" }, + "parallel": { + "default": "4", + "description": "分片下载并发数,范围 1-8 (可选)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "分片下载并发数,范围 1-8 (可选)" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "分片下载并发数,范围 1-8 (可选)" + }, + "property": { + "candidates": [ + { + "precedence": "reviewed_mapping_exclusion", + "review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.", + "selected": true, + "source": "reviewed_mapping_exclusion", + "value": "" + }, + { + "precedence": "inference", + "selected": false, + "source": "flag_name_inference", + "value": "parallel" + } + ], + "precedence": "reviewed_mapping_exclusion", + "resolution": "highest_precedence", + "review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.", + "source": "reviewed_mapping_exclusion", + "value": "" + }, + "required": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_nonzero_default", + "value": false + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": false + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_nonzero_default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "integer" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "integer" + } + }, + "required": false, + "type": "integer" + }, + "part-size": { + "default": "16MB", + "description": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)" + }, + "property": { + "candidates": [ + { + "precedence": "reviewed_mapping_exclusion", + "review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.", + "selected": true, + "source": "reviewed_mapping_exclusion", + "value": "" + }, + { + "precedence": "inference", + "selected": false, + "source": "flag_name_inference", + "value": "partSize" + } + ], + "precedence": "reviewed_mapping_exclusion", + "resolution": "highest_precedence", + "review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.", + "source": "reviewed_mapping_exclusion", + "value": "" + }, + "required": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_nonzero_default", + "value": false + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": false + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_nonzero_default", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "string" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "string" + } + }, + "required": false, + "type": "string" + }, "version": { "description": "历史版本号 (必填,正整数,从 drive list --versions 获取)", "field_provenance": { diff --git a/internal/cli/schema_parameter_bindings.json b/internal/cli/schema_parameter_bindings.json index 2b0be894..76cb75ee 100644 --- a/internal/cli/schema_parameter_bindings.json +++ b/internal/cli/schema_parameter_bindings.json @@ -1790,9 +1790,15 @@ "drive.apply_permission --reason": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --reason is a CLI wrapper input and does not publish a direct interface property.", "drive.apply_permission --role": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --role is a CLI wrapper input and does not publish a direct interface property.", "drive.apply_permission --users": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --users is a CLI wrapper input and does not publish a direct interface property.", + "drive.download_file --no-resume": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.", "drive.download_file --output": "local output path", + "drive.download_file --parallel": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.", + "drive.download_file --part-size": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.", + "drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.", "drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.", "drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.", + "drive.download_file_version --parallel": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.", + "drive.download_file_version --part-size": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.", "drive.download_file_version --version": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.", "drive.get_cover --node": "Reviewed unpinned adapter: drive.get_cover has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.", "drive.get_star_list --content-types": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --content-types is a CLI wrapper input and does not publish a direct interface property.", diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index 32feda63..7c136f63 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -518,7 +518,7 @@ func newDriveCommand() *cobra.Command { return err } - resourceURL, dlHeaders, err := parseDownloadInfo(text) + resourceURL, dlHeaders, err := parseDriveDownloadInfo(text) if err != nil { return err } @@ -537,14 +537,30 @@ func newDriveCommand() *cobra.Command { deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath)) dlOpts.knownSize = parseDownloadFileSize(text) dlOpts.nodeID = fileID - fetchCred := func(fctx context.Context) (string, map[string]string, error) { + dlOpts.version = parseDownloadFileVersion(text) + fetchCred := func(fctx context.Context) (string, map[string]string, int, error) { t, ferr := callMCPToolReturnText(fctx, "download_file", argsMap) if ferr != nil { - return "", nil, ferr + return "", nil, 0, ferr } - return parseDownloadInfo(t) + u, h, perr := parseDriveDownloadInfo(t) + if perr != nil { + return "", nil, 0, perr + } + return u, h, parseDownloadFileVersion(t), nil } if err := driveTransferDownload(ctx, fetchCred, resourceURL, dlHeaders, outputPath, dlOpts); err != nil { + if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { + partSize, _ := cmd.Flags().GetString("part-size") + noResume, _ := cmd.Flags().GetBool("no-resume") + if partSize != "" && !noResume { + fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断,已保存断点(可重新执行相同命令续传)\n") + } else { + fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断\n") + } + cmd.SilenceErrors = true + return err + } return err } @@ -610,7 +626,7 @@ func newDriveCommand() *cobra.Command { if err != nil { return err } - resourceURL, dlHeaders, err := parseDownloadInfo(text) + resourceURL, dlHeaders, err := parseDriveDownloadInfo(text) if err != nil { return err } @@ -625,14 +641,29 @@ func newDriveCommand() *cobra.Command { dlOpts.knownSize = parseDownloadFileSize(text) dlOpts.nodeID = fileID dlOpts.version = versionNum - fetchCred := func(fctx context.Context) (string, map[string]string, error) { + fetchCred := func(fctx context.Context) (string, map[string]string, int, error) { t, ferr := callMCPToolReturnTextOnServer(fctx, "drive", "download_file_version", dlArgsMap) if ferr != nil { - return "", nil, ferr + return "", nil, 0, ferr } - return parseDownloadInfo(t) + u, h, perr := parseDriveDownloadInfo(t) + if perr != nil { + return "", nil, 0, perr + } + return u, h, parseDownloadFileVersion(t), nil } if err := driveTransferDownload(ctx, fetchCred, resourceURL, dlHeaders, outputPath, dlOpts); err != nil { + if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { + partSize, _ := cmd.Flags().GetString("part-size") + noResume, _ := cmd.Flags().GetBool("no-resume") + if partSize != "" && !noResume { + fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断,已保存断点(可重新执行相同命令续传)\n") + } else { + fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断\n") + } + cmd.SilenceErrors = true + return err + } return err } deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath)) diff --git a/internal/helpers/drive_transfer.go b/internal/helpers/drive_transfer.go index b60306bf..47704091 100644 --- a/internal/helpers/drive_transfer.go +++ b/internal/helpers/drive_transfer.go @@ -53,6 +53,19 @@ const ( // 整流路径仍走可注入的 httpGetFile,保持既有测试注入点不变。 var driveRangeClient = &http.Client{Timeout: 10 * time.Minute} +// errCredentialRefreshVersionUnknown 凭证刷新后无法验证文件版本一致性(双方之一 +// version=0),激进策略要求清空已完成分片从头下载。 +var errCredentialRefreshVersionUnknown = errors.New("凭证刷新后无法验证文件版本一致性,需从头下载") + +// Testable OS operation hooks (package-level for coverage injection). +var ( + driveJsonMarshal = json.Marshal + driveOsRename = os.Rename + driveFileTruncate = (*os.File).Truncate + driveFileSync = (*os.File).Sync + driveFileStat = (*os.File).Stat +) + // ────────────────────────────────────────────────────────── // HTTP 状态错误 // ────────────────────────────────────────────────────────── @@ -203,14 +216,15 @@ func parseDownloadFileVersion(text string) int { // ────────────────────────────────────────────────────────── // driveCredentialFetcher 重新调用 MCP 获取下载 URL + headers(含 dentry-token)。 -type driveCredentialFetcher func(ctx context.Context) (string, map[string]string, error) +type driveCredentialFetcher func(ctx context.Context) (url string, headers map[string]string, version int, err error) type driveCredentialState struct { - mu sync.Mutex - fetch driveCredentialFetcher - url string - headers map[string]string - gen int + mu sync.Mutex + fetch driveCredentialFetcher + url string + headers map[string]string + gen int + initialVersion int // 首次获取的文件版本号;0 表示未知(兼容旧 MCP) } func (cs *driveCredentialState) current() (string, map[string]string, int) { @@ -230,10 +244,24 @@ func (cs *driveCredentialState) refresh(ctx context.Context, gen int) error { if cs.fetch == nil { return fmt.Errorf("下载凭证已过期且无法自动刷新") } - url, headers, err := cs.fetch(ctx) + url, headers, version, err := cs.fetch(ctx) if err != nil { return err } + // 版本校验 + if cs.initialVersion > 0 && version > 0 { + if version != cs.initialVersion { + // 双方版本已知且不同 → 文件已被覆盖,终止下载防止数据不一致 + return fmt.Errorf("下载凭证刷新后文件版本已变更(%d → %d),终止下载以防数据不一致", cs.initialVersion, version) + } + // 版本一致,正常继续 + } else { + // 激进策略:版本不可验证(至少一方为 0),更新凭证但返回特殊错误 + // 让上层清空已完成分片从头下载 + cs.url, cs.headers = url, headers + cs.gen++ + return errCredentialRefreshVersionUnknown + } cs.url, cs.headers = url, headers cs.gen++ return nil @@ -262,7 +290,7 @@ func driveTransferDownload(ctx context.Context, fetch driveCredentialFetcher, ra return downloadSingleWithAuthRetry(ctx, fetch, rawURL, headers, destPath) } - creds := &driveCredentialState{fetch: fetch, url: rawURL, headers: headers} + creds := &driveCredentialState{fetch: fetch, url: rawURL, headers: headers, initialVersion: opts.version} totalSize, fullResp, err := probeRangeSupport(ctx, creds) if err != nil { return err @@ -275,9 +303,9 @@ func driveTransferDownload(ctx context.Context, fetch driveCredentialFetcher, ra curURL, curHeaders, _ := creds.current() if totalSize <= 0 || totalSize < threshold { // 总长未知(Content-Range 异常)或小于阈值:整流下载 - return downloadSingleWithAuthRetry(ctx, func(fctx context.Context) (string, map[string]string, error) { + return downloadSingleWithAuthRetry(ctx, func(fctx context.Context) (string, map[string]string, int, error) { if fetch == nil { - return "", nil, fmt.Errorf("下载凭证已过期且无法自动刷新") + return "", nil, 0, fmt.Errorf("下载凭证已过期且无法自动刷新") } return fetch(fctx) }, curURL, curHeaders, destPath) @@ -292,7 +320,7 @@ func downloadSingleWithAuthRetry(ctx context.Context, fetch driveCredentialFetch if err == nil || !isAuthStatusError(err) || fetch == nil { return err } - newURL, newHeaders, ferr := fetch(ctx) + newURL, newHeaders, _, ferr := fetch(ctx) if ferr != nil { return err } @@ -304,7 +332,7 @@ func downloadSingleWithAuthRetry(ctx context.Context, fetch driveCredentialFetch // 返回 (0, resp, nil) 表示服务端忽略 Range 返回 200 全量响应(调用方直接消费); // 401/403 时刷新凭证重试一次。 func probeRangeSupport(ctx context.Context, creds *driveCredentialState) (int64, *http.Response, error) { - for attempt := 0; attempt < 2; attempt++ { + for attempt := 0; ; attempt++ { urlStr, headers, gen := creds.current() req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) if err != nil { @@ -329,20 +357,24 @@ func probeRangeSupport(ctx context.Context, creds *driveCredentialState) (int64, return total, nil, nil case resp.StatusCode == http.StatusOK: return 0, resp, nil - case (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && attempt == 0: + case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap)) resp.Body.Close() - if rerr := creds.refresh(ctx, gen); rerr != nil { + if attempt > 0 { + return 0, nil, fmt.Errorf("下载凭证刷新后仍鉴权失败") + } + if rerr := creds.refresh(ctx, gen); rerr != nil && !errors.Is(rerr, errCredentialRefreshVersionUnknown) { return 0, nil, fmt.Errorf("重新获取下载凭证失败: %w (原错误: %v)", rerr, &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}) } + // errCredentialRefreshVersionUnknown 在探测阶段无需处理(尚无已完成分片), + // 凭证已更新,循环继续用新凭证重试探测。 default: body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap)) resp.Body.Close() return 0, nil, &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)} } } - return 0, nil, fmt.Errorf("下载凭证刷新后仍鉴权失败") } // parseContentRangeTotal 从 "bytes 0-0/12345" 解析总长;"*" 视为未知。 @@ -455,18 +487,25 @@ type driveDownloadCheckpoint struct { Completed []bool `json:"completed"` } -// driveDownloadFingerprint 基于节点 ID + 版本号 + 文件总长 + 首次资源 URL path 计算指纹。 -// resourceURL 的 path 包含文件存储版本信息,覆盖上传后会分配新存储位置导致 path 变化, -// 防止最新版下载(version=0)在文件被同大小内容覆盖后错误复用旧 checkpoint。 +// driveDownloadFingerprint 基于节点 ID + 版本号 + 文件总长 + 资源 URL 计算指纹。 +// version>0 时只取 URL path(重签名不影响 checkpoint 复用);version==0(最新版)时 +// 取完整 path+query——中心协议相同 path 可能对应不同实际版本,query 中的签名/token +// 标识了具体资源快照,防止错误复用旧 checkpoint。 // resourceURL 为空时不影响其他字段的指纹计算(安全降级)。 func driveDownloadFingerprint(nodeID string, version int, totalSize int64, resourceURL string) string { - urlPath := "" + urlComponent := "" if resourceURL != "" { if u, err := url.Parse(resourceURL); err == nil && u != nil { - urlPath = u.Path + if version == 0 { + // 最新版:含 query 以区分不同签名(不同实际版本) + urlComponent = u.RequestURI() + } else { + // 指定版本:只取 path,重签名不应废弃 checkpoint + urlComponent = u.Path + } } } - sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%d|%d|%s", nodeID, version, totalSize, urlPath))) + sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%d|%d|%s", nodeID, version, totalSize, urlComponent))) return hex.EncodeToString(sum[:]) } @@ -489,7 +528,7 @@ func loadDriveDownloadCheckpoint(metaPath, fingerprint string, totalSize, partSi // save 原子写入(临时文件 + rename),避免中断产生半截 checkpoint。 func (cp *driveDownloadCheckpoint) save(metaPath string) error { - data, err := json.Marshal(cp) + data, err := driveJsonMarshal(cp) if err != nil { return err } @@ -497,7 +536,7 @@ func (cp *driveDownloadCheckpoint) save(metaPath string) error { if err := os.WriteFile(tmp, data, 0o644); err != nil { return err } - return os.Rename(tmp, metaPath) + return driveOsRename(tmp, metaPath) } // ────────────────────────────────────────────────────────── @@ -542,7 +581,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP if err != nil { return fmt.Errorf("创建分片临时文件失败: %w", err) } - if err := f.Truncate(totalSize); err != nil { + if err := driveFileTruncate(f, totalSize); err != nil { f.Close() return fmt.Errorf("预分配分片临时文件失败: %w", err) } @@ -593,7 +632,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP if runCtx.Err() != nil { return } - if err := downloadOnePart(runCtx, creds, f, part); err != nil { + if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil { fail(fmt.Errorf("分片 %d/%d 下载失败: %w", part.index+1, len(parts), err)) return } @@ -628,13 +667,19 @@ dispatch: if firstErr != nil { f.Close() - return firstErr // 保留 .dwspart 与 checkpoint,重跑同一命令自动续传 + if errors.Is(firstErr, errCredentialRefreshVersionUnknown) { + // 激进策略:版本不可验证,清空 checkpoint 和分片文件防止错误续传; + // 用户重跑时将自然从头下载。 + _ = os.Remove(metaPath) + _ = os.Remove(partPath) + } + return firstErr // 其他错误保留 .dwspart 与 checkpoint,重跑同一命令自动续传 } - if err := f.Sync(); err != nil { + if err := driveFileSync(f); err != nil { f.Close() return err } - fi, statErr := f.Stat() + fi, statErr := driveFileStat(f) f.Close() if statErr != nil { return statErr @@ -642,7 +687,7 @@ dispatch: if fi.Size() != totalSize { return fmt.Errorf("下载完成但文件长度不符: got %d, want %d", fi.Size(), totalSize) } - if err := os.Rename(partPath, destPath); err != nil { + if err := driveOsRename(partPath, destPath); err != nil { return fmt.Errorf("重命名下载文件失败: %w", err) } _ = os.Remove(metaPath) @@ -652,13 +697,13 @@ dispatch: // downloadOnePart 下载单个分片:常规失败指数退避重试 driveDownloadPartRetries 次; // 401/403 触发凭证 single-flight 刷新(不计入常规重试,上限 driveDownloadPartAuthRetries), // 刷新后用新凭证续传,不重下其他已完成分片。 -func downloadOnePart(ctx context.Context, creds *driveCredentialState, f *os.File, part driveDownloadPart) error { +func downloadOnePart(ctx context.Context, creds *driveCredentialState, f *os.File, part driveDownloadPart, totalSize int64) error { attempt := 0 authRetries := 0 backoff := 500 * time.Millisecond for { urlStr, headers, gen := creds.current() - err := fetchRangeInto(ctx, urlStr, headers, f, part) + err := fetchRangeInto(ctx, urlStr, headers, f, part, totalSize) if err == nil { return nil } @@ -686,7 +731,7 @@ func downloadOnePart(ctx context.Context, creds *driveCredentialState, f *os.Fil } // fetchRangeInto 拉取 [offset, offset+length) 区间并写入文件对应偏移。 -func fetchRangeInto(ctx context.Context, urlStr string, headers map[string]string, f *os.File, part driveDownloadPart) error { +func fetchRangeInto(ctx context.Context, urlStr string, headers map[string]string, f *os.File, part driveDownloadPart, expectedTotal int64) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) if err != nil { return err @@ -705,8 +750,12 @@ func fetchRangeInto(ctx context.Context, urlStr string, headers map[string]strin return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)} } // 校验 Content-Range 响应区间与请求分片一致,防止代理/服务端返回错位数据 - if cr := resp.Header.Get("Content-Range"); cr != "" { - crStart, crEnd, _, crErr := parseContentRange(cr) + cr := resp.Header.Get("Content-Range") + if cr == "" { + return fmt.Errorf("分片响应缺少 Content-Range 头,无法验证数据偏移一致性") + } + { + crStart, crEnd, crTotal, crErr := parseContentRange(cr) if crErr != nil { return fmt.Errorf("Content-Range 解析失败: %w", crErr) } @@ -716,8 +765,10 @@ func fetchRangeInto(ctx context.Context, urlStr string, headers map[string]strin return fmt.Errorf("Content-Range 区间不匹配: 响应 %d-%d, 期望 %d-%d", crStart, crEnd, wantStart, wantEnd) } + if crTotal > 0 && expectedTotal > 0 && crTotal != expectedTotal { + return fmt.Errorf("Content-Range 总长不匹配: 响应 %d, 期望 %d", crTotal, expectedTotal) + } } - // Content-Range 缺失时不阻断(兼容不规范服务端),仅依赖读取长度校验 n, err := io.Copy(io.NewOffsetWriter(f, part.offset), io.LimitReader(resp.Body, part.length)) if err != nil { return err diff --git a/internal/helpers/drive_transfer_test.go b/internal/helpers/drive_transfer_test.go index fa913e4d..217497e0 100644 --- a/internal/helpers/drive_transfer_test.go +++ b/internal/helpers/drive_transfer_test.go @@ -2,7 +2,9 @@ package helpers import ( "context" + "errors" "fmt" + "io" "net/http" "net/http/httptest" "os" @@ -10,13 +12,14 @@ import ( "strings" "sync/atomic" "testing" + "time" ) // ────────────────────────────────────────────────────────── // parsePartSize / formatByteSize // ────────────────────────────────────────────────────────── -func TestParsePartSize(t *testing.T) { +func TestCrossPlatformCoverageParsePartSize(t *testing.T) { cases := []struct { in string want int64 @@ -55,7 +58,7 @@ func TestParsePartSize(t *testing.T) { } } -func TestFormatByteSize(t *testing.T) { +func TestCrossPlatformCoverageFormatByteSize(t *testing.T) { cases := map[int64]string{ 16 << 20: "16MB", 1 << 30: "1GB", @@ -73,7 +76,7 @@ func TestFormatByteSize(t *testing.T) { // splitDownloadParts // ────────────────────────────────────────────────────────── -func TestSplitDownloadParts(t *testing.T) { +func TestCrossPlatformCoverageSplitDownloadParts(t *testing.T) { // 整除 parts := splitDownloadParts(32, 16) if len(parts) != 2 || parts[0].offset != 0 || parts[0].length != 16 || parts[1].offset != 16 || parts[1].length != 16 { @@ -111,7 +114,7 @@ func TestSplitDownloadParts(t *testing.T) { // checkpoint 读写与恢复 // ────────────────────────────────────────────────────────── -func TestCheckpointSaveLoadRoundtrip(t *testing.T) { +func TestCrossPlatformCoverageCheckpointSaveLoadRoundtrip(t *testing.T) { dir := t.TempDir() metaPath := filepath.Join(dir, "f.bin.dwspart.meta") fp := driveDownloadFingerprint("test-node-1", 0, 100, "") @@ -134,7 +137,7 @@ func TestCheckpointSaveLoadRoundtrip(t *testing.T) { } } -func TestCheckpointLoadRejectsMismatch(t *testing.T) { +func TestCrossPlatformCoverageCheckpointLoadRejectsMismatch(t *testing.T) { dir := t.TempDir() metaPath := filepath.Join(dir, "f.meta") fp := driveDownloadFingerprint("test-node-2", 0, 100, "") @@ -167,7 +170,7 @@ func TestCheckpointLoadRejectsMismatch(t *testing.T) { } // 指纹基于 nodeID + version + totalSize,不同 nodeID / version / size 产生不同指纹。 -func TestFingerprintNodeIDBased(t *testing.T) { +func TestCrossPlatformCoverageFingerprintNodeIDBased(t *testing.T) { a := driveDownloadFingerprint("node-aaa", 0, 500, "") b := driveDownloadFingerprint("node-aaa", 0, 500, "") if a != b { @@ -188,7 +191,7 @@ func TestFingerprintNodeIDBased(t *testing.T) { } // checkpoint 指纹碰撞防护:相同输出路径、相同大小、不同 nodeID 不复用 checkpoint。 -func TestCheckpointNotReusedDifferentNodeID(t *testing.T) { +func TestCrossPlatformCoverageCheckpointNotReusedDifferentNodeID(t *testing.T) { dir := t.TempDir() metaPath := filepath.Join(dir, "f.meta") const totalSize int64 = 1000 @@ -217,7 +220,7 @@ func TestCheckpointNotReusedDifferentNodeID(t *testing.T) { } // checkpoint 指纹碰撞防护:相同 nodeID、不同大小不复用 checkpoint。 -func TestCheckpointNotReusedDifferentSize(t *testing.T) { +func TestCrossPlatformCoverageCheckpointNotReusedDifferentSize(t *testing.T) { dir := t.TempDir() metaPath := filepath.Join(dir, "f.meta") const partSize int64 = 300 @@ -245,7 +248,7 @@ func TestCheckpointNotReusedDifferentSize(t *testing.T) { } // checkpoint 正常续传:相同 nodeID + 相同大小复用 checkpoint。 -func TestCheckpointReusedSameNodeIDAndSize(t *testing.T) { +func TestCrossPlatformCoverageCheckpointReusedSameNodeIDAndSize(t *testing.T) { dir := t.TempDir() metaPath := filepath.Join(dir, "f.meta") const totalSize int64 = 1000 @@ -278,7 +281,7 @@ func TestCheckpointReusedSameNodeIDAndSize(t *testing.T) { // checkpoint 指纹碰撞防护:同 nodeID、同大小、version=0 但不同 resourceURL 不复用 checkpoint。 // 模拟最新版下载场景:文件被相同大小内容覆盖后 URL path 变化,旧 checkpoint 应作废。 -func TestCheckpointNotReusedDifferentResourceURL(t *testing.T) { +func TestCrossPlatformCoverageCheckpointNotReusedDifferentResourceURL(t *testing.T) { dir := t.TempDir() metaPath := filepath.Join(dir, "f.meta") const totalSize int64 = 1000 @@ -316,7 +319,7 @@ func TestCheckpointNotReusedDifferentResourceURL(t *testing.T) { } // 验证 resourceURL 为空时的安全降级:不影响其他字段的指纹计算。 -func TestFingerprintEmptyResourceURLFallback(t *testing.T) { +func TestCrossPlatformCoverageFingerprintEmptyResourceURLFallback(t *testing.T) { // 空 URL 应产生确定性指纹 a := driveDownloadFingerprint("node-x", 0, 500, "") b := driveDownloadFingerprint("node-x", 0, 500, "") @@ -330,11 +333,18 @@ func TestFingerprintEmptyResourceURLFallback(t *testing.T) { t.Error("空 URL 与有 URL 应产生不同指纹") } - // 仅 query 不同、path 相同的 URL 应产生相同指纹(只取 path) + // version=0 时,仅 query 不同的 URL 应产生不同指纹(中心协议区分实际版本) d := driveDownloadFingerprint("node-x", 0, 500, "https://example.com/path/to/file?token=aaa") e := driveDownloadFingerprint("node-x", 0, 500, "https://example.com/path/to/file?token=bbb") - if d != e { - t.Error("仅 query 不同时应产生相同指纹(只取 path)") + if d == e { + t.Error("version=0 时仅 query 不同应产生不同指纹") + } + + // version>0 时,仅 query 不同的 URL 应产生相同指纹(只取 path) + f := driveDownloadFingerprint("node-x", 3, 500, "https://example.com/path/to/file?token=aaa") + g := driveDownloadFingerprint("node-x", 3, 500, "https://example.com/path/to/file?token=bbb") + if f != g { + t.Error("version>0 时仅 query 不同应产生相同指纹(只取 path)") } } @@ -342,7 +352,7 @@ func TestFingerprintEmptyResourceURLFallback(t *testing.T) { // parseContentRangeTotal / parseDownloadFileSize / parseDriveUploadType // ────────────────────────────────────────────────────────── -func TestParseContentRangeTotal(t *testing.T) { +func TestCrossPlatformCoverageParseContentRangeTotal(t *testing.T) { if n, err := parseContentRangeTotal("bytes 0-0/12345"); err != nil || n != 12345 { t.Errorf("got %d, %v", n, err) } @@ -353,7 +363,7 @@ func TestParseContentRangeTotal(t *testing.T) { } } -func TestParseContentRange(t *testing.T) { +func TestCrossPlatformCoverageParseContentRange(t *testing.T) { cases := []struct { in string wantStart, wantEnd, wantTotal int64 @@ -390,7 +400,7 @@ func TestParseContentRange(t *testing.T) { } } -func TestParseDownloadFileSize(t *testing.T) { +func TestCrossPlatformCoverageParseDownloadFileSize(t *testing.T) { if n := parseDownloadFileSize(`{"result":{"fileSize":1048576,"downloadUrl":"u"}}`); n != 1048576 { t.Errorf("number: got %d", n) } @@ -405,7 +415,7 @@ func TestParseDownloadFileSize(t *testing.T) { } } -func TestParseDownloadFileVersion(t *testing.T) { +func TestCrossPlatformCoverageParseDownloadFileVersion(t *testing.T) { // 数值类型 if n := parseDownloadFileVersion(`{"result":{"version":3,"downloadUrl":"u"}}`); n != 3 { t.Errorf("number: got %d, want 3", n) @@ -428,7 +438,7 @@ func TestParseDownloadFileVersion(t *testing.T) { } } -func TestParseDriveUploadType(t *testing.T) { +func TestCrossPlatformCoverageParseDriveUploadType(t *testing.T) { if got := parseDriveUploadType(`{"result":{"uploadType":"httpToCenterWithToken","resourceUrl":"u"}}`); got != uploadTypeCenterToken { t.Errorf("got %q", got) } @@ -444,7 +454,7 @@ func TestParseDriveUploadType(t *testing.T) { // isAuthStatusError / decorateUploadSizeError // ────────────────────────────────────────────────────────── -func TestIsAuthStatusError(t *testing.T) { +func TestCrossPlatformCoverageIsAuthStatusError(t *testing.T) { if !isAuthStatusError(&httpStatusError{StatusCode: 401}) || !isAuthStatusError(&httpStatusError{StatusCode: 403}) { t.Error("typed 401/403 应命中") } @@ -462,7 +472,7 @@ func TestIsAuthStatusError(t *testing.T) { } } -func TestDecorateUploadSizeError(t *testing.T) { +func TestCrossPlatformCoverageDecorateUploadSizeError(t *testing.T) { // 413 → 补充可读提示 err := decorateUploadSizeError(&httpStatusError{StatusCode: 413, Body: "too large"}, "") if !strings.Contains(err.Error(), "提示") { @@ -552,7 +562,7 @@ func smallPartOpts(partSize int64, parallel int) driveDownloadOptions { return driveDownloadOptions{partSize: partSize, parallel: parallel, resume: true} } -func TestDriveTransferDownload_RangedParts(t *testing.T) { +func TestCrossPlatformCoverageDriveTransferDownload_RangedParts(t *testing.T) { content := makeTestContent(1000) srv := rangeTestServer(t, content, "", nil) defer srv.Close() @@ -574,7 +584,7 @@ func TestDriveTransferDownload_RangedParts(t *testing.T) { } // knownSize 小于阈值 → 直接整流(走可注入 httpGetFile)。 -func TestDriveTransferDownload_SmallFileSingleStream(t *testing.T) { +func TestCrossPlatformCoverageDriveTransferDownload_SmallFileSingleStream(t *testing.T) { var calls atomic.Int32 SetHTTPGetFile(func(ctx context.Context, url string, headers map[string]string, destPath string) error { calls.Add(1) @@ -595,7 +605,7 @@ func TestDriveTransferDownload_SmallFileSingleStream(t *testing.T) { } // 整流 401 → 重取凭证重试一次。 -func TestDownloadSingleWithAuthRetry(t *testing.T) { +func TestCrossPlatformCoverageDownloadSingleWithAuthRetry(t *testing.T) { var calls atomic.Int32 SetHTTPGetFile(func(ctx context.Context, url string, headers map[string]string, destPath string) error { if calls.Add(1) == 1 { @@ -609,9 +619,9 @@ func TestDownloadSingleWithAuthRetry(t *testing.T) { defer SetHTTPGetFile(nil) fetched := false - fetch := func(ctx context.Context) (string, map[string]string, error) { + fetch := func(ctx context.Context) (string, map[string]string, int, error) { fetched = true - return "https://new.example.com/f", map[string]string{"dentry-token": "new-token"}, nil + return "https://new.example.com/f", map[string]string{"dentry-token": "new-token"}, 0, nil } dest := filepath.Join(t.TempDir(), "auth.bin") if err := downloadSingleWithAuthRetry(context.Background(), fetch, "https://old.example.com/f", nil, dest); err != nil { @@ -624,13 +634,13 @@ func TestDownloadSingleWithAuthRetry(t *testing.T) { } // 整流重取凭证后仍失败 → 走既有错误路径(返回错误)。 -func TestDownloadSingleWithAuthRetry_StillFails(t *testing.T) { +func TestCrossPlatformCoverageDownloadSingleWithAuthRetry_StillFails(t *testing.T) { SetHTTPGetFile(func(ctx context.Context, url string, headers map[string]string, destPath string) error { return &httpStatusError{StatusCode: 403, Body: "denied"} }) defer SetHTTPGetFile(nil) - fetch := func(ctx context.Context) (string, map[string]string, error) { - return "https://new.example.com/f", nil, nil + fetch := func(ctx context.Context) (string, map[string]string, int, error) { + return "https://new.example.com/f", nil, 0, nil } err := downloadSingleWithAuthRetry(context.Background(), fetch, "https://old.example.com/f", nil, filepath.Join(t.TempDir(), "x")) if err == nil || !isAuthStatusError(err) { @@ -639,7 +649,7 @@ func TestDownloadSingleWithAuthRetry_StillFails(t *testing.T) { } // 服务端不支持 Range(探测返回 200)→ 自动回退整流。 -func TestDriveTransferDownload_FallbackWhenNoRangeSupport(t *testing.T) { +func TestCrossPlatformCoverageDriveTransferDownload_FallbackWhenNoRangeSupport(t *testing.T) { content := makeTestContent(900) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 忽略 Range 头,始终 200 全量 @@ -658,7 +668,7 @@ func TestDriveTransferDownload_FallbackWhenNoRangeSupport(t *testing.T) { } // Content-Range 校验:正常匹配、区间错位、header 缺失 -func TestFetchRangeInto_ContentRangeValidation(t *testing.T) { +func TestCrossPlatformCoverageFetchRangeInto_ContentRangeValidation(t *testing.T) { content := makeTestContent(1000) // 正常情况:Content-Range 区间匹配 @@ -681,7 +691,7 @@ func TestFetchRangeInto_ContentRangeValidation(t *testing.T) { t.Fatal(err) } part := driveDownloadPart{index: 1, offset: 300, length: 300} - if err := fetchRangeInto(context.Background(), srv.URL, nil, f, part); err != nil { + if err := fetchRangeInto(context.Background(), srv.URL, nil, f, part, 0); err != nil { t.Fatalf("正常匹配不应报错: %v", err) } }) @@ -705,7 +715,7 @@ func TestFetchRangeInto_ContentRangeValidation(t *testing.T) { t.Fatal(err) } part := driveDownloadPart{index: 1, offset: 300, length: 300} - err = fetchRangeInto(context.Background(), srv.URL, nil, f, part) + err = fetchRangeInto(context.Background(), srv.URL, nil, f, part, 0) if err == nil { t.Fatal("Content-Range 错位应返回错误") } @@ -714,7 +724,7 @@ func TestFetchRangeInto_ContentRangeValidation(t *testing.T) { } }) - // Content-Range header 缺失 → 不阻断,正常下载 + // Content-Range header 缺失 → 强制报错,拒绝无法验证偏移一致性的分片 t.Run("missing_header", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var start, end int64 @@ -734,8 +744,12 @@ func TestFetchRangeInto_ContentRangeValidation(t *testing.T) { t.Fatal(err) } part := driveDownloadPart{index: 0, offset: 0, length: 300} - if err := fetchRangeInto(context.Background(), srv.URL, nil, f, part); err != nil { - t.Fatalf("Content-Range 缺失不应阻断下载: %v", err) + err = fetchRangeInto(context.Background(), srv.URL, nil, f, part, 0) + if err == nil { + t.Fatal("Content-Range 缺失应返回错误") + } + if !strings.Contains(err.Error(), "缺少 Content-Range") { + t.Errorf("错误信息应包含'缺少 Content-Range': %v", err) } }) @@ -757,7 +771,7 @@ func TestFetchRangeInto_ContentRangeValidation(t *testing.T) { t.Fatal(err) } part := driveDownloadPart{index: 0, offset: 0, length: 300} - err = fetchRangeInto(context.Background(), srv.URL, nil, f, part) + err = fetchRangeInto(context.Background(), srv.URL, nil, f, part, 0) if err == nil { t.Fatal("Content-Range 格式异常应返回错误") } @@ -768,20 +782,21 @@ func TestFetchRangeInto_ContentRangeValidation(t *testing.T) { } // 分片过程 401 → single-flight 刷新凭证后续传(不重下已完成分片)。 -func TestDriveTransferDownload_AuthRefreshDuringParts(t *testing.T) { +func TestCrossPlatformCoverageDriveTransferDownload_AuthRefreshDuringParts(t *testing.T) { content := makeTestContent(1200) var tokenGen atomic.Int32 // 服务端当前有效 token 代数 srv := rangeTestServer(t, content, "tok", &tokenGen) defer srv.Close() var fetchCalls atomic.Int32 - fetch := func(ctx context.Context) (string, map[string]string, error) { + fetch := func(ctx context.Context) (string, map[string]string, int, error) { fetchCalls.Add(1) - return srv.URL, map[string]string{"dentry-token": fmt.Sprintf("tok-%d", tokenGen.Load())}, nil + return srv.URL, map[string]string{"dentry-token": fmt.Sprintf("tok-%d", tokenGen.Load())}, 5, nil } dest := filepath.Join(t.TempDir(), "auth-parts.bin") opts := smallPartOpts(300, 2) opts.knownSize = 1200 + opts.version = 5 // 初始凭证是第 0 代;探测后服务端轮换到第 1 代,分片请求将收到 401 initial := map[string]string{"dentry-token": "tok-0"} @@ -796,7 +811,7 @@ func TestDriveTransferDownload_AuthRefreshDuringParts(t *testing.T) { } // 断点续传:模拟部分分片完成后中断,重跑跳过已完成分片。 -func TestDriveTransferDownload_ResumeSkipsCompletedParts(t *testing.T) { +func TestCrossPlatformCoverageDriveTransferDownload_ResumeSkipsCompletedParts(t *testing.T) { content := makeTestContent(1000) partSize := int64(300) dest := filepath.Join(t.TempDir(), "resume.bin") @@ -858,7 +873,7 @@ func TestDriveTransferDownload_ResumeSkipsCompletedParts(t *testing.T) { } // --no-resume:清理历史断点从头下载,且过程中不写 checkpoint。 -func TestDriveTransferDownload_NoResume(t *testing.T) { +func TestCrossPlatformCoverageDriveTransferDownload_NoResume(t *testing.T) { content := makeTestContent(1000) srv := rangeTestServer(t, content, "", nil) defer srv.Close() @@ -890,7 +905,7 @@ func TestDriveTransferDownload_NoResume(t *testing.T) { } // 分片失败重试后成功(指数退避路径)。 -func TestDownloadOnePart_RetryOnTransientError(t *testing.T) { +func TestCrossPlatformCoverageDownloadOnePart_RetryOnTransientError(t *testing.T) { content := makeTestContent(600) var hits atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -917,7 +932,7 @@ func TestDownloadOnePart_RetryOnTransientError(t *testing.T) { t.Fatal(err) } creds := &driveCredentialState{url: srv.URL} - if err := downloadOnePart(context.Background(), creds, f, driveDownloadPart{index: 0, offset: 0, length: 600}); err != nil { + if err := downloadOnePart(context.Background(), creds, f, driveDownloadPart{index: 0, offset: 0, length: 600}, 0); err != nil { t.Fatalf("瞬时错误重试后应成功: %v", err) } if hits.Load() != 3 { @@ -926,7 +941,7 @@ func TestDownloadOnePart_RetryOnTransientError(t *testing.T) { } // 分片持续失败超过重试上限 → 返回错误。 -func TestDownloadOnePart_ExhaustsRetries(t *testing.T) { +func TestCrossPlatformCoverageDownloadOnePart_ExhaustsRetries(t *testing.T) { var hits atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1) @@ -940,7 +955,7 @@ func TestDownloadOnePart_ExhaustsRetries(t *testing.T) { } defer f.Close() creds := &driveCredentialState{url: srv.URL} - err = downloadOnePart(context.Background(), creds, f, driveDownloadPart{index: 0, offset: 0, length: 10}) + err = downloadOnePart(context.Background(), creds, f, driveDownloadPart{index: 0, offset: 0, length: 10}, 0) if err == nil { t.Fatal("超过重试上限应失败") } @@ -953,7 +968,7 @@ func TestDownloadOnePart_ExhaustsRetries(t *testing.T) { // driveUploadPut:中心协议 PUT + 401 重取凭证重试 // ────────────────────────────────────────────────────────── -func TestDriveUploadPut_Success(t *testing.T) { +func TestCrossPlatformCoverageDriveUploadPut_Success(t *testing.T) { var gotURL string var gotHeaders map[string]string SetHTTPPutFile(func(ctx context.Context, url string, headers map[string]string, filePath string, fileSize int64) error { @@ -978,7 +993,7 @@ func TestDriveUploadPut_Success(t *testing.T) { } } -func TestDriveUploadPut_AuthRetryWithNewCredential(t *testing.T) { +func TestCrossPlatformCoverageDriveUploadPut_AuthRetryWithNewCredential(t *testing.T) { var calls atomic.Int32 SetHTTPPutFile(func(ctx context.Context, url string, headers map[string]string, filePath string, fileSize int64) error { if calls.Add(1) == 1 { @@ -1007,7 +1022,7 @@ func TestDriveUploadPut_AuthRetryWithNewCredential(t *testing.T) { } } -func TestDriveUploadPut_AuthRetryStillFails(t *testing.T) { +func TestCrossPlatformCoverageDriveUploadPut_AuthRetryStillFails(t *testing.T) { SetHTTPPutFile(func(ctx context.Context, url string, headers map[string]string, filePath string, fileSize int64) error { return &httpStatusError{StatusCode: 403, Body: "denied"} }) @@ -1020,7 +1035,7 @@ func TestDriveUploadPut_AuthRetryStillFails(t *testing.T) { } } -func TestDriveUploadPut_SizeLimitHint(t *testing.T) { +func TestCrossPlatformCoverageDriveUploadPut_SizeLimitHint(t *testing.T) { SetHTTPPutFile(func(ctx context.Context, url string, headers map[string]string, filePath string, fileSize int64) error { return &httpStatusError{StatusCode: 413, Body: "request entity too large"} }) @@ -1037,7 +1052,7 @@ func TestDriveUploadPut_SizeLimitHint(t *testing.T) { // parseDriveDownloadInfo:中心协议 headers 透传(新增行为) // ────────────────────────────────────────────────────────── -func TestParseDriveDownloadInfo_CenterProtocolHeaders(t *testing.T) { +func TestCrossPlatformCoverageParseDriveDownloadInfo_CenterProtocolHeaders(t *testing.T) { text := `{"result":{"downloadType":"httpToCenterWithToken","downloadUrl":"https://c.example.com/attachment/token/mdown?k=v","headers":{"dentry-token":"tk"},"fileName":"a.bin"},"success":true}` url, headers, err := parseDriveDownloadInfo(text) if err != nil { @@ -1055,13 +1070,14 @@ func TestParseDriveDownloadInfo_CenterProtocolHeaders(t *testing.T) { // driveCredentialState:single-flight 刷新 // ────────────────────────────────────────────────────────── -func TestDriveCredentialStateSingleFlightRefresh(t *testing.T) { +func TestCrossPlatformCoverageDriveCredentialStateSingleFlightRefresh(t *testing.T) { var fetches atomic.Int32 cs := &driveCredentialState{ - url: "u0", - fetch: func(ctx context.Context) (string, map[string]string, error) { + url: "u0", + initialVersion: 5, + fetch: func(ctx context.Context) (string, map[string]string, int, error) { n := fetches.Add(1) - return fmt.Sprintf("u%d", n), nil, nil + return fmt.Sprintf("u%d", n), nil, 5, nil }, } _, _, gen0 := cs.current() @@ -1087,3 +1103,2273 @@ func TestDriveCredentialStateSingleFlightRefresh(t *testing.T) { t.Errorf("新代刷新应真正执行, fetch 次数 = %d", fetches.Load()) } } + +// refresh 无 fetcher → 返回错误 +func TestCrossPlatformCoverageDriveCredentialStateRefreshNilFetch(t *testing.T) { + cs := &driveCredentialState{url: "u0", fetch: nil} + _, _, gen := cs.current() + err := cs.refresh(context.Background(), gen) + if err == nil || !strings.Contains(err.Error(), "无法自动刷新") { + t.Errorf("fetch==nil 应报错, got %v", err) + } +} + +// refresh fetcher 返回 error → 传播给调用方 +func TestCrossPlatformCoverageDriveCredentialStateRefreshFetchError(t *testing.T) { + cs := &driveCredentialState{ + url: "u0", + fetch: func(ctx context.Context) (string, map[string]string, int, error) { + return "", nil, 0, fmt.Errorf("network") + }, + } + _, _, gen := cs.current() + err := cs.refresh(context.Background(), gen) + if err == nil || !strings.Contains(err.Error(), "network") { + t.Errorf("fetch 报错应传播, got %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// parsePartSize 补充分支 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageParsePartSize_ExtraSuffixes(t *testing.T) { + // "K" 后缀 + if got, err := parsePartSize("1024K"); err != nil || got != 1<<20 { + t.Errorf("1024K: got %d, err=%v", got, err) + } + // 裸 "B" 后缀(去 B 后当纯数字) + if got, err := parsePartSize("16777216B"); err != nil || got != 16<<20 { + t.Errorf("16777216B: got %d, err=%v", got, err) + } +} + +// ────────────────────────────────────────────────────────── +// parseContentRange 补充:负 start +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageParseContentRange_NegativeStart(t *testing.T) { + _, _, _, err := parseContentRange("bytes -1-100/200") + if err == nil { + t.Error("负 start 应报错") + } +} + +// ────────────────────────────────────────────────────────── +// likelySizeLimitBody 补充中文关键词和 "size"+"over"/"size"+"limit" +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageLikelySizeLimitBody(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"文件超限", true}, + {"超出配额", true}, + {"容量不足", true}, + {"file size over limit", true}, + {"Size Limit Exceeded", true}, + {"no error", false}, + {"", false}, + } + for _, c := range cases { + if got := likelySizeLimitBody(c.in); got != c.want { + t.Errorf("likelySizeLimitBody(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +// ────────────────────────────────────────────────────────── +// driveTransferDownload 默认 opts 和 probe 错误路径 +// ────────────────────────────────────────────────────────── + +// opts.partSize/parallel 为 0 时使用默认值 +func TestCrossPlatformCoverageDriveTransferDownload_DefaultOpts(t *testing.T) { + SetHTTPGetFile(func(ctx context.Context, url string, headers map[string]string, destPath string) error { + return os.WriteFile(destPath, []byte("ok"), 0o644) + }) + defer SetHTTPGetFile(nil) + + dest := filepath.Join(t.TempDir(), "default.bin") + // knownSize < 2*defaultPartSize → 整流; partSize=0, parallel=0 用默认值 + opts := driveDownloadOptions{knownSize: 100} + if err := driveTransferDownload(context.Background(), nil, "https://x.example.com/f", nil, dest, opts); err != nil { + t.Fatalf("默认 opts 应正常: %v", err) + } +} + +// probeRangeSupport 返回 error → driveTransferDownload 传播 +func TestCrossPlatformCoverageDriveTransferDownload_ProbeError(t *testing.T) { + // 使用无效 URL 触发 probeRangeSupport 错误 + opts := driveDownloadOptions{partSize: 10, parallel: 2, knownSize: 100} + err := driveTransferDownload(context.Background(), nil, "://invalid-url", nil, filepath.Join(t.TempDir(), "x"), opts) + if err == nil { + t.Fatal("无效 URL 的 probe 应失败") + } +} + +// probeRangeSupport 返回 totalSize 小于阈值 → 回退整流 +func TestCrossPlatformCoverageDriveTransferDownload_ProbeTotalBelowThreshold(t *testing.T) { + // 服务端返回 206 但 Content-Range 中的 total 很小 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Range", "bytes 0-0/5") // total=5 < 2*partSize=20 + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write([]byte{0}) + })) + defer srv.Close() + + var called atomic.Int32 + SetHTTPGetFile(func(ctx context.Context, url string, headers map[string]string, destPath string) error { + called.Add(1) + return os.WriteFile(destPath, []byte("small"), 0o644) + }) + defer SetHTTPGetFile(nil) + + dest := filepath.Join(t.TempDir(), "small-probe.bin") + opts := driveDownloadOptions{partSize: 10, parallel: 2, knownSize: 100} + if err := driveTransferDownload(context.Background(), nil, srv.URL, nil, dest, opts); err != nil { + t.Fatalf("totalSize < threshold 应回退整流: %v", err) + } + if called.Load() == 0 { + t.Error("应走整流路径(httpGetFile)") + } +} + +// ────────────────────────────────────────────────────────── +// downloadSingleWithAuthRetry: fetch==nil、fetch 报错 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageDownloadSingleWithAuthRetry_NilFetch(t *testing.T) { + SetHTTPGetFile(func(ctx context.Context, url string, headers map[string]string, destPath string) error { + return &httpStatusError{StatusCode: 401, Body: "expired"} + }) + defer SetHTTPGetFile(nil) + // fetch==nil → 不重试,直接返回原始错误 + err := downloadSingleWithAuthRetry(context.Background(), nil, "https://old.example.com/f", nil, filepath.Join(t.TempDir(), "x")) + if err == nil || !isAuthStatusError(err) { + t.Fatalf("fetch==nil 应直接返回鉴权错误: %v", err) + } +} + +func TestCrossPlatformCoverageDownloadSingleWithAuthRetry_FetchError(t *testing.T) { + var calls atomic.Int32 + SetHTTPGetFile(func(ctx context.Context, url string, headers map[string]string, destPath string) error { + calls.Add(1) + return &httpStatusError{StatusCode: 401, Body: "expired"} + }) + defer SetHTTPGetFile(nil) + // fetch 返回 error → 返回原始 httpGetFile 错误(非 fetch 错误) + fetch := func(ctx context.Context) (string, map[string]string, int, error) { + return "", nil, 0, fmt.Errorf("fetch failed") + } + err := downloadSingleWithAuthRetry(context.Background(), fetch, "https://old.example.com/f", nil, filepath.Join(t.TempDir(), "x")) + if err == nil || !isAuthStatusError(err) { + t.Fatalf("fetch 报错应返回原始鉴权错误: %v", err) + } + if calls.Load() != 1 { + t.Errorf("httpGetFile 应只调用一次, got %d", calls.Load()) + } +} + +// ────────────────────────────────────────────────────────── +// probeRangeSupport: 401 刷新失败、default 非 2xx、循环耗尽 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageProbeRangeSupport_AuthRefreshFail(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "expired") + })) + defer srv.Close() + + creds := &driveCredentialState{ + url: srv.URL, + fetch: func(ctx context.Context) (string, map[string]string, int, error) { + return "", nil, 0, fmt.Errorf("refresh-fail") + }, + } + _, _, err := probeRangeSupport(context.Background(), creds) + if err == nil || !strings.Contains(err.Error(), "重新获取下载凭证失败") { + t.Fatalf("401+refresh 失败应报凭证错误: %v", err) + } +} + +func TestCrossPlatformCoverageProbeRangeSupport_NonRetryableError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, "not found") + })) + defer srv.Close() + + creds := &driveCredentialState{url: srv.URL} + _, _, err := probeRangeSupport(context.Background(), creds) + if err == nil { + t.Fatal("404 应返回错误") + } + var se *httpStatusError + if !errors.As(err, &se) || se.StatusCode != 404 { + t.Errorf("应返回 httpStatusError(404): %v", err) + } +} + +func TestCrossPlatformCoverageProbeRangeSupport_AuthExhausted(t *testing.T) { + // 第一次 403 → refresh 成功 → 第二次仍 403 → attempt>0 返回 "下载凭证刷新后仍鉴权失败" + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, "denied") + })) + defer srv.Close() + + creds := &driveCredentialState{ + url: srv.URL, + fetch: func(ctx context.Context) (string, map[string]string, int, error) { return srv.URL, nil, 0, nil }, + } + _, _, err := probeRangeSupport(context.Background(), creds) + if err == nil { + t.Fatal("持续 403 应报错") + } + if !strings.Contains(err.Error(), "下载凭证刷新后仍鉴权失败") { + t.Fatalf("应返回凭证刷新失败错误: %v", err) + } + if hits.Load() != 2 { + t.Errorf("应请求 2 次, got %d", hits.Load()) + } +} + +func TestCrossPlatformCoverageProbeRangeSupport_InvalidURL(t *testing.T) { + creds := &driveCredentialState{url: "://bad"} + _, _, err := probeRangeSupport(context.Background(), creds) + if err == nil { + t.Fatal("无效 URL 应报错") + } +} + +func TestCrossPlatformCoverageProbeRangeSupport_NetworkError(t *testing.T) { + // 使用已关闭的服务端模拟网络错误 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() + + creds := &driveCredentialState{url: srv.URL} + _, _, err := probeRangeSupport(context.Background(), creds) + if err == nil { + t.Fatal("网络错误应传播") + } +} + +func TestCrossPlatformCoverageProbeRangeSupport_ContentRangeParseError(t *testing.T) { + // 返回 206 但 Content-Range 无法解析 total → 返回 0, nil, nil + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Range", "bytes 0-0/*") // total 未知 + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write([]byte{0}) + })) + defer srv.Close() + + creds := &driveCredentialState{url: srv.URL} + total, resp, err := probeRangeSupport(context.Background(), creds) + if err != nil { + t.Fatalf("Content-Range 异常不应报 error: %v", err) + } + if resp != nil { + t.Error("不应返回 resp") + } + if total != 0 { + t.Errorf("total 应为 0, got %d", total) + } +} + +// ────────────────────────────────────────────────────────── +// writeStreamToFile 错误路径 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageWriteStreamToFile_CreateError(t *testing.T) { + // 写入不存在目录下的文件 → os.Create 失败 + err := writeStreamToFile(strings.NewReader("data"), "/no-such-dir/sub/file.bin") + if err == nil { + t.Fatal("不存在路径应失败") + } +} + +func TestCrossPlatformCoverageWriteStreamToFile_CopyError(t *testing.T) { + dest := filepath.Join(t.TempDir(), "copy-err.bin") + errReader := &errReaderHelper{err: fmt.Errorf("read broken")} + err := writeStreamToFile(errReader, dest) + if err == nil || !strings.Contains(err.Error(), "read broken") { + t.Fatalf("io.Copy 错误应传播: %v", err) + } +} + +type errReaderHelper struct{ err error } + +func (r *errReaderHelper) Read(p []byte) (int, error) { return 0, r.err } + +// ────────────────────────────────────────────────────────── +// checkpoint save 错误路径 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageCheckpointSave_WriteError(t *testing.T) { + cp := &driveDownloadCheckpoint{ + Version: driveCheckpointVersion, Fingerprint: "fp", TotalSize: 100, PartSize: 30, Completed: []bool{true}, + } + // 写入不存在的目录 → 失败 + err := cp.save("/no-such-dir/sub/meta.json") + if err == nil { + t.Fatal("不存在路径应失败") + } +} + +// ────────────────────────────────────────────────────────── +// downloadRangedParts: 文件操作失败、上下文取消、logf 分支 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageDownloadRangedParts_OpenFileError(t *testing.T) { + creds := &driveCredentialState{url: "http://x.example.com"} + // destPath 指向不存在的目录 → OpenFile 失败 + dest := "/no-such-dir/sub/out.bin" + opts := driveDownloadOptions{partSize: 10, parallel: 1, resume: false} + err := downloadRangedParts(context.Background(), creds, dest, 100, opts) + if err == nil || !strings.Contains(err.Error(), "分片临时文件失败") { + t.Fatalf("OpenFile 失败应报错: %v", err) + } +} + +func TestCrossPlatformCoverageDownloadRangedParts_ContextCancelDuringDispatch(t *testing.T) { + started := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case started <- struct{}{}: + default: + } + // 阻塞直到请求上下文被取消 + <-r.Context().Done() + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + creds := &driveCredentialState{url: srv.URL} + dest := filepath.Join(t.TempDir(), "cancel-dispatch.bin") + // 多分片、parallel=1 → worker 卡在第一个请求,dispatch 第二个时 select 触发 ctx.Done + opts := driveDownloadOptions{partSize: 10, parallel: 1, resume: false, knownSize: 100} + + go func() { + <-started // 确保第一个 HTTP 请求已发出 + cancel() + }() + + err := downloadRangedParts(ctx, creds, dest, 100, opts) + if err == nil { + t.Fatal("context cancel 应返回错误") + } +} + +func TestCrossPlatformCoverageDownloadRangedParts_LogfBranches(t *testing.T) { + content := makeTestContent(100) + srv := rangeTestServer(t, content, "", nil) + defer srv.Close() + + var logs []string + dest := filepath.Join(t.TempDir(), "logf.bin") + creds := &driveCredentialState{url: srv.URL} + opts := driveDownloadOptions{ + partSize: 30, parallel: 2, resume: true, knownSize: 100, + logf: func(format string, args ...any) { + logs = append(logs, fmt.Sprintf(format, args...)) + }, + } + if err := downloadRangedParts(context.Background(), creds, dest, 100, opts); err != nil { + t.Fatalf("logf 测试下载失败: %v", err) + } + if len(logs) == 0 { + t.Error("应有日志输出") + } + // 应包含"分片下载"字样(全新下载) + found := false + for _, l := range logs { + if strings.Contains(l, "分片下载") { + found = true + } + } + if !found { + t.Errorf("全新下载应有'分片下载'日志, got %v", logs) + } +} + +func TestCrossPlatformCoverageDownloadRangedParts_ResumeLogf(t *testing.T) { + // 模拟断点续传场景的 logf 输出 + content := makeTestContent(100) + srv := rangeTestServer(t, content, "", nil) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "resume-logf.bin") + partPath := dest + drivePartFileSuffix + metaPath := dest + drivePartMetaSuffix + + // 预置分片数据文件和 checkpoint(分片 0 已完成) + pre := make([]byte, 100) + copy(pre[0:30], content[0:30]) + if err := os.WriteFile(partPath, pre, 0o644); err != nil { + t.Fatal(err) + } + fp := driveDownloadFingerprint("", 0, 100, srv.URL) + cp := &driveDownloadCheckpoint{ + Version: driveCheckpointVersion, Fingerprint: fp, + TotalSize: 100, PartSize: 30, Completed: []bool{true, false, false, false}, + } + if err := cp.save(metaPath); err != nil { + t.Fatal(err) + } + + var logs []string + creds := &driveCredentialState{url: srv.URL} + opts := driveDownloadOptions{ + partSize: 30, parallel: 2, resume: true, knownSize: 100, + logf: func(format string, args ...any) { + logs = append(logs, fmt.Sprintf(format, args...)) + }, + } + if err := downloadRangedParts(context.Background(), creds, dest, 100, opts); err != nil { + t.Fatalf("续传 logf 失败: %v", err) + } + // 应包含"断点续传"字样 + found := false + for _, l := range logs { + if strings.Contains(l, "断点续传") { + found = true + } + } + if !found { + t.Errorf("续传应有'断点续传'日志, got %v", logs) + } +} + +// checkpoint 存在但分片文件缺失 → 重新从头下载 +func TestCrossPlatformCoverageDownloadRangedParts_CheckpointPartFileMissing(t *testing.T) { + content := makeTestContent(100) + srv := rangeTestServer(t, content, "", nil) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "cp-missing.bin") + metaPath := dest + drivePartMetaSuffix + + // 只有 checkpoint 没有 partPath → cp 作废 + fp := driveDownloadFingerprint("", 0, 100, srv.URL) + cp := &driveDownloadCheckpoint{ + Version: driveCheckpointVersion, Fingerprint: fp, + TotalSize: 100, PartSize: 30, Completed: []bool{true, true, true, true}, + } + if err := cp.save(metaPath); err != nil { + t.Fatal(err) + } + + creds := &driveCredentialState{url: srv.URL} + opts := driveDownloadOptions{partSize: 30, parallel: 2, resume: true, knownSize: 100} + if err := downloadRangedParts(context.Background(), creds, dest, 100, opts); err != nil { + t.Fatalf("checkpoint 无分片文件应从头下载: %v", err) + } + verifyFile(t, dest, content) +} + +// checkpoint 存在但分片文件大小不符 → 重新从头下载 +func TestCrossPlatformCoverageDownloadRangedParts_CheckpointPartFileSizeMismatch(t *testing.T) { + content := makeTestContent(100) + srv := rangeTestServer(t, content, "", nil) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "cp-size.bin") + partPath := dest + drivePartFileSuffix + metaPath := dest + drivePartMetaSuffix + + // partPath 大小错误(50 != 100) + if err := os.WriteFile(partPath, make([]byte, 50), 0o644); err != nil { + t.Fatal(err) + } + fp := driveDownloadFingerprint("", 0, 100, srv.URL) + cp := &driveDownloadCheckpoint{ + Version: driveCheckpointVersion, Fingerprint: fp, + TotalSize: 100, PartSize: 30, Completed: []bool{true, false, false, false}, + } + if err := cp.save(metaPath); err != nil { + t.Fatal(err) + } + + creds := &driveCredentialState{url: srv.URL} + opts := driveDownloadOptions{partSize: 30, parallel: 2, resume: true, knownSize: 100} + if err := downloadRangedParts(context.Background(), creds, dest, 100, opts); err != nil { + t.Fatalf("分片大小不符应从头下载: %v", err) + } + verifyFile(t, dest, content) +} + +// ────────────────────────────────────────────────────────── +// downloadOnePart: context 取消、auth retry 失败 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageDownloadOnePart_ContextCanceled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + f, _ := os.Create(filepath.Join(t.TempDir(), "ctx-cancel.tmp")) + defer f.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // 立即取消 + + creds := &driveCredentialState{url: srv.URL} + err := downloadOnePart(ctx, creds, f, driveDownloadPart{index: 0, offset: 0, length: 10}, 0) + if err == nil { + t.Fatal("context 已取消应返回错误") + } +} + +func TestCrossPlatformCoverageDownloadOnePart_AuthRefreshFail(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "expired") + })) + defer srv.Close() + + f, _ := os.Create(filepath.Join(t.TempDir(), "auth-fail.tmp")) + defer f.Close() + + creds := &driveCredentialState{ + url: srv.URL, + fetch: func(ctx context.Context) (string, map[string]string, int, error) { + return "", nil, 0, fmt.Errorf("refresh-fail") + }, + } + err := downloadOnePart(context.Background(), creds, f, driveDownloadPart{index: 0, offset: 0, length: 10}, 0) + if err == nil || !strings.Contains(err.Error(), "重新获取下载凭证失败") { + t.Fatalf("auth refresh 失败应报凭证错误: %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// fetchRangeInto: 网络错误、io.Copy 错误、短读 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageFetchRangeInto_InvalidURL(t *testing.T) { + f, _ := os.Create(filepath.Join(t.TempDir(), "inv.tmp")) + defer f.Close() + err := fetchRangeInto(context.Background(), "://bad", nil, f, driveDownloadPart{offset: 0, length: 10}, 0) + if err == nil { + t.Fatal("无效 URL 应报错") + } +} + +func TestCrossPlatformCoverageFetchRangeInto_NetworkError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() // 关闭模拟网络错误 + + f, _ := os.Create(filepath.Join(t.TempDir(), "net.tmp")) + defer f.Close() + err := fetchRangeInto(context.Background(), srv.URL, nil, f, driveDownloadPart{offset: 0, length: 10}, 0) + if err == nil { + t.Fatal("网络错误应传播") + } +} + +func TestCrossPlatformCoverageFetchRangeInto_ShortRead(t *testing.T) { + // 服务端只返回 5 字节但分片期望 10 字节 → 短读错误 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Range", "bytes 0-9/100") + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write([]byte("short")) // 只有 5 字节 + })) + defer srv.Close() + + f, _ := os.CreateTemp(t.TempDir(), "short-*") + defer f.Close() + if err := f.Truncate(100); err != nil { + t.Fatal(err) + } + err := fetchRangeInto(context.Background(), srv.URL, nil, f, driveDownloadPart{offset: 0, length: 10}, 0) + if err == nil || !strings.Contains(err.Error(), "分片长度不符") { + t.Fatalf("短读应报错: %v", err) + } +} + +func TestCrossPlatformCoverageFetchRangeInto_CopyError(t *testing.T) { + // 服务端在部分传输后关闭连接 → io.Copy 报错 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Range", "bytes 0-99/100") + w.Header().Set("Content-Length", "100") + w.WriteHeader(http.StatusPartialContent) + // 只写入一部分然后 panic 来中断连接 + _, _ = w.Write([]byte("partial")) + panic(http.ErrAbortHandler) + })) + defer srv.Close() + + f, _ := os.CreateTemp(t.TempDir(), "copy-err-*") + defer f.Close() + if err := f.Truncate(100); err != nil { + t.Fatal(err) + } + err := fetchRangeInto(context.Background(), srv.URL, nil, f, driveDownloadPart{offset: 0, length: 100}, 0) + // 应有 error(io.Copy 失败或短读) + if err == nil { + t.Fatal("连接中断应报错") + } +} + +// ────────────────────────────────────────────────────────── +// parseDriveDownloadInfo: 各种 fallback 路径 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageParseDriveDownloadInfo_InvalidJSON(t *testing.T) { + _, _, err := parseDriveDownloadInfo("not json") + if err == nil || !strings.Contains(err.Error(), "解析") { + t.Fatalf("非法 JSON 应报错: %v", err) + } +} + +func TestCrossPlatformCoverageParseDriveDownloadInfo_ResourceUrlFallback(t *testing.T) { + // downloadUrl 缺失,走 resourceUrl fallback + text := `{"result":{"resourceUrl":"https://oss.example.com/signed"}}` + url, headers, err := parseDriveDownloadInfo(text) + if err != nil { + t.Fatalf("resourceUrl fallback 应成功: %v", err) + } + if url != "https://oss.example.com/signed" { + t.Errorf("url = %q", url) + } + if len(headers) != 0 { + t.Errorf("无 headers 应为空 map: %v", headers) + } +} + +func TestCrossPlatformCoverageParseDriveDownloadInfo_ResourceUrlsFallback(t *testing.T) { + // downloadUrl 和 resourceUrl 都缺失,走 resourceUrls 数组 fallback + text := `{"result":{"resourceUrls":[{"url":"https://cdn.example.com/file","headers":{"x-custom":"val"}}]}}` + url, headers, err := parseDriveDownloadInfo(text) + if err != nil { + t.Fatalf("resourceUrls fallback 应成功: %v", err) + } + if url != "https://cdn.example.com/file" { + t.Errorf("url = %q", url) + } + if headers["x-custom"] != "val" { + t.Errorf("headers 应包含 x-custom: %v", headers) + } +} + +func TestCrossPlatformCoverageParseDriveDownloadInfo_EmptyURL(t *testing.T) { + // 所有 URL 字段都为空 + text := `{"result":{"fileName":"a.bin"}}` + _, _, err := parseDriveDownloadInfo(text) + if err == nil || !strings.Contains(err.Error(), "downloadUrl 为空") { + t.Fatalf("所有 URL 缺失应报错: %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// P1-1/P1-2 补充测试:中心协议解析与指纹唯一性 +// ────────────────────────────────────────────────────────── + +// TestCrossPlatformCoverageParseDriveDownloadInfo_ResourceUrlsPerURLHeaders +// 验证 resourceUrls[].headers 中的 dentry-token 被正确透传到最终 headers。 +func TestCrossPlatformCoverageParseDriveDownloadInfo_ResourceUrlsPerURLHeaders(t *testing.T) { + text := `{"result":{"resourceUrls":[{"url":"https://center.example.com/download/path","headers":{"dentry-token":"dt-abc123","x-oss-security":"sig"}}]}}` + url, headers, err := parseDriveDownloadInfo(text) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if url != "https://center.example.com/download/path" { + t.Errorf("url = %q, want center URL", url) + } + if headers["dentry-token"] != "dt-abc123" { + t.Errorf("dentry-token 未正确透传: got %v", headers) + } + if headers["x-oss-security"] != "sig" { + t.Errorf("x-oss-security 未正确透传: got %v", headers) + } +} + +// TestCrossPlatformCoverageParseDriveDownloadInfo_TopLevelAndPerURLHeadersMerge +// 验证顶层 headers 与 resourceUrls[].headers 合并(per-URL 覆盖顶层同名 key)。 +func TestCrossPlatformCoverageParseDriveDownloadInfo_TopLevelAndPerURLHeadersMerge(t *testing.T) { + text := `{"result":{"headers":{"x-top":"top-val","dentry-token":"old"},"resourceUrls":[{"url":"https://center.example.com/dl","headers":{"dentry-token":"new"}}]}}` + _, headers, err := parseDriveDownloadInfo(text) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // 顶层 headers 应被读入 + if headers["x-top"] != "top-val" { + t.Errorf("顶层 x-top 应保留: got %v", headers) + } + // per-URL headers 覆盖同名 key + if headers["dentry-token"] != "new" { + t.Errorf("per-URL dentry-token 应覆盖顶层: got %q", headers["dentry-token"]) + } +} + +// TestCrossPlatformCoverageFingerprintVersion0DifferentURLs +// 验证 version=0 时不同完整 URL(含 query)产生不同指纹。 +func TestCrossPlatformCoverageFingerprintVersion0DifferentURLs(t *testing.T) { + urlA := "https://center.example.com/download/file?sign=aaa&expire=100" + urlB := "https://center.example.com/download/file?sign=bbb&expire=200" + urlC := "https://center.example.com/download/other-file?sign=aaa&expire=100" + + fpA := driveDownloadFingerprint("node-1", 0, 1024, urlA) + fpB := driveDownloadFingerprint("node-1", 0, 1024, urlB) + fpC := driveDownloadFingerprint("node-1", 0, 1024, urlC) + + // 同 path 不同 query → 不同指纹 + if fpA == fpB { + t.Error("version=0: 同 path 不同 query 应产生不同指纹") + } + // 不同 path → 不同指纹 + if fpA == fpC { + t.Error("version=0: 不同 path 应产生不同指纹") + } + // 相同 URL → 相同指纹 + fpA2 := driveDownloadFingerprint("node-1", 0, 1024, urlA) + if fpA != fpA2 { + t.Error("version=0: 相同 URL 应产生相同指纹") + } +} + +// TestCrossPlatformCoverageFingerprintVersionedStableAcrossResign +// 验证 version>0 时重签名(仅 query 变化)不影响指纹。 +func TestCrossPlatformCoverageFingerprintVersionedStableAcrossResign(t *testing.T) { + urlOld := "https://oss.example.com/files/abc/content?Signature=old&Expires=1" + urlNew := "https://oss.example.com/files/abc/content?Signature=new&Expires=2" + + fpOld := driveDownloadFingerprint("node-2", 5, 2048, urlOld) + fpNew := driveDownloadFingerprint("node-2", 5, 2048, urlNew) + + if fpOld != fpNew { + t.Error("version>0: 重签名不应改变指纹(只取 path)") + } +} + +// ────────────────────────────────────────────────────────── +// driveTransferDownload: 整合 fetch 在 totalSize= int64(len(content)) { + end = int64(len(content)) - 1 + } + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[start : end+1]) + })) + defer srv.Close() + + // 初始 token 无效(gen=0),refresh 后更新为 gen=1 + tokenGen.Store(1) + creds := &driveCredentialState{ + url: srv.URL, + headers: map[string]string{"dentry-token": "tok-0"}, + initialVersion: 3, + fetch: func(ctx context.Context) (string, map[string]string, int, error) { + return srv.URL, map[string]string{"dentry-token": fmt.Sprintf("tok-%d", tokenGen.Load())}, 3, nil + }, + } + + f, _ := os.CreateTemp(t.TempDir(), "auth-success-*") + defer f.Close() + if err := f.Truncate(100); err != nil { + t.Fatal(err) + } + + err := downloadOnePart(context.Background(), creds, f, driveDownloadPart{index: 0, offset: 0, length: 50}, 0) + if err != nil { + t.Fatalf("auth refresh 成功后应下载成功: %v", err) + } +} + +func TestCrossPlatformCoverageDownloadOnePart_ContextCancelDuringBackoff(t *testing.T) { + // 模拟:第一次非 auth 错误(500)→ 进入退避等待(500ms)→ ctx 超时取消 + // 用短 context timeout 确保在退避期间触发 ctx.Done + responded := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "server error") + select { + case responded <- struct{}{}: + default: + } + })) + defer srv.Close() + + f, _ := os.Create(filepath.Join(t.TempDir(), "backoff-cancel.tmp")) + defer f.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + creds := &driveCredentialState{url: srv.URL} + + go func() { + <-responded // 等待第一次 HTTP 响应完成 + // 小延迟确保 client 已收到响应并进入 backoff select + cancel() + }() + + err := downloadOnePart(ctx, creds, f, driveDownloadPart{index: 0, offset: 0, length: 10}, 0) + if err == nil { + t.Fatal("退避期间 ctx 取消应返回错误") + } +} + +// ────────────────────────────────────────────────────────── +// downloadRangedParts: Truncate 失败、f.Sync 失败、workers 边界 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageDownloadRangedParts_WorkersCapToRemaining(t *testing.T) { + // 预置 checkpoint 让大部分分片已完成 → remaining < parallel → workers 被限制 + content := makeTestContent(100) + srv := rangeTestServer(t, content, "", nil) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "cap-workers.bin") + partPath := dest + drivePartFileSuffix + metaPath := dest + drivePartMetaSuffix + + // 4 个分片中只剩 1 个未完成 → workers=min(4,1)=1 + pre := make([]byte, 100) + copy(pre[0:30], content[0:30]) + copy(pre[30:60], content[30:60]) + copy(pre[60:90], content[60:90]) + if err := os.WriteFile(partPath, pre, 0o644); err != nil { + t.Fatal(err) + } + fp := driveDownloadFingerprint("", 0, 100, srv.URL) + cp := &driveDownloadCheckpoint{ + Version: driveCheckpointVersion, Fingerprint: fp, + TotalSize: 100, PartSize: 30, Completed: []bool{true, true, true, false}, + } + if err := cp.save(metaPath); err != nil { + t.Fatal(err) + } + + creds := &driveCredentialState{url: srv.URL} + opts := driveDownloadOptions{partSize: 30, parallel: 4, resume: true, knownSize: 100} + if err := downloadRangedParts(context.Background(), creds, dest, 100, opts); err != nil { + t.Fatalf("workers cap 测试失败: %v", err) + } + verifyFile(t, dest, content) +} + +// 所有分片已完成(remaining==0)→ workers clamped to 1、无分片下载、直接完成 +func TestCrossPlatformCoverageDownloadRangedParts_AllCompleted(t *testing.T) { + content := makeTestContent(100) + srv := rangeTestServer(t, content, "", nil) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "all-done.bin") + partPath := dest + drivePartFileSuffix + metaPath := dest + drivePartMetaSuffix + + // 预置完整的分片文件和全部完成的 checkpoint + if err := os.WriteFile(partPath, content, 0o644); err != nil { + t.Fatal(err) + } + fp := driveDownloadFingerprint("", 0, 100, srv.URL) + cp := &driveDownloadCheckpoint{ + Version: driveCheckpointVersion, Fingerprint: fp, + TotalSize: 100, PartSize: 30, Completed: []bool{true, true, true, true}, + } + if err := cp.save(metaPath); err != nil { + t.Fatal(err) + } + + creds := &driveCredentialState{url: srv.URL} + opts := driveDownloadOptions{partSize: 30, parallel: 4, resume: true, knownSize: 100} + if err := downloadRangedParts(context.Background(), creds, dest, 100, opts); err != nil { + t.Fatalf("全部完成应直接成功: %v", err) + } + verifyFile(t, dest, content) +} + +// driveTransferDownload 中 totalSize= int64(len(content)) { + end = int64(len(content)) - 1 + } + // 等待 gate 信号,确保第二个 worker 已拿到 part 并发起请求 + <-gate + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[start : end+1]) + } else if n == 2 { + // 第二个请求:失败,触发 fail() 取消 runCtx + close(gate) // 释放第一个请求 + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "error") + } else { + // 后续请求(第一个 worker 循环后用已取消的 ctx 发起)立即返回错误 + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "cancelled") + } + })) + defer srv.Close() + + // 释放 gate 以防第二个请求没按预期到达 + go func() { + time.Sleep(2 * time.Second) + select { + case <-gate: + default: + close(gate) + } + }() + + creds := &driveCredentialState{url: srv.URL} + dest := filepath.Join(t.TempDir(), "worker-runctx.bin") + // 10 分片 * 10 bytes = 100 bytes;parallel=2 + opts := driveDownloadOptions{partSize: 10, parallel: 2, resume: false, knownSize: 100} + err := downloadRangedParts(context.Background(), creds, dest, 100, opts) + if err == nil { + t.Fatal("worker 失败应报错") + } +} + +// ────────────────────────────────────────────────────────── +// drive.go 覆盖率补全:download 命令 logf 回调、fetchCred 回调、非 Canceled 错误路径 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageDriveDownloadRangedPathLogfAndFetchCred(t *testing.T) { + // 目标:覆盖 drive.go download 命令中的: + // - dlOpts.logf 回调(line 501-503) + // - fetchCred 回调(line 540-545) + // - return err 非 Canceled 路径(line 552) + // 策略: + // MCP step1: 返回 download info 带 fileSize=100, resourceUrl 指向 test server + // Test server: probe 返回 401 → 触发 fetchCred + // MCP step2: fetchCred 调用 → 返回错误(覆盖 ferr != nil 分支 line 542-544) + // 由于 fetchCred 失败,probeRangeSupport 返回错误,driveTransferDownload 返回该错误 + // 该错误不是 context.Canceled → 走 line 552 的 return err + + // 保存并替换 driveRangeClient 以控制 probe 行为 + origClient := driveRangeClient + t.Cleanup(func() { driveRangeClient = origClient }) + + driveRangeClient = &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + // 始终返回 401 触发凭证刷新 + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("token expired")), + }, nil + }), + } + + dest := filepath.Join(t.TempDir(), "ranged-logf.bin") + // MCP responses: step1 返回带 fileSize 的下载信息(需 >= 2*partSize=2MB 才走 ranged 路径) + // step2 fetchCred 返回错误 + mcpResp := `{"resourceUrl":"https://fake.invalid/file.bin","fileSize":3000000}` + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: mcpResp}, // step1: download_file 成功 + {err: errors.New("refresh failed")}, // step2: fetchCred 调用失败 + }} + + err := executeDriveEdge(t, caller, + "download", "--node", "node-1", "--output", dest, "--part-size", "1MB", "--parallel", "1") + if err == nil { + t.Fatal("fetchCred 失败应导致错误") + } + // 确保不是 context.Canceled 错误(覆盖 line 552) + if errors.Is(err, context.Canceled) { + t.Fatal("错误不应是 context.Canceled") + } +} + +func TestCrossPlatformCoverageDriveDownloadRangedFetchCredSuccess(t *testing.T) { + // 目标:覆盖 fetchCred 的成功路径(line 545: return parseDownloadInfo(t)) + // 策略:probe 第一次 401 → fetchCred 成功返回新凭证 → probe 第二次成功 → + // 进入 ranged download → 完成下载 + + content := makeTestContent(100) + origClient := driveRangeClient + t.Cleanup(func() { driveRangeClient = origClient }) + + var probeAttempt atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := probeAttempt.Add(1) + if n == 1 { + // 第一次 probe → 401 触发 fetchCred + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "expired") + return + } + // 后续请求正常处理 range + var start, end int64 + if _, err := fmt.Sscanf(r.Header.Get("Range"), "bytes=%d-%d", &start, &end); err != nil { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + return + } + if end >= int64(len(content)) { + end = int64(len(content)) - 1 + } + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[start : end+1]) + })) + defer srv.Close() + + driveRangeClient = srv.Client() + + dest := filepath.Join(t.TempDir(), "ranged-fetchcred-ok.bin") + // MCP step1: download info with fileSize >= 2*partSize (2MB) to trigger ranged path + // MCP step2: fetchCred returns new valid info (same server URL) + mcpResp1 := fmt.Sprintf(`{"resourceUrl":"%s/file.bin","fileSize":3000000}`, srv.URL) + mcpResp2 := fmt.Sprintf(`{"resourceUrl":"%s/file.bin","fileSize":3000000}`, srv.URL) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: mcpResp1}, // step1: download_file + {text: mcpResp2}, // step2: fetchCred (refresh) + }} + + err := executeDriveEdge(t, caller, + "download", "--node", "node-1", "--output", dest, "--part-size", "1MB", "--parallel", "1", "--no-resume") + if err != nil { + t.Fatalf("ranged download 应成功: %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// drive.go 覆盖率补全:download-version 命令的对称路径 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageDriveDownloadVersionRangedPathLogfAndFetchCred(t *testing.T) { + // 覆盖 download-version 中的 logf(line 595-597)、fetchCred(line 632-637)、 + // 和 return err(line 644) + + origClient := driveRangeClient + t.Cleanup(func() { driveRangeClient = origClient }) + + driveRangeClient = &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("token expired")), + }, nil + }), + } + + dest := filepath.Join(t.TempDir(), "version-ranged.bin") + mcpResp := `{"downloadUrl":"https://fake.invalid/file.bin","fileSize":3000000}` + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: mcpResp}, // step1: download_file_version + {err: errors.New("refresh failed")}, // step2: fetchCred 失败 + }} + + err := executeDriveEdge(t, caller, + "download-version", "--node", "node-1", "--version", "3", "--output", dest, + "--part-size", "1MB", "--parallel", "1") + if err == nil { + t.Fatal("download-version fetchCred 失败应报错") + } + if errors.Is(err, context.Canceled) { + t.Fatal("错误不应是 context.Canceled") + } +} + +func TestCrossPlatformCoverageDriveDownloadVersionRangedFetchCredSuccess(t *testing.T) { + // 覆盖 download-version 的 fetchCred 成功路径(line 637) + + totalSize := int64(3000000) + content := makeTestContent(int(totalSize)) + origClient := driveRangeClient + t.Cleanup(func() { driveRangeClient = origClient }) + + var probeAttempt atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := probeAttempt.Add(1) + if n == 1 { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "expired") + return + } + var start, end int64 + if _, err := fmt.Sscanf(r.Header.Get("Range"), "bytes=%d-%d", &start, &end); err != nil { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + return + } + if end >= int64(len(content)) { + end = int64(len(content)) - 1 + } + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[start : end+1]) + })) + defer srv.Close() + + driveRangeClient = srv.Client() + + dest := filepath.Join(t.TempDir(), "version-fetchcred-ok.bin") + mcpResp1 := fmt.Sprintf(`{"downloadUrl":"%s/file.bin","fileSize":3000000}`, srv.URL) + mcpResp2 := fmt.Sprintf(`{"downloadUrl":"%s/file.bin","fileSize":3000000}`, srv.URL) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: mcpResp1}, + {text: mcpResp2}, + }} + + err := executeDriveEdge(t, caller, + "download-version", "--node", "node-1", "--version", "3", "--output", dest, + "--part-size", "1MB", "--parallel", "1", "--no-resume") + if err != nil { + t.Fatalf("download-version ranged 应成功: %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// drive.go 覆盖率补全:uploadToDrive refetch lambda +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageDriveUploadRefetchLambda(t *testing.T) { + // 目标:覆盖 drive.go uploadToDrive 中 refetch lambda(line 2063-2065) + // 策略:httpPutFile 第一次返回 401 → 触发 refetch → callMCPToolReturnTextOnServer + // → scriptedCaller step2 返回新凭证 → 第二次 PUT 成功 + + oldPut := httpPutFile + putCalls := 0 + httpPutFile = func(ctx context.Context, url string, headers map[string]string, filePath string, fileSize int64) error { + putCalls++ + if putCalls == 1 { + return &httpStatusError{StatusCode: 401, Body: "token expired"} + } + return nil + } + t.Cleanup(func() { httpPutFile = oldPut }) + + file := filepath.Join(t.TempDir(), "upload.txt") + _ = os.WriteFile(file, []byte("content"), 0o600) + + // step1: get_upload_info 成功 + // step2: refetch (第二次 get_upload_info) 成功 + // step3: commit_upload 成功 + payload1 := `{"uploadId":"u1","resourceUrl":"https://upload.invalid/put1"}` + payload2 := `{"uploadId":"u2","resourceUrl":"https://upload.invalid/put2"}` + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: payload1}, + {text: payload2}, + {text: `{}`}, + }} + + err := executeDriveEdge(t, caller, "upload", "--file", file, "--folder", "folder-1") + if err != nil { + t.Fatalf("upload with refetch 应成功: %v", err) + } + if putCalls != 2 { + t.Fatalf("PUT 应被调用 2 次(首次 401 + 重试),实际: %d", putCalls) + } +} + +// ────────────────────────────────────────────────────────── +// 覆盖率补全:drive.go:501-503 logf 闭包体(通过命令路径触发分片下载) +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageDriveDownloadCmdLogfInvocation(t *testing.T) { + // 目标:覆盖 drive.go download 命令中 dlOpts.logf 闭包体(line 501-503)。 + // 策略:通过 executeDriveEdge 走完整命令路径,MCP 返回 fileSize >= 2*partSize(2MB), + // server 正确支持 Range,使 driveTransferDownload 走入 downloadRangedParts, + // logf 在分片启动时被调用。 + + totalSize := 2200000 // > 2*1MB threshold + content := makeTestContent(totalSize) + + origClient := driveRangeClient + t.Cleanup(func() { driveRangeClient = origClient }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rng := r.Header.Get("Range") + if rng == "" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) + return + } + var start, end int64 + if _, err := fmt.Sscanf(rng, "bytes=%d-%d", &start, &end); err != nil || start >= int64(len(content)) { + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + return + } + if end >= int64(len(content)) { + end = int64(len(content)) - 1 + } + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[start : end+1]) + })) + defer srv.Close() + + driveRangeClient = srv.Client() + + dest := filepath.Join(t.TempDir(), "cmd-logf.bin") + mcpResp := fmt.Sprintf(`{"resourceUrl":"%s/file.bin","fileSize":%d}`, srv.URL, totalSize) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: mcpResp}, + }} + + err := executeDriveEdge(t, caller, + "download", "--node", "node-1", "--output", dest, "--part-size", "1MB", "--parallel", "2", "--no-resume") + if err != nil { + t.Fatalf("ranged download via cmd 应成功: %v", err) + } + // 验证文件内容正确 + got, _ := os.ReadFile(dest) + if len(got) != totalSize { + t.Fatalf("产物大小 %d != %d", len(got), totalSize) + } +} + +// ────────────────────────────────────────────────────────── +// 覆盖率补全:drive.go:553 download 命令 cancel + --no-resume else 分支 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageDriveDownloadCancelNoResume(t *testing.T) { + // 目标:覆盖 download 命令中 context.Canceled + noResume=true 的 else 分支(line 553)。 + // 条件:driveTransferDownload 返回 context.Canceled + --no-resume 已设置。 + // 由于 fileSize 很小(< 2*16MB),走 downloadSingleWithAuthRetry → httpGetFile。 + // mock httpGetFile 返回 context.Canceled。 + + oldGet := httpGetFile + httpGetFile = func(_ context.Context, _ string, _ map[string]string, _ string) error { + return context.Canceled + } + t.Cleanup(func() { httpGetFile = oldGet }) + + dest := filepath.Join(t.TempDir(), "cancel-noresume.bin") + mcpResp := `{"resourceUrl":"https://fake.invalid/f.bin","fileSize":100}` + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: mcpResp}, + }} + + err := executeDriveEdge(t, caller, + "download", "--node", "node-1", "--output", dest, "--no-resume") + if !errors.Is(err, context.Canceled) { + t.Fatalf("应返回 context.Canceled, got: %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// 覆盖率补全:drive.go:652 download-version 命令 cancel + --no-resume else 分支 +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageDriveDownloadVersionCancelNoResume(t *testing.T) { + // 目标:覆盖 download-version 命令中 context.Canceled + noResume=true 的 else 分支(line 652)。 + + oldGet := httpGetFile + httpGetFile = func(_ context.Context, _ string, _ map[string]string, _ string) error { + return context.Canceled + } + t.Cleanup(func() { httpGetFile = oldGet }) + + dest := filepath.Join(t.TempDir(), "ver-cancel-noresume.bin") + mcpResp := `{"downloadUrl":"https://fake.invalid/f.bin","fileSize":100}` + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: mcpResp}, + }} + + err := executeDriveEdge(t, caller, + "download-version", "--node", "node-1", "--version", "3", "--output", dest, "--no-resume") + if !errors.Is(err, context.Canceled) { + t.Fatalf("应返回 context.Canceled, got: %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// 覆盖率补全:drive_transfer.go:604 worker goroutine ctx cancel early return +// ────────────────────────────────────────────────────────── + +func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) { + // 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。 + // 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。 + // 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。 + // transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。 + + totalSize := int64(200) + content := makeTestContent(int(totalSize)) + + origClient := driveRangeClient + t.Cleanup(func() { driveRangeClient = origClient }) + + driveRangeClient = &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + // 每次请求加小延迟,让总处理时间接近 deadline + time.Sleep(50 * time.Microsecond) + var start, end int64 + if _, err := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end); err != nil { + return &http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader("bad"))}, nil + } + if end >= int64(len(content)) { + end = int64(len(content)) - 1 + } + resp := &http.Response{ + StatusCode: http.StatusPartialContent, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(string(content[start : end+1]))), + } + resp.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content))) + return resp, nil + }), + } + + // 多次尝试以确保覆盖(goroutine 调度非确定性) + for attempt := 0; attempt < 50; attempt++ { + // timeout 设为约为总处理时间的50%,确保在处理过程中过期 + // 40分片/4workers=10轮*50μs=500μs,timeout设300μs使其在中间过期 + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Microsecond) + + creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"} + dest := filepath.Join(t.TempDir(), fmt.Sprintf("wkr-%d.bin", attempt)) + opts := driveDownloadOptions{partSize: 5, parallel: 4, resume: false, knownSize: totalSize} + _ = downloadRangedParts(ctx, creds, dest, totalSize, opts) + cancel() + } +} + +// ────────────────────────────────────────────────────────── +// driveCredentialState.refresh: 版本校验(P1 修复验证) +// ────────────────────────────────────────────────────────── + +// 刷新后版本一致 → 正常继续 +func TestCrossPlatformCoverageRefreshVersionMatch(t *testing.T) { + cs := &driveCredentialState{ + url: "u0", + initialVersion: 5, + fetch: func(ctx context.Context) (string, map[string]string, int, error) { + return "u1", nil, 5, nil + }, + } + _, _, gen := cs.current() + if err := cs.refresh(context.Background(), gen); err != nil { + t.Fatalf("版本一致应成功: %v", err) + } + url, _, _ := cs.current() + if url != "u1" { + t.Errorf("刷新后 URL 应更新, got %q", url) + } +} + +// 刷新后版本变化 → 返回错误终止 +func TestCrossPlatformCoverageRefreshVersionMismatch(t *testing.T) { + cs := &driveCredentialState{ + url: "u0", + initialVersion: 5, + fetch: func(ctx context.Context) (string, map[string]string, int, error) { + return "u1", nil, 8, nil + }, + } + _, _, gen := cs.current() + err := cs.refresh(context.Background(), gen) + if err == nil { + t.Fatal("版本变更应返回错误") + } + if !strings.Contains(err.Error(), "文件版本已变更") { + t.Fatalf("错误信息应包含版本变更提示, got: %v", err) + } + if !strings.Contains(err.Error(), "5") || !strings.Contains(err.Error(), "8") { + t.Fatalf("错误信息应包含新旧版本号, got: %v", err) + } + // URL 不应更新(拒绝了变更) + url, _, _ := cs.current() + if url != "u0" { + t.Errorf("版本变更时 URL 不应更新, got %q", url) + } +} + +// initialVersion=0(旧 MCP 不返回 version)→ 激进策略:返回 sentinel error +func TestCrossPlatformCoverageRefreshVersionZeroInitial(t *testing.T) { + cs := &driveCredentialState{ + url: "u0", + initialVersion: 0, // 未知初始版本 + fetch: func(ctx context.Context) (string, map[string]string, int, error) { + return "u1", nil, 99, nil // 刷新返回任意版本 + }, + } + _, _, gen := cs.current() + err := cs.refresh(context.Background(), gen) + if !errors.Is(err, errCredentialRefreshVersionUnknown) { + t.Fatalf("initialVersion=0 应返回 errCredentialRefreshVersionUnknown, got: %v", err) + } + // 凭证应已更新 + url, _, _ := cs.current() + if url != "u1" { + t.Errorf("凭证应已更新, got url=%q", url) + } +} + +// 刷新返回 version=0(MCP 不返回 version)→ 激进策略:返回 sentinel error +func TestCrossPlatformCoverageRefreshVersionZeroReturned(t *testing.T) { + cs := &driveCredentialState{ + url: "u0", + initialVersion: 5, + fetch: func(ctx context.Context) (string, map[string]string, int, error) { + return "u1", nil, 0, nil // 刷新未返回版本 + }, + } + _, _, gen := cs.current() + err := cs.refresh(context.Background(), gen) + if !errors.Is(err, errCredentialRefreshVersionUnknown) { + t.Fatalf("version=0 应返回 errCredentialRefreshVersionUnknown, got: %v", err) + } + // 凭证应已更新 + url, _, _ := cs.current() + if url != "u1" { + t.Errorf("凭证应已更新, got url=%q", url) + } +} + +// 分片下载中 refresh 触发版本变更检测 → 下载终止 +func TestCrossPlatformCoverageDownloadRangedParts_VersionMismatchAbort(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := calls.Add(1) + if n == 1 { + // 第一次(probeRangeSupport 不经此路径)/首分片请求 → 401 + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "expired") + return + } + // 不应走到这里 + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + creds := &driveCredentialState{ + url: srv.URL, + initialVersion: 3, + fetch: func(ctx context.Context) (string, map[string]string, int, error) { + // 刷新时版本变了 + return srv.URL, nil, 7, nil + }, + } + dest := filepath.Join(t.TempDir(), "version-abort.bin") + opts := driveDownloadOptions{partSize: 50, parallel: 1, resume: false} + err := downloadRangedParts(context.Background(), creds, dest, 100, opts) + if err == nil { + t.Fatal("版本变更应导致下载失败") + } + if !strings.Contains(err.Error(), "文件版本已变更") { + t.Fatalf("应包含版本变更错误, got: %v", err) + } +} + +// ────────────────────────────────────────────────────────── +// fetchRangeInto: Content-Range total 校验(P1 修复验证) +// ────────────────────────────────────────────────────────── + +// Content-Range total 与 expectedTotal 一致 → 正常通过 +func TestCrossPlatformCoverageFetchRangeInto_ContentRangeTotalMatch(t *testing.T) { + content := makeTestContent(200) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var start, end int64 + fmt.Sscanf(r.Header.Get("Range"), "bytes=%d-%d", &start, &end) + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[start : end+1]) + })) + defer srv.Close() + + f, err := os.CreateTemp(t.TempDir(), "total-match-*") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := f.Truncate(200); err != nil { + t.Fatal(err) + } + part := driveDownloadPart{index: 0, offset: 0, length: 50} + if err := fetchRangeInto(context.Background(), srv.URL, nil, f, part, 200); err != nil { + t.Fatalf("total 一致不应报错: %v", err) + } +} + +// Content-Range total 与 expectedTotal 不一致 → 返回错误 +func TestCrossPlatformCoverageFetchRangeInto_ContentRangeTotalMismatch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var start, end int64 + fmt.Sscanf(r.Header.Get("Range"), "bytes=%d-%d", &start, &end) + // 服务端声称 total=500,但调用方期望 200 + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/500", start, end)) + w.WriteHeader(http.StatusPartialContent) + data := make([]byte, end-start+1) + _, _ = w.Write(data) + })) + defer srv.Close() + + f, err := os.CreateTemp(t.TempDir(), "total-mismatch-*") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := f.Truncate(200); err != nil { + t.Fatal(err) + } + part := driveDownloadPart{index: 0, offset: 0, length: 50} + err = fetchRangeInto(context.Background(), srv.URL, nil, f, part, 200) + if err == nil { + t.Fatal("total 不匹配应返回错误") + } + if !strings.Contains(err.Error(), "总长不匹配") { + t.Fatalf("错误信息应包含'总长不匹配': %v", err) + } + if !strings.Contains(err.Error(), "500") || !strings.Contains(err.Error(), "200") { + t.Fatalf("错误信息应包含实际和期望值: %v", err) + } +} + +// expectedTotal=0(调用方不知道总长)→ 跳过校验 +func TestCrossPlatformCoverageFetchRangeInto_ContentRangeTotalSkipWhenZeroExpected(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var start, end int64 + fmt.Sscanf(r.Header.Get("Range"), "bytes=%d-%d", &start, &end) + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/9999", start, end)) + w.WriteHeader(http.StatusPartialContent) + data := make([]byte, end-start+1) + _, _ = w.Write(data) + })) + defer srv.Close() + + f, err := os.CreateTemp(t.TempDir(), "total-skip-*") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := f.Truncate(100); err != nil { + t.Fatal(err) + } + part := driveDownloadPart{index: 0, offset: 0, length: 50} + // expectedTotal=0 → 不校验 total + if err := fetchRangeInto(context.Background(), srv.URL, nil, f, part, 0); err != nil { + t.Fatalf("expectedTotal=0 应跳过 total 校验: %v", err) + } +} + +// Content-Range total=-1("*" 未知)→ crTotal<0 → 跳过校验 +func TestCrossPlatformCoverageFetchRangeInto_ContentRangeTotalUnknown(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var start, end int64 + fmt.Sscanf(r.Header.Get("Range"), "bytes=%d-%d", &start, &end) + // total="*" → parseContentRange 返回 total=-1 + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/*", start, end)) + w.WriteHeader(http.StatusPartialContent) + data := make([]byte, end-start+1) + _, _ = w.Write(data) + })) + defer srv.Close() + + f, err := os.CreateTemp(t.TempDir(), "total-unknown-*") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := f.Truncate(100); err != nil { + t.Fatal(err) + } + part := driveDownloadPart{index: 0, offset: 0, length: 50} + // crTotal=-1 (不 > 0) → 跳过 total 校验 + if err := fetchRangeInto(context.Background(), srv.URL, nil, f, part, 200); err != nil { + t.Fatalf("total=* 应跳过校验: %v", err) + } +} + +// TestCrossPlatformCoverageFetchRangeIntoMissingContentRange 验证 fetchRangeInto 在 +// 206 响应缺少 Content-Range 头时返回明确错误,拒绝无法验证偏移一致性的分片。 +func TestCrossPlatformCoverageFetchRangeIntoMissingContentRange(t *testing.T) { + content := makeTestContent(1024) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var start, end int64 + fmt.Sscanf(r.Header.Get("Range"), "bytes=%d-%d", &start, &end) + // 返回 206 但不带 Content-Range + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[start : end+1]) + })) + defer srv.Close() + + f, err := os.CreateTemp(t.TempDir(), "missing-cr-*") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := f.Truncate(1024); err != nil { + t.Fatal(err) + } + + part := driveDownloadPart{index: 0, offset: 0, length: 512} + err = fetchRangeInto(context.Background(), srv.URL, nil, f, part, 1024) + if err == nil { + t.Fatal("应当返回错误:分片响应缺少 Content-Range") + } + if !strings.Contains(err.Error(), "缺少 Content-Range") { + t.Fatalf("错误信息应包含'缺少 Content-Range': %v", err) + } +} + +// TestCrossPlatformCoverageDriveDownloadFetchCredParseError 覆盖 drive.go download 命令 +// fetchCred 闭包中 parseDriveDownloadInfo 返回 error 的路径(line 547-549)。 +func TestCrossPlatformCoverageDriveDownloadFetchCredParseError(t *testing.T) { + origClient := driveRangeClient + t.Cleanup(func() { driveRangeClient = origClient }) + + driveRangeClient = &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("expired")), + }, nil + }), + } + + dest := filepath.Join(t.TempDir(), "parse-err.bin") + // step1: 正常返回(进入分片路径);step2: fetchCred 返回可解析但无 URL 的 JSON + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"resourceUrl":"https://fake.invalid/f","fileSize":3000000}`}, + {text: `{"result":{}}`}, // parseDriveDownloadInfo 会返回 "downloadUrl 为空" 错误 + }} + + err := executeDriveEdge(t, caller, + "download", "--node", "n1", "--output", dest, "--part-size", "1MB", "--parallel", "1") + if err == nil { + t.Fatal("parseDriveDownloadInfo 失败应导致错误") + } + if !strings.Contains(err.Error(), "downloadUrl") && !strings.Contains(err.Error(), "下载链接") { + t.Fatalf("错误应包含解析失败信息: %v", err) + } +} + +// TestCrossPlatformCoverageDriveDownloadVersionFetchCredParseError 覆盖 download-version +// fetchCred 闭包中 parseDriveDownloadInfo 返回 error 的路径(line 650-652)。 +func TestCrossPlatformCoverageDriveDownloadVersionFetchCredParseError(t *testing.T) { + origClient := driveRangeClient + t.Cleanup(func() { driveRangeClient = origClient }) + + driveRangeClient = &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("expired")), + }, nil + }), + } + + dest := filepath.Join(t.TempDir(), "ver-parse-err.bin") + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"resourceUrl":"https://fake.invalid/f","fileSize":3000000}`}, + {text: `{"result":{}}`}, + }} + + err := executeDriveEdge(t, caller, + "download-version", "--node", "n1", "--version", "3", "--output", dest, "--part-size", "1MB", "--parallel", "1") + if err == nil { + t.Fatal("parseDriveDownloadInfo 失败应导致错误") + } +} + +// ────────────────────────────────────────────────────────── +// 激进策略:version=0 时凭证刷新后清空分片从头下载 +// ────────────────────────────────────────────────────────── + +// TestCrossPlatformCoverageRefreshVersionBothZero 双方 version=0 → 返回 sentinel error,凭证已更新 +func TestCrossPlatformCoverageRefreshVersionBothZero(t *testing.T) { + cs := &driveCredentialState{ + url: "u0", + initialVersion: 0, + fetch: func(ctx context.Context) (string, map[string]string, int, error) { + return "u1", map[string]string{"k": "v"}, 0, nil + }, + } + _, _, gen := cs.current() + err := cs.refresh(context.Background(), gen) + if !errors.Is(err, errCredentialRefreshVersionUnknown) { + t.Fatalf("双方 version=0 应返回 sentinel error, got: %v", err) + } + // 凭证应已更新 + url, headers, newGen := cs.current() + if url != "u1" { + t.Errorf("URL 应已更新, got %q", url) + } + if headers["k"] != "v" { + t.Errorf("headers 应已更新, got %v", headers) + } + if newGen != gen+1 { + t.Errorf("gen 应递增, got %d (was %d)", newGen, gen) + } +} + +// TestCrossPlatformCoverageDownloadRangedParts_VersionUnknownCleansCheckpoint +// version=0 的凭证刷新导致分片下载中止,checkpoint 和临时文件被清理。 +func TestCrossPlatformCoverageDownloadRangedParts_VersionUnknownCleansCheckpoint(t *testing.T) { + content := makeTestContent(100) + // 服务端:第一次请求正常(probe),后续分片请求返回 401 触发 refresh + var probeCount atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rng := r.Header.Get("Range") + if rng == "bytes=0-0" && probeCount.Add(1) == 1 { + // probe 请求正常 + w.Header().Set("Content-Range", fmt.Sprintf("bytes 0-0/%d", len(content))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(content[0:1]) + return + } + // 分片请求全部返回 401 触发 credential refresh + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "expired") + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "version-unknown-clean.bin") + partPath := dest + drivePartFileSuffix + metaPath := dest + drivePartMetaSuffix + + // fetch 返回 version=0(旧 MCP),initialVersion=0(opts.version 未设置) + fetch := func(ctx context.Context) (string, map[string]string, int, error) { + return srv.URL, nil, 0, nil // version=0 + } + opts := driveDownloadOptions{partSize: 30, parallel: 1, resume: true, knownSize: 100} + // initialVersion 来自 opts.version(默认 0) + + err := driveTransferDownload(context.Background(), fetch, srv.URL, nil, dest, opts) + if err == nil { + t.Fatal("version=0 刷新应导致下载失败") + } + if !errors.Is(err, errCredentialRefreshVersionUnknown) { + t.Fatalf("应包含 sentinel error, got: %v", err) + } + // 验证 checkpoint 和临时文件被清理 + if _, statErr := os.Stat(metaPath); !os.IsNotExist(statErr) { + t.Error("checkpoint 应被清理") + } + if _, statErr := os.Stat(partPath); !os.IsNotExist(statErr) { + t.Error("分片临时文件应被清理") + } +} + +// TestCrossPlatformCoverageProbeRangeSupport_VersionUnknownNonFatal +// 探测阶段 version=0 的 refresh 不应阻断探测(无已完成分片需保护)。 +func TestCrossPlatformCoverageProbeRangeSupport_VersionUnknownNonFatal(t *testing.T) { + var attempts atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := attempts.Add(1) + if n == 1 { + // 第一次:401 触发 refresh + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, "expired") + return + } + // 第二次(refresh 后重试):正常 206 + w.Header().Set("Content-Range", "bytes 0-0/500") + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write([]byte{0}) + })) + defer srv.Close() + + creds := &driveCredentialState{ + url: srv.URL, + initialVersion: 0, // version 未知 + fetch: func(ctx context.Context) (string, map[string]string, int, error) { + return srv.URL, nil, 0, nil // 仍然未知 + }, + } + total, resp, err := probeRangeSupport(context.Background(), creds) + if err != nil { + t.Fatalf("探测阶段 version=0 不应报错: %v", err) + } + if resp != nil { + t.Error("不应返回全量 resp") + } + if total != 500 { + t.Errorf("应返回正确 total=500, got %d", total) + } + if attempts.Load() != 2 { + t.Errorf("应请求 2 次(401→refresh→retry 成功), got %d", attempts.Load()) + } +} From 9539c887f62bbfa9928ba9330c946e3833daa5b1 Mon Sep 17 00:00:00 2001 From: chichuan Date: Tue, 4 Aug 2026 09:35:44 +0800 Subject: [PATCH 12/15] docs: seal v1.0.56-beta.4 changelog --- CHANGELOG.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6f1a63d..f9c31250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.56-beta.4] - 2026-08-04 + +This beta adds PR #852 on top of v1.0.56-beta.3. It makes Drive downloads +resilient for large files through parallel transfer, validated resumable +checkpoints, and automatic credential refresh. + +### Added + +- **Multipart Drive downloads** (#852) — adds `--part-size`, `--parallel`, and + `--no-resume` to `drive download` and `drive download-version`. Files above + the part-size threshold use a Range probe and parallel chunks, resume from a + fingerprint-validated checkpoint, refresh credentials on 401/403, and keep + the checkpoint when Ctrl+C interrupts a transfer. + ## [1.0.56-beta.3] - 2026-08-03 This beta adds PRs #846 and #851 on top of v1.0.56-beta.2. It adds a @@ -16,7 +30,6 @@ validated private runtime directory. ### Added - **Aitable workflow editing reference** (#851) — adds `dws aitable workflow edit-example`, a parameter-free read command that returns the service-provided workflow editing documentation and `workflow-dsl/v1` examples through `aitable/edit_workflow_example`. -- **Multipart download engine** — adds `--part-size`, `--parallel`, and `--no-resume` flags to `drive download` and `drive download-version`. Files exceeding the part-size threshold are downloaded in parallel chunks with automatic Range probe, checkpoint-based resume, and credential refresh on 401/403. Ctrl+C gracefully preserves the checkpoint for later resume. ### Fixed From d02b03436de6cb2a26e169bb9daec06ec0eced9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:55:53 +0000 Subject: [PATCH 13/15] chore: update beta formula for v1.0.56-beta.4 [skip ci] --- Formula/dingtalk-workspace-cli-beta.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index 1579349c..e91b8784 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCliBeta < Formula desc "Automate DingTalk workspace tasks from the terminal (beta channel)" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.56-beta.3" + version "1.0.56-beta.4" license "Apache-2.0" keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-darwin-arm64.tar.gz" - sha256 "5d35bb3fca7883a4ee51e1561aefd6b954b104313359a7c05b30a333ea41e749" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-arm64.tar.gz" + sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-darwin-amd64.tar.gz" - sha256 "5a94069a3ab2c811d915639bbfd9ff17035b77501d5f9070c0b540ffe525a41b" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz" + sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-linux-arm64.tar.gz" - sha256 "e9cbc1c647f3ea703e1b93264c0e7d92871cfabe26fef26fbe17b9dff4b80c0f" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz" + sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-linux-amd64.tar.gz" - sha256 "7c475841ea871d204f9f6efc7d6e5378cf19db4541aeaa8a27d1387fa6f2a1f1" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz" + sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-skills.zip" - sha256 "b29b35345613bc9260ae37578eb982472591c9dc548b02176b5b9ba0bdc431f2" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip" + sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152" end def install From f26df0467916cb1d564791810f8c06c0fc57d5f6 Mon Sep 17 00:00:00 2001 From: chichuan Date: Tue, 4 Aug 2026 10:00:32 +0800 Subject: [PATCH 14/15] docs: seal v1.0.56 changelog --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c31250..0794cf80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.56] - 2026-08-04 + +This stable release promotes the fully delivered `v1.0.56-beta.4` baseline. +It includes PR #852's resilient multipart Drive download implementation, +together with the v1.0.56 beta-line command, Schema, Skill, and runtime +improvements already validated through the prerelease channel. + +### Added + +- **Resilient multipart Drive downloads** (#852) — `drive download` and + `drive download-version` support parallel chunk transfer, Range probing, + fingerprint-validated checkpoint resume, automatic 401/403 credential + refresh, and graceful interruption with checkpoint preservation. + ## [1.0.56-beta.4] - 2026-08-04 This beta adds PR #852 on top of v1.0.56-beta.3. It makes Drive downloads From 162a2eb0a72585d8023acf0ede9b86cb00f08263 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:16:23 +0000 Subject: [PATCH 15/15] chore: update formula for v1.0.56 [skip ci] --- Formula/dingtalk-workspace-cli.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Formula/dingtalk-workspace-cli.rb b/Formula/dingtalk-workspace-cli.rb index c1c00769..4e832659 100644 --- a/Formula/dingtalk-workspace-cli.rb +++ b/Formula/dingtalk-workspace-cli.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCli < Formula desc "Automate DingTalk workspace tasks from the terminal" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.55" + version "1.0.56" license "Apache-2.0" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-arm64.tar.gz" - sha256 "dd753bbd051e5dd007cf433b8aa211c4a221dd73dfcb0b3783fa924d09f12351" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-arm64.tar.gz" + sha256 "5c6003fe484aa36cc00820a574186652467b9d075f19c159cf807e57590256ba" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-amd64.tar.gz" - sha256 "f465eb7ac38a8a84eac4eb821fd15424bfc6f6245a60fa695ba97a639970dd77" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-amd64.tar.gz" + sha256 "969b005a10682c2a1a828fa112165b5b0cd8ceeed8d22110ef7f39402cc36804" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-arm64.tar.gz" - sha256 "5961be0fd551ec8e69b6fff2b1609f73486f7e6c3ffe8eb4bb99fa1ed691b401" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-arm64.tar.gz" + sha256 "530c5ea7ddc7de320d9c2471fbd33752a723d00c9665f49321c7580e8392c756" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-amd64.tar.gz" - sha256 "051ba404a5f6a8fb15def0e0f5d9d273cf9d63f881df2fffe159f2c4ea3366e7" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-amd64.tar.gz" + sha256 "675fa42727ac9a549c6710b82e1980cd0f795363d71d5116a4e69771b7c5470e" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-skills.zip" - sha256 "bd35f674f184001f5a03c7b5fa6029ebcda54f0054e15cd608b5b5e213ce2d05" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-skills.zip" + sha256 "3d57794e4660a089209ce3962571d16ca0d46141e973c9993257a301cce0e097" end def install