mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40a0a9de66 | ||
|
|
a8ad44ba13 | ||
|
|
003d0f42f8 | ||
|
|
7946e5c81d | ||
|
|
5cf09ecfda | ||
|
|
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 |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,4 +1,7 @@
|
||||
/go.mod @liangshuo-1
|
||||
/go.sum @liangshuo-1
|
||||
/internal/ @liangshuo-1
|
||||
/shortcuts/common/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
|
||||
46
.github/workflows/semantic-review.yml
vendored
46
.github/workflows/semantic-review.yml
vendored
@@ -25,19 +25,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
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}`);
|
||||
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.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
@@ -253,19 +250,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
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}`);
|
||||
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.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
|
||||
118
CHANGELOG.md
118
CHANGELOG.md
@@ -2,6 +2,119 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.81] - 2026-07-31
|
||||
|
||||
### Features
|
||||
|
||||
- support visible_rule for form questions (#1891)
|
||||
- **contact**: add bot search shortcut (#2083)
|
||||
- add SXSD schema validation to Slides lint (#2103)
|
||||
- **drive**: add comment-operation shortcuts (#1898)
|
||||
- **drive**: extend permission shortcuts for Miaoda (#2070)
|
||||
- **apps**: add cache debug commands (+cache-get/-delete/-clear) (#1896)
|
||||
- support source file preview artifacts (#2085)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **contact**: stop bot match segments carrying tags or empty entries (#2115)
|
||||
- **base**: resolve Base URL block types accurately (#2099)
|
||||
- **drive**: use title for default download filename (#2089)
|
||||
- drop stale target version from root upgrade prompt (#2100)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **calendar**: warn against container-default timezone in time conversion (#2104)
|
||||
- **calendar**: confirm scope before editing recurring events (#2119)
|
||||
- **base**: clarify form and file operation routing (#2110)
|
||||
|
||||
### Misc
|
||||
|
||||
- add protected public domain allowlists (#2111)
|
||||
|
||||
## [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
|
||||
@@ -1638,6 +1751,11 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.81]: https://github.com/larksuite/cli/releases/tag/v1.0.81
|
||||
[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.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
|
||||
|
||||
27
README.md
27
README.md
@@ -285,12 +285,31 @@ To reduce these risks, the tool enables default security protections at multiple
|
||||
|
||||
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
|
||||
|
||||
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
|
||||
|
||||
- Operating system type: macOS, Windows, or Linux
|
||||
- Device hardware model: for example, Mac17,9
|
||||
|
||||
To disable this protection for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control off
|
||||
```
|
||||
|
||||
To enable this protection for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control on
|
||||
```
|
||||
|
||||
To restore the default policy for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control default
|
||||
```
|
||||
|
||||
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#larksuite/cli&Date)
|
||||
|
||||
## Contributing
|
||||
|
||||
Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls).
|
||||
|
||||
27
README.zh.md
27
README.zh.md
@@ -286,12 +286,31 @@ 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
|
||||
|
||||
[](https://star-history.com/#larksuite/cli&Date)
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## +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
|
||||
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(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigRiskControl(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||
|
||||
@@ -179,8 +179,8 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
}
|
||||
|
||||
// Step 1: Request app registration (begin)
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
// Registration is platform traffic, so it must use the provider-aware
|
||||
// transport as well as the shared proxy configuration.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -157,8 +157,8 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
|
||||
// the real egress path (and are blocked when proxy plugin fails closed).
|
||||
// Connectivity checks are platform traffic and must exercise the same
|
||||
// provider-aware route as real platform requests.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
mcpURL := ep.MCP + "/mcp"
|
||||
|
||||
|
||||
@@ -65,7 +65,17 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
if info == nil {
|
||||
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) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -128,6 +128,17 @@ func TestOfferRootUpgrade(t *testing.T) {
|
||||
if gotPrompt != tc.wantPrompt {
|
||||
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 {
|
||||
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,18 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
|
||||
type failingBody struct {
|
||||
err error
|
||||
@@ -263,3 +272,55 @@ func TestInterceptor_EmptyBody(t *testing.T) {
|
||||
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacySidecarProviderStillHandlesForcedExternalRequests(t *testing.T) {
|
||||
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
t.Setenv(envvars.CliProxyKey, "test-key")
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(&Provider{})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
seen := make(chan *http.Request, 2)
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
seen <- req.Clone(req.Context())
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: internaltransport.NewHTTPPolicyRouter(base, base)},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
|
||||
withSentinel, err := http.NewRequest(http.MethodGet, "https://external.example/protected", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withSentinel.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
resp, err := client.Do(withSentinel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
withoutSentinel, err := http.NewRequest(http.MethodGet, "https://external.example/public", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err = client.Do(withoutSentinel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
proxied := <-seen
|
||||
if proxied.URL.Scheme != "http" || proxied.URL.Host != "127.0.0.1:16384" {
|
||||
t.Fatalf("sentinel request URL = %s, want sidecar route", proxied.URL)
|
||||
}
|
||||
if got := proxied.Header.Get(sidecar.HeaderProxyTarget); got != "https://external.example" {
|
||||
t.Fatalf("sentinel request proxy target = %q", got)
|
||||
}
|
||||
|
||||
passthrough := <-seen
|
||||
if got := passthrough.URL.String(); got != "https://external.example/public" {
|
||||
t.Fatalf("non-sentinel request URL = %q, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,27 @@ type Provider interface {
|
||||
ResolveInterceptor(ctx context.Context) Interceptor
|
||||
}
|
||||
|
||||
// RequestClass describes the trust boundary of an outbound HTTP request.
|
||||
// Platform requests target endpoints owned by the CLI's endpoint resolver;
|
||||
// external requests target user-provided, pre-signed, CDN, registry, or other
|
||||
// non-platform URLs. Redirect targets are classified again from each hop's
|
||||
// logical URL; rewriting a host in an interceptor does not add that host to
|
||||
// the platform endpoint catalog.
|
||||
type RequestClass string
|
||||
|
||||
const (
|
||||
RequestClassPlatform RequestClass = "platform"
|
||||
RequestClassExternal RequestClass = "external"
|
||||
)
|
||||
|
||||
// ScopedProvider optionally limits a Provider to selected request classes.
|
||||
// Providers that do not implement this interface retain the original
|
||||
// behavior and apply to every request class.
|
||||
type ScopedProvider interface {
|
||||
Provider
|
||||
SupportsRequestClass(RequestClass) bool
|
||||
}
|
||||
|
||||
// Interceptor defines network-layer customization via a pre/post hook pair.
|
||||
// The built-in transport chain always executes between PreRoundTrip and the
|
||||
// returned post function, and cannot be skipped or overridden by the extension.
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
var _ transport.RoundTripperDecorator = (*SecurityPolicyTransport)(nil)
|
||||
|
||||
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
|
||||
// and checks for security policy errors.
|
||||
type SecurityPolicyTransport struct {
|
||||
@@ -31,6 +33,16 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *SecurityPolicyTransport) BaseRoundTripper() http.RoundTripper {
|
||||
return t.base()
|
||||
}
|
||||
|
||||
func (t *SecurityPolicyTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.base().RoundTrip(req)
|
||||
|
||||
@@ -212,6 +212,9 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
cancel()
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
|
||||
}
|
||||
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
|
||||
|
||||
@@ -518,6 +518,29 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoStream_PreservesTypedTransportError(t *testing.T) {
|
||||
policyErr := errs.NewSecurityPolicyError(errs.SubtypeAccessDenied, "blocked redirect")
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, policyErr
|
||||
})},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/drive/v1/files/file_token/download",
|
||||
}, core.AsBot)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("DoStream() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if !errors.Is(err, policyErr) {
|
||||
t.Fatal("DoStream() did not preserve the typed transport error")
|
||||
}
|
||||
}
|
||||
|
||||
// failingTokenResolver always returns TokenUnavailableError, exercising the
|
||||
// auth/credential failure path through resolveAccessToken.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
@@ -16,10 +16,12 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// Factory holds shared dependencies injected into every command.
|
||||
@@ -31,7 +33,7 @@ type InvocationContext struct {
|
||||
|
||||
type Factory struct {
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
||||
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
|
||||
HttpClient func() (*http.Client, error) // policy-routed HTTP client for direct requests
|
||||
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
|
||||
IOStreams *IOStreams // stdin/stdout/stderr streams
|
||||
|
||||
@@ -48,6 +50,18 @@ type Factory struct {
|
||||
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||
}
|
||||
|
||||
// ExternalHTTPClient returns a clone of the existing Factory client whose
|
||||
// requests are explicitly classified as external. The underlying client,
|
||||
// redirect policy, timeout, proxy configuration, and legacy transport provider
|
||||
// behavior are preserved.
|
||||
func (f *Factory) ExternalHTTPClient() (*http.Client, error) {
|
||||
client, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transport.ClientForRequestClass(client, exttransport.RequestClassExternal), nil
|
||||
}
|
||||
|
||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||
// The provider controls whether the returned instance is fresh or cached.
|
||||
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
||||
|
||||
@@ -5,16 +5,18 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
@@ -22,6 +24,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
|
||||
@@ -33,7 +36,7 @@ import (
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
// Phase 2: Credential (sole data source for account info)
|
||||
// Phase 3: Config derived from Credential
|
||||
// Phase 4: LarkClient derived from Credential
|
||||
// Phase 4: LarkClient derived from Credential and workspace policy
|
||||
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
streams = normalizeStreams(streams)
|
||||
f := &Factory{
|
||||
@@ -47,6 +50,19 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
// workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged.
|
||||
ws := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
core.SetCurrentWorkspace(ws)
|
||||
workspaceConfig := core.NewConfigSnapshot()
|
||||
bootstrapHostSignalSource := sync.OnceValue(func() riskcontrol.Source {
|
||||
return resolveSDKHostSignalSource(workspaceConfig)
|
||||
})
|
||||
// Install after workspace selection so the dependency bootstrap bridge uses
|
||||
// the correct shared proxy configuration. NewDefault is also used by cmd.Build
|
||||
// consumers, so this keeps their request routing identical to cmd.Execute.
|
||||
transport.InstallSDKTransportBridge(func(base http.RoundTripper) http.RoundTripper {
|
||||
return buildSDKPlatformTransportWithBase(
|
||||
base,
|
||||
bootstrapHostSignalSource(),
|
||||
)
|
||||
})
|
||||
|
||||
// Inject workspace-aware dir into keychain's log system.
|
||||
// This breaks the core↔keychain import cycle by using a function variable.
|
||||
@@ -56,7 +72,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
f.FileIOProvider = fileio.GetProvider()
|
||||
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc(f)
|
||||
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
|
||||
|
||||
// Phase 2: Credential (sole data source)
|
||||
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
||||
@@ -67,7 +83,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
|
||||
// Phase 3: Config derived from Credential via an explicit conversion boundary.
|
||||
// Phase 3: Runtime config contains resolved account data only.
|
||||
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -78,21 +94,52 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
return cfg, nil
|
||||
})
|
||||
|
||||
// Phase 4: LarkClient from Credential (placeholder AppSecret)
|
||||
f.LarkClient = cachedLarkClientFunc(f)
|
||||
// Phase 4: LarkClient composes account data and workspace policy at the SDK
|
||||
// transport boundary.
|
||||
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
// safeRedirectPolicy prevents credential headers from being forwarded
|
||||
// when a response redirects to a different host (e.g. Lark API 302 → CDN).
|
||||
// Strips Authorization, X-Lark-MCP-UAT, and X-Lark-MCP-TAT on cross-host
|
||||
// redirects; other headers like X-Cli-* pass through.
|
||||
// safeRedirectPolicy permits cross-origin redirects only for bodyless GET and
|
||||
// HEAD requests. This allows API download redirects while preventing OAuth or
|
||||
// other credential-bearing request bodies from being replayed to another
|
||||
// origin. HTTPS requests can never be downgraded to HTTP.
|
||||
func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "too many redirects")
|
||||
}
|
||||
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
original := via[0]
|
||||
previous := via[len(via)-1]
|
||||
if previous.URL != nil && req.URL != nil && strings.EqualFold(previous.URL.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"redirect from HTTPS to %s is not allowed",
|
||||
req.URL.Scheme,
|
||||
)
|
||||
}
|
||||
if !sameRedirectOrigin(previous.URL, req.URL) {
|
||||
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"cross-origin redirect for HTTP method %s is not allowed",
|
||||
req.Method,
|
||||
)
|
||||
}
|
||||
if req.Body != nil || req.GetBody != nil {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"cross-origin redirect with a request body is not allowed",
|
||||
)
|
||||
}
|
||||
}
|
||||
// net/http copies initial headers onto every redirect request. Continue
|
||||
// stripping credentials for every hop outside the initial origin, even when
|
||||
// two consecutive redirect targets share an origin.
|
||||
if !sameRedirectOrigin(original.URL, req.URL) {
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Del("X-Lark-MCP-UAT")
|
||||
req.Header.Del("X-Lark-MCP-TAT")
|
||||
@@ -100,6 +147,29 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameRedirectOrigin(left, right *url.URL) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left.Scheme, right.Scheme) &&
|
||||
strings.EqualFold(left.Hostname(), right.Hostname()) &&
|
||||
effectivePort(left) == effectivePort(right)
|
||||
}
|
||||
|
||||
func effectivePort(candidate *url.URL) string {
|
||||
if port := candidate.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch strings.ToLower(candidate.Scheme) {
|
||||
case "http":
|
||||
return "80"
|
||||
case "https":
|
||||
return "443"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// warnIfProxied is a test seam for the proxy-warning gate. Production wires it
|
||||
// to transport.WarnIfProxied; tests swap in a spy to count invocations. It is
|
||||
// needed because the real function is guarded by an internal sync.Once, so
|
||||
@@ -108,19 +178,19 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
// .StderrIsTerminal field, which tests set directly.
|
||||
var warnIfProxied = transport.WarnIfProxied
|
||||
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
if f.IOStreams.StderrIsTerminal {
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
rt = &RetryTransport{Base: rt}
|
||||
rt = &SecurityHeaderTransport{Base: rt}
|
||||
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
|
||||
rt = wrapWithExtension(rt)
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
shared := transport.Shared()
|
||||
outbound := riskcontrol.NewTransport(shared, hostSignalSource)
|
||||
platform := buildDirectHTTPTransport(outbound, true)
|
||||
external := buildDirectHTTPTransport(outbound, false)
|
||||
client := &http.Client{
|
||||
Transport: rt,
|
||||
Transport: transport.NewHTTPPolicyRouter(platform, external),
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}
|
||||
@@ -128,7 +198,16 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
func buildDirectHTTPTransport(base http.RoundTripper, platform bool) http.RoundTripper {
|
||||
var builtIn http.RoundTripper = &RetryTransport{Base: base}
|
||||
builtIn = &SecurityHeaderTransport{Base: builtIn}
|
||||
if platform {
|
||||
builtIn = &auth.SecurityPolicyTransport{Base: builtIn}
|
||||
}
|
||||
return builtIn
|
||||
}
|
||||
|
||||
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
|
||||
return sync.OnceValues(func() (*lark.Client, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -142,8 +221,9 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
if f.IOStreams.StderrIsTerminal {
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
Transport: buildSDKTransport(hostSignalSource),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}))
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
@@ -152,13 +232,41 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func buildSDKTransport() http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = transport.Shared()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
return wrapWithExtension(sdkTransport)
|
||||
func buildSDKTransport(hostSignalSource riskcontrol.Source) http.RoundTripper {
|
||||
return buildSDKTransportWithBase(transport.Shared(), hostSignalSource)
|
||||
}
|
||||
|
||||
func buildSDKPlatformTransportWithBase(
|
||||
base http.RoundTripper,
|
||||
hostSignalSource riskcontrol.Source,
|
||||
) http.RoundTripper {
|
||||
outbound := riskcontrol.NewTransport(base, hostSignalSource)
|
||||
return buildSDKHTTPTransport(outbound, true)
|
||||
}
|
||||
|
||||
func buildSDKTransportWithBase(
|
||||
base http.RoundTripper,
|
||||
hostSignalSource riskcontrol.Source,
|
||||
) http.RoundTripper {
|
||||
// Risk control is the innermost trusted boundary for both request classes.
|
||||
// It therefore observes the final URL and strips extension-supplied reserved
|
||||
// headers immediately before the network transport.
|
||||
outbound := riskcontrol.NewTransport(base, hostSignalSource)
|
||||
return transport.NewHTTPPolicyRouter(
|
||||
buildSDKHTTPTransport(outbound, true),
|
||||
buildSDKHTTPTransport(outbound, false),
|
||||
)
|
||||
}
|
||||
|
||||
func buildSDKHTTPTransport(base http.RoundTripper, platform bool) http.RoundTripper {
|
||||
var builtIn http.RoundTripper = &RetryTransport{Base: base}
|
||||
builtIn = &UserAgentTransport{Base: builtIn}
|
||||
builtIn = &BuildHeaderTransport{Base: builtIn}
|
||||
builtIn = &SecurityHeaderTransport{Base: builtIn}
|
||||
if platform {
|
||||
builtIn = &auth.SecurityPolicyTransport{Base: builtIn}
|
||||
}
|
||||
return builtIn
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
|
||||
@@ -4,12 +4,24 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
func TestCachedHTTPClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
|
||||
c1, err := fn()
|
||||
if err != nil {
|
||||
@@ -28,18 +40,304 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
func TestCachedHTTPClientFunc_HasTimeout(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
c, _ := fn()
|
||||
if c.Timeout == 0 {
|
||||
t.Error("expected non-zero timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
func TestCachedHTTPClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
c, _ := fn()
|
||||
if c.CheckRedirect == nil {
|
||||
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryExternalHTTPClientClonesExistingClient(t *testing.T) {
|
||||
base := &http.Client{Timeout: 17, CheckRedirect: safeRedirectPolicy}
|
||||
factory := &Factory{HttpClient: func() (*http.Client, error) { return base, nil }}
|
||||
|
||||
external, err := factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if external == base {
|
||||
t.Fatal("ExternalHTTPClient returned the cached client instead of a clone")
|
||||
}
|
||||
if external.Timeout != base.Timeout || external.CheckRedirect == nil {
|
||||
t.Fatal("ExternalHTTPClient did not preserve client policy")
|
||||
}
|
||||
if base.Transport != nil {
|
||||
t.Fatal("ExternalHTTPClient mutated the cached client's transport")
|
||||
}
|
||||
}
|
||||
|
||||
type platformOnlyStubProvider struct {
|
||||
*stubTransportProvider
|
||||
}
|
||||
|
||||
func (*platformOnlyStubProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == exttransport.RequestClassPlatform
|
||||
}
|
||||
|
||||
func TestFactoryHTTPClientRoutesPoliciesByRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_NO_PROXY", "1")
|
||||
|
||||
interceptor := &headerCapturingInterceptor{}
|
||||
exttransport.Register(&platformOnlyStubProvider{stubTransportProvider: &stubTransportProvider{interceptor: interceptor}})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
received := make(chan http.Header, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Clone()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
factory := &Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}
|
||||
client, err := cachedHttpClientFunc(factory, nil)()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory.HttpClient = func() (*http.Client, error) { return client, nil }
|
||||
platformClient := internaltransport.ClientForRequestClass(client, exttransport.RequestClassPlatform)
|
||||
externalClient, err := factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, client := range []*http.Client{platformClient, externalClient} {
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
platformHeaders := <-received
|
||||
if got := platformHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
|
||||
t.Fatalf("platform extension header = %q, want ext-trace-123", got)
|
||||
}
|
||||
if got := platformHeaders.Get(HeaderSource); got != SourceValue {
|
||||
t.Fatalf("platform security header = %q, want %q", got, SourceValue)
|
||||
}
|
||||
|
||||
externalHeaders := <-received
|
||||
if got := externalHeaders.Get("X-Custom-Trace"); got != "" {
|
||||
t.Fatalf("external request leaked extension header %q", got)
|
||||
}
|
||||
for header, values := range BaseSecurityHeaders() {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
want := values[len(values)-1]
|
||||
if got := externalHeaders.Get(header); got != want {
|
||||
t.Fatalf("external security header %s = %q, want preserved value %q", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryExternalHTTPClientDoesNotParsePlatformErrorProtocol(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_NO_PROXY", "1")
|
||||
exttransport.Register(nil)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"code":21000,"msg":"application-defined external response","data":{"cli_hint":"external-defined"}}`)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
factory := &Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}
|
||||
client, err := cachedHttpClientFunc(factory, nil)()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory.HttpClient = func() (*http.Client, error) { return client, nil }
|
||||
|
||||
platform := internaltransport.ClientForRequestClass(client, exttransport.RequestClassPlatform)
|
||||
if _, err := platform.Get(server.URL); err == nil {
|
||||
t.Fatal("platform request error = nil, want security policy classification")
|
||||
} else {
|
||||
var policyErr *errs.SecurityPolicyError
|
||||
if !errors.As(err, &policyErr) {
|
||||
t.Fatalf("platform request error type = %T, want *errs.SecurityPolicyError", err)
|
||||
}
|
||||
}
|
||||
|
||||
external, err := factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := external.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("external request parsed platform error protocol: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyAllowsBodylessCrossOriginGetAndStripsCredentials(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, header := range []string{"Authorization", "X-Lark-MCP-UAT", "X-Lark-MCP-TAT"} {
|
||||
redirect.Header.Set(header, "secret")
|
||||
}
|
||||
|
||||
if err := safeRedirectPolicy(redirect, []*http.Request{original}); err != nil {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want allowed GET redirect", err)
|
||||
}
|
||||
for _, header := range []string{"Authorization", "X-Lark-MCP-UAT", "X-Lark-MCP-TAT"} {
|
||||
if got := redirect.Header.Get(header); got != "" {
|
||||
t.Fatalf("redirect retained %s=%q", header, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsHTTPSDowngrade(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "http://open.feishu.cn/next", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want HTTPS downgrade rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsCrossOriginMethod(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn/token", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodPost, "https://external.example/token", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP method POST") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want cross-origin method rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsCrossOriginRequestBody(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://accounts.feishu.cn/token", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "https://external.example/token", strings.NewReader("client_secret=secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original})
|
||||
if err == nil || !strings.Contains(err.Error(), "request body") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want cross-origin body rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsTooManyRedirects(t *testing.T) {
|
||||
err := safeRedirectPolicy(&http.Request{}, make([]*http.Request, 10))
|
||||
if err == nil || err.Error() != "too many redirects" {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want redirect limit rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyTreatsDefaultHTTPSPortAsSameOrigin(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn/token", strings.NewReader("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn:443/token-next", strings.NewReader("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := safeRedirectPolicy(redirect, []*http.Request{original}); err != nil {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want same-origin redirect", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyKeepsCredentialsStrippedAcrossExternalHops(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/first", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/second", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect.Header.Set("Authorization", "Bearer copied-from-initial-request")
|
||||
|
||||
if err := safeRedirectPolicy(redirect, []*http.Request{original, previous}); err != nil {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want same-CDN redirect", err)
|
||||
}
|
||||
if got := redirect.Header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("redirect retained Authorization=%q outside the initial origin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsDowngradeOnLaterHop(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "http://source.example/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/secure", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "http://cdn.example.com/plain", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original, previous})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want later-hop HTTPS downgrade rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func requireRedirectProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed error", err)
|
||||
}
|
||||
if problem.Category != category || problem.Subtype != subtype {
|
||||
t.Fatalf(
|
||||
"error category/subtype = %s/%s, want %s/%s",
|
||||
problem.Category,
|
||||
problem.Subtype,
|
||||
category,
|
||||
subtype,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
@@ -33,16 +34,18 @@ var proxyWarnGateCases = []struct {
|
||||
{"non-terminal stderr stays silent", false, 0},
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
func TestCachedHTTPClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
isEnabled := false
|
||||
for _, tc := range proxyWarnGateCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
calls := installProxyWarnSpy(t)
|
||||
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
|
||||
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
|
||||
}})
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
f.IOStreams.StderrIsTerminal = tc.terminal
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
if _, err := fn(); err != nil {
|
||||
t.Fatalf("http client init: %v", err)
|
||||
}
|
||||
@@ -73,7 +76,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
// normalizeStreams copies the struct (out := *s), so the
|
||||
// StderrIsTerminal field survives into f.IOStreams.
|
||||
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
|
||||
if _, err := cachedLarkClientFunc(f)(); err != nil {
|
||||
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
|
||||
t.Fatalf("lark client init: %v", err)
|
||||
}
|
||||
|
||||
|
||||
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"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentTrace = "X-Agent-Trace"
|
||||
HeaderAgentName = "X-Agent-Name"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
|
||||
@@ -55,6 +56,9 @@ func BaseSecurityHeaders() http.Header {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
if v := envvars.AgentName(); v != "" {
|
||||
h.Set(HeaderAgentName, v)
|
||||
}
|
||||
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) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestTestFactory_ReplacesGlobals(t *testing.T) {
|
||||
URL: "/test",
|
||||
Body: "ok",
|
||||
})
|
||||
// Use the stub via Factory HttpClient
|
||||
// Use the stub via Factory HttpClient.
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
t.Fatalf("HttpClient() error: %v", err)
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
var (
|
||||
_ transport.RoundTripperDecorator = (*RetryTransport)(nil)
|
||||
_ transport.RoundTripperDecorator = (*UserAgentTransport)(nil)
|
||||
_ transport.RoundTripperDecorator = (*BuildHeaderTransport)(nil)
|
||||
_ transport.RoundTripperDecorator = (*SecurityHeaderTransport)(nil)
|
||||
)
|
||||
|
||||
// RetryTransport is an http.RoundTripper that retries on 5xx responses
|
||||
// and network errors. MaxRetries defaults to 0 (no retries).
|
||||
type RetryTransport struct {
|
||||
@@ -27,6 +32,16 @@ func (t *RetryTransport) base() http.RoundTripper {
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *RetryTransport) BaseRoundTripper() http.RoundTripper {
|
||||
return t.base()
|
||||
}
|
||||
|
||||
func (t *RetryTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (t *RetryTransport) delay() time.Duration {
|
||||
if t.Delay > 0 {
|
||||
return t.Delay
|
||||
@@ -63,6 +78,19 @@ type UserAgentTransport struct {
|
||||
Base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *UserAgentTransport) BaseRoundTripper() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *UserAgentTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(HeaderUserAgent, UserAgentValue())
|
||||
@@ -73,14 +101,25 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
}
|
||||
|
||||
// BuildHeaderTransport is an http.RoundTripper that force-writes the
|
||||
// X-Cli-Build header before every request. Used in the SDK transport chain,
|
||||
// where SecurityHeaderTransport is not installed, to prevent extensions from
|
||||
// tampering with the build classification. The direct HTTP chain is already
|
||||
// covered by SecurityHeaderTransport iterating BaseSecurityHeaders.
|
||||
// X-Cli-Build header before every request. It remains in the SDK transport
|
||||
// chain as a narrow defense-in-depth layer alongside SecurityHeaderTransport.
|
||||
type BuildHeaderTransport struct {
|
||||
Base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *BuildHeaderTransport) BaseRoundTripper() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *BuildHeaderTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(HeaderBuild, DetectBuildKind())
|
||||
@@ -103,6 +142,16 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *SecurityHeaderTransport) BaseRoundTripper() http.RoundTripper {
|
||||
return t.base()
|
||||
}
|
||||
|
||||
func (t *SecurityHeaderTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
@@ -120,67 +169,3 @@ func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response,
|
||||
}
|
||||
return t.base().RoundTrip(req)
|
||||
}
|
||||
|
||||
// extensionMiddleware wraps the built-in transport chain with pre/post hooks.
|
||||
// The built-in chain always executes unless the extension is an
|
||||
// exttransport.AbortableInterceptor and its PreRoundTripE returns a non-nil
|
||||
// error; it cannot otherwise be skipped or overridden.
|
||||
//
|
||||
// The original request context is restored after the pre hook to prevent
|
||||
// extensions from tampering with cancellation, deadlines, or built-in values.
|
||||
// Cloning the request isolates header/URL/etc. mutations from the caller's
|
||||
// request object; req.Body is intentionally shared — extensions that consume
|
||||
// it are responsible for rewinding (see Interceptor doc).
|
||||
type extensionMiddleware struct {
|
||||
Base http.RoundTripper
|
||||
Ext exttransport.Interceptor
|
||||
ExtName string // Provider.Name(), captured at wrap time for *AbortError.Extension
|
||||
}
|
||||
|
||||
// RoundTrip invokes the interceptor pre hook, restores the original context,
|
||||
// executes the built-in chain (unless aborted), then calls the post hook if
|
||||
// non-nil. When the extension implements AbortableInterceptor and returns a
|
||||
// non-nil error from PreRoundTripE, the built-in chain is skipped and an
|
||||
// *exttransport.AbortError is returned; the post hook is still invoked with
|
||||
// (nil, reason) so extensions can unwind resources.
|
||||
func (m *extensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
origCtx := req.Context()
|
||||
req = req.Clone(origCtx)
|
||||
|
||||
var (
|
||||
post func(*http.Response, error)
|
||||
abortEr error
|
||||
)
|
||||
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
|
||||
post, abortEr = a.PreRoundTripE(req)
|
||||
} else {
|
||||
post = m.Ext.PreRoundTrip(req)
|
||||
}
|
||||
if abortEr != nil {
|
||||
if post != nil {
|
||||
post(nil, abortEr)
|
||||
}
|
||||
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortEr}
|
||||
}
|
||||
|
||||
req = req.WithContext(origCtx) // restore original context
|
||||
resp, err := m.Base.RoundTrip(req)
|
||||
if post != nil {
|
||||
post(resp, err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// wrapWithExtension wraps transport with the registered extension middleware.
|
||||
// If no extension is registered, returns transport unchanged.
|
||||
func wrapWithExtension(transport http.RoundTripper) http.RoundTripper {
|
||||
p := exttransport.GetProvider()
|
||||
if p == nil {
|
||||
return transport
|
||||
}
|
||||
tr := p.ResolveInterceptor(context.Background())
|
||||
if tr == nil {
|
||||
return transport
|
||||
}
|
||||
return &extensionMiddleware{Base: transport, Ext: tr, ExtName: p.Name()}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
@@ -90,79 +91,107 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildSDKTransport chain composition
|
||||
// buildSDKTransport policy behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := buildSDKTransport()
|
||||
func TestBuildSDKTransportAppliesSecurityHeadersToEveryRequestClass(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
received := make(chan http.Header, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Clone()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
}
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
for _, class := range []exttransport.RequestClass{
|
||||
exttransport.RequestClassPlatform,
|
||||
exttransport.RequestClassExternal,
|
||||
} {
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransport(nil)},
|
||||
class,
|
||||
)
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
headers := <-received
|
||||
for header, values := range BaseSecurityHeaders() {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
want := values[len(values)-1]
|
||||
if got := headers.Get(header); got != want {
|
||||
t.Fatalf("SDK %s header %s = %q, want %q", class, header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
previous := exttransport.GetProvider()
|
||||
interceptor := &headerCapturingInterceptor{}
|
||||
exttransport.Register(&platformOnlyStubProvider{
|
||||
stubTransportProvider: &stubTransportProvider{interceptor: interceptor},
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := buildSDKTransport()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
mid, ok := transport.(*extensionMiddleware)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransport(nil)},
|
||||
exttransport.RequestClassPlatform,
|
||||
)
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
|
||||
}
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
}
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
|
||||
resp.Body.Close()
|
||||
if !interceptor.preCalled || !interceptor.postCalled {
|
||||
t.Fatal("SDK platform request did not execute extension pre/post hooks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := buildSDKTransport()
|
||||
if _, ok := buildSDKTransport(nil).(*internaltransport.HTTPPolicyRouter); !ok {
|
||||
t.Fatalf(
|
||||
"buildSDKTransport() type = %T, want *transport.HTTPPolicyRouter",
|
||||
buildSDKTransport(nil),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
func TestBuildSDKTransportSupportsPolicyLeafCloning(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
base := &http.Transport{}
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransportWithBase(base, nil)},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
source, ok := client.Transport.(interface {
|
||||
CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
t.Fatalf("SDK request-class transport type = %T, want clone capability", client.Transport)
|
||||
}
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
rebuilt, concrete, ok := source.CloneHTTPTransport()
|
||||
if !ok || rebuilt == nil || concrete == nil {
|
||||
t.Fatal("SDK policy graph could not clone its HTTP transport leaf")
|
||||
}
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
if concrete == base {
|
||||
t.Fatal("SDK policy graph reused the original HTTP transport")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +251,7 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &SecurityHeaderTransport{Base: base}
|
||||
transport := wrapWithExtension(base)
|
||||
transport := internaltransport.WrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
@@ -250,26 +279,132 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// buildTamperingInterceptor tries to delete and spoof X-Cli-Build via
|
||||
// PreRoundTrip. The SDK chain's BuildHeaderTransport must restore the real
|
||||
// value before the request leaves the process.
|
||||
// buildTamperingInterceptor tries to delete and spoof security headers via
|
||||
// PreRoundTrip. The SDK built-in chain must restore the real values before the
|
||||
// request leaves the process.
|
||||
type buildTamperingInterceptor struct{}
|
||||
|
||||
func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Del(HeaderBuild)
|
||||
req.Header.Set(HeaderBuild, "ext-tampered-build")
|
||||
req.Header.Del(HeaderSource)
|
||||
req.Header.Set(HeaderSource, "ext-tampered-source")
|
||||
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
|
||||
}
|
||||
|
||||
type bootstrapPolicyTamperingInterceptor struct{}
|
||||
|
||||
func (bootstrapPolicyTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Set(HeaderSource, "extension-value")
|
||||
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewDefaultInstallsSDKBootstrapSecurityPolicy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
oldTransport := http.DefaultClient.Transport
|
||||
oldCheckRedirect := http.DefaultClient.CheckRedirect
|
||||
t.Cleanup(func() {
|
||||
http.DefaultClient.Transport = oldTransport
|
||||
http.DefaultClient.CheckRedirect = oldCheckRedirect
|
||||
})
|
||||
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&platformOnlyStubProvider{
|
||||
stubTransportProvider: &stubTransportProvider{
|
||||
interceptor: bootstrapPolicyTamperingInterceptor{},
|
||||
},
|
||||
})
|
||||
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.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
http.DefaultClient.Transport = network
|
||||
http.DefaultClient.CheckRedirect = nil
|
||||
_ = NewDefault(nil, InvocationContext{})
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://open.feishu.cn/callback/ws/endpoint",
|
||||
strings.NewReader(`{"app_secret":"secret"}`),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got := received.Get(HeaderSource); got != SourceValue {
|
||||
t.Fatalf("%s = %q, want trusted value %q", HeaderSource, got, SourceValue)
|
||||
}
|
||||
if got := received.Get(riskcontrol.HeaderOSType); got != "" {
|
||||
t.Fatalf("%s = %q, want extension value stripped", riskcontrol.HeaderOSType, got)
|
||||
}
|
||||
if got := received.Get(HeaderBuild); got != DetectBuildKind() {
|
||||
t.Fatalf("%s = %q, want %q", HeaderBuild, got, DetectBuildKind())
|
||||
}
|
||||
if got := received.Get(HeaderUserAgent); got != UserAgentValue() {
|
||||
t.Fatalf("%s = %q, want %q", HeaderUserAgent, got, UserAgentValue())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_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")
|
||||
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransportWithBase(network, nil)},
|
||||
exttransport.RequestClassPlatform,
|
||||
)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
|
||||
t.Fatalf("extension risk headers reached network: %v", received)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
|
||||
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
|
||||
// transport chain, even when an extension tries to delete or spoof it. This
|
||||
// closes the gap where the SDK chain had no equivalent of
|
||||
// SecurityHeaderTransport (see design doc §3.3.3).
|
||||
// SDK chain restores both the build classification and the full security
|
||||
// header set after an extension runs.
|
||||
func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
var receivedBuild string
|
||||
var receivedBuild, receivedSource string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedBuild = r.Header.Get(HeaderBuild)
|
||||
receivedSource = r.Header.Get(HeaderSource)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
@@ -277,12 +412,13 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
// Replicate the SDK chain layering used by buildSDKTransport.
|
||||
// Replicate the SDK built-in chain inside buildSDKTransport.
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &UserAgentTransport{Base: base}
|
||||
base = &BuildHeaderTransport{Base: base}
|
||||
transport := wrapWithExtension(base)
|
||||
base = &SecurityHeaderTransport{Base: base}
|
||||
transport := internaltransport.WrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
@@ -299,6 +435,9 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
if receivedBuild != want {
|
||||
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
|
||||
}
|
||||
if receivedSource != SourceValue {
|
||||
t.Fatalf("%s = %q, want %q", HeaderSource, receivedSource, SourceValue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_OverridesEvenWithoutTamper verifies that even if
|
||||
@@ -388,7 +527,7 @@ func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: capturer, Ext: tamperIC}
|
||||
|
||||
origCtx := context.WithValue(context.Background(), testKey, "original")
|
||||
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
|
||||
@@ -450,7 +589,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
|
||||
@@ -491,7 +630,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
_, err := mid.RoundTrip(req)
|
||||
|
||||
@@ -510,7 +649,7 @@ func TestExtensionMiddleware_PreRoundTripEHappyPath(t *testing.T) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -60,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
|
||||
// MultiAppConfig is the multi-app config file format.
|
||||
type MultiAppConfig struct {
|
||||
StrictMode StrictMode `json:"strictMode,omitempty"`
|
||||
RiskControl *bool `json:"riskControl,omitempty"`
|
||||
CurrentApp string `json:"currentApp,omitempty"`
|
||||
PreviousApp string `json:"previousApp,omitempty"`
|
||||
Apps []AppConfig `json:"apps"`
|
||||
}
|
||||
|
||||
// RiskControlEnabled resolves the workspace policy. An omitted preference
|
||||
// keeps the default-on account-protection behavior.
|
||||
func (m *MultiAppConfig) RiskControlEnabled() bool {
|
||||
return m != nil && (m.RiskControl == nil || *m.RiskControl)
|
||||
}
|
||||
|
||||
// CurrentAppConfig returns the currently active app config.
|
||||
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
|
||||
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {
|
||||
|
||||
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) {
|
||||
disabled := false
|
||||
config := &MultiAppConfig{
|
||||
RiskControl: &disabled,
|
||||
Apps: []AppConfig{{
|
||||
AppId: "cli_test", AppSecret: PlainSecret("s"),
|
||||
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
|
||||
@@ -84,6 +86,9 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
|
||||
if got.Apps[0].Brand != BrandLark {
|
||||
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
|
||||
}
|
||||
if got.RiskControl == nil || *got.RiskControl {
|
||||
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package core
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LarkBrand represents the Lark platform brand.
|
||||
// "feishu" targets China-mainland, "lark" targets international.
|
||||
@@ -63,3 +66,39 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
func ResolveOpenBaseURL(brand LarkBrand) string {
|
||||
return ResolveEndpoints(brand).Open
|
||||
}
|
||||
|
||||
var platformEndpointHosts = func() map[string]struct{} {
|
||||
hosts := make(map[string]struct{})
|
||||
for _, brand := range []LarkBrand{BrandFeishu, BrandLark} {
|
||||
endpoints := ResolveEndpoints(brand)
|
||||
for _, rawURL := range []string{endpoints.Open, endpoints.Accounts, endpoints.MCP, endpoints.AppLink} {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err == nil && parsed.Hostname() != "" {
|
||||
hosts[strings.ToLower(parsed.Hostname())] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return hosts
|
||||
}()
|
||||
|
||||
// IsPlatformEndpointHost reports whether hostname exactly matches one of the
|
||||
// endpoint hosts produced by ResolveEndpoints. It intentionally does not use a
|
||||
// suffix match: lookalike external domains must never enter the platform
|
||||
// transport extension.
|
||||
func IsPlatformEndpointHost(hostname string) bool {
|
||||
_, ok := platformEndpointHosts[strings.ToLower(hostname)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsPlatformEndpointURL reports whether candidate uses a secure origin for a
|
||||
// configured platform endpoint. Non-TLS and non-standard-port lookalikes are
|
||||
// excluded even when their hostname matches.
|
||||
func IsPlatformEndpointURL(candidate *url.URL) bool {
|
||||
if candidate == nil || !strings.EqualFold(candidate.Scheme, "https") {
|
||||
return false
|
||||
}
|
||||
if port := candidate.Port(); port != "" && port != "443" {
|
||||
return false
|
||||
}
|
||||
return IsPlatformEndpointHost(candidate.Hostname())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveEndpoints_Feishu(t *testing.T) {
|
||||
ep := ResolveEndpoints(BrandFeishu)
|
||||
@@ -91,3 +95,85 @@ func TestResolveEndpoints_NormalizesBrand(t *testing.T) {
|
||||
t.Errorf("ResolveEndpoints(unexpected).Open = %q, want the feishu default", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPlatformEndpointHost_ExactMatchOnly(t *testing.T) {
|
||||
for _, host := range []string{
|
||||
"open.feishu.cn",
|
||||
"accounts.feishu.cn",
|
||||
"mcp.feishu.cn",
|
||||
"applink.feishu.cn",
|
||||
"open.larksuite.com",
|
||||
"accounts.larksuite.com",
|
||||
"mcp.larksuite.com",
|
||||
"applink.larksuite.com",
|
||||
} {
|
||||
if !IsPlatformEndpointHost(host) {
|
||||
t.Errorf("IsPlatformEndpointHost(%q) = false, want true", host)
|
||||
}
|
||||
}
|
||||
|
||||
for _, host := range []string{
|
||||
"example.com",
|
||||
"open.feishu.cn.example.com",
|
||||
"notopen.feishu.cn",
|
||||
"",
|
||||
} {
|
||||
if IsPlatformEndpointHost(host) {
|
||||
t.Errorf("IsPlatformEndpointHost(%q) = true, want false", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPlatformEndpointHost_CoversEveryResolvedEndpoint(t *testing.T) {
|
||||
for _, brand := range []LarkBrand{BrandFeishu, BrandLark} {
|
||||
endpoints := reflect.ValueOf(ResolveEndpoints(brand))
|
||||
for i := 0; i < endpoints.NumField(); i++ {
|
||||
rawURL := endpoints.Field(i).String()
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEndpoints(%q) field %d URL %q: %v", brand, i, rawURL, err)
|
||||
}
|
||||
if !IsPlatformEndpointHost(parsed.Hostname()) {
|
||||
t.Errorf("ResolveEndpoints(%q) field %d host %q is missing from the platform transport boundary", brand, i, parsed.Hostname())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPlatformEndpointURL_RequiresSecureStandardOrigin(t *testing.T) {
|
||||
if IsPlatformEndpointURL(nil) {
|
||||
t.Error("IsPlatformEndpointURL(nil) = true, want false")
|
||||
}
|
||||
uppercaseScheme := &url.URL{Scheme: "HTTPS", Host: "open.feishu.cn", Path: "/path"}
|
||||
if !IsPlatformEndpointURL(uppercaseScheme) {
|
||||
t.Error("IsPlatformEndpointURL() rejected uppercase HTTPS scheme")
|
||||
}
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"http://open.feishu.cn/path",
|
||||
"https://open.feishu.cn:8443/path",
|
||||
"https://open.feishu.cn.example.com/path",
|
||||
} {
|
||||
candidate, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if IsPlatformEndpointURL(candidate) {
|
||||
t.Errorf("IsPlatformEndpointURL(%q) = true, want false", rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"https://open.feishu.cn/path",
|
||||
"https://open.feishu.cn:443/path",
|
||||
"https://OPEN.FEISHU.CN/path",
|
||||
} {
|
||||
candidate, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsPlatformEndpointURL(candidate) {
|
||||
t.Errorf("IsPlatformEndpointURL(%q) = false, want true", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, agentName)
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, " "+agentName+" ")
|
||||
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.
|
||||
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.
|
||||
// Populated after RoundTrip matches this stub.
|
||||
CapturedHeaders http.Header
|
||||
@@ -137,6 +141,9 @@ func (r *Registry) Verify(t testing.TB) {
|
||||
if s.matched {
|
||||
continue
|
||||
}
|
||||
if s.Optional {
|
||||
continue
|
||||
}
|
||||
// Reusable stubs never set s.matched; treat any captured hit as a match.
|
||||
if s.Reusable && len(s.CapturedBodies) > 0 {
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
@@ -180,8 +180,8 @@ func saveCachedMerged(data []byte, cm CacheMeta) error {
|
||||
// localVersion is sent as data_version query param for server-side version comparison.
|
||||
// Returns (data, reg, err). A nil reg means the version is unchanged (not modified).
|
||||
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
|
||||
// Route through the shared proxy-plugin-aware transport so remote API
|
||||
// definition fetches honor proxy plugin mode instead of bypassing it.
|
||||
// Remote metadata is platform traffic and must honor both the shared proxy
|
||||
// configuration and the registered platform transport extension.
|
||||
client := transport.NewHTTPClient(fetchTimeout)
|
||||
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
|
||||
if err != nil {
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
162
internal/riskcontrol/transport.go
Normal file
162
internal/riskcontrol/transport.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
var _ internaltransport.RoundTripperDecorator = (*Transport)(nil)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// BaseRoundTripper exposes the network transport so policy routers can clone
|
||||
// and rebuild the complete decorator graph without dropping risk control.
|
||||
func (t *Transport) BaseRoundTripper() http.RoundTripper {
|
||||
if t == nil || t.next == nil {
|
||||
return internaltransport.Fallback()
|
||||
}
|
||||
return t.next
|
||||
}
|
||||
|
||||
// WithBaseRoundTripper returns an equivalent risk-control boundary over base.
|
||||
func (t *Transport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
if t == nil {
|
||||
return NewTransport(base, nil)
|
||||
}
|
||||
cloned := *t
|
||||
if base == nil {
|
||||
base = internaltransport.Fallback()
|
||||
}
|
||||
cloned.next = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package transport owns how the CLI assembles its outbound HTTP transport: the
|
||||
// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY
|
||||
// shared base RoundTripper (Shared/Fallback and the HTTP client constructors), the LARK_CLI_NO_PROXY
|
||||
// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode.
|
||||
//
|
||||
// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback
|
||||
|
||||
258
internal/transport/default_client.go
Normal file
258
internal/transport/default_client.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type requestMatcher func(*http.Request) bool
|
||||
type transportPolicyBuilder func(http.RoundTripper) http.RoundTripper
|
||||
|
||||
type sdkBootstrapRedirectContextKey struct{}
|
||||
|
||||
var (
|
||||
// larkws pins this client during package initialization.
|
||||
sdkBootstrapHTTPClient = http.DefaultClient
|
||||
installDefaultClientMu sync.Mutex
|
||||
)
|
||||
|
||||
// sdkBootstrapTransport applies the platform HTTP policy only to dependency
|
||||
// bootstrap requests selected by match. Unmatched DefaultClient traffic is
|
||||
// delegated directly to the previous transport.
|
||||
type sdkBootstrapTransport struct {
|
||||
base http.RoundTripper
|
||||
match requestMatcher
|
||||
buildPlatformPolicy transportPolicyBuilder
|
||||
|
||||
policyMu sync.RWMutex
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if !t.isBootstrapRequest(req) {
|
||||
return t.fallbackTransport().RoundTrip(req)
|
||||
}
|
||||
|
||||
base := t.base
|
||||
if base == nil {
|
||||
// Resolve Shared lazily so bridge installation never initializes
|
||||
// workspace-scoped proxy state ahead of workspace selection.
|
||||
base = Shared()
|
||||
}
|
||||
buildPlatformPolicy := t.platformPolicyBuilder()
|
||||
if buildPlatformPolicy == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"SDK bootstrap transport policy is not configured",
|
||||
)
|
||||
}
|
||||
base = buildPlatformPolicy(base)
|
||||
if base == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"SDK bootstrap transport policy returned a nil transport",
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve extensions per hop so redirects retain platform policy.
|
||||
extended := WrapWithExtensionForClass(base, exttransport.RequestClassPlatform)
|
||||
guarded := &sameOriginRedirectTransport{base: extended}
|
||||
return guarded.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) platformPolicyBuilder() transportPolicyBuilder {
|
||||
t.policyMu.RLock()
|
||||
defer t.policyMu.RUnlock()
|
||||
return t.buildPlatformPolicy
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) setPlatformPolicyBuilder(build transportPolicyBuilder) {
|
||||
t.policyMu.Lock()
|
||||
t.buildPlatformPolicy = build
|
||||
t.policyMu.Unlock()
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) isBootstrapRequest(req *http.Request) bool {
|
||||
if req == nil {
|
||||
return false
|
||||
}
|
||||
if _, redirected := req.Context().Value(sdkBootstrapRedirectContextKey{}).(struct{}); redirected {
|
||||
return true
|
||||
}
|
||||
return t.match != nil && t.match(req)
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) fallbackTransport() http.RoundTripper {
|
||||
if t.base != nil {
|
||||
return t.base
|
||||
}
|
||||
// Preserve net/http's dynamic nil-Transport fallback.
|
||||
return http.DefaultTransport
|
||||
}
|
||||
|
||||
// sameOriginRedirectTransport rejects redirects before net/http can replay a
|
||||
// bootstrap request to a different logical origin.
|
||||
type sameOriginRedirectTransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *sameOriginRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
if err != nil || resp == nil || !isFollowedRedirect(resp.StatusCode) {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
return resp, nil
|
||||
}
|
||||
target, parseErr := req.URL.Parse(location)
|
||||
if parseErr != nil {
|
||||
if resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"platform request returned an invalid redirect location: %v",
|
||||
parseErr,
|
||||
).WithCause(parseErr)
|
||||
}
|
||||
if sameOrigin(req.URL, target) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"platform bootstrap blocked cross-origin redirect from %q to %q",
|
||||
originName(req.URL),
|
||||
originName(target),
|
||||
)
|
||||
}
|
||||
|
||||
// sdkBootstrapRedirectPolicy preserves the prior hook and marks each redirect hop.
|
||||
func sdkBootstrapRedirectPolicy(
|
||||
match requestMatcher,
|
||||
previous func(*http.Request, []*http.Request) error,
|
||||
) func(*http.Request, []*http.Request) error {
|
||||
return func(req *http.Request, via []*http.Request) error {
|
||||
if previous != nil {
|
||||
if err := previous(req, via); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(via) >= 10 {
|
||||
// Retain net/http's default redirect limit.
|
||||
return errs.NewNetworkError(
|
||||
errs.SubtypeNetworkTransport,
|
||||
"stopped after 10 redirects",
|
||||
)
|
||||
}
|
||||
|
||||
if req == nil || len(via) == 0 || match == nil || !match(via[0]) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), sdkBootstrapRedirectContextKey{}, struct{}{})
|
||||
*req = *req.WithContext(ctx)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func originName(candidate *url.URL) string {
|
||||
if candidate == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(candidate.Scheme) + "://" + candidate.Host
|
||||
}
|
||||
|
||||
func sameOrigin(left, right *url.URL) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left.Scheme, right.Scheme) &&
|
||||
strings.EqualFold(left.Hostname(), right.Hostname()) &&
|
||||
originPort(left) == originPort(right)
|
||||
}
|
||||
|
||||
func originPort(candidate *url.URL) string {
|
||||
if port := candidate.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch strings.ToLower(candidate.Scheme) {
|
||||
case "http":
|
||||
return "80"
|
||||
case "https":
|
||||
return "443"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isFollowedRedirect(status int) bool {
|
||||
switch status {
|
||||
case http.StatusMovedPermanently,
|
||||
http.StatusFound,
|
||||
http.StatusSeeOther,
|
||||
http.StatusTemporaryRedirect,
|
||||
http.StatusPermanentRedirect:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// InstallSDKTransportBridge wraps larkws's captured HTTP bootstrap client. All
|
||||
// requests through that client hit the bridge, but only matched bootstrap
|
||||
// traffic uses platform policy. The SDK owns the subsequent WebSocket dial,
|
||||
// which does not use this net/http transport.
|
||||
func InstallSDKTransportBridge(buildPlatformPolicy func(http.RoundTripper) http.RoundTripper) {
|
||||
installDefaultClientMu.Lock()
|
||||
defer installDefaultClientMu.Unlock()
|
||||
installSDKTransportBridge(
|
||||
sdkBootstrapHTTPClient,
|
||||
isSDKWebSocketBootstrapRequest,
|
||||
buildPlatformPolicy,
|
||||
)
|
||||
}
|
||||
|
||||
func isSDKWebSocketBootstrapRequest(req *http.Request) bool {
|
||||
return req != nil &&
|
||||
req.Method == http.MethodPost &&
|
||||
core.IsPlatformEndpointURL(req.URL) &&
|
||||
req.URL.Path == larkws.GenEndpointUri
|
||||
}
|
||||
|
||||
func installSDKTransportBridge(
|
||||
client *http.Client,
|
||||
match requestMatcher,
|
||||
buildPlatformPolicy transportPolicyBuilder,
|
||||
) {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
if existing, ok := client.Transport.(*sdkBootstrapTransport); ok {
|
||||
existing.setPlatformPolicyBuilder(buildPlatformPolicy)
|
||||
return
|
||||
}
|
||||
base := client.Transport
|
||||
previousRedirect := client.CheckRedirect
|
||||
client.Transport = &sdkBootstrapTransport{
|
||||
base: base,
|
||||
match: match,
|
||||
buildPlatformPolicy: buildPlatformPolicy,
|
||||
}
|
||||
client.CheckRedirect = sdkBootstrapRedirectPolicy(match, previousRedirect)
|
||||
}
|
||||
120
internal/transport/extension.go
Normal file
120
internal/transport/extension.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil)
|
||||
|
||||
type resolvedExtension struct {
|
||||
provider exttransport.Provider
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func resolveExtension() *resolvedExtension {
|
||||
p := exttransport.GetProvider()
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
interceptor := p.ResolveInterceptor(context.Background())
|
||||
if interceptor == nil {
|
||||
return nil
|
||||
}
|
||||
return &resolvedExtension{provider: p, interceptor: interceptor}
|
||||
}
|
||||
|
||||
func (e *resolvedExtension) wrap(base http.RoundTripper, class exttransport.RequestClass, enforceScope bool) http.RoundTripper {
|
||||
if base == nil {
|
||||
base = Shared()
|
||||
}
|
||||
if e == nil {
|
||||
return base
|
||||
}
|
||||
if enforceScope {
|
||||
if scoped, ok := e.provider.(exttransport.ScopedProvider); ok && !scoped.SupportsRequestClass(class) {
|
||||
return base
|
||||
}
|
||||
}
|
||||
return &ExtensionMiddleware{Base: base, Ext: e.interceptor, ExtName: e.provider.Name()}
|
||||
}
|
||||
|
||||
// ExtensionMiddleware wraps the built-in transport chain with extension
|
||||
// pre/post hooks. The built-in chain always executes unless an
|
||||
// exttransport.AbortableInterceptor rejects the request.
|
||||
//
|
||||
// The original request context is restored after the pre hook to prevent an
|
||||
// extension from replacing cancellation, deadlines, or built-in values. The
|
||||
// request is cloned so URL and header mutations do not alter the caller's
|
||||
// request object. The body remains shared; interceptors that consume it must
|
||||
// restore it before returning.
|
||||
type ExtensionMiddleware struct {
|
||||
Base http.RoundTripper
|
||||
Ext exttransport.Interceptor
|
||||
ExtName string
|
||||
}
|
||||
|
||||
// BaseRoundTripper returns the wrapped built-in transport chain.
|
||||
func (m *ExtensionMiddleware) BaseRoundTripper() http.RoundTripper {
|
||||
if m.Base == nil {
|
||||
return Shared()
|
||||
}
|
||||
return m.Base
|
||||
}
|
||||
|
||||
// WithBaseRoundTripper clones the middleware over base.
|
||||
func (m *ExtensionMiddleware) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *m
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip invokes the extension pre hook, the wrapped transport, and then
|
||||
// the optional post hook. Abortable interceptors can stop the request before
|
||||
// the wrapped transport is called.
|
||||
func (m *ExtensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
origCtx := req.Context()
|
||||
req = req.Clone(origCtx)
|
||||
|
||||
var (
|
||||
post func(*http.Response, error)
|
||||
abortErr error
|
||||
)
|
||||
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
|
||||
post, abortErr = a.PreRoundTripE(req)
|
||||
} else {
|
||||
post = m.Ext.PreRoundTrip(req)
|
||||
}
|
||||
if abortErr != nil {
|
||||
if post != nil {
|
||||
post(nil, abortErr)
|
||||
}
|
||||
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortErr}
|
||||
}
|
||||
|
||||
req = req.WithContext(origCtx)
|
||||
resp, err := m.BaseRoundTripper().RoundTrip(req)
|
||||
if post != nil {
|
||||
post(resp, err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// WrapWithExtension wraps base with the currently registered transport
|
||||
// extension. With no registered provider or no resolved interceptor, base is
|
||||
// returned unchanged.
|
||||
func WrapWithExtension(base http.RoundTripper) http.RoundTripper {
|
||||
return resolveExtension().wrap(base, "", false)
|
||||
}
|
||||
|
||||
// WrapWithExtensionForClass wraps base only when the registered provider
|
||||
// supports class. Providers without the optional ScopedProvider interface keep
|
||||
// their historical all-request behavior.
|
||||
func WrapWithExtensionForClass(base http.RoundTripper, class exttransport.RequestClass) http.RoundTripper {
|
||||
return resolveExtension().wrap(base, class, true)
|
||||
}
|
||||
924
internal/transport/extension_test.go
Normal file
924
internal/transport/extension_test.go
Normal file
@@ -0,0 +1,924 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
type testProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
resolveCalls *int
|
||||
}
|
||||
|
||||
func (p testProvider) Name() string { return "test-provider" }
|
||||
|
||||
func (p testProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
if p.resolveCalls != nil {
|
||||
*p.resolveCalls++
|
||||
}
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
type scopedTestProvider struct {
|
||||
testProvider
|
||||
supported exttransport.RequestClass
|
||||
}
|
||||
|
||||
func (p scopedTestProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == p.supported
|
||||
}
|
||||
|
||||
type testHeaderInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *testHeaderInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-Test-Platform", "routed")
|
||||
return nil
|
||||
}
|
||||
|
||||
type abortingTestInterceptor struct {
|
||||
reason error
|
||||
post func(*http.Response, error)
|
||||
}
|
||||
|
||||
func (i *abortingTestInterceptor) PreRoundTrip(*http.Request) func(*http.Response, error) {
|
||||
panic("PreRoundTrip called for abortable interceptor")
|
||||
}
|
||||
|
||||
func (i *abortingTestInterceptor) PreRoundTripE(*http.Request) (func(*http.Response, error), error) {
|
||||
return i.post, i.reason
|
||||
}
|
||||
|
||||
func TestLegacyProviderKeepsAllRequestBehavior(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(testProvider{interceptor: interceptor})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
received := make(chan string, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Get("X-Test-Platform")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
for _, client := range []*http.Client{
|
||||
ClientForRequestClass(NewHTTPClient(0), exttransport.RequestClassPlatform),
|
||||
NewExternalHTTPClient(0),
|
||||
} {
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if got := <-received; got != "routed" {
|
||||
t.Fatalf("platform request header = %q, want routed", got)
|
||||
}
|
||||
if got := <-received; got != "routed" {
|
||||
t.Fatalf("external request header = %q, want routed for legacy provider", got)
|
||||
}
|
||||
if interceptor.calls != 2 {
|
||||
t.Fatalf("extension calls = %d, want exactly 2", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedProviderOnlyRunsForSupportedRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
received := make(chan string, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Get("X-Test-Platform")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
clients := []*http.Client{
|
||||
ClientForRequestClass(NewHTTPClient(0), exttransport.RequestClassPlatform),
|
||||
NewExternalHTTPClient(0),
|
||||
}
|
||||
for _, client := range clients {
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if got := <-received; got != "routed" {
|
||||
t.Fatalf("platform request header = %q, want routed", got)
|
||||
}
|
||||
if got := <-received; got != "" {
|
||||
t.Fatalf("external request received scoped provider header %q", got)
|
||||
}
|
||||
if interceptor.calls != 1 {
|
||||
t.Fatalf("extension calls = %d, want exactly 1", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterResolvesProviderOnce(t *testing.T) {
|
||||
resolveCalls := 0
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(testProvider{
|
||||
interceptor: &testHeaderInterceptor{},
|
||||
resolveCalls: &resolveCalls,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
_ = NewHTTPPolicyRouter(base, base)
|
||||
|
||||
if resolveCalls != 1 {
|
||||
t.Fatalf("ResolveInterceptor() calls = %d, want 1 per router", resolveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapBridgeBlocksCrossOriginRedirectAfterSameOriginHop(t *testing.T) {
|
||||
var externalCalls atomic.Int32
|
||||
var relayBody string
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host == "external.example" {
|
||||
externalCalls.Add(1)
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
switch req.URL.Path {
|
||||
case "/bootstrap":
|
||||
return redirectResponse(req, http.StatusTemporaryRedirect, "/relay"), nil
|
||||
case "/relay":
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relayBody = string(body)
|
||||
return redirectResponse(
|
||||
req,
|
||||
http.StatusPermanentRedirect,
|
||||
"https://external.example/target",
|
||||
), nil
|
||||
default:
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(client, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
}, identityTransportPolicy)
|
||||
|
||||
const secret = "app_secret=secret"
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader(secret),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||
t.Fatalf("Do() error = %v, want cross-origin redirect rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("Do() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if relayBody != secret {
|
||||
t.Fatalf("same-origin relay body = %q, want %q", relayBody, secret)
|
||||
}
|
||||
if got := externalCalls.Load(); got != 0 {
|
||||
t.Fatalf("cross-origin target calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectGuardClassifiesInvalidLocation(t *testing.T) {
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return redirectResponse(req, http.StatusFound, "%"), nil
|
||||
})
|
||||
client := &http.Client{Transport: &sameOriginRedirectTransport{base: base}}
|
||||
resp, err := client.Get("https://platform.example/bootstrap")
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid redirect location") {
|
||||
t.Fatalf("Do() error = %v, want invalid redirect rejection", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("Do() problem = %#v, %v; want internal/invalid_response", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
type redirectPolicyInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *redirectPolicyInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-Extension-Hop", strconv.Itoa(i.calls))
|
||||
req.Header.Set("X-Reserved", "extension")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSDKBootstrapBridgeRetainsPoliciesAcrossSameOriginRedirect(t *testing.T) {
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &redirectPolicyInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
var finalHeaders http.Header
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/bootstrap":
|
||||
return redirectResponse(req, http.StatusTemporaryRedirect, "/next"), nil
|
||||
case "/next":
|
||||
finalHeaders = req.Header.Clone()
|
||||
return noContentResponse(req), nil
|
||||
default:
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
})
|
||||
|
||||
builtInCalls := 0
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(
|
||||
client,
|
||||
func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
},
|
||||
func(base http.RoundTripper) http.RoundTripper {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
builtInCalls++
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("X-Builtin-Hop", strconv.Itoa(builtInCalls))
|
||||
req.Header.Set("X-Reserved", "trusted")
|
||||
return base.RoundTrip(req)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader("body"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if finalHeaders == nil {
|
||||
t.Fatal("same-origin redirect target was not called")
|
||||
}
|
||||
if interceptor.calls != 2 {
|
||||
t.Fatalf("extension calls = %d, want 2", interceptor.calls)
|
||||
}
|
||||
if builtInCalls != 2 {
|
||||
t.Fatalf("built-in policy calls = %d, want 2", builtInCalls)
|
||||
}
|
||||
if got := finalHeaders.Get("X-Extension-Hop"); got != "2" {
|
||||
t.Fatalf("final X-Extension-Hop = %q, want 2", got)
|
||||
}
|
||||
if got := finalHeaders.Get("X-Builtin-Hop"); got != "2" {
|
||||
t.Fatalf("final X-Builtin-Hop = %q, want 2", got)
|
||||
}
|
||||
if got := finalHeaders.Get("X-Reserved"); got != "trusted" {
|
||||
t.Fatalf("final X-Reserved = %q, want trusted built-in value", got)
|
||||
}
|
||||
}
|
||||
|
||||
type redirectRewriteInterceptor struct {
|
||||
target *url.URL
|
||||
postLocation string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *redirectRewriteInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.URL.Scheme = i.target.Scheme
|
||||
req.URL.Host = i.target.Host
|
||||
if i.postLocation == "" {
|
||||
return nil
|
||||
}
|
||||
return func(resp *http.Response, err error) {
|
||||
if err == nil && resp != nil && isFollowedRedirect(resp.StatusCode) {
|
||||
resp.Header.Set("Location", i.postLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectGuardUsesLogicalURLAfterExtensionRewrite(t *testing.T) {
|
||||
sidecarURL, err := url.Parse("https://sidecar.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sidecarCalls := 0
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host != sidecarURL.Host {
|
||||
t.Fatalf("network host = %q, want extension target %q", req.URL.Host, sidecarURL.Host)
|
||||
}
|
||||
sidecarCalls++
|
||||
switch req.URL.Path {
|
||||
case "/bootstrap":
|
||||
return redirectResponse(
|
||||
req,
|
||||
http.StatusTemporaryRedirect,
|
||||
"https://platform.example/next",
|
||||
), nil
|
||||
case "/next":
|
||||
return noContentResponse(req), nil
|
||||
default:
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
})
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &redirectRewriteInterceptor{target: sidecarURL}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(client, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
}, identityTransportPolicy)
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader("body"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if sidecarCalls != 2 {
|
||||
t.Fatalf("sidecar calls = %d, want 2", sidecarCalls)
|
||||
}
|
||||
if interceptor.calls != 2 {
|
||||
t.Fatalf("extension calls = %d, want 2", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectGuardChecksLocationAfterExtensionPostHook(t *testing.T) {
|
||||
var externalCalls atomic.Int32
|
||||
sidecarURL, err := url.Parse("https://sidecar.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host == "external.example" {
|
||||
externalCalls.Add(1)
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
return redirectResponse(
|
||||
req,
|
||||
http.StatusTemporaryRedirect,
|
||||
"https://platform.example/next",
|
||||
), nil
|
||||
})
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: &redirectRewriteInterceptor{
|
||||
target: sidecarURL,
|
||||
postLocation: "https://external.example/target",
|
||||
}},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(client, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
}, identityTransportPolicy)
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader("secret"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||
t.Fatalf("Do() error = %v, want post-hook Location rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("Do() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if got := externalCalls.Load(); got != 0 {
|
||||
t.Fatalf("post-hook redirect target calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameOriginNormalizesDefaultPort(t *testing.T) {
|
||||
left, err := url.Parse("https://platform.example/bootstrap")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
right, err := url.Parse("https://platform.example:443/next")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameOrigin(left, right) {
|
||||
t.Fatal("sameOrigin() = false for equivalent default HTTPS ports")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultClientBridgeCoversWebSocketSDKBootstrap(t *testing.T) {
|
||||
preserveHTTPClientState(t, sdkBootstrapHTTPClient)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
seenHeader := make(chan string, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
seenHeader <- req.Header.Get("X-Test-Platform")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, `{"code":400,"msg":"stop after bootstrap"}`)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
installSDKTransportBridge(sdkBootstrapHTTPClient, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Host == strings.TrimPrefix(server.URL, "http://")
|
||||
}, identityTransportPolicy)
|
||||
|
||||
client := larkws.NewClient(
|
||||
"test-app",
|
||||
"test-secret",
|
||||
larkws.WithDomain(server.URL),
|
||||
larkws.WithAutoReconnect(false),
|
||||
)
|
||||
if err := client.Start(context.Background()); err == nil {
|
||||
t.Fatal("WebSocket SDK Start() error = nil, want bootstrap failure")
|
||||
}
|
||||
if got := <-seenHeader; got != "routed" {
|
||||
t.Fatalf("WebSocket bootstrap header = %q, want routed", got)
|
||||
}
|
||||
if interceptor.calls != 1 {
|
||||
t.Fatalf("extension calls = %d, want exactly 1 bootstrap call", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeUsesPinnedClientAfterGlobalReplacement(t *testing.T) {
|
||||
preserveHTTPClientState(t, sdkBootstrapHTTPClient)
|
||||
oldDefaultClient := http.DefaultClient
|
||||
t.Cleanup(func() { http.DefaultClient = oldDefaultClient })
|
||||
|
||||
var pinnedCalls atomic.Int32
|
||||
pinnedHeader := make(chan string, 1)
|
||||
sdkBootstrapHTTPClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
pinnedCalls.Add(1)
|
||||
pinnedHeader <- req.Header.Get("X-Pinned-Bridge")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":400,"msg":"stop"}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
sdkBootstrapHTTPClient.CheckRedirect = nil
|
||||
|
||||
var replacementCalls atomic.Int32
|
||||
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
replacementCalls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
InstallSDKTransportBridge(func(base http.RoundTripper) http.RoundTripper {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("X-Pinned-Bridge", "routed")
|
||||
return base.RoundTrip(req)
|
||||
})
|
||||
})
|
||||
|
||||
client := larkws.NewClient(
|
||||
"test-app",
|
||||
"test-secret",
|
||||
larkws.WithAutoReconnect(false),
|
||||
)
|
||||
if err := client.Start(context.Background()); err == nil {
|
||||
t.Fatal("WebSocket SDK Start() error = nil, want bootstrap failure")
|
||||
}
|
||||
if got := pinnedCalls.Load(); got != 1 {
|
||||
t.Fatalf("SDK-pinned client calls = %d, want 1", got)
|
||||
}
|
||||
if got := <-pinnedHeader; got != "routed" {
|
||||
t.Fatalf("SDK-pinned bridge header = %q, want routed", got)
|
||||
}
|
||||
if got := replacementCalls.Load(); got != 0 {
|
||||
t.Fatalf("replacement DefaultClient calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKWebSocketBootstrapMatcherIsNarrow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "platform bootstrap",
|
||||
method: http.MethodPost,
|
||||
url: "https://open.feishu.cn/callback/ws/endpoint",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "other platform path",
|
||||
method: http.MethodPost,
|
||||
url: "https://open.feishu.cn/open-apis/test",
|
||||
},
|
||||
{
|
||||
name: "wrong bootstrap method",
|
||||
method: http.MethodGet,
|
||||
url: "https://open.feishu.cn/callback/ws/endpoint",
|
||||
},
|
||||
{
|
||||
name: "external lookalike",
|
||||
method: http.MethodPost,
|
||||
url: "https://external.example/callback/ws/endpoint",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(tt.method, tt.url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := isSDKWebSocketBootstrapRequest(req); got != tt.want {
|
||||
t.Fatalf("isSDKWebSocketBootstrapRequest() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeLeavesOtherPlatformPathsUntouched(t *testing.T) {
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
baseCalls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
baseCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})}
|
||||
installSDKTransportBridge(client, isSDKWebSocketBootstrapRequest, nil)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if baseCalls != 1 {
|
||||
t.Fatalf("base calls = %d, want 1", baseCalls)
|
||||
}
|
||||
if interceptor.calls != 0 {
|
||||
t.Fatalf("extension calls = %d, want 0 for unmatched DefaultClient traffic", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeNilBasePreservesDefaultTransportForUnmatchedRequest(t *testing.T) {
|
||||
oldDefaultTransport := http.DefaultTransport
|
||||
t.Cleanup(func() { http.DefaultTransport = oldDefaultTransport })
|
||||
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
var firstCalls atomic.Int32
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
firstCalls.Add(1)
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
client := &http.Client{}
|
||||
installSDKTransportBridge(client, func(*http.Request) bool { return false }, nil)
|
||||
|
||||
var currentCalls atomic.Int32
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
currentCalls.Add(1)
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:1/unmatched", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got := firstCalls.Load(); got != 0 {
|
||||
t.Fatalf("install-time DefaultTransport calls = %d, want 0", got)
|
||||
}
|
||||
if got := currentCalls.Load(); got != 1 {
|
||||
t.Fatalf("request-time DefaultTransport calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeUpdatesPlatformPolicy(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return noContentResponse(req), nil
|
||||
})}
|
||||
var firstCalls, secondCalls int
|
||||
build := func(calls *int) transportPolicyBuilder {
|
||||
return func(base http.RoundTripper) http.RoundTripper {
|
||||
*calls++
|
||||
return base
|
||||
}
|
||||
}
|
||||
match := func(*http.Request) bool { return true }
|
||||
installSDKTransportBridge(client, match, build(&firstCalls))
|
||||
installSDKTransportBridge(client, match, build(&secondCalls))
|
||||
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if firstCalls != 0 || secondCalls != 1 {
|
||||
t.Fatalf("policy calls = (%d, %d), want (0, 1)", firstCalls, secondCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapTransportFailsClosedWithoutPlatformPolicy(t *testing.T) {
|
||||
var baseCalls atomic.Int32
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
baseCalls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
installSDKTransportBridge(client, func(*http.Request) bool { return true }, nil)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "policy is not configured") {
|
||||
t.Fatalf("Do() error = %v, want missing policy rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryInternal ||
|
||||
problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("Do() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if got := baseCalls.Load(); got != 0 {
|
||||
t.Fatalf("base transport calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapTransportFailsClosedForNilPlatformTransport(t *testing.T) {
|
||||
var baseCalls atomic.Int32
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
baseCalls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
installSDKTransportBridge(client, func(*http.Request) bool { return true }, func(http.RoundTripper) http.RoundTripper {
|
||||
return nil
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "nil transport") {
|
||||
t.Fatalf("Do() error = %v, want nil policy transport rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryInternal ||
|
||||
problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("Do() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if got := baseCalls.Load(); got != 0 {
|
||||
t.Fatalf("base transport calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectPolicyRetainsDefaultLimit(t *testing.T) {
|
||||
policy := sdkBootstrapRedirectPolicy(nil, nil)
|
||||
via := make([]*http.Request, 10)
|
||||
err := policy(&http.Request{}, via)
|
||||
if err == nil {
|
||||
t.Fatal("redirect policy error = nil after 10 redirects")
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryNetwork ||
|
||||
problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("redirect problem = %#v, %v; want network/transport", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionMiddlewareUsesFallbackWhenBaseIsNil(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
previous := http.DefaultTransport
|
||||
var calls atomic.Int32
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
t.Cleanup(func() { http.DefaultTransport = previous })
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := (&ExtensionMiddleware{Ext: &testHeaderInterceptor{}}).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("fallback transport calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionMiddlewareAbortsBeforeBase(t *testing.T) {
|
||||
reason := errors.New("blocked")
|
||||
baseCalled := false
|
||||
postCalled := false
|
||||
interceptor := &abortingTestInterceptor{
|
||||
reason: reason,
|
||||
post: func(resp *http.Response, err error) {
|
||||
postCalled = true
|
||||
if resp != nil || err != reason {
|
||||
t.Errorf("post arguments = (%v, %v), want (nil, reason)", resp, err)
|
||||
}
|
||||
},
|
||||
}
|
||||
middleware := &ExtensionMiddleware{
|
||||
Base: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
baseCalled = true
|
||||
return nil, nil
|
||||
}),
|
||||
Ext: interceptor,
|
||||
ExtName: "test-provider",
|
||||
}
|
||||
|
||||
resp, err := middleware.RoundTrip(httptest.NewRequest(http.MethodGet, "https://example.com", nil))
|
||||
if resp != nil {
|
||||
t.Fatalf("response = %v, want nil", resp)
|
||||
}
|
||||
var abortErr *exttransport.AbortError
|
||||
if !errors.As(err, &abortErr) {
|
||||
t.Fatalf("error = %T, want *transport.AbortError", err)
|
||||
}
|
||||
if abortErr.Extension != "test-provider" || abortErr.Reason != reason {
|
||||
t.Fatalf("abort error = %#v, want provider and reason", abortErr)
|
||||
}
|
||||
if baseCalled {
|
||||
t.Fatal("base transport was called")
|
||||
}
|
||||
if !postCalled {
|
||||
t.Fatal("post hook was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func preserveHTTPClientState(t *testing.T, client *http.Client) {
|
||||
t.Helper()
|
||||
oldTransport := client.Transport
|
||||
oldCheckRedirect := client.CheckRedirect
|
||||
t.Cleanup(func() {
|
||||
client.Transport = oldTransport
|
||||
client.CheckRedirect = oldCheckRedirect
|
||||
})
|
||||
}
|
||||
|
||||
func identityTransportPolicy(base http.RoundTripper) http.RoundTripper {
|
||||
return base
|
||||
}
|
||||
|
||||
func redirectResponse(req *http.Request, status int, location string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Location": []string{location}},
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
}
|
||||
|
||||
func noContentResponse(req *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
232
internal/transport/policy_router.go
Normal file
232
internal/transport/policy_router.go
Normal file
@@ -0,0 +1,232 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type requestClassContextKey struct{}
|
||||
type forcedRequestClassContextKey struct{}
|
||||
|
||||
// HTTPPolicyRouter selects an HTTP transport policy from request intent and
|
||||
// the endpoint catalog. Explicit request intent takes precedence; otherwise
|
||||
// known platform endpoints use the platform policy and all other URLs use the
|
||||
// external policy.
|
||||
type HTTPPolicyRouter struct {
|
||||
platform http.RoundTripper
|
||||
external http.RoundTripper
|
||||
}
|
||||
|
||||
// RoundTripperDecorator describes a transport layer that can be rebuilt over
|
||||
// a cloned base transport. Connection-policy helpers use this contract to
|
||||
// preserve retry, response, and extension layers while safely customizing the
|
||||
// innermost *http.Transport.
|
||||
type RoundTripperDecorator interface {
|
||||
BaseRoundTripper() http.RoundTripper
|
||||
WithBaseRoundTripper(http.RoundTripper) http.RoundTripper
|
||||
}
|
||||
|
||||
// NewHTTPPolicyRouter constructs a router over two policy chains. A nil chain
|
||||
// falls back to the shared proxy-aware transport. The currently registered
|
||||
// extension provider is resolved once and applied according to its optional
|
||||
// ScopedProvider contract.
|
||||
func NewHTTPPolicyRouter(platform, external http.RoundTripper) *HTTPPolicyRouter {
|
||||
if platform == nil {
|
||||
platform = Shared()
|
||||
}
|
||||
if external == nil {
|
||||
external = Shared()
|
||||
}
|
||||
|
||||
extension := resolveExtension()
|
||||
return &HTTPPolicyRouter{
|
||||
platform: extension.wrap(platform, exttransport.RequestClassPlatform, true),
|
||||
external: extension.wrap(external, exttransport.RequestClassExternal, true),
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip dispatches the request to its selected policy chain.
|
||||
func (r *HTTPPolicyRouter) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"HTTP policy router received a nil request",
|
||||
)
|
||||
}
|
||||
class, err := classifyRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if class == exttransport.RequestClassPlatform {
|
||||
return r.platform.RoundTrip(req)
|
||||
}
|
||||
return r.external.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (r *HTTPPolicyRouter) transportForClass(class exttransport.RequestClass) (http.RoundTripper, bool) {
|
||||
switch class {
|
||||
case exttransport.RequestClassPlatform:
|
||||
return r.platform, true
|
||||
case exttransport.RequestClassExternal:
|
||||
return r.external, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func classifyRequest(req *http.Request) (exttransport.RequestClass, error) {
|
||||
if explicit, ok := req.Context().Value(requestClassContextKey{}).(exttransport.RequestClass); ok {
|
||||
switch explicit {
|
||||
case exttransport.RequestClassPlatform, exttransport.RequestClassExternal:
|
||||
return explicit, nil
|
||||
default:
|
||||
return "", errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"unsupported HTTP request class %q",
|
||||
explicit,
|
||||
)
|
||||
}
|
||||
}
|
||||
if core.IsPlatformEndpointURL(req.URL) {
|
||||
return exttransport.RequestClassPlatform, nil
|
||||
}
|
||||
return exttransport.RequestClassExternal, nil
|
||||
}
|
||||
|
||||
// WithRequestClass returns a shallow copy of req with explicit routing intent.
|
||||
func WithRequestClass(req *http.Request, class exttransport.RequestClass) *http.Request {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), requestClassContextKey{}, class)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func withForcedRequestClass(req *http.Request, class exttransport.RequestClass) *http.Request {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
if _, forced := req.Context().Value(forcedRequestClassContextKey{}).(struct{}); forced {
|
||||
return req
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), requestClassContextKey{}, class)
|
||||
ctx = context.WithValue(ctx, forcedRequestClassContextKey{}, struct{}{})
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
type requestClassTransport struct {
|
||||
base http.RoundTripper
|
||||
class exttransport.RequestClass
|
||||
}
|
||||
|
||||
func (t *requestClassTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.base.RoundTrip(withForcedRequestClass(req, t.class))
|
||||
}
|
||||
|
||||
// CloneHTTPTransport exposes a structural cloning capability without requiring
|
||||
// higher-level safety helpers to import this package. The explicit request
|
||||
// class selects the policy branch that must be rebuilt.
|
||||
func (t *requestClassTransport) CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool) {
|
||||
return CloneHTTPTransportForRequestClass(t.base, t.class)
|
||||
}
|
||||
|
||||
// TransformHTTPTransport clones the selected policy branch and replaces its
|
||||
// concrete transport in place. Keeping the replacement at the graph leaf is
|
||||
// important for policies that must observe requests after outer decorators
|
||||
// have run, such as proxy selection.
|
||||
func (t *requestClassTransport) TransformHTTPTransport(transform func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool) {
|
||||
return transformHTTPTransportForRequestClass(t.base, t.class, transform, 0)
|
||||
}
|
||||
|
||||
// ClientForRequestClass clones client and forces all of its requests through a
|
||||
// specific policy class. The original client is never mutated.
|
||||
func ClientForRequestClass(client *http.Client, class exttransport.RequestClass) *http.Client {
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
cloned := *client
|
||||
base := client.Transport
|
||||
if base == nil {
|
||||
base = Shared()
|
||||
}
|
||||
cloned.Transport = &requestClassTransport{base: base, class: class}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// CloneHTTPTransportForRequestClass selects one policy branch, clones its
|
||||
// innermost *http.Transport, and rebuilds every composable decorator around
|
||||
// the clone. Callers can customize concrete before using rebuilt. The original
|
||||
// transport graph is never mutated.
|
||||
func CloneHTTPTransportForRequestClass(base http.RoundTripper, class exttransport.RequestClass) (rebuilt http.RoundTripper, concrete *http.Transport, ok bool) {
|
||||
rebuilt, ok = transformHTTPTransportForRequestClass(base, class, func(cloned *http.Transport) (http.RoundTripper, bool) {
|
||||
concrete = cloned
|
||||
return cloned, true
|
||||
}, 0)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
return rebuilt, concrete, true
|
||||
}
|
||||
|
||||
func transformHTTPTransportForRequestClass(
|
||||
base http.RoundTripper,
|
||||
class exttransport.RequestClass,
|
||||
transform func(*http.Transport) (http.RoundTripper, bool),
|
||||
depth int,
|
||||
) (http.RoundTripper, bool) {
|
||||
if depth > 32 {
|
||||
return nil, false
|
||||
}
|
||||
if base == nil || transform == nil {
|
||||
if transform == nil {
|
||||
return nil, false
|
||||
}
|
||||
base = Shared()
|
||||
}
|
||||
|
||||
switch current := base.(type) {
|
||||
case *http.Transport:
|
||||
cloned := cloneHTTPTransport(current)
|
||||
rebuilt, valid := transform(cloned)
|
||||
return rebuilt, valid && rebuilt != nil
|
||||
case *requestClassTransport:
|
||||
return transformHTTPTransportForRequestClass(current.base, class, transform, depth+1)
|
||||
case *HTTPPolicyRouter:
|
||||
selected, valid := current.transportForClass(class)
|
||||
if !valid {
|
||||
return nil, false
|
||||
}
|
||||
return transformHTTPTransportForRequestClass(selected, class, transform, depth+1)
|
||||
case RoundTripperDecorator:
|
||||
inner := current.BaseRoundTripper()
|
||||
if inner == nil || inner == base {
|
||||
return nil, false
|
||||
}
|
||||
rebuiltInner, valid := transformHTTPTransportForRequestClass(inner, class, transform, depth+1)
|
||||
if !valid {
|
||||
return nil, false
|
||||
}
|
||||
rebuilt := current.WithBaseRoundTripper(rebuiltInner)
|
||||
return rebuilt, rebuilt != nil
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func cloneHTTPTransport(source *http.Transport) *http.Transport {
|
||||
cloned := source.Clone()
|
||||
// Clone leaves an auto-configured h2 handler on source.
|
||||
if cloned.TLSNextProto == nil {
|
||||
if _, ok := source.TLSNextProto["h2"]; ok {
|
||||
cloned.ForceAttemptHTTP2 = true
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
351
internal/transport/policy_router_test.go
Normal file
351
internal/transport/policy_router_test.go
Normal file
@@ -0,0 +1,351 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
type cloneTestDecorator struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (d *cloneTestDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return d.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (d *cloneTestDecorator) BaseRoundTripper() http.RoundTripper {
|
||||
return d.base
|
||||
}
|
||||
|
||||
func (d *cloneTestDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
return &cloneTestDecorator{base: base}
|
||||
}
|
||||
|
||||
type headerCloneTestDecorator struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (d *headerCloneTestDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("X-Decorator", "applied")
|
||||
return d.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (d *headerCloneTestDecorator) BaseRoundTripper() http.RoundTripper {
|
||||
return d.base
|
||||
}
|
||||
|
||||
func (d *headerCloneTestDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
return &headerCloneTestDecorator{base: base}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterClassifiesFromEndpointCatalog(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
|
||||
platformCalls := 0
|
||||
externalCalls := 0
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
platformCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
externalCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
)
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"https://open.feishu.cn/open-apis/test",
|
||||
"https://example.com/file",
|
||||
} {
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := router.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if platformCalls != 1 || externalCalls != 1 {
|
||||
t.Fatalf("platform calls = %d, external calls = %d; want 1 each", platformCalls, externalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterExplicitClassOverridesCatalog(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
|
||||
platformCalls := 0
|
||||
externalCalls := 0
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
platformCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
externalCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = WithRequestClass(req, exttransport.RequestClassExternal)
|
||||
resp, err := router.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if platformCalls != 0 || externalCalls != 1 {
|
||||
t.Fatalf("platform calls = %d, external calls = %d; want 0 and 1", platformCalls, externalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientForRequestClassOutermostIntentWins(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
platformCalls := 0
|
||||
externalCalls := 0
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
platformCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
externalCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
)
|
||||
|
||||
platform := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassPlatform)
|
||||
external := ClientForRequestClass(platform, exttransport.RequestClassExternal)
|
||||
resp, err := external.Get("https://open.feishu.cn/open-apis/test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if platformCalls != 0 || externalCalls != 1 {
|
||||
t.Fatalf("platform calls = %d, external calls = %d; want outer external intent to win", platformCalls, externalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterRejectsInvalidExplicitClass(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
router := NewHTTPPolicyRouter(nil, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = WithRequestClass(req, exttransport.RequestClass("invalid"))
|
||||
if _, err := router.RoundTrip(req); err == nil || !strings.Contains(err.Error(), "unsupported HTTP request class") {
|
||||
t.Fatalf("RoundTrip() error = %v, want unsupported request class", err)
|
||||
} else if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryInternal ||
|
||||
problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterRejectsNilRequest(t *testing.T) {
|
||||
router := NewHTTPPolicyRouter(nil, nil)
|
||||
_, err := router.RoundTrip(nil)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterReclassifiesRedirectTargets(t *testing.T) {
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
receivedHeader := make(chan string, 1)
|
||||
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
receivedHeader <- req.Header.Get("X-Test-Platform")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(external.Close)
|
||||
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusFound,
|
||||
Header: http.Header{"Location": []string{external.URL}},
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
http.DefaultTransport,
|
||||
)
|
||||
client := &http.Client{Transport: router}
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got := <-receivedHeader; got != "" {
|
||||
t.Fatalf("redirect target received platform-scoped header %q", got)
|
||||
}
|
||||
if interceptor.calls != 1 {
|
||||
t.Fatalf("extension calls = %d, want only the initial platform request", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneHTTPTransportForRequestClassRebuildsDecorators(t *testing.T) {
|
||||
wantErr := errors.New("preserved proxy policy")
|
||||
base := &http.Transport{
|
||||
Proxy: func(*http.Request) (*url.URL, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
decorated := &cloneTestDecorator{base: base}
|
||||
router := NewHTTPPolicyRouter(decorated, decorated)
|
||||
|
||||
rebuilt, concrete, ok := CloneHTTPTransportForRequestClass(router, exttransport.RequestClassExternal)
|
||||
if !ok {
|
||||
t.Fatal("CloneHTTPTransportForRequestClass() ok = false")
|
||||
}
|
||||
if concrete == base {
|
||||
t.Fatal("CloneHTTPTransportForRequestClass() reused the original *http.Transport")
|
||||
}
|
||||
if _, ok := rebuilt.(*cloneTestDecorator); !ok {
|
||||
t.Fatalf("rebuilt transport type = %T, want *cloneTestDecorator", rebuilt)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rebuilt.RoundTrip(req); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneHTTPTransportForRequestClassPreservesAutomaticHTTP2(t *testing.T) {
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
source := &http.Transport{
|
||||
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
|
||||
}
|
||||
router := NewHTTPPolicyRouter(&http.Transport{}, source)
|
||||
|
||||
_, cloned, ok := CloneHTTPTransportForRequestClass(router, exttransport.RequestClassExternal)
|
||||
if !ok {
|
||||
t.Fatal("CloneHTTPTransportForRequestClass() ok = false")
|
||||
}
|
||||
if !cloned.ForceAttemptHTTP2 {
|
||||
t.Fatal("ForceAttemptHTTP2 = false, want true")
|
||||
}
|
||||
if cloned.TLSNextProto != nil {
|
||||
t.Fatal("TLSNextProto is non-nil, want automatic HTTP/2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneHTTPTransportForRequestClassKeepsOutermostIntent(t *testing.T) {
|
||||
platformErr := errors.New("platform transport")
|
||||
externalErr := errors.New("external transport")
|
||||
newBlocked := func(reason error) *http.Transport {
|
||||
return &http.Transport{Proxy: func(*http.Request) (*url.URL, error) { return nil, reason }}
|
||||
}
|
||||
router := NewHTTPPolicyRouter(newBlocked(platformErr), newBlocked(externalErr))
|
||||
platform := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassPlatform)
|
||||
external := ClientForRequestClass(platform, exttransport.RequestClassExternal)
|
||||
|
||||
source, ok := external.Transport.(interface {
|
||||
CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("transport type %T has no clone capability", external.Transport)
|
||||
}
|
||||
rebuilt, _, ok := source.CloneHTTPTransport()
|
||||
if !ok {
|
||||
t.Fatal("CloneHTTPTransport() ok = false")
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rebuilt.RoundTrip(req); !errors.Is(err, externalErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want outer external transport error %v", err, externalErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientForRequestClassOverridesCallerIntent(t *testing.T) {
|
||||
platformErr := errors.New("platform transport")
|
||||
externalErr := errors.New("external transport")
|
||||
newBlocked := func(reason error) *http.Transport {
|
||||
return &http.Transport{Proxy: func(*http.Request) (*url.URL, error) { return nil, reason }}
|
||||
}
|
||||
router := NewHTTPPolicyRouter(newBlocked(platformErr), newBlocked(externalErr))
|
||||
client := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassExternal)
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = WithRequestClass(req, exttransport.RequestClassPlatform)
|
||||
|
||||
if _, err := client.Do(req); !errors.Is(err, externalErr) {
|
||||
t.Fatalf("Do() error = %v, want forced external transport error %v", err, externalErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformHTTPTransportReplacesLeafInsideDecorators(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
decorated := &headerCloneTestDecorator{base: &http.Transport{}}
|
||||
router := NewHTTPPolicyRouter(decorated, decorated)
|
||||
client := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassExternal)
|
||||
|
||||
source, ok := client.Transport.(interface {
|
||||
TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("transport type %T has no transform capability", client.Transport)
|
||||
}
|
||||
rebuilt, ok := source.TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool) {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if got := req.Header.Get("X-Decorator"); got != "applied" {
|
||||
t.Fatalf("leaf received X-Decorator = %q, want applied", got)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}), true
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("TransformHTTPTransport() ok = false")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := rebuilt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
// Shared returns the base http.RoundTripper for all CLI HTTP clients.
|
||||
@@ -55,21 +57,29 @@ func Fallback() *http.Transport {
|
||||
return noProxyTransport()
|
||||
}
|
||||
|
||||
// NewHTTPClient returns an *http.Client whose Transport is the shared,
|
||||
// proxy-plugin-aware base (see Shared). Prefer this over a bare &http.Client{}
|
||||
// for outbound requests: a bare client falls back to http.DefaultTransport and
|
||||
// therefore silently bypasses proxy plugin mode (fixed proxy + trusted CA, or
|
||||
// fail-closed), creating an audit blind spot.
|
||||
// NewHTTPClient returns a policy-routed client over the shared proxy-aware
|
||||
// transport. Known platform endpoints use the platform request class; all
|
||||
// other URLs use the external request class. Existing unscoped transport
|
||||
// providers continue to apply to both classes.
|
||||
//
|
||||
// A zero timeout means no client-level timeout (callers relying on context
|
||||
// deadlines pass 0).
|
||||
func NewHTTPClient(timeout time.Duration) *http.Client {
|
||||
base := Shared()
|
||||
return &http.Client{
|
||||
Transport: Shared(),
|
||||
Transport: NewHTTPPolicyRouter(base, base),
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// NewExternalHTTPClient returns a client for user-provided, pre-signed, CDN,
|
||||
// package-registry, and other non-platform URLs. It forces the external policy
|
||||
// while preserving the shared proxy configuration and the historical behavior
|
||||
// of unscoped transport providers. A zero timeout means no client-level timeout.
|
||||
func NewExternalHTTPClient(timeout time.Duration) *http.Client {
|
||||
return ClientForRequestClass(NewHTTPClient(timeout), exttransport.RequestClassExternal)
|
||||
}
|
||||
|
||||
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, lazily
|
||||
// built the first time LARK_CLI_NO_PROXY is observed set.
|
||||
var noProxyTransport = sync.OnceValue(func() *http.Transport {
|
||||
|
||||
@@ -88,23 +88,24 @@ func TestShared_NoProxyOverridesSystemProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware
|
||||
// transport (instead of a bare client that bypasses proxy plugin mode).
|
||||
func TestNewHTTPClient(t *testing.T) {
|
||||
// TestHTTPClientConstructors verifies both the policy-routed client and its
|
||||
// forced-external view retain explicit transports and configured timeouts.
|
||||
func TestHTTPClientConstructors(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
c := NewHTTPClient(7 * time.Second)
|
||||
if c.Transport == nil {
|
||||
t.Fatal("NewHTTPClient transport is nil; want shared transport")
|
||||
}
|
||||
if c.Transport != Shared() {
|
||||
t.Errorf("NewHTTPClient transport = %v, want Shared()", c.Transport)
|
||||
}
|
||||
if c.Timeout != 7*time.Second {
|
||||
t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout)
|
||||
for name, client := range map[string]*http.Client{
|
||||
"routed": NewHTTPClient(7 * time.Second),
|
||||
"external": NewExternalHTTPClient(7 * time.Second),
|
||||
} {
|
||||
if client.Transport == nil {
|
||||
t.Fatalf("%s client transport is nil", name)
|
||||
}
|
||||
if client.Timeout != 7*time.Second {
|
||||
t.Errorf("%s client timeout = %v, want 7s", name, client.Timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,4 +154,32 @@ func TestShared_MalformedConfigFailsClosedEvenWithNoProxy(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("RoundTrip() err = nil (resp=%v); malformed config must fail closed", resp)
|
||||
}
|
||||
|
||||
for name, test := range map[string]struct {
|
||||
client *http.Client
|
||||
url string
|
||||
}{
|
||||
"platform": {
|
||||
client: NewHTTPClient(time.Second),
|
||||
url: "https://open.feishu.cn/open-apis/test",
|
||||
},
|
||||
"external": {
|
||||
client: NewHTTPClient(time.Second),
|
||||
url: "https://external.example/test",
|
||||
},
|
||||
"forced external": {
|
||||
client: NewExternalHTTPClient(time.Second),
|
||||
url: "https://external.example/test",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resp, err := test.client.Get(test.url)
|
||||
if err == nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
t.Fatalf("policy-routed client succeeded with malformed proxy config")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,10 +62,7 @@ func httpClient() *http.Client {
|
||||
if DefaultClient != nil {
|
||||
return DefaultClient
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: fetchTimeout,
|
||||
Transport: transport.Shared(),
|
||||
}
|
||||
return transport.NewExternalHTTPClient(fetchTimeout)
|
||||
}
|
||||
|
||||
// updateState is persisted to disk for caching.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -12,6 +13,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
// roundTripFunc adapts a function to http.RoundTripper.
|
||||
@@ -19,6 +22,30 @@ type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
type updateExternalProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (p updateExternalProvider) Name() string { return "update-external-test" }
|
||||
|
||||
func (p updateExternalProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
func (updateExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == exttransport.RequestClassExternal
|
||||
}
|
||||
|
||||
type updateExternalInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *updateExternalInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-External-Route", "1")
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearSkipEnv unsets all env vars that shouldSkip checks,
|
||||
// preventing the host environment (e.g. CI=true) from polluting test results.
|
||||
func clearSkipEnv(t *testing.T) {
|
||||
@@ -242,6 +269,46 @@ func TestRefreshCache(t *testing.T) {
|
||||
RefreshCache("1.0.0")
|
||||
}
|
||||
|
||||
func TestHTTPClientUsesExternalRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARK_CLI_NO_PROXY", "")
|
||||
previousClient := DefaultClient
|
||||
DefaultClient = nil
|
||||
t.Cleanup(func() { DefaultClient = previousClient })
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &updateExternalInterceptor{}
|
||||
exttransport.Register(updateExternalProvider{interceptor: interceptor})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
previousTransport := http.DefaultTransport
|
||||
var receivedHeader string
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
receivedHeader = req.Header.Get("X-External-Route")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
t.Cleanup(func() { http.DefaultTransport = previousTransport })
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/npm/latest", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if interceptor.calls != 1 || receivedHeader != "1" {
|
||||
t.Fatalf("external route = calls %d, header %q; want 1, %q", interceptor.calls, receivedHeader, "1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingAtomicAccess(t *testing.T) {
|
||||
// Initially nil
|
||||
if got := GetPending(); got != nil {
|
||||
|
||||
@@ -17,6 +17,13 @@ func SafeInputPath(path string) (string, error) {
|
||||
return localfileio.SafeInputPath(path)
|
||||
}
|
||||
|
||||
// LocalInputPath validates a local input path without restricting it to the
|
||||
// current working directory. It delegates to localfileio.LocalInputPath so
|
||||
// command validation and shared local-file readers use one policy.
|
||||
func LocalInputPath(path string) (string, error) {
|
||||
return localfileio.LocalInputPath(path)
|
||||
}
|
||||
|
||||
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||
// Delegates to localfileio.SafeEnvDirPath.
|
||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||
|
||||
@@ -211,6 +211,18 @@ func TestSafeLocalFlagPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_AllowsLocalPathsAndRejectsUnsafeCharacters(t *testing.T) {
|
||||
for _, path := range []string{"/tmp/report.pdf", "../report.pdf"} {
|
||||
got, err := LocalInputPath(path)
|
||||
if err != nil || got != path {
|
||||
t.Fatalf("LocalInputPath(%q) = %q, %v; want unchanged path", path, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := LocalInputPath("report\n.pdf"); err == nil {
|
||||
t.Fatal("LocalInputPath() unexpectedly accepted a control character")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a real temp file (absolute path under os.TempDir())
|
||||
f, err := os.CreateTemp("", "upload-test-*.bin")
|
||||
|
||||
@@ -5,11 +5,15 @@ package validate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,6 +38,9 @@ func isRestrictedDownloadIP(ip net.IP) bool {
|
||||
return true
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
if v4[0] == 0 { // RFC 1122 "this network"
|
||||
return true
|
||||
}
|
||||
if v4[0] == 10 || v4[0] == 127 {
|
||||
return true
|
||||
}
|
||||
@@ -52,6 +59,9 @@ func isRestrictedDownloadIP(ip net.IP) bool {
|
||||
if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) { // RFC2544 benchmarking
|
||||
return true
|
||||
}
|
||||
if v4[0] >= 240 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if ip.IsPrivate() {
|
||||
@@ -76,32 +86,42 @@ func ValidateDownloadSourceURL(ctx context.Context, rawURL string) error {
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("only http/https URLs are supported")
|
||||
}
|
||||
host := strings.TrimSpace(strings.ToLower(u.Hostname()))
|
||||
_, err = resolveDownloadHost(ctx, u.Hostname(), net.DefaultResolver.LookupIP)
|
||||
return err
|
||||
}
|
||||
|
||||
type downloadLookupIPFunc func(context.Context, string, string) ([]net.IP, error)
|
||||
|
||||
func resolveDownloadHost(ctx context.Context, rawHost string, lookupIP downloadLookupIPFunc) ([]net.IP, error) {
|
||||
host := strings.TrimSpace(strings.ToLower(rawHost))
|
||||
if host == "" {
|
||||
return fmt.Errorf("URL host is required")
|
||||
return nil, fmt.Errorf("URL host is required")
|
||||
}
|
||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if isRestrictedDownloadIP(ip) {
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
return nil
|
||||
return []net.IP{ip}, nil
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if lookupIP == nil {
|
||||
lookupIP = net.DefaultResolver.LookupIP
|
||||
}
|
||||
ips, err := lookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve host")
|
||||
return nil, fmt.Errorf("failed to resolve host")
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return fmt.Errorf("failed to resolve host")
|
||||
return nil, fmt.Errorf("failed to resolve host")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isRestrictedDownloadIP(ip) {
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// NewDownloadHTTPClient clones base client and enforces download-safe redirect
|
||||
@@ -115,7 +135,10 @@ func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *h
|
||||
}
|
||||
|
||||
cloned := *base
|
||||
cloned.Transport = cloneDownloadTransport(base.Transport)
|
||||
cloned.Transport = &downloadSchemeTransport{
|
||||
base: cloneDownloadTransport(base.Transport),
|
||||
allowHTTP: opts.AllowHTTP,
|
||||
}
|
||||
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= opts.MaxRedirects {
|
||||
return fmt.Errorf("too many redirects")
|
||||
@@ -138,18 +161,310 @@ func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *h
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
|
||||
var cloned *http.Transport
|
||||
if src, ok := base.(*http.Transport); ok && src != nil {
|
||||
cloned = src.Clone()
|
||||
} else {
|
||||
if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil {
|
||||
cloned = def.Clone()
|
||||
} else {
|
||||
cloned = &http.Transport{}
|
||||
}
|
||||
type downloadSchemeTransport struct {
|
||||
base http.RoundTripper
|
||||
allowHTTP bool
|
||||
}
|
||||
|
||||
func (t *downloadSchemeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"download transport received a nil request",
|
||||
)
|
||||
}
|
||||
switch {
|
||||
case strings.EqualFold(req.URL.Scheme, "https"):
|
||||
case t.allowHTTP && strings.EqualFold(req.URL.Scheme, "http"):
|
||||
default:
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"only https URLs are supported",
|
||||
)
|
||||
}
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
type selectedDownloadProxyKey struct{}
|
||||
|
||||
type proxyAwareDownloadTransport struct {
|
||||
selectProxy func(*http.Request) (*url.URL, error)
|
||||
direct http.RoundTripper
|
||||
proxied *http.Transport
|
||||
lookupIP downloadLookupIPFunc
|
||||
|
||||
mu sync.Mutex
|
||||
proxiedByTLSServer map[string]*http.Transport
|
||||
}
|
||||
|
||||
func (t *proxyAwareDownloadTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, fmt.Errorf("download transport received a nil request")
|
||||
}
|
||||
proxyURL, err := t.selectProxy(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if proxyURL == nil {
|
||||
return t.direct.RoundTrip(req)
|
||||
}
|
||||
|
||||
targetIPs, err := resolveDownloadHost(req.Context(), req.URL.Hostname(), t.lookupIP)
|
||||
if err != nil {
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"blocked download target: %v",
|
||||
err,
|
||||
).WithCause(err)
|
||||
}
|
||||
if strings.EqualFold(req.URL.Scheme, "http") && net.ParseIP(req.URL.Hostname()) == nil {
|
||||
// HTTP proxies cannot pin the target IP separately from the Host header.
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"plain HTTP hostname downloads through a proxy are not allowed",
|
||||
).WithHint("use HTTPS or a literal public IP")
|
||||
}
|
||||
|
||||
selected := *proxyURL
|
||||
proxied := t.proxied
|
||||
if strings.EqualFold(req.URL.Scheme, "https") {
|
||||
proxied = t.proxiedTransportForTLSServer(req.URL.Hostname())
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for index, targetIP := range targetIPs {
|
||||
proxiedReq, pinErr := pinDownloadRequestTargetToIP(req, targetIP)
|
||||
if pinErr != nil {
|
||||
return nil, pinErr
|
||||
}
|
||||
ctx := context.WithValue(proxiedReq.Context(), selectedDownloadProxyKey{}, &selected)
|
||||
proxiedReq = proxiedReq.WithContext(ctx)
|
||||
|
||||
resp, roundTripErr := proxied.RoundTrip(proxiedReq)
|
||||
if roundTripErr == nil {
|
||||
if resp != nil {
|
||||
// Hide the internal pinned URL from redirect handling.
|
||||
resp.Request = req
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = roundTripErr
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if req.Context().Err() != nil {
|
||||
break
|
||||
}
|
||||
if index+1 < len(targetIPs) && !canRetryDownloadTarget(req) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (t *proxyAwareDownloadTransport) CloseIdleConnections() {
|
||||
if closer, ok := t.direct.(interface{ CloseIdleConnections() }); ok {
|
||||
closer.CloseIdleConnections()
|
||||
}
|
||||
t.proxied.CloseIdleConnections()
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, transport := range t.proxiedByTLSServer {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *proxyAwareDownloadTransport) proxiedTransportForTLSServer(serverName string) *http.Transport {
|
||||
if configured := t.proxied.TLSClientConfig; configured != nil && configured.ServerName != "" {
|
||||
serverName = configured.ServerName
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if transport := t.proxiedByTLSServer[serverName]; transport != nil {
|
||||
return transport
|
||||
}
|
||||
|
||||
transport := t.proxied.Clone()
|
||||
targetTLSConfig := cloneDownloadTLSConfig(transport.TLSClientConfig)
|
||||
targetTLSConfig.ServerName = serverName
|
||||
transport.TLSClientConfig = targetTLSConfig
|
||||
configureHTTPSProxyTLSDialer(transport, t.proxied)
|
||||
if t.proxiedByTLSServer == nil {
|
||||
t.proxiedByTLSServer = make(map[string]*http.Transport)
|
||||
}
|
||||
t.proxiedByTLSServer[serverName] = transport
|
||||
return transport
|
||||
}
|
||||
|
||||
type blockedDownloadTransport struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (t *blockedDownloadTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, t.err
|
||||
}
|
||||
|
||||
func cloneDownloadTransport(base http.RoundTripper) http.RoundTripper {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
if source, ok := base.(interface {
|
||||
TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool)
|
||||
}); ok {
|
||||
rebuilt, transformed := source.TransformHTTPTransport(newDownloadTransportLeaf)
|
||||
if transformed && rebuilt != nil {
|
||||
return rebuilt
|
||||
}
|
||||
}
|
||||
if source, ok := base.(*http.Transport); ok && source != nil {
|
||||
rebuilt, transformed := newDownloadTransportLeaf(source)
|
||||
if transformed && rebuilt != nil {
|
||||
return rebuilt
|
||||
}
|
||||
}
|
||||
return &blockedDownloadTransport{err: errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"cannot safely clone download transport %T",
|
||||
base,
|
||||
)}
|
||||
}
|
||||
|
||||
func newDownloadTransportLeaf(source *http.Transport) (http.RoundTripper, bool) {
|
||||
return newDownloadTransportLeafWithResolver(source, net.DefaultResolver.LookupIP)
|
||||
}
|
||||
|
||||
func newDownloadTransportLeafWithResolver(source *http.Transport, lookupIP downloadLookupIPFunc) (http.RoundTripper, bool) {
|
||||
if source == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
selectProxy := source.Proxy
|
||||
direct := cloneDownloadHTTPTransport(source)
|
||||
direct.Proxy = nil
|
||||
configureDirectDownloadTransport(direct)
|
||||
if selectProxy == nil {
|
||||
return direct, true
|
||||
}
|
||||
|
||||
// The proxied branch validates the requested URL before construction and
|
||||
// on every redirect. Its TCP peer is the selected proxy, so applying the
|
||||
// direct-origin IP guard there would incorrectly reject trusted loopback or
|
||||
// private-network proxies. Freeze the selected proxy in request context so
|
||||
// a stateful selector cannot switch the second lookup to direct egress.
|
||||
proxied := cloneDownloadHTTPTransport(source)
|
||||
proxied.Proxy = func(req *http.Request) (*url.URL, error) {
|
||||
selected, ok := req.Context().Value(selectedDownloadProxyKey{}).(*url.URL)
|
||||
if !ok || selected == nil {
|
||||
return nil, fmt.Errorf("download proxy selection is missing")
|
||||
}
|
||||
cloned := *selected
|
||||
return &cloned, nil
|
||||
}
|
||||
return &proxyAwareDownloadTransport{
|
||||
selectProxy: selectProxy,
|
||||
direct: direct,
|
||||
proxied: proxied,
|
||||
lookupIP: lookupIP,
|
||||
proxiedByTLSServer: make(map[string]*http.Transport),
|
||||
}, true
|
||||
}
|
||||
|
||||
func cloneDownloadHTTPTransport(source *http.Transport) *http.Transport {
|
||||
cloned := source.Clone()
|
||||
if cloned.TLSNextProto == nil {
|
||||
if _, ok := source.TLSNextProto["h2"]; ok {
|
||||
cloned.ForceAttemptHTTP2 = true
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func pinDownloadRequestTargetToIP(req *http.Request, targetIP net.IP) (*http.Request, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, fmt.Errorf("download request URL is missing")
|
||||
}
|
||||
if targetIP == nil || isRestrictedDownloadIP(targetIP) {
|
||||
return nil, fmt.Errorf("blocked download target: local/internal host is not allowed")
|
||||
}
|
||||
|
||||
originalHost := req.URL.Host
|
||||
pinnedHost := targetIP.String()
|
||||
if port := req.URL.Port(); port != "" {
|
||||
pinnedHost = net.JoinHostPort(pinnedHost, port)
|
||||
} else if strings.Contains(pinnedHost, ":") {
|
||||
pinnedHost = "[" + pinnedHost + "]"
|
||||
}
|
||||
|
||||
pinned := req.Clone(req.Context())
|
||||
pinnedURL := *req.URL
|
||||
pinnedURL.Host = pinnedHost
|
||||
pinned.URL = &pinnedURL
|
||||
pinned.Host = originalHost
|
||||
return pinned, nil
|
||||
}
|
||||
|
||||
func canRetryDownloadTarget(req *http.Request) bool {
|
||||
if req == nil || req.Body != nil {
|
||||
return false
|
||||
}
|
||||
return req.Method == http.MethodGet || req.Method == http.MethodHead
|
||||
}
|
||||
|
||||
func cloneDownloadTLSConfig(config *tls.Config) *tls.Config {
|
||||
if config == nil {
|
||||
return &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
return config.Clone()
|
||||
}
|
||||
|
||||
func configureHTTPSProxyTLSDialer(transport, source *http.Transport) {
|
||||
if transport.DialTLSContext != nil || transport.DialTLS != nil {
|
||||
return
|
||||
}
|
||||
|
||||
proxyTLSConfig := cloneDownloadTLSConfig(source.TLSClientConfig)
|
||||
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
rawConn, err := dialDownloadProxy(ctx, source, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config := proxyTLSConfig.Clone()
|
||||
serverName, _, splitErr := net.SplitHostPort(addr)
|
||||
if splitErr != nil {
|
||||
rawConn.Close()
|
||||
return nil, fmt.Errorf("invalid HTTPS proxy address: %w", splitErr)
|
||||
}
|
||||
config.ServerName = serverName
|
||||
tlsConn := tls.Client(rawConn, config)
|
||||
handshakeCtx := ctx
|
||||
cancel := func() {}
|
||||
if source.TLSHandshakeTimeout > 0 {
|
||||
handshakeCtx, cancel = context.WithTimeout(ctx, source.TLSHandshakeTimeout)
|
||||
}
|
||||
defer cancel()
|
||||
if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
|
||||
rawConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return tlsConn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func dialDownloadProxy(ctx context.Context, source *http.Transport, network, addr string) (net.Conn, error) {
|
||||
if source.DialContext != nil {
|
||||
return source.DialContext(ctx, network, addr)
|
||||
}
|
||||
if source.Dial != nil {
|
||||
return source.Dial(network, addr)
|
||||
}
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
func configureDirectDownloadTransport(cloned *http.Transport) {
|
||||
origDial := cloned.DialContext
|
||||
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialConn(ctx, origDial, network, addr)
|
||||
@@ -158,7 +473,7 @@ func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
@@ -172,13 +487,26 @@ func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
if cloned.DialTLS != nil {
|
||||
origDialTLS := cloned.DialTLS
|
||||
cloned.DialTLS = func(network, addr string) (net.Conn, error) {
|
||||
conn, err := origDialTLS(network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
// DialContextFunc is the signature for DialContext / DialTLSContext.
|
||||
@@ -194,7 +522,7 @@ func WrapDialContextWithIPCheck(origDial DialContextFunc) DialContextFunc {
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
@@ -208,6 +536,14 @@ func dialConn(ctx context.Context, dialFn func(context.Context, string, string)
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
func downloadTargetPolicyError(err error) error {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"blocked download target: %v",
|
||||
err,
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
func validateConnRemoteIP(conn net.Conn) error {
|
||||
if conn == nil {
|
||||
return fmt.Errorf("nil connection")
|
||||
|
||||
529
internal/validate/url_internal_test.go
Normal file
529
internal/validate/url_internal_test.go
Normal file
@@ -0,0 +1,529 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package validate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestProxiedHTTPSDownloadPinsValidatedTargetIP(t *testing.T) {
|
||||
const (
|
||||
targetHost = "rebind.example"
|
||||
targetIP = "203.0.113.10"
|
||||
)
|
||||
|
||||
proxyCalled := make(chan struct{}, 1)
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
proxyCalled <- struct{}{}
|
||||
if req.Method != http.MethodConnect {
|
||||
t.Errorf("proxy request method = %q, want CONNECT", req.Method)
|
||||
}
|
||||
if got := req.Host; got != targetIP+":443" {
|
||||
t.Errorf("proxy CONNECT target = %q, want validated IP %q", got, targetIP+":443")
|
||||
}
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lookupIP := func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP(targetIP)}, nil
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
lookupIP,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://"+targetHost+"/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pinned, err := pinDownloadRequestTargetToIP(req, net.ParseIP(targetIP))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pinned.Host != targetHost {
|
||||
t.Fatalf("pinned request Host = %q, want %q", pinned.Host, targetHost)
|
||||
}
|
||||
if _, err := transport.RoundTrip(req); err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want proxy rejection after CONNECT")
|
||||
}
|
||||
select {
|
||||
case <-proxyCalled:
|
||||
default:
|
||||
t.Fatal("proxy was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedDownloadIPBlocksReservedIPv4(t *testing.T) {
|
||||
for _, rawIP := range []string{"0.1.2.3", "240.0.0.1"} {
|
||||
if !isRestrictedDownloadIP(net.ParseIP(rawIP)) {
|
||||
t.Fatalf("%s was classified as safe", rawIP)
|
||||
}
|
||||
}
|
||||
if isRestrictedDownloadIP(net.ParseIP("1.1.1.1")) {
|
||||
t.Fatal("1.1.1.1 was classified as restricted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneDownloadTLSConfigSetsMinimumVersion(t *testing.T) {
|
||||
if got := cloneDownloadTLSConfig(nil).MinVersion; got != tls.VersionTLS12 {
|
||||
t.Fatalf("MinVersion = %d, want TLS 1.2", got)
|
||||
}
|
||||
configured := &tls.Config{MinVersion: tls.VersionTLS13}
|
||||
if got := cloneDownloadTLSConfig(configured).MinVersion; got != tls.VersionTLS13 {
|
||||
t.Fatalf("cloned MinVersion = %d, want TLS 1.3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneDownloadHTTPTransportPreservesHTTP2Policy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source *http.Transport
|
||||
wantForce bool
|
||||
wantH2Handler bool
|
||||
wantProtocolMap bool
|
||||
}{
|
||||
{
|
||||
name: "automatic",
|
||||
source: &http.Transport{
|
||||
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
|
||||
},
|
||||
wantForce: true,
|
||||
},
|
||||
{
|
||||
name: "custom TLS without opt-in",
|
||||
source: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom dial without opt-in",
|
||||
source: &http.Transport{
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return nil, errors.New("unused")
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit opt-in",
|
||||
source: &http.Transport{ForceAttemptHTTP2: true},
|
||||
wantForce: true,
|
||||
},
|
||||
{
|
||||
name: "explicit h2 handler",
|
||||
source: &http.Transport{
|
||||
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{
|
||||
"h2": func(string, *tls.Conn) http.RoundTripper { return nil },
|
||||
},
|
||||
},
|
||||
wantH2Handler: true,
|
||||
wantProtocolMap: true,
|
||||
},
|
||||
{
|
||||
name: "explicit opt-out",
|
||||
source: &http.Transport{
|
||||
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
|
||||
},
|
||||
wantProtocolMap: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cloned := cloneDownloadHTTPTransport(test.source)
|
||||
if cloned.ForceAttemptHTTP2 != test.wantForce {
|
||||
t.Fatalf("ForceAttemptHTTP2 = %v, want %v", cloned.ForceAttemptHTTP2, test.wantForce)
|
||||
}
|
||||
_, hasH2Handler := cloned.TLSNextProto["h2"]
|
||||
if hasH2Handler != test.wantH2Handler {
|
||||
t.Fatalf("h2 handler = %v, want %v", hasH2Handler, test.wantH2Handler)
|
||||
}
|
||||
if hasProtocolMap := cloned.TLSNextProto != nil; hasProtocolMap != test.wantProtocolMap {
|
||||
t.Fatalf("TLSNextProto is non-nil = %v, want %v", hasProtocolMap, test.wantProtocolMap)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneDownloadTransportPreservesAutomaticHTTP2(t *testing.T) {
|
||||
source := &http.Transport{
|
||||
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
|
||||
}
|
||||
rebuilt := cloneDownloadTransport(source)
|
||||
proxyAware, ok := rebuilt.(*proxyAwareDownloadTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *proxyAwareDownloadTransport", rebuilt)
|
||||
}
|
||||
direct, ok := proxyAware.direct.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("direct transport type = %T, want *http.Transport", proxyAware.direct)
|
||||
}
|
||||
for name, transport := range map[string]*http.Transport{
|
||||
"direct": direct,
|
||||
"proxied": proxyAware.proxied,
|
||||
} {
|
||||
if !transport.ForceAttemptHTTP2 {
|
||||
t.Fatalf("%s ForceAttemptHTTP2 = false, want true", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedDownloadRejectsRestrictedResolvedTarget(t *testing.T) {
|
||||
var proxyCalled atomic.Bool
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
proxyCalled.Store(true)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lookupIP := func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("127.0.0.1")}, nil
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
lookupIP,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://rebind.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = transport.RoundTrip(req)
|
||||
if err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want restricted target rejection")
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if proxyCalled.Load() {
|
||||
t.Fatal("proxy was called for a restricted resolved target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedPlainHTTPHostnameRejectsLocalProxy(t *testing.T) {
|
||||
var proxyCalled atomic.Bool
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
proxyCalled.Store(true)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lookupIP := func(ctx context.Context, _, host string) ([]net.IP, error) {
|
||||
if host == "public.example" {
|
||||
return []net.IP{net.ParseIP("203.0.113.10")}, nil
|
||||
}
|
||||
return net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
lookupIP,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://public.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = transport.RoundTrip(req)
|
||||
if err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want plain HTTP hostname rejection")
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
} else if problem.Hint != "use HTTPS or a literal public IP" {
|
||||
t.Fatalf("RoundTrip() hint = %q, want recovery guidance", problem.Hint)
|
||||
}
|
||||
if proxyCalled.Load() {
|
||||
t.Fatal("proxy was called for a plain HTTP hostname target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedHTTPSDownloadTriesEveryValidatedTargetIP(t *testing.T) {
|
||||
connectTargets := make(chan string, 2)
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
connectTargets <- req.Host
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{
|
||||
net.ParseIP("203.0.113.10"),
|
||||
net.ParseIP("203.0.113.11"),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://multi.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := transport.RoundTrip(req); err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want proxy rejection")
|
||||
}
|
||||
for _, want := range []string{"203.0.113.10:443", "203.0.113.11:443"} {
|
||||
select {
|
||||
case got := <-connectTargets:
|
||||
if got != want {
|
||||
t.Fatalf("proxy CONNECT target = %q, want %q", got, want)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("proxy did not receive CONNECT target %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanRetryDownloadTargetOnlyAllowsBodylessReads(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
method string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{method: http.MethodGet, want: true},
|
||||
{method: http.MethodHead, want: true},
|
||||
{method: http.MethodPost},
|
||||
{method: http.MethodGet, body: "body"},
|
||||
} {
|
||||
req, err := http.NewRequest(test.method, "https://download.example/file", strings.NewReader(test.body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.body == "" {
|
||||
req.Body = nil
|
||||
}
|
||||
if got := canRetryDownloadTarget(req); got != test.want {
|
||||
t.Fatalf("canRetryDownloadTarget(%s, body=%q) = %v, want %v", test.method, test.body, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedHTTPSTargetPreservesOriginalTLSServerName(t *testing.T) {
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"})},
|
||||
func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("203.0.113.10")}, nil
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
proxyAware, ok := transport.(*proxyAwareDownloadTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *proxyAwareDownloadTransport", transport)
|
||||
}
|
||||
|
||||
pinned := proxyAware.proxiedTransportForTLSServer("download.example")
|
||||
if pinned.TLSClientConfig == nil {
|
||||
t.Fatal("TLSClientConfig = nil")
|
||||
}
|
||||
if pinned.TLSClientConfig.ServerName != "download.example" {
|
||||
t.Fatalf("TLS ServerName = %q, want download.example", pinned.TLSClientConfig.ServerName)
|
||||
}
|
||||
if proxyAware.proxied.TLSClientConfig != nil && proxyAware.proxied.TLSClientConfig.ServerName != "" {
|
||||
t.Fatalf("base proxy TLS ServerName = %q, want unchanged", proxyAware.proxied.TLSClientConfig.ServerName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSProxyTLSDialerUsesLegacyDial(t *testing.T) {
|
||||
wantErr := errors.New("legacy dial used")
|
||||
source := &http.Transport{
|
||||
Dial: func(string, string) (net.Conn, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
target := source.Clone()
|
||||
configureHTTPSProxyTLSDialer(target, source)
|
||||
if target.DialTLSContext == nil {
|
||||
t.Fatal("DialTLSContext = nil")
|
||||
}
|
||||
if _, err := target.DialTLSContext(context.Background(), "tcp", "proxy.example:443"); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("DialTLSContext() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectDownloadLegacyDialTLSClosesRestrictedConnection(t *testing.T) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() { serverConn.Close() })
|
||||
conn := &trackedDownloadConn{
|
||||
Conn: clientConn,
|
||||
remoteAddr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443},
|
||||
}
|
||||
rebuilt, ok := newDownloadTransportLeaf(&http.Transport{
|
||||
DialTLS: func(string, string) (net.Conn, error) {
|
||||
return conn, nil
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeaf() did not rebuild transport")
|
||||
}
|
||||
transport, ok := rebuilt.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("rebuilt transport = %T, want *http.Transport", rebuilt)
|
||||
}
|
||||
|
||||
_, err := transport.DialTLS("tcp", "public.example:443")
|
||||
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
|
||||
t.Fatalf("DialTLS() error = %v, want restricted target rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("DialTLS() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if !conn.closed {
|
||||
t.Fatal("restricted connection was not closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectDownloadLegacyDialTLSPreservesDialError(t *testing.T) {
|
||||
wantErr := errors.New("dial failed")
|
||||
rebuilt, ok := newDownloadTransportLeaf(&http.Transport{
|
||||
DialTLS: func(string, string) (net.Conn, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeaf() did not rebuild transport")
|
||||
}
|
||||
transport, ok := rebuilt.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("rebuilt transport = %T, want *http.Transport", rebuilt)
|
||||
}
|
||||
|
||||
if _, err := transport.DialTLS("tcp", "public.example:443"); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("DialTLS() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSProxyTLSDialerRetainsHandshakeTimeout(t *testing.T) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
clientConn.Close()
|
||||
serverConn.Close()
|
||||
})
|
||||
source := &http.Transport{
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return clientConn, nil
|
||||
},
|
||||
TLSHandshakeTimeout: 50 * time.Millisecond,
|
||||
}
|
||||
target := source.Clone()
|
||||
configureHTTPSProxyTLSDialer(target, source)
|
||||
|
||||
started := time.Now()
|
||||
if _, err := target.DialTLSContext(context.Background(), "tcp", "proxy.example:443"); err == nil {
|
||||
t.Fatal("DialTLSContext() error = nil, want TLS handshake timeout")
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed > time.Second {
|
||||
t.Fatalf("TLS handshake timeout took %s, want under 1s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSProxyTLSDialerUsesProxyServerName(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
clientConn.Close()
|
||||
serverConn.Close()
|
||||
})
|
||||
|
||||
proxySNI := make(chan string, 1)
|
||||
serverTLSConfig := server.TLS.Clone()
|
||||
serverTLSConfig.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
proxySNI <- info.ServerName
|
||||
return nil, nil
|
||||
}
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- tls.Server(serverConn, serverTLSConfig).Handshake()
|
||||
}()
|
||||
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(server.Certificate())
|
||||
source := &http.Transport{
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return clientConn, nil
|
||||
},
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: roots,
|
||||
ServerName: "target.example.com",
|
||||
},
|
||||
}
|
||||
target := source.Clone()
|
||||
configureHTTPSProxyTLSDialer(target, source)
|
||||
|
||||
conn, err := target.DialTLSContext(context.Background(), "tcp", "example.com:443")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
if err := <-serverErr; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := <-proxySNI; got != "example.com" {
|
||||
t.Fatalf("proxy TLS ServerName = %q, want example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
type trackedDownloadConn struct {
|
||||
net.Conn
|
||||
remoteAddr net.Addr
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *trackedDownloadConn) RemoteAddr() net.Addr {
|
||||
return c.remoteAddr
|
||||
}
|
||||
|
||||
func (c *trackedDownloadConn) Close() error {
|
||||
c.closed = true
|
||||
return c.Conn.Close()
|
||||
}
|
||||
222
internal/validate/url_test.go
Normal file
222
internal/validate/url_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package validate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type opaqueRoundTripper struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (t *opaqueRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
t.called = true
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}
|
||||
|
||||
type downloadTestProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (p downloadTestProvider) Name() string { return "download-test" }
|
||||
|
||||
func (p downloadTestProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
type downloadHeaderInterceptor struct{}
|
||||
|
||||
func (downloadHeaderInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Set("X-Use-Proxy", "1")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientPreservesPolicyRouterBaseTransport(t *testing.T) {
|
||||
wantErr := errors.New("proxy policy blocked request")
|
||||
base := &http.Transport{
|
||||
Proxy: func(*http.Request) (*url.URL, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
router := internaltransport.NewHTTPPolicyRouter(base, base)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: router},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := download.Transport.RoundTrip(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want preserved proxy error %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientRejectsInitialHTTPBeforeTransport(t *testing.T) {
|
||||
base := &opaqueRoundTripper{}
|
||||
download := validate.NewDownloadHTTPClient(
|
||||
&http.Client{Transport: base},
|
||||
validate.DownloadHTTPClientOptions{},
|
||||
)
|
||||
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = download.Transport.RoundTrip(req)
|
||||
if err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want initial HTTP rejection")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if base.called {
|
||||
t.Fatal("base transport was called for a disallowed initial HTTP request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientAllowsSelectedLoopbackProxy(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.Host != "203.0.113.10" {
|
||||
t.Errorf("proxy request target = %q, want 203.0.113.10", req.URL.Host)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||
router := internaltransport.NewHTTPPolicyRouter(base, base)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: router},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := download.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("download through selected loopback proxy: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientSelectsProxyAfterOuterDecorators(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if got := req.Header.Get("X-Use-Proxy"); got != "1" {
|
||||
t.Errorf("proxy received X-Use-Proxy = %q, want 1", got)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantErr := errors.New("proxy selector ran before decorators")
|
||||
base := &http.Transport{Proxy: func(req *http.Request) (*url.URL, error) {
|
||||
if req.Header.Get("X-Use-Proxy") != "1" {
|
||||
return nil, wantErr
|
||||
}
|
||||
return proxyURL, nil
|
||||
}}
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(downloadTestProvider{interceptor: downloadHeaderInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
router := internaltransport.NewHTTPPolicyRouter(base, base)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: router},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := download.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("download through decorator-selected proxy: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientGuardsLegacyDialTLS(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
base := &http.Transport{DialTLS: func(_, _ string) (net.Conn, error) {
|
||||
return tls.Dial("tcp", server.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true}) //nolint:gosec // local TLS server verifies the connection guard.
|
||||
}}
|
||||
download := validate.NewDownloadHTTPClient(&http.Client{Transport: base}, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://public.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = download.Transport.RoundTrip(req)
|
||||
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
|
||||
t.Fatalf("RoundTrip() error = %v, want legacy DialTLS IP guard", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
var policyErr *errs.SecurityPolicyError
|
||||
if !errors.As(err, &policyErr) || policyErr.Cause == nil {
|
||||
t.Fatalf("RoundTrip() error = %T, want policy error with cause", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientFailsClosedForOpaqueTransport(t *testing.T) {
|
||||
opaque := &opaqueRoundTripper{}
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: opaque},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://public.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = download.Transport.RoundTrip(req)
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot safely clone download transport") {
|
||||
t.Fatalf("RoundTrip() error = %v, want fail-closed clone error", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if opaque.called {
|
||||
t.Fatal("opaque transport was called after safe cloning failed")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -22,6 +23,32 @@ func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
// LocalInputPath validates an input path in the process local filesystem
|
||||
// namespace. It intentionally does not impose cwd containment or canonicalize
|
||||
// the path: absolute paths, parent-relative paths, and symlink traversal retain
|
||||
// their normal OS semantics. Character validation remains mandatory because
|
||||
// paths are user-controlled and may appear in errors or progress output.
|
||||
func LocalInputPath(path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", fmt.Errorf("local input path must not be empty")
|
||||
}
|
||||
if strings.IndexFunc(path, unicode.IsControl) >= 0 {
|
||||
return "", fmt.Errorf("local input path must not contain control characters")
|
||||
}
|
||||
if err := charcheck.RejectControlChars(path, "local input path"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateLocalInputPlatform(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isWindowsNonLocalNamespace(path string) bool {
|
||||
normalized := strings.ReplaceAll(path, "/", `\`)
|
||||
return strings.HasPrefix(normalized, `\\`) || strings.HasPrefix(normalized, `\??\`)
|
||||
}
|
||||
|
||||
// SafeLocalFlagPath validates a flag value as a local file path.
|
||||
// Empty values and http/https URLs are returned unchanged without validation.
|
||||
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
@@ -29,7 +56,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
if _, err := SafeInputPath(value); err != nil {
|
||||
return "", fmt.Errorf("%s: %v", flagName, err)
|
||||
return "", fmt.Errorf("%s: %w", flagName, err)
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -71,6 +72,72 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_AllowsLocalNamespaceWithoutRewriting(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"/tmp/report.pdf",
|
||||
"../outside/report.pdf",
|
||||
"./report.pdf",
|
||||
"nested/../report.pdf",
|
||||
`C:\Users\agent\report.pdf`,
|
||||
"报告.pdf",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
got, err := LocalInputPath(input)
|
||||
if err != nil {
|
||||
t.Fatalf("LocalInputPath(%q) error = %v", input, err)
|
||||
}
|
||||
if got != input {
|
||||
t.Fatalf("LocalInputPath(%q) = %q, want path preserved verbatim", input, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsNonLocalNamespace(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`\\server\share\report.pdf`,
|
||||
`//server/share/report.pdf`,
|
||||
`\\.\pipe\upload`,
|
||||
`\\?\C:\Users\agent\report.pdf`,
|
||||
`\\?\UNC\server\share\report.pdf`,
|
||||
`\??\C:\Users\agent\report.pdf`,
|
||||
} {
|
||||
if !isWindowsNonLocalNamespace(input) {
|
||||
t.Errorf("isWindowsNonLocalNamespace(%q) = false, want true", input)
|
||||
}
|
||||
}
|
||||
|
||||
for _, input := range []string{
|
||||
`C:\Users\agent\report.pdf`,
|
||||
`C:/Users/agent/report.pdf`,
|
||||
`..\outside\report.pdf`,
|
||||
`.\report.pdf`,
|
||||
} {
|
||||
if isWindowsNonLocalNamespace(input) {
|
||||
t.Errorf("isWindowsNonLocalNamespace(%q) = true, want false", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_RejectsEmptyControlAndDangerousUnicode(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"",
|
||||
" ",
|
||||
"file\x00.txt",
|
||||
"file\tname.txt",
|
||||
"file\nname.txt",
|
||||
"file\rname.txt",
|
||||
"file\u202Ename.txt",
|
||||
"file\u200Bname.txt",
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%q", input), func(t *testing.T) {
|
||||
if _, err := LocalInputPath(input); err == nil {
|
||||
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a clean temp directory as CWD
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -19,7 +19,7 @@ lint/
|
||||
├── lintapi/ # shared types every domain returns
|
||||
│ └── violation.go # Violation, Action, ActionReject / ActionLabel / ActionWarning
|
||||
└── 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
|
||||
├── typecheck.go
|
||||
├── violation.go # local type aliases to lintapi
|
||||
@@ -30,16 +30,19 @@ lint/
|
||||
├── rule_subtype_classifier.go
|
||||
├── rule_typed_error_completeness.go
|
||||
└── *_test.go
|
||||
└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts
|
||||
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
|
||||
└── scan_test.go
|
||||
└── domaincontract/ # resolver ownership + approved public hostname policy
|
||||
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
|
||||
├── 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`)
|
||||
|
||||
`domaincontract` is a syntax-level regression guard for the resolver-owned
|
||||
Open, Accounts, MCP, and AppLink hosts used by the Go CLI. In production `.go`
|
||||
files it rejects:
|
||||
`domaincontract` contains two complementary Go source guards.
|
||||
|
||||
The resolver-ownership guard rejects:
|
||||
|
||||
- string literals containing a resolver-owned host FQDN
|
||||
(`{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
|
||||
the guard fails the lint module's tests.
|
||||
|
||||
This is not a general outbound-URL or data-flow analyzer. It does not inspect
|
||||
non-Go assets, hosts assembled from string fragments, SDK constructor option
|
||||
flow, or previously unknown Feishu/Lark hosts. The literal rule and code review
|
||||
remain the backstop for those cases.
|
||||
The approved-domain guard parses every Git-tracked Go file in full. In CI,
|
||||
unapproved-host findings are limited to values whose expressions intersect an
|
||||
added line; policy validation and unused-entry checks remain repository-wide.
|
||||
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
|
||||
|
||||
```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 . ..
|
||||
```
|
||||
|
||||
@@ -100,10 +140,14 @@ Exit codes follow `lint/main.go`:
|
||||
|
||||
import "github.com/larksuite/cli/lint/lintapi"
|
||||
|
||||
// ScanRepo 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 ScanRepo(root string) ([]lintapi.Violation, error) { ... }
|
||||
type ScanOptions struct {
|
||||
ChangedFrom string
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -114,8 +158,12 @@ Exit codes follow `lint/main.go`:
|
||||
|
||||
```go
|
||||
var scanners = []scanner{
|
||||
{name: "errscontract", fn: errscontract.ScanRepo},
|
||||
{name: "<domain>", fn: <domain>.ScanRepo}, // ← add here
|
||||
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
|
||||
{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.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package domaincontract guards the Go CLI against direct reuse of the current
|
||||
// resolver-owned host FQDNs outside core.ResolveEndpoints.
|
||||
// Package domaincontract guards resolver ownership and rejects newly introduced
|
||||
// static Go hostnames that are not covered by the repository domain policy.
|
||||
package domaincontract
|
||||
|
||||
import (
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -75,10 +76,40 @@ func skipDir(name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ScanRepo walks production .go files under root and flags string literals
|
||||
// containing a forbidden resolver host outside the allowlist. Comments and
|
||||
// _test.go files are not scanned.
|
||||
// ScanRepo runs the resolver-owned endpoint guard and a full repository domain
|
||||
// inventory. CI should use ScanRepoWithOptions with a changed-from revision so
|
||||
// historical unapproved domains are not attributed to an unrelated change.
|
||||
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
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
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
|
||||
// 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
|
||||
// dependency on golang.org/x/tools/go/packages does not leak into the
|
||||
@@ -43,8 +43,10 @@ type scanner struct {
|
||||
|
||||
var scanners = []scanner{
|
||||
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
|
||||
{name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||
return domaincontract.ScanRepo(root)
|
||||
{name: "domaincontract", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
|
||||
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")
|
||||
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.Parse()
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.76",
|
||||
"version": "1.0.81",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.76",
|
||||
"version": "1.0.81",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.76",
|
||||
"version": "1.0.81",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -176,7 +176,15 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
|
||||
exit 1
|
||||
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.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"
|
||||
@@ -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" '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.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"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// maxFileListPageSize 是 file_list 分页上限,与后端 paas_storage checkMaxKeys 的 (0, 200] 契约对齐:
|
||||
// page_size > 200 服务端直接返回 ErrInvalidRequest("maxKeys not in range (0, 200]")。CLI 前置校验避免无谓往返。
|
||||
// 注:服务端对 page_size<=0 会兜底为默认值,但 CLI 默认已是 20、显式传 <1 属误用,故与其它 list 命令一致地按 [1, 200] 校验。
|
||||
const maxFileListPageSize = 200
|
||||
|
||||
// validateFileListPageSize 前置校验 --page-size ∈ [1, maxFileListPageSize],与后端 checkMaxKeys 的 (0, 200] 契约对齐。
|
||||
func validateFileListPageSize(n int) error {
|
||||
if n < 1 || n > maxFileListPageSize {
|
||||
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxFileListPageSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
|
||||
//
|
||||
// GET /apps/{app_id}/storage/file_list。过滤器:--name / --path / --type / --size-gt /
|
||||
// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size/--page-token。
|
||||
// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size(1..200)/--page-token。
|
||||
// file 域不分 dev/online,无 --env。
|
||||
//
|
||||
// pretty 渲染 5 列:file_name / path / size / type / uploaded_at;空结果打 "No files found."。
|
||||
@@ -41,13 +54,17 @@ var AppsFileList = common.Shortcut{
|
||||
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
|
||||
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1..200)"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
// page_size 前置校验:对齐后端 checkMaxKeys 的 (0, 200] 契约,避免 >200 触发服务端 ErrInvalidRequest。
|
||||
if err := validateFileListPageSize(rctx.Int("page-size")); err != nil {
|
||||
return err
|
||||
}
|
||||
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。
|
||||
for _, f := range []string{"uploaded-since", "uploaded-until"} {
|
||||
if strings.TrimSpace(rctx.Str(f)) == "" {
|
||||
|
||||
@@ -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 原样)。
|
||||
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -47,21 +46,7 @@ var AppsFileUpload = common.Shortcut{
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
f := strings.TrimSpace(rctx.Str("file"))
|
||||
if f == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
|
||||
}
|
||||
st, err := rctx.FileIO().Stat(f)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
||||
}
|
||||
if st.IsDir() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
|
||||
}
|
||||
if st.Size() > fileUploadMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
|
||||
}
|
||||
return nil
|
||||
return rctx.ValidateLocalFileFlag("file", fileUploadMaxBytes)
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
@@ -76,9 +61,9 @@ var AppsFileUpload = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
localPath := strings.TrimSpace(rctx.Str("file"))
|
||||
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
|
||||
content, err := rctx.ReadLocalFileFlag("file", fileUploadMaxBytes)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
||||
return err
|
||||
}
|
||||
fileName := filepath.Base(localPath)
|
||||
contentType := mimeByExt(fileName)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -58,22 +59,17 @@ func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_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) {
|
||||
// Validate 会 Stat --file(在 DryRun 之前),故 dry-run 也需要真实存在的文件。
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
|
||||
absolutePath := filepath.Join(t.TempDir(), "logo.png")
|
||||
if err := os.WriteFile(absolutePath, []byte("not-read-by-dry-run"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", absolutePath, "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
@@ -87,6 +83,18 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_DryRunRejectsMissingFile(t *testing.T) {
|
||||
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "logo.png")
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", missingAbsolutePath, "--dry-run", "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 三步直传:pre-upload → 客户端 PUT 字节 → callback。
|
||||
func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
var putBody []byte
|
||||
@@ -149,6 +157,142 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_AcceptsAbsolutePath verifies that file-upload can read an
|
||||
// absolute path outside the current working directory.
|
||||
func TestAppsFileUpload_AcceptsAbsolutePath(t *testing.T) {
|
||||
var putBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
putBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("ETag", `"etag-abs"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Keep the process cwd unchanged so the temporary file is outside it.
|
||||
dir := t.TempDir()
|
||||
absFile := filepath.Join(dir, "report.pdf")
|
||||
if !filepath.IsAbs(absFile) {
|
||||
t.Fatalf("test setup: %q is not absolute", absFile)
|
||||
}
|
||||
if err := os.WriteFile(absFile, []byte("PDFBYTES"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-abs"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "report.pdf", "path": "/1858537546760999.pdf", "size_bytes": 8,
|
||||
}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", absFile, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute with absolute path err=%v", err)
|
||||
}
|
||||
if string(putBody) != "PDFBYTES" {
|
||||
t.Fatalf("PUT body = %q, want file bytes", putBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_AcceptsParentRelativePathOutsideCWD(t *testing.T) {
|
||||
var putBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
putBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("ETag", `"etag-parent"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
workDir := filepath.Join(root, "work")
|
||||
if err := os.Mkdir(workDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte("PARENT"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(workDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-parent"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "report.pdf", "path": "/parent.pdf", "size_bytes": 6,
|
||||
}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", filepath.Join("..", "report.pdf"), "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute with parent-relative path err=%v", err)
|
||||
}
|
||||
if string(putBody) != "PARENT" {
|
||||
t.Fatalf("PUT body = %q, want PARENT", putBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_RejectsFileAboveLimit(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "too-large.bin")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Truncate(fileUploadMaxBytes + 1); err != nil {
|
||||
_ = f.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err = runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", path, "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), "limit") {
|
||||
t.Fatalf("error = %v, want size limit context", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_RejectsDeviceWithoutReadingIt(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("/dev/zero is unavailable on Windows")
|
||||
}
|
||||
if _, err := os.Stat("/dev/zero"); err != nil {
|
||||
t.Skipf("/dev/zero unavailable: %v", err)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "/dev/zero", "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), "regular file") {
|
||||
t.Fatalf("error = %v, want non-regular-file context", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName:空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
|
||||
func TestSanitizeUploadFileName_Cases(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -74,11 +75,9 @@ func normalizeTimestamp(raw string) (string, error) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid timestamp %q (want relative 7d/2h/30s, date 2026-04-15, datetime 2026-04-15T10:00:00, or ISO 8601 with TZ)", s)
|
||||
}
|
||||
|
||||
// newFileTransferClient 直传 / 直下对象存储 presigned URL 用(绕开 Lark 网关,无需 auth、无超时以容纳大文件)。
|
||||
//
|
||||
//nolint:forbidigo // presigned object-storage transfer bypasses the Lark gateway — raw http.Client is required (no Lark auth, no gateway routing); not a Lark API call, so RuntimeContext.DoAPI does not apply.
|
||||
//nolint:forbidigo // Presigned transfers use the external HTTP policy.
|
||||
func newFileTransferClient() *http.Client {
|
||||
return &http.Client{Transport: http.DefaultTransport}
|
||||
return transport.NewExternalHTTPClient(0)
|
||||
}
|
||||
|
||||
// URL helpers for the file (storage) CLI commands.
|
||||
|
||||
79
shortcuts/apps/file_common_test.go
Normal file
79
shortcuts/apps/file_common_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
type appsExternalProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (p appsExternalProvider) Name() string { return "apps-external-test" }
|
||||
|
||||
func (p appsExternalProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
func (appsExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == exttransport.RequestClassExternal
|
||||
}
|
||||
|
||||
type appsExternalInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *appsExternalInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-External-Route", "1")
|
||||
return nil
|
||||
}
|
||||
|
||||
type appsRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f appsRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestFileTransferClientUsesExternalRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARK_CLI_NO_PROXY", "")
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &appsExternalInterceptor{}
|
||||
exttransport.Register(appsExternalProvider{interceptor: interceptor})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
previousTransport := http.DefaultTransport
|
||||
var receivedHeader string
|
||||
http.DefaultTransport = appsRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
receivedHeader = req.Header.Get("X-External-Route")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
t.Cleanup(func() { http.DefaultTransport = previousTransport })
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/presigned/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := newFileTransferClient().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if interceptor.calls != 1 || receivedHeader != "1" {
|
||||
t.Fatalf("external route = calls %d, header %q; want 1, %q", interceptor.calls, receivedHeader, "1")
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,9 @@ func Shortcuts() []common.Shortcut {
|
||||
AppsFileUpload,
|
||||
AppsFileDelete,
|
||||
AppsFileQuotaGet,
|
||||
AppsCacheGet,
|
||||
AppsCacheDelete,
|
||||
AppsCacheClear,
|
||||
AppsGitCredentialInit,
|
||||
AppsGitCredentialList,
|
||||
AppsGitCredentialRemove,
|
||||
|
||||
@@ -20,13 +20,14 @@ import (
|
||||
// - 3 git-credential
|
||||
// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list
|
||||
// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)
|
||||
// - 3 cache(get/delete/clear)
|
||||
// - 3 plugin(install/uninstall/list)
|
||||
// - 6 automation(list/get/create/update/enable/disable)
|
||||
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 79。
|
||||
func TestAppsShortcuts_Returns79(t *testing.T) {
|
||||
// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 82。
|
||||
func TestAppsShortcuts_Returns82(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
if len(got) != 79 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
|
||||
if len(got) != 82 {
|
||||
t.Fatalf("Shortcuts() returned %d entries, want 82", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user