Compare commits

..

35 Commits

Author SHA1 Message Date
liangshuo-1
03de81c5f3 chore: release v1.0.78 (#2061) 2026-07-27 19:17:53 +08:00
yballul-bytedance
7abcaa7f68 feat(drive): add title+body joint search guidance and Top N pagination rules (#2059)
* feat(drive): add title+body joint search guidance and pagination rules for Top N results

- Add new blockquote explaining combined title+body search: use a single
  --query with both keywords instead of splitting into two searches
- Add rule for Top N results: N is an output cap, not --page-size; scan
  up to 3 pages filtering by title and summary_highlighted, read body
  only for title-matched candidates, stop early at N confirmed results
- Add quick-reference table row for folder-scoped title+body search
- Update pagination strategy rule to cover the 3-page cap for joint
  search in addition to the existing 5-page limit for other scenarios

* feat(drive): clarify Top N search output limit

* feat(drive): clarify search filters share one call

---------

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-27 17:19:48 +08:00
zhangjun-bytedance
8fb2476985 0727 fix rich text (#2062) 2026-07-27 16:17:08 +08:00
zhanghuanxu
56c9a2afd8 fix: exempt ghost text from slides lint 2026-07-27 11:59:04 +08:00
zhanghuanxu
2029189809 fix(slides):text may over flow shape 2026-07-27 11:59:04 +08:00
zhanghuanxu
ee427979a8 fix(slides): preserve info lint severity 2026-07-27 11:59:04 +08:00
zhanghuanxu
545abcbbde fix: refine character width estimation for lark-slides text lint
Replace the uniform 0.55em half-width coefficient with per-character-type
coefficients, add font-family awareness (sans/serif), bold multiplier,
letter-spacing support, and fix padding-aware line wrapping.

- Split half-width chars into uppercase (0.57), lowercase (0.51 sans / 0.53
  serif), digits (0.58), and punctuation (0.50)
- Add classify_font_family() to apply slightly wider lowercase widths for
  serif fonts (Georgia, Source Han Serif/思源宋体, Times, etc.)
- Add 5% width multiplier for bold text; detect <strong>/<b>/<i>/<em> tags
  and span-level bold/italic attributes in addition to content attrs
- Fix estimate_text_line_count_for_text to subtract paddingLeft/paddingRight
  from available width before computing wrap lines
- Add resolve_letter_spacing and wire letterSpacing through estimate_text_width
- Extract fontFamily/bold/italic/letterSpacing into element dict during parse
2026-07-27 11:59:04 +08:00
zhanghuanxu
4a73e83f1e fix(slides): allow chartParsedValues roundtrip tag
chartParsedValues is a server-injected roundtrip child tag under
chartField, not an attribute. Move it from ROUNDTRIP_SXSD_ATTRS to a
new ROUNDTRIP_SXSD_TAGS set and skip the tag (and its subtree) in the
SXSD tag whitelist check.
2026-07-27 11:59:04 +08:00
zhanghuanxu
7496420fa8 fix(slides): downgrade background-decoration text overflow to info
Large low-alpha text underneath other text shapes is typically a
background design element; treat text_may_overflow_shape as info in
that case instead of warning/error.
2026-07-27 11:59:04 +08:00
zhanghuanxu
43fabdf524 fix(slides): detect letterSpacing-driven text overflow
Extract letterSpacing from content/paragraph attrs and factor it into
width and line-count estimates, and stop short-circuiting the shape
overflow check for autoFit shapes so that letterSpacing-heavy captions
under normal-auto-fit no longer escape detection.
2026-07-27 11:59:04 +08:00
zhanghuanxu
8c46c74105 fix(slides): upgrade text overflow to error above 10px threshold
Text-shape overflow was always reported as a warning, which let clearly
broken pages pass the lint gate. Overflow > 10px now upgrades to error;
smaller overflows stay as warning to avoid flagging near-fit cases.
2026-07-27 11:59:04 +08:00
zhanghuanxu
70777c86c3 fix(slides): restrict canvas overflow checks 2026-07-27 11:59:04 +08:00
zhangjun-bytedance
38e8806d91 feat: event description support rich text (#1975)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:48:01 +08:00
liangshuo-1
a7865cd0a7 chore: release v1.0.77 (#2051) 2026-07-24 19:20:52 +08:00
BD-ZERO
f77b7eea68 fix(slides): support CSV multi-value for --slide-id in screenshot (#2047)
--slide-id used the cobra StringArray flag type, which only accepts
repeated flags and does not split comma-separated values, unlike
--slide-number (int_array -> cobra IntSlice) which already supported
CSV input. This made the two selector flags inconsistent.

Switch --slide-id to the string_slice flag type (cobra StringSlice),
which natively supports both comma-separated and repeated values, and
update the flag readers from StrArray to StrSlice. normalizeSlideIDs
already trims/dedupes/filters blanks, and
validateSlidesScreenshotSelectorLimit already caps the combined
selector count, so both continue to apply unchanged to CSV input.

Add tests covering --slide-id CSV parsing, whitespace/duplicate
normalization, and the >10 selector limit via CSV, mirroring the
existing --slide-number coverage.

Address review feedback:
- Fix "comma-separate" -> "comma-separated" wording in the --slide-id
  flag description (CodeRabbit).
- Set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() in the new screenshot
  tests, per the AGENTS.md testing convention, so local configuration
  state cannot leak into or be modified by the suite.
- Add a dry-run E2E test (tests/cli_e2e/slides) that pins --slide-id
  CSV parsing through the built CLI binary and asserts the emitted
  slide_ids request body, per the AGENTS.md dry-run E2E requirement
  for shortcut flag/param changes.
- Update the lark-slides skill reference to document that --slide-id
  and --slide-number both accept comma-separated values, not just
  repeated flags, so agents can discover the new syntax.
2026-07-24 18:32:36 +08:00
fangshuyu-768
dd7f741b62 docs(skills): clarify callout child rules (#2048) 2026-07-24 18:18:32 +08:00
kiraWangRuilong
e7d5ecdd01 feat: add risk-control protection (#1910)
1. Add baseline safe protection for Feishu/Lark API endpoints.
2. Add lark-cli config risk-control on|off|default command for workspace-level safety protection control.
2026-07-24 17:12:10 +08:00
zhanghuanxu
4807283368 fix(slides): declare screenshot scope 2026-07-24 15:25:11 +08:00
ILUO
d2bb36591f fix/task search pagination (#2041)
* fix: send task search page token in query

* test: assert task search dry-run pagination contract
2026-07-24 14:28:54 +08:00
yballul-bytedance
5a54bc07db fix(base): classify +form-submit as high-risk-write (#1969)
Form submission writes and submits data through a public share link, an
irreversible action that should require explicit confirmation. Reclassify
the shortcut from write to high-risk-write so the runner's --yes gate fires
before execution, matching +form-delete and other high-risk base commands.

Update the lark-base skill docs (--yes on all examples, param table, tips)
and add tests pinning the confirmation gate (unit) and dry-run structure (e2e).

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-24 11:11:37 +08:00
BD-ZERO
a528b3cb69 feat(slides): add layout density lint for sparse/empty containers (#2022)
feat(slides): add layout density lint for sparse/empty containers

Extend the XML layout lint into a single release gate for Slides XML:

- Add blank_slide, sparse_container_content, and sparse_slide_content
  detection, using visibility- and coverage-aware heuristics (alpha
  filtering, image-overlay/layout-panel exemptions, similar-short-card
  grouping) to avoid flagging intentional whitespace or background
  panels
- Broaden out-of-canvas detection from table/chart/text-only to every
  element kind, with rotation-aware bounding boxes and geometry
  extraction for icon/line/polyline
- Restructure output to schema v2.0: every issue carries rule
  (id/name/comparison/threshold), measurement, related_objects, and
  hint; summary gains status/release_ready/screenshot_review_required
- Change CLI exit-code semantics so only errors block (exit 1);
  warning-only output still exits 0 to let downstream screenshot review
  proceed
- Harden XML attribute parsing (single/double-quoted and spaced
  attributes, self-closing tags no longer bleeding content into the
  next element) and fix edge cases surfaced during review
  (image-overlay coverage ratio, invisible container/panel exemptions,
  bbox_overlap measurement consistency, background-only slide bypass,
  invisible short-card peers)
- Update SKILL.md, validation-checklist.md, and troubleshooting.md to
  match the new gate; add regression tests for the new rules and fixes
2026-07-24 10:47:15 +08:00
huarenmin13
f0176af330 docs(base): clarify complete and partial updates (#1993)
* docs(base): clarify complete and partial updates

Consolidate the update rule introduced in #1879 and make the command-contract boundary explicit. Full-update commands must use trusted current configuration for the first actual request, while delta commands should send the smallest legal payload.

* docs(base): clarify full-update state preservation

Address review feedback by requiring unchanged writable configuration to remain intact, except when the requested update makes a setting inapplicable.

* docs(base): strengthen update contract guidance
2026-07-24 00:01:35 +08:00
R0bynZhu
715aa8d960 feat(slides): fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
From EVAL-07-22-02-53 (42 convos), agents fell back to the full XSD for:
- shape type enum + presetHandlers (rounded corners)
- polyline (bounding-box positioning, required border, connector type)
- table merged cells (colspan / rowspan)

Add compact coverage for each, sized to real usage (shape/polyline type
lists trimmed to what actually appears in generations). Chart gaps deferred.
2026-07-23 22:18:17 +08:00
ILUO
ebc0c53ab5 fix/task id handling (#2023)
* fix: validate task GUID inputs

* fix: make task updates self-confirming

* fix: confirm task completion state

* docs: clarify task ID workflow

* test: cover task ID dry runs

* fix: address task ID review feedback
2026-07-23 20:48:38 +08:00
fangshuyu-768
1e682bd97c fix(slides): normalize presentation flag aliases (#2032) 2026-07-23 18:43:30 +08:00
fangshuyu-768
70424c486c docs(skill): clarify scope handling for query expansion (#2030) 2026-07-23 18:35:44 +08:00
liangshuo-1
b8f56dbc0b feat(apps): support absolute and relative upload paths (#2005) 2026-07-23 17:52:49 +08:00
chenxingyang1019
c74d9b63fb feat(apps): validate +file-list --page-size against server (0, 200] range (#2007)
paas_storage AppFileListForOpenAPI rejects page_size > 200 at the inner
checkMaxKeys guard with ErrInvalidRequest("maxKeys not in range (0, 200]").
Previously the CLI forwarded any --page-size straight to the API, so
--page-size 500 produced an opaque server error round-trip.

Add a client-side Validate check bounding --page-size to [1, 200] (aligned
with the existing validateAppsPageSize precedent in the observability
commands): out-of-range values now fail fast with a typed validation error
and never hit the network. The server tolerates page_size <= 0 by defaulting
to 20, but the CLI default is already 20 and an explicit < 1 is a user error,
so we reject it for a clearer message, consistent with other list commands.

Update the flag description and the lark-apps-file skill reference to
document the 1..200 range, and cover the boundaries in unit tests.
2026-07-23 15:55:08 +08:00
91-enjoy
67015eef8e feat: introducing official card icon (#1973)
Card header icon documentation contained invalid tokens (e.g., mail_colorful, approve_colorful) that do not render, and icon guidance lacked precise token enumeration, causing LLM to guess or fabricate icon tokens. This PR replaces
examples with valid tokens and adds a definitive colorful icon reference table.
2026-07-23 11:01:14 +08:00
liangshuo-1
af8507ea8e chore: release v1.0.76 (#2016) 2026-07-22 23:36:33 +08:00
liangshuo-1
02c2ebcf7c chore: release v1.0.75 (#2014) 2026-07-22 22:29:15 +08:00
liangshuo-1
abf6f99d7e fix(slides): preserve raw XML output verbatim (#2013)
Keep --raw and file output byte-exact by returning the server response without XML reserialization.
2026-07-22 22:06:26 +08:00
tianyouskrrr
8ba910eb9f fix(slides): reindent xml-get output for readability (#1987)
The API always returns presentation/slide XML as a single unindented
line, which is unreadable for decks with many shapes (e.g. PPTX-imported
presentations). slides +xml-get now formats it on the surfaces meant for
a human or a line tool to read:

- --raw and --output reindent the XML with etree so each structural
  element (presentation/slide/shape/style/...) sits on its own line.
  Reformatting never recurses into schema-mixed text-bearing elements
  (p, span, strong, em, u, del, a, shadow, outline, chartTitle,
  chartSubTitle), so rich-text content stays exactly as parsed. CDATA
  sections and the schema's &#32;/&#9;/&#13;/&#10; whitespace character
  references (decimal, hex, and zero-padded) are preserved through the
  parse/write pass instead of being silently normalized away. There is
  no flag to disable this formatting.
- The default JSON envelope returns the server's XML verbatim: it is
  never parsed, so it stays a byte-exact copy of the API response, at
  no reformatting cost and with no failure mode on this path.
- If reformatting --raw/--output content fails (non-strict XML from the
  service), the command falls back to the original content, prints a
  warning to stderr, and reports pretty_printed: false in --output file
  metadata.

Adds github.com/beevik/etree as a direct dependency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 21:08:29 +08:00
zgz2048
78bf126bb0 docs(base): align record write schema guidance (#2000)
* docs(base): align record write schema guidance

* docs(base): use canonical select field naming

* docs(base): simplify select option guidance
2026-07-22 20:54:54 +08:00
guokexin.02
4eefe32c1a ci: harden npm release publishing (#1918) 2026-07-22 20:53:43 +08:00
170 changed files with 8298 additions and 3935 deletions

View File

@@ -9,7 +9,40 @@ permissions:
contents: read
jobs:
goreleaser:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate tag and commit
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
exit 1
fi
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
exit 1
fi
build-release:
needs: preflight
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -26,35 +59,79 @@ jobs:
with:
python-version: '3.x'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
- name: Include release checksums
run: |
set -euo pipefail
test -s dist/checksums.txt
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
if-no-files-found: error
overwrite: true
publish-npm:
needs: goreleaser
needs: build-release
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '20'
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
PACK_JSON="$(npm pack --ignore-scripts --json)"
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
test -s "$PACK_FILE"
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
rm "$PACK_FILE"
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

@@ -2,6 +2,83 @@
All notable changes to this project will be documented in this file.
## [v1.0.78] - 2026-07-27
### Features
- event description support rich text (#1975)
### Bug Fixes
- **slides**: restrict canvas overflow checks
- **slides**: upgrade text overflow to error above 10px threshold
- **slides**: detect letterSpacing-driven text overflow
- **slides**: downgrade background-decoration text overflow to info
- **slides**: allow chartParsedValues roundtrip tag
- refine character width estimation for lark-slides text lint
- **slides**: preserve info lint severity
- **slides**: text may over flow shape
- exempt ghost text from slides lint
## [v1.0.77] - 2026-07-24
### Features
- introducing official card icon (#1973)
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
- **apps**: support absolute and relative upload paths (#2005)
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
- **slides**: add layout density lint for sparse/empty containers (#2022)
- add risk-control protection (#1910)
### Bug Fixes
- **slides**: normalize presentation flag aliases (#2032)
- **base**: classify +form-submit as high-risk-write (#1969)
- **slides**: declare screenshot scope
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
### Documentation
- **skill**: clarify scope handling for query expansion (#2030)
- **base**: clarify complete and partial updates (#1993)
- **skills**: clarify callout child rules (#2048)
### Misc
- fix/task id handling (#2023)
- fix/task search pagination (#2041)
## [v1.0.75] - 2026-07-22
### Features
- add okr single create shortcut & skill text opti (#1941)
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
### Bug Fixes
- **base**: improve table shortcut behavior & guidance (#1803)
- issue#1935 & whiteboard shortcut reformat (#1980)
- remove legacy shortcut (#1997)
- **e2e**: inject shared credentials by identity (#1995)
### Documentation
- **skill**: describe html5 block xml usage (#1380)
- clarify fetch metadata and user cites (#1981)
- add topic move collector workflow (#1473)
- update lark doc HTML size limit (#2001)
- **base**: align record write schema guidance (#2000)
### Tests
- **e2e**: declare request identities explicitly (#2004)
### Misc
- harden npm release publishing (#1918)
## [v1.0.74] - 2026-07-21
### Features
@@ -1608,6 +1685,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72

View File

@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

@@ -285,6 +285,29 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
lark-cli config risk-control default
```
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History

View File

@@ -286,6 +286,29 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
为降低访问令牌被盗用后的安全风险CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
- 操作系统类型macOS、Windows 或 Linux
- 设备的硬件产品型号:例如 Mac17,9
如需让当前 workspace 退出该保护,可执行以下命令:
```bash
lark-cli config risk-control off
```
如需开启当前 workspace 的保护,可执行以下命令:
```bash
lark-cli config risk-control on
```
恢复当前 workspace 默认策略可执行:
```bash
lark-cli config risk-control default
```
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History

View File

@@ -5,6 +5,8 @@ package api
import (
"context"
"fmt"
"io"
"regexp"
"strings"
@@ -232,15 +234,6 @@ func apiRun(opts *APIOptions) error {
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
)
}
// Parse before the dry-run branch so both dry-run and emit reject unknown
// values. Raw API responses accept four formats; pretty remains available
// only for the dry-run request preview handled below.
format, ok := output.ParseFormat(opts.Format)
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown output format %q (want json, ndjson, table, or csv)", opts.Format).
WithParam("--format")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
}
@@ -257,17 +250,9 @@ func apiRun(opts *APIOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts, format), *fileMeta)
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
}
return apiDryRun(f, request, config, opts, format)
}
// pretty is a shortcut-only presentation format; the raw api command has no
// pretty renderer for responses, so reject it before client init rather than
// fall back. (Dry-run keeps its own plain-text pretty preview, handled above.)
if format == output.FormatPretty {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--format pretty is not supported here (use json, ndjson, table, or csv)").
WithParam("--format")
return apiDryRun(f, request, config, opts)
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
@@ -278,20 +263,14 @@ func apiRun(opts *APIOptions) error {
}
out := f.IOStreams.Out
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
if opts.PageAll {
return client.PaginateToOutput(opts.Ctx, client.PaginateOutputOptions{
Client: ac,
Request: request,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
CommandPath: opts.Cmd.CommandPath(),
Pagination: client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay},
CheckErr: ac.CheckResponse,
MarkErr: errs.MarkRaw,
})
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
}
resp, err := ac.DoAPI(opts.Ctx, request)
@@ -325,13 +304,13 @@ func apiRun(opts *APIOptions) error {
return nil
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions, format output.Format) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts, format))
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
}
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions, format output.Format) cmdutil.DryRunOutputOptions {
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: format.String(),
Format: opts.Format,
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
@@ -339,3 +318,75 @@ func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions, format output.For
ErrOut: f.IOStreams.ErrOut,
}
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
return errs.MarkRaw(apiErr)
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -66,23 +66,6 @@ func apiPaginateRequest() client.RawApiRequest {
}
}
// apiPaginate adapts the positional test calls to PaginateToOutput's options
// struct so each test case stays a single readable statement.
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pag client.PaginationOptions, checkErr func(interface{}, core.Identity) error, markErr func(error) error) error {
return client.PaginateToOutput(ctx, client.PaginateOutputOptions{
Client: ac,
Request: request,
Format: format,
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Pagination: pag,
CheckErr: checkErr,
MarkErr: markErr,
})
}
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
@@ -129,10 +112,10 @@ func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse, errs.MarkRaw)
})
if err != nil {
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
@@ -212,10 +195,10 @@ func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse, errs.MarkRaw)
})
if err != nil {
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
@@ -256,14 +239,14 @@ func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
if !errors.Is(err, sentinel) {
t.Fatalf("PaginateToOutput() error = %v, want preserved writer cause", err)
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("PaginateToOutput() problem = %#v, %v; want internal typed error", problem, ok)
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
@@ -273,7 +256,7 @@ func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
}
}
func TestAPIPaginate_StreamingFormatHonorsNDJSONWithoutList(t *testing.T) {
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
@@ -288,17 +271,22 @@ func TestAPIPaginate_StreamingFormatHonorsNDJSONWithoutList(t *testing.T) {
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err != nil {
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
const want = "{\"name\":\"Test User\",\"user_id\":\"u123\"}\n"
if got := out.String(); got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
if errOut.Len() != 0 {
t.Fatalf("stderr bytes = %q, want empty", errOut.String())
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
}
}
@@ -326,10 +314,10 @@ func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("PaginateToOutput() error = nil, want business error")
t.Fatal("apiPaginate() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
@@ -361,10 +349,10 @@ func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("PaginateToOutput() error = nil, want transport error")
t.Fatal("apiPaginate() error = nil, want transport error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
@@ -391,10 +379,10 @@ func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("PaginateToOutput() error = nil, want business error")
t.Fatal("apiPaginate() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)

View File

@@ -118,101 +118,6 @@ func TestApiCmd_DryRunWithJq(t *testing.T) {
}
}
// An unknown --format is a typed validation error, not a silent JSON fallback —
// on both the emit path and (parsed before the dry-run branch) the dry-run path.
// No stub is registered because the command must fail before any API call.
func TestApiCmd_UnknownFormat_Rejected(t *testing.T) {
for _, extra := range [][]string{nil, {"--dry-run"}} {
name := "emit"
if len(extra) > 0 {
name = "dry-run"
}
t.Run(name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs(append([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "bogus"}, extra...))
err := cmd.Execute()
if err == nil {
t.Fatal("expected a validation error for unknown --format")
}
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "unknown output format") {
t.Errorf("error = %v, want unknown-format message", err)
}
if strings.Contains(strings.ToLower(err.Error()), "pretty") {
t.Errorf("error = %v, raw api format choices must exclude pretty", err)
}
if stdout.String() != "" {
t.Errorf("unknown --format must not write stdout, got:\n%s", stdout.String())
}
})
}
}
func TestApiCmd_UnknownFormatPrecedesJqConflict(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{
"GET", "/open-apis/test", "--as", "bot",
"--format", "tabel", "--jq", ".",
})
err := cmd.Execute()
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "unknown output format") {
t.Fatalf("error = %v, want unknown-format message", err)
}
if strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("error = %v, unknown format should be reported before jq conflict", err)
}
if stdout.Len() != 0 {
t.Fatalf("unknown --format wrote stdout:\n%s", stdout.String())
}
}
// pretty is shortcut-only: the raw api command rejects it on the emit path
// (before client init) but keeps the dry-run plain-text preview.
func TestApiCmd_Pretty_RejectedOnEmit(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "pretty"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected a validation error for --format pretty on the emit path")
}
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "pretty") {
t.Errorf("error = %v, want pretty-not-supported message", err)
}
if stdout.String() != "" {
t.Errorf("rejected --format pretty must not write stdout, got:\n%s", stdout.String())
}
}
func TestApiCmd_MixedCasePretty_PreservedOnDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "Pretty", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run --format pretty must be accepted, got: %v", err)
}
if !strings.Contains(stdout.String(), "# dry-run: request not sent") {
t.Fatalf("dry-run --format pretty lost its plain-text preview, stdout:\n%s", stdout.String())
}
}
// Regression: --params null parses to a nil map; writing page_size onto it must
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
// write into the map ParseJSONMap returns.
@@ -499,7 +404,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
}
}
func TestApiCmd_PageAll_NonBatchAPI_HonorsNDJSON(t *testing.T) {
func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
})
@@ -522,15 +427,24 @@ func TestApiCmd_PageAll_NonBatchAPI_HonorsNDJSON(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(stderr.String(), "falling back") {
t.Fatalf("stderr contains format fallback warning: %q", stderr.String())
// Should print fallback warning to stderr
if !strings.Contains(stderr.String(), "warning: this API does not return a list") {
t.Error("expected fallback warning in stderr")
}
if !strings.Contains(stderr.String(), "falling back to json") {
t.Error("expected 'falling back to json' in stderr")
}
// Should output JSON result to stdout
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid NDJSON object: %v\n%s", err, stdout.String())
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
if got["user_id"] != "u123" || got["name"] != "Test User" {
t.Fatalf("unexpected NDJSON object: %#v", got)
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok || data["user_id"] != "u123" {
t.Fatalf("unexpected fallback envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("fallback success envelope leaked outer code: %s", stdout.String())
}
}
@@ -707,10 +621,6 @@ func (p *apiContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest
return &extcs.Alert{Provider: "api-test", MatchedRules: []string{"pagination"}}, nil
}
func (p *apiContentSafetyProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
@@ -744,12 +654,12 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
data, ok := provider.data.(string)
data, ok := provider.data.(map[string]interface{})
if !ok {
t.Fatalf("scanned data type = %T, want rendered JSON string", provider.data)
t.Fatalf("scanned data type = %T, want map", provider.data)
}
if strings.Contains(data, `"code"`) || !strings.Contains(data, `"data"`) {
t.Fatalf("scanned JSON should be the success envelope without an API code, got %q", data)
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
}
var got map[string]interface{}
@@ -795,11 +705,9 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
// Streaming now scans the exact rendered page bytes (not the structured
// item) so a rule match formed only in the rendered output cannot slip past.
scanned, ok := provider.data.(string)
if !ok || !strings.Contains(scanned, `"id":"1"`) {
t.Fatalf("scanned data = %#v, want rendered ndjson page text", provider.data)
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from api-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
@@ -859,8 +767,11 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if out != "" {
t.Fatalf("blocked complete stream was written before safety block: %s", out)
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
}
}
@@ -875,18 +786,6 @@ func requireProblem(t *testing.T, err error, category errs.Category, subtype err
}
}
func requireValidationParam(t *testing.T, err error, param string) {
t.Helper()
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, 0)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Param != param {
t.Fatalf("Param = %q, want %q", validationErr.Param, param)
}
}
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
for _, tt := range []struct {
name string

View File

@@ -381,34 +381,6 @@ func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
}
}
func TestAuthScopesCmd_RejectsUnknownFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
runCalled := false
cmd := NewCmdAuthScopes(f, func(*ScopesOptions) error {
runCalled = true
return nil
})
cmd.SetArgs([]string{"--format", "tabel"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected invalid format error")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Category != errs.CategoryValidation || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--format" {
t.Fatalf("validation error = %#v; want validation/invalid_argument with --format", validationErr)
}
if runCalled {
t.Fatal("auth scopes runner was called for an invalid format")
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,

View File

@@ -6,8 +6,6 @@ package auth
import (
"context"
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
@@ -35,13 +33,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
} else {
opts.Format = strings.ToLower(strings.TrimSpace(opts.Format))
if opts.Format != "json" && opts.Format != "pretty" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown output format %q (want json or pretty)", opts.Format).
WithParam("--format")
}
}
if runF != nil {
return runF(opts)
@@ -83,36 +74,20 @@ func authScopesRun(opts *ScopesOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError,
"failed to get app scope info: %v", err).WithCause(err)
}
data := map[string]interface{}{
"appId": config.AppID,
"brand": config.Brand,
"tokenType": "user",
"userScopes": appInfo.UserScopes,
"count": len(appInfo.UserScopes),
}
emitter := output.NewEmitter(output.EmitterConfig{
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
CommandPath: "lark-cli auth scopes",
})
if opts.Format == "pretty" {
return emitter.Value(data, output.StreamOptions{
Format: output.FormatPretty,
Pretty: func(w io.Writer, _ bool) error {
if _, err := fmt.Fprintf(w, "App ID: %s\n", config.AppID); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes)); err != nil {
return err
}
for _, scope := range appInfo.UserScopes {
if _, err := fmt.Fprintf(w, " • %s\n", scope); err != nil {
return err
}
}
return nil
},
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
fmt.Fprintf(f.IOStreams.ErrOut, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
for _, s := range appInfo.UserScopes {
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n", s)
}
} else {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"appId": config.AppID,
"brand": config.Brand,
"tokenType": "user",
"userScopes": appInfo.UserScopes,
"count": len(appInfo.UserScopes),
})
}
return emitter.Value(data, output.StreamOptions{Format: output.FormatJSON})
return nil
}

View File

@@ -7,7 +7,6 @@ import (
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
@@ -44,36 +43,6 @@ func scopesTestFactory(t *testing.T) *ScopesOptions {
}
}
func TestAuthScopesRunPrettyWritesBusinessDataToStdout(t *testing.T) {
previous := getAppInfoFn
getAppInfoFn = func(context.Context, *cmdutil.Factory, string) (*appInfo, error) {
return &appInfo{UserScopes: []string{"im:message"}}, nil
}
t.Cleanup(func() { getAppInfoFn = previous })
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
})
err := authScopesRun(&ScopesOptions{
Factory: f,
Ctx: context.Background(),
Format: "pretty",
})
if err != nil {
t.Fatalf("authScopesRun() error = %v", err)
}
for _, want := range []string{"App ID: test-app", "Enabled scopes (1)", "im:message"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("stdout missing %q: %s", want, stdout.String())
}
if strings.Contains(stderr.String(), want) {
t.Fatalf("stderr contains business data %q: %s", want, stderr.String())
}
}
}
// TestAuthScopesRun_NetworkErrorPassedThrough pins that a typed NetworkError
// surfaced by the dependency is not re-classified as PermissionError —
// re-auth does not fix DNS / transport failures and blanket-wrapping them

View File

@@ -31,6 +31,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "risk-control [on|off|default]",
Short: "Manage workspace account-protection policy",
Long: `View or set the account-protection risk-control policy for this workspace.
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
// This is persistent workspace policy, not credential management.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
config, err := core.LoadOrNotConfigured()
if err != nil {
return err
}
if len(args) == 0 {
printRiskControl(f, config)
return nil
}
switch args[0] {
case "on":
enabled := true
config.RiskControl = &enabled
case "off":
enabled := false
config.RiskControl = &enabled
case "default":
config.RiskControl = nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := core.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
return nil
},
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"
}
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
}
func riskControlState(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
if !strings.Contains(stderr.String(), "set to off") {
t.Fatalf("stderr = %q", stderr.String())
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show: %v", err)
}
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
t.Fatalf("stdout = %q", got)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"on"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || !*loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"default"})
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl != nil {
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show default: %v", err)
}
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"invalid"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"risk-control", "off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
}

View File

@@ -6,6 +6,7 @@ package service
import (
"context"
"fmt"
"io"
"sort"
"strings"
@@ -379,15 +380,6 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if opts.PageAll && opts.Output != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --page-all are mutually exclusive").WithParam("--output")
}
// Parse before the dry-run branch so both dry-run and emit reject unknown
// values. Raw service responses accept four formats; pretty remains available
// only for the dry-run request preview handled below.
format, ok := output.ParseFormat(opts.Format)
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown output format %q (want json, ndjson, table, or csv)", opts.Format).
WithParam("--format")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
}
@@ -411,18 +403,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts, format), *fileMeta)
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts), *fileMeta)
}
return serviceDryRun(f, request, config, opts, format)
}
// pretty is a shortcut-only presentation format; the raw service command has
// no pretty renderer for responses, so reject it before the confirmation and
// client init rather than fall back. (Dry-run keeps its own plain-text pretty
// preview, handled above.)
if format == output.FormatPretty {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--format pretty is not supported here (use json, ndjson, table, or csv)").
WithParam("--format")
return serviceDryRun(f, request, config, opts)
}
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
@@ -437,6 +420,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
out := f.IOStreams.Out
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
// Scope-insufficient (99991679) and all other Lark API codes route through
// errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError
@@ -444,18 +431,8 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
checkErr := ac.CheckResponse
if opts.PageAll {
return client.PaginateToOutput(opts.Ctx, client.PaginateOutputOptions{
Client: ac,
Request: request,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
CommandPath: opts.Cmd.CommandPath(),
Pagination: client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay},
CheckErr: checkErr,
MarkErr: nil,
})
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
}
resp, err := ac.DoAPI(opts.Ctx, request)
@@ -690,13 +667,13 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
return request, nil, nil
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions, format output.Format) error {
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts, format))
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error {
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts))
}
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions, format output.Format) cmdutil.DryRunOutputOptions {
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: format.String(),
Format: opts.Format,
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
@@ -704,3 +681,75 @@ func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions,
ErrOut: f.IOStreams.ErrOut,
}
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
}, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
return apiErr
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -66,23 +66,6 @@ func servicePaginateRequest() client.RawApiRequest {
}
}
// servicePaginate adapts the positional test calls to PaginateToOutput's options
// struct so each test case stays a single readable statement.
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pag client.PaginationOptions, checkErr func(interface{}, core.Identity) error, markErr func(error) error) error {
return client.PaginateToOutput(ctx, client.PaginateOutputOptions{
Client: ac,
Request: request,
Format: format,
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Pagination: pag,
CheckErr: checkErr,
MarkErr: markErr,
})
}
func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
@@ -129,10 +112,10 @@ func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) {
output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse, nil)
}, ac.CheckResponse)
if err != nil {
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
@@ -212,10 +195,10 @@ func TestServicePaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tt.format, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse, nil)
}, ac.CheckResponse)
if err != nil {
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
@@ -256,14 +239,14 @@ func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse, nil)
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse)
if !errors.Is(err, sentinel) {
t.Fatalf("PaginateToOutput() error = %v, want preserved writer cause", err)
t.Fatalf("servicePaginate() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("PaginateToOutput() problem = %#v, %v; want internal typed error", problem, ok)
t.Fatalf("servicePaginate() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
@@ -273,7 +256,7 @@ func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
}
}
func TestServicePaginate_StreamingFormatHonorsNDJSONWithoutList(t *testing.T) {
func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
@@ -289,17 +272,22 @@ func TestServicePaginate_StreamingFormatHonorsNDJSONWithoutList(t *testing.T) {
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items get",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err != nil {
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
const want = "{\"name\":\"Test User\",\"user_id\":\"u123\"}\n"
if got := out.String(); got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
if errOut.Len() != 0 {
t.Fatalf("stderr bytes = %q, want empty", errOut.String())
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
}
}
@@ -328,10 +316,10 @@ func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) {
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("PaginateToOutput() error = nil, want business error")
t.Fatal("servicePaginate() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
@@ -364,10 +352,10 @@ func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) {
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("PaginateToOutput() error = nil, want transport error")
t.Fatal("servicePaginate() error = nil, want transport error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
@@ -395,10 +383,10 @@ func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) {
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("PaginateToOutput() error = nil, want business error")
t.Fatal("servicePaginate() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")

View File

@@ -257,21 +257,6 @@ func TestServiceMethod_DryRunWithJq(t *testing.T) {
}
}
func TestServiceMethod_DryRunMixedCasePrettyUsesPlainTextPreview(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--dry-run", "--format", "Pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run --format Pretty must be accepted, got: %v", err)
}
if !strings.Contains(stdout.String(), "# dry-run: request not sent") {
t.Fatalf("dry-run --format Pretty lost its plain-text preview, stdout:\n%s", stdout.String())
}
}
func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) {
tests := []struct {
name string
@@ -540,10 +525,6 @@ func (p *serviceContentSafetyProvider) Scan(_ context.Context, req extcs.ScanReq
return &extcs.Alert{Provider: "service-test", MatchedRules: []string{"pagination"}}, nil
}
func (p *serviceContentSafetyProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
@@ -580,12 +561,12 @@ func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
data, ok := provider.data.(string)
data, ok := provider.data.(map[string]interface{})
if !ok {
t.Fatalf("scanned data type = %T, want rendered JSON string", provider.data)
t.Fatalf("scanned data type = %T, want map", provider.data)
}
if strings.Contains(data, `"code"`) || !strings.Contains(data, `"data"`) {
t.Fatalf("scanned JSON should be the success envelope without an API code, got %q", data)
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
}
var got map[string]interface{}
@@ -634,11 +615,9 @@ func TestServiceMethod_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
// Streaming now scans the exact rendered page bytes (not the structured
// item) so a rule match formed only in the rendered output cannot slip past.
scanned, ok := provider.data.(string)
if !ok || !strings.Contains(scanned, `"id":"1"`) {
t.Fatalf("scanned data = %#v, want rendered ndjson page text", provider.data)
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from service-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
@@ -701,8 +680,11 @@ func TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if out != "" {
t.Fatalf("blocked complete stream was written before safety block: %s", out)
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
}
}
@@ -813,81 +795,26 @@ func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T)
}
}
func TestServiceMethod_UnknownFormat_Rejected(t *testing.T) {
// No stub is registered: the unknown --format must be rejected before any
// API call is made.
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
// An unknown --format is a typed validation error, not a silent JSON fallback.
err := cmd.Execute()
if err == nil {
t.Fatal("expected a validation error for unknown --format")
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "unknown output format") {
t.Errorf("error = %v, want unknown-format message", err)
}
if strings.Contains(strings.ToLower(err.Error()), "pretty") {
t.Errorf("error = %v, raw service format choices must exclude pretty", err)
}
if stdout.String() != "" {
t.Errorf("unknown --format must not write stdout, got:\n%s", stdout.String())
}
// The old degrade-to-JSON warning must be gone, not merely accompanied by an error.
if strings.Contains(stderr.String(), "falling back to json") {
t.Errorf("unknown --format must not emit the legacy fallback warning, got stderr:\n%s", stderr.String())
}
}
func TestServiceMethod_UnknownFormatPrecedesJqConflict(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{
"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{},
})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "tabel", "--jq", "."})
err := cmd.Execute()
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "unknown output format") {
t.Fatalf("error = %v, want unknown-format message", err)
}
if strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("error = %v, unknown format should be reported before jq conflict", err)
}
if stdout.Len() != 0 {
t.Fatalf("unknown --format wrote stdout:\n%s", stdout.String())
}
}
func TestServiceMethod_PrettyRejectedOnEmit(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{
"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{},
})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "pretty"})
err := cmd.Execute()
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "pretty") {
t.Fatalf("error = %v, want pretty-not-supported message", err)
}
if stdout.Len() != 0 {
t.Fatalf("rejected --format pretty wrote stdout:\n%s", stdout.String())
if !strings.Contains(stderr.String(), "warning: unknown format") {
t.Errorf("expected format warning in stderr, got:\n%s", stderr.String())
}
}
@@ -1101,18 +1028,6 @@ func requireProblem(t *testing.T, err error, category errs.Category, subtype err
}
}
func requireValidationParam(t *testing.T, err error, param string) {
t.Helper()
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, 0)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Param != param {
t.Fatalf("Param = %q, want %q", validationErr.Param, param)
}
}
// ── file upload ──
func imImageMethod() meta.Method {

View File

@@ -9,30 +9,17 @@ import (
)
// Provider scans parsed response data for content-safety issues.
// Implementations must be safe for concurrent use. Scan may be a best-effort
// scan with bounded string length or nesting depth.
// Implementations must be safe for concurrent use.
type Provider interface {
Name() string
Scan(ctx context.Context, req ScanRequest) (*Alert, error)
}
// FullTextProvider is a Provider that guarantees a complete scan of Data with
// NO per-string or depth truncation. Block mode requires this capability so a
// match anywhere in the output cannot slip past a truncation boundary.
type FullTextProvider interface {
Provider
ScanFullText(ctx context.Context, req ScanRequest) (*Alert, error)
}
// ScanRequest carries the data to scan.
type ScanRequest struct {
Path string // normalized command path (e.g. "im.messages_search")
Data any // parsed response data (generic JSON shape)
ErrOut io.Writer // stderr for provider-level notices (e.g. lazy-config creation)
// FullText marks Data as one complete rendered-output string. It remains a
// compatibility hint for Provider.Scan; block mode calls
// FullTextProvider.ScanFullText to enforce complete scanning.
FullText bool
}
// Alert holds the result of a content-safety scan that detected issues.

View File

@@ -29,14 +29,6 @@ func (s *stubProvider) Scan(_ context.Context, _ ScanRequest) (*Alert, error) {
return &Alert{Provider: "stub", MatchedRules: []string{"test"}}, nil
}
type fullTextStubProvider struct {
stubProvider
}
func (s *fullTextStubProvider) ScanFullText(ctx context.Context, req ScanRequest) (*Alert, error) {
return s.Scan(ctx, req)
}
func TestProviderInterface(t *testing.T) {
var p Provider = &stubProvider{}
if p.Name() != "stub" {
@@ -51,17 +43,6 @@ func TestProviderInterface(t *testing.T) {
}
}
func TestFullTextProviderInterface(t *testing.T) {
var p FullTextProvider = &fullTextStubProvider{}
alert, err := p.ScanFullText(context.Background(), ScanRequest{Path: "test", Data: "full", ErrOut: io.Discard})
if err != nil {
t.Fatalf("ScanFullText() error = %v", err)
}
if alert.Provider != "stub" {
t.Errorf("alert.Provider = %q, want %q", alert.Provider, "stub")
}
}
func TestRegistryLastWriteWins(t *testing.T) {
mu.Lock()
old := provider

View File

@@ -711,19 +711,3 @@ func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
t.Errorf("ExitCodeOf = %d, want %d (internal)", output.ExitCodeOf(err), output.ExitInternal)
}
}
func TestPaginateToOutputRejectsUnsupportedInternalFormat(t *testing.T) {
for _, format := range []output.Format{output.FormatPretty, output.Format(99)} {
err := PaginateToOutput(context.Background(), PaginateOutputOptions{
Request: RawApiRequest{},
Format: format,
Out: io.Discard,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture",
})
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("format %q error = %T, want *errs.InternalError", format, err)
}
}
}

View File

@@ -1,129 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"context"
"io"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// PaginateOutputOptions bundles the inputs for PaginateToOutput. Grouping the
// writers, callbacks, and pagination knobs into one struct keeps the call sites
// readable and avoids positional-argument mistakes across the many parameters.
type PaginateOutputOptions struct {
Client *APIClient
Request RawApiRequest
Format output.Format
JqExpr string
Out io.Writer
ErrOut io.Writer
CommandPath string
Pagination PaginationOptions
CheckErr func(interface{}, core.Identity) error
MarkErr func(error) error
}
// PaginateToOutput fetches all requested pages and emits them in the selected format.
func PaginateToOutput(ctx context.Context, opts PaginateOutputOptions) error {
ac := opts.Client
request := opts.Request
format := opts.Format
jqExpr := opts.JqExpr
out := opts.Out
errOut := opts.ErrOut
commandPath := opts.CommandPath
pagOpts := opts.Pagination
checkErr := opts.CheckErr
markErr := opts.MarkErr
if !format.Valid() || format == output.FormatPretty {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unsupported pagination output format %q", format)
}
if markErr == nil {
markErr = func(err error) error { return err }
}
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
emitValue := func(data interface{}, valueFormat output.Format) error {
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
})
return emitter.Value(data, output.StreamOptions{Format: valueFormat})
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return markErr(err)
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
if emitErr := emitValue(result, output.FormatJSON); emitErr != nil {
return markErr(emitErr)
}
return markErr(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
return emitter.StreamPage(items, output.StreamOptions{Format: format})
}, pagOpts)
if err != nil && errs.IsContentSafety(err) {
return markErr(err)
}
if finishErr := emitter.FinishStream(); finishErr != nil {
return markErr(finishErr)
}
if err != nil {
return markErr(err)
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
return markErr(apiErr)
}
if !hasItems {
return emitter.Value(output.SuccessEnvelopeData(result), output.StreamOptions{Format: format})
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return markErr(err)
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
if emitErr := emitValue(result, output.FormatJSON); emitErr != nil {
return markErr(emitErr)
}
return markErr(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -139,7 +139,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
Identity: string(identity),
NoticeProvider: output.GetNotice,
})
return emitter.Success(result, output.EmitOptions{Format: opts.Format})
return emitter.Success(result, output.EmitOptions{Format: opts.Format.String()})
}
// Non-JSON (binary) responses.

View File

@@ -22,6 +22,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/riskcontrol"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
@@ -33,7 +34,7 @@ import (
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
// Phase 4: LarkClient derived from Credential and workspace policy
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
@@ -54,9 +55,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
@@ -67,7 +69,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Config derived from Credential via an explicit conversion boundary.
// Phase 3: Runtime config contains resolved account data only.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -78,8 +80,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return cfg, nil
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
// Phase 4: LarkClient composes account data and workspace policy at the SDK
// transport boundary.
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
return f
}
@@ -108,13 +111,16 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var rt http.RoundTripper = transport.Shared()
rt = riskcontrol.NewTransport(rt, hostSignalSource)
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
@@ -128,7 +134,7 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
})
}
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -142,8 +148,15 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var sdkBase http.RoundTripper = transport.Shared()
// The innermost SDK boundary always strips reserved host-signal headers;
// a nil source makes it strip-only when workspace policy disables signal
// collection.
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
sdkTransport := wrapSDKTransport(sdkBase)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
Transport: sdkTransport,
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -152,9 +165,8 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
})
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}

View File

@@ -6,10 +6,15 @@ package cmdutil
import (
"io"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c1, err := fn()
if err != nil {
@@ -29,7 +34,10 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -37,7 +45,10 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")

View File

@@ -8,6 +8,7 @@ import (
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -36,13 +37,15 @@ var proxyWarnGateCases = []struct {
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
isEnabled := false
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
f.IOStreams.StderrIsTerminal = tc.terminal
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
@@ -73,7 +76,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
if _, err := cachedLarkClientFunc(f)(); err != nil {
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
t.Fatalf("lark client init: %v", err)
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io/fs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// StatLocalFile returns metadata for a path in the process filesystem namespace.
// It is intended for advisory validation; callers must validate the opened file
// again before using its contents.
func StatLocalFile(path string) (fs.FileInfo, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Stat(localPath)
}
// OpenLocalFile opens a path in the process filesystem namespace.
// Absolute and relative paths are accepted. It is the shared replacement for
// direct os.Open/os.ReadFile use in commands that intentionally read local
// paths outside the workspace sandbox. Callers inspect the returned descriptor
// before reading so validation and use apply to the same opened file.
func OpenLocalFile(path string) (fs.File, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Open(localPath)
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs"
)
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
TestChdir(t, workDir)
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
f, err := OpenLocalFile(input)
if err != nil {
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
}
got, readErr := io.ReadAll(f)
closeErr := f.Close()
if readErr != nil || closeErr != nil || string(got) != "content" {
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
}
}
}
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
}
}
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
info, err := StatLocalFile(t.TempDir())
if err != nil {
t.Fatalf("StatLocalFile() error = %v", err)
}
if !info.IsDir() {
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
}
}
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
previous := vfs.DefaultFS
counting := &countingLocalFileFS{FS: previous}
vfs.DefaultFS = counting
t.Cleanup(func() { vfs.DefaultFS = previous })
f, err := OpenLocalFile(path)
if err != nil {
t.Fatalf("OpenLocalFile() error = %v", err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if counting.openCalls != 1 || counting.statCalls != 0 {
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
}
}
type countingLocalFileFS struct {
vfs.FS
openCalls int
statCalls int
}
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
f.openCalls++
return f.FS.Open(name)
}
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
f.statCalls++
return f.FS.Stat(name)
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/riskcontrol"
)
type workspaceConfigSource interface {
MultiAppConfig() (*core.MultiAppConfig, error)
}
// resolveSDKHostSignalSource applies workspace policy at the SDK transport
// boundary.
func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
if config == nil {
return nil
}
workspace, configErr := config.MultiAppConfig()
// Default-on means an existing config with no explicit preference. Absent
// or unreadable config cannot authorize host-signal collection.
if configErr != nil || !workspace.RiskControlEnabled() {
return nil
}
return riskcontrol.NewHostSource()
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
)
type staticWorkspaceConfig struct {
config *core.MultiAppConfig
err error
}
func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
return s.config, s.err
}
func TestResolveSDKHostSignalSource(t *testing.T) {
disabled := false
tests := []struct {
name string
config workspaceConfigSource
wantSource bool
}{
{name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
{name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
{name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
{name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
{name: "nil config value", config: staticWorkspaceConfig{}},
{name: "nil config source"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := resolveSDKHostSignalSource(test.config)
if (got != nil) != test.wantSource {
t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
}
})
}
}

View File

@@ -15,6 +15,7 @@ import (
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/riskcontrol"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -91,13 +92,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
// ---------------------------------------------------------------------------
// buildSDKTransport chain composition
// wrapSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -110,18 +111,23 @@ func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithExtension(t *testing.T) {
func TestWrapSDKTransport_WithExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
@@ -138,17 +144,23 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -161,9 +173,13 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
// ---------------------------------------------------------------------------
@@ -261,6 +277,40 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
return nil
}
type riskHeaderTamperingInterceptor struct{}
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
return nil
}
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(previous) })
var received http.Header
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
t.Fatalf("extension risk headers reached network: %v", received)
}
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
@@ -277,7 +327,7 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by buildSDKTransport.
// Replicate the SDK chain layering used by wrapSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}

