From 0a84f100be9090e3b4c41ec8e9dd27226f4ebac2 Mon Sep 17 00:00:00 2001 From: liushiyao Date: Thu, 2 Apr 2026 22:07:01 +0800 Subject: [PATCH 01/17] feat: add FileIO extension for file transfer abstraction Introduce extension/fileio package with Provider + FileIO interface (Open/Stat/Save) following the same pattern as extension/credential. Default LocalFileIO implementation in internal/cmdutil handles path validation, directory creation, and atomic writes. This enables internal forks to inject custom FileIO implementations (e.g. streaming) without modifying shortcut code. Change-Id: I20dfbc1a597cb638690ead0c398d003f7eb5cae6 Co-Authored-By: Claude Opus 4.6 (1M context) --- extension/fileio/registry.go | 28 ++++++++++++ extension/fileio/types.go | 56 ++++++++++++++++++++++++ internal/cmdutil/factory.go | 3 ++ internal/cmdutil/factory_default.go | 6 +++ internal/cmdutil/local_fileio.go | 67 +++++++++++++++++++++++++++++ shortcuts/common/runner.go | 6 +++ 6 files changed, 166 insertions(+) create mode 100644 extension/fileio/registry.go create mode 100644 extension/fileio/types.go create mode 100644 internal/cmdutil/local_fileio.go diff --git a/extension/fileio/registry.go b/extension/fileio/registry.go new file mode 100644 index 00000000..db84db45 --- /dev/null +++ b/extension/fileio/registry.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package fileio + +import "sync" + +var ( + mu sync.Mutex + provider Provider +) + +// Register registers a FileIO Provider. +// Later registrations override earlier ones. +// Typically called from init() via blank import. +func Register(p Provider) { + mu.Lock() + defer mu.Unlock() + provider = p +} + +// GetProvider returns the currently registered Provider. +// Returns nil if no provider has been registered. +func GetProvider() Provider { + mu.Lock() + defer mu.Unlock() + return provider +} diff --git a/extension/fileio/types.go b/extension/fileio/types.go new file mode 100644 index 00000000..d18c008d --- /dev/null +++ b/extension/fileio/types.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package fileio + +import ( + "context" + "io" + "os" +) + +// Provider creates FileIO instances. +// Follows the same API style as extension/credential.Provider. +type Provider interface { + Name() string + ResolveFileIO(ctx context.Context) FileIO +} + +// FileIO abstracts file transfer operations for CLI commands. +// The default implementation operates on the local filesystem with +// path validation, directory creation, and atomic writes. +// Inject a custom implementation via Factory.FileIO to replace +// file transfer behavior (e.g. streaming in server mode). +type FileIO interface { + // Open opens a file for reading (upload, attachment, template scenarios). + // The default implementation validates the path via SafeInputPath. + Open(name string) (File, error) + + // Stat returns file metadata (size validation, existence checks). + // The default implementation validates the path via SafeInputPath. + // Use os.IsNotExist(err) to distinguish "file not found" from "invalid path". + Stat(name string) (os.FileInfo, error) + + // Save writes content to the target path. + // The default implementation validates via SafeOutputPath, creates + // parent directories, and writes atomically. + Save(path string, opts SaveOptions, body io.Reader) error +} + +// File is the interface returned by FileIO.Open. +// It covers the subset of *os.File methods actually used by CLI commands. +// *os.File satisfies this interface without adaptation. +type File interface { + io.Reader + io.ReaderAt + io.Closer + Stat() (os.FileInfo, error) +} + +// SaveOptions carries metadata for Save. +// The default (local) implementation ignores these fields; +// server-mode implementations use them to construct streaming response frames. +type SaveOptions struct { + ContentType string // MIME type + ContentLength int64 // content length; -1 if unknown +} diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index 8845f1dc..ff93a6ca 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" extcred "github.com/larksuite/cli/extension/credential" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" @@ -40,6 +41,8 @@ type Factory struct { ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call Credential *credential.CredentialProvider + + FileIO fileio.FileIO // file transfer abstraction (default: local filesystem) } // ResolveAs returns the effective identity type. diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index 8c8ea4f8..661dc689 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -17,6 +17,7 @@ import ( "golang.org/x/term" extcred "github.com/larksuite/cli/extension/credential" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" @@ -44,6 +45,11 @@ func NewDefault(inv InvocationContext) *Factory { IsTerminal: term.IsTerminal(int(os.Stdin.Fd())), } + // Phase 0: FileIO (no dependency) + if p := fileio.GetProvider(); p != nil { + f.FileIO = p.ResolveFileIO(context.Background()) + } + // Phase 1: HttpClient (no credential dependency) f.HttpClient = cachedHttpClientFunc() diff --git a/internal/cmdutil/local_fileio.go b/internal/cmdutil/local_fileio.go new file mode 100644 index 00000000..5e8ad2e4 --- /dev/null +++ b/internal/cmdutil/local_fileio.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "context" + "io" + "os" + "path/filepath" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/validate" +) + +// localFileIOProvider is the default fileio.Provider backed by the local filesystem. +type localFileIOProvider struct{} + +func (p *localFileIOProvider) Name() string { return "local" } + +func (p *localFileIOProvider) ResolveFileIO(_ context.Context) fileio.FileIO { + return &LocalFileIO{} +} + +func init() { + fileio.Register(&localFileIOProvider{}) +} + +// LocalFileIO implements fileio.FileIO using the local filesystem. +// Path validation (SafeInputPath/SafeOutputPath), directory creation, +// and atomic writes are handled internally. +type LocalFileIO struct{} + +// Open opens a local file for reading after validating the path. +func (l *LocalFileIO) Open(name string) (fileio.File, error) { + safePath, err := validate.SafeInputPath(name) + if err != nil { + return nil, err + } + return os.Open(safePath) +} + +// Stat returns file metadata after validating the path. +func (l *LocalFileIO) Stat(name string) (os.FileInfo, error) { + safePath, err := validate.SafeInputPath(name) + if err != nil { + return nil, err + } + return os.Stat(safePath) +} + +// Save writes body to path atomically after validating the output path. +// Parent directories are created as needed. +func (l *LocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) error { + safePath, err := validate.SafeOutputPath(path) + if err != nil { + return err + } + data, err := io.ReadAll(body) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil { + return err + } + return validate.AtomicWrite(safePath, data, 0644) +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 1141b0bc..fd59b548 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -17,6 +17,7 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdutil" @@ -296,6 +297,11 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams { return ctx.Factory.IOStreams } +// FileIO returns the FileIO from the Factory. +func (ctx *RuntimeContext) FileIO() fileio.FileIO { + return ctx.Factory.FileIO +} + // ── Output helpers ── // Out prints a success JSON envelope to stdout. From 2ecd703e81d9bf75a8ce34ccc38ddd6e90170d29 Mon Sep 17 00:00:00 2001 From: liushiyao Date: Thu, 2 Apr 2026 22:21:46 +0800 Subject: [PATCH 02/17] feat: migrate download shortcuts to FileIO.Save (Phase 2) Replace direct os.MkdirAll + validate.AtomicWrite with FileIO.Save in all download shortcuts: drive +download, docs +media-download, drive +export, im +messages-resources-download, sheets +export, vc +notes, and internal/client/response.go SaveResponse. LocalFileIO.Save now uses AtomicWriteFromReader for streaming writes instead of io.ReadAll, avoiding full in-memory buffering. Path validation and overwrite checks use FileIO.Stat instead of direct validate.SafeOutputPath + os.Stat calls. Change-Id: I516d2c9911f6d7bc464b1abfa7d6b6050270debf Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/api/api.go | 1 + cmd/service/service.go | 1 + extension/fileio/types.go | 9 ++- internal/client/response.go | 35 +++++------ internal/client/response_test.go | 60 ++++++++++++++++--- internal/cmdutil/local_fileio.go | 24 +++++--- internal/cmdutil/testing.go | 1 + shortcuts/doc/doc_media_download.go | 34 +++++------ shortcuts/drive/drive_download.go | 29 ++++----- shortcuts/drive/drive_export.go | 2 +- shortcuts/drive/drive_export_common.go | 27 ++++----- shortcuts/drive/drive_export_test.go | 2 +- shortcuts/im/helpers_network_test.go | 17 ++++-- .../im/im_messages_resources_download.go | 30 +++++----- shortcuts/sheets/sheet_export.go | 20 +++---- shortcuts/vc/vc_notes.go | 23 +++---- 16 files changed, 185 insertions(+), 130 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index 084cb059..ffd48e50 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -207,6 +207,7 @@ func apiRun(opts *APIOptions) error { JqExpr: opts.JqExpr, Out: out, ErrOut: f.IOStreams.ErrOut, + FileIO: f.FileIO, }) // MarkRaw tells root error handler to skip enrichPermissionError, // preserving the original API error detail (log_id, troubleshooter, etc.). diff --git a/cmd/service/service.go b/cmd/service/service.go index 85c62cc3..45f198bc 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -250,6 +250,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { JqExpr: opts.JqExpr, Out: out, ErrOut: f.IOStreams.ErrOut, + FileIO: f.FileIO, CheckError: checkErr, }) } diff --git a/extension/fileio/types.go b/extension/fileio/types.go index d18c008d..9d84f081 100644 --- a/extension/fileio/types.go +++ b/extension/fileio/types.go @@ -31,10 +31,10 @@ type FileIO interface { // Use os.IsNotExist(err) to distinguish "file not found" from "invalid path". Stat(name string) (os.FileInfo, error) - // Save writes content to the target path. + // Save writes content to the target path and returns a SaveResult. // The default implementation validates via SafeOutputPath, creates // parent directories, and writes atomically. - Save(path string, opts SaveOptions, body io.Reader) error + Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error) } // File is the interface returned by FileIO.Open. @@ -47,6 +47,11 @@ type File interface { Stat() (os.FileInfo, error) } +// SaveResult holds the outcome of a Save operation. +type SaveResult interface { + Size() int64 // actual bytes written +} + // SaveOptions carries metadata for Save. // The default (local) implementation ignores these fields; // server-mode implementations use them to construct streaming response frames. diff --git a/internal/client/response.go b/internal/client/response.go index 10695614..ccd25f43 100644 --- a/internal/client/response.go +++ b/internal/client/response.go @@ -9,15 +9,13 @@ import ( "fmt" "io" "mime" - "path/filepath" "strings" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" ) // ── Response routing ── @@ -29,6 +27,7 @@ type ResponseOptions struct { JqExpr string // if set, apply jq filter instead of Format Out io.Writer // stdout ErrOut io.Writer // stderr + FileIO fileio.FileIO // file transfer abstraction; nil falls back to direct os calls // CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse. CheckError func(interface{}) error } @@ -61,7 +60,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { return apiErr } if opts.OutputPath != "" { - return saveAndPrint(resp, opts.OutputPath, opts.Out) + return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out) } if opts.JqExpr != "" { return output.JqFilter(opts.Out, result, opts.JqExpr) @@ -75,11 +74,11 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct) } if opts.OutputPath != "" { - return saveAndPrint(resp, opts.OutputPath, opts.Out) + return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out) } // No --output: auto-save with derived filename. - meta, err := SaveResponse(resp, ResolveFilename(resp)) + meta, err := SaveResponse(opts.FileIO, resp, ResolveFilename(resp)) if err != nil { return output.Errorf(output.ExitInternal, "file_error", "%s", err) } @@ -88,8 +87,8 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { return nil } -func saveAndPrint(resp *larkcore.ApiResp, path string, w io.Writer) error { - meta, err := SaveResponse(resp, path) +func saveAndPrint(fio fileio.FileIO, resp *larkcore.ApiResp, path string, w io.Writer) error { + meta, err := SaveResponse(fio, resp, path) if err != nil { return output.Errorf(output.ExitInternal, "file_error", "%s", err) } @@ -119,23 +118,19 @@ func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) { // ── File saving ── // SaveResponse writes an API response body to the given outputPath and returns metadata. -func SaveResponse(resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) { - safePath, err := validate.SafeOutputPath(outputPath) +// When fio is non-nil, it delegates to FileIO.Save (with path validation and atomic write). +func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) { + result, err := fio.Save(outputPath, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: int64(len(resp.RawBody)), + }, bytes.NewReader(resp.RawBody)) if err != nil { - return nil, fmt.Errorf("unsafe output path: %s", err) - } - - if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil { - return nil, fmt.Errorf("create directory: %s", err) - } - - if err := validate.AtomicWrite(safePath, resp.RawBody, 0644); err != nil { return nil, fmt.Errorf("cannot write file: %s", err) } return map[string]interface{}{ - "saved_path": safePath, - "size_bytes": len(resp.RawBody), + "saved_path": outputPath, + "size_bytes": result.Size(), "content_type": resp.Header.Get("Content-Type"), }, nil } diff --git a/internal/client/response_test.go b/internal/client/response_test.go index 0de09f97..69493509 100644 --- a/internal/client/response_test.go +++ b/internal/client/response_test.go @@ -6,6 +6,7 @@ package client import ( "bytes" "errors" + "io" "net/http" "os" "path/filepath" @@ -14,6 +15,9 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/output" ) @@ -150,11 +154,11 @@ func TestSaveResponse(t *testing.T) { body := []byte("hello binary data") resp := newApiResp(body, map[string]string{"Content-Type": "application/octet-stream"}) - meta, err := SaveResponse(resp, "test_output.bin") + meta, err := SaveResponse(&testLocalFileIO{}, resp, "test_output.bin") if err != nil { t.Fatalf("SaveResponse failed: %v", err) } - if meta["size_bytes"] != len(body) { + if meta["size_bytes"] != int64(len(body)) { t.Errorf("expected size_bytes=%d, got %v", len(body), meta["size_bytes"]) } @@ -176,7 +180,7 @@ func TestSaveResponse_CreatesDir(t *testing.T) { resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"}) - meta, err := SaveResponse(resp, filepath.Join("sub", "deep", "out.bin")) + meta, err := SaveResponse(&testLocalFileIO{}, resp, filepath.Join("sub", "deep", "out.bin")) if err != nil { t.Fatalf("SaveResponse with nested dir failed: %v", err) } @@ -195,6 +199,7 @@ func TestHandleResponse_JSON(t *testing.T) { err := HandleResponse(resp, ResponseOptions{ Out: &out, ErrOut: &errOut, + FileIO: &testLocalFileIO{}, }) if err != nil { t.Fatalf("HandleResponse failed: %v", err) @@ -213,6 +218,7 @@ func TestHandleResponse_JSONWithError(t *testing.T) { err := HandleResponse(resp, ResponseOptions{ Out: &out, ErrOut: &errOut, + FileIO: &testLocalFileIO{}, }) if err == nil { t.Error("expected error for non-zero code") @@ -232,6 +238,7 @@ func TestHandleResponse_BinaryAutoSave(t *testing.T) { err := HandleResponse(resp, ResponseOptions{ Out: &out, ErrOut: &errOut, + FileIO: &testLocalFileIO{}, }) if err != nil { t.Fatalf("HandleResponse binary failed: %v", err) @@ -255,6 +262,7 @@ func TestHandleResponse_BinaryWithOutput(t *testing.T) { OutputPath: "out.png", Out: &out, ErrOut: &errOut, + FileIO: &testLocalFileIO{}, }) if err != nil { t.Fatalf("HandleResponse with output path failed: %v", err) @@ -269,7 +277,7 @@ func TestHandleResponse_NonJSONError_404(t *testing.T) { resp := newApiRespWithStatus(404, []byte("404 page not found"), map[string]string{"Content-Type": "text/plain"}) var out, errOut bytes.Buffer - err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut}) + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &testLocalFileIO{}}) if err == nil { t.Fatal("expected error for 404 text/plain") } @@ -287,7 +295,7 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) { resp := newApiRespWithStatus(502, []byte("Bad Gateway"), map[string]string{"Content-Type": "text/html"}) var out, errOut bytes.Buffer - err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut}) + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &testLocalFileIO{}}) if err == nil { t.Fatal("expected error for 502 text/html") } @@ -310,7 +318,7 @@ func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) { resp := newApiRespWithStatus(200, []byte("plain text file content"), map[string]string{"Content-Type": "text/plain"}) var out, errOut bytes.Buffer - err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut}) + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &testLocalFileIO{}}) if err != nil { t.Fatalf("expected no error for 200 text/plain, got: %v", err) } @@ -341,7 +349,7 @@ func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) { resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"}) var out, errOut bytes.Buffer - err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut}) + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &testLocalFileIO{}}) if err == nil { t.Fatal("expected error for 403 JSON with non-zero code") } @@ -349,3 +357,41 @@ func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) { t.Errorf("expected lark error code in message, got: %s", err.Error()) } } + +// testLocalFileIO is a minimal fileio.FileIO for tests that writes to the local filesystem. +type testLocalFileIO struct{} + +func (t *testLocalFileIO) Open(name string) (fileio.File, error) { + safePath, err := validate.SafeInputPath(name) + if err != nil { + return nil, err + } + return os.Open(safePath) +} + +func (t *testLocalFileIO) Stat(name string) (os.FileInfo, error) { + safePath, err := validate.SafeInputPath(name) + if err != nil { + return nil, err + } + return os.Stat(safePath) +} + +type testSaveResult struct{ size int64 } + +func (r *testSaveResult) Size() int64 { return r.size } + +func (t *testLocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) { + safePath, err := validate.SafeOutputPath(path) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil { + return nil, err + } + n, err := validate.AtomicWriteFromReader(safePath, body, 0644) + if err != nil { + return nil, err + } + return &testSaveResult{size: n}, nil +} diff --git a/internal/cmdutil/local_fileio.go b/internal/cmdutil/local_fileio.go index 5e8ad2e4..37dfae22 100644 --- a/internal/cmdutil/local_fileio.go +++ b/internal/cmdutil/local_fileio.go @@ -49,19 +49,25 @@ func (l *LocalFileIO) Stat(name string) (os.FileInfo, error) { return os.Stat(safePath) } +// localSaveResult implements fileio.SaveResult. +type localSaveResult struct{ size int64 } + +func (r *localSaveResult) Size() int64 { return r.size } + // Save writes body to path atomically after validating the output path. -// Parent directories are created as needed. -func (l *LocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) error { +// Parent directories are created as needed. The body is streamed directly +// to a temp file and renamed, avoiding full in-memory buffering. +func (l *LocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) { safePath, err := validate.SafeOutputPath(path) if err != nil { - return err - } - data, err := io.ReadAll(body) - if err != nil { - return err + return nil, err } if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil { - return err + return nil, err + } + n, err := validate.AtomicWriteFromReader(safePath, body, 0644) + if err != nil { + return nil, err } - return validate.AtomicWrite(safePath, data, 0644) + return &localSaveResult{size: n}, nil } diff --git a/internal/cmdutil/testing.go b/internal/cmdutil/testing.go index 7a70ed2b..2f6708a9 100644 --- a/internal/cmdutil/testing.go +++ b/internal/cmdutil/testing.go @@ -68,6 +68,7 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer, IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf}, Keychain: &noopKeychain{}, Credential: testCred, + FileIO: &LocalFileIO{}, } return f, stdoutBuf, stderrBuf, reg } diff --git a/shortcuts/doc/doc_media_download.go b/shortcuts/doc/doc_media_download.go index 581efc64..0b547f98 100644 --- a/shortcuts/doc/doc_media_download.go +++ b/shortcuts/doc/doc_media_download.go @@ -4,6 +4,7 @@ package doc import ( + "bytes" "context" "fmt" "net/http" @@ -12,6 +13,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" @@ -66,9 +68,9 @@ var DocMediaDownload = common.Shortcut{ if err := validate.ResourceName(token, "--token"); err != nil { return output.ErrValidation("%s", err) } - // Early path validation before API call (final validation after auto-extension below) - if _, err := validate.SafeOutputPath(outputPath); err != nil { - return output.ErrValidation("unsafe output path: %s", err) + // Early path validation via FileIO.Stat + if _, statErr := runtime.FileIO().Stat(outputPath); statErr != nil && !os.IsNotExist(statErr) { + return output.ErrValidation("unsafe output path: %s", statErr) } fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s %s\n", mediaType, common.MaskToken(token)) @@ -105,27 +107,25 @@ var DocMediaDownload = common.Shortcut{ } } - safePath, err := validate.SafeOutputPath(finalPath) - if err != nil { - return output.ErrValidation("unsafe output path: %s", err) - } - if err := common.EnsureWritableFile(safePath, overwrite); err != nil { - return err - } - - if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil { - return output.Errorf(output.ExitInternal, "io", "cannot create parent directory: %v", err) + // Overwrite check on final path (after extension detection) + if !overwrite { + if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil { + return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", finalPath) + } } - sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600) + result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{ + ContentType: apiResp.Header.Get("Content-Type"), + ContentLength: int64(len(apiResp.RawBody)), + }, bytes.NewReader(apiResp.RawBody)) if err != nil { return output.Errorf(output.ExitInternal, "io", "cannot create file: %v", err) } runtime.Out(map[string]interface{}{ - "saved_path": safePath, - "size_bytes": sizeBytes, - "content_type": resp.Header.Get("Content-Type"), + "saved_path": finalPath, + "size_bytes": result.Size(), + "content_type": apiResp.Header.Get("Content-Type"), }, nil) return nil }, diff --git a/shortcuts/drive/drive_download.go b/shortcuts/drive/drive_download.go index 86039cec..401e8196 100644 --- a/shortcuts/drive/drive_download.go +++ b/shortcuts/drive/drive_download.go @@ -4,13 +4,15 @@ package drive import ( + "bytes" "context" "fmt" "net/http" - "path/filepath" + "os" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" @@ -51,12 +53,12 @@ var DriveDownload = common.Shortcut{ if outputPath == "" { outputPath = fileToken } - safePath, err := validate.SafeOutputPath(outputPath) - if err != nil { - return output.ErrValidation("unsafe output path: %s", err) - } - if err := common.EnsureWritableFile(safePath, overwrite); err != nil { - return err + + // Early path validation + overwrite check via FileIO.Stat + if _, statErr := runtime.FileIO().Stat(outputPath); statErr != nil && !os.IsNotExist(statErr) { + return output.ErrValidation("unsafe output path: %s", statErr) + } else if statErr == nil && !overwrite { + return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath) } fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken)) @@ -70,18 +72,17 @@ var DriveDownload = common.Shortcut{ } defer resp.Body.Close() - if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil { - return output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err) - } - - sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600) + result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ + ContentType: apiResp.Header.Get("Content-Type"), + ContentLength: int64(len(apiResp.RawBody)), + }, bytes.NewReader(apiResp.RawBody)) if err != nil { return output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err) } runtime.Out(map[string]interface{}{ - "saved_path": safePath, - "size_bytes": sizeBytes, + "saved_path": outputPath, + "size_bytes": result.Size(), }, nil) return nil }, diff --git a/shortcuts/drive/drive_export.go b/shortcuts/drive/drive_export.go index edffcb04..271e0763 100644 --- a/shortcuts/drive/drive_export.go +++ b/shortcuts/drive/drive_export.go @@ -114,7 +114,7 @@ var DriveExport = common.Shortcut{ title = spec.Token } fileName := ensureExportFileExtension(sanitizeExportFileName(title, spec.Token), spec.FileExtension) - savedPath, err := saveContentToOutputDir(outputDir, fileName, []byte(common.GetString(data, "content")), overwrite) + savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite) if err != nil { return err } diff --git a/shortcuts/drive/drive_export_common.go b/shortcuts/drive/drive_export_common.go index a95daac6..218a2ada 100644 --- a/shortcuts/drive/drive_export_common.go +++ b/shortcuts/drive/drive_export_common.go @@ -4,6 +4,7 @@ package drive import ( + "bytes" "context" "fmt" "net/http" @@ -14,6 +15,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" @@ -252,8 +254,8 @@ func fetchDriveMetaTitle(runtime *common.RuntimeContext, token, docType string) } // saveContentToOutputDir validates the target path, enforces overwrite policy, -// and writes the payload atomically to disk. -func saveContentToOutputDir(outputDir, fileName string, payload []byte, overwrite bool) (string, error) { +// and writes the payload atomically via FileIO.Save. +func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, payload []byte, overwrite bool) (string, error) { if outputDir == "" { outputDir = "." } @@ -262,21 +264,18 @@ func saveContentToOutputDir(outputDir, fileName string, payload []byte, overwrit // names cannot escape the requested output directory. safeName := sanitizeExportFileName(fileName, "export.bin") target := filepath.Join(outputDir, safeName) - safePath, err := validate.SafeOutputPath(target) - if err != nil { - return "", output.ErrValidation("unsafe output path: %s", err) - } - if err := common.EnsureWritableFile(safePath, overwrite); err != nil { - return "", err - } - if err := vfs.MkdirAll(filepath.Dir(safePath), 0755); err != nil { - return "", output.Errorf(output.ExitInternal, "io", "cannot create output directory: %s", err) + // Overwrite check via FileIO.Stat + if !overwrite { + if _, statErr := fio.Stat(target); statErr == nil { + return "", output.ErrValidation("output file already exists: %s (use --overwrite to replace)", target) + } } - if err := validate.AtomicWrite(safePath, payload, 0644); err != nil { + + if _, err := fio.Save(target, fileio.SaveOptions{}, bytes.NewReader(payload)); err != nil { return "", output.Errorf(output.ExitInternal, "io", "cannot write file: %s", err) } - return safePath, nil + return target, nil } // downloadDriveExportFile downloads the exported artifact, derives a safe local @@ -303,7 +302,7 @@ func downloadDriveExportFile(ctx context.Context, runtime *common.RuntimeContext // request an explicit local file name. fileName = client.ResolveFilename(apiResp) } - savedPath, err := saveContentToOutputDir(outputDir, fileName, apiResp.RawBody, overwrite) + savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, apiResp.RawBody, overwrite) if err != nil { return nil, err } diff --git a/shortcuts/drive/drive_export_test.go b/shortcuts/drive/drive_export_test.go index 46ba0bfa..599a3e52 100644 --- a/shortcuts/drive/drive_export_test.go +++ b/shortcuts/drive/drive_export_test.go @@ -472,7 +472,7 @@ func TestSaveContentToOutputDirRejectsOverwriteWithoutFlag(t *testing.T) { } t.Cleanup(func() { _ = os.Chdir(cwd) }) - _, err = saveContentToOutputDir(".", "exists.txt", []byte("new"), false) + _, err = saveContentToOutputDir(&cmdutil.LocalFileIO{}, ".", "exists.txt", []byte("new"), false) if err == nil || !strings.Contains(err.Error(), "already exists") { t.Fatalf("expected overwrite error, got %v", err) } diff --git a/shortcuts/im/helpers_network_test.go b/shortcuts/im/helpers_network_test.go index 9e914fdd..8fb77a66 100644 --- a/shortcuts/im/helpers_network_test.go +++ b/shortcuts/im/helpers_network_test.go @@ -52,9 +52,10 @@ func shortcutRawResponse(status int, body []byte, headers http.Header) *http.Res headers = make(http.Header) } return &http.Response{ - StatusCode: status, - Header: headers, - Body: io.NopCloser(bytes.NewReader(body)), + StatusCode: status, + Header: headers, + Body: io.NopCloser(bytes.NewReader(body)), + ContentLength: int64(len(body)), } } @@ -92,6 +93,7 @@ func newBotShortcutRuntime(t *testing.T, rt http.RoundTripper) *common.RuntimeCo HttpClient: func() (*http.Client, error) { return httpClient, nil }, LarkClient: func() (*lark.Client, error) { return sdk, nil }, Credential: testCred, + FileIO: &cmdutil.LocalFileIO{}, IOStreams: &cmdutil.IOStreams{ Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}, @@ -241,7 +243,12 @@ func TestDownloadIMResourceToPathSuccess(t *testing.T) { } })) - target := filepath.Join(t.TempDir(), "nested", "resource.bin") + tmpDir := t.TempDir() + origWd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(origWd) + + target := filepath.Join("nested", "resource.bin") _, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "file_123", "file", target) if err != nil { t.Fatalf("downloadIMResourceToPath() error = %v", err) @@ -249,7 +256,7 @@ func TestDownloadIMResourceToPathSuccess(t *testing.T) { if size != int64(len(payload)) { t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload)) } - data, err := os.ReadFile(target) + data, err := os.ReadFile(filepath.Join(tmpDir, "nested", "resource.bin")) if err != nil { t.Fatalf("ReadFile() error = %v", err) } diff --git a/shortcuts/im/im_messages_resources_download.go b/shortcuts/im/im_messages_resources_download.go index 0f29392a..ff4c35ab 100644 --- a/shortcuts/im/im_messages_resources_download.go +++ b/shortcuts/im/im_messages_resources_download.go @@ -11,10 +11,8 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) @@ -54,8 +52,9 @@ var ImMessagesResourcesDownload = common.Shortcut{ if err != nil { return output.ErrValidation("%s", err) } - if _, err := validate.SafeOutputPath(relPath); err != nil { - return output.ErrValidation("unsafe output path: %s", err) + // Early path validation via FileIO.Stat + if _, statErr := runtime.FileIO().Stat(relPath); statErr != nil && !os.IsNotExist(statErr) { + return output.ErrValidation("unsafe output path: %s", statErr) } return nil }, @@ -67,12 +66,8 @@ var ImMessagesResourcesDownload = common.Shortcut{ if err != nil { return output.ErrValidation("invalid output path: %s", err) } - safePath, err := validate.SafeOutputPath(relPath) - if err != nil { - return output.ErrValidation("unsafe output path: %s", err) - } - finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, safePath) + finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, relPath) if err != nil { return err } @@ -156,8 +151,12 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex } defer downloadResp.Body.Close() - if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil { - return "", 0, output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err) + if downloadResp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(downloadResp.Body, 4096)) + if len(body) > 0 { + return "", 0, output.ErrNetwork("download failed: HTTP %d: %s", downloadResp.StatusCode, strings.TrimSpace(string(body))) + } + return "", 0, output.ErrNetwork("download failed: HTTP %d", downloadResp.StatusCode) } // Auto-detect extension from Content-Type if missing @@ -171,9 +170,12 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex } } - sizeBytes, err := validate.AtomicWriteFromReader(finalPath, downloadResp.Body, 0600) + result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{ + ContentType: downloadResp.Header.Get("Content-Type"), + ContentLength: downloadResp.ContentLength, + }, downloadResp.Body) if err != nil { return "", 0, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err) } - return finalPath, sizeBytes, nil + return finalPath, result.Size(), nil } diff --git a/shortcuts/sheets/sheet_export.go b/shortcuts/sheets/sheet_export.go index 2216be6e..713a5706 100644 --- a/shortcuts/sheets/sheet_export.go +++ b/shortcuts/sheets/sheet_export.go @@ -4,14 +4,15 @@ package sheets import ( + "bytes" "context" "fmt" "net/http" - "path/filepath" "time" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" @@ -129,22 +130,17 @@ var SheetExport = common.Shortcut{ } defer resp.Body.Close() - safePath, pathErr := validate.SafeOutputPath(outputPath) - if pathErr != nil { - return output.ErrValidation("unsafe output path: %s", pathErr) - } - if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil { - return output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err) - } - - sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600) + result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ + ContentType: apiResp.Header.Get("Content-Type"), + ContentLength: int64(len(apiResp.RawBody)), + }, bytes.NewReader(apiResp.RawBody)) if err != nil { return output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err) } runtime.Out(map[string]interface{}{ - "saved_path": safePath, - "size_bytes": sizeBytes, + "saved_path": outputPath, + "size_bytes": result.Size(), }, nil) return nil }, diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index ffd52710..6f273f53 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -11,6 +11,7 @@ package vc import ( + "bytes" "context" "encoding/json" "fmt" @@ -22,6 +23,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/output" @@ -252,25 +254,16 @@ func downloadTranscriptFile(runtime *common.RuntimeContext, minuteToken string, base = outDir } dirName := filepath.Join(base, sanitizeDirName(title, minuteToken)) + transcriptPath := filepath.Join(dirName, "transcript.txt") + + // Overwrite check via FileIO.Stat if !runtime.Bool("overwrite") { - transcriptPath := filepath.Join(dirName, "transcript.txt") - if _, statErr := vfs.Stat(transcriptPath); statErr == nil { + if _, statErr := runtime.FileIO().Stat(transcriptPath); statErr == nil { fmt.Fprintf(errOut, "%s transcript already exists: %s (use --overwrite to replace)\n", logPrefix, transcriptPath) return transcriptPath } } - transcriptPath := filepath.Join(dirName, "transcript.txt") - safePath, err := validate.SafeOutputPath(transcriptPath) - if err != nil { - fmt.Fprintf(errOut, "%s invalid transcript path: %v\n", logPrefix, err) - return "" - } - if err := vfs.MkdirAll(filepath.Dir(safePath), 0755); err != nil { - fmt.Fprintf(errOut, "%s failed to create directory: %v\n", logPrefix, err) - return "" - } - fmt.Fprintf(errOut, "%s downloading transcript: %s\n", logPrefix, transcriptPath) apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ HttpMethod: http.MethodGet, @@ -293,7 +286,9 @@ func downloadTranscriptFile(runtime *common.RuntimeContext, minuteToken string, fmt.Fprintf(errOut, "%s transcript is empty (not available for this minute)\n", logPrefix) return "" } - if err := validate.AtomicWrite(safePath, apiResp.RawBody, 0644); err != nil { + if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{ + ContentType: "text/plain", + }, bytes.NewReader(apiResp.RawBody)); err != nil { fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err) return "" } From cd306f689b6920cd673b633628ac28e99b413cf4 Mon Sep 17 00:00:00 2001 From: liushiyao Date: Thu, 2 Apr 2026 22:55:54 +0800 Subject: [PATCH 03/17] feat: migrate upload/read-file shortcuts to FileIO.Open/Stat (Phase 3) Replace direct os.Stat + os.Open + os.ReadFile with FileIO.Stat + FileIO.Open across all upload and file-read shortcuts: drive, doc, im, base, mail (draft/patch, emlbuilder, helpers, draft_edit). Add PlainFileIO in cmdutil/testing.go for tests that need absolute paths (e.g. t.TempDir()) without path validation. Production code continues to use LocalFileIO with SafeInputPath enforcement. Change-Id: I7afd62a39da253896ef089dfb3954f293039b967 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cmdutil/testing.go | 18 +++++ shortcuts/base/base_shortcut_helpers.go | 23 +++--- shortcuts/base/base_shortcuts_test.go | 14 ++-- shortcuts/base/dashboard_block_create.go | 4 +- shortcuts/base/dashboard_block_update.go | 4 +- shortcuts/base/dashboard_ops.go | 8 +-- shortcuts/base/field_ops.go | 10 +-- shortcuts/base/helpers.go | 15 ++-- shortcuts/base/helpers_test.go | 28 ++++---- shortcuts/base/record_ops.go | 4 +- shortcuts/base/record_upload_attachment.go | 8 ++- shortcuts/base/table_ops.go | 4 +- shortcuts/base/view_ops.go | 12 ++-- shortcuts/base/workflow_create.go | 12 ++-- shortcuts/base/workflow_update.go | 12 ++-- shortcuts/doc/doc_media_insert.go | 10 +-- shortcuts/doc/doc_media_upload.go | 14 ++-- shortcuts/drive/drive_import.go | 70 +++++++++++++++++++ shortcuts/drive/drive_upload.go | 14 ++-- shortcuts/im/coverage_additional_test.go | 39 +++++------ shortcuts/im/helpers.go | 28 +++----- shortcuts/im/helpers_network_test.go | 26 ++++--- shortcuts/im/helpers_test.go | 8 ++- shortcuts/mail/draft/acceptance_test.go | 18 +++-- shortcuts/mail/draft/patch.go | 57 ++++++++------- shortcuts/mail/draft/patch_attachment_test.go | 38 +++++----- shortcuts/mail/draft/patch_body_test.go | 28 ++++---- shortcuts/mail/draft/patch_header_test.go | 20 +++--- shortcuts/mail/draft/patch_recipient_test.go | 24 ++++--- shortcuts/mail/draft/patch_test.go | 52 +++++++------- shortcuts/mail/draft/serialize_golden_test.go | 4 +- shortcuts/mail/draft/serialize_test.go | 14 ++-- shortcuts/mail/emlbuilder/builder.go | 28 +++++--- shortcuts/mail/emlbuilder/builder_test.go | 14 ++-- shortcuts/mail/helpers.go | 15 ++-- shortcuts/mail/helpers_test.go | 8 +-- shortcuts/mail/mail_draft_create.go | 4 +- shortcuts/mail/mail_draft_edit.go | 16 +++-- shortcuts/mail/mail_forward.go | 6 +- shortcuts/mail/mail_reply.go | 4 +- shortcuts/mail/mail_reply_all.go | 4 +- shortcuts/mail/mail_send.go | 4 +- 42 files changed, 437 insertions(+), 306 deletions(-) diff --git a/internal/cmdutil/testing.go b/internal/cmdutil/testing.go index 2f6708a9..dc6ac2fb 100644 --- a/internal/cmdutil/testing.go +++ b/internal/cmdutil/testing.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "net/http" + "os" "testing" lark "github.com/larksuite/oapi-sdk-go/v3" @@ -84,6 +85,23 @@ func (a *testDefaultAcct) ResolveAccount(ctx context.Context) (*credential.Accou return credential.AccountFromCliConfig(a.config), nil } +// TestChdir changes the working directory to dir for the duration of the test. +// The original directory is restored via t.Cleanup. +// This enables tests to use LocalFileIO (which resolves relative paths under cwd) +// with temporary directories, keeping test artifacts out of the source tree. +// Not compatible with t.Parallel() — os.Chdir is process-wide. +func TestChdir(t *testing.T, dir string) { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%s): %v", dir, err) + } + t.Cleanup(func() { os.Chdir(orig) }) +} + type testDefaultToken struct{} func (t *testDefaultToken) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) { diff --git a/shortcuts/base/base_shortcut_helpers.go b/shortcuts/base/base_shortcut_helpers.go index 06b21e00..3cbbd998 100644 --- a/shortcuts/base/base_shortcut_helpers.go +++ b/shortcuts/base/base_shortcut_helpers.go @@ -6,10 +6,10 @@ package base import ( "encoding/json" "fmt" + "io" "strings" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/shortcuts/common" ) @@ -17,7 +17,7 @@ func baseTableID(runtime *common.RuntimeContext) string { return strings.TrimSpace(runtime.Str("table-id")) } -func loadJSONInput(raw string, flagName string) (string, error) { +func loadJSONInput(fio fileio.FileIO, raw string, flagName string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { return "", common.FlagErrorf("--%s cannot be empty", flagName) @@ -29,11 +29,12 @@ func loadJSONInput(raw string, flagName string) (string, error) { if path == "" { return "", common.FlagErrorf("--%s file path cannot be empty after @", flagName) } - safePath, err := validate.SafeInputPath(path) + f, err := fio.Open(path) if err != nil { return "", common.FlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, err) } - data, err := vfs.ReadFile(safePath) + defer f.Close() + data, err := io.ReadAll(f) if err != nil { return "", common.FlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err) } @@ -86,18 +87,18 @@ func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags return active[0], nil } -func parseObjectList(raw string, flagName string) ([]map[string]interface{}, error) { +func parseObjectList(fio fileio.FileIO, raw string, flagName string) ([]map[string]interface{}, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } var err error - raw, err = loadJSONInput(raw, flagName) + raw, err = loadJSONInput(fio, raw, flagName) if err != nil { return nil, err } if strings.HasPrefix(raw, "[") { - arr, err := parseJSONArray(raw, flagName) + arr, err := parseJSONArray(fio, raw, flagName) if err != nil { return nil, err } @@ -111,16 +112,16 @@ func parseObjectList(raw string, flagName string) ([]map[string]interface{}, err } return items, nil } - obj, err := parseJSONObject(raw, flagName) + obj, err := parseJSONObject(fio, raw, flagName) if err != nil { return nil, err } return []map[string]interface{}{obj}, nil } -func parseJSONValue(raw string, flagName string) (interface{}, error) { +func parseJSONValue(fio fileio.FileIO, raw string, flagName string) (interface{}, error) { var err error - raw, err = loadJSONInput(raw, flagName) + raw, err = loadJSONInput(fio, raw, flagName) if err != nil { return nil, err } diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index a6f1c61d..1cf1f60d 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -40,7 +40,11 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool for name, value := range intFlags { _ = cmd.Flags().Set(name, strconv.Itoa(value)) } - return &common.RuntimeContext{Cmd: cmd, Config: &core.CliConfig{UserOpenId: "ou_test"}} + return &common.RuntimeContext{ + Cmd: cmd, + Config: &core.CliConfig{UserOpenId: "ou_test"}, + Factory: &cmdutil.Factory{FileIO: &cmdutil.LocalFileIO{}}, + } } func TestBaseAction(t *testing.T) { @@ -70,22 +74,22 @@ func TestBaseAction(t *testing.T) { } func TestParseObjectList(t *testing.T) { - items, err := parseObjectList("", "view") + items, err := parseObjectList(nil, "", "view") if err != nil || items != nil { t.Fatalf("items=%v err=%v", items, err) } - items, err = parseObjectList(`{"name":"grid"}`, "view") + items, err = parseObjectList(nil, `{"name":"grid"}`, "view") if err != nil || len(items) != 1 || items[0]["name"] != "grid" { t.Fatalf("items=%v err=%v", items, err) } - items, err = parseObjectList(`[{"name":"grid"}]`, "view") + items, err = parseObjectList(nil, `[{"name":"grid"}]`, "view") if err != nil || len(items) != 1 || items[0]["name"] != "grid" { t.Fatalf("items=%v err=%v", items, err) } - _, err = parseObjectList(`[1]`, "view") + _, err = parseObjectList(nil, `[1]`, "view") if err == nil || !strings.Contains(err.Error(), "must be an object") { t.Fatalf("err=%v", err) } diff --git a/shortcuts/base/dashboard_block_create.go b/shortcuts/base/dashboard_block_create.go index 9b663d33..831f6f4f 100644 --- a/shortcuts/base/dashboard_block_create.go +++ b/shortcuts/base/dashboard_block_create.go @@ -36,7 +36,7 @@ var BaseDashboardBlockCreate = common.Shortcut{ if strings.TrimSpace(raw) == "" { return nil // 允许无 data_config 的创建(某些类型可先创建后配置) } - cfg, err := parseJSONObject(raw, "data-config") + cfg, err := parseJSONObject(runtime.FileIO(), raw, "data-config") if err != nil { return err } @@ -58,7 +58,7 @@ var BaseDashboardBlockCreate = common.Shortcut{ body["type"] = t } if raw := runtime.Str("data-config"); raw != "" { - if parsed, err := parseJSONObject(raw, "data-config"); err == nil { + if parsed, err := parseJSONObject(runtime.FileIO(), raw, "data-config"); err == nil { body["data_config"] = parsed } } diff --git a/shortcuts/base/dashboard_block_update.go b/shortcuts/base/dashboard_block_update.go index 3a6cc59e..e5114e3b 100644 --- a/shortcuts/base/dashboard_block_update.go +++ b/shortcuts/base/dashboard_block_update.go @@ -36,7 +36,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{ if strings.TrimSpace(raw) == "" { return nil } - cfg, err := parseJSONObject(raw, "data-config") + cfg, err := parseJSONObject(runtime.FileIO(), raw, "data-config") if err != nil { return err } @@ -54,7 +54,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{ body["name"] = name } if raw := runtime.Str("data-config"); raw != "" { - if parsed, err := parseJSONObject(raw, "data-config"); err == nil { + if parsed, err := parseJSONObject(runtime.FileIO(), raw, "data-config"); err == nil { body["data_config"] = parsed } } diff --git a/shortcuts/base/dashboard_ops.go b/shortcuts/base/dashboard_ops.go index c319fde5..21bb571d 100644 --- a/shortcuts/base/dashboard_ops.go +++ b/shortcuts/base/dashboard_ops.go @@ -103,7 +103,7 @@ func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContex body["type"] = blockType } if raw := runtime.Str("data-config"); raw != "" { - if parsed, err := parseJSONObject(raw, "data-config"); err == nil { + if parsed, err := parseJSONObject(runtime.FileIO(), raw, "data-config"); err == nil { body["data_config"] = parsed } } @@ -124,7 +124,7 @@ func dryRunDashboardBlockUpdate(_ context.Context, runtime *common.RuntimeContex body["name"] = name } if raw := runtime.Str("data-config"); raw != "" { - if parsed, err := parseJSONObject(raw, "data-config"); err == nil { + if parsed, err := parseJSONObject(runtime.FileIO(), raw, "data-config"); err == nil { body["data_config"] = parsed } } @@ -248,7 +248,7 @@ func executeDashboardBlockCreate(runtime *common.RuntimeContext) error { body["type"] = blockType } if raw := runtime.Str("data-config"); raw != "" { - parsed, err := parseJSONObject(raw, "data-config") + parsed, err := parseJSONObject(runtime.FileIO(), raw, "data-config") if err != nil { return err } @@ -274,7 +274,7 @@ func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error { body["name"] = name } if raw := runtime.Str("data-config"); raw != "" { - parsed, err := parseJSONObject(raw, "data-config") + parsed, err := parseJSONObject(runtime.FileIO(), raw, "data-config") if err != nil { return err } diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index 0976c90d..5b3ed20d 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -32,7 +32,7 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D } func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - body, _ := parseJSONObject(runtime.Str("json"), "json") + body, _ := parseJSONObject(runtime.FileIO(), runtime.Str("json"), "json") return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields"). Body(body). @@ -41,7 +41,7 @@ func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *commo } func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - body, _ := parseJSONObject(runtime.Str("json"), "json") + body, _ := parseJSONObject(runtime.FileIO(), runtime.Str("json"), "json") return common.NewDryRunAPI(). PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id"). Body(body). @@ -78,7 +78,7 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext) } func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) { - raw, _ := loadJSONInput(runtime.Str("json"), "json") + raw, _ := loadJSONInput(runtime.FileIO(), runtime.Str("json"), "json") if raw == "" { return nil, nil } @@ -148,7 +148,7 @@ func executeFieldGet(runtime *common.RuntimeContext) error { } func executeFieldCreate(runtime *common.RuntimeContext) error { - body, err := parseJSONObject(runtime.Str("json"), "json") + body, err := parseJSONObject(runtime.FileIO(), runtime.Str("json"), "json") if err != nil { return err } @@ -163,7 +163,7 @@ func executeFieldCreate(runtime *common.RuntimeContext) error { func executeFieldUpdate(runtime *common.RuntimeContext) error { baseToken := runtime.Str("base-token") tableIDValue := baseTableID(runtime) - body, err := parseJSONObject(runtime.Str("json"), "json") + body, err := parseJSONObject(runtime.FileIO(), runtime.Str("json"), "json") if err != nil { return err } diff --git a/shortcuts/base/helpers.go b/shortcuts/base/helpers.go index 9032ab5a..78224c69 100644 --- a/shortcuts/base/helpers.go +++ b/shortcuts/base/helpers.go @@ -16,6 +16,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/shortcuts/common" ) @@ -29,8 +30,8 @@ type fieldTypeSpec struct { Extra map[string]interface{} } -func parseJSONObject(raw string, flagName string) (map[string]interface{}, error) { - resolved, err := loadJSONInput(raw, flagName) +func parseJSONObject(fio fileio.FileIO, raw string, flagName string) (map[string]interface{}, error) { + resolved, err := loadJSONInput(fio, raw, flagName) if err != nil { return nil, err } @@ -41,8 +42,8 @@ func parseJSONObject(raw string, flagName string) (map[string]interface{}, error return result, nil } -func parseJSONArray(raw string, flagName string) ([]interface{}, error) { - resolved, err := loadJSONInput(raw, flagName) +func parseJSONArray(fio fileio.FileIO, raw string, flagName string) ([]interface{}, error) { + resolved, err := loadJSONInput(fio, raw, flagName) if err != nil { return nil, err } @@ -53,12 +54,12 @@ func parseJSONArray(raw string, flagName string) ([]interface{}, error) { return result, nil } -func parseStringListFlexible(raw string, flagName string) ([]string, error) { +func parseStringListFlexible(fio fileio.FileIO, raw string, flagName string) ([]string, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } - resolved, err := loadJSONInput(raw, flagName) + resolved, err := loadJSONInput(fio, raw, flagName) if err != nil { return nil, err } @@ -82,7 +83,7 @@ func parseStringListFlexible(raw string, flagName string) ([]string, error) { } func parseStringList(raw string) []string { - items, _ := parseStringListFlexible(raw, "fields") + items, _ := parseStringListFlexible(nil, raw, "fields") return items } diff --git a/shortcuts/base/helpers_test.go b/shortcuts/base/helpers_test.go index 61806c4a..076ede84 100644 --- a/shortcuts/base/helpers_test.go +++ b/shortcuts/base/helpers_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" "time" + + "github.com/larksuite/cli/internal/cmdutil" ) func TestParseHelpers(t *testing.T) { @@ -30,36 +32,37 @@ func TestParseHelpers(t *testing.T) { t.Fatalf("write temp file err=%v", err) } _ = tmp.Close() - obj, err := parseJSONObject(`{"name":"demo"}`, "json") + fio := &cmdutil.LocalFileIO{} + obj, err := parseJSONObject(nil, `{"name":"demo"}`, "json") if err != nil || obj["name"] != "demo" { t.Fatalf("obj=%v err=%v", obj, err) } - if _, err := parseJSONObject(`[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") { + if _, err := parseJSONObject(nil, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") { t.Fatalf("err=%v", err) } - obj, err = parseJSONObject("@"+tmp.Name(), "json") + obj, err = parseJSONObject(fio, "@"+tmp.Name(), "json") if err != nil || obj["name"] != "from-file" { t.Fatalf("file obj=%v err=%v", obj, err) } - arr, err := parseJSONArray(`[1,2]`, "items") + arr, err := parseJSONArray(nil, `[1,2]`, "items") if err != nil || len(arr) != 2 { t.Fatalf("arr=%v err=%v", arr, err) } - if _, err := parseJSONArray(`{"a":1}`, "items"); err == nil || !strings.Contains(err.Error(), "invalid JSON array") { + if _, err := parseJSONArray(nil, `{"a":1}`, "items"); err == nil || !strings.Contains(err.Error(), "invalid JSON array") { t.Fatalf("err=%v", err) } - list, err := parseStringListFlexible("a, b, ,c", "fields") + list, err := parseStringListFlexible(nil, "a, b, ,c", "fields") if err != nil || !reflect.DeepEqual(list, []string{"a", "b", "c"}) { t.Fatalf("list=%v err=%v", list, err) } - list, err = parseStringListFlexible(`["x","y"]`, "fields") + list, err = parseStringListFlexible(nil, `["x","y"]`, "fields") if err != nil || !reflect.DeepEqual(list, []string{"x", "y"}) { t.Fatalf("list=%v err=%v", list, err) } - if _, err := parseStringListFlexible(`[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") { + if _, err := parseStringListFlexible(nil, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") { t.Fatalf("err=%v", err) } - if _, err := parseJSONValue("{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") { + if _, err := parseJSONValue(nil, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") { t.Fatalf("err=%v", err) } if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) { @@ -262,10 +265,11 @@ func TestFilterAndSortHelpers(t *testing.T) { } func TestJSONInputHelpers(t *testing.T) { - if got, err := loadJSONInput(`{"name":"demo"}`, "json"); err != nil || got != `{"name":"demo"}` { + fio2 := &cmdutil.LocalFileIO{} + if got, err := loadJSONInput(nil, `{"name":"demo"}`, "json"); err != nil || got != `{"name":"demo"}` { t.Fatalf("got=%q err=%v", got, err) } - if _, err := loadJSONInput("@", "json"); err == nil || !strings.Contains(err.Error(), "file path cannot be empty") { + if _, err := loadJSONInput(nil, "@", "json"); err == nil || !strings.Contains(err.Error(), "file path cannot be empty") { t.Fatalf("err=%v", err) } tmp := t.TempDir() @@ -281,7 +285,7 @@ func TestJSONInputHelpers(t *testing.T) { if err := os.WriteFile(emptyPath, []byte(" \n"), 0o644); err != nil { t.Fatalf("write empty file err=%v", err) } - if _, err := loadJSONInput("@"+emptyPath, "json"); err == nil || !strings.Contains(err.Error(), "is empty") { + if _, err := loadJSONInput(fio2, "@"+emptyPath, "json"); err == nil || !strings.Contains(err.Error(), "is empty") { t.Fatalf("err=%v", err) } syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7}) diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go index 280b1c58..82809746 100644 --- a/shortcuts/base/record_ops.go +++ b/shortcuts/base/record_ops.go @@ -35,7 +35,7 @@ func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common. } func dryRunRecordUpsert(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - body, _ := parseJSONObject(runtime.Str("json"), "json") + body, _ := parseJSONObject(runtime.FileIO(), runtime.Str("json"), "json") if recordID := runtime.Str("record-id"); recordID != "" { return common.NewDryRunAPI(). PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id"). @@ -106,7 +106,7 @@ func executeRecordGet(runtime *common.RuntimeContext) error { } func executeRecordUpsert(runtime *common.RuntimeContext) error { - body, err := parseJSONObject(runtime.Str("json"), "json") + body, err := parseJSONObject(runtime.FileIO(), runtime.Str("json"), "json") if err != nil { return err } diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go index 31d0ffc5..6699c959 100644 --- a/shortcuts/base/record_upload_attachment.go +++ b/shortcuts/base/record_upload_attachment.go @@ -16,8 +16,7 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" +>>>>>>> 2b941a5 (feat: migrate upload/read-file shortcuts to FileIO.Open/Stat (Phase 3)) "github.com/larksuite/cli/shortcuts/common" ) @@ -91,6 +90,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont func executeRecordUploadAttachment(runtime *common.RuntimeContext) error { filePath := runtime.Str("file") +<<<<<<< HEAD safeFilePath, err := validate.SafeInputPath(filePath) if err != nil { return output.ErrValidation("unsafe file path: %s", err) @@ -98,6 +98,8 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error { filePath = safeFilePath fileInfo, err := vfs.Stat(filePath) +======= + fileInfo, err := runtime.FileIO().Stat(filePath) if err != nil { return output.ErrValidation("file not found: %s", filePath) } @@ -209,7 +211,7 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i } func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) { - f, err := vfs.Open(filePath) + f, err := runtime.FileIO().Open(filePath) if err != nil { return nil, output.ErrValidation("cannot open file: %v", err) } diff --git a/shortcuts/base/table_ops.go b/shortcuts/base/table_ops.go index 04482396..b613de8f 100644 --- a/shortcuts/base/table_ops.go +++ b/shortcuts/base/table_ops.go @@ -108,7 +108,7 @@ func executeTableCreate(runtime *common.RuntimeContext) error { result := map[string]interface{}{"table": created} tableIDValue := tableID(created) if tableIDValue != "" && runtime.Str("fields") != "" { - fieldItems, err := parseJSONArray(runtime.Str("fields"), "fields") + fieldItems, err := parseJSONArray(runtime.FileIO(), runtime.Str("fields"), "fields") if err != nil { return err } @@ -139,7 +139,7 @@ func executeTableCreate(runtime *common.RuntimeContext) error { result["fields"] = createdFields } if tableIDValue != "" && runtime.Str("view") != "" { - viewItems, err := parseObjectList(runtime.Str("view"), "view") + viewItems, err := parseObjectList(runtime.FileIO(), runtime.Str("view"), "view") if err != nil { return err } diff --git a/shortcuts/base/view_ops.go b/shortcuts/base/view_ops.go index 53211cf1..0e8ec398 100644 --- a/shortcuts/base/view_ops.go +++ b/shortcuts/base/view_ops.go @@ -36,7 +36,7 @@ func dryRunViewGet(_ context.Context, runtime *common.RuntimeContext) *common.Dr func dryRunViewCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { api := dryRunViewBase(runtime) - bodyList, err := parseObjectList(runtime.Str("json"), "json") + bodyList, err := parseObjectList(runtime.FileIO(), runtime.Str("json"), "json") if err != nil || len(bodyList) == 0 { return api.POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/views") } @@ -57,14 +57,14 @@ func dryRunViewGetProperty(runtime *common.RuntimeContext, segment string) *comm } func dryRunViewSetJSONObject(runtime *common.RuntimeContext, segment string) *common.DryRunAPI { - body, _ := parseJSONObject(runtime.Str("json"), "json") + body, _ := parseJSONObject(runtime.FileIO(), runtime.Str("json"), "json") return dryRunViewBase(runtime). PUT(fmt.Sprintf("/open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/%s", url.PathEscape(segment))). Body(body) } func dryRunViewSetWrapped(runtime *common.RuntimeContext, segment string, wrapper string) *common.DryRunAPI { - raw, err := parseJSONValue(runtime.Str("json"), "json") + raw, err := parseJSONValue(runtime.FileIO(), runtime.Str("json"), "json") if err != nil { raw = nil } @@ -170,7 +170,7 @@ func executeViewGet(runtime *common.RuntimeContext) error { func executeViewCreate(runtime *common.RuntimeContext) error { baseToken := runtime.Str("base-token") tableIDValue := baseTableID(runtime) - viewItems, err := parseObjectList(runtime.Str("json"), "json") + viewItems, err := parseObjectList(runtime.FileIO(), runtime.Str("json"), "json") if err != nil { return err } @@ -214,7 +214,7 @@ func executeViewSetJSONObject(runtime *common.RuntimeContext, segment string, ke baseToken := runtime.Str("base-token") tableIDValue := baseTableID(runtime) viewRef := runtime.Str("view-id") - body, err := parseJSONObject(runtime.Str("json"), "json") + body, err := parseJSONObject(runtime.FileIO(), runtime.Str("json"), "json") if err != nil { return err } @@ -230,7 +230,7 @@ func executeViewSetWrapped(runtime *common.RuntimeContext, segment string, wrapp baseToken := runtime.Str("base-token") tableIDValue := baseTableID(runtime) viewRef := runtime.Str("view-id") - raw, err := parseJSONValue(runtime.Str("json"), "json") + raw, err := parseJSONValue(runtime.FileIO(), runtime.Str("json"), "json") if err != nil { return err } diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go index 0bba5bbe..da953a5c 100644 --- a/shortcuts/base/workflow_create.go +++ b/shortcuts/base/workflow_create.go @@ -25,19 +25,19 @@ var BaseWorkflowCreate = common.Shortcut{ if strings.TrimSpace(runtime.Str("base-token")) == "" { return common.FlagErrorf("--base-token must not be blank") } - raw, err := loadJSONInput(runtime.Str("json"), "json") + raw, err := loadJSONInput(runtime.FileIO(), runtime.Str("json"), "json") if err != nil { return err } - if _, err := parseJSONObject(raw, "json"); err != nil { + if _, err := parseJSONObject(runtime.FileIO(), raw, "json"); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { var body map[string]interface{} - if raw, err := loadJSONInput(runtime.Str("json"), "json"); err == nil { - body, _ = parseJSONObject(raw, "json") + if raw, err := loadJSONInput(runtime.FileIO(), runtime.Str("json"), "json"); err == nil { + body, _ = parseJSONObject(runtime.FileIO(), raw, "json") } return common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/workflows"). @@ -45,11 +45,11 @@ var BaseWorkflowCreate = common.Shortcut{ Set("base_token", runtime.Str("base-token")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - raw, err := loadJSONInput(runtime.Str("json"), "json") + raw, err := loadJSONInput(runtime.FileIO(), runtime.Str("json"), "json") if err != nil { return err } - body, err := parseJSONObject(raw, "json") + body, err := parseJSONObject(runtime.FileIO(), raw, "json") if err != nil { return err } diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go index 0b316e4f..27fe96a5 100644 --- a/shortcuts/base/workflow_update.go +++ b/shortcuts/base/workflow_update.go @@ -29,19 +29,19 @@ var BaseWorkflowUpdate = common.Shortcut{ if strings.TrimSpace(runtime.Str("workflow-id")) == "" { return common.FlagErrorf("--workflow-id must not be blank") } - raw, err := loadJSONInput(runtime.Str("json"), "json") + raw, err := loadJSONInput(runtime.FileIO(), runtime.Str("json"), "json") if err != nil { return err } - if _, err := parseJSONObject(raw, "json"); err != nil { + if _, err := parseJSONObject(runtime.FileIO(), raw, "json"); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { var body map[string]interface{} - if raw, err := loadJSONInput(runtime.Str("json"), "json"); err == nil { - body, _ = parseJSONObject(raw, "json") + if raw, err := loadJSONInput(runtime.FileIO(), runtime.Str("json"), "json"); err == nil { + body, _ = parseJSONObject(runtime.FileIO(), raw, "json") } return common.NewDryRunAPI(). PUT("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id"). @@ -50,11 +50,11 @@ var BaseWorkflowUpdate = common.Shortcut{ Set("workflow_id", runtime.Str("workflow-id")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { - raw, err := loadJSONInput(runtime.Str("json"), "json") + raw, err := loadJSONInput(runtime.FileIO(), runtime.Str("json"), "json") if err != nil { return err } - body, err := parseJSONObject(raw, "json") + body, err := parseJSONObject(runtime.FileIO(), raw, "json") if err != nil { return err } diff --git a/shortcuts/doc/doc_media_insert.go b/shortcuts/doc/doc_media_insert.go index 8242080e..2ebfb2ac 100644 --- a/shortcuts/doc/doc_media_insert.go +++ b/shortcuts/doc/doc_media_insert.go @@ -108,19 +108,13 @@ var DocMediaInsert = common.Shortcut{ alignStr := runtime.Str("align") caption := runtime.Str("caption") - safeFilePath, pathErr := validate.SafeInputPath(filePath) - if pathErr != nil { - return output.ErrValidation("unsafe file path: %s", pathErr) - } - filePath = safeFilePath - documentID, err := resolveDocxDocumentID(runtime, docInput) if err != nil { return err } // Validate file - stat, err := vfs.Stat(filePath) + stat, err := runtime.FileIO().Stat(filePath) if err != nil { return output.ErrValidation("file not found: %s", filePath) } @@ -359,7 +353,7 @@ func extractCreatedBlockTargets(createData map[string]interface{}, mediaType str // uploadMediaFile uploads a file to Feishu drive as media. func uploadMediaFile(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, mediaType, parentNode, docId string) (string, error) { - f, err := vfs.Open(filePath) + f, err := runtime.FileIO().Open(filePath) if err != nil { return "", err } diff --git a/shortcuts/doc/doc_media_upload.go b/shortcuts/doc/doc_media_upload.go index 39db9300..158b8b18 100644 --- a/shortcuts/doc/doc_media_upload.go +++ b/shortcuts/doc/doc_media_upload.go @@ -15,8 +15,7 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" +>>>>>>> 2b941a5 (feat: migrate upload/read-file shortcuts to FileIO.Open/Stat (Phase 3)) "github.com/larksuite/cli/shortcuts/common" ) @@ -58,14 +57,11 @@ var MediaUpload = common.Shortcut{ parentNode := runtime.Str("parent-node") docId := runtime.Str("doc-id") - safeFilePath, pathErr := validate.SafeInputPath(filePath) - if pathErr != nil { - return output.ErrValidation("unsafe file path: %s", pathErr) - } - filePath = safeFilePath - // Validate file +<<<<<<< HEAD stat, err := vfs.Stat(filePath) +======= + stat, err := runtime.FileIO().Stat(filePath) if err != nil { return output.ErrValidation("file not found: %s", filePath) } @@ -76,7 +72,7 @@ var MediaUpload = common.Shortcut{ fileName := filepath.Base(filePath) fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%d bytes)\n", fileName, stat.Size()) - f, err := vfs.Open(filePath) + f, err := runtime.FileIO().Open(filePath) if err != nil { return output.ErrValidation("cannot open file: %v", err) } diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go index f2aed91e..cc777bba 100644 --- a/shortcuts/drive/drive_import.go +++ b/shortcuts/drive/drive_import.go @@ -229,3 +229,73 @@ func importDefaultFileName(filePath string) string { } return name } + +// uploadMediaForImport uploads the source file to the temporary import media +// endpoint and returns the file token consumed by import_tasks. +func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) { + importInfo, err := runtime.FileIO().Stat(filePath) + if err != nil { + return "", output.ErrValidation("cannot read file: %s", err) + } + fileSize := importInfo.Size() + if fileSize > maxDriveUploadFileSize { + return "", output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileSize)/1024/1024) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import: %s (%s)\n", fileName, common.FormatSize(fileSize)) + + f, err := runtime.FileIO().Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") + extraMap := map[string]string{ + "obj_type": docType, + "file_extension": ext, + } + extraBytes, _ := json.Marshal(extraMap) + + // Build SDK Formdata + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddField("parent_type", "ccm_import_open") + fd.AddField("size", fmt.Sprintf("%d", fileSize)) + fd.AddField("extra", string(extraBytes)) + fd.AddFile("file", f) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/medias/upload_all", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + // Preserve already-classified CLI errors from lower layers instead of + // wrapping them as a generic network failure. + return "", err + } + return "", output.ErrNetwork("upload media failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { + return "", output.Errorf(output.ExitAPI, "api_error", "upload media failed: invalid response JSON: %v", err) + } + + if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 { + // Surface the backend error body so callers can see import-specific + // validation failures such as unsupported formats or permission issues. + msg, _ := result["msg"].(string) + return "", output.ErrAPI(larkCode, fmt.Sprintf("upload media failed: [%d] %s", larkCode, msg), result["error"]) + } + + data, _ := result["data"].(map[string]interface{}) + fileToken, _ := data["file_token"].(string) + if fileToken == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "upload media failed: no file_token returned") + } + return fileToken, nil +} diff --git a/shortcuts/drive/drive_upload.go b/shortcuts/drive/drive_upload.go index 2846f604..665b740e 100644 --- a/shortcuts/drive/drive_upload.go +++ b/shortcuts/drive/drive_upload.go @@ -16,8 +16,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" +>>>>>>> 2b941a5 (feat: migrate upload/read-file shortcuts to FileIO.Open/Stat (Phase 3)) "github.com/larksuite/cli/shortcuts/common" ) @@ -58,18 +57,15 @@ var DriveUpload = common.Shortcut{ folderToken := runtime.Str("folder-token") name := runtime.Str("name") - safeFilePath, err := validate.SafeInputPath(filePath) - if err != nil { - return output.ErrValidation("unsafe file path: %s", err) - } - filePath = safeFilePath - fileName := name if fileName == "" { fileName = filepath.Base(filePath) } +<<<<<<< HEAD info, err := vfs.Stat(filePath) +======= + info, err := runtime.FileIO().Stat(filePath) if err != nil { return output.ErrValidation("cannot read file: %s", err) } @@ -98,7 +94,7 @@ var DriveUpload = common.Shortcut{ } func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, folderToken string, fileSize int64) (string, error) { - f, err := vfs.Open(filePath) + f, err := runtime.FileIO().Open(filePath) if err != nil { return "", err } diff --git a/shortcuts/im/coverage_additional_test.go b/shortcuts/im/coverage_additional_test.go index ac7f82dc..3500021a 100644 --- a/shortcuts/im/coverage_additional_test.go +++ b/shortcuts/im/coverage_additional_test.go @@ -16,6 +16,8 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" ) func TestSanitizeURLForDisplay(t *testing.T) { @@ -404,39 +406,36 @@ func TestBuildSearchChatBodyAdditionalBranches(t *testing.T) { func TestParseMediaDurationSuccess(t *testing.T) { t.Run("mp4", func(t *testing.T) { - f, err := os.CreateTemp("", "im-duration-*.mp4") - if err != nil { - t.Fatalf("CreateTemp() error = %v", err) + cmdutil.TestChdir(t, t.TempDir()) + fname := "im-duration-test.mp4" + if err := os.WriteFile(fname, wrapInMoov(buildMvhdBox(0, 1000, 5000)), 0644); err != nil { + t.Fatalf("WriteFile() error = %v", err) } - defer os.Remove(f.Name()) - defer f.Close() - - if _, err := f.Write(wrapInMoov(buildMvhdBox(0, 1000, 5000))); err != nil { - t.Fatalf("Write() error = %v", err) - } - if got := parseMediaDuration(f.Name(), "mp4"); got != "5000" { + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("unexpected") + })) + if got := parseMediaDuration(rt, fname, "mp4"); got != "5000" { t.Fatalf("parseMediaDuration(mp4) = %q, want %q", got, "5000") } }) t.Run("opus", func(t *testing.T) { - f, err := os.CreateTemp("", "im-duration-*.ogg") - if err != nil { - t.Fatalf("CreateTemp() error = %v", err) - } - defer os.Remove(f.Name()) - defer f.Close() - + cmdutil.TestChdir(t, t.TempDir()) page := make([]byte, 27) copy(page[0:4], "OggS") page[5] = 4 page[6] = 0x00 page[7] = 0x53 page[8] = 0x07 - if _, err := f.Write(page); err != nil { - t.Fatalf("Write() error = %v", err) + + fname := "im-duration-test.ogg" + if err := os.WriteFile(fname, page, 0644); err != nil { + t.Fatalf("WriteFile() error = %v", err) } - if got := parseMediaDuration(f.Name(), "opus"); got != "10000" { + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("unexpected") + })) + if got := parseMediaDuration(rt, fname, "opus"); got != "10000" { t.Fatalf("parseMediaDuration(opus) = %q, want %q", got, "10000") } }) diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index 57f354a1..933f5303 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -13,13 +13,13 @@ import ( "math" "net/http" "net/url" - "os" "path" "path/filepath" "regexp" "strconv" "strings" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" @@ -339,7 +339,7 @@ func resolveLocalMedia(ctx context.Context, runtime *common.RuntimeContext, s me ft := detectIMFileType(safePath) dur := "" if s.withDuration { - dur = parseMediaDuration(safePath, ft) + dur = parseMediaDuration(runtime, safePath, ft) } return uploadFileToIM(ctx, runtime, safePath, ft, dur) } @@ -556,11 +556,11 @@ func findMP4Box(data []byte, start, end int, boxType string) (int, int) { // for audio/video uploads. Only reads the minimal portion of the file needed // for parsing (tail for OGG, box headers + moov for MP4). // Returns "" if parsing fails or the file type is not audio/video. -func parseMediaDuration(filePath, fileType string) string { +func parseMediaDuration(runtime *common.RuntimeContext, filePath, fileType string) string { if fileType != "opus" && fileType != "mp4" { return "" } - f, err := vfs.Open(filePath) + f, err := runtime.FileIO().Open(filePath) if err != nil { return "" } @@ -698,7 +698,7 @@ func readMp4DurationBytes(data []byte) int64 { } // readOggDuration reads the tail of an OGG file (up to 64 KB) and parses duration. -func readOggDuration(f *os.File, fileSize int64) int64 { +func readOggDuration(f fileio.File, fileSize int64) int64 { const maxTail = 65536 readSize := fileSize if readSize > maxTail { @@ -713,7 +713,7 @@ func readOggDuration(f *os.File, fileSize int64) int64 { // readMp4Duration walks top-level MP4 boxes via file seeks to find moov, // then reads only the moov content to locate mvhd and extract the duration. -func readMp4Duration(f *os.File, fileSize int64) int64 { +func readMp4Duration(f fileio.File, fileSize int64) int64 { hdr := make([]byte, 16) var offset int64 for offset+8 <= fileSize { @@ -1005,14 +1005,11 @@ const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images const maxFileUploadSize = 100 * 1024 * 1024 // 100MB — Lark API limit for files func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, imageType string) (string, error) { - // filePath is already validated by the caller (resolveLocalMedia). - safePath := filePath - - if info, err := vfs.Stat(safePath); err == nil && info.Size() > maxImageUploadSize { + if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxImageUploadSize { return "", fmt.Errorf("image size %s exceeds limit (max 5MB)", common.FormatSize(info.Size())) } - f, err := vfs.Open(safePath) + f, err := runtime.FileIO().Open(filePath) if err != nil { return "", err } @@ -1045,14 +1042,11 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa } func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, fileType, duration string) (string, error) { - // filePath is already validated by the caller (resolveLocalMedia). - safePath := filePath - - if info, err := vfs.Stat(safePath); err == nil && info.Size() > maxFileUploadSize { + if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxFileUploadSize { return "", fmt.Errorf("file size %s exceeds limit (max 100MB)", common.FormatSize(info.Size())) } - f, err := vfs.Open(safePath) + f, err := runtime.FileIO().Open(filePath) if err != nil { return "", err } @@ -1060,7 +1054,7 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat fd := larkcore.NewFormdata() fd.AddField("file_type", fileType) - fd.AddField("file_name", filepath.Base(safePath)) + fd.AddField("file_name", filepath.Base(filePath)) if duration != "" { fd.AddField("duration", duration) } diff --git a/shortcuts/im/helpers_network_test.go b/shortcuts/im/helpers_network_test.go index 8fb77a66..8e62d9f5 100644 --- a/shortcuts/im/helpers_network_test.go +++ b/shortcuts/im/helpers_network_test.go @@ -243,10 +243,7 @@ func TestDownloadIMResourceToPathSuccess(t *testing.T) { } })) - tmpDir := t.TempDir() - origWd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(origWd) + cmdutil.TestChdir(t, t.TempDir()) target := filepath.Join("nested", "resource.bin") _, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "file_123", "file", target) @@ -256,7 +253,7 @@ func TestDownloadIMResourceToPathSuccess(t *testing.T) { if size != int64(len(payload)) { t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload)) } - data, err := os.ReadFile(filepath.Join(tmpDir, "nested", "resource.bin")) + data, err := os.ReadFile(target) if err != nil { t.Fatalf("ReadFile() error = %v", err) } @@ -287,7 +284,9 @@ func TestDownloadIMResourceToPathHTTPErrorBody(t *testing.T) { } })) - _, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_403", "file_403", "file", filepath.Join(t.TempDir(), "out.bin")) + cmdutil.TestChdir(t, t.TempDir()) + + _, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_403", "file_403", "file", "out.bin") if err == nil || !strings.Contains(err.Error(), "HTTP 403: denied") { t.Fatalf("downloadIMResourceToPath() error = %v", err) } @@ -411,7 +410,10 @@ func TestUploadImageToIMSizeLimit(t *testing.T) { } f.Close() - _, err = uploadImageToIM(context.Background(), nil, path, "message") + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("unexpected") + })) + _, err = uploadImageToIM(context.Background(), rt, "./"+path, "message") if err == nil || !strings.Contains(err.Error(), "exceeds limit") { t.Fatalf("uploadImageToIM() error = %v", err) } @@ -428,7 +430,10 @@ func TestUploadFileToIMSizeLimit(t *testing.T) { } f.Close() - _, err = uploadFileToIM(context.Background(), nil, path, "stream", "") + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("unexpected") + })) + _, err = uploadFileToIM(context.Background(), rt, "./"+path, "stream", "") if err == nil || !strings.Contains(err.Error(), "exceeds limit") { t.Fatalf("uploadFileToIM() error = %v", err) } @@ -437,6 +442,7 @@ func TestUploadFileToIMSizeLimit(t *testing.T) { func TestResolveMediaContentWrapsUploadError(t *testing.T) { runtime := &common.RuntimeContext{ Factory: &cmdutil.Factory{ + FileIO: &cmdutil.LocalFileIO{}, IOStreams: &cmdutil.IOStreams{ Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}, @@ -444,7 +450,9 @@ func TestResolveMediaContentWrapsUploadError(t *testing.T) { }, } - missing := filepath.Join(t.TempDir(), "missing.png") + cmdutil.TestChdir(t, t.TempDir()) + + missing := "missing.png" _, _, err := resolveMediaContent(context.Background(), runtime, "", missing, "", "", "", "") if err == nil || !strings.Contains(err.Error(), "image upload failed") { t.Fatalf("resolveMediaContent() error = %v", err) diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index f1813e9e..c64785e5 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/binary" "errors" + "fmt" "net/http" "reflect" "strings" @@ -263,10 +264,13 @@ func TestParseMp4Duration(t *testing.T) { } func TestParseMediaDuration(t *testing.T) { - if got := parseMediaDuration("test.pdf", "pdf"); got != "" { + rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("unexpected") + })) + if got := parseMediaDuration(rt, "test.pdf", "pdf"); got != "" { t.Fatalf("parseMediaDuration(pdf) = %q, want empty", got) } - if got := parseMediaDuration("nonexistent.opus", "opus"); got != "" { + if got := parseMediaDuration(rt, "nonexistent.opus", "opus"); got != "" { t.Fatalf("parseMediaDuration(missing) = %q, want empty", got) } } diff --git a/shortcuts/mail/draft/acceptance_test.go b/shortcuts/mail/draft/acceptance_test.go index f86c0911..6c62fcbf 100644 --- a/shortcuts/mail/draft/acceptance_test.go +++ b/shortcuts/mail/draft/acceptance_test.go @@ -3,7 +3,11 @@ package draft -import "testing" +import ( + "testing" + + "github.com/larksuite/cli/internal/cmdutil" +) func TestAcceptanceReplyDraftSubjectOnly(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/reply_draft_with_inline_attachment.eml")) @@ -11,7 +15,7 @@ func TestAcceptanceReplyDraftSubjectOnly(t *testing.T) { originalInline := findPart(snapshot.Body, "1.2") originalAttachment := findPart(snapshot.Body, "1.3") - if err := Apply(snapshot, Patch{ + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_subject", Value: "Reply updated"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -46,7 +50,7 @@ func TestAcceptanceReplyDraftSubjectOnly(t *testing.T) { func TestAcceptanceHTMLInlineReplaceHTML(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/html_inline_draft.eml")) - if err := Apply(snapshot, Patch{ + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/html", Selector: "primary", Value: `
updated
`}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -70,7 +74,7 @@ func TestAcceptanceHTMLInlineReplaceHTML(t *testing.T) { func TestAcceptanceAlternativeSetBodyUpdatesHTMLAndSummary(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/alternative_draft.eml")) - if err := Apply(snapshot, Patch{ + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "
updated body
"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -97,7 +101,7 @@ func TestAcceptanceCalendarDraftAppendPlainPreservesCalendar(t *testing.T) { if originalCalendar == nil { t.Fatalf("calendar part missing") } - if err := Apply(snapshot, Patch{ + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: "\nagenda"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -122,7 +126,7 @@ func TestAcceptanceCalendarDraftAppendPlainPreservesCalendar(t *testing.T) { func TestAcceptanceSignedDraftSubjectOnlyPreservesSignedEntity(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/multipart_signed_draft.eml")) originalBodyEntity := string(snapshot.Body.RawEntity) - if err := Apply(snapshot, Patch{ + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_subject", Value: "Signed updated"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -144,7 +148,7 @@ func TestAcceptanceDirtyMultipartAppendPlainPreservesOuterNoise(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/dirty_multipart_preamble.eml")) originalPreamble := string(snapshot.Body.Preamble) originalEpilogue := string(snapshot.Body.Epilogue) - if err := Apply(snapshot, Patch{ + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: "\nworld"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) diff --git a/shortcuts/mail/draft/patch.go b/shortcuts/mail/draft/patch.go index 1a245a94..32b789b3 100644 --- a/shortcuts/mail/draft/patch.go +++ b/shortcuts/mail/draft/patch.go @@ -5,10 +5,12 @@ package draft import ( "fmt" + "io" "mime" "path/filepath" "strings" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/shortcuts/mail/filecheck" @@ -24,12 +26,12 @@ var protectedHeaders = map[string]bool{ "reply-to": true, } -func Apply(snapshot *DraftSnapshot, patch Patch) error { +func Apply(fio fileio.FileIO, snapshot *DraftSnapshot, patch Patch) error { if err := patch.Validate(); err != nil { return err } for _, op := range patch.Ops { - if err := applyOp(snapshot, op, patch.Options); err != nil { + if err := applyOp(fio, snapshot, op, patch.Options); err != nil { return err } } @@ -42,7 +44,7 @@ func Apply(snapshot *DraftSnapshot, patch Patch) error { return validateOrphanedInlineCIDAfterApply(snapshot) } -func applyOp(snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error { +func applyOp(fio fileio.FileIO, snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error { switch op.Op { case "set_subject": if strings.ContainsAny(op.Value, "\r\n") { @@ -84,7 +86,7 @@ func applyOp(snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error { } removeHeader(&snapshot.Headers, op.Name) case "add_attachment": - return addAttachment(snapshot, op.Path) + return addAttachment(fio, snapshot, op.Path) case "remove_attachment": partID, err := resolveTarget(snapshot, op.Target) if err != nil { @@ -92,13 +94,13 @@ func applyOp(snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error { } return removeAttachment(snapshot, partID) case "add_inline": - return addInline(snapshot, op.Path, op.CID, op.FileName, op.ContentType) + return addInline(fio, snapshot, op.Path, op.CID, op.FileName, op.ContentType) case "replace_inline": partID, err := resolveTarget(snapshot, op.Target) if err != nil { return fmt.Errorf("replace_inline: %w", err) } - return replaceInline(snapshot, partID, op.Path, op.CID, op.FileName, op.ContentType) + return replaceInline(fio, snapshot, partID, op.Path, op.CID, op.FileName, op.ContentType) case "remove_inline": partID, err := resolveTarget(snapshot, op.Target) if err != nil { @@ -462,22 +464,23 @@ func newMultipartContainer(mediaType string) *Part { } } -func addAttachment(snapshot *DraftSnapshot, path string) error { - safePath, err := validate.SafeInputPath(path) - if err != nil { - return fmt.Errorf("attachment %q: %w", path, err) - } +func addAttachment(fio fileio.FileIO, snapshot *DraftSnapshot, path string) error { if err := checkBlockedExtension(filepath.Base(path)); err != nil { return err } - info, err := vfs.Stat(safePath) + info, err := fio.Stat(path) if err != nil { - return err + return fmt.Errorf("attachment %q: %w", path, err) } if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), nil); err != nil { return err } - content, err := vfs.ReadFile(safePath) + f, err := fio.Open(path) + if err != nil { + return err + } + defer f.Close() + content, err := io.ReadAll(f) if err != nil { return err } @@ -523,19 +526,20 @@ func addAttachment(snapshot *DraftSnapshot, path string) error { return nil } -func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string) error { - safePath, err := validate.SafeInputPath(path) +func addInline(fio fileio.FileIO, snapshot *DraftSnapshot, path, cid, fileName, contentType string) error { + info, err := fio.Stat(path) if err != nil { return fmt.Errorf("inline image %q: %w", path, err) } - info, err := vfs.Stat(safePath) - if err != nil { + if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), nil); err != nil { return err } - if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), nil); err != nil { + f, err := fio.Open(path) + if err != nil { return err } - content, err := vfs.ReadFile(safePath) + defer f.Close() + content, err := io.ReadAll(f) if err != nil { return err } @@ -564,7 +568,7 @@ func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string) return nil } -func replaceInline(snapshot *DraftSnapshot, partID, path, cid, fileName, contentType string) error { +func replaceInline(fio fileio.FileIO, snapshot *DraftSnapshot, partID, path, cid, fileName, contentType string) error { part := findPart(snapshot.Body, partID) if part == nil { return fmt.Errorf("inline part %q not found", partID) @@ -572,18 +576,19 @@ func replaceInline(snapshot *DraftSnapshot, partID, path, cid, fileName, content if !isInlinePart(part) { return fmt.Errorf("part %q is not an inline MIME part", partID) } - safePath, err := validate.SafeInputPath(path) + info, err := fio.Stat(path) if err != nil { return fmt.Errorf("inline image %q: %w", path, err) } - info, err := vfs.Stat(safePath) - if err != nil { + if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), part); err != nil { return err } - if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), part); err != nil { + f, err := fio.Open(path) + if err != nil { return err } - content, err := vfs.ReadFile(safePath) + defer f.Close() + content, err := io.ReadAll(f) if err != nil { return err } diff --git a/shortcuts/mail/draft/patch_attachment_test.go b/shortcuts/mail/draft/patch_attachment_test.go index c7470199..f49c21c6 100644 --- a/shortcuts/mail/draft/patch_attachment_test.go +++ b/shortcuts/mail/draft/patch_attachment_test.go @@ -7,6 +7,8 @@ import ( "os" "strings" "testing" + + "github.com/larksuite/cli/internal/cmdutil" ) // --------------------------------------------------------------------------- @@ -27,7 +29,7 @@ func TestAddAttachmentToNilBodyCreatesRoot(t *testing.T) { } // Apply manually with a minimal patch (bypass Patch validation since we // have no body part to detect) - err := addAttachment(snapshot, "file.txt") + err := addAttachment(&cmdutil.LocalFileIO{}, snapshot, "file.txt") if err != nil { t.Fatalf("addAttachment() error = %v", err) } @@ -51,7 +53,7 @@ func TestAddAttachmentToExistingMultipartMixed(t *testing.T) { } snapshot := mustParseFixtureDraft(t, fixtureData) originalChildren := len(snapshot.Body.Children) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_attachment", Path: "second.txt"}}, }) if err != nil { @@ -84,7 +86,7 @@ func TestAddAttachmentBlockedExtensionViaApply(t *testing.T) { snapshot := mustParseFixtureDraft(t, fixtureData) for _, name := range blocked { t.Run(name, func(t *testing.T) { - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_attachment", Path: name}}, }) if err == nil { @@ -111,7 +113,7 @@ func TestAddAttachmentAllowedExtensionViaApply(t *testing.T) { for _, name := range allowed { t.Run(name, func(t *testing.T) { snapshot := mustParseFixtureDraft(t, fixtureData) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_attachment", Path: name}}, }) if err != nil { @@ -142,7 +144,7 @@ Content-Type: text/html; charset=UTF-8 `) for _, name := range []string{"icon.svg", "evil.png"} { t.Run(name, func(t *testing.T) { - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_inline", Path: name, CID: "img1"}}, }) if err == nil { @@ -167,7 +169,7 @@ Content-Type: text/html; charset=UTF-8
hello
`) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_inline", Path: name, CID: "img1"}}, }) if err != nil { @@ -194,7 +196,7 @@ Content-Type: text/html; charset=UTF-8
hello
`) // User passes a spoofed content_type; it should be ignored in favor of detected type. - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_inline", Path: "logo.png", CID: "img1", ContentType: "application/octet-stream"}}, }) if err != nil { @@ -234,7 +236,7 @@ PHN2Zz48L3N2Zz4= // The old part has image/svg+xml. Replace with a PNG file; the filename // falls back to the path ("new.png") since the old part's name is "icon.svg" // which would fail the extension whitelist, so we pass an explicit filename. - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, @@ -257,7 +259,7 @@ PHN2Zz48L3N2Zz4= func TestRemoveAttachmentRejectsInlinePart(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/html_inline_draft.eml")) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_attachment", Target: AttachmentTarget{PartID: "1.2"}}}, }) if err == nil || !strings.Contains(err.Error(), "use remove_inline") { @@ -280,7 +282,7 @@ Content-Transfer-Encoding: base64 YQ== `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_attachment", Target: AttachmentTarget{PartID: "1"}}}, }) if err == nil || !strings.Contains(err.Error(), "cannot remove root") { @@ -301,7 +303,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_attachment", Target: AttachmentTarget{PartID: "99"}}}, }) if err == nil || !strings.Contains(err.Error(), "not found") { @@ -316,7 +318,7 @@ hello func TestRemoveInlineRejectsNonInlinePart(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/forward_draft.eml")) // 1.2 is an attachment in forward_draft, not an inline - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_inline", Target: AttachmentTarget{PartID: "1.2"}}}, }) if err == nil || !strings.Contains(err.Error(), "not an inline") { @@ -340,7 +342,7 @@ Content-Transfer-Encoding: base64 cG5n `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_inline", Target: AttachmentTarget{PartID: "1"}}}, }) if err == nil || !strings.Contains(err.Error(), "cannot remove root") { @@ -361,7 +363,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_inline", Target: AttachmentTarget{PartID: "99"}}}, }) if err == nil || !strings.Contains(err.Error(), "not found") { @@ -376,7 +378,7 @@ hello func TestResolveTargetByCID(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/html_inline_draft.eml")) // Remove via CID target - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "replace_inline", Target: AttachmentTarget{CID: "logo"}, @@ -397,7 +399,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_inline", Target: AttachmentTarget{CID: "nonexistent"}}}, }) if err == nil || !strings.Contains(err.Error(), "no part with cid") { @@ -433,7 +435,7 @@ func TestReplaceInlineRejectsNonInlinePart(t *testing.T) { t.Fatal(err) } snapshot := mustParseFixtureDraft(t, fixtureData) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, @@ -462,7 +464,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "replace_inline", Target: AttachmentTarget{PartID: "99"}, diff --git a/shortcuts/mail/draft/patch_body_test.go b/shortcuts/mail/draft/patch_body_test.go index ff5718f7..37193818 100644 --- a/shortcuts/mail/draft/patch_body_test.go +++ b/shortcuts/mail/draft/patch_body_test.go @@ -6,6 +6,8 @@ package draft import ( "strings" "testing" + + "github.com/larksuite/cli/internal/cmdutil" ) // --------------------------------------------------------------------------- @@ -21,7 +23,7 @@ Content-Type: text/html; charset=UTF-8

hello

`) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "
updated
"}}, }) if err != nil { @@ -43,7 +45,7 @@ Content-Type: text/html; charset=UTF-8 func TestApplySetBodyNoPrimaryBodyFails(t *testing.T) { // A multipart/signed draft has no editable primary body snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/multipart_signed_draft.eml")) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "anything"}}, }) if err == nil || !strings.Contains(err.Error(), "no unique primary body") { @@ -65,7 +67,7 @@ Content-Type: text/html; charset=UTF-8
old reply
`+quoteHTML+` `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_reply_body", Value: "
new reply
"}}, }) if err != nil { @@ -101,7 +103,7 @@ Content-Type: text/html; charset=UTF-8
old note
`+quoteHTML+` `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_reply_body", Value: "
updated note
"}}, }) if err != nil { @@ -130,7 +132,7 @@ Content-Type: text/html; charset=UTF-8

original body

`) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_reply_body", Value: "
replaced
"}}, }) if err != nil { @@ -164,7 +166,7 @@ Content-Type: text/html; charset=UTF-8
old reply
`+quoteHTML+` --alt-- `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_reply_body", Value: "
new reply
"}}, }) if err != nil { @@ -201,7 +203,7 @@ Content-Type: text/plain; charset=UTF-8 original text `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_reply_body", Value: "replaced text"}}, }) if err != nil { @@ -226,7 +228,7 @@ Content-Type: text/plain; charset=UTF-8 original content `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/plain", Value: "replaced content"}}, }) if err != nil { @@ -247,7 +249,7 @@ Content-Type: text/plain; charset=UTF-8 original `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Value: " appended"}}, }) if err != nil { @@ -272,7 +274,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/csv", Value: "data"}}, }) if err == nil || !strings.Contains(err.Error(), "body_kind must be text/plain or text/html") { @@ -293,7 +295,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/html", Value: "

new

"}}, }) if err == nil || !strings.Contains(err.Error(), "no primary text/html body part") { @@ -322,7 +324,7 @@ Content-Type: text/html; charset=UTF-8

real body

--alt-- `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "just plain text without any tags"}}, }) if err == nil || !strings.Contains(err.Error(), "requires HTML input") { @@ -343,7 +345,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{ {Op: "set_subject", Value: "Updated Subject"}, {Op: "add_recipient", Field: "cc", Name: "Carol", Address: "carol@example.com"}, diff --git a/shortcuts/mail/draft/patch_header_test.go b/shortcuts/mail/draft/patch_header_test.go index 25ccf013..02219b8e 100644 --- a/shortcuts/mail/draft/patch_header_test.go +++ b/shortcuts/mail/draft/patch_header_test.go @@ -6,6 +6,8 @@ package draft import ( "strings" "testing" + + "github.com/larksuite/cli/internal/cmdutil" ) // --------------------------------------------------------------------------- @@ -21,7 +23,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "set_reply_to", Addresses: []Address{{Name: "Support", Address: "support@example.com"}}, @@ -48,7 +50,7 @@ hello if len(snapshot.ReplyTo) == 0 { t.Fatalf("ReplyTo should be set before clear") } - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "clear_reply_to"}}, }) if err != nil { @@ -76,7 +78,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_header", Name: "X-Priority"}}, }) if err != nil { @@ -96,7 +98,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_header", Name: "Content-Type"}}, }) if err == nil || !strings.Contains(err.Error(), "protected") { @@ -114,7 +116,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_header", Name: "Reply-To"}}, Options: PatchOptions{AllowProtectedHeaderEdits: true}, }) @@ -139,7 +141,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_header", Name: "Bad:Name", Value: "value"}}, }) if err == nil || !strings.Contains(err.Error(), "must not contain") { @@ -156,7 +158,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_header", Name: "X-Custom", Value: "val\r\ninjected"}}, }) if err == nil || !strings.Contains(err.Error(), "must not contain") { @@ -177,7 +179,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_subject", Value: "Subject\ninjection"}}, }) if err == nil || !strings.Contains(err.Error(), "must not contain") { @@ -198,7 +200,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "unknown_op"}}, }) if err == nil || !strings.Contains(err.Error(), "unsupported") { diff --git a/shortcuts/mail/draft/patch_recipient_test.go b/shortcuts/mail/draft/patch_recipient_test.go index 9aadfbb7..378142d8 100644 --- a/shortcuts/mail/draft/patch_recipient_test.go +++ b/shortcuts/mail/draft/patch_recipient_test.go @@ -6,6 +6,8 @@ package draft import ( "strings" "testing" + + "github.com/larksuite/cli/internal/cmdutil" ) // --------------------------------------------------------------------------- @@ -21,7 +23,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "add_recipient", Field: "to", @@ -49,7 +51,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "add_recipient", Field: "to", @@ -74,7 +76,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "add_recipient", Field: "cc", @@ -99,7 +101,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "add_recipient", Field: "bcc", @@ -124,7 +126,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "add_recipient", Field: "to", @@ -150,7 +152,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "remove_recipient", Field: "to", @@ -177,7 +179,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "remove_recipient", Field: "to", @@ -201,7 +203,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "remove_recipient", Field: "to", @@ -222,7 +224,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "remove_recipient", Field: "cc", @@ -244,7 +246,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "remove_recipient", Field: "cc", @@ -276,7 +278,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "set_recipients", Field: "cc", diff --git a/shortcuts/mail/draft/patch_test.go b/shortcuts/mail/draft/patch_test.go index a69417bd..e2df4b0a 100644 --- a/shortcuts/mail/draft/patch_test.go +++ b/shortcuts/mail/draft/patch_test.go @@ -7,6 +7,8 @@ import ( "os" "strings" "testing" + + "github.com/larksuite/cli/internal/cmdutil" ) func chdirTemp(t *testing.T) { @@ -37,7 +39,7 @@ Content-Transfer-Encoding: 7bit hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_subject", Value: "Updated"}}, }) if err != nil { @@ -67,7 +69,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_header", Name: "Message-ID", Value: ""}}, }) if err == nil { @@ -84,7 +86,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "set_recipients", Field: "to", @@ -115,7 +117,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "updated"}}, }) if err != nil { @@ -143,7 +145,7 @@ Content-Type: text/html; charset=UTF-8

hello

--alt-- `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "
updated body
"}}, }) if err != nil { @@ -174,7 +176,7 @@ Content-Type: text/html; charset=UTF-8

hello

--alt-- `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "updated plain text"}}, }) if err == nil || !strings.Contains(err.Error(), "draft main body is text/html") { @@ -199,7 +201,7 @@ Content-Type: text/html; charset=UTF-8
hello world
--alt-- `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "
updated body
"}}, }) if err != nil { @@ -224,7 +226,7 @@ Content-Transfer-Encoding: 7bit hello `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/html", Selector: "primary", Value: "

hello

"}}, Options: PatchOptions{ RewriteEntireDraft: true, @@ -264,7 +266,7 @@ Content-Type: text/html; charset=UTF-8

hello

--alt-- `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/html", Selector: "primary", Value: "
updated
"}}, }) if err == nil || !strings.Contains(err.Error(), "edit them together with set_body") { @@ -289,7 +291,7 @@ Content-Type: text/html; charset=UTF-8

hello

--alt-- `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: "\nappend"}}, }) if err == nil || !strings.Contains(err.Error(), "edit them together with set_body") { @@ -319,7 +321,7 @@ aGVsbG8= --rel-- `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/plain", Selector: "primary", Value: "hello plain"}}, Options: PatchOptions{ RewriteEntireDraft: true, @@ -345,7 +347,7 @@ aGVsbG8= func TestRemoveAttachmentKeepsRemainingOrder(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/forward_draft.eml")) - if err := Apply(snapshot, Patch{ + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_attachment", Target: AttachmentTarget{PartID: "1.3"}}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -380,7 +382,7 @@ Content-Transfer-Encoding: base64 cG5n --rel-- `) - if err := Apply(snapshot, Patch{ + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_inline", Target: AttachmentTarget{CID: "logo-cid"}}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -404,7 +406,7 @@ Content-Transfer-Encoding: 7bit
hello
`) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{ {Op: "add_inline", Path: "logo.png", CID: "logo"}, }, @@ -431,7 +433,7 @@ func TestReplaceInlineKeepsCIDByDefault(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } snapshot := mustParseFixtureDraft(t, fixtureData) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{ {Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, Path: "updated.png"}, }, @@ -450,7 +452,7 @@ func TestReplaceInlineKeepsCIDByDefault(t *testing.T) { func TestRemoveInlineFailsWhenHTMLStillReferencesCID(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/html_inline_draft.eml")) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{ {Op: "remove_inline", Target: AttachmentTarget{PartID: "1.2"}}, }, @@ -481,7 +483,7 @@ cG5n --rel-- `) // set_body that drops the existing cid:logo reference → logo becomes orphaned - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "
replaced body without cid reference
"}}, }) if err == nil || !strings.Contains(err.Error(), "orphaned cids") { @@ -510,7 +512,7 @@ cG5n --rel-- `) // set_body that preserves the existing cid:logo reference → should succeed - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: `
updated body
`}}, }) if err != nil { @@ -520,7 +522,7 @@ cG5n func TestApplySetBodyRejectsSignedDraft(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/multipart_signed_draft.eml")) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "updated"}}, }) if err == nil { @@ -535,7 +537,7 @@ func TestApplyAppendTextKeepsCalendarPart(t *testing.T) { t.Fatalf("calendar part missing before patch") } originalCalendar := string(calendar.RawEntity) - if err := Apply(snapshot, Patch{ + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: "\nupdated"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -559,7 +561,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - if err := Apply(snapshot, Patch{ + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_attachment", Path: "note.txt"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -664,7 +666,7 @@ Content-Type: text/html; charset=UTF-8
hello
`) for _, bad := range []string{"logo\ninjected", "logo\rinjected", "lo\r\ngo"} { - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_inline", Path: "logo.png", CID: bad}}, }) if err == nil { @@ -687,7 +689,7 @@ Content-Type: text/html; charset=UTF-8
hello
`) for _, bad := range []string{"logo\ninjected.png", "logo\r.png", "lo\r\ngo.png"} { - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_inline", Path: "logo.png", CID: "safecid", FileName: bad}}, }) if err == nil { @@ -723,7 +725,7 @@ func TestReplaceInlineRejectsCRLFInCID(t *testing.T) { } snapshot := mustParseFixtureDraft(t, fixtureData) for _, bad := range []string{"logo\ninjected", "logo\rinjected"} { - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, Path: "updated.png", CID: bad}}, }) if err == nil { @@ -740,7 +742,7 @@ func TestReplaceInlineRejectsCRLFInFileName(t *testing.T) { } snapshot := mustParseFixtureDraft(t, fixtureData) for _, bad := range []string{"logo\ninjected.png", "logo\r.png"} { - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, Path: "updated.png", FileName: bad}}, }) if err == nil { diff --git a/shortcuts/mail/draft/serialize_golden_test.go b/shortcuts/mail/draft/serialize_golden_test.go index 83cf8be2..da21b430 100644 --- a/shortcuts/mail/draft/serialize_golden_test.go +++ b/shortcuts/mail/draft/serialize_golden_test.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/larksuite/cli/internal/cmdutil" ) func TestSerializeGoldenFixtures(t *testing.T) { @@ -81,7 +83,7 @@ func TestSerializeGoldenFixtures(t *testing.T) { if tc.patchFn != nil { patch = tc.patchFn(t) } - if err := Apply(snapshot, patch); err != nil { + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, patch); err != nil { t.Fatalf("Apply() error = %v", err) } raw, err := Serialize(snapshot) diff --git a/shortcuts/mail/draft/serialize_test.go b/shortcuts/mail/draft/serialize_test.go index 834b84cd..b14a6797 100644 --- a/shortcuts/mail/draft/serialize_test.go +++ b/shortcuts/mail/draft/serialize_test.go @@ -6,6 +6,8 @@ package draft import ( "strings" "testing" + + "github.com/larksuite/cli/internal/cmdutil" ) func TestSerializeRoundTripKeepsAttachmentsAndHTML(t *testing.T) { @@ -41,7 +43,7 @@ aGVsbG8= --mix-- `) - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{ {Op: "set_subject", Value: "Updated"}, {Op: "set_body", Value: "
updated body
"}, @@ -104,7 +106,7 @@ aGVsbG8= --mix-- ` snapshot := mustParseFixtureDraft(t, original) - if err := Apply(snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated"}}}); err != nil { + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated"}}}); err != nil { t.Fatalf("Apply() error = %v", err) } serialized, err := Serialize(snapshot) @@ -141,7 +143,7 @@ Content-Transfer-Encoding: quoted-printable caf=E9 `) - if err := Apply(snapshot, Patch{ + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: " déjà"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -173,7 +175,7 @@ caf=E9 func TestSerializeSubjectOnlyPreservesEmbeddedMessageAttachment(t *testing.T) { original := mustReadFixture(t, "testdata/message_rfc822_draft.eml") snapshot := mustParseFixtureDraft(t, original) - if err := Apply(snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated forward"}}}); err != nil { + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated forward"}}}); err != nil { t.Fatalf("Apply() error = %v", err) } serialized, err := Serialize(snapshot) @@ -196,7 +198,7 @@ func TestSerializeSubjectOnlyPreservesEmbeddedMessageAttachment(t *testing.T) { func TestSerializeSubjectOnlyPreservesSignedBodyEntity(t *testing.T) { original := mustReadFixture(t, "testdata/multipart_signed_draft.eml") snapshot := mustParseFixtureDraft(t, original) - if err := Apply(snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated signed"}}}); err != nil { + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated signed"}}}); err != nil { t.Fatalf("Apply() error = %v", err) } serialized, err := Serialize(snapshot) @@ -226,7 +228,7 @@ func TestSerializeSubjectOnlyPreservesSignedBodyEntity(t *testing.T) { func TestSerializeDirtyMultipartKeepsPreambleAndEpilogue(t *testing.T) { original := mustReadFixture(t, "testdata/dirty_multipart_preamble.eml") snapshot := mustParseFixtureDraft(t, original) - if err := Apply(snapshot, Patch{ + if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: "\nworld"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) diff --git a/shortcuts/mail/emlbuilder/builder.go b/shortcuts/mail/emlbuilder/builder.go index dd0ca955..e56213ca 100644 --- a/shortcuts/mail/emlbuilder/builder.go +++ b/shortcuts/mail/emlbuilder/builder.go @@ -44,6 +44,7 @@ import ( "bytes" "encoding/base64" "fmt" + "io" "math/rand" "mime" "net/mail" @@ -51,21 +52,21 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/shortcuts/mail/filecheck" ) // MaxEMLSize is the maximum allowed raw EML size in bytes. const MaxEMLSize = 25 * 1024 * 1024 // 25 MB -// readFile reads the named file and returns its contents. -func readFile(path string) ([]byte, error) { - safePath, err := validate.SafeInputPath(path) +// readFile reads the named file and returns its contents via the Builder's FileIO. +func (b Builder) readFile(path string) ([]byte, error) { + f, err := b.fio.Open(path) if err != nil { return nil, fmt.Errorf("attachment %q: %w", path, err) } - return vfs.ReadFile(safePath) + defer f.Close() + return io.ReadAll(f) } // Builder constructs a Lark-compatible RFC 2822 EML message. @@ -90,6 +91,7 @@ type Builder struct { inlines []inline extraHeaders [][2]string // ordered list of [name, value] pairs allowNoRecipients bool // when true, Build() skips the recipient check (for drafts) + fio fileio.FileIO err error } @@ -113,6 +115,14 @@ func New() Builder { return Builder{} } +// WithFileIO returns a copy of the Builder that uses the given FileIO for +// file-reading operations (AddFileAttachment, AddFileInline, AddFileOtherPart). +func (b Builder) WithFileIO(fio fileio.FileIO) Builder { + cp := b + cp.fio = fio + return cp +} + // validateHeaderValue rejects strings that contain characters unsafe in MIME // header values: C0 control chars (except \t for folded headers), DEL (0x7F), // and dangerous Unicode (Bidi overrides, zero-width chars) that enable @@ -425,7 +435,7 @@ func (b Builder) AddFileAttachment(path string) Builder { b.err = err return b } - content, err := readFile(path) + content, err := b.readFile(path) if err != nil { b.err = err return b @@ -480,7 +490,7 @@ func (b Builder) AddFileInline(path, contentID string) Builder { if b.err != nil { return b } - content, err := readFile(path) + content, err := b.readFile(path) if err != nil { b.err = err return b @@ -539,7 +549,7 @@ func (b Builder) AddFileOtherPart(path, contentID string) Builder { if b.err != nil { return b } - content, err := readFile(path) + content, err := b.readFile(path) if err != nil { b.err = err return b diff --git a/shortcuts/mail/emlbuilder/builder_test.go b/shortcuts/mail/emlbuilder/builder_test.go index cef11475..7877a257 100644 --- a/shortcuts/mail/emlbuilder/builder_test.go +++ b/shortcuts/mail/emlbuilder/builder_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" "time" + + "github.com/larksuite/cli/internal/cmdutil" ) var fixedDate = time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC) @@ -958,9 +960,10 @@ func TestAddFileAttachmentBlockedExtension(t *testing.T) { for _, name := range blocked { os.WriteFile(name, []byte("content"), 0o644) } + fio := &cmdutil.LocalFileIO{} for _, name := range blocked { t.Run(name, func(t *testing.T) { - _, err := New(). + _, err := New().WithFileIO(fio). From("", "alice@example.com"). To("", "bob@example.com"). Subject("test"). @@ -991,9 +994,10 @@ func TestAddFileInlineBlockedFormat(t *testing.T) { // .png extension but EXE content → rejected (bad content) os.WriteFile("evil.png", []byte("MZ"), 0o644) + fio := &cmdutil.LocalFileIO{} for _, name := range []string{"icon.svg", "evil.png"} { t.Run(name, func(t *testing.T) { - _, err := New(). + _, err := New().WithFileIO(fio). From("", "alice@example.com"). To("", "bob@example.com"). Subject("test"). @@ -1020,9 +1024,10 @@ func TestAddFileInlineAllowedFormat(t *testing.T) { os.WriteFile("logo.png", pngContent, 0o644) os.WriteFile("photo.jpg", jpegContent, 0o644) + fio := &cmdutil.LocalFileIO{} for _, name := range []string{"logo.png", "photo.jpg"} { t.Run(name, func(t *testing.T) { - _, err := New(). + _, err := New().WithFileIO(fio). From("", "alice@example.com"). To("", "bob@example.com"). Subject("test"). @@ -1048,9 +1053,10 @@ func TestAddFileAttachmentAllowedExtension(t *testing.T) { for _, name := range allowed { os.WriteFile(name, []byte("content"), 0o644) } + fio := &cmdutil.LocalFileIO{} for _, name := range allowed { t.Run(name, func(t *testing.T) { - _, err := New(). + _, err := New().WithFileIO(fio). From("", "alice@example.com"). To("", "bob@example.com"). Subject("test"). diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index 88629323..6a38c089 100644 --- a/shortcuts/mail/helpers.go +++ b/shortcuts/mail/helpers.go @@ -17,6 +17,7 @@ import ( "strconv" "strings" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" @@ -1832,9 +1833,9 @@ func inlineSpecFilePaths(specs []InlineSpec) []string { // checkAttachmentSizeLimit returns an error if the combined attachment count exceeds // MaxAttachmentCount or the combined size exceeds MaxAttachmentBytes. -// filePaths are read via os.Stat (no full read); extraBytes / extraCount account for +// filePaths are read via fio.Stat (no full read); extraBytes / extraCount account for // already-loaded content (e.g. downloaded original attachments in +forward). -func checkAttachmentSizeLimit(filePaths []string, extraBytes int64, extraCount ...int) error { +func checkAttachmentSizeLimit(fio fileio.FileIO, filePaths []string, extraBytes int64, extraCount ...int) error { extra := 0 for _, c := range extraCount { extra += c @@ -1845,11 +1846,7 @@ func checkAttachmentSizeLimit(filePaths []string, extraBytes int64, extraCount . } totalBytes := extraBytes for _, p := range filePaths { - safePath, err := validate.SafeInputPath(p) - if err != nil { - return fmt.Errorf("unsafe attachment path %s: %w", p, err) - } - info, err := vfs.Stat(safePath) + info, err := fio.Stat(p) if err != nil { return fmt.Errorf("failed to stat attachment %s: %w", p, err) } @@ -1952,7 +1949,7 @@ func validateRecipientCount(to, cc, bcc string) error { return nil } -func validateComposeInlineAndAttachments(attachFlag, inlineFlag string, plainText bool, body string) error { +func validateComposeInlineAndAttachments(fio fileio.FileIO, attachFlag, inlineFlag string, plainText bool, body string) error { if strings.TrimSpace(inlineFlag) != "" { if plainText { return fmt.Errorf("--inline is not supported with --plain-text (inline images require HTML body)") @@ -1966,7 +1963,7 @@ func validateComposeInlineAndAttachments(attachFlag, inlineFlag string, plainTex return err } allFiles := append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...) - if err := checkAttachmentSizeLimit(allFiles, 0); err != nil { + if err := checkAttachmentSizeLimit(fio, allFiles, 0); err != nil { return err } return nil diff --git a/shortcuts/mail/helpers_test.go b/shortcuts/mail/helpers_test.go index 3b5d3159..e44b9ed9 100644 --- a/shortcuts/mail/helpers_test.go +++ b/shortcuts/mail/helpers_test.go @@ -568,13 +568,13 @@ func TestToOriginalMessageForCompose_EmptyReferences(t *testing.T) { // --------------------------------------------------------------------------- func TestCheckAttachmentSizeLimit_NoFiles(t *testing.T) { - if err := checkAttachmentSizeLimit(nil, 0); err != nil { + if err := checkAttachmentSizeLimit(nil, nil, 0); err != nil { t.Fatalf("unexpected error for empty: %v", err) } } func TestCheckAttachmentSizeLimit_CountExceeded(t *testing.T) { - err := checkAttachmentSizeLimit(nil, 0, MaxAttachmentCount+1) + err := checkAttachmentSizeLimit(nil, nil, 0, MaxAttachmentCount+1) if err == nil { t.Fatal("expected error for count exceeded") } @@ -585,7 +585,7 @@ func TestCheckAttachmentSizeLimit_CountExceeded(t *testing.T) { func TestCheckAttachmentSizeLimit_SizeExceeded(t *testing.T) { // extraBytes alone exceeds the limit - err := checkAttachmentSizeLimit(nil, MaxAttachmentBytes+1) + err := checkAttachmentSizeLimit(nil, nil, MaxAttachmentBytes+1) if err == nil { t.Fatal("expected error for size exceeded") } @@ -608,7 +608,7 @@ func TestCheckAttachmentSizeLimit_WithFiles(t *testing.T) { } defer os.Chdir(oldWd) - err := checkAttachmentSizeLimit([]string{"./small.txt"}, 0) + err := checkAttachmentSizeLimit(&cmdutil.LocalFileIO{}, []string{"./small.txt"}, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/shortcuts/mail/mail_draft_create.go b/shortcuts/mail/mail_draft_create.go index d6f70690..490091fd 100644 --- a/shortcuts/mail/mail_draft_create.go +++ b/shortcuts/mail/mail_draft_create.go @@ -71,7 +71,7 @@ var MailDraftCreate = common.Shortcut{ if strings.TrimSpace(runtime.Str("body")) == "" { return output.ErrValidation("--body is required; pass the full email body") } - if err := validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil { + if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil { return err } return nil @@ -133,7 +133,7 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate return "", err } - bld := emlbuilder.New(). + bld := emlbuilder.New().WithFileIO(runtime.FileIO()). AllowNoRecipients(). Subject(input.Subject) if strings.TrimSpace(input.To) != "" { diff --git a/shortcuts/mail/mail_draft_edit.go b/shortcuts/mail/mail_draft_edit.go index 44f0d5f0..82862423 100644 --- a/shortcuts/mail/mail_draft_edit.go +++ b/shortcuts/mail/mail_draft_edit.go @@ -10,9 +10,9 @@ import ( "io" "strings" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" +>>>>>>> 2b941a5 (feat: migrate upload/read-file shortcuts to FileIO.Open/Stat (Phase 3)) "github.com/larksuite/cli/shortcuts/common" draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" ) @@ -93,7 +93,7 @@ var MailDraftEdit = common.Shortcut{ if err != nil { return output.ErrValidation("parse draft raw EML failed: %v", err) } - if err := draftpkg.Apply(snapshot, patch); err != nil { + if err := draftpkg.Apply(runtime.FileIO(), snapshot, patch); err != nil { return output.ErrValidation("apply draft patch failed: %v", err) } serialized, err := draftpkg.Serialize(snapshot) @@ -216,7 +216,7 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error) patchFile := strings.TrimSpace(runtime.Str("patch-file")) if patchFile != "" { - filePatch, err := loadPatchFile(patchFile) + filePatch, err := loadPatchFile(runtime.FileIO(), patchFile) if err != nil { return patch, err } @@ -264,13 +264,17 @@ func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error) return patch, patch.Validate() } -func loadPatchFile(path string) (draftpkg.Patch, error) { +func loadPatchFile(fio fileio.FileIO, path string) (draftpkg.Patch, error) { var patch draftpkg.Patch - safePath, err := validate.SafeInputPath(path) + f, err := fio.Open(path) if err != nil { return patch, fmt.Errorf("--patch-file %q: %w", path, err) } +<<<<<<< HEAD data, err := vfs.ReadFile(safePath) +======= + defer f.Close() + data, err := io.ReadAll(f) if err != nil { return patch, err } diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 905bc8af..96ac9959 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -63,7 +63,7 @@ var MailForward = common.Shortcut{ return err } } - return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") + return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { messageId := runtime.Str("message-id") @@ -99,7 +99,7 @@ var MailForward = common.Shortcut{ return err } - bld := emlbuilder.New(). + bld := emlbuilder.New().WithFileIO(runtime.FileIO()). Subject(buildForwardSubject(orig.subject)). ToAddrs(parseNetAddrs(to)) if senderEmail != "" { @@ -173,7 +173,7 @@ var MailForward = common.Shortcut{ if err != nil { return err } - if err := checkAttachmentSizeLimit(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), origAttBytes, len(origAtts)); err != nil { + if err := checkAttachmentSizeLimit(runtime.FileIO(), append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), origAttBytes, len(origAtts)); err != nil { return err } for _, att := range origAtts { diff --git a/shortcuts/mail/mail_reply.go b/shortcuts/mail/mail_reply.go index b89bc5d6..16995804 100644 --- a/shortcuts/mail/mail_reply.go +++ b/shortcuts/mail/mail_reply.go @@ -55,7 +55,7 @@ var MailReply = common.Shortcut{ if err := validateConfirmSendScope(runtime); err != nil { return err } - return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") + return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { messageId := runtime.Str("message-id") @@ -110,7 +110,7 @@ var MailReply = common.Shortcut{ } quoted := quoteForReply(&orig, useHTML) - bld := emlbuilder.New(). + bld := emlbuilder.New().WithFileIO(runtime.FileIO()). Subject(buildReplySubject(orig.subject)). ToAddrs(parseNetAddrs(replyTo)) if senderEmail != "" { diff --git a/shortcuts/mail/mail_reply_all.go b/shortcuts/mail/mail_reply_all.go index 6b82365e..8a94260b 100644 --- a/shortcuts/mail/mail_reply_all.go +++ b/shortcuts/mail/mail_reply_all.go @@ -56,7 +56,7 @@ var MailReplyAll = common.Shortcut{ if err := validateConfirmSendScope(runtime); err != nil { return err } - return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") + return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { messageId := runtime.Str("message-id") @@ -124,7 +124,7 @@ var MailReplyAll = common.Shortcut{ bodyStr = body } quoted := quoteForReply(&orig, useHTML) - bld := emlbuilder.New(). + bld := emlbuilder.New().WithFileIO(runtime.FileIO()). Subject(buildReplySubject(orig.subject)). ToAddrs(parseNetAddrs(toList)) if senderEmail != "" { diff --git a/shortcuts/mail/mail_send.go b/shortcuts/mail/mail_send.go index 43b63826..ee99d39b 100644 --- a/shortcuts/mail/mail_send.go +++ b/shortcuts/mail/mail_send.go @@ -61,7 +61,7 @@ var MailSend = common.Shortcut{ if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil { return err } - return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")) + return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { to := runtime.Str("to") @@ -80,7 +80,7 @@ var MailSend = common.Shortcut{ senderEmail = fetchCurrentUserEmail(runtime) } - bld := emlbuilder.New(). + bld := emlbuilder.New().WithFileIO(runtime.FileIO()). Subject(subject). ToAddrs(parseNetAddrs(to)) if senderEmail != "" { From 18c43a754c47918ab5e2e0af76941bef0260f420 Mon Sep 17 00:00:00 2001 From: liushiyao Date: Fri, 3 Apr 2026 18:21:09 +0800 Subject: [PATCH 04/17] fix: resolve merge conflict markers and remove stale imports Clean up leftover conflict markers from Phase 3 merge and remove unused vfs/bytes imports now that FileIO abstraction is in place. Change-Id: Id4a9435a4db5f24eefda2e2112454d99d685d6f6 --- shortcuts/base/record_upload_attachment.go | 10 ---------- shortcuts/doc/doc_media_download.go | 11 +++++------ shortcuts/doc/doc_media_insert.go | 1 - shortcuts/doc/doc_media_upload.go | 4 ---- shortcuts/drive/drive_download.go | 8 +++----- shortcuts/drive/drive_export_common.go | 1 - shortcuts/drive/drive_upload.go | 4 ---- shortcuts/im/helpers.go | 1 - shortcuts/im/im_messages_resources_download.go | 3 +++ shortcuts/mail/draft/patch.go | 1 - shortcuts/mail/helpers.go | 1 - shortcuts/mail/mail_draft_edit.go | 4 ---- shortcuts/sheets/sheet_export.go | 8 +++----- shortcuts/vc/vc_notes.go | 1 - 14 files changed, 14 insertions(+), 44 deletions(-) diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go index 6699c959..ea915b59 100644 --- a/shortcuts/base/record_upload_attachment.go +++ b/shortcuts/base/record_upload_attachment.go @@ -16,7 +16,6 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" ->>>>>>> 2b941a5 (feat: migrate upload/read-file shortcuts to FileIO.Open/Stat (Phase 3)) "github.com/larksuite/cli/shortcuts/common" ) @@ -90,15 +89,6 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont func executeRecordUploadAttachment(runtime *common.RuntimeContext) error { filePath := runtime.Str("file") -<<<<<<< HEAD - safeFilePath, err := validate.SafeInputPath(filePath) - if err != nil { - return output.ErrValidation("unsafe file path: %s", err) - } - filePath = safeFilePath - - fileInfo, err := vfs.Stat(filePath) -======= fileInfo, err := runtime.FileIO().Stat(filePath) if err != nil { return output.ErrValidation("file not found: %s", filePath) diff --git a/shortcuts/doc/doc_media_download.go b/shortcuts/doc/doc_media_download.go index 0b547f98..49b771c5 100644 --- a/shortcuts/doc/doc_media_download.go +++ b/shortcuts/doc/doc_media_download.go @@ -4,10 +4,10 @@ package doc import ( - "bytes" "context" "fmt" "net/http" + "os" "path/filepath" "strings" @@ -16,7 +16,6 @@ import ( "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/shortcuts/common" ) @@ -115,9 +114,9 @@ var DocMediaDownload = common.Shortcut{ } result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{ - ContentType: apiResp.Header.Get("Content-Type"), - ContentLength: int64(len(apiResp.RawBody)), - }, bytes.NewReader(apiResp.RawBody)) + ContentType: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) if err != nil { return output.Errorf(output.ExitInternal, "io", "cannot create file: %v", err) } @@ -125,7 +124,7 @@ var DocMediaDownload = common.Shortcut{ runtime.Out(map[string]interface{}{ "saved_path": finalPath, "size_bytes": result.Size(), - "content_type": apiResp.Header.Get("Content-Type"), + "content_type": resp.Header.Get("Content-Type"), }, nil) return nil }, diff --git a/shortcuts/doc/doc_media_insert.go b/shortcuts/doc/doc_media_insert.go index 2ebfb2ac..0d6dcb15 100644 --- a/shortcuts/doc/doc_media_insert.go +++ b/shortcuts/doc/doc_media_insert.go @@ -16,7 +16,6 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/shortcuts/common" ) diff --git a/shortcuts/doc/doc_media_upload.go b/shortcuts/doc/doc_media_upload.go index 158b8b18..ac2d62a2 100644 --- a/shortcuts/doc/doc_media_upload.go +++ b/shortcuts/doc/doc_media_upload.go @@ -15,7 +15,6 @@ import ( "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" ->>>>>>> 2b941a5 (feat: migrate upload/read-file shortcuts to FileIO.Open/Stat (Phase 3)) "github.com/larksuite/cli/shortcuts/common" ) @@ -58,9 +57,6 @@ var MediaUpload = common.Shortcut{ docId := runtime.Str("doc-id") // Validate file -<<<<<<< HEAD - stat, err := vfs.Stat(filePath) -======= stat, err := runtime.FileIO().Stat(filePath) if err != nil { return output.ErrValidation("file not found: %s", filePath) diff --git a/shortcuts/drive/drive_download.go b/shortcuts/drive/drive_download.go index 401e8196..f45aec6e 100644 --- a/shortcuts/drive/drive_download.go +++ b/shortcuts/drive/drive_download.go @@ -4,7 +4,6 @@ package drive import ( - "bytes" "context" "fmt" "net/http" @@ -15,7 +14,6 @@ import ( "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/shortcuts/common" ) @@ -73,9 +71,9 @@ var DriveDownload = common.Shortcut{ defer resp.Body.Close() result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ - ContentType: apiResp.Header.Get("Content-Type"), - ContentLength: int64(len(apiResp.RawBody)), - }, bytes.NewReader(apiResp.RawBody)) + ContentType: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) if err != nil { return output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err) } diff --git a/shortcuts/drive/drive_export_common.go b/shortcuts/drive/drive_export_common.go index 218a2ada..3b519a4c 100644 --- a/shortcuts/drive/drive_export_common.go +++ b/shortcuts/drive/drive_export_common.go @@ -19,7 +19,6 @@ import ( "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/shortcuts/common" ) diff --git a/shortcuts/drive/drive_upload.go b/shortcuts/drive/drive_upload.go index 665b740e..6c9f18d8 100644 --- a/shortcuts/drive/drive_upload.go +++ b/shortcuts/drive/drive_upload.go @@ -16,7 +16,6 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/internal/output" ->>>>>>> 2b941a5 (feat: migrate upload/read-file shortcuts to FileIO.Open/Stat (Phase 3)) "github.com/larksuite/cli/shortcuts/common" ) @@ -62,9 +61,6 @@ var DriveUpload = common.Shortcut{ fileName = filepath.Base(filePath) } -<<<<<<< HEAD - info, err := vfs.Stat(filePath) -======= info, err := runtime.FileIO().Stat(filePath) if err != nil { return output.ErrValidation("cannot read file: %s", err) diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index 933f5303..a0b3b6af 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -22,7 +22,6 @@ import ( "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/spf13/cobra" diff --git a/shortcuts/im/im_messages_resources_download.go b/shortcuts/im/im_messages_resources_download.go index ff4c35ab..6fa5c3c5 100644 --- a/shortcuts/im/im_messages_resources_download.go +++ b/shortcuts/im/im_messages_resources_download.go @@ -6,12 +6,15 @@ package im import ( "context" "fmt" + "io" "net/http" + "os" "path/filepath" "strings" "time" "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" diff --git a/shortcuts/mail/draft/patch.go b/shortcuts/mail/draft/patch.go index 32b789b3..4b7dacab 100644 --- a/shortcuts/mail/draft/patch.go +++ b/shortcuts/mail/draft/patch.go @@ -12,7 +12,6 @@ import ( "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/shortcuts/mail/filecheck" ) diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go index 6a38c089..d38bde9f 100644 --- a/shortcuts/mail/helpers.go +++ b/shortcuts/mail/helpers.go @@ -21,7 +21,6 @@ import ( "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/mail/emlbuilder" ) diff --git a/shortcuts/mail/mail_draft_edit.go b/shortcuts/mail/mail_draft_edit.go index 82862423..152de968 100644 --- a/shortcuts/mail/mail_draft_edit.go +++ b/shortcuts/mail/mail_draft_edit.go @@ -12,7 +12,6 @@ import ( "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" ->>>>>>> 2b941a5 (feat: migrate upload/read-file shortcuts to FileIO.Open/Stat (Phase 3)) "github.com/larksuite/cli/shortcuts/common" draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" ) @@ -270,9 +269,6 @@ func loadPatchFile(fio fileio.FileIO, path string) (draftpkg.Patch, error) { if err != nil { return patch, fmt.Errorf("--patch-file %q: %w", path, err) } -<<<<<<< HEAD - data, err := vfs.ReadFile(safePath) -======= defer f.Close() data, err := io.ReadAll(f) if err != nil { diff --git a/shortcuts/sheets/sheet_export.go b/shortcuts/sheets/sheet_export.go index 713a5706..1dcebfba 100644 --- a/shortcuts/sheets/sheet_export.go +++ b/shortcuts/sheets/sheet_export.go @@ -4,7 +4,6 @@ package sheets import ( - "bytes" "context" "fmt" "net/http" @@ -15,7 +14,6 @@ import ( "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/shortcuts/common" ) @@ -131,9 +129,9 @@ var SheetExport = common.Shortcut{ defer resp.Body.Close() result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ - ContentType: apiResp.Header.Get("Content-Type"), - ContentLength: int64(len(apiResp.RawBody)), - }, bytes.NewReader(apiResp.RawBody)) + ContentType: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) if err != nil { return output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err) } diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index 6f273f53..af5b94e1 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -28,7 +28,6 @@ import ( "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/shortcuts/common" ) From d372c0da2e388a79ba63bfa04d19f1e07d3f8378 Mon Sep 17 00:00:00 2001 From: liushiyao Date: Tue, 7 Apr 2026 14:27:43 +0800 Subject: [PATCH 05/17] fix: adapt fileio cherry-picks to refactor plugin base Change-Id: I30647cd56089668bcab015e14ee226a4f73af1ee --- shortcuts/drive/drive_import.go | 73 +------------------------- shortcuts/drive/drive_import_common.go | 8 ++- shortcuts/im/helpers.go | 11 ++-- shortcuts/im/helpers_network_test.go | 66 +++++------------------ shortcuts/mail/draft/patch_test.go | 10 ++-- 5 files changed, 26 insertions(+), 142 deletions(-) diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go index cc777bba..85f41fd2 100644 --- a/shortcuts/drive/drive_import.go +++ b/shortcuts/drive/drive_import.go @@ -147,9 +147,8 @@ func preflightDriveImportFile(spec *driveImportSpec) (int64, error) { if err != nil { return 0, output.ErrValidation("unsafe file path: %s", err) } - spec.FilePath = safeFilePath - info, err := vfs.Stat(spec.FilePath) + info, err := vfs.Stat(safeFilePath) if err != nil { return 0, output.ErrValidation("cannot read file: %s", err) } @@ -229,73 +228,3 @@ func importDefaultFileName(filePath string) string { } return name } - -// uploadMediaForImport uploads the source file to the temporary import media -// endpoint and returns the file token consumed by import_tasks. -func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) { - importInfo, err := runtime.FileIO().Stat(filePath) - if err != nil { - return "", output.ErrValidation("cannot read file: %s", err) - } - fileSize := importInfo.Size() - if fileSize > maxDriveUploadFileSize { - return "", output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileSize)/1024/1024) - } - - fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import: %s (%s)\n", fileName, common.FormatSize(fileSize)) - - f, err := runtime.FileIO().Open(filePath) - if err != nil { - return "", err - } - defer f.Close() - - ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") - extraMap := map[string]string{ - "obj_type": docType, - "file_extension": ext, - } - extraBytes, _ := json.Marshal(extraMap) - - // Build SDK Formdata - fd := larkcore.NewFormdata() - fd.AddField("file_name", fileName) - fd.AddField("parent_type", "ccm_import_open") - fd.AddField("size", fmt.Sprintf("%d", fileSize)) - fd.AddField("extra", string(extraBytes)) - fd.AddFile("file", f) - - apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ - HttpMethod: http.MethodPost, - ApiPath: "/open-apis/drive/v1/medias/upload_all", - Body: fd, - }, larkcore.WithFileUpload()) - if err != nil { - var exitErr *output.ExitError - if errors.As(err, &exitErr) { - // Preserve already-classified CLI errors from lower layers instead of - // wrapping them as a generic network failure. - return "", err - } - return "", output.ErrNetwork("upload media failed: %v", err) - } - - var result map[string]interface{} - if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { - return "", output.Errorf(output.ExitAPI, "api_error", "upload media failed: invalid response JSON: %v", err) - } - - if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 { - // Surface the backend error body so callers can see import-specific - // validation failures such as unsupported formats or permission issues. - msg, _ := result["msg"].(string) - return "", output.ErrAPI(larkCode, fmt.Sprintf("upload media failed: [%d] %s", larkCode, msg), result["error"]) - } - - data, _ := result["data"].(map[string]interface{}) - fileToken, _ := data["file_token"].(string) - if fileToken == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "upload media failed: no file_token returned") - } - return fileToken, nil -} diff --git a/shortcuts/drive/drive_import_common.go b/shortcuts/drive/drive_import_common.go index f3b61c47..3586257a 100644 --- a/shortcuts/drive/drive_import_common.go +++ b/shortcuts/drive/drive_import_common.go @@ -15,8 +15,6 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/vfs" - larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/internal/output" @@ -97,7 +95,7 @@ func (s driveImportSpec) CreateTaskBody(fileToken string) map[string]interface{} // uploadMediaForImport uploads the source file to the temporary import media // endpoint and returns the file token consumed by import_tasks. func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, docType string) (string, error) { - importInfo, err := vfs.Stat(filePath) + importInfo, err := runtime.FileIO().Stat(filePath) if err != nil { return "", output.ErrValidation("cannot read file: %s", err) } @@ -126,7 +124,7 @@ func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, f } func uploadMediaForImportAll(runtime *common.RuntimeContext, filePath, fileName string, fileSize int, extra string) (string, error) { - f, err := vfs.Open(filePath) + f, err := runtime.FileIO().Open(filePath) if err != nil { return "", output.ErrValidation("cannot read file: %s", err) } @@ -165,7 +163,7 @@ func uploadMediaForImportMultipart(runtime *common.RuntimeContext, filePath, fil totalBlocks := session.BlockNum fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", totalBlocks, common.FormatSize(int64(session.BlockSize))) - f, err := vfs.Open(filePath) + f, err := runtime.FileIO().Open(filePath) if err != nil { return "", output.ErrValidation("cannot read file: %s", err) } diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index a0b3b6af..88035859 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -326,21 +326,20 @@ func resolveURLMedia(ctx context.Context, runtime *common.RuntimeContext, s medi func resolveLocalMedia(ctx context.Context, runtime *common.RuntimeContext, s mediaSpec) (string, error) { fmt.Fprintf(runtime.IO().ErrOut, "uploading %s: %s\n", s.mediaType, filepath.Base(s.value)) - safePath, err := validate.SafeInputPath(s.value) - if err != nil { + if _, err := validate.SafeInputPath(s.value); err != nil { return "", err } if s.kind == mediaKindImage { - return uploadImageToIM(ctx, runtime, safePath, "message") + return uploadImageToIM(ctx, runtime, s.value, "message") } - ft := detectIMFileType(safePath) + ft := detectIMFileType(s.value) dur := "" if s.withDuration { - dur = parseMediaDuration(runtime, safePath, ft) + dur = parseMediaDuration(runtime, s.value, ft) } - return uploadFileToIM(ctx, runtime, safePath, ft, dur) + return uploadFileToIM(ctx, runtime, s.value, ft, dur) } // resolveVideoContent handles the video case which needs both a file_key and diff --git a/shortcuts/im/helpers_network_test.go b/shortcuts/im/helpers_network_test.go index 8e62d9f5..e1b95444 100644 --- a/shortcuts/im/helpers_network_test.go +++ b/shortcuts/im/helpers_network_test.go @@ -311,28 +311,14 @@ func TestUploadImageToIMSuccess(t *testing.T) { } })) - wd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - tmpDir := t.TempDir() - if err := os.Chdir(tmpDir); err != nil { - t.Fatalf("Chdir() error = %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(wd) - }) + cmdutil.TestChdir(t, t.TempDir()) path := "demo.png" if err := os.WriteFile(path, []byte("png"), 0600); err != nil { t.Fatalf("WriteFile() error = %v", err) } - absPath, err := filepath.Abs(path) - if err != nil { - t.Fatalf("Abs() error = %v", err) - } - got, err := uploadImageToIM(context.Background(), runtime, absPath, "message") + got, err := uploadImageToIM(context.Background(), runtime, path, "message") if err != nil { t.Fatalf("uploadImageToIM() error = %v", err) } @@ -363,28 +349,14 @@ func TestUploadFileToIMSuccess(t *testing.T) { } })) - wd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - tmpDir := t.TempDir() - if err := os.Chdir(tmpDir); err != nil { - t.Fatalf("Chdir() error = %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(wd) - }) + cmdutil.TestChdir(t, t.TempDir()) path := "demo.txt" if err := os.WriteFile(path, []byte("demo"), 0600); err != nil { t.Fatalf("WriteFile() error = %v", err) } - absPath, err := filepath.Abs(path) - if err != nil { - t.Fatalf("Abs() error = %v", err) - } - got, err := uploadFileToIM(context.Background(), runtime, absPath, "stream", "1200") + got, err := uploadFileToIM(context.Background(), runtime, path, "stream", "1200") if err != nil { t.Fatalf("uploadFileToIM() error = %v", err) } @@ -400,7 +372,8 @@ func TestUploadFileToIMSuccess(t *testing.T) { } func TestUploadImageToIMSizeLimit(t *testing.T) { - path := filepath.Join(t.TempDir(), "too-large.png") + cmdutil.TestChdir(t, t.TempDir()) + path := "too-large.png" f, err := os.Create(path) if err != nil { t.Fatalf("Create() error = %v", err) @@ -413,14 +386,15 @@ func TestUploadImageToIMSizeLimit(t *testing.T) { rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { return nil, fmt.Errorf("unexpected") })) - _, err = uploadImageToIM(context.Background(), rt, "./"+path, "message") + _, err = uploadImageToIM(context.Background(), rt, path, "message") if err == nil || !strings.Contains(err.Error(), "exceeds limit") { t.Fatalf("uploadImageToIM() error = %v", err) } } func TestUploadFileToIMSizeLimit(t *testing.T) { - path := filepath.Join(t.TempDir(), "too-large.bin") + cmdutil.TestChdir(t, t.TempDir()) + path := "too-large.bin" f, err := os.Create(path) if err != nil { t.Fatalf("Create() error = %v", err) @@ -433,7 +407,7 @@ func TestUploadFileToIMSizeLimit(t *testing.T) { rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { return nil, fmt.Errorf("unexpected") })) - _, err = uploadFileToIM(context.Background(), rt, "./"+path, "stream", "") + _, err = uploadFileToIM(context.Background(), rt, path, "stream", "") if err == nil || !strings.Contains(err.Error(), "exceeds limit") { t.Fatalf("uploadFileToIM() error = %v", err) } @@ -472,15 +446,7 @@ func TestResolveLocalMediaImage(t *testing.T) { return nil, fmt.Errorf("unexpected request: %s", req.URL.String()) })) - wd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - tmpDir := t.TempDir() - if err := os.Chdir(tmpDir); err != nil { - t.Fatalf("Chdir() error = %v", err) - } - t.Cleanup(func() { _ = os.Chdir(wd) }) + cmdutil.TestChdir(t, t.TempDir()) if err := os.WriteFile("test.png", []byte("png-data"), 0600); err != nil { t.Fatalf("WriteFile() error = %v", err) @@ -511,15 +477,7 @@ func TestResolveLocalMediaFile(t *testing.T) { return nil, fmt.Errorf("unexpected request: %s", req.URL.String()) })) - wd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - tmpDir := t.TempDir() - if err := os.Chdir(tmpDir); err != nil { - t.Fatalf("Chdir() error = %v", err) - } - t.Cleanup(func() { _ = os.Chdir(wd) }) + cmdutil.TestChdir(t, t.TempDir()) if err := os.WriteFile("test.txt", []byte("file-data"), 0600); err != nil { t.Fatalf("WriteFile() error = %v", err) diff --git a/shortcuts/mail/draft/patch_test.go b/shortcuts/mail/draft/patch_test.go index e2df4b0a..d8054c88 100644 --- a/shortcuts/mail/draft/patch_test.go +++ b/shortcuts/mail/draft/patch_test.go @@ -599,7 +599,7 @@ Content-Type: text/html; charset=UTF-8
hello
`) for _, bad := range []string{"my logo", "cid\there", "loid", "img(1)"} { - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_inline", Path: "logo.png", CID: bad}}, }) if err == nil { @@ -627,7 +627,7 @@ Content-Type: text/html; charset=UTF-8
`) // Step 1: add inline — this wraps body into multipart/related - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_inline", Path: "logo.png", CID: "logo"}}, }) if err != nil { @@ -636,7 +636,7 @@ Content-Type: text/html; charset=UTF-8 // Step 2: set_body — this restructures the MIME tree, potentially making // PrimaryHTMLPartID stale - err = Apply(snapshot, Patch{ + err = Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: `
updated
`}}, }) if err != nil { @@ -644,7 +644,7 @@ Content-Type: text/html; charset=UTF-8 } // Step 3: set_body again dropping the CID reference — should fail validation - err = Apply(snapshot, Patch{ + err = Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: `
no image here
`}}, }) if err == nil || !strings.Contains(err.Error(), "orphaned cids") { @@ -706,7 +706,7 @@ func TestReplaceInlineRejectsInvalidCharactersInCID(t *testing.T) { } snapshot := mustParseFixtureDraft(t, fixtureData) for _, bad := range []string{"my logo", "cid\there", "loid", "img(1)"} { - err := Apply(snapshot, Patch{ + err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, Path: "updated.png", CID: bad}}, }) if err == nil { From f39f91bc8c9a6e76411abbd7791e493cbb72f6d8 Mon Sep 17 00:00:00 2001 From: liushiyao Date: Tue, 7 Apr 2026 15:27:45 +0800 Subject: [PATCH 06/17] refactor: resolve fileio at runtime Change-Id: I69a2c63497a755e2137dd5a6808c554ce2f4d9fe --- cmd/api/api.go | 2 +- cmd/service/service.go | 2 +- extension/fileio/types.go | 2 +- internal/cmdutil/factory.go | 11 +- internal/cmdutil/factory_default.go | 6 +- internal/cmdutil/factory_default_test.go | 188 ++++------------------- internal/cmdutil/testing.go | 15 +- shortcuts/base/base_shortcuts_test.go | 3 +- shortcuts/common/runner.go | 7 +- shortcuts/common/runner_jq_test.go | 46 +++++- shortcuts/im/helpers_network_test.go | 13 +- 11 files changed, 108 insertions(+), 187 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index ffd48e50..696eda9b 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -207,7 +207,7 @@ func apiRun(opts *APIOptions) error { JqExpr: opts.JqExpr, Out: out, ErrOut: f.IOStreams.ErrOut, - FileIO: f.FileIO, + FileIO: f.ResolveFileIO(opts.Ctx), }) // MarkRaw tells root error handler to skip enrichPermissionError, // preserving the original API error detail (log_id, troubleshooter, etc.). diff --git a/cmd/service/service.go b/cmd/service/service.go index 45f198bc..61759fa6 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -250,7 +250,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { JqExpr: opts.JqExpr, Out: out, ErrOut: f.IOStreams.ErrOut, - FileIO: f.FileIO, + FileIO: f.ResolveFileIO(opts.Ctx), CheckError: checkErr, }) } diff --git a/extension/fileio/types.go b/extension/fileio/types.go index 9d84f081..32bb11c0 100644 --- a/extension/fileio/types.go +++ b/extension/fileio/types.go @@ -19,7 +19,7 @@ type Provider interface { // FileIO abstracts file transfer operations for CLI commands. // The default implementation operates on the local filesystem with // path validation, directory creation, and atomic writes. -// Inject a custom implementation via Factory.FileIO to replace +// Inject a custom implementation via Factory.FileIOProvider to replace // file transfer behavior (e.g. streaming in server mode). type FileIO interface { // Open opens a file for reading (upload, attachment, template scenarios). diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index ff93a6ca..3f475983 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -42,7 +42,16 @@ type Factory struct { Credential *credential.CredentialProvider - FileIO fileio.FileIO // file transfer abstraction (default: local filesystem) + FileIOProvider fileio.Provider // file transfer provider (default: local filesystem) +} + +// ResolveFileIO resolves a FileIO instance using the current execution context. +// The provider controls whether the returned instance is fresh or cached. +func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO { + if f == nil || f.FileIOProvider == nil { + return nil + } + return f.FileIOProvider.ResolveFileIO(ctx) } // ResolveAs returns the effective identity type. diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index 661dc689..6e51e5d5 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -45,10 +45,8 @@ func NewDefault(inv InvocationContext) *Factory { IsTerminal: term.IsTerminal(int(os.Stdin.Fd())), } - // Phase 0: FileIO (no dependency) - if p := fileio.GetProvider(); p != nil { - f.FileIO = p.ResolveFileIO(context.Background()) - } + // Phase 0: FileIO provider (no dependency) + f.FileIOProvider = fileio.GetProvider() // Phase 1: HttpClient (no credential dependency) f.HttpClient = cachedHttpClientFunc() diff --git a/internal/cmdutil/factory_default_test.go b/internal/cmdutil/factory_default_test.go index 9d01e82f..70a8d6e2 100644 --- a/internal/cmdutil/factory_default_test.go +++ b/internal/cmdutil/factory_default_test.go @@ -6,18 +6,27 @@ package cmdutil import ( "context" "errors" - "net/http" - "net/http/httptest" "testing" _ "github.com/larksuite/cli/extension/credential/env" - exttransport "github.com/larksuite/cli/extension/transport" + "github.com/larksuite/cli/extension/fileio" internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/envvars" ) +type countingFileIOProvider struct { + resolveCalls int +} + +func (p *countingFileIOProvider) Name() string { return "counting" } + +func (p *countingFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO { + p.resolveCalls++ + return &LocalFileIO{} +} + func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) { t.Setenv(envvars.CliAppID, "") t.Setenv(envvars.CliAppSecret, "") @@ -198,169 +207,24 @@ func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testin } } -type stubTransportProvider struct { - interceptor exttransport.Interceptor -} - -func (s *stubTransportProvider) Name() string { return "stub" } -func (s *stubTransportProvider) ResolveInterceptor(context.Context) exttransport.Interceptor { - if s.interceptor != nil { - return s.interceptor - } - return &stubTransportImpl{} -} - -type stubTransportImpl struct{} - -func (s *stubTransportImpl) PreRoundTrip(req *http.Request) func(*http.Response, error) { - return nil -} - -// headerCapturingInterceptor sets custom headers in PreRoundTrip and records -// whether PostRoundTrip was called, to verify execution order. -type headerCapturingInterceptor struct { - preCalled bool - postCalled bool -} - -func (h *headerCapturingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) { - h.preCalled = true - // Set a custom header that should survive (no built-in override) - req.Header.Set("X-Custom-Trace", "ext-trace-123") - // Try to override a security header — should be overwritten by SecurityHeaderTransport - req.Header.Set(HeaderSource, "ext-tampered") - return func(resp *http.Response, err error) { - h.postCalled = true - } -} - -func TestExtensionInterceptor_ExecutionOrder(t *testing.T) { - var receivedHeaders http.Header - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - receivedHeaders = r.Header.Clone() - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - ic := &headerCapturingInterceptor{} - exttransport.Register(&stubTransportProvider{interceptor: ic}) - t.Cleanup(func() { exttransport.Register(nil) }) - - // Use HTTP transport chain (has SecurityHeaderTransport) - var base http.RoundTripper = http.DefaultTransport - base = &RetryTransport{Base: base} - base = &SecurityHeaderTransport{Base: base} - transport := wrapWithExtension(base) - client := &http.Client{Transport: transport} - - req, _ := http.NewRequest("GET", srv.URL, nil) - resp, err := client.Do(req) - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.Body.Close() - - // PreRoundTrip was called - if !ic.preCalled { - t.Fatal("PreRoundTrip was not called") - } - // PostRoundTrip (closure) was called - if !ic.postCalled { - t.Fatal("PostRoundTrip closure was not called") - } - // Custom header set by extension survives (no built-in override) - if got := receivedHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" { - t.Fatalf("X-Custom-Trace = %q, want %q", got, "ext-trace-123") - } - // Security header overridden by extension is restored by SecurityHeaderTransport - if got := receivedHeaders.Get(HeaderSource); got != SourceValue { - t.Fatalf("%s = %q, want %q (built-in should override extension)", HeaderSource, got, SourceValue) - } -} - -func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) { - type ctxKeyType string - const testKey ctxKeyType = "original" - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - var ctxValue any - - // Use a custom transport that captures the context value seen by the built-in chain - capturer := roundTripFunc(func(req *http.Request) (*http.Response, error) { - ctxValue = req.Context().Value(testKey) - return http.DefaultTransport.RoundTrip(req) - }) - - // Interceptor that tries to tamper with context - tamperIC := interceptorFunc(func(req *http.Request) func(*http.Response, error) { - // Try to replace context with a new one - *req = *req.WithContext(context.WithValue(req.Context(), testKey, "tampered")) - return nil - }) +func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.T) { + prev := fileio.GetProvider() + provider := &countingFileIOProvider{} + fileio.Register(provider) + t.Cleanup(func() { fileio.Register(prev) }) - mid := &extensionMiddleware{Base: capturer, Ext: tamperIC} - - origCtx := context.WithValue(context.Background(), testKey, "original") - req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil) - resp, err := mid.RoundTrip(req) - if err != nil { - t.Fatalf("request failed: %v", err) - } - resp.Body.Close() - - // Built-in chain should see original context, not tampered - if ctxValue != "original" { - t.Fatalf("built-in chain saw context value %q, want %q", ctxValue, "original") - } -} - -// interceptorFunc adapts a function to exttransport.Interceptor. -type interceptorFunc func(*http.Request) func(*http.Response, error) - -func (f interceptorFunc) PreRoundTrip(req *http.Request) func(*http.Response, error) { return f(req) } - -func TestBuildSDKTransport_WithExtension(t *testing.T) { - exttransport.Register(&stubTransportProvider{}) - t.Cleanup(func() { exttransport.Register(nil) }) - - transport := buildSDKTransport() - - // Chain: extensionMiddleware → SecurityPolicy → UserAgent → Retry → Base - mid, ok := transport.(*extensionMiddleware) - if !ok { - t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport) - } - sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport) - if !ok { - t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base) - } - ua, ok := sec.Base.(*UserAgentTransport) - if !ok { - t.Fatalf("transport type = %T, want *UserAgentTransport", sec.Base) + f := NewDefault(InvocationContext{}) + if f.FileIOProvider != provider { + t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider) } - if _, ok := ua.Base.(*RetryTransport); !ok { - t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base) + if provider.resolveCalls != 0 { + t.Fatalf("ResolveFileIO() calls after NewDefault() = %d, want 0", provider.resolveCalls) } -} -func TestBuildSDKTransport_WithoutExtension(t *testing.T) { - exttransport.Register(nil) - - transport := buildSDKTransport() - - sec, ok := transport.(*internalauth.SecurityPolicyTransport) - if !ok { - t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport) - } - ua, ok := sec.Base.(*UserAgentTransport) - if !ok { - t.Fatalf("middle transport type = %T, want *UserAgentTransport", sec.Base) + if got := f.ResolveFileIO(context.Background()); got == nil { + t.Fatal("ResolveFileIO() = nil, want non-nil") } - if _, ok := ua.Base.(*RetryTransport); !ok { - t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base) + if provider.resolveCalls != 1 { + t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls) } } diff --git a/internal/cmdutil/testing.go b/internal/cmdutil/testing.go index dc6ac2fb..fa9ab267 100644 --- a/internal/cmdutil/testing.go +++ b/internal/cmdutil/testing.go @@ -13,6 +13,7 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" @@ -63,13 +64,13 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer, ) f := &Factory{ - Config: func() (*core.CliConfig, error) { return config, nil }, - HttpClient: func() (*http.Client, error) { return mockClient, nil }, - LarkClient: func() (*lark.Client, error) { return testLarkClient, nil }, - IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf}, - Keychain: &noopKeychain{}, - Credential: testCred, - FileIO: &LocalFileIO{}, + Config: func() (*core.CliConfig, error) { return config, nil }, + HttpClient: func() (*http.Client, error) { return mockClient, nil }, + LarkClient: func() (*lark.Client, error) { return testLarkClient, nil }, + IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf}, + Keychain: &noopKeychain{}, + Credential: testCred, + FileIOProvider: fileio.GetProvider(), } return f, stdoutBuf, stderrBuf, reg } diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 1cf1f60d..7fec1abd 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/shortcuts/common" @@ -43,7 +44,7 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool return &common.RuntimeContext{ Cmd: cmd, Config: &core.CliConfig{UserOpenId: "ou_test"}, - Factory: &cmdutil.Factory{FileIO: &cmdutil.LocalFileIO{}}, + Factory: &cmdutil.Factory{FileIOProvider: fileio.GetProvider()}, } } diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index fd59b548..d89125e8 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -297,9 +297,12 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams { return ctx.Factory.IOStreams } -// FileIO returns the FileIO from the Factory. +// FileIO resolves the FileIO using the current execution context. func (ctx *RuntimeContext) FileIO() fileio.FileIO { - return ctx.Factory.FileIO + if ctx == nil || ctx.Factory == nil { + return nil + } + return ctx.Factory.ResolveFileIO(ctx.ctx) } // ── Output helpers ── diff --git a/shortcuts/common/runner_jq_test.go b/shortcuts/common/runner_jq_test.go index cce144f4..075f399a 100644 --- a/shortcuts/common/runner_jq_test.go +++ b/shortcuts/common/runner_jq_test.go @@ -7,12 +7,14 @@ import ( "bytes" "context" "io" + "os" "strings" "testing" lark "github.com/larksuite/oapi-sdk-go/v3" "github.com/spf13/cobra" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" @@ -102,6 +104,47 @@ func TestRuntimeContext_Out_WithJq_InvalidExpr_WritesStderr(t *testing.T) { } } +type testResolvedFileIO struct{} + +func (testResolvedFileIO) Open(string) (fileio.File, error) { return nil, nil } +func (testResolvedFileIO) Stat(string) (os.FileInfo, error) { return nil, nil } +func (testResolvedFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) { + return nil, nil +} + +type capturingFileIOProvider struct { + gotCtx context.Context + fileIO fileio.FileIO +} + +func (p *capturingFileIOProvider) Name() string { return "capture" } + +func (p *capturingFileIOProvider) ResolveFileIO(ctx context.Context) fileio.FileIO { + p.gotCtx = ctx + return p.fileIO +} + +func TestRuntimeContext_FileIO_UsesExecutionContext(t *testing.T) { + execCtx := context.WithValue(context.Background(), "key", "value") + resolved := testResolvedFileIO{} + provider := &capturingFileIOProvider{fileIO: resolved} + + rctx := &RuntimeContext{ + ctx: execCtx, + Factory: &cmdutil.Factory{ + FileIOProvider: provider, + }, + } + + got := rctx.FileIO() + if got != resolved { + t.Fatalf("FileIO() returned %T, want %T", got, resolved) + } + if provider.gotCtx != execCtx { + t.Fatal("ResolveFileIO() did not receive the runtime execution context") + } +} + func newTestShortcutCmd(s *Shortcut) *cobra.Command { cmd := &cobra.Command{Use: "test-shortcut"} cmd.SetContext(context.Background()) @@ -119,7 +162,8 @@ func newTestFactory() *cmdutil.Factory { LarkClient: func() (*lark.Client, error) { return lark.NewClient("test", "test"), nil }, - IOStreams: &cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}, + IOStreams: &cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}, + FileIOProvider: fileio.GetProvider(), } } diff --git a/shortcuts/im/helpers_network_test.go b/shortcuts/im/helpers_network_test.go index e1b95444..d7b6c374 100644 --- a/shortcuts/im/helpers_network_test.go +++ b/shortcuts/im/helpers_network_test.go @@ -20,6 +20,7 @@ import ( lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" @@ -89,11 +90,11 @@ func newBotShortcutRuntime(t *testing.T, rt http.RoundTripper) *common.RuntimeCo runtime := &common.RuntimeContext{ Config: cfg, Factory: &cmdutil.Factory{ - Config: func() (*core.CliConfig, error) { return cfg, nil }, - HttpClient: func() (*http.Client, error) { return httpClient, nil }, - LarkClient: func() (*lark.Client, error) { return sdk, nil }, - Credential: testCred, - FileIO: &cmdutil.LocalFileIO{}, + Config: func() (*core.CliConfig, error) { return cfg, nil }, + HttpClient: func() (*http.Client, error) { return httpClient, nil }, + LarkClient: func() (*lark.Client, error) { return sdk, nil }, + Credential: testCred, + FileIOProvider: fileio.GetProvider(), IOStreams: &cmdutil.IOStreams{ Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}, @@ -416,7 +417,7 @@ func TestUploadFileToIMSizeLimit(t *testing.T) { func TestResolveMediaContentWrapsUploadError(t *testing.T) { runtime := &common.RuntimeContext{ Factory: &cmdutil.Factory{ - FileIO: &cmdutil.LocalFileIO{}, + FileIOProvider: fileio.GetProvider(), IOStreams: &cmdutil.IOStreams{ Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}, From 3e48f83b966304a6ebce9c3888041544994c100e Mon Sep 17 00:00:00 2001 From: liushiyao Date: Tue, 7 Apr 2026 20:02:30 +0800 Subject: [PATCH 07/17] refactor: consolidate file I/O into localfileio and eliminate direct os/vfs bypasses - Move LocalFileIO from internal/cmdutil to internal/vfs/localfileio - Move SafeInputPath/SafeOutputPath/AtomicWrite from validate to localfileio, keep thin wrappers in validate for backward compatibility - Fix shortcuts bypassing FileIO: drive_upload os.Open, drive_import vfs.Stat, runner vfs.ReadFile, minutes_download manual SafeOutputPath+MkdirAll+AtomicWrite - Remove dead code: EnsureWritableFile, ValidateSafeOutputDir - RuntimeContext.FileIO() falls back to global registered provider when Factory or FileIOProvider is nil Change-Id: I2e00711c648499fd679321ffab7da353c496d246 --- internal/client/response.go | 2 +- internal/cmdutil/factory_default.go | 1 + internal/cmdutil/factory_default_test.go | 3 +- internal/cmdutil/testing.go | 2 +- internal/validate/atomicwrite.go | 66 +-------- internal/validate/path.go | 122 +---------------- internal/vfs/localfileio/atomicwrite.go | 77 +++++++++++ .../localfileio}/atomicwrite_test.go | 2 +- internal/vfs/localfileio/input.go | 38 ++++++ .../localfileio/localfileio.go} | 29 ++-- internal/vfs/localfileio/path.go | 129 ++++++++++++++++++ .../localfileio}/path_test.go | 2 +- shortcuts/base/helpers_test.go | 6 +- shortcuts/common/common_test.go | 29 ---- shortcuts/common/helpers.go | 17 --- shortcuts/common/runner.go | 26 ++-- shortcuts/common/runner_input_test.go | 1 + shortcuts/common/validate.go | 41 ------ shortcuts/common/validate_test.go | 77 ----------- shortcuts/drive/drive_export_test.go | 3 +- shortcuts/drive/drive_import.go | 18 +-- shortcuts/drive/drive_import_test.go | 5 +- shortcuts/drive/drive_upload.go | 9 +- shortcuts/mail/draft/acceptance_test.go | 14 +- shortcuts/mail/draft/patch_attachment_test.go | 38 +++--- shortcuts/mail/draft/patch_body_test.go | 28 ++-- shortcuts/mail/draft/patch_header_test.go | 20 +-- shortcuts/mail/draft/patch_recipient_test.go | 24 ++-- shortcuts/mail/draft/patch_test.go | 62 ++++----- shortcuts/mail/draft/serialize_golden_test.go | 4 +- shortcuts/mail/draft/serialize_test.go | 14 +- shortcuts/mail/emlbuilder/builder_test.go | 10 +- shortcuts/mail/helpers_test.go | 3 +- shortcuts/minutes/minutes_download.go | 32 ++--- shortcuts/vc/vc_notes.go | 5 +- 35 files changed, 439 insertions(+), 520 deletions(-) create mode 100644 internal/vfs/localfileio/atomicwrite.go rename internal/{validate => vfs/localfileio}/atomicwrite_test.go (99%) create mode 100644 internal/vfs/localfileio/input.go rename internal/{cmdutil/local_fileio.go => vfs/localfileio/localfileio.go} (63%) create mode 100644 internal/vfs/localfileio/path.go rename internal/{validate => vfs/localfileio}/path_test.go (99%) diff --git a/internal/client/response.go b/internal/client/response.go index ccd25f43..bac3381c 100644 --- a/internal/client/response.go +++ b/internal/client/response.go @@ -118,7 +118,7 @@ func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) { // ── File saving ── // SaveResponse writes an API response body to the given outputPath and returns metadata. -// When fio is non-nil, it delegates to FileIO.Save (with path validation and atomic write). +// It delegates to FileIO.Save for path validation and atomic write; fio must not be nil. func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) { result, err := fio.Save(outputPath, fileio.SaveOptions{ ContentType: resp.Header.Get("Content-Type"), diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index 6e51e5d5..162dcb1c 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -24,6 +24,7 @@ import ( "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/util" + _ "github.com/larksuite/cli/internal/vfs/localfileio" ) // NewDefault creates a production Factory with cached closures. diff --git a/internal/cmdutil/factory_default_test.go b/internal/cmdutil/factory_default_test.go index 70a8d6e2..55ee8d1f 100644 --- a/internal/cmdutil/factory_default_test.go +++ b/internal/cmdutil/factory_default_test.go @@ -10,6 +10,7 @@ import ( _ "github.com/larksuite/cli/extension/credential/env" "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/vfs/localfileio" internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" @@ -24,7 +25,7 @@ func (p *countingFileIOProvider) Name() string { return "counting" } func (p *countingFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO { p.resolveCalls++ - return &LocalFileIO{} + return &localfileio.LocalFileIO{} } func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) { diff --git a/internal/cmdutil/testing.go b/internal/cmdutil/testing.go index fa9ab267..3c25e5ef 100644 --- a/internal/cmdutil/testing.go +++ b/internal/cmdutil/testing.go @@ -88,7 +88,7 @@ func (a *testDefaultAcct) ResolveAccount(ctx context.Context) (*credential.Accou // TestChdir changes the working directory to dir for the duration of the test. // The original directory is restored via t.Cleanup. -// This enables tests to use LocalFileIO (which resolves relative paths under cwd) +// This enables tests to use localfileio.LocalFileIO (which resolves relative paths under cwd) // with temporary directories, keeping test artifacts out of the source tree. // Not compatible with t.Parallel() — os.Chdir is process-wide. func TestChdir(t *testing.T, dir string) { diff --git a/internal/validate/atomicwrite.go b/internal/validate/atomicwrite.go index 5c1ec9c9..e60f52a7 100644 --- a/internal/validate/atomicwrite.go +++ b/internal/validate/atomicwrite.go @@ -4,74 +4,18 @@ package validate import ( - "fmt" "io" "os" - "path/filepath" - "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/internal/vfs/localfileio" ) -// AtomicWrite writes data to path atomically by creating a temp file in the -// same directory, writing and fsyncing the data, then renaming over the target. -// It replaces os.WriteFile for all config and download file writes. -// -// os.WriteFile truncates the target before writing, so a process kill (CI timeout, -// OOM, Ctrl+C) between truncate and completion leaves the file empty or partial. -// AtomicWrite avoids this: on any failure the temp file is cleaned up and the -// original file remains untouched. +// AtomicWrite delegates to localfileio.AtomicWrite. func AtomicWrite(path string, data []byte, perm os.FileMode) error { - return atomicWrite(path, perm, func(tmp *os.File) error { - _, err := tmp.Write(data) - return err - }) + return localfileio.AtomicWrite(path, data, perm) } -// AtomicWriteFromReader atomically copies reader contents into path. +// AtomicWriteFromReader delegates to localfileio.AtomicWriteFromReader. func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) { - var copied int64 - err := atomicWrite(path, perm, func(tmp *os.File) error { - n, err := io.Copy(tmp, reader) - copied = n - return err - }) - if err != nil { - return 0, err - } - return copied, nil -} - -func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error { - dir := filepath.Dir(path) - tmp, err := vfs.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") - if err != nil { - return fmt.Errorf("create temp file: %w", err) - } - tmpName := tmp.Name() - - success := false - defer func() { - if !success { - tmp.Close() - vfs.Remove(tmpName) - } - }() - - if err := tmp.Chmod(perm); err != nil { - return err - } - if err := writeFn(tmp); err != nil { - return err - } - if err := tmp.Sync(); err != nil { - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := vfs.Rename(tmpName, path); err != nil { - return err - } - success = true - return nil + return localfileio.AtomicWriteFromReader(path, reader, perm) } diff --git a/internal/validate/path.go b/internal/validate/path.go index ecc6e973..120bf276 100644 --- a/internal/validate/path.go +++ b/internal/validate/path.go @@ -3,127 +3,19 @@ package validate -import ( - "fmt" - "path/filepath" - "strings" +import "github.com/larksuite/cli/internal/vfs/localfileio" - "github.com/larksuite/cli/internal/vfs" -) - -// SafeOutputPath validates a download/export target path for --output flags. -// It rejects absolute paths, resolves symlinks to their real location, and -// verifies the canonical result is still under the current working directory. -// This prevents an AI Agent from being tricked into writing files outside the -// working directory (e.g. "../../.ssh/authorized_keys") or following symlinks -// to sensitive locations. -// -// The returned absolute path MUST be used for all subsequent I/O to prevent -// time-of-check-to-time-of-use (TOCTOU) race conditions. +// SafeOutputPath delegates to localfileio.SafeOutputPath. func SafeOutputPath(path string) (string, error) { - return safePath(path, "--output") + return localfileio.SafeOutputPath(path) } -// SafeInputPath validates an upload/read source path for --file flags. -// It applies the same rules as SafeOutputPath — rejecting absolute paths, -// resolving symlinks, and enforcing working directory containment — to prevent an AI Agent -// from being tricked into reading sensitive files like /etc/passwd. +// SafeInputPath delegates to localfileio.SafeInputPath. func SafeInputPath(path string) (string, error) { - return safePath(path, "--file") + return localfileio.SafeInputPath(path) } -// SafeLocalFlagPath validates a flag value as a local file path. -// Empty values and http/https URLs are returned unchanged without validation, -// allowing the caller to handle non-path inputs (e.g. API keys, URLs) upstream. -// For all other values, SafeInputPath rules apply. -// The original relative path is returned unchanged (not resolved to absolute) so -// upload helpers can re-validate at the actual I/O point via SafeUploadPath. +// SafeLocalFlagPath delegates to localfileio.SafeLocalFlagPath. func SafeLocalFlagPath(flagName, value string) (string, error) { - if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { - return value, nil - } - if _, err := SafeInputPath(value); err != nil { - return "", fmt.Errorf("%s: %v", flagName, err) - } - return value, nil -} - -// safePath is the shared implementation for SafeOutputPath and SafeInputPath. -func safePath(raw, flagName string) (string, error) { - if err := RejectControlChars(raw, flagName); err != nil { - return "", err - } - - path := filepath.Clean(raw) - - if filepath.IsAbs(path) { - return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw) - } - - cwd, err := vfs.Getwd() - if err != nil { - return "", fmt.Errorf("cannot determine working directory: %w", err) - } - resolved := filepath.Join(cwd, path) - - // Resolve symlinks: for existing paths, follow to real location; - // for non-existing paths, walk up to the nearest existing ancestor, - // resolve its symlinks, and re-attach the remaining tail segments. - // This prevents TOCTOU attacks where a non-existent intermediate - // directory is replaced with a symlink between check and use. - if _, err := vfs.Lstat(resolved); err == nil { - resolved, err = filepath.EvalSymlinks(resolved) - if err != nil { - return "", fmt.Errorf("cannot resolve symlinks: %w", err) - } - } else { - resolved, err = resolveNearestAncestor(resolved) - if err != nil { - return "", fmt.Errorf("cannot resolve symlinks: %w", err) - } - } - - canonicalCwd, _ := filepath.EvalSymlinks(cwd) - if !isUnderDir(resolved, canonicalCwd) { - return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw) - } - - return resolved, nil -} - -// resolveNearestAncestor walks up from path until it finds an existing -// ancestor, resolves that ancestor's symlinks, and re-joins the tail. -// This ensures even deeply nested non-existent paths are anchored to a -// real filesystem location, closing the TOCTOU symlink gap. -func resolveNearestAncestor(path string) (string, error) { - var tail []string - cur := path - for { - if _, err := vfs.Lstat(cur); err == nil { - real, err := filepath.EvalSymlinks(cur) - if err != nil { - return "", err - } - parts := append([]string{real}, tail...) - return filepath.Join(parts...), nil - } - parent := filepath.Dir(cur) - if parent == cur { - // Reached filesystem root without finding an existing ancestor; - // return path as-is and let the containment check reject it. - parts := append([]string{cur}, tail...) - return filepath.Join(parts...), nil - } - tail = append([]string{filepath.Base(cur)}, tail...) - cur = parent - } -} - -// isUnderDir checks whether child is under parent directory. -func isUnderDir(child, parent string) bool { - rel, err := filepath.Rel(parent, child) - if err != nil { - return false - } - return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".." + return localfileio.SafeLocalFlagPath(flagName, value) } diff --git a/internal/vfs/localfileio/atomicwrite.go b/internal/vfs/localfileio/atomicwrite.go new file mode 100644 index 00000000..9035170e --- /dev/null +++ b/internal/vfs/localfileio/atomicwrite.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package localfileio + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/larksuite/cli/internal/vfs" +) + +// AtomicWrite writes data to path atomically by creating a temp file in the +// same directory, writing and fsyncing the data, then renaming over the target. +// It replaces os.WriteFile for all config and download file writes. +// +// os.WriteFile truncates the target before writing, so a process kill (CI timeout, +// OOM, Ctrl+C) between truncate and completion leaves the file empty or partial. +// AtomicWrite avoids this: on any failure the temp file is cleaned up and the +// original file remains untouched. +func AtomicWrite(path string, data []byte, perm os.FileMode) error { + return atomicWrite(path, perm, func(tmp *os.File) error { + _, err := tmp.Write(data) + return err + }) +} + +// AtomicWriteFromReader atomically copies reader contents into path. +func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) { + var copied int64 + err := atomicWrite(path, perm, func(tmp *os.File) error { + n, err := io.Copy(tmp, reader) + copied = n + return err + }) + if err != nil { + return 0, err + } + return copied, nil +} + +func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error { + dir := filepath.Dir(path) + tmp, err := vfs.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + + success := false + defer func() { + if !success { + tmp.Close() + vfs.Remove(tmpName) + } + }() + + if err := tmp.Chmod(perm); err != nil { + return err + } + if err := writeFn(tmp); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := vfs.Rename(tmpName, path); err != nil { + return err + } + success = true + return nil +} diff --git a/internal/validate/atomicwrite_test.go b/internal/vfs/localfileio/atomicwrite_test.go similarity index 99% rename from internal/validate/atomicwrite_test.go rename to internal/vfs/localfileio/atomicwrite_test.go index b4e328b0..d8dbbb75 100644 --- a/internal/validate/atomicwrite_test.go +++ b/internal/vfs/localfileio/atomicwrite_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package validate +package localfileio import ( "os" diff --git a/internal/vfs/localfileio/input.go b/internal/vfs/localfileio/input.go new file mode 100644 index 00000000..f1069f01 --- /dev/null +++ b/internal/vfs/localfileio/input.go @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package localfileio + +import "fmt" + +// rejectControlChars rejects C0 control characters (except \t and \n) and +// dangerous Unicode characters from path input. Used by safePath before +// any filesystem access. +func rejectControlChars(value, flagName string) error { + for _, r := range value { + if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) { + return fmt.Errorf("%s contains invalid control characters", flagName) + } + if isDangerousUnicode(r) { + return fmt.Errorf("%s contains dangerous Unicode characters", flagName) + } + } + return nil +} + +// isDangerousUnicode identifies Unicode code points used for visual spoofing attacks. +func isDangerousUnicode(r rune) bool { + switch { + case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner + return true + case r == 0xFEFF: // BOM / ZWNBSP + return true + case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO + return true + case r >= 0x2028 && r <= 0x2029: // line/paragraph separator + return true + case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI + return true + } + return false +} diff --git a/internal/cmdutil/local_fileio.go b/internal/vfs/localfileio/localfileio.go similarity index 63% rename from internal/cmdutil/local_fileio.go rename to internal/vfs/localfileio/localfileio.go index 37dfae22..2ce09aa4 100644 --- a/internal/cmdutil/local_fileio.go +++ b/internal/vfs/localfileio/localfileio.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package cmdutil +package localfileio import ( "context" @@ -10,20 +10,19 @@ import ( "path/filepath" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/validate" ) -// localFileIOProvider is the default fileio.Provider backed by the local filesystem. -type localFileIOProvider struct{} +// Provider is the default fileio.Provider backed by the local filesystem. +type Provider struct{} -func (p *localFileIOProvider) Name() string { return "local" } +func (p *Provider) Name() string { return "local" } -func (p *localFileIOProvider) ResolveFileIO(_ context.Context) fileio.FileIO { +func (p *Provider) ResolveFileIO(_ context.Context) fileio.FileIO { return &LocalFileIO{} } func init() { - fileio.Register(&localFileIOProvider{}) + fileio.Register(&Provider{}) } // LocalFileIO implements fileio.FileIO using the local filesystem. @@ -33,7 +32,7 @@ type LocalFileIO struct{} // Open opens a local file for reading after validating the path. func (l *LocalFileIO) Open(name string) (fileio.File, error) { - safePath, err := validate.SafeInputPath(name) + safePath, err := SafeInputPath(name) if err != nil { return nil, err } @@ -42,32 +41,32 @@ func (l *LocalFileIO) Open(name string) (fileio.File, error) { // Stat returns file metadata after validating the path. func (l *LocalFileIO) Stat(name string) (os.FileInfo, error) { - safePath, err := validate.SafeInputPath(name) + safePath, err := SafeInputPath(name) if err != nil { return nil, err } return os.Stat(safePath) } -// localSaveResult implements fileio.SaveResult. -type localSaveResult struct{ size int64 } +// saveResult implements fileio.SaveResult. +type saveResult struct{ size int64 } -func (r *localSaveResult) Size() int64 { return r.size } +func (r *saveResult) Size() int64 { return r.size } // Save writes body to path atomically after validating the output path. // Parent directories are created as needed. The body is streamed directly // to a temp file and renamed, avoiding full in-memory buffering. func (l *LocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) { - safePath, err := validate.SafeOutputPath(path) + safePath, err := SafeOutputPath(path) if err != nil { return nil, err } if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil { return nil, err } - n, err := validate.AtomicWriteFromReader(safePath, body, 0644) + n, err := AtomicWriteFromReader(safePath, body, 0644) if err != nil { return nil, err } - return &localSaveResult{size: n}, nil + return &saveResult{size: n}, nil } diff --git a/internal/vfs/localfileio/path.go b/internal/vfs/localfileio/path.go new file mode 100644 index 00000000..3beb0983 --- /dev/null +++ b/internal/vfs/localfileio/path.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package localfileio + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/vfs" +) + +// SafeOutputPath validates a download/export target path for --output flags. +// It rejects absolute paths, resolves symlinks to their real location, and +// verifies the canonical result is still under the current working directory. +// This prevents an AI Agent from being tricked into writing files outside the +// working directory (e.g. "../../.ssh/authorized_keys") or following symlinks +// to sensitive locations. +// +// The returned absolute path MUST be used for all subsequent I/O to prevent +// time-of-check-to-time-of-use (TOCTOU) race conditions. +func SafeOutputPath(path string) (string, error) { + return safePath(path, "--output") +} + +// SafeInputPath validates an upload/read source path for --file flags. +// It applies the same rules as SafeOutputPath — rejecting absolute paths, +// resolving symlinks, and enforcing working directory containment — to prevent an AI Agent +// from being tricked into reading sensitive files like /etc/passwd. +func SafeInputPath(path string) (string, error) { + return safePath(path, "--file") +} + +// SafeLocalFlagPath validates a flag value as a local file path. +// Empty values and http/https URLs are returned unchanged without validation, +// allowing the caller to handle non-path inputs (e.g. API keys, URLs) upstream. +// For all other values, SafeInputPath rules apply. +// The original relative path is returned unchanged (not resolved to absolute) so +// upload helpers can re-validate at the actual I/O point via SafeUploadPath. +func SafeLocalFlagPath(flagName, value string) (string, error) { + if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { + return value, nil + } + if _, err := SafeInputPath(value); err != nil { + return "", fmt.Errorf("%s: %v", flagName, err) + } + return value, nil +} + +// safePath is the shared implementation for SafeOutputPath and SafeInputPath. +func safePath(raw, flagName string) (string, error) { + if err := rejectControlChars(raw, flagName); err != nil { + return "", err + } + + path := filepath.Clean(raw) + + if filepath.IsAbs(path) { + return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw) + } + + cwd, err := vfs.Getwd() + if err != nil { + return "", fmt.Errorf("cannot determine working directory: %w", err) + } + resolved := filepath.Join(cwd, path) + + // Resolve symlinks: for existing paths, follow to real location; + // for non-existing paths, walk up to the nearest existing ancestor, + // resolve its symlinks, and re-attach the remaining tail segments. + // This prevents TOCTOU attacks where a non-existent intermediate + // directory is replaced with a symlink between check and use. + if _, err := vfs.Lstat(resolved); err == nil { + resolved, err = filepath.EvalSymlinks(resolved) + if err != nil { + return "", fmt.Errorf("cannot resolve symlinks: %w", err) + } + } else { + resolved, err = resolveNearestAncestor(resolved) + if err != nil { + return "", fmt.Errorf("cannot resolve symlinks: %w", err) + } + } + + canonicalCwd, _ := filepath.EvalSymlinks(cwd) + if !isUnderDir(resolved, canonicalCwd) { + return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw) + } + + return resolved, nil +} + +// resolveNearestAncestor walks up from path until it finds an existing +// ancestor, resolves that ancestor's symlinks, and re-joins the tail. +// This ensures even deeply nested non-existent paths are anchored to a +// real filesystem location, closing the TOCTOU symlink gap. +func resolveNearestAncestor(path string) (string, error) { + var tail []string + cur := path + for { + if _, err := vfs.Lstat(cur); err == nil { + real, err := filepath.EvalSymlinks(cur) + if err != nil { + return "", err + } + parts := append([]string{real}, tail...) + return filepath.Join(parts...), nil + } + parent := filepath.Dir(cur) + if parent == cur { + // Reached filesystem root without finding an existing ancestor; + // return path as-is and let the containment check reject it. + parts := append([]string{cur}, tail...) + return filepath.Join(parts...), nil + } + tail = append([]string{filepath.Base(cur)}, tail...) + cur = parent + } +} + +// isUnderDir checks whether child is under parent directory. +func isUnderDir(child, parent string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".." +} diff --git a/internal/validate/path_test.go b/internal/vfs/localfileio/path_test.go similarity index 99% rename from internal/validate/path_test.go rename to internal/vfs/localfileio/path_test.go index bc6b1f48..8a490397 100644 --- a/internal/validate/path_test.go +++ b/internal/vfs/localfileio/path_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package validate +package localfileio import ( "os" diff --git a/shortcuts/base/helpers_test.go b/shortcuts/base/helpers_test.go index 076ede84..bdd582ce 100644 --- a/shortcuts/base/helpers_test.go +++ b/shortcuts/base/helpers_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/vfs/localfileio" ) func TestParseHelpers(t *testing.T) { @@ -32,7 +32,7 @@ func TestParseHelpers(t *testing.T) { t.Fatalf("write temp file err=%v", err) } _ = tmp.Close() - fio := &cmdutil.LocalFileIO{} + fio := &localfileio.LocalFileIO{} obj, err := parseJSONObject(nil, `{"name":"demo"}`, "json") if err != nil || obj["name"] != "demo" { t.Fatalf("obj=%v err=%v", obj, err) @@ -265,7 +265,7 @@ func TestFilterAndSortHelpers(t *testing.T) { } func TestJSONInputHelpers(t *testing.T) { - fio2 := &cmdutil.LocalFileIO{} + fio2 := &localfileio.LocalFileIO{} if got, err := loadJSONInput(nil, `{"name":"demo"}`, "json"); err != nil || got != `{"name":"demo"}` { t.Fatalf("got=%q err=%v", got, err) } diff --git a/shortcuts/common/common_test.go b/shortcuts/common/common_test.go index 7c8f02e9..a8cb7dc8 100644 --- a/shortcuts/common/common_test.go +++ b/shortcuts/common/common_test.go @@ -5,8 +5,6 @@ package common import ( "fmt" - "os" - "path/filepath" "strconv" "testing" "time" @@ -58,31 +56,4 @@ func TestParseTimeEndHint(t *testing.T) { } } -func TestEnsureWritableFile(t *testing.T) { - t.Run("allows missing target", func(t *testing.T) { - path := filepath.Join(t.TempDir(), "missing.txt") - if err := EnsureWritableFile(path, false); err != nil { - t.Fatalf("EnsureWritableFile() unexpected error: %v", err) - } - }) - t.Run("rejects existing target without overwrite", func(t *testing.T) { - path := filepath.Join(t.TempDir(), "exists.txt") - if err := os.WriteFile(path, []byte("data"), 0644); err != nil { - t.Fatalf("WriteFile() error: %v", err) - } - if err := EnsureWritableFile(path, false); err == nil { - t.Fatalf("expected overwrite protection error, got nil") - } - }) - - t.Run("allows existing target with overwrite", func(t *testing.T) { - path := filepath.Join(t.TempDir(), "exists.txt") - if err := os.WriteFile(path, []byte("data"), 0644); err != nil { - t.Fatalf("WriteFile() error: %v", err) - } - if err := EnsureWritableFile(path, true); err != nil { - t.Fatalf("EnsureWritableFile() unexpected error: %v", err) - } - }) -} diff --git a/shortcuts/common/helpers.go b/shortcuts/common/helpers.go index a4704346..3e06ecd1 100644 --- a/shortcuts/common/helpers.go +++ b/shortcuts/common/helpers.go @@ -5,14 +5,9 @@ package common import ( "encoding/json" - "errors" "io" "mime/multipart" "net/textproto" - "os" - - "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/vfs" ) // MultipartWriter wraps multipart.Writer for file uploads. @@ -38,15 +33,3 @@ func ParseJSON(data []byte, v interface{}) error { return json.Unmarshal(data, v) } -// EnsureWritableFile refuses to overwrite an existing file unless overwrite is true. -func EnsureWritableFile(path string, overwrite bool) error { - if overwrite { - return nil - } - if _, err := vfs.Stat(path); err == nil { - return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", path) - } else if !errors.Is(err, os.ErrNotExist) { - return output.Errorf(output.ExitInternal, "io", "cannot access output path %s: %v", path, err) - } - return nil -} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index d89125e8..423297eb 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -24,8 +24,6 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" "github.com/spf13/cobra" ) @@ -298,11 +296,22 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams { } // FileIO resolves the FileIO using the current execution context. +// Falls back to the globally registered provider when Factory or its +// FileIOProvider is nil (e.g. in lightweight test helpers). func (ctx *RuntimeContext) FileIO() fileio.FileIO { - if ctx == nil || ctx.Factory == nil { - return nil + if ctx != nil && ctx.Factory != nil { + if fio := ctx.Factory.ResolveFileIO(ctx.ctx); fio != nil { + return fio + } + } + if p := fileio.GetProvider(); p != nil { + c := context.Background() + if ctx != nil { + c = ctx.ctx + } + return p.ResolveFileIO(c) } - return ctx.Factory.ResolveFileIO(ctx.ctx) + return nil } // ── Output helpers ── @@ -584,11 +593,12 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error { if path == "" { return FlagErrorf("--%s: file path cannot be empty after @", fl.Name) } - safePath, err := validate.SafeInputPath(path) + f, err := rctx.FileIO().Open(path) if err != nil { - return FlagErrorf("--%s: invalid file path %q: %v", fl.Name, path, err) + return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err) } - data, err := vfs.ReadFile(safePath) + data, err := io.ReadAll(f) + f.Close() if err != nil { return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err) } diff --git a/shortcuts/common/runner_input_test.go b/shortcuts/common/runner_input_test.go index 25aa806b..058e2870 100644 --- a/shortcuts/common/runner_input_test.go +++ b/shortcuts/common/runner_input_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/larksuite/cli/internal/cmdutil" + _ "github.com/larksuite/cli/internal/vfs/localfileio" "github.com/spf13/cobra" ) diff --git a/shortcuts/common/validate.go b/shortcuts/common/validate.go index b894ddf8..37d817c8 100644 --- a/shortcuts/common/validate.go +++ b/shortcuts/common/validate.go @@ -5,13 +5,10 @@ package common import ( "fmt" - "os" - "path/filepath" "strconv" "strings" "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/vfs" ) // FlagErrorf returns a validation error with flag context (exit code 2). @@ -85,44 +82,6 @@ func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int { return v } -// ValidateSafeOutputDir ensures outputDir is a relative path that resolves -// within the current working directory, preventing path traversal attacks -// (including symlink-based escape). -func ValidateSafeOutputDir(outputDir string) error { - if filepath.IsAbs(outputDir) { - return fmt.Errorf("--output-dir must be a relative path, got: %q", outputDir) - } - cwd, err := vfs.Getwd() - if err != nil { - return fmt.Errorf("cannot determine working directory: %w", err) - } - canonicalCwd, err := filepath.EvalSymlinks(cwd) - if err != nil { - canonicalCwd = cwd - } - abs := filepath.Clean(filepath.Join(cwd, outputDir)) - - // Resolve symlinks in abs to prevent symlink-escape attacks (e.g. an - // attacker-controlled symlink inside CWD pointing outside). - canonicalAbs, err := filepath.EvalSymlinks(abs) - if err != nil { - if !os.IsNotExist(err) { - return fmt.Errorf("--output-dir %q: %w", outputDir, err) - } - // Path does not exist yet. If os.Lstat succeeds the entry is a dangling - // symlink — reject it to prevent future escapes once the target is created. - if _, lstErr := vfs.Lstat(abs); lstErr == nil { - return fmt.Errorf("--output-dir %q is a symlink with a non-existent target", outputDir) - } - // The path itself doesn't exist; the string-level check is sufficient. - canonicalAbs = abs - } - - if !strings.HasPrefix(canonicalAbs, canonicalCwd+string(filepath.Separator)) { - return fmt.Errorf("--output-dir %q resolves outside the working directory", outputDir) - } - return nil -} // RejectDangerousChars returns an error if value contains ASCII control // characters or dangerous Unicode code points. diff --git a/shortcuts/common/validate_test.go b/shortcuts/common/validate_test.go index d33d2535..87a6c753 100644 --- a/shortcuts/common/validate_test.go +++ b/shortcuts/common/validate_test.go @@ -4,8 +4,6 @@ package common import ( - "os" - "path/filepath" "testing" "github.com/spf13/cobra" @@ -170,78 +168,3 @@ func TestParseIntBounded(t *testing.T) { } } -// --------------------------------------------------------------------------- -// ValidateSafeOutputDir — symlink escape prevention -// --------------------------------------------------------------------------- - -// chdirForTest changes CWD to dir and restores the original CWD on cleanup. -func chdirForTest(t *testing.T, dir string) { - t.Helper() - orig, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd: %v", err) - } - if err := os.Chdir(dir); err != nil { - t.Fatalf("Chdir(%q): %v", dir, err) - } - t.Cleanup(func() { os.Chdir(orig) }) -} - -// TestValidateSafeOutputDir_RejectsSymlinkEscape verifies that a relative path -// that resolves to a symlink pointing outside CWD is rejected. -func TestValidateSafeOutputDir_RejectsSymlinkEscape(t *testing.T) { - outside := t.TempDir() // target outside CWD - workDir := t.TempDir() - chdirForTest(t, workDir) - - // Create a symlink inside CWD pointing to outside. - if err := os.Symlink(outside, filepath.Join(workDir, "evil_out")); err != nil { - t.Fatalf("Symlink: %v", err) - } - - if err := ValidateSafeOutputDir("evil_out"); err == nil { - t.Fatal("expected error for symlink pointing outside CWD, got nil") - } -} - -// TestValidateSafeOutputDir_RejectsDanglingSymlink verifies that a dangling -// symlink (target does not exist) is rejected to prevent future escapes. -func TestValidateSafeOutputDir_RejectsDanglingSymlink(t *testing.T) { - workDir := t.TempDir() - chdirForTest(t, workDir) - - if err := os.Symlink("/nonexistent/outside/target", filepath.Join(workDir, "dangling")); err != nil { - t.Fatalf("Symlink: %v", err) - } - - if err := ValidateSafeOutputDir("dangling"); err == nil { - t.Fatal("expected error for dangling symlink, got nil") - } -} - -// TestValidateSafeOutputDir_AllowsNormalSubdir verifies that an existing real -// subdirectory within CWD is accepted. -func TestValidateSafeOutputDir_AllowsNormalSubdir(t *testing.T) { - workDir := t.TempDir() - chdirForTest(t, workDir) - - subDir := filepath.Join(workDir, "output") - if err := os.Mkdir(subDir, 0700); err != nil { - t.Fatalf("Mkdir: %v", err) - } - - if err := ValidateSafeOutputDir("output"); err != nil { - t.Fatalf("expected no error for real subdir, got: %v", err) - } -} - -// TestValidateSafeOutputDir_AllowsNonExistentPath verifies that a path that -// does not yet exist (new output directory) is accepted. -func TestValidateSafeOutputDir_AllowsNonExistentPath(t *testing.T) { - workDir := t.TempDir() - chdirForTest(t, workDir) - - if err := ValidateSafeOutputDir("new_output_dir"); err != nil { - t.Fatalf("expected no error for non-existent path, got: %v", err) - } -} diff --git a/shortcuts/drive/drive_export_test.go b/shortcuts/drive/drive_export_test.go index 599a3e52..3ec1f4a7 100644 --- a/shortcuts/drive/drive_export_test.go +++ b/shortcuts/drive/drive_export_test.go @@ -14,6 +14,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/vfs/localfileio" "github.com/larksuite/cli/internal/output" ) @@ -472,7 +473,7 @@ func TestSaveContentToOutputDirRejectsOverwriteWithoutFlag(t *testing.T) { } t.Cleanup(func() { _ = os.Chdir(cwd) }) - _, err = saveContentToOutputDir(&cmdutil.LocalFileIO{}, ".", "exists.txt", []byte("new"), false) + _, err = saveContentToOutputDir(&localfileio.LocalFileIO{}, ".", "exists.txt", []byte("new"), false) if err == nil || !strings.Contains(err.Error(), "already exists") { t.Fatalf("expected overwrite error, got %v", err) } diff --git a/shortcuts/drive/drive_import.go b/shortcuts/drive/drive_import.go index 85f41fd2..33983da5 100644 --- a/shortcuts/drive/drive_import.go +++ b/shortcuts/drive/drive_import.go @@ -10,10 +10,8 @@ import ( "strings" - "github.com/larksuite/cli/internal/vfs" - + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -50,7 +48,7 @@ var DriveImport = common.Shortcut{ FolderToken: runtime.Str("folder-token"), Name: runtime.Str("name"), } - fileSize, err := preflightDriveImportFile(&spec) + fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec) if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } @@ -77,7 +75,7 @@ var DriveImport = common.Shortcut{ FolderToken: runtime.Str("folder-token"), Name: runtime.Str("name"), } - if _, err := preflightDriveImportFile(&spec); err != nil { + if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil { return err } @@ -140,15 +138,11 @@ var DriveImport = common.Shortcut{ }, } -func preflightDriveImportFile(spec *driveImportSpec) (int64, error) { +func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) { // Keep dry-run and execution aligned on path normalization, file existence, // and format-specific size limits before planning the upload path. - safeFilePath, err := validate.SafeInputPath(spec.FilePath) - if err != nil { - return 0, output.ErrValidation("unsafe file path: %s", err) - } - - info, err := vfs.Stat(safeFilePath) + // Path validation (SafeInputPath) is handled internally by fio.Stat. + info, err := fio.Stat(spec.FilePath) if err != nil { return 0, output.ErrValidation("cannot read file: %s", err) } diff --git a/shortcuts/drive/drive_import_test.go b/shortcuts/drive/drive_import_test.go index 91301a1d..ff05eb44 100644 --- a/shortcuts/drive/drive_import_test.go +++ b/shortcuts/drive/drive_import_test.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" + _ "github.com/larksuite/cli/internal/vfs/localfileio" "github.com/larksuite/cli/shortcuts/common" ) @@ -218,8 +219,8 @@ func TestDriveImportDryRunReturnsErrorForUnsafePath(t *testing.T) { if err := json.Unmarshal(data, &got); err != nil { t.Fatalf("unmarshal dry run json: %v", err) } - if got.Error == "" || !strings.Contains(got.Error, "unsafe file path") { - t.Fatalf("dry-run error = %q, want unsafe file path error", got.Error) + if got.Error == "" || !strings.Contains(got.Error, "resolves outside the current working directory") { + t.Fatalf("dry-run error = %q, want path escape error", got.Error) } if len(got.API) != 0 { t.Fatalf("expected no API calls when preflight fails, got %d", len(got.API)) diff --git a/shortcuts/drive/drive_upload.go b/shortcuts/drive/drive_upload.go index 6c9f18d8..9ec46f47 100644 --- a/shortcuts/drive/drive_upload.go +++ b/shortcuts/drive/drive_upload.go @@ -10,7 +10,6 @@ import ( "fmt" "io" "net/http" - "os" "path/filepath" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -175,20 +174,16 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file partSize = remaining } - partFile, err := os.Open(filePath) + partFile, err := runtime.FileIO().Open(filePath) if err != nil { return "", output.ErrValidation("cannot open file: %v", err) } - if _, err := partFile.Seek(offset, io.SeekStart); err != nil { - partFile.Close() - return "", output.Errorf(output.ExitInternal, "internal_error", "seek to block %d failed: %v", seq, err) - } fd := larkcore.NewFormdata() fd.AddField("upload_id", uploadID) fd.AddField("seq", fmt.Sprintf("%d", seq)) fd.AddField("size", fmt.Sprintf("%d", partSize)) - fd.AddFile("file", io.LimitReader(partFile, partSize)) + fd.AddFile("file", io.NewSectionReader(partFile, offset, partSize)) apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ HttpMethod: http.MethodPost, diff --git a/shortcuts/mail/draft/acceptance_test.go b/shortcuts/mail/draft/acceptance_test.go index 6c62fcbf..63d6a029 100644 --- a/shortcuts/mail/draft/acceptance_test.go +++ b/shortcuts/mail/draft/acceptance_test.go @@ -6,7 +6,7 @@ package draft import ( "testing" - "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/vfs/localfileio" ) func TestAcceptanceReplyDraftSubjectOnly(t *testing.T) { @@ -15,7 +15,7 @@ func TestAcceptanceReplyDraftSubjectOnly(t *testing.T) { originalInline := findPart(snapshot.Body, "1.2") originalAttachment := findPart(snapshot.Body, "1.3") - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_subject", Value: "Reply updated"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -50,7 +50,7 @@ func TestAcceptanceReplyDraftSubjectOnly(t *testing.T) { func TestAcceptanceHTMLInlineReplaceHTML(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/html_inline_draft.eml")) - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/html", Selector: "primary", Value: `
updated
`}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -74,7 +74,7 @@ func TestAcceptanceHTMLInlineReplaceHTML(t *testing.T) { func TestAcceptanceAlternativeSetBodyUpdatesHTMLAndSummary(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/alternative_draft.eml")) - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "
updated body
"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -101,7 +101,7 @@ func TestAcceptanceCalendarDraftAppendPlainPreservesCalendar(t *testing.T) { if originalCalendar == nil { t.Fatalf("calendar part missing") } - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: "\nagenda"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -126,7 +126,7 @@ func TestAcceptanceCalendarDraftAppendPlainPreservesCalendar(t *testing.T) { func TestAcceptanceSignedDraftSubjectOnlyPreservesSignedEntity(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/multipart_signed_draft.eml")) originalBodyEntity := string(snapshot.Body.RawEntity) - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_subject", Value: "Signed updated"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -148,7 +148,7 @@ func TestAcceptanceDirtyMultipartAppendPlainPreservesOuterNoise(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/dirty_multipart_preamble.eml")) originalPreamble := string(snapshot.Body.Preamble) originalEpilogue := string(snapshot.Body.Epilogue) - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: "\nworld"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) diff --git a/shortcuts/mail/draft/patch_attachment_test.go b/shortcuts/mail/draft/patch_attachment_test.go index f49c21c6..af2ed06d 100644 --- a/shortcuts/mail/draft/patch_attachment_test.go +++ b/shortcuts/mail/draft/patch_attachment_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/vfs/localfileio" ) // --------------------------------------------------------------------------- @@ -29,7 +29,7 @@ func TestAddAttachmentToNilBodyCreatesRoot(t *testing.T) { } // Apply manually with a minimal patch (bypass Patch validation since we // have no body part to detect) - err := addAttachment(&cmdutil.LocalFileIO{}, snapshot, "file.txt") + err := addAttachment(&localfileio.LocalFileIO{}, snapshot, "file.txt") if err != nil { t.Fatalf("addAttachment() error = %v", err) } @@ -53,7 +53,7 @@ func TestAddAttachmentToExistingMultipartMixed(t *testing.T) { } snapshot := mustParseFixtureDraft(t, fixtureData) originalChildren := len(snapshot.Body.Children) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_attachment", Path: "second.txt"}}, }) if err != nil { @@ -86,7 +86,7 @@ func TestAddAttachmentBlockedExtensionViaApply(t *testing.T) { snapshot := mustParseFixtureDraft(t, fixtureData) for _, name := range blocked { t.Run(name, func(t *testing.T) { - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_attachment", Path: name}}, }) if err == nil { @@ -113,7 +113,7 @@ func TestAddAttachmentAllowedExtensionViaApply(t *testing.T) { for _, name := range allowed { t.Run(name, func(t *testing.T) { snapshot := mustParseFixtureDraft(t, fixtureData) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_attachment", Path: name}}, }) if err != nil { @@ -144,7 +144,7 @@ Content-Type: text/html; charset=UTF-8 `) for _, name := range []string{"icon.svg", "evil.png"} { t.Run(name, func(t *testing.T) { - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_inline", Path: name, CID: "img1"}}, }) if err == nil { @@ -169,7 +169,7 @@ Content-Type: text/html; charset=UTF-8
hello
`) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_inline", Path: name, CID: "img1"}}, }) if err != nil { @@ -196,7 +196,7 @@ Content-Type: text/html; charset=UTF-8
hello
`) // User passes a spoofed content_type; it should be ignored in favor of detected type. - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_inline", Path: "logo.png", CID: "img1", ContentType: "application/octet-stream"}}, }) if err != nil { @@ -236,7 +236,7 @@ PHN2Zz48L3N2Zz4= // The old part has image/svg+xml. Replace with a PNG file; the filename // falls back to the path ("new.png") since the old part's name is "icon.svg" // which would fail the extension whitelist, so we pass an explicit filename. - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, @@ -259,7 +259,7 @@ PHN2Zz48L3N2Zz4= func TestRemoveAttachmentRejectsInlinePart(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/html_inline_draft.eml")) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_attachment", Target: AttachmentTarget{PartID: "1.2"}}}, }) if err == nil || !strings.Contains(err.Error(), "use remove_inline") { @@ -282,7 +282,7 @@ Content-Transfer-Encoding: base64 YQ== `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_attachment", Target: AttachmentTarget{PartID: "1"}}}, }) if err == nil || !strings.Contains(err.Error(), "cannot remove root") { @@ -303,7 +303,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_attachment", Target: AttachmentTarget{PartID: "99"}}}, }) if err == nil || !strings.Contains(err.Error(), "not found") { @@ -318,7 +318,7 @@ hello func TestRemoveInlineRejectsNonInlinePart(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/forward_draft.eml")) // 1.2 is an attachment in forward_draft, not an inline - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_inline", Target: AttachmentTarget{PartID: "1.2"}}}, }) if err == nil || !strings.Contains(err.Error(), "not an inline") { @@ -342,7 +342,7 @@ Content-Transfer-Encoding: base64 cG5n `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_inline", Target: AttachmentTarget{PartID: "1"}}}, }) if err == nil || !strings.Contains(err.Error(), "cannot remove root") { @@ -363,7 +363,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_inline", Target: AttachmentTarget{PartID: "99"}}}, }) if err == nil || !strings.Contains(err.Error(), "not found") { @@ -378,7 +378,7 @@ hello func TestResolveTargetByCID(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/html_inline_draft.eml")) // Remove via CID target - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "replace_inline", Target: AttachmentTarget{CID: "logo"}, @@ -399,7 +399,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_inline", Target: AttachmentTarget{CID: "nonexistent"}}}, }) if err == nil || !strings.Contains(err.Error(), "no part with cid") { @@ -435,7 +435,7 @@ func TestReplaceInlineRejectsNonInlinePart(t *testing.T) { t.Fatal(err) } snapshot := mustParseFixtureDraft(t, fixtureData) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, @@ -464,7 +464,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "replace_inline", Target: AttachmentTarget{PartID: "99"}, diff --git a/shortcuts/mail/draft/patch_body_test.go b/shortcuts/mail/draft/patch_body_test.go index 37193818..0baa139b 100644 --- a/shortcuts/mail/draft/patch_body_test.go +++ b/shortcuts/mail/draft/patch_body_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/vfs/localfileio" ) // --------------------------------------------------------------------------- @@ -23,7 +23,7 @@ Content-Type: text/html; charset=UTF-8

hello

`) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "
updated
"}}, }) if err != nil { @@ -45,7 +45,7 @@ Content-Type: text/html; charset=UTF-8 func TestApplySetBodyNoPrimaryBodyFails(t *testing.T) { // A multipart/signed draft has no editable primary body snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/multipart_signed_draft.eml")) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "anything"}}, }) if err == nil || !strings.Contains(err.Error(), "no unique primary body") { @@ -67,7 +67,7 @@ Content-Type: text/html; charset=UTF-8
old reply
`+quoteHTML+` `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_reply_body", Value: "
new reply
"}}, }) if err != nil { @@ -103,7 +103,7 @@ Content-Type: text/html; charset=UTF-8
old note
`+quoteHTML+` `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_reply_body", Value: "
updated note
"}}, }) if err != nil { @@ -132,7 +132,7 @@ Content-Type: text/html; charset=UTF-8

original body

`) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_reply_body", Value: "
replaced
"}}, }) if err != nil { @@ -166,7 +166,7 @@ Content-Type: text/html; charset=UTF-8
old reply
`+quoteHTML+` --alt-- `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_reply_body", Value: "
new reply
"}}, }) if err != nil { @@ -203,7 +203,7 @@ Content-Type: text/plain; charset=UTF-8 original text `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_reply_body", Value: "replaced text"}}, }) if err != nil { @@ -228,7 +228,7 @@ Content-Type: text/plain; charset=UTF-8 original content `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/plain", Value: "replaced content"}}, }) if err != nil { @@ -249,7 +249,7 @@ Content-Type: text/plain; charset=UTF-8 original `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Value: " appended"}}, }) if err != nil { @@ -274,7 +274,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/csv", Value: "data"}}, }) if err == nil || !strings.Contains(err.Error(), "body_kind must be text/plain or text/html") { @@ -295,7 +295,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/html", Value: "

new

"}}, }) if err == nil || !strings.Contains(err.Error(), "no primary text/html body part") { @@ -324,7 +324,7 @@ Content-Type: text/html; charset=UTF-8

real body

--alt-- `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "just plain text without any tags"}}, }) if err == nil || !strings.Contains(err.Error(), "requires HTML input") { @@ -345,7 +345,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{ {Op: "set_subject", Value: "Updated Subject"}, {Op: "add_recipient", Field: "cc", Name: "Carol", Address: "carol@example.com"}, diff --git a/shortcuts/mail/draft/patch_header_test.go b/shortcuts/mail/draft/patch_header_test.go index 02219b8e..ef763d7d 100644 --- a/shortcuts/mail/draft/patch_header_test.go +++ b/shortcuts/mail/draft/patch_header_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/vfs/localfileio" ) // --------------------------------------------------------------------------- @@ -23,7 +23,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "set_reply_to", Addresses: []Address{{Name: "Support", Address: "support@example.com"}}, @@ -50,7 +50,7 @@ hello if len(snapshot.ReplyTo) == 0 { t.Fatalf("ReplyTo should be set before clear") } - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "clear_reply_to"}}, }) if err != nil { @@ -78,7 +78,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_header", Name: "X-Priority"}}, }) if err != nil { @@ -98,7 +98,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_header", Name: "Content-Type"}}, }) if err == nil || !strings.Contains(err.Error(), "protected") { @@ -116,7 +116,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_header", Name: "Reply-To"}}, Options: PatchOptions{AllowProtectedHeaderEdits: true}, }) @@ -141,7 +141,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_header", Name: "Bad:Name", Value: "value"}}, }) if err == nil || !strings.Contains(err.Error(), "must not contain") { @@ -158,7 +158,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_header", Name: "X-Custom", Value: "val\r\ninjected"}}, }) if err == nil || !strings.Contains(err.Error(), "must not contain") { @@ -179,7 +179,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_subject", Value: "Subject\ninjection"}}, }) if err == nil || !strings.Contains(err.Error(), "must not contain") { @@ -200,7 +200,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "unknown_op"}}, }) if err == nil || !strings.Contains(err.Error(), "unsupported") { diff --git a/shortcuts/mail/draft/patch_recipient_test.go b/shortcuts/mail/draft/patch_recipient_test.go index 378142d8..d5f373f5 100644 --- a/shortcuts/mail/draft/patch_recipient_test.go +++ b/shortcuts/mail/draft/patch_recipient_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/vfs/localfileio" ) // --------------------------------------------------------------------------- @@ -23,7 +23,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "add_recipient", Field: "to", @@ -51,7 +51,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "add_recipient", Field: "to", @@ -76,7 +76,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "add_recipient", Field: "cc", @@ -101,7 +101,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "add_recipient", Field: "bcc", @@ -126,7 +126,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "add_recipient", Field: "to", @@ -152,7 +152,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "remove_recipient", Field: "to", @@ -179,7 +179,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "remove_recipient", Field: "to", @@ -203,7 +203,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "remove_recipient", Field: "to", @@ -224,7 +224,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "remove_recipient", Field: "cc", @@ -246,7 +246,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "remove_recipient", Field: "cc", @@ -278,7 +278,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "set_recipients", Field: "cc", diff --git a/shortcuts/mail/draft/patch_test.go b/shortcuts/mail/draft/patch_test.go index d8054c88..932156a8 100644 --- a/shortcuts/mail/draft/patch_test.go +++ b/shortcuts/mail/draft/patch_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/vfs/localfileio" ) func chdirTemp(t *testing.T) { @@ -39,7 +39,7 @@ Content-Transfer-Encoding: 7bit hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_subject", Value: "Updated"}}, }) if err != nil { @@ -69,7 +69,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_header", Name: "Message-ID", Value: ""}}, }) if err == nil { @@ -86,7 +86,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{ Op: "set_recipients", Field: "to", @@ -117,7 +117,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "updated"}}, }) if err != nil { @@ -145,7 +145,7 @@ Content-Type: text/html; charset=UTF-8

