mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
68 Commits
codex/fix-
...
docs/wiki-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e19aae336 | ||
|
|
6f542aafe2 | ||
|
|
41692b7041 | ||
|
|
b79827d60a | ||
|
|
0f35676a28 | ||
|
|
946964e093 | ||
|
|
cfe76ad56a | ||
|
|
fa9c30c690 | ||
|
|
ba95252019 | ||
|
|
4a16139348 | ||
|
|
6e5308af01 | ||
|
|
87be09ef5f | ||
|
|
a575a8ba60 | ||
|
|
1f565a290b | ||
|
|
68a77eee5c | ||
|
|
29a97dbde8 | ||
|
|
29a6a7b600 | ||
|
|
c167163d70 | ||
|
|
7988515e1c | ||
|
|
c7adff7a3b | ||
|
|
59237f3104 | ||
|
|
358cd06838 | ||
|
|
b0b1ca4b5d | ||
|
|
781d188a60 | ||
|
|
2e0fb9a880 | ||
|
|
927b37cd63 | ||
|
|
d2e22c5fca | ||
|
|
fdae560014 | ||
|
|
1b173e1953 | ||
|
|
57db1b3a8d | ||
|
|
4c1c5f5287 | ||
|
|
3d2c10cd0b | ||
|
|
03de81c5f3 | ||
|
|
7abcaa7f68 | ||
|
|
8fb2476985 | ||
|
|
56c9a2afd8 | ||
|
|
2029189809 | ||
|
|
ee427979a8 | ||
|
|
545abcbbde | ||
|
|
4a73e83f1e | ||
|
|
7496420fa8 | ||
|
|
43fabdf524 | ||
|
|
8c46c74105 | ||
|
|
70777c86c3 | ||
|
|
38e8806d91 | ||
|
|
a7865cd0a7 | ||
|
|
f77b7eea68 | ||
|
|
dd7f741b62 | ||
|
|
e7d5ecdd01 | ||
|
|
4807283368 | ||
|
|
d2bb36591f | ||
|
|
5a54bc07db | ||
|
|
a528b3cb69 | ||
|
|
f0176af330 | ||
|
|
715aa8d960 | ||
|
|
ebc0c53ab5 | ||
|
|
1e682bd97c | ||
|
|
70424c486c | ||
|
|
b8f56dbc0b | ||
|
|
c74d9b63fb | ||
|
|
67015eef8e | ||
|
|
af8507ea8e | ||
|
|
02c2ebcf7c | ||
|
|
abf6f99d7e | ||
|
|
8ba910eb9f | ||
|
|
78bf126bb0 | ||
|
|
4eefe32c1a | ||
|
|
8f6f8eb0fc |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,4 +1,7 @@
|
|||||||
|
/go.mod @liangshuo-1
|
||||||
|
/go.sum @liangshuo-1
|
||||||
/internal/ @liangshuo-1
|
/internal/ @liangshuo-1
|
||||||
|
/shortcuts/common/ @liangshuo-1
|
||||||
|
|
||||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||||
/skills/ @liangshuo-1
|
/skills/ @liangshuo-1
|
||||||
|
|||||||
103
.github/workflows/release.yml
vendored
103
.github/workflows/release.yml
vendored
@@ -9,7 +9,40 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
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
|
runs-on: ubuntu-22.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -26,35 +59,79 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: '3.x'
|
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
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||||
with:
|
with:
|
||||||
version: '~> v2'
|
version: '~> v2'
|
||||||
args: release --clean
|
args: release --clean
|
||||||
env:
|
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:
|
publish-npm:
|
||||||
needs: goreleaser
|
needs: build-release
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
environment: npm-production
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22.14.0'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
package-manager-cache: false
|
||||||
|
|
||||||
- name: Download checksums from release
|
- name: Install pinned npm
|
||||||
env:
|
run: npm install --global npm@11.16.0
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
- 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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG="${GITHUB_REF_NAME}"
|
(cd npm-publish-asset && sha256sum --check checksums.txt)
|
||||||
gh release download "${TAG}" --pattern checksums.txt --dir .
|
cp npm-publish-asset/checksums.txt checksums.txt
|
||||||
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
|
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
|
- name: Publish to npm
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
run: npm publish --access public
|
run: npm publish --access public
|
||||||
|
|||||||
46
.github/workflows/semantic-review.yml
vendored
46
.github/workflows/semantic-review.yml
vendored
@@ -25,19 +25,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const run = context.payload.workflow_run;
|
const run = context.payload.workflow_run;
|
||||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
const workflowId = Number(run.workflow_id || 0);
|
||||||
let workflowPath = run.path || "";
|
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||||
if (!workflowPath) {
|
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||||
const workflowId = Number(run.workflow_id || 0);
|
owner: context.repo.owner,
|
||||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
repo: context.repo.repo,
|
||||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
workflow_id: workflowId,
|
||||||
owner: context.repo.owner,
|
});
|
||||||
repo: context.repo.repo,
|
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||||
workflow_id: workflowId,
|
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||||
});
|
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||||
workflowPath = workflow.path || "";
|
|
||||||
}
|
|
||||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
|
||||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||||
@@ -253,19 +250,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const run = context.payload.workflow_run;
|
const run = context.payload.workflow_run;
|
||||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
const workflowId = Number(run.workflow_id || 0);
|
||||||
let workflowPath = run.path || "";
|
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||||
if (!workflowPath) {
|
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||||
const workflowId = Number(run.workflow_id || 0);
|
owner: context.repo.owner,
|
||||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
repo: context.repo.repo,
|
||||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
workflow_id: workflowId,
|
||||||
owner: context.repo.owner,
|
});
|
||||||
repo: context.repo.repo,
|
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||||
workflow_id: workflowId,
|
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||||
});
|
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||||
workflowPath = workflow.path || "";
|
|
||||||
}
|
|
||||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
|
||||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||||
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
|
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
|
||||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||||
|
|||||||
119
CHANGELOG.md
119
CHANGELOG.md
@@ -2,6 +2,120 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [v1.0.80] - 2026-07-29
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **drive**: add +member-list shortcut (#1795)
|
||||||
|
- **drive**: add +permission-get-setting shortcut (#1738)
|
||||||
|
- propagate invocation metadata (#2097)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
|
||||||
|
- **slides**: +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- **e2e**: wait for base role update visibility (#2087)
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
|
- Feat/detect line text overlap (#2069)
|
||||||
|
|
||||||
|
## [v1.0.79] - 2026-07-28
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **slides**: update xsd (#2067)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **ci**: validate static workflow identity (#2015)
|
||||||
|
- **sheets**: recognize OFL0X local office tokens (#2063)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **calendar**: clarify identity selection by event ownership (#2071)
|
||||||
|
- **slides**: add formula inline element syntax to quick-ref (#2077)
|
||||||
|
|
||||||
|
## [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
|
## [v1.0.74] - 2026-07-21
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -1608,6 +1722,11 @@ Bundled AI agent skills for intelligent assistance:
|
|||||||
- Bilingual documentation (English & Chinese).
|
- Bilingual documentation (English & Chinese).
|
||||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||||
|
|
||||||
|
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||||
|
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
|
||||||
|
[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.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.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
|
||||||
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
|
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
|||||||
bash scripts/resolve-changed-from.test.sh
|
bash scripts/resolve-changed-from.test.sh
|
||||||
bash scripts/ci-workflow.test.sh
|
bash scripts/ci-workflow.test.sh
|
||||||
bash scripts/semantic-review-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.
|
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||||
unit-test: fetch_meta
|
unit-test: fetch_meta
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -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.
|
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.
|
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|||||||
23
README.zh.md
23
README.zh.md
@@ -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
|
## Star History
|
||||||
|
|||||||
@@ -23,6 +23,41 @@ lark-cli contact +search-user --query "alice" --as user
|
|||||||
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
|
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## +search-bot
|
||||||
|
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
|
||||||
|
|
||||||
|
### Skills
|
||||||
|
- lark-contact/references/lark-contact-search-bot.md
|
||||||
|
|
||||||
|
### Avoid when
|
||||||
|
- Looking for a person rather than a bot → use [[+search-user]]
|
||||||
|
- Running as a bot — this shortcut is user-only
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Find bots by keyword**
|
||||||
|
```bash
|
||||||
|
lark-cli contact +search-bot --query "会议助手" --as user
|
||||||
|
```
|
||||||
|
|
||||||
|
**Search inside one chat**
|
||||||
|
```bash
|
||||||
|
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
|
||||||
|
```
|
||||||
|
|
||||||
|
**Find bots you've chatted with**
|
||||||
|
```bash
|
||||||
|
lark-cli contact +search-bot --query "助手" --has-chatted --as user
|
||||||
|
```
|
||||||
|
|
||||||
|
**Search several bot keywords in one call**
|
||||||
|
```bash
|
||||||
|
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
|
||||||
|
```
|
||||||
|
|
||||||
## +get-user
|
## +get-user
|
||||||
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
|
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
|||||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||||
|
cmd.AddCommand(NewCmdConfigRiskControl(f))
|
||||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||||
|
|||||||
80
cmd/config/risk_control.go
Normal file
80
cmd/config/risk_control.go
Normal 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"
|
||||||
|
}
|
||||||
130
cmd/config/risk_control_test.go
Normal file
130
cmd/config/risk_control_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,17 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
|||||||
if info == nil {
|
if info == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
|
// Deliberately no target version here: info.Latest comes from the on-disk
|
||||||
|
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
|
||||||
|
// failed refresh leaves the old value in place), so it can name a version
|
||||||
|
// that is no longer the one npm would install. The version actually
|
||||||
|
// installed is resolved live by the update subcommand, which prints
|
||||||
|
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
|
||||||
|
// that is where the user sees the real target. Keep going through the
|
||||||
|
// update subcommand rather than calling RunNpmInstall directly, otherwise
|
||||||
|
// that line disappears and the user approves a global install without ever
|
||||||
|
// being told what gets installed.
|
||||||
|
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
|
||||||
if !readYes(ios.In) {
|
if !readYes(ios.In) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,17 @@ func TestOfferRootUpgrade(t *testing.T) {
|
|||||||
if gotPrompt != tc.wantPrompt {
|
if gotPrompt != tc.wantPrompt {
|
||||||
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
|
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
|
||||||
}
|
}
|
||||||
|
// The prompt must not name a target version: info.Latest comes from
|
||||||
|
// the on-disk cache and can be stale, while the version actually
|
||||||
|
// installed is resolved live by the update subcommand.
|
||||||
|
if tc.wantPrompt {
|
||||||
|
if strings.Contains(errBuf.String(), tc.latest) {
|
||||||
|
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(errBuf.String(), build.Version) {
|
||||||
|
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
if called != tc.wantRun {
|
if called != tc.wantRun {
|
||||||
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
|
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/credential"
|
"github.com/larksuite/cli/internal/credential"
|
||||||
"github.com/larksuite/cli/internal/keychain"
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"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/security/contentsafety" // register content safety provider
|
||||||
"github.com/larksuite/cli/internal/transport"
|
"github.com/larksuite/cli/internal/transport"
|
||||||
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
|
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
|
||||||
@@ -33,7 +34,7 @@ import (
|
|||||||
// Phase 1: HttpClient (no credential dependency)
|
// Phase 1: HttpClient (no credential dependency)
|
||||||
// Phase 2: Credential (sole data source for account info)
|
// Phase 2: Credential (sole data source for account info)
|
||||||
// Phase 3: Config derived from Credential
|
// 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 {
|
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||||
streams = normalizeStreams(streams)
|
streams = normalizeStreams(streams)
|
||||||
f := &Factory{
|
f := &Factory{
|
||||||
@@ -54,9 +55,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
|||||||
|
|
||||||
// Phase 0: FileIO provider (no dependency)
|
// Phase 0: FileIO provider (no dependency)
|
||||||
f.FileIOProvider = fileio.GetProvider()
|
f.FileIOProvider = fileio.GetProvider()
|
||||||
|
workspaceConfig := core.NewConfigSnapshot()
|
||||||
|
|
||||||
// Phase 1: HttpClient (no credential dependency)
|
// Phase 1: HttpClient (no credential dependency)
|
||||||
f.HttpClient = cachedHttpClientFunc(f)
|
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
|
||||||
|
|
||||||
// Phase 2: Credential (sole data source)
|
// Phase 2: Credential (sole data source)
|
||||||
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
// 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,
|
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) {
|
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
|
||||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -78,8 +80,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// Phase 4: LarkClient from Credential (placeholder AppSecret)
|
// Phase 4: LarkClient composes account data and workspace policy at the SDK
|
||||||
f.LarkClient = cachedLarkClientFunc(f)
|
// transport boundary.
|
||||||
|
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
|
||||||
|
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
@@ -108,13 +111,16 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
|||||||
// .StderrIsTerminal field, which tests set directly.
|
// .StderrIsTerminal field, which tests set directly.
|
||||||
var warnIfProxied = transport.WarnIfProxied
|
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) {
|
return sync.OnceValues(func() (*http.Client, error) {
|
||||||
if f.IOStreams.StderrIsTerminal {
|
if f.IOStreams.StderrIsTerminal {
|
||||||
warnIfProxied(f.IOStreams.ErrOut)
|
warnIfProxied(f.IOStreams.ErrOut)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||||
|
|
||||||
var rt http.RoundTripper = transport.Shared()
|
var rt http.RoundTripper = transport.Shared()
|
||||||
|
rt = riskcontrol.NewTransport(rt, hostSignalSource)
|
||||||
rt = &RetryTransport{Base: rt}
|
rt = &RetryTransport{Base: rt}
|
||||||
rt = &SecurityHeaderTransport{Base: rt}
|
rt = &SecurityHeaderTransport{Base: rt}
|
||||||
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
|
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) {
|
return sync.OnceValues(func() (*lark.Client, error) {
|
||||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -142,8 +148,15 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
|||||||
if f.IOStreams.StderrIsTerminal {
|
if f.IOStreams.StderrIsTerminal {
|
||||||
warnIfProxied(f.IOStreams.ErrOut)
|
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{
|
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||||
Transport: buildSDKTransport(),
|
Transport: sdkTransport,
|
||||||
CheckRedirect: safeRedirectPolicy,
|
CheckRedirect: safeRedirectPolicy,
|
||||||
}))
|
}))
|
||||||
ep := core.ResolveEndpoints(acct.Brand)
|
ep := core.ResolveEndpoints(acct.Brand)
|
||||||
@@ -152,9 +165,8 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSDKTransport() http.RoundTripper {
|
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
|
||||||
var sdkTransport http.RoundTripper = transport.Shared()
|
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
|
||||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
|
||||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
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()
|
c1, err := fn()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -29,7 +34,10 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCachedHttpClientFunc_HasTimeout(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()
|
c, _ := fn()
|
||||||
if c.Timeout == 0 {
|
if c.Timeout == 0 {
|
||||||
t.Error("expected non-zero timeout")
|
t.Error("expected non-zero timeout")
|
||||||
@@ -37,7 +45,10 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCachedHttpClientFunc_HasRedirectPolicy(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()
|
c, _ := fn()
|
||||||
if c.CheckRedirect == nil {
|
if c.CheckRedirect == nil {
|
||||||
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
|
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
|
_ "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"
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,13 +37,15 @@ var proxyWarnGateCases = []struct {
|
|||||||
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
|
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
|
||||||
// invokes WarnIfProxied only when stderr is an interactive terminal.
|
// invokes WarnIfProxied only when stderr is an interactive terminal.
|
||||||
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
|
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
|
||||||
|
isEnabled := false
|
||||||
for _, tc := range proxyWarnGateCases {
|
for _, tc := range proxyWarnGateCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
calls := installProxyWarnSpy(t)
|
calls := installProxyWarnSpy(t)
|
||||||
|
|
||||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
|
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||||
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
|
f.IOStreams.ErrOut = io.Discard
|
||||||
}})
|
f.IOStreams.StderrIsTerminal = tc.terminal
|
||||||
|
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||||
if _, err := fn(); err != nil {
|
if _, err := fn(); err != nil {
|
||||||
t.Fatalf("http client init: %v", err)
|
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
|
// normalizeStreams copies the struct (out := *s), so the
|
||||||
// StderrIsTerminal field survives into f.IOStreams.
|
// StderrIsTerminal field survives into f.IOStreams.
|
||||||
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
|
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)
|
t.Fatalf("lark client init: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
36
internal/cmdutil/localfile.go
Normal file
36
internal/cmdutil/localfile.go
Normal 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)
|
||||||
|
}
|
||||||
96
internal/cmdutil/localfile_test.go
Normal file
96
internal/cmdutil/localfile_test.go
Normal 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)
|
||||||
|
}
|
||||||
28
internal/cmdutil/risk_control.go
Normal file
28
internal/cmdutil/risk_control.go
Normal 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()
|
||||||
|
}
|
||||||
45
internal/cmdutil/risk_control_test.go
Normal file
45
internal/cmdutil/risk_control_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ const (
|
|||||||
HeaderShortcut = "X-Cli-Shortcut"
|
HeaderShortcut = "X-Cli-Shortcut"
|
||||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||||
HeaderAgentTrace = "X-Agent-Trace"
|
HeaderAgentTrace = "X-Agent-Trace"
|
||||||
|
HeaderAgentName = "X-Agent-Name"
|
||||||
|
|
||||||
SourceValue = "lark-cli"
|
SourceValue = "lark-cli"
|
||||||
|
|
||||||
@@ -55,6 +56,9 @@ func BaseSecurityHeaders() http.Header {
|
|||||||
if v := envvars.AgentTrace(); v != "" {
|
if v := envvars.AgentTrace(); v != "" {
|
||||||
h.Set(HeaderAgentTrace, v)
|
h.Set(HeaderAgentTrace, v)
|
||||||
}
|
}
|
||||||
|
if v := envvars.AgentName(); v != "" {
|
||||||
|
h.Set(HeaderAgentName, v)
|
||||||
|
}
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
// Agent headers injected via BaseSecurityHeaders
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAgentName, "")
|
||||||
|
h := BaseSecurityHeaders()
|
||||||
|
if v := h.Get(HeaderAgentName); v != "" {
|
||||||
|
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
|
||||||
|
const agentName = "sample-agent"
|
||||||
|
t.Setenv(envvars.CliAgentName, agentName)
|
||||||
|
h := BaseSecurityHeaders()
|
||||||
|
if v := h.Get(HeaderAgentName); v != agentName {
|
||||||
|
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
|
||||||
|
h := BaseSecurityHeaders()
|
||||||
|
if v := h.Get(HeaderAgentName); v != "" {
|
||||||
|
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||||
t.Setenv(envvars.CliAgentTrace, "")
|
t.Setenv(envvars.CliAgentTrace, "")
|
||||||
h := BaseSecurityHeaders()
|
h := BaseSecurityHeaders()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
exttransport "github.com/larksuite/cli/extension/transport"
|
exttransport "github.com/larksuite/cli/extension/transport"
|
||||||
internalauth "github.com/larksuite/cli/internal/auth"
|
internalauth "github.com/larksuite/cli/internal/auth"
|
||||||
|
"github.com/larksuite/cli/internal/riskcontrol"
|
||||||
)
|
)
|
||||||
|
|
||||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
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) {
|
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||||
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)
|
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||||
@@ -110,18 +111,23 @@ func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
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)
|
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{})
|
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)
|
mid, ok := transport.(*extensionMiddleware)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
||||||
@@ -138,17 +144,23 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
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)
|
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)
|
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)
|
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||||
@@ -161,9 +173,13 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
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)
|
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
|
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
|
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
|
||||||
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
|
// 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
|
// 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{}})
|
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
|
||||||
t.Cleanup(func() { exttransport.Register(nil) })
|
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
|
var base http.RoundTripper = http.DefaultTransport
|
||||||
base = &RetryTransport{Base: base}
|
base = &RetryTransport{Base: base}
|
||||||
base = &UserAgentTransport{Base: base}
|
base = &UserAgentTransport{Base: base}
|
||||||
|
|||||||
@@ -60,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
|
|||||||
// MultiAppConfig is the multi-app config file format.
|
// MultiAppConfig is the multi-app config file format.
|
||||||
type MultiAppConfig struct {
|
type MultiAppConfig struct {
|
||||||
StrictMode StrictMode `json:"strictMode,omitempty"`
|
StrictMode StrictMode `json:"strictMode,omitempty"`
|
||||||
|
RiskControl *bool `json:"riskControl,omitempty"`
|
||||||
CurrentApp string `json:"currentApp,omitempty"`
|
CurrentApp string `json:"currentApp,omitempty"`
|
||||||
PreviousApp string `json:"previousApp,omitempty"`
|
PreviousApp string `json:"previousApp,omitempty"`
|
||||||
Apps []AppConfig `json:"apps"`
|
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.
|
// CurrentAppConfig returns the currently active app config.
|
||||||
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
|
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
|
||||||
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {
|
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {
|
||||||
|
|||||||
37
internal/core/config_snapshot.go
Normal file
37
internal/core/config_snapshot.go
Normal 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()
|
||||||
|
}
|
||||||
58
internal/core/config_snapshot_test.go
Normal file
58
internal/core/config_snapshot_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,9 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMultiAppConfig_RoundTrip(t *testing.T) {
|
func TestMultiAppConfig_RoundTrip(t *testing.T) {
|
||||||
|
disabled := false
|
||||||
config := &MultiAppConfig{
|
config := &MultiAppConfig{
|
||||||
|
RiskControl: &disabled,
|
||||||
Apps: []AppConfig{{
|
Apps: []AppConfig{{
|
||||||
AppId: "cli_test", AppSecret: PlainSecret("s"),
|
AppId: "cli_test", AppSecret: PlainSecret("s"),
|
||||||
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
|
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
|
||||||
@@ -84,6 +86,9 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
|
|||||||
if got.Apps[0].Brand != BrandLark {
|
if got.Apps[0].Brand != BrandLark {
|
||||||
t.Errorf("Brand = %q, want %q", 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) {
|
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
|
||||||
|
|||||||
@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||||
t.Setenv(CliAgentName, "claude-code")
|
const agentName = "sample-agent"
|
||||||
if got := AgentName(); got != "claude-code" {
|
t.Setenv(CliAgentName, agentName)
|
||||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
if got := AgentName(); got != agentName {
|
||||||
|
t.Fatalf("AgentName() = %q, want %q", got, agentName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||||
t.Setenv(CliAgentName, " cursor ")
|
const agentName = "sample-agent"
|
||||||
if got := AgentName(); got != "cursor" {
|
t.Setenv(CliAgentName, " "+agentName+" ")
|
||||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
if got := AgentName(); got != agentName {
|
||||||
|
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ type Stub struct {
|
|||||||
// matches after the first hit. Each match appends to CapturedBodies.
|
// matches after the first hit. Each match appends to CapturedBodies.
|
||||||
Reusable bool
|
Reusable bool
|
||||||
|
|
||||||
|
// Optional (optional): when true, Verify does not require this stub to be
|
||||||
|
// matched. Useful for negative assertions via OnMatch.
|
||||||
|
Optional bool
|
||||||
|
|
||||||
// CapturedHeaders records the request headers of the matched request.
|
// CapturedHeaders records the request headers of the matched request.
|
||||||
// Populated after RoundTrip matches this stub.
|
// Populated after RoundTrip matches this stub.
|
||||||
CapturedHeaders http.Header
|
CapturedHeaders http.Header
|
||||||
@@ -137,6 +141,9 @@ func (r *Registry) Verify(t testing.TB) {
|
|||||||
if s.matched {
|
if s.matched {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if s.Optional {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Reusable stubs never set s.matched; treat any captured hit as a match.
|
// Reusable stubs never set s.matched; treat any captured hit as a match.
|
||||||
if s.Reusable && len(s.CapturedBodies) > 0 {
|
if s.Reusable && len(s.CapturedBodies) > 0 {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ Adding a new row requires approval from the matching CODEOWNERS or quality gate
|
|||||||
|
|
||||||
`legacy-commands.txt` only covers hand-authored legacy commands. Generated OpenAPI service commands are intentionally excluded from `command-manifest.json`; they are included in `command-index.json` only so command references can be checked against the real CLI surface.
|
`legacy-commands.txt` only covers hand-authored legacy commands. Generated OpenAPI service commands are intentionally excluded from `command-manifest.json`; they are included in `command-index.json` only so command references can be checked against the real CLI surface.
|
||||||
|
|
||||||
|
## Public Domain Allowlists
|
||||||
|
|
||||||
|
`internal/qualitygate/config/allowlists/public-domains.txt` contains supported public hostnames approved for Go source. `fixture-domains.txt` contains test-only hostnames used by `*_test.go`, the repository-root `tests/` directory, or any `testdata/` directory; fixture entries do not apply to production Go files or `skills/`.
|
||||||
|
|
||||||
|
Keep one lowercase exact hostname per line, sorted alphabetically. Wildcards, suffix rules, duplicates, schemes, ports, and paths are rejected; approving `larkoffice.com` does not approve its subdomains.
|
||||||
|
|
||||||
|
RFC 2606 reserves the `.test`, `.example`, `.invalid`, and `.localhost` namespaces plus the exact names `example.com`, `example.net`, and `example.org`. These names are accepted without an allowlist entry and must not be listed.
|
||||||
|
|
||||||
|
Every public entry needs a current non-fixture Go use, evidence that it is a supported public endpoint, and CODEOWNER approval. Other test-only hostnames belong in the fixture list. Tenant-specific, private-control-plane, and internal API hostnames are not eligible.
|
||||||
|
|
||||||
|
`lint/domaincontract` validates both lists and scans complete Go files. In CI, unapproved-host findings are limited to values whose expressions intersect added lines; list validation and unused-entry checks remain repository-wide. See `lint/README.md` for scanner semantics.
|
||||||
|
|
||||||
## Semantic Blocker Policy
|
## Semantic Blocker Policy
|
||||||
|
|
||||||
The semantic reviewer can propose findings, but the local gatekeeper recomputes whether each finding is reproducible from `facts.json`. A finding blocks only when all of these are true:
|
The semantic reviewer can propose findings, but the local gatekeeper recomputes whether each finding is reproducible from `facts.json`. A finding blocks only when all of these are true:
|
||||||
|
|||||||
24
internal/qualitygate/config/allowlists/fixture-domains.txt
Normal file
24
internal/qualitygate/config/allowlists/fixture-domains.txt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Exact test-only hostnames. Keep sorted.
|
||||||
|
abc.feishu.cn
|
||||||
|
attacker.example.com
|
||||||
|
bytedance.feishu.cn
|
||||||
|
cdn.feishu.cn
|
||||||
|
evil.example.com
|
||||||
|
example.feishu.cn
|
||||||
|
example.larkoffice.com
|
||||||
|
example.larksuite.com
|
||||||
|
feishu.cn
|
||||||
|
feishu.doubao.com
|
||||||
|
gateway.docker.internal
|
||||||
|
host.containers.internal
|
||||||
|
host.docker.internal
|
||||||
|
host.lima.internal
|
||||||
|
lf3-static.bytednsdoc.com
|
||||||
|
meetings.feishu.cn
|
||||||
|
meetings.larksuite.com
|
||||||
|
p3-lark-file.byteimg.com
|
||||||
|
passport.feishu.cn
|
||||||
|
sample.feishu.cn
|
||||||
|
x.feishu.cn
|
||||||
|
xxx.feishu.cn
|
||||||
|
xxx.larksuite.com
|
||||||
18
internal/qualitygate/config/allowlists/public-domains.txt
Normal file
18
internal/qualitygate/config/allowlists/public-domains.txt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Exact public hostnames. Keep sorted.
|
||||||
|
accounts.feishu.cn
|
||||||
|
accounts.larksuite.com
|
||||||
|
applink.feishu.cn
|
||||||
|
applink.larksuite.com
|
||||||
|
ark.ap-southeast.bytepluses.com
|
||||||
|
github.com
|
||||||
|
larkoffice.com
|
||||||
|
lf-larkemail.bytetos.com
|
||||||
|
mcp.feishu.cn
|
||||||
|
mcp.larksuite.com
|
||||||
|
open.feishu.cn
|
||||||
|
open.larksuite.com
|
||||||
|
registry.npmjs.org
|
||||||
|
registry.npmmirror.com
|
||||||
|
sf16-sg.tiktokcdn.com
|
||||||
|
www.feishu.cn
|
||||||
|
www.larksuite.com
|
||||||
142
internal/riskcontrol/osmodel.go
Normal file
142
internal/riskcontrol/osmodel.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
27
internal/riskcontrol/osmodel_darwin.go
Normal file
27
internal/riskcontrol/osmodel_darwin.go
Normal 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 ""
|
||||||
|
}
|
||||||
48
internal/riskcontrol/osmodel_darwin_test.go
Normal file
48
internal/riskcontrol/osmodel_darwin_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
17
internal/riskcontrol/osmodel_linux.go
Normal file
17
internal/riskcontrol/osmodel_linux.go
Normal 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"
|
||||||
|
}
|
||||||
20
internal/riskcontrol/osmodel_linux_test.go
Normal file
20
internal/riskcontrol/osmodel_linux_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
11
internal/riskcontrol/osmodel_other.go
Normal file
11
internal/riskcontrol/osmodel_other.go
Normal 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 ""
|
||||||
|
}
|
||||||
143
internal/riskcontrol/osmodel_test.go
Normal file
143
internal/riskcontrol/osmodel_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
44
internal/riskcontrol/osmodel_windows.go
Normal file
44
internal/riskcontrol/osmodel_windows.go
Normal 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 ""
|
||||||
|
}
|
||||||
78
internal/riskcontrol/osmodel_windows_test.go
Normal file
78
internal/riskcontrol/osmodel_windows_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
138
internal/riskcontrol/transport.go
Normal file
138
internal/riskcontrol/transport.go
Normal 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
|
||||||
|
}
|
||||||
124
internal/riskcontrol/transport_test.go
Normal file
124
internal/riskcontrol/transport_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,13 @@ func SafeInputPath(path string) (string, error) {
|
|||||||
return localfileio.SafeInputPath(path)
|
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.
|
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||||
// Delegates to localfileio.SafeEnvDirPath.
|
// Delegates to localfileio.SafeEnvDirPath.
|
||||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||||
|
|||||||
@@ -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) {
|
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||||
// GIVEN: a real temp file (absolute path under os.TempDir())
|
// GIVEN: a real temp file (absolute path under os.TempDir())
|
||||||
f, err := os.CreateTemp("", "upload-test-*.bin")
|
f, err := os.CreateTemp("", "upload-test-*.bin")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/charcheck"
|
"github.com/larksuite/cli/internal/charcheck"
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
@@ -22,6 +23,32 @@ func SafeInputPath(path string) (string, error) {
|
|||||||
return safePath(path, "--file")
|
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.
|
// SafeLocalFlagPath validates a flag value as a local file path.
|
||||||
// Empty values and http/https URLs are returned unchanged without validation.
|
// Empty values and http/https URLs are returned unchanged without validation.
|
||||||
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||||
@@ -29,7 +56,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
|
|||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
if _, err := SafeInputPath(value); err != nil {
|
if _, err := SafeInputPath(value); err != nil {
|
||||||
return "", fmt.Errorf("%s: %v", flagName, err)
|
return "", fmt.Errorf("%s: %w", flagName, err)
|
||||||
}
|
}
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|||||||
8
internal/vfs/localfileio/path_local_other.go
Normal file
8
internal/vfs/localfileio/path_local_other.go
Normal 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 }
|
||||||
33
internal/vfs/localfileio/path_local_windows.go
Normal file
33
internal/vfs/localfileio/path_local_windows.go
Normal 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
|
||||||
|
}
|
||||||
27
internal/vfs/localfileio/path_local_windows_test.go
Normal file
27
internal/vfs/localfileio/path_local_windows_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
package localfileio
|
package localfileio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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) {
|
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
|
||||||
// GIVEN: a clean temp directory as CWD
|
// GIVEN: a clean temp directory as CWD
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ lint/
|
|||||||
├── lintapi/ # shared types every domain returns
|
├── lintapi/ # shared types every domain returns
|
||||||
│ └── violation.go # Violation, Action, ActionReject / ActionLabel / ActionWarning
|
│ └── violation.go # Violation, Action, ActionReject / ActionLabel / ActionWarning
|
||||||
└── errscontract/ # first domain: typed-error contract guards
|
└── errscontract/ # first domain: typed-error contract guards
|
||||||
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
|
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
|
||||||
├── runner.go
|
├── runner.go
|
||||||
├── typecheck.go
|
├── typecheck.go
|
||||||
├── violation.go # local type aliases to lintapi
|
├── violation.go # local type aliases to lintapi
|
||||||
@@ -30,16 +30,19 @@ lint/
|
|||||||
├── rule_subtype_classifier.go
|
├── rule_subtype_classifier.go
|
||||||
├── rule_typed_error_completeness.go
|
├── rule_typed_error_completeness.go
|
||||||
└── *_test.go
|
└── *_test.go
|
||||||
└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts
|
└── domaincontract/ # resolver ownership + approved public hostname policy
|
||||||
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
|
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
|
||||||
└── scan_test.go
|
├── unapproved.go # Go AST/type-aware hostname extraction
|
||||||
|
├── policy.go # exact public/fixture allowlist validation
|
||||||
|
├── diff.go # added-line attribution
|
||||||
|
└── *_test.go
|
||||||
```
|
```
|
||||||
|
|
||||||
## Endpoint domain contract (`domaincontract`)
|
## Endpoint domain contract (`domaincontract`)
|
||||||
|
|
||||||
`domaincontract` is a syntax-level regression guard for the resolver-owned
|
`domaincontract` contains two complementary Go source guards.
|
||||||
Open, Accounts, MCP, and AppLink hosts used by the Go CLI. In production `.go`
|
|
||||||
files it rejects:
|
The resolver-ownership guard rejects:
|
||||||
|
|
||||||
- string literals containing a resolver-owned host FQDN
|
- string literals containing a resolver-owned host FQDN
|
||||||
(`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`), and
|
(`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`), and
|
||||||
@@ -59,17 +62,54 @@ parse-level guard). The forbidden-host list is bound to the resolver source by
|
|||||||
`TestForbiddenHostsMatchResolver`, so adding a resolver domain without updating
|
`TestForbiddenHostsMatchResolver`, so adding a resolver domain without updating
|
||||||
the guard fails the lint module's tests.
|
the guard fails the lint module's tests.
|
||||||
|
|
||||||
This is not a general outbound-URL or data-flow analyzer. It does not inspect
|
The approved-domain guard parses every Git-tracked Go file in full. In CI,
|
||||||
non-Go assets, hosts assembled from string fragments, SDK constructor option
|
unapproved-host findings are limited to values whose expressions intersect an
|
||||||
flow, or previously unknown Feishu/Lark hosts. The literal rule and code review
|
added line; policy validation and unused-entry checks remain repository-wide.
|
||||||
remain the backstop for those cases.
|
It rejects an exact hostname unless it is present in one of:
|
||||||
|
|
||||||
To add or change an outbound endpoint, edit the resolver — never hardcode a host.
|
- `internal/qualitygate/config/allowlists/public-domains.txt`, for production
|
||||||
|
and test code; or
|
||||||
|
- `internal/qualitygate/config/allowlists/fixture-domains.txt`, only for
|
||||||
|
`*_test.go`, the repository-root `tests/`, and any `testdata/` (never
|
||||||
|
`skills/`).
|
||||||
|
|
||||||
|
RFC 2606 example/test names are accepted independently of those lists. This
|
||||||
|
includes the reserved `.test`, `.example`, `.invalid`, and `.localhost`
|
||||||
|
namespaces and the exact names `example.com`, `example.net`, and `example.org`;
|
||||||
|
they are safe placeholders rather than supported public endpoints.
|
||||||
|
|
||||||
|
High-confidence evidence is deliberately limited to static string expressions
|
||||||
|
assigned to `host`, `hostname`, or `domain` semantic names (including common
|
||||||
|
case/plural forms and collections), plus static strings whose entire value is
|
||||||
|
an absolute `http`, `https`, `ws`, or `wss` URL. It supports Go literals,
|
||||||
|
escapes, compile-time concatenation, constant references, grouped declarations,
|
||||||
|
multi-value assignments, and multiline expressions. Bare domain-shaped strings
|
||||||
|
without hostname semantics are not blocked.
|
||||||
|
|
||||||
|
Sequence values are scanned individually. For a hostname-semantic map, a key or
|
||||||
|
value is evidence only when it is the sole hostname-shaped side of that entry;
|
||||||
|
ambiguous string-to-string entries are not guessed. Struct fields use Go type
|
||||||
|
information so known non-network `Host` / `Domain` fields do not become hostname
|
||||||
|
evidence merely because an enum or command category contains a dot.
|
||||||
|
|
||||||
|
Allowlist matching is lowercase and exact: there are no wildcard, suffix, DNS,
|
||||||
|
or public-suffix exceptions. Entries must be sorted and unique, use ASCII
|
||||||
|
hostnames, and have a current in-scope use. See
|
||||||
|
`internal/qualitygate/config/README.md` for admission and approval rules.
|
||||||
|
|
||||||
|
This is not a general outbound-URL or cross-language data-flow analyzer. It does
|
||||||
|
not inspect non-Go assets or dynamically constructed values.
|
||||||
|
|
||||||
|
To add or change a resolver-owned Feishu/Lark endpoint, edit the resolver rather
|
||||||
|
than hardcoding the host elsewhere.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# from the repo root (one level above lint/)
|
# PR-scoped scan from the repo root (one level above lint/)
|
||||||
|
go run -C lint . --changed-from <base-revision> ..
|
||||||
|
|
||||||
|
# Full inventory (also reports historical unapproved hostnames)
|
||||||
go run -C lint . ..
|
go run -C lint . ..
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -100,10 +140,14 @@ Exit codes follow `lint/main.go`:
|
|||||||
|
|
||||||
import "github.com/larksuite/cli/lint/lintapi"
|
import "github.com/larksuite/cli/lint/lintapi"
|
||||||
|
|
||||||
// ScanRepo walks root and returns every violation produced by this
|
type ScanOptions struct {
|
||||||
// domain's checks. Domains MUST return []lintapi.Violation so the
|
ChangedFrom string
|
||||||
// top-level dispatcher can aggregate uniformly.
|
}
|
||||||
func ScanRepo(root string) ([]lintapi.Violation, error) { ... }
|
|
||||||
|
// ScanRepoWithOptions walks root and returns every violation produced
|
||||||
|
// by this domain's checks. Domains MUST return []lintapi.Violation so
|
||||||
|
// the top-level dispatcher can aggregate uniformly.
|
||||||
|
func ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Per-rule files are named `rule_<name>.go` with sibling
|
3. Per-rule files are named `rule_<name>.go` with sibling
|
||||||
@@ -114,8 +158,12 @@ Exit codes follow `lint/main.go`:
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
var scanners = []scanner{
|
var scanners = []scanner{
|
||||||
{name: "errscontract", fn: errscontract.ScanRepo},
|
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
|
||||||
{name: "<domain>", fn: <domain>.ScanRepo}, // ← add here
|
{name: "<domain>", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||||
|
return <domain>.ScanRepoWithOptions(root, <domain>.ScanOptions{
|
||||||
|
ChangedFrom: opts.ChangedFrom,
|
||||||
|
})
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
171
lint/domaincontract/diff.go
Normal file
171
lint/domaincontract/diff.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package domaincontract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type addedLineRange struct {
|
||||||
|
Start int
|
||||||
|
End int
|
||||||
|
}
|
||||||
|
|
||||||
|
type changedGoPath struct {
|
||||||
|
Old string
|
||||||
|
New string
|
||||||
|
}
|
||||||
|
|
||||||
|
var unifiedHunkRE = regexp.MustCompile(`^@@ -[0-9]+(?:,[0-9]+)? \+([0-9]+)(?:,([0-9]+))? @@`)
|
||||||
|
|
||||||
|
func changedGoLineRanges(root, from string) (map[string][]addedLineRange, error) {
|
||||||
|
if from == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
names, err := gitCommandOutput(
|
||||||
|
root,
|
||||||
|
"diff",
|
||||||
|
"--name-status",
|
||||||
|
"-z",
|
||||||
|
"--find-renames",
|
||||||
|
"--diff-filter=ACMR",
|
||||||
|
from+"...HEAD",
|
||||||
|
"--",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list changed Go files: %w", err)
|
||||||
|
}
|
||||||
|
paths, err := parseChangedGoPaths(names)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse changed Go files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string][]addedLineRange{}
|
||||||
|
for _, path := range paths {
|
||||||
|
args := []string{
|
||||||
|
"diff",
|
||||||
|
"--unified=0",
|
||||||
|
"--no-color",
|
||||||
|
"--no-ext-diff",
|
||||||
|
"--find-renames",
|
||||||
|
"--diff-filter=ACMR",
|
||||||
|
from + "...HEAD",
|
||||||
|
"--",
|
||||||
|
}
|
||||||
|
if path.Old != path.New {
|
||||||
|
args = append(args, path.Old)
|
||||||
|
}
|
||||||
|
args = append(args, path.New)
|
||||||
|
patch, err := gitCommandOutput(root, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read diff for %s: %w", path.New, err)
|
||||||
|
}
|
||||||
|
ranges, err := parseAddedLineRanges(patch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse diff for %s: %w", path.New, err)
|
||||||
|
}
|
||||||
|
out[path.New] = ranges
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseChangedGoPaths(raw []byte) ([]changedGoPath, error) {
|
||||||
|
fields := bytes.Split(raw, []byte{0})
|
||||||
|
var out []changedGoPath
|
||||||
|
for i := 0; i < len(fields); {
|
||||||
|
status := string(fields[i])
|
||||||
|
i++
|
||||||
|
if status == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i >= len(fields) || len(fields[i]) == 0 {
|
||||||
|
return nil, fmt.Errorf("truncated name-status record")
|
||||||
|
}
|
||||||
|
oldPath := filepath.ToSlash(string(fields[i]))
|
||||||
|
i++
|
||||||
|
newPath := oldPath
|
||||||
|
if status[0] == 'R' || status[0] == 'C' {
|
||||||
|
if i >= len(fields) || len(fields[i]) == 0 {
|
||||||
|
return nil, fmt.Errorf("truncated rename/copy record for %q", oldPath)
|
||||||
|
}
|
||||||
|
newPath = filepath.ToSlash(string(fields[i]))
|
||||||
|
i++
|
||||||
|
if status[0] == 'C' {
|
||||||
|
// A copy introduces every destination line. Diff only the new
|
||||||
|
// path so Git presents it as an added file rather than a
|
||||||
|
// metadata-only copy with no added-line ranges.
|
||||||
|
oldPath = newPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(newPath, ".go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, changedGoPath{Old: oldPath, New: newPath})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAddedLineRanges(patch []byte) ([]addedLineRange, error) {
|
||||||
|
var out []addedLineRange
|
||||||
|
for _, raw := range bytes.Split(patch, []byte{'\n'}) {
|
||||||
|
line := string(raw)
|
||||||
|
if !strings.HasPrefix(line, "@@") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
match := unifiedHunkRE.FindStringSubmatch(line)
|
||||||
|
if match == nil {
|
||||||
|
return nil, fmt.Errorf("unsupported unified hunk header %q", line)
|
||||||
|
}
|
||||||
|
start, err := strconv.Atoi(match[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse added start line in %q: %w", line, err)
|
||||||
|
}
|
||||||
|
count := 1
|
||||||
|
if match[2] != "" {
|
||||||
|
count, err = strconv.Atoi(match[2])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse added line count in %q: %w", line, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, addedLineRange{Start: start, End: start + count - 1})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstAddedLineInSpan(ranges []addedLineRange, start, end int) (int, bool) {
|
||||||
|
for _, r := range ranges {
|
||||||
|
if start <= r.End && end >= r.Start {
|
||||||
|
if start > r.Start {
|
||||||
|
return start, true
|
||||||
|
}
|
||||||
|
return r.Start, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitCommandOutput(root string, args ...string) ([]byte, error) {
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = root
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
stderr := strings.TrimSpace(string(exitErr.Stderr))
|
||||||
|
if stderr != "" {
|
||||||
|
return nil, fmt.Errorf("%w: %s", err, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
96
lint/domaincontract/diff_test.go
Normal file
96
lint/domaincontract/diff_test.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package domaincontract
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseChangedGoPaths(t *testing.T) {
|
||||||
|
raw := []byte("M\x00changed.go\x00R100\x00old.go\x00renamed.go\x00C100\x00source.go\x00copied.go\x00A\x00README.md\x00")
|
||||||
|
got, err := parseChangedGoPaths(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []changedGoPath{
|
||||||
|
{Old: "changed.go", New: "changed.go"},
|
||||||
|
{Old: "old.go", New: "renamed.go"},
|
||||||
|
{Old: "copied.go", New: "copied.go"},
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("paths = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("paths = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseChangedGoPathsRejectsTruncatedRename(t *testing.T) {
|
||||||
|
if _, err := parseChangedGoPaths([]byte("R100\x00old.go\x00")); err == nil {
|
||||||
|
t.Fatal("expected truncated rename error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAddedLineRanges(t *testing.T) {
|
||||||
|
patch := []byte(`diff --git a/x.go b/x.go
|
||||||
|
index 1111111..2222222 100644
|
||||||
|
--- a/x.go
|
||||||
|
+++ b/x.go
|
||||||
|
@@ -2,0 +3,2 @@
|
||||||
|
+first
|
||||||
|
+second
|
||||||
|
@@ -10 +12 @@
|
||||||
|
-old
|
||||||
|
+new
|
||||||
|
@@ -20 +21,0 @@
|
||||||
|
-deleted
|
||||||
|
`)
|
||||||
|
got, err := parseAddedLineRanges(patch)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []addedLineRange{{Start: 3, End: 4}, {Start: 12, End: 12}}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("ranges = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("ranges = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAddedLineRangesRejectsUnknownHunk(t *testing.T) {
|
||||||
|
if _, err := parseAddedLineRanges([]byte("@@@ unsupported @@@\n")); err == nil {
|
||||||
|
t.Fatal("expected unsupported hunk error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirstAddedLineInSpan(t *testing.T) {
|
||||||
|
ranges := []addedLineRange{{Start: 5, End: 7}, {Start: 10, End: 10}}
|
||||||
|
tests := []struct {
|
||||||
|
start, end int
|
||||||
|
line int
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{start: 1, end: 4, ok: false},
|
||||||
|
{start: 4, end: 6, line: 5, ok: true},
|
||||||
|
{start: 6, end: 9, line: 6, ok: true},
|
||||||
|
{start: 8, end: 12, line: 10, ok: true},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
line, ok := firstAddedLineInSpan(ranges, tc.start, tc.end)
|
||||||
|
if line != tc.line || ok != tc.ok {
|
||||||
|
t.Errorf(
|
||||||
|
"firstAddedLineInSpan(%d, %d) = (%d, %v), want (%d, %v)",
|
||||||
|
tc.start,
|
||||||
|
tc.end,
|
||||||
|
line,
|
||||||
|
ok,
|
||||||
|
tc.line,
|
||||||
|
tc.ok,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
lint/domaincontract/policy.go
Normal file
126
lint/domaincontract/policy.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package domaincontract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
publicDomainsPath = "internal/qualitygate/config/allowlists/public-domains.txt"
|
||||||
|
fixtureDomainsPath = "internal/qualitygate/config/allowlists/fixture-domains.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type domainPolicyEntry struct {
|
||||||
|
Host string
|
||||||
|
File string
|
||||||
|
Line int
|
||||||
|
}
|
||||||
|
|
||||||
|
type domainPolicy struct {
|
||||||
|
Public map[string]domainPolicyEntry
|
||||||
|
Fixtures map[string]domainPolicyEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// isReservedExampleHostname recognizes only names reserved by RFC 2606 for
|
||||||
|
// examples, testing, invalid-name examples, and localhost use. These names are
|
||||||
|
// safe source placeholders and are policy exceptions, not supported public
|
||||||
|
// endpoints.
|
||||||
|
func isReservedExampleHostname(host string) bool {
|
||||||
|
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
|
||||||
|
switch host {
|
||||||
|
case "example.com", "example.net", "example.org":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
labels := strings.Split(host, ".")
|
||||||
|
switch labels[len(labels)-1] {
|
||||||
|
case "test", "example", "invalid", "localhost":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDomainPolicy(root string) (domainPolicy, error) {
|
||||||
|
public, err := loadDomainList(root, publicDomainsPath)
|
||||||
|
if err != nil {
|
||||||
|
return domainPolicy{}, err
|
||||||
|
}
|
||||||
|
fixtures, err := loadDomainList(root, fixtureDomainsPath)
|
||||||
|
if err != nil {
|
||||||
|
return domainPolicy{}, err
|
||||||
|
}
|
||||||
|
for host, entry := range fixtures {
|
||||||
|
if publicEntry, ok := public[host]; ok {
|
||||||
|
return domainPolicy{}, fmt.Errorf(
|
||||||
|
"%s:%d: hostname %q is already listed at %s:%d",
|
||||||
|
entry.File, entry.Line, host, publicEntry.File, publicEntry.Line,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return domainPolicy{Public: public, Fixtures: fixtures}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDomainList(root, rel string) (map[string]domainPolicyEntry, error) {
|
||||||
|
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open domain allowlist %s: %w", rel, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
entries := map[string]domainPolicyEntry{}
|
||||||
|
var previous string
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for line := 1; scanner.Scan(); line++ {
|
||||||
|
host := strings.TrimSpace(scanner.Text())
|
||||||
|
if host == "" || strings.HasPrefix(host, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if host != strings.ToLower(host) {
|
||||||
|
return nil, fmt.Errorf("%s:%d: hostname must be lowercase: %q", rel, line, host)
|
||||||
|
}
|
||||||
|
if err := validatePolicyHostname(host); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s:%d: %w", rel, line, err)
|
||||||
|
}
|
||||||
|
if previous != "" && host <= previous {
|
||||||
|
return nil, fmt.Errorf("%s:%d: hostnames must be unique and sorted: %q", rel, line, host)
|
||||||
|
}
|
||||||
|
entries[host] = domainPolicyEntry{Host: host, File: rel, Line: line}
|
||||||
|
previous = host
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("read domain allowlist %s: %w", rel, err)
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil, fmt.Errorf("%s: domain list must not be empty", rel)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePolicyHostname(host string) error {
|
||||||
|
if len(host) > 253 || !strings.Contains(host, ".") || strings.HasSuffix(host, ".") {
|
||||||
|
return fmt.Errorf("invalid exact hostname %q", host)
|
||||||
|
}
|
||||||
|
labels := strings.Split(host, ".")
|
||||||
|
for _, label := range labels {
|
||||||
|
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||||
|
return fmt.Errorf("invalid exact hostname %q", host)
|
||||||
|
}
|
||||||
|
for _, r := range label {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("invalid exact hostname %q", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.ContainsAny(labels[len(labels)-1], "abcdefghijklmnopqrstuvwxyz") {
|
||||||
|
return fmt.Errorf("invalid exact hostname %q", host)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
120
lint/domaincontract/policy_test.go
Normal file
120
lint/domaincontract/policy_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package domaincontract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadDomainPolicy(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, root, publicDomainsPath, "# public\napi.example.com\nwww.example.com\n")
|
||||||
|
writeFile(t, root, fixtureDomainsPath, "# fixtures\nfixture.example.com\n")
|
||||||
|
|
||||||
|
policy, err := loadDomainPolicy(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(policy.Public) != 2 || len(policy.Fixtures) != 1 {
|
||||||
|
t.Fatalf("unexpected policy sizes: public=%d fixtures=%d", len(policy.Public), len(policy.Fixtures))
|
||||||
|
}
|
||||||
|
if policy.Public["api.example.com"].Line != 2 {
|
||||||
|
t.Fatalf("api.example.com line = %d, want 2", policy.Public["api.example.com"].Line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDomainPolicyRejectsInvalidLists(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
public string
|
||||||
|
fixtures string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "uppercase",
|
||||||
|
public: "API.example.com\n",
|
||||||
|
fixtures: "fixture.example.com\n",
|
||||||
|
want: "must be lowercase",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsorted",
|
||||||
|
public: "www.example.com\napi.example.com\n",
|
||||||
|
fixtures: "fixture.example.com\n",
|
||||||
|
want: "unique and sorted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate",
|
||||||
|
public: "api.example.com\napi.example.com\n",
|
||||||
|
fixtures: "fixture.example.com\n",
|
||||||
|
want: "unique and sorted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard",
|
||||||
|
public: "*.example.com\n",
|
||||||
|
fixtures: "fixture.example.com\n",
|
||||||
|
want: "invalid exact hostname",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scheme",
|
||||||
|
public: "https://example.com\n",
|
||||||
|
fixtures: "fixture.example.com\n",
|
||||||
|
want: "invalid exact hostname",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path",
|
||||||
|
public: "api.example.com/v1\n",
|
||||||
|
fixtures: "fixture.example.com\n",
|
||||||
|
want: "invalid exact hostname",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port",
|
||||||
|
public: "api.example.com:443\n",
|
||||||
|
fixtures: "fixture.example.com\n",
|
||||||
|
want: "invalid exact hostname",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cross-list duplicate",
|
||||||
|
public: "api.example.com\n",
|
||||||
|
fixtures: "api.example.com\n",
|
||||||
|
want: "already listed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, root, publicDomainsPath, tc.public)
|
||||||
|
writeFile(t, root, fixtureDomainsPath, tc.fixtures)
|
||||||
|
_, err := loadDomainPolicy(root)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatalf("loadDomainPolicy() error = %v, want substring %q", err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReservedExampleHostname(t *testing.T) {
|
||||||
|
for _, host := range []string{
|
||||||
|
"example.com",
|
||||||
|
"example.net",
|
||||||
|
"example.org",
|
||||||
|
"example.test",
|
||||||
|
"docs.example",
|
||||||
|
"missing.invalid",
|
||||||
|
"service.localhost",
|
||||||
|
} {
|
||||||
|
if !isReservedExampleHostname(host) {
|
||||||
|
t.Errorf("%q should be a reserved example hostname", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, host := range []string{
|
||||||
|
"attacker.example.com",
|
||||||
|
"example.dev",
|
||||||
|
"private.corp.internal",
|
||||||
|
} {
|
||||||
|
if isReservedExampleHostname(host) {
|
||||||
|
t.Errorf("%q must still require policy approval", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// Package domaincontract guards the Go CLI against direct reuse of the current
|
// Package domaincontract guards resolver ownership and rejects newly introduced
|
||||||
// resolver-owned host FQDNs outside core.ResolveEndpoints.
|
// static Go hostnames that are not covered by the repository domain policy.
|
||||||
package domaincontract
|
package domaincontract
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"go/token"
|
"go/token"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -75,10 +76,40 @@ func skipDir(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanRepo walks production .go files under root and flags string literals
|
// ScanRepo runs the resolver-owned endpoint guard and a full repository domain
|
||||||
// containing a forbidden resolver host outside the allowlist. Comments and
|
// inventory. CI should use ScanRepoWithOptions with a changed-from revision so
|
||||||
// _test.go files are not scanned.
|
// historical unapproved domains are not attributed to an unrelated change.
|
||||||
func ScanRepo(root string) ([]lintapi.Violation, error) {
|
func ScanRepo(root string) ([]lintapi.Violation, error) {
|
||||||
|
return ScanRepoWithOptions(root, ScanOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScanOptions struct {
|
||||||
|
ChangedFrom string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) {
|
||||||
|
out, err := scanHardcodedEndpoints(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
domainViolations, err := scanUnapprovedDomains(root, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, domainViolations...)
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].File != out[j].File {
|
||||||
|
return out[i].File < out[j].File
|
||||||
|
}
|
||||||
|
if out[i].Line != out[j].Line {
|
||||||
|
return out[i].Line < out[j].Line
|
||||||
|
}
|
||||||
|
return out[i].Rule < out[j].Rule
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanHardcodedEndpoints(root string) ([]lintapi.Violation, error) {
|
||||||
var out []lintapi.Violation
|
var out []lintapi.Violation
|
||||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
911
lint/domaincontract/unapproved.go
Normal file
911
lint/domaincontract/unapproved.go
Normal file
@@ -0,0 +1,911 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package domaincontract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/constant"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"go/types"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/lint/lintapi"
|
||||||
|
"golang.org/x/tools/go/packages"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
unapprovedDomainRule = "unapproved-domain"
|
||||||
|
unusedDomainRule = "domain-allowlist-unused"
|
||||||
|
incompleteDomainRule = "domain-scan-incomplete"
|
||||||
|
)
|
||||||
|
|
||||||
|
type typedGoFile struct {
|
||||||
|
File *ast.File
|
||||||
|
Fset *token.FileSet
|
||||||
|
Info *types.Info
|
||||||
|
}
|
||||||
|
|
||||||
|
type domainEvidence struct {
|
||||||
|
Host string
|
||||||
|
Kind string
|
||||||
|
Expr ast.Expr
|
||||||
|
}
|
||||||
|
|
||||||
|
type evidenceKey struct {
|
||||||
|
Host string
|
||||||
|
Start, End token.Pos
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileDomainScan struct {
|
||||||
|
File *ast.File
|
||||||
|
Fset *token.FileSet
|
||||||
|
Info *types.Info
|
||||||
|
Evidence []domainEvidence
|
||||||
|
TypeInfoRequired []ast.Expr
|
||||||
|
seen map[evidenceKey]bool
|
||||||
|
parents map[ast.Node]ast.Node
|
||||||
|
}
|
||||||
|
|
||||||
|
type collectionCompositeKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
notCollectionComposite collectionCompositeKind = iota
|
||||||
|
sequenceComposite
|
||||||
|
mapComposite
|
||||||
|
)
|
||||||
|
|
||||||
|
type hostnameFieldID struct {
|
||||||
|
Type string
|
||||||
|
Field string
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonNetworkHostnameFields = map[hostnameFieldID]bool{
|
||||||
|
{Type: "github.com/larksuite/cli/events/im.CardActionTriggerOutput", Field: "Host"}: true,
|
||||||
|
{Type: "github.com/larksuite/cli/internal/cmdmeta.Meta", Field: "Domain"}: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanUnapprovedDomains(root string, opts ScanOptions) ([]lintapi.Violation, error) {
|
||||||
|
root, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve repository root: %w", err)
|
||||||
|
}
|
||||||
|
publicPath := filepath.Join(root, filepath.FromSlash(publicDomainsPath))
|
||||||
|
if _, err := os.Stat(publicPath); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
if _, goModErr := os.Stat(filepath.Join(root, "go.mod")); os.IsNotExist(goModErr) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("domain policy unavailable: %w", err)
|
||||||
|
}
|
||||||
|
policy, err := loadDomainPolicy(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
added, err := changedGoLineRanges(root, opts.ChangedFrom)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
typed, typeLoadErr := loadTypedGoFiles(root)
|
||||||
|
goFiles, err := trackedGoFiles(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
observedPublic := map[string]bool{}
|
||||||
|
observedFixtures := map[string]bool{}
|
||||||
|
inventoryComplete := typeLoadErr == nil
|
||||||
|
var out []lintapi.Violation
|
||||||
|
parseFailureReported := false
|
||||||
|
typeInfoGapReported := false
|
||||||
|
for _, rel := range goFiles {
|
||||||
|
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||||
|
parsedFset := token.NewFileSet()
|
||||||
|
parsedFile, parseErr := parser.ParseFile(parsedFset, path, nil, 0)
|
||||||
|
if parseErr != nil {
|
||||||
|
inventoryComplete = false
|
||||||
|
if opts.ChangedFrom == "" {
|
||||||
|
out = append(out, incompleteDomainViolation(rel, parseErr))
|
||||||
|
parseFailureReported = true
|
||||||
|
} else if _, changed := added[rel]; changed {
|
||||||
|
out = append(out, incompleteDomainViolation(rel, parseErr))
|
||||||
|
parseFailureReported = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tf, ok := typed[filepath.Clean(path)]
|
||||||
|
if !ok {
|
||||||
|
tf = typedGoFile{File: parsedFile, Fset: parsedFset}
|
||||||
|
}
|
||||||
|
|
||||||
|
scan := newFileDomainScan(tf)
|
||||||
|
scan.collectSemanticEvidence()
|
||||||
|
scan.collectAbsoluteURLEvidence()
|
||||||
|
if len(scan.TypeInfoRequired) > 0 {
|
||||||
|
// Inventory completeness is a property of the whole HEAD. Whether
|
||||||
|
// this PR owns an incomplete-scan diagnostic is decided separately
|
||||||
|
// by the added-line intersection below.
|
||||||
|
inventoryComplete = false
|
||||||
|
}
|
||||||
|
for _, expr := range scan.TypeInfoRequired {
|
||||||
|
start := tf.Fset.Position(expr.Pos()).Line
|
||||||
|
end := tf.Fset.Position(expr.End()).Line
|
||||||
|
line := start
|
||||||
|
if opts.ChangedFrom != "" {
|
||||||
|
var intersects bool
|
||||||
|
line, intersects = firstAddedLineInSpan(added[rel], start, end)
|
||||||
|
if !intersects {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
typeInfoGapReported = true
|
||||||
|
out = append(out, incompleteDomainViolationAt(
|
||||||
|
rel,
|
||||||
|
line,
|
||||||
|
fmt.Errorf("Go type information unavailable for hostname-oriented field evidence"),
|
||||||
|
))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fixture := isDomainFixturePath(rel)
|
||||||
|
// The detector's own policy literals and contract corpus may be
|
||||||
|
// scanned, but they cannot justify keeping an allowlist entry.
|
||||||
|
policyOwner := strings.HasPrefix(rel, "lint/domaincontract/")
|
||||||
|
for _, evidence := range scan.Evidence {
|
||||||
|
if isReservedExampleHostname(evidence.Host) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := policy.Public[evidence.Host]; ok {
|
||||||
|
if !fixture && !policyOwner {
|
||||||
|
observedPublic[evidence.Host] = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := policy.Fixtures[evidence.Host]; ok && fixture {
|
||||||
|
if !policyOwner {
|
||||||
|
observedFixtures[evidence.Host] = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := tf.Fset.Position(evidence.Expr.Pos()).Line
|
||||||
|
end := tf.Fset.Position(evidence.Expr.End()).Line
|
||||||
|
line := start
|
||||||
|
if opts.ChangedFrom != "" {
|
||||||
|
var intersects bool
|
||||||
|
line, intersects = firstAddedLineInSpan(added[rel], start, end)
|
||||||
|
if !intersects {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suggestion := "remove the hostname or replace it with an approved public endpoint; " +
|
||||||
|
"public allowlist additions require evidence and CODEOWNER approval"
|
||||||
|
if _, fixtureOnly := policy.Fixtures[evidence.Host]; fixtureOnly && !fixture {
|
||||||
|
suggestion = "remove the fixture-only hostname or move this use into an approved fixture scope; " +
|
||||||
|
"fixture entries are not approved for production Go code or skills"
|
||||||
|
}
|
||||||
|
out = append(out, lintapi.Violation{
|
||||||
|
Rule: unapprovedDomainRule,
|
||||||
|
Action: lintapi.ActionReject,
|
||||||
|
File: rel,
|
||||||
|
Line: line,
|
||||||
|
Message: fmt.Sprintf(
|
||||||
|
"unapproved hostname %q found in %s",
|
||||||
|
evidence.Host,
|
||||||
|
evidence.Kind,
|
||||||
|
),
|
||||||
|
Suggestion: suggestion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A syntax error is also surfaced by go/packages. Prefer the file-specific
|
||||||
|
// parse diagnostic when one was already reported; otherwise make a
|
||||||
|
// repository-wide type-loading failure explicit instead of silently
|
||||||
|
// continuing without the type information required by field evidence.
|
||||||
|
if typeLoadErr != nil && !parseFailureReported && !typeInfoGapReported {
|
||||||
|
out = append(out, incompleteDomainViolation("go.mod", typeLoadErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
if inventoryComplete {
|
||||||
|
for host, entry := range policy.Public {
|
||||||
|
if !observedPublic[host] {
|
||||||
|
out = append(out, unusedDomainViolation(entry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for host, entry := range policy.Fixtures {
|
||||||
|
if !observedFixtures[host] {
|
||||||
|
out = append(out, unusedDomainViolation(entry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trackedGoFiles(root string) ([]string, error) {
|
||||||
|
out, err := gitCommandOutput(root, "ls-files", "-z", "--", "*.go")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list tracked Go files: %w", err)
|
||||||
|
}
|
||||||
|
var files []string
|
||||||
|
for _, raw := range strings.Split(string(out), "\x00") {
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rel := filepath.ToSlash(raw)
|
||||||
|
if strings.HasPrefix(rel, "vendor/") || strings.HasPrefix(rel, "node_modules/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, rel)
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTypedGoFiles(root string) (map[string]typedGoFile, error) {
|
||||||
|
moduleDirs, err := trackedGoModuleDirs(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]typedGoFile{}
|
||||||
|
var firstLoadErr error
|
||||||
|
var loadErrCount int
|
||||||
|
for _, moduleDir := range moduleDirs {
|
||||||
|
moduleRoot := root
|
||||||
|
if moduleDir != "." {
|
||||||
|
moduleRoot = filepath.Join(root, filepath.FromSlash(moduleDir))
|
||||||
|
}
|
||||||
|
files, err := loadTypedGoModule(moduleRoot)
|
||||||
|
for path, file := range files {
|
||||||
|
out[path] = file
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
loadErrCount++
|
||||||
|
if firstLoadErr == nil {
|
||||||
|
firstLoadErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loadErrCount == 1 {
|
||||||
|
return out, firstLoadErr
|
||||||
|
}
|
||||||
|
if loadErrCount > 1 {
|
||||||
|
return out, fmt.Errorf("%w (and %d more module errors)", firstLoadErr, loadErrCount-1)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trackedGoModuleDirs(root string) ([]string, error) {
|
||||||
|
raw, err := gitCommandOutput(root, "ls-files", "-z")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list tracked Go modules: %w", err)
|
||||||
|
}
|
||||||
|
var dirs []string
|
||||||
|
for _, path := range strings.Split(string(raw), "\x00") {
|
||||||
|
path = filepath.ToSlash(path)
|
||||||
|
if path != "go.mod" && !strings.HasSuffix(path, "/go.mod") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := filepath.ToSlash(filepath.Dir(path))
|
||||||
|
dirs = append(dirs, dir)
|
||||||
|
}
|
||||||
|
return dirs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTypedGoModule(moduleRoot string) (map[string]typedGoFile, error) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
cfg := &packages.Config{
|
||||||
|
Mode: packages.NeedName |
|
||||||
|
packages.NeedFiles |
|
||||||
|
packages.NeedCompiledGoFiles |
|
||||||
|
packages.NeedImports |
|
||||||
|
packages.NeedDeps |
|
||||||
|
packages.NeedTypes |
|
||||||
|
packages.NeedSyntax |
|
||||||
|
packages.NeedTypesInfo,
|
||||||
|
Dir: moduleRoot,
|
||||||
|
Fset: fset,
|
||||||
|
Tests: true,
|
||||||
|
}
|
||||||
|
pkgs, err := packages.Load(cfg, "./...")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load Go type information: %w", err)
|
||||||
|
}
|
||||||
|
out := map[string]typedGoFile{}
|
||||||
|
var firstPackageErr string
|
||||||
|
var packageErrCount int
|
||||||
|
packages.Visit(pkgs, nil, func(pkg *packages.Package) {
|
||||||
|
if pkg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, pkgErr := range pkg.Errors {
|
||||||
|
packageErrCount++
|
||||||
|
if firstPackageErr == "" {
|
||||||
|
firstPackageErr = pkgErr.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pkg.TypesInfo == nil || pkg.Fset == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, file := range pkg.Syntax {
|
||||||
|
if i >= len(pkg.CompiledGoFiles) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
path := filepath.Clean(pkg.CompiledGoFiles[i])
|
||||||
|
if _, exists := out[path]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[path] = typedGoFile{File: file, Fset: pkg.Fset, Info: pkg.TypesInfo}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if packageErrCount == 1 {
|
||||||
|
return out, fmt.Errorf("load Go type information: %s", firstPackageErr)
|
||||||
|
}
|
||||||
|
if packageErrCount > 1 {
|
||||||
|
return out, fmt.Errorf(
|
||||||
|
"load Go type information: %s (and %d more package errors)",
|
||||||
|
firstPackageErr,
|
||||||
|
packageErrCount-1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFileDomainScan(file typedGoFile) *fileDomainScan {
|
||||||
|
return &fileDomainScan{
|
||||||
|
File: file.File,
|
||||||
|
Fset: file.Fset,
|
||||||
|
Info: file.Info,
|
||||||
|
seen: map[evidenceKey]bool{},
|
||||||
|
parents: astParentMap(file.File),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) collectSemanticEvidence() {
|
||||||
|
ast.Inspect(s.File, func(node ast.Node) bool {
|
||||||
|
switch n := node.(type) {
|
||||||
|
case *ast.AssignStmt:
|
||||||
|
if len(n.Lhs) != len(n.Rhs) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for i, lhs := range n.Lhs {
|
||||||
|
if s.Info == nil &&
|
||||||
|
potentialHostnameSelectorTarget(lhs) &&
|
||||||
|
s.hasStaticBareHostnameValue(n.Rhs[i]) {
|
||||||
|
s.requireTypeInfo(n.Rhs[i])
|
||||||
|
}
|
||||||
|
if index, ok := stripParens(lhs).(*ast.IndexExpr); ok {
|
||||||
|
switch {
|
||||||
|
case s.isHostnameTarget(index.X):
|
||||||
|
s.addMapPair(index.Index, n.Rhs[i])
|
||||||
|
case s.isHostnameMapKey(index.Index):
|
||||||
|
s.addHostValue(n.Rhs[i], "host assignment")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s.isHostnameTarget(lhs) {
|
||||||
|
s.addHostValue(n.Rhs[i], "host assignment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *ast.ValueSpec:
|
||||||
|
if len(n.Names) != len(n.Values) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for i, name := range n.Names {
|
||||||
|
if isHostnameSemanticName(name.Name) {
|
||||||
|
s.addHostValue(n.Values[i], "host assignment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *ast.KeyValueExpr:
|
||||||
|
if s.Info == nil && s.keyValueNeedsTypeInfo(n) {
|
||||||
|
s.requireTypeInfo(n.Value)
|
||||||
|
}
|
||||||
|
if s.isHostnameKeyValue(n) {
|
||||||
|
s.addHostValue(n.Value, "host assignment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) requireTypeInfo(expr ast.Expr) {
|
||||||
|
for _, existing := range s.TypeInfoRequired {
|
||||||
|
if existing.Pos() == expr.Pos() && existing.End() == expr.End() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.TypeInfoRequired = append(s.TypeInfoRequired, expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) hasStaticBareHostnameValue(expr ast.Expr) bool {
|
||||||
|
value, ok := staticStringValue(expr, s.Info, nil)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host, ok := semanticHostname(value)
|
||||||
|
return ok && !isReservedExampleHostname(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) keyValueNeedsTypeInfo(pair *ast.KeyValueExpr) bool {
|
||||||
|
composite, ok := s.parents[pair].(*ast.CompositeLit)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, explicitMap := composite.Type.(*ast.MapType); explicitMap {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
key, ok := pair.Key.(*ast.Ident)
|
||||||
|
return ok && isHostnameSemanticName(key.Name) && s.hasStaticBareHostnameValue(pair.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func potentialHostnameSelectorTarget(expr ast.Expr) bool {
|
||||||
|
switch n := stripParens(expr).(type) {
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
return isHostnameSemanticName(n.Sel.Name)
|
||||||
|
case *ast.StarExpr:
|
||||||
|
return potentialHostnameSelectorTarget(n.X)
|
||||||
|
case *ast.IndexExpr:
|
||||||
|
return potentialHostnameSelectorTarget(n.X)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) collectAbsoluteURLEvidence() {
|
||||||
|
ast.Inspect(s.File, func(node ast.Node) bool {
|
||||||
|
expr, ok := node.(ast.Expr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ident, ok := expr.(*ast.Ident); ok && s.Info != nil && s.Info.Defs[ident] != nil {
|
||||||
|
// A declaration name may carry the constant value in types.Info,
|
||||||
|
// but it is not a second source expression.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
value, ok := staticStringValue(expr, s.Info, nil)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if s.hasStaticStringContainer(expr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
host, ok := absoluteURLHostname(value)
|
||||||
|
if ok {
|
||||||
|
s.addEvidence(host, "absolute URL", expr)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) hasStaticStringContainer(expr ast.Expr) bool {
|
||||||
|
parent, ok := s.parents[expr].(ast.Expr)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch parent.(type) {
|
||||||
|
case *ast.BinaryExpr, *ast.ParenExpr:
|
||||||
|
_, ok := staticStringValue(parent, s.Info, nil)
|
||||||
|
return ok
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) addHostValue(expr ast.Expr, kind string) {
|
||||||
|
expr = stripParens(expr)
|
||||||
|
if composite, ok := expr.(*ast.CompositeLit); ok {
|
||||||
|
switch s.collectionCompositeKind(composite) {
|
||||||
|
case sequenceComposite:
|
||||||
|
for _, element := range composite.Elts {
|
||||||
|
if valueExpr, ok := element.(ast.Expr); ok {
|
||||||
|
s.addHostValue(valueExpr, "host collection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case mapComposite:
|
||||||
|
for _, element := range composite.Elts {
|
||||||
|
pair, ok := element.(*ast.KeyValueExpr)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keyExpr, ok := pair.Key.(ast.Expr)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.addMapPair(keyExpr, pair.Value)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if s.Info == nil {
|
||||||
|
s.requireTypeInfoForUnclassifiedCollection(composite)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if evidence, ok := s.hostnameEvidence(expr, kind); ok {
|
||||||
|
s.addEvidence(evidence.Host, evidence.Kind, evidence.Expr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) requireTypeInfoForUnclassifiedCollection(composite *ast.CompositeLit) {
|
||||||
|
for _, element := range composite.Elts {
|
||||||
|
if pair, ok := element.(*ast.KeyValueExpr); ok {
|
||||||
|
keyExpr, ok := pair.Key.(ast.Expr)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keyIsHost := s.hasStaticBareHostnameValue(keyExpr)
|
||||||
|
valueIsHost := s.hasStaticBareHostnameValue(pair.Value)
|
||||||
|
if keyIsHost == valueIsHost {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if keyIsHost {
|
||||||
|
s.requireTypeInfo(keyExpr)
|
||||||
|
} else {
|
||||||
|
s.requireTypeInfo(pair.Value)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valueExpr, ok := element.(ast.Expr)
|
||||||
|
if ok && s.hasStaticBareHostnameValue(valueExpr) {
|
||||||
|
s.requireTypeInfo(valueExpr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addMapPair reports a map side only when it is the sole hostname-shaped
|
||||||
|
// static value. A semantic map name does not establish whether a string map
|
||||||
|
// is hostname->metadata or alias->hostname, so reporting both sides would turn
|
||||||
|
// filenames such as client.pem into blocking hostname evidence.
|
||||||
|
func (s *fileDomainScan) addMapPair(key, value ast.Expr) {
|
||||||
|
keyEvidence, keyOK := s.hostnameEvidence(key, "host collection")
|
||||||
|
valueEvidence, valueOK := s.hostnameEvidence(value, "host collection")
|
||||||
|
if keyOK == valueOK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if keyOK {
|
||||||
|
s.addEvidence(keyEvidence.Host, keyEvidence.Kind, keyEvidence.Expr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.addEvidence(valueEvidence.Host, valueEvidence.Kind, valueEvidence.Expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) hostnameEvidence(expr ast.Expr, kind string) (domainEvidence, bool) {
|
||||||
|
expr = stripParens(expr)
|
||||||
|
value, ok := staticStringValue(expr, s.Info, nil)
|
||||||
|
if !ok {
|
||||||
|
return domainEvidence{}, false
|
||||||
|
}
|
||||||
|
if host, ok := absoluteURLHostname(value); ok {
|
||||||
|
return domainEvidence{Host: host, Kind: "absolute URL", Expr: expr}, true
|
||||||
|
}
|
||||||
|
if host, ok := semanticHostname(value); ok {
|
||||||
|
return domainEvidence{Host: host, Kind: kind, Expr: expr}, true
|
||||||
|
}
|
||||||
|
return domainEvidence{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) collectionCompositeKind(expr *ast.CompositeLit) collectionCompositeKind {
|
||||||
|
if s.Info != nil {
|
||||||
|
if tv, ok := s.Info.Types[expr]; ok && tv.Type != nil {
|
||||||
|
switch tv.Type.Underlying().(type) {
|
||||||
|
case *types.Array, *types.Slice:
|
||||||
|
return sequenceComposite
|
||||||
|
case *types.Map:
|
||||||
|
return mapComposite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch expr.Type.(type) {
|
||||||
|
case *ast.ArrayType:
|
||||||
|
return sequenceComposite
|
||||||
|
case *ast.MapType:
|
||||||
|
return mapComposite
|
||||||
|
default:
|
||||||
|
return notCollectionComposite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) addEvidence(host, kind string, expr ast.Expr) {
|
||||||
|
key := evidenceKey{Host: host, Start: expr.Pos(), End: expr.End()}
|
||||||
|
if s.seen[key] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.seen[key] = true
|
||||||
|
s.Evidence = append(s.Evidence, domainEvidence{Host: host, Kind: kind, Expr: expr})
|
||||||
|
}
|
||||||
|
|
||||||
|
func staticStringValue(expr ast.Expr, info *types.Info, seen map[*ast.Object]bool) (string, bool) {
|
||||||
|
if info != nil {
|
||||||
|
if tv, ok := info.Types[expr]; ok && tv.Value != nil && tv.Value.Kind() == constant.String {
|
||||||
|
return constant.StringVal(tv.Value), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch n := expr.(type) {
|
||||||
|
case *ast.BasicLit:
|
||||||
|
if n.Kind != token.STRING {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
value, err := strconv.Unquote(n.Value)
|
||||||
|
return value, err == nil
|
||||||
|
case *ast.ParenExpr:
|
||||||
|
return staticStringValue(n.X, info, seen)
|
||||||
|
case *ast.BinaryExpr:
|
||||||
|
if n.Op != token.ADD {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
left, ok := staticStringValue(n.X, info, seen)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
right, ok := staticStringValue(n.Y, info, seen)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return left + right, true
|
||||||
|
case *ast.Ident:
|
||||||
|
if info != nil {
|
||||||
|
if obj := info.ObjectOf(n); obj != nil {
|
||||||
|
if c, ok := obj.(*types.Const); ok {
|
||||||
|
if c.Val().Kind() == constant.String {
|
||||||
|
return constant.StringVal(c.Val()), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n.Obj == nil || n.Obj.Kind != ast.Con {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if seen == nil {
|
||||||
|
seen = map[*ast.Object]bool{}
|
||||||
|
}
|
||||||
|
if seen[n.Obj] {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
seen[n.Obj] = true
|
||||||
|
defer delete(seen, n.Obj)
|
||||||
|
spec, ok := n.Obj.Decl.(*ast.ValueSpec)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for i, name := range spec.Names {
|
||||||
|
if name.Name == n.Name && i < len(spec.Values) {
|
||||||
|
return staticStringValue(spec.Values[i], info, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func absoluteURLHostname(value string) (string, bool) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(parsed.Scheme) {
|
||||||
|
case "http", "https", "ws", "wss":
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return normalizeCandidateHostname(parsed.Hostname())
|
||||||
|
}
|
||||||
|
|
||||||
|
func semanticHostname(value string) (string, bool) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || strings.ContainsAny(value, `/\?#@`) || strings.ContainsAny(value, " \t\r\n") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse("//" + value)
|
||||||
|
if err != nil || parsed.Host == "" || parsed.Path != "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return normalizeCandidateHostname(parsed.Hostname())
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCandidateHostname(host string) (string, bool) {
|
||||||
|
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
|
||||||
|
if host == "" || !strings.Contains(host, ".") || net.ParseIP(host) != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
labels := strings.Split(host, ".")
|
||||||
|
for _, label := range labels {
|
||||||
|
if label == "" || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, r := range label {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return host, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) isHostnameTarget(expr ast.Expr) bool {
|
||||||
|
switch n := stripParens(expr).(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
return isHostnameSemanticName(n.Name)
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
return s.isHostnameSelector(n)
|
||||||
|
case *ast.StarExpr:
|
||||||
|
return s.isHostnameTarget(n.X)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) isHostnameKeyValue(pair *ast.KeyValueExpr) bool {
|
||||||
|
composite, ok := s.parents[pair].(*ast.CompositeLit)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch s.collectionCompositeKind(composite) {
|
||||||
|
case mapComposite:
|
||||||
|
key, ok := pair.Key.(ast.Expr)
|
||||||
|
return ok && s.isHostnameMapKey(key)
|
||||||
|
case notCollectionComposite:
|
||||||
|
ident, ok := pair.Key.(*ast.Ident)
|
||||||
|
return ok && s.isHostnameStructField(composite, ident.Name)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) isHostnameMapKey(expr ast.Expr) bool {
|
||||||
|
value, ok := staticStringValue(expr, s.Info, nil)
|
||||||
|
return ok && isHostnameSemanticName(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) isHostnameSelector(selector *ast.SelectorExpr) bool {
|
||||||
|
if s.Info == nil || !isHostnameSemanticName(selector.Sel.Name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
selection := s.Info.Selections[selector]
|
||||||
|
if selection == nil || selection.Kind() != types.FieldVal {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !nonNetworkHostnameFields[hostnameFieldID{
|
||||||
|
Type: namedTypeID(selection.Recv()),
|
||||||
|
Field: selector.Sel.Name,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fileDomainScan) isHostnameStructField(composite *ast.CompositeLit, field string) bool {
|
||||||
|
if s.Info == nil || !isHostnameSemanticName(field) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
typeID := namedTypeID(s.Info.TypeOf(composite))
|
||||||
|
if typeID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !nonNetworkHostnameFields[hostnameFieldID{Type: typeID, Field: field}]
|
||||||
|
}
|
||||||
|
|
||||||
|
func namedTypeID(typ types.Type) string {
|
||||||
|
for {
|
||||||
|
switch t := typ.(type) {
|
||||||
|
case *types.Pointer:
|
||||||
|
typ = t.Elem()
|
||||||
|
case *types.Named:
|
||||||
|
obj := t.Obj()
|
||||||
|
if obj == nil || obj.Pkg() == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return obj.Pkg().Path() + "." + obj.Name()
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHostnameSemanticName(name string) bool {
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
switch lower {
|
||||||
|
case "host", "hosts", "hostname", "hostnames", "domain", "domains":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, marker := range []string{
|
||||||
|
"HostBy", "HostsBy", "HostnameBy", "HostnamesBy", "DomainBy", "DomainsBy",
|
||||||
|
} {
|
||||||
|
if i := strings.Index(name, marker); i >= 0 {
|
||||||
|
end := i + len(marker)
|
||||||
|
if end < len(name) && unicode.IsUpper(rune(name[end])) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, prefix := range []string{
|
||||||
|
"hostBy", "hostsBy", "hostnameBy", "hostnamesBy", "domainBy", "domainsBy",
|
||||||
|
} {
|
||||||
|
if strings.HasPrefix(name, prefix) &&
|
||||||
|
len(name) > len(prefix) &&
|
||||||
|
unicode.IsUpper(rune(name[len(prefix)])) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if i := strings.LastIndexAny(name, "_-"); i >= 0 {
|
||||||
|
return isHostnameSemanticName(name[i+1:])
|
||||||
|
}
|
||||||
|
for _, suffix := range []string{"Hostnames", "Hostname", "Domains", "Domain", "Hosts", "Host"} {
|
||||||
|
if strings.HasSuffix(name, suffix) && len(name) > len(suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripParens(expr ast.Expr) ast.Expr {
|
||||||
|
for {
|
||||||
|
paren, ok := expr.(*ast.ParenExpr)
|
||||||
|
if !ok {
|
||||||
|
return expr
|
||||||
|
}
|
||||||
|
expr = paren.X
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func astParentMap(root ast.Node) map[ast.Node]ast.Node {
|
||||||
|
parents := map[ast.Node]ast.Node{}
|
||||||
|
var stack []ast.Node
|
||||||
|
ast.Inspect(root, func(node ast.Node) bool {
|
||||||
|
if node == nil {
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(stack) > 0 {
|
||||||
|
parents[node] = stack[len(stack)-1]
|
||||||
|
}
|
||||||
|
stack = append(stack, node)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return parents
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDomainFixturePath(rel string) bool {
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
if strings.HasPrefix(rel, "skills/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(rel, "_test.go") || strings.HasPrefix(rel, "tests/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, part := range strings.Split(rel, "/") {
|
||||||
|
if part == "testdata" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func unusedDomainViolation(entry domainPolicyEntry) lintapi.Violation {
|
||||||
|
return lintapi.Violation{
|
||||||
|
Rule: unusedDomainRule,
|
||||||
|
Action: lintapi.ActionReject,
|
||||||
|
File: entry.File,
|
||||||
|
Line: entry.Line,
|
||||||
|
Message: fmt.Sprintf("domain allowlist entry %q has no in-scope Go reference", entry.Host),
|
||||||
|
Suggestion: "remove the unused entry; allowlist entries must be justified by a current in-scope reference",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func incompleteDomainViolation(file string, err error) lintapi.Violation {
|
||||||
|
return incompleteDomainViolationAt(file, 1, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func incompleteDomainViolationAt(file string, line int, err error) lintapi.Violation {
|
||||||
|
return lintapi.Violation{
|
||||||
|
Rule: incompleteDomainRule,
|
||||||
|
Action: lintapi.ActionReject,
|
||||||
|
File: file,
|
||||||
|
Line: line,
|
||||||
|
Message: "domain scan incomplete: " + err.Error(),
|
||||||
|
Suggestion: "fix the Go parse or type-loading error so hostname analysis can complete",
|
||||||
|
}
|
||||||
|
}
|
||||||
462
lint/domaincontract/unapproved_repo_test.go
Normal file
462
lint/domaincontract/unapproved_repo_test.go
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package domaincontract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/lint/lintapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func gitTestCommand(t *testing.T, root string, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = root
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupDomainDiffRepo(t *testing.T, target string) (root, base string) {
|
||||||
|
t.Helper()
|
||||||
|
root = t.TempDir()
|
||||||
|
writeFile(t, root, "go.mod", "module example.com/domainfixture\n\ngo 1.23.0\n")
|
||||||
|
writeFile(t, root, publicDomainsPath, "# public\npublic.example.com\n")
|
||||||
|
writeFile(t, root, fixtureDomainsPath, "# fixtures\nfixture.example.com\n")
|
||||||
|
writeFile(t, root, "policy_refs.go", "package sample\n\nvar APIHost = \"public.example.com\"\n")
|
||||||
|
writeFile(t, root, "policy_refs_test.go", "package sample\n\nvar FixtureHost = \"fixture.example.com\"\n")
|
||||||
|
writeFile(t, root, "target.go", target)
|
||||||
|
|
||||||
|
gitTestCommand(t, root, "init", "-q")
|
||||||
|
gitTestCommand(t, root, "config", "user.name", "Domain Contract Test")
|
||||||
|
gitTestCommand(t, root, "config", "user.email", "domain-contract@example.com")
|
||||||
|
gitTestCommand(t, root, "add", ".")
|
||||||
|
gitTestCommand(t, root, "-c", "commit.gpgsign=false", "commit", "-qm", "base")
|
||||||
|
return root, gitTestCommand(t, root, "rev-parse", "HEAD")
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitDomainDiff(t *testing.T, root, message string) {
|
||||||
|
t.Helper()
|
||||||
|
gitTestCommand(t, root, "add", "-A")
|
||||||
|
gitTestCommand(t, root, "-c", "commit.gpgsign=false", "commit", "-qm", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func violationsForRule(vs []lintapi.Violation, rule string) []lintapi.Violation {
|
||||||
|
var out []lintapi.Violation
|
||||||
|
for _, v := range vs {
|
||||||
|
if v.Rule == rule {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanDomainDiff(t *testing.T, root, base string) []lintapi.Violation {
|
||||||
|
t.Helper()
|
||||||
|
vs, err := ScanRepoWithOptions(root, ScanOptions{ChangedFrom: base})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return vs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnapprovedDomainDiffContract(t *testing.T) {
|
||||||
|
t.Run("new PR 1975 case", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "target.go",
|
||||||
|
"package sample\n\nvar unrelated = 1\nvar APIHost = \"internal-api-drive-stream.larkoffice.com\"\n")
|
||||||
|
commitDomainDiff(t, root, "add internal host")
|
||||||
|
|
||||||
|
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0].Message, "internal-api-drive-stream.larkoffice.com") {
|
||||||
|
t.Fatalf("violations = %+v, want PR 1975 hostname", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("hostname field in nested Go module", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "nested/go.mod", "module example.com/nested\n\ngo 1.23.0\n")
|
||||||
|
writeFile(t, root, "nested/target.go",
|
||||||
|
"package nested\n\ntype Config struct{ Host string }\n\n"+
|
||||||
|
"var config = Config{Host: \"private.corp.internal\"}\n")
|
||||||
|
commitDomainDiff(t, root, "add nested module hostname")
|
||||||
|
|
||||||
|
all := scanDomainDiff(t, root, base)
|
||||||
|
got := violationsForRule(all, unapprovedDomainRule)
|
||||||
|
if len(got) != 1 || filepath.ToSlash(got[0].File) != "nested/target.go" ||
|
||||||
|
!strings.Contains(got[0].Message, "private.corp.internal") {
|
||||||
|
t.Fatalf("violations = %+v, want nested-module hostname rejection", got)
|
||||||
|
}
|
||||||
|
if incomplete := violationsForRule(all, incompleteDomainRule); len(incomplete) != 0 {
|
||||||
|
t.Fatalf("nested module must have complete type information: %+v", incomplete)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("changed excluded field reports incomplete scan", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "excluded.go",
|
||||||
|
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||||
|
"type Config struct{ Host string }\n\n"+
|
||||||
|
"var config = Config{Host: \"private.corp.internal\"}\n")
|
||||||
|
commitDomainDiff(t, root, "add excluded hostname field")
|
||||||
|
|
||||||
|
all := scanDomainDiff(t, root, base)
|
||||||
|
got := violationsForRule(all, incompleteDomainRule)
|
||||||
|
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 7 {
|
||||||
|
t.Fatalf("violations = %+v, want changed field scan-incomplete at line 7", got)
|
||||||
|
}
|
||||||
|
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
|
||||||
|
t.Fatalf("untyped field must not produce an unverified hostname finding: %+v", unapproved)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("changed excluded selector reports incomplete scan", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "excluded.go",
|
||||||
|
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||||
|
"type Config struct{ Host string }\n\n"+
|
||||||
|
"func configure(config *Config) { config.Host = \"private.corp.internal\" }\n")
|
||||||
|
commitDomainDiff(t, root, "add excluded hostname selector")
|
||||||
|
|
||||||
|
got := violationsForRule(scanDomainDiff(t, root, base), incompleteDomainRule)
|
||||||
|
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 7 {
|
||||||
|
t.Fatalf("violations = %+v, want changed selector scan-incomplete at line 7", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("changed excluded named slice reports incomplete scan", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "excluded.go",
|
||||||
|
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||||
|
"type HostList []string\n\n"+
|
||||||
|
"var AllowedHosts = HostList{\n\t\"attacker.zip\",\n}\n")
|
||||||
|
commitDomainDiff(t, root, "add excluded hostname slice")
|
||||||
|
|
||||||
|
all := scanDomainDiff(t, root, base)
|
||||||
|
got := violationsForRule(all, incompleteDomainRule)
|
||||||
|
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 8 {
|
||||||
|
t.Fatalf("violations = %+v, want named-slice scan-incomplete at line 8", got)
|
||||||
|
}
|
||||||
|
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
|
||||||
|
t.Fatalf("untyped named slice must not produce an unverified hostname finding: %+v", unapproved)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("changed excluded named map reports incomplete scan", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "excluded.go",
|
||||||
|
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||||
|
"type HostSet map[string]struct{}\n\n"+
|
||||||
|
"var AllowedHosts = HostSet{\n\t\"attacker.zip\": {},\n}\n")
|
||||||
|
commitDomainDiff(t, root, "add excluded hostname map")
|
||||||
|
|
||||||
|
all := scanDomainDiff(t, root, base)
|
||||||
|
got := violationsForRule(all, incompleteDomainRule)
|
||||||
|
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 8 {
|
||||||
|
t.Fatalf("violations = %+v, want named-map scan-incomplete at line 8", got)
|
||||||
|
}
|
||||||
|
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
|
||||||
|
t.Fatalf("untyped named map must not produce an unverified hostname finding: %+v", unapproved)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("changed excluded unrelated code stays allowed", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "excluded.go",
|
||||||
|
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\nvar unrelated = 2\n")
|
||||||
|
commitDomainDiff(t, root, "add excluded unrelated code")
|
||||||
|
|
||||||
|
if got := violationsForRule(scanDomainDiff(t, root, base), incompleteDomainRule); len(got) != 0 {
|
||||||
|
t.Fatalf("unrelated excluded code must not require hostname type information: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("new element in existing collection", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t,
|
||||||
|
"package sample\n\nvar ExtraHosts = []string{\n\t\"public.example.com\",\n}\n")
|
||||||
|
writeFile(t, root, "target.go",
|
||||||
|
"package sample\n\nvar ExtraHosts = []string{\n\t\"public.example.com\",\n\t\"attacker.zip\",\n}\n")
|
||||||
|
commitDomainDiff(t, root, "add collection host")
|
||||||
|
|
||||||
|
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0].Message, "attacker.zip") {
|
||||||
|
t.Fatalf("violations = %+v, want attacker.zip", got)
|
||||||
|
}
|
||||||
|
if got[0].Line != 5 {
|
||||||
|
t.Fatalf("violation line = %d, want 5", got[0].Line)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multiline expression changed segment", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t,
|
||||||
|
"package sample\n\nvar ExtraHost = \"private.corp.\" +\n\t\"example.com\"\n")
|
||||||
|
writeFile(t, root, "target.go",
|
||||||
|
"package sample\n\nvar ExtraHost = \"private.corp.\" +\n\t\"internal\"\n")
|
||||||
|
commitDomainDiff(t, root, "change concatenated host")
|
||||||
|
|
||||||
|
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0].Message, "private.corp.internal") {
|
||||||
|
t.Fatalf("violations = %+v, want private.corp.internal", got)
|
||||||
|
}
|
||||||
|
if got[0].Line != 4 {
|
||||||
|
t.Fatalf("violation line = %d, want changed line 4", got[0].Line)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unrelated change beside historical hostname", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t,
|
||||||
|
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
|
||||||
|
writeFile(t, root, "target.go",
|
||||||
|
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\nvar unrelated = 1\n")
|
||||||
|
commitDomainDiff(t, root, "add unrelated value")
|
||||||
|
|
||||||
|
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||||
|
t.Fatalf("unexpected historical-domain violation: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("historical hostname expression changed", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t,
|
||||||
|
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
|
||||||
|
writeFile(t, root, "target.go",
|
||||||
|
"package sample\n\nvar HistoricalHost = \"replacement.private.internal\"\n")
|
||||||
|
commitDomainDiff(t, root, "change historical host")
|
||||||
|
|
||||||
|
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0].Message, "replacement.private.internal") {
|
||||||
|
t.Fatalf("violations = %+v, want replacement.private.internal", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("new assignment references existing constant", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t,
|
||||||
|
"package sample\n\nconst existingConst = \"private.corp.internal\"\n")
|
||||||
|
writeFile(t, root, "target.go",
|
||||||
|
"package sample\n\nconst existingConst = \"private.corp.internal\"\nvar APIHost = existingConst\n")
|
||||||
|
commitDomainDiff(t, root, "use existing hostname constant")
|
||||||
|
|
||||||
|
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0].Message, "private.corp.internal") {
|
||||||
|
t.Fatalf("violations = %+v, want private.corp.internal", got)
|
||||||
|
}
|
||||||
|
if got[0].Line != 4 {
|
||||||
|
t.Fatalf("violation line = %d, want 4", got[0].Line)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allowlisted hostname", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "target.go",
|
||||||
|
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"public.example.com\"\n")
|
||||||
|
commitDomainDiff(t, root, "add public host")
|
||||||
|
|
||||||
|
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||||
|
t.Fatalf("unexpected public-domain violation: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("reserved example URL", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "target.go",
|
||||||
|
"package sample\n\nvar unrelated = 1\nfunc fakeValue() string { return \"https://example.test/resource\" }\n")
|
||||||
|
commitDomainDiff(t, root, "add safe example URL")
|
||||||
|
|
||||||
|
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||||
|
t.Fatalf("unexpected reserved-example violation: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("historical type gap suppresses unused policy diagnostics", func(t *testing.T) {
|
||||||
|
root, _ := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, publicDomainsPath,
|
||||||
|
"# public\nplatform.example.com\npublic.example.com\n")
|
||||||
|
writeFile(t, root, "excluded.go",
|
||||||
|
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
|
||||||
|
"type Config struct{ Host string }\n\n"+
|
||||||
|
"var config = Config{Host: \"platform.example.com\"}\n")
|
||||||
|
commitDomainDiff(t, root, "add historical platform hostname")
|
||||||
|
base := gitTestCommand(t, root, "rev-parse", "HEAD")
|
||||||
|
|
||||||
|
writeFile(t, root, "target.go", "package sample\n\nvar unrelated = 2\n")
|
||||||
|
commitDomainDiff(t, root, "change unrelated code")
|
||||||
|
|
||||||
|
all := scanDomainDiff(t, root, base)
|
||||||
|
if got := violationsForRule(all, incompleteDomainRule); len(got) != 0 {
|
||||||
|
t.Fatalf("historical type gap must not be attributed to this change: %+v", got)
|
||||||
|
}
|
||||||
|
if got := violationsForRule(all, unusedDomainRule); len(got) != 0 {
|
||||||
|
t.Fatalf("incomplete inventory must not produce unused-policy diagnostics: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allowlist does not approve subdomains", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "target.go",
|
||||||
|
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"evil.public.example.com\"\n")
|
||||||
|
commitDomainDiff(t, root, "add unapproved public subdomain")
|
||||||
|
|
||||||
|
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0].Message, "evil.public.example.com") {
|
||||||
|
t.Fatalf("violations = %+v, want evil.public.example.com", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multi assignment pairs names and values", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, publicDomainsPath,
|
||||||
|
"# public\nopen.larksuite.com\npublic.example.com\n")
|
||||||
|
writeFile(t, root, "target.go",
|
||||||
|
"package sample\n\nvar unrelated = 1\nvar APIHost, BackupHost = \"open.larksuite.com\", \"attacker.zip\"\n")
|
||||||
|
commitDomainDiff(t, root, "add multiple hosts")
|
||||||
|
|
||||||
|
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0].Message, "attacker.zip") {
|
||||||
|
t.Fatalf("violations = %+v, want only attacker.zip", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IDN hostname is rejected", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "target.go",
|
||||||
|
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"例子.公司.cn\"\n")
|
||||||
|
commitDomainDiff(t, root, "add IDN hostname")
|
||||||
|
|
||||||
|
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0].Message, "例子.公司.cn") {
|
||||||
|
t.Fatalf("violations = %+v, want IDN hostname", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fixture limited to test files", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "target.go",
|
||||||
|
"package sample\n\nvar unrelated = 1\nvar ProductionHost = \"fixture.example.com\"\n")
|
||||||
|
commitDomainDiff(t, root, "use fixture in production")
|
||||||
|
|
||||||
|
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0].Message, "fixture.example.com") {
|
||||||
|
t.Fatalf("violations = %+v, want production fixture rejection", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got[0].Suggestion, "fixture-only hostname") ||
|
||||||
|
strings.Contains(got[0].Suggestion, "public allowlist") {
|
||||||
|
t.Fatalf("suggestion = %q, want fixture-scope guidance", got[0].Suggestion)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fixture accepted in test file", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "new_target_test.go",
|
||||||
|
"package sample\n\nvar BackupHost = \"fixture.example.com\"\n")
|
||||||
|
commitDomainDiff(t, root, "use fixture in test")
|
||||||
|
|
||||||
|
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||||
|
t.Fatalf("unexpected fixture-domain violation: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fixture allowlist does not approve subdomains", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "new_target_test.go",
|
||||||
|
"package sample\n\nvar BackupHost = \"evil.fixture.example.com\"\n")
|
||||||
|
commitDomainDiff(t, root, "use unapproved fixture subdomain")
|
||||||
|
|
||||||
|
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0].Message, "evil.fixture.example.com") {
|
||||||
|
t.Fatalf("violations = %+v, want exact fixture match", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fixture rejected in skills", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "skills/example/example_test.go",
|
||||||
|
"package example\n\nvar BackupHost = \"fixture.example.com\"\n")
|
||||||
|
commitDomainDiff(t, root, "use fixture in skill")
|
||||||
|
|
||||||
|
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0].Message, "fixture.example.com") {
|
||||||
|
t.Fatalf("violations = %+v, want skill fixture rejection", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pure rename", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t,
|
||||||
|
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
|
||||||
|
gitTestCommand(t, root, "mv", "target.go", "renamed.go")
|
||||||
|
commitDomainDiff(t, root, "rename file")
|
||||||
|
|
||||||
|
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
|
||||||
|
t.Fatalf("unexpected rename violation: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnapprovedDomainPolicyAndFailurePaths(t *testing.T) {
|
||||||
|
t.Run("unused policy entry", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, publicDomainsPath,
|
||||||
|
"# public\npublic.example.com\nunused.example.com\n")
|
||||||
|
commitDomainDiff(t, root, "add unused policy")
|
||||||
|
|
||||||
|
got := violationsForRule(scanDomainDiff(t, root, base), unusedDomainRule)
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0].Message, "unused.example.com") {
|
||||||
|
t.Fatalf("violations = %+v, want unused.example.com", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("public entry used only by fixture", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, publicDomainsPath,
|
||||||
|
"# public\npublic.example.com\ntest-only.example.com\n")
|
||||||
|
writeFile(t, root, "public_only_test.go",
|
||||||
|
"package sample\n\nvar BackupHost = \"test-only.example.com\"\n")
|
||||||
|
commitDomainDiff(t, root, "add test-only public policy")
|
||||||
|
|
||||||
|
got := violationsForRule(scanDomainDiff(t, root, base), unusedDomainRule)
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0].Message, "test-only.example.com") {
|
||||||
|
t.Fatalf("violations = %+v, want test-only.example.com", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("changed Go parse failure", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "target.go", "package sample\n\nfunc broken(\n")
|
||||||
|
commitDomainDiff(t, root, "break source")
|
||||||
|
|
||||||
|
all := scanDomainDiff(t, root, base)
|
||||||
|
got := violationsForRule(all, incompleteDomainRule)
|
||||||
|
if len(got) != 1 || filepath.Base(got[0].File) != "target.go" {
|
||||||
|
t.Fatalf("violations = %+v, want target.go scan-incomplete", got)
|
||||||
|
}
|
||||||
|
if unused := violationsForRule(all, unusedDomainRule); len(unused) != 0 {
|
||||||
|
t.Fatalf("parse failure must not produce unreliable unused-policy diagnostics: %+v", unused)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("repository type loading failure", func(t *testing.T) {
|
||||||
|
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
|
||||||
|
writeFile(t, root, "go.mod", "module example.com/domainfixture\n\ngo 1.23.0\n\n"+
|
||||||
|
"require example.com/missing v0.0.0\n\nreplace example.com/missing => ./missing\n")
|
||||||
|
writeFile(t, root, "target.go",
|
||||||
|
"package sample\n\nimport _ \"example.com/missing\"\n\n"+
|
||||||
|
"type Config struct{ Host string }\nvar config = Config{Host: \"malicious.corp.internal\"}\n")
|
||||||
|
commitDomainDiff(t, root, "break type loading")
|
||||||
|
|
||||||
|
all := scanDomainDiff(t, root, base)
|
||||||
|
got := violationsForRule(all, incompleteDomainRule)
|
||||||
|
if len(got) != 1 || filepath.Base(got[0].File) != "go.mod" {
|
||||||
|
t.Fatalf("violations = %+v, want go.mod scan-incomplete", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got[0].Message, "load Go type information") {
|
||||||
|
t.Fatalf("message = %q, want type-loading failure", got[0].Message)
|
||||||
|
}
|
||||||
|
if unused := violationsForRule(all, unusedDomainRule); len(unused) != 0 {
|
||||||
|
t.Fatalf("type-loading failure must not produce unreliable unused-policy diagnostics: %+v", unused)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
380
lint/domaincontract/unapproved_test.go
Normal file
380
lint/domaincontract/unapproved_test.go
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package domaincontract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"go/types"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func scanDomainEvidence(t *testing.T, source string) []domainEvidence {
|
||||||
|
t.Helper()
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
file, err := parser.ParseFile(fset, "fixture.go", source, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse fixture: %v\n%s", err, source)
|
||||||
|
}
|
||||||
|
scan := newFileDomainScan(typedGoFile{File: file, Fset: fset})
|
||||||
|
scan.collectSemanticEvidence()
|
||||||
|
scan.collectAbsoluteURLEvidence()
|
||||||
|
sort.Slice(scan.Evidence, func(i, j int) bool {
|
||||||
|
if scan.Evidence[i].Host != scan.Evidence[j].Host {
|
||||||
|
return scan.Evidence[i].Host < scan.Evidence[j].Host
|
||||||
|
}
|
||||||
|
return scan.Evidence[i].Expr.Pos() < scan.Evidence[j].Expr.Pos()
|
||||||
|
})
|
||||||
|
return scan.Evidence
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanTypedDomainEvidence(t *testing.T, source string) []domainEvidence {
|
||||||
|
t.Helper()
|
||||||
|
return scanTypedDomainEvidenceInPackage(t, "fixture", source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanTypedDomainEvidenceInPackage(t *testing.T, packagePath, source string) []domainEvidence {
|
||||||
|
t.Helper()
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
file, err := parser.ParseFile(fset, "fixture.go", source, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse fixture: %v\n%s", err, source)
|
||||||
|
}
|
||||||
|
info := &types.Info{
|
||||||
|
Types: map[ast.Expr]types.TypeAndValue{},
|
||||||
|
Defs: map[*ast.Ident]types.Object{},
|
||||||
|
Uses: map[*ast.Ident]types.Object{},
|
||||||
|
Selections: map[*ast.SelectorExpr]*types.Selection{},
|
||||||
|
}
|
||||||
|
if _, err := (&types.Config{}).Check(packagePath, fset, []*ast.File{file}, info); err != nil {
|
||||||
|
t.Fatalf("type-check fixture: %v\n%s", err, source)
|
||||||
|
}
|
||||||
|
scan := newFileDomainScan(typedGoFile{File: file, Fset: fset, Info: info})
|
||||||
|
scan.collectSemanticEvidence()
|
||||||
|
scan.collectAbsoluteURLEvidence()
|
||||||
|
sort.Slice(scan.Evidence, func(i, j int) bool {
|
||||||
|
if scan.Evidence[i].Host != scan.Evidence[j].Host {
|
||||||
|
return scan.Evidence[i].Host < scan.Evidence[j].Host
|
||||||
|
}
|
||||||
|
return scan.Evidence[i].Expr.Pos() < scan.Evidence[j].Expr.Pos()
|
||||||
|
})
|
||||||
|
return scan.Evidence
|
||||||
|
}
|
||||||
|
|
||||||
|
func evidenceHosts(evidence []domainEvidence) []string {
|
||||||
|
hosts := make([]string, 0, len(evidence))
|
||||||
|
for _, item := range evidence {
|
||||||
|
hosts = append(hosts, item.Host)
|
||||||
|
}
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypedAbsoluteURLDeclarationProducesOneFinding(t *testing.T) {
|
||||||
|
evidence := scanTypedDomainEvidence(t,
|
||||||
|
"package p\nconst DomainContractE2EURL = \"https://private.corp.internal/v1\"\n")
|
||||||
|
if got := evidenceHosts(evidence); len(got) != 1 || got[0] != "private.corp.internal" {
|
||||||
|
t.Fatalf("hosts = %v, want [private.corp.internal]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoDomainEvidenceTruePositives(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "PR 1975 Feishu assignment",
|
||||||
|
source: "package p\nfunc f() { host := \"internal-api-drive-stream.feishu.cn\"; _ = host }\n",
|
||||||
|
want: []string{"internal-api-drive-stream.feishu.cn"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PR 1975 Lark assignment",
|
||||||
|
source: "package p\nfunc f() { var host string; host = \"internal-api-drive-stream.larksuite.com\"; _ = host }\n",
|
||||||
|
want: []string{"internal-api-drive-stream.larksuite.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uppercase snake target",
|
||||||
|
source: "package p\nfunc f() { API_HOST := \"private.corp.internal\"; _ = API_HOST }\n",
|
||||||
|
want: []string{"private.corp.internal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "typed declaration",
|
||||||
|
source: "package p\nconst APIHost string = \"attacker.zip\"\n",
|
||||||
|
want: []string{"attacker.zip"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "grouped const declaration",
|
||||||
|
source: "package p\nconst (\n APIHost string = \"attacker.zip\"\n)\n",
|
||||||
|
want: []string{"attacker.zip"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "grouped var declaration",
|
||||||
|
source: "package p\nvar (\n APIHost string = \"attacker.zip\"\n)\n",
|
||||||
|
want: []string{"attacker.zip"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multi assignment",
|
||||||
|
source: "package p\nfunc f() {\n" +
|
||||||
|
" APIHost, BackupHost := \"public.example.com\", \"attacker.zip\"\n" +
|
||||||
|
" _, _ = APIHost, BackupHost\n}\n",
|
||||||
|
want: []string{"attacker.zip", "public.example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "map semantic key",
|
||||||
|
source: "package p\nvar c = map[string]string{\"host\": \"private.corp.internal\"}\n",
|
||||||
|
want: []string{"private.corp.internal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "map semantic key assignment",
|
||||||
|
source: "package p\nfunc f() { c := map[string]string{}; c[\"host\"] = \"private.corp.internal\" }\n",
|
||||||
|
want: []string{"private.corp.internal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host collection values",
|
||||||
|
source: "package p\nvar ALLOWED_HOSTS = []string{\"private.corp.internal\", \"attacker.zip\"}\n",
|
||||||
|
want: []string{"attacker.zip", "private.corp.internal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host collection map keys",
|
||||||
|
source: "package p\nvar allowedHosts = map[string]struct{}{\"attacker.zip\": {}}\n",
|
||||||
|
want: []string{"attacker.zip"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host collection bool map keys",
|
||||||
|
source: "package p\nvar AllowedHosts = map[string]bool{\"api.example.com\": true}\n",
|
||||||
|
want: []string{"api.example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host collection map values",
|
||||||
|
source: "package p\nvar HostsByRegion = map[string]string{\"sg\": \"api.example.com\"}\n",
|
||||||
|
want: []string{"api.example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "host collection map value assignment",
|
||||||
|
source: "package p\nfunc f() {\n" +
|
||||||
|
" HostsByRegion := map[string]string{}\n" +
|
||||||
|
" HostsByRegion[\"sg\"] = \"api.example.com\"\n" +
|
||||||
|
"}\n",
|
||||||
|
want: []string{"api.example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "static concatenation",
|
||||||
|
source: "package p\nvar APIHost = \"attacker.\" + \"zip\"\n",
|
||||||
|
want: []string{"attacker.zip"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiline assignment",
|
||||||
|
source: "package p\nfunc f() {\n APIHost :=\n \"attacker.zip\"\n _ = APIHost\n}\n",
|
||||||
|
want: []string{"attacker.zip"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escaped hostname",
|
||||||
|
source: "package p\nvar APIHost = \"private\\u002ecorp\\u002einternal\"\n",
|
||||||
|
want: []string{"private.corp.internal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hex escaped hostname",
|
||||||
|
source: "package p\nvar APIHost = \"private\\x2ecorp\\x2einternal\"\n",
|
||||||
|
want: []string{"private.corp.internal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "octal escaped hostname",
|
||||||
|
source: "package p\nvar APIHost = \"private\\056corp\\056internal\"\n",
|
||||||
|
want: []string{"private.corp.internal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "raw hostname",
|
||||||
|
source: "package p\nvar APIHost = `private.corp.internal`\n",
|
||||||
|
want: []string{"private.corp.internal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same-file constant reference",
|
||||||
|
source: "package p\nconst existingConst = \"private.corp.internal\"\n" +
|
||||||
|
"func f() { APIHost := existingConst; _ = APIHost }\n",
|
||||||
|
want: []string{"private.corp.internal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absolute URL",
|
||||||
|
source: "package p\nvar message = \"https://private.corp.internal/v1\"\n",
|
||||||
|
want: []string{"private.corp.internal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "websocket URL with port",
|
||||||
|
source: "package p\nvar endpoint = \"wss://private.corp.internal:443/v1\"\n",
|
||||||
|
want: []string{"private.corp.internal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL userinfo query and fragment",
|
||||||
|
source: "package p\nvar endpoint = \" https://user:pass@private.corp.internal:8443/v1?q=1#result \"\n",
|
||||||
|
want: []string{"private.corp.internal"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IDN hostname",
|
||||||
|
source: "package p\nvar APIHost = \"例子.公司.cn\"\n",
|
||||||
|
want: []string{"例子.公司.cn"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case port and trailing dot normalization",
|
||||||
|
source: "package p\nvar APIHost = \"EXAMPLE.COM.:443\"\n",
|
||||||
|
want: []string{"example.com"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := evidenceHosts(scanDomainEvidence(t, tc.source))
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("hosts = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tc.want[i] {
|
||||||
|
t.Fatalf("hosts = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoDomainEvidenceTrueNegatives(t *testing.T) {
|
||||||
|
source := `package p
|
||||||
|
|
||||||
|
import _ "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
|
|
||||||
|
var file = "archive.zip"
|
||||||
|
var event = "card.action.trigger"
|
||||||
|
var schema = "im.messages.list"
|
||||||
|
var configFile = "service.prod.json"
|
||||||
|
var version = "v1.2.3"
|
||||||
|
var email = "name@example.com"
|
||||||
|
var lowConfidence = "attacker.zip"
|
||||||
|
var downloadURL = "archive.zip/file"
|
||||||
|
var prose = "See https://private.corp.internal/v1 for details"
|
||||||
|
// https://private.corp.internal/v1
|
||||||
|
var ghost = "private.corp.internal"
|
||||||
|
var hostnameParser = "private.corp.internal"
|
||||||
|
var domainError = "private.corp.internal"
|
||||||
|
var APIHost = "localhost"
|
||||||
|
var BackupHost = "127.0.0.1"
|
||||||
|
var hosts = struct{ File string }{File: "archive.zip"}
|
||||||
|
var AllowedHosts = map[string]string{"api.example.com": "client.pem"}
|
||||||
|
|
||||||
|
func dynamicValue() string { return "private.corp.internal" }
|
||||||
|
var DynamicHost = dynamicValue()
|
||||||
|
|
||||||
|
func setAmbiguousHostMetadata() {
|
||||||
|
AllowedHosts["api.example.com"] = "client.pem"
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if got := scanDomainEvidence(t, source); len(got) != 0 {
|
||||||
|
t.Fatalf("unexpected evidence: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypedStructFieldHostnameSemantics(t *testing.T) {
|
||||||
|
t.Run("network fields", func(t *testing.T) {
|
||||||
|
source := `package source
|
||||||
|
|
||||||
|
type Config struct { Host string }
|
||||||
|
type FeishuSource struct { Domain string }
|
||||||
|
|
||||||
|
var config = Config{Host: "api.example.com"}
|
||||||
|
var source = FeishuSource{Domain: "events.example.com"}
|
||||||
|
`
|
||||||
|
got := evidenceHosts(scanTypedDomainEvidenceInPackage(
|
||||||
|
t,
|
||||||
|
"github.com/larksuite/cli/internal/event/source",
|
||||||
|
source,
|
||||||
|
))
|
||||||
|
want := []string{"api.example.com", "events.example.com"}
|
||||||
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||||
|
t.Fatalf("hosts = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("command metadata domain", func(t *testing.T) {
|
||||||
|
source := `package cmdmeta
|
||||||
|
|
||||||
|
type Meta struct { Domain string }
|
||||||
|
|
||||||
|
var meta = Meta{Domain: "im.messages"}
|
||||||
|
func update(meta *Meta) { meta.Domain = "docs.pages" }
|
||||||
|
`
|
||||||
|
if got := scanTypedDomainEvidenceInPackage(
|
||||||
|
t,
|
||||||
|
"github.com/larksuite/cli/internal/cmdmeta",
|
||||||
|
source,
|
||||||
|
); len(got) != 0 {
|
||||||
|
t.Fatalf("unexpected command metadata evidence: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("card action host", func(t *testing.T) {
|
||||||
|
source := `package im
|
||||||
|
|
||||||
|
type CardActionTriggerOutput struct { Host string }
|
||||||
|
|
||||||
|
var output = CardActionTriggerOutput{Host: "card.action"}
|
||||||
|
func update(output *CardActionTriggerOutput) { output.Host = "im.message" }
|
||||||
|
`
|
||||||
|
if got := scanTypedDomainEvidenceInPackage(
|
||||||
|
t,
|
||||||
|
"github.com/larksuite/cli/events/im",
|
||||||
|
source,
|
||||||
|
); len(got) != 0 {
|
||||||
|
t.Fatalf("unexpected card host evidence: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown field ownership is conservative", func(t *testing.T) {
|
||||||
|
source := "package p\ntype Config struct { Host string }\nvar c = Config{Host: \"api.example.com\"}\n"
|
||||||
|
if got := scanDomainEvidence(t, source); len(got) != 0 {
|
||||||
|
t.Fatalf("unexpected untyped field evidence: %+v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostnameSemanticNames(t *testing.T) {
|
||||||
|
for _, name := range []string{
|
||||||
|
"host", "HOST", "hosts", "hostname", "domains",
|
||||||
|
"api_host", "API_HOST", "ALLOWED_HOSTS",
|
||||||
|
"apiHost", "APIHost", "backupHostname",
|
||||||
|
"HostsByRegion", "APIHostsByRegion", "hostsByRegion",
|
||||||
|
} {
|
||||||
|
if !isHostnameSemanticName(name) {
|
||||||
|
t.Errorf("%q should be hostname-semantic", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{
|
||||||
|
"ghost", "hostnameParser", "domainError", "hostValue", "downloadURL", "endpoint", "origin",
|
||||||
|
"HostBypass", "APIHostBypass",
|
||||||
|
} {
|
||||||
|
if isHostnameSemanticName(name) {
|
||||||
|
t.Errorf("%q must not be hostname-semantic", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainFixturePaths(t *testing.T) {
|
||||||
|
for _, path := range []string{
|
||||||
|
"internal/x/x_test.go",
|
||||||
|
"tests/cli_e2e/x.go",
|
||||||
|
"internal/x/testdata/sample.go",
|
||||||
|
} {
|
||||||
|
if !isDomainFixturePath(path) {
|
||||||
|
t.Errorf("%q should be fixture scope", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []string{
|
||||||
|
"internal/x/test_helper.go",
|
||||||
|
"examples/demo.go",
|
||||||
|
"skills/example/testdata/sample.go",
|
||||||
|
"skills/example/example_test.go",
|
||||||
|
} {
|
||||||
|
if isDomainFixturePath(path) {
|
||||||
|
t.Errorf("%q must not be fixture scope", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
lint/main.go
10
lint/main.go
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
// Command lintcheck runs repository source-contract guards that golangci-lint
|
// Command lintcheck runs repository source-contract guards that golangci-lint
|
||||||
// cannot express directly. It currently covers typed-error contracts and the
|
// cannot express directly. It currently covers typed-error contracts and the
|
||||||
// resolver-owned endpoint contract.
|
// resolver-owned endpoint and approved-domain contracts.
|
||||||
//
|
//
|
||||||
// lintcheck lives in its own Go module under lint/ so its build-time
|
// lintcheck lives in its own Go module under lint/ so its build-time
|
||||||
// dependency on golang.org/x/tools/go/packages does not leak into the
|
// dependency on golang.org/x/tools/go/packages does not leak into the
|
||||||
@@ -43,8 +43,10 @@ type scanner struct {
|
|||||||
|
|
||||||
var scanners = []scanner{
|
var scanners = []scanner{
|
||||||
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
|
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
|
||||||
{name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
{name: "domaincontract", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||||
return domaincontract.ScanRepo(root)
|
return domaincontract.ScanRepoWithOptions(root, domaincontract.ScanOptions{
|
||||||
|
ChangedFrom: opts.ChangedFrom,
|
||||||
|
})
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ func main() {
|
|||||||
"Runs every registered lint domain against repo-root (default: current directory).\n")
|
"Runs every registered lint domain against repo-root (default: current directory).\n")
|
||||||
flag.PrintDefaults()
|
flag.PrintDefaults()
|
||||||
}
|
}
|
||||||
flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental boundary-error checks")
|
flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental source-contract checks")
|
||||||
flag.BoolVar(&printLegacyCommandErrorCandidates, "print-legacy-command-error-candidates", false, "print existing command boundary bare errors as allowlist candidates")
|
flag.BoolVar(&printLegacyCommandErrorCandidates, "print-legacy-command-error-candidates", false, "print existing command boundary bare errors as allowlist candidates")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "@larksuite/cli",
|
"name": "@larksuite/cli",
|
||||||
"version": "1.0.11",
|
"version": "1.0.80",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@larksuite/cli",
|
"name": "@larksuite/cli",
|
||||||
"version": "1.0.11",
|
"version": "1.0.80",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64",
|
"x64",
|
||||||
"arm64"
|
"arm64",
|
||||||
|
"riscv64"
|
||||||
],
|
],
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@larksuite/cli",
|
"name": "@larksuite/cli",
|
||||||
"version": "1.0.74",
|
"version": "1.0.80",
|
||||||
"description": "The official CLI for Lark/Feishu open platform",
|
"description": "The official CLI for Lark/Feishu open platform",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lark-cli": "scripts/run.js"
|
"lark-cli": "scripts/run.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "node scripts/install.js"
|
"postinstall": "node scripts/install.js",
|
||||||
|
"release:check": "node scripts/release-preflight.js"
|
||||||
},
|
},
|
||||||
"os": [
|
"os": [
|
||||||
"darwin",
|
"darwin",
|
||||||
|
|||||||
@@ -265,10 +265,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
|||||||
const checksumsPath = path.join(dir, "checksums.txt");
|
const checksumsPath = path.join(dir, "checksums.txt");
|
||||||
|
|
||||||
if (!fs.existsSync(checksumsPath)) {
|
if (!fs.existsSync(checksumsPath)) {
|
||||||
console.error(
|
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
|
||||||
"[WARN] checksums.txt not found, skipping checksum verification"
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = fs.readFileSync(checksumsPath, "utf8");
|
const content = fs.readFileSync(checksumsPath, "utf8");
|
||||||
@@ -286,7 +283,14 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function verifyChecksum(archivePath, expectedHash) {
|
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.
|
// Stream the file to avoid loading the entire archive into memory.
|
||||||
// Archives can be 10-100MB; streaming keeps RSS constant.
|
// Archives can be 10-100MB; streaming keeps RSS constant.
|
||||||
|
|||||||
@@ -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-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
|
||||||
// No checksums.txt in dir
|
assert.throws(
|
||||||
const result = getExpectedChecksum("anything.tar.gz", dir);
|
() => getExpectedChecksum("anything.tar.gz", dir),
|
||||||
assert.equal(result, null);
|
{ message: /^\[SECURITY\] checksums\.txt not found/ }
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips malformed lines and still finds valid entry", () => {
|
it("skips malformed lines and still finds valid entry", () => {
|
||||||
@@ -106,7 +107,7 @@ describe("verifyChecksum", () => {
|
|||||||
verifyChecksum(filePath, hash);
|
verifyChecksum(filePath, hash);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches case-insensitively", () => {
|
it("accepts a valid uppercase 64-character hex hash", () => {
|
||||||
const content = "case test";
|
const content = "case test";
|
||||||
const filePath = makeTmpFile(content);
|
const filePath = makeTmpFile(content);
|
||||||
const hash = sha256(content).toUpperCase();
|
const hash = sha256(content).toUpperCase();
|
||||||
@@ -114,6 +115,40 @@ describe("verifyChecksum", () => {
|
|||||||
verifyChecksum(filePath, hash);
|
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", () => {
|
it("throws [SECURITY]-prefixed Error on mismatch", () => {
|
||||||
const filePath = makeTmpFile("real content");
|
const filePath = makeTmpFile("real content");
|
||||||
assert.throws(
|
assert.throws(
|
||||||
|
|||||||
108
scripts/release-preflight.js
Normal file
108
scripts/release-preflight.js
Normal 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();
|
||||||
66
scripts/release-preflight.test.js
Normal file
66
scripts/release-preflight.test.js
Normal 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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -176,7 +176,15 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
|
if grep -Fq 'run.name !== "CI"' "$workflow"; then
|
||||||
|
echo "semantic-review must not use the dynamic workflow run name as workflow identity" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_in_step "$summary_verify_step" 'github.rest.actions.getWorkflow' "PR quality summary must resolve static workflow metadata"
|
||||||
|
require_in_step "$summary_verify_step" 'workflow.name !== "CI"' "PR quality summary must verify the static workflow name"
|
||||||
|
require_in_step "$summary_verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "PR quality summary must verify the static workflow path"
|
||||||
|
require_in_step "$summary_verify_step" 'run.path && run.path !== workflow.path' "PR quality summary must reject workflow path metadata mismatches"
|
||||||
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
|
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
|
||||||
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
|
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
|
||||||
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
|
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
|
||||||
@@ -201,7 +209,10 @@ require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_BASE_SHA' "PR qualit
|
|||||||
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
|
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
|
||||||
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
|
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
|
||||||
|
|
||||||
require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
|
require_in_step "$verify_step" 'github.rest.actions.getWorkflow' "semantic-review must resolve static workflow metadata"
|
||||||
|
require_in_step "$verify_step" 'workflow.name !== "CI"' "semantic-review must verify the static workflow name"
|
||||||
|
require_in_step "$verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "semantic-review must verify the static workflow path"
|
||||||
|
require_in_step "$verify_step" 'run.path && run.path !== workflow.path' "semantic-review must reject workflow path metadata mismatches"
|
||||||
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
|
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
|
||||||
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
|
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
|
||||||
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
|
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
|
||||||
|
|||||||
@@ -3,49 +3,48 @@ set -euo pipefail
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
cd "${REPO_ROOT}"
|
||||||
|
|
||||||
# Read version from package.json
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
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
|
|
||||||
|
|
||||||
TAG="v${VERSION}"
|
TAG="v${VERSION}"
|
||||||
|
|
||||||
|
node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
|
||||||
|
|
||||||
echo "Version: ${VERSION}"
|
echo "Version: ${VERSION}"
|
||||||
echo "Tag: ${TAG}"
|
echo "Tag: ${TAG}"
|
||||||
|
|
||||||
# Check if tag already exists locally
|
CURRENT_BRANCH=$(git branch --show-current)
|
||||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
if [ "${CURRENT_BRANCH}" != "main" ]; then
|
||||||
echo "Tag ${TAG} already exists locally, skipping."
|
echo "Error: releases must be tagged from main; current branch is '${CURRENT_BRANCH}'." >&2
|
||||||
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
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure current branch is pushed to remote before tagging
|
if ! git diff --quiet HEAD -- package.json package-lock.json; then
|
||||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
echo "Error: package.json or package-lock.json has uncommitted changes. Please commit them before tagging." >&2
|
||||||
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
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create and push tag
|
git fetch origin main
|
||||||
git tag "$TAG"
|
|
||||||
git push origin "$TAG"
|
|
||||||
|
|
||||||
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}"
|
||||||
|
|||||||
71
shortcuts/apps/apps_cache_clear.go
Normal file
71
shortcuts/apps/apps_cache_clear.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package apps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppsCacheClear clears all cache entries for the app in the given environment.
|
||||||
|
//
|
||||||
|
// POST /apps/{app_id}/cache/clear,body {env}。清空当前应用指定环境下全部缓存,用于无法定位
|
||||||
|
// 具体 key 的快速恢复;影响面大,定 high-risk-write(框架自动注入 --yes 确认)。
|
||||||
|
var AppsCacheClear = common.Shortcut{
|
||||||
|
Service: appsService,
|
||||||
|
Command: "+cache-clear",
|
||||||
|
Description: "Clear all cache entries for the app in the given environment",
|
||||||
|
Risk: "high-risk-write",
|
||||||
|
Tips: []string{
|
||||||
|
"Example: lark-cli apps +cache-clear --app-id <app_id> --environment dev --yes",
|
||||||
|
},
|
||||||
|
Scopes: []string{"spark:app:write"},
|
||||||
|
AuthTypes: []string{"user"},
|
||||||
|
HasFormat: true,
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
|
cacheEnvFlag(),
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
|
_, err := requireAppID(rctx.Str("app-id"))
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||||
|
return common.NewDryRunAPI().
|
||||||
|
POST(appCacheClearPath(appID)).
|
||||||
|
Desc("Clear all cache entries for the app in the given environment").
|
||||||
|
Body(dbEnvParams(rctx, map[string]interface{}{}))
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
|
appID, err := requireAppID(rctx.Str("app-id"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := rctx.CallAPITyped("POST", appCacheClearPath(appID), nil, dbEnvParams(rctx, map[string]interface{}{}))
|
||||||
|
if err != nil {
|
||||||
|
return withAppsHint(err, appIDListHint)
|
||||||
|
}
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"environment": resolvedEnv(data, rctx),
|
||||||
|
"deleted_key_count": cacheInt(data["deleted_key_count"]),
|
||||||
|
}
|
||||||
|
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||||
|
renderCacheClearPretty(w, out)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCacheClearPretty 打 "✓ cache cleared: N entries (env)"。
|
||||||
|
func renderCacheClearPretty(w io.Writer, out map[string]interface{}) {
|
||||||
|
n := int64(0)
|
||||||
|
if f, ok := numericAsFloat(out["deleted_key_count"]); ok {
|
||||||
|
n = int64(f)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "✓ cache cleared: %d entries (%s)\n", n, common.GetString(out, "environment"))
|
||||||
|
}
|
||||||
75
shortcuts/apps/apps_cache_delete.go
Normal file
75
shortcuts/apps/apps_cache_delete.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package apps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppsCacheDelete deletes a single business cache key (idempotent).
|
||||||
|
//
|
||||||
|
// DELETE /apps/{app_id}/cache?env=&key=。缓存是派生数据、删单 key 影响面小且可重建,
|
||||||
|
// 故定 write(非 high-risk-write、不需 --yes)。目标不存在按幂等成功处理(deleted_key_count=0)。
|
||||||
|
var AppsCacheDelete = common.Shortcut{
|
||||||
|
Service: appsService,
|
||||||
|
Command: "+cache-delete",
|
||||||
|
Description: "Delete a single business cache key (idempotent)",
|
||||||
|
Risk: "write",
|
||||||
|
Tips: []string{
|
||||||
|
"Example: lark-cli apps +cache-delete --app-id <app_id> --environment dev --key <key>",
|
||||||
|
},
|
||||||
|
Scopes: []string{"spark:app:write"},
|
||||||
|
AuthTypes: []string{"user"},
|
||||||
|
HasFormat: true,
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
|
{Name: "key", Desc: "business cache key", Required: true},
|
||||||
|
cacheEnvFlag(),
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
|
_, err := requireAppID(rctx.Str("app-id"))
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||||
|
return common.NewDryRunAPI().
|
||||||
|
DELETE(appCachePath(appID)).
|
||||||
|
Desc("Delete a Miaoda app runtime cache key").
|
||||||
|
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
|
appID, err := requireAppID(rctx.Str("app-id"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key := rctx.Str("key")
|
||||||
|
data, err := rctx.CallAPITyped("DELETE", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
|
||||||
|
if err != nil {
|
||||||
|
return withAppsHint(err, appIDListHint)
|
||||||
|
}
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"key": key,
|
||||||
|
"environment": resolvedEnv(data, rctx),
|
||||||
|
"deleted_key_count": cacheInt(data["deleted_key_count"]),
|
||||||
|
}
|
||||||
|
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||||
|
renderCacheDeletePretty(w, out)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCacheDeletePretty 命中打 "✓ cache deleted",幂等未命中打 "✓ cache already absent"(措辞区分,都成功)。
|
||||||
|
func renderCacheDeletePretty(w io.Writer, out map[string]interface{}) {
|
||||||
|
key := common.GetString(out, "key")
|
||||||
|
if n, ok := numericAsFloat(out["deleted_key_count"]); ok && n > 0 {
|
||||||
|
fmt.Fprintf(w, "✓ cache deleted: %s\n", key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "✓ cache already absent: %s\n", key)
|
||||||
|
}
|
||||||
105
shortcuts/apps/apps_cache_get.go
Normal file
105
shortcuts/apps/apps_cache_get.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package apps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppsCacheGet reads a single business cache key's value + metadata.
|
||||||
|
//
|
||||||
|
// GET /apps/{app_id}/cache?env=&key=。value 在 wire 上是 JSON 字符串透传:--format json
|
||||||
|
// 原样输出该字符串(不反序列化),--format pretty 反序列化后缩进展开。value_size_bytes 由 CLI
|
||||||
|
// 按 value 字节长度算出(端点不返回);未命中(exists=false)时不带 value,ttl_ms/value_size_bytes 为 null。
|
||||||
|
var AppsCacheGet = common.Shortcut{
|
||||||
|
Service: appsService,
|
||||||
|
Command: "+cache-get",
|
||||||
|
Description: "Get a business cache key's value and metadata",
|
||||||
|
Risk: "read",
|
||||||
|
Tips: []string{
|
||||||
|
"Example: lark-cli apps +cache-get --app-id <app_id> --key spotbonus:2026:winners:list:v1",
|
||||||
|
"Example: lark-cli apps +cache-get --app-id <app_id> --environment online --key <key>",
|
||||||
|
},
|
||||||
|
Scopes: []string{"spark:app:read"},
|
||||||
|
AuthTypes: []string{"user"},
|
||||||
|
HasFormat: true,
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||||
|
{Name: "key", Desc: "business cache key", Required: true},
|
||||||
|
cacheEnvFlag(),
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
|
_, err := requireAppID(rctx.Str("app-id"))
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||||
|
return common.NewDryRunAPI().
|
||||||
|
GET(appCachePath(appID)).
|
||||||
|
Desc("Get a Miaoda app runtime cache key").
|
||||||
|
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
|
appID, err := requireAppID(rctx.Str("app-id"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key := rctx.Str("key")
|
||||||
|
data, err := rctx.CallAPITyped("GET", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
|
||||||
|
if err != nil {
|
||||||
|
return withAppsHint(err, appIDListHint)
|
||||||
|
}
|
||||||
|
out := projectCacheGet(data, key, rctx)
|
||||||
|
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||||
|
renderCacheGetPretty(w, out)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectCacheGet 组装 cache-get 输出:key 回显、environment 取 resolved env、exists 直读;
|
||||||
|
// 命中时带 ttl_ms + value(原始串)+ value_size_bytes(CLI 算),未命中时 ttl_ms/value_size_bytes 为 null、无 value。
|
||||||
|
func projectCacheGet(data map[string]interface{}, key string, rctx *common.RuntimeContext) map[string]interface{} {
|
||||||
|
exists := cacheBool(data["exists"])
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"key": key,
|
||||||
|
"environment": resolvedEnv(data, rctx),
|
||||||
|
"exists": exists,
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
val := common.GetString(data, "value")
|
||||||
|
out["ttl_ms"] = cacheInt(data["ttl_ms"])
|
||||||
|
out["value_size_bytes"] = len([]byte(val))
|
||||||
|
out["value"] = val
|
||||||
|
} else {
|
||||||
|
out["ttl_ms"] = nil
|
||||||
|
out["value_size_bytes"] = nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCacheGetPretty 打元信息块(key/environment/exists,命中再加 ttl/value_size),命中时末尾展开 value。
|
||||||
|
func renderCacheGetPretty(w io.Writer, out map[string]interface{}) {
|
||||||
|
exists, _ := out["exists"].(bool)
|
||||||
|
pairs := [][2]string{
|
||||||
|
{"key", common.GetString(out, "key")},
|
||||||
|
{"environment", common.GetString(out, "environment")},
|
||||||
|
{"exists", fmt.Sprintf("%v", exists)},
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
pairs = append(pairs,
|
||||||
|
[2]string{"ttl", formatCacheTTL(out["ttl_ms"])},
|
||||||
|
[2]string{"value_size", humanBytes(out["value_size_bytes"])},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderKeyValuePairs(w, pairs)
|
||||||
|
if exists {
|
||||||
|
fmt.Fprintln(w, "value:")
|
||||||
|
printCacheValuePretty(w, common.GetString(out, "value"))
|
||||||
|
}
|
||||||
|
}
|
||||||
357
shortcuts/apps/apps_cache_test.go
Normal file
357
shortcuts/apps/apps_cache_test.go
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package apps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cacheURL = "/open-apis/spark/v1/apps/app_x/cache"
|
||||||
|
cacheClearURL = "/open-apis/spark/v1/apps/app_x/cache/clear"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cacheValueStr 是服务端在 wire 上透传的原始 JSON 字符串(value 不反序列化)。
|
||||||
|
const cacheValueStr = `[{"name":"Alice","award":"Gold"},{"name":"Bob","award":"Silver"}]`
|
||||||
|
|
||||||
|
// ── cache-get ──
|
||||||
|
|
||||||
|
// TestAppsCacheGet_HitJSON:命中时 json 默认——value 原样透传(不反序列化),
|
||||||
|
// value_size_bytes 由 CLI 按 value 字节长度算出,environment 取服务端 resolved env。
|
||||||
|
func TestAppsCacheGet_HitJSON(t *testing.T) {
|
||||||
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET", URL: cacheURL,
|
||||||
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||||
|
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err := runAppsShortcut(t, AppsCacheGet,
|
||||||
|
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("execute err=%v", err)
|
||||||
|
}
|
||||||
|
d := parseEnvelopeData(t, stdout)
|
||||||
|
if d["key"] != "k:1" || d["environment"] != "online" || d["exists"] != true {
|
||||||
|
t.Fatalf("get hit data=%v", d)
|
||||||
|
}
|
||||||
|
if v, _ := d["value"].(string); v != cacheValueStr {
|
||||||
|
t.Fatalf("value must be raw passthrough string, got %v", d["value"])
|
||||||
|
}
|
||||||
|
if sz, _ := numericAsFloat(d["value_size_bytes"]); int(sz) != len(cacheValueStr) {
|
||||||
|
t.Fatalf("value_size_bytes = %v, want %d", d["value_size_bytes"], len(cacheValueStr))
|
||||||
|
}
|
||||||
|
// ttl_ms 必须是 JSON number(透传服务端数字,不得变成字符串);JSON 解析后为 float64。
|
||||||
|
if _, ok := d["ttl_ms"].(float64); !ok {
|
||||||
|
t.Fatalf("ttl_ms must be a JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheGet_HitPretty:pretty 把 value 反序列化后展开(含缩进后的字段),并打元信息标签。
|
||||||
|
func TestAppsCacheGet_HitPretty(t *testing.T) {
|
||||||
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET", URL: cacheURL,
|
||||||
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||||
|
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err := runAppsShortcut(t, AppsCacheGet,
|
||||||
|
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("execute err=%v", err)
|
||||||
|
}
|
||||||
|
got := stdout.String()
|
||||||
|
for _, want := range []string{"key", "environment", "exists", "value", "Alice"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("pretty missing %q:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheGet_Miss:未命中——exists=false,无 value,ttl_ms / value_size_bytes 为 null。
|
||||||
|
func TestAppsCacheGet_Miss(t *testing.T) {
|
||||||
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET", URL: cacheURL,
|
||||||
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||||
|
"env": "online", "exists": false,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err := runAppsShortcut(t, AppsCacheGet,
|
||||||
|
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("execute err=%v", err)
|
||||||
|
}
|
||||||
|
d := parseEnvelopeData(t, stdout)
|
||||||
|
if d["exists"] != false {
|
||||||
|
t.Fatalf("miss exists=%v", d["exists"])
|
||||||
|
}
|
||||||
|
if _, ok := d["value"]; ok {
|
||||||
|
t.Fatalf("miss must not carry value: %v", d)
|
||||||
|
}
|
||||||
|
if d["ttl_ms"] != nil || d["value_size_bytes"] != nil {
|
||||||
|
t.Fatalf("miss ttl_ms/value_size_bytes must be null: %v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheGet_ExistsAsString:服务端把 exists 返成字符串 "true" 时仍按命中处理
|
||||||
|
// (cacheBool 容错,防 exists 以字符串形态出现被误判成未命中、hit→miss 翻转)。
|
||||||
|
func TestAppsCacheGet_ExistsAsString(t *testing.T) {
|
||||||
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET", URL: cacheURL,
|
||||||
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||||
|
"env": "online", "exists": "true", "ttl_ms": 272000, "value": cacheValueStr,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err := runAppsShortcut(t, AppsCacheGet,
|
||||||
|
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("execute err=%v", err)
|
||||||
|
}
|
||||||
|
d := parseEnvelopeData(t, stdout)
|
||||||
|
if d["exists"] != true {
|
||||||
|
t.Fatalf("exists string \"true\" 应按命中解析, got exists=%v", d["exists"])
|
||||||
|
}
|
||||||
|
if v, _ := d["value"].(string); v != cacheValueStr {
|
||||||
|
t.Fatalf("命中应带 value, got %v", d["value"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheGet_PrettyNonJSONFallback:pretty 下 value 不是合法 JSON 时降级原样输出
|
||||||
|
// (safeParseJSON 解析失败→原样打印,不报错、不吞值)。补齐 HitPretty 只覆盖了"能反序列化"路径的缺口。
|
||||||
|
func TestAppsCacheGet_PrettyNonJSONFallback(t *testing.T) {
|
||||||
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET", URL: cacheURL,
|
||||||
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||||
|
"env": "online", "exists": true, "ttl_ms": 272000, "value": "hello-plain-not-json",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err := runAppsShortcut(t, AppsCacheGet,
|
||||||
|
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("execute err=%v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "hello-plain-not-json") {
|
||||||
|
t.Fatalf("非 JSON value 应原样输出(降级), got:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheGet_TTLAsStringNormalized:服务端把 ttl_ms 返成字符串 "272000" 时,
|
||||||
|
// 输出的 ttl_ms 必须归一成 JSON number(cacheInt),不得随 wire 形态漂移成字符串。
|
||||||
|
func TestAppsCacheGet_TTLAsStringNormalized(t *testing.T) {
|
||||||
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET", URL: cacheURL,
|
||||||
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||||
|
"env": "online", "exists": true, "ttl_ms": "272000", "value": cacheValueStr,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err := runAppsShortcut(t, AppsCacheGet,
|
||||||
|
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("execute err=%v", err)
|
||||||
|
}
|
||||||
|
d := parseEnvelopeData(t, stdout)
|
||||||
|
f, ok := d["ttl_ms"].(float64)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ttl_ms string wire 应归一成 JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
|
||||||
|
}
|
||||||
|
if int(f) != 272000 {
|
||||||
|
t.Fatalf("ttl_ms = %v, want 272000", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheDelete_CountAsStringNormalized:服务端把 deleted_key_count 返成字符串 "1" 时,
|
||||||
|
// 输出必须归一成 JSON number(cacheInt)。
|
||||||
|
func TestAppsCacheDelete_CountAsStringNormalized(t *testing.T) {
|
||||||
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "DELETE", URL: cacheURL,
|
||||||
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": "1"}},
|
||||||
|
})
|
||||||
|
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||||
|
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("execute err=%v", err)
|
||||||
|
}
|
||||||
|
d := parseEnvelopeData(t, stdout)
|
||||||
|
if _, ok := d["deleted_key_count"].(float64); !ok {
|
||||||
|
t.Fatalf("deleted_key_count string wire 应归一成 JSON number, got %T (%v)", d["deleted_key_count"], d["deleted_key_count"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheGet_DryRunOmitsEnv:不传 --environment 时 dry-run query 不带 env(服务端自动选),但带 key。
|
||||||
|
func TestAppsCacheGet_DryRunOmitsEnv(t *testing.T) {
|
||||||
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
|
if err := runAppsShortcut(t, AppsCacheGet,
|
||||||
|
[]string{"+cache-get", "--app-id", "app_x", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("dry-run err=%v", err)
|
||||||
|
}
|
||||||
|
a := firstDryRunAPI(t, stdout.String())
|
||||||
|
if a.Method != "GET" || a.URL != cacheURL {
|
||||||
|
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||||
|
}
|
||||||
|
if _, ok := a.Params["env"]; ok {
|
||||||
|
t.Fatalf("no --environment → env must be omitted, params=%v", a.Params)
|
||||||
|
}
|
||||||
|
if a.Params["key"] != "k:1" {
|
||||||
|
t.Fatalf("key must be in query, params=%v", a.Params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheGet_DryRunWithEnv:显式 --environment dev → query 带 env=dev。
|
||||||
|
func TestAppsCacheGet_DryRunWithEnv(t *testing.T) {
|
||||||
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
|
if err := runAppsShortcut(t, AppsCacheGet,
|
||||||
|
[]string{"+cache-get", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("dry-run err=%v", err)
|
||||||
|
}
|
||||||
|
a := firstDryRunAPI(t, stdout.String())
|
||||||
|
if a.Params["env"] != "dev" {
|
||||||
|
t.Fatalf("env must be dev, params=%v", a.Params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheGet_RequiresKey:缺 --key → 校验错。
|
||||||
|
func TestAppsCacheGet_RequiresKey(t *testing.T) {
|
||||||
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
|
if err := runAppsShortcut(t, AppsCacheGet,
|
||||||
|
[]string{"+cache-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||||
|
t.Fatalf("expected required --key error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cache-delete ──
|
||||||
|
|
||||||
|
// TestAppsCacheDelete_Hit:删中命中的 key → deleted_key_count=1;pretty 打 "✓ cache deleted"。
|
||||||
|
func TestAppsCacheDelete_Hit(t *testing.T) {
|
||||||
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "DELETE", URL: cacheURL,
|
||||||
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 1}},
|
||||||
|
})
|
||||||
|
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||||
|
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("execute err=%v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "✓ cache deleted") {
|
||||||
|
t.Fatalf("pretty: %s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheDelete_AbsentJSON:目标不存在 → 幂等成功,deleted_key_count=0,pretty 措辞区分。
|
||||||
|
func TestAppsCacheDelete_AbsentJSON(t *testing.T) {
|
||||||
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "DELETE", URL: cacheURL,
|
||||||
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
|
||||||
|
})
|
||||||
|
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||||
|
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("execute err=%v", err)
|
||||||
|
}
|
||||||
|
d := parseEnvelopeData(t, stdout)
|
||||||
|
if sz, _ := numericAsFloat(d["deleted_key_count"]); int(sz) != 0 || d["key"] != "k:1" || d["environment"] != "dev" {
|
||||||
|
t.Fatalf("absent data=%v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheDelete_AbsentPretty:不存在 pretty 打 "✓ cache already absent"。
|
||||||
|
func TestAppsCacheDelete_AbsentPretty(t *testing.T) {
|
||||||
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "DELETE", URL: cacheURL,
|
||||||
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
|
||||||
|
})
|
||||||
|
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||||
|
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("execute err=%v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "already absent") {
|
||||||
|
t.Fatalf("pretty: %s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheDelete_DryRun:DELETE 方法、/cache 路由,query 带 key + env。
|
||||||
|
func TestAppsCacheDelete_DryRun(t *testing.T) {
|
||||||
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
|
if err := runAppsShortcut(t, AppsCacheDelete,
|
||||||
|
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("dry-run err=%v", err)
|
||||||
|
}
|
||||||
|
a := firstDryRunAPI(t, stdout.String())
|
||||||
|
if a.Method != "DELETE" || a.URL != cacheURL {
|
||||||
|
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||||
|
}
|
||||||
|
if a.Params["key"] != "k:1" || a.Params["env"] != "dev" {
|
||||||
|
t.Fatalf("params=%v", a.Params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cache-clear ──
|
||||||
|
|
||||||
|
// TestAppsCacheClear_Success:清空成功 → deleted_key_count=128;pretty 打 "✓ cache cleared: 128 entries (dev)"。
|
||||||
|
func TestAppsCacheClear_Success(t *testing.T) {
|
||||||
|
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST", URL: cacheClearURL,
|
||||||
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 128}},
|
||||||
|
})
|
||||||
|
if err := runAppsShortcut(t, AppsCacheClear,
|
||||||
|
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("execute err=%v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "✓ cache cleared: 128 entries (dev)") {
|
||||||
|
t.Fatalf("pretty: %s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheClear_RequiresConfirmation:high-risk-write 无 --yes → 被确认门拦截。
|
||||||
|
func TestAppsCacheClear_RequiresConfirmation(t *testing.T) {
|
||||||
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
|
if err := runAppsShortcut(t, AppsCacheClear,
|
||||||
|
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout); err == nil {
|
||||||
|
t.Fatalf("expected confirmation gate without --yes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheClear_DryRunBodyWithEnv:dry-run POST /cache/clear,body 带 env=dev。
|
||||||
|
func TestAppsCacheClear_DryRunBodyWithEnv(t *testing.T) {
|
||||||
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
|
if err := runAppsShortcut(t, AppsCacheClear,
|
||||||
|
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("dry-run err=%v", err)
|
||||||
|
}
|
||||||
|
a := firstDryRunAPI(t, stdout.String())
|
||||||
|
if a.Method != "POST" || a.URL != cacheClearURL {
|
||||||
|
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||||
|
}
|
||||||
|
if a.Body["env"] != "dev" {
|
||||||
|
t.Fatalf("body must carry env=dev, body=%v", a.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppsCacheClear_DryRunBodyOmitsEnv:不传 --environment → body 不带 env(服务端自动选)。
|
||||||
|
func TestAppsCacheClear_DryRunBodyOmitsEnv(t *testing.T) {
|
||||||
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
|
if err := runAppsShortcut(t, AppsCacheClear,
|
||||||
|
[]string{"+cache-clear", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("dry-run err=%v", err)
|
||||||
|
}
|
||||||
|
a := firstDryRunAPI(t, stdout.String())
|
||||||
|
if _, ok := a.Body["env"]; ok {
|
||||||
|
t.Fatalf("no --environment → body env must be omitted, body=%v", a.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstDryRunAPI 解析 dry-run 输出的第一个 api[] 项(method/url/params/body)。
|
||||||
|
// 复用本包规范的 dryRunAPIEnvelope(api 现嵌在 data.api 下,见 dryrun_test.go)。
|
||||||
|
func firstDryRunAPI(t *testing.T, s string) dryRunAPICall {
|
||||||
|
t.Helper()
|
||||||
|
var env dryRunAPIEnvelope
|
||||||
|
if err := json.Unmarshal([]byte(s), &env); err != nil || len(env.API) == 0 {
|
||||||
|
t.Fatalf("bad dry-run json: %v\n%s", err, s)
|
||||||
|
}
|
||||||
|
return env.API[0]
|
||||||
|
}
|
||||||
@@ -12,10 +12,23 @@ import (
|
|||||||
"github.com/larksuite/cli/shortcuts/common"
|
"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)。
|
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
|
||||||
//
|
//
|
||||||
// GET /apps/{app_id}/storage/file_list。过滤器:--name / --path / --type / --size-gt /
|
// 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。
|
// file 域不分 dev/online,无 --env。
|
||||||
//
|
//
|
||||||
// pretty 渲染 5 列:file_name / path / size / type / uploaded_at;空结果打 "No files found."。
|
// 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: "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-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: "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"},
|
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
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 透传。
|
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。
|
||||||
for _, f := range []string{"uploaded-since", "uploaded-until"} {
|
for _, f := range []string{"uploaded-since", "uploaded-until"} {
|
||||||
if strings.TrimSpace(rctx.Str(f)) == "" {
|
if strings.TrimSpace(rctx.Str(f)) == "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 过滤器 + 分页全部进 query(size-gt/lt 走 int,uploaded_since/until 原样)。
|
// 过滤器 + 分页全部进 query(size-gt/lt 走 int,uploaded_since/until 原样)。
|
||||||
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
|
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
|
||||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,21 +46,7 @@ var AppsFileUpload = common.Shortcut{
|
|||||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f := strings.TrimSpace(rctx.Str("file"))
|
return rctx.ValidateLocalFileFlag("file", fileUploadMaxBytes)
|
||||||
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
|
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||||
@@ -76,9 +61,9 @@ var AppsFileUpload = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
localPath := strings.TrimSpace(rctx.Str("file"))
|
localPath := strings.TrimSpace(rctx.Str("file"))
|
||||||
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
|
content, err := rctx.ReadLocalFileFlag("file", fileUploadMaxBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
return err
|
||||||
}
|
}
|
||||||
fileName := filepath.Base(localPath)
|
fileName := filepath.Base(localPath)
|
||||||
contentType := mimeByExt(fileName)
|
contentType := mimeByExt(fileName)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -58,22 +59,17 @@ func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_upload,body.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) {
|
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||||
// Validate 会 Stat --file(在 DryRun 之前),故 dry-run 也需要真实存在的文件。
|
absolutePath := filepath.Join(t.TempDir(), "logo.png")
|
||||||
dir := t.TempDir()
|
if err := os.WriteFile(absolutePath, []byte("not-read-by-dry-run"), 0o600); err != nil {
|
||||||
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
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)
|
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||||
if err := runAppsShortcut(t, AppsFileUpload,
|
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)
|
t.Fatalf("dry-run err=%v", err)
|
||||||
}
|
}
|
||||||
var env dryRunAPIEnvelope
|
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。
|
// 三步直传:pre-upload → 客户端 PUT 字节 → callback。
|
||||||
func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||||
var putBody []byte
|
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 百分号编码。
|
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName:空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
|
||||||
func TestSanitizeUploadFileName_Cases(t *testing.T) {
|
func TestSanitizeUploadFileName_Cases(t *testing.T) {
|
||||||
cases := []struct{ in, want string }{
|
cases := []struct{ in, want string }{
|
||||||
|
|||||||
99
shortcuts/apps/cache_common.go
Normal file
99
shortcuts/apps/cache_common.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package apps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 应用运行时缓存(Cache)调试命令共享件:路由 + 环境 flag + 渲染。
|
||||||
|
//
|
||||||
|
// 三条命令都走 spark OpenAPI `/apps/{app_id}/cache[/clear]`,按运行环境(env→dbBranch)隔离:
|
||||||
|
// 环境 flag 用 cacheEnvFlag()(只 --environment,不带 db 家族的旧名 --env),env 值经 dbEnv 读、
|
||||||
|
// 经 dbEnvParams 注入——get/delete 放 query,clear 放 body(省略即服务端自动选分支)。
|
||||||
|
|
||||||
|
// appCachePath 返回缓存单 key 读/删 URL:cache(GET 读、DELETE 删,靠方法区分)。
|
||||||
|
func appCachePath(appID string) string {
|
||||||
|
return fmt.Sprintf("%s/apps/%s/cache", apiBasePath, validate.EncodePathSegment(appID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// appCacheClearPath 返回清空指定环境缓存 URL:cache/clear。
|
||||||
|
func appCacheClearPath(appID string) string {
|
||||||
|
return fmt.Sprintf("%s/apps/%s/cache/clear", apiBasePath, validate.EncodePathSegment(appID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheEnvFlag 返回缓存命令的运行环境 flag。cache 是全新命令、从无旧名 --env,
|
||||||
|
// 故只注册干净的 --environment(不带 db 家族那套隐藏 --env + 拒收逻辑)。
|
||||||
|
// 省略即服务端按应用多环境状态自动选分支(多环境→dev,非多环境→online)。
|
||||||
|
func cacheEnvFlag() common.Flag {
|
||||||
|
return common.Flag{
|
||||||
|
Name: "environment",
|
||||||
|
Enum: []string{"dev", "online"},
|
||||||
|
Desc: "target runtime environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheBool 防御性解析布尔:真 bool 直接用;若服务端把 exists 返成字符串 "true"/"false" 也归一成 bool,
|
||||||
|
// 其它类型按 false。避免 exists 万一以字符串形态出现时被误判成未命中(hit→miss 翻转)。
|
||||||
|
func cacheBool(v interface{}) bool {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case bool:
|
||||||
|
return x
|
||||||
|
case string:
|
||||||
|
return strings.EqualFold(strings.TrimSpace(x), "true")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheInt 把服务端下发的数值字段归一成 int64(无法解析→nil)。本仓惯例:数值可能以字符串下发
|
||||||
|
// (见 numericAsFloat 的 string 分支),若直接透传,--format json 的字段类型会随服务端 wire 形态漂移
|
||||||
|
// (number ↔ string)。归一后输出类型恒定为数字或 null,消费方无需自己容忍字符串。
|
||||||
|
func cacheInt(raw interface{}) interface{} {
|
||||||
|
if f, ok := numericAsFloat(raw); ok {
|
||||||
|
return int64(f)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvedEnv 取服务端回吐的 resolved env;缺失时兜底成请求侧 --environment(可能为空)。
|
||||||
|
// 省略 --environment 时服务端自动选分支,靠服务端回吐才知道实际命中 dev / online。
|
||||||
|
func resolvedEnv(data map[string]interface{}, rctx *common.RuntimeContext) string {
|
||||||
|
if env := common.GetString(data, "env"); env != "" {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
return dbEnv(rctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatCacheTTL 把剩余 TTL(毫秒)格式化成 4m32s 这样的时长串;非数字返回 "—"。
|
||||||
|
func formatCacheTTL(ms interface{}) string {
|
||||||
|
f, ok := numericAsFloat(ms)
|
||||||
|
if !ok {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return (time.Duration(int64(f)) * time.Millisecond).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// printCacheValuePretty 把 value 反序列化后缩进展开(pretty 口径);非 JSON 则原样打印。
|
||||||
|
// 与「json 原样字符串、pretty 才反序列化」的设计一致。
|
||||||
|
func printCacheValuePretty(w io.Writer, raw string) {
|
||||||
|
v := safeParseJSON(raw)
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
fmt.Fprintln(w, s)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(w, raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(b)
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
@@ -64,6 +64,9 @@ func Shortcuts() []common.Shortcut {
|
|||||||
AppsFileUpload,
|
AppsFileUpload,
|
||||||
AppsFileDelete,
|
AppsFileDelete,
|
||||||
AppsFileQuotaGet,
|
AppsFileQuotaGet,
|
||||||
|
AppsCacheGet,
|
||||||
|
AppsCacheDelete,
|
||||||
|
AppsCacheClear,
|
||||||
AppsGitCredentialInit,
|
AppsGitCredentialInit,
|
||||||
AppsGitCredentialList,
|
AppsGitCredentialList,
|
||||||
AppsGitCredentialRemove,
|
AppsGitCredentialRemove,
|
||||||
|
|||||||
@@ -20,13 +20,14 @@ import (
|
|||||||
// - 3 git-credential
|
// - 3 git-credential
|
||||||
// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list
|
// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list
|
||||||
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)
|
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)
|
||||||
|
// - 3 cache(get/delete/clear)
|
||||||
// - 3 plugin(install/uninstall/list)
|
// - 3 plugin(install/uninstall/list)
|
||||||
// - 6 automation(list/get/create/update/enable/disable)
|
// - 6 automation(list/get/create/update/enable/disable)
|
||||||
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 79。
|
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 82。
|
||||||
func TestAppsShortcuts_Returns79(t *testing.T) {
|
func TestAppsShortcuts_Returns82(t *testing.T) {
|
||||||
got := Shortcuts()
|
got := Shortcuts()
|
||||||
if len(got) != 79 {
|
if len(got) != 82 {
|
||||||
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
|
t.Fatalf("Shortcuts() returned %d entries, want 82", len(got))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2435,16 +2435,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
|||||||
Body: map[string]interface{}{
|
Body: map[string]interface{}{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"fields": []interface{}{"Name"},
|
|
||||||
"record_id_list": []interface{}{"rec_1", "rec_2"},
|
"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)
|
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)
|
t.Fatalf("stdout=%s", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package base
|
package base
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -250,7 +251,8 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
|||||||
"total": 2,
|
"total": 2,
|
||||||
"questions": []interface{}{
|
"questions": []interface{}{
|
||||||
map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil},
|
map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil},
|
||||||
map[string]interface{}{"id": "q_002", "title": "您的年龄", "required": false, "description": nil},
|
map[string]interface{}{"id": "q_002", "title": "发票抬头", "required": false, "description": nil,
|
||||||
|
"visible_rule": map[string]interface{}{"logic": "and", "conditions": []interface{}{[]interface{}{"q_001", "==", "是"}}}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -258,9 +260,14 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
|||||||
if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil {
|
if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil {
|
||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
}
|
}
|
||||||
if got := stdout.String(); !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
got := stdout.String()
|
||||||
|
if !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||||
t.Fatalf("stdout=%s", got)
|
t.Fatalf("stdout=%s", got)
|
||||||
}
|
}
|
||||||
|
// The list output must forward visible_rule verbatim so agents can read existing display conditions.
|
||||||
|
if !strings.Contains(got, `"visible_rule"`) {
|
||||||
|
t.Fatalf("visible_rule missing from list output: %s", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||||
@@ -296,11 +303,49 @@ func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
|||||||
t.Fatalf("expected error for invalid questions JSON")
|
t.Fatalf("expected error for invalid questions JSON")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("visible_rule passthrough", func(t *testing.T) {
|
||||||
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
|
stub := &httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"questions": []interface{}{
|
||||||
|
map[string]interface{}{"id": "q_new1", "title": "发票抬头"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
reg.Register(stub)
|
||||||
|
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||||
|
"--questions", `[{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]`}
|
||||||
|
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Questions []map[string]interface{} `json:"questions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||||
|
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||||
|
}
|
||||||
|
if len(body.Questions) != 1 {
|
||||||
|
t.Fatalf("questions=%#v", body.Questions)
|
||||||
|
}
|
||||||
|
rule, ok := body.Questions[0]["visible_rule"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||||
|
}
|
||||||
|
if rule["logic"] != "and" {
|
||||||
|
t.Fatalf("visible_rule logic not preserved: %#v", rule)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||||
factory, stdout, reg := newExecuteFactory(t)
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
reg.Register(&httpmock.Stub{
|
stub := &httpmock.Stub{
|
||||||
Method: "PATCH",
|
Method: "PATCH",
|
||||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||||
Body: map[string]interface{}{
|
Body: map[string]interface{}{
|
||||||
@@ -311,15 +356,29 @@ func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
reg.Register(stub)
|
||||||
args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true}]`}
|
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true,"visible_rule":{"logic":"and","conditions":[["q_002","==","是"]]}}]`}
|
||||||
if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil {
|
if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil {
|
||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
}
|
}
|
||||||
if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) {
|
if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) {
|
||||||
t.Fatalf("stdout=%s", got)
|
t.Fatalf("stdout=%s", got)
|
||||||
}
|
}
|
||||||
|
// visible_rule must be forwarded verbatim to the API (transcribe faithfully).
|
||||||
|
var body struct {
|
||||||
|
Questions []map[string]interface{} `json:"questions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||||
|
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||||
|
}
|
||||||
|
if len(body.Questions) != 1 {
|
||||||
|
t.Fatalf("questions=%#v", body.Questions)
|
||||||
|
}
|
||||||
|
if _, ok := body.Questions[0]["visible_rule"].(map[string]interface{}); !ok {
|
||||||
|
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBaseFormQuestionsExecuteDelete(t *testing.T) {
|
func TestBaseFormQuestionsExecuteDelete(t *testing.T) {
|
||||||
|
|||||||
@@ -25,14 +25,21 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
|||||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||||
{Name: "table-id", Desc: "table ID", Required: true},
|
{Name: "table-id", Desc: "table ID", Required: true},
|
||||||
{Name: "form-id", Desc: "form ID", Required: true},
|
{Name: "form-id", Desc: "form ID", Required: true},
|
||||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
return common.NewDryRunAPI().
|
api := common.NewDryRunAPI().
|
||||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||||
Set("base_token", runtime.Str("base-token")).
|
Set("base_token", runtime.Str("base-token")).
|
||||||
Set("table_id", runtime.Str("table-id")).
|
Set("table_id", runtime.Str("table-id")).
|
||||||
Set("form_id", runtime.Str("form-id"))
|
Set("form_id", runtime.Str("form-id"))
|
||||||
|
// Transcribe the questions body verbatim so the preview shows exactly
|
||||||
|
// what would be sent (including optional fields like visible_rule).
|
||||||
|
var questions []interface{}
|
||||||
|
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||||
|
api.Body(map[string]interface{}{"questions": questions})
|
||||||
|
}
|
||||||
|
return api
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
baseToken := runtime.Str("base-token")
|
baseToken := runtime.Str("base-token")
|
||||||
|
|||||||
@@ -25,14 +25,26 @@ var BaseFormQuestionsUpdate = common.Shortcut{
|
|||||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||||
{Name: "table-id", Desc: "table ID", Required: true},
|
{Name: "table-id", Desc: "table ID", Required: true},
|
||||||
{Name: "form-id", Desc: "form ID", Required: true},
|
{Name: "form-id", Desc: "form ID", Required: true},
|
||||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Update uses full question overwrite semantics: omitted/empty fields are written as defaults/empty, so run +form-questions-list first and include existing values you want to keep. Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id; pass null or omit to clear). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||||
|
},
|
||||||
|
Tips: []string{
|
||||||
|
"Update uses full question overwrite semantics, not a patch.",
|
||||||
|
"Run +form-questions-list first and include existing title/description/required/option_display_mode/visible_rule values you want to keep.",
|
||||||
|
"Omitted fields reset to defaults; empty strings, null, and empty arrays are written as empty/clear when accepted by the API.",
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
return common.NewDryRunAPI().
|
api := common.NewDryRunAPI().
|
||||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||||
Set("base_token", runtime.Str("base-token")).
|
Set("base_token", runtime.Str("base-token")).
|
||||||
Set("table_id", runtime.Str("table-id")).
|
Set("table_id", runtime.Str("table-id")).
|
||||||
Set("form_id", runtime.Str("form-id"))
|
Set("form_id", runtime.Str("form-id"))
|
||||||
|
// Transcribe the questions body verbatim so the preview shows exactly
|
||||||
|
// what would be sent (including optional fields like visible_rule).
|
||||||
|
var questions []interface{}
|
||||||
|
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||||
|
api.Body(map[string]interface{}{"questions": questions})
|
||||||
|
}
|
||||||
|
return api
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
baseToken := runtime.Str("base-token")
|
baseToken := runtime.Str("base-token")
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ var BaseFormSubmit = common.Shortcut{
|
|||||||
Service: "base",
|
Service: "base",
|
||||||
Command: "+form-submit",
|
Command: "+form-submit",
|
||||||
Description: "Submit a form (fill and submit form data)",
|
Description: "Submit a form (fill and submit form data)",
|
||||||
Risk: "write",
|
Risk: "high-risk-write",
|
||||||
Scopes: []string{"base:form:update", "docs:document.media:upload"},
|
Scopes: []string{"base:form:update", "docs:document.media:upload"},
|
||||||
AuthTypes: authTypes(),
|
AuthTypes: authTypes(),
|
||||||
HasFormat: true,
|
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 (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"]}}'`,
|
`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.`,
|
`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 {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateFormSubmit(runtime)
|
return validateFormSubmit(runtime)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ var BaseURLResolve = common.Shortcut{
|
|||||||
Risk: "read",
|
Risk: "read",
|
||||||
Scopes: []string{},
|
Scopes: []string{},
|
||||||
ConditionalScopes: []string{
|
ConditionalScopes: []string{
|
||||||
|
"base:block:read",
|
||||||
"base:field:read",
|
"base:field:read",
|
||||||
"base:record:read",
|
"base:record:read",
|
||||||
"wiki:node:retrieve",
|
"wiki:node:retrieve",
|
||||||
@@ -40,7 +41,7 @@ var BaseURLResolve = common.Shortcut{
|
|||||||
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
|
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
|
||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<table_id>&view=<view_id>"`,
|
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<block_id>&view=<view_id>"`,
|
||||||
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
|
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
@@ -57,10 +58,34 @@ var BaseURLResolve = common.Shortcut{
|
|||||||
return common.NewDryRunAPI().Set("error", err.Error())
|
return common.NewDryRunAPI().Set("error", err.Error())
|
||||||
}
|
}
|
||||||
switch classifyBaseURL(parsed) {
|
switch classifyBaseURL(parsed) {
|
||||||
|
case "base_url":
|
||||||
|
baseToken := firstPathSegmentAfter(parsed.Path, "/base/")
|
||||||
|
if selectedBlockID := strings.TrimSpace(parsed.Query().Get("table")); selectedBlockID != "" {
|
||||||
|
return common.NewDryRunAPI().
|
||||||
|
POST("/open-apis/base/v3/bases/:base_token/blocks/list").
|
||||||
|
Body(map[string]interface{}{}).
|
||||||
|
Set("base_token", baseToken).
|
||||||
|
Set("selected_block_id", selectedBlockID)
|
||||||
|
}
|
||||||
|
return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local")
|
||||||
case "wiki_url":
|
case "wiki_url":
|
||||||
return common.NewDryRunAPI().
|
dry := common.NewDryRunAPI()
|
||||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
selectedBlockID := strings.TrimSpace(parsed.Query().Get("table"))
|
||||||
|
if selectedBlockID == "" {
|
||||||
|
return dry.
|
||||||
|
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||||
|
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
|
||||||
|
}
|
||||||
|
dry.Desc("2-step: resolve the Wiki node to a Base, then identify the selected Base block")
|
||||||
|
dry.GET("/open-apis/wiki/v2/spaces/get_node").
|
||||||
|
Desc("[1] Resolve the Wiki node to its underlying Base").
|
||||||
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
|
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
|
||||||
|
dry.POST("/open-apis/base/v3/bases/:base_token/blocks/list").
|
||||||
|
Desc("[2] List Base blocks and match selected_block_id").
|
||||||
|
Body(map[string]interface{}{})
|
||||||
|
return dry.
|
||||||
|
Set("base_token", "<obj_token from step 1>").
|
||||||
|
Set("selected_block_id", selectedBlockID)
|
||||||
case "record_share_url":
|
case "record_share_url":
|
||||||
return common.NewDryRunAPI().
|
return common.NewDryRunAPI().
|
||||||
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
|
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
|
||||||
@@ -170,7 +195,7 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error {
|
|||||||
switch classifyBaseURL(parsed) {
|
switch classifyBaseURL(parsed) {
|
||||||
case "base_url":
|
case "base_url":
|
||||||
out := resolveBaseURL(parsed)
|
out := resolveBaseURL(parsed)
|
||||||
enrichBaseResolveHint(runtime, out)
|
enrichBaseResolveHint(runtime, out, resolveBaseURLSelection(parsed))
|
||||||
runtime.OutFormat(out, nil, nil)
|
runtime.OutFormat(out, nil, nil)
|
||||||
return nil
|
return nil
|
||||||
case "wiki_url":
|
case "wiki_url":
|
||||||
@@ -178,6 +203,9 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
selection := resolveBaseURLSelection(parsed)
|
||||||
|
applyBaseURLSelection(out, selection)
|
||||||
|
enrichBaseResolveHint(runtime, out, selection)
|
||||||
runtime.OutFormat(out, nil, nil)
|
runtime.OutFormat(out, nil, nil)
|
||||||
return nil
|
return nil
|
||||||
case "record_share_url":
|
case "record_share_url":
|
||||||
@@ -251,24 +279,50 @@ func classifyBaseURL(u *url.URL) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolveBaseURL(u *url.URL) map[string]interface{} {
|
func resolveBaseURL(u *url.URL) map[string]interface{} {
|
||||||
query := u.Query()
|
|
||||||
out := map[string]interface{}{
|
out := map[string]interface{}{
|
||||||
"input_type": "base_url",
|
"input_type": "base_url",
|
||||||
"resource_type": "bitable",
|
"resource_type": "bitable",
|
||||||
"base_token": firstPathSegmentAfter(u.Path, "/base/"),
|
"base_token": firstPathSegmentAfter(u.Path, "/base/"),
|
||||||
}
|
}
|
||||||
if tableID := strings.TrimSpace(query.Get("table")); tableID != "" {
|
applyBaseURLSelection(out, resolveBaseURLSelection(u))
|
||||||
out["table_id"] = tableID
|
|
||||||
}
|
|
||||||
if viewID := strings.TrimSpace(query.Get("view")); viewID != "" {
|
|
||||||
out["view_id"] = viewID
|
|
||||||
}
|
|
||||||
if recordID := strings.TrimSpace(query.Get("record")); recordID != "" {
|
|
||||||
out["record_id"] = recordID
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type baseURLSelection struct {
|
||||||
|
blockID string
|
||||||
|
viewID string
|
||||||
|
recordID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBaseURLSelection(u *url.URL) baseURLSelection {
|
||||||
|
query := u.Query()
|
||||||
|
return baseURLSelection{
|
||||||
|
blockID: strings.TrimSpace(query.Get("table")),
|
||||||
|
viewID: strings.TrimSpace(query.Get("view")),
|
||||||
|
recordID: strings.TrimSpace(query.Get("record")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyBaseURLSelection(out map[string]interface{}, selection baseURLSelection) {
|
||||||
|
if selection.blockID != "" {
|
||||||
|
// The Base web UI historically uses the query key "table" for the
|
||||||
|
// currently selected top-level block. Its value can identify a table,
|
||||||
|
// dashboard, workflow, or another block type. Keep it neutral until the
|
||||||
|
// block directory confirms the resource type.
|
||||||
|
out["block_id"] = selection.blockID
|
||||||
|
out["selection_source"] = "url_query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyResolvedTableSelection(out map[string]interface{}, selection baseURLSelection) {
|
||||||
|
if selection.viewID != "" {
|
||||||
|
out["view_id"] = selection.viewID
|
||||||
|
}
|
||||||
|
if selection.recordID != "" {
|
||||||
|
out["record_id"] = selection.recordID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
|
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
|
||||||
token := firstPathSegmentAfter(u.Path, "/wiki/")
|
token := firstPathSegmentAfter(u.Path, "/wiki/")
|
||||||
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
|
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
|
||||||
@@ -368,13 +422,89 @@ func executeBaseTitleResolve(runtime *common.RuntimeContext) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}, selection baseURLSelection) {
|
||||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
selectedBlockID := strings.TrimSpace(common.GetString(out, "block_id"))
|
||||||
if baseToken == "" || tableID == "" {
|
if baseToken == "" || selectedBlockID == "" {
|
||||||
out["hint"] = resolveHint("", nil)
|
out["hint"] = resolveHint("", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if block, found, err := resolveSelectedBaseBlock(runtime, baseToken, selectedBlockID); err == nil && found {
|
||||||
|
out["block_type"] = block.Type
|
||||||
|
if block.Name != "" {
|
||||||
|
out["block_name"] = block.Name
|
||||||
|
}
|
||||||
|
switch block.Type {
|
||||||
|
case "table":
|
||||||
|
applyResolvedTableSelection(out, selection)
|
||||||
|
enrichResolvedTable(runtime, out, baseToken, selectedBlockID)
|
||||||
|
case "dashboard":
|
||||||
|
out["dashboard_id"] = selectedBlockID
|
||||||
|
out["hint"] = map[string]interface{}{
|
||||||
|
"next_step": "this dashboard is only the block currently selected by the URL; if the user names a different dashboard than block_name, use +dashboard-list and match that name first, otherwise use +dashboard-get to inspect this dashboard",
|
||||||
|
}
|
||||||
|
case "workflow":
|
||||||
|
out["workflow_id"] = selectedBlockID
|
||||||
|
out["hint"] = map[string]interface{}{
|
||||||
|
"next_step": "use +workflow-get to inspect the resolved workflow",
|
||||||
|
}
|
||||||
|
case "folder":
|
||||||
|
out["hint"] = map[string]interface{}{
|
||||||
|
"next_step": fmt.Sprintf("use +base-block-list --base-token %s --parent-id %s to list this folder's direct children", baseToken, selectedBlockID),
|
||||||
|
}
|
||||||
|
case "docx":
|
||||||
|
if block.DocxToken != "" {
|
||||||
|
out["docx_token"] = block.DocxToken
|
||||||
|
out["hint"] = map[string]interface{}{
|
||||||
|
"next_step": fmt.Sprintf("use docs +fetch --doc %s to read this document", block.DocxToken),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out["hint"] = map[string]interface{}{
|
||||||
|
"next_step": "use +base-block-list --type docx and match block_id to retrieve this document's docx_token",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
out["hint"] = resolveUnknownBlockHint()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out["hint"] = resolveUnknownBlockHint()
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolvedBaseBlock struct {
|
||||||
|
ID string
|
||||||
|
Type string
|
||||||
|
Name string
|
||||||
|
DocxToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSelectedBaseBlock(runtime *common.RuntimeContext, baseToken, selectedBlockID string) (resolvedBaseBlock, bool, error) {
|
||||||
|
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "blocks", "list"), nil, map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return resolvedBaseBlock{}, false, err
|
||||||
|
}
|
||||||
|
for _, item := range common.GetSlice(data, "blocks") {
|
||||||
|
row, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
block := resolvedBaseBlock{
|
||||||
|
ID: strings.TrimSpace(common.GetString(row, "id")),
|
||||||
|
Type: strings.TrimSpace(common.GetString(row, "type")),
|
||||||
|
Name: strings.TrimSpace(common.GetString(row, "name")),
|
||||||
|
DocxToken: strings.TrimSpace(common.GetString(row, "docx_token")),
|
||||||
|
}
|
||||||
|
if block.ID == selectedBlockID {
|
||||||
|
return block, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolvedBaseBlock{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrichResolvedTable(runtime *common.RuntimeContext, out map[string]interface{}, baseToken, tableID string) {
|
||||||
|
out["table_id"] = tableID
|
||||||
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
|
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out["hint"] = resolveHint(tableID, nil)
|
out["hint"] = resolveHint(tableID, nil)
|
||||||
@@ -383,6 +513,12 @@ func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interf
|
|||||||
out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}})
|
out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveUnknownBlockHint() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"next_step": "use +base-block-list and match block_id to determine whether this is a table, dashboard, workflow, folder, or docx block",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package base
|
package base
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -17,6 +18,9 @@ import (
|
|||||||
func TestBaseURLResolveBaseURL(t *testing.T) {
|
func TestBaseURLResolveBaseURL(t *testing.T) {
|
||||||
t.Run("with coordinates", func(t *testing.T) {
|
t.Run("with coordinates", func(t *testing.T) {
|
||||||
factory, stdout, reg := newExecuteFactory(t)
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
|
reg.Register(baseBlockListResolveStub("bas123",
|
||||||
|
map[string]interface{}{"id": "tbl123", "type": "table", "name": "Orders"},
|
||||||
|
))
|
||||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||||
"+url-resolve",
|
"+url-resolve",
|
||||||
@@ -31,7 +35,7 @@ func TestBaseURLResolveBaseURL(t *testing.T) {
|
|||||||
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
|
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
|
||||||
t.Fatalf("unexpected output: %#v", data)
|
t.Fatalf("unexpected output: %#v", data)
|
||||||
}
|
}
|
||||||
if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
|
if data["block_id"] != "tbl123" || data["selection_source"] != "url_query" || data["block_type"] != "table" || data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
|
||||||
t.Fatalf("missing Base coordinates: %#v", data)
|
t.Fatalf("missing Base coordinates: %#v", data)
|
||||||
}
|
}
|
||||||
hint, _ := data["hint"].(map[string]interface{})
|
hint, _ := data["hint"].(map[string]interface{})
|
||||||
@@ -62,45 +66,213 @@ func TestBaseURLResolveBaseURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) {
|
t.Run("unconfirmed selected block stays neutral", func(t *testing.T) {
|
||||||
factory, stdout, _ := newExecuteFactory(t)
|
factory, stdout, _ := newExecuteFactory(t)
|
||||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user",
|
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale&record=rec_stale", "--as", "user",
|
||||||
}, factory, stdout)
|
}, factory, stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
}
|
}
|
||||||
data := decodeBaseEnvelope(t, stdout)
|
data := decodeBaseEnvelope(t, stdout)
|
||||||
if data["base_token"] != "bas123" || data["table_id"] != "tbl123" {
|
if data["base_token"] != "bas123" || data["block_id"] != "tbl123" {
|
||||||
t.Fatalf("unexpected output: %#v", data)
|
t.Fatalf("unexpected output: %#v", data)
|
||||||
}
|
}
|
||||||
|
if _, ok := data["table_id"]; ok {
|
||||||
|
t.Fatalf("unconfirmed block must not be reported as a table: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["view_id"]; ok {
|
||||||
|
t.Fatalf("unconfirmed block must not expose table-only view_id: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["record_id"]; ok {
|
||||||
|
t.Fatalf("unconfirmed block must not expose table-only record_id: %#v", data)
|
||||||
|
}
|
||||||
hint, _ := data["hint"].(map[string]interface{})
|
hint, _ := data["hint"].(map[string]interface{})
|
||||||
if hint["next_step"] != nextStepRecordList {
|
if !strings.Contains(hint["next_step"].(string), "+base-block-list") {
|
||||||
t.Fatalf("unexpected hint: %#v", hint)
|
t.Fatalf("unexpected hint: %#v", hint)
|
||||||
}
|
}
|
||||||
if _, ok := hint["fields"]; ok {
|
if _, ok := hint["fields"]; ok {
|
||||||
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
|
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("field endpoint does not confirm untyped block", func(t *testing.T) {
|
||||||
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
|
reg.Register(baseBlockListResolveStub("bas123",
|
||||||
|
map[string]interface{}{"id": "tbl_other", "type": "table", "name": "Other"},
|
||||||
|
))
|
||||||
|
fieldStub := fieldListStub("bas123", "tbl123")
|
||||||
|
fieldStub.Optional = true
|
||||||
|
fieldStub.OnMatch = func(_ *http.Request) {
|
||||||
|
t.Fatalf("field endpoint must not be used to infer selected block type")
|
||||||
|
}
|
||||||
|
reg.Register(fieldStub)
|
||||||
|
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||||
|
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale", "--as", "user",
|
||||||
|
}, factory, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
data := decodeBaseEnvelope(t, stdout)
|
||||||
|
if data["block_id"] != "tbl123" {
|
||||||
|
t.Fatalf("unexpected block coordinates: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["block_type"]; ok {
|
||||||
|
t.Fatalf("field endpoint must not confirm block type without block directory: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["table_id"]; ok {
|
||||||
|
t.Fatalf("field endpoint must not promote an untyped block to table_id: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["view_id"]; ok {
|
||||||
|
t.Fatalf("untyped block must not expose table-only view_id: %#v", data)
|
||||||
|
}
|
||||||
|
hint, _ := data["hint"].(map[string]interface{})
|
||||||
|
if _, ok := hint["fields"]; ok {
|
||||||
|
t.Fatalf("fields should be omitted when block type is unconfirmed: %#v", hint)
|
||||||
|
}
|
||||||
|
if !strings.Contains(hint["next_step"].(string), "+base-block-list") {
|
||||||
|
t.Fatalf("unexpected hint: %#v", hint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dashboard selected through table query key", func(t *testing.T) {
|
||||||
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
|
reg.Register(baseBlockListResolveStub("bas123",
|
||||||
|
map[string]interface{}{"id": "blk_dashboard", "type": "dashboard", "name": "Sales"},
|
||||||
|
))
|
||||||
|
|
||||||
|
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||||
|
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=blk_dashboard&view=vew_stale&record=rec_stale", "--as", "user",
|
||||||
|
}, factory, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
data := decodeBaseEnvelope(t, stdout)
|
||||||
|
if data["block_id"] != "blk_dashboard" || data["selection_source"] != "url_query" || data["block_type"] != "dashboard" || data["dashboard_id"] != "blk_dashboard" || data["block_name"] != "Sales" {
|
||||||
|
t.Fatalf("unexpected dashboard coordinates: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["table_id"]; ok {
|
||||||
|
t.Fatalf("dashboard must not be reported as table_id: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["view_id"]; ok {
|
||||||
|
t.Fatalf("dashboard must not expose table-only view_id: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["record_id"]; ok {
|
||||||
|
t.Fatalf("dashboard must not expose table-only record_id: %#v", data)
|
||||||
|
}
|
||||||
|
hint, _ := data["hint"].(map[string]interface{})
|
||||||
|
nextStep := hint["next_step"].(string)
|
||||||
|
if !strings.Contains(nextStep, "+dashboard-get") || !strings.Contains(nextStep, "+dashboard-list") || !strings.Contains(nextStep, "different dashboard than block_name") {
|
||||||
|
t.Fatalf("unexpected dashboard hint: %#v", hint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("workflow selected through table query key", func(t *testing.T) {
|
||||||
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
|
reg.Register(baseBlockListResolveStub("bas123",
|
||||||
|
map[string]interface{}{"id": "wkf_notify", "type": "workflow", "name": "Notify"},
|
||||||
|
))
|
||||||
|
|
||||||
|
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||||
|
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=wkf_notify&view=vew_stale&record=rec_stale", "--as", "user",
|
||||||
|
}, factory, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
data := decodeBaseEnvelope(t, stdout)
|
||||||
|
if data["block_id"] != "wkf_notify" || data["block_type"] != "workflow" || data["workflow_id"] != "wkf_notify" {
|
||||||
|
t.Fatalf("unexpected workflow coordinates: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["table_id"]; ok {
|
||||||
|
t.Fatalf("workflow must not be reported as table_id: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["view_id"]; ok {
|
||||||
|
t.Fatalf("workflow must not expose table-only view_id: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["record_id"]; ok {
|
||||||
|
t.Fatalf("workflow must not expose table-only record_id: %#v", data)
|
||||||
|
}
|
||||||
|
hint, _ := data["hint"].(map[string]interface{})
|
||||||
|
if !strings.Contains(hint["next_step"].(string), "+workflow-get") {
|
||||||
|
t.Fatalf("unexpected workflow hint: %#v", hint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("folder selected through table query key", func(t *testing.T) {
|
||||||
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
|
reg.Register(baseBlockListResolveStub("bas123",
|
||||||
|
map[string]interface{}{"id": "bfl_projects", "type": "folder", "name": "Projects"},
|
||||||
|
))
|
||||||
|
|
||||||
|
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||||
|
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=bfl_projects&view=vew_stale&record=rec_stale", "--as", "user",
|
||||||
|
}, factory, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
data := decodeBaseEnvelope(t, stdout)
|
||||||
|
if data["block_id"] != "bfl_projects" || data["block_type"] != "folder" || data["block_name"] != "Projects" {
|
||||||
|
t.Fatalf("unexpected folder coordinates: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["table_id"]; ok {
|
||||||
|
t.Fatalf("folder must not be reported as table_id: %#v", data)
|
||||||
|
}
|
||||||
|
hint, _ := data["hint"].(map[string]interface{})
|
||||||
|
nextStep := hint["next_step"].(string)
|
||||||
|
if !strings.Contains(nextStep, "+base-block-list --base-token bas123 --parent-id bfl_projects") || strings.Contains(nextStep, "determine whether") {
|
||||||
|
t.Fatalf("unexpected folder hint: %#v", hint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("docx selected through table query key", func(t *testing.T) {
|
||||||
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
|
reg.Register(baseBlockListResolveStub("bas123",
|
||||||
|
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec", "docx_token": "docx123"},
|
||||||
|
))
|
||||||
|
|
||||||
|
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||||
|
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=blk_doc&view=vew_stale&record=rec_stale", "--as", "user",
|
||||||
|
}, factory, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
data := decodeBaseEnvelope(t, stdout)
|
||||||
|
if data["block_id"] != "blk_doc" || data["block_type"] != "docx" || data["block_name"] != "Spec" || data["docx_token"] != "docx123" {
|
||||||
|
t.Fatalf("unexpected docx coordinates: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["table_id"]; ok {
|
||||||
|
t.Fatalf("docx must not be reported as table_id: %#v", data)
|
||||||
|
}
|
||||||
|
hint, _ := data["hint"].(map[string]interface{})
|
||||||
|
nextStep := hint["next_step"].(string)
|
||||||
|
if !strings.Contains(nextStep, "docs +fetch --doc docx123") || strings.Contains(nextStep, "determine whether") {
|
||||||
|
t.Fatalf("unexpected docx hint: %#v", hint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseBlockListResolveStub(baseToken string, blocks ...map[string]interface{}) *httpmock.Stub {
|
||||||
|
items := make([]interface{}, 0, len(blocks))
|
||||||
|
for _, block := range blocks {
|
||||||
|
items = append(items, block)
|
||||||
|
}
|
||||||
|
return &httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/open-apis/base/v3/bases/" + baseToken + "/blocks/list",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"blocks": items,
|
||||||
|
"total": len(items),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBaseURLResolveWikiURL(t *testing.T) {
|
func TestBaseURLResolveWikiURL(t *testing.T) {
|
||||||
t.Run("bitable", func(t *testing.T) {
|
t.Run("bitable", func(t *testing.T) {
|
||||||
factory, stdout, reg := newExecuteFactory(t)
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
|
||||||
Method: "GET",
|
|
||||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123",
|
|
||||||
Body: map[string]interface{}{
|
|
||||||
"code": 0,
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"node": map[string]interface{}{
|
|
||||||
"obj_type": "bitable",
|
|
||||||
"obj_token": "bas123",
|
|
||||||
"title": "Demo Base",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||||
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
|
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
|
||||||
@@ -114,6 +286,57 @@ func TestBaseURLResolveWikiURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("bitable with table coordinates", func(t *testing.T) {
|
||||||
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
|
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
|
||||||
|
reg.Register(baseBlockListResolveStub("bas123",
|
||||||
|
map[string]interface{}{"id": "tbl123", "type": "table", "name": "Orders"},
|
||||||
|
))
|
||||||
|
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||||
|
|
||||||
|
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||||
|
"+url-resolve",
|
||||||
|
"--url", "https://example.larkoffice.com/wiki/wik123?table=tbl123&view=vew123&record=rec123",
|
||||||
|
"--as", "user",
|
||||||
|
}, factory, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := decodeBaseEnvelope(t, stdout)
|
||||||
|
if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["block_id"] != "tbl123" || data["block_type"] != "table" || data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
|
||||||
|
t.Fatalf("unexpected Wiki Base table coordinates: %#v", data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bitable with dashboard selection", func(t *testing.T) {
|
||||||
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
|
reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base"))
|
||||||
|
reg.Register(baseBlockListResolveStub("bas123",
|
||||||
|
map[string]interface{}{"id": "blk_dashboard", "type": "dashboard", "name": "Sales"},
|
||||||
|
))
|
||||||
|
|
||||||
|
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||||
|
"+url-resolve",
|
||||||
|
"--url", "https://example.larkoffice.com/wiki/wik123?table=blk_dashboard&view=vew_stale&record=rec_stale",
|
||||||
|
"--as", "user",
|
||||||
|
}, factory, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := decodeBaseEnvelope(t, stdout)
|
||||||
|
if data["input_type"] != "wiki_url" || data["block_id"] != "blk_dashboard" || data["block_type"] != "dashboard" || data["dashboard_id"] != "blk_dashboard" {
|
||||||
|
t.Fatalf("unexpected Wiki Base dashboard coordinates: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["view_id"]; ok {
|
||||||
|
t.Fatalf("dashboard must not expose table-only view_id: %#v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["record_id"]; ok {
|
||||||
|
t.Fatalf("dashboard must not expose table-only record_id: %#v", data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("non bitable", func(t *testing.T) {
|
t.Run("non bitable", func(t *testing.T) {
|
||||||
factory, stdout, reg := newExecuteFactory(t)
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
@@ -136,6 +359,23 @@ func TestBaseURLResolveWikiURL(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func wikiBaseNodeStub(wikiToken, baseToken, title string) *httpmock.Stub {
|
||||||
|
return &httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/wiki/v2/spaces/get_node?token=" + wikiToken,
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"node": map[string]interface{}{
|
||||||
|
"obj_type": "bitable",
|
||||||
|
"obj_token": baseToken,
|
||||||
|
"title": title,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBaseURLResolveRecordShareURL(t *testing.T) {
|
func TestBaseURLResolveRecordShareURL(t *testing.T) {
|
||||||
t.Run("enriched", func(t *testing.T) {
|
t.Run("enriched", func(t *testing.T) {
|
||||||
factory, stdout, reg := newExecuteFactory(t)
|
factory, stdout, reg := newExecuteFactory(t)
|
||||||
|
|||||||
@@ -783,6 +783,20 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
|||||||
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
|
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "form question create visible_rule",
|
||||||
|
shortcut: BaseFormQuestionsCreate,
|
||||||
|
wantHelp: []string{
|
||||||
|
`"visible_rule"(display condition; same shape as view filter`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "form question update visible_rule",
|
||||||
|
shortcut: BaseFormQuestionsUpdate,
|
||||||
|
wantHelp: []string{
|
||||||
|
`"visible_rule"(display condition; same shape as view filter`,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "record search json",
|
name: "record search json",
|
||||||
shortcut: BaseRecordSearch,
|
shortcut: BaseRecordSearch,
|
||||||
@@ -801,7 +815,8 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
|||||||
name: "record batch create json",
|
name: "record batch create json",
|
||||||
shortcut: BaseRecordBatchCreate,
|
shortcut: BaseRecordBatchCreate,
|
||||||
wantHelp: []string{
|
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 +865,8 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
|||||||
`{"Parent Link":[{"id":"rec_xxx"}]}`,
|
`{"Parent Link":[{"id":"rec_xxx"}]}`,
|
||||||
"do not look for parent_record_id or a separate child-record API",
|
"do not look for parent_record_id or a separate child-record API",
|
||||||
"CellValue happy path: text/phone/url",
|
"CellValue happy path: text/phone/url",
|
||||||
"select -> \"Todo\"",
|
"select (multiple=false) -> \"Todo\"",
|
||||||
"multi-select -> [\"Tag A\",\"Tag B\"]",
|
"select (multiple=true) -> [\"Tag A\",\"Tag B\"]",
|
||||||
"datetime -> \"2026-03-24 10:00:00\"",
|
"datetime -> \"2026-03-24 10:00:00\"",
|
||||||
"checkbox -> true/false",
|
"checkbox -> true/false",
|
||||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||||
@@ -865,11 +880,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
|||||||
name: "record batch create",
|
name: "record batch create",
|
||||||
shortcut: BaseRecordBatchCreate,
|
shortcut: BaseRecordBatchCreate,
|
||||||
wantTips: []string{
|
wantTips: []string{
|
||||||
"Happy path fields: fields is the column order",
|
"Happy path field: create_records",
|
||||||
"rows is an array of row arrays",
|
"create_records is an array of independent record field maps",
|
||||||
"may use null for empty cells",
|
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
|
||||||
"use +field-list to confirm real writable fields",
|
"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",
|
"do not immediately +record-list the same table",
|
||||||
"CellValue happy path: text/phone/url",
|
"CellValue happy path: text/phone/url",
|
||||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||||
@@ -1027,6 +1042,39 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBaseFormQuestionsUpdateHelpGuidesFullOverwrite(t *testing.T) {
|
||||||
|
parent := &cobra.Command{Use: "base"}
|
||||||
|
BaseFormQuestionsUpdate.Mount(parent, &cmdutil.Factory{})
|
||||||
|
cmd := parent.Commands()[0]
|
||||||
|
|
||||||
|
help := cmd.Flags().FlagUsages()
|
||||||
|
wantHelp := []string{
|
||||||
|
"Update uses full question overwrite semantics",
|
||||||
|
"run +form-questions-list first",
|
||||||
|
"include existing values you want to keep",
|
||||||
|
"pass null or omit to clear",
|
||||||
|
}
|
||||||
|
for _, want := range wantHelp {
|
||||||
|
if !strings.Contains(help, want) {
|
||||||
|
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||||
|
wantTips := []string{
|
||||||
|
"full question overwrite semantics, not a patch",
|
||||||
|
"Run +form-questions-list first",
|
||||||
|
"title/description/required/option_display_mode/visible_rule",
|
||||||
|
"Omitted fields reset to defaults",
|
||||||
|
"empty strings, null, and empty arrays are written as empty/clear",
|
||||||
|
}
|
||||||
|
for _, want := range wantTips {
|
||||||
|
if !strings.Contains(tips, want) {
|
||||||
|
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -2055,8 +2103,8 @@ func TestBaseFormSubmitShortcut(t *testing.T) {
|
|||||||
if s.Service != "base" {
|
if s.Service != "base" {
|
||||||
t.Fatalf("Service=%q want base", s.Service)
|
t.Fatalf("Service=%q want base", s.Service)
|
||||||
}
|
}
|
||||||
if s.Risk != "write" {
|
if s.Risk != "high-risk-write" {
|
||||||
t.Fatalf("Risk=%q want write", s.Risk)
|
t.Fatalf("Risk=%q want high-risk-write", s.Risk)
|
||||||
}
|
}
|
||||||
if !s.HasFormat {
|
if !s.HasFormat {
|
||||||
t.Fatal("HasFormat should be true")
|
t.Fatal("HasFormat should be true")
|
||||||
@@ -2356,6 +2404,7 @@ func TestExecuteFormSubmit(t *testing.T) {
|
|||||||
"+form-submit",
|
"+form-submit",
|
||||||
"--share-token", "shr_exec1",
|
"--share-token", "shr_exec1",
|
||||||
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
|
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
|
||||||
|
"--yes",
|
||||||
}
|
}
|
||||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
@@ -2424,6 +2473,7 @@ func TestExecuteFormSubmit(t *testing.T) {
|
|||||||
"--share-token", "shr_exec6",
|
"--share-token", "shr_exec6",
|
||||||
"--base-token", "bas_exec6",
|
"--base-token", "bas_exec6",
|
||||||
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
|
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
|
||||||
|
"--yes",
|
||||||
}
|
}
|
||||||
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -2472,6 +2522,7 @@ func TestExecuteFormSubmit(t *testing.T) {
|
|||||||
"--share-token", "shr_dedup",
|
"--share-token", "shr_dedup",
|
||||||
"--base-token", "bas_dedup",
|
"--base-token", "bas_dedup",
|
||||||
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
|
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
|
||||||
|
"--yes",
|
||||||
}
|
}
|
||||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
@@ -2483,6 +2534,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) {
|
func TestUploadAttachmentsParallel(t *testing.T) {
|
||||||
t.Run("single file upload via execute path", func(t *testing.T) {
|
t.Run("single file upload via execute path", func(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
@@ -2519,6 +2597,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
|
|||||||
"--share-token", "shr_para1",
|
"--share-token", "shr_para1",
|
||||||
"--base-token", "bas_para1",
|
"--base-token", "bas_para1",
|
||||||
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
|
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
|
||||||
|
"--yes",
|
||||||
}
|
}
|
||||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||||
t.Fatalf("err=%v", err)
|
t.Fatalf("err=%v", err)
|
||||||
@@ -2553,6 +2632,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
|
|||||||
"--share-token", "shr_err",
|
"--share-token", "shr_err",
|
||||||
"--base-token", "bas_err",
|
"--base-token", "bas_err",
|
||||||
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
|
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
|
||||||
|
"--yes",
|
||||||
}
|
}
|
||||||
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ var BaseFieldSearchOptions = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Tips: []string{
|
Tips: []string{
|
||||||
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
|
`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 {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ var BaseRecordBatchCreate = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
baseTokenFlag(true),
|
baseTokenFlag(true),
|
||||||
tableRefFlag(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{
|
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.",
|
"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.",
|
"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.",
|
"Use the record-batch-create guide for command limits and edge cases.",
|
||||||
}, recordCellValueHappyPathTips...),
|
}, recordCellValueHappyPathTips...),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const maxBatchGetSelectFieldCount = 100
|
|||||||
const maxRecordSearchSelectFieldCount = 50
|
const maxRecordSearchSelectFieldCount = 50
|
||||||
|
|
||||||
var recordCellValueHappyPathTips = []string{
|
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.`,
|
`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.",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ var CalendarAgenda = common.Shortcut{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
collapseDescription(e)
|
||||||
|
|
||||||
filtered = append(filtered, e)
|
filtered = append(filtered, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import (
|
|||||||
func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} {
|
func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} {
|
||||||
eventData := map[string]interface{}{
|
eventData := map[string]interface{}{
|
||||||
"summary": runtime.Str("summary"),
|
"summary": runtime.Str("summary"),
|
||||||
"description": runtime.Str("description"),
|
|
||||||
"start_time": map[string]string{"timestamp": startTs},
|
"start_time": map[string]string{"timestamp": startTs},
|
||||||
"end_time": map[string]string{"timestamp": endTs},
|
"end_time": map[string]string{"timestamp": endTs},
|
||||||
"attendee_ability": "can_modify_event",
|
"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 != "" {
|
if rrule := runtime.Str("rrule"); rrule != "" {
|
||||||
eventData["recurrence"] = rrule
|
eventData["recurrence"] = rrule
|
||||||
}
|
}
|
||||||
|
if description := descriptionToSend(runtime); description != "" {
|
||||||
|
eventData["description_rich"] = description
|
||||||
|
}
|
||||||
return eventData
|
return eventData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ var CalendarCreate = common.Shortcut{
|
|||||||
{Name: "summary", Desc: "event title"},
|
{Name: "summary", Desc: "event title"},
|
||||||
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
|
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
|
||||||
{Name: "end", Desc: "end 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 (``; 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`, `<br>**bold**`).", Input: []string{common.File, common.Stdin}},
|
||||||
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
|
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
|
||||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||||
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
||||||
@@ -231,6 +233,9 @@ var CalendarCreate = common.Shortcut{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||||
}
|
}
|
||||||
|
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
eventData := buildEventData(runtime, startTs, endTs)
|
eventData := buildEventData(runtime, startTs, endTs)
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ type calendarEvent struct {
|
|||||||
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
|
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
|
||||||
Summary string `json:"summary,omitempty"`
|
Summary string `json:"summary,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
|
DescriptionRich string `json:"description_rich,omitempty"`
|
||||||
StartTime *calendarEventTime `json:"start_time,omitempty"`
|
StartTime *calendarEventTime `json:"start_time,omitempty"`
|
||||||
EndTime *calendarEventTime `json:"end_time,omitempty"`
|
EndTime *calendarEventTime `json:"end_time,omitempty"`
|
||||||
VChat *calendarEventVChat `json:"vchat,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" {
|
if status, _ := out["status"].(string); status != "cancelled" {
|
||||||
delete(out, "status")
|
delete(out, "status")
|
||||||
}
|
}
|
||||||
|
collapseDescription(out)
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -988,9 +988,15 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
|
|||||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||||
t.Fatalf("unmarshal captured patch body: %v", err)
|
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)
|
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 {
|
if body["need_notification"] != false {
|
||||||
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
|
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
|
||||||
}
|
}
|
||||||
@@ -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) {
|
func TestAgenda_EmptyResult(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
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) {
|
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ var CalendarUpdate = common.Shortcut{
|
|||||||
{Name: "event-id", Desc: "event ID to update", Required: true},
|
{Name: "event-id", Desc: "event ID to update", Required: true},
|
||||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||||
{Name: "summary", Desc: "event title"},
|
{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 (``; 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`, `<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: "start", Desc: "new start time (ISO 8601); requires --end"},
|
||||||
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
|
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
|
||||||
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
||||||
@@ -109,11 +109,13 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
|
|||||||
body := map[string]interface{}{}
|
body := map[string]interface{}{}
|
||||||
hasFields := false
|
hasFields := false
|
||||||
|
|
||||||
for _, field := range []string{"summary", "description"} {
|
if runtime.Cmd.Flags().Changed("summary") {
|
||||||
if runtime.Cmd.Flags().Changed(field) {
|
body["summary"] = runtime.Str("summary")
|
||||||
body[field] = runtime.Str(field)
|
hasFields = true
|
||||||
hasFields = true
|
}
|
||||||
}
|
if runtime.Cmd.Flags().Changed("description") {
|
||||||
|
body["description_rich"] = runtime.Str("description")
|
||||||
|
hasFields = true
|
||||||
}
|
}
|
||||||
if runtime.Cmd.Flags().Changed("rrule") {
|
if runtime.Cmd.Flags().Changed("rrule") {
|
||||||
rrule := strings.TrimSpace(runtime.Str("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")
|
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)
|
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -428,8 +436,10 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
|
|||||||
if summary, _ := event["summary"].(string); summary != "" {
|
if summary, _ := event["summary"].(string); summary != "" {
|
||||||
result["summary"] = summary
|
result["summary"] = summary
|
||||||
}
|
}
|
||||||
if description, _ := event["description"].(string); description != "" {
|
if rich, _ := event["description_rich"].(string); rich != "" {
|
||||||
result["description"] = description
|
result["description"] = rich
|
||||||
|
} else if plain, _ := event["description"].(string); plain != "" {
|
||||||
|
result["description"] = plain
|
||||||
}
|
}
|
||||||
if start := formatCalendarEventTime(event["start_time"]); start != "" {
|
if start := formatCalendarEventTime(event["start_time"]); start != "" {
|
||||||
result["start"] = start
|
result["start"] = start
|
||||||
|
|||||||
172
shortcuts/calendar/description_rich_images.go
Normal file
172
shortcuts/calendar/description_rich_images.go
Normal 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.feishu.cn"
|
||||||
|
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
|
||||||
|
}
|
||||||
279
shortcuts/calendar/description_rich_images_test.go
Normal file
279
shortcuts/calendar/description_rich_images_test.go
Normal 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, "feishu.cn"},
|
||||||
|
{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  more "
|
||||||
|
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", "",
|
||||||
|
"--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", "",
|
||||||
|
"--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", "",
|
||||||
|
"--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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,26 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
|
|||||||
return startInput, endInput
|
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 {
|
func hasExplicitBotFlag(cmd *cobra.Command) bool {
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
146
shortcuts/common/localfile.go
Normal file
146
shortcuts/common/localfile.go
Normal 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
|
||||||
|
}
|
||||||
95
shortcuts/common/localfile_test.go
Normal file
95
shortcuts/common/localfile_test.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadLocalFileFlag_AcceptsAbsolutePath(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "input.txt")
|
||||||
|
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rctx := localFileTestRuntime(t, path)
|
||||||
|
|
||||||
|
if err := rctx.ValidateLocalFileFlag("file", 7); err != nil {
|
||||||
|
t.Fatalf("ValidateLocalFileFlag() error = %v", err)
|
||||||
|
}
|
||||||
|
got, err := rctx.ReadLocalFileFlag("file", 7)
|
||||||
|
if err != nil || string(got) != "content" {
|
||||||
|
t.Fatalf("ReadLocalFileFlag() = %q, %v; want content", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
path func(t *testing.T) string
|
||||||
|
max int64
|
||||||
|
}{
|
||||||
|
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
|
||||||
|
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
|
||||||
|
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
|
||||||
|
{name: "too large", path: func(t *testing.T) string {
|
||||||
|
path := filepath.Join(t.TempDir(), "large")
|
||||||
|
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}, max: 5},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := localFileTestRuntime(t, tc.path(t)).ValidateLocalFileFlag("file", tc.max)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
path func(t *testing.T) string
|
||||||
|
max int64
|
||||||
|
}{
|
||||||
|
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
|
||||||
|
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
|
||||||
|
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
|
||||||
|
{name: "too large", path: func(t *testing.T) string {
|
||||||
|
path := filepath.Join(t.TempDir(), "large")
|
||||||
|
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}, max: 5},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := localFileTestRuntime(t, tc.path(t)).ReadLocalFileFlag("file", tc.max)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func localFileTestRuntime(t *testing.T, path string) *RuntimeContext {
|
||||||
|
t.Helper()
|
||||||
|
cmd := &cobra.Command{Use: "test"}
|
||||||
|
cmd.Flags().String("file", "", "")
|
||||||
|
if err := cmd.Flags().Set("file", path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &RuntimeContext{ctx: context.Background(), Cmd: cmd}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user