View File

@@ -60,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
StrictMode StrictMode `json:"strictMode,omitempty"`
RiskControl *bool `json:"riskControl,omitempty"`
CurrentApp string `json:"currentApp,omitempty"`
PreviousApp string `json:"previousApp,omitempty"`
Apps []AppConfig `json:"apps"`
}
// RiskControlEnabled resolves the workspace policy. An omitted preference
// keeps the default-on account-protection behavior.
func (m *MultiAppConfig) RiskControlEnabled() bool {
return m != nil && (m.RiskControl == nil || *m.RiskControl)
}
// CurrentAppConfig returns the currently active app config.
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"io/fs"
"sync"
)
// ConfigSnapshot lazily captures one stable view of config.json for a CLI
// invocation. All runtime consumers share the same load result so account and
// workspace policy resolution cannot observe different file revisions. Callers
// must treat the returned config as read-only.
type ConfigSnapshot struct {
load func() (*MultiAppConfig, error)
}
// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
func NewConfigSnapshot() *ConfigSnapshot {
return newConfigSnapshot(LoadMultiAppConfig)
}
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
if load == nil {
return &ConfigSnapshot{}
}
return &ConfigSnapshot{load: sync.OnceValues(load)}
}
// MultiAppConfig returns the captured persistent config and load error.
func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
if s == nil || s.load == nil {
return nil, fs.ErrNotExist
}
return s.load()
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"io/fs"
"testing"
)
func TestConfigSnapshotLoadsOnce(t *testing.T) {
calls := 0
want := &MultiAppConfig{}
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return want, nil
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if err != nil {
t.Fatal(err)
}
if config != want {
t.Fatal("snapshot returned a different config instance")
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}
func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
config, err := (&ConfigSnapshot{}).MultiAppConfig()
if config != nil || !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
}
}
func TestConfigSnapshotCachesError(t *testing.T) {
calls := 0
want := errors.New("load failed")
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return nil, want
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if config != nil || !errors.Is(err, want) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}

