Compare commits

..

18 Commits

Author SHA1 Message Date
梁硕
c6b57311b2 docs: add v1.0.6 changelog
Change-Id: Ic5a1d128c9ec903c0e1a9a673f7da8340e775dc0
2026-04-08 21:50:08 +08:00
JackZhao10086
db9ca5c2a4 feat: improve login scope validation and success output (#317)
* feat(auth): improve scope handling and output in login flow

- Add scope validation to check for missing requested scopes
- Implement detailed scope breakdown in login success output
- Add new message strings for scope-related output
- Refactor login success output to handle both JSON and text formats
- Add tests for scope validation and output scenarios

* feat(auth): add requested scope caching for device code login

Implement caching of requested scopes during device code login flow to ensure proper scope validation after authorization. The cache is stored in JSON files under config directory and automatically cleaned up after successful or failed authorization.

Add tests for scope caching functionality and verify proper integration with existing login flow.

* docs(auth): add function comments for login scope handling

Add detailed doc comments to all functions in login scope cache and result handling files to improve code documentation and maintainability.

* refactor(auth): remove pending scopes and improve json output stability

- Remove PendingScopes field and related logic as it's no longer needed
- Add emptyIfNil helper to ensure nil slices are normalized to empty slices in JSON output
- Update tests to verify JSON output stability and fix expected text outputs

* refactor(auth): extract device token polling function for testability

Move device token polling to a package-level variable to enable mocking in tests
Add test case for scope cleanup when token is nil

* fix(auth): return JSON write errors instead of ignoring them

Previously, JSON write errors were only logged to stderr but not returned, causing tests to pass when they should fail. Now properly propagate these errors to callers and update tests to verify error handling.

* refactor(auth): simplify scope handling and improve user messaging

remove redundant scope display and consolidate hint messages to focus on actionable guidance

* refactor(auth): improve scope handling and messaging in login flow

remove ShortHint field and simplify scope hint messages
always display missing scopes section with consistent formatting
add StatusHint for successful login with no missing scopes
update tests to reflect new message structure and content
2026-04-08 21:06:58 +08:00
ILUO
1f8d4b211d feat(task): support starting pagination from page token (#332) 2026-04-08 21:06:43 +08:00
tuxedomm
63ea52b2e6 refactor: migrate vc/minutes shortcuts to FileIO (#336)
* refactor: migrate vc/minutes shortcuts to FileIO

- vc_notes: replace vfs.Stat + validate.SafeOutputPath + validate.AtomicWrite
  with FileIO.Stat/Save for transcript download
- minutes_download: replace validate.SafeOutputPath + validate.AtomicWriteFromReader
  with FileIO.Save, use FileIO.Stat for overwrite checks
- Use WrapSaveError to preserve original error messages
2026-04-08 19:34:19 +08:00
liangshuo-1
555722ac8e fix: resolve concurrency races in RuntimeContext (#330)
* fix: resolve concurrency races in RuntimeContext

- getAPIClient: replace check-then-act with sync.OnceValues, matching
  the factory_default.go convention; use NewAPIClientWithConfig to avoid
  post-construction config override; fall back to direct construction
  for test contexts that bypass newRuntimeContext.

- outputErr: guard first-error capture with sync.Once to prevent data
  races if Out() is ever called from concurrent goroutines.

Change-Id: I99c94c3dcb7663fa61571c9720163e41a5fc0e36

* fix: use tenant token for auth scopes

Change-Id: I83bb677e9a33e906e207679b2ba8d0364bc20fe3
2026-04-08 19:14:45 +08:00
tuxedomm
f5a8fbf8f1 refactor: migrate common/client/im to FileIO and add localfileio tests (#322)
* refactor: migrate common/client/im to FileIO and add localfileio tests

- runner resolveInputFlags: replace validate.SafeInputPath + vfs.ReadFile
  with FileIO.Open + io.ReadAll
- SaveResponse: delegate to FileIO.Save + ResolvePath
- cmd/api, cmd/service: pass FileIO to ResponseOptions
- im: replace validate.SafeLocalFlagPath with RuntimeContext.ValidatePath,
  migrate download/upload to FileIO.Save/Open/Stat
- Add path_test.go and atomicwrite_test.go for localfileio
- Add validate_media_test.go for im media flag validation
- Adapt test mocks to fileio.FileInfo interface
2026-04-08 17:31:21 +08:00
OwenYWT
adef52ada5 fix(config): save empty config before clearing keychain entries (#291)
* fix(config): save empty config before clearing keychain entries
2026-04-08 16:34:50 +08:00
liujinkun2025
6ac5b4d566 support multipart doc media uploads (#294)
Change-Id: I9d9fb00079dacfc96b5781e12e6ce79945baa2ed
2026-04-08 15:43:15 +08:00
MaxHuang22
7158dc2f3c fix: reject positional arguments in shortcuts (#227)
* fix: reject positional arguments in shortcuts with clear error

Shortcuts silently ignored positional arguments (e.g. `lark-cli docs
+search "hello"`), causing empty results. Add Args validator to all
declarative shortcuts so cobra prints usage and a clear error message
telling users to pass values via flags instead.

Change-Id: I7579f9c871138cf91dd5f5d8c1d51bda3f77a1db

* fix: address PR review comments

- Remove unused *Shortcut parameter from rejectPositionalArgs
- Show all positional args in error message instead of only the first
- Add test case for multiple positional arguments

Change-Id: Ifea92d09ddabcd35fbf2db98d9888d18af59b894
2026-04-08 15:11:36 +08:00
feng zhi hao
c54a1354a0 feat(mail): auto-resolve local image paths in all draft entry points (#205)
All draft-related shortcuts now support <img src="./local.png"> in --body,automatically resolving relative paths into cid: inline MIME parts. Only relative paths are supported; absolute paths are rejected. Previously only +draft-edit supported this; now extended to +draft-create, +send, +reply, +reply-all, and +forward.
2026-04-08 14:36:01 +08:00
Vux
a73c9ae27e fix: improve raw API diagnostics for invalid or empty JSON responses (#257)
- Add internal/client/api_errors.go with WrapDoAPIError and WrapJSONResponseParseError to classify JSON decode issues vs generic network errors
- Route cmd/api DoAPI errors and HandleResponse JSON parse errors through the new helpers
- Add regression tests in cmd/api and internal/client

Related: https://github.com/larksuite/cli/issues/215
2026-04-08 14:28:02 +08:00
tuxedomm
900c12ce8d feat: add FileIO extension for file transfer abstraction (#314)
* feat: add FileIO extension for file transfer abstraction

Introduce extension/fileio package with Provider/FileIO/File interfaces
and a global registry, following the same pattern as extension/credential.

- Add LocalFileIO default implementation with path validation and atomic writes
- Wire FileIOProvider into Factory and resolve at runtime via RuntimeContext.FileIO()
- Factory holds Provider (not resolved instance), deferring resolution to execution time
2026-04-08 14:13:59 +08:00
JackZhao10086
f3c3a4c49f feat: support custom data dir and log directories (#302)
* feat: linux support custom data dir via environment variable

* feat(keychain): support custom log directory via LARKSUITE_CLI_LOG_DIR

* feat(security): validate env dir paths for security

Add validation for environment variable directory paths to ensure they are absolute and safe. This prevents potential security issues from malformed paths. Also add corresponding tests to verify the validation behavior.

* docs(validate): add function and test documentation comments

Add missing documentation comments for SafeEnvDirPath function and related test cases to improve code clarity and maintainability

* refactor(keychain): remove warning logs for invalid env vars
2026-04-08 11:06:58 +08:00
max
2e345a4fdd feat(vc): add +recording shortcut for meeting_id to minute_token conversion (#246)
* feat(vc): add +recording shortcut for meeting_id to minute_token conversion

* fix(vc): address PR review feedback for +recording shortcut

* docs(vc): merge Recording and Minutes in resource diagram as they share minute_token

* docs(vc): simplify resource diagram to use Minutes only

* test(vc): add integration eval for +recording execute paths

* docs(vc): fix +recording description to include both input modes

* fix(vc): address review findings for +recording docs and code consistency
2026-04-08 11:02:24 +08:00
eggyrooch-blip
78bc66ce14 fix(docs): normalize board_tokens in +create response for mermaid/whiteboard content (#10)
+update already calls normalizeDocsUpdateResult to surface board_tokens when
markdown contains mermaid/plantuml/whiteboard blocks. +create was missing the
same call, so callers could not know how many whiteboards were created or
retrieve their tokens. One-line fix: call normalizeDocsUpdateResult after
CallMCPTool in DocsCreate.Execute.
2026-04-08 11:01:45 +08:00
zero-my
fe8da8d924 Fix/task get my tasks complete flag help (#310)
* docs: clarify --complete flag behavior in get-my-tasks reference

* fix: clarify complete flag description in get-my-tasks command
2026-04-08 10:33:22 +08:00
zero-my
12bb01addf docs: clarify --complete flag behavior in get-my-tasks reference (#308) 2026-04-08 09:53:13 +08:00
OwenYWT
d6fada01f5 fix(help): point root help Agent Skills link to README section (#289) 2026-04-07 23:24:24 +08:00
105 changed files with 7799 additions and 1609 deletions

View File

@@ -2,6 +2,35 @@
All notable changes to this project will be documented in this file.
## [v1.0.6] - 2026-04-08
### Features
- Improve login scope validation and success output (#317)
- **task**: Support starting pagination from page token (#332)
- Support multipart doc media uploads (#294)
- **mail**: Auto-resolve local image paths in all draft entry points (#205)
- **vc**: Add `+recording` shortcut for `meeting_id` to `minute_token` conversion (#246)
### Bug Fixes
- Resolve concurrency races in RuntimeContext (#330)
- **config**: Save empty config before clearing keychain entries (#291)
- Reject positional arguments in shortcuts (#227)
- Improve raw API diagnostics for invalid or empty JSON responses (#257)
- **docs**: Normalize `board_tokens` in `+create` response for mermaid/whiteboard content (#10)
- **task**: Clarify `--complete` flag help for `get-my-tasks` (#310)
- **help**: Point root help Agent Skills link to README section (#289)
### Documentation
- Clarify `--complete` flag behavior in `get-my-tasks` reference (#308)
### Refactor
- Migrate VC/minutes shortcuts to FileIO (#336)
- Migrate common/client/IM to FileIO and add localfileio tests (#322)
## [v1.0.5] - 2026-04-07
### Features
@@ -193,6 +222,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.6]: https://github.com/larksuite/cli/releases/tag/v1.0.6
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5
[v1.0.4]: https://github.com/larksuite/cli/releases/tag/v1.0.4
[v1.0.3]: https://github.com/larksuite/cli/releases/tag/v1.0.3

View File

@@ -199,7 +199,7 @@ func apiRun(opts *APIOptions) error {
resp, err := ac.DoAPI(opts.Ctx, request)
if err != nil {
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
return output.MarkRaw(client.WrapDoAPIError(err))
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -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.).

View File

@@ -446,6 +446,43 @@ func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
}
}
func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/invalidjson",
RawBody: []byte{},
ContentType: "application/json",
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil {
t.Fatal("expected detail on exit error")
}
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--output") {
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
}
}
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,

View File

@@ -16,6 +16,7 @@ import (
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdAuth creates the auth command with subcommands.
@@ -100,7 +101,7 @@ type appInfoResponse struct {
// getAppInfo queries app info from the Lark API.
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
sdk, err := f.LarkClient()
ac, err := f.NewAPIClient()
if err != nil {
return nil, err
}
@@ -108,12 +109,11 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
queryParams := make(larkcore.QueryParams)
queryParams.Set("lang", "zh_cn")
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: larkauth.ApplicationInfoPath(appId),
QueryParams: queryParams,
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
})
apiResp, err := ac.DoSDKRequest(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: larkauth.ApplicationInfoPath(appId),
QueryParams: queryParams,
}, core.AsBot)
if err != nil {
return nil, err
}

View File

@@ -4,12 +4,16 @@
package auth
import (
"context"
"net/http"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/registry"
)
@@ -231,3 +235,71 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
})
tokenResolver := &authScopesTokenResolver{}
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
appInfoStub := &httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/application/v6/applications/test-app",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"app": map[string]interface{}{
"creator_id": "ou_creator",
"scopes": []map[string]interface{}{
{
"scope": "im:message",
"token_types": []string{"tenant"},
},
{
"scope": "im:message:send_as_user",
"token_types": []string{"user"},
},
},
},
},
},
}
reg.Register(appInfoStub)
err := authScopesRun(&ScopesOptions{
Factory: f,
Ctx: context.Background(),
Format: "json",
})
if err != nil {
t.Fatalf("authScopesRun() error = %v", err)
}
if len(tokenResolver.requests) != 1 {
t.Fatalf("resolved token requests = %v, want exactly one request", tokenResolver.requests)
}
if got := tokenResolver.requests[0].Type; got != credential.TokenTypeTAT {
t.Fatalf("resolved token type = %q, want %q", got, credential.TokenTypeTAT)
}
if got := appInfoStub.CapturedHeaders.Get("Authorization"); got != "Bearer tenant-token" {
t.Fatalf("Authorization header = %q, want %q", got, "Bearer tenant-token")
}
}
type authScopesTokenResolver struct {
requests []credential.TokenSpec
}
func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
r.requests = append(r.requests, req)
switch req.Type {
case credential.TokenTypeTAT:
return &credential.TokenResult{Token: "tenant-token"}, nil
case credential.TokenTypeUAT:
return &credential.TokenResult{Token: "user-token"}, nil
default:
return &credential.TokenResult{Token: "unexpected-token"}, nil
}
}

View File

@@ -34,6 +34,8 @@ type LoginOptions struct {
DeviceCode string
}
var pollDeviceToken = larkauth.PollDeviceToken
// NewCmdAuthLogin creates the auth login subcommand.
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
opts := &LoginOptions{Factory: f}
@@ -235,6 +237,9 @@ func authLoginRun(opts *LoginOptions) error {
// --no-wait: return immediately with device code and URL
if opts.NoWait {
if err := saveLoginRequestedScope(authResp.DeviceCode, finalScope); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to cache requested scopes: %v\n", err)
}
data := map[string]interface{}{
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
@@ -244,7 +249,7 @@ func authLoginRun(opts *LoginOptions) error {
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
return nil
}
@@ -261,7 +266,7 @@ func authLoginRun(opts *LoginOptions) error {
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
@@ -270,20 +275,26 @@ func authLoginRun(opts *LoginOptions) error {
// Step 3: Poll for token
log(msg.WaitingAuth)
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if !result.OK {
if opts.JSON {
b, _ := json.Marshal(map[string]interface{}{
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(map[string]interface{}{
"event": "authorization_failed",
"error": result.Message,
})
fmt.Fprintln(f.IOStreams.Out, string(b))
}); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
return output.ErrBare(output.ExitAuth)
}
return output.ErrAuth("authorization failed: %s", result.Message)
}
if result.Token == nil {
return output.ErrAuth("authorization succeeded but no token returned")
}
// Step 6: Get user info
log(msg.AuthSuccess)
@@ -296,6 +307,8 @@ func authLoginRun(opts *LoginOptions) error {
return output.ErrAuth("failed to get user info: %v", err)
}
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
// Step 7: Store token
now := time.Now().UnixMilli()
storedToken := &larkauth.StoredUAToken{
@@ -318,21 +331,11 @@ func authLoginRun(opts *LoginOptions) error {
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
}
if opts.JSON {
b, _ := json.Marshal(map[string]interface{}{
"event": "authorization_complete",
"user_open_id": openId,
"user_name": userName,
"scope": result.Token.Scope,
})
fmt.Fprintln(f.IOStreams.Out, string(b))
} else {
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
if result.Token.Scope != "" {
fmt.Fprintf(f.IOStreams.ErrOut, msg.GrantedScopes, result.Token.Scope)
}
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
}
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
return nil
}
@@ -345,13 +348,26 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
if err != nil {
return err
}
requestedScope, err := loadLoginRequestedScope(opts.DeviceCode)
if err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to load cached requested scopes: %v\n", err)
}
cleanupRequestedScope := func() {
if err := removeLoginRequestedScope(opts.DeviceCode); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
}
}
log(msg.WaitingAuth)
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
if !result.OK {
if shouldRemoveLoginRequestedScope(result) {
cleanupRequestedScope()
}
return output.ErrAuth("authorization failed: %s", result.Message)
}
defer cleanupRequestedScope()
if result.Token == nil {
return output.ErrAuth("authorization succeeded but no token returned")
}
@@ -367,6 +383,8 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
return output.ErrAuth("failed to get user info: %v", err)
}
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
// Store token
now := time.Now().UnixMilli()
storedToken := &larkauth.StoredUAToken{
@@ -389,7 +407,11 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
}
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
return nil
}

View File

@@ -20,11 +20,17 @@ type loginMsg struct {
ConfirmAuth string
// Non-interactive prompts (login.go)
OpenURL string
WaitingAuth string
AuthSuccess string
LoginSuccess string
GrantedScopes string
OpenURL string
WaitingAuth string
AuthSuccess string
LoginSuccess string
ScopeMismatch string
ScopeHint string
RequestedScopes string
NewlyGrantedScopes string
MissingScopes string
NoScopes string
StatusHint string
// Non-interactive hint (no flags)
HintHeader string
@@ -50,11 +56,17 @@ var loginMsgZh = &loginMsg{
ErrNoDomain: "请至少选择一个业务域",
ConfirmAuth: "确认授权?",
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AuthSuccess: "授权成功,正在获取用户信息...",
LoginSuccess: "登录成功! 用户: %s (%s)",
GrantedScopes: " 已授权 scopes: %s\n",
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AuthSuccess: "授权成功,正在获取用户信息...",
LoginSuccess: "登录成功! 用户: %s (%s)",
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
RequestedScopes: " 本次请求 scopes: %s\n",
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
MissingScopes: " 本次未授予 scopes: %s\n",
NoScopes: "(空)",
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
HintHeader: "请指定要授权的权限:\n",
HintCommon1: " --recommend 授权推荐权限",
@@ -79,11 +91,17 @@ var loginMsgEn = &loginMsg{
ErrNoDomain: "please select at least one domain",
ConfirmAuth: "Confirm authorization?",
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AuthSuccess: "Authorization successful, fetching user info...",
LoginSuccess: "Login successful! User: %s (%s)",
GrantedScopes: " Granted scopes: %s\n",
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AuthSuccess: "Authorization successful, fetching user info...",
LoginSuccess: "Login successful! User: %s (%s)",
ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s",
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
RequestedScopes: " Requested scopes: %s\n",
NewlyGrantedScopes: " Newly granted scopes: %s\n",
MissingScopes: " Not granted scopes: %s\n",
NoScopes: "(none)",
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
HintHeader: "Please specify the scopes to authorize:\n",
HintCommon1: " --recommend authorize recommended scopes",

View File

@@ -69,12 +69,6 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
t.Errorf("%s LoginSuccess has no format verb", lang)
}
// GrantedScopes should contain %s
got = fmt.Sprintf(msg.GrantedScopes, "scope1 scope2")
if got == msg.GrantedScopes {
t.Errorf("%s GrantedScopes has no format verb", lang)
}
// SummaryDomains should contain %s
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
if got == msg.SummaryDomains {

232
cmd/auth/login_result.go Normal file
View File

@@ -0,0 +1,232 @@
package auth
import (
"encoding/json"
"fmt"
"strings"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
type loginScopeSummary struct {
Requested []string
NewlyGranted []string
AlreadyGranted []string
Granted []string
Missing []string
}
type loginScopeIssue struct {
Message string
Hint string
Summary *loginScopeSummary
}
// ensureRequestedScopesGranted checks whether all requested scopes were granted
// and returns a structured issue when any requested scope is missing.
func ensureRequestedScopesGranted(requestedScope, grantedScope string, msg *loginMsg, summary *loginScopeSummary) *loginScopeIssue {
requested := uniqueScopeList(requestedScope)
if len(requested) == 0 {
return nil
}
missing := larkauth.MissingScopes(grantedScope, requested)
if len(missing) == 0 {
return nil
}
if summary == nil {
summary = &loginScopeSummary{
Requested: requested,
Granted: strings.Fields(grantedScope),
Missing: missing,
}
}
return &loginScopeIssue{
Message: fmt.Sprintf(msg.ScopeMismatch, strings.Join(missing, " ")),
Hint: msg.ScopeHint,
Summary: summary,
}
}
// loadLoginScopeSummary builds a scope summary by comparing the requested scopes,
// previously stored scopes, and the newly granted scopes from the current login.
func loadLoginScopeSummary(appID, openId, requestedScope, grantedScope string) *loginScopeSummary {
previousScope := ""
if previous := larkauth.GetStoredToken(appID, openId); previous != nil {
previousScope = previous.Scope
}
return buildLoginScopeSummary(requestedScope, previousScope, grantedScope)
}
// buildLoginScopeSummary classifies requested scopes into newly granted,
// already granted, and missing buckets while preserving the final granted list.
func buildLoginScopeSummary(requestedScope, previousScope, grantedScope string) *loginScopeSummary {
requested := uniqueScopeList(requestedScope)
previous := uniqueScopeList(previousScope)
granted := uniqueScopeList(grantedScope)
previousSet := make(map[string]bool, len(previous))
for _, scope := range previous {
previousSet[scope] = true
}
grantedSet := make(map[string]bool, len(granted))
for _, scope := range granted {
grantedSet[scope] = true
}
summary := &loginScopeSummary{
Requested: requested,
Granted: granted,
}
for _, scope := range requested {
if !grantedSet[scope] {
summary.Missing = append(summary.Missing, scope)
continue
}
if previousSet[scope] {
summary.AlreadyGranted = append(summary.AlreadyGranted, scope)
continue
}
summary.NewlyGranted = append(summary.NewlyGranted, scope)
}
return summary
}
// uniqueScopeList splits a scope string into a de-duplicated ordered slice.
func uniqueScopeList(scope string) []string {
seen := make(map[string]bool)
var result []string
for _, item := range strings.Fields(scope) {
if seen[item] {
continue
}
seen[item] = true
result = append(result, item)
}
return result
}
// formatScopeList joins scopes for display and falls back to the provided empty
// label when the input slice is empty.
func formatScopeList(scopes []string, empty string) string {
if len(scopes) == 0 {
return empty
}
return strings.Join(scopes, " ")
}
// emptyIfNil normalizes nil slices to empty slices for stable JSON output.
func emptyIfNil(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// writeLoginScopeBreakdown renders the requested/newly granted/missing scope
// breakdown to stderr.
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
if summary == nil {
summary = &loginScopeSummary{}
}
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
fmt.Fprintf(errOut.ErrOut, msg.MissingScopes, formatScopeList(summary.Missing, msg.NoScopes))
}
// writeLoginSuccess emits the successful login payload in either JSON or text
// format together with the computed scope breakdown.
func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary) {
if summary == nil {
summary = &loginScopeSummary{}
}
if opts.JSON {
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil))
fmt.Fprintln(f.IOStreams.Out, string(b))
return
}
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
writeLoginScopeBreakdown(f.IOStreams, msg, summary)
if len(summary.Missing) == 0 && msg.StatusHint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, msg.StatusHint)
}
}
// handleLoginScopeIssue prints or returns a structured missing-scope result
// while preserving a successful login outcome when authorization completed.
func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName string) error {
if issue == nil {
return nil
}
loginSucceeded := openId != ""
if opts.JSON {
if loginSucceeded {
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
fmt.Fprintln(f.IOStreams.Out, string(b))
return nil
}
detail := map[string]interface{}{
"requested": issue.Summary.Requested,
"granted": issue.Summary.Granted,
"missing": issue.Summary.Missing,
}
return &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{
Type: "missing_scope",
Message: issue.Message,
Hint: issue.Hint,
Detail: detail,
},
}
}
fmt.Fprintln(f.IOStreams.ErrOut)
if loginSucceeded {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
} else {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
}
if loginSucceeded {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
}
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
if issue.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
}
if loginSucceeded {
return nil
}
return output.ErrBare(output.ExitAuth)
}
// authorizationCompletePayload builds the JSON payload for a completed login,
// optionally attaching a warning when requested scopes are missing.
func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue) map[string]interface{} {
if summary == nil {
summary = &loginScopeSummary{}
}
payload := map[string]interface{}{
"event": "authorization_complete",
"user_open_id": openId,
"user_name": userName,
"scope": strings.Join(summary.Granted, " "),
"requested": emptyIfNil(summary.Requested),
"newly_granted": emptyIfNil(summary.NewlyGranted),
"already_granted": emptyIfNil(summary.AlreadyGranted),
"missing": emptyIfNil(summary.Missing),
"granted": emptyIfNil(summary.Granted),
}
if issue != nil {
payload["warning"] = map[string]interface{}{
"type": "missing_scope",
"message": issue.Message,
"hint": issue.Hint,
}
}
return payload
}

View File

@@ -0,0 +1,91 @@
package auth
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"regexp"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
var loginScopeCacheSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
type loginScopeCacheRecord struct {
RequestedScope string `json:"requested_scope"`
}
// loginScopeCacheDir returns the directory used to persist auth login --no-wait
// requested scopes keyed by device_code.
func loginScopeCacheDir() string {
return filepath.Join(core.GetConfigDir(), "cache", "auth_login_scopes")
}
// loginScopeCachePath returns the cache file path for a given device_code.
func loginScopeCachePath(deviceCode string) string {
return filepath.Join(loginScopeCacheDir(), sanitizeLoginScopeCacheKey(deviceCode)+".json")
}
// sanitizeLoginScopeCacheKey converts a device_code into a safe filename token.
func sanitizeLoginScopeCacheKey(deviceCode string) string {
sanitized := loginScopeCacheSafeChars.ReplaceAllString(deviceCode, "_")
if sanitized == "" {
return "default"
}
return sanitized
}
// saveLoginRequestedScope persists the requested scope string for a device_code.
func saveLoginRequestedScope(deviceCode, requestedScope string) error {
if err := vfs.MkdirAll(loginScopeCacheDir(), 0700); err != nil {
return err
}
data, err := json.Marshal(loginScopeCacheRecord{RequestedScope: requestedScope})
if err != nil {
return err
}
return validate.AtomicWrite(loginScopeCachePath(deviceCode), data, 0600)
}
// loadLoginRequestedScope loads the cached requested scope string for a device_code.
// It returns an empty string if no cache entry exists.
func loadLoginRequestedScope(deviceCode string) (string, error) {
data, err := vfs.ReadFile(loginScopeCachePath(deviceCode))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
return "", err
}
var record loginScopeCacheRecord
if err := json.Unmarshal(data, &record); err != nil {
_ = vfs.Remove(loginScopeCachePath(deviceCode))
return "", err
}
return record.RequestedScope, nil
}
// removeLoginRequestedScope deletes the cache entry for a device_code.
func removeLoginRequestedScope(deviceCode string) error {
err := vfs.Remove(loginScopeCachePath(deviceCode))
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// shouldRemoveLoginRequestedScope indicates whether the requested-scope cache
// should be removed after polling finishes.
func shouldRemoveLoginRequestedScope(result *larkauth.DeviceFlowResult) bool {
if result == nil {
return false
}
if result.OK || result.Error == "access_denied" {
return true
}
return result.Error == "expired_token" && result.Message != "Polling was cancelled"
}

View File

@@ -0,0 +1,48 @@
package auth
import (
"errors"
"os"
"testing"
"github.com/larksuite/cli/internal/vfs"
)
func TestLoginRequestedScopeCache_RoundTrip(t *testing.T) {
setupLoginConfigDir(t)
deviceCode := "device/code:123"
requestedScope := "im:message:send im:message:reply"
if err := saveLoginRequestedScope(deviceCode, requestedScope); err != nil {
t.Fatalf("saveLoginRequestedScope() error = %v", err)
}
got, err := loadLoginRequestedScope(deviceCode)
if err != nil {
t.Fatalf("loadLoginRequestedScope() error = %v", err)
}
if got != requestedScope {
t.Fatalf("requestedScope = %q, want %q", got, requestedScope)
}
if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); err != nil {
t.Fatalf("Stat(cachePath) error = %v", err)
}
if err := removeLoginRequestedScope(deviceCode); err != nil {
t.Fatalf("removeLoginRequestedScope() error = %v", err)
}
if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Stat(cachePath) error = %v, want not exist", err)
}
}
func TestLoadLoginRequestedScope_MissingReturnsEmpty(t *testing.T) {
setupLoginConfigDir(t)
got, err := loadLoginRequestedScope("missing-device-code")
if err != nil {
t.Fatalf("loadLoginRequestedScope() error = %v", err)
}
if got != "" {
t.Fatalf("requestedScope = %q, want empty", got)
}
}

View File

@@ -5,16 +5,29 @@ package auth
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"sort"
"strings"
"testing"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/common"
"github.com/zalando/go-keyring"
)
type failWriter struct{}
func (failWriter) Write([]byte) (int, error) {
return 0, errors.New("write failed")
}
func TestSuggestDomain_PrefixMatch(t *testing.T) {
known := map[string]bool{
"calendar": true,
@@ -282,6 +295,606 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
}
}
func TestEnsureRequestedScopesGranted(t *testing.T) {
issue := ensureRequestedScopesGranted("im:message:send im:message:reply", "im:message:reply", getLoginMsg("en"), nil)
if issue == nil {
t.Fatal("expected missing scope issue")
}
if !strings.Contains(issue.Message, "im:message:send") {
t.Fatalf("message %q missing requested scope", issue.Message)
}
for _, want := range []string{"Do not retry continuously", "scope being disabled", "lark-cli auth status"} {
if !strings.Contains(issue.Hint, want) {
t.Fatalf("hint %q missing %q", issue.Hint, want)
}
}
if got := strings.Join(issue.Summary.Missing, " "); got != "im:message:send" {
t.Fatalf("Missing = %q", got)
}
}
func TestBuildLoginScopeSummary(t *testing.T) {
summary := buildLoginScopeSummary("im:message:send im:message:reply im:message:send", "im:message:reply", "im:message:send im:message:reply im:chat:read")
if got := strings.Join(summary.Requested, " "); got != "im:message:send im:message:reply" {
t.Fatalf("Requested = %q", got)
}
if got := strings.Join(summary.NewlyGranted, " "); got != "im:message:send" {
t.Fatalf("NewlyGranted = %q", got)
}
if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" {
t.Fatalf("AlreadyGranted = %q", got)
}
if len(summary.Missing) != 0 {
t.Fatalf("Missing = %v, want empty", summary.Missing)
}
if got := strings.Join(summary.Granted, " "); got != "im:message:send im:message:reply im:chat:read" {
t.Fatalf("Granted = %q", got)
}
}
func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
Requested: []string{"im:message:send", "im:message:reply"},
NewlyGranted: []string{"im:message:send"},
AlreadyGranted: []string{"im:message:reply"},
Granted: []string{"im:message:send", "im:message:reply"},
})
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
}
if data["event"] != "authorization_complete" {
t.Fatalf("event = %v", data["event"])
}
if data["scope"] != "im:message:send im:message:reply" {
t.Fatalf("scope = %v", data["scope"])
}
if len(data["newly_granted"].([]interface{})) != 1 {
t.Fatalf("newly_granted = %#v", data["newly_granted"])
}
if len(data["already_granted"].([]interface{})) != 1 {
t.Fatalf("already_granted = %#v", data["already_granted"])
}
}
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
Message: "授权完成,但以下请求 scopes 未被授予: im:message:send",
Hint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
Summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
Missing: []string{"im:message:send"},
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"授权完成,但以下请求 scopes 未被授予: im:message:send",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
}
}
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
Hint: "Granted scopes: base:app:copy. Check app scopes.",
Summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
Missing: []string{"im:message:send"},
Granted: []string{"base:app:copy"},
},
}, "ou_user", "tester")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
}
if data["event"] != "authorization_complete" {
t.Fatalf("event = %v", data["event"])
}
if data["user_open_id"] != "ou_user" {
t.Fatalf("user_open_id = %v", data["user_open_id"])
}
warning, ok := data["warning"].(map[string]interface{})
if !ok {
t.Fatalf("warning = %#v", data["warning"])
}
if warning["type"] != "missing_scope" {
t.Fatalf("warning.type = %v", warning["type"])
}
}
func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
Granted: []string{"offline_access"},
})
var data map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
}
for _, k := range []string{"requested", "newly_granted", "already_granted", "missing", "granted"} {
v, ok := data[k]
if !ok {
t.Fatalf("missing key %q in payload: %v", k, data)
}
if _, ok := v.([]interface{}); !ok {
t.Fatalf("%s = %#v, want JSON array", k, v)
}
}
}
func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
tests := []struct {
name string
summary *loginScopeSummary
expectedPresent []string
expectedAbsent []string
}{
{
name: "mixed newly granted and already granted",
summary: &loginScopeSummary{
Requested: []string{"im:message:send", "im:message:reply"},
NewlyGranted: []string{"im:message:send"},
AlreadyGranted: []string{"im:message:reply"},
Granted: []string{"im:message:send", "im:message:reply"},
},
expectedPresent: []string{
"登录成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: im:message:send",
"本次未授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"最终已授权 scopes:",
"已有 scopes:",
},
},
{
name: "all already granted",
summary: &loginScopeSummary{
Requested: []string{"im:message:send"},
AlreadyGranted: []string{"im:message:send"},
Granted: []string{"im:message:send", "contact:user.base:readonly"},
},
expectedPresent: []string{
"本次请求 scopes: im:message:send",
"本次新授予 scopes: (空)",
"本次未授予 scopes: (空)",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
expectedAbsent: []string{
"最终已授权 scopes:",
"已有 scopes:",
},
},
{
name: "missing scopes are shown",
summary: &loginScopeSummary{
Requested: []string{"im:message:send", "im:message:reply"},
Missing: []string{"im:message:send"},
Granted: []string{"im:message:reply"},
},
expectedPresent: []string{
"本次请求 scopes: im:message:send im:message:reply",
"本次新授予 scopes: (空)",
"本次未授予 scopes: im:message:send",
},
expectedAbsent: []string{
"已有 scopes:",
"最终已授权 scopes:",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary)
got := stderr.String()
for _, want := range tt.expectedPresent {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
for _, unwanted := range tt.expectedAbsent {
if strings.Contains(got, unwanted) {
t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got)
}
}
})
}
}
func TestBuildLoginScopeSummary_WithMissingScopes(t *testing.T) {
summary := buildLoginScopeSummary("im:message:send im:message:reply", "im:message:reply", "im:message:reply")
if got := strings.Join(summary.NewlyGranted, " "); got != "" {
t.Fatalf("NewlyGranted = %q, want empty", got)
}
if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" {
t.Fatalf("AlreadyGranted = %q", got)
}
if got := strings.Join(summary.Missing, " "); got != "im:message:send" {
t.Fatalf("Missing = %q", got)
}
}
func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "cli_test"},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 0,
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthTokenV2,
Body: map[string]interface{}{
"access_token": "user-access-token",
"refresh_token": "refresh-token",
"expires_in": 7200,
"refresh_token_expires_in": 604800,
"scope": "offline_access",
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: larkauth.PathUserInfoV1,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"open_id": "ou_user",
"name": "tester",
},
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"授权完成,但以下请求 scopes 未被授予: im:message:send",
"本次请求 scopes: im:message:send",
"本次未授予 scopes: im:message:send",
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
"scope 被禁用",
"lark-cli auth status",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if strings.Contains(got, "ERROR:") {
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
}
stored := larkauth.GetStoredToken("cli_test", "ou_user")
if stored == nil {
t.Fatal("expected token to be stored when authorization succeeds with missing scopes")
}
if stored.Scope != "offline_access" {
t.Fatalf("stored scope = %q", stored.Scope)
}
cfg, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(cfg.Apps) != 1 || len(cfg.Apps[0].Users) != 1 {
t.Fatalf("unexpected users in config: %#v", cfg.Apps)
}
if cfg.Apps[0].Users[0].UserOpenId != "ou_user" {
t.Fatalf("stored user open id = %q", cfg.Apps[0].Users[0].UserOpenId)
}
if cfg.Apps[0].Users[0].UserName != "tester" {
t.Fatalf("stored user name = %q", cfg.Apps[0].Users[0].UserName)
}
}
func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "cli_test"},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 0,
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthTokenV2,
Body: map[string]interface{}{
"access_token": "user-access-token",
"refresh_token": "refresh-token",
"expires_in": 7200,
"refresh_token_expires_in": 604800,
"scope": "im:message:send offline_access",
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: larkauth.PathUserInfoV1,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"open_id": "ou_user",
"name": "tester",
},
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
NoWait: true,
})
if err != nil {
t.Fatalf("no-wait authLoginRun() error = %v", err)
}
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "im:message:send" {
t.Fatalf("loadLoginRequestedScope() = (%q, %v), want requested scope", got, err)
}
stdout.Reset()
stderr.Reset()
err = authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
DeviceCode: "device-code",
})
if err != nil {
t.Fatalf("device-code authLoginRun() error = %v", err)
}
got := stderr.String()
for _, want := range []string{
"OK: 登录成功! 用户: tester (ou_user)",
"本次请求 scopes: im:message:send",
"本次新授予 scopes: im:message:send",
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
if strings.Contains(got, "最终已授权 scopes:") {
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
}
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
t.Fatalf("loadLoginRequestedScope() after cleanup = (%q, %v), want empty", got, err)
}
}
func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScopes(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
writeLoginSuccess(&LoginOptions{}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
Requested: []string{"im:message:send"},
NewlyGranted: []string{"im:message:send"},
Granted: []string{"im:message:send"},
})
got := stderr.String()
for _, want := range []string{
"Login successful! User: tester (ou_user)",
"Requested scopes: im:message:send",
"Newly granted scopes: im:message:send",
"Not granted scopes: (none)",
"Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
} {
if !strings.Contains(got, want) {
t.Fatalf("stderr missing %q, got:\n%s", want, got)
}
}
}
func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
if err := saveLoginRequestedScope("device-code", "im:message:send"); err != nil {
t.Fatalf("saveLoginRequestedScope() error = %v", err)
}
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
DeviceCode: "device-code",
})
if err == nil {
t.Fatal("expected error for nil token")
}
if !strings.Contains(err.Error(), "authorization succeeded but no token returned") {
t.Fatalf("error = %v, want nil token error", err)
}
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
t.Fatalf("loadLoginRequestedScope() after nil token = (%q, %v), want empty", got, err)
}
}
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
f.IOStreams.Out = failWriter{}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
})
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: context.Background(),
Scope: "im:message:send",
NoWait: true,
JSON: true,
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "failed to write JSON output") {
t.Fatalf("error = %v, want JSON write failure", err)
}
}
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
f.IOStreams.Out = failWriter{}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathDeviceAuthorization,
Body: map[string]interface{}{
"device_code": "device-code",
"user_code": "user-code",
"verification_uri": "https://example.com/verify",
"verification_uri_complete": "https://example.com/verify?code=123",
"expires_in": 240,
"interval": 5,
},
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := authLoginRun(&LoginOptions{
Factory: f,
Ctx: ctx,
Scope: "im:message:send",
JSON: true,
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "failed to write JSON output") {
t.Fatalf("error = %v, want JSON write failure", err)
}
}
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {

View File

@@ -6,6 +6,8 @@ package config
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
@@ -21,6 +23,17 @@ func (n *noopConfigKeychain) Get(service, account string) (string, error) { retu
func (n *noopConfigKeychain) Set(service, account, value string) error { return nil }
func (n *noopConfigKeychain) Remove(service, account string) error { return nil }
type recordingConfigKeychain struct {
removed []string
}
func (r *recordingConfigKeychain) Get(service, account string) (string, error) { return "", nil }
func (r *recordingConfigKeychain) Set(service, account, value string) error { return nil }
func (r *recordingConfigKeychain) Remove(service, account string) error {
r.removed = append(r.removed, service+":"+account)
return nil
}
func TestConfigInitCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret123\n")
@@ -221,6 +234,66 @@ func TestConfigRemoveCmd_FlagParsing(t *testing.T) {
}
}
func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing.T) {
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
multi := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: "app-test",
AppSecret: core.SecretInput{
Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
},
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
kc := &recordingConfigKeychain{}
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Keychain = kc
// Make subsequent config saves fail while keeping the existing config readable.
if err := os.Chmod(configDir, 0500); err != nil {
t.Fatalf("Chmod(%s) error = %v", configDir, err)
}
defer os.Chmod(configDir, 0700)
err := configRemoveRun(&ConfigRemoveOptions{Factory: f})
if err == nil {
t.Fatal("expected save failure")
}
if !strings.Contains(err.Error(), "failed to save config") {
t.Fatalf("error = %v, want failed to save config", err)
}
if len(kc.removed) != 0 {
t.Fatalf("expected no keychain cleanup before successful save, got removals: %v", kc.removed)
}
// Restore permissions and confirm the original config is still intact.
if err := os.Chmod(configDir, 0700); err != nil {
t.Fatalf("restore Chmod(%s) error = %v", configDir, err)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if saved == nil || len(saved.Apps) != 1 || saved.Apps[0].AppId != "app-test" {
t.Fatalf("saved config = %#v, want original single app preserved", saved)
}
if got := saved.Apps[0].AppSecret.Ref; got == nil || got.ID != "appsecret:app-test" {
t.Fatalf("saved app secret ref = %#v, want preserved keychain ref", got)
}
configPath := filepath.Join(configDir, "config.json")
if _, err := os.Stat(configPath); err != nil {
t.Fatalf("expected existing config file to remain, stat error = %v", err)
}
}
func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -44,19 +44,21 @@ func configRemoveRun(opts *ConfigRemoveOptions) error {
return output.ErrValidation("not configured yet")
}
// Clean up keychain entries for all apps
for _, app := range config.Apps {
core.RemoveSecretStore(app.AppSecret, f.Keychain)
for _, user := range app.Users {
auth.RemoveStoredToken(app.AppId, user.UserOpenId)
}
}
// Save empty config
// Save empty config first. If this fails, keep secrets and tokens intact so the
// existing config can still be retried instead of ending up half-removed.
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
if err := core.SaveMultiAppConfig(empty); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
// Clean up keychain entries for all apps after config is cleared.
for _, app := range config.Apps {
core.RemoveSecretStore(app.AppSecret, f.Keychain)
for _, user := range app.Users {
_ = auth.RemoveStoredToken(app.AppId, user.UserOpenId)
}
}
output.PrintSuccess(f.IOStreams.ErrOut, "Configuration removed")
userCount := 0
for _, app := range config.Apps {

View File

@@ -78,7 +78,7 @@ AI AGENT SKILLS:
npx skills add larksuite/cli -s lark-calendar -y
npx skills add larksuite/cli -s lark-im -y
Learn more: https://github.com/larksuite/cli#install-ai-agent-skills
Learn more: https://github.com/larksuite/cli#agent-skills
COMMUNITY:
GitHub: https://github.com/larksuite/cli

View File

@@ -187,3 +187,12 @@ func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
})
}
}
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
}
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
}
}