hello

--alt-- `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "
updated body
"}}, }) if err != nil { @@ -176,7 +176,7 @@ Content-Type: text/html; charset=UTF-8

hello

--alt-- `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "updated plain text"}}, }) if err == nil || !strings.Contains(err.Error(), "draft main body is text/html") { @@ -201,7 +201,7 @@ Content-Type: text/html; charset=UTF-8
hello world
--alt-- `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "
updated body
"}}, }) if err != nil { @@ -226,7 +226,7 @@ Content-Transfer-Encoding: 7bit hello `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/html", Selector: "primary", Value: "

hello

"}}, Options: PatchOptions{ RewriteEntireDraft: true, @@ -266,7 +266,7 @@ Content-Type: text/html; charset=UTF-8

hello

--alt-- `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/html", Selector: "primary", Value: "
updated
"}}, }) if err == nil || !strings.Contains(err.Error(), "edit them together with set_body") { @@ -291,7 +291,7 @@ Content-Type: text/html; charset=UTF-8

hello

--alt-- `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: "\nappend"}}, }) if err == nil || !strings.Contains(err.Error(), "edit them together with set_body") { @@ -321,7 +321,7 @@ aGVsbG8= --rel-- `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/plain", Selector: "primary", Value: "hello plain"}}, Options: PatchOptions{ RewriteEntireDraft: true, @@ -347,7 +347,7 @@ aGVsbG8= func TestRemoveAttachmentKeepsRemainingOrder(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/forward_draft.eml")) - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_attachment", Target: AttachmentTarget{PartID: "1.3"}}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -382,7 +382,7 @@ Content-Transfer-Encoding: base64 cG5n --rel-- `) - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "remove_inline", Target: AttachmentTarget{CID: "logo-cid"}}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -406,7 +406,7 @@ Content-Transfer-Encoding: 7bit
hello
`) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{ {Op: "add_inline", Path: "logo.png", CID: "logo"}, }, @@ -433,7 +433,7 @@ func TestReplaceInlineKeepsCIDByDefault(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } snapshot := mustParseFixtureDraft(t, fixtureData) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{ {Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, Path: "updated.png"}, }, @@ -452,7 +452,7 @@ func TestReplaceInlineKeepsCIDByDefault(t *testing.T) { func TestRemoveInlineFailsWhenHTMLStillReferencesCID(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/html_inline_draft.eml")) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{ {Op: "remove_inline", Target: AttachmentTarget{PartID: "1.2"}}, }, @@ -483,7 +483,7 @@ cG5n --rel-- `) // set_body that drops the existing cid:logo reference → logo becomes orphaned - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "
replaced body without cid reference
"}}, }) if err == nil || !strings.Contains(err.Error(), "orphaned cids") { @@ -512,7 +512,7 @@ cG5n --rel-- `) // set_body that preserves the existing cid:logo reference → should succeed - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: `
updated body
`}}, }) if err != nil { @@ -522,7 +522,7 @@ cG5n func TestApplySetBodyRejectsSignedDraft(t *testing.T) { snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/multipart_signed_draft.eml")) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: "updated"}}, }) if err == nil { @@ -537,7 +537,7 @@ func TestApplyAppendTextKeepsCalendarPart(t *testing.T) { t.Fatalf("calendar part missing before patch") } originalCalendar := string(calendar.RawEntity) - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: "\nupdated"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -561,7 +561,7 @@ Content-Type: text/plain; charset=UTF-8 hello `) - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_attachment", Path: "note.txt"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -599,7 +599,7 @@ Content-Type: text/html; charset=UTF-8
hello
`) for _, bad := range []string{"my logo", "cid\there", "loid", "img(1)"} { - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_inline", Path: "logo.png", CID: bad}}, }) if err == nil { @@ -627,7 +627,7 @@ Content-Type: text/html; charset=UTF-8
`) // Step 1: add inline — this wraps body into multipart/related - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_inline", Path: "logo.png", CID: "logo"}}, }) if err != nil { @@ -636,7 +636,7 @@ Content-Type: text/html; charset=UTF-8 // Step 2: set_body — this restructures the MIME tree, potentially making // PrimaryHTMLPartID stale - err = Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err = Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: `
updated
`}}, }) if err != nil { @@ -644,7 +644,7 @@ Content-Type: text/html; charset=UTF-8 } // Step 3: set_body again dropping the CID reference — should fail validation - err = Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err = Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "set_body", Value: `
no image here
`}}, }) if err == nil || !strings.Contains(err.Error(), "orphaned cids") { @@ -666,7 +666,7 @@ Content-Type: text/html; charset=UTF-8
hello
`) for _, bad := range []string{"logo\ninjected", "logo\rinjected", "lo\r\ngo"} { - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_inline", Path: "logo.png", CID: bad}}, }) if err == nil { @@ -689,7 +689,7 @@ Content-Type: text/html; charset=UTF-8
hello
`) for _, bad := range []string{"logo\ninjected.png", "logo\r.png", "lo\r\ngo.png"} { - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "add_inline", Path: "logo.png", CID: "safecid", FileName: bad}}, }) if err == nil { @@ -706,7 +706,7 @@ func TestReplaceInlineRejectsInvalidCharactersInCID(t *testing.T) { } snapshot := mustParseFixtureDraft(t, fixtureData) for _, bad := range []string{"my logo", "cid\there", "loid", "img(1)"} { - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, Path: "updated.png", CID: bad}}, }) if err == nil { @@ -725,7 +725,7 @@ func TestReplaceInlineRejectsCRLFInCID(t *testing.T) { } snapshot := mustParseFixtureDraft(t, fixtureData) for _, bad := range []string{"logo\ninjected", "logo\rinjected"} { - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, Path: "updated.png", CID: bad}}, }) if err == nil { @@ -742,7 +742,7 @@ func TestReplaceInlineRejectsCRLFInFileName(t *testing.T) { } snapshot := mustParseFixtureDraft(t, fixtureData) for _, bad := range []string{"logo\ninjected.png", "logo\r.png"} { - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, Path: "updated.png", FileName: bad}}, }) if err == nil { diff --git a/shortcuts/mail/draft/serialize_golden_test.go b/shortcuts/mail/draft/serialize_golden_test.go index da21b430..89d98850 100644 --- a/shortcuts/mail/draft/serialize_golden_test.go +++ b/shortcuts/mail/draft/serialize_golden_test.go @@ -8,7 +8,7 @@ import ( "path/filepath" "testing" - "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/vfs/localfileio" ) func TestSerializeGoldenFixtures(t *testing.T) { @@ -83,7 +83,7 @@ func TestSerializeGoldenFixtures(t *testing.T) { if tc.patchFn != nil { patch = tc.patchFn(t) } - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, patch); err != nil { + if err := Apply(&localfileio.LocalFileIO{}, snapshot, patch); err != nil { t.Fatalf("Apply() error = %v", err) } raw, err := Serialize(snapshot) diff --git a/shortcuts/mail/draft/serialize_test.go b/shortcuts/mail/draft/serialize_test.go index b14a6797..0633441a 100644 --- a/shortcuts/mail/draft/serialize_test.go +++ b/shortcuts/mail/draft/serialize_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/vfs/localfileio" ) func TestSerializeRoundTripKeepsAttachmentsAndHTML(t *testing.T) { @@ -43,7 +43,7 @@ aGVsbG8= --mix-- `) - err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{ {Op: "set_subject", Value: "Updated"}, {Op: "set_body", Value: "
updated body
"}, @@ -106,7 +106,7 @@ aGVsbG8= --mix-- ` snapshot := mustParseFixtureDraft(t, original) - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated"}}}); err != nil { + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated"}}}); err != nil { t.Fatalf("Apply() error = %v", err) } serialized, err := Serialize(snapshot) @@ -143,7 +143,7 @@ Content-Transfer-Encoding: quoted-printable caf=E9 `) - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: " déjà"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) @@ -175,7 +175,7 @@ caf=E9 func TestSerializeSubjectOnlyPreservesEmbeddedMessageAttachment(t *testing.T) { original := mustReadFixture(t, "testdata/message_rfc822_draft.eml") snapshot := mustParseFixtureDraft(t, original) - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated forward"}}}); err != nil { + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated forward"}}}); err != nil { t.Fatalf("Apply() error = %v", err) } serialized, err := Serialize(snapshot) @@ -198,7 +198,7 @@ func TestSerializeSubjectOnlyPreservesEmbeddedMessageAttachment(t *testing.T) { func TestSerializeSubjectOnlyPreservesSignedBodyEntity(t *testing.T) { original := mustReadFixture(t, "testdata/multipart_signed_draft.eml") snapshot := mustParseFixtureDraft(t, original) - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated signed"}}}); err != nil { + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated signed"}}}); err != nil { t.Fatalf("Apply() error = %v", err) } serialized, err := Serialize(snapshot) @@ -228,7 +228,7 @@ func TestSerializeSubjectOnlyPreservesSignedBodyEntity(t *testing.T) { func TestSerializeDirtyMultipartKeepsPreambleAndEpilogue(t *testing.T) { original := mustReadFixture(t, "testdata/dirty_multipart_preamble.eml") snapshot := mustParseFixtureDraft(t, original) - if err := Apply(&cmdutil.LocalFileIO{}, snapshot, Patch{ + if err := Apply(&localfileio.LocalFileIO{}, snapshot, Patch{ Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: "\nworld"}}, }); err != nil { t.Fatalf("Apply() error = %v", err) diff --git a/shortcuts/mail/emlbuilder/builder_test.go b/shortcuts/mail/emlbuilder/builder_test.go index 7877a257..e1a02770 100644 --- a/shortcuts/mail/emlbuilder/builder_test.go +++ b/shortcuts/mail/emlbuilder/builder_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/vfs/localfileio" ) var fixedDate = time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC) @@ -960,7 +960,7 @@ func TestAddFileAttachmentBlockedExtension(t *testing.T) { for _, name := range blocked { os.WriteFile(name, []byte("content"), 0o644) } - fio := &cmdutil.LocalFileIO{} + fio := &localfileio.LocalFileIO{} for _, name := range blocked { t.Run(name, func(t *testing.T) { _, err := New().WithFileIO(fio). @@ -994,7 +994,7 @@ func TestAddFileInlineBlockedFormat(t *testing.T) { // .png extension but EXE content → rejected (bad content) os.WriteFile("evil.png", []byte("MZ"), 0o644) - fio := &cmdutil.LocalFileIO{} + fio := &localfileio.LocalFileIO{} for _, name := range []string{"icon.svg", "evil.png"} { t.Run(name, func(t *testing.T) { _, err := New().WithFileIO(fio). @@ -1024,7 +1024,7 @@ func TestAddFileInlineAllowedFormat(t *testing.T) { os.WriteFile("logo.png", pngContent, 0o644) os.WriteFile("photo.jpg", jpegContent, 0o644) - fio := &cmdutil.LocalFileIO{} + fio := &localfileio.LocalFileIO{} for _, name := range []string{"logo.png", "photo.jpg"} { t.Run(name, func(t *testing.T) { _, err := New().WithFileIO(fio). @@ -1053,7 +1053,7 @@ func TestAddFileAttachmentAllowedExtension(t *testing.T) { for _, name := range allowed { os.WriteFile(name, []byte("content"), 0o644) } - fio := &cmdutil.LocalFileIO{} + fio := &localfileio.LocalFileIO{} for _, name := range allowed { t.Run(name, func(t *testing.T) { _, err := New().WithFileIO(fio). diff --git a/shortcuts/mail/helpers_test.go b/shortcuts/mail/helpers_test.go index e44b9ed9..1c3cfd9f 100644 --- a/shortcuts/mail/helpers_test.go +++ b/shortcuts/mail/helpers_test.go @@ -19,6 +19,7 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/vfs/localfileio" "github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/mail/emlbuilder" ) @@ -608,7 +609,7 @@ func TestCheckAttachmentSizeLimit_WithFiles(t *testing.T) { } defer os.Chdir(oldWd) - err := checkAttachmentSizeLimit(&cmdutil.LocalFileIO{}, []string{"./small.txt"}, 0) + err := checkAttachmentSizeLimit(&localfileio.LocalFileIO{}, []string{"./small.txt"}, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/shortcuts/minutes/minutes_download.go b/shortcuts/minutes/minutes_download.go index 1c8a423f..a5fdb159 100644 --- a/shortcuts/minutes/minutes_download.go +++ b/shortcuts/minutes/minutes_download.go @@ -14,8 +14,7 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/vfs" - + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -79,7 +78,7 @@ var MinutesDownload = common.Shortcut{ // Batch mode: --output must be a directory, not an existing file. if !single && outputPath != "" { - if fi, err := vfs.Stat(outputPath); err == nil && !fi.IsDir() { + if fi, err := runtime.FileIO().Stat(outputPath); err == nil && !fi.IsDir() { return output.ErrValidation("--output %q is a file; batch mode expects a directory path", outputPath) } } @@ -162,7 +161,7 @@ var MinutesDownload = common.Shortcut{ fmt.Fprintf(errOut, "Downloading media: %s\n", common.MaskToken(token)) // single token: --output is a file path; batch: --output is a directory - opts := downloadOpts{overwrite: overwrite, usedNames: usedNames} + opts := downloadOpts{fio: runtime.FileIO(), overwrite: overwrite, usedNames: usedNames} if single { opts.outputPath = outputPath } else { @@ -229,8 +228,9 @@ type downloadResult struct { } type downloadOpts struct { - outputPath string // explicit output file path (single mode only) - outputDir string // output directory (batch mode) + fio fileio.FileIO // file I/O abstraction + outputPath string // explicit output file path (single mode only) + outputDir string // output directory (batch mode) overwrite bool usedNames map[string]bool // tracks used filenames to deduplicate in batch mode } @@ -275,22 +275,20 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi outputPath = filepath.Join(opts.outputDir, filename) } - safePath, err := validate.SafeOutputPath(outputPath) - if err != nil { - return nil, output.ErrValidation("unsafe output path: %s", err) - } - if err := common.EnsureWritableFile(safePath, opts.overwrite); err != nil { - return nil, err - } - if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil { - return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err) + if !opts.overwrite { + if _, statErr := opts.fio.Stat(outputPath); statErr == nil { + return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath) + } } - sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600) + result, err := opts.fio.Save(outputPath, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) if err != nil { return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err) } - return &downloadResult{savedPath: safePath, sizeBytes: sizeBytes}, nil + return &downloadResult{savedPath: outputPath, sizeBytes: result.Size()}, nil } // resolveFilenameFromResponse derives the filename from HTTP response headers. diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index af5b94e1..8427cf52 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -17,6 +17,7 @@ import ( "fmt" "io" "net/http" + "os" "path/filepath" "strings" "time" @@ -420,9 +421,9 @@ var VCNotes = common.Shortcut{ } } } - // output-dir 路径安全校验 + // output-dir 路径安全校验(FileIO.Stat 内部做 SafeInputPath) if outDir := runtime.Str("output-dir"); outDir != "" { - if err := common.ValidateSafeOutputDir(outDir); err != nil { + if _, err := runtime.FileIO().Stat(outDir); err != nil && !os.IsNotExist(err) { return err } } From 71ea3f403230ff068f0a28e9c6de3eddf954bcbb Mon Sep 17 00:00:00 2001 From: liushiyao Date: Tue, 7 Apr 2026 20:05:55 +0800 Subject: [PATCH 08/17] refactor: replace validate.SafeLocalFlagPath with FileIO-based validation in shortcuts - im: add validateMediaFlagPath helper using FileIO.Stat, replace 20 SafeLocalFlagPath calls - im/helpers.go: remove redundant SafeInputPath (FileIO.Open does it internally) - sheets/sheet_export.go: replace SafeOutputPath with FileIO.Stat for early path validation Change-Id: Iebad72763fc30632f2b7169a4379c861df68c140 --- shortcuts/im/helpers.go | 4 -- shortcuts/im/im_messages_reply.go | 60 ++++++------------------- shortcuts/im/im_messages_send.go | 75 ++++++++++++------------------- shortcuts/sheets/sheet_export.go | 3 +- 4 files changed, 44 insertions(+), 98 deletions(-) diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index 88035859..cebb65f8 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -326,10 +326,6 @@ func resolveURLMedia(ctx context.Context, runtime *common.RuntimeContext, s medi func resolveLocalMedia(ctx context.Context, runtime *common.RuntimeContext, s mediaSpec) (string, error) { fmt.Fprintf(runtime.IO().ErrOut, "uploading %s: %s\n", s.mediaType, filepath.Base(s.value)) - if _, err := validate.SafeInputPath(s.value); err != nil { - return "", err - } - if s.kind == mediaKindImage { return uploadImageToIM(ctx, runtime, s.value, "message") } diff --git a/shortcuts/im/im_messages_reply.go b/shortcuts/im/im_messages_reply.go index f7b73cc0..806ee739 100644 --- a/shortcuts/im/im_messages_reply.go +++ b/shortcuts/im/im_messages_reply.go @@ -91,29 +91,13 @@ var ImMessagesReply = common.Shortcut{ videoCoverKey := runtime.Str("video-cover") audioKey := runtime.Str("audio") - if !isMediaKey(imageKey) { - if _, err := validate.SafeLocalFlagPath("--image", imageKey); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(fileKey) { - if _, err := validate.SafeLocalFlagPath("--file", fileKey); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(videoKey) { - if _, err := validate.SafeLocalFlagPath("--video", videoKey); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(videoCoverKey) { - if _, err := validate.SafeLocalFlagPath("--video-cover", videoCoverKey); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(audioKey) { - if _, err := validate.SafeLocalFlagPath("--audio", audioKey); err != nil { - return output.ErrValidation("%v", err) + fio := runtime.FileIO() + for _, mf := range []struct{ flag, val string }{ + {"--image", imageKey}, {"--file", fileKey}, {"--video", videoKey}, + {"--video-cover", videoCoverKey}, {"--audio", audioKey}, + } { + if err := validateMediaFlagPath(fio, mf.flag, mf.val); err != nil { + return err } } @@ -149,29 +133,13 @@ var ImMessagesReply = common.Shortcut{ audioVal := runtime.Str("audio") replyInThread := runtime.Bool("reply-in-thread") idempotencyKey := runtime.Str("idempotency-key") - if !isMediaKey(imageVal) { - if _, err := validate.SafeLocalFlagPath("--image", imageVal); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(fileVal) { - if _, err := validate.SafeLocalFlagPath("--file", fileVal); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(videoVal) { - if _, err := validate.SafeLocalFlagPath("--video", videoVal); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(videoCoverVal) { - if _, err := validate.SafeLocalFlagPath("--video-cover", videoCoverVal); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(audioVal) { - if _, err := validate.SafeLocalFlagPath("--audio", audioVal); err != nil { - return output.ErrValidation("%v", err) + fio := runtime.FileIO() + for _, mf := range []struct{ flag, val string }{ + {"--image", imageVal}, {"--file", fileVal}, {"--video", videoVal}, + {"--video-cover", videoCoverVal}, {"--audio", audioVal}, + } { + if err := validateMediaFlagPath(fio, mf.flag, mf.val); err != nil { + return err } } diff --git a/shortcuts/im/im_messages_send.go b/shortcuts/im/im_messages_send.go index 116b7b9b..efaa5485 100644 --- a/shortcuts/im/im_messages_send.go +++ b/shortcuts/im/im_messages_send.go @@ -7,10 +7,11 @@ import ( "context" "encoding/json" "net/http" + "os" "strings" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) @@ -98,29 +99,13 @@ var ImMessagesSend = common.Shortcut{ videoCoverKey := runtime.Str("video-cover") audioKey := runtime.Str("audio") - if !isMediaKey(imageKey) { - if _, err := validate.SafeLocalFlagPath("--image", imageKey); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(fileKey) { - if _, err := validate.SafeLocalFlagPath("--file", fileKey); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(videoKey) { - if _, err := validate.SafeLocalFlagPath("--video", videoKey); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(videoCoverKey) { - if _, err := validate.SafeLocalFlagPath("--video-cover", videoCoverKey); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(audioKey) { - if _, err := validate.SafeLocalFlagPath("--audio", audioKey); err != nil { - return output.ErrValidation("%v", err) + fio := runtime.FileIO() + for _, mf := range []struct{ flag, val string }{ + {"--image", imageKey}, {"--file", fileKey}, {"--video", videoKey}, + {"--video-cover", videoCoverKey}, {"--audio", audioKey}, + } { + if err := validateMediaFlagPath(fio, mf.flag, mf.val); err != nil { + return err } } @@ -165,29 +150,13 @@ var ImMessagesSend = common.Shortcut{ videoVal := runtime.Str("video") videoCoverVal := runtime.Str("video-cover") audioVal := runtime.Str("audio") - if !isMediaKey(imageVal) { - if _, err := validate.SafeLocalFlagPath("--image", imageVal); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(fileVal) { - if _, err := validate.SafeLocalFlagPath("--file", fileVal); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(videoVal) { - if _, err := validate.SafeLocalFlagPath("--video", videoVal); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(videoCoverVal) { - if _, err := validate.SafeLocalFlagPath("--video-cover", videoCoverVal); err != nil { - return output.ErrValidation("%v", err) - } - } - if !isMediaKey(audioVal) { - if _, err := validate.SafeLocalFlagPath("--audio", audioVal); err != nil { - return output.ErrValidation("%v", err) + fio := runtime.FileIO() + for _, mf := range []struct{ flag, val string }{ + {"--image", imageVal}, {"--file", fileVal}, {"--video", videoVal}, + {"--video-cover", videoCoverVal}, {"--audio", audioVal}, + } { + if err := validateMediaFlagPath(fio, mf.flag, mf.val); err != nil { + return err } } // Resolve content type @@ -239,3 +208,15 @@ var ImMessagesSend = common.Shortcut{ func isMediaKey(value string) bool { return strings.HasPrefix(value, "img_") || strings.HasPrefix(value, "file_") } + +// validateMediaFlagPath validates a media flag value as a local file path via FileIO. +// Empty values, URLs, and media keys are skipped (not local files). +func validateMediaFlagPath(fio fileio.FileIO, flagName, value string) error { + if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") || isMediaKey(value) { + return nil + } + if _, err := fio.Stat(value); err != nil && !os.IsNotExist(err) { + return output.ErrValidation("%s: %v", flagName, err) + } + return nil +} diff --git a/shortcuts/sheets/sheet_export.go b/shortcuts/sheets/sheet_export.go index 1dcebfba..ad44eae2 100644 --- a/shortcuts/sheets/sheet_export.go +++ b/shortcuts/sheets/sheet_export.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "net/http" + "os" "time" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -63,7 +64,7 @@ var SheetExport = common.Shortcut{ // Early path validation before any API call if outputPath != "" { - if _, err := validate.SafeOutputPath(outputPath); err != nil { + if _, err := runtime.FileIO().Stat(outputPath); err != nil && !os.IsNotExist(err) { return output.ErrValidation("unsafe output path: %s", err) } } From 9f7aa78da04de83622465cccbc8a6b5287ae7189 Mon Sep 17 00:00:00 2001 From: liushiyao Date: Tue, 7 Apr 2026 20:10:48 +0800 Subject: [PATCH 09/17] refactor: make SafeInputPath and SafeLocalFlagPath private in localfileio - Remove validate.SafeInputPath and validate.SafeLocalFlagPath (no external callers) - Downgrade to unexported safeInputPath/safeLocalFlagPath in localfileio - Replace testLocalFileIO in response_test.go with localfileio.LocalFileIO Change-Id: I9fca2536abf028c2f81497a5551ac8d0558a5eb4 --- internal/client/response_test.go | 62 +++++-------------------- internal/validate/path.go | 10 ---- internal/vfs/localfileio/localfileio.go | 6 +-- internal/vfs/localfileio/path.go | 14 +++--- internal/vfs/localfileio/path_test.go | 22 ++++----- 5 files changed, 32 insertions(+), 82 deletions(-) diff --git a/internal/client/response_test.go b/internal/client/response_test.go index 69493509..b182df87 100644 --- a/internal/client/response_test.go +++ b/internal/client/response_test.go @@ -6,7 +6,6 @@ package client import ( "bytes" "errors" - "io" "net/http" "os" "path/filepath" @@ -15,10 +14,8 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/vfs/localfileio" ) func newApiResp(body []byte, headers map[string]string) *larkcore.ApiResp { @@ -154,7 +151,7 @@ func TestSaveResponse(t *testing.T) { body := []byte("hello binary data") resp := newApiResp(body, map[string]string{"Content-Type": "application/octet-stream"}) - meta, err := SaveResponse(&testLocalFileIO{}, resp, "test_output.bin") + meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "test_output.bin") if err != nil { t.Fatalf("SaveResponse failed: %v", err) } @@ -180,7 +177,7 @@ func TestSaveResponse_CreatesDir(t *testing.T) { resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"}) - meta, err := SaveResponse(&testLocalFileIO{}, resp, filepath.Join("sub", "deep", "out.bin")) + meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, filepath.Join("sub", "deep", "out.bin")) if err != nil { t.Fatalf("SaveResponse with nested dir failed: %v", err) } @@ -199,7 +196,7 @@ func TestHandleResponse_JSON(t *testing.T) { err := HandleResponse(resp, ResponseOptions{ Out: &out, ErrOut: &errOut, - FileIO: &testLocalFileIO{}, + FileIO: &localfileio.LocalFileIO{}, }) if err != nil { t.Fatalf("HandleResponse failed: %v", err) @@ -218,7 +215,7 @@ func TestHandleResponse_JSONWithError(t *testing.T) { err := HandleResponse(resp, ResponseOptions{ Out: &out, ErrOut: &errOut, - FileIO: &testLocalFileIO{}, + FileIO: &localfileio.LocalFileIO{}, }) if err == nil { t.Error("expected error for non-zero code") @@ -238,7 +235,7 @@ func TestHandleResponse_BinaryAutoSave(t *testing.T) { err := HandleResponse(resp, ResponseOptions{ Out: &out, ErrOut: &errOut, - FileIO: &testLocalFileIO{}, + FileIO: &localfileio.LocalFileIO{}, }) if err != nil { t.Fatalf("HandleResponse binary failed: %v", err) @@ -262,7 +259,7 @@ func TestHandleResponse_BinaryWithOutput(t *testing.T) { OutputPath: "out.png", Out: &out, ErrOut: &errOut, - FileIO: &testLocalFileIO{}, + FileIO: &localfileio.LocalFileIO{}, }) if err != nil { t.Fatalf("HandleResponse with output path failed: %v", err) @@ -277,7 +274,7 @@ func TestHandleResponse_NonJSONError_404(t *testing.T) { resp := newApiRespWithStatus(404, []byte("404 page not found"), map[string]string{"Content-Type": "text/plain"}) var out, errOut bytes.Buffer - err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &testLocalFileIO{}}) + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}}) if err == nil { t.Fatal("expected error for 404 text/plain") } @@ -295,7 +292,7 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) { resp := newApiRespWithStatus(502, []byte("Bad Gateway"), map[string]string{"Content-Type": "text/html"}) var out, errOut bytes.Buffer - err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &testLocalFileIO{}}) + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}}) if err == nil { t.Fatal("expected error for 502 text/html") } @@ -318,7 +315,7 @@ func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) { resp := newApiRespWithStatus(200, []byte("plain text file content"), map[string]string{"Content-Type": "text/plain"}) var out, errOut bytes.Buffer - err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &testLocalFileIO{}}) + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}}) if err != nil { t.Fatalf("expected no error for 200 text/plain, got: %v", err) } @@ -349,7 +346,7 @@ func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) { resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"}) var out, errOut bytes.Buffer - err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &testLocalFileIO{}}) + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}}) if err == nil { t.Fatal("expected error for 403 JSON with non-zero code") } @@ -358,40 +355,3 @@ func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) { } } -// testLocalFileIO is a minimal fileio.FileIO for tests that writes to the local filesystem. -type testLocalFileIO struct{} - -func (t *testLocalFileIO) Open(name string) (fileio.File, error) { - safePath, err := validate.SafeInputPath(name) - if err != nil { - return nil, err - } - return os.Open(safePath) -} - -func (t *testLocalFileIO) Stat(name string) (os.FileInfo, error) { - safePath, err := validate.SafeInputPath(name) - if err != nil { - return nil, err - } - return os.Stat(safePath) -} - -type testSaveResult struct{ size int64 } - -func (r *testSaveResult) Size() int64 { return r.size } - -func (t *testLocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) { - safePath, err := validate.SafeOutputPath(path) - if err != nil { - return nil, err - } - if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil { - return nil, err - } - n, err := validate.AtomicWriteFromReader(safePath, body, 0644) - if err != nil { - return nil, err - } - return &testSaveResult{size: n}, nil -} diff --git a/internal/validate/path.go b/internal/validate/path.go index 120bf276..ba202ff9 100644 --- a/internal/validate/path.go +++ b/internal/validate/path.go @@ -9,13 +9,3 @@ import "github.com/larksuite/cli/internal/vfs/localfileio" func SafeOutputPath(path string) (string, error) { return localfileio.SafeOutputPath(path) } - -// SafeInputPath delegates to localfileio.SafeInputPath. -func SafeInputPath(path string) (string, error) { - return localfileio.SafeInputPath(path) -} - -// SafeLocalFlagPath delegates to localfileio.SafeLocalFlagPath. -func SafeLocalFlagPath(flagName, value string) (string, error) { - return localfileio.SafeLocalFlagPath(flagName, value) -} diff --git a/internal/vfs/localfileio/localfileio.go b/internal/vfs/localfileio/localfileio.go index 2ce09aa4..c77ecf41 100644 --- a/internal/vfs/localfileio/localfileio.go +++ b/internal/vfs/localfileio/localfileio.go @@ -26,13 +26,13 @@ func init() { } // LocalFileIO implements fileio.FileIO using the local filesystem. -// Path validation (SafeInputPath/SafeOutputPath), directory creation, +// Path validation (safeInputPath/SafeOutputPath), directory creation, // and atomic writes are handled internally. type LocalFileIO struct{} // Open opens a local file for reading after validating the path. func (l *LocalFileIO) Open(name string) (fileio.File, error) { - safePath, err := SafeInputPath(name) + safePath, err := safeInputPath(name) if err != nil { return nil, err } @@ -41,7 +41,7 @@ func (l *LocalFileIO) Open(name string) (fileio.File, error) { // Stat returns file metadata after validating the path. func (l *LocalFileIO) Stat(name string) (os.FileInfo, error) { - safePath, err := SafeInputPath(name) + safePath, err := safeInputPath(name) if err != nil { return nil, err } diff --git a/internal/vfs/localfileio/path.go b/internal/vfs/localfileio/path.go index 3beb0983..f0c7eed5 100644 --- a/internal/vfs/localfileio/path.go +++ b/internal/vfs/localfileio/path.go @@ -24,31 +24,31 @@ func SafeOutputPath(path string) (string, error) { return safePath(path, "--output") } -// SafeInputPath validates an upload/read source path for --file flags. +// safeInputPath validates an upload/read source path for --file flags. // It applies the same rules as SafeOutputPath — rejecting absolute paths, // resolving symlinks, and enforcing working directory containment — to prevent an AI Agent // from being tricked into reading sensitive files like /etc/passwd. -func SafeInputPath(path string) (string, error) { +func safeInputPath(path string) (string, error) { return safePath(path, "--file") } -// SafeLocalFlagPath validates a flag value as a local file path. +// safeLocalFlagPath validates a flag value as a local file path. // Empty values and http/https URLs are returned unchanged without validation, // allowing the caller to handle non-path inputs (e.g. API keys, URLs) upstream. -// For all other values, SafeInputPath rules apply. +// For all other values, safeInputPath rules apply. // The original relative path is returned unchanged (not resolved to absolute) so // upload helpers can re-validate at the actual I/O point via SafeUploadPath. -func SafeLocalFlagPath(flagName, value string) (string, error) { +func safeLocalFlagPath(flagName, value string) (string, error) { if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { return value, nil } - if _, err := SafeInputPath(value); err != nil { + if _, err := safeInputPath(value); err != nil { return "", fmt.Errorf("%s: %v", flagName, err) } return value, nil } -// safePath is the shared implementation for SafeOutputPath and SafeInputPath. +// safePath is the shared implementation for SafeOutputPath and safeInputPath. func safePath(raw, flagName string) (string, error) { if err := rejectControlChars(raw, flagName); err != nil { return "", err diff --git a/internal/vfs/localfileio/path_test.go b/internal/vfs/localfileio/path_test.go index 8a490397..bc79dd7f 100644 --- a/internal/vfs/localfileio/path_test.go +++ b/internal/vfs/localfileio/path_test.go @@ -167,7 +167,7 @@ func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) { } } -func TestSafeLocalFlagPath(t *testing.T) { +func Test_safeLocalFlagPath(t *testing.T) { dir := t.TempDir() dir, _ = filepath.EvalSymlinks(dir) orig, _ := os.Getwd() @@ -190,18 +190,18 @@ func TestSafeLocalFlagPath(t *testing.T) { {"absolute path rejected", "--image", "/etc/passwd", "", "--image"}, } { t.Run(tt.name, func(t *testing.T) { - got, err := SafeLocalFlagPath(tt.flag, tt.value) + got, err := safeLocalFlagPath(tt.flag, tt.value) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("SafeLocalFlagPath(%q, %q) error = %v, want contains %q", tt.flag, tt.value, err, tt.wantErr) + t.Fatalf("safeLocalFlagPath(%q, %q) error = %v, want contains %q", tt.flag, tt.value, err, tt.wantErr) } return } if err != nil { - t.Fatalf("SafeLocalFlagPath(%q, %q) unexpected error: %v", tt.flag, tt.value, err) + t.Fatalf("safeLocalFlagPath(%q, %q) unexpected error: %v", tt.flag, tt.value, err) } if got != tt.want { - t.Fatalf("SafeLocalFlagPath(%q, %q) = %q, want %q", tt.flag, tt.value, got, tt.want) + t.Fatalf("safeLocalFlagPath(%q, %q) = %q, want %q", tt.flag, tt.value, got, tt.want) } }) } @@ -218,7 +218,7 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) { t.Cleanup(func() { os.Remove(tmpPath) }) // WHEN: SafeUploadPath validates the absolute temp path - _, err = SafeInputPath(tmpPath) + _, err = safeInputPath(tmpPath) // THEN: absolute paths are rejected even in temp dir if err == nil { @@ -229,7 +229,7 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) { func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) { // GIVEN: an absolute path outside the temp directory // WHEN / THEN: SafeUploadPath rejects it - _, err := SafeInputPath("/etc/passwd") + _, err := safeInputPath("/etc/passwd") if err == nil { t.Error("expected error for absolute non-temp path, got nil") } @@ -246,7 +246,7 @@ func TestSafeUploadPath_AcceptsRelativePath(t *testing.T) { os.WriteFile(filepath.Join(dir, "upload.bin"), []byte("data"), 0600) // WHEN: SafeUploadPath validates a relative path to an existing file - got, err := SafeInputPath("upload.bin") + got, err := safeInputPath("upload.bin") // THEN: accepted and returned as absolute canonical path if err != nil { @@ -258,11 +258,11 @@ func TestSafeUploadPath_AcceptsRelativePath(t *testing.T) { } } -func TestSafeInputPath_ErrorMessageContainsCorrectFlagName(t *testing.T) { +func Test_safeInputPath_ErrorMessageContainsCorrectFlagName(t *testing.T) { // GIVEN: an absolute path - // WHEN: SafeInputPath rejects it - _, err := SafeInputPath("/etc/passwd") + // WHEN: safeInputPath rejects it + _, err := safeInputPath("/etc/passwd") // THEN: error message mentions --file (not --output) if err == nil { From 7b6f14de500093a2ce083f39e0de6f2bd039012c Mon Sep 17 00:00:00 2001 From: liushiyao Date: Tue, 7 Apr 2026 20:16:54 +0800 Subject: [PATCH 10/17] refactor: privatize AtomicWriteFromReader/safeLocalFlagPath, add validateMediaFlagPath test - Make AtomicWriteFromReader private (only used inside localfileio.Save) - Remove dead safeLocalFlagPath function and its tests - Add unit test for validateMediaFlagPath covering skip/accept/reject cases Change-Id: I3aa6959279b51c7d8d3ff87402153e5c52f61522 --- internal/validate/atomicwrite.go | 6 --- internal/vfs/localfileio/atomicwrite.go | 4 +- internal/vfs/localfileio/localfileio.go | 2 +- internal/vfs/localfileio/path.go | 15 -------- internal/vfs/localfileio/path_test.go | 38 ------------------ shortcuts/im/validate_media_test.go | 51 +++++++++++++++++++++++++ 6 files changed, 54 insertions(+), 62 deletions(-) create mode 100644 shortcuts/im/validate_media_test.go diff --git a/internal/validate/atomicwrite.go b/internal/validate/atomicwrite.go index e60f52a7..088c44e2 100644 --- a/internal/validate/atomicwrite.go +++ b/internal/validate/atomicwrite.go @@ -4,7 +4,6 @@ package validate import ( - "io" "os" "github.com/larksuite/cli/internal/vfs/localfileio" @@ -14,8 +13,3 @@ import ( func AtomicWrite(path string, data []byte, perm os.FileMode) error { return localfileio.AtomicWrite(path, data, perm) } - -// AtomicWriteFromReader delegates to localfileio.AtomicWriteFromReader. -func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) { - return localfileio.AtomicWriteFromReader(path, reader, perm) -} diff --git a/internal/vfs/localfileio/atomicwrite.go b/internal/vfs/localfileio/atomicwrite.go index 9035170e..6603554d 100644 --- a/internal/vfs/localfileio/atomicwrite.go +++ b/internal/vfs/localfileio/atomicwrite.go @@ -27,8 +27,8 @@ func AtomicWrite(path string, data []byte, perm os.FileMode) error { }) } -// AtomicWriteFromReader atomically copies reader contents into path. -func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) { +// atomicWriteFromReader atomically copies reader contents into path. +func atomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) { var copied int64 err := atomicWrite(path, perm, func(tmp *os.File) error { n, err := io.Copy(tmp, reader) diff --git a/internal/vfs/localfileio/localfileio.go b/internal/vfs/localfileio/localfileio.go index c77ecf41..4c624c19 100644 --- a/internal/vfs/localfileio/localfileio.go +++ b/internal/vfs/localfileio/localfileio.go @@ -64,7 +64,7 @@ func (l *LocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) (f if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil { return nil, err } - n, err := AtomicWriteFromReader(safePath, body, 0644) + n, err := atomicWriteFromReader(safePath, body, 0644) if err != nil { return nil, err } diff --git a/internal/vfs/localfileio/path.go b/internal/vfs/localfileio/path.go index f0c7eed5..5e75197e 100644 --- a/internal/vfs/localfileio/path.go +++ b/internal/vfs/localfileio/path.go @@ -32,21 +32,6 @@ func safeInputPath(path string) (string, error) { return safePath(path, "--file") } -// safeLocalFlagPath validates a flag value as a local file path. -// Empty values and http/https URLs are returned unchanged without validation, -// allowing the caller to handle non-path inputs (e.g. API keys, URLs) upstream. -// For all other values, safeInputPath rules apply. -// The original relative path is returned unchanged (not resolved to absolute) so -// upload helpers can re-validate at the actual I/O point via SafeUploadPath. -func safeLocalFlagPath(flagName, value string) (string, error) { - if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { - return value, nil - } - if _, err := safeInputPath(value); err != nil { - return "", fmt.Errorf("%s: %v", flagName, err) - } - return value, nil -} // safePath is the shared implementation for SafeOutputPath and safeInputPath. func safePath(raw, flagName string) (string, error) { diff --git a/internal/vfs/localfileio/path_test.go b/internal/vfs/localfileio/path_test.go index bc79dd7f..dcfc6bad 100644 --- a/internal/vfs/localfileio/path_test.go +++ b/internal/vfs/localfileio/path_test.go @@ -167,45 +167,7 @@ func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) { } } -func Test_safeLocalFlagPath(t *testing.T) { - dir := t.TempDir() - dir, _ = filepath.EvalSymlinks(dir) - orig, _ := os.Getwd() - defer os.Chdir(orig) - os.Chdir(dir) - os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0600) - for _, tt := range []struct { - name string - flag string - value string - want string - wantErr string - }{ - {"empty value passes through", "--image", "", "", ""}, - {"http URL passes through", "--image", "http://example.com/a.jpg", "http://example.com/a.jpg", ""}, - {"https URL passes through", "--image", "https://example.com/a.jpg", "https://example.com/a.jpg", ""}, - {"relative path accepted, returned unchanged", "--file", "photo.jpg", "photo.jpg", ""}, - {"path traversal rejected", "--file", "../escape.txt", "", "--file"}, - {"absolute path rejected", "--image", "/etc/passwd", "", "--image"}, - } { - t.Run(tt.name, func(t *testing.T) { - got, err := safeLocalFlagPath(tt.flag, tt.value) - if tt.wantErr != "" { - if err == nil || !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("safeLocalFlagPath(%q, %q) error = %v, want contains %q", tt.flag, tt.value, err, tt.wantErr) - } - return - } - if err != nil { - t.Fatalf("safeLocalFlagPath(%q, %q) unexpected error: %v", tt.flag, tt.value, err) - } - if got != tt.want { - t.Fatalf("safeLocalFlagPath(%q, %q) = %q, want %q", tt.flag, tt.value, got, tt.want) - } - }) - } -} func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) { // GIVEN: a real temp file (absolute path under os.TempDir()) diff --git a/shortcuts/im/validate_media_test.go b/shortcuts/im/validate_media_test.go new file mode 100644 index 00000000..b6c63efd --- /dev/null +++ b/shortcuts/im/validate_media_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/internal/vfs/localfileio" +) + +func TestValidateMediaFlagPath(t *testing.T) { + dir := t.TempDir() + orig, _ := os.Getwd() + defer os.Chdir(orig) + os.Chdir(dir) + os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("img"), 0644) + + fio := &localfileio.LocalFileIO{} + + tests := []struct { + name string + flag string + value string + wantErr bool + }{ + {"empty value skipped", "--image", "", false}, + {"http URL skipped", "--image", "http://example.com/a.jpg", false}, + {"https URL skipped", "--file", "https://example.com/b.mp4", false}, + {"media key skipped", "--image", "img_abc123", false}, + {"file key skipped", "--file", "file_abc123", false}, + {"valid local file", "--image", "photo.jpg", false}, + {"nonexistent file allowed", "--file", "missing.txt", false}, + {"path traversal rejected", "--image", "../../etc/passwd", true}, + {"absolute path rejected", "--file", "/etc/passwd", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateMediaFlagPath(fio, tt.flag, tt.value) + if tt.wantErr && err == nil { + t.Fatalf("expected error for %s=%q, got nil", tt.flag, tt.value) + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error for %s=%q: %v", tt.flag, tt.value, err) + } + }) + } +} From 1cf4b587dbd922cdbdb11de255bc0271289a3482 Mon Sep 17 00:00:00 2001 From: liushiyao Date: Tue, 7 Apr 2026 20:20:48 +0800 Subject: [PATCH 11/17] refactor: add RuntimeContext.ValidatePath to deduplicate FileIO.Stat path checks Replace 4 instances of the repeated FileIO().Stat + os.IsNotExist pattern with a single runtime.ValidatePath(path) call. drive_download retains inline Stat for its combined validate+overwrite check. Change-Id: I69e74c7e82b14f92dfb53d851c911fde301b4714 --- shortcuts/common/runner.go | 12 ++++++++++++ shortcuts/doc/doc_media_download.go | 6 ++---- shortcuts/im/im_messages_resources_download.go | 6 ++---- shortcuts/sheets/sheet_export.go | 3 +-- shortcuts/vc/vc_notes.go | 5 ++--- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 423297eb..4ebdba69 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "net/http" + "os" "slices" "strings" @@ -314,6 +315,17 @@ func (ctx *RuntimeContext) FileIO() fileio.FileIO { return nil } +// ValidatePath checks that path is a valid relative path within the working +// directory (via FileIO.Stat). Returns nil if the path is valid or does not +// exist yet; returns an error only for illegal paths (absolute, traversal, +// symlink escape, control chars). +func (ctx *RuntimeContext) ValidatePath(path string) error { + if _, err := ctx.FileIO().Stat(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + // ── Output helpers ── // Out prints a success JSON envelope to stdout. diff --git a/shortcuts/doc/doc_media_download.go b/shortcuts/doc/doc_media_download.go index 49b771c5..54806eaf 100644 --- a/shortcuts/doc/doc_media_download.go +++ b/shortcuts/doc/doc_media_download.go @@ -7,7 +7,6 @@ import ( "context" "fmt" "net/http" - "os" "path/filepath" "strings" @@ -67,9 +66,8 @@ var DocMediaDownload = common.Shortcut{ if err := validate.ResourceName(token, "--token"); err != nil { return output.ErrValidation("%s", err) } - // Early path validation via FileIO.Stat - if _, statErr := runtime.FileIO().Stat(outputPath); statErr != nil && !os.IsNotExist(statErr) { - return output.ErrValidation("unsafe output path: %s", statErr) + if err := runtime.ValidatePath(outputPath); err != nil { + return output.ErrValidation("unsafe output path: %s", err) } fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s %s\n", mediaType, common.MaskToken(token)) diff --git a/shortcuts/im/im_messages_resources_download.go b/shortcuts/im/im_messages_resources_download.go index 6fa5c3c5..5d4c9a89 100644 --- a/shortcuts/im/im_messages_resources_download.go +++ b/shortcuts/im/im_messages_resources_download.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "net/http" - "os" "path/filepath" "strings" "time" @@ -55,9 +54,8 @@ var ImMessagesResourcesDownload = common.Shortcut{ if err != nil { return output.ErrValidation("%s", err) } - // Early path validation via FileIO.Stat - if _, statErr := runtime.FileIO().Stat(relPath); statErr != nil && !os.IsNotExist(statErr) { - return output.ErrValidation("unsafe output path: %s", statErr) + if err := runtime.ValidatePath(relPath); err != nil { + return output.ErrValidation("unsafe output path: %s", err) } return nil }, diff --git a/shortcuts/sheets/sheet_export.go b/shortcuts/sheets/sheet_export.go index ad44eae2..79755de5 100644 --- a/shortcuts/sheets/sheet_export.go +++ b/shortcuts/sheets/sheet_export.go @@ -7,7 +7,6 @@ import ( "context" "fmt" "net/http" - "os" "time" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -64,7 +63,7 @@ var SheetExport = common.Shortcut{ // Early path validation before any API call if outputPath != "" { - if _, err := runtime.FileIO().Stat(outputPath); err != nil && !os.IsNotExist(err) { + if err := runtime.ValidatePath(outputPath); err != nil { return output.ErrValidation("unsafe output path: %s", err) } } diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index 8427cf52..e298dfef 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -17,7 +17,6 @@ import ( "fmt" "io" "net/http" - "os" "path/filepath" "strings" "time" @@ -421,9 +420,9 @@ var VCNotes = common.Shortcut{ } } } - // output-dir 路径安全校验(FileIO.Stat 内部做 SafeInputPath) + // output-dir 路径安全校验 if outDir := runtime.Str("output-dir"); outDir != "" { - if _, err := runtime.FileIO().Stat(outDir); err != nil && !os.IsNotExist(err) { + if err := runtime.ValidatePath(outDir); err != nil { return err } } From bf5d6bd99058084d0b48e08bab2ee66f56f229dc Mon Sep 17 00:00:00 2001 From: liushiyao Date: Tue, 7 Apr 2026 20:27:15 +0800 Subject: [PATCH 12/17] chore: add lint rules to enforce FileIO boundary in shortcuts - depguard: block shortcuts from importing internal/vfs and localfileio - forbidigo: add missing os functions (Chtimes, CopyFS, MkdirTemp) and filepath functions that access filesystem (EvalSymlinks, Walk, Glob, Abs) - Consolidate forbidigo patterns using regex alternation to reduce config size Change-Id: I39bd750c5e74fd753751dd4220ec5eb124a2a0d4 --- .golangci.yml | 95 ++++++++++++++++++++++----------------------------- 1 file changed, 41 insertions(+), 54 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index da2d5320..0992f7ec 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -27,6 +27,7 @@ linters: - reassign # checks that package variables are not reassigned - unconvert # removes unnecessary type conversions - unused # checks for unused constants, variables, functions and types + - depguard # blocks forbidden package imports - forbidigo # forbids specific function calls # To enable later after fixing existing issues: @@ -45,6 +46,7 @@ linters: linters: - bodyclose - gocritic + - depguard - forbidigo - path-except: (shortcuts/|internal/) linters: @@ -54,67 +56,52 @@ linters: - forbidigo settings: + depguard: + rules: + shortcuts-no-vfs: + files: + - "**/shortcuts/**" + deny: + - pkg: "github.com/larksuite/cli/internal/vfs" + desc: >- + shortcuts must not import internal/vfs directly. + Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation. + - pkg: "github.com/larksuite/cli/internal/vfs/localfileio" + desc: >- + shortcuts must not import internal/vfs/localfileio directly. + Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation. forbidigo: forbid: - # ── Filesystem operations: use internal/vfs instead ── - - pattern: os\.Stat\b - msg: "use vfs.Stat() from internal/vfs" - - pattern: os\.Lstat\b - msg: "use vfs.Lstat() from internal/vfs" - - pattern: os\.Open\b - msg: "use vfs.Open() from internal/vfs" - - pattern: os\.OpenFile\b - msg: "use vfs.OpenFile() from internal/vfs" - - pattern: os\.Create\b - msg: "use vfs.OpenFile() from internal/vfs" - - pattern: os\.CreateTemp\b + # ── os: already wrapped in internal/vfs ── + - pattern: os\.(Stat|Lstat|Open|OpenFile|Rename|ReadFile|WriteFile|Getwd|UserHomeDir|ReadDir)\b + msg: "use the corresponding vfs.Xxx() from internal/vfs" + - pattern: os\.(Create|CreateTemp|MkdirTemp)\b msg: >- - internal/: use vfs.CreateTemp() from internal/vfs. - shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead. - - pattern: os\.Mkdir\b + internal/: use vfs.CreateTemp() or vfs.OpenFile(). + shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers. + - pattern: os\.Mkdir(All)?\b msg: "use vfs.MkdirAll() from internal/vfs" - - pattern: os\.MkdirAll\b - msg: "use vfs.MkdirAll() from internal/vfs" - - pattern: os\.Remove\b + - pattern: os\.Remove(All)?\b msg: >- internal/: use vfs.Remove() from internal/vfs. - shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead. - - pattern: os\.RemoveAll\b + shortcuts/: avoid temp files — use io.Reader streaming or in-memory buffers. + # ── os: not yet in vfs — add to vfs/fs.go first ── + - pattern: os\.(Chdir|Chmod|Chown|Lchown|Chtimes|CopyFS|DirFS|Link|Symlink|Readlink|Truncate|SameFile)\b + msg: "add this function to internal/vfs/fs.go first, then use vfs.Xxx()" + # ── os: IO streams ── + - pattern: os\.Std(in|out|err)\b + msg: "use IOStreams (In/Out/ErrOut) instead of os.Stdin/Stdout/Stderr" + # ── os: process ── + - pattern: os\.Exit\b + msg: >- + Do not use os.Exit in shortcuts/. Return an error instead and let + the caller (cmd layer) decide how to terminate. + # ── filepath: functions that access the filesystem ── + - pattern: filepath\.(EvalSymlinks|Walk|WalkDir|Glob|Abs)\b msg: >- - internal/: add RemoveAll to internal/vfs/fs.go first, then use vfs.RemoveAll(). - shortcuts/: avoid temp files entirely — use io.Reader streaming or in-memory buffers instead. - - pattern: os\.Rename\b - msg: "use vfs.Rename() from internal/vfs" - - pattern: os\.ReadFile\b - msg: "use vfs.ReadFile() from internal/vfs" - - pattern: os\.WriteFile\b - msg: "use vfs.WriteFile() from internal/vfs" - - pattern: os\.ReadDir\b - msg: "add ReadDir to internal/vfs/fs.go first, then use vfs.ReadDir()" - - pattern: os\.Getwd\b - msg: "use vfs.Getwd() from internal/vfs" - - pattern: os\.Chdir\b - msg: "add Chdir to internal/vfs/fs.go first, then use vfs.Chdir()" - - pattern: os\.UserHomeDir\b - msg: "use vfs.UserHomeDir() from internal/vfs" - - pattern: os\.Chmod\b - msg: "add Chmod to internal/vfs/fs.go first, then use vfs.Chmod()" - - pattern: os\.Chown\b - msg: "add Chown to internal/vfs/fs.go first, then use vfs.Chown()" - - pattern: os\.Lchown\b - msg: "add Lchown to internal/vfs/fs.go first, then use vfs.Lchown()" - - pattern: os\.Link\b - msg: "add Link to internal/vfs/fs.go first, then use vfs.Link()" - - pattern: os\.Symlink\b - msg: "add Symlink to internal/vfs/fs.go first, then use vfs.Symlink()" - - pattern: os\.Readlink\b - msg: "add Readlink to internal/vfs/fs.go first, then use vfs.Readlink()" - - pattern: os\.Truncate\b - msg: "add Truncate to internal/vfs/fs.go first, then use vfs.Truncate()" - - pattern: os\.DirFS\b - msg: "add DirFS to internal/vfs/fs.go first, then use vfs.DirFS()" - - pattern: os\.SameFile\b - msg: "add SameFile to internal/vfs/fs.go first, then use vfs.SameFile()" + These filepath functions access the filesystem directly. + internal/: use vfs helpers or localfileio path validation. + shortcuts/: use runtime.ValidatePath() or runtime.FileIO(). # ── IO streams: use IOStreams from cmdutil instead ── - pattern: os\.Stdin\b msg: "use IOStreams.In instead of os.Stdin" From a4e0fcf80f18378f6b202e6ac04bcfecfeabc036 Mon Sep 17 00:00:00 2001 From: liushiyao Date: Tue, 7 Apr 2026 21:03:04 +0800 Subject: [PATCH 13/17] fix: tighten file permissions in LocalFileIO.Save to 0700/0600 Change-Id: I6206a8622bdc65caebf29a57d9f0e4ec487685eb --- internal/vfs/localfileio/localfileio.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/vfs/localfileio/localfileio.go b/internal/vfs/localfileio/localfileio.go index 4c624c19..943f7667 100644 --- a/internal/vfs/localfileio/localfileio.go +++ b/internal/vfs/localfileio/localfileio.go @@ -61,10 +61,10 @@ func (l *LocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) (f if err != nil { return nil, err } - if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(safePath), 0700); err != nil { return nil, err } - n, err := atomicWriteFromReader(safePath, body, 0644) + n, err := atomicWriteFromReader(safePath, body, 0600) if err != nil { return nil, err } From d9dc585f7485a8699f93d137b8d59a461cbb43e0 Mon Sep 17 00:00:00 2001 From: liushiyao Date: Tue, 7 Apr 2026 21:22:43 +0800 Subject: [PATCH 14/17] fix: resolve gofmt and forbidigo lint errors Change-Id: I9a7c4dd296d9c742144ec9e8e11465b5fbe6debf --- internal/client/response_test.go | 1 - internal/cmdutil/factory_default_test.go | 2 +- internal/cmdutil/testing.go | 7 ++++--- internal/vfs/localfileio/path.go | 1 - internal/vfs/localfileio/path_test.go | 2 -- shortcuts/drive/drive_export_test.go | 2 +- shortcuts/minutes/minutes_download.go | 6 +++--- 7 files changed, 9 insertions(+), 12 deletions(-) diff --git a/internal/client/response_test.go b/internal/client/response_test.go index b182df87..a22390cc 100644 --- a/internal/client/response_test.go +++ b/internal/client/response_test.go @@ -354,4 +354,3 @@ func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) { t.Errorf("expected lark error code in message, got: %s", err.Error()) } } - diff --git a/internal/cmdutil/factory_default_test.go b/internal/cmdutil/factory_default_test.go index 55ee8d1f..7fee3faa 100644 --- a/internal/cmdutil/factory_default_test.go +++ b/internal/cmdutil/factory_default_test.go @@ -10,11 +10,11 @@ import ( _ "github.com/larksuite/cli/extension/credential/env" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/vfs/localfileio" internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/vfs/localfileio" ) type countingFileIOProvider struct { diff --git a/internal/cmdutil/testing.go b/internal/cmdutil/testing.go index 3c25e5ef..401d9805 100644 --- a/internal/cmdutil/testing.go +++ b/internal/cmdutil/testing.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/vfs" ) // noopKeychain is a no-op KeychainAccess for tests that don't need keychain. @@ -93,14 +94,14 @@ func (a *testDefaultAcct) ResolveAccount(ctx context.Context) (*credential.Accou // Not compatible with t.Parallel() — os.Chdir is process-wide. func TestChdir(t *testing.T, dir string) { t.Helper() - orig, err := os.Getwd() + orig, err := vfs.Getwd() if err != nil { t.Fatalf("Getwd: %v", err) } - if err := os.Chdir(dir); err != nil { + if err := os.Chdir(dir); err != nil { //nolint:forbidigo // no vfs.Chdir yet; test-only, process-wide chdir t.Fatalf("Chdir(%s): %v", dir, err) } - t.Cleanup(func() { os.Chdir(orig) }) + t.Cleanup(func() { os.Chdir(orig) }) //nolint:forbidigo // matching restore } type testDefaultToken struct{} diff --git a/internal/vfs/localfileio/path.go b/internal/vfs/localfileio/path.go index 5e75197e..f7405bd1 100644 --- a/internal/vfs/localfileio/path.go +++ b/internal/vfs/localfileio/path.go @@ -32,7 +32,6 @@ func safeInputPath(path string) (string, error) { return safePath(path, "--file") } - // safePath is the shared implementation for SafeOutputPath and safeInputPath. func safePath(raw, flagName string) (string, error) { if err := rejectControlChars(raw, flagName); err != nil { diff --git a/internal/vfs/localfileio/path_test.go b/internal/vfs/localfileio/path_test.go index dcfc6bad..90e8e34c 100644 --- a/internal/vfs/localfileio/path_test.go +++ b/internal/vfs/localfileio/path_test.go @@ -167,8 +167,6 @@ func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) { } } - - func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) { // GIVEN: a real temp file (absolute path under os.TempDir()) f, err := os.CreateTemp("", "upload-test-*.bin") diff --git a/shortcuts/drive/drive_export_test.go b/shortcuts/drive/drive_export_test.go index 3ec1f4a7..f035756d 100644 --- a/shortcuts/drive/drive_export_test.go +++ b/shortcuts/drive/drive_export_test.go @@ -14,8 +14,8 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" - "github.com/larksuite/cli/internal/vfs/localfileio" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/vfs/localfileio" ) func TestValidateDriveExportSpec(t *testing.T) { diff --git a/shortcuts/minutes/minutes_download.go b/shortcuts/minutes/minutes_download.go index a5fdb159..bf77213f 100644 --- a/shortcuts/minutes/minutes_download.go +++ b/shortcuts/minutes/minutes_download.go @@ -228,9 +228,9 @@ type downloadResult struct { } type downloadOpts struct { - fio fileio.FileIO // file I/O abstraction - outputPath string // explicit output file path (single mode only) - outputDir string // output directory (batch mode) + fio fileio.FileIO // file I/O abstraction + outputPath string // explicit output file path (single mode only) + outputDir string // output directory (batch mode) overwrite bool usedNames map[string]bool // tracks used filenames to deduplicate in batch mode } From cb15a09c82d938a3b4ae3c38e6f0f544e7111ff9 Mon Sep 17 00:00:00 2001 From: liushiyao Date: Tue, 7 Apr 2026 21:28:38 +0800 Subject: [PATCH 15/17] fix: avoid nil FileIO in parseStringList by inlining comma-split logic Change-Id: Ic04827e1f1be20c036c5b731825f894ce57052d0 --- shortcuts/base/helpers.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/shortcuts/base/helpers.go b/shortcuts/base/helpers.go index 78224c69..49df2624 100644 --- a/shortcuts/base/helpers.go +++ b/shortcuts/base/helpers.go @@ -83,8 +83,19 @@ func parseStringListFlexible(fio fileio.FileIO, raw string, flagName string) ([] } func parseStringList(raw string) []string { - items, _ := parseStringListFlexible(nil, raw, "fields") - return items + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + item := strings.TrimSpace(part) + if item != "" { + result = append(result, item) + } + } + return result } func deepMergeMaps(dst, src map[string]interface{}) map[string]interface{} { From 8867dca98f151c14edb832a06ab1aa94319099e3 Mon Sep 17 00:00:00 2001 From: liushiyao Date: Tue, 7 Apr 2026 21:43:07 +0800 Subject: [PATCH 16/17] feat: add FileIO.ResolvePath and fix saved_path to return absolute paths Add ResolvePath to FileIO interface so callers can obtain the validated absolute path without re-importing internal path helpers. Update all download shortcuts and SaveResponse to return resolved absolute paths in saved_path output, fixing a regression where relative paths were returned instead of absolute ones. Also add nil guard in RuntimeContext.ValidatePath for defensive safety. Change-Id: I8bb0e323f7e44149df5e6f558a030e505380a1ce --- extension/fileio/types.go | 5 +++++ internal/client/response.go | 6 +++++- internal/vfs/localfileio/localfileio.go | 5 +++++ shortcuts/common/runner.go | 17 ++++++++++++++++- shortcuts/common/runner_jq_test.go | 5 +++-- shortcuts/doc/doc_media_download.go | 2 +- shortcuts/drive/drive_download.go | 2 +- shortcuts/drive/drive_export_common.go | 6 +++++- shortcuts/im/im_messages_resources_download.go | 2 +- shortcuts/minutes/minutes_download.go | 6 +++++- shortcuts/sheets/sheet_export.go | 2 +- 11 files changed, 48 insertions(+), 10 deletions(-) diff --git a/extension/fileio/types.go b/extension/fileio/types.go index 32bb11c0..c116e8a8 100644 --- a/extension/fileio/types.go +++ b/extension/fileio/types.go @@ -31,6 +31,11 @@ type FileIO interface { // Use os.IsNotExist(err) to distinguish "file not found" from "invalid path". Stat(name string) (os.FileInfo, error) + // ResolvePath returns the validated, absolute path for the given output path. + // The default implementation delegates to SafeOutputPath. + // Use this to obtain the canonical saved path for user-facing output. + ResolvePath(path string) (string, error) + // Save writes content to the target path and returns a SaveResult. // The default implementation validates via SafeOutputPath, creates // parent directories, and writes atomically. diff --git a/internal/client/response.go b/internal/client/response.go index bac3381c..0c8f859b 100644 --- a/internal/client/response.go +++ b/internal/client/response.go @@ -128,8 +128,12 @@ func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string) return nil, fmt.Errorf("cannot write file: %s", err) } + resolvedPath, _ := fio.ResolvePath(outputPath) + if resolvedPath == "" { + resolvedPath = outputPath + } return map[string]interface{}{ - "saved_path": outputPath, + "saved_path": resolvedPath, "size_bytes": result.Size(), "content_type": resp.Header.Get("Content-Type"), }, nil diff --git a/internal/vfs/localfileio/localfileio.go b/internal/vfs/localfileio/localfileio.go index 943f7667..9b4745ad 100644 --- a/internal/vfs/localfileio/localfileio.go +++ b/internal/vfs/localfileio/localfileio.go @@ -48,6 +48,11 @@ func (l *LocalFileIO) Stat(name string) (os.FileInfo, error) { return os.Stat(safePath) } +// ResolvePath returns the validated absolute path for the given output path. +func (l *LocalFileIO) ResolvePath(path string) (string, error) { + return SafeOutputPath(path) +} + // saveResult implements fileio.SaveResult. type saveResult struct{ size int64 } diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 4ebdba69..c0aa0262 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -319,8 +319,23 @@ func (ctx *RuntimeContext) FileIO() fileio.FileIO { // directory (via FileIO.Stat). Returns nil if the path is valid or does not // exist yet; returns an error only for illegal paths (absolute, traversal, // symlink escape, control chars). +// ResolveSavePath returns the validated absolute path for user-facing output. +// Falls back to the original path if resolution fails (e.g. server mode). +func (ctx *RuntimeContext) ResolveSavePath(path string) string { + if fio := ctx.FileIO(); fio != nil { + if resolved, err := fio.ResolvePath(path); err == nil && resolved != "" { + return resolved + } + } + return path +} + func (ctx *RuntimeContext) ValidatePath(path string) error { - if _, err := ctx.FileIO().Stat(path); err != nil && !os.IsNotExist(err) { + fio := ctx.FileIO() + if fio == nil { + return fmt.Errorf("no file I/O provider registered") + } + if _, err := fio.Stat(path); err != nil && !os.IsNotExist(err) { return err } return nil diff --git a/shortcuts/common/runner_jq_test.go b/shortcuts/common/runner_jq_test.go index 075f399a..a4b72fcc 100644 --- a/shortcuts/common/runner_jq_test.go +++ b/shortcuts/common/runner_jq_test.go @@ -106,8 +106,9 @@ func TestRuntimeContext_Out_WithJq_InvalidExpr_WritesStderr(t *testing.T) { type testResolvedFileIO struct{} -func (testResolvedFileIO) Open(string) (fileio.File, error) { return nil, nil } -func (testResolvedFileIO) Stat(string) (os.FileInfo, error) { return nil, nil } +func (testResolvedFileIO) Open(string) (fileio.File, error) { return nil, nil } +func (testResolvedFileIO) Stat(string) (os.FileInfo, error) { return nil, nil } +func (testResolvedFileIO) ResolvePath(path string) (string, error) { return path, nil } func (testResolvedFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) { return nil, nil } diff --git a/shortcuts/doc/doc_media_download.go b/shortcuts/doc/doc_media_download.go index 54806eaf..f4e00596 100644 --- a/shortcuts/doc/doc_media_download.go +++ b/shortcuts/doc/doc_media_download.go @@ -120,7 +120,7 @@ var DocMediaDownload = common.Shortcut{ } runtime.Out(map[string]interface{}{ - "saved_path": finalPath, + "saved_path": runtime.ResolveSavePath(finalPath), "size_bytes": result.Size(), "content_type": resp.Header.Get("Content-Type"), }, nil) diff --git a/shortcuts/drive/drive_download.go b/shortcuts/drive/drive_download.go index f45aec6e..b393e324 100644 --- a/shortcuts/drive/drive_download.go +++ b/shortcuts/drive/drive_download.go @@ -79,7 +79,7 @@ var DriveDownload = common.Shortcut{ } runtime.Out(map[string]interface{}{ - "saved_path": outputPath, + "saved_path": runtime.ResolveSavePath(outputPath), "size_bytes": result.Size(), }, nil) return nil diff --git a/shortcuts/drive/drive_export_common.go b/shortcuts/drive/drive_export_common.go index 3b519a4c..b90d96ae 100644 --- a/shortcuts/drive/drive_export_common.go +++ b/shortcuts/drive/drive_export_common.go @@ -274,7 +274,11 @@ func saveContentToOutputDir(fio fileio.FileIO, outputDir, fileName string, paylo if _, err := fio.Save(target, fileio.SaveOptions{}, bytes.NewReader(payload)); err != nil { return "", output.Errorf(output.ExitInternal, "io", "cannot write file: %s", err) } - return target, nil + resolvedPath, _ := fio.ResolvePath(target) + if resolvedPath == "" { + resolvedPath = target + } + return resolvedPath, nil } // downloadDriveExportFile downloads the exported artifact, derives a safe local diff --git a/shortcuts/im/im_messages_resources_download.go b/shortcuts/im/im_messages_resources_download.go index 5d4c9a89..8bbe0b1c 100644 --- a/shortcuts/im/im_messages_resources_download.go +++ b/shortcuts/im/im_messages_resources_download.go @@ -178,5 +178,5 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex if err != nil { return "", 0, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err) } - return finalPath, result.Size(), nil + return runtime.ResolveSavePath(finalPath), result.Size(), nil } diff --git a/shortcuts/minutes/minutes_download.go b/shortcuts/minutes/minutes_download.go index bf77213f..4d992bb1 100644 --- a/shortcuts/minutes/minutes_download.go +++ b/shortcuts/minutes/minutes_download.go @@ -288,7 +288,11 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi if err != nil { return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err) } - return &downloadResult{savedPath: outputPath, sizeBytes: result.Size()}, nil + resolvedPath, _ := opts.fio.ResolvePath(outputPath) + if resolvedPath == "" { + resolvedPath = outputPath + } + return &downloadResult{savedPath: resolvedPath, sizeBytes: result.Size()}, nil } // resolveFilenameFromResponse derives the filename from HTTP response headers. diff --git a/shortcuts/sheets/sheet_export.go b/shortcuts/sheets/sheet_export.go index 79755de5..85a399a8 100644 --- a/shortcuts/sheets/sheet_export.go +++ b/shortcuts/sheets/sheet_export.go @@ -137,7 +137,7 @@ var SheetExport = common.Shortcut{ } runtime.Out(map[string]interface{}{ - "saved_path": outputPath, + "saved_path": runtime.ResolveSavePath(outputPath), "size_bytes": result.Size(), }, nil) return nil From 1c30e7408a338b348abb5a7eb86f7e58113fedd8 Mon Sep 17 00:00:00 2001 From: liushiyao Date: Tue, 7 Apr 2026 21:46:50 +0800 Subject: [PATCH 17/17] fix: gofmt runner_jq_test.go Change-Id: If8a0f8588dddf4dca8741a2646881c7729fdfa2e --- shortcuts/common/runner_jq_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shortcuts/common/runner_jq_test.go b/shortcuts/common/runner_jq_test.go index a4b72fcc..32ed3370 100644 --- a/shortcuts/common/runner_jq_test.go +++ b/shortcuts/common/runner_jq_test.go @@ -106,9 +106,9 @@ func TestRuntimeContext_Out_WithJq_InvalidExpr_WritesStderr(t *testing.T) { type testResolvedFileIO struct{} -func (testResolvedFileIO) Open(string) (fileio.File, error) { return nil, nil } -func (testResolvedFileIO) Stat(string) (os.FileInfo, error) { return nil, nil } -func (testResolvedFileIO) ResolvePath(path string) (string, error) { return path, nil } +func (testResolvedFileIO) Open(string) (fileio.File, error) { return nil, nil } +func (testResolvedFileIO) Stat(string) (os.FileInfo, error) { return nil, nil } +func (testResolvedFileIO) ResolvePath(path string) (string, error) { return path, nil } func (testResolvedFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) { return nil, nil }