View File

@@ -60,7 +60,9 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
disabled := false
config := &MultiAppConfig{
RiskControl: &disabled,
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
@@ -84,6 +86,9 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
if got.RiskControl == nil || *got.RiskControl {
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {

View File

@@ -4,7 +4,6 @@
package output
import (
"context"
"errors"
"fmt"
"io"
@@ -16,19 +15,17 @@ import (
// ScanResult holds the output of ScanForSafety.
type ScanResult struct {
Alert *extcs.Alert
Blocked bool
BlockErr error
scanFailed bool
Alert *extcs.Alert
Blocked bool
BlockErr error
}
// ScanForSafety scans structured response data.
// ScanForSafety runs content-safety scanning on the given data.
// cmdPath is the raw cobra CommandPath().
// When MODE=off, no provider registered, or the command is not allowlisted,
// returns a zero ScanResult.
func ScanForSafety(cmdPath string, data any, errOut io.Writer) ScanResult {
return scanForSafetyMode(cmdPath, data, errOut, false, modeFromEnv(errOut), defaultContentSafetyContext)
}
func scanForSafetyMode(cmdPath string, data any, errOut io.Writer, fullText bool, m mode, newScanContext scanContextFactory) ScanResult {
alert, csErr := runContentSafety(cmdPath, data, errOut, fullText, m, newScanContext)
alert, csErr := runContentSafety(cmdPath, data, errOut)
if errors.Is(csErr, errBlocked) {
return ScanResult{
Alert: alert,
@@ -36,18 +33,10 @@ func scanForSafetyMode(cmdPath string, data any, errOut io.Writer, fullText bool
BlockErr: wrapBlockError(alert),
}
}
if errors.Is(csErr, errScanIncomplete) {
return ScanResult{
Blocked: true,
BlockErr: wrapScanIncompleteError(csErr),
}
}
if errors.Is(csErr, errScanFailed) {
return ScanResult{scanFailed: true}
}
return ScanResult{Alert: alert}
}
// wrapBlockError creates a typed error for content-safety block.
func wrapBlockError(alert *extcs.Alert) error {
var matchedRules []string
if alert != nil {
@@ -59,16 +48,8 @@ func wrapBlockError(alert *extcs.Alert) error {
WithCause(errBlocked)
}
func wrapScanIncompleteError(cause error) error {
message := "content-safety scan did not complete; blocked (block mode)"
if errors.Is(cause, context.DeadlineExceeded) {
message = "content-safety scan did not complete in time; blocked (block mode)"
}
return errs.NewContentSafetyError(errs.SubtypeContentSafety, "%s", message).
WithCause(cause)
}
// WriteAlertWarning writes a content-safety warning.
// WriteAlertWarning writes a human-readable content-safety warning to w.
// Used by non-JSON output paths (pretty, table, csv) in warn mode.
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) error {
if alert == nil {
return nil

View File

@@ -6,7 +6,6 @@ package output
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
@@ -25,15 +24,11 @@ const (
modeBlock
)
// scanTimeout also bounds untruncated rendered-text scans.
// scanTimeout caps the content-safety scan so it cannot dominate CLI latency.
// 100 ms is generous for a regex walk of a typical API response (KB-scale JSON);
// larger responses hit maxDepth/maxStringBytes well before this fires.
const scanTimeout = 100 * time.Millisecond
type scanContextFactory func() (context.Context, context.CancelFunc)
func defaultContentSafetyContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), scanTimeout)
}
// modeFromEnv reads LARKSUITE_CLI_CONTENT_SAFETY_MODE.
func modeFromEnv(errOut io.Writer) mode {
raw := strings.TrimSpace(os.Getenv(envvars.CliContentSafetyMode))
@@ -71,13 +66,11 @@ func normalizeCommandPath(cobraPath string) string {
return strings.Join(segs, ".")
}
var (
errBlocked = errors.New("content safety blocked")
errScanFailed = errors.New("content safety scan failed")
errScanIncomplete = errors.New("content safety scan incomplete")
)
var errBlocked = fmt.Errorf("content safety blocked")
func runContentSafety(cobraPath string, data any, errOut io.Writer, fullText bool, m mode, newScanContext scanContextFactory) (*extcs.Alert, error) {
// runContentSafety orchestrates the scan: mode check -> provider -> scan with timeout + panic recovery.
func runContentSafety(cobraPath string, data any, errOut io.Writer) (*extcs.Alert, error) {
m := modeFromEnv(errOut)
if m == modeOff {
return nil, nil
}
@@ -92,28 +85,17 @@ func runContentSafety(cobraPath string, data any, errOut io.Writer, fullText boo
return nil, nil
}
scan := p.Scan
if m == modeBlock {
fullTextProvider, ok := p.(extcs.FullTextProvider)
if !ok {
return nil, fmt.Errorf("%w: provider %q does not support complete scans",
errScanIncomplete, p.Name())
}
scan = fullTextProvider.ScanFullText
}
type result struct {
alert *extcs.Alert
err error
}
ch := make(chan result, 1)
if newScanContext == nil {
newScanContext = defaultContentSafetyContext
}
ctx, cancel := newScanContext()
ctx, cancel := context.WithTimeout(context.Background(), scanTimeout)
defer cancel()
// A timed-out provider may outlive this call, so it cannot share errOut.
// Give the goroutine its own writer so it cannot race on errOut after timeout.
// On success, we copy any provider notices to the real errOut.
// On timeout, the buffer is owned by the goroutine until it finishes; no shared access.
scanErrBuf := &bytes.Buffer{}
go func() {
defer func() {
@@ -121,12 +103,7 @@ func runContentSafety(cobraPath string, data any, errOut io.Writer, fullText boo
ch <- result{nil, fmt.Errorf("content safety panic: %v", r)}
}
}()
a, e := scan(ctx, extcs.ScanRequest{
Path: cmdPath,
Data: data,
ErrOut: scanErrBuf,
FullText: fullText,
})
a, e := p.Scan(ctx, extcs.ScanRequest{Path: cmdPath, Data: data, ErrOut: scanErrBuf})
ch <- result{a, e}
}()
@@ -136,22 +113,13 @@ func runContentSafety(cobraPath string, data any, errOut io.Writer, fullText boo
if scanErrBuf.Len() > 0 {
_, _ = io.Copy(errOut, scanErrBuf)
}
if ctx.Err() != nil && m == modeBlock {
return nil, fmt.Errorf("%w: %w", errScanIncomplete, ctx.Err())
}
case <-ctx.Done():
if m == modeBlock {
return nil, fmt.Errorf("%w: %w", errScanIncomplete, ctx.Err())
}
return nil, fmt.Errorf("%w: %w", errScanFailed, ctx.Err())
return nil, nil // timeout, fail-open; scanErrBuf stays with the goroutine
}
if res.err != nil {
fmt.Fprintf(errOut, "warning: content safety scan error: %v\n", res.err)
if m == modeBlock {
return nil, fmt.Errorf("%w: %w", errScanIncomplete, res.err)
}
return nil, fmt.Errorf("%w: %w", errScanFailed, res.err)
return nil, nil // fail-open
}
if res.alert == nil {
return nil, nil

View File

@@ -8,7 +8,6 @@ import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
@@ -23,70 +22,11 @@ type mockProvider struct {
err error
}
type resultFirstCanceledContext struct {
selectDone chan struct{}
providerDone chan struct{}
selectWaiting chan struct{}
doneCallCounter atomic.Int32
}
func newResultFirstCanceledContext() *resultFirstCanceledContext {
providerDone := make(chan struct{})
close(providerDone)
return &resultFirstCanceledContext{
selectDone: make(chan struct{}),
providerDone: providerDone,
selectWaiting: make(chan struct{}),
}
}
func (c *resultFirstCanceledContext) Deadline() (time.Time, bool) {
return time.Time{}, false
}
func (c *resultFirstCanceledContext) Done() <-chan struct{} {
if c.doneCallCounter.Add(1) == 1 {
close(c.selectWaiting)
return c.selectDone
}
return c.providerDone
}
func (c *resultFirstCanceledContext) Err() error {
return context.DeadlineExceeded
}
func (c *resultFirstCanceledContext) Value(any) any {
return nil
}
type abortedCleanProvider struct {
selectWaiting <-chan struct{}
}
func (p *abortedCleanProvider) Name() string {
return "aborted-clean"
}
func (p *abortedCleanProvider) Scan(ctx context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
<-p.selectWaiting
<-ctx.Done()
return nil, nil
}
func (p *abortedCleanProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
func (m *mockProvider) Name() string { return m.name }
func (m *mockProvider) Scan(_ context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
return m.alert, m.err
}
func (m *mockProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return m.Scan(ctx, req)
}
func TestScanForSafety_ModeOff(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
var buf bytes.Buffer
@@ -162,131 +102,36 @@ func TestScanForSafety_NoProvider(t *testing.T) {
}
}
func TestScanForSafety_ScanError_ModeBehavior(t *testing.T) {
for _, tt := range []struct {
name string
mode string
wantBlocked bool
wantWarning bool
}{
{name: "block fails closed", mode: "block", wantBlocked: true, wantWarning: true},
{name: "warn fails open", mode: "warn", wantWarning: true},
{name: "off skips scan", mode: "off"},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", tt.mode)
mp := &mockProvider{name: "mock", err: errors.New("scan broke")}
extcs.Register(mp)
t.Cleanup(func() { extcs.Register(nil) })
func TestScanForSafety_ScanError_FailOpen(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
mp := &mockProvider{name: "mock", err: errors.New("scan broke")}
extcs.Register(mp)
defer extcs.Register(nil)
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Blocked != tt.wantBlocked {
t.Fatalf("Blocked = %v, want %v", result.Blocked, tt.wantBlocked)
}
if tt.wantBlocked {
var safetyErr *errs.ContentSafetyError
if !errors.As(result.BlockErr, &safetyErr) {
t.Fatalf("BlockErr = %T, want *errs.ContentSafetyError", result.BlockErr)
}
if !strings.Contains(safetyErr.Message, "scan did not complete") {
t.Fatalf("BlockErr message = %q, want scan-incomplete message", safetyErr.Message)
}
if !errors.Is(result.BlockErr, errScanIncomplete) {
t.Fatal("BlockErr should preserve errScanIncomplete cause")
}
}
if got := strings.Contains(buf.String(), "scan error"); got != tt.wantWarning {
t.Fatalf("scan warning present = %v, want %v; stderr=%q", got, tt.wantWarning, buf.String())
}
})
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Blocked {
t.Error("scan error should fail-open, not block")
}
if !strings.Contains(buf.String(), "scan error") {
t.Errorf("expected warning on stderr, got: %s", buf.String())
}
}
func TestScanForSafety_SlowProvider_TimeoutModeBehavior(t *testing.T) {
for _, tt := range []struct {
name string
mode string
wantBlocked bool
}{
{name: "block fails closed", mode: "block", wantBlocked: true},
{name: "warn fails open", mode: "warn"},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", tt.mode)
extcs.Register(&slowProvider{})
t.Cleanup(func() { extcs.Register(nil) })
func TestScanForSafety_SlowProvider_Timeout_FailOpen(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Blocked != tt.wantBlocked {
t.Fatalf("Blocked = %v, want %v", result.Blocked, tt.wantBlocked)
}
if result.Alert != nil {
t.Error("slow provider should return nil alert on timeout")
}
if tt.wantBlocked {
var safetyErr *errs.ContentSafetyError
if !errors.As(result.BlockErr, &safetyErr) {
t.Fatalf("BlockErr = %T, want *errs.ContentSafetyError", result.BlockErr)
}
if !strings.Contains(safetyErr.Message, "did not complete in time") {
t.Fatalf("BlockErr message = %q, want timeout message", safetyErr.Message)
}
}
})
slow := &slowProvider{}
extcs.Register(slow)
defer extcs.Register(nil)
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Blocked {
t.Error("slow provider should fail-open on timeout, not block")
}
}
func TestEmitterAbortedCleanLookingScanModeBehavior(t *testing.T) {
tests := []struct {
name string
mode string
wantBlocked bool
}{
{name: "block fails closed", mode: "block", wantBlocked: true},
{name: "warn fails open", mode: "warn"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", tt.mode)
scanCtx := newResultFirstCanceledContext()
extcs.Register(&abortedCleanProvider{selectWaiting: scanCtx.selectWaiting})
t.Cleanup(func() { extcs.Register(nil) })
stdout := &bytes.Buffer{}
emitter := NewEmitter(EmitterConfig{
Out: stdout,
ErrOut: &bytes.Buffer{},
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
})
emitter.scanCtx = func() (context.Context, context.CancelFunc) {
return scanCtx, func() {}
}
err := emitter.Success(map[string]any{"id": "1"}, EmitOptions{Format: FormatJSON})
if tt.wantBlocked {
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", err)
}
if !strings.Contains(safetyErr.Message, "scan did not complete") {
t.Fatalf("Emitter.Success() error = %v, want scan-incomplete message", err)
}
if stdout.Len() != 0 {
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
}
return
}
if err != nil {
t.Fatalf("Emitter.Success() error = %v, want nil", err)
}
if stdout.Len() == 0 {
t.Fatal("Emitter.Success() stdout is empty, want emitted output")
}
})
if result.Alert != nil {
t.Error("slow provider should return nil alert on timeout")
}
}
@@ -303,10 +148,6 @@ func (s *slowProvider) Scan(ctx context.Context, _ extcs.ScanRequest) (*extcs.Al
}
}
func (s *slowProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return s.Scan(ctx, req)
}
func TestWriteAlertWarning(t *testing.T) {
alert := &extcs.Alert{Provider: "regex", MatchedRules: []string{"r1", "r2"}}
var buf bytes.Buffer

View File

@@ -6,11 +6,11 @@ package output
import (
"bytes"
"encoding/json"
"fmt"
"io"
"sort"
"maps"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
)
// NoticeProvider supplies the notice attached to a structured envelope.
@@ -25,30 +25,31 @@ type PrettyRenderer func(w io.Writer, colorEnabled bool) error
// EmitterConfig contains command-scoped dependencies. A command constructs one
// Emitter and reuses it for its success result or streamed pages.
type EmitterConfig struct {
Out io.Writer
ErrOut io.Writer
CommandPath string
Identity string
ColorEnabled bool
NoticeProvider NoticeProvider
MaxBufferedStreamBytes int
Out io.Writer
ErrOut io.Writer
CommandPath string
Identity string
ColorEnabled bool
NoticeProvider NoticeProvider
}
// EmitOptions describes one result's wire representation.
//
// The format contract is explicit: FormatJSON (the zero value) uses an
// The format contract is explicit: JSON (including the empty default) uses an
// Envelope; pretty, table, csv, and ndjson render naked business data. JQ takes
// precedence over Format and filters the JSON Envelope. Raw affects only JSON
// envelope encoding and jq's complex-value encoding. Format is a canonical
// typed value — boundaries reject unknown formats via ParseFormatStrict, so the
// Emitter never sees one and never falls back.
// envelope encoding and jq's complex-value encoding.
//
// JQSafetyWarning preserves the legacy difference between RuntimeContext.emit
// (false) and WriteSuccessEnvelope (true) until their callers are migrated.
type EmitOptions struct {
Raw bool
Meta *Meta
Format Format
JQ string
DryRun bool
Pretty PrettyRenderer
Raw bool
Meta *Meta
Format string
JQ string
DryRun bool
Pretty PrettyRenderer
JQSafetyWarning bool
}
// StreamOptions describes one streamed page's wire representation. Streaming
@@ -58,7 +59,7 @@ type EmitOptions struct {
// the aggregated result, which the caller's pagination layer owns before it
// streams pages.
type StreamOptions struct {
Format Format
Format string
Pretty PrettyRenderer
}
@@ -71,33 +72,17 @@ type Emitter struct {
identity string
colorEnabled bool
noticeProvider NoticeProvider
scanCtx scanContextFactory
streamFormat Format
streamFormatSet bool
streamPrettySet bool
streamHasPretty bool
streamFormat string
streamFormatter *PaginatedFormatter
streamMode mode
streamModeSet bool
streamBuffer bytes.Buffer
maxStreamBytes int
streamFinished bool
streamFinishErr error
}
const defaultMaxBufferedStreamBytes = 64 << 20
// NewEmitter constructs a command-scoped output emitter.
func NewEmitter(config EmitterConfig) *Emitter {
errOut := config.ErrOut
if errOut == nil {
errOut = io.Discard
}
maxStreamBytes := config.MaxBufferedStreamBytes
if maxStreamBytes <= 0 {
maxStreamBytes = defaultMaxBufferedStreamBytes
}
return &Emitter{
out: config.Out,
errOut: errOut,
@@ -105,8 +90,6 @@ func NewEmitter(config EmitterConfig) *Emitter {
identity: config.Identity,
colorEnabled: config.ColorEnabled,
noticeProvider: config.NoticeProvider,
scanCtx: defaultContentSafetyContext,
maxStreamBytes: maxStreamBytes,
}
}
@@ -114,10 +97,6 @@ func NewEmitter(config EmitterConfig) *Emitter {
// primitives. JSON and jq use the standard envelope; pretty, table, csv, and
// ndjson render the business value directly.
func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
if !opts.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(opts.Format))
}
if err := e.requireOutput(); err != nil {
return err
}
@@ -127,49 +106,26 @@ func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
}
switch opts.Format {
case FormatJSON:
case "", "json":
return e.emitEnvelope(data, true, opts)
case FormatPretty:
case "pretty":
return e.emitPretty(data, opts)
default:
return e.emitFormatted(data, opts.Format)
}
}
// Value scans and emits one naked business value. It is intended for
// long-running streams and custom-format shortcuts whose public contract does
// not use the standard success envelope.
func (e *Emitter) Value(data interface{}, opts StreamOptions) error {
if !opts.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(opts.Format))
}
if err := e.requireOutput(); err != nil {
return err
}
if opts.Format == FormatPretty && opts.Pretty != nil {
return e.emitPrettyRenderer(data, opts.Pretty)
}
return e.emitValue(data, opts.Format)
}
// PartialFailure emits a multi-status result whose envelope honestly reports
// ok:false. It is the typed counterpart to Success for batch operations where
// some items failed but the per-item outcomes are the primary stdout output.
// JSON and jq retain the failure envelope. Other formats emit the selected
// naked representation while the caller supplies the non-zero exit signal.
// Like the legacy OutPartialFailure it produces only the JSON/jq envelope; the
// caller owns the non-zero exit signal, keeping the Emitter free of exit
// semantics.
func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
if !opts.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(opts.Format))
}
if err := e.requireOutput(); err != nil {
return err
}
if opts.JQ != "" || opts.Format == FormatJSON {
return e.emitEnvelope(data, false, opts)
}
return e.Value(data, StreamOptions{Format: opts.Format, Pretty: opts.Pretty})
return e.emitEnvelope(data, false, opts)
}
// StreamPage scans and emits one page while retaining table/csv columns from
@@ -180,80 +136,54 @@ func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
// jq from the type makes "jq requires aggregated output" a compile-time fact
// instead of a runtime rejection.
func (e *Emitter) StreamPage(data interface{}, opts StreamOptions) error {
if !opts.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(opts.Format))
}
if err := e.requireOutput(); err != nil {
return err
}
if e.streamFinished {
return errs.NewInternalError(errs.SubtypeUnknown,
"stream output is already finished")
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if !e.streamFormatSet {
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
if opts.Format == "pretty" {
if opts.Pretty == nil {
return errs.NewInternalError(errs.SubtypeUnknown,
"pretty output requires a renderer")
}
return e.emit(func(w io.Writer) error {
return opts.Pretty(w, e.colorEnabled)
})
}
format, known := ParseFormat(opts.Format)
if !known && e.streamFormatter == nil && e.errOut != nil {
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
if e.streamFormatter == nil {
e.streamFormat = opts.Format
e.streamFormatSet = true
e.streamFormatter = NewPaginatedFormatter(nil, format)
} else if opts.Format != e.streamFormat {
return errs.NewInternalError(errs.SubtypeUnknown,
"stream output format changed from %q to %q", e.streamFormat, opts.Format)
}
if opts.Format == FormatPretty {
hasPretty := opts.Pretty != nil
if !e.streamPrettySet {
e.streamHasPretty = hasPretty
e.streamPrettySet = true
} else if hasPretty != e.streamHasPretty {
return errs.NewInternalError(errs.SubtypeUnknown,
"stream pretty renderer availability changed between pages")
}
if opts.Pretty != nil {
var buf bytes.Buffer
if err := opts.Pretty(&buf, e.colorEnabled); err != nil {
return wrapOutputError("render", err)
}
return e.emitStreamBuffer(data, &buf)
}
// Commands without a curated pretty renderer use the generic table
// representation. This keeps --format pretty truthful without requiring
// every shortcut to duplicate a renderer.
opts.Format = FormatTable
}
if e.streamFormatter == nil {
e.streamFormatter = NewPaginatedFormatter(nil, opts.Format)
}
// Render this page, then scan the exact bytes before writing: a rule match
// can form in the rendered page (joined table cells, adjacent objects) even
// when no single value matches.
var buf bytes.Buffer
e.streamFormatter.W = &buf
if err := e.streamFormatter.WritePage(data); err != nil {
return wrapOutputError("render", err)
}
return e.emitStreamBuffer(data, &buf)
}
// FinishStream commits output buffered by StreamPage in block mode. Warn mode
// remains incremental: each page is scanned and written by StreamPage. Callers
// must invoke FinishStream after the final page, including when pagination ends
// with an API error and partial block-mode output should remain visible.
func (e *Emitter) FinishStream() error {
if e.streamFinished {
return e.streamFinishErr
}
e.streamFinished = true
if !e.streamModeSet || e.streamMode != modeBlock || e.streamBuffer.Len() == 0 {
return nil
}
e.streamFinishErr = e.emitScannedBufferMode(&e.streamBuffer, e.streamMode)
return e.streamFinishErr
return e.emit(func(w io.Writer) error {
e.streamFormatter.W = w
return e.streamFormatter.WritePage(data)
})
}
func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) error {
m := modeFromEnv(e.errOut)
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
env := Envelope{
OK: ok,
Identity: e.identity,
@@ -262,14 +192,15 @@ func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) erro
Meta: opts.Meta,
Notice: e.notice(),
}
if scanResult.Alert != nil {
env.ContentSafetyAlert = scanResult.Alert
}
if opts.JQ != "" {
sourceScan := e.scanForSafetyMode(data, false, m)
if sourceScan.Blocked {
return sourceScan.BlockErr
}
if sourceScan.Alert != nil {
env.ContentSafetyAlert = sourceScan.Alert
if scanResult.Alert != nil && opts.JQSafetyWarning {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
// Buffer the jq output manually so jq's own typed error (a validation
// error for a bad expression, an api error for a runtime failure) is
@@ -285,121 +216,25 @@ func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) erro
if jqErr != nil {
return jqErr
}
var renderedScan ScanResult
if !sourceScan.scanFailed {
renderedScan = e.scanRenderedBufferMode(&buf, m)
}
if renderedScan.Blocked {
return renderedScan.BlockErr
}
alert := mergeSafetyAlerts(sourceScan.Alert, renderedScan.Alert)
if alert != nil {
if err := WriteAlertWarning(e.errOut, alert); err != nil {
return wrapOutputError("write", err)
}
}
if _, err := io.Copy(e.out, &buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
// Scan both representations. The structured scan detects content changed by
// JSON escaping, while the rendered scan detects matches formed across
// serialized fields.
sourceScan := e.scanForSafetyMode(data, false, m)
if sourceScan.Blocked {
return sourceScan.BlockErr
}
var buf bytes.Buffer
if err := renderEnvelope(&buf, env, opts.Raw); err != nil {
return wrapOutputError("render", err)
}
var renderedScan ScanResult
if !sourceScan.scanFailed {
renderedScan = e.scanRenderedBufferMode(&buf, m)
}
if renderedScan.Blocked {
return renderedScan.BlockErr
}
if alert := mergeSafetyAlerts(sourceScan.Alert, renderedScan.Alert); alert != nil {
env.ContentSafetyAlert = alert
buf.Reset()
if err := renderEnvelope(&buf, env, opts.Raw); err != nil {
return wrapOutputError("render", err)
return e.emit(func(w io.Writer) error {
if opts.Raw {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(env)
}
}
if _, err := io.Copy(e.out, &buf); err != nil {
return wrapOutputError("write", err)
}
return nil
return WriteJSON(w, env)
})
}
func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
if opts.Pretty != nil {
return e.emitPrettyRenderer(data, opts.Pretty)
}
return e.emitFormatted(data, FormatPretty)
}
func (e *Emitter) emitPrettyRenderer(data interface{}, renderer PrettyRenderer) error {
// Buffer pretty output so the safety scan sees the exact text that will be
// written to stdout, including anything captured by the opaque renderer.
var buf bytes.Buffer
if err := renderer(&buf, e.colorEnabled); err != nil {
return wrapOutputError("render", err)
}
return e.emitSourceAndRenderedBufferMode(data, &buf, modeFromEnv(e.errOut))
}
// emitFormatted renders naked business data for ndjson, table, csv, and the
// generic pretty representation. Success routes FormatJSON to the envelope and
// curated pretty output to its renderer.
func (e *Emitter) emitFormatted(data interface{}, format Format) error {
var buf bytes.Buffer
if err := WriteFormatted(&buf, data, format); err != nil {
return wrapOutputError("render", err)
}
return e.emitSourceAndRenderedBufferMode(data, &buf, modeFromEnv(e.errOut))
}
func (e *Emitter) emitValue(data interface{}, format Format) error {
var buf bytes.Buffer
var err error
switch format {
case FormatJSON:
err = WriteJSON(&buf, data)
case FormatNDJSON:
err = WriteNDJSON(&buf, data)
case FormatTable:
err = WriteTable(&buf, data)
case FormatCSV:
err = WriteCSV(&buf, data)
case FormatPretty:
err = WriteFormatted(&buf, data, format)
default:
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(format))
}
if err != nil {
return wrapOutputError("render", err)
}
return e.emitSourceAndRenderedBufferMode(data, &buf, modeFromEnv(e.errOut))
}
func (e *Emitter) emitScannedBufferMode(buf *bytes.Buffer, m mode) error {
scanResult := e.scanRenderedBufferMode(buf, m)
return e.emitBufferAfterScan(buf, scanResult)
}
func (e *Emitter) emitSourceAndRenderedBufferMode(data interface{}, buf *bytes.Buffer, m mode) error {
scanResult := e.scanSourceAndRenderedBufferMode(data, buf, m)
return e.emitBufferAfterScan(buf, scanResult)
}
func (e *Emitter) emitBufferAfterScan(buf *bytes.Buffer, scanResult ScanResult) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
@@ -408,97 +243,79 @@ func (e *Emitter) emitBufferAfterScan(buf *bytes.Buffer, scanResult ScanResult)
return wrapOutputError("write", err)
}
}
if _, err := io.Copy(e.out, buf); err != nil {
if opts.Pretty != nil {
return e.emit(func(w io.Writer) error {
return opts.Pretty(w, e.colorEnabled)
})
}
// RuntimeContext.outFormat falls back through Out/OutRaw when no pretty
// renderer is supplied. Keep that second scan visible in the leaf contract
// until production callers are migrated and the legacy behavior is removed.
return e.emitEnvelope(data, true, opts)
}
func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
format, known := ParseFormat(rawFormat)
if !known && e.errOut != nil {
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", rawFormat)
}
if format == FormatJSON {
return e.printLegacyDataJSON(data)
}
return e.emit(func(w io.Writer) error {
return WriteFormatted(w, data, format)
})
}
type emitterDataMap map[string]interface{}
// printLegacyDataJSON matches FormatValue's JSON branch while sourcing notice
// data from this Emitter instead of PrintJson's global PendingNotice hook.
func (e *Emitter) printLegacyDataJSON(data interface{}) error {
// Normalise structs / named maps to plain generic types first, exactly as
// FormatValue does, so a struct or named-map payload still matches the map
// case below and keeps its injected _notice on the unknown-format fallback.
data = toGeneric(data)
if m, ok := data.(map[string]interface{}); ok {
if _, isEnvelope := m["ok"]; isEnvelope {
if notice := e.notice(); notice != nil {
m = maps.Clone(m)
m["_notice"] = notice
}
}
// The named map retains identical JSON bytes while preventing PrintJson
// from consulting its legacy global notice hook a second time.
return e.emit(func(w io.Writer) error {
return WriteJSON(w, emitterDataMap(m))
})
}
return e.emit(func(w io.Writer) error {
return WriteJSON(w, data)
})
}
func (e *Emitter) emit(render func(io.Writer) error) error {
var buf bytes.Buffer
if err := render(&buf); err != nil {
return wrapOutputError("render", err)
}
if _, err := io.Copy(e.out, &buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
func (e *Emitter) emitStreamBuffer(data interface{}, buf *bytes.Buffer) error {
if !e.streamModeSet {
e.streamMode = modeFromEnv(e.errOut)
e.streamModeSet = true
}
switch e.streamMode {
case modeWarn:
return e.emitSourceAndRenderedBufferMode(data, buf, e.streamMode)
case modeBlock:
sourceScan := e.scanForSafetyMode(data, false, e.streamMode)
if sourceScan.Blocked {
return sourceScan.BlockErr
}
if buf.Len() > e.maxStreamBytes-e.streamBuffer.Len() {
return errs.NewContentSafetyError(errs.SubtypeContentSafety,
"content-safety scan input exceeds the %d-byte stream limit; blocked",
e.maxStreamBytes).
WithHint("reduce --page-limit or request fewer records")
}
_, _ = e.streamBuffer.Write(buf.Bytes())
return nil
}
if _, err := io.Copy(e.out, buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
func (e *Emitter) scanSourceAndRenderedBufferMode(data interface{}, buf *bytes.Buffer, m mode) ScanResult {
sourceScan := e.scanForSafetyMode(data, false, m)
if sourceScan.Blocked || sourceScan.scanFailed {
return sourceScan
}
renderedScan := e.scanRenderedBufferMode(buf, m)
if renderedScan.Blocked {
return renderedScan
}
renderedScan.Alert = mergeSafetyAlerts(sourceScan.Alert, renderedScan.Alert)
return renderedScan
}
func (e *Emitter) scanRenderedBufferMode(buf *bytes.Buffer, m mode) ScanResult {
return e.scanForSafetyMode(buf.String(), true, m)
}
func renderEnvelope(w io.Writer, env Envelope, raw bool) error {
if raw {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(env)
}
return WriteJSON(w, env)
}
func mergeSafetyAlerts(first, second *extcs.Alert) *extcs.Alert {
if first == nil {
return second
}
if second == nil {
return first
}
rules := make(map[string]struct{}, len(first.MatchedRules)+len(second.MatchedRules))
for _, rule := range first.MatchedRules {
rules[rule] = struct{}{}
}
for _, rule := range second.MatchedRules {
rules[rule] = struct{}{}
}
mergedRules := make([]string, 0, len(rules))
for rule := range rules {
mergedRules = append(mergedRules, rule)
}
sort.Strings(mergedRules)
provider := first.Provider
if provider == "" {
provider = second.Provider
}
return &extcs.Alert{Provider: provider, MatchedRules: mergedRules}
}
func (e *Emitter) scanForSafetyMode(data interface{}, fullText bool, m mode) ScanResult {
return scanForSafetyMode(e.commandPath, data, e.errOut, fullText, m, e.scanCtx)
}
func wrapOutputError(op string, err error) error {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s command output", op).WithCause(err)
}

File diff suppressed because it is too large Load Diff

View File

@@ -45,10 +45,6 @@ func (p *emitterSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs
return p.alert, p.err
}
func (p *emitterSafetyProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
const (
runtimeContextLegacyGoldenPath = "testdata/runtime_context_legacy.golden.json"
writeSuccessEnvelopeLegacyGoldenPath = "testdata/write_success_envelope_legacy.golden.json"
@@ -64,7 +60,6 @@ type runtimeContextOracleCase struct {
format string
useFormat bool
pretty bool
keepError bool
notice map[string]interface{}
safetyMode string
safetyAlert *extcs.Alert
@@ -193,6 +188,15 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
useFormat: true,
pretty: true,
},
{
name: "pretty_without_renderer",
data: func() interface{} {
return map[string]interface{}{"name": "Alice"}
},
ok: true,
format: "pretty",
useFormat: true,
},
{
name: "ndjson",
data: func() interface{} {
@@ -232,7 +236,7 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
useFormat: true,
},
{
name: "jq_safety_alert_writes_stderr_warning",
name: "jq_safety_alert_without_stderr_warning",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
@@ -245,7 +249,7 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
},
},
{
name: "scanner_error_warn_mode_fails_open",
name: "scanner_error_fails_open",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
@@ -253,16 +257,6 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
safetyMode: "warn",
safetyErr: errors.New("scanner unavailable"),
},
// Block mode intentionally fails closed when scanning errors.
{
name: "scanner_error_block_mode_fails_closed",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: false,
safetyMode: "block",
safetyErr: errors.New("scanner unavailable"),
},
{
name: "scanner_block",
data: func() interface{} {
@@ -275,6 +269,16 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "unknown_format_data_envelope_notice",
data: func() interface{} {
return map[string]interface{}{"ok": true, "value": "fixture"}
},
ok: true,
format: "yaml",
useFormat: true,
notice: map[string]interface{}{"skills": map[string]interface{}{"current": "1.0.0"}},
},
}
golden := loadRuntimeContextLegacyGolden(t)
@@ -305,11 +309,7 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
format: tc.format,
useFormat: tc.useFormat,
pretty: tc.pretty,
keepError: tc.keepError,
}
// tc.format is the string a shortcut's --format flag would carry; the
// boundary parses it to a canonical Format before the Emitter sees it.
format, _ := output.ParseFormat(tc.format)
current := runEmitterWithRuntimeContextContract(tc.data(), output.EmitterConfig{
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
@@ -317,10 +317,10 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
}, tc.ok, output.EmitOptions{
Raw: tc.raw,
Meta: tc.meta,
Format: format,
Format: tc.format,
JQ: tc.jq,
Pretty: emitterPrettyRenderer(tc.pretty),
}, tc.keepError)
})
assertEmitterGolden(t, want, current)
@@ -388,15 +388,10 @@ type runtimeOracleOptions struct {
format string
useFormat bool
pretty bool
keepError bool
}
func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
t.Helper()
if opts.keepError {
return runRuntimeContextShortcutOracle(t, data, opts)
}
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
parent := &cobra.Command{Use: "lark-cli"}
@@ -436,42 +431,6 @@ func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleO
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runRuntimeContextShortcutOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
})
root := &cobra.Command{Use: "lark-cli", SilenceErrors: true, SilenceUsage: true}
fixture := &cobra.Command{Use: "fixture"}
root.AddCommand(fixture)
shortcut := common.Shortcut{
Service: "fixture",
Command: "+emit",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, runtime *common.RuntimeContext) error {
pretty := func(w io.Writer) {
fmt.Fprintln(w, "pretty:fixture")
}
if !opts.pretty {
pretty = nil
}
if opts.raw {
runtime.OutFormatRaw(data, opts.meta, pretty)
} else {
runtime.OutFormat(data, opts.meta, pretty)
}
return nil
},
}
shortcut.Mount(fixture, factory)
root.SetArgs([]string{"fixture", "+emit", "--as", "bot", "--format", opts.format})
err := root.Execute()
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
@@ -487,13 +446,7 @@ func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, o
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runEmitterWithRuntimeContextContract(
data interface{},
config output.EmitterConfig,
ok bool,
opts output.EmitOptions,
keepError bool,
) emitterCapture {
func runEmitterWithRuntimeContextContract(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
capture := runEmitterSuccess(data, config, ok, opts)
if capture.err != nil {
var safetyErr *errs.ContentSafetyError
@@ -504,9 +457,6 @@ func runEmitterWithRuntimeContextContract(
capture.stderr += fmt.Sprintf("error: %v\n", capture.err)
return capture
}
if keepError {
return capture
}
capture.err = nil
}
if !ok {
@@ -596,10 +546,11 @@ func TestEmitterMatchesWriteSuccessEnvelopeLegacyOracle(t *testing.T) {
Identity: "bot",
NoticeProvider: func() map[string]interface{} { return notice },
}, true, output.EmitOptions{
Format: output.FormatJSON,
Raw: false,
JQ: tc.jq,
DryRun: tc.dryRun,
Format: "",
Raw: false,
JQ: tc.jq,
DryRun: tc.dryRun,
JQSafetyWarning: true,
})
assertEmitterGolden(t, want, current)
@@ -654,14 +605,23 @@ func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) {
{name: "table", format: output.FormatTable},
{name: "csv", format: output.FormatCSV},
{
name: "table warn",
format: output.FormatTable,
name: "warn",
format: output.FormatNDJSON,
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "block",
format: output.FormatTable,
safetyMode: "block",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
}
pages := []interface{}{
@@ -680,7 +640,7 @@ func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) {
t.Cleanup(func() { extcs.Register(nil) })
legacy := runPaginationOracle(pages, tc.format)
current := runEmitterStreamPages(pages, tc.format)
current := runEmitterStreamPages(pages, tc.format.String())
assertEmitterBytes(t, legacy, current)
assertEquivalentError(t, legacy.err, current.err)
@@ -707,7 +667,7 @@ func runPaginationOracle(pages []interface{}, format output.Format) emitterCaptu
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
}
func runEmitterStreamPages(pages []interface{}, format output.Format) emitterCapture {
func runEmitterStreamPages(pages []interface{}, format string) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
@@ -722,9 +682,6 @@ func runEmitterStreamPages(pages []interface{}, format output.Format) emitterCap
break
}
}
if emitErr == nil {
emitErr = emitter.FinishStream()
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
}
@@ -749,7 +706,7 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
return map[string]interface{}{"source": "captured"}
},
})
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: output.FormatJSON}); err != nil {
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
@@ -757,7 +714,7 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
}
stdout.Reset()
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: output.FormatPretty,
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "pretty",
Pretty: func(w io.Writer, colorEnabled bool) error {
colorSeen = colorEnabled
_, err := fmt.Fprintln(w, "pretty")
@@ -769,6 +726,14 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
if !colorSeen {
t.Fatal("PrettyRenderer did not receive captured ColorEnabled value")
}
stdout.Reset()
if err := emitter.Success(map[string]interface{}{"ok": true, "id": "1"}, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success(unknown format) error = %v", err)
}
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
t.Fatalf("legacy JSON fallback consulted global notice:\n%s", stdout.String())
}
}
type failingEmitterWriter struct {
@@ -786,7 +751,7 @@ func TestEmitterPropagatesOutputError(t *testing.T) {
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Raw: true, Format: output.FormatJSON,
Raw: true, Format: "json",
JQ: ".data",
})
if !errors.Is(err, sentinel) {

View File

@@ -41,9 +41,10 @@ func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
Identity: opts.Identity,
NoticeProvider: GetNotice,
}).Success(data, EmitOptions{
Format: FormatJSON,
Raw: false,
JQ: opts.JqExpr,
DryRun: opts.DryRun,
Format: "",
Raw: false,
JQ: opts.JqExpr,
DryRun: opts.DryRun,
JQSafetyWarning: true,
})
}