View File

@@ -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,
})
}

View File

@@ -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 }

View File

@@ -0,0 +1,31 @@
// 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 (last-write-wins).
// Unlike credential.Register which appends to a chain (multiple credential
// sources are tried in order), FileIO uses a single active provider because
// only one file I/O backend is active at a time (local vs server mode).
// 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
}

71
extension/fileio/types.go Normal file
View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package fileio
import (
"context"
"io"
)
// 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.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).
// 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) (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.
Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error)
}
// FileInfo is a minimal subset of os.FileInfo covering actual CLI usage.
// os.FileInfo satisfies this interface.
type FileInfo interface {
Size() int64
IsDir() bool
}
// 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
}
// 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.
type SaveOptions struct {
ContentType string // MIME type
ContentLength int64 // content length; -1 if unknown
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package charcheck provides character-level security checks shared across
// path validation (localfileio) and input validation (validate) packages.
// Keeping these checks in one place ensures consistent detection of dangerous
// Unicode and control characters throughout the codebase.
package charcheck
import "fmt"
// RejectControlChars rejects C0 control characters (except \t and \n) and
// dangerous Unicode characters (Bidi overrides, zero-width, line/paragraph
// separators) that enable visual spoofing attacks.
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. These characters are invisible or alter text direction, allowing
// attackers to make "report.exe" display as "report.txt" (Bidi override) or
// insert hidden content (zero-width characters).
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
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
)
const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard JSON body. If it returns a file, rerun with --output."
// WrapDoAPIError upgrades malformed JSON decode errors from the SDK into
// actionable API errors for raw `lark-cli api` calls. All other failures
// remain network errors.
func WrapDoAPIError(err error) error {
if err == nil {
return nil
}
if isJSONDecodeError(err, false) {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
}
return output.ErrNetwork("API call failed: %v", err)
}
// WrapJSONResponseParseError upgrades empty or malformed JSON response bodies
// into API errors with hints instead of generic parse failures.
func WrapJSONResponseParseError(err error, body []byte) error {
if err == nil {
return nil
}
if len(bytes.TrimSpace(body)) == 0 {
return output.ErrWithHint(output.ExitAPI, "api_error",
"API returned an empty JSON response body", rawAPIJSONHint)
}
if isJSONDecodeError(err, true) {
return output.ErrWithHint(output.ExitAPI, "api_error",
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
}
return output.ErrNetwork("API call failed: %v", err)
}
func isJSONDecodeError(err error, allowEOF bool) bool {
var syntaxErr *json.SyntaxError
var unmarshalTypeErr *json.UnmarshalTypeError
if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalTypeErr) {
return true
}
if allowEOF && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
return true
}
msg := err.Error()
if allowEOF && strings.Contains(msg, "unexpected EOF") {
return true
}
return strings.Contains(msg, "unexpected end of JSON input") ||
strings.Contains(msg, "invalid character") ||
strings.Contains(msg, "cannot unmarshal")
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"encoding/json"
"errors"
"io"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
)
func TestWrapDoAPIError_BareEOFIsNetworkError(t *testing.T) {
err := WrapDoAPIError(io.EOF)
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("expected ExitNetwork, got %d", exitErr.Code)
}
if strings.Contains(exitErr.Error(), "invalid JSON response") {
t.Fatalf("unexpected JSON diagnostic for bare EOF: %q", exitErr.Error())
}
}
func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
t.Fatalf("expected JSON diagnostic message, got %#v", exitErr.Detail)
}
}
func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{"))
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail)
}
}

View File

@@ -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 ──
@@ -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
}
@@ -55,13 +55,13 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
if IsJSONContentType(ct) || ct == "" {
result, err := ParseJSONResponse(resp)
if err != nil {
return output.ErrNetwork("API call failed: %v", err)
return WrapJSONResponseParseError(err, resp.RawBody)
}
if apiErr := check(result); apiErr != nil {
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 +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)
}
@@ -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)
}
@@ -111,7 +111,7 @@ func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) {
dec := json.NewDecoder(bytes.NewReader(resp.RawBody))
dec.UseNumber()
if err := dec.Decode(&result); err != nil {
return nil, fmt.Errorf("response parse error: %v (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
return nil, fmt.Errorf("response parse error: %w (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
}
return result, nil
}
@@ -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)
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 := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
return nil, fmt.Errorf("create directory: %s", err)
resolvedPath, err := fio.ResolvePath(outputPath)
if err != nil || resolvedPath == "" {
resolvedPath = outputPath
}
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": resolvedPath,
"size_bytes": result.Size(),
"content_type": resp.Header.Get("Content-Type"),
}, nil
}

View File

@@ -6,6 +6,7 @@ package client
import (
"bytes"
"errors"
"io"
"net/http"
"os"
"path/filepath"
@@ -15,6 +16,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 {
@@ -75,6 +77,17 @@ func TestParseJSONResponse_Invalid(t *testing.T) {
}
}
func TestParseJSONResponse_EmptyBody_WrapsEOF(t *testing.T) {
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
_, err := ParseJSONResponse(resp)
if err == nil {
t.Fatal("expected error for empty body")
}
if !errors.Is(err, io.EOF) {
t.Fatalf("expected wrapped io.EOF, got %v", err)
}
}
func TestResolveFilename(t *testing.T) {
tests := []struct {
name string
@@ -150,11 +163,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"])
}
@@ -176,7 +189,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)
}
@@ -195,6 +208,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)
@@ -213,12 +227,44 @@ 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")
}
}
func TestHandleResponse_EmptyJSONBody_ShowsDiagnostic(t *testing.T) {
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
})
if err == nil {
t.Fatal("expected error for empty JSON body")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil {
t.Fatal("expected detail on exit error")
}
if exitErr.Detail.Message != "API returned an empty JSON response body" {
t.Fatalf("unexpected message: %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--output") {
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
}
}
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
@@ -232,6 +278,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)
@@ -255,6 +302,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)
@@ -269,7 +317,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")
}
@@ -287,7 +335,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")
}
@@ -310,7 +358,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)
}
@@ -336,12 +384,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")
}
}
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")
}

View File

@@ -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,17 @@ type Factory struct {
ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call
Credential *credential.CredentialProvider
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.

View File

@@ -17,12 +17,14 @@ 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"
"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" // register default FileIO provider
)
// NewDefault creates a production Factory with cached closures.
@@ -44,6 +46,9 @@ func NewDefault(inv InvocationContext) *Factory {
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
}
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc()

View File

@@ -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, "")
@@ -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
}

View File

@@ -7,14 +7,17 @@ import (
"bytes"
"context"
"net/http"
"os"
"testing"
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"
"github.com/larksuite/cli/internal/vfs"
)
// noopKeychain is a no-op KeychainAccess for tests that don't need keychain.
@@ -62,12 +65,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,
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
}
@@ -83,6 +87,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 := vfs.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}
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) }) //nolint:forbidigo // matching restore
}
type testDefaultToken struct{}
func (t *testDefaultToken) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {

View File

@@ -9,6 +9,7 @@ import (
"sync"
"time"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -21,6 +22,13 @@ var (
)
func authLogDir() string {
if dir := os.Getenv("LARKSUITE_CLI_LOG_DIR"); dir != "" {
safeDir, err := validate.SafeEnvDirPath(dir, "LARKSUITE_CLI_LOG_DIR")
if err == nil {
return safeDir
}
}
if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" {
return filepath.Join(dir, "logs")
}

View File

@@ -0,0 +1,35 @@
package keychain
import (
"path/filepath"
"testing"
)
// TestAuthLogDir_UsesValidatedLogDirEnv verifies that a valid absolute
// LARKSUITE_CLI_LOG_DIR is normalized and used as the auth log directory.
func TestAuthLogDir_UsesValidatedLogDirEnv(t *testing.T) {
base := t.TempDir()
base, _ = filepath.EvalSymlinks(base)
t.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(base, "logs", "..", "auth"))
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "")
got := authLogDir()
want := filepath.Join(base, "auth")
if got != want {
t.Fatalf("authLogDir() = %q, want %q", got, want)
}
}
// TestAuthLogDir_InvalidLogDirFallsBackToConfigDir verifies that an invalid
// LARKSUITE_CLI_LOG_DIR falls back to LARKSUITE_CLI_CONFIG_DIR/logs.
func TestAuthLogDir_InvalidLogDirFallsBackToConfigDir(t *testing.T) {
t.Setenv("LARKSUITE_CLI_LOG_DIR", "relative-logs")
configDir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
got := authLogDir()
want := filepath.Join(configDir, "logs")
if got != want {
t.Fatalf("authLogDir() = %q, want %q", got, want)
}
}

View File

@@ -16,6 +16,7 @@ import (
"regexp"
"github.com/google/uuid"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -25,6 +26,12 @@ const tagBytes = 16
// StorageDir returns the directory where encrypted files are stored.
func StorageDir(service string) string {
if dir := os.Getenv("LARKSUITE_CLI_DATA_DIR"); dir != "" {
safeDir, err := validate.SafeEnvDirPath(dir, "LARKSUITE_CLI_DATA_DIR")
if err == nil {
return filepath.Join(safeDir, service)
}
}
home, err := vfs.UserHomeDir()
if err != nil || home == "" {
// If home is missing, fallback to relative path and print warning.

View File

@@ -0,0 +1,37 @@
//go:build linux
package keychain
import (
"path/filepath"
"testing"
)
// TestStorageDir_UsesValidatedDataDirEnv verifies that a valid absolute
// LARKSUITE_CLI_DATA_DIR is normalized and still preserves service isolation.
func TestStorageDir_UsesValidatedDataDirEnv(t *testing.T) {
base := t.TempDir()
base, _ = filepath.EvalSymlinks(base)
t.Setenv("LARKSUITE_CLI_DATA_DIR", filepath.Join(base, "data", "..", "store"))
got := StorageDir("svc")
want := filepath.Join(base, "store", "svc")
if got != want {
t.Fatalf("StorageDir() = %q, want %q", got, want)
}
}
// TestStorageDir_InvalidDataDirFallsBackToDefault verifies that an invalid
// LARKSUITE_CLI_DATA_DIR falls back to the default per-service storage path.
func TestStorageDir_InvalidDataDirFallsBackToDefault(t *testing.T) {
home := t.TempDir()
home, _ = filepath.EvalSymlinks(home)
t.Setenv("LARKSUITE_CLI_DATA_DIR", "relative-data")
t.Setenv("HOME", home)
got := StorageDir("svc")
want := filepath.Join(home, ".local", "share", "svc")
if got != want {
t.Fatalf("StorageDir() = %q, want %q", got, want)
}
}

View File

@@ -4,74 +4,20 @@
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 writes data to path atomically.
// 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.
// 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)
}

View File

@@ -6,25 +6,17 @@ package validate
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/charcheck"
)
// RejectControlChars rejects C0 control characters (except \t and \n) and
// dangerous Unicode characters from user input.
//
// Control characters cause subtle security issues: null bytes truncate strings
// at the C layer, \r\n enables HTTP header injection
// Unicode characters allow visual spoofing (e.g. making "report.exe" display
// as "report.txt").
// Delegates to charcheck.RejectControlChars — the single source of truth
// for character-level security checks.
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
return charcheck.RejectControlChars(value, flagName)
}
// RejectCRLF rejects strings containing carriage return (\r) or line feed (\n).
@@ -48,23 +40,3 @@ func StripQueryFragment(path string) string {
}
return path
}
// isDangerousUnicode identifies Unicode code points used for visual spoofing attacks.
// These characters are invisible or alter text direction, allowing attackers to make
// "report.exe" display as "report.txt" (Bidi override) or insert hidden content
// (zero-width characters).
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
}

View File

@@ -3,127 +3,28 @@
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 validates a download/export target path.
// 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 validates an upload/read source path.
// Delegates to localfileio.SafeInputPath.
func SafeInputPath(path string) (string, error) {
return safePath(path, "--file")
return localfileio.SafeInputPath(path)
}
// SafeEnvDirPath validates an environment-provided application directory path.
// Delegates to localfileio.SafeEnvDirPath.
func SafeEnvDirPath(path, envName string) (string, error) {
return localfileio.SafeEnvDirPath(path, envName)
}
// 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.
// 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)
}

View File

@@ -283,3 +283,30 @@ func TestSafeInputPath_ErrorMessageContainsCorrectFlagName(t *testing.T) {
t.Errorf("error should mention --output, got: %s", err.Error())
}
}
// TestSafeEnvDirPath_RequiresAbsolutePath verifies that environment-provided
// directory paths must be absolute.
func TestSafeEnvDirPath_RequiresAbsolutePath(t *testing.T) {
_, err := SafeEnvDirPath("logs", "LARKSUITE_CLI_LOG_DIR")
if err == nil {
t.Fatal("expected error for relative path")
}
if !strings.Contains(err.Error(), "LARKSUITE_CLI_LOG_DIR") {
t.Fatalf("error should mention env name, got %v", err)
}
}
// TestSafeEnvDirPath_ReturnsNormalizedAbsolutePath verifies that a valid
// absolute environment directory is cleaned and resolved to its canonical path.
func TestSafeEnvDirPath_ReturnsNormalizedAbsolutePath(t *testing.T) {
base := t.TempDir()
base, _ = filepath.EvalSymlinks(base)
got, err := SafeEnvDirPath(filepath.Join(base, "logs", "..", "auth"), "LARKSUITE_CLI_LOG_DIR")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := filepath.Join(base, "auth")
if got != want {
t.Fatalf("SafeEnvDirPath() = %q, want %q", got, want)
}
}

View File

