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