cj-tauri 1.1.2 → 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.
Files changed (162) hide show
  1. package/README.md +137 -77
  2. package/bin/cj-tauri.js +287 -287
  3. package/cjpm.toml +13 -2
  4. package/cli/cjpm.toml +22 -13
  5. package/cli/src/doctor.cj +308 -304
  6. package/cli/src/main.cj +129 -121
  7. package/cli/src/manifest.cj +632 -0
  8. package/cli/src/manifest_test.cj +87 -0
  9. package/cli/src/project.cj +770 -754
  10. package/cli/src/resolve.cj +348 -348
  11. package/cli/src/scaffold.cj +46 -38
  12. package/cli/src/sync.cj +526 -0
  13. package/cli/src/util.cj +9 -0
  14. package/cli/templates/app/README.md +30 -59
  15. package/cli/templates/app/{ui → assets/ui}/index.html +2 -0
  16. package/cli/templates/app/cj-tauri.toml +32 -0
  17. package/cli/templates/app/cjpm.toml +21 -4
  18. package/cli/templates/app/src/main.cj +29 -16
  19. package/cli/templates/app-react/README.md +34 -45
  20. package/cli/templates/app-react/{ui → assets/ui}/index.html +25 -25
  21. package/cli/templates/app-react/{ui → assets/ui}/src/App.css +67 -67
  22. package/cli/templates/app-react/{ui → assets/ui}/src/App.jsx +124 -124
  23. package/cli/templates/app-react/cj-tauri.toml +33 -0
  24. package/cli/templates/app-react/cjpm.toml +21 -4
  25. package/cli/templates/app-react/src/main.cj +18 -29
  26. package/cli/templates/app-vue/README.md +34 -38
  27. package/cli/templates/app-vue/{ui → assets/ui}/index.html +25 -25
  28. package/cli/templates/app-vue/{ui → assets/ui}/src/App.vue +176 -176
  29. package/cli/templates/app-vue/cj-tauri.toml +33 -0
  30. package/cli/templates/app-vue/cjpm.toml +21 -4
  31. package/cli/templates/app-vue/src/main.cj +18 -29
  32. package/cli/templates/{ohos-pc → ohos-skeleton}/README.md +5 -0
  33. package/cli/templates/{ohos-pc → ohos-skeleton}/build-hap.sh +15 -0
  34. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/cjpm.toml +8 -0
  35. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/dialog_host.cj +217 -0
  36. package/cli/templates/ohos-skeleton/entry/src/main/cangjie/index.cj +527 -0
  37. package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/module.json5 +8 -0
  38. package/native/build_win.bat +62 -62
  39. package/native/core/bridge_core.c +1681 -1626
  40. package/native/core/bridge_core.h +675 -618
  41. package/native/core/bridge_js.h +108 -108
  42. package/native/platforms/harmonyos/README.md +115 -6
  43. package/native/platforms/linux/bridge_linux.c +2787 -2485
  44. package/native/platforms/windows/bridge_win.c +3740 -3248
  45. package/native/tests/gtk_threading_probe.c +143 -143
  46. package/native/tests/test_bridge_core.c +1339 -1228
  47. package/package.json +53 -51
  48. package/prebuilt/linux-x64/cj-tauri +0 -0
  49. package/src/api_system.cj +67 -67
  50. package/src/app.cj +325 -940
  51. package/src/app_events.cj +166 -0
  52. package/src/app_host_ops.cj +287 -0
  53. package/src/app_run.cj +283 -0
  54. package/src/app_spec.cj +76 -0
  55. package/src/arkweb_menu.cj +216 -0
  56. package/src/arkweb_script.cj +0 -12
  57. package/src/capability.cj +409 -409
  58. package/src/clipboard_bridge.cj +178 -0
  59. package/src/dialog_spec.cj +79 -0
  60. package/src/dragdrop.cj +26 -26
  61. package/src/embedded_assets.cj +98 -98
  62. package/src/host.cj +752 -604
  63. package/src/host_arkweb.cj +201 -17
  64. package/src/host_webkit.cj +663 -548
  65. package/src/host_webview2.cj +663 -548
  66. package/src/hotkey.cj +66 -66
  67. package/src/ipc_hub.cj +337 -337
  68. package/src/ipc_message.cj +286 -286
  69. package/src/launch.cj +62 -0
  70. package/src/logging.cj +127 -127
  71. package/src/menu.cj +197 -197
  72. package/src/platform/platform_app_context.cj +77 -0
  73. package/src/platform/platform_assets.cj +33 -0
  74. package/src/platform/platform_discover.cj +176 -0
  75. package/src/platform/platform_env.cj +29 -0
  76. package/src/platform/platform_feature.cj +31 -0
  77. package/src/platform/platform_page_source.cj +87 -0
  78. package/src/platform/platform_target_info.cj +32 -0
  79. package/src/platform_host_provider.cj +87 -0
  80. package/src/plugin.cj +333 -299
  81. package/src/plugins/clipboard/plugin_clipboard.cj +152 -144
  82. package/src/plugins/dialog/plugin_dialog.cj +209 -209
  83. package/src/plugins/fs/plugin_fs.cj +105 -1
  84. package/src/plugins/http/http_backend.cj +131 -0
  85. package/src/plugins/http/http_desktop.cj +195 -0
  86. package/src/plugins/http/http_ohos.cj +204 -0
  87. package/src/plugins/http/plugin_http.cj +310 -459
  88. package/src/plugins/log/plugin_log.cj +105 -105
  89. package/src/plugins/menu/plugin_menu.cj +8 -0
  90. package/src/plugins/notification/plugin_notification.cj +154 -146
  91. package/src/plugins/serial/plugin_serial.cj +17 -1
  92. package/src/plugins/serial/serial_bridge.cj +9 -1
  93. package/src/plugins/serial/serial_manager_ohos.cj +92 -0
  94. package/src/plugins/serial/serial_ohos.cj +529 -0
  95. package/src/plugins/shell/plugin_shell.cj +29 -1
  96. package/src/plugins/sqlite/plugin_sqlite.cj +8 -0
  97. package/src/plugins/ws/plugin_ws.cj +488 -470
  98. package/src/process_hidden.cj +174 -174
  99. package/src/scope.cj +391 -391
  100. package/src/shell_event.cj +69 -69
  101. package/src/tests/app_spec_test.cj +126 -0
  102. package/src/tests/capability_test.cj +243 -243
  103. package/src/tests/dialog_spec_test.cj +73 -0
  104. package/src/tests/fake_host.cj +201 -186
  105. package/src/tests/ipc_hub_test.cj +487 -487
  106. package/src/tests/logging_test.cj +62 -62
  107. package/src/tests/platform_contract_test.cj +251 -0
  108. package/src/tests/plugin_clipboard_test.cj +222 -222
  109. package/src/tests/plugin_dialog_test.cj +237 -237
  110. package/src/tests/plugin_fs_test.cj +207 -0
  111. package/src/tests/plugin_http_test.cj +124 -124
  112. package/src/tests/plugin_notification_test.cj +205 -205
  113. package/src/tests/plugin_serial_test.cj +48 -0
  114. package/src/tests/plugin_test.cj +66 -3
  115. package/src/tests/plugin_ws_test.cj +40 -40
  116. package/src/tests/process_hidden_test.cj +129 -129
  117. package/src/tests/scope_test.cj +350 -350
  118. package/src/tests/system_host_test.cj +99 -81
  119. package/src/tests/urlutil_test.cj +53 -53
  120. package/src/tests/version_test.cj +75 -75
  121. package/src/tests/window_registry_test.cj +123 -123
  122. package/src/tray.cj +90 -90
  123. package/src/urlutil.cj +159 -159
  124. package/src/version.cj +44 -44
  125. package/src/window.cj +51 -51
  126. package/src/window_registry.cj +116 -116
  127. package/cli/templates/ohos-pc/entry/src/main/cangjie/index.cj +0 -167
  128. package/prebuilt/win32-x64/cj-tauri.exe +0 -0
  129. package/src/host_factory.cj +0 -48
  130. /package/cli/templates/app/{capabilities → assets/capabilities}/default.json +0 -0
  131. /package/cli/templates/app-react/{capabilities → assets/capabilities}/default.json +0 -0
  132. /package/cli/templates/app-react/{ui → assets/ui}/package.json +0 -0
  133. /package/cli/templates/app-react/{ui → assets/ui}/src/main.jsx +0 -0
  134. /package/cli/templates/app-react/{ui → assets/ui}/vite.config.js +0 -0
  135. /package/cli/templates/app-vue/{capabilities → assets/capabilities}/default.json +0 -0
  136. /package/cli/templates/app-vue/{ui → assets/ui}/package.json +0 -0
  137. /package/cli/templates/app-vue/{ui → assets/ui}/src/main.js +0 -0
  138. /package/cli/templates/app-vue/{ui → assets/ui}/vite.config.js +0 -0
  139. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/app.json5 +0 -0
  140. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/element/string.json +0 -0
  141. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/background.png +0 -0
  142. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/foreground.png +0 -0
  143. /package/cli/templates/{ohos-pc → ohos-skeleton}/AppScope/resources/base/media/layered_image.json +0 -0
  144. /package/cli/templates/{ohos-pc → ohos-skeleton}/build-profile.json5 +0 -0
  145. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/build-profile.json5 +0 -0
  146. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/hvigorfile.ts +0 -0
  147. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/oh-package.json5 +0 -0
  148. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/ability_stage.cj +0 -0
  149. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/app_host.cj +0 -0
  150. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/cangjie/main_ability.cj +0 -0
  151. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/element/color.json +0 -0
  152. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/element/string.json +0 -0
  153. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/background.png +0 -0
  154. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/foreground.png +0 -0
  155. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/layered_image.json +0 -0
  156. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/media/startIcon.png +0 -0
  157. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/base/profile/main_pages.json +0 -0
  158. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/rawfile/capabilities.json +0 -0
  159. /package/cli/templates/{ohos-pc → ohos-skeleton}/entry/src/main/resources/rawfile/index.html +0 -0
  160. /package/cli/templates/{ohos-pc → ohos-skeleton}/hvigor/hvigor-config.json5 +0 -0
  161. /package/cli/templates/{ohos-pc → ohos-skeleton}/hvigorfile.ts +0 -0
  162. /package/cli/templates/{ohos-pc → ohos-skeleton}/oh-package.json5 +0 -0
@@ -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
+ }