@@ -8,6 +8,8 @@ import (
"net/url"
"regexp"
"strings"
"github.com/larksuite/cli/internal/charcheck"
)
// unsafeResourceChars matches URL-special characters, control characters,
@@ -35,7 +37,7 @@ func ResourceName(name, flagName string) error {
return fmt.Errorf("%s contains invalid characters", flagName)
}
for _, r := range name {
if isDangerousUnicode(r) {
if charcheck.IsDangerousUnicode(r) {
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
}
}

View File

@@ -6,6 +6,8 @@ package validate
import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/charcheck"
)
// ansiEscape matches ANSI CSI sequences (ESC[ ... letter) and OSC sequences (ESC] ... BEL).
@@ -34,7 +36,7 @@ func SanitizeForTerminal(text string) string {
b.WriteRune(r)
case r < 0x20 || r == 0x7f:
continue
case isDangerousUnicode(r):
case charcheck.IsDangerousUnicode(r):
continue
default:
b.WriteRune(r)

View File

@@ -5,6 +5,8 @@ package validate
import (
"testing"
"github.com/larksuite/cli/internal/charcheck"
)
func TestSanitizeForTerminal_StripsEscapesAndDangerousChars(t *testing.T) {
@@ -74,16 +76,16 @@ func TestIsDangerousUnicode_IdentifiesAllDangerousRanges(t *testing.T) {
0x2066, 0x2067, 0x2068, 0x2069, // isolates
}
for _, r := range dangerous {
if !isDangerousUnicode(r) {
t.Errorf("isDangerousUnicode(%U) = false, want true", r)
if !charcheck.IsDangerousUnicode(r) {
t.Errorf("charcheck.IsDangerousUnicode(%U) = false, want true", r)
}
}
// ── GIVEN: safe Unicode code points → THEN: returns false ──
safe := []rune{'A', '中', '!', ' ', '\t', '\n', 0x200A, 0x2070}
for _, r := range safe {
if isDangerousUnicode(r) {
t.Errorf("isDangerousUnicode(%U) = true, want false", r)
if charcheck.IsDangerousUnicode(r) {
t.Errorf("charcheck.IsDangerousUnicode(%U) = true, want false", r)
}
}
}

View File

@@ -0,0 +1,74 @@
// 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 via temp file + rename.
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()
closed := false
success := false
defer func() {
if !success {
if !closed {
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
}
closed = true
if err := vfs.Rename(tmpName, path); err != nil {
return err
}
success = true
return nil
}

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package localfileio
import (
"os"
"path/filepath"
"runtime"
"sync"
"testing"
)
func TestAtomicWrite_WritesContentAndPermissionCorrectly(t *testing.T) {
// GIVEN: a target path in a temp directory
dir := t.TempDir()
path := filepath.Join(dir, "test.json")
data := []byte(`{"key":"value"}`)
// WHEN: AtomicWrite writes data with 0644 permission
if err := AtomicWrite(path, data, 0644); err != nil {
t.Fatalf("AtomicWrite failed: %v", err)
}
// THEN: file content matches exactly
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if string(got) != string(data) {
t.Errorf("content = %q, want %q", got, data)
}
}
func TestAtomicWrite_SetsRestrictivePermission(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("permission test not reliable on Windows")
}
// GIVEN: a target path
dir := t.TempDir()
path := filepath.Join(dir, "secret.json")
// WHEN: AtomicWrite writes with 0600 permission
if err := AtomicWrite(path, []byte("secret"), 0600); err != nil {
t.Fatalf("AtomicWrite failed: %v", err)
}
// THEN: file permission is exactly 0600 (owner read-write only)
info, _ := os.Stat(path)
if perm := info.Mode().Perm(); perm != 0600 {
t.Errorf("permission = %04o, want 0600", perm)
}
}
func TestAtomicWrite_OverwritesExistingFile(t *testing.T) {
// GIVEN: an existing file with old content
dir := t.TempDir()
path := filepath.Join(dir, "test.json")
AtomicWrite(path, []byte("old"), 0644)
// WHEN: AtomicWrite overwrites with new content
if err := AtomicWrite(path, []byte("new"), 0644); err != nil {
t.Fatalf("second write failed: %v", err)
}
// THEN: file contains new content
got, _ := os.ReadFile(path)
if string(got) != "new" {
t.Errorf("content = %q, want %q", got, "new")
}
}
func TestAtomicWrite_LeavesNoResidualTempFileOnError(t *testing.T) {
// GIVEN: a target path in a non-existent nested directory
path := filepath.Join(t.TempDir(), "nonexistent", "subdir", "file.txt")
// WHEN: AtomicWrite fails (parent directory doesn't exist)
err := AtomicWrite(path, []byte("data"), 0644)
// THEN: the write fails
if err == nil {
t.Fatal("expected error writing to nonexistent dir")
}
// THEN: no .tmp files are left behind
parentDir := filepath.Dir(filepath.Dir(path))
entries, _ := os.ReadDir(parentDir)
for _, e := range entries {
if filepath.Ext(e.Name()) == ".tmp" {
t.Errorf("residual temp file found: %s", e.Name())
}
}
}
func TestAtomicWrite_PreservesOriginalFileOnFailure(t *testing.T) {
// GIVEN: an existing file with known content
dir := t.TempDir()
original := []byte("original content")
path := filepath.Join(dir, "file.json")
if err := AtomicWrite(path, original, 0644); err != nil {
t.Fatal(err)
}
// WHEN: AtomicWrite targets a non-existent directory (guaranteed to fail even as root)
badPath := filepath.Join(dir, "no", "such", "dir", "file.json")
err := AtomicWrite(badPath, []byte("new"), 0644)
// THEN: write fails
if err == nil {
t.Fatal("expected error writing to non-existent dir")
}
// THEN: the original file at the valid path is untouched
got, _ := os.ReadFile(path)
if string(got) != string(original) {
t.Errorf("original file corrupted: got %q, want %q", got, original)
}
}
func TestAtomicWrite_HandlesCorrectlyUnderConcurrentWrites(t *testing.T) {
// GIVEN: a target file that will be written by 20 concurrent goroutines
dir := t.TempDir()
path := filepath.Join(dir, "concurrent.json")
// WHEN: 20 goroutines write simultaneously
var wg sync.WaitGroup
for i := range 20 {
wg.Add(1)
go func(n int) {
defer wg.Done()
data := []byte(`{"n":` + string(rune('0'+n%10)) + `}`)
AtomicWrite(path, data, 0644)
}(i)
}
wg.Wait()
// THEN: file exists and is valid (not corrupted by interleaved writes)
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if len(got) == 0 {
t.Error("file is empty after concurrent writes")
}
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package localfileio
import (
"context"
"io"
"path/filepath"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs"
)
// Provider is the default fileio.Provider backed by the local filesystem.
type Provider struct{}
func (p *Provider) Name() string { return "local" }
func (p *Provider) ResolveFileIO(_ context.Context) fileio.FileIO {
return &LocalFileIO{}
}
func init() {
fileio.Register(&Provider{})
}
// 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 := SafeInputPath(name)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Open(safePath)
}
// Stat returns file metadata after validating the path.
func (l *LocalFileIO) Stat(name string) (fileio.FileInfo, error) {
safePath, err := SafeInputPath(name)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Stat(safePath)
}
// saveResult implements fileio.SaveResult.
type saveResult struct{ size int64 }
func (r *saveResult) Size() int64 { return r.size }
// ResolvePath returns the validated absolute path for the given output path.
func (l *LocalFileIO) ResolvePath(path string) (string, error) {
resolved, err := SafeOutputPath(path)
if err != nil {
return "", &fileio.PathValidationError{Err: err}
}
return resolved, nil
}
// 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 := SafeOutputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
return nil, &fileio.MkdirError{Err: err}
}
n, err := AtomicWriteFromReader(safePath, body, 0600)
if err != nil {
return nil, &fileio.WriteError{Err: err}
}
return &saveResult{size: n}, nil
}

View File

@@ -0,0 +1,306 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package localfileio
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/extension/fileio"
)
// testChdir temporarily changes the working directory for a test.
// Not compatible with t.Parallel().
func testChdir(t *testing.T, dir string) {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chdir(orig) })
}
// ── Provider ──
func TestProvider_Name(t *testing.T) {
p := &Provider{}
if got := p.Name(); got != "local" {
t.Errorf("Provider.Name() = %q, want %q", got, "local")
}
}
func TestProvider_ResolveFileIO(t *testing.T) {
p := &Provider{}
fio := p.ResolveFileIO(nil)
if fio == nil {
t.Fatal("Provider.ResolveFileIO returned nil")
}
if _, ok := fio.(*LocalFileIO); !ok {
t.Errorf("expected *LocalFileIO, got %T", fio)
}
}
// ── Open ──
func TestLocalFileIO_Open_ValidFile(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
content := []byte("hello world")
os.WriteFile("test.txt", content, 0644)
fio := &LocalFileIO{}
f, err := fio.Open("test.txt")
if err != nil {
t.Fatalf("Open failed: %v", err)
}
defer f.Close()
got, err := io.ReadAll(f)
if err != nil {
t.Fatalf("ReadAll failed: %v", err)
}
if string(got) != string(content) {
t.Errorf("content = %q, want %q", got, content)
}
}
func TestLocalFileIO_Open_RejectsTraversal(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
_, err := fio.Open("../../etc/passwd")
if err == nil {
t.Error("expected error for path traversal")
}
}
func TestLocalFileIO_Open_RejectsAbsolutePath(t *testing.T) {
fio := &LocalFileIO{}
_, err := fio.Open("/etc/passwd")
if err == nil {
t.Error("expected error for absolute path")
}
if err != nil && !strings.Contains(err.Error(), "relative path") {
t.Errorf("error should mention relative path, got: %v", err)
}
}
func TestLocalFileIO_Open_NonexistentFile(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
_, err := fio.Open("nonexistent.txt")
if err == nil {
t.Error("expected error for nonexistent file")
}
}
// ── Stat ──
func TestLocalFileIO_Stat_ValidFile(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
os.WriteFile("stat.txt", []byte("12345"), 0644)
fio := &LocalFileIO{}
info, err := fio.Stat("stat.txt")
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
if info.Size() != 5 {
t.Errorf("Size() = %d, want 5", info.Size())
}
if info.IsDir() {
t.Error("expected IsDir() = false")
}
}
func TestLocalFileIO_Stat_RejectsTraversal(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
_, err := fio.Stat("../../etc/passwd")
if err == nil {
t.Error("expected error for path traversal")
}
if err != nil && os.IsNotExist(err) {
t.Error("traversal should not be os.IsNotExist, should be a validation error")
}
}
func TestLocalFileIO_Stat_NonexistentReturnsIsNotExist(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
_, err := fio.Stat("nope.txt")
if err == nil {
t.Error("expected error for nonexistent file")
}
if !os.IsNotExist(err) {
t.Errorf("expected os.IsNotExist, got: %v", err)
}
}
// ── Save ──
func TestLocalFileIO_Save_WritesContent(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
body := strings.NewReader("saved content")
result, err := fio.Save("output.bin", fileio.SaveOptions{}, body)
if err != nil {
t.Fatalf("Save failed: %v", err)
}
if result.Size() != int64(len("saved content")) {
t.Errorf("Size() = %d, want %d", result.Size(), len("saved content"))
}
got, _ := os.ReadFile(filepath.Join(dir, "output.bin"))
if string(got) != "saved content" {
t.Errorf("file content = %q, want %q", got, "saved content")
}
}
func TestLocalFileIO_Save_CreatesParentDirs(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
body := strings.NewReader("nested")
_, err := fio.Save(filepath.Join("a", "b", "c.txt"), fileio.SaveOptions{}, body)
if err != nil {
t.Fatalf("Save with nested dir failed: %v", err)
}
got, _ := os.ReadFile(filepath.Join(dir, "a", "b", "c.txt"))
if string(got) != "nested" {
t.Errorf("file content = %q, want %q", got, "nested")
}
}
func TestLocalFileIO_Save_RejectsTraversal(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
_, err := fio.Save("../../evil.txt", fileio.SaveOptions{}, strings.NewReader("bad"))
if err == nil {
t.Error("expected error for path traversal in Save")
}
}
func TestLocalFileIO_Save_RejectsAbsolutePath(t *testing.T) {
fio := &LocalFileIO{}
_, err := fio.Save("/tmp/evil.txt", fileio.SaveOptions{}, strings.NewReader("bad"))
if err == nil {
t.Error("expected error for absolute path in Save")
}
}
// ── ResolvePath ──
func TestLocalFileIO_ResolvePath_ReturnsAbsolute(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
resolved, err := fio.ResolvePath("file.txt")
if err != nil {
t.Fatalf("ResolvePath failed: %v", err)
}
if !filepath.IsAbs(resolved) {
t.Errorf("expected absolute path, got %q", resolved)
}
if filepath.Base(resolved) != "file.txt" {
t.Errorf("expected base name file.txt, got %q", filepath.Base(resolved))
}
}
func TestLocalFileIO_ResolvePath_RejectsTraversal(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
_, err := fio.ResolvePath("../../etc/passwd")
if err == nil {
t.Error("expected error for path traversal in ResolvePath")
}
}
func TestLocalFileIO_ResolvePath_RejectsAbsolute(t *testing.T) {
fio := &LocalFileIO{}
_, err := fio.ResolvePath("/etc/passwd")
if err == nil {
t.Error("expected error for absolute path in ResolvePath")
}
}
// ── Error message consistency ──
func TestLocalFileIO_ErrorMessages_ContainCorrectFlagName(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
// Open/Stat use SafeInputPath → errors should mention "--file"
_, err := fio.Open("/absolute/path")
if err == nil || !strings.Contains(err.Error(), "--file") {
t.Errorf("Open absolute path error should mention --file, got: %v", err)
}
_, err = fio.Stat("/absolute/path")
if err == nil || !strings.Contains(err.Error(), "--file") {
t.Errorf("Stat absolute path error should mention --file, got: %v", err)
}
// Save/ResolvePath use SafeOutputPath → errors should mention "--output"
_, err = fio.Save("/absolute/path", fileio.SaveOptions{}, strings.NewReader(""))
if err == nil || !strings.Contains(err.Error(), "--output") {
t.Errorf("Save absolute path error should mention --output, got: %v", err)
}
_, err = fio.ResolvePath("/absolute/path")
if err == nil || !strings.Contains(err.Error(), "--output") {
t.Errorf("ResolvePath absolute path error should mention --output, got: %v", err)
}
}
// ── Control character / Unicode rejection ──
func TestLocalFileIO_RejectsControlCharsInPath(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
paths := []string{
"file\x00name.txt", // null byte
"file\x1fname.txt", // control char
"file\u200Bname.txt", // zero-width space
"file\u202Ename.txt", // bidi override
}
for _, p := range paths {
if _, err := fio.Open(p); err == nil {
t.Errorf("Open(%q) should reject control/dangerous chars", p)
}
if _, err := fio.Save(p, fileio.SaveOptions{}, strings.NewReader("")); err == nil {
t.Errorf("Save(%q) should reject control/dangerous chars", p)
}
}
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package localfileio
import (
"fmt"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/vfs"
)
// SafeOutputPath validates a download/export target path for --output flags.
func SafeOutputPath(path string) (string, error) {
return safePath(path, "--output")
}
// SafeInputPath validates an upload/read source path for --file flags.
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.
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
}
// SafeEnvDirPath validates an environment-provided application directory path.
// It requires an absolute path, rejects control characters, normalizes the
// input, and resolves symlinks through the nearest existing ancestor.
func SafeEnvDirPath(path, envName string) (string, error) {
if err := charcheck.RejectControlChars(path, envName); err != nil {
return "", err
}
path = filepath.Clean(path)
if !filepath.IsAbs(path) {
return "", fmt.Errorf("%s must be an absolute path, got %q", envName, path)
}
resolved, err := resolveNearestAncestor(path)
if err != nil {
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
}
return resolved, nil
}
// safePath is the shared implementation for SafeOutputPath and SafeInputPath.
func safePath(raw, flagName string) (string, error) {
if err := charcheck.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)
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
}
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 {
parts := append([]string{cur}, tail...)
return filepath.Join(parts...), nil
}
tail = append([]string{filepath.Base(cur)}, tail...)
cur = parent
}
}
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 != ".."
}
// RejectControlChars delegates to charcheck.RejectControlChars.
// Kept as a package-level alias for backward compatibility with callers
// that import localfileio directly.
var RejectControlChars = charcheck.RejectControlChars
// IsDangerousUnicode delegates to charcheck.IsDangerousUnicode.
var IsDangerousUnicode = charcheck.IsDangerousUnicode

View File

@@ -0,0 +1,245 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package localfileio
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
for _, tt := range []struct {
name string
input string
wantErr bool
}{
// ── GIVEN: normal relative paths → THEN: allowed ──
{"normal file", "report.xlsx", false},
{"subdir file", "output/report.xlsx", false},
{"current dir explicit", "./file.txt", false},
{"nested subdir", "a/b/c/file.txt", false},
{"dot in name", "my.report.v2.xlsx", false},
{"space in name", "my file.txt", false},
{"unicode normal", "报告.xlsx", false},
{"dot-dot resolves to cwd", "subdir/..", false},
// ── GIVEN: path traversal via .. → THEN: rejected ──
{"dot-dot escape", "../../.ssh/authorized_keys", true},
{"dot-dot mid path", "subdir/../../etc/passwd", true},
{"triple dot-dot", "../../../etc/shadow", true},
// ── GIVEN: absolute paths → THEN: rejected ──
{"absolute path unix", "/etc/passwd", true},
{"absolute path root", "/tmp/evil", true},
// ── GIVEN: control characters in path → THEN: rejected ──
{"null byte", "file\x00.txt", true},
{"carriage return", "file\r.txt", true},
{"bell char", "file\x07.txt", true},
// ── GIVEN: dangerous Unicode in path → THEN: rejected ──
{"bidi RLO", "file\u202Ename.txt", true},
{"zero width space", "file\u200Bname.txt", true},
{"BOM char", "file\uFEFFname.txt", true},
{"line separator", "file\u2028name.txt", true},
{"bidi LRI", "file\u2066name.txt", true},
// ── GIVEN: looks dangerous but is actually safe → THEN: allowed ──
{"literal percent 2e", "%2e%2e/etc/passwd", false},
{"tilde path", "~/file.txt", false},
} {
t.Run(tt.name, func(t *testing.T) {
// WHEN: SafeOutputPath validates the path
_, err := SafeOutputPath(tt.input)
// THEN: error matches expectation
if (err != nil) != tt.wantErr {
t.Errorf("SafeOutputPath(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
// GIVEN: a clean temp directory as CWD
dir := t.TempDir()
dir, _ = filepath.EvalSymlinks(dir)
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(dir)
// WHEN: SafeOutputPath validates a relative path
got, err := SafeOutputPath("output/file.txt")
// THEN: returns the canonical absolute path for subsequent I/O
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := filepath.Join(dir, "output", "file.txt")
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestSafeOutputPath_RejectsSymlinkEscapingCWD(t *testing.T) {
// GIVEN: a symlink in CWD pointing to /etc (outside CWD)
dir := t.TempDir()
dir, _ = filepath.EvalSymlinks(dir)
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(dir)
os.Symlink("/etc", filepath.Join(dir, "link-to-etc"))
// WHEN: SafeOutputPath validates a path through the symlink
_, err := SafeOutputPath("link-to-etc/passwd")
// THEN: rejected because the resolved path is outside CWD
if err == nil {
t.Error("expected error for symlink escaping CWD, got nil")
}
}
func TestSafeOutputPath_AllowsSymlinkWithinCWD(t *testing.T) {
// GIVEN: a symlink in CWD pointing to a subdirectory within CWD
dir := t.TempDir()
dir, _ = filepath.EvalSymlinks(dir)
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(dir)
os.MkdirAll(filepath.Join(dir, "real"), 0755)
os.Symlink(filepath.Join(dir, "real"), filepath.Join(dir, "link"))
// WHEN: SafeOutputPath validates a path through the internal symlink
got, err := SafeOutputPath("link/file.txt")
// THEN: allowed, resolved to the real path within CWD
if err != nil {
t.Fatalf("symlink within CWD should be allowed: %v", err)
}
want := filepath.Join(dir, "real", "file.txt")
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestSafeOutputPath_ResolvesAncestorSymlinkWhenParentMissing(t *testing.T) {
// GIVEN: CWD contains a symlink "escape" → /etc, and the target path
// goes through "escape/sub/file.txt" where "sub" does not exist.
// The old code failed to resolve the symlink because the immediate
// parent ("escape/sub") didn't exist, leaving resolved un-anchored.
dir := t.TempDir()
dir, _ = filepath.EvalSymlinks(dir)
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(dir)
os.Symlink("/etc", filepath.Join(dir, "escape"))
// WHEN: SafeOutputPath validates a path through the symlink with missing intermediate dirs
_, err := SafeOutputPath("escape/nonexistent/file.txt")
// THEN: rejected — the resolved path is under /etc, outside CWD
if err == nil {
t.Error("expected error for symlink escaping CWD via non-existent parent, got nil")
}
}
func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) {
// GIVEN: a deeply nested non-existent path with no symlinks
dir := t.TempDir()
dir, _ = filepath.EvalSymlinks(dir)
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(dir)
// WHEN: SafeOutputPath validates "a/b/c/d/file.txt" (none of a/b/c/d exist)
got, err := SafeOutputPath("a/b/c/d/file.txt")
// THEN: allowed, resolved to canonical path under CWD
if err != nil {
t.Fatalf("deep non-existent path within CWD should be allowed: %v", err)
}
want := filepath.Join(dir, "a", "b", "c", "d", "file.txt")
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
tmpPath := f.Name()
f.Close()
t.Cleanup(func() { os.Remove(tmpPath) })
// WHEN: SafeUploadPath validates the absolute temp path
_, err = SafeInputPath(tmpPath)
// THEN: absolute paths are rejected even in temp dir
if err == nil {
t.Fatal("expected error for absolute temp path, got nil")
}
}
func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) {
// GIVEN: an absolute path outside the temp directory
// WHEN / THEN: SafeUploadPath rejects it
_, err := SafeInputPath("/etc/passwd")
if err == nil {
t.Error("expected error for absolute non-temp path, got nil")
}
}
func TestSafeUploadPath_AcceptsRelativePath(t *testing.T) {
// GIVEN: a clean temp CWD with a real file
dir := t.TempDir()
dir, _ = filepath.EvalSymlinks(dir)
orig, _ := os.Getwd()
defer os.Chdir(orig)
os.Chdir(dir)
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")
// THEN: accepted and returned as absolute canonical path
if err != nil {
t.Fatalf("SafeUploadPath(relative) error = %v", err)
}
want := filepath.Join(dir, "upload.bin")
if got != want {
t.Errorf("SafeUploadPath(relative) = %q, want %q", got, want)
}
}
func Test_SafeInputPath_ErrorMessageContainsCorrectFlagName(t *testing.T) {
// GIVEN: an absolute path
// WHEN: SafeInputPath rejects it
_, err := SafeInputPath("/etc/passwd")
// THEN: error message mentions --file (not --output)
if err == nil {
t.Fatal("expected error for absolute path")
}
if !strings.Contains(err.Error(), "--file") {
t.Errorf("error should mention --file, got: %s", err.Error())
}
// WHEN: SafeOutputPath rejects it
_, err = SafeOutputPath("/etc/passwd")
// THEN: error message mentions --output (not --file)
if err == nil {
t.Fatal("expected error for absolute path")
}
if !strings.Contains(err.Error(), "--output") {
t.Errorf("error should mention --output, got: %s", err.Error())
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.5",
"version": "1.0.6",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -0,0 +1,245 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"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"
)
const MaxDriveMediaUploadSinglePartSize int64 = 20 * 1024 * 1024 // 20MB
const (
driveMediaUploadAllAction = "upload media failed"
driveMediaUploadPartAction = "upload media part failed"
driveMediaUploadFinishAction = "upload media finish failed"
)
type DriveMediaMultipartUploadSession struct {
UploadID string
BlockSize int64
BlockNum int
}
type DriveMediaUploadAllConfig struct {
FilePath string
FileName string
FileSize int64
ParentType string
ParentNode *string
Extra string
}
type DriveMediaMultipartUploadConfig struct {
FilePath string
FileName string
FileSize int64
ParentType string
ParentNode string
Extra string
}
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
safeFilePath, err := validate.SafeInputPath(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)
}
defer f.Close()
fd := larkcore.NewFormdata()
fd.AddField("file_name", cfg.FileName)
fd.AddField("parent_type", cfg.ParentType)
fd.AddField("size", fmt.Sprintf("%d", cfg.FileSize))
if cfg.ParentNode != nil {
fd.AddField("parent_node", *cfg.ParentNode)
}
if cfg.Extra != "" {
fd.AddField("extra", cfg.Extra)
}
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 {
return "", WrapDriveMediaUploadRequestError(err, driveMediaUploadAllAction)
}
data, err := ParseDriveMediaUploadResponse(apiResp, driveMediaUploadAllAction)
if err != nil {
return "", err
}
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadAllAction)
}
func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
// upload_prepare expects parent_node to be present even when the caller wants
// the service default/root behavior, so multipart callers pass an explicit
// string instead of relying on field omission like upload_all does.
prepareBody := map[string]interface{}{
"file_name": cfg.FileName,
"parent_type": cfg.ParentType,
"parent_node": cfg.ParentNode,
"size": cfg.FileSize,
}
if cfg.Extra != "" {
prepareBody["extra"] = cfg.Extra
}
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody)
if err != nil {
return "", err
}
session, err := ParseDriveMediaMultipartUploadSession(data)
if err != nil {
return "", err
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
if err = uploadDriveMediaMultipartParts(runtime, cfg.FilePath, cfg.FileSize, session); err != nil {
return "", err
}
return finishDriveMediaMultipartUpload(runtime, session.UploadID, session.BlockNum)
}
func ParseDriveMediaMultipartUploadSession(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
// The backend chooses both chunk size and chunk count. Validate them once so
// the streaming loop can follow the returned plan without re-checking shape.
session := DriveMediaMultipartUploadSession{
UploadID: GetString(data, "upload_id"),
BlockSize: int64(GetFloat(data, "block_size")),
BlockNum: int(GetFloat(data, "block_num")),
}
if session.UploadID == "" {
return DriveMediaMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: no upload_id returned")
}
if session.BlockSize <= 0 {
return DriveMediaMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
}
if session.BlockNum <= 0 {
return DriveMediaMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_num returned")
}
return session, nil
}
func WrapDriveMediaUploadRequestError(err error, action string) error {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return output.ErrNetwork("%s: %v", action, err)
}
func ParseDriveMediaUploadResponse(apiResp *larkcore.ApiResp, action string) (map[string]interface{}, error) {
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: invalid response JSON: %v", action, err)
}
if larkCode := int(GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), result["error"])
}
data, _ := result["data"].(map[string]interface{})
return data, nil
}
func ExtractDriveMediaUploadFileToken(data map[string]interface{}, action string) (string, error) {
fileToken := GetString(data, "file_token")
if fileToken == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "%s: no file_token returned", action)
}
return fileToken, nil
}
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)
if err != nil {
return output.ErrValidation("cannot read file: %s", err)
}
defer f.Close()
maxInt := int64(^uint(0) >> 1)
bufferSize := session.BlockSize
if bufferSize <= 0 || bufferSize > maxInt {
return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
}
buffer := make([]byte, int(bufferSize))
remaining := fileSize
// Follow the server-declared block plan exactly; upload_finish expects the
// same block count returned by upload_prepare.
for seq := 0; seq < session.BlockNum; seq++ {
chunkSize := session.BlockSize
if remaining > 0 && chunkSize > remaining {
chunkSize = remaining
}
n, readErr := io.ReadFull(f, buffer[:int(chunkSize)])
if readErr != nil {
return output.ErrValidation("cannot read file: %s", readErr)
}
if err = uploadDriveMediaMultipartPart(runtime, session.UploadID, seq, buffer[:n]); err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n)))
remaining -= int64(n)
}
return nil
}
func uploadDriveMediaMultipartPart(runtime *RuntimeContext, uploadID string, seq int, chunk []byte) error {
fd := larkcore.NewFormdata()
fd.AddField("upload_id", uploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", len(chunk)))
fd.AddFile("file", bytes.NewReader(chunk))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return WrapDriveMediaUploadRequestError(err, driveMediaUploadPartAction)
}
_, err = ParseDriveMediaUploadResponse(apiResp, driveMediaUploadPartAction)
return err
}
func finishDriveMediaMultipartUpload(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})
if err != nil {
return "", err
}
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadFinishAction)
}

View File

