diff --git a/extension/fileio/types.go b/extension/fileio/types.go index 7fd47506e..386bda198 100644 --- a/extension/fileio/types.go +++ b/extension/fileio/types.go @@ -6,6 +6,7 @@ package fileio import ( "context" "io" + "io/fs" ) // Provider creates FileIO instances. @@ -46,6 +47,7 @@ type FileIO interface { type FileInfo interface { Size() int64 IsDir() bool + Mode() fs.FileMode } // File is the interface returned by FileIO.Open. diff --git a/shortcuts/common/common_test.go b/shortcuts/common/common_test.go index 7c8f02e90..94a2c9599 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" @@ -57,32 +55,3 @@ func TestParseTimeEndHint(t *testing.T) { t.Errorf("ParseTime(2026-03-15, end) = %v, want 23:59:59", parsed) } } - -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/drive_media_upload.go b/shortcuts/common/drive_media_upload.go index c0bd04982..907cb47a1 100644 --- a/shortcuts/common/drive_media_upload.go +++ b/shortcuts/common/drive_media_upload.go @@ -11,8 +11,6 @@ import ( "io" "net/http" - "github.com/larksuite/cli/internal/validate" - "github.com/larksuite/cli/internal/vfs" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/internal/output" @@ -51,13 +49,9 @@ type DriveMediaMultipartUploadConfig struct { } func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) { - safeFilePath, err := validate.SafeInputPath(cfg.FilePath) + f, err := runtime.FileIO().Open(cfg.FilePath) if err != nil { - return "", output.ErrValidation("invalid file path: %s", err) - } - f, err := vfs.Open(safeFilePath) - if err != nil { - return "", output.ErrValidation("cannot read file: %s", err) + return "", WrapInputStatError(err) } defer f.Close() @@ -173,13 +167,9 @@ func ExtractDriveMediaUploadFileToken(data map[string]interface{}, action string } func uploadDriveMediaMultipartParts(runtime *RuntimeContext, filePath string, fileSize int64, session DriveMediaMultipartUploadSession) error { - safeFilePath, err := validate.SafeInputPath(filePath) - if err != nil { - return output.ErrValidation("invalid file path: %s", err) - } - f, err := vfs.Open(safeFilePath) + f, err := runtime.FileIO().Open(filePath) if err != nil { - return output.ErrValidation("cannot read file: %s", err) + return WrapInputStatError(err) } defer f.Close() diff --git a/shortcuts/common/helpers.go b/shortcuts/common/helpers.go index a47043468..1a15da436 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. @@ -37,16 +32,3 @@ func (mw *MultipartWriter) CreateFormFile(fieldname, filename string) (io.Writer 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 1d95f300e..328ae12db 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -363,6 +363,26 @@ func WrapOpenError(err error, pathMsg, readMsg string) error { return fmt.Errorf("%s: %w", readMsg, err) } +// WrapInputStatError wraps a FileIO.Stat/Open error for input file validation, +// returning output.ErrValidation with the appropriate message: +// - Path validation failures → "unsafe file path: ..." +// - Other errors → readMsg prefix (default "cannot read file") +// +// Pass an optional readMsg to override the non-path-validation message prefix. +func WrapInputStatError(err error, readMsg ...string) error { + if err == nil { + return nil + } + if errors.Is(err, fileio.ErrPathValidation) { + return output.ErrValidation("unsafe file path: %s", err) + } + msg := "cannot read file" + if len(readMsg) > 0 && readMsg[0] != "" { + msg = readMsg[0] + } + return output.ErrValidation("%s: %s", msg, err) +} + // WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors, // using standardized messages and the given error category (e.g. "api_error", "io"). // Path validation errors always use ErrValidation (exit code 2). diff --git a/shortcuts/common/validate.go b/shortcuts/common/validate.go index b894ddf88..a50b7ff10 100644 --- a/shortcuts/common/validate.go +++ b/shortcuts/common/validate.go @@ -5,13 +5,11 @@ package common import ( "fmt" - "os" - "path/filepath" "strconv" "strings" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/vfs" ) // FlagErrorf returns a validation error with flag context (exit code 2). @@ -88,40 +86,11 @@ func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int { // 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 +// It delegates all validation to FileIO.ResolvePath which already performs +// cwd-boundary checks, symlink resolution, and control-character rejection. +func ValidateSafeOutputDir(fio fileio.FileIO, outputDir string) error { + _, err := fio.ResolvePath(outputDir) + return err } // RejectDangerousChars returns an error if value contains ASCII control diff --git a/shortcuts/common/validate_test.go b/shortcuts/common/validate_test.go index d33d2535b..572e9c959 100644 --- a/shortcuts/common/validate_test.go +++ b/shortcuts/common/validate_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "testing" + "github.com/larksuite/cli/internal/vfs/localfileio" "github.com/spf13/cobra" ) @@ -199,7 +200,7 @@ func TestValidateSafeOutputDir_RejectsSymlinkEscape(t *testing.T) { t.Fatalf("Symlink: %v", err) } - if err := ValidateSafeOutputDir("evil_out"); err == nil { + if err := ValidateSafeOutputDir(&localfileio.LocalFileIO{}, "evil_out"); err == nil { t.Fatal("expected error for symlink pointing outside CWD, got nil") } } @@ -214,7 +215,7 @@ func TestValidateSafeOutputDir_RejectsDanglingSymlink(t *testing.T) { t.Fatalf("Symlink: %v", err) } - if err := ValidateSafeOutputDir("dangling"); err == nil { + if err := ValidateSafeOutputDir(&localfileio.LocalFileIO{}, "dangling"); err == nil { t.Fatal("expected error for dangling symlink, got nil") } } @@ -230,7 +231,7 @@ func TestValidateSafeOutputDir_AllowsNormalSubdir(t *testing.T) { t.Fatalf("Mkdir: %v", err) } - if err := ValidateSafeOutputDir("output"); err != nil { + if err := ValidateSafeOutputDir(&localfileio.LocalFileIO{}, "output"); err != nil { t.Fatalf("expected no error for real subdir, got: %v", err) } } @@ -241,7 +242,7 @@ func TestValidateSafeOutputDir_AllowsNonExistentPath(t *testing.T) { workDir := t.TempDir() chdirForTest(t, workDir) - if err := ValidateSafeOutputDir("new_output_dir"); err != nil { + if err := ValidateSafeOutputDir(&localfileio.LocalFileIO{}, "new_output_dir"); err != nil { t.Fatalf("expected no error for non-existent path, got: %v", err) } } diff --git a/shortcuts/doc/doc_media_download.go b/shortcuts/doc/doc_media_download.go index 581efc64c..839627a25 100644 --- a/shortcuts/doc/doc_media_download.go +++ b/shortcuts/doc/doc_media_download.go @@ -12,9 +12,9 @@ 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" "github.com/larksuite/cli/shortcuts/common" ) @@ -66,8 +66,7 @@ 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 { + if _, err := runtime.ResolveSavePath(outputPath); err != nil { return output.ErrValidation("unsafe output path: %s", err) } @@ -105,26 +104,35 @@ 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 + // Validate final path after extension append + if finalPath != outputPath { + if _, err := runtime.ResolveSavePath(finalPath); err != nil { + return output.ErrValidation("unsafe output path: %s", 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: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) if err != nil { - return output.Errorf(output.ExitInternal, "io", "cannot create file: %v", err) + return common.WrapSaveErrorByCategory(err, "io") } + savedPath, _ := runtime.ResolveSavePath(finalPath) + if savedPath == "" { + savedPath = finalPath + } runtime.Out(map[string]interface{}{ - "saved_path": safePath, - "size_bytes": sizeBytes, + "saved_path": savedPath, + "size_bytes": result.Size(), "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 5be0eed1f..a31106367 100644 --- a/shortcuts/doc/doc_media_insert.go +++ b/shortcuts/doc/doc_media_insert.go @@ -8,9 +8,9 @@ import ( "fmt" "path/filepath" + "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" ) @@ -79,7 +79,7 @@ var DocMediaInsert = common.Shortcut{ POST("/open-apis/docx/v1/documents/:document_id/blocks/:document_id/children"). Desc(fmt.Sprintf("[%d] Create empty block at document end", stepBase+1)). Body(createBlockData) - appendDocMediaInsertUploadDryRun(d, filePath, parentType, stepBase+2) + appendDocMediaInsertUploadDryRun(d, runtime.FileIO(), filePath, parentType, stepBase+2) d.PATCH("/open-apis/docx/v1/documents/:document_id/blocks/batch_update"). Desc(fmt.Sprintf("[%d] Bind uploaded file token to the new block", stepBase+3)). Body(batchUpdateData) @@ -93,20 +93,15 @@ 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) - } - documentID, err := resolveDocxDocumentID(runtime, docInput) if err != nil { return err } // Validate file - stat, err := vfs.Stat(safeFilePath) + stat, err := runtime.FileIO().Stat(filePath) if err != nil { - return output.ErrValidation("file not found: %s", filePath) + return common.WrapInputStatError(err, "file not found") } if !stat.Mode().IsRegular() { return output.ErrValidation("file must be a regular file: %s", filePath) @@ -347,12 +342,12 @@ func extractCreatedBlockTargets(createData map[string]interface{}, mediaType str return blockID, uploadParentNode, replaceBlockID } -func appendDocMediaInsertUploadDryRun(d *common.DryRunAPI, filePath, parentType string, step int) { +func appendDocMediaInsertUploadDryRun(d *common.DryRunAPI, fio fileio.FileIO, filePath, parentType string, step int) { // The upload step runs only after the empty placeholder block is created, so // dry-run can refer to that future block ID only symbolically. For large // files, keep multipart internals as substeps of the single user-facing // "upload file" step. - if docMediaShouldUseMultipart(filePath) { + if docMediaShouldUseMultipart(fio, filePath) { d.POST("/open-apis/drive/v1/medias/upload_prepare"). Desc(fmt.Sprintf("[%da] Initialize multipart upload", step)). Body(map[string]interface{}{ diff --git a/shortcuts/doc/doc_media_preview.go b/shortcuts/doc/doc_media_preview.go index 989732644..e362f1897 100644 --- a/shortcuts/doc/doc_media_preview.go +++ b/shortcuts/doc/doc_media_preview.go @@ -12,9 +12,9 @@ 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" "github.com/larksuite/cli/shortcuts/common" ) @@ -61,7 +61,7 @@ var DocMediaPreview = common.Shortcut{ return output.ErrValidation("%s", err) } // Early path validation before API call (final validation after auto-extension below) - if _, err := validate.SafeOutputPath(outputPath); err != nil { + if _, err := runtime.ResolveSavePath(outputPath); err != nil { return output.ErrValidation("unsafe output path: %s", err) } @@ -93,26 +93,32 @@ var DocMediaPreview = 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 + // Validate final path after extension append + if finalPath != outputPath { + if _, err := runtime.ResolveSavePath(finalPath); err != nil { + return output.ErrValidation("unsafe output path: %s", 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: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) if err != nil { - return output.Errorf(output.ExitInternal, "io", "cannot create file: %v", err) + return common.WrapSaveErrorByCategory(err, "io") } + savedPath, _ := runtime.ResolveSavePath(finalPath) runtime.Out(map[string]interface{}{ - "saved_path": safePath, - "size_bytes": sizeBytes, + "saved_path": savedPath, + "size_bytes": result.Size(), "content_type": resp.Header.Get("Content-Type"), }, nil) return nil diff --git a/shortcuts/doc/doc_media_upload.go b/shortcuts/doc/doc_media_upload.go index 2d12301cb..1eae151ab 100644 --- a/shortcuts/doc/doc_media_upload.go +++ b/shortcuts/doc/doc_media_upload.go @@ -8,9 +8,8 @@ import ( "fmt" "path/filepath" + "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" ) @@ -41,7 +40,7 @@ var MediaUpload = common.Shortcut{ body["extra"] = fmt.Sprintf(`{"drive_route_token":"%s"}`, docId) } dry := common.NewDryRunAPI() - if docMediaShouldUseMultipart(filePath) { + if docMediaShouldUseMultipart(runtime.FileIO(), filePath) { prepareBody := map[string]interface{}{ "file_name": filepath.Base(filePath), "parent_type": parentType, @@ -81,15 +80,10 @@ 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) - } - // Validate file - stat, err := vfs.Stat(safeFilePath) + stat, err := runtime.FileIO().Stat(filePath) if err != nil { - return output.ErrValidation("file not found: %s", filePath) + return common.WrapInputStatError(err, "file not found") } if !stat.Mode().IsRegular() { return output.ErrValidation("file must be a regular file: %s", filePath) @@ -147,14 +141,10 @@ func uploadDocMediaFile(runtime *common.RuntimeContext, filePath, fileName strin }) } -func docMediaShouldUseMultipart(filePath string) bool { +func docMediaShouldUseMultipart(fio fileio.FileIO, filePath string) bool { // Dry-run uses local stat as a best-effort planning hint. Execute re-validates // the file before choosing the actual upload path. - safeFilePath, err := validate.SafeInputPath(filePath) - if err != nil { - return false - } - info, err := vfs.Stat(safeFilePath) + info, err := fio.Stat(filePath) if err != nil { return false } diff --git a/shortcuts/drive/drive_download.go b/shortcuts/drive/drive_download.go index 86039cec7..13d96d619 100644 --- a/shortcuts/drive/drive_download.go +++ b/shortcuts/drive/drive_download.go @@ -7,13 +7,12 @@ import ( "context" "fmt" "net/http" - "path/filepath" 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" "github.com/larksuite/cli/shortcuts/common" ) @@ -51,12 +50,13 @@ var DriveDownload = common.Shortcut{ if outputPath == "" { outputPath = fileToken } - safePath, err := validate.SafeOutputPath(outputPath) - if err != nil { - return output.ErrValidation("unsafe output path: %s", err) + + // Early path validation + overwrite check + if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil { + return output.ErrValidation("unsafe output path: %s", resolveErr) } - if err := common.EnsureWritableFile(safePath, overwrite); err != nil { - return err + if _, statErr := runtime.FileIO().Stat(outputPath); 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 +70,21 @@ 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: 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) + return common.WrapSaveErrorByCategory(err, "io") } + savedPath, _ := runtime.ResolveSavePath(outputPath) + if savedPath == "" { + savedPath = outputPath + } runtime.Out(map[string]interface{}{ - "saved_path": safePath, - "size_bytes": sizeBytes, + "saved_path": savedPath, + "size_bytes": result.Size(), }, nil) return nil }, diff --git a/shortcuts/drive/drive_export.go b/shortcuts/drive/drive_export.go index edffcb044..271e07637 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 a95daac67..cc73f82af 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,10 +15,10 @@ 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" - "github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/shortcuts/common" ) @@ -252,8 +253,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 +263,22 @@ 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 + + // 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 := vfs.MkdirAll(filepath.Dir(safePath), 0755); err != nil { - return "", output.Errorf(output.ExitInternal, "io", "cannot create output directory: %s", err) + if _, err := fio.Save(target, fileio.SaveOptions{}, bytes.NewReader(payload)); err != nil { + return "", common.WrapSaveErrorByCategory(err, "io") } - if err := validate.AtomicWrite(safePath, payload, 0644); err != nil { - return "", output.Errorf(output.ExitInternal, "io", "cannot write file: %s", err) + resolvedPath, _ := fio.ResolvePath(target) + if resolvedPath == "" { + resolvedPath = target } - return safePath, nil + return resolvedPath, nil } // downloadDriveExportFile downloads the exported artifact, derives a safe local @@ -303,7 +305,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 fed45ed1b..c80c9eb1e 100644 --- a/shortcuts/drive/drive_export_test.go +++ b/shortcuts/drive/drive_export_test.go @@ -15,6 +15,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/vfs/localfileio" ) func TestValidateDriveExportSpec(t *testing.T) { @@ -465,7 +466,8 @@ func TestSaveContentToOutputDirRejectsOverwriteWithoutFlag(t *testing.T) { } t.Cleanup(func() { _ = os.Chdir(cwd) }) - _, err = saveContentToOutputDir(".", "exists.txt", []byte("new"), false) + fio := &localfileio.LocalFileIO{} + _, err = saveContentToOutputDir(fio, ".", "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 9c25b2af9..39cf8981f 100644 --- a/shortcuts/drive/drive_import.go +++ b/shortcuts/drive/drive_import.go @@ -9,10 +9,8 @@ import ( "path/filepath" "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" ) @@ -49,7 +47,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()) } @@ -76,7 +74,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 } @@ -139,17 +137,12 @@ 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) + info, err := fio.Stat(spec.FilePath) if err != nil { - return 0, output.ErrValidation("cannot read file: %s", err) + return 0, common.WrapInputStatError(err) } if !info.Mode().IsRegular() { return 0, output.ErrValidation("file must be a regular file: %s", spec.FilePath) diff --git a/shortcuts/drive/drive_import_common.go b/shortcuts/drive/drive_import_common.go index 35c14772c..cfde7cfe3 100644 --- a/shortcuts/drive/drive_import_common.go +++ b/shortcuts/drive/drive_import_common.go @@ -11,8 +11,6 @@ import ( "strings" "time" - "github.com/larksuite/cli/internal/vfs" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" @@ -85,9 +83,9 @@ 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) + return "", common.WrapInputStatError(err) } fileSize := importInfo.Size() diff --git a/shortcuts/drive/drive_import_test.go b/shortcuts/drive/drive_import_test.go index 9caef2430..ca72c04a1 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" ) diff --git a/shortcuts/drive/drive_upload.go b/shortcuts/drive/drive_upload.go index 891a254c3..550292815 100644 --- a/shortcuts/drive/drive_upload.go +++ b/shortcuts/drive/drive_upload.go @@ -15,8 +15,6 @@ 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" "github.com/larksuite/cli/shortcuts/common" ) @@ -55,20 +53,14 @@ 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) } - info, err := vfs.Stat(filePath) + info, err := runtime.FileIO().Stat(filePath) if err != nil { - return output.ErrValidation("cannot read file: %s", err) + return common.WrapInputStatError(err) } fileSize := info.Size() @@ -95,9 +87,9 @@ 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 + return "", common.WrapInputStatError(err) } defer f.Close() @@ -180,20 +172,16 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file partSize = remaining } - partFile, err := vfs.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) + return "", common.WrapInputStatError(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/sheets/sheet_export.go b/shortcuts/sheets/sheet_export.go index 2216be6e2..a9162bc7a 100644 --- a/shortcuts/sheets/sheet_export.go +++ b/shortcuts/sheets/sheet_export.go @@ -7,14 +7,13 @@ import ( "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" "github.com/larksuite/cli/shortcuts/common" ) @@ -64,7 +63,7 @@ var SheetExport = common.Shortcut{ // Early path validation before any API call if outputPath != "" { - if _, err := validate.SafeOutputPath(outputPath); err != nil { + if _, err := runtime.ResolveSavePath(outputPath); err != nil { return output.ErrValidation("unsafe output path: %s", err) } } @@ -129,22 +128,21 @@ 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: 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) + return common.WrapSaveErrorByCategory(err, "io") } + savedPath, _ := runtime.ResolveSavePath(outputPath) + if savedPath == "" { + savedPath = outputPath + } runtime.Out(map[string]interface{}{ - "saved_path": safePath, - "size_bytes": sizeBytes, + "saved_path": savedPath, + "size_bytes": result.Size(), }, nil) return nil }, diff --git a/shortcuts/vc/vc_notes.go b/shortcuts/vc/vc_notes.go index f4e4da88d..592ff2f5f 100644 --- a/shortcuts/vc/vc_notes.go +++ b/shortcuts/vc/vc_notes.go @@ -516,7 +516,7 @@ var VCNotes = common.Shortcut{ } // output-dir 路径安全校验 if outDir := runtime.Str("output-dir"); outDir != "" { - if err := common.ValidateSafeOutputDir(outDir); err != nil { + if err := common.ValidateSafeOutputDir(runtime.FileIO(), outDir); err != nil { return err } }