cj-tauri 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -99
- package/bin/cj-tauri.js +287 -287
- package/cjpm.toml +63 -52
- package/cli/cjpm.toml +22 -22
- package/cli/src/doctor.cj +308 -306
- package/cli/src/main.cj +129 -129
- package/cli/src/manifest.cj +632 -558
- package/cli/src/manifest_test.cj +87 -0
- package/cli/src/project.cj +770 -770
- package/cli/src/resolve.cj +348 -348
- package/cli/src/scaffold.cj +369 -369
- package/cli/src/sync.cj +526 -526
- package/cli/src/util.cj +168 -168
- package/cli/templates/app/README.md +51 -48
- package/cli/templates/app/assets/capabilities/default.json +7 -7
- package/cli/templates/app/assets/ui/index.html +120 -118
- package/cli/templates/app/cj-tauri.toml +32 -22
- package/cli/templates/app/cjpm.toml +34 -34
- package/cli/templates/app/src/main.cj +101 -90
- package/cli/templates/app-react/README.md +56 -56
- package/cli/templates/app-react/assets/capabilities/default.json +7 -7
- package/cli/templates/app-react/assets/ui/index.html +25 -25
- package/cli/templates/app-react/assets/ui/package.json +19 -19
- package/cli/templates/app-react/assets/ui/src/App.css +67 -67
- package/cli/templates/app-react/assets/ui/src/App.jsx +124 -124
- package/cli/templates/app-react/assets/ui/src/main.jsx +6 -6
- package/cli/templates/app-react/assets/ui/vite.config.js +24 -24
- package/cli/templates/app-react/cj-tauri.toml +33 -23
- package/cli/templates/app-react/cjpm.toml +34 -34
- package/cli/templates/app-react/src/main.cj +91 -91
- package/cli/templates/app-vue/README.md +56 -56
- package/cli/templates/app-vue/assets/capabilities/default.json +7 -7
- package/cli/templates/app-vue/assets/ui/index.html +25 -25
- package/cli/templates/app-vue/assets/ui/package.json +18 -18
- package/cli/templates/app-vue/assets/ui/src/App.vue +176 -176
- package/cli/templates/app-vue/assets/ui/src/main.js +4 -4
- package/cli/templates/app-vue/assets/ui/vite.config.js +24 -24
- package/cli/templates/app-vue/cj-tauri.toml +33 -23
- package/cli/templates/app-vue/cjpm.toml +34 -34
- package/cli/templates/app-vue/src/main.cj +91 -91
- package/cli/templates/ohos-skeleton/AppScope/app.json5 +13 -13
- package/cli/templates/ohos-skeleton/AppScope/resources/base/element/string.json +8 -8
- package/cli/templates/ohos-skeleton/AppScope/resources/base/media/layered_image.json +6 -6
- package/cli/templates/ohos-skeleton/README.md +84 -79
- package/cli/templates/ohos-skeleton/build-hap.sh +15 -0
- package/cli/templates/ohos-skeleton/build-profile.json5 +46 -46
- package/cli/templates/ohos-skeleton/entry/build-profile.json5 +26 -26
- package/cli/templates/ohos-skeleton/entry/cjpm.toml +51 -43
- package/cli/templates/ohos-skeleton/entry/hvigorfile.ts +6 -6
- package/cli/templates/ohos-skeleton/entry/oh-package.json5 +9 -9
- package/cli/templates/ohos-skeleton/entry/src/main/cangjie/ability_stage.cj +17 -17
- package/cli/templates/ohos-skeleton/entry/src/main/cangjie/app_host.cj +94 -94
- package/cli/templates/ohos-skeleton/entry/src/main/cangjie/dialog_host.cj +217 -0
- package/cli/templates/ohos-skeleton/entry/src/main/cangjie/index.cj +527 -167
- package/cli/templates/ohos-skeleton/entry/src/main/cangjie/main_ability.cj +139 -139
- package/cli/templates/ohos-skeleton/entry/src/main/module.json5 +57 -49
- package/cli/templates/ohos-skeleton/entry/src/main/resources/base/element/color.json +8 -8
- package/cli/templates/ohos-skeleton/entry/src/main/resources/base/element/string.json +16 -16
- package/cli/templates/ohos-skeleton/entry/src/main/resources/base/media/layered_image.json +6 -6
- package/cli/templates/ohos-skeleton/entry/src/main/resources/base/profile/main_pages.json +2 -2
- package/cli/templates/ohos-skeleton/entry/src/main/resources/rawfile/capabilities.json +6 -6
- package/cli/templates/ohos-skeleton/entry/src/main/resources/rawfile/index.html +92 -92
- package/cli/templates/ohos-skeleton/hvigor/hvigor-config.json5 +5 -5
- package/cli/templates/ohos-skeleton/hvigorfile.ts +6 -6
- package/cli/templates/ohos-skeleton/oh-package.json5 +10 -10
- package/native/build_win.bat +62 -62
- package/native/core/bridge_core.c +1681 -1626
- package/native/core/bridge_core.h +675 -618
- package/native/core/bridge_js.h +108 -108
- package/native/platforms/harmonyos/README.md +115 -6
- package/native/platforms/linux/bridge_linux.c +2787 -2485
- package/native/platforms/windows/bridge_win.c +3740 -3248
- package/native/tests/gtk_threading_probe.c +143 -143
- package/native/tests/test_bridge_core.c +1339 -1228
- package/package.json +53 -53
- package/prebuilt/linux-x64/cj-tauri +0 -0
- package/src/api_system.cj +67 -67
- package/src/app.cj +325 -969
- package/src/app_events.cj +166 -0
- package/src/app_host_ops.cj +287 -0
- package/src/app_run.cj +283 -0
- package/src/app_spec.cj +76 -76
- package/src/arkweb_menu.cj +216 -0
- package/src/arkweb_script.cj +142 -142
- package/src/capability.cj +409 -409
- package/src/clipboard_bridge.cj +178 -0
- package/src/dialog_spec.cj +79 -0
- package/src/dragdrop.cj +26 -26
- package/src/embedded_assets.cj +98 -98
- package/src/host.cj +752 -607
- package/src/host_arkweb.cj +924 -778
- package/src/host_webkit.cj +663 -570
- package/src/host_webview2.cj +663 -570
- package/src/hotkey.cj +66 -66
- package/src/ipc_hub.cj +337 -337
- package/src/ipc_message.cj +286 -286
- package/src/launch.cj +62 -62
- package/src/logging.cj +127 -127
- package/src/menu.cj +197 -197
- package/src/platform/platform_app_context.cj +77 -77
- package/src/platform/platform_assets.cj +33 -33
- package/src/platform/platform_discover.cj +176 -176
- package/src/platform/platform_env.cj +29 -29
- package/src/platform/platform_feature.cj +31 -31
- package/src/platform/platform_page_source.cj +87 -87
- package/src/platform/platform_target_info.cj +32 -32
- package/src/platform_host_provider.cj +87 -87
- package/src/plugin.cj +333 -333
- package/src/plugins/clipboard/plugin_clipboard.cj +152 -152
- package/src/plugins/dialog/plugin_dialog.cj +209 -209
- package/src/plugins/fs/plugin_fs.cj +105 -1
- package/src/plugins/http/http_backend.cj +131 -0
- package/src/plugins/http/http_desktop.cj +195 -0
- package/src/plugins/http/http_ohos.cj +204 -0
- package/src/plugins/http/plugin_http.cj +310 -459
- package/src/plugins/log/plugin_log.cj +105 -105
- package/src/plugins/menu/plugin_menu.cj +158 -158
- package/src/plugins/notification/plugin_notification.cj +154 -154
- package/src/plugins/serial/plugin_serial.cj +458 -454
- package/src/plugins/serial/serial_bridge.cj +194 -194
- package/src/plugins/serial/serial_manager_ohos.cj +92 -0
- package/src/plugins/serial/serial_ohos.cj +529 -0
- package/src/plugins/shell/plugin_shell.cj +258 -258
- package/src/plugins/sqlite/plugin_sqlite.cj +705 -710
- package/src/plugins/sqlite/sqlite_ffi.cj +206 -211
- package/src/plugins/ws/plugin_ws.cj +488 -470
- package/src/process_hidden.cj +174 -174
- package/src/scope.cj +391 -391
- package/src/shell_event.cj +69 -69
- package/src/tests/app_spec_test.cj +126 -127
- package/src/tests/capability_test.cj +243 -243
- package/src/tests/dialog_spec_test.cj +73 -0
- package/src/tests/fake_host.cj +201 -201
- package/src/tests/ipc_hub_test.cj +487 -487
- package/src/tests/logging_test.cj +62 -62
- package/src/tests/platform_contract_test.cj +251 -251
- package/src/tests/plugin_clipboard_test.cj +222 -222
- package/src/tests/plugin_dialog_test.cj +237 -237
- package/src/tests/plugin_fs_test.cj +207 -0
- package/src/tests/plugin_http_test.cj +124 -124
- package/src/tests/plugin_notification_test.cj +205 -205
- package/src/tests/plugin_serial_test.cj +48 -0
- package/src/tests/plugin_test.cj +605 -585
- package/src/tests/plugin_ws_test.cj +40 -40
- package/src/tests/process_hidden_test.cj +129 -129
- package/src/tests/scope_test.cj +350 -350
- package/src/tests/system_host_test.cj +99 -81
- package/src/tests/urlutil_test.cj +53 -53
- package/src/tests/version_test.cj +75 -75
- package/src/tests/window_registry_test.cj +123 -123
- package/src/tray.cj +90 -90
- package/src/urlutil.cj +159 -159
- package/src/version.cj +44 -44
- package/src/window.cj +51 -51
- package/src/window_registry.cj +116 -116
- package/prebuilt/win32-x64/cj-tauri.exe +0 -0
- package/src/plugins/serial/serial_ohos_stub.cj +0 -61
- package/src/plugins/sqlite/sqlite_ohos_stub.cj +0 -91
package/src/tests/scope_test.cj
CHANGED
|
@@ -1,350 +1,350 @@
|
|
|
1
|
-
package cjTauri.tests
|
|
2
|
-
|
|
3
|
-
import cjTauri.*
|
|
4
|
-
import cjTauri.plugins.fs.*
|
|
5
|
-
import std.collection.*
|
|
6
|
-
import std.env.*
|
|
7
|
-
import std.fs.*
|
|
8
|
-
import stdx.encoding.json.*
|
|
9
|
-
import std.unittest.*
|
|
10
|
-
import std.unittest.testmacro.*
|
|
11
|
-
|
|
12
|
-
/*
|
|
13
|
-
* 路径 scope(RFC-003 第 4 期)单元测试。
|
|
14
|
-
*
|
|
15
|
-
* 锁住四件事:glob 语义(`*` 不跨分隔符、`**` 跨、`?` 吃一个**码点**)、变量展开(认不出就原文保留
|
|
16
|
-
* = 失败即拒)、规范化(折叠 `.` 与 `..`)、判定顺序(deny 优先)。
|
|
17
|
-
* 末尾是端到端对照组:同一份 fs 清单下,白名单内可读写、越界即拒、没写 scope 时旧行为照旧。
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
func scopeRuleOf(allow: Array<String>, deny: Array<String>): ScopeRule {
|
|
21
|
-
let r = ScopeRule()
|
|
22
|
-
for (a in allow) {
|
|
23
|
-
r.allow.add(a)
|
|
24
|
-
}
|
|
25
|
-
for (d in deny) {
|
|
26
|
-
r.deny.add(d)
|
|
27
|
-
}
|
|
28
|
-
return r
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
@Test
|
|
32
|
-
func testGlobStarDoesNotCrossSeparator(): Unit {
|
|
33
|
-
@Expect(globMatch("/a/b.txt", "/a/b.txt"), true)
|
|
34
|
-
@Expect(globMatch("/a/*.txt", "/a/b.txt"), true)
|
|
35
|
-
@Expect(globMatch("/a/*.txt", "/a/sub/b.txt"), false)
|
|
36
|
-
@Expect(globMatch("/a/*", "/a/sub"), true)
|
|
37
|
-
@Expect(globMatch("/a/*", "/a/sub/b"), false)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
@Test
|
|
41
|
-
func testGlobDoubleStarCrossesSeparator(): Unit {
|
|
42
|
-
@Expect(globMatch("/a/**", "/a/x/y/z"), true)
|
|
43
|
-
@Expect(globMatch("/a/**/b.txt", "/a/x/y/b.txt"), true)
|
|
44
|
-
@Expect(globMatch("/a/**/b.txt", "/a/b.txt"), true)
|
|
45
|
-
@Expect(globMatch("/a/**/b.txt", "/a/x/y/c.txt"), false)
|
|
46
|
-
@Expect(globMatch("/**", "/anything/at/all"), true)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
@Test
|
|
50
|
-
func testGlobQuestionMatchesOneCodePoint(): Unit {
|
|
51
|
-
@Expect(globMatch("/a/?.txt", "/a/b.txt"), true)
|
|
52
|
-
// 汉字是 3 字节:`?` 必须吃掉整个码点,按字节吃会把它切碎
|
|
53
|
-
@Expect(globMatch("/a/?.txt", "/a/中.txt"), true)
|
|
54
|
-
@Expect(globMatch("/a/??.txt", "/a/中.txt"), false)
|
|
55
|
-
// `?` 不吃分隔符
|
|
56
|
-
@Expect(globMatch("/a/x?", "/a/x/"), false)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
@Test
|
|
60
|
-
func testExpandScopeVars(): Unit {
|
|
61
|
-
// $CWD 就是进程工作目录
|
|
62
|
-
@Expect(expandScopeVars("$CWD/x"), scopeCwdPath() + "/x")
|
|
63
|
-
// $HOME 按环境变量展开(环境里没有就原样保留,绝不悄悄放宽)
|
|
64
|
-
match (getVariable("HOME")) {
|
|
65
|
-
case Some(home) => @Expect(expandScopeVars("$HOME/notes"), home + "/notes")
|
|
66
|
-
case None => @Expect(expandScopeVars("$HOME/notes"), "$HOME/notes")
|
|
67
|
-
}
|
|
68
|
-
// 认不出的变量与孤立的 $ 都原样保留 = 模式匹配不上 = 拒
|
|
69
|
-
@Expect(expandScopeVars("$NOPE/x"), "$NOPE/x")
|
|
70
|
-
@Expect(expandScopeVars("/a$/b"), "/a$/b")
|
|
71
|
-
// 不含变量时原样返回
|
|
72
|
-
@Expect(expandScopeVars("/plain/path"), "/plain/path")
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
@Test
|
|
76
|
-
func testNormalizeCollapsesLexicalDots(): Unit {
|
|
77
|
-
let base = scopeCwdPath()
|
|
78
|
-
@Expect(normalizeScopePath("target/./scope-ok.txt"), base + "/target/scope-ok.txt")
|
|
79
|
-
// `..` 折叠:绕不过白名单
|
|
80
|
-
@Expect(normalizeScopePath("target/sub/../../outside.txt"), base + "/outside.txt")
|
|
81
|
-
// 绝对路径原样(不存在的也成立)
|
|
82
|
-
@Expect(normalizeScopePath("/tmp/../etc/passwd"), "/etc/passwd")
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
@Test
|
|
86
|
-
func testScopeAllowsDenyWins(): Unit {
|
|
87
|
-
let rule = scopeRuleOf(["/data/**"], ["/data/secret/**"])
|
|
88
|
-
@Expect(scopeAllows(rule, "/data/ok.txt"), true)
|
|
89
|
-
@Expect(scopeAllows(rule, "/data/secret/x.txt"), false)
|
|
90
|
-
@Expect(scopeAllows(rule, "/etc/passwd"), false)
|
|
91
|
-
// 空 allow = 全拒(显式最小权限,不做「空即放行」)
|
|
92
|
-
let empty = scopeRuleOf([], ["/x"])
|
|
93
|
-
@Expect(scopeAllows(empty, "/anything"), false)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
@Test
|
|
97
|
-
func testCapabilityParsesScopesAndRegistryUnions(): Unit {
|
|
98
|
-
let raw = "{\"identifier\":\"a\",\"commands\":[\"fs:readText\"]," +
|
|
99
|
-
"\"scopes\":{\"fs\":{\"allow\":[\"/one/**\"],\"deny\":[\"/one/bad\"]}}}"
|
|
100
|
-
let raw2 = "{\"identifier\":\"b\",\"commands\":[\"fs:readText\"]," +
|
|
101
|
-
"\"scopes\":{\"fs\":{\"allow\":[\"/two/**\"]}}}"
|
|
102
|
-
let reg = CapabilityRegistry()
|
|
103
|
-
@Expect(reg.addFromJson(JsonValue.fromStr(raw)), true)
|
|
104
|
-
@Expect(reg.addFromJson(JsonValue.fromStr(raw2)), true)
|
|
105
|
-
@Expect(reg.hasScopeForPlugin("fs"), true)
|
|
106
|
-
@Expect(reg.hasScopeForPlugin("other"), false)
|
|
107
|
-
let merged = reg.scopeForPlugin("fs").getOrThrow()
|
|
108
|
-
// 并集:两份清单的 allow/deny 都收进来(与 canInvoke 的「任一放行即放行」同口径)
|
|
109
|
-
@Expect(merged.allow.size, 2)
|
|
110
|
-
@Expect(merged.deny.size, 1)
|
|
111
|
-
@Expect(scopeAllows(merged, "/two/x"), true)
|
|
112
|
-
@Expect(scopeAllows(merged, "/one/bad"), false)
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
@Test
|
|
116
|
-
func testScopeRuleAcceptsBareStringForm(): Unit {
|
|
117
|
-
let rule = parseScopeRule(JsonValue.fromStr("{\"allow\":\"/plain/**\"}")).getOrThrow()
|
|
118
|
-
@Expect(rule.allow.size, 1)
|
|
119
|
-
@Expect(rule.allow[0], "/plain/**")
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
@Test
|
|
123
|
-
func testScopeRuleNormalizesManifestSlashes(): Unit {
|
|
124
|
-
// Windows 回归(2026-10-14 实测):清单模式里的 $HOME 在 Windows 展开出 USERPROFILE 原值
|
|
125
|
-
// (反斜杠),不归一的话拿它匹配 normalizeScopePath 的正斜杠结果永远不中——$HOME/** 形同虚设。
|
|
126
|
-
// 用显式反斜杠模式锁住「清单模式也过同款分隔符归一」这个口径,两个平台都成立。
|
|
127
|
-
let rule = parseScopeRule(
|
|
128
|
-
JsonValue.fromStr("{\"allow\":[\"C:\\\\Users\\\\yang\\\\**\",\"$CWD\\\\notes\"]}")).getOrThrow()
|
|
129
|
-
@Expect(rule.allow[0], "C:/Users/yang/**")
|
|
130
|
-
@Expect(rule.allow[1], scopeCwdPath() + "/notes")
|
|
131
|
-
// 归一之后真的能匹配正斜杠目标。大小写折叠只在 Windows 锁——foldCase 的设计口径是
|
|
132
|
-
// 「Linux 文件系统大小写敏感,globMatch 保持敏感」,这条断言按平台分支,别把 Linux 锁死。
|
|
133
|
-
let rxTarget = if (scopeCaseFoldsOnThisPlatform()) {
|
|
134
|
-
"c:/users/yang/desktop/note.md"
|
|
135
|
-
} else {
|
|
136
|
-
"C:/Users/yang/desktop/note.md"
|
|
137
|
-
}
|
|
138
|
-
@Expect(globMatch(rule.allow[0], rxTarget), true)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** 本平台 globMatch 是否折叠大小写(与 scope.cj foldCase 的平台口径一致,测试专用) */
|
|
142
|
-
@When[os == "Windows"]
|
|
143
|
-
func scopeCaseFoldsOnThisPlatform(): Bool {
|
|
144
|
-
return true
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
@When[os == "Linux"]
|
|
148
|
-
func scopeCaseFoldsOnThisPlatform(): Bool {
|
|
149
|
-
return false
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/* ===== 端到端对照组:走真实 fs 插件 + 真实文件 ===== */
|
|
153
|
-
|
|
154
|
-
func fsHandler(shortName: String): CommandHandler {
|
|
155
|
-
return FsPlugin().commands().get(shortName).getOrThrow()
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
func fsCtxWithScopes(allow: Array<String>, deny: Array<String>): IpcContext {
|
|
159
|
-
let json = JsonObject()
|
|
160
|
-
json.put("identifier", JsonString("probe"))
|
|
161
|
-
let cmds = JsonArray()
|
|
162
|
-
cmds.add(JsonString("fs:readText"))
|
|
163
|
-
cmds.add(JsonString("fs:writeText"))
|
|
164
|
-
json.put("commands", cmds)
|
|
165
|
-
let rule = JsonObject()
|
|
166
|
-
let arr = JsonArray()
|
|
167
|
-
for (a in allow) {
|
|
168
|
-
arr.add(JsonString(a))
|
|
169
|
-
}
|
|
170
|
-
rule.put("allow", arr)
|
|
171
|
-
let darr = JsonArray()
|
|
172
|
-
for (d in deny) {
|
|
173
|
-
darr.add(JsonString(d))
|
|
174
|
-
}
|
|
175
|
-
if (deny.size > 0) {
|
|
176
|
-
rule.put("deny", darr)
|
|
177
|
-
}
|
|
178
|
-
let scopes = JsonObject()
|
|
179
|
-
scopes.put("fs", rule)
|
|
180
|
-
json.put("scopes", scopes)
|
|
181
|
-
let reg = CapabilityRegistry()
|
|
182
|
-
reg.add(Capability.fromJson(json).getOrThrow())
|
|
183
|
-
let hub = IpcHub()
|
|
184
|
-
hub.setCapabilities(reg)
|
|
185
|
-
return IpcContext("main", hub)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
func fsCtxLegacy(): IpcContext {
|
|
189
|
-
let json = JsonObject()
|
|
190
|
-
json.put("identifier", JsonString("legacy"))
|
|
191
|
-
let cmds = JsonArray()
|
|
192
|
-
cmds.add(JsonString("fs:writeText"))
|
|
193
|
-
json.put("commands", cmds)
|
|
194
|
-
let reg = CapabilityRegistry()
|
|
195
|
-
reg.add(Capability.fromJson(json).getOrThrow())
|
|
196
|
-
let hub = IpcHub()
|
|
197
|
-
hub.setCapabilities(reg)
|
|
198
|
-
return IpcContext("main", hub)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** JsonKind 没有 ==(比较走 match),断言里需要判类型时用这个 */
|
|
202
|
-
func isJsonBool(v: JsonValue): Bool {
|
|
203
|
-
match (v.kind()) {
|
|
204
|
-
case JsonKind.JsBool => return true
|
|
205
|
-
case _ => return false
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
func fsWriteArgs(path: String, contents: String): JsonObject {
|
|
210
|
-
let args = JsonObject()
|
|
211
|
-
args.put("path", JsonString(path))
|
|
212
|
-
args.put("contents", JsonString(contents))
|
|
213
|
-
return args
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
func fsErrorMessage(handler: CommandHandler, cmd: String, args: JsonObject, ctx: IpcContext): String {
|
|
217
|
-
try {
|
|
218
|
-
handler.handle(cmd, args, ctx)
|
|
219
|
-
return ""
|
|
220
|
-
} catch (e: CommandException) {
|
|
221
|
-
return e.message
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
@Test
|
|
226
|
-
func testFsScopeAllowsInsideWriteAndRead(): Unit {
|
|
227
|
-
let ctx = fsCtxWithScopes([scopeCwdPath() + "/target/**"], [])
|
|
228
|
-
let writer = fsHandler("writeText")
|
|
229
|
-
let reader = fsHandler("readText")
|
|
230
|
-
let path = "target/scope-probe-ok.txt"
|
|
231
|
-
@Expect(isJsonBool(writer.handle("fs:writeText", fsWriteArgs(path, "scope payload"), ctx)), true)
|
|
232
|
-
let got = reader.handle("fs:readText", fsWriteArgs(path, ""), ctx)
|
|
233
|
-
@Expect(got.asString().getValue(), "scope payload")
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
@Test
|
|
237
|
-
func testFsScopeDeniesOutsideAllowList(): Unit {
|
|
238
|
-
// 对照组:同一份清单,路径出了白名单 → 拒(错误可读,不是崩)
|
|
239
|
-
let ctx = fsCtxWithScopes([scopeCwdPath() + "/target/**"], [])
|
|
240
|
-
let msg = fsErrorMessage(fsHandler("writeText"), "fs:writeText",
|
|
241
|
-
fsWriteArgs("target-scope-outside.txt", "nope"), ctx)
|
|
242
|
-
@Expect(msg.contains("outside the authorized scope"), true)
|
|
243
|
-
// 真实文件没被创建(拒发生在写之前)
|
|
244
|
-
@Expect(exists(scopeCwdPath() + "/target-scope-outside.txt"), false)
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
@Test
|
|
248
|
-
func testFsScopeDenyListWins(): Unit {
|
|
249
|
-
let ctx = fsCtxWithScopes([scopeCwdPath() + "/target/**"], [scopeCwdPath() + "/target/**blocked*"])
|
|
250
|
-
let msg = fsErrorMessage(fsHandler("writeText"), "fs:writeText",
|
|
251
|
-
fsWriteArgs("target/blocked-scope.txt", "nope"), ctx)
|
|
252
|
-
@Expect(msg.contains("outside the authorized scope"), true)
|
|
253
|
-
@Expect(exists(scopeCwdPath() + "/target/blocked-scope.txt"), false)
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
@Test
|
|
257
|
-
func testFsScopeCannotEscapeWithDotDot(): Unit {
|
|
258
|
-
// `..` 绕不过:规范化之后按绝对路径判定
|
|
259
|
-
let ctx = fsCtxWithScopes([scopeCwdPath() + "/target/**"], [])
|
|
260
|
-
let msg = fsErrorMessage(fsHandler("writeText"), "fs:writeText",
|
|
261
|
-
fsWriteArgs("target/../target-scope-escape.txt", "nope"), ctx)
|
|
262
|
-
@Expect(msg.contains("outside the authorized scope"), true)
|
|
263
|
-
@Expect(exists(scopeCwdPath() + "/target-scope-escape.txt"), false)
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
@Test
|
|
267
|
-
func testLegacyManifestWithoutScopesStillWorks(): Unit {
|
|
268
|
-
// 旧清单(没写 scopes):行为不变——全量放行,只是启动时有一行 stderr 提示
|
|
269
|
-
let ctx = fsCtxLegacy()
|
|
270
|
-
let path = "target/scope-legacy-ok.txt"
|
|
271
|
-
let out = fsHandler("writeText").handle("fs:writeText", fsWriteArgs(path, "legacy"), ctx)
|
|
272
|
-
@Expect(isJsonBool(out), true)
|
|
273
|
-
@Expect(exists(scopeCwdPath() + "/target/scope-legacy-ok.txt"), true)
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
@Test
|
|
277
|
-
func testScopeAwareIsDeclaredOnlyByFsPlugin(): Unit {
|
|
278
|
-
// 只有 fs 声明吃 scope:别的插件不该被这层校验影响(框架不替插件猜哪个参数是路径)
|
|
279
|
-
@Expect(FsPlugin().scopeAware(), true)
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
@Test
|
|
283
|
-
func testScopeSymlinkEscapeIsDenied(): Unit {
|
|
284
|
-
// SEC-003(软链接逃逸):白名单目录里一条指向外面的软链接——
|
|
285
|
-
// 规范化会解析软链接,落点出白名单就必须拒(读和写都拒)
|
|
286
|
-
let base = scopeCwdPath()
|
|
287
|
-
let outside = "${base}/target-sym-escape.txt"
|
|
288
|
-
// 上轮运行可能留下同名目录/软链接(Directory.create 遇已存在会抛),先清场
|
|
289
|
-
removeIfExists("${base}/target/scope-sym/escape.txt")
|
|
290
|
-
removeIfExists("${base}/target/scope-sym")
|
|
291
|
-
removeIfExists(outside)
|
|
292
|
-
Directory.create("${base}/target/scope-sym", recursive: true)
|
|
293
|
-
let hi: Array<UInt8> = [104u8, 105u8] // "hi"
|
|
294
|
-
File.writeTo(outside, hi)
|
|
295
|
-
// 建完链路再把软链接指过去(目标已存在,Windows 也稳)
|
|
296
|
-
// Windows 无开发者特权时建链被系统拒(错误 1314):这是环境限制不是判定缺陷,
|
|
297
|
-
// 如实跳过并打一行说明——该用例的判定面由 Linux 轮覆盖
|
|
298
|
-
try {
|
|
299
|
-
SymbolicLink.create("${base}/target/scope-sym/escape.txt", to: outside)
|
|
300
|
-
} catch (e: Exception) {
|
|
301
|
-
eprintln("[skip] testScopeSymlinkEscapeIsDenied: 本机无法创建符号链接(${e.message})")
|
|
302
|
-
removeIfExists(outside)
|
|
303
|
-
return
|
|
304
|
-
}
|
|
305
|
-
let ctx = fsCtxWithScopes([scopeCwdPath() + "/target/**"], [])
|
|
306
|
-
let msg = fsErrorMessage(fsHandler("readText"), "fs:readText",
|
|
307
|
-
fsWriteArgs("target/scope-sym/escape.txt", ""), ctx)
|
|
308
|
-
@Expect(msg.contains("outside the authorized scope"), true)
|
|
309
|
-
removeIfExists(outside)
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
@Test
|
|
313
|
-
func testScopeSymlinkInsideIsAllowed(): Unit {
|
|
314
|
-
// 对照组:软链接落点还在白名单内 → 放行(证「拒」确实来自越界判定,不是见了软链接就拒)
|
|
315
|
-
let base = scopeCwdPath()
|
|
316
|
-
removeIfExists("${base}/target/scope-sym2/link.txt")
|
|
317
|
-
removeIfExists("${base}/target/scope-sym2/inner.txt")
|
|
318
|
-
removeIfExists("${base}/target/scope-sym2")
|
|
319
|
-
Directory.create("${base}/target/scope-sym2", recursive: true)
|
|
320
|
-
let inner: Array<UInt8> = [104u8, 105u8]
|
|
321
|
-
File.writeTo("${base}/target/scope-sym2/inner.txt", inner)
|
|
322
|
-
// 同上:Windows 无特权建链被拒时如实跳过(Linux 轮覆盖判定面)
|
|
323
|
-
try {
|
|
324
|
-
SymbolicLink.create("${base}/target/scope-sym2/link.txt", to: "${base}/target/scope-sym2/inner.txt")
|
|
325
|
-
} catch (e: Exception) {
|
|
326
|
-
eprintln("[skip] testScopeSymlinkInsideIsAllowed: 本机无法创建符号链接(${e.message})")
|
|
327
|
-
return
|
|
328
|
-
}
|
|
329
|
-
let ctx = fsCtxWithScopes([scopeCwdPath() + "/target/**"], [])
|
|
330
|
-
let out = fsHandler("readText").handle("fs:readText", fsWriteArgs("target/scope-sym2/link.txt", ""), ctx)
|
|
331
|
-
@Expect(out.asString().getValue(), "hi")
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
@Test
|
|
335
|
-
func testScopePercentEncodedPathIsNotPreDecoded(): Unit {
|
|
336
|
-
// SEC-003(百分比编码):scope 判定不做 %XX 解码——`a%2F..%2Fb` 是一个字面量文件名,
|
|
337
|
-
// 不是 `a/../b` 的伪装。反转义应当在「插件自己处理 URI 载荷」时做(如 drop 链路在 C 桥 core),
|
|
338
|
-
// scope 层对着的是「应用/插件已经解析完的路径」,两层各管各的语义,不重复解码也不漏解码
|
|
339
|
-
let ctx = fsCtxWithScopes([scopeCwdPath() + "/target/**"], [])
|
|
340
|
-
// 文件名里塞 %2e%2e(`..` 的百分比编码):scope 层不做 %XX 解码,
|
|
341
|
-
// 它就是一个字面量文件名——不会被当 `../` 穿越判定,正常落在 target/ 下
|
|
342
|
-
@Expect(isJsonBool(fsHandler("writeText").handle("fs:writeText",
|
|
343
|
-
fsWriteArgs("target/percent%2e%2e-escape.txt", "ok"), ctx)), true)
|
|
344
|
-
@Expect(exists(scopeCwdPath() + "/target/percent%2e%2e-escape.txt"), true)
|
|
345
|
-
// 对照:真正的 `..` 穿越会被拒(既有用例已覆盖,这里验证编码形态不享受同款待遇)
|
|
346
|
-
let msg = fsErrorMessage(fsHandler("writeText"), "fs:writeText",
|
|
347
|
-
fsWriteArgs("target/../../percent-real-escape.txt", "nope"), ctx)
|
|
348
|
-
@Expect(msg.contains("outside the authorized scope"), true)
|
|
349
|
-
@Expect(exists(scopeCwdPath() + "/percent-real-escape.txt"), false)
|
|
350
|
-
}
|
|
1
|
+
package cjTauri.tests
|
|
2
|
+
|
|
3
|
+
import cjTauri.*
|
|
4
|
+
import cjTauri.plugins.fs.*
|
|
5
|
+
import std.collection.*
|
|
6
|
+
import std.env.*
|
|
7
|
+
import std.fs.*
|
|
8
|
+
import stdx.encoding.json.*
|
|
9
|
+
import std.unittest.*
|
|
10
|
+
import std.unittest.testmacro.*
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* 路径 scope(RFC-003 第 4 期)单元测试。
|
|
14
|
+
*
|
|
15
|
+
* 锁住四件事:glob 语义(`*` 不跨分隔符、`**` 跨、`?` 吃一个**码点**)、变量展开(认不出就原文保留
|
|
16
|
+
* = 失败即拒)、规范化(折叠 `.` 与 `..`)、判定顺序(deny 优先)。
|
|
17
|
+
* 末尾是端到端对照组:同一份 fs 清单下,白名单内可读写、越界即拒、没写 scope 时旧行为照旧。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
func scopeRuleOf(allow: Array<String>, deny: Array<String>): ScopeRule {
|
|
21
|
+
let r = ScopeRule()
|
|
22
|
+
for (a in allow) {
|
|
23
|
+
r.allow.add(a)
|
|
24
|
+
}
|
|
25
|
+
for (d in deny) {
|
|
26
|
+
r.deny.add(d)
|
|
27
|
+
}
|
|
28
|
+
return r
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Test
|
|
32
|
+
func testGlobStarDoesNotCrossSeparator(): Unit {
|
|
33
|
+
@Expect(globMatch("/a/b.txt", "/a/b.txt"), true)
|
|
34
|
+
@Expect(globMatch("/a/*.txt", "/a/b.txt"), true)
|
|
35
|
+
@Expect(globMatch("/a/*.txt", "/a/sub/b.txt"), false)
|
|
36
|
+
@Expect(globMatch("/a/*", "/a/sub"), true)
|
|
37
|
+
@Expect(globMatch("/a/*", "/a/sub/b"), false)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Test
|
|
41
|
+
func testGlobDoubleStarCrossesSeparator(): Unit {
|
|
42
|
+
@Expect(globMatch("/a/**", "/a/x/y/z"), true)
|
|
43
|
+
@Expect(globMatch("/a/**/b.txt", "/a/x/y/b.txt"), true)
|
|
44
|
+
@Expect(globMatch("/a/**/b.txt", "/a/b.txt"), true)
|
|
45
|
+
@Expect(globMatch("/a/**/b.txt", "/a/x/y/c.txt"), false)
|
|
46
|
+
@Expect(globMatch("/**", "/anything/at/all"), true)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@Test
|
|
50
|
+
func testGlobQuestionMatchesOneCodePoint(): Unit {
|
|
51
|
+
@Expect(globMatch("/a/?.txt", "/a/b.txt"), true)
|
|
52
|
+
// 汉字是 3 字节:`?` 必须吃掉整个码点,按字节吃会把它切碎
|
|
53
|
+
@Expect(globMatch("/a/?.txt", "/a/中.txt"), true)
|
|
54
|
+
@Expect(globMatch("/a/??.txt", "/a/中.txt"), false)
|
|
55
|
+
// `?` 不吃分隔符
|
|
56
|
+
@Expect(globMatch("/a/x?", "/a/x/"), false)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@Test
|
|
60
|
+
func testExpandScopeVars(): Unit {
|
|
61
|
+
// $CWD 就是进程工作目录
|
|
62
|
+
@Expect(expandScopeVars("$CWD/x"), scopeCwdPath() + "/x")
|
|
63
|
+
// $HOME 按环境变量展开(环境里没有就原样保留,绝不悄悄放宽)
|
|
64
|
+
match (getVariable("HOME")) {
|
|
65
|
+
case Some(home) => @Expect(expandScopeVars("$HOME/notes"), home + "/notes")
|
|
66
|
+
case None => @Expect(expandScopeVars("$HOME/notes"), "$HOME/notes")
|
|
67
|
+
}
|
|
68
|
+
// 认不出的变量与孤立的 $ 都原样保留 = 模式匹配不上 = 拒
|
|
69
|
+
@Expect(expandScopeVars("$NOPE/x"), "$NOPE/x")
|
|
70
|
+
@Expect(expandScopeVars("/a$/b"), "/a$/b")
|
|
71
|
+
// 不含变量时原样返回
|
|
72
|
+
@Expect(expandScopeVars("/plain/path"), "/plain/path")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@Test
|
|
76
|
+
func testNormalizeCollapsesLexicalDots(): Unit {
|
|
77
|
+
let base = scopeCwdPath()
|
|
78
|
+
@Expect(normalizeScopePath("target/./scope-ok.txt"), base + "/target/scope-ok.txt")
|
|
79
|
+
// `..` 折叠:绕不过白名单
|
|
80
|
+
@Expect(normalizeScopePath("target/sub/../../outside.txt"), base + "/outside.txt")
|
|
81
|
+
// 绝对路径原样(不存在的也成立)
|
|
82
|
+
@Expect(normalizeScopePath("/tmp/../etc/passwd"), "/etc/passwd")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Test
|
|
86
|
+
func testScopeAllowsDenyWins(): Unit {
|
|
87
|
+
let rule = scopeRuleOf(["/data/**"], ["/data/secret/**"])
|
|
88
|
+
@Expect(scopeAllows(rule, "/data/ok.txt"), true)
|
|
89
|
+
@Expect(scopeAllows(rule, "/data/secret/x.txt"), false)
|
|
90
|
+
@Expect(scopeAllows(rule, "/etc/passwd"), false)
|
|
91
|
+
// 空 allow = 全拒(显式最小权限,不做「空即放行」)
|
|
92
|
+
let empty = scopeRuleOf([], ["/x"])
|
|
93
|
+
@Expect(scopeAllows(empty, "/anything"), false)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@Test
|
|
97
|
+
func testCapabilityParsesScopesAndRegistryUnions(): Unit {
|
|
98
|
+
let raw = "{\"identifier\":\"a\",\"commands\":[\"fs:readText\"]," +
|
|
99
|
+
"\"scopes\":{\"fs\":{\"allow\":[\"/one/**\"],\"deny\":[\"/one/bad\"]}}}"
|
|
100
|
+
let raw2 = "{\"identifier\":\"b\",\"commands\":[\"fs:readText\"]," +
|
|
101
|
+
"\"scopes\":{\"fs\":{\"allow\":[\"/two/**\"]}}}"
|
|
102
|
+
let reg = CapabilityRegistry()
|
|
103
|
+
@Expect(reg.addFromJson(JsonValue.fromStr(raw)), true)
|
|
104
|
+
@Expect(reg.addFromJson(JsonValue.fromStr(raw2)), true)
|
|
105
|
+
@Expect(reg.hasScopeForPlugin("fs"), true)
|
|
106
|
+
@Expect(reg.hasScopeForPlugin("other"), false)
|
|
107
|
+
let merged = reg.scopeForPlugin("fs").getOrThrow()
|
|
108
|
+
// 并集:两份清单的 allow/deny 都收进来(与 canInvoke 的「任一放行即放行」同口径)
|
|
109
|
+
@Expect(merged.allow.size, 2)
|
|
110
|
+
@Expect(merged.deny.size, 1)
|
|
111
|
+
@Expect(scopeAllows(merged, "/two/x"), true)
|
|
112
|
+
@Expect(scopeAllows(merged, "/one/bad"), false)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@Test
|
|
116
|
+
func testScopeRuleAcceptsBareStringForm(): Unit {
|
|
117
|
+
let rule = parseScopeRule(JsonValue.fromStr("{\"allow\":\"/plain/**\"}")).getOrThrow()
|
|
118
|
+
@Expect(rule.allow.size, 1)
|
|
119
|
+
@Expect(rule.allow[0], "/plain/**")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@Test
|
|
123
|
+
func testScopeRuleNormalizesManifestSlashes(): Unit {
|
|
124
|
+
// Windows 回归(2026-10-14 实测):清单模式里的 $HOME 在 Windows 展开出 USERPROFILE 原值
|
|
125
|
+
// (反斜杠),不归一的话拿它匹配 normalizeScopePath 的正斜杠结果永远不中——$HOME/** 形同虚设。
|
|
126
|
+
// 用显式反斜杠模式锁住「清单模式也过同款分隔符归一」这个口径,两个平台都成立。
|
|
127
|
+
let rule = parseScopeRule(
|
|
128
|
+
JsonValue.fromStr("{\"allow\":[\"C:\\\\Users\\\\yang\\\\**\",\"$CWD\\\\notes\"]}")).getOrThrow()
|
|
129
|
+
@Expect(rule.allow[0], "C:/Users/yang/**")
|
|
130
|
+
@Expect(rule.allow[1], scopeCwdPath() + "/notes")
|
|
131
|
+
// 归一之后真的能匹配正斜杠目标。大小写折叠只在 Windows 锁——foldCase 的设计口径是
|
|
132
|
+
// 「Linux 文件系统大小写敏感,globMatch 保持敏感」,这条断言按平台分支,别把 Linux 锁死。
|
|
133
|
+
let rxTarget = if (scopeCaseFoldsOnThisPlatform()) {
|
|
134
|
+
"c:/users/yang/desktop/note.md"
|
|
135
|
+
} else {
|
|
136
|
+
"C:/Users/yang/desktop/note.md"
|
|
137
|
+
}
|
|
138
|
+
@Expect(globMatch(rule.allow[0], rxTarget), true)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 本平台 globMatch 是否折叠大小写(与 scope.cj foldCase 的平台口径一致,测试专用) */
|
|
142
|
+
@When[os == "Windows"]
|
|
143
|
+
func scopeCaseFoldsOnThisPlatform(): Bool {
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@When[os == "Linux"]
|
|
148
|
+
func scopeCaseFoldsOnThisPlatform(): Bool {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ===== 端到端对照组:走真实 fs 插件 + 真实文件 ===== */
|
|
153
|
+
|
|
154
|
+
func fsHandler(shortName: String): CommandHandler {
|
|
155
|
+
return FsPlugin().commands().get(shortName).getOrThrow()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func fsCtxWithScopes(allow: Array<String>, deny: Array<String>): IpcContext {
|
|
159
|
+
let json = JsonObject()
|
|
160
|
+
json.put("identifier", JsonString("probe"))
|
|
161
|
+
let cmds = JsonArray()
|
|
162
|
+
cmds.add(JsonString("fs:readText"))
|
|
163
|
+
cmds.add(JsonString("fs:writeText"))
|
|
164
|
+
json.put("commands", cmds)
|
|
165
|
+
let rule = JsonObject()
|
|
166
|
+
let arr = JsonArray()
|
|
167
|
+
for (a in allow) {
|
|
168
|
+
arr.add(JsonString(a))
|
|
169
|
+
}
|
|
170
|
+
rule.put("allow", arr)
|
|
171
|
+
let darr = JsonArray()
|
|
172
|
+
for (d in deny) {
|
|
173
|
+
darr.add(JsonString(d))
|
|
174
|
+
}
|
|
175
|
+
if (deny.size > 0) {
|
|
176
|
+
rule.put("deny", darr)
|
|
177
|
+
}
|
|
178
|
+
let scopes = JsonObject()
|
|
179
|
+
scopes.put("fs", rule)
|
|
180
|
+
json.put("scopes", scopes)
|
|
181
|
+
let reg = CapabilityRegistry()
|
|
182
|
+
reg.add(Capability.fromJson(json).getOrThrow())
|
|
183
|
+
let hub = IpcHub()
|
|
184
|
+
hub.setCapabilities(reg)
|
|
185
|
+
return IpcContext("main", hub)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func fsCtxLegacy(): IpcContext {
|
|
189
|
+
let json = JsonObject()
|
|
190
|
+
json.put("identifier", JsonString("legacy"))
|
|
191
|
+
let cmds = JsonArray()
|
|
192
|
+
cmds.add(JsonString("fs:writeText"))
|
|
193
|
+
json.put("commands", cmds)
|
|
194
|
+
let reg = CapabilityRegistry()
|
|
195
|
+
reg.add(Capability.fromJson(json).getOrThrow())
|
|
196
|
+
let hub = IpcHub()
|
|
197
|
+
hub.setCapabilities(reg)
|
|
198
|
+
return IpcContext("main", hub)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** JsonKind 没有 ==(比较走 match),断言里需要判类型时用这个 */
|
|
202
|
+
func isJsonBool(v: JsonValue): Bool {
|
|
203
|
+
match (v.kind()) {
|
|
204
|
+
case JsonKind.JsBool => return true
|
|
205
|
+
case _ => return false
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
func fsWriteArgs(path: String, contents: String): JsonObject {
|
|
210
|
+
let args = JsonObject()
|
|
211
|
+
args.put("path", JsonString(path))
|
|
212
|
+
args.put("contents", JsonString(contents))
|
|
213
|
+
return args
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
func fsErrorMessage(handler: CommandHandler, cmd: String, args: JsonObject, ctx: IpcContext): String {
|
|
217
|
+
try {
|
|
218
|
+
handler.handle(cmd, args, ctx)
|
|
219
|
+
return ""
|
|
220
|
+
} catch (e: CommandException) {
|
|
221
|
+
return e.message
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@Test
|
|
226
|
+
func testFsScopeAllowsInsideWriteAndRead(): Unit {
|
|
227
|
+
let ctx = fsCtxWithScopes([scopeCwdPath() + "/target/**"], [])
|
|
228
|
+
let writer = fsHandler("writeText")
|
|
229
|
+
let reader = fsHandler("readText")
|
|
230
|
+
let path = "target/scope-probe-ok.txt"
|
|
231
|
+
@Expect(isJsonBool(writer.handle("fs:writeText", fsWriteArgs(path, "scope payload"), ctx)), true)
|
|
232
|
+
let got = reader.handle("fs:readText", fsWriteArgs(path, ""), ctx)
|
|
233
|
+
@Expect(got.asString().getValue(), "scope payload")
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
@Test
|
|
237
|
+
func testFsScopeDeniesOutsideAllowList(): Unit {
|
|
238
|
+
// 对照组:同一份清单,路径出了白名单 → 拒(错误可读,不是崩)
|
|
239
|
+
let ctx = fsCtxWithScopes([scopeCwdPath() + "/target/**"], [])
|
|
240
|
+
let msg = fsErrorMessage(fsHandler("writeText"), "fs:writeText",
|
|
241
|
+
fsWriteArgs("target-scope-outside.txt", "nope"), ctx)
|
|
242
|
+
@Expect(msg.contains("outside the authorized scope"), true)
|
|
243
|
+
// 真实文件没被创建(拒发生在写之前)
|
|
244
|
+
@Expect(exists(scopeCwdPath() + "/target-scope-outside.txt"), false)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
@Test
|
|
248
|
+
func testFsScopeDenyListWins(): Unit {
|
|
249
|
+
let ctx = fsCtxWithScopes([scopeCwdPath() + "/target/**"], [scopeCwdPath() + "/target/**blocked*"])
|
|
250
|
+
let msg = fsErrorMessage(fsHandler("writeText"), "fs:writeText",
|
|
251
|
+
fsWriteArgs("target/blocked-scope.txt", "nope"), ctx)
|
|
252
|
+
@Expect(msg.contains("outside the authorized scope"), true)
|
|
253
|
+
@Expect(exists(scopeCwdPath() + "/target/blocked-scope.txt"), false)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
@Test
|
|
257
|
+
func testFsScopeCannotEscapeWithDotDot(): Unit {
|
|
258
|
+
// `..` 绕不过:规范化之后按绝对路径判定
|
|
259
|
+
let ctx = fsCtxWithScopes([scopeCwdPath() + "/target/**"], [])
|
|
260
|
+
let msg = fsErrorMessage(fsHandler("writeText"), "fs:writeText",
|
|
261
|
+
fsWriteArgs("target/../target-scope-escape.txt", "nope"), ctx)
|
|
262
|
+
@Expect(msg.contains("outside the authorized scope"), true)
|
|
263
|
+
@Expect(exists(scopeCwdPath() + "/target-scope-escape.txt"), false)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
@Test
|
|
267
|
+
func testLegacyManifestWithoutScopesStillWorks(): Unit {
|
|
268
|
+
// 旧清单(没写 scopes):行为不变——全量放行,只是启动时有一行 stderr 提示
|
|
269
|
+
let ctx = fsCtxLegacy()
|
|
270
|
+
let path = "target/scope-legacy-ok.txt"
|
|
271
|
+
let out = fsHandler("writeText").handle("fs:writeText", fsWriteArgs(path, "legacy"), ctx)
|
|
272
|
+
@Expect(isJsonBool(out), true)
|
|
273
|
+
@Expect(exists(scopeCwdPath() + "/target/scope-legacy-ok.txt"), true)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
@Test
|
|
277
|
+
func testScopeAwareIsDeclaredOnlyByFsPlugin(): Unit {
|
|
278
|
+
// 只有 fs 声明吃 scope:别的插件不该被这层校验影响(框架不替插件猜哪个参数是路径)
|
|
279
|
+
@Expect(FsPlugin().scopeAware(), true)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
@Test
|
|
283
|
+
func testScopeSymlinkEscapeIsDenied(): Unit {
|
|
284
|
+
// SEC-003(软链接逃逸):白名单目录里一条指向外面的软链接——
|
|
285
|
+
// 规范化会解析软链接,落点出白名单就必须拒(读和写都拒)
|
|
286
|
+
let base = scopeCwdPath()
|
|
287
|
+
let outside = "${base}/target-sym-escape.txt"
|
|
288
|
+
// 上轮运行可能留下同名目录/软链接(Directory.create 遇已存在会抛),先清场
|
|
289
|
+
removeIfExists("${base}/target/scope-sym/escape.txt")
|
|
290
|
+
removeIfExists("${base}/target/scope-sym")
|
|
291
|
+
removeIfExists(outside)
|
|
292
|
+
Directory.create("${base}/target/scope-sym", recursive: true)
|
|
293
|
+
let hi: Array<UInt8> = [104u8, 105u8] // "hi"
|
|
294
|
+
File.writeTo(outside, hi)
|
|
295
|
+
// 建完链路再把软链接指过去(目标已存在,Windows 也稳)
|
|
296
|
+
// Windows 无开发者特权时建链被系统拒(错误 1314):这是环境限制不是判定缺陷,
|
|
297
|
+
// 如实跳过并打一行说明——该用例的判定面由 Linux 轮覆盖
|
|
298
|
+
try {
|
|
299
|
+
SymbolicLink.create("${base}/target/scope-sym/escape.txt", to: outside)
|
|
300
|
+
} catch (e: Exception) {
|
|
301
|
+
eprintln("[skip] testScopeSymlinkEscapeIsDenied: 本机无法创建符号链接(${e.message})")
|
|
302
|
+
removeIfExists(outside)
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
let ctx = fsCtxWithScopes([scopeCwdPath() + "/target/**"], [])
|
|
306
|
+
let msg = fsErrorMessage(fsHandler("readText"), "fs:readText",
|
|
307
|
+
fsWriteArgs("target/scope-sym/escape.txt", ""), ctx)
|
|
308
|
+
@Expect(msg.contains("outside the authorized scope"), true)
|
|
309
|
+
removeIfExists(outside)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
@Test
|
|
313
|
+
func testScopeSymlinkInsideIsAllowed(): Unit {
|
|
314
|
+
// 对照组:软链接落点还在白名单内 → 放行(证「拒」确实来自越界判定,不是见了软链接就拒)
|
|
315
|
+
let base = scopeCwdPath()
|
|
316
|
+
removeIfExists("${base}/target/scope-sym2/link.txt")
|
|
317
|
+
removeIfExists("${base}/target/scope-sym2/inner.txt")
|
|
318
|
+
removeIfExists("${base}/target/scope-sym2")
|
|
319
|
+
Directory.create("${base}/target/scope-sym2", recursive: true)
|
|
320
|
+
let inner: Array<UInt8> = [104u8, 105u8]
|
|
321
|
+
File.writeTo("${base}/target/scope-sym2/inner.txt", inner)
|
|
322
|
+
// 同上:Windows 无特权建链被拒时如实跳过(Linux 轮覆盖判定面)
|
|
323
|
+
try {
|
|
324
|
+
SymbolicLink.create("${base}/target/scope-sym2/link.txt", to: "${base}/target/scope-sym2/inner.txt")
|
|
325
|
+
} catch (e: Exception) {
|
|
326
|
+
eprintln("[skip] testScopeSymlinkInsideIsAllowed: 本机无法创建符号链接(${e.message})")
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
let ctx = fsCtxWithScopes([scopeCwdPath() + "/target/**"], [])
|
|
330
|
+
let out = fsHandler("readText").handle("fs:readText", fsWriteArgs("target/scope-sym2/link.txt", ""), ctx)
|
|
331
|
+
@Expect(out.asString().getValue(), "hi")
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
@Test
|
|
335
|
+
func testScopePercentEncodedPathIsNotPreDecoded(): Unit {
|
|
336
|
+
// SEC-003(百分比编码):scope 判定不做 %XX 解码——`a%2F..%2Fb` 是一个字面量文件名,
|
|
337
|
+
// 不是 `a/../b` 的伪装。反转义应当在「插件自己处理 URI 载荷」时做(如 drop 链路在 C 桥 core),
|
|
338
|
+
// scope 层对着的是「应用/插件已经解析完的路径」,两层各管各的语义,不重复解码也不漏解码
|
|
339
|
+
let ctx = fsCtxWithScopes([scopeCwdPath() + "/target/**"], [])
|
|
340
|
+
// 文件名里塞 %2e%2e(`..` 的百分比编码):scope 层不做 %XX 解码,
|
|
341
|
+
// 它就是一个字面量文件名——不会被当 `../` 穿越判定,正常落在 target/ 下
|
|
342
|
+
@Expect(isJsonBool(fsHandler("writeText").handle("fs:writeText",
|
|
343
|
+
fsWriteArgs("target/percent%2e%2e-escape.txt", "ok"), ctx)), true)
|
|
344
|
+
@Expect(exists(scopeCwdPath() + "/target/percent%2e%2e-escape.txt"), true)
|
|
345
|
+
// 对照:真正的 `..` 穿越会被拒(既有用例已覆盖,这里验证编码形态不享受同款待遇)
|
|
346
|
+
let msg = fsErrorMessage(fsHandler("writeText"), "fs:writeText",
|
|
347
|
+
fsWriteArgs("target/../../percent-real-escape.txt", "nope"), ctx)
|
|
348
|
+
@Expect(msg.contains("outside the authorized scope"), true)
|
|
349
|
+
@Expect(exists(scopeCwdPath() + "/percent-real-escape.txt"), false)
|
|
350
|
+
}
|