@@ -0,0 +1,528 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"os"
"strings"
"sync/atomic"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
var commonDriveMediaUploadTestSeq atomic.Int64
func TestUploadDriveMediaAllBuildsMultipartBody(t *testing.T) {
tests := []struct {
name string
parentNode *string
wantParentNode string
wantParentSet bool
}{
{
name: "includes parent_node when provided",
parentNode: strPtr("blk_parent"),
wantParentNode: "blk_parent",
wantParentSet: true,
},
{
name: "omits parent_node when not provided",
parentNode: nil,
wantParentSet: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_all_123"},
},
}
reg.Register(uploadStub)
filePath := writeDriveMediaUploadTestFile(t, "small.bin", 3)
fileToken, err := UploadDriveMediaAll(runtime, DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: "small.bin",
FileSize: 3,
ParentType: "docx_file",
ParentNode: tt.parentNode,
Extra: `{"drive_route_token":"doxcn123"}`,
})
if err != nil {
t.Fatalf("UploadDriveMediaAll() error: %v", err)
}
if fileToken != "file_all_123" {
t.Fatalf("fileToken = %q, want %q", fileToken, "file_all_123")
}
body := decodeCapturedDriveMediaMultipartBody(t, uploadStub)
if got := body.Fields["file_name"]; got != "small.bin" {
t.Fatalf("file_name = %q, want %q", got, "small.bin")
}
if got := body.Fields["parent_type"]; got != "docx_file" {
t.Fatalf("parent_type = %q, want %q", got, "docx_file")
}
if got := body.Fields["size"]; got != "3" {
t.Fatalf("size = %q, want %q", got, "3")
}
if got := body.Fields["extra"]; got != `{"drive_route_token":"doxcn123"}` {
t.Fatalf("extra = %q, want drive route token payload", got)
}
if got := len(body.Files["file"]); got != 3 {
t.Fatalf("file size = %d, want %d", got, 3)
}
gotParentNode, hasParentNode := body.Fields["parent_node"]
if hasParentNode != tt.wantParentSet {
t.Fatalf("parent_node present = %v, want %v", hasParentNode, tt.wantParentSet)
}
if hasParentNode && gotParentNode != tt.wantParentNode {
t.Fatalf("parent_node = %q, want %q", gotParentNode, tt.wantParentNode)
}
})
}
}
func TestUploadDriveMediaMultipartBuildsPreparePartsAndFinish(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
prepareStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
}
reg.Register(prepareStub)
partStubs := make([]*httpmock.Stub, 0, 6)
for i := 0; i < 6; i++ {
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
}
partStubs = append(partStubs, stub)
reg.Register(stub)
}
finishStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_multi_123"},
},
}
reg.Register(finishStub)
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
fileToken, err := UploadDriveMediaMultipart(runtime, DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: "large.bin",
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
ParentType: "ccm_import_open",
ParentNode: "",
Extra: `{"obj_type":"sheet","file_extension":"xlsx"}`,
})
if err != nil {
t.Fatalf("UploadDriveMediaMultipart() error: %v", err)
}
if fileToken != "file_multi_123" {
t.Fatalf("fileToken = %q, want %q", fileToken, "file_multi_123")
}
prepareBody := decodeCapturedDriveMediaJSONBody(t, prepareStub)
if got, _ := prepareBody["parent_type"].(string); got != "ccm_import_open" {
t.Fatalf("prepare parent_type = %q, want %q", got, "ccm_import_open")
}
rawParentNode, ok := prepareBody["parent_node"]
if !ok {
t.Fatal("prepare body missing parent_node")
}
if got, ok := rawParentNode.(string); !ok || got != "" {
t.Fatalf("prepare parent_node = %#v, want empty string", rawParentNode)
}
if got, _ := prepareBody["extra"].(string); got != `{"obj_type":"sheet","file_extension":"xlsx"}` {
t.Fatalf("prepare extra = %q, want import payload", got)
}
if got, _ := prepareBody["size"].(float64); got != float64(MaxDriveMediaUploadSinglePartSize+1) {
t.Fatalf("prepare size = %v, want %d", got, MaxDriveMediaUploadSinglePartSize+1)
}
firstPart := decodeCapturedDriveMediaMultipartBody(t, partStubs[0])
if got := firstPart.Fields["upload_id"]; got != "upload_123" {
t.Fatalf("first part upload_id = %q, want %q", got, "upload_123")
}
if got := firstPart.Fields["seq"]; got != "0" {
t.Fatalf("first part seq = %q, want %q", got, "0")
}
if got := firstPart.Fields["size"]; got != "4194304" {
t.Fatalf("first part size = %q, want %q", got, "4194304")
}
lastPart := decodeCapturedDriveMediaMultipartBody(t, partStubs[len(partStubs)-1])
if got := lastPart.Fields["seq"]; got != "5" {
t.Fatalf("last part seq = %q, want %q", got, "5")
}
if got := lastPart.Fields["size"]; got != "1" {
t.Fatalf("last part size = %q, want %q", got, "1")
}
if got := len(lastPart.Files["file"]); got != 1 {
t.Fatalf("last part file size = %d, want %d", got, 1)
}
finishBody := decodeCapturedDriveMediaJSONBody(t, finishStub)
if got, _ := finishBody["upload_id"].(string); got != "upload_123" {
t.Fatalf("finish upload_id = %q, want %q", got, "upload_123")
}
if got, _ := finishBody["block_num"].(float64); got != 6 {
t.Fatalf("finish block_num = %v, want %d", got, 6)
}
}
func TestParseDriveMediaMultipartUploadSessionValidatesResponseFields(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data map[string]interface{}
wantText string
}{
{
name: "missing upload id",
data: map[string]interface{}{
"block_size": 4 * 1024 * 1024,
"block_num": 6,
},
wantText: "upload prepare failed: no upload_id returned",
},
{
name: "missing block size",
data: map[string]interface{}{
"upload_id": "upload_123",
"block_num": 6,
},
wantText: "upload prepare failed: invalid block_size returned",
},
{
name: "missing block num",
data: map[string]interface{}{
"upload_id": "upload_123",
"block_size": 4 * 1024 * 1024,
},
wantText: "upload prepare failed: invalid block_num returned",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := ParseDriveMediaMultipartUploadSession(tt.data)
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
}
})
}
}
func TestUploadDriveMediaMultipartPartAPIFailure(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 999,
"msg": "chunk rejected",
},
})
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
_, err := UploadDriveMediaMultipart(runtime, DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: "large.bin",
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
ParentType: "ccm_import_open",
ParentNode: "",
})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "upload media part failed: [999] chunk rejected") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUploadDriveMediaMultipartFinishRequiresFileToken(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
_, err := UploadDriveMediaMultipart(runtime, DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: "large.bin",
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
ParentType: "ccm_import_open",
ParentNode: "",
})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "upload media finish failed: no file_token returned") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestParseDriveMediaUploadResponseErrors(t *testing.T) {
t.Parallel()
t.Run("invalid json", func(t *testing.T) {
t.Parallel()
_, err := ParseDriveMediaUploadResponse(&larkcore.ApiResp{RawBody: []byte("{")}, "upload media failed")
if err == nil || !strings.Contains(err.Error(), "invalid response JSON") {
t.Fatalf("expected invalid JSON error, got %v", err)
}
})
t.Run("api code error", func(t *testing.T) {
t.Parallel()
_, err := ParseDriveMediaUploadResponse(&larkcore.ApiResp{RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`)}, "upload media failed")
if err == nil || !strings.Contains(err.Error(), "upload media failed: [999] boom") {
t.Fatalf("expected API error, got %v", err)
}
})
}
func TestExtractDriveMediaUploadFileTokenRequiresToken(t *testing.T) {
t.Parallel()
_, err := ExtractDriveMediaUploadFileToken(map[string]interface{}{}, "upload media failed")
if err == nil || !strings.Contains(err.Error(), "upload media failed: no file_token returned") {
t.Fatalf("err = %v, want missing file_token error", err)
}
}
func TestWrapDriveMediaUploadRequestError(t *testing.T) {
t.Parallel()
t.Run("preserves exit error", func(t *testing.T) {
t.Parallel()
original := output.ErrValidation("bad input")
got := WrapDriveMediaUploadRequestError(original, "upload media failed")
if got != original {
t.Fatalf("expected same exit error pointer, got %v", got)
}
})
t.Run("wraps generic error as network", func(t *testing.T) {
t.Parallel()
got := WrapDriveMediaUploadRequestError(io.EOF, "upload media failed")
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected ExitError, got %T", got)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
}
if !strings.Contains(got.Error(), "upload media failed") {
t.Fatalf("unexpected error: %v", got)
}
})
}
type capturedDriveMediaMultipartBody struct {
Fields map[string]string
Files map[string][]byte
}
func newDriveMediaUploadTestRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: fmt.Sprintf("common-drive-media-test-%d", commonDriveMediaUploadTestSeq.Add(1)), AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
runtime := &RuntimeContext{
ctx: context.Background(),
Config: cfg,
Factory: f,
resolvedAs: core.AsBot,
}
return runtime, reg
}
func withDriveMediaUploadWorkingDir(t *testing.T, dir string) {
t.Helper()
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("Chdir(%q) error: %v", dir, err)
}
t.Cleanup(func() {
if err := os.Chdir(cwd); err != nil {
t.Fatalf("restore cwd error: %v", err)
}
})
}
func writeDriveMediaUploadTestFile(t *testing.T, name string, size int) string {
t.Helper()
if err := os.WriteFile(name, bytes.Repeat([]byte("a"), size), 0644); err != nil {
t.Fatalf("WriteFile(%q) error: %v", name, err)
}
return name
}
func writeDriveMediaUploadSizedFile(t *testing.T, name string, size int64) string {
t.Helper()
fh, err := os.Create(name)
if err != nil {
t.Fatalf("Create(%q) error: %v", name, err)
}
if err := fh.Truncate(size); err != nil {
t.Fatalf("Truncate(%q) error: %v", name, err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close(%q) error: %v", name, err)
}
return name
}
func decodeCapturedDriveMediaJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode captured JSON body: %v", err)
}
return body
}
func decodeCapturedDriveMediaMultipartBody(t *testing.T, stub *httpmock.Stub) capturedDriveMediaMultipartBody {
t.Helper()
contentType := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
t.Fatalf("parse multipart content type: %v", err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
}
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
body := capturedDriveMediaMultipartBody{
Fields: map[string]string{},
Files: map[string][]byte{},
}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read multipart part: %v", err)
}
data, err := io.ReadAll(part)
if err != nil {
t.Fatalf("read multipart data: %v", err)
}
if part.FileName() != "" {
body.Files[part.FormName()] = data
continue
}
body.Fields[part.FormName()] = string(data)
}
return body
}
func strPtr(s string) *string {
return &s
}

View File

@@ -10,37 +10,39 @@ import (
"fmt"
"io"
"net/http"
"os"
"slices"
"strings"
"sync"
"github.com/google/uuid"
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"
"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"
)
// RuntimeContext provides helpers for shortcut execution.
type RuntimeContext struct {
ctx context.Context // from cmd.Context(), propagated through the call chain
Config *core.CliConfig
Cmd *cobra.Command
Format string
JqExpr string // --jq expression; empty = no filter
outputErr error // deferred error from Out()/OutFormat() jq filtering
botOnly bool // set by framework for bot-only shortcuts
resolvedAs core.Identity // effective identity resolved by framework
Factory *cmdutil.Factory // injected by framework
apiClient *client.APIClient // lazily initialized, cached
larkSDK *lark.Client // eagerly initialized in mountDeclarative
ctx context.Context // from cmd.Context(), propagated through the call chain
Config *core.CliConfig
Cmd *cobra.Command
Format string
JqExpr string // --jq expression; empty = no filter
outputErrOnce sync.Once // guards first-error capture in Out()/OutFormat()
outputErr error // deferred error from jq filtering; written at most once
botOnly bool // set by framework for bot-only shortcuts
resolvedAs core.Identity // effective identity resolved by framework
Factory *cmdutil.Factory // injected by framework
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
larkSDK *lark.Client // eagerly initialized in mountDeclarative
}
// ── Identity ──
@@ -73,18 +75,13 @@ func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
func (ctx *RuntimeContext) Ctx() context.Context { return ctx.ctx }
// getAPIClient returns the cached APIClient, creating it on first use.
// Thread-safe via sync.OnceValues (initialized in newRuntimeContext).
// Falls back to direct construction for test contexts that bypass newRuntimeContext.
func (ctx *RuntimeContext) getAPIClient() (*client.APIClient, error) {
if ctx.apiClient != nil {
return ctx.apiClient, nil
if ctx.apiClientFunc != nil {
return ctx.apiClientFunc()
}
ac, err := ctx.Factory.NewAPIClient()
if err != nil {
return nil, err
}
// Override config with the one resolved for this context (may differ from Factory's)
ac.Config = ctx.Config
ctx.apiClient = ac
return ac, nil
return ctx.Factory.NewAPIClientWithConfig(ctx.Config)
}
// AccessToken returns a valid access token for the current identity.
@@ -296,6 +293,113 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams {
return ctx.Factory.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 {
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 nil
}
// ResolveSavePath resolves a relative path to a validated absolute path via
// FileIO.ResolvePath. It returns an error if no FileIO provider is registered
// or if the path fails validation (e.g. traversal, symlink escape).
func (ctx *RuntimeContext) ResolveSavePath(path string) (string, error) {
fio := ctx.FileIO()
if fio == nil {
return "", fmt.Errorf("no file I/O provider registered")
}
resolved, err := fio.ResolvePath(path)
if err != nil {
return "", fmt.Errorf("resolve save path: %w", err)
}
if resolved == "" {
return "", fmt.Errorf("resolve save path: empty result for %q", path)
}
return resolved, nil
}
// WrapSaveError matches a FileIO.Save error against known categories and wraps
// it with the caller-provided message prefix, preserving backward-compatible
// error text per shortcut.
func WrapSaveError(err error, pathMsg, mkdirMsg, writeMsg string) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
var we *fileio.WriteError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return fmt.Errorf("%s: %w", pathMsg, err)
case errors.As(err, &me):
return fmt.Errorf("%s: %w", mkdirMsg, err)
case errors.As(err, &we):
return fmt.Errorf("%s: %w", writeMsg, err)
default:
return fmt.Errorf("%s: %w", writeMsg, err)
}
}
// WrapOpenError matches a FileIO.Open/Stat error and wraps it with the
// caller-provided message prefix.
func WrapOpenError(err error, pathMsg, readMsg string) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return fmt.Errorf("%s: %w", pathMsg, err)
}
return fmt.Errorf("%s: %w", readMsg, 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).
func WrapSaveErrorByCategory(err error, category string) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return output.ErrValidation("unsafe output path: %s", err)
case errors.As(err, &me):
return output.Errorf(output.ExitInternal, category, "cannot create parent directory: %s", err)
default:
return output.Errorf(output.ExitInternal, category, "cannot create file: %s", err)
}
}
// ValidatePath checks that path is a valid relative input path within the
// working directory by delegating to 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).
//
// NOTE: This validates input (read) paths via SafeInputPath semantics inside
// the FileIO implementation. For output (write) path validation, use
// ResolveSavePath instead.
func (ctx *RuntimeContext) ValidatePath(path string) error {
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
}
// ── Output helpers ──
// Out prints a success JSON envelope to stdout.
@@ -304,9 +408,7 @@ func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
if ctx.JqExpr != "" {
if err := output.JqFilter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
if ctx.outputErr == nil {
ctx.outputErr = err
}
ctx.outputErrOnce.Do(func() { ctx.outputErr = err })
}
return
}
@@ -412,6 +514,7 @@ func (s Shortcut) mountDeclarative(parent *cobra.Command, f *cmdutil.Factory) {
cmd := &cobra.Command{
Use: shortcut.Command,
Short: shortcut.Description,
Args: rejectPositionalArgs(),
RunE: func(cmd *cobra.Command, _ []string) error {
return runShortcut(cmd, f, &shortcut, botOnly)
},
@@ -513,6 +616,9 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
ctx := cmd.Context()
ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String())
rctx := &RuntimeContext{ctx: ctx, Config: config, Cmd: cmd, botOnly: botOnly, resolvedAs: as, Factory: f}
rctx.apiClientFunc = sync.OnceValues(func() (*client.APIClient, error) {
return f.NewAPIClientWithConfig(config)
})
sdk, err := f.LarkClient()
if err != nil {
@@ -575,11 +681,15 @@ 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)
if errors.Is(err, fileio.ErrPathValidation) {
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)
}
@@ -627,6 +737,19 @@ func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut)
return nil
}
// rejectPositionalArgs returns a cobra.PositionalArgs that rejects any
// positional arguments. The error is intentionally a plain error (not
// ExitError) so that cobra prints usage and the root handler prints a
// simple "Error:" line instead of a JSON envelope.
func rejectPositionalArgs() cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return nil
}
return fmt.Errorf("positional arguments are not supported (got %q); pass values via flags", args)
}
}
func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
for _, fl := range s.Flags {
desc := fl.Desc

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestRejectPositionalArgs_WithArgs(t *testing.T) {
t.Parallel()
validator := rejectPositionalArgs()
err := validator(&cobra.Command{}, []string{"hello"})
if err == nil {
t.Fatal("expected error for positional arg, got nil")
}
if !strings.Contains(err.Error(), "positional arguments are not supported") {
t.Errorf("expected positional args rejection message, got: %v", err)
}
if !strings.Contains(err.Error(), `"hello"`) {
t.Errorf("expected the positional arg value in error, got: %v", err)
}
}
func TestRejectPositionalArgs_MultipleArgs(t *testing.T) {
t.Parallel()
validator := rejectPositionalArgs()
err := validator(&cobra.Command{}, []string{"hello", "world"})
if err == nil {
t.Fatal("expected error for multiple positional args, got nil")
}
if !strings.Contains(err.Error(), "positional arguments are not supported") {
t.Errorf("unexpected error message: %v", err)
}
if !strings.Contains(err.Error(), "hello") || !strings.Contains(err.Error(), "world") {
t.Errorf("expected all positional args in error, got: %v", err)
}
}
func TestRejectPositionalArgs_NoArgs(t *testing.T) {
t.Parallel()
validator := rejectPositionalArgs()
if err := validator(&cobra.Command{}, nil); err != nil {
t.Fatalf("expected no error for nil args, got: %v", err)
}
if err := validator(&cobra.Command{}, []string{}); err != nil {
t.Fatalf("expected no error for empty args, got: %v", err)
}
}

View File

@@ -10,6 +10,7 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/spf13/cobra"
)

View File

@@ -13,6 +13,7 @@ import (
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 +103,48 @@ 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) (fileio.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
}
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(),
}
}

View File

@@ -5,23 +5,15 @@ package doc
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"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"
)
const maxFileSize = 20 * 1024 * 1024 // 20MB
var alignMap = map[string]int{
"left": 1,
"center": 2,
@@ -36,7 +28,7 @@ var DocMediaInsert = common.Shortcut{
Scopes: []string{"docs:document.media:upload", "docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (max 20MB)", Required: true},
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
{Name: "doc", Desc: "document URL or document_id", Required: true},
{Name: "type", Default: "image", Desc: "type: image | file"},
{Name: "align", Desc: "alignment: left | center | right"},
@@ -86,16 +78,9 @@ var DocMediaInsert = common.Shortcut{
Desc(fmt.Sprintf("[%d] Get document root block", stepBase)).
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).
POST("/open-apis/drive/v1/medias/upload_all").
Desc(fmt.Sprintf("[%d] Upload local file (multipart/form-data)", stepBase+2)).
Body(map[string]interface{}{
"file_name": filepath.Base(filePath),
"parent_type": parentType,
"parent_node": "<new_block_id>",
"file": "@" + filePath,
}).
PATCH("/open-apis/docx/v1/documents/:document_id/blocks/batch_update").
Body(createBlockData)
appendDocMediaInsertUploadDryRun(d, 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)
@@ -112,7 +97,6 @@ var DocMediaInsert = common.Shortcut{
if pathErr != nil {
return output.ErrValidation("unsafe file path: %s", pathErr)
}
filePath = safeFilePath
documentID, err := resolveDocxDocumentID(runtime, docInput)
if err != nil {
@@ -120,16 +104,19 @@ var DocMediaInsert = common.Shortcut{
}
// Validate file
stat, err := vfs.Stat(filePath)
stat, err := vfs.Stat(safeFilePath)
if err != nil {
return output.ErrValidation("file not found: %s", filePath)
}
if stat.Size() > maxFileSize {
return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
}
fileName := filepath.Base(filePath)
fmt.Fprintf(runtime.IO().ErrOut, "Inserting: %s -> document %s\n", fileName, common.MaskToken(documentID))
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
}
// Step 1: Get document root block to find where to insert
rootData, err := runtime.CallAPI("GET",
@@ -166,7 +153,8 @@ var DocMediaInsert = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Resolved file block targets: upload=%s replace=%s\n", uploadParentNode, replaceBlockID)
}
// Rollback helper
// The placeholder block is created before any upload starts, so failures in
// later steps should try to remove it instead of leaving an empty artifact.
rollback := func() error {
fmt.Fprintf(runtime.IO().ErrOut, "Rolling back: deleting block %s\n", blockId)
_, err := runtime.CallAPI("DELETE",
@@ -185,7 +173,7 @@ var DocMediaInsert = common.Shortcut{
}
// Step 3: Upload media file
fileToken, err := uploadMediaFile(ctx, runtime, filePath, fileName, mediaType, uploadParentNode, documentID)
fileToken, err := uploadDocMediaFile(runtime, filePath, fileName, stat.Size(), parentTypeForMediaType(mediaType), uploadParentNode, documentID)
if err != nil {
return withRollbackWarning(err)
}
@@ -346,6 +334,8 @@ func extractCreatedBlockTargets(createData map[string]interface{}, mediaType str
return blockID, uploadParentNode, replaceBlockID
}
// File blocks are wrapped: the created top-level block owns a nested child
// that is both the upload target and the replace_file target.
nestedChildren, _ := child["children"].([]interface{})
if len(nestedChildren) == 0 {
return blockID, uploadParentNode, replaceBlockID
@@ -357,66 +347,44 @@ func extractCreatedBlockTargets(createData map[string]interface{}, mediaType str
return blockID, uploadParentNode, replaceBlockID
}
// 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)
if err != nil {
return "", err
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return "", output.Errorf(output.ExitInternal, "internal_error", "failed to stat file: %v", err)
}
fileSize := stat.Size()
parentType := parentTypeForMediaType(mediaType)
// Build SDK Formdata
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddField("parent_type", parentType)
fd.AddField("parent_node", parentNode)
fd.AddField("size", fmt.Sprintf("%d", fileSize))
if docId != "" {
extra, err := buildDriveRouteExtra(docId)
if err != nil {
return "", err
}
fd.AddField("extra", extra)
}
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) {
return "", err
}
return "", output.ErrNetwork("file upload failed: %v", err)
func appendDocMediaInsertUploadDryRun(d *common.DryRunAPI, 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) {
d.POST("/open-apis/drive/v1/medias/upload_prepare").
Desc(fmt.Sprintf("[%da] Initialize multipart upload", step)).
Body(map[string]interface{}{
"file_name": filepath.Base(filePath),
"parent_type": parentType,
"parent_node": "<new_block_id>",
"size": "<file_size>",
}).
POST("/open-apis/drive/v1/medias/upload_part").
Desc(fmt.Sprintf("[%db] Upload file parts (repeated)", step)).
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/medias/upload_finish").
Desc(fmt.Sprintf("[%dc] Finalize multipart upload and get file_token", step)).
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
return
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return "", output.Errorf(output.ExitAPI, "api_error", "file upload failed: invalid response JSON: %v", err)
}
code, _ := util.ToFloat64(result["code"])
if code != 0 {
msg, _ := result["msg"].(string)
return "", output.ErrAPI(int(code), fmt.Sprintf("file upload failed: [%d] %s", int(code), msg), result["error"])
}
data, _ := result["data"].(map[string]interface{})
fileToken, _ := data["file_token"].(string)
if fileToken == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "file upload failed: no file_token returned")
}
return fileToken, nil
d.POST("/open-apis/drive/v1/medias/upload_all").
Desc(fmt.Sprintf("[%d] Upload local file (multipart/form-data)", step)).
Body(map[string]interface{}{
"file_name": filepath.Base(filePath),
"parent_type": parentType,
"parent_node": "<new_block_id>",
"size": "<file_size>",
"file": "@" + filePath,
})
}

View File

@@ -5,6 +5,8 @@ package doc
import (
"bytes"
"context"
"encoding/json"
"net/http"
"os"
"path/filepath"
@@ -19,10 +21,6 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
func docsTestConfig() *core.CliConfig {
return docsTestConfigWithAppID("docs-test-app")
}
func docsTestConfigWithAppID(appID string) *core.CliConfig {
return &core.CliConfig{
AppID: appID, AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -59,7 +57,7 @@ func withDocsWorkingDir(t *testing.T, dir string) {
}
func TestDocMediaInsertRejectsOldDocURL(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfig())
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-test-app"))
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
@@ -77,7 +75,7 @@ func TestDocMediaInsertRejectsOldDocURL(t *testing.T) {
}
func TestDocMediaInsertDryRunWikiAddsResolveStep(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfig())
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-test-app"))
err := mountAndRunDocs(t, DocMediaInsert, []string{
"+media-insert",
@@ -99,6 +97,98 @@ func TestDocMediaInsertDryRunWikiAddsResolveStep(t *testing.T) {
}
}
func TestDocMediaUploadDryRunUsesMultipartForLargeFile(t *testing.T) {
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
writeSizedDocTestFile(t, "large.bin", common.MaxDriveMediaUploadSinglePartSize+1)
cmd := &cobra.Command{Use: "docs +media-upload"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("parent-type", "", "")
cmd.Flags().String("parent-node", "", "")
cmd.Flags().String("doc-id", "", "")
if err := cmd.Flags().Set("file", "./large.bin"); err != nil {
t.Fatalf("set --file: %v", err)
}
if err := cmd.Flags().Set("parent-type", "docx_file"); err != nil {
t.Fatalf("set --parent-type: %v", err)
}
if err := cmd.Flags().Set("parent-node", "blk_parent"); err != nil {
t.Fatalf("set --parent-node: %v", err)
}
dry := decodeDocDryRun(t, MediaUpload.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
if dry.Description != "chunked media upload (files > 20MB)" {
t.Fatalf("dry-run description = %q", dry.Description)
}
if len(dry.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(dry.API))
}
if dry.API[0].URL != "/open-apis/drive/v1/medias/upload_prepare" {
t.Fatalf("first URL = %q, want upload_prepare", dry.API[0].URL)
}
if dry.API[1].URL != "/open-apis/drive/v1/medias/upload_part" {
t.Fatalf("second URL = %q, want upload_part", dry.API[1].URL)
}
if dry.API[2].URL != "/open-apis/drive/v1/medias/upload_finish" {
t.Fatalf("third URL = %q, want upload_finish", dry.API[2].URL)
}
if got, _ := dry.API[0].Body["parent_node"].(string); got != "blk_parent" {
t.Fatalf("prepare parent_node = %q, want %q", got, "blk_parent")
}
}
func TestDocMediaInsertDryRunUsesMultipartForLargeFile(t *testing.T) {
tmpDir := t.TempDir()
withDocsWorkingDir(t, tmpDir)
writeSizedDocTestFile(t, "large.bin", common.MaxDriveMediaUploadSinglePartSize+1)
cmd := &cobra.Command{Use: "docs +media-insert"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("doc", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("align", "", "")
cmd.Flags().String("caption", "", "")
if err := cmd.Flags().Set("doc", "doxcnDryRunLarge"); err != nil {
t.Fatalf("set --doc: %v", err)
}
if err := cmd.Flags().Set("file", "./large.bin"); err != nil {
t.Fatalf("set --file: %v", err)
}
dry := decodeDocDryRun(t, DocMediaInsert.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
if dry.Description != "4-step orchestration: query root → create block → upload file → bind to block (auto-rollback on failure)" {
t.Fatalf("dry-run description = %q", dry.Description)
}
if len(dry.API) != 6 {
t.Fatalf("expected 6 API calls, got %d", len(dry.API))
}
if dry.API[2].URL != "/open-apis/drive/v1/medias/upload_prepare" {
t.Fatalf("third URL = %q, want upload_prepare", dry.API[2].URL)
}
if dry.API[3].URL != "/open-apis/drive/v1/medias/upload_part" {
t.Fatalf("fourth URL = %q, want upload_part", dry.API[3].URL)
}
if dry.API[4].URL != "/open-apis/drive/v1/medias/upload_finish" {
t.Fatalf("fifth URL = %q, want upload_finish", dry.API[4].URL)
}
if dry.API[5].URL != "/open-apis/docx/v1/documents/doxcnDryRunLarge/blocks/batch_update" {
t.Fatalf("last URL = %q, want batch_update", dry.API[5].URL)
}
if !strings.Contains(dry.API[2].Desc, "[3a]") {
t.Fatalf("upload_prepare desc = %q, want [3a] step marker", dry.API[2].Desc)
}
if !strings.Contains(dry.API[3].Desc, "[3b]") {
t.Fatalf("upload_part desc = %q, want [3b] step marker", dry.API[3].Desc)
}
if !strings.Contains(dry.API[4].Desc, "[3c]") {
t.Fatalf("upload_finish desc = %q, want [3c] step marker", dry.API[4].Desc)
}
if !strings.Contains(dry.API[5].Desc, "[4]") {
t.Fatalf("batch_update desc = %q, want [4] step marker", dry.API[5].Desc)
}
}
func TestDocMediaInsertExecuteResolvesWikiBeforeFileCheck(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-insert-exec-app"))
reg.Register(&httpmock.Stub{
@@ -194,3 +284,42 @@ func TestDocMediaDownloadRejectsHTTPErrorBeforeWrite(t *testing.T) {
t.Fatalf("download target should not be created, statErr=%v", statErr)
}
}
type docDryRunOutput struct {
Description string `json:"description"`
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
func writeSizedDocTestFile(t *testing.T, name string, size int64) {
t.Helper()
fh, err := os.Create(name)
if err != nil {
t.Fatalf("Create(%q) error: %v", name, err)
}
if err := fh.Truncate(size); err != nil {
t.Fatalf("Truncate(%q) error: %v", name, err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close(%q) error: %v", name, err)
}
}
func decodeDocDryRun(t *testing.T, dryAPI *common.DryRunAPI) docDryRunOutput {
t.Helper()
raw, err := json.Marshal(dryAPI)
if err != nil {
t.Fatalf("marshal dry-run output: %v", err)
}
var dry docDryRunOutput
if err := json.Unmarshal(raw, &dry); err != nil {
t.Fatalf("decode dry-run output: %v", err)
}
return dry
}

View File

@@ -5,16 +5,10 @@ package doc
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"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"
@@ -28,7 +22,7 @@ var MediaUpload = common.Shortcut{
Scopes: []string{"docs:document.media:upload"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (max 20MB)", Required: true},
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
{Name: "parent-type", Desc: "parent type: docx_image | docx_file", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id)", Required: true},
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
@@ -42,13 +36,42 @@ var MediaUpload = common.Shortcut{
"file_name": filepath.Base(filePath),
"parent_type": parentType,
"parent_node": parentNode,
"file": "@" + filePath,
}
if docId != "" {
body["extra"] = fmt.Sprintf(`{"drive_route_token":"%s"}`, docId)
}
return common.NewDryRunAPI().
Desc("multipart/form-data upload").
dry := common.NewDryRunAPI()
if docMediaShouldUseMultipart(filePath) {
prepareBody := map[string]interface{}{
"file_name": filepath.Base(filePath),
"parent_type": parentType,
"parent_node": parentNode,
"size": "<file_size>",
}
if extra, ok := body["extra"]; ok {
prepareBody["extra"] = extra
}
dry.Desc("chunked media upload (files > 20MB)").
POST("/open-apis/drive/v1/medias/upload_prepare").
Body(prepareBody).
POST("/open-apis/drive/v1/medias/upload_part").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/medias/upload_finish").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
return dry
}
body["file"] = "@" + filePath
body["size"] = "<file_size>"
return dry.Desc("multipart/form-data upload").
POST("/open-apis/drive/v1/medias/upload_all").
Body(body)
},
@@ -62,69 +85,25 @@ var MediaUpload = common.Shortcut{
if pathErr != nil {
return output.ErrValidation("unsafe file path: %s", pathErr)
}
filePath = safeFilePath
// Validate file
stat, err := vfs.Stat(filePath)
stat, err := vfs.Stat(safeFilePath)
if err != nil {
return output.ErrValidation("file not found: %s", filePath)
}
if stat.Size() > maxFileSize {
return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
if !stat.Mode().IsRegular() {
return output.ErrValidation("file must be a regular file: %s", filePath)
}
fileName := filepath.Base(filePath)
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%d bytes)\n", fileName, stat.Size())
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
}
f, err := vfs.Open(filePath)
fileToken, err := uploadDocMediaFile(runtime, filePath, fileName, stat.Size(), parentType, parentNode, docId)
if err != nil {
return output.ErrValidation("cannot open file: %v", err)
}
defer f.Close()
// Build SDK Formdata
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddField("parent_type", parentType)
fd.AddField("parent_node", parentNode)
fd.AddField("size", fmt.Sprintf("%d", stat.Size()))
if docId != "" {
extra, err := buildDriveRouteExtra(docId)
if err != nil {
return err
}
fd.AddField("extra", extra)
}
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) {
return err
}
return output.ErrNetwork("upload 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 failed: invalid response JSON: %v", err)
}
code, _ := util.ToFloat64(result["code"])
if code != 0 {
msg, _ := result["msg"].(string)
return output.ErrAPI(int(code), fmt.Sprintf("upload failed: [%d] %s", int(code), msg), result["error"])
}
data, _ := result["data"].(map[string]interface{})
fileToken, _ := data["file_token"].(string)
if fileToken == "" {
return output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
return err
}
runtime.Out(map[string]interface{}{
@@ -135,3 +114,49 @@ var MediaUpload = common.Shortcut{
return nil
},
}
func uploadDocMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentType, parentNode, docID string) (string, error) {
var extra string
if docID != "" {
var err error
extra, err = buildDriveRouteExtra(docID)
if err != nil {
return "", err
}
}
// Doc media uploads share the generic Drive media transport. The doc-specific
// routing only shows up in parent_type/parent_node and optional route extra.
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: parentType,
ParentNode: &parentNode,
Extra: extra,
})
}
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: parentType,
ParentNode: parentNode,
Extra: extra,
})
}
func docMediaShouldUseMultipart(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)
if err != nil {
return false
}
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
}

View File

@@ -83,6 +83,7 @@ var DocsCreate = common.Shortcut{
return err
}
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
},

View File

@@ -65,7 +65,6 @@ func TestValidateDriveExportSpec(t *testing.T) {
func TestDriveExportMarkdownWritesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
@@ -117,7 +116,6 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
func TestDriveExportAsyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
@@ -188,7 +186,6 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
@@ -267,7 +264,6 @@ func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) {
func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
@@ -333,7 +329,6 @@ func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) {
func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
@@ -389,7 +384,6 @@ func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
func TestDriveExportDownloadUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_789/download",
@@ -424,7 +418,6 @@ func TestDriveExportDownloadUsesProvidedFileName(t *testing.T) {
func TestDriveExportDownloadRejectsOverwriteWithoutFlag(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_dup/download",
@@ -480,7 +473,6 @@ func TestSaveContentToOutputDirRejectsOverwriteWithoutFlag(t *testing.T) {
func TestDriveTaskResultExportIncludesReadyFlags(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_export",

View File

@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/vfs"
@@ -147,9 +146,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)
}
@@ -168,7 +166,7 @@ func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec,
extra = fmt.Sprintf(`{"obj_type":"%s","file_extension":"%s"}`, spec.DocType, spec.FileExtension())
}
if fileSize > maxDriveUploadFileSize {
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
Desc("[1a] Initialize multipart upload").
Body(map[string]interface{}{

View File

@@ -4,21 +4,15 @@
package drive
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/larksuite/cli/internal/vfs"
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/shortcuts/common"
@@ -37,12 +31,6 @@ const (
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
)
type driveMultipartUploadSession struct {
UploadID string
BlockSize int
BlockNum int
}
// driveImportExtToDocTypes defines which source file extensions can be imported
// into which Drive-native document types.
var driveImportExtToDocTypes = map[string][]string{
@@ -106,163 +94,41 @@ func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, f
if err = validateDriveImportFileSize(filePath, docType, fileSize); err != nil {
return "", err
}
fileSizeValue, err := driveUploadSizeValue(fileSize)
if err != nil {
return "", err
}
extra, err := buildImportMediaExtra(filePath, docType)
if err != nil {
return "", err
}
if fileSize <= maxDriveUploadFileSize {
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import: %s (%s)\n", fileName, common.FormatSize(fileSize))
return uploadMediaForImportAll(runtime, filePath, fileName, fileSizeValue, extra)
// upload_all for import works without parent_node; omitting it preserves
// the existing root-level import staging behavior.
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: "ccm_import_open",
Extra: extra,
})
}
fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import via multipart upload: %s (%s)\n", fileName, common.FormatSize(fileSize))
return uploadMediaForImportMultipart(runtime, filePath, fileName, fileSizeValue, extra)
}
func uploadMediaForImportAll(runtime *common.RuntimeContext, filePath, fileName string, fileSize int, extra string) (string, error) {
f, err := vfs.Open(filePath)
if err != nil {
return "", output.ErrValidation("cannot read file: %s", err)
}
defer f.Close()
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", extra)
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 {
return "", wrapDriveUploadRequestError(err, "upload media failed")
}
data, err := parseDriveUploadResponse(apiResp, "upload media failed")
if err != nil {
return "", err
}
return extractDriveUploadFileToken(data, "upload media failed")
}
func uploadMediaForImportMultipart(runtime *common.RuntimeContext, filePath, fileName string, fileSize int, extra string) (string, error) {
session, err := prepareMediaImportUpload(runtime, fileName, fileSize, extra)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload prepare failed: %s\n", err)
return "", err
}
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)
if err != nil {
return "", output.ErrValidation("cannot read file: %s", err)
}
defer f.Close()
buffer := make([]byte, session.BlockSize)
remaining := fileSize
uploadedBlocks := 0
for remaining > 0 {
chunkSize := session.BlockSize
if chunkSize > remaining {
chunkSize = remaining
}
n, readErr := io.ReadFull(f, buffer[:chunkSize])
if readErr != nil {
return "", output.ErrValidation("cannot read file: %s", readErr)
}
if err = uploadMediaImportPart(runtime, session.UploadID, uploadedBlocks, buffer[:n]); err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload part failed: %s\n", err)
return "", err
}
remaining -= n
uploadedBlocks++
}
if session.BlockNum > 0 && session.BlockNum != uploadedBlocks {
return "", output.Errorf(output.ExitAPI, "api_error", "upload prepare mismatch: expected %d blocks, uploaded %d", session.BlockNum, uploadedBlocks)
}
return finishMediaImportUpload(runtime, session.UploadID, uploadedBlocks)
}
func prepareMediaImportUpload(runtime *common.RuntimeContext, fileName string, fileSize int, extra string) (driveMultipartUploadSession, error) {
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, map[string]interface{}{
"file_name": fileName,
"parent_type": "ccm_import_open", // For media import uploads, parent_type must be ccm_import_open.
"size": fileSize,
"extra": extra,
"parent_node": "", // For media import uploads, parent_node must be an explicit empty string; unlike medias/upload_all, this field cannot be omitted.
// upload_prepare is stricter than upload_all here and expects parent_node to
// be sent explicitly, even when import uses the implicit root staging area.
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: "ccm_import_open",
ParentNode: "",
Extra: extra,
})
if err != nil {
return driveMultipartUploadSession{}, err
}
session := driveMultipartUploadSession{
UploadID: common.GetString(data, "upload_id"),
BlockSize: int(common.GetFloat(data, "block_size")),
BlockNum: int(common.GetFloat(data, "block_num")),
}
if session.UploadID == "" {
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: no upload_id returned")
}
if session.BlockSize <= 0 {
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
}
if session.BlockNum <= 0 {
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_num returned")
}
return session, nil
}
func uploadMediaImportPart(runtime *common.RuntimeContext, uploadID string, seq int, chunk []byte) error {
fd := larkcore.NewFormdata()
fd.AddField("upload_id", uploadID)
fd.AddField("seq", seq)
fd.AddField("size", len(chunk))
fd.AddFile("file", bytes.NewReader(chunk))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return wrapDriveUploadRequestError(err, "upload media part failed")
}
_, err = parseDriveUploadResponse(apiResp, "upload media part failed")
return err
}
func finishMediaImportUpload(runtime *common.RuntimeContext, uploadID string, blockNum int) (string, error) {
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload finish failed: %s\n", err)
return "", err
}
return extractDriveUploadFileToken(data, "upload media finish failed")
}
func buildImportMediaExtra(filePath, docType string) (string, error) {
// The import media endpoint uses extra to decide both the target native type
// and how to interpret the uploaded source file.
extraBytes, err := json.Marshal(map[string]string{
"obj_type": docType,
"file_extension": strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), "."),
@@ -318,45 +184,6 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error
)
}
func driveUploadSizeValue(fileSize int64) (int, error) {
maxInt := int64(^uint(0) >> 1)
if fileSize > maxInt {
return 0, output.ErrValidation("file %s is too large to upload", common.FormatSize(fileSize))
}
return int(fileSize), nil
}
func wrapDriveUploadRequestError(err error, action string) error {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return output.ErrNetwork("%s: %v", action, err)
}
func parseDriveUploadResponse(apiResp *larkcore.ApiResp, action string) (map[string]interface{}, error) {
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: invalid response JSON: %v", action, err)
}
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
msg, _ := result["msg"].(string)
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), result["error"])
}
data, _ := result["data"].(map[string]interface{})
return data, nil
}
func extractDriveUploadFileToken(data map[string]interface{}, action string) (string, error) {
fileToken := common.GetString(data, "file_token")
if fileToken == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "%s: no file_token returned", action)
}
return fileToken, nil
}
// validateDriveImportSpec enforces the CLI-level compatibility rules before any
// upload or import request is sent to the backend.
func validateDriveImportSpec(spec driveImportSpec) error {

View File

@@ -5,20 +5,12 @@ package drive
import (
"bytes"
"encoding/json"
"errors"
"io"
"mime"
"mime/multipart"
"os"
"strings"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestValidateDriveImportSpecRejectsMismatchedType(t *testing.T) {
@@ -144,7 +136,6 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
@@ -207,295 +198,6 @@ func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
}
}
func TestDriveImportUsesMultipartUploadForLargeFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
prepareStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": 4 * 1024 * 1024,
"block_num": 6,
},
},
}
reg.Register(prepareStub)
partStubs := make([]*httpmock.Stub, 0, 6)
for i := 0; i < 6; i++ {
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
}
partStubs = append(partStubs, stub)
reg.Register(stub)
}
finishStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "file_123",
},
},
}
reg.Register(finishStub)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/import_tasks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"ticket": "tk_import"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_import",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"result": map[string]interface{}{
"type": "sheet",
"job_status": 0,
"token": "sheet_123",
"url": "https://example.com/sheets/sheet_123",
},
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "large.xlsx",
"--type", "sheet",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"token": "sheet_123"`)) {
t.Fatalf("stdout missing imported token: %s", stdout.String())
}
prepareBody := decodeCapturedJSONBody(t, prepareStub)
if got, _ := prepareBody["parent_type"].(string); got != "ccm_import_open" {
t.Fatalf("prepare parent_type = %q, want %q", got, "ccm_import_open")
}
if got, _ := prepareBody["file_name"].(string); got != "large.xlsx" {
t.Fatalf("prepare file_name = %q, want %q", got, "large.xlsx")
}
if got, _ := prepareBody["size"].(float64); got != float64(maxDriveUploadFileSize+1) {
t.Fatalf("prepare size = %v, want %d", got, maxDriveUploadFileSize+1)
}
firstPart := decodeCapturedMultipartBody(t, partStubs[0])
if got := firstPart.Fields["upload_id"]; got != "upload_123" {
t.Fatalf("first part upload_id = %q, want %q", got, "upload_123")
}
if got := firstPart.Fields["seq"]; got != "0" {
t.Fatalf("first part seq = %q, want %q", got, "0")
}
if got := firstPart.Fields["size"]; got != "4194304" {
t.Fatalf("first part size = %q, want %q", got, "4194304")
}
if got := len(firstPart.Files["file"]); got != 4*1024*1024 {
t.Fatalf("first part file size = %d, want %d", got, 4*1024*1024)
}
lastPart := decodeCapturedMultipartBody(t, partStubs[len(partStubs)-1])
if got := lastPart.Fields["seq"]; got != "5" {
t.Fatalf("last part seq = %q, want %q", got, "5")
}
if got := lastPart.Fields["size"]; got != "1" {
t.Fatalf("last part size = %q, want %q", got, "1")
}
if got := len(lastPart.Files["file"]); got != 1 {
t.Fatalf("last part file size = %d, want %d", got, 1)
}
finishBody := decodeCapturedJSONBody(t, finishStub)
if got, _ := finishBody["upload_id"].(string); got != "upload_123" {
t.Fatalf("finish upload_id = %q, want %q", got, "upload_123")
}
if got, _ := finishBody["block_num"].(float64); got != 6 {
t.Fatalf("finish block_num = %v, want %d", got, 6)
}
}
func TestDriveImportMultipartPrepareValidatesResponseFields(t *testing.T) {
tests := []struct {
name string
data map[string]interface{}
wantText string
}{
{
name: "missing upload id",
data: map[string]interface{}{
"block_size": 4 * 1024 * 1024,
"block_num": 6,
},
wantText: "upload prepare failed: no upload_id returned",
},
{
name: "missing block size",
data: map[string]interface{}{
"upload_id": "upload_123",
"block_num": 6,
},
wantText: "upload prepare failed: invalid block_size returned",
},
{
name: "missing block num",
data: map[string]interface{}{
"upload_id": "upload_123",
"block_size": 4 * 1024 * 1024,
},
wantText: "upload prepare failed: invalid block_num returned",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": tt.data,
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "large.xlsx",
"--type", "sheet",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("error = %v, want substring %q", err, tt.wantText)
}
})
}
}
func TestDriveImportMultipartUploadPartAPIFailure(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": 4 * 1024 * 1024,
"block_num": 6,
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 999,
"msg": "chunk rejected",
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "large.xlsx",
"--type", "sheet",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "upload media part failed: [999] chunk rejected") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDriveImportMultipartFinishRequiresFileToken(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": 4 * 1024 * 1024,
"block_num": 6,
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
err := mountAndRunDrive(t, DriveImport, []string{
"+import",
"--file", "large.xlsx",
"--type", "sheet",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "upload media finish failed: no file_token returned") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
@@ -517,73 +219,6 @@ func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) {
}
}
func TestParseDriveUploadResponseErrors(t *testing.T) {
t.Parallel()
t.Run("invalid json", func(t *testing.T) {
t.Parallel()
_, err := parseDriveUploadResponse(&larkcore.ApiResp{RawBody: []byte("{")}, "upload media failed")
if err == nil || !strings.Contains(err.Error(), "invalid response JSON") {
t.Fatalf("expected invalid JSON error, got %v", err)
}
})
t.Run("api code error", func(t *testing.T) {
t.Parallel()
_, err := parseDriveUploadResponse(&larkcore.ApiResp{RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`)}, "upload media failed")
if err == nil || !strings.Contains(err.Error(), "upload media failed: [999] boom") {
t.Fatalf("expected API error, got %v", err)
}
})
}
func TestWrapDriveUploadRequestError(t *testing.T) {
t.Parallel()
t.Run("preserves exit error", func(t *testing.T) {
t.Parallel()
original := output.ErrValidation("bad input")
got := wrapDriveUploadRequestError(original, "upload media failed")
if got != original {
t.Fatalf("expected same exit error pointer, got %v", got)
}
})
t.Run("wraps generic error as network", func(t *testing.T) {
t.Parallel()
got := wrapDriveUploadRequestError(io.EOF, "upload media failed")
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected ExitError, got %T", got)
}
if exitErr.Code != output.ExitNetwork {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
}
if !strings.Contains(got.Error(), "upload media failed") {
t.Fatalf("unexpected error: %v", got)
}
})
}
type capturedMultipartBody struct {
Fields map[string]string
Files map[string][]byte
}
func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode captured JSON body: %v", err)
}
return body
}
func writeSizedDriveImportFile(t *testing.T, name string, size int64) {
t.Helper()
@@ -598,42 +233,3 @@ func writeSizedDriveImportFile(t *testing.T, name string, size int64) {
t.Fatalf("Close(%q) error: %v", name, err)
}
}
func decodeCapturedMultipartBody(t *testing.T, stub *httpmock.Stub) capturedMultipartBody {
t.Helper()
contentType := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
t.Fatalf("parse multipart content type: %v", err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
}
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
body := capturedMultipartBody{
Fields: map[string]string{},
Files: map[string][]byte{},
}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read multipart part: %v", err)
}
data, err := io.ReadAll(part)
if err != nil {
t.Fatalf("read multipart data: %v", err)
}
if part.FileName() != "" {
body.Files[part.FormName()] = data
continue
}
body.Fields[part.FormName()] = string(data)
}
return body
}

View File

@@ -132,7 +132,7 @@ func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) {
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(int64(maxDriveUploadFileSize) + 1); err != nil {
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {

View File

@@ -18,9 +18,6 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// registerDriveBotTokenStub is a no-op. TAT is now managed by CredentialProvider, not SDK.
func registerDriveBotTokenStub(_ *httpmock.Registry) {}
func driveTestConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "drive-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -62,7 +59,6 @@ func TestDriveUploadLargeFileUsesMultipart(t *testing.T) {
AppID: "drive-upload-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
registerDriveBotTokenStub(reg)
// Step 1: upload_prepare
reg.Register(&httpmock.Stub{
@@ -72,7 +68,7 @@ func TestDriveUploadLargeFileUsesMultipart(t *testing.T) {
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"upload_id": "test-upload-id",
"block_size": float64(maxDriveUploadFileSize),
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize),
"block_num": float64(2),
},
},
@@ -116,7 +112,7 @@ func TestDriveUploadLargeFileUsesMultipart(t *testing.T) {
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(maxDriveUploadFileSize + 1); err != nil {
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
@@ -141,7 +137,6 @@ func TestDriveUploadSmallFile(t *testing.T) {
AppID: "drive-upload-small-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -181,7 +176,6 @@ func TestDriveUploadSmallFileAPIError(t *testing.T) {
AppID: "drive-upload-small-err", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -218,7 +212,6 @@ func TestDriveUploadSmallFileNoToken(t *testing.T) {
AppID: "drive-upload-small-notoken", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -256,7 +249,6 @@ func TestDriveUploadSmallFileInvalidJSON(t *testing.T) {
AppID: "drive-upload-small-json", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -291,7 +283,6 @@ func TestDriveUploadPrepareInvalidResponse(t *testing.T) {
AppID: "drive-upload-prepare-bad", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -317,7 +308,7 @@ func TestDriveUploadPrepareInvalidResponse(t *testing.T) {
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(maxDriveUploadFileSize + 1); err != nil {
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
fh.Close()
@@ -338,7 +329,6 @@ func TestDriveUploadPartAPIError(t *testing.T) {
AppID: "drive-upload-part-err", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -347,7 +337,7 @@ func TestDriveUploadPartAPIError(t *testing.T) {
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"upload_id": "test-upload-id",
"block_size": float64(maxDriveUploadFileSize),
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize),
"block_num": float64(2),
},
},
@@ -380,7 +370,7 @@ func TestDriveUploadPartAPIError(t *testing.T) {
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(maxDriveUploadFileSize + 1); err != nil {
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
fh.Close()
@@ -401,7 +391,6 @@ func TestDriveUploadPartInvalidJSON(t *testing.T) {
AppID: "drive-upload-part-json", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -410,7 +399,7 @@ func TestDriveUploadPartInvalidJSON(t *testing.T) {
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"upload_id": "test-upload-id",
"block_size": float64(maxDriveUploadFileSize + 1),
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize + 1),
"block_num": float64(1),
},
},
@@ -433,7 +422,7 @@ func TestDriveUploadPartInvalidJSON(t *testing.T) {
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(maxDriveUploadFileSize + 1); err != nil {
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
fh.Close()
@@ -454,7 +443,6 @@ func TestDriveUploadFinishNoToken(t *testing.T) {
AppID: "drive-upload-finish-notoken", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -463,7 +451,7 @@ func TestDriveUploadFinishNoToken(t *testing.T) {
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"upload_id": "test-upload-id",
"block_size": float64(maxDriveUploadFileSize + 1),
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize + 1),
"block_num": float64(1),
},
},
@@ -495,7 +483,7 @@ func TestDriveUploadFinishNoToken(t *testing.T) {
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(maxDriveUploadFileSize + 1); err != nil {
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
fh.Close()
@@ -516,7 +504,6 @@ func TestDriveUploadWithCustomName(t *testing.T) {
AppID: "drive-upload-name-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",

View File

@@ -104,7 +104,6 @@ func TestDriveMoveDryRunFolderIncludesTaskCheckParams(t *testing.T) {
func TestDriveMoveFolderSuccessUsesTaskCheckHelper(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/fld_src/move",
@@ -148,7 +147,6 @@ func TestDriveMoveFolderSuccessUsesTaskCheckHelper(t *testing.T) {
func TestDriveMoveFolderTimeoutReturnsFollowUpCommand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/fld_src/move",

View File

@@ -13,7 +13,6 @@ import (
func TestDriveMoveUsesRootFolderWhenFolderTokenMissing(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
@@ -52,7 +51,6 @@ func TestDriveMoveUsesRootFolderWhenFolderTokenMissing(t *testing.T) {
func TestDriveMoveRootFolderLookupRequiresToken(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/explorer/v2/root_folder/meta",

View File

@@ -127,7 +127,6 @@ func TestDriveTaskResultDryRunExportIncludesTokenParam(t *testing.T) {
func TestDriveTaskResultImportIncludesReadyFlags(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_import",
@@ -161,7 +160,6 @@ func TestDriveTaskResultImportIncludesReadyFlags(t *testing.T) {
func TestDriveTaskResultTaskCheckIncludesReadyFlags(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
registerDriveBotTokenStub(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",

View File

@@ -10,7 +10,6 @@ import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -21,8 +20,6 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const maxDriveUploadFileSize = 20 * 1024 * 1024 // 20MB
var DriveUpload = common.Shortcut{
Service: "drive",
Command: "+upload",
@@ -78,7 +75,7 @@ var DriveUpload = common.Shortcut{
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s)\n", fileName, common.FormatSize(fileSize))
var fileToken string
if fileSize > maxDriveUploadFileSize {
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
fileToken, err = uploadFileMultipart(ctx, runtime, filePath, fileName, folderToken, fileSize)
} else {
@@ -183,7 +180,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
partSize = remaining
}
partFile, err := os.Open(filePath)
partFile, err := vfs.Open(filePath)
if err != nil {
return "", output.ErrValidation("cannot open file: %v", err)
}

View File

@@ -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")
}
})

View File

@@ -13,16 +13,15 @@ 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"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
@@ -327,21 +326,16 @@ 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 {
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(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
@@ -556,18 +550,16 @@ 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)
if err != nil {
info, err := runtime.FileIO().Stat(filePath)
if err != nil || info.Size() == 0 {
return ""
}
defer f.Close()
info, err := f.Stat()
if err != nil || info.Size() == 0 {
f, err := runtime.FileIO().Open(filePath)
if err != nil {
return ""
}
@@ -698,7 +690,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 +705,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 +997,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 +1034,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 +1046,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)
}

View File

@@ -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"
@@ -52,9 +53,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)),
}
}
@@ -88,10 +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,
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{},
@@ -241,7 +244,9 @@ func TestDownloadIMResourceToPathSuccess(t *testing.T) {
}
}))
target := filepath.Join(t.TempDir(), "nested", "resource.bin")
cmdutil.TestChdir(t, t.TempDir())
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)
@@ -280,7 +285,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)
}
@@ -305,28 +312,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)
}
@@ -357,28 +350,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)
}
@@ -394,7 +373,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)
@@ -404,14 +384,18 @@ 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)
}
}
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)
@@ -421,7 +405,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)
}
@@ -430,6 +417,7 @@ func TestUploadFileToIMSizeLimit(t *testing.T) {
func TestResolveMediaContentWrapsUploadError(t *testing.T) {
runtime := &common.RuntimeContext{
Factory: &cmdutil.Factory{
FileIOProvider: fileio.GetProvider(),
IOStreams: &cmdutil.IOStreams{
Out: &bytes.Buffer{},
ErrOut: &bytes.Buffer{},
@@ -437,7 +425,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)
@@ -457,15 +447,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)
@@ -496,15 +478,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)

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -6,15 +6,15 @@ package im
import (
"context"
"fmt"
"io"
"net/http"
"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/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
@@ -54,7 +54,7 @@ var ImMessagesResourcesDownload = common.Shortcut{
if err != nil {
return output.ErrValidation("%s", err)
}
if _, err := validate.SafeOutputPath(relPath); err != nil {
if _, err := runtime.ResolveSavePath(relPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
return nil
@@ -67,12 +67,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
}
@@ -109,33 +105,33 @@ func normalizeDownloadOutputPath(fileKey, outputPath string) (string, error) {
const defaultIMResourceDownloadTimeout = 120 * time.Second
var imMimeToExt = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
"application/pdf": ".pdf",
"video/mp4": ".mp4",
"video/3gpp": ".3gp",
"video/x-msvideo": ".avi",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/wav": ".wav",
"text/plain": ".txt",
"text/html": ".html",
"text/css": ".css",
"text/csv": ".csv",
"application/zip": ".zip",
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
"application/pdf": ".pdf",
"video/mp4": ".mp4",
"video/3gpp": ".3gp",
"video/x-msvideo": ".avi",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/wav": ".wav",
"text/plain": ".txt",
"text/html": ".html",
"text/css": ".css",
"text/csv": ".csv",
"application/zip": ".zip",
"application/x-zip-compressed": ".zip",
"application/x-rar-compressed": ".rar",
"application/json": ".json",
"application/xml": ".xml",
"application/octet-stream": ".bin",
"application/msword": ".doc",
"application/json": ".json",
"application/xml": ".xml",
"application/octet-stream": ".bin",
"application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.ms-excel": ".xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.ms-excel": ".xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
}
@@ -156,8 +152,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 +171,19 @@ 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 "", 0, output.Errorf(output.ExitInternal, "api_error", "%s",
common.WrapSaveError(err, "unsafe output path", "cannot create parent directory", "cannot create file"))
}
return finalPath, sizeBytes, nil
savedPath, resolveErr := runtime.ResolveSavePath(finalPath)
if resolveErr != nil {
// Save succeeded — file is on disk. Fall back to the relative path
// rather than returning an error for a successfully written file.
savedPath = finalPath
}
return savedPath, result.Size(), nil
}

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -7,13 +7,19 @@ import (
"fmt"
"mime"
"path/filepath"
"regexp"
"strings"
"github.com/google/uuid"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/mail/filecheck"
)
// imgSrcRegexp matches <img ... src="value" ...> and captures the src value.
// It handles both single and double quotes.
var imgSrcRegexp = regexp.MustCompile(`(?i)<img\s(?:[^>]*?\s)?src\s*=\s*["']([^"']+)["']`)
var protectedHeaders = map[string]bool{
"message-id": true,
"mime-version": true,
@@ -24,22 +30,32 @@ var protectedHeaders = map[string]bool{
"reply-to": true,
}
// bodyChangingOps lists patch operations that modify the HTML body content,
// which is the trigger for running local image path resolution.
var bodyChangingOps = map[string]bool{
"set_body": true,
"set_reply_body": true,
"replace_body": true,
"append_body": true,
}
func Apply(snapshot *DraftSnapshot, patch Patch) error {
if err := patch.Validate(); err != nil {
return err
}
hasBodyChange := false
for _, op := range patch.Ops {
if err := applyOp(snapshot, op, patch.Options); err != nil {
return err
}
if bodyChangingOps[op.Op] {
hasBodyChange = true
}
}
if err := refreshSnapshot(snapshot); err != nil {
if err := postProcessInlineImages(snapshot, hasBodyChange); err != nil {
return err
}
if err := validateInlineCIDAfterApply(snapshot); err != nil {
return err
}
return validateOrphanedInlineCIDAfterApply(snapshot)
return refreshSnapshot(snapshot)
}
func applyOp(snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error {
@@ -523,21 +539,25 @@ func addAttachment(snapshot *DraftSnapshot, path string) error {
return nil
}
func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string) error {
// loadAndAttachInline reads a local image file, validates its format,
// creates a MIME inline part, and attaches it to the snapshot's
// multipart/related container. If container is non-nil it is reused;
// otherwise the container is resolved from the snapshot.
func loadAndAttachInline(snapshot *DraftSnapshot, path, cid, fileName string, container *Part) (*Part, error) {
safePath, err := validate.SafeInputPath(path)
if err != nil {
return fmt.Errorf("inline image %q: %w", path, err)
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
info, err := vfs.Stat(safePath)
if err != nil {
return err
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), nil); err != nil {
return err
return nil, err
}
content, err := vfs.ReadFile(safePath)
if err != nil {
return err
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
name := fileName
if strings.TrimSpace(name) == "" {
@@ -545,23 +565,30 @@ func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string)
}
detectedCT, err := filecheck.CheckInlineImageFormat(name, content)
if err != nil {
return err
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
inline, err := newInlinePart(path, content, cid, fileName, detectedCT)
inline, err := newInlinePart(safePath, content, cid, name, detectedCT)
if err != nil {
return err
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
containerRef := primaryBodyRootRef(&snapshot.Body)
if containerRef == nil || *containerRef == nil {
return fmt.Errorf("draft has no primary body container")
}
container, err := ensureInlineContainerRef(containerRef)
if err != nil {
return err
if container == nil {
containerRef := primaryBodyRootRef(&snapshot.Body)
if containerRef == nil || *containerRef == nil {
return nil, fmt.Errorf("draft has no primary body container")
}
container, err = ensureInlineContainerRef(containerRef)
if err != nil {
return nil, fmt.Errorf("inline image %q: %w", path, err)
}
}
container.Children = append(container.Children, inline)
container.Dirty = true
return nil
return container, nil
}
func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string) error {
_, err := loadAndAttachInline(snapshot, path, cid, fileName, nil)
return err
}
func replaceInline(snapshot *DraftSnapshot, partID, path, cid, fileName, contentType string) error {
@@ -605,13 +632,10 @@ func replaceInline(snapshot *DraftSnapshot, partID, path, cid, fileName, content
}
contentType = detectedCT
contentType, mediaParams := normalizedDetectedMediaType(contentType)
finalCID := strings.Trim(strings.TrimSpace(cid), "<>")
if err := validate.RejectCRLF(finalCID, "inline cid"); err != nil {
finalCID := normalizeCID(cid)
if err := validateCID(finalCID); err != nil {
return err
}
if strings.ContainsAny(finalCID, " \t<>()") {
return fmt.Errorf("inline cid %q contains invalid characters (spaces, tabs, angle brackets, or parentheses are not allowed)", finalCID)
}
if err := validate.RejectCRLF(fileName, "inline filename"); err != nil {
return err
}
@@ -734,6 +758,33 @@ func findPart(root *Part, partID string) *Part {
return nil
}
// normalizeCID strips a single RFC 2392 angle-bracket wrapper (<...>) from the
// CID if present, and trims surrounding whitespace. Unlike strings.Trim, it
// only removes a matched pair so that stray brackets like "test<>" are preserved
// for validation to reject.
func normalizeCID(cid string) string {
cid = strings.TrimSpace(cid)
if strings.HasPrefix(cid, "<") && strings.HasSuffix(cid, ">") {
cid = cid[1 : len(cid)-1]
}
return cid
}
// validateCID checks that a Content-ID value is non-empty and free of
// characters that would break MIME headers or cause ambiguous references.
func validateCID(cid string) error {
if cid == "" {
return fmt.Errorf("inline cid is empty")
}
if err := validate.RejectCRLF(cid, "inline cid"); err != nil {
return err
}
if strings.ContainsAny(cid, " \t<>()") {
return fmt.Errorf("inline cid %q contains invalid characters (spaces, tabs, angle brackets, or parentheses are not allowed)", cid)
}
return nil
}
func ensureInlineContainerRef(partRef **Part) (*Part, error) {
if partRef == nil || *partRef == nil {
return nil, fmt.Errorf("body container is nil")
@@ -758,16 +809,10 @@ func newInlinePart(path string, content []byte, cid, fileName, contentType strin
}
contentType, mediaParams := normalizedDetectedMediaType(contentType)
mediaParams["name"] = fileName
cid = strings.Trim(strings.TrimSpace(cid), "<>")
if cid == "" {
return nil, fmt.Errorf("inline cid is empty")
}
if err := validate.RejectCRLF(cid, "inline cid"); err != nil {
cid = normalizeCID(cid)
if err := validateCID(cid); err != nil {
return nil, err
}
if strings.ContainsAny(cid, " \t<>()") {
return nil, fmt.Errorf("inline cid %q contains invalid characters (spaces, tabs, angle brackets, or parentheses are not allowed)", cid)
}
if err := validate.RejectCRLF(fileName, "inline filename"); err != nil {
return nil, err
}
@@ -863,59 +908,227 @@ func removeHeader(headers *[]Header, name string) {
*headers = next
}
// validateInlineCIDAfterApply checks that all CID references in the HTML body
// resolve to actual inline MIME parts. This is called after Apply (editing) to
// prevent broken CID references, but NOT during Parse (where broken CIDs
// should not block opening the draft).
func validateInlineCIDAfterApply(snapshot *DraftSnapshot) error {
htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html")
if htmlPart == nil {
return nil
// uriSchemeRegexp matches a URI scheme (RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":").
var uriSchemeRegexp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.\-]*:`)
// isLocalFileSrc returns true if src is a local file path.
// Any URI with a scheme (http:, cid:, data:, ftp:, blob:, file:, etc.)
// or protocol-relative URL (//host/...) is rejected.
func isLocalFileSrc(src string) bool {
trimmed := strings.TrimSpace(src)
if trimmed == "" {
return false
}
refs := extractCIDRefs(string(htmlPart.Body))
if strings.HasPrefix(trimmed, "//") {
return false
}
return !uriSchemeRegexp.MatchString(trimmed)
}
// generateCID returns a random UUID string suitable for use as a Content-ID.
// UUIDs contain only [0-9a-f-], which is inherently RFC-safe and unique,
// avoiding all filename-derived encoding/collision issues.
func generateCID() (string, error) {
id, err := uuid.NewRandom()
if err != nil {
return "", fmt.Errorf("failed to generate CID: %w", err)
}
return id.String(), nil
}
// LocalImageRef represents a local image found in an HTML body that needs
// to be embedded as an inline MIME part.
type LocalImageRef struct {
FilePath string // original src value from the HTML
CID string // generated Content-ID
}
// ResolveLocalImagePaths scans HTML for <img src="local/path"> references,
// validates each path, generates CIDs, and returns the modified HTML with
// cid: URIs plus the list of local image references to embed as inline parts.
// This function handles only the HTML transformation; callers are responsible
// for embedding the actual file data (e.g., via emlbuilder.AddFileInline).
func ResolveLocalImagePaths(html string) (string, []LocalImageRef, error) {
matches := imgSrcRegexp.FindAllStringSubmatchIndex(html, -1)
if len(matches) == 0 {
return html, nil, nil
}
// Cache resolved paths so the same file is only attached once.
pathToCID := make(map[string]string)
var refs []LocalImageRef
// Iterate in reverse so that index offsets remain valid after replacement.
for i := len(matches) - 1; i >= 0; i-- {
srcStart, srcEnd := matches[i][2], matches[i][3]
src := html[srcStart:srcEnd]
if !isLocalFileSrc(src) {
continue
}
resolvedPath, err := validate.SafeInputPath(src)
if err != nil {
return "", nil, fmt.Errorf("inline image %q: %w", src, err)
}
cid, ok := pathToCID[resolvedPath]
if !ok {
cid, err = generateCID()
if err != nil {
return "", nil, err
}
pathToCID[resolvedPath] = cid
refs = append(refs, LocalImageRef{FilePath: src, CID: cid})
}
html = html[:srcStart] + "cid:" + cid + html[srcEnd:]
}
return html, refs, nil
}
// resolveLocalImgSrc scans HTML for <img src="local/path"> references,
// creates MIME inline parts for each local file, and returns the HTML
// with those src attributes replaced by cid: URIs.
func resolveLocalImgSrc(snapshot *DraftSnapshot, html string) (string, error) {
resolved, refs, err := ResolveLocalImagePaths(html)
if err != nil {
return "", err
}
var container *Part
for _, ref := range refs {
fileName := filepath.Base(ref.FilePath)
container, err = loadAndAttachInline(snapshot, ref.FilePath, ref.CID, fileName, container)
if err != nil {
return "", err
}
}
return resolved, nil
}
// removeOrphanedInlineParts removes inline MIME parts whose ContentID
// is not in the referencedCIDs set. It searches multipart/related and
// multipart/mixed containers, because some servers flatten the MIME tree
// and place inline parts directly under multipart/mixed.
func removeOrphanedInlineParts(root *Part, referencedCIDs map[string]bool) {
if root == nil {
return
}
isRelated := strings.EqualFold(root.MediaType, "multipart/related")
isMixed := strings.EqualFold(root.MediaType, "multipart/mixed")
if !isRelated && !isMixed {
for _, child := range root.Children {
removeOrphanedInlineParts(child, referencedCIDs)
}
return
}
kept := make([]*Part, 0, len(root.Children))
for _, child := range root.Children {
if child == nil {
continue
}
if strings.EqualFold(child.ContentDisposition, "inline") && child.ContentID != "" {
if !referencedCIDs[strings.ToLower(child.ContentID)] {
root.Dirty = true
continue
}
}
kept = append(kept, child)
}
root.Children = kept
for _, child := range root.Children {
removeOrphanedInlineParts(child, referencedCIDs)
}
}
// ValidateCIDReferences checks that every cid: reference in the HTML body has
// a matching entry in availableCIDs. Returns an error for the first missing CID.
// Both sides are compared case-insensitively.
func ValidateCIDReferences(html string, availableCIDs []string) error {
refs := extractCIDRefs(html)
if len(refs) == 0 {
return nil
}
cids := make(map[string]bool)
for _, part := range flattenParts(snapshot.Body) {
if part == nil || part.ContentID == "" {
continue
}
cids[strings.ToLower(part.ContentID)] = true
cidSet := make(map[string]bool, len(availableCIDs))
for _, cid := range availableCIDs {
cidSet[strings.ToLower(cid)] = true
}
for _, ref := range refs {
if !cids[strings.ToLower(ref)] {
if !cidSet[strings.ToLower(ref)] {
return fmt.Errorf("html body references missing inline cid %q", ref)
}
}
return nil
}
// validateOrphanedInlineCIDAfterApply checks the reverse direction: every
// inline MIME part with a ContentID must be referenced by the HTML body.
// An orphaned inline part (CID exists but HTML has no <img src="cid:...">) will
// be displayed as an unexpected attachment by most mail clients.
func validateOrphanedInlineCIDAfterApply(snapshot *DraftSnapshot) error {
htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html")
if htmlPart == nil {
return nil
}
refs := extractCIDRefs(string(htmlPart.Body))
// FindOrphanedCIDs returns CIDs from addedCIDs that are not referenced in the
// HTML body via <img src="cid:...">. These would appear as unexpected
// attachments when the email is sent.
func FindOrphanedCIDs(html string, addedCIDs []string) []string {
refs := extractCIDRefs(html)
refSet := make(map[string]bool, len(refs))
for _, ref := range refs {
refSet[strings.ToLower(ref)] = true
}
var orphaned []string
for _, cid := range addedCIDs {
if !refSet[strings.ToLower(cid)] {
orphaned = append(orphaned, cid)
}
}
return orphaned
}
// postProcessInlineImages is the unified post-processing step that:
// 1. Resolves local <img src="./path"> to inline CID parts (only when resolveLocal is true).
// 2. Validates all CID references in HTML resolve to MIME parts.
// 3. Removes orphaned inline MIME parts no longer referenced by HTML.
//
// resolveLocal should be true only when a body-changing op was applied;
// metadata-only edits skip local path resolution to avoid disk I/O side effects.
//
// NOTE: The EML builder path has an equivalent function processInlineImagesForEML
// in shortcuts/mail/helpers.go. When adding new validation or processing logic here,
// update processInlineImagesForEML as well (or extract a shared function).
func postProcessInlineImages(snapshot *DraftSnapshot, resolveLocal bool) error {
htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html")
if htmlPart == nil {
return nil
}
origHTML := string(htmlPart.Body)
html := origHTML
if resolveLocal {
var err error
html, err = resolveLocalImgSrc(snapshot, origHTML)
if err != nil {
return err
}
if html != origHTML {
htmlPart.Body = []byte(html)
htmlPart.Dirty = true
}
}
// Collect all CIDs present as MIME parts.
var cidParts []string
for _, part := range flattenParts(snapshot.Body) {
if part == nil || part.ContentID == "" {
continue
}
if !refSet[strings.ToLower(part.ContentID)] {
orphaned = append(orphaned, part.ContentID)
if part != nil && part.ContentID != "" {
cidParts = append(cidParts, part.ContentID)
}
}
if len(orphaned) > 0 {
return fmt.Errorf("inline MIME parts have no <img> reference in the HTML body and will appear as unexpected attachments: orphaned cids %v; if you used set_body, make sure the new body preserves all existing cid:... references", orphaned)
if err := ValidateCIDReferences(html, cidParts); err != nil {
return err
}
refs := extractCIDRefs(html)
refSet := make(map[string]bool, len(refs))
for _, ref := range refs {
refSet[strings.ToLower(ref)] = true
}
removeOrphanedInlineParts(snapshot.Body, refSet)
return nil
}

View File

@@ -0,0 +1,979 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"os"
"regexp"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// resolveLocalImgSrc — basic auto-resolve
// ---------------------------------------------------------------------------
func TestResolveLocalImgSrcBasic(t *testing.T) {
chdirTemp(t)
os.WriteFile("logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>Hello<img src="./logo.png" /></div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div>Hello<img src="./logo.png" /></div>`}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
t.Fatal("HTML part not found")
}
body := string(htmlPart.Body)
if strings.Contains(body, "./logo.png") {
t.Fatal("local path should have been replaced")
}
// Extract the generated CID from the HTML body.
cidRe := regexp.MustCompile(`src="cid:([^"]+)"`)
m := cidRe.FindStringSubmatch(body)
if m == nil {
t.Fatalf("expected src to contain a cid: reference, got: %s", body)
}
cid := m[1]
// Verify MIME inline part was created with the matching CID.
found := false
for _, part := range flattenParts(snapshot.Body) {
if part != nil && part.ContentID == cid {
found = true
if part.MediaType != "image/png" {
t.Fatalf("expected image/png, got %q", part.MediaType)
}
}
}
if !found {
t.Fatalf("expected inline MIME part with CID %q to be created", cid)
}
}
// ---------------------------------------------------------------------------
// resolveLocalImgSrc — multiple images
// ---------------------------------------------------------------------------
func TestResolveLocalImgSrcMultipleImages(t *testing.T) {
chdirTemp(t)
os.WriteFile("a.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
os.WriteFile("b.jpg", []byte{0xFF, 0xD8, 0xFF, 0xE0}, 0o644)
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./a.png" /><img src="./b.jpg" /></div>`}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
body := string(htmlPart.Body)
cidRe := regexp.MustCompile(`src="cid:([^"]+)"`)
matches := cidRe.FindAllStringSubmatch(body, -1)
if len(matches) != 2 {
t.Fatalf("expected 2 cid: references, got %d in: %s", len(matches), body)
}
if matches[0][1] == matches[1][1] {
t.Fatalf("expected different CIDs for different files, both got: %s", matches[0][1])
}
}
// ---------------------------------------------------------------------------
// resolveLocalImgSrc — skips cid/http/data URIs
// ---------------------------------------------------------------------------
func TestResolveLocalImgSrcSkipsNonLocalSrc(t *testing.T) {
chdirTemp(t)
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="rel"
--rel
Content-Type: text/html; charset=UTF-8
<div><img src="cid:existing" /><img src="https://example.com/img.png" /><img src="data:image/png;base64,abc" /></div>
--rel
Content-Type: image/png; name=existing.png
Content-Disposition: inline; filename=existing.png
Content-ID: <existing>
Content-Transfer-Encoding: base64
cG5n
--rel--
`)
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
originalBody := string(htmlPart.Body)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: originalBody}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
htmlPart = findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if string(htmlPart.Body) != originalBody {
t.Fatalf("body should be unchanged, got: %s", string(htmlPart.Body))
}
}
// ---------------------------------------------------------------------------
// resolveLocalImgSrc — duplicate file names get unique CIDs
// ---------------------------------------------------------------------------
func TestResolveLocalImgSrcDuplicateCID(t *testing.T) {
chdirTemp(t)
os.MkdirAll("sub", 0o755)
os.WriteFile("logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
os.WriteFile("sub/logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./logo.png" /><img src="./sub/logo.png" /></div>`}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
body := string(htmlPart.Body)
cidRe := regexp.MustCompile(`src="cid:([^"]+)"`)
matches := cidRe.FindAllStringSubmatch(body, -1)
if len(matches) != 2 {
t.Fatalf("expected 2 cid: references, got %d in: %s", len(matches), body)
}
if matches[0][1] == matches[1][1] {
t.Fatalf("expected different CIDs for different files, both got: %s", matches[0][1])
}
}
// ---------------------------------------------------------------------------
// resolveLocalImgSrc — same file referenced multiple times reuses one CID
// ---------------------------------------------------------------------------
func TestResolveLocalImgSrcSameFileReused(t *testing.T) {
chdirTemp(t)
os.WriteFile("logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./logo.png" /><p>text</p><img src="./logo.png" /></div>`}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
body := string(htmlPart.Body)
// Both references should resolve to the same CID.
cidRe := regexp.MustCompile(`src="cid:([^"]+)"`)
matches := cidRe.FindAllStringSubmatch(body, -1)
if len(matches) != 2 {
t.Fatalf("expected 2 cid: references, got %d in: %s", len(matches), body)
}
if matches[0][1] != matches[1][1] {
t.Fatalf("expected same CID reused, got %q and %q", matches[0][1], matches[1][1])
}
// Count inline MIME parts — should be exactly 1.
var count int
for _, part := range flattenParts(snapshot.Body) {
if part != nil && strings.EqualFold(part.ContentDisposition, "inline") {
count++
}
}
if count != 1 {
t.Fatalf("expected 1 inline part (reused), got %d", count)
}
}
// ---------------------------------------------------------------------------
// resolveLocalImgSrc — non-image format rejected
// ---------------------------------------------------------------------------
func TestResolveLocalImgSrcRejectsNonImage(t *testing.T) {
chdirTemp(t)
os.WriteFile("doc.txt", []byte("not an image"), 0o644)
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./doc.txt" /></div>`}},
})
if err == nil {
t.Fatal("expected error for non-image file")
}
}
// ---------------------------------------------------------------------------
// orphan cleanup — delete inline image by removing <img> from body
// ---------------------------------------------------------------------------
func TestOrphanCleanupOnImgRemoval(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Inline
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="rel"
--rel
Content-Type: text/html; charset=UTF-8
<div>hello<img src="cid:logo" /></div>
--rel
Content-Type: image/png; name=logo.png
Content-Disposition: inline; filename=logo.png
Content-ID: <logo>
Content-Transfer-Encoding: base64
cG5n
--rel--
`)
// Remove the <img> tag from body.
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: "<div>hello</div>"}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
for _, part := range flattenParts(snapshot.Body) {
if part != nil && part.ContentID == "logo" {
t.Fatal("expected orphaned inline part 'logo' to be removed")
}
}
}
// ---------------------------------------------------------------------------
// orphan cleanup — replace inline image
// ---------------------------------------------------------------------------
func TestOrphanCleanupOnImgReplace(t *testing.T) {
chdirTemp(t)
os.WriteFile("new.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
snapshot := mustParseFixtureDraft(t, `Subject: Inline
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="rel"
--rel
Content-Type: text/html; charset=UTF-8
<div><img src="cid:old" /></div>
--rel
Content-Type: image/png; name=old.png
Content-Disposition: inline; filename=old.png
Content-ID: <old>
Content-Transfer-Encoding: base64
cG5n
--rel--
`)
// Replace old image reference with a new local file.
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./new.png" /></div>`}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
var foundOld bool
var newInlineCount int
for _, part := range flattenParts(snapshot.Body) {
if part == nil {
continue
}
if part.ContentID == "old" {
foundOld = true
}
if strings.EqualFold(part.ContentDisposition, "inline") && part.ContentID != "" && part.ContentID != "old" {
newInlineCount++
}
}
if foundOld {
t.Fatal("expected old inline part to be removed")
}
if newInlineCount != 1 {
t.Fatalf("expected 1 new inline part, got %d", newInlineCount)
}
}
// ---------------------------------------------------------------------------
// set_reply_body — local path resolved, quote block preserved
// ---------------------------------------------------------------------------
func TestSetReplyBodyResolvesLocalImgSrc(t *testing.T) {
chdirTemp(t)
os.WriteFile("photo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
snapshot := mustParseFixtureDraft(t, `Subject: Re: Hello
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>original reply</div><div class="history-quote-wrapper"><div>quoted text</div></div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_reply_body", Value: `<div>new reply<img src="./photo.png" /></div>`}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
t.Fatal("HTML part not found")
}
body := string(htmlPart.Body)
if strings.Contains(body, "./photo.png") {
t.Fatal("local path should have been replaced")
}
cidRe := regexp.MustCompile(`src="cid:([^"]+)"`)
m := cidRe.FindStringSubmatch(body)
if m == nil {
t.Fatalf("expected cid: reference in body, got: %s", body)
}
if !strings.Contains(body, "history-quote-wrapper") {
t.Fatalf("expected quote block preserved, got: %s", body)
}
found := false
for _, part := range flattenParts(snapshot.Body) {
if part != nil && part.ContentID == m[1] {
found = true
}
}
if !found {
t.Fatalf("expected inline MIME part with CID %q to be created", m[1])
}
}
// ---------------------------------------------------------------------------
// mixed usage — add_inline + local path in body
// ---------------------------------------------------------------------------
func TestMixedAddInlineAndLocalPath(t *testing.T) {
chdirTemp(t)
os.WriteFile("a.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
os.WriteFile("b.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{
{Op: "add_inline", Path: "a.png", CID: "a"},
{Op: "set_body", Value: `<div><img src="cid:a" /><img src="./b.png" /></div>`},
},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
var foundA bool
var autoResolvedCount int
for _, part := range flattenParts(snapshot.Body) {
if part == nil {
continue
}
if part.ContentID == "a" {
foundA = true
} else if strings.EqualFold(part.ContentDisposition, "inline") && part.ContentID != "" {
autoResolvedCount++
}
}
if !foundA {
t.Fatal("expected inline part 'a' from add_inline")
}
if autoResolvedCount != 1 {
t.Fatalf("expected 1 auto-resolved inline part for b.png, got %d", autoResolvedCount)
}
}
// ---------------------------------------------------------------------------
// conflict: add_inline same file + body local path → redundant part cleaned
// ---------------------------------------------------------------------------
func TestAddInlineSameFileAsLocalPath(t *testing.T) {
chdirTemp(t)
os.WriteFile("logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>empty</div>
`)
// add_inline creates CID "logo", but body uses local path instead of cid:logo.
// resolve generates a UUID CID, orphan cleanup removes the unused "logo".
err := Apply(snapshot, Patch{
Ops: []PatchOp{
{Op: "add_inline", Path: "logo.png", CID: "logo"},
{Op: "set_body", Value: `<div><img src="./logo.png" /></div>`},
},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
// The explicitly added "logo" CID is orphaned (not referenced in HTML)
// and should be auto-removed. Only the auto-generated CID remains.
var foundLogo bool
var count int
for _, part := range flattenParts(snapshot.Body) {
if part != nil && strings.EqualFold(part.ContentDisposition, "inline") {
count++
if part.ContentID == "logo" {
foundLogo = true
}
}
}
if foundLogo {
t.Fatal("expected orphaned 'logo' inline part to be removed")
}
if count != 1 {
t.Fatalf("expected 1 inline part after orphan cleanup, got %d", count)
}
}
// ---------------------------------------------------------------------------
// conflict: remove_inline but body still references its CID → error
// ---------------------------------------------------------------------------
func TestRemoveInlineButBodyStillReferencesCID(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Inline
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="rel"
--rel
Content-Type: text/html; charset=UTF-8
<div><img src="cid:logo" /></div>
--rel
Content-Type: image/png; name=logo.png
Content-Disposition: inline; filename=logo.png
Content-ID: <logo>
Content-Transfer-Encoding: base64
cG5n
--rel--
`)
// remove_inline removes the MIME part, but set_body still references cid:logo.
err := Apply(snapshot, Patch{
Ops: []PatchOp{
{Op: "remove_inline", Target: AttachmentTarget{CID: "logo"}},
{Op: "set_body", Value: `<div><img src="cid:logo" /></div>`},
},
})
if err == nil || !strings.Contains(err.Error(), "missing inline cid") {
t.Fatalf("expected missing cid error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// conflict: remove_inline + body replaces with local path → works
// ---------------------------------------------------------------------------
func TestRemoveInlineAndReplaceWithLocalPath(t *testing.T) {
chdirTemp(t)
os.WriteFile("new.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
snapshot := mustParseFixtureDraft(t, `Subject: Inline
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="rel"
--rel
Content-Type: text/html; charset=UTF-8
<div><img src="cid:old" /></div>
--rel
Content-Type: image/png; name=old.png
Content-Disposition: inline; filename=old.png
Content-ID: <old>
Content-Transfer-Encoding: base64
cG5n
--rel--
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{
{Op: "remove_inline", Target: AttachmentTarget{CID: "old"}},
{Op: "set_body", Value: `<div><img src="./new.png" /></div>`},
},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
var foundOld bool
var newInlineCount int
for _, part := range flattenParts(snapshot.Body) {
if part == nil {
continue
}
if part.ContentID == "old" {
foundOld = true
}
if strings.EqualFold(part.ContentDisposition, "inline") && part.ContentID != "" && part.ContentID != "old" {
newInlineCount++
}
}
if foundOld {
t.Fatal("expected old inline part to be removed")
}
if newInlineCount != 1 {
t.Fatalf("expected 1 new inline part from local path resolve, got %d", newInlineCount)
}
}
// ---------------------------------------------------------------------------
// no HTML body — text/plain only draft
// ---------------------------------------------------------------------------
func TestResolveLocalImgSrcNoHTMLBody(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Plain
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Just plain text.
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: "Updated plain text."}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
textPart := findPrimaryBodyPart(snapshot.Body, "text/plain")
if textPart == nil {
t.Fatal("text/plain part not found")
}
if got := string(textPart.Body); got != "Updated plain text." {
t.Fatalf("text/plain body = %q, want %q", got, "Updated plain text.")
}
for _, part := range flattenParts(snapshot.Body) {
if part != nil && strings.EqualFold(part.ContentDisposition, "inline") && part.ContentID != "" {
t.Fatalf("unexpected inline part with CID %q in text-only draft", part.ContentID)
}
}
}
// ---------------------------------------------------------------------------
// regression: HTML body with Content-ID must not be removed by orphan cleanup
// ---------------------------------------------------------------------------
func TestOrphanCleanupPreservesHTMLBodyWithContentID(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Test
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: multipart/related; boundary="rel"
--rel
Content-Type: text/html; charset=UTF-8
Content-ID: <body-part>
<div>hello world</div>
--rel
Content-Type: image/png; name=logo.png
Content-Disposition: inline; filename=logo.png
Content-ID: <logo>
Content-Transfer-Encoding: base64
cG5n
--rel--
`)
// A metadata-only edit should not destroy the HTML body part even though
// its Content-ID is not referenced by any <img src="cid:...">.
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_subject", Value: "Updated subject"}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html")
if htmlPart == nil {
t.Fatal("HTML body part was deleted by orphan cleanup")
}
if !strings.Contains(string(htmlPart.Body), "hello world") {
t.Fatalf("HTML body content changed unexpectedly: %s", string(htmlPart.Body))
}
}
// ---------------------------------------------------------------------------
// helper unit tests
// ---------------------------------------------------------------------------
func TestIsLocalFileSrc(t *testing.T) {
tests := []struct {
src string
want bool
}{
{"./logo.png", true},
{"../images/logo.png", true},
{"logo.png", true},
{"/absolute/path/logo.png", true},
{`C:\images\logo.png`, false},
{"C:/images/logo.png", false},
{`c:\path\file.png`, false},
{"cid:logo", false},
{"CID:logo", false},
{"http://example.com/img.png", false},
{"https://example.com/img.png", false},
{"data:image/png;base64,abc", false},
{"//cdn.example.com/a.png", false},
{"blob:https://example.com/uuid", false},
{"ftp://example.com/file.png", false},
{"file:///local/file.png", false},
{"mailto:test@example.com", false},
{"", false},
}
for _, tt := range tests {
if got := isLocalFileSrc(tt.src); got != tt.want {
t.Errorf("isLocalFileSrc(%q) = %v, want %v", tt.src, got, tt.want)
}
}
}
func TestGenerateCID(t *testing.T) {
seen := make(map[string]bool)
for i := 0; i < 100; i++ {
cid, err := generateCID()
if err != nil {
t.Fatalf("generateCID() error = %v", err)
}
if cid == "" {
t.Fatal("generateCID() returned empty string")
}
if strings.ContainsAny(cid, " \t\r\n<>()") {
t.Fatalf("generateCID() returned CID with invalid characters: %q", cid)
}
if seen[cid] {
t.Fatalf("generateCID() returned duplicate CID: %q", cid)
}
seen[cid] = true
}
}
// ---------------------------------------------------------------------------
// imgSrcRegexp — must not match data-src or similar attribute names
// ---------------------------------------------------------------------------
func TestImgSrcRegexpSkipsDataSrc(t *testing.T) {
tests := []struct {
name string
html string
want string // expected captured src value, empty if no match
}{
{
name: "plain src",
html: `<img src="./logo.png" />`,
want: "./logo.png",
},
{
name: "src with alt before",
html: `<img alt="pic" src="./logo.png" />`,
want: "./logo.png",
},
{
name: "data-src before real src",
html: `<img data-src="lazy.png" src="./logo.png" />`,
want: "./logo.png",
},
{
name: "only data-src, no src",
html: `<img data-src="lazy.png" />`,
want: "",
},
{
name: "x-src before real src",
html: `<img x-src="other.png" src="./real.png" />`,
want: "./real.png",
},
{
name: "single-quoted src",
html: `<img src='./logo.png' />`,
want: "./logo.png",
},
{
name: "multiple spaces before src",
html: `<img src="./logo.png" />`,
want: "./logo.png",
},
{
name: "newline before src",
html: "<img\nsrc=\"./logo.png\" />",
want: "./logo.png",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matches := imgSrcRegexp.FindStringSubmatch(tt.html)
got := ""
if len(matches) > 1 {
got = matches[1]
}
if got != tt.want {
t.Errorf("imgSrcRegexp on %q: got %q, want %q", tt.html, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// ResolveLocalImagePaths — exported function for EML build paths
// ---------------------------------------------------------------------------
func TestResolveLocalImagePathsBasic(t *testing.T) {
chdirTemp(t)
os.WriteFile("photo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
html := `<div>Hello<img src="./photo.png" /></div>`
resolved, refs, err := ResolveLocalImagePaths(html)
if err != nil {
t.Fatalf("ResolveLocalImagePaths() error = %v", err)
}
if strings.Contains(resolved, "./photo.png") {
t.Fatal("local path should have been replaced")
}
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
if refs[0].FilePath != "./photo.png" {
t.Errorf("expected FilePath ./photo.png, got %q", refs[0].FilePath)
}
if !strings.Contains(resolved, "cid:"+refs[0].CID) {
t.Fatalf("expected resolved HTML to contain cid:%s", refs[0].CID)
}
}
func TestResolveLocalImagePathsSkipsRemoteURLs(t *testing.T) {
html := `<div><img src="https://example.com/img.png" /></div>`
resolved, refs, err := ResolveLocalImagePaths(html)
if err != nil {
t.Fatalf("ResolveLocalImagePaths() error = %v", err)
}
if resolved != html {
t.Fatal("expected unchanged HTML for remote URLs")
}
if len(refs) != 0 {
t.Fatalf("expected 0 refs, got %d", len(refs))
}
}
func TestResolveLocalImagePathsDeduplicatesSameFile(t *testing.T) {
chdirTemp(t)
os.WriteFile("icon.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
html := `<img src="./icon.png" /><img src="./icon.png" />`
_, refs, err := ResolveLocalImagePaths(html)
if err != nil {
t.Fatalf("ResolveLocalImagePaths() error = %v", err)
}
if len(refs) != 1 {
t.Fatalf("same file should produce 1 ref, got %d", len(refs))
}
}
func TestResolveLocalImagePathsNoImages(t *testing.T) {
html := "no html images at all"
resolved, refs, err := ResolveLocalImagePaths(html)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resolved != html {
t.Fatal("expected unchanged text")
}
if len(refs) != 0 {
t.Fatalf("expected 0 refs, got %d", len(refs))
}
}
// ---------------------------------------------------------------------------
// newInlinePart — rejects CIDs with spaces or other invalid characters
// ---------------------------------------------------------------------------
func TestNewInlinePartRejectsInvalidCIDChars(t *testing.T) {
content := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
for _, bad := range []string{"my logo", "a\tb", "cid<x>", "cid(x)", "cid\r\nx", "test<>", "<>bad"} {
_, err := newInlinePart("test.png", content, bad, "test.png", "image/png")
if err == nil {
t.Errorf("expected error for CID %q, got nil", bad)
}
}
// Valid CIDs should pass (including RFC <...> wrapper which gets unwrapped).
for _, good := range []string{"logo", "my-logo", "img_01", "photo.2", "<wrapped>"} {
_, err := newInlinePart("test.png", content, good, "test.png", "image/png")
if err != nil {
t.Errorf("unexpected error for CID %q: %v", good, err)
}
}
}
// ---------------------------------------------------------------------------
// Regression: orphaned inline under multipart/mixed (not multipart/related)
// ---------------------------------------------------------------------------
// TestSetBodyReplacesOrphanedInlineUnderMixed reproduces the bug where the
// server returns a draft with an inline part as a direct child of
// multipart/mixed (not wrapped in multipart/related). When set_body replaces
// the HTML with a local <img src>, postProcessInlineImages must remove the
// old inline part even though it lives under multipart/mixed.
func TestSetBodyReplacesOrphanedInlineUnderMixed(t *testing.T) {
chdirTemp(t)
os.WriteFile("Peter1.jpeg", []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'}, 0o644)
// Simulate a server-returned draft where the inline part is a direct
// child of multipart/mixed (no multipart/related wrapper).
snapshot := mustParseFixtureDraft(t, "Subject: Test\r\n"+
"From: alice@example.com\r\n"+
"MIME-Version: 1.0\r\n"+
"Content-Type: multipart/mixed; boundary=outer\r\n"+
"\r\n"+
"--outer\r\n"+
"Content-Type: text/html; charset=UTF-8\r\n"+
"\r\n"+
"<p>111<img src=\"cid:peter1-inline\"></p><p>222</p>\r\n"+
"--outer\r\n"+
"Content-Type: image/jpeg; name=\"Peter1.jpeg\"\r\n"+
"Content-Disposition: inline; filename=\"Peter1.jpeg\"\r\n"+
"Content-ID: <peter1-inline>\r\n"+
"Content-Transfer-Encoding: base64\r\n"+
"\r\n"+
"/9j/4AAQ\r\n"+
"--outer--\r\n")
// Verify the old inline part exists before patching.
oldInlineFound := false
for _, part := range flattenParts(snapshot.Body) {
if part != nil && part.ContentID == "peter1-inline" {
oldInlineFound = true
}
}
if !oldInlineFound {
t.Fatal("expected old inline part with CID 'peter1-inline' in parsed draft")
}
// Apply set_body with a local image path (triggers auto-resolve).
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<p>111<img src="./Peter1.jpeg" /></p><p>222</p>`}},
})
if err != nil {
t.Fatalf("Apply() error = %v", err)
}
// After apply, the HTML should reference a UUID CID, not peter1-inline.
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
t.Fatal("HTML part not found after apply")
}
body := string(htmlPart.Body)
if strings.Contains(body, "peter1-inline") {
t.Fatalf("HTML should not reference old CID 'peter1-inline', got: %s", body)
}
if strings.Contains(body, "./Peter1.jpeg") {
t.Fatal("local path should have been replaced with cid: reference")
}
// Extract the new CID from HTML.
cidRe := regexp.MustCompile(`src="cid:([^"]+)"`)
m := cidRe.FindStringSubmatch(body)
if m == nil {
t.Fatalf("expected cid: reference in HTML, got: %s", body)
}
newCID := m[1]
// Verify: the old inline part must be gone, and a new one with the UUID CID must exist.
oldFound := false
newFound := false
for _, part := range flattenParts(snapshot.Body) {
if part == nil {
continue
}
if part.ContentID == "peter1-inline" {
oldFound = true
}
if part.ContentID == newCID {
newFound = true
}
}
if oldFound {
t.Error("old inline part with CID 'peter1-inline' should have been removed")
}
if !newFound {
t.Errorf("new inline part with CID %q should exist", newCID)
}
}
// ---------------------------------------------------------------------------
// Metadata-only edit must NOT trigger local path resolution
// ---------------------------------------------------------------------------
// TestMetadataEditSkipsLocalPathResolve ensures that a pure metadata edit
// (set_subject) does not attempt to resolve <img src="./..."> paths from
// disk. If the HTML happens to contain a local path (e.g. from an external
// client), the edit should still succeed without file I/O.
func TestMetadataEditSkipsLocalPathResolve(t *testing.T) {
// Draft HTML contains a local path that does NOT exist on disk.
// A body-changing op would fail trying to read this file.
snapshot := mustParseFixtureDraft(t, `Subject: Original
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
<div>Hello<img src="./nonexistent-image.png" /></div>
`)
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_subject", Value: "Updated subject"}},
})
if err != nil {
t.Fatalf("metadata-only edit should not trigger local path resolution, got: %v", err)
}
}

View File

@@ -460,7 +460,7 @@ func TestRemoveInlineFailsWhenHTMLStillReferencesCID(t *testing.T) {
}
}
func TestApplySetBodyOrphanedInlineCIDIsRejected(t *testing.T) {
func TestApplySetBodyOrphanedInlineCIDIsAutoRemoved(t *testing.T) {
snapshot := mustParseFixtureDraft(t, `Subject: Inline
From: Alice <alice@example.com>
To: Bob <bob@example.com>
@@ -480,12 +480,18 @@ Content-Transfer-Encoding: base64
cG5n
--rel--
`)
// set_body that drops the existing cid:logo reference → logo becomes orphaned
// set_body that drops the existing cid:logo reference → logo is auto-removed
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: "<div>replaced body without cid reference</div>"}},
})
if err == nil || !strings.Contains(err.Error(), "orphaned cids") {
t.Fatalf("expected orphaned cid error, got: %v", err)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
// The orphaned inline part should be removed from the MIME tree.
for _, part := range flattenParts(snapshot.Body) {
if part != nil && part.ContentID == "logo" {
t.Fatal("expected orphaned inline part 'logo' to be removed")
}
}
}
@@ -641,12 +647,18 @@ Content-Type: text/html; charset=UTF-8
t.Fatalf("Apply(set_body) error = %v", err)
}
// Step 3: set_body again dropping the CID reference — should fail validation
// Step 3: set_body again dropping the CID reference — orphaned inline part
// should be auto-removed (not error), matching the auto-cleanup behavior.
err = Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "set_body", Value: `<div>no image here</div>`}},
})
if err == nil || !strings.Contains(err.Error(), "orphaned cids") {
t.Fatalf("expected orphaned cid error, got: %v", err)
if err != nil {
t.Fatalf("Apply(set_body drop CID) error = %v", err)
}
for _, part := range flattenParts(snapshot.Body) {
if part != nil && part.ContentID == "logo" {
t.Fatal("expected orphaned inline part 'logo' to be auto-removed")
}
}
}
@@ -732,6 +744,23 @@ func TestReplaceInlineRejectsCRLFInCID(t *testing.T) {
}
}
func TestReplaceInlineRejectsInvalidCIDChars(t *testing.T) {
fixtureData := mustReadFixture(t, "testdata/html_inline_draft.eml")
chdirTemp(t)
if err := os.WriteFile("updated.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
snapshot := mustParseFixtureDraft(t, fixtureData)
for _, bad := range []string{"my logo", "a\tb", "cid<x>", "cid(x)"} {
err := Apply(snapshot, Patch{
Ops: []PatchOp{{Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, Path: "updated.png", CID: bad}},
})
if err == nil {
t.Errorf("expected error for CID %q, got nil", bad)
}
}
}
func TestReplaceInlineRejectsCRLFInFileName(t *testing.T) {
fixtureData := mustReadFixture(t, "testdata/html_inline_draft.eml")
chdirTemp(t)

View File

@@ -22,6 +22,7 @@ import (
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
)
@@ -1770,11 +1771,33 @@ func normalizeInlineCID(cid string) string {
return strings.TrimSpace(trimmed)
}
func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Builder, images []inlineSourcePart) (emlbuilder.Builder, error) {
// validateInlineCIDs checks bidirectional CID consistency between HTML body and
// inline MIME parts — the same checks as postProcessInlineImages in draft-edit.
// 1. Every cid: reference in HTML must have a corresponding inline part (checked
// against userCIDs + extraCIDs combined).
// 2. Every user-provided inline part must be referenced in HTML (orphan check
// against userCIDs only — extraCIDs such as source-message images in
// reply/forward are excluded because quoting may drop some references).
func validateInlineCIDs(html string, userCIDs, extraCIDs []string) error {
allCIDs := append(append([]string{}, userCIDs...), extraCIDs...)
if err := draftpkg.ValidateCIDReferences(html, allCIDs); err != nil {
return err
}
if len(userCIDs) > 0 {
orphaned := draftpkg.FindOrphanedCIDs(html, userCIDs)
if len(orphaned) > 0 {
return fmt.Errorf("inline images with cids %v are not referenced by any <img src=\"cid:...\"> in the HTML body and will appear as unexpected attachments; remove unused --inline entries or add matching <img> tags", orphaned)
}
}
return nil
}
func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Builder, images []inlineSourcePart) (emlbuilder.Builder, []string, error) {
var cids []string
for _, img := range images {
content, err := downloadAttachmentContent(runtime, img.DownloadURL)
if err != nil {
return bld, fmt.Errorf("failed to download inline resource %s: %w", img.Filename, err)
return bld, nil, fmt.Errorf("failed to download inline resource %s: %w", img.Filename, err)
}
cid := normalizeInlineCID(img.CID)
if cid == "" {
@@ -1785,8 +1808,9 @@ func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Bui
contentType = "application/octet-stream"
}
bld = bld.AddInline(content, contentType, img.Filename, cid)
cids = append(cids, cid)
}
return bld, nil
return bld, cids, nil
}
// InlineSpec represents one inline image entry from the --inline JSON array.
@@ -1961,13 +1985,14 @@ func validateComposeInlineAndAttachments(attachFlag, inlineFlag string, plainTex
return fmt.Errorf("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
}
}
// Validate explicitly provided files (--attach + --inline) early so that
// dry-run and reply/forward can catch local errors before Execute.
// Auto-resolved local images are only known at Execute time, so Execute
// performs a second, complete size check that includes them.
inlineSpecs, err := parseInlineSpecs(inlineFlag)
if err != nil {
return err
}
allFiles := append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...)
if err := checkAttachmentSizeLimit(allFiles, 0); err != nil {
return err
}
return nil
return checkAttachmentSizeLimit(allFiles, 0)
}

View File

@@ -614,6 +614,67 @@ func TestCheckAttachmentSizeLimit_WithFiles(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// validateInlineCIDs — bidirectional CID consistency
// ---------------------------------------------------------------------------
func TestValidateInlineCIDs_UserOrphanError(t *testing.T) {
// User-provided CID not referenced in body → error.
err := validateInlineCIDs(`<p>no image</p>`, []string{"orphan-cid"}, nil)
if err == nil {
t.Fatal("expected orphaned CID error")
}
if !strings.Contains(err.Error(), "orphan-cid") {
t.Fatalf("expected error mentioning orphan-cid, got: %v", err)
}
}
func TestValidateInlineCIDs_SourceOrphanAllowed(t *testing.T) {
// Source-message CID not referenced in body → allowed (quoting may drop references).
err := validateInlineCIDs(`<p>no image</p>`, nil, []string{"source-unused"})
if err != nil {
t.Fatalf("source CID orphan should not error, got: %v", err)
}
}
func TestValidateInlineCIDs_SourceAndUserMixed(t *testing.T) {
// Body references both a source CID and a user CID.
// Source has an extra unreferenced CID — should not error.
html := `<p><img src="cid:src-used" /><img src="cid:user-img" /></p>`
err := validateInlineCIDs(html, []string{"user-img"}, []string{"src-used", "src-unused"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateInlineCIDs_MissingRefError(t *testing.T) {
// Body references a CID that nobody provided → error.
html := `<p><img src="cid:exists" /><img src="cid:missing" /></p>`
err := validateInlineCIDs(html, []string{"exists"}, nil)
if err == nil {
t.Fatal("expected missing CID error")
}
if !strings.Contains(err.Error(), "missing") {
t.Fatalf("expected error mentioning missing, got: %v", err)
}
}
func TestValidateInlineCIDs_MissingRefSatisfiedBySource(t *testing.T) {
// Body references a CID that only exists in source (extraCIDs) → ok.
html := `<p><img src="cid:from-source" /></p>`
err := validateInlineCIDs(html, nil, []string{"from-source"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateInlineCIDs_NoCIDsNoError(t *testing.T) {
err := validateInlineCIDs(`<p>plain text</p>`, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// downloadAttachmentContent — size limit enforcement
// ---------------------------------------------------------------------------
@@ -678,7 +739,7 @@ func TestAddInlineImagesToBuilder_EmptyCIDSkipped(t *testing.T) {
images := []inlineSourcePart{
{ID: "img1", Filename: "logo.png", ContentType: "image/png", CID: "", DownloadURL: srv.URL + "/img1"},
}
_, err := addInlineImagesToBuilder(rt, bld, images)
_, _, err := addInlineImagesToBuilder(rt, bld, images)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -699,7 +760,7 @@ func TestAddInlineImagesToBuilder_Success(t *testing.T) {
images := []inlineSourcePart{
{ID: "img1", Filename: "banner.png", ContentType: "image/png", CID: "cid:banner", DownloadURL: srv.URL + "/img1"},
}
result, err := addInlineImagesToBuilder(rt, bld, images)
result, _, err := addInlineImagesToBuilder(rt, bld, images)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

View File

@@ -148,23 +148,42 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
if input.BCC != "" {
bld = bld.BCCAddrs(parseNetAddrs(input.BCC))
}
if input.PlainText {
bld = bld.TextBody([]byte(input.Body))
} else if bodyIsHTML(input.Body) {
bld = bld.HTMLBody([]byte(input.Body))
} else {
bld = bld.TextBody([]byte(input.Body))
}
inlineSpecs, err := parseInlineSpecs(input.Inline)
if err != nil {
return "", output.ErrValidation("%v", err)
}
var autoResolvedPaths []string
if input.PlainText {
bld = bld.TextBody([]byte(input.Body))
} else if bodyIsHTML(input.Body) {
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(input.Body)
if resolveErr != nil {
return "", resolveErr
}
bld = bld.HTMLBody([]byte(resolved))
var allCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
allCIDs = append(allCIDs, ref.CID)
}
for _, spec := range inlineSpecs {
bld = bld.AddFileInline(spec.FilePath, spec.CID)
allCIDs = append(allCIDs, spec.CID)
}
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
return "", err
}
} else {
bld = bld.TextBody([]byte(input.Body))
}
allFilePaths := append(append(splitByComma(input.Attach), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
if err := checkAttachmentSizeLimit(allFilePaths, 0); err != nil {
return "", err
}
for _, path := range splitByComma(input.Attach) {
bld = bld.AddFileAttachment(path)
}
for _, spec := range inlineSpecs {
bld = bld.AddFileInline(spec.FilePath, spec.CID)
}
rawEML, err := bld.BuildBase64URL()
if err != nil {
return "", output.ErrValidation("build EML failed: %v", err)

View File

@@ -0,0 +1,152 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"os"
"strings"
"testing"
)
func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
chdirTemp(t)
os.WriteFile("test_image.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
input := draftCreateInput{
From: "sender@example.com",
Subject: "local image test",
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
}
rawEML, err := buildRawEMLForDraftCreate(nil, input)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
eml := decodeBase64URL(rawEML)
if strings.Contains(eml, `src="./test_image.png"`) {
t.Fatal("local image path should have been replaced with cid: reference")
}
if !strings.Contains(eml, "cid:") {
t.Fatal("expected cid: reference in resolved HTML body")
}
if !strings.Contains(eml, "Content-Disposition: inline") {
t.Fatal("expected inline MIME part for the resolved image")
}
}
func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
input := draftCreateInput{
From: "sender@example.com",
Subject: "plain html",
Body: `<p>Hello <b>world</b></p>`,
}
rawEML, err := buildRawEMLForDraftCreate(nil, input)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
eml := decodeBase64URL(rawEML)
if !strings.Contains(eml, "Hello") {
t.Fatal("expected body content in EML")
}
if strings.Contains(eml, "Content-Disposition: inline") {
t.Fatal("no inline parts expected without local images")
}
}
func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
chdirTemp(t)
// Create a 1KB PNG file — small, but enough to push over the limit
// when combined with a near-limit --attach file.
pngHeader := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
imgData := make([]byte, 1024)
copy(imgData, pngHeader)
os.WriteFile("photo.png", imgData, 0o644)
// Create an attach file that's just under the 25MB limit (use .txt — allowed extension).
bigFile := make([]byte, MaxAttachmentBytes-500)
os.WriteFile("big.txt", bigFile, 0o644)
input := draftCreateInput{
From: "sender@example.com",
Subject: "size limit test",
Body: `<p><img src="./photo.png" /></p>`,
Attach: "./big.txt",
}
_, err := buildRawEMLForDraftCreate(nil, input)
if err == nil {
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
}
if !strings.Contains(err.Error(), "25 MB") {
t.Fatalf("expected 25 MB limit error, got: %v", err)
}
}
func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
chdirTemp(t)
os.WriteFile("unused.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
input := draftCreateInput{
From: "sender@example.com",
Subject: "orphan test",
Body: `<p>No image reference here</p>`,
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
}
_, err := buildRawEMLForDraftCreate(nil, input)
if err == nil {
t.Fatal("expected error for orphaned --inline CID not referenced in body")
}
if !strings.Contains(err.Error(), "orphan") {
t.Fatalf("expected error mentioning orphan, got: %v", err)
}
}
func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
chdirTemp(t)
os.WriteFile("present.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
input := draftCreateInput{
From: "sender@example.com",
Subject: "missing cid test",
Body: `<p><img src="cid:present" /><img src="cid:missing" /></p>`,
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
}
_, err := buildRawEMLForDraftCreate(nil, input)
if err == nil {
t.Fatal("expected error for missing CID reference")
}
if !strings.Contains(err.Error(), "missing") {
t.Fatalf("expected error mentioning missing, got: %v", err)
}
}
func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
chdirTemp(t)
os.WriteFile("img.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
input := draftCreateInput{
From: "sender@example.com",
Subject: "plain text",
Body: `check <img src="./img.png" /> text`,
PlainText: true,
}
rawEML, err := buildRawEMLForDraftCreate(nil, input)
if err != nil {
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
}
eml := decodeBase64URL(rawEML)
if strings.Contains(eml, "cid:") {
t.Fatal("plain-text mode should not resolve local images")
}
}

View File

@@ -303,13 +303,13 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
{"op": "set_recipients", "shape": map[string]interface{}{"field": "to|cc|bcc", "addresses": []map[string]interface{}{{"address": "string", "name": "string(optional)"}}}},
{"op": "add_recipient", "shape": map[string]interface{}{"field": "to|cc|bcc", "address": "string", "name": "string(optional)"}},
{"op": "remove_recipient", "shape": map[string]interface{}{"field": "to|cc|bcc", "address": "string"}},
{"op": "set_body", "shape": map[string]interface{}{"value": "string"}},
{"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically)"}},
{"op": "set_body", "shape": map[string]interface{}{"value": "string (supports <img src=\"./local/path.png\" /> — local paths auto-resolved to inline MIME parts)"}},
{"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically; supports <img src=\"./local/path.png\" /> — local paths auto-resolved to inline MIME parts)"}},
{"op": "set_header", "shape": map[string]interface{}{"name": "string", "value": "string"}},
{"op": "remove_header", "shape": map[string]interface{}{"name": "string"}},
{"op": "add_attachment", "shape": map[string]interface{}{"path": "string(relative path)"}},
{"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}},
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}, "note": "advanced: prefer <img src=\"./path\"> in set_body/set_reply_body instead"},
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
},
@@ -318,8 +318,8 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
"group": "subject_and_body",
"ops": []map[string]interface{}{
{"op": "set_subject", "shape": map[string]interface{}{"value": "string"}},
{"op": "set_body", "shape": map[string]interface{}{"value": "string"}},
{"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically)"}},
{"op": "set_body", "shape": map[string]interface{}{"value": "string (supports <img src=\"./local/path.png\" /> — local paths auto-resolved to inline MIME parts)"}},
{"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically; supports <img src=\"./local/path.png\" /> — local paths auto-resolved to inline MIME parts)"}},
},
},
{
@@ -342,7 +342,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
"ops": []map[string]interface{}{
{"op": "add_attachment", "shape": map[string]interface{}{"path": "string(relative path)"}},
{"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}},
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}, "note": "advanced: prefer <img src=\"./path\"> in set_body/set_reply_body instead"},
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
},
@@ -359,12 +359,13 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
{"situation": "draft created by +reply or +forward (has_quoted_content=true)", "recommended_op": "set_reply_body — replaces only the user-authored portion and automatically preserves the quoted original message; if user explicitly wants to remove the quote, use set_body instead"},
},
"notes": []string{
"`set_body`/`set_reply_body` support inline images via local file paths: use <img src=\"./local/file.png\" /> in the HTML value — the local path is automatically resolved into an inline MIME part with a generated CID; removing or replacing an <img> tag automatically cleans up or replaces the corresponding MIME part; do NOT use `add_inline` for this; example: {\"op\":\"set_body\",\"value\":\"<div>Hello<img src=\\\"./logo.png\\\" /></div>\"}",
"`add_inline` is an advanced op for precise CID control only — in most cases, use <img src=\"./path\"> in `set_body`/`set_reply_body` instead",
"`ops` is executed in order",
"all file paths (--patch-file and `path` fields in ops) must be relative — no absolute paths or .. traversal",
"all body edits MUST go through --patch-file; there is no --set-body flag",
"`set_body` replaces the ENTIRE body including any reply/forward quote block; when the draft has both text/plain and text/html, it updates the HTML body and regenerates the plain-text summary, so the input should be HTML",
"`set_reply_body` replaces only the user-authored portion of the body and automatically re-appends the trailing reply/forward quote block (generated by +reply or +forward); the value you pass should contain ONLY the new user-authored content WITHOUT the quote block — the quote block will be re-inserted automatically; if the user wants to modify content INSIDE the quote block, use `set_body` instead for full replacement; if the draft has no quote block, it behaves identically to `set_body`",
"`add_inline` only adds the MIME binary part; it does NOT insert an <img> tag into the HTML body; to display the image in the body, you must ALSO use set_body/set_reply_body to insert <img src=\"cid:...\"> into the body content; forgetting this causes the inline part to become an orphaned attachment when sent",
"`body_kind` only supports text/plain and text/html",
"`selector` currently only supports primary",
"`remove_attachment` target supports part_id or cid; priority: part_id > cid",

View File

@@ -121,16 +121,41 @@ var MailForward = common.Shortcut{
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
}
inlineSpecs, err := parseInlineSpecs(inlineFlag)
if err != nil {
return err
}
var autoResolvedPaths []string
if useHTML {
if err := validateInlineImageURLs(sourceMsg); err != nil {
return fmt.Errorf("forward blocked: %w", err)
}
processedBody := buildBodyDiv(body, bodyIsHTML(body))
bld = bld.HTMLBody([]byte(processedBody + buildForwardQuoteHTML(&orig)))
bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
forwardQuote := buildForwardQuoteHTML(&orig)
var srcCIDs []string
bld, srcCIDs, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
if err != nil {
return err
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(processedBody)
if resolveErr != nil {
return resolveErr
}
fullHTML := resolved + forwardQuote
bld = bld.HTMLBody([]byte(fullHTML))
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
userCIDs = append(userCIDs, ref.CID)
}
for _, spec := range inlineSpecs {
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
return err
}
} else {
bld = bld.TextBody([]byte(buildForwardedMessage(&orig, body)))
}
@@ -169,11 +194,8 @@ var MailForward = common.Shortcut{
}
bld = bld.Header("X-Lms-Large-Attachment-Ids", base64.StdEncoding.EncodeToString(idsJSON))
}
inlineSpecs, err := parseInlineSpecs(inlineFlag)
if err != nil {
return err
}
if err := checkAttachmentSizeLimit(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), origAttBytes, len(origAtts)); err != nil {
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
if err := checkAttachmentSizeLimit(allFilePaths, origAttBytes, len(origAtts)); err != nil {
return err
}
for _, att := range origAtts {
@@ -182,9 +204,6 @@ var MailForward = common.Shortcut{
for _, path := range splitByComma(attachFlag) {
bld = bld.AddFileAttachment(path)
}
for _, spec := range inlineSpecs {
bld = bld.AddFileInline(spec.FilePath, spec.CID)
}
rawEML, err := bld.BuildBase64URL()
if err != nil {
return fmt.Errorf("failed to build EML: %w", err)

View File

@@ -128,24 +128,45 @@ var MailReply = common.Shortcut{
if messageId != "" {
bld = bld.LMSReplyToMessageID(messageId)
}
var autoResolvedPaths []string
if useHTML {
if err := validateInlineImageURLs(sourceMsg); err != nil {
return fmt.Errorf("HTML reply blocked: %w", err)
}
bld = bld.HTMLBody([]byte(bodyStr + quoted))
bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
var srcCIDs []string
bld, srcCIDs, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
if err != nil {
return err
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr)
if resolveErr != nil {
return resolveErr
}
fullHTML := resolved + quoted
bld = bld.HTMLBody([]byte(fullHTML))
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
userCIDs = append(userCIDs, ref.CID)
}
for _, spec := range inlineSpecs {
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
return err
}
} else {
bld = bld.TextBody([]byte(bodyStr + quoted))
}
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
if err := checkAttachmentSizeLimit(allFilePaths, 0); err != nil {
return err
}
for _, path := range splitByComma(attachFlag) {
bld = bld.AddFileAttachment(path)
}
for _, spec := range inlineSpecs {
bld = bld.AddFileInline(spec.FilePath, spec.CID)
}
rawEML, err := bld.BuildBase64URL()
if err != nil {
return fmt.Errorf("failed to build EML: %w", err)

View File

@@ -142,24 +142,45 @@ var MailReplyAll = common.Shortcut{
if messageId != "" {
bld = bld.LMSReplyToMessageID(messageId)
}
var autoResolvedPaths []string
if useHTML {
if err := validateInlineImageURLs(sourceMsg); err != nil {
return fmt.Errorf("HTML reply-all blocked: %w", err)
}
bld = bld.HTMLBody([]byte(bodyStr + quoted))
bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
var srcCIDs []string
bld, srcCIDs, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
if err != nil {
return err
}
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr)
if resolveErr != nil {
return resolveErr
}
fullHTML := resolved + quoted
bld = bld.HTMLBody([]byte(fullHTML))
var userCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
userCIDs = append(userCIDs, ref.CID)
}
for _, spec := range inlineSpecs {
bld = bld.AddFileInline(spec.FilePath, spec.CID)
userCIDs = append(userCIDs, spec.CID)
}
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
return err
}
} else {
bld = bld.TextBody([]byte(bodyStr + quoted))
}
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
if err := checkAttachmentSizeLimit(allFilePaths, 0); err != nil {
return err
}
for _, path := range splitByComma(attachFlag) {
bld = bld.AddFileAttachment(path)
}
for _, spec := range inlineSpecs {
bld = bld.AddFileInline(spec.FilePath, spec.CID)
}
rawEML, err := bld.BuildBase64URL()
if err != nil {
return fmt.Errorf("failed to build EML: %w", err)

View File

@@ -0,0 +1,275 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/base64"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// stubSourceMessageWithInlineImages registers HTTP stubs for a source message.
func stubSourceMessageWithInlineImages(reg *httpmock.Registry, bodyHTML string, allImages []map[string]interface{}) {
// Profile
reg.Register(&httpmock.Stub{
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"primary_email_address": "me@example.com",
},
},
})
// Message get
atts := allImages
if atts == nil {
atts = []map[string]interface{}{}
}
reg.Register(&httpmock.Stub{
URL: "/user_mailboxes/me/messages/msg_001",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message": map[string]interface{}{
"message_id": "msg_001",
"thread_id": "thread_001",
"smtp_message_id": "<msg_001@example.com>",
"subject": "Original Subject",
"head_from": map[string]interface{}{"mail_address": "sender@example.com", "name": "Sender"},
"to": []map[string]interface{}{{"mail_address": "me@example.com", "name": "Me"}},
"cc": []interface{}{},
"bcc": []interface{}{},
"body_html": base64.URLEncoding.EncodeToString([]byte(bodyHTML)),
"body_plain_text": base64.URLEncoding.EncodeToString([]byte("plain")),
"internal_date": "1704067200000",
"attachments": atts,
},
},
},
})
// Download URLs
if len(allImages) > 0 {
downloadURLs := make([]map[string]interface{}, 0, len(allImages))
for _, img := range allImages {
id, _ := img["id"].(string)
downloadURLs = append(downloadURLs, map[string]interface{}{
"attachment_id": id,
"download_url": "https://storage.example.com/" + id,
})
}
reg.Register(&httpmock.Stub{
URL: "/attachments/download_url",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"download_urls": downloadURLs,
"failed_ids": []interface{}{},
},
},
})
}
// Image downloads
pngBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
for _, img := range allImages {
id, _ := img["id"].(string)
reg.Register(&httpmock.Stub{
URL: "https://storage.example.com/" + id,
RawBody: pngBytes,
})
}
// Draft create
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"draft_id": "draft_001",
},
},
})
}
// ---------------------------------------------------------------------------
// +reply with source inline images
// ---------------------------------------------------------------------------
func TestReply_SourceInlineImagesPreserved(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessageWithInlineImages(reg,
`<p>Hello <img src="cid:banner_001" /></p>`,
[]map[string]interface{}{
{"id": "img_001", "filename": "banner.png", "is_inline": true, "cid": "banner_001", "content_type": "image/png"},
},
)
err := runMountedMailShortcut(t, MailReply, []string{
"+reply", "--message-id", "msg_001", "--body", "<p>Thanks!</p>",
}, f, stdout)
if err != nil {
t.Fatalf("reply failed: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["draft_id"] == nil || data["draft_id"] == "" {
t.Fatal("expected draft_id in output")
}
}
func TestReply_SourceOrphanCIDNotBlocked(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
// Source has TWO inline images, but body HTML only references one.
// The unreferenced image should NOT be downloaded or cause an error.
stubSourceMessageWithInlineImages(reg,
`<p>Hello <img src="cid:used_001" /></p>`,
[]map[string]interface{}{
{"id": "img_001", "filename": "used.png", "is_inline": true, "cid": "used_001", "content_type": "image/png"},
{"id": "img_002", "filename": "unused.png", "is_inline": true, "cid": "unused_002", "content_type": "image/png"},
},
)
err := runMountedMailShortcut(t, MailReply, []string{
"+reply", "--message-id", "msg_001", "--body", "<p>Reply</p>",
}, f, stdout)
if err != nil {
t.Fatalf("reply should succeed even with unreferenced source CID, got: %v", err)
}
}
func TestReply_WithAutoResolveLocalImage(t *testing.T) {
chdirTemp(t)
os.WriteFile("local.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessageWithInlineImages(reg,
`<p>Hello</p>`,
nil,
)
err := runMountedMailShortcut(t, MailReply, []string{
"+reply", "--message-id", "msg_001",
"--body", `<p>See image: <img src="./local.png" /></p>`,
}, f, stdout)
if err != nil {
t.Fatalf("reply with auto-resolved local image failed: %v", err)
}
}
// ---------------------------------------------------------------------------
// +reply-all with source inline images
// ---------------------------------------------------------------------------
func TestReplyAll_SourceOrphanCIDNotBlocked(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessageWithInlineImages(reg,
`<p>Hello <img src="cid:used_001" /></p>`,
[]map[string]interface{}{
{"id": "img_001", "filename": "used.png", "is_inline": true, "cid": "used_001", "content_type": "image/png"},
{"id": "img_002", "filename": "orphan.png", "is_inline": true, "cid": "orphan_002", "content_type": "image/png"},
},
)
// reply-all also needs self-exclusion profile lookup
reg.Register(&httpmock.Stub{
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"primary_email_address": "me@example.com",
},
},
})
err := runMountedMailShortcut(t, MailReplyAll, []string{
"+reply-all", "--message-id", "msg_001", "--body", "<p>Reply all</p>",
}, f, stdout)
if err != nil {
t.Fatalf("reply-all should succeed with unreferenced source CID, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// +forward with source inline images
// ---------------------------------------------------------------------------
func TestForward_SourceOrphanCIDNotBlocked(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessageWithInlineImages(reg,
`<p>Hello <img src="cid:used_001" /></p>`,
[]map[string]interface{}{
{"id": "img_001", "filename": "used.png", "is_inline": true, "cid": "used_001", "content_type": "image/png"},
{"id": "img_002", "filename": "orphan.png", "is_inline": true, "cid": "orphan_002", "content_type": "image/png"},
},
)
err := runMountedMailShortcut(t, MailForward, []string{
"+forward", "--message-id", "msg_001",
"--to", "alice@example.com",
"--body", "<p>FYI</p>",
}, f, stdout)
if err != nil {
t.Fatalf("forward should succeed with unreferenced source CID, got: %v", err)
}
}
func TestForward_WithAutoResolveLocalImage(t *testing.T) {
chdirTemp(t)
os.WriteFile("chart.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessageWithInlineImages(reg,
`<p>Original content</p>`,
nil,
)
err := runMountedMailShortcut(t, MailForward, []string{
"+forward", "--message-id", "msg_001",
"--to", "alice@example.com",
"--body", `<p>See chart: <img src="./chart.png" /></p>`,
}, f, stdout)
if err != nil {
t.Fatalf("forward with auto-resolved local image failed: %v", err)
}
}
// ---------------------------------------------------------------------------
// +reply body auto-resolve does NOT scan quoted content
// ---------------------------------------------------------------------------
func TestReply_QuotedContentNotAutoResolved(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
// Source message body has a relative <img src> — this should NOT be
// auto-resolved because it's in the quoted portion, not the user body.
stubSourceMessageWithInlineImages(reg,
`<p>See <img src="./should-not-resolve.png" /></p>`,
nil,
)
err := runMountedMailShortcut(t, MailReply, []string{
"+reply", "--message-id", "msg_001",
"--body", "<p>Got it</p>",
}, f, stdout)
// Should succeed — the ./should-not-resolve.png in quoted content is
// NOT auto-resolved (file doesn't exist, would fail if scanned).
if err != nil {
if strings.Contains(err.Error(), "should-not-resolve") {
t.Fatalf("auto-resolve incorrectly scanned quoted content: %v", err)
}
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -92,16 +92,37 @@ var MailSend = common.Shortcut{
if bccFlag != "" {
bld = bld.BCCAddrs(parseNetAddrs(bccFlag))
}
inlineSpecs, err := parseInlineSpecs(inlineFlag)
if err != nil {
return err
}
var autoResolvedPaths []string
if plainText {
bld = bld.TextBody([]byte(body))
} else if bodyIsHTML(body) {
bld = bld.HTMLBody([]byte(body))
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(body)
if resolveErr != nil {
return resolveErr
}
bld = bld.HTMLBody([]byte(resolved))
var allCIDs []string
for _, ref := range refs {
bld = bld.AddFileInline(ref.FilePath, ref.CID)
autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
allCIDs = append(allCIDs, ref.CID)
}
for _, spec := range inlineSpecs {
bld = bld.AddFileInline(spec.FilePath, spec.CID)
allCIDs = append(allCIDs, spec.CID)
}
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
return err
}
} else {
bld = bld.TextBody([]byte(body))
}
inlineSpecs, err := parseInlineSpecs(inlineFlag)
if err != nil {
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
if err := checkAttachmentSizeLimit(allFilePaths, 0); err != nil {
return err
}
@@ -109,10 +130,6 @@ var MailSend = common.Shortcut{
bld = bld.AddFileAttachment(path)
}
for _, spec := range inlineSpecs {
bld = bld.AddFileInline(spec.FilePath, spec.CID)
}
rawEML, err := bld.BuildBase64URL()
if err != nil {
return fmt.Errorf("failed to build EML: %w", err)

View File

@@ -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,24 @@ 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 nil, common.WrapSaveErrorByCategory(err, "io")
}
return &downloadResult{savedPath: safePath, sizeBytes: sizeBytes}, nil
resolvedPath, err := opts.fio.ResolvePath(outputPath)
if err != nil || resolvedPath == "" {
resolvedPath = outputPath
}
return &downloadResult{savedPath: resolvedPath, sizeBytes: result.Size()}, nil
}
// resolveFilenameFromResponse derives the filename from HTTP response headers.

View File

@@ -29,12 +29,13 @@ var GetMyTasks = common.Shortcut{
Flags: []common.Flag{
{Name: "query", Desc: "search for tasks by summary (exact match first, then partial match)"},
{Name: "complete", Type: "bool", Desc: "if true, query completed tasks; default is false"},
{Name: "complete", Type: "bool", Desc: "if true, query completed tasks;if false, query incompleted tasks; if not provided, both completed and incompleted tasks are queried."},
{Name: "created_at", Desc: "query tasks created after this time (date/relative/ms)"},
{Name: "due-start", Desc: "query tasks with due date after this time (date/relative/ms)"},
{Name: "due-end", Desc: "query tasks with due date before this time (date/relative/ms)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40 with --page-all)"},
{Name: "page-token", Desc: "start from the specified page token"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -48,6 +49,9 @@ var GetMyTasks = common.Shortcut{
if runtime.Cmd.Flags().Changed("complete") {
params["completed"] = runtime.Bool("complete")
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
params["page_token"] = pageToken
}
return d.GET("/open-apis/task/v2/tasks").Params(params)
},
@@ -66,6 +70,9 @@ var GetMyTasks = common.Shortcut{
queryParams.Set("completed", "false")
}
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
queryParams.Set("page_token", pageToken)
}
// parse time flags to ms timestamp if provided
var createdAfterMs, dueStartMs, dueEndMs int64

View File

@@ -22,11 +22,14 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
tests := []struct {
name string
formatFlag string
pageToken string
stubURL string
expectedOutput []string
}{
{
name: "pretty format",
formatFlag: "pretty",
stubURL: "/open-apis/task/v2/tasks",
expectedOutput: []string{
"Due: " + expectedDueTimeStr,
"Created: " + expectedCreatedDateStr,
@@ -35,11 +38,21 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
{
name: "json format",
formatFlag: "json",
stubURL: "/open-apis/task/v2/tasks",
expectedOutput: []string{
`"due_at": "` + expectedRFC3339 + `"`,
`"created_at": "` + expectedRFC3339 + `"`,
},
},
{
name: "start from page token",
formatFlag: "json",
pageToken: "pt_001",
stubURL: "page_token=pt_001",
expectedOutput: []string{
`"guid": "task-123"`,
},
},
}
for _, tt := range tests {
@@ -49,7 +62,7 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks",
URL: tt.stubURL,
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
@@ -73,7 +86,11 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
s := GetMyTasks
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", tt.formatFlag, "--as", "bot"}, f, stdout)
args := []string{"+get-my-tasks", "--format", tt.formatFlag, "--as", "bot"}
if tt.pageToken != "" {
args = append(args, "--page-token", tt.pageToken)
}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}

View File

@@ -10,5 +10,6 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
VCSearch,
VCNotes,
VCRecording,
}
}

View File

@@ -11,8 +11,10 @@
package vc
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -22,11 +24,11 @@ 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"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -90,44 +92,34 @@ func getPrimaryCalendarID(runtime *common.RuntimeContext) (string, error) {
return calID, nil
}
// fetchNoteByCalendarEventID queries notes via calendar event instance ID.
// Chain: primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note_id
func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeContext, instanceID string, calendarID string) map[string]any {
errOut := runtime.IO().ErrOut
// call mget_instance_relation_info to get meeting_id
// resolveMeetingIDsFromCalendarEvent resolves a calendar event instance to its
// associated meeting IDs via the mget_instance_relation_info API.
// Shared by +notes and +recording for the --calendar-event-ids path.
func resolveMeetingIDsFromCalendarEvent(runtime *common.RuntimeContext, instanceID string, calendarID string) ([]string, error) {
data, err := runtime.DoAPIJSON(http.MethodPost,
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)),
nil,
map[string]any{
"instance_ids": []string{instanceID},
"need_meeting_instance_ids": true,
"need_meeting_notes": true,
"need_ai_meeting_notes": true,
})
if err != nil {
return map[string]any{"calendar_event_id": instanceID, "error": fmt.Sprintf("failed to query event relation info: %v", err)}
return nil, fmt.Errorf("failed to query event relation info: %w", err)
}
// parse instance_relation_infos
infos, _ := data["instance_relation_infos"].([]any)
if len(infos) == 0 {
return map[string]any{"calendar_event_id": instanceID, "error": "no event relation info found"}
return nil, fmt.Errorf("no event relation info found")
}
info, _ := infos[0].(map[string]any)
// get meeting_instance_ids
meetingIDs, _ := info["meeting_instance_ids"].([]any)
if len(meetingIDs) == 0 {
return map[string]any{"calendar_event_id": instanceID, "error": "no associated video meeting for this event"}
rawIDs, _ := info["meeting_instance_ids"].([]any)
if len(rawIDs) == 0 {
return nil, fmt.Errorf("no associated video meeting for this event")
}
if len(meetingIDs) > 1 {
fmt.Fprintf(errOut, "%s event %s has %d meetings, trying each\n", logPrefix, sanitizeLogValue(instanceID), len(meetingIDs))
}
// try each meeting_instance_id until one has notes
for _, mid := range meetingIDs {
var ids []string
for _, mid := range rawIDs {
if mid == nil {
continue
}
@@ -140,12 +132,32 @@ func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeCont
default:
meetingID = fmt.Sprintf("%v", v)
}
ids = append(ids, meetingID)
}
return ids, nil
}
// fetchNoteByCalendarEventID queries notes via calendar event instance ID.
// Chain: primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note_id
func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeContext, instanceID string, calendarID string) map[string]any {
errOut := runtime.IO().ErrOut
meetingIDs, err := resolveMeetingIDsFromCalendarEvent(runtime, instanceID, calendarID)
if err != nil {
return map[string]any{"calendar_event_id": instanceID, "error": err.Error()}
}
if len(meetingIDs) > 1 {
fmt.Fprintf(errOut, "%s event %s has %d meetings, trying each\n", logPrefix, sanitizeLogValue(instanceID), len(meetingIDs))
}
// try each associated meeting until one has notes
for _, meetingID := range meetingIDs {
fmt.Fprintf(errOut, "%s event %s → meeting_id=%s\n", logPrefix, sanitizeLogValue(instanceID), sanitizeLogValue(meetingID))
result := fetchNoteByMeetingID(ctx, runtime, meetingID)
if result["error"] == nil {
return result
}
// if this meeting has no notes, try next
fmt.Fprintf(errOut, "%s meeting_id=%s: %s, trying next\n", logPrefix, sanitizeLogValue(meetingID), result["error"])
}
return map[string]any{"calendar_event_id": instanceID, "error": "no notes found in any associated meeting"}
@@ -252,25 +264,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,8 +296,16 @@ 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 {
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err)
if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(apiResp.RawBody)); err != nil {
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
fmt.Fprintf(errOut, "%s invalid transcript path: %v\n", logPrefix, err)
case errors.As(err, &me):
fmt.Fprintf(errOut, "%s failed to create directory: %v\n", logPrefix, err)
default:
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err)
}
return ""
}
return transcriptPath

View File

@@ -0,0 +1,252 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// vc +recording — query minute_token from meeting-ids or calendar-event-ids
//
// Two mutually exclusive input modes:
// meeting-ids: recording API → extract minute_token from URL
// calendar-event-ids: primary calendar → mget_instance_relation_info → meeting_id → recording API
package vc
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const recordingLogPrefix = "[vc +recording]"
var (
scopesRecordingMeetingIDs = []string{
"vc:record:readonly",
}
scopesRecordingCalendarEventIDs = []string{
"vc:record:readonly",
"calendar:calendar:read",
"calendar:calendar.event:read",
}
)
// extractMinuteToken parses minute_token from a recording URL.
// URL format: https://meetings.feishu.cn/minutes/{minute_token}
func extractMinuteToken(recordingURL string) string {
u, err := url.Parse(recordingURL)
if err != nil {
return ""
}
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
for i, p := range parts {
if p == "minutes" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
// fetchRecordingByMeetingID queries recording info for a single meeting.
func fetchRecordingByMeetingID(_ context.Context, runtime *common.RuntimeContext, meetingID string) map[string]any {
data, err := runtime.DoAPIJSON(http.MethodGet,
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
nil, nil)
if err != nil {
return map[string]any{"meeting_id": meetingID, "error": fmt.Sprintf("failed to query recording: %v", err)}
}
recording, _ := data["recording"].(map[string]any)
if recording == nil {
return map[string]any{"meeting_id": meetingID, "error": "no recording available for this meeting"}
}
recordingURL, _ := recording["url"].(string)
duration, _ := recording["duration"].(string)
result := map[string]any{"meeting_id": meetingID}
if recordingURL != "" {
result["recording_url"] = recordingURL
}
if duration != "" {
result["duration"] = duration
}
if token := extractMinuteToken(recordingURL); token != "" {
result["minute_token"] = token
}
return result
}
// VCRecording gets meeting recording info and extracts minute_token.
var VCRecording = common.Shortcut{
Service: "vc",
Command: "+recording",
Description: "Query minute_token from meeting-ids or calendar-event-ids",
Risk: "read",
Scopes: []string{"vc:record:readonly"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"},
{Name: "calendar-event-ids", Desc: "calendar event instance IDs, comma-separated for batch"},
},
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
if err := common.ExactlyOne(runtime, "meeting-ids", "calendar-event-ids"); err != nil {
return err
}
const maxBatchSize = 50
for _, flag := range []string{"meeting-ids", "calendar-event-ids"} {
if v := runtime.Str(flag); v != "" {
if ids := common.SplitCSV(v); len(ids) > maxBatchSize {
return output.ErrValidation("--%s: too many IDs (%d), maximum is %d", flag, len(ids), maxBatchSize)
}
}
}
var required []string
switch {
case runtime.Str("meeting-ids") != "":
required = scopesRecordingMeetingIDs
case runtime.Str("calendar-event-ids") != "":
required = scopesRecordingCalendarEventIDs
}
appID := runtime.Config.AppID
userOpenID := runtime.UserOpenId()
if appID != "" && userOpenID != "" {
stored := auth.GetStoredToken(appID, userOpenID)
if stored != nil {
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
}
}
}
return nil
},
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if ids := runtime.Str("meeting-ids"); ids != "" {
return common.NewDryRunAPI().
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
Set("meeting_ids", common.SplitCSV(ids)).
Set("steps", "meeting recording API → extract minute_token from URL")
}
ids := runtime.Str("calendar-event-ids")
return common.NewDryRunAPI().
POST("/open-apis/calendar/v4/calendars/primary").
POST("/open-apis/calendar/v4/calendars/{calendar_id}/events/mget_instance_relation_info").
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
Set("calendar_event_ids", common.SplitCSV(ids)).
Set("steps", "primary calendar → mget_instance_relation_info → meeting_id → recording API → extract minute_token")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
errOut := runtime.IO().ErrOut
var results []any
const batchDelay = 100 * time.Millisecond
if ids := runtime.Str("meeting-ids"); ids != "" {
meetingIDs := common.SplitCSV(ids)
fmt.Fprintf(errOut, "%s querying %d meeting_id(s)\n", recordingLogPrefix, len(meetingIDs))
for i, id := range meetingIDs {
if err := ctx.Err(); err != nil {
return err
}
if i > 0 {
time.Sleep(batchDelay)
}
fmt.Fprintf(errOut, "%s querying meeting_id=%s ...\n", recordingLogPrefix, sanitizeLogValue(id))
results = append(results, fetchRecordingByMeetingID(ctx, runtime, id))
}
} else {
instanceIDs := common.SplitCSV(runtime.Str("calendar-event-ids"))
fmt.Fprintf(errOut, "%s querying %d calendar_event_id(s)\n", recordingLogPrefix, len(instanceIDs))
calendarID, err := getPrimaryCalendarID(runtime)
if err != nil {
return err
}
fmt.Fprintf(errOut, "%s primary calendar: %s\n", recordingLogPrefix, calendarID)
for i, instanceID := range instanceIDs {
if err := ctx.Err(); err != nil {
return err
}
if i > 0 {
time.Sleep(batchDelay)
}
fmt.Fprintf(errOut, "%s resolving calendar_event_id=%s ...\n", recordingLogPrefix, sanitizeLogValue(instanceID))
meetingIDs, resolveErr := resolveMeetingIDsFromCalendarEvent(runtime, instanceID, calendarID)
if resolveErr != nil {
results = append(results, map[string]any{"calendar_event_id": instanceID, "error": resolveErr.Error()})
continue
}
found := false
for _, meetingID := range meetingIDs {
fmt.Fprintf(errOut, "%s event %s → meeting_id=%s\n", recordingLogPrefix, sanitizeLogValue(instanceID), sanitizeLogValue(meetingID))
result := fetchRecordingByMeetingID(ctx, runtime, meetingID)
if result["error"] == nil {
result["calendar_event_id"] = instanceID
results = append(results, result)
found = true
break
}
fmt.Fprintf(errOut, "%s meeting_id=%s: %s, trying next\n", recordingLogPrefix, sanitizeLogValue(meetingID), result["error"])
}
if !found {
results = append(results, map[string]any{"calendar_event_id": instanceID, "error": "no recording found in any associated meeting"})
}
}
}
successCount := 0
for _, r := range results {
m, _ := r.(map[string]any)
if m["error"] == nil {
successCount++
}
}
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", recordingLogPrefix, len(results), successCount, len(results)-successCount)
if successCount == 0 && len(results) > 0 {
outData := map[string]any{"recordings": results}
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, nil)
return output.ErrAPI(0, fmt.Sprintf("all %d queries failed", len(results)), nil)
}
outData := map[string]any{"recordings": results}
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
var rows []map[string]interface{}
for _, r := range results {
m, _ := r.(map[string]any)
meetingID, _ := m["meeting_id"].(string)
row := map[string]interface{}{}
if meetingID != "" {
row["meeting_id"] = meetingID
}
if calEventID, _ := m["calendar_event_id"].(string); calEventID != "" {
row["calendar_event_id"] = calEventID
}
if errMsg, _ := m["error"].(string); errMsg != "" {
row["status"] = "FAIL"
row["error"] = errMsg
} else {
row["status"] = "OK"
if v, _ := m["minute_token"].(string); v != "" {
row["minute_token"] = v
}
if v, _ := m["duration"].(string); v != "" {
row["duration"] = v
}
}
rows = append(rows, row)
}
output.PrintTable(w, rows)
fmt.Fprintf(w, "\n%d recording(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
})
return nil
},
}

View File

@@ -0,0 +1,774 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"fmt"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// ---------------------------------------------------------------------------
// Unit tests: extractMinuteToken
// ---------------------------------------------------------------------------
func TestExtractMinuteToken(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{"standard feishu URL", "https://meetings.feishu.cn/minutes/obcn37dxcftoc3656rgyejm7", "obcn37dxcftoc3656rgyejm7"},
{"larksuite URL", "https://meetings.larksuite.com/minutes/obcn12345678", "obcn12345678"},
{"trailing slash", "https://meetings.feishu.cn/minutes/obcntoken123/", "obcntoken123"},
{"with query params", "https://meetings.feishu.cn/minutes/obcntoken123?from=share", "obcntoken123"},
{"with fragment", "https://meetings.feishu.cn/minutes/obcntoken123#section", "obcntoken123"},
{"empty URL", "", ""},
{"no minutes path", "https://meetings.feishu.cn/other/path", ""},
{"only domain", "https://meetings.feishu.cn", ""},
{"minutes at end with no token", "https://meetings.feishu.cn/minutes", ""},
{"minutes trailing slash only", "https://meetings.feishu.cn/minutes/", ""},
{"invalid URL", "://invalid", ""},
{"nested path after token", "https://meetings.feishu.cn/minutes/obcntoken123/extra/path", "obcntoken123"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractMinuteToken(tt.url)
if got != tt.want {
t.Errorf("extractMinuteToken(%q) = %q, want %q", tt.url, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// Validation tests
// ---------------------------------------------------------------------------
func TestRecording_Validation_ExactlyOne(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
// 没传任何 flag
err := mountAndRun(t, VCRecording, []string{"+recording", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for no flags")
}
// 两个 flag 都传了
err = mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m1", "--calendar-event-ids", "e1", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for two flags")
}
}
func TestRecording_BatchLimit_MeetingIDs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
ids := make([]string, 51)
for i := range ids {
ids[i] = fmt.Sprintf("m%d", i)
}
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected batch limit error")
}
if !strings.Contains(err.Error(), "too many IDs") {
t.Errorf("expected 'too many IDs' error, got: %v", err)
}
}
func TestRecording_BatchLimit_CalendarEventIDs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
ids := make([]string, 51)
for i := range ids {
ids[i] = fmt.Sprintf("e%d", i)
}
err := mountAndRun(t, VCRecording, []string{"+recording", "--calendar-event-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected batch limit error")
}
if !strings.Contains(err.Error(), "too many IDs") {
t.Errorf("expected 'too many IDs' error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// DryRun tests
// ---------------------------------------------------------------------------
func TestRecording_DryRun_MeetingIDs(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "recording") {
t.Errorf("dry-run should show recording API, got: %s", out)
}
if !strings.Contains(out, "minute_token") {
t.Errorf("dry-run should mention minute_token, got: %s", out)
}
}
func TestRecording_DryRun_CalendarEventIDs(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCRecording, []string{"+recording", "--calendar-event-ids", "evt001", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "mget_instance_relation_info") {
t.Errorf("dry-run should show mget step, got: %s", out)
}
if !strings.Contains(out, "recording") {
t.Errorf("dry-run should show recording step, got: %s", out)
}
}
func TestRecording_DryRun_BatchIDs(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001,m002,m003", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "m001") || !strings.Contains(out, "m002") || !strings.Contains(out, "m003") {
t.Errorf("dry-run should list all meeting IDs, got: %s", out)
}
}
// ---------------------------------------------------------------------------
// Unit tests: fetchRecordingByMeetingID via bot shortcut wrapper
// ---------------------------------------------------------------------------
func TestFetchRecordingByMeetingID_Success(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m001/recording",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"recording": map[string]interface{}{
"url": "https://meetings.feishu.cn/minutes/obcntoken123",
"duration": "30000",
},
},
},
})
s := common.Shortcut{
Service: "test",
Command: "+fetch-recording",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchRecordingByMeetingID(context.Background(), rctx, "m001")
if result["error"] != nil {
t.Errorf("unexpected error: %v", result["error"])
}
if result["minute_token"] != "obcntoken123" {
t.Errorf("minute_token = %v, want obcntoken123", result["minute_token"])
}
if result["duration"] != "30000" {
t.Errorf("duration = %v, want 30000", result["duration"])
}
if result["meeting_id"] != "m001" {
t.Errorf("meeting_id = %v, want m001", result["meeting_id"])
}
return nil
},
}
parent := &cobra.Command{Use: "vc"}
s.Mount(parent, f)
parent.SetArgs([]string{"+fetch-recording"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchRecordingByMeetingID_NoRecording(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m002/recording",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
})
s := common.Shortcut{
Service: "test",
Command: "+fetch-no-recording",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchRecordingByMeetingID(context.Background(), rctx, "m002")
errMsg, _ := result["error"].(string)
if errMsg == "" {
t.Error("expected error for missing recording")
}
if !strings.Contains(errMsg, "no recording") {
t.Errorf("error should mention no recording, got: %s", errMsg)
}
return nil
},
}
parent := &cobra.Command{Use: "vc"}
s.Mount(parent, f)
parent.SetArgs([]string{"+fetch-no-recording"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchRecordingByMeetingID_APIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m003/recording",
Body: map[string]interface{}{
"code": 121004, "msg": "data not found",
},
})
s := common.Shortcut{
Service: "test",
Command: "+fetch-api-error",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchRecordingByMeetingID(context.Background(), rctx, "m003")
errMsg, _ := result["error"].(string)
if errMsg == "" {
t.Error("expected error for API failure")
}
if !strings.Contains(errMsg, "failed to query recording") {
t.Errorf("error should mention query failure, got: %s", errMsg)
}
return nil
},
}
parent := &cobra.Command{Use: "vc"}
s.Mount(parent, f)
parent.SetArgs([]string{"+fetch-api-error"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFetchRecordingByMeetingID_URLWithoutMinuteToken(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m004/recording",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"recording": map[string]interface{}{
"url": "https://example.com/some/other/path",
"duration": "5000",
},
},
},
})
s := common.Shortcut{
Service: "test",
Command: "+fetch-no-token",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchRecordingByMeetingID(context.Background(), rctx, "m004")
if result["error"] != nil {
t.Errorf("should not error even without minute_token: %v", result["error"])
}
if _, exists := result["minute_token"]; exists {
t.Error("should not have minute_token for non-standard URL")
}
if result["recording_url"] != "https://example.com/some/other/path" {
t.Errorf("recording_url = %v, want the original URL", result["recording_url"])
}
return nil
},
}
parent := &cobra.Command{Use: "vc"}
s.Mount(parent, f)
parent.SetArgs([]string{"+fetch-no-token"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// Unit tests: resolveMeetingIDsFromCalendarEvent
// ---------------------------------------------------------------------------
func TestResolveMeetingIDs_TypeCoercion(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"instance_relation_infos": []interface{}{
map[string]interface{}{
"meeting_instance_ids": []interface{}{
float64(12345678),
"string_id",
nil,
},
},
},
},
},
})
s := common.Shortcut{
Service: "test",
Command: "+resolve-test",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
ids, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001")
if err != nil {
return err
}
if len(ids) != 2 {
t.Errorf("expected 2 IDs (nil skipped), got %d: %v", len(ids), ids)
}
if len(ids) > 0 && ids[0] != "12345678" {
t.Errorf("expected float64 coerced to string, got %q", ids[0])
}
if len(ids) > 1 && ids[1] != "string_id" {
t.Errorf("expected string preserved, got %q", ids[1])
}
return nil
},
}
parent := &cobra.Command{Use: "vc"}
s.Mount(parent, f)
parent.SetArgs([]string{"+resolve-test"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestResolveMeetingIDs_NoMeetings(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"instance_relation_infos": []interface{}{
map[string]interface{}{
"meeting_instance_ids": []interface{}{},
},
},
},
},
})
s := common.Shortcut{
Service: "test",
Command: "+resolve-no-meetings",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
_, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001")
if err == nil {
t.Error("expected error for no meetings")
}
if !strings.Contains(err.Error(), "no associated video meeting") {
t.Errorf("error should mention no meeting, got: %v", err)
}
return nil
},
}
parent := &cobra.Command{Use: "vc"}
s.Mount(parent, f)
parent.SetArgs([]string{"+resolve-no-meetings"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestResolveMeetingIDs_NoRelationInfo(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"instance_relation_infos": []interface{}{},
},
},
})
s := common.Shortcut{
Service: "test",
Command: "+resolve-no-info",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
_, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001")
if err == nil {
t.Error("expected error for no relation info")
}
if !strings.Contains(err.Error(), "no event relation info found") {
t.Errorf("error should mention no info, got: %v", err)
}
return nil
},
}
parent := &cobra.Command{Use: "vc"}
s.Mount(parent, f)
parent.SetArgs([]string{"+resolve-no-info"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// Integration tests: Execute path via bot wrapper
// ---------------------------------------------------------------------------
// botExec runs a function within a bot shortcut context, reusing the httpmock registry.
func botExec(t *testing.T, name string, f *cmdutil.Factory, fn func(context.Context, *common.RuntimeContext) error) error {
t.Helper()
warmTokenCache(t)
s := common.Shortcut{
Service: "test",
Command: "+" + name,
AuthTypes: []string{"bot"},
HasFormat: true,
Execute: fn,
}
parent := &cobra.Command{Use: "vc"}
s.Mount(parent, f)
parent.SetArgs([]string{"+" + name, "--format", "json"})
parent.SilenceErrors = true
parent.SilenceUsage = true
return parent.Execute()
}
func TestRecording_Execute_MeetingIDs_PartialFailure(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
// m001 succeeds, m002 fails (API error)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m001/recording",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"recording": map[string]interface{}{
"url": "https://meetings.feishu.cn/minutes/obcnpartial1",
"duration": "10000",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m002/recording",
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
})
err := botExec(t, "partial-fail", f, func(ctx context.Context, rctx *common.RuntimeContext) error {
r1 := fetchRecordingByMeetingID(ctx, rctx, "m001")
r2 := fetchRecordingByMeetingID(ctx, rctx, "m002")
if r1["error"] != nil {
t.Errorf("m001 should succeed, got error: %v", r1["error"])
}
if r1["minute_token"] != "obcnpartial1" {
t.Errorf("m001 minute_token = %v, want obcnpartial1", r1["minute_token"])
}
if r2["error"] == nil {
t.Error("m002 should fail")
}
// verify counting logic
results := []any{r1, r2}
successCount := 0
for _, r := range results {
m, _ := r.(map[string]any)
if m["error"] == nil {
successCount++
}
}
if successCount != 1 {
t.Errorf("expected 1 success, got %d", successCount)
}
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRecording_Execute_CalendarPath_ResolveAndFetch(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"instance_relation_infos": []interface{}{
map[string]interface{}{
"meeting_instance_ids": []interface{}{"m001"},
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m001/recording",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"recording": map[string]interface{}{
"url": "https://meetings.feishu.cn/minutes/obcnfromcal",
"duration": "60000",
},
},
},
})
err := botExec(t, "cal-resolve", f, func(ctx context.Context, rctx *common.RuntimeContext) error {
ids, resolveErr := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001")
if resolveErr != nil {
t.Fatalf("resolve failed: %v", resolveErr)
}
if len(ids) != 1 || ids[0] != "m001" {
t.Fatalf("expected [m001], got %v", ids)
}
result := fetchRecordingByMeetingID(ctx, rctx, ids[0])
if result["error"] != nil {
t.Errorf("fetch should succeed, got: %v", result["error"])
}
if result["minute_token"] != "obcnfromcal" {
t.Errorf("minute_token = %v, want obcnfromcal", result["minute_token"])
}
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRecording_Execute_CalendarPath_MultiMeetingFallback(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
// calendar resolve returns two meetings: m001 (no recording) and m002 (has recording)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"instance_relation_infos": []interface{}{
map[string]interface{}{
"meeting_instance_ids": []interface{}{"m001", "m002"},
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m001/recording",
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m002/recording",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"recording": map[string]interface{}{
"url": "https://meetings.feishu.cn/minutes/obcnfallback",
"duration": "45000",
},
},
},
})
err := botExec(t, "cal-fallback", f, func(ctx context.Context, rctx *common.RuntimeContext) error {
ids, resolveErr := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001")
if resolveErr != nil {
t.Fatalf("resolve failed: %v", resolveErr)
}
if len(ids) != 2 {
t.Fatalf("expected 2 meeting IDs, got %d", len(ids))
}
// simulate fallback: try each until success
var found bool
for _, meetingID := range ids {
result := fetchRecordingByMeetingID(ctx, rctx, meetingID)
if result["error"] == nil {
if result["minute_token"] != "obcnfallback" {
t.Errorf("minute_token = %v, want obcnfallback", result["minute_token"])
}
found = true
break
}
}
if !found {
t.Error("expected fallback to succeed on m002")
}
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRecording_Execute_AllFailed_ErrorMessage(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m001/recording",
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m002/recording",
Body: map[string]interface{}{"code": 121005, "msg": "no permission"},
})
err := botExec(t, "all-fail", f, func(ctx context.Context, rctx *common.RuntimeContext) error {
r1 := fetchRecordingByMeetingID(ctx, rctx, "m001")
r2 := fetchRecordingByMeetingID(ctx, rctx, "m002")
if r1["error"] == nil || r2["error"] == nil {
t.Error("both should fail")
}
e1, _ := r1["error"].(string)
e2, _ := r2["error"].(string)
if !strings.Contains(e1, "data not found") {
t.Errorf("m001 error should contain API message, got: %s", e1)
}
if !strings.Contains(e2, "no permission") {
t.Errorf("m002 error should contain API message, got: %s", e2)
}
if r1["meeting_id"] != "m001" {
t.Errorf("error result should preserve meeting_id, got: %v", r1["meeting_id"])
}
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRecording_Execute_EmptyURL(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m001/recording",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"recording": map[string]interface{}{
"url": "",
"duration": "1000",
},
},
},
})
err := botExec(t, "empty-url", f, func(ctx context.Context, rctx *common.RuntimeContext) error {
result := fetchRecordingByMeetingID(ctx, rctx, "m001")
if result["error"] != nil {
t.Errorf("empty URL should not cause error: %v", result["error"])
}
if _, exists := result["minute_token"]; exists {
t.Error("empty URL should not produce minute_token")
}
if _, exists := result["recording_url"]; exists {
t.Error("empty URL should not produce recording_url")
}
if result["duration"] != "1000" {
t.Errorf("duration should be preserved, got: %v", result["duration"])
}
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRecording_Execute_RecordingGenerating(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m001/recording",
Body: map[string]interface{}{"code": 124002, "msg": "recording generating"},
})
err := botExec(t, "generating", f, func(ctx context.Context, rctx *common.RuntimeContext) error {
result := fetchRecordingByMeetingID(ctx, rctx, "m001")
errMsg, _ := result["error"].(string)
if errMsg == "" {
t.Error("should return error for generating recording")
}
if !strings.Contains(errMsg, "recording generating") {
t.Errorf("error should mention recording generating, got: %s", errMsg)
}
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -30,7 +30,7 @@ lark-cli docs +media-insert --doc doxcnXXX --file ./arch.png --align center --ca
| 参数 | 必填 | 说明 |
|------|------|------|
| `--doc <id>` | 是 | 文档 ID 或 docx URL仅支持 `/docx/<document_id>` 形式自动提取;**不支持 `/wiki/...` URL 自动提取** |
| `--file <path>` | 是 | 本地文件路径(最大 20MB |
| `--file <path>` | 是 | 本地文件路径(文件大于 20MB 时自动切换分片上传 |
| `--type <type>` | 否 | `image`(默认)或 `file` |
| `--align <align>` | 否 | 仅图片:`left` / `center`(默认)/ `right` |
| `--caption <text>` | 否 | 仅图片:图片描述 |

View File

@@ -27,8 +27,8 @@ lark-cli mail +draft-create --to alice@example.com --subject '周报' \
# 不带收件人的 HTML 草稿(用户之后可自行添加)
lark-cli mail +draft-create --subject '周报' --body '<p>草稿内容</p>'
# 带附件和内嵌图片的 HTML 草稿(CID 为唯一标识符,可用随机十六进制字符串
lark-cli mail +draft-create --to alice@example.com --subject '预览图' --body '<img src="cid:a1b2c3d4e5f6a7b8c9d0">' --attach ./report.pdf --inline '[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'
# 带附件和内嵌图片的 HTML 草稿(推荐:直接用相对路径,自动解析
lark-cli mail +draft-create --to alice@example.com --subject '预览图' --body '<p>见附件和图:<img src="./logo.png" /></p>' --attach ./report.pdf
# 纯文本草稿(仅在内容极简时使用)
lark-cli mail +draft-create --to alice@example.com --subject '简短通知' --body '收到,谢谢'
@@ -43,13 +43,13 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te
|------|------|------|
| `--to <emails>` | 否 | 完整收件人列表,多个用逗号分隔。支持 `Alice <alice@example.com>` 格式。省略时草稿不带收件人(之后可通过 `+draft-edit` 添加) |
| `--subject <text>` | 是 | 草稿主题 |
| `--body <text>` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式 |
| `--body <text>` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
| `--from <email>` | 否 | 发件人邮箱地址(作为邮箱选择器)。省略时使用当前登录用户的主邮箱地址 |
| `--cc <emails>` | 否 | 完整抄送列表,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 完整密送列表,多个用逗号分隔 |
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--attach <paths>` | 否 | 普通附件文件路径,多个用逗号分隔。相对路径 |
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `<img src="cid:a1b2c3d4e5f6a7b8c9d0">`用 |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--format <mode>` | 否 | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` |
| `--dry-run` | 否 | 仅打印请求,不执行 |
@@ -83,8 +83,16 @@ lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_
### 创建带内嵌图片的 HTML 草稿
> **推荐方式:** 直接在 `--body` HTML 中使用 `<img src="./logo.png" />`(相对路径),系统会自动创建内嵌 MIME 部分并替换为 `cid:` 引用。仅支持相对路径(如 `./logo.png`),不支持绝对路径(如 `/tmp/logo.png`)。
```bash
# CID 为唯一标识符,可用随机十六进制字符串
# 推荐:直接使用相对路径,自动解析为内嵌图片
lark-cli mail +draft-create \
--to alice@example.com \
--subject '通讯稿' \
--body '<h1>你好</h1><img src="./banner.png" />'
# 高级用法:手动指定 CIDCID 为唯一标识符,可用随机十六进制字符串)
lark-cli mail +draft-create \
--to alice@example.com \
--subject '通讯稿' \

View File

@@ -198,9 +198,9 @@ lark-cli mail +draft-edit --draft-id <draft_id> --inspect
{ "op": "add_inline", "path": "./logo.png", "cid": "logo" }
```
> **重要:`add_inline` 仅添加 MIME 二进制部分,不会在 HTML 正文中插入 `<img>` 标签。**
> 如需图片在邮件正文中可见,**必须**同时使用 `set_body` 或 `set_reply_body` 更新 HTML 正文并加入 `<img src="cid:...">` 标签。参见[在正文中插入内嵌图片](#在正文中插入内嵌图片)的完整流程。
> 如果忘记添加 `<img>` 引用,该内嵌部分在发送时会变成孤立附件
> **推荐方式:** 直接在 `set_body`/`set_reply_body` 的 HTML 中使用 `<img src="./logo.png" />`(相对路径),系统会自动创建 MIME 内嵌部分、生成 CID 并替换为 `cid:` 引用。仅支持相对路径(如 `./logo.png`),不支持绝对路径。删除或替换 `<img>` 标签时,对应的 MIME 部分会自动清理。详见[在正文中插入内嵌图片](#在正文中插入内嵌图片)。
>
> `add_inline` 仅在需要精确控制 CID 命名时使用。使用时仍需在 HTML 正文中加入 `<img src="cid:...">` 引用
`replace_inline`
@@ -304,23 +304,18 @@ lark-cli mail +draft-edit --draft-id <draft_id> --patch-file ./patch.json
### 在正文中插入内嵌图片
添加内嵌图片需要**两个协同编辑**1通过 `add_inline` 添加 MIME 部分2通过 `set_body``set_reply_body` HTML 正文中插入 `<img src="cid:...">` 标签
直接在 `set_body`/`set_reply_body` HTML 中使用相对路径即可(如 `./logo.png`,不支持绝对路径)。系统会自动创建 MIME 内嵌部分并替换为 `cid:` 引用
```bash
# 1. 查看草稿以获取当前 HTML 正文和已有的内嵌部分
# 1. 查看草稿以获取当前 HTML 正文
lark-cli mail +draft-edit --draft-id <draft_id> --inspect
# 返回包含:
# projection.body_html_summary: "<div>原有内容<img src=\"cid:existing.png\" /></div>"
# projection.inline_summary: [{"part_id":"1.1.2","cid":"existing.png", ...}]
# 2. 编写补丁(注意:回复草稿用 set_reply_body普通草稿用 set_body
# 2. 编写补丁 — 直接使用相对路径(注意:回复草稿用 set_reply_body普通草稿用 set_body
cat > ./patch.json << 'EOF'
{
"ops": [
{ "op": "set_body", "value": "<div>原有内容<img src=\"cid:existing.png\" /><img src=\"cid:new-image\" /></div>" },
{ "op": "add_inline", "path": "./new-image.png", "cid": "new-image" }
],
"options": {}
{ "op": "set_body", "value": "<div>内容<img src=\"./logo.png\" /><img src=\"./photo.jpg\" /></div>" }
]
}
EOF
@@ -328,6 +323,13 @@ EOF
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file ./patch.json
```
内嵌图片的增删改通过 HTML 正文自动联动:
- **添加**:在 HTML 中写 `<img src="./image.png" />`,自动创建 MIME 部分
- **删除**:从 HTML 中移除 `<img>` 标签,对应 MIME 部分自动清理
- **替换**:将 `src` 改为新的相对路径,旧 MIME 部分自动移除、新部分自动创建
> **高级用法:** 需要精确控制 CID 命名时,仍可使用 `add_inline` 手动添加 MIME 部分,并在 HTML 中用 `<img src="cid:your-cid">` 引用。
### 使用 patch-file 进行高级编辑
```bash

View File

@@ -39,8 +39,8 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --body '<p
# 转发并附加说明 + 抄送(草稿)
lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --cc bob@example.com --body '<b>请参考</b>'
# 转发时插入内嵌图片(CID 为唯一标识符,可用随机字符串
lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --body '<img src="cid:a1b2c3d4e5f6a7b8c9d0"> 详见图示。' --inline '[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'
# 转发时插入内嵌图片(推荐:直接用相对路径,自动解析
lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --body '<p>详见图示:<img src="./logo.png" /></p>'
# 纯文本转发(仅在内容极简时使用)
lark-cli mail +forward --message-id <邮件ID> --to alice@example.com
@@ -58,13 +58,13 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run
|------|------|------|
| `--message-id <id>` | 是 | 被转发的邮件 ID |
| `--to <emails>` | 是 | 收件人邮箱,多个用逗号分隔 |
| `--body <text>` | 否 | 转发时附加的说明文字。推荐使用 HTML 获得富文本排版;也支持纯文本。根据转发正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式 |
| `--body <text>` | 否 | 转发时附加的说明文字。推荐使用 HTML 获得富文本排版;也支持纯文本。根据转发正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
| `--from <email>` | 否 | 发件人邮箱地址(默认读取 user_mailboxes.profile.primary_email_address |
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔 |
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径 |
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `<img src="cid:...">` 引用 |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--confirm-send` | 否 | 确认发送转发(默认只保存草稿)。仅在用户明确确认后使用 |
| `--dry-run` | 否 | 仅打印请求,不执行 |

View File

@@ -42,8 +42,8 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '<p>同步更新</p>' --
# 从回复名单中排除某些地址(草稿)
lark-cli mail +reply-all --message-id <邮件ID> --body '<p>见上</p>' --remove bot@example.com,noreply@example.com
# 回复全部时插入内嵌图片(CID 为唯一标识符,可用随机字符串
lark-cli mail +reply-all --message-id <邮件ID> --body '<img src="cid:a1b2c3d4e5f6a7b8c9d0"> 详见图示。' --inline '[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'
# 回复全部时插入内嵌图片(推荐:直接用相对路径,自动解析
lark-cli mail +reply-all --message-id <邮件ID> --body '<p>详见图示:<img src="./logo.png" /></p>'
# 纯文本回复全部(仅在内容极简时使用)
lark-cli mail +reply-all --message-id <邮件ID> --body '收到,已处理。'
@@ -60,7 +60,7 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run
| 参数 | 必填 | 说明 |
|------|------|------|
| `--message-id <id>` | 是 | 被回复的邮件 ID |
| `--body <text>` | 是 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式 |
| `--body <text>` | 是 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
| `--from <email>` | 否 | 发件人邮箱地址(默认读取 user_mailboxes.profile.primary_email_address |
| `--to <emails>` | 否 | 额外收件人,多个用逗号分隔(追加到自动聚合结果) |
| `--cc <emails>` | 否 | 额外抄送,多个用逗号分隔 |
@@ -68,7 +68,7 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run
| `--remove <emails>` | 否 | 从自动聚合结果中排除的邮箱,多个用逗号分隔 |
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径 |
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `<img src="cid:...">` 引用 |
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
| `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 |
| `--dry-run` | 否 | 仅打印请求,不执行 |

Some files were not shown because too many files have changed in this diff Show More