View File

@@ -9,8 +9,6 @@ import (
"fmt"
"io"
"sort"
"github.com/larksuite/cli/errs"
)
// Known array field names for pagination.
@@ -116,22 +114,8 @@ func FormatValue(w io.Writer, data interface{}, format Format) {
// WriteFormatted formats a single response and returns marshal or write errors.
func WriteFormatted(w io.Writer, data interface{}, format Format) error {
if !format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(format))
}
data = toGeneric(data)
switch format {
case FormatJSON:
return WriteJSON(w, data)
case FormatPretty:
switch data.(type) {
case map[string]interface{}, []interface{}:
return WriteTable(w, data)
default:
_, err := fmt.Fprintln(w, cellStr(data))
return err
}
case FormatNDJSON:
items := ExtractItems(data)
if items != nil {
@@ -153,9 +137,9 @@ func WriteFormatted(w io.Writer, data interface{}, format Format) error {
}
return WriteCSV(w, data)
default: // FormatJSON
return WriteJSON(w, data)
}
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(format))
}
// PaginatedFormatter holds state across paginated calls to ensure
@@ -182,10 +166,6 @@ func (pf *PaginatedFormatter) FormatPage(data interface{}) {
// WritePage formats one page of items and returns marshal or write errors.
func (pf *PaginatedFormatter) WritePage(data interface{}) error {
if !pf.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(pf.Format))
}
switch pf.Format {
case FormatJSON, FormatNDJSON:
if arr, ok := data.([]interface{}); ok {
@@ -214,8 +194,7 @@ func (pf *PaginatedFormatter) WritePage(data interface{}) error {
return writeCSVRows(w, rows, cols, isFirst)
})
}
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(pf.Format))
return nil
}
// formatStructuredPage handles column-locking logic shared by table and csv.

View File

@@ -6,11 +6,8 @@ package output
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestFormatValue_JSON(t *testing.T) {
@@ -101,18 +98,6 @@ func TestFormatValue_CSV(t *testing.T) {
}
}
func TestWriteFormatted_InvalidFormatReturnsInternalErrorWithoutOutput(t *testing.T) {
var buf bytes.Buffer
err := WriteFormatted(&buf, map[string]interface{}{"id": "1"}, Format(99))
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("WriteFormatted() problem = %#v, %v; want internal/unknown", problem, ok)
}
if buf.Len() != 0 {
t.Fatalf("WriteFormatted() wrote %d bytes, want 0", buf.Len())
}
}
func TestPaginatedFormatter_JSON(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, FormatJSON)
@@ -185,22 +170,6 @@ func TestPaginatedFormatter_CSV(t *testing.T) {
}
}
func TestPaginatedFormatterWritePage_InvalidFormatReturnsInternalErrorWithoutOutput(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, Format(99))
err := pf.WritePage([]interface{}{map[string]interface{}{"id": "1"}})
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("WritePage() error = %T, want *errs.InternalError", err)
}
if internalErr.Category != errs.CategoryInternal || internalErr.Subtype != errs.SubtypeUnknown {
t.Fatalf("WritePage() problem = %s/%s, want internal/unknown", internalErr.Category, internalErr.Subtype)
}
if buf.Len() != 0 {
t.Fatalf("WritePage() wrote %d bytes, want 0", buf.Len())
}
}
func TestPaginatedFormatter_ColumnConsistency(t *testing.T) {
// Page 1 has {a, b}, page 2 has {a, b, c} — c should be ignored in CSV
var buf bytes.Buffer

View File

@@ -3,12 +3,7 @@
package output
import (
"fmt"
"strings"
"github.com/larksuite/cli/errs"
)
import "strings"
// Format represents an output format type.
type Format int
@@ -18,22 +13,11 @@ const (
FormatNDJSON
FormatTable
FormatCSV
FormatPretty
)
// Valid reports whether f is one of the defined output formats.
func (f Format) Valid() bool {
return f >= FormatJSON && f <= FormatPretty
}
// ParseFormat parses a format string into a Format value.
// The second return value is false if the format string was not recognized,
// in which case FormatJSON is returned as default.
//
// Prefer ParseFormatStrict at flag boundaries so an unknown --format fails
// loudly instead of degrading to JSON. ParseFormat's lenient fallback is kept
// for internal callers that only need a best-effort classification (e.g.
// ValidateJqFlags, which folds any non-JSON — known or not — into one branch).
func ParseFormat(s string) (Format, bool) {
switch strings.ToLower(s) {
case "json", "":
@@ -44,41 +28,21 @@ func ParseFormat(s string) (Format, bool) {
return FormatTable, true
case "csv":
return FormatCSV, true
case "pretty":
return FormatPretty, true
default:
return FormatJSON, false
}
}
// ParseFormatStrict parses a --format value into a typed Format, returning a
// typed ValidationError for any unrecognized value instead of silently falling
// back to JSON. Flag boundaries use this so an unknown format is a typed
// failure the caller cannot accidentally serve as JSON, and so the Emitter
// downstream only ever receives a canonical Format.
func ParseFormatStrict(s string) (Format, error) {
if f, ok := ParseFormat(s); ok {
return f, nil
}
return FormatJSON, errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown output format %q (want json, ndjson, table, csv, or pretty)", s).
WithParam("--format")
}
// String returns the string representation of a Format.
func (f Format) String() string {
switch f {
case FormatJSON:
return "json"
case FormatNDJSON:
return "ndjson"
case FormatTable:
return "table"
case FormatCSV:
return "csv"
case FormatPretty:
return "pretty"
default:
return fmt.Sprintf("unknown(%d)", int(f))
return "json"
}
}

View File

@@ -3,11 +3,7 @@
package output
import (
"testing"
"github.com/larksuite/cli/errs"
)
import "testing"
func TestParseFormat(t *testing.T) {
tests := []struct {
@@ -27,9 +23,6 @@ func TestParseFormat(t *testing.T) {
{"csv", FormatCSV, true},
{"CSV", FormatCSV, true},
{"Csv", FormatCSV, true},
{"pretty", FormatPretty, true},
{"PRETTY", FormatPretty, true},
{"Pretty", FormatPretty, true},
{"", FormatJSON, true},
// Legacy/unknown values fall back to JSON with ok=false
{"data", FormatJSON, false},
@@ -62,8 +55,7 @@ func TestFormatString(t *testing.T) {
{FormatNDJSON, "ndjson"},
{FormatTable, "table"},
{FormatCSV, "csv"},
{FormatPretty, "pretty"},
{Format(99), "unknown(99)"},
{Format(99), "json"}, // unknown falls back
}
for _, tt := range tests {
@@ -75,59 +67,3 @@ func TestFormatString(t *testing.T) {
})
}
}
func TestFormatValid(t *testing.T) {
for _, format := range []Format{FormatJSON, FormatNDJSON, FormatTable, FormatCSV, FormatPretty} {
if !format.Valid() {
t.Errorf("Format(%d).Valid() = false, want true", format)
}
}
if Format(99).Valid() {
t.Error("Format(99).Valid() = true, want false")
}
}
func TestParseFormatStrict(t *testing.T) {
valid := []struct {
input string
want Format
}{
{"", FormatJSON},
{"json", FormatJSON},
{"JSON", FormatJSON},
{"ndjson", FormatNDJSON},
{"table", FormatTable},
{"csv", FormatCSV},
{"pretty", FormatPretty},
{"Pretty", FormatPretty},
}
for _, tt := range valid {
t.Run("valid/"+tt.input, func(t *testing.T) {
got, err := ParseFormatStrict(tt.input)
if err != nil {
t.Fatalf("ParseFormatStrict(%q) error = %v, want nil", tt.input, err)
}
if got != tt.want {
t.Errorf("ParseFormatStrict(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
// Unknown values are a typed validation error on --format, never a silent
// fallback to JSON.
for _, input := range []string{"yaml", "xml", "data", "raw", "tabel"} {
t.Run("unknown/"+input, func(t *testing.T) {
got, err := ParseFormatStrict(input)
if err == nil {
t.Fatalf("ParseFormatStrict(%q) error = nil, want validation error", input)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation {
t.Fatalf("ParseFormatStrict(%q) problem = %#v, %v; want validation category", input, problem, ok)
}
if got != FormatJSON {
t.Errorf("ParseFormatStrict(%q) format = %v, want FormatJSON sentinel", input, got)
}
})
}
}

View File

@@ -70,14 +70,7 @@ func ValidateJqFlags(jqExpr, outputFlag, format string) error {
if outputFlag != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--jq and --output are mutually exclusive")
}
// Classify via ParseFormat so the JSON check is case-insensitive and shares
// the single canonical format definition. Only a recognized JSON format is
// compatible with --jq; every other value conflicts and is rejected: known
// non-JSON framework formats ("csv", "pretty", ...) and values ParseFormat
// does not recognize as JSON (a shortcut's own "markdown"/"data" enum, or an
// unknown format that ParseFormatStrict rejects downstream). The !ok guard
// keeps those unrecognized values out of the JSON-compatible branch.
if f, ok := ParseFormat(format); !ok || f != FormatJSON {
if format != "" && format != "json" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--jq and --format %s are mutually exclusive", format)
}
return ValidateJqExpression(jqExpr)

View File

@@ -160,13 +160,8 @@ func TestValidateJqFlags(t *testing.T) {
{name: "empty jq is noop", jqExpr: "", outputFlag: "file.json", format: "csv", wantErr: ""},
{name: "jq only", jqExpr: ".data", outputFlag: "", format: "", wantErr: ""},
{name: "jq with json format", jqExpr: ".data", outputFlag: "", format: "json", wantErr: ""},
// Format classification is case-insensitive via ParseFormat: an
// upper/mixed-case JSON must not be mistaken for a conflicting format.
{name: "jq with uppercase JSON format", jqExpr: ".data", outputFlag: "", format: "JSON", wantErr: ""},
{name: "jq with mixed-case Json format", jqExpr: ".data", outputFlag: "", format: "Json", wantErr: ""},
{name: "jq and output conflict", jqExpr: ".data", outputFlag: "out.json", format: "", wantErr: "--jq and --output are mutually exclusive"},
{name: "jq and csv conflict", jqExpr: ".data", outputFlag: "", format: "csv", wantErr: "--jq and --format csv are mutually exclusive"},
{name: "jq and pretty conflict", jqExpr: ".data", outputFlag: "", format: "pretty", wantErr: "--jq and --format pretty are mutually exclusive"},
{name: "jq and ndjson conflict", jqExpr: ".data", outputFlag: "", format: "ndjson", wantErr: "--jq and --format ndjson are mutually exclusive"},
{name: "invalid expression", jqExpr: "invalid[", outputFlag: "", format: "", wantErr: "invalid jq expression"},
}

View File

@@ -81,6 +81,21 @@ func injectNotice(data interface{}) {
m["_notice"] = notice
}
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
func PrintNdjson(w io.Writer, data interface{}) {
if arr, ok := data.([]interface{}); ok {
for _, item := range arr {
if err := WriteNDJSON(w, item); isOutputMarshalError(err) {
legacyStderrf("ndjson marshal error: %v\n", err)
}
}
return
}
if err := WriteNDJSON(w, data); isOutputMarshalError(err) {
legacyStderrf("ndjson marshal error: %v\n", err)
}
}
// WriteNDJSON writes data as NDJSON and returns marshal or write errors.
func WriteNDJSON(w io.Writer, data interface{}) error {
emit := func(item interface{}) error {

View File

@@ -5,7 +5,7 @@
"stderr": ""
},
"format_raw_json_preserves_html": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"<p>a&b</p>\"\n }\n}\n",
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
"stderr": ""
},
"jq_invalid_expression": {
@@ -22,9 +22,9 @@
"exit_code": 2
}
},
"jq_safety_alert_writes_stderr_warning": {
"jq_safety_alert_without_stderr_warning": {
"stdout": "1\n",
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
"stderr": ""
},
"jq_scalar": {
"stdout": "Alice\n",
@@ -62,12 +62,16 @@
"stdout": "pretty:fixture\n",
"stderr": ""
},
"pretty_without_renderer": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"name\": \"Alice\"\n }\n}\n",
"stderr": ""
},
"raw_jq_complex": {
"stdout": "{\n \"html\": \"<p>a&b</p>\"\n}\n",
"stdout": "{\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n}\n",
"stderr": ""
},
"raw_json_preserves_html": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"<p>a&b</p>\"\n }\n}\n",
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
"stderr": ""
},
"scanner_block": {
@@ -87,27 +91,17 @@
"exit_code": 6
}
},
"scanner_error_block_mode_fails_closed": {
"stdout": "",
"stderr": "warning: content safety scan error: scanner unavailable\n",
"error": {
"go_type": "*errs.ContentSafetyError",
"json": {
"type": "policy",
"subtype": "content_safety",
"message": "content-safety scan did not complete; blocked (block mode)"
},
"message": "content-safety scan did not complete; blocked (block mode)",
"exit_code": 6
}
},
"scanner_error_warn_mode_fails_open": {
"scanner_error_fails_open": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
"stderr": "warning: content safety scan error: scanner unavailable\n"
},
"table_with_safety_warning": {
"stdout": "id name \n── ─────\n1 Alice\n",
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
},
"unknown_format_data_envelope_notice": {
"stdout": "{\n \"_notice\": {\n \"skills\": {\n \"current\": \"1.0.0\"\n }\n },\n \"ok\": true,\n \"value\": \"fixture\"\n}\n",
"stderr": "warning: unknown format \"yaml\", falling back to json\n"
}
}
}

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deviceinfo collects the platform hardware product model and the
// platform values used by device-related risk-control headers.
package riskcontrol
import (
"runtime"
"strings"
"sync"
"unicode"
"unicode/utf8"
"golang.org/x/net/http/httpguts"
)
// OSType is the server-side risk-control operating-system enum.
type OSType string
// OS type enum values for X-Agent-Os-Type.
const (
OSTypeUnknown = "0"
OSTypeWindows = "1"
OSTypeLinux = "2"
OSTypeMacOS = "3"
)
const (
// TerminalTypePC is the fixed X-Agent-Terminal-Type value for the CLI.
TerminalTypePC = "1"
// Unknown is used when the hardware product model cannot be collected.
Unknown = "Unknown"
// deviceModelMaxBytes bounds the value added to X-Agent-Device-Type.
// Device models are short identifiers; a larger value is treated as
// malformed rather than truncated so the header never misrepresents it.
deviceModelMaxBytes = 256
)
// Snapshot contains the deliberately small risk-control signal set.
// ProductModel is omitted when the platform cannot provide a safe value.
type Snapshot struct {
OSType OSType
ProductModel string
}
// Source supplies one immutable process-level snapshot.
type Source interface {
Snapshot() Snapshot
}
// HostSource lazily reads host signals once, after outbound policy authorizes
// the first request. Failed probes are cached and are not retried per request.
type HostSource struct {
once sync.Once
value Snapshot
readModel func() string
}
// NewHostSource creates the production host signal source.
func NewHostSource() *HostSource {
return &HostSource{readModel: readDeviceModel}
}
// Snapshot returns the cached host signal snapshot.
func (s *HostSource) Snapshot() Snapshot {
if s == nil {
return Snapshot{}
}
s.once.Do(func() {
readModel := s.readModel
if readModel == nil {
readModel = readDeviceModel
}
s.value = Snapshot{
OSType: GetOSType(OSName()),
ProductModel: normalizeDeviceModel(readModel()),
}
})
return s.value
}
// normalizeModel removes non-printable characters and returns a model only
// when the remaining text is safe to use as an HTTP header value. Input that
// cannot produce a valid model is rejected so Get can fall back to Unknown.
func normalizeDeviceModel(model string) string {
if !utf8.ValidString(model) {
return ""
}
model = strings.Map(func(r rune) rune {
switch {
case r == '\r' || r == '\n' || r == '\x00':
return -1
case unicode.IsSpace(r):
return ' '
case unicode.IsPrint(r):
return r
default:
return -1
}
}, model)
model = strings.Join(strings.Fields(model), " ")
if model == "" || len(model) > deviceModelMaxBytes {
return ""
}
if !httpguts.ValidHeaderFieldValue(model) {
return ""
}
return model
}
// GetOSType maps a platform name to the X-Agent-Os-Type enum.
func GetOSType(osName string) OSType {
switch osName {
case "Windows":
return OSTypeWindows
case "Linux":
return OSTypeLinux
case "MacOS":
return OSTypeMacOS
default:
return OSTypeUnknown
}
}
// OSName returns the platform name used by GetOSType.
func OSName() string {
switch runtime.GOOS {
case "darwin":
return "MacOS"
case "windows":
return "Windows"
case "linux":
return "Linux"
default:
return runtime.GOOS
}
}

View File

@@ -0,0 +1,27 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/unix"
// readDeviceModel reads the current product key first and falls back to the
// legacy model key. Trying both keys is more robust than branching on a macOS
// version because virtualized or restricted environments may expose only one.
func readDeviceModel() string {
return readDarwinDeviceModel(unix.Sysctl)
}
func readDarwinDeviceModel(readSysctl func(string) (string, error)) string {
for _, key := range [...]string{"hw.product", "hw.model"} {
model, err := readSysctl(key)
if err == nil {
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
}
return ""
}

View File

@@ -0,0 +1,48 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadDarwinDeviceModelPrefersProductAndFallsBackToModel(t *testing.T) {
t.Run("product available", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.product" {
return "Mac16,1", nil
}
return "", errors.New("unexpected fallback")
})
if got != "Mac16,1" {
t.Fatalf("model = %q, want %q", got, "Mac16,1")
}
if want := []string{"hw.product"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
t.Run("product unavailable", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.model" {
return "MacBookPro18,3", nil
}
return "", errors.New("not available")
})
if got != "MacBookPro18,3" {
t.Fatalf("model = %q, want %q", got, "MacBookPro18,3")
}
if want := []string{"hw.product", "hw.model"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
}

View File

@@ -0,0 +1,17 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns a stable device model for Linux. DMI and device-tree
// values vary widely and can expose the host or virtualization platform when
// the CLI runs in a container or sandbox.
func readDeviceModel() string {
return readLinuxDeviceModel()
}
func readLinuxDeviceModel() string {
return "linux"
}

View File

@@ -0,0 +1,20 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "testing"
func TestReadDeviceModelReturnsLinux(t *testing.T) {
if got := readDeviceModel(); got != "linux" {
t.Fatalf("readDeviceModel() = %q, want %q", got, "linux")
}
}
func TestReadLinuxDeviceModel(t *testing.T) {
if got := readLinuxDeviceModel(); got != "linux" {
t.Fatalf("readLinuxDeviceModel() = %q, want %q", got, "linux")
}
}

View File

@@ -0,0 +1,11 @@
//go:build !darwin && !windows && !linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns an empty model on unsupported platforms.
func readDeviceModel() string {
return ""
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"unicode"
)
func TestHostSourceCachesNonEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return " MacBookPro18,3\n"
}}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("first Snapshot().ProductModel = %q, want %q", got.ProductModel, "MacBookPro18,3")
}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached model", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceCachesEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return ""
}}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("first Snapshot().ProductModel = %q, want empty", got.ProductModel)
}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached empty result", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceReadsOnceAcrossConcurrentCalls(t *testing.T) {
var calls atomic.Int32
s := &HostSource{readModel: func() string {
calls.Add(1)
return "ThinkPad X1 Carbon"
}}
const goroutines = 32
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
snapshot := s.Snapshot()
if snapshot.ProductModel != "ThinkPad X1 Carbon" {
t.Errorf("Snapshot().ProductModel = %q, want %q", snapshot.ProductModel, "ThinkPad X1 Carbon")
}
}()
}
wg.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("read called %d times, want 1", got)
}
}
func TestNormalizeDeviceModel(t *testing.T) {
tests := []struct {
name string
model string
want string
}{
{name: "trims surrounding whitespace", model: " MacBookPro18,3\n", want: "MacBookPro18,3"},
{name: "trims device tree terminator", model: "Raspberry Pi 5\x00", want: "Raspberry Pi 5"},
{name: "allows printable Unicode", model: "联想 ThinkPad X1", want: "联想 ThinkPad X1"},
{name: "rejects empty", model: " \t\r\n"},
{name: "rejects invalid UTF-8", model: string([]byte{'M', 0xff, '1'})},
{name: "removes CRLF", model: "model\r\nname", want: "modelname"},
{name: "normalizes tab", model: "model\tname", want: "model name"},
{name: "removes NUL", model: "model\x00name", want: "modelname"},
{name: "removes control character", model: "model\x1fname", want: "modelname"},
{name: "removes DEL", model: "model\x7fname", want: "modelname"},
{name: "normalizes Unicode line separator", model: "model\u2028name", want: "model name"},
{name: "collapses whitespace", model: " model\t \u00a0 name ", want: "model name"},
{name: "accepts maximum byte length", model: strings.Repeat("a", deviceModelMaxBytes), want: strings.Repeat("a", deviceModelMaxBytes)},
{name: "rejects overlong value", model: strings.Repeat("a", deviceModelMaxBytes+1)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeDeviceModel(tt.model); got != tt.want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", tt.model, got, tt.want)
}
})
}
}
func TestNormalizeDeviceModelRemovesHTTPControlBytes(t *testing.T) {
for value := 0; value <= 0x7f; value++ {
if value >= 0x20 && value < 0x7f {
continue
}
t.Run(fmt.Sprintf("0x%02x", value), func(t *testing.T) {
model := "model" + string(rune(value)) + "name"
want := "modelname"
if value != '\r' && value != '\n' && value != '\x00' && unicode.IsSpace(rune(value)) {
want = "model name"
}
if got := normalizeDeviceModel(model); got != want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", model, got, want)
}
})
}
}
func TestGetOSType(t *testing.T) {
tests := []struct {
name string
want OSType
}{
{name: "Windows", want: OSTypeWindows},
{name: "Linux", want: OSTypeLinux},
{name: "MacOS", want: OSTypeMacOS},
{name: "unknown", want: OSTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetOSType(tt.name); got != tt.want {
t.Errorf("GetOSType(%q) = %q, want %q", tt.name, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,44 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/windows/registry"
// systemInfoRegistryPaths lists registry locations in device-model lookup order.
var systemInfoRegistryPaths = [...]string{
`HARDWARE\DESCRIPTION\System\BIOS`,
`SYSTEM\CurrentControlSet\Control\SystemInformation`,
`SYSTEM\HardwareConfig\Current`,
}
// readDeviceModel returns the first product name found in the Windows registry.
func readDeviceModel() string {
return readWindowsDeviceModel(readWindowsRegistryModel)
}
func readWindowsRegistryModel(path string) (string, error) {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.READ)
if err != nil {
return "", err
}
defer key.Close()
model, _, err := key.GetStringValue("SystemProductName")
return model, err
}
func readWindowsDeviceModel(readRegistryModel func(string) (string, error)) string {
for _, path := range systemInfoRegistryPaths {
model, err := readRegistryModel(path)
if err != nil {
continue
}
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
return ""
}

View File

@@ -0,0 +1,78 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadWindowsDeviceModelFallback(t *testing.T) {
readError := errors.New("registry read failed")
tests := []struct {
name string
values map[string]string
errors map[string]error
want string
wantPaths []string
}{
{
name: "first path wins",
values: map[string]string{systemInfoRegistryPaths[0]: "Surface Laptop"},
want: "Surface Laptop",
wantPaths: []string{systemInfoRegistryPaths[0]},
},
{
name: "read failure falls back",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
},
values: map[string]string{
systemInfoRegistryPaths[1]: "ThinkPad X1 Carbon",
},
want: "ThinkPad X1 Carbon",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "empty normalized value falls back",
values: map[string]string{
systemInfoRegistryPaths[0]: " \r\n\x00",
systemInfoRegistryPaths[1]: "Latitude 7450",
},
want: "Latitude 7450",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "all paths fail",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
systemInfoRegistryPaths[1]: readError,
systemInfoRegistryPaths[2]: readError,
},
wantPaths: systemInfoRegistryPaths[:],
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var paths []string
got := readWindowsDeviceModel(func(path string) (string, error) {
paths = append(paths, path)
if err := tt.errors[path]; err != nil {
return "", err
}
return tt.values[path], nil
})
if got != tt.want {
t.Fatalf("model = %q, want %q", got, tt.want)
}
if !reflect.DeepEqual(paths, tt.wantPaths) {
t.Fatalf("registry paths = %v, want %v", paths, tt.wantPaths)
}
})
}
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/internal/core"
internaltransport "github.com/larksuite/cli/internal/transport"
)
const (
HeaderProductModel = "X-Agent-Device-Type"
HeaderOSType = "X-Agent-Os-Type"
)
var restrictedHeaders = [...]string{HeaderProductModel, HeaderOSType}
// Transport is the feature's final outbound boundary. It removes caller- or
// extension-supplied signal headers first and writes trusted values only after
// authorizing an official SDK origin and authentication state.
type Transport struct {
next http.RoundTripper
source Source
}
// NewTransport creates the final SDK outbound policy boundary. A nil source
// disables collection and injection while preserving restricted-header
// stripping for opt-out and extension-credential requests.
func NewTransport(next http.RoundTripper, source Source) *Transport {
if next == nil {
next = internaltransport.Fallback()
}
return &Transport{
next: next,
source: source,
}
}
// RoundTrip implements http.RoundTripper.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
if req.Header == nil {
req.Header = make(http.Header)
}
stripRestrictedHeaders(req.Header)
if t.source != nil && t.routeAllowsSignals(req) {
snapshot := t.source.Snapshot()
if isSupportedOSType(snapshot.OSType) {
req.Header.Set(HeaderOSType, string(snapshot.OSType))
}
if model := normalizeDeviceModel(snapshot.ProductModel); model != "" {
req.Header.Set(HeaderProductModel, model)
}
}
return t.next.RoundTrip(req)
}
func isSupportedOSType(value OSType) bool {
switch value {
case OSTypeWindows, OSTypeLinux, OSTypeMacOS:
return true
default:
return false
}
}
func stripRestrictedHeaders(header http.Header) {
for name := range header {
for _, restricted := range restrictedHeaders {
if strings.EqualFold(name, restricted) {
delete(header, name)
break
}
}
}
}
type origin struct {
scheme string
host string
port string
}
var officialFeishuOrigins = [...]origin{
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open),
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts),
}
func (t *Transport) routeAllowsSignals(req *http.Request) bool {
if req == nil || req.URL == nil {
return false
}
return isOfficialFeishuOrigin(originOf(req.URL))
}
func originOf(value *url.URL) origin {
if value == nil {
return origin{}
}
scheme := strings.ToLower(value.Scheme)
port := value.Port()
if port == "" {
switch scheme {
case "https":
port = "443"
case "http":
port = "80"
}
}
return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port}
}
func apiOrigin(brand core.LarkBrand, endpointURL string) origin {
endpoint, err := url.Parse(endpointURL)
if err != nil {
return origin{}
}
return originOf(endpoint)
}
func isOfficialFeishuOrigin(candidate origin) bool {
if candidate.scheme != "https" || candidate.port != "443" {
return false
}
for _, official := range officialFeishuOrigins {
if candidate == official {
return true
}
}
return false
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"strings"
"sync/atomic"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
type countingSource struct {
calls atomic.Int32
}
func (s *countingSource) Snapshot() Snapshot {
s.calls.Add(1)
return Snapshot{OSType: OSTypeMacOS, ProductModel: "Mac16,1"}
}
type staticSource Snapshot
func (s staticSource) Snapshot() Snapshot { return Snapshot(s) }
func TestTransportAuthorizesBeforeCollecting(t *testing.T) {
tests := []struct {
name string
requestURL string
authorization string
wantSignals bool
}{
{name: "authenticated official HTTPS", requestURL: "https://open.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "Lark official HTTPS", requestURL: "https://open.larksuite.com/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "official explicit HTTPS port", requestURL: "https://OPEN.FEISHU.CN:443/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "unauthenticated", requestURL: "https://open.feishu.cn/open-apis/test", wantSignals: true},
{name: "official non-OpenAPI origin", requestURL: "https://accounts.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "off domain", requestURL: "https://example.com/test", authorization: "Bearer token", wantSignals: false},
{name: "lookalike", requestURL: "https://open.feishu.cn.evil.example/test", authorization: "Bearer token", wantSignals: false},
{name: "plain HTTP", requestURL: "http://open.feishu.cn/test", authorization: "Bearer token", wantSignals: false},
{name: "non-default port", requestURL: "https://open.feishu.cn:8443/test", authorization: "Bearer token", wantSignals: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
source := &countingSource{}
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, test.requestURL, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", test.authorization)
req.Header.Set(HeaderOSType, "caller-value")
req.Header.Set(HeaderProductModel, "caller-value")
req.Header["x-agent-device-type"] = []string{"non-canonical-caller-value"}
resp, err := NewTransport(base, source).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
gotSignals := received.Get(HeaderOSType) != ""
if gotSignals != test.wantSignals {
t.Fatalf("signals present = %t, want %t; headers=%v", gotSignals, test.wantSignals, received)
}
wantCalls := int32(0)
if test.wantSignals {
wantCalls = 1
}
if got := source.calls.Load(); got != wantCalls {
t.Fatalf("Snapshot calls = %d, want %d", got, wantCalls)
}
if got := req.Header.Get(HeaderOSType); got != "caller-value" {
t.Fatalf("caller request OS header = %q, want unchanged", got)
}
if got := req.Header.Get(HeaderProductModel); got != "caller-value" {
t.Fatalf("caller request product-model header = %q, want unchanged", got)
}
if !test.wantSignals {
for name := range received {
if strings.EqualFold(name, HeaderProductModel) || strings.EqualFold(name, HeaderOSType) {
t.Fatalf("restricted header leaked as %q", name)
}
}
}
})
}
}
func TestTransportValidatesSourceSnapshot(t *testing.T) {
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := NewTransport(base, staticSource{
OSType: OSType("unsupported"),
ProductModel: "unsafe\nvalue",
}).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(HeaderOSType) == "" && received.Get(HeaderProductModel) == "" {
t.Fatalf("no signals collected: %v", received)
}
}

View File

@@ -24,15 +24,6 @@ type regexProvider struct {
func (p *regexProvider) Name() string { return "regex" }
func (p *regexProvider) Scan(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.scan(ctx, req, false)
}
func (p *regexProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
req.FullText = true
return p.scan(ctx, req, true)
}
func (p *regexProvider) scan(ctx context.Context, req extcs.ScanRequest, fullText bool) (*extcs.Alert, error) {
cfg, err := p.loadOrCreate(req.ErrOut)
if err != nil {
return nil, err
@@ -46,11 +37,9 @@ func (p *regexProvider) scan(ctx context.Context, req extcs.ScanRequest, fullTex
}
data := normalize(req.Data)
s := &scanner{rules: cfg.Rules, fullText: fullText}
s := &scanner{rules: cfg.Rules}
hits := make(map[string]struct{})
if err := s.walk(ctx, data, hits, 0); err != nil {
return nil, err
}
s.walk(ctx, data, hits, 0)
if len(hits) == 0 {
return nil, nil

View File

@@ -4,22 +4,15 @@
package contentsafety
import (
"bytes"
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/output"
)
var _ extcs.FullTextProvider = (*regexProvider)(nil)
func writeTestConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
@@ -77,28 +70,6 @@ func TestProvider_ScanCleanData(t *testing.T) {
}
}
func TestProvider_ScanCanceledContextReturnsError(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [{"id": "r1", "pattern": "(?i)inject"}]
}`)
p := &regexProvider{configDir: dir}
ctx, cancel := context.WithCancel(context.Background())
cancel()
alert, err := p.Scan(ctx, extcs.ScanRequest{
Path: "im.messages_search",
Data: map[string]any{"text": "Hello, clean data"},
ErrOut: io.Discard,
})
if alert != nil {
t.Fatalf("Scan() alert = %v, want nil", alert)
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("Scan() error = %v, want context.Canceled", err)
}
}
func TestProvider_ScanNotInAllowlist(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["im"],
@@ -170,169 +141,6 @@ func TestProvider_ScanNestedData(t *testing.T) {
}
}
func TestProvider_FullTextBypassesPerStringCap(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [{"id": "tail", "pattern": "TAIL_MARKER"}]
}`)
p := &regexProvider{configDir: dir}
text := strings.Repeat("x", maxStringBytes+1) + "TAIL_MARKER"
alert, err := p.Scan(context.Background(), extcs.ScanRequest{
Path: "test",
Data: text,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("Scan() structured-data error = %v", err)
}
if alert != nil {
t.Fatalf("structured-data scan should retain the per-string cap, got %v", alert)
}
alert, err = p.ScanFullText(context.Background(), extcs.ScanRequest{
Path: "test",
Data: text,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("ScanFullText() error = %v", err)
}
if alert == nil || len(alert.MatchedRules) != 1 || alert.MatchedRules[0] != "tail" {
t.Fatalf("full-text scan alert = %v, want tail match", alert)
}
}
func TestEmitterStructuredBlockFullTextWritesZeroBytesAndWarnEmits(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [
{"id": "prefix", "pattern": "PREFIX_MARKER"},
{"id": "tail", "pattern": "TAIL_MARKER"}
]
}`)
p := &regexProvider{configDir: dir}
extcs.Register(p)
t.Cleanup(func() { extcs.Register(nil) })
data := map[string]any{
"text": "PREFIX_MARKER" + strings.Repeat("x", maxStringBytes+1) + "TAIL_MARKER",
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
blockStdout := &bytes.Buffer{}
blockEmitter := output.NewEmitter(output.EmitterConfig{
Out: blockStdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := blockEmitter.Success(data, output.EmitOptions{Format: output.FormatJSON})
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("block Emitter.Success() error = %T, want *errs.ContentSafetyError", err)
}
foundTail := false
for _, ruleID := range safetyErr.Rules {
if ruleID == "tail" {
foundTail = true
break
}
}
if !foundTail {
t.Fatalf("block matched rules = %v, want tail match beyond per-string cap", safetyErr.Rules)
}
if blockStdout.Len() != 0 {
t.Fatalf("block stdout bytes = %d, want 0", blockStdout.Len())
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
warnStdout := &bytes.Buffer{}
warnStderr := &bytes.Buffer{}
warnEmitter := output.NewEmitter(output.EmitterConfig{
Out: warnStdout,
ErrOut: warnStderr,
CommandPath: "lark-cli fixture +emit",
})
if err := warnEmitter.Success(data, output.EmitOptions{Format: output.FormatJSON}); err != nil {
t.Fatalf("warn Emitter.Success() error = %v", err)
}
if warnStdout.Len() == 0 {
t.Fatal("warn stdout bytes = 0, want emitted structured payload")
}
if !strings.Contains(warnStdout.String(), `"_content_safety_alert"`) ||
!strings.Contains(warnStdout.String(), `"prefix"`) {
t.Fatalf("warn stdout = %q, want embedded prefix content-safety warning", warnStdout.String())
}
if warnStderr.Len() != 0 {
t.Fatalf("warn stderr = %q, want empty for JSON envelope warning", warnStderr.String())
}
}
func TestEmitterStructuredBlockDepthIncompleteWritesZeroBytes(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [{"id": "deep", "pattern": "DEEP_MARKER"}]
}`)
p := &regexProvider{configDir: dir}
extcs.Register(p)
t.Cleanup(func() { extcs.Register(nil) })
var data any = "DEEP_MARKER"
for i := 0; i < maxDepth+5; i++ {
data = map[string]any{"nested": data}
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(data, output.EmitOptions{Format: output.FormatJSON})
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", err)
}
if !strings.Contains(safetyErr.Message, "scan did not complete") {
t.Fatalf("Emitter.Success() error = %v, want scan-incomplete message", err)
}
if stdout.Len() != 0 {
t.Fatalf("block stdout bytes = %d, want 0", stdout.Len())
}
}
func TestProvider_ScanDetectsInjectionInMapKey(t *testing.T) {
// A rule match hiding in a map key (which JSON/NDJSON/table/CSV all emit)
// must be detected, not just matches in values.
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [{"id": "override", "pattern": "(?i)ignore previous instructions"}]
}`)
p := &regexProvider{configDir: dir}
data := map[string]any{"ignore previous instructions": "ok"}
for _, tc := range []struct {
name string
scan func() (*extcs.Alert, error)
}{
{"Scan", func() (*extcs.Alert, error) {
return p.Scan(context.Background(), extcs.ScanRequest{Path: "test", Data: data, ErrOut: io.Discard})
}},
{"ScanFullText", func() (*extcs.Alert, error) {
return p.ScanFullText(context.Background(), extcs.ScanRequest{Path: "test", Data: data, ErrOut: io.Discard})
}},
} {
t.Run(tc.name, func(t *testing.T) {
alert, err := tc.scan()
if err != nil {
t.Fatalf("%s() error = %v", tc.name, err)
}
if alert == nil || len(alert.MatchedRules) != 1 || alert.MatchedRules[0] != "override" {
t.Fatalf("%s() alert = %v, want override match on the map key", tc.name, alert)
}
})
}
}
func TestProvider_EmptyRulesNoAlert(t *testing.T) {
dir := writeTestConfig(t, `{"allowlist":["all"],"rules":[]}`)
p := &regexProvider{configDir: dir}

View File

@@ -5,8 +5,6 @@ package contentsafety
import (
"context"
"errors"
"fmt"
"regexp"
)
@@ -15,52 +13,38 @@ const (
maxDepth = 64
)
var errScanIncomplete = errors.New("content safety scan incomplete")
type rule struct {
ID string
Pattern *regexp.Regexp
}
type scanner struct {
rules []rule
fullText bool
rules []rule
}
func (s *scanner) walk(ctx context.Context, v any, hits map[string]struct{}, depth int) error {
if err := ctx.Err(); err != nil {
return err
}
func (s *scanner) walk(ctx context.Context, v any, hits map[string]struct{}, depth int) {
if depth > maxDepth {
if s.fullText {
return fmt.Errorf("%w: maximum depth %d exceeded", errScanIncomplete, maxDepth)
}
return nil
return
}
if ctx.Err() != nil {
return
}
switch t := v.(type) {
case string:
s.scanString(t, hits)
case map[string]any:
for k, child := range t {
// Scan the key too: JSON/NDJSON/table/CSV all emit map keys, so a
// rule match hiding in a key must not slip past block mode.
s.scanString(k, hits)
if err := s.walk(ctx, child, hits, depth+1); err != nil {
return err
}
for _, child := range t {
s.walk(ctx, child, hits, depth+1)
}
case []any:
for _, child := range t {
if err := s.walk(ctx, child, hits, depth+1); err != nil {
return err
}
s.walk(ctx, child, hits, depth+1)
}
}
return ctx.Err()
}
func (s *scanner) scanString(text string, hits map[string]struct{}) {
if !s.fullText && len(text) > maxStringBytes {
if len(text) > maxStringBytes {
text = text[:maxStringBytes]
}
for _, r := range s.rules {

View File

@@ -5,7 +5,6 @@ package contentsafety
import (
"context"
"errors"
"regexp"
"testing"
)
@@ -46,23 +45,6 @@ func TestScanString_Truncate(t *testing.T) {
}
}
func TestScanString_FullTextDoesNotTruncate(t *testing.T) {
s := &scanner{
rules: []rule{testRule("tail", `TAIL_MARKER`)},
fullText: true,
}
big := make([]byte, maxStringBytes+100)
for i := range big {
big[i] = 'x'
}
copy(big[maxStringBytes+10:], "TAIL_MARKER")
hits := make(map[string]struct{})
s.scanString(string(big), hits)
if _, ok := hits["tail"]; !ok {
t.Error("full-text scan should match marker beyond maxStringBytes")
}
}
func TestScanString_SkipsDuplicate(t *testing.T) {
s := &scanner{rules: []rule{testRule("r1", `match`)}}
hits := map[string]struct{}{"r1": {}}
@@ -80,34 +62,16 @@ func TestWalk_NestedMap(t *testing.T) {
},
}
hits := make(map[string]struct{})
if err := s.walk(context.Background(), data, hits, 0); err != nil {
t.Fatalf("walk() error = %v", err)
}
s.walk(context.Background(), data, hits, 0)
if _, ok := hits["found"]; !ok {
t.Error("expected to find 'inject' in nested map")
}
}
func TestWalk_ScansMapKeys(t *testing.T) {
// JSON/NDJSON/table/CSV all emit map keys, so a rule match hiding in a key
// must be scanned too — not only the value.
s := &scanner{rules: []rule{testRule("found", `(?i)inject`)}}
data := map[string]any{"please inject this": "harmless value"}
hits := make(map[string]struct{})
if err := s.walk(context.Background(), data, hits, 0); err != nil {
t.Fatalf("walk() error = %v", err)
}
if _, ok := hits["found"]; !ok {
t.Error("expected to match a rule hiding in a map key")
}
}
func TestWalk_Array(t *testing.T) {
s := &scanner{rules: []rule{testRule("found", `(?i)inject`)}}
hits := make(map[string]struct{})
if err := s.walk(context.Background(), []any{"normal", "try to inject"}, hits, 0); err != nil {
t.Fatalf("walk() error = %v", err)
}
s.walk(context.Background(), []any{"normal", "try to inject"}, hits, 0)
if _, ok := hits["found"]; !ok {
t.Error("expected to find 'inject' in array")
}
@@ -120,42 +84,18 @@ func TestWalk_MaxDepth(t *testing.T) {
data = map[string]any{"n": data}
}
hits := make(map[string]struct{})
if err := s.walk(context.Background(), data, hits, 0); err != nil {
t.Fatalf("walk() error = %v", err)
}
s.walk(context.Background(), data, hits, 0)
if _, ok := hits["deep"]; ok {
t.Error("should not reach string beyond maxDepth")
}
}
func TestWalk_FullTextMaxDepthReturnsIncomplete(t *testing.T) {
s := &scanner{
rules: []rule{testRule("deep", `secret`)},
fullText: true,
}
var data any = "secret"
for i := 0; i < maxDepth+5; i++ {
data = map[string]any{"n": data}
}
hits := make(map[string]struct{})
err := s.walk(context.Background(), data, hits, 0)
if !errors.Is(err, errScanIncomplete) {
t.Fatalf("walk() error = %v, want errScanIncomplete", err)
}
if _, ok := hits["deep"]; ok {
t.Error("full-text walk should report incomplete before matching data beyond maxDepth")
}
}
func TestWalk_ContextCancel(t *testing.T) {
s := &scanner{rules: []rule{testRule("found", `target`)}}
ctx, cancel := context.WithCancel(context.Background())
cancel()
hits := make(map[string]struct{})
err := s.walk(ctx, map[string]any{"key": "target"}, hits, 0)
if !errors.Is(err, context.Canceled) {
t.Fatalf("walk() error = %v, want context.Canceled", err)
}
s.walk(ctx, map[string]any{"key": "target"}, hits, 0)
if _, ok := hits["found"]; ok {
t.Error("should not match after context cancel")
}

View File

@@ -17,6 +17,13 @@ func SafeInputPath(path string) (string, error) {
return localfileio.SafeInputPath(path)
}
// LocalInputPath validates a local input path without restricting it to the
// current working directory. It delegates to localfileio.LocalInputPath so
// command validation and shared local-file readers use one policy.
func LocalInputPath(path string) (string, error) {
return localfileio.LocalInputPath(path)
}
// SafeEnvDirPath validates an environment-provided application directory path.
// Delegates to localfileio.SafeEnvDirPath.
func SafeEnvDirPath(path, envName string) (string, error) {

View File

@@ -211,6 +211,18 @@ func TestSafeLocalFlagPath(t *testing.T) {
}
}
func TestLocalInputPath_AllowsLocalPathsAndRejectsUnsafeCharacters(t *testing.T) {
for _, path := range []string{"/tmp/report.pdf", "../report.pdf"} {
got, err := LocalInputPath(path)
if err != nil || got != path {
t.Fatalf("LocalInputPath(%q) = %q, %v; want unchanged path", path, got, err)
}
}
if _, err := LocalInputPath("report\n.pdf"); err == nil {
t.Fatal("LocalInputPath() unexpectedly accepted a control character")
}
}
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"path/filepath"
"strings"
"unicode"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/vfs"
@@ -22,6 +23,32 @@ func SafeInputPath(path string) (string, error) {
return safePath(path, "--file")
}
// LocalInputPath validates an input path in the process local filesystem
// namespace. It intentionally does not impose cwd containment or canonicalize
// the path: absolute paths, parent-relative paths, and symlink traversal retain
// their normal OS semantics. Character validation remains mandatory because
// paths are user-controlled and may appear in errors or progress output.
func LocalInputPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("local input path must not be empty")
}
if strings.IndexFunc(path, unicode.IsControl) >= 0 {
return "", fmt.Errorf("local input path must not contain control characters")
}
if err := charcheck.RejectControlChars(path, "local input path"); err != nil {
return "", err
}
if err := validateLocalInputPlatform(path); err != nil {
return "", err
}
return path, nil
}
func isWindowsNonLocalNamespace(path string) bool {
normalized := strings.ReplaceAll(path, "/", `\`)
return strings.HasPrefix(normalized, `\\`) || strings.HasPrefix(normalized, `\??\`)
}
// 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) {

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !windows
package localfileio
func validateLocalInputPlatform(string) error { return nil }

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package localfileio
import (
"fmt"
"path/filepath"
"strings"
)
func validateLocalInputPlatform(path string) error {
if isWindowsNonLocalNamespace(path) {
return fmt.Errorf("local input path must not use a Windows network or device namespace")
}
cleaned := filepath.Clean(path)
volume := filepath.VolumeName(cleaned)
remainder := strings.TrimLeft(cleaned[len(volume):], `\/`)
for _, component := range strings.FieldsFunc(remainder, func(r rune) bool {
return r == '\\' || r == '/'
}) {
if component == "." || component == ".." {
continue
}
if !filepath.IsLocal(component) {
return fmt.Errorf("local input path contains a reserved Windows path component %q", component)
}
}
return nil
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package localfileio
import "testing"
func TestLocalInputPath_RejectsWindowsNetworkDeviceAndReservedPaths(t *testing.T) {
for _, input := range []string{
`\\server\share\report.pdf`,
`//server/share/report.pdf`,
`\\.\pipe\upload`,
`\\?\C:\Users\agent\report.pdf`,
`\\?\UNC\server\share\report.pdf`,
`\??\C:\Users\agent\report.pdf`,
`C:\Users\agent\NUL.txt`,
`CON`,
} {
t.Run(input, func(t *testing.T) {
if _, err := LocalInputPath(input); err == nil {
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
}
})
}
}

View File

@@ -4,6 +4,7 @@
package localfileio
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -71,6 +72,72 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
}
}
func TestLocalInputPath_AllowsLocalNamespaceWithoutRewriting(t *testing.T) {
for _, input := range []string{
"/tmp/report.pdf",
"../outside/report.pdf",
"./report.pdf",
"nested/../report.pdf",
`C:\Users\agent\report.pdf`,
"报告.pdf",
} {
t.Run(input, func(t *testing.T) {
got, err := LocalInputPath(input)
if err != nil {
t.Fatalf("LocalInputPath(%q) error = %v", input, err)
}
if got != input {
t.Fatalf("LocalInputPath(%q) = %q, want path preserved verbatim", input, got)
}
})
}
}
func TestWindowsNonLocalNamespace(t *testing.T) {
for _, input := range []string{
`\\server\share\report.pdf`,
`//server/share/report.pdf`,
`\\.\pipe\upload`,
`\\?\C:\Users\agent\report.pdf`,
`\\?\UNC\server\share\report.pdf`,
`\??\C:\Users\agent\report.pdf`,
} {
if !isWindowsNonLocalNamespace(input) {
t.Errorf("isWindowsNonLocalNamespace(%q) = false, want true", input)
}
}
for _, input := range []string{
`C:\Users\agent\report.pdf`,
`C:/Users/agent/report.pdf`,
`..\outside\report.pdf`,
`.\report.pdf`,
} {
if isWindowsNonLocalNamespace(input) {
t.Errorf("isWindowsNonLocalNamespace(%q) = true, want false", input)
}
}
}
func TestLocalInputPath_RejectsEmptyControlAndDangerousUnicode(t *testing.T) {
for _, input := range []string{
"",
" ",
"file\x00.txt",
"file\tname.txt",
"file\nname.txt",
"file\rname.txt",
"file\u202Ename.txt",
"file\u200Bname.txt",
} {
t.Run(fmt.Sprintf("%q", input), func(t *testing.T) {
if _, err := LocalInputPath(input); err == nil {
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
}
})
}
}
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
// GIVEN: a clean temp directory as CWD
dir := t.TempDir()

