Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ func apiRun(opts *APIOptions) error {
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
})
// MarkRaw tells root error handler to skip enrichPermissionError,
// preserving the original API error detail (log_id, troubleshooter, etc.).
Expand Down
1 change: 1 addition & 0 deletions cmd/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
CheckError: checkErr,
})
}
Expand Down
40 changes: 40 additions & 0 deletions extension/fileio/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package fileio

import "errors"

// ErrPathValidation indicates the path failed security validation
// (traversal, absolute, control chars, symlink escape, etc.).
var ErrPathValidation = errors.New("path validation failed")

// PathValidationError wraps a path validation error.
// errors.Is(err, ErrPathValidation) returns true.
// errors.Is(err, <original OS error>) also works via the chain.
type PathValidationError struct {
Err error // original error
}

func (e *PathValidationError) Error() string { return e.Err.Error() }
func (e *PathValidationError) Unwrap() []error {
return []error{ErrPathValidation, e.Err}
}

// MkdirError indicates parent directory creation failed.
// Use errors.As(err, &fileio.MkdirError{}) to match.
type MkdirError struct {
Err error
}

func (e *MkdirError) Error() string { return e.Err.Error() }
func (e *MkdirError) Unwrap() error { return e.Err }

// WriteError indicates file write failed.
// Use errors.As(err, &fileio.WriteError{}) to match.
type WriteError struct {
Err error
}

func (e *WriteError) Error() string { return e.Err.Error() }
func (e *WriteError) Unwrap() error { return e.Err }
51 changes: 31 additions & 20 deletions internal/client/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,17 @@ package client
import (
"bytes"
"encoding/json"
"errors"
"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 ──
Expand All @@ -29,6 +28,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; required when saving files (--output or binary response)
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
CheckError func(interface{}) error
}
Expand Down Expand Up @@ -61,7 +61,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)
Expand All @@ -75,11 +75,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)
}
Expand All @@ -88,8 +88,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)
}
Expand Down Expand Up @@ -119,23 +119,34 @@ 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)
// 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"),
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)
var me *fileio.MkdirError
var we *fileio.WriteError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return nil, fmt.Errorf("unsafe output path: %s", err)
case errors.As(err, &me):
return nil, fmt.Errorf("create directory: %s", err)
case errors.As(err, &we):
return nil, fmt.Errorf("cannot write file: %s", err)
default:
return nil, fmt.Errorf("cannot write file: %s", err)
}
}

if err := validate.AtomicWrite(safePath, resp.RawBody, 0644); err != nil {
return nil, fmt.Errorf("cannot write file: %s", err)
resolvedPath, err := fio.ResolvePath(outputPath)
if err != nil || resolvedPath == "" {
resolvedPath = outputPath
}

return map[string]interface{}{
"saved_path": safePath,
"size_bytes": len(resp.RawBody),
"saved_path": resolvedPath,
"size_bytes": result.Size(),
"content_type": resp.Header.Get("Content-Type"),
}, nil
}
Expand Down
60 changes: 53 additions & 7 deletions internal/client/response_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"

"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)

func newApiResp(body []byte, headers map[string]string) *larkcore.ApiResp {
Expand Down Expand Up @@ -150,11 +151,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(&localfileio.LocalFileIO{}, 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"])
}

Expand All @@ -176,7 +177,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(&localfileio.LocalFileIO{}, resp, filepath.Join("sub", "deep", "out.bin"))
if err != nil {
t.Fatalf("SaveResponse with nested dir failed: %v", err)
}
Expand All @@ -195,6 +196,7 @@ func TestHandleResponse_JSON(t *testing.T) {
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse failed: %v", err)
Expand All @@ -213,6 +215,7 @@ func TestHandleResponse_JSONWithError(t *testing.T) {
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err == nil {
t.Error("expected error for non-zero code")
Expand All @@ -232,6 +235,7 @@ func TestHandleResponse_BinaryAutoSave(t *testing.T) {
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse binary failed: %v", err)
Expand All @@ -255,6 +259,7 @@ func TestHandleResponse_BinaryWithOutput(t *testing.T) {
OutputPath: "out.png",
Out: &out,
ErrOut: &errOut,
FileIO: &localfileio.LocalFileIO{},
})
if err != nil {
t.Fatalf("HandleResponse with output path failed: %v", err)
Expand All @@ -269,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})
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 404 text/plain")
}
Expand All @@ -287,7 +292,7 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) {
resp := newApiRespWithStatus(502, []byte("<html>Bad Gateway</html>"), 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: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 502 text/html")
}
Expand All @@ -310,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})
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)
}
Expand All @@ -336,12 +341,53 @@ func TestHandleResponse_BinaryWithJq_RejectsNonJSON(t *testing.T) {
}
}

func TestSaveResponse_RejectsPathTraversal(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)

resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "../../evil.txt")
if err == nil {
t.Fatal("expected error for path traversal")
}
if !strings.Contains(err.Error(), "unsafe output path") {
t.Errorf("expected 'unsafe output path' wrapper, got: %v", err)
}
}

func TestSaveResponse_RejectsAbsolutePath(t *testing.T) {
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "/tmp/evil.txt")
if err == nil {
t.Fatal("expected error for absolute path")
}
}
Comment thread
tuxedomm marked this conversation as resolved.

func TestSaveResponse_MetadataContainsAbsolutePath(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)

resp := newApiResp([]byte("x"), map[string]string{"Content-Type": "text/plain"})
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "rel.txt")
if err != nil {
t.Fatalf("SaveResponse failed: %v", err)
}
savedPath, _ := meta["saved_path"].(string)
if !filepath.IsAbs(savedPath) {
t.Errorf("saved_path should be absolute, got %q", savedPath)
}
}

func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) {
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
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: &localfileio.LocalFileIO{}})
if err == nil {
t.Fatal("expected error for 403 JSON with non-zero code")
}
Expand Down
35 changes: 35 additions & 0 deletions internal/cmdutil/factory_default_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,26 @@ import (
"testing"

_ "github.com/larksuite/cli/extension/credential/env"
"github.com/larksuite/cli/extension/fileio"
exttransport "github.com/larksuite/cli/extension/transport"
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 {
resolveCalls int
}

func (p *countingFileIOProvider) Name() string { return "counting" }

func (p *countingFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO {
p.resolveCalls++
return &localfileio.LocalFileIO{}
}

func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliAppSecret, "")
Expand Down Expand Up @@ -198,6 +211,28 @@ func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testin
}
}

func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.T) {
prev := fileio.GetProvider()
provider := &countingFileIOProvider{}
fileio.Register(provider)
t.Cleanup(func() { fileio.Register(prev) })

f := NewDefault(InvocationContext{})
if f.FileIOProvider != provider {
t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider)
}
if provider.resolveCalls != 0 {
t.Fatalf("ResolveFileIO() calls after NewDefault() = %d, want 0", provider.resolveCalls)
}

if got := f.ResolveFileIO(context.Background()); got == nil {
t.Fatal("ResolveFileIO() = nil, want non-nil")
}
if provider.resolveCalls != 1 {
t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls)
}
}

type stubTransportProvider struct {
interceptor exttransport.Interceptor
}
Expand Down
Loading
Loading