7
package-lock.json generated
View File

@@ -1,15 +1,16 @@
{
"name": "@larksuite/cli",
"version": "1.0.11",
"version": "1.0.78",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.11",
"version": "1.0.78",
"cpu": [
"x64",
"arm64"
"arm64",
"riscv64"
],
"hasInstallScript": true,
"license": "MIT",

View File

@@ -1,12 +1,13 @@
{
"name": "@larksuite/cli",
"version": "1.0.74",
"version": "1.0.78",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
},
"scripts": {
"postinstall": "node scripts/install.js"
"postinstall": "node scripts/install.js",
"release:check": "node scripts/release-preflight.js"
},
"os": [
"darwin",

View File

@@ -265,10 +265,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
const checksumsPath = path.join(dir, "checksums.txt");
if (!fs.existsSync(checksumsPath)) {
console.error(
"[WARN] checksums.txt not found, skipping checksum verification"
);
return null;
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
}
const content = fs.readFileSync(checksumsPath, "utf8");
@@ -286,7 +283,14 @@ function getExpectedChecksum(archiveName, checksumsDir) {
}
function verifyChecksum(archivePath, expectedHash) {
if (expectedHash === null) return;
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
throw new Error("[SECURITY] Expected checksum is missing or invalid");
}
if (!/^[0-9a-f]{64}$/i.test(expectedHash)) {
throw new Error(
"[SECURITY] Expected checksum must be a 64-character hexadecimal SHA-256 digest"
);
}
// Stream the file to avoid loading the entire archive into memory.
// Archives can be 10-100MB; streaming keeps RSS constant.

View File

@@ -52,11 +52,12 @@ describe("getExpectedChecksum", () => {
);
});
it("returns null when checksums.txt does not exist", () => {
it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
// No checksums.txt in dir
const result = getExpectedChecksum("anything.tar.gz", dir);
assert.equal(result, null);
assert.throws(
() => getExpectedChecksum("anything.tar.gz", dir),
{ message: /^\[SECURITY\] checksums\.txt not found/ }
);
});
it("skips malformed lines and still finds valid entry", () => {
@@ -106,7 +107,7 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
it("matches case-insensitively", () => {
it("accepts a valid uppercase 64-character hex hash", () => {
const content = "case test";
const filePath = makeTmpFile(content);
const hash = sha256(content).toUpperCase();
@@ -114,6 +115,40 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
for (const [name, expectedHash] of [
["null", null],
["empty", ""],
["non-string", 123],
]) {
it(`throws [SECURITY]-prefixed Error for ${name} expected hash`, () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, expectedHash),
(err) => {
assert.match(err.message, /^\[SECURITY\]/);
assert.match(err.message, /Expected checksum is missing or invalid/);
return true;
}
);
});
}
it("throws [SECURITY] format Error for an incorrectly sized hash", () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, "abc123"),
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
);
});
it("throws [SECURITY] format Error for a non-hex hash", () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, "g".repeat(64)),
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
);
});
it("throws [SECURITY]-prefixed Error on mismatch", () => {
const filePath = makeTmpFile("real content");
assert.throws(

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
function isStableVersion(value) {
return typeof value === "string" && STABLE_VERSION_PATTERN.test(value);
}
function releaseError(message, observed, hint) {
return { ok: false, error: { type: "release_preflight", message, observed, hint } };
}
function validateReleasePreflight(packageJson, packageLockJson, tag) {
const packageVersion = packageJson?.version;
const lockVersion = packageLockJson?.version;
const lockRootVersion = packageLockJson?.packages?.[""]?.version;
const observed = {
packageVersion: packageVersion ?? null,
lockVersion: lockVersion ?? null,
lockRootVersion: lockRootVersion ?? null,
tagVersion: null,
};
for (const [field, value] of [
["package.json.version", packageVersion],
["package-lock.json.version", lockVersion],
['package-lock.json.packages[""].version', lockRootVersion],
]) {
if (!isStableVersion(value)) {
return releaseError(
`${field} must be a stable release version in X.Y.Z form`,
observed,
"Use the same stable X.Y.Z version in all package fields; prerelease and build metadata are not allowed for production releases.",
);
}
}
if (packageVersion !== lockVersion || packageVersion !== lockRootVersion) {
return releaseError(
"Package version fields do not match",
observed,
"Synchronize package.json.version and both package-lock.json version fields.",
);
}
if (tag === undefined) {
return { ok: true, data: observed };
}
if (typeof tag !== "string" || !tag.startsWith("v") || !isStableVersion(tag.slice(1))) {
return releaseError(
"--tag must use the stable release form vX.Y.Z",
{ ...observed, tag },
`Use --tag v${packageVersion}; prerelease and build metadata are not allowed for production releases.`,
);
}
const tagVersion = tag.slice(1);
if (tagVersion !== packageVersion) {
return releaseError(
"Tag version does not match the package version",
{ ...observed, tagVersion, tag },
`Use --tag v${packageVersion}.`,
);
}
return { ok: true, data: { ...observed, tagVersion } };
}
function writeResult(result) {
(result.ok ? process.stdout : process.stderr).write(`${JSON.stringify(result)}\n`);
if (!result.ok) process.exitCode = 1;
}
function main() {
const args = process.argv.slice(2);
let tag;
if (args.length === 2 && args[0] === "--tag") {
tag = args[1];
} else if (args.length !== 0) {
writeResult(releaseError(
"Expected no arguments or --tag vX.Y.Z",
{ arguments: args },
"Run release:check without arguments or pass exactly one --tag value.",
));
return;
}
const repoRoot = path.resolve(__dirname, "..");
try {
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
const packageLockJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8"));
writeResult(validateReleasePreflight(packageJson, packageLockJson, tag));
} catch (error) {
writeResult(releaseError(
"Could not read release package metadata",
{ reason: error.message },
"Ensure package.json and package-lock.json exist and contain valid JSON.",
));
}
}
module.exports = { validateReleasePreflight };
if (require.main === module) main();

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const { describe, it } = require("node:test");
const { validateReleasePreflight } = require("./release-preflight");
function metadata(version = "1.2.3") {
return {
packageJson: { version },
packageLockJson: {
version,
packages: { "": { version } },
},
};
}
function assertRejected(result) {
assert.equal(result.ok, false);
assert.equal(result.error.type, "release_preflight");
assert.equal(typeof result.error.message, "string");
}
describe("validateReleasePreflight", () => {
it("accepts matching stable package, lock, and tag versions", () => {
const { packageJson, packageLockJson } = metadata();
assert.deepEqual(
validateReleasePreflight(packageJson, packageLockJson, "v1.2.3"),
{
ok: true,
data: {
packageVersion: "1.2.3",
lockVersion: "1.2.3",
lockRootVersion: "1.2.3",
tagVersion: "1.2.3",
},
},
);
});
it("rejects non-stable or inconsistent package metadata", () => {
const prerelease = metadata("1.2.3-beta.1");
const topLevelMismatch = metadata();
topLevelMismatch.packageLockJson.version = "1.2.4";
const rootMismatch = metadata();
rootMismatch.packageLockJson.packages[""].version = "1.2.4";
for (const { packageJson, packageLockJson } of [
prerelease,
topLevelMismatch,
rootMismatch,
]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson));
}
});
it("rejects an invalid or mismatched release tag", () => {
const { packageJson, packageLockJson } = metadata();
for (const tag of ["1.2.3", "v1.2.3-beta.1", "v1.2.4"]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson, tag));
}
});
});

View File

@@ -3,49 +3,48 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
# Read version from package.json
VERSION=$(node -p "require('${REPO_ROOT}/package.json').version")
if [ -z "$VERSION" ]; then
echo "Error: could not read version from package.json" >&2
exit 1
fi
VERSION=$(node -p "require('./package.json').version")
TAG="v${VERSION}"
node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
echo "Version: ${VERSION}"
echo "Tag: ${TAG}"
# Check if tag already exists locally
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag ${TAG} already exists locally, skipping."
exit 0
fi
# Check if tag already exists on remote
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "Tag ${TAG} already exists on remote, skipping."
exit 0
fi
# Ensure package.json changes are committed before tagging
if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then
echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2
CURRENT_BRANCH=$(git branch --show-current)
if [ "${CURRENT_BRANCH}" != "main" ]; then
echo "Error: releases must be tagged from main; current branch is '${CURRENT_BRANCH}'." >&2
exit 1
fi
# Ensure current branch is pushed to remote before tagging
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
LOCAL_SHA=$(git rev-parse HEAD)
REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "")
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2
if ! git diff --quiet HEAD -- package.json package-lock.json; then
echo "Error: package.json or package-lock.json has uncommitted changes. Please commit them before tagging." >&2
exit 1
fi
# Create and push tag
git tag "$TAG"
git push origin "$TAG"
git fetch origin main
echo "Successfully created and pushed tag ${TAG}"
HEAD_SHA=$(git rev-parse HEAD)
FETCHED_MAIN_SHA=$(git rev-parse "FETCH_HEAD^{commit}")
if [ "${HEAD_SHA}" != "${FETCHED_MAIN_SHA}" ]; then
echo "Error: HEAD must exactly match origin/main before tagging." >&2
exit 1
fi
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Error: local tag ${TAG} already exists." >&2
exit 1
fi
REMOTE_TAG=$(git ls-remote --tags origin "refs/tags/${TAG}")
if [ -n "${REMOTE_TAG}" ]; then
echo "Error: remote tag ${TAG} already exists." >&2
exit 1
fi
git tag "${TAG}" "${HEAD_SHA}"
git push origin "refs/tags/${TAG}"
echo "Successfully pushed tag ${TAG}"

View File

@@ -12,10 +12,23 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// maxFileListPageSize 是 file_list 分页上限,与后端 paas_storage checkMaxKeys 的 (0, 200] 契约对齐:
// page_size > 200 服务端直接返回 ErrInvalidRequest("maxKeys not in range (0, 200]")。CLI 前置校验避免无谓往返。
// 注:服务端对 page_size<=0 会兜底为默认值,但 CLI 默认已是 20、显式传 <1 属误用,故与其它 list 命令一致地按 [1, 200] 校验。
const maxFileListPageSize = 200
// validateFileListPageSize 前置校验 --page-size ∈ [1, maxFileListPageSize],与后端 checkMaxKeys 的 (0, 200] 契约对齐。
func validateFileListPageSize(n int) error {
if n < 1 || n > maxFileListPageSize {
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxFileListPageSize)
}
return nil
}
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
//
// GET /apps/{app_id}/storage/file_list。过滤器--name / --path / --type / --size-gt /
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size/--page-token。
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size(1..200)/--page-token。
// file 域不分 dev/online无 --env。
//
// pretty 渲染 5 列file_name / path / size / type / uploaded_at空结果打 "No files found."。
@@ -41,13 +54,17 @@ var AppsFileList = common.Shortcut{
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1..200)"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
// page_size 前置校验:对齐后端 checkMaxKeys 的 (0, 200] 契约,避免 >200 触发服务端 ErrInvalidRequest。
if err := validateFileListPageSize(rctx.Int("page-size")); err != nil {
return err
}
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC回写到 flag 供 buildFileListParams 透传。
for _, f := range []string{"uploaded-since", "uploaded-until"} {
if strings.TrimSpace(rctx.Str(f)) == "" {

View File

@@ -82,6 +82,34 @@ func TestAppsFileList_RequiresAppID(t *testing.T) {
}
}
// TestAppsFileList_PageSizeOutOfRange 验证 --page-size 超出 (0, 200] 契约时前置报 --page-size 校验错误,不发请求。
func TestAppsFileList_PageSizeOutOfRange(t *testing.T) {
for _, ps := range []string{"0", "201", "500"} {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("page-size=%s: err = %T %v, want *errs.ValidationError", ps, err, err)
}
if ve.Param != "--page-size" {
t.Fatalf("page-size=%s: Param = %q, want --page-size", ps, ve.Param)
}
}
}
// TestAppsFileList_PageSizeBoundaryOK 验证边界值 1 与 200 通过校验dry-run 不报错并把 page_size 下发)。
func TestAppsFileList_PageSizeBoundaryOK(t *testing.T) {
for _, ps := range []string{"1", "200"} {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("page-size=%s: dry-run err=%v", ps, err)
}
}
}
// 过滤器 + 分页全部进 querysize-gt/lt 走 intuploaded_since/until 原样)。
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)

View File

@@ -14,7 +14,6 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,21 +46,7 @@ var AppsFileUpload = common.Shortcut{
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
f := strings.TrimSpace(rctx.Str("file"))
if f == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
}
st, err := rctx.FileIO().Stat(f)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}
if st.IsDir() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
}
if st.Size() > fileUploadMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
}
return nil
return rctx.ValidateLocalFileFlag("file", fileUploadMaxBytes)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
@@ -76,9 +61,9 @@ var AppsFileUpload = common.Shortcut{
return err
}
localPath := strings.TrimSpace(rctx.Str("file"))
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
content, err := rctx.ReadLocalFileFlag("file", fileUploadMaxBytes)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
return err
}
fileName := filepath.Base(localPath)
contentType := mimeByExt(fileName)

View File

@@ -12,6 +12,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -58,22 +59,17 @@ func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
}
}
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_uploadbody.file_name 取文件 basename。
// TestAppsFileUpload_DryRunPreUpload verifies that dry-run validates the local
// file and previews the pre-upload request without reading or uploading it.
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
// Validate 会 Stat --file在 DryRun 之前),故 dry-run 也需要真实存在的文件。
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
absolutePath := filepath.Join(t.TempDir(), "logo.png")
if err := os.WriteFile(absolutePath, []byte("not-read-by-dry-run"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
[]string{"+file-upload", "--app-id", "app_x", "--file", absolutePath, "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env dryRunAPIEnvelope
@@ -87,6 +83,18 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
}
}
func TestAppsFileUpload_DryRunRejectsMissingFile(t *testing.T) {
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "logo.png")
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", missingAbsolutePath, "--dry-run", "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
}
// 三步直传pre-upload → 客户端 PUT 字节 → callback。
func TestAppsFileUpload_EndToEnd(t *testing.T) {
var putBody []byte
@@ -149,6 +157,142 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
}
}
// TestAppsFileUpload_AcceptsAbsolutePath verifies that file-upload can read an
// absolute path outside the current working directory.
func TestAppsFileUpload_AcceptsAbsolutePath(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-abs"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// Keep the process cwd unchanged so the temporary file is outside it.
dir := t.TempDir()
absFile := filepath.Join(dir, "report.pdf")
if !filepath.IsAbs(absFile) {
t.Fatalf("test setup: %q is not absolute", absFile)
}
if err := os.WriteFile(absFile, []byte("PDFBYTES"), 0o600); err != nil {
t.Fatal(err)
}
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-abs"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/1858537546760999.pdf", "size_bytes": 8,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", absFile, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with absolute path err=%v", err)
}
if string(putBody) != "PDFBYTES" {
t.Fatalf("PUT body = %q, want file bytes", putBody)
}
}
func TestAppsFileUpload_AcceptsParentRelativePathOutsideCWD(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-parent"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte("PARENT"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(workDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-parent"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/parent.pdf", "size_bytes": 6,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", filepath.Join("..", "report.pdf"), "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with parent-relative path err=%v", err)
}
if string(putBody) != "PARENT" {
t.Fatalf("PUT body = %q, want PARENT", putBody)
}
}
func TestAppsFileUpload_RejectsFileAboveLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "too-large.bin")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(fileUploadMaxBytes + 1); err != nil {
_ = f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err = runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", path, "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
}
if !strings.Contains(validationErr.Error(), "limit") {
t.Fatalf("error = %v, want size limit context", validationErr)
}
}
func TestAppsFileUpload_RejectsDeviceWithoutReadingIt(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("/dev/zero is unavailable on Windows")
}
if _, err := os.Stat("/dev/zero"); err != nil {
t.Skipf("/dev/zero unavailable: %v", err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "/dev/zero", "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
}
if !strings.Contains(validationErr.Error(), "regular file") {
t.Fatalf("error = %v, want non-regular-file context", validationErr)
}
}
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
func TestSanitizeUploadFileName_Cases(t *testing.T) {
cases := []struct{ in, want string }{

View File

@@ -2435,16 +2435,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1", "rec_2"},
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{"Bob"}},
},
},
})
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":["Name"],"rows":[["Alice"],["Bob"]]}`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"create_records":[{"Name":"Alice"},{"Name":"Bob"}]}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"Alice"`) {
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
t.Fatalf("stdout=%s", got)
}
})

View File

@@ -26,7 +26,7 @@ var BaseFormSubmit = common.Shortcut{
Service: "base",
Command: "+form-submit",
Description: "Submit a form (fill and submit form data)",
Risk: "write",
Risk: "high-risk-write",
Scopes: []string{"base:form:update", "docs:document.media:upload"},
AuthTypes: authTypes(),
HasFormat: true,
@@ -39,6 +39,7 @@ var BaseFormSubmit = common.Shortcut{
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
baseHighRiskYesTip,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFormSubmit(runtime)

View File

@@ -801,7 +801,8 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
name: "record batch create json",
shortcut: BaseRecordBatchCreate,
wantHelp: []string{
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
"create_records contains one field map per record",
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
},
},
{
@@ -850,8 +851,8 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
`{"Parent Link":[{"id":"rec_xxx"}]}`,
"do not look for parent_record_id or a separate child-record API",
"CellValue happy path: text/phone/url",
"select -> \"Todo\"",
"multi-select -> [\"Tag A\",\"Tag B\"]",
"select (multiple=false) -> \"Todo\"",
"select (multiple=true) -> [\"Tag A\",\"Tag B\"]",
"datetime -> \"2026-03-24 10:00:00\"",
"checkbox -> true/false",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
@@ -865,11 +866,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
name: "record batch create",
shortcut: BaseRecordBatchCreate,
wantTips: []string{
"Happy path fields: fields is the column order",
"rows is an array of row arrays",
"may use null for empty cells",
"Happy path field: create_records",
"create_records is an array of independent record field maps",
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
"use +field-list to confirm real writable fields",
"Batch create supports max 200 rows per call",
"Batch create supports max 200 records per call",
"do not immediately +record-list the same table",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
@@ -2055,8 +2056,8 @@ func TestBaseFormSubmitShortcut(t *testing.T) {
if s.Service != "base" {
t.Fatalf("Service=%q want base", s.Service)
}
if s.Risk != "write" {
t.Fatalf("Risk=%q want write", s.Risk)
if s.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", s.Risk)
}
if !s.HasFormat {
t.Fatal("HasFormat should be true")
@@ -2356,6 +2357,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"+form-submit",
"--share-token", "shr_exec1",
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2424,6 +2426,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_exec6",
"--base-token", "bas_exec6",
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
"--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
@@ -2472,6 +2475,7 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_dedup",
"--base-token", "bas_dedup",
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2483,6 +2487,33 @@ func TestExecuteFormSubmit(t *testing.T) {
})
}
// TestFormSubmitRequiresConfirmation pins the high-risk-write classification:
// without --yes the runner's confirmation gate must fire before Execute runs,
// returning a typed confirmation_required error and touching no API.
func TestFormSubmitRequiresConfirmation(t *testing.T) {
if BaseFormSubmit.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", BaseFormSubmit.Risk)
}
factory, stdout, _ := newExecuteFactory(t)
args := []string{
"+form-submit",
"--share-token", "shr_confirm",
"--json", `{"fields":{"Rating":5}}`,
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
t.Fatal("expected confirmation_required error without --yes")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("subtype=%q want %q", problem.Subtype, errs.SubtypeConfirmationRequired)
}
}
func TestUploadAttachmentsParallel(t *testing.T) {
t.Run("single file upload via execute path", func(t *testing.T) {
tmpDir := t.TempDir()
@@ -2519,6 +2550,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_para1",
"--base-token", "bas_para1",
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2553,6 +2585,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_err",
"--base-token", "bas_err",
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
"--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {

View File

@@ -27,7 +27,7 @@ var BaseFieldSearchOptions = common.Shortcut{
},
Tips: []string{
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
"Use only for fields with options, such as select or multi-select fields.",
"Use only for select fields, whether multiple is false or true.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateLimitPageSizeAlias(runtime); err != nil {

View File

@@ -19,12 +19,13 @@ var BaseRecordBatchCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true},
{Name: "json", Desc: `batch create JSON object; create_records contains one field map per record, e.g. {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`, Required: true},
},
Tips: append([]string{
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
"Happy path field: create_records is an array of independent record field maps.",
`Example: {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}.`,
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch create supports max 200 rows per call.",
"Batch create supports max 200 records per call.",
"After batch-creating known helper rows, use the returned record IDs and your submitted rows; do not immediately +record-list the same table unless you need server-normalized formula/lookup values or failure diagnosis.",
"Use the record-batch-create guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),

View File

@@ -6,9 +6,10 @@ package base
import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -30,7 +31,7 @@ func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interf
func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[string]interface{}, renderer func(map[string]interface{}) (string, error)) error {
if runtime.JqExpr != "" {
if !runtime.Changed("format") {
runtime.OutJSON(data, nil)
runtime.Out(data, nil)
return nil
}
return baseValidationErrorf("--jq and --format markdown are mutually exclusive")
@@ -38,13 +39,32 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
rendered, err := renderer(data)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: record markdown render failed, falling back to json: %v\n", err)
runtime.OutJSON(data, nil)
runtime.Out(data, nil)
return nil
}
return runtime.EmitRenderedValue(data, func(w io.Writer) error {
_, writeErr := io.WriteString(w, rendered)
return writeErr
})
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
if scanResult.Blocked {
return baseContentSafetyBlockError(scanResult)
}
if scanResult.Alert != nil {
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
}
fmt.Fprint(runtime.IO().Out, rendered)
return nil
}
func baseContentSafetyBlockError(scanResult output.ScanResult) error {
message := "content safety violation detected"
var rules []string
if scanResult.Alert != nil {
rules = scanResult.Alert.MatchedRules
}
if len(rules) > 0 {
message = fmt.Sprintf("content safety violation detected (rules: %s)", strings.Join(rules, ", "))
}
return errs.NewContentSafetyError(errs.SubtypeUnknown, "%s", message).
WithRules(rules...).
WithCause(scanResult.BlockErr)
}
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {

View File

@@ -31,10 +31,6 @@ func (p *recordMarkdownCSTestProvider) Scan(_ context.Context, _ extcs.ScanReque
return p.alert, nil
}
func (p *recordMarkdownCSTestProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeContext {
parentCmd := &cobra.Command{Use: "lark-cli"}
baseCmd := &cobra.Command{Use: "base"}
@@ -45,7 +41,6 @@ func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeC
return &common.RuntimeContext{
Config: &core.CliConfig{Brand: core.BrandFeishu},
Cmd: cmd,
Format: "markdown",
Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}},
}
}

View File

@@ -19,7 +19,7 @@ const maxBatchGetSelectFieldCount = 100
const maxRecordSearchSelectFieldCount = 50
var recordCellValueHappyPathTips = []string{
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select (multiple=false) -> "Todo"; select (multiple=true) -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`,
"Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.",
"Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.",

View File

@@ -250,6 +250,8 @@ var CalendarAgenda = common.Shortcut{
}
}
collapseDescription(e)
filtered = append(filtered, e)
}
}

View File

@@ -20,7 +20,6 @@ import (
func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} {
eventData := map[string]interface{}{
"summary": runtime.Str("summary"),
"description": runtime.Str("description"),
"start_time": map[string]string{"timestamp": startTs},
"end_time": map[string]string{"timestamp": endTs},
"attendee_ability": "can_modify_event",
@@ -33,6 +32,9 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
if rrule := runtime.Str("rrule"); rrule != "" {
eventData["recurrence"] = rrule
}
if description := descriptionToSend(runtime); description != "" {
eventData["description_rich"] = description
}
return eventData
}
@@ -118,7 +120,7 @@ var CalendarCreate = common.Shortcut{
{Name: "summary", Desc: "event title"},
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
{Name: "description", Desc: "event description"},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`).", Input: []string{common.File, common.Stdin}},
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -231,6 +233,9 @@ var CalendarCreate = common.Shortcut{
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
return err
}
eventData := buildEventData(runtime, startTs, endTs)

View File

@@ -81,6 +81,7 @@ type calendarEvent struct {
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
DescriptionRich string `json:"description_rich,omitempty"`
StartTime *calendarEventTime `json:"start_time,omitempty"`
EndTime *calendarEventTime `json:"end_time,omitempty"`
VChat *calendarEventVChat `json:"vchat,omitempty"`
@@ -169,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
if status, _ := out["status"].(string); status != "cancelled" {
delete(out, "status")
}
collapseDescription(out)
return out, nil
}

View File

@@ -324,7 +324,7 @@ var CalendarSearchEvent = common.Shortcut{
})
if hasMore && runtime.Format != "json" && runtime.Format != "" {
fmt.Fprintf(runtime.IO().ErrOut, "\n(more available, page_token: %s)\n", pageToken)
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
}
return nil
},

View File

@@ -988,9 +988,15 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured patch body: %v", err)
}
if body["summary"] != "Updated Meeting" || body["description"] != "Updated description" {
// --description is the unified field, treated as rich text and sent as
// description_rich; the CLI never sends the plain description field
// (mutually exclusive downstream).
if body["summary"] != "Updated Meeting" || body["description_rich"] != "Updated description" {
t.Fatalf("unexpected patch body: %#v", body)
}
if _, ok := body["description"]; ok {
t.Fatalf("plain description must not be sent, got: %#v", body)
}
if body["need_notification"] != false {
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
}
@@ -1352,7 +1358,7 @@ func TestAgenda_Success(t *testing.T) {
"+agenda",
"--start", "2025-03-21",
"--end", "2025-03-21",
"--format", "pretty",
"--format", "prettry",
"--as", "bot",
}, f, stdout)
@@ -1364,6 +1370,62 @@ func TestAgenda_Success(t *testing.T) {
}
}
func TestAgenda_UnifiesDescriptionRich(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/events/instance_view",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"event_id": "evt_rich",
"summary": "Rich",
"status": "confirmed",
"description": "[测试]\n友情提醒",
"description_rich": "友情提醒",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
map[string]interface{}{
"event_id": "evt_plain",
"summary": "Plain",
"status": "confirmed",
"description": "just text",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
},
},
},
})
err := mountAndRun(t, CalendarAgenda, []string{
"+agenda",
"--start", "2025-03-21",
"--end", "2025-03-21",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Read exposes a single unified description field: it carries the rich
// (Markdown) value when present, and the plain text otherwise. The internal
// description_rich key is never surfaced.
if !strings.Contains(out, "\"description\": \"友情提醒\"") {
t.Errorf("expected rich value surfaced under description, got: %s", out)
}
if !strings.Contains(out, "\"description\": \"just text\"") {
t.Errorf("expected plain description surfaced for plain-only event, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
}
func TestAgenda_EmptyResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
@@ -3375,6 +3437,72 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
}
}
func TestGet_UnifiesDescriptionRich(t *testing.T) {
// Read exposes a single unified description field carrying the rich value
// when present, and the plain text otherwise; description_rich is dropped.
t.Run("rich present", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_rich",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_rich",
"summary": "Rich",
"description": "[表格]",
"description_rich": "| a | b |\n| --- | --- |\n| c | d |",
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
},
},
},
})
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_rich", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "| a | b |") {
t.Errorf("expected rich value surfaced under description, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
})
// When only a plain description exists, it is surfaced under description.
t.Run("only plain surfaces under description", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_plain",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_plain",
"summary": "Plain",
"description": "just text",
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
},
},
},
})
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_plain", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "\"description\": \"just text\"") {
t.Errorf("expected plain description surfaced, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
})
}
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())

View File

@@ -29,7 +29,7 @@ var CalendarUpdate = common.Shortcut{
{Name: "event-id", Desc: "event ID to update", Required: true},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "summary", Desc: "event title"},
{Name: "description", Desc: "event description"},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`). Passing an empty string clears the description.", Input: []string{common.File, common.Stdin}},
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -109,11 +109,13 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
body := map[string]interface{}{}
hasFields := false
for _, field := range []string{"summary", "description"} {
if runtime.Cmd.Flags().Changed(field) {
body[field] = runtime.Str(field)
hasFields = true
}
if runtime.Cmd.Flags().Changed("summary") {
body["summary"] = runtime.Str("summary")
hasFields = true
}
if runtime.Cmd.Flags().Changed("description") {
body["description_rich"] = runtime.Str("description")
hasFields = true
}
if runtime.Cmd.Flags().Changed("rrule") {
rrule := strings.TrimSpace(runtime.Str("rrule"))
@@ -356,6 +358,12 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
}
if runtime.Cmd.Flags().Changed("description") {
if err := resolveDescriptionImages(runtime, calendarID); err != nil {
return err
}
}
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
if err != nil {
return err
@@ -428,8 +436,10 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
if summary, _ := event["summary"].(string); summary != "" {
result["summary"] = summary
}
if description, _ := event["description"].(string); description != "" {
result["description"] = description
if rich, _ := event["description_rich"].(string); rich != "" {
result["description"] = rich
} else if plain, _ := event["description"].(string); plain != "" {
result["description"] = plain
}
if start := formatCalendarEventTime(event["start_time"]); start != "" {
result["start"] = start

View File

@@ -0,0 +1,172 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"fmt"
"image"
// Register the common image decoders so DecodeConfig can read intrinsic
// dimensions for PNG/JPEG/GIF sources.
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/url"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const calendarMediaParentType = "calendar"
var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`)
func resolveDescriptionImages(runtime *common.RuntimeContext, calendarID string) error {
md := runtime.Str("description")
if md == "" || !strings.Contains(md, "![") {
return nil
}
rewritten, changed, err := uploadLocalDescriptionImages(runtime, calendarID, md)
if err != nil {
return err
}
if changed {
if err := runtime.Cmd.Flags().Set("description", rewritten); err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to update --description after image upload: %v", err).WithCause(err)
}
}
return nil
}
func uploadLocalDescriptionImages(runtime *common.RuntimeContext, calendarID, md string) (string, bool, error) {
matches := markdownImageRe.FindAllStringSubmatchIndex(md, -1)
if len(matches) == 0 {
return md, false, nil
}
var out strings.Builder
last := 0
changed := false
cache := map[string]string{}
for _, m := range matches {
altStart, altEnd, srcStart, srcEnd := m[2], m[3], m[4], m[5]
src := strings.TrimSpace(md[srcStart:srcEnd])
if !isLocalImageSrc(src) {
continue
}
alt := md[altStart:altEnd]
uploadedURL, err := resolveLocalImage(runtime, calendarID, src, alt, cache)
if err != nil {
return "", false, err
}
out.WriteString(md[last:srcStart])
out.WriteString(uploadedURL)
last = srcEnd
changed = true
}
if !changed {
return md, false, nil
}
out.WriteString(md[last:])
return out.String(), true, nil
}
func resolveLocalImage(runtime *common.RuntimeContext, calendarID, src, alt string, cache map[string]string) (string, error) {
localPath := localImagePath(src)
if cached, ok := cache[localPath]; ok {
return cached, nil
}
safePath, err := validate.SafeInputPath(localPath)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description image %q could not be read: %v", src, err).
WithParam("--description").
WithHint("reference local images by a path inside the current working directory (e.g. ./images/pic.png; cd there first), or use an already-uploaded Lark image URL").
WithCause(err)
}
info, err := runtime.FileIO().Stat(localPath)
if err != nil {
return "", common.WrapInputStatErrorTyped(err)
}
fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
FilePath: localPath,
FileName: filepath.Base(safePath),
FileSize: info.Size(),
ParentType: calendarMediaParentType,
ParentNode: &calendarID,
})
if err != nil {
return "", err
}
width, height := decodeImageDimensions(runtime, localPath)
uploadedURL := buildCalendarImagePreviewURL(runtime.Config.Brand, fileToken, width, height, info.Size())
cache[localPath] = uploadedURL
return uploadedURL, nil
}
func decodeImageDimensions(runtime *common.RuntimeContext, path string) (int, int) {
f, err := runtime.FileIO().Open(path)
if err != nil {
return 0, 0
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0
}
return cfg.Width, cfg.Height
}
func isLocalImageSrc(src string) bool {
if src == "" {
return false
}
lower := strings.ToLower(src)
switch {
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "data:"):
return false
case strings.HasPrefix(lower, "file://"):
return true
}
if i := strings.Index(src, "://"); i > 0 {
return false
}
return true
}
func localImagePath(src string) string {
s := strings.TrimSpace(src)
if strings.HasPrefix(strings.ToLower(s), "file://") {
if u, err := url.Parse(s); err == nil && u.Path != "" {
s = u.Path
}
}
if decoded, err := url.PathUnescape(s); err == nil {
return decoded
}
return s
}
func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string {
host := "internal-api-drive-stream.larkoffice.com"
if brand == core.BrandLark {
host = "internal-api-drive-stream.larksuite.com"
}
u := fmt.Sprintf("https://%s/space/api/box/stream/download/preview/%s?preview_type=16", host, fileToken)
if width > 0 && height > 0 {
u += fmt.Sprintf("&im_w=%d&im_h=%d", width, height)
}
if size > 0 {
u += fmt.Sprintf("&im_size=%d", size)
}
return u
}

View File

@@ -0,0 +1,279 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"bytes"
"encoding/json"
"errors"
"image"
"image/png"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
func TestIsLocalImageSrc(t *testing.T) {
cases := []struct {
src string
want bool
}{
{"./images/pic.png", true},
{"images/pic.png", true},
{"../assets/a.png", true},
{"/Users/me/Desktop/a.png", true},
{`C:\Users\me\a.png`, true},
{"file:///Users/me/a.png", true},
{"图片和附件/测试图片.png", true},
{"https://example.com/a.png", false},
{"http://example.com/a.png", false},
{"HTTPS://EXAMPLE.com/a.png", false},
{"data:image/png;base64,iVBOR", false},
{"ftp://host/a.png", false},
{"", false},
}
for _, c := range cases {
if got := isLocalImageSrc(c.src); got != c.want {
t.Errorf("isLocalImageSrc(%q) = %v, want %v", c.src, got, c.want)
}
}
}
func TestLocalImagePath(t *testing.T) {
cases := []struct{ in, want string }{
{"images/pic.png", "images/pic.png"},
{"images/my%20pic.png", "images/my pic.png"},
{"file:///Users/me/a.png", "/Users/me/a.png"},
}
for _, c := range cases {
if got := localImagePath(c.in); got != c.want {
t.Errorf("localImagePath(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestBuildCalendarImagePreviewURL guards the contract the OpenAPI service
// relies on: a Lark host (so token extraction triggers) whose final path
// segment is exactly the uploaded file token.
func TestBuildCalendarImagePreviewURL(t *testing.T) {
for _, tc := range []struct {
brand core.LarkBrand
hostFrag string
}{
{core.BrandFeishu, "larkoffice"},
{core.BrandLark, "larksuite"},
} {
raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568)
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("built URL not parseable: %v", err)
}
if !strings.Contains(u.Host, tc.hostFrag) {
t.Errorf("brand %s host = %q, want fragment %q", tc.brand, u.Host, tc.hostFrag)
}
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
if last := segs[len(segs)-1]; last != "boxcnTOKEN123" {
t.Errorf("last path segment = %q, want token", last)
}
q := u.Query()
if q.Get("im_w") != "416" || q.Get("im_h") != "306" || q.Get("im_size") != "142568" {
t.Errorf("dimension params missing: im_w=%q im_h=%q im_size=%q", q.Get("im_w"), q.Get("im_h"), q.Get("im_size"))
}
}
// With unknown dimensions the helper params are omitted entirely.
raw := buildCalendarImagePreviewURL(core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0)
if strings.Contains(raw, "im_w") || strings.Contains(raw, "im_size") {
t.Errorf("expected no dimension params for unknown size, got %q", raw)
}
}
// TestUploadLocalDescriptionImages_RemoteUntouched verifies remote/data images
// pass through unchanged and never trigger an upload (runtime unused → nil).
func TestUploadLocalDescriptionImages_RemoteUntouched(t *testing.T) {
md := "text ![a](https://example.com/a.png) more ![b](data:image/png;base64,xx)"
got, changed, err := uploadLocalDescriptionImages(nil, "cal", md)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if changed {
t.Errorf("changed = true, want false")
}
if got != md {
t.Errorf("markdown mutated: %q", got)
}
}
// TestCreate_UploadsLocalDescriptionImage runs +create with a local image path,
// mocks the drive upload, and asserts the create body's description_rich carries
// the uploaded token (not the local path).
func TestCreate_UploadsLocalDescriptionImage(t *testing.T) {
dir := t.TempDir()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(orig)
if err := os.WriteFile(filepath.Join(dir, "pic.png"), []byte("PNGDATA"), 0600); err != nil {
t.Fatal(err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
}
reg.Register(uploadStub)
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_001",
"summary": "Pic",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
}},
}
reg.Register(createStub)
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![pic](./pic.png)",
"--as", "bot",
}, f, stdout)
if runErr != nil {
t.Fatalf("unexpected error: %v", runErr)
}
if uploadStub.CapturedBody == nil {
t.Fatalf("expected drive upload to be called")
}
if createStub.CapturedBody == nil {
t.Fatalf("expected create event to be called")
}
var body map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
t.Fatalf("create body unmarshal: %v", err)
}
dr, _ := body["description_rich"].(string)
if !strings.Contains(dr, "boxcnTOKEN123") {
t.Fatalf("description_rich should contain uploaded token, got %q", dr)
}
if strings.Contains(dr, "./pic.png") {
t.Fatalf("local path should be rewritten away, got %q", dr)
}
}
// TestCreate_LocalImageCarriesDimensions verifies a real decodable image's
// intrinsic width/height and byte size are appended to the rewritten drive URL
// (so the facade can populate originalWidth/originalHeight and the client can
// render the image inline).
func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
dir := t.TempDir()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(orig)
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 5, 7))); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "pic.png"), buf.Bytes(), 0600); err != nil {
t.Fatal(err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
})
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_001",
"summary": "Pic",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
}},
}
reg.Register(createStub)
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![pic](./pic.png)",
"--as", "bot",
}, f, stdout)
if runErr != nil {
t.Fatalf("unexpected error: %v", runErr)
}
var body map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
t.Fatalf("create body unmarshal: %v", err)
}
dr, _ := body["description_rich"].(string)
if !strings.Contains(dr, "im_w=5") || !strings.Contains(dr, "im_h=7") {
t.Fatalf("description_rich should carry image dimensions, got %q", dr)
}
if !strings.Contains(dr, "im_size=") {
t.Fatalf("description_rich should carry image byte size, got %q", dr)
}
}
// TestCreate_LocalImageAbsolutePathRejected verifies an out-of-cwd absolute path
// yields a typed --description validation error before any API call.
func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![p](/etc/hosts)",
"--as", "bot",
}, f, stdout)
if runErr == nil {
t.Fatalf("expected error for absolute image path")
}
var ve *errs.ValidationError
if !errors.As(runErr, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", runErr, runErr)
}
if ve.Param != "--description" {
t.Errorf("param = %q, want --description", ve.Param)
}
}

View File

@@ -30,6 +30,26 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
return startInput, endInput
}
func collapseDescription(event map[string]interface{}) {
if event == nil {
return
}
rich, _ := event["description_rich"].(string)
plain, _ := event["description"].(string)
delete(event, "description_rich")
switch {
case rich != "":
event["description"] = rich
case plain != "":
event["description"] = plain
default:
delete(event, "description")
}
}
func descriptionToSend(runtime *common.RuntimeContext) string {
return runtime.Str("description")
}
func hasExplicitBotFlag(cmd *cobra.Command) bool {
if cmd == nil {
return false

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"errors"
"io"
"io/fs"
"math"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/validate"
)
// ValidateLocalFileFlag validates that a local input path exists, is a regular
// file, and does not exceed maxBytes. Absolute and relative paths use
// the process filesystem namespace.
func (ctx *RuntimeContext) ValidateLocalFileFlag(flagName string, maxBytes int64) error {
path, param, err := ctx.localFileFlag(flagName, maxBytes)
if err != nil {
return err
}
info, err := cmdutil.StatLocalFile(path)
if err != nil {
return localFileReadError(param, path, "inspect", err)
}
if err := localFileRegularError(param, path, info.Mode()); err != nil {
return err
}
if info.Size() > maxBytes {
return localFileSizeError(param, path, info.Size(), maxBytes)
}
return nil
}
// ReadLocalFileFlag is the shared replacement for direct os.ReadFile calls in
// shortcuts. It accepts absolute and relative paths, enforces a hard size
// limit, and returns command-facing typed errors.
func (ctx *RuntimeContext) ReadLocalFileFlag(flagName string, maxBytes int64) (data []byte, retErr error) {
path, param, err := ctx.localFileFlag(flagName, maxBytes)
if err != nil {
return nil, err
}
f, err := cmdutil.OpenLocalFile(path)
if err != nil {
return nil, localFileReadError(param, path, "open", err)
}
defer func() {
if err := f.Close(); err != nil && retErr == nil {
data = nil
retErr = errs.NewInternalError(errs.SubtypeFileIO, "cannot close %s %q: %v", param, path, err).WithCause(err)
}
}()
openedInfo, err := f.Stat()
if err != nil {
return nil, localFileReadError(param, path, "inspect opened", err)
}
if err := localFileRegularError(param, path, openedInfo.Mode()); err != nil {
return nil, err
}
if openedInfo.Size() > maxBytes {
return nil, localFileSizeError(param, path, openedInfo.Size(), maxBytes)
}
readLimit := maxBytes + 1
if maxBytes == math.MaxInt64 {
readLimit = maxBytes
}
data, err = io.ReadAll(io.LimitReader(f, readLimit))
if err != nil {
return nil, localFileReadError(param, path, "read", err)
}
if int64(len(data)) > maxBytes {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q grew beyond the %d-byte limit while being read", param, path, maxBytes).
WithParam(param)
}
return data, nil
}
func (ctx *RuntimeContext) localFileFlag(flagName string, maxBytes int64) (path, param string, err error) {
name, param, err := localFileFlagNames(flagName)
if err != nil {
return "", "", err
}
if ctx == nil || ctx.Cmd == nil {
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "cannot read %s: runtime command is unavailable", param)
}
path = strings.TrimSpace(ctx.Str(name))
if path == "" {
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required", param).WithParam(param)
}
if _, err := validate.LocalInputPath(path); err != nil {
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s path: %v", param, err).
WithParam(param).
WithCause(err)
}
if maxBytes < 0 {
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "invalid read limit configured for %s", param)
}
return path, param, nil
}
func localFileRegularError(param, path string, mode fs.FileMode) error {
if mode.IsRegular() {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q is not a regular file", param, path).
WithParam(param)
}
func localFileReadError(param, path, op string, cause error) error {
if errors.Is(cause, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s %q: %v", param, path, cause).
WithParam(param).
WithCause(cause)
}
if errors.Is(cause, fs.ErrNotExist) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s %q does not exist", param, path).
WithParam(param).
WithCause(cause)
}
return errs.NewInternalError(errs.SubtypeFileIO, "cannot %s %s %q: %v", op, param, path, cause).WithCause(cause)
}
func localFileSizeError(param, path string, size, limit int64) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s %q is %d bytes; limit is %d bytes", param, path, size, limit).
WithParam(param)
}
func localFileFlagNames(flagName string) (name, param string, err error) {
name = strings.TrimLeft(strings.TrimSpace(flagName), "-")
if name == "" {
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "local file flag name must not be empty")
}
return name, "--" + name, nil
}

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