cj-tauri 0.8.0 → 1.1.1

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 (124) hide show
  1. package/README.md +53 -7
  2. package/cjpm.toml +52 -25
  3. package/cli/cjpm.toml +1 -1
  4. package/cli/src/doctor.cj +304 -0
  5. package/cli/src/main.cj +33 -7
  6. package/cli/src/project.cj +59 -1
  7. package/cli/src/resolve.cj +348 -304
  8. package/cli/src/scaffold.cj +139 -22
  9. package/cli/templates/ohos-pc/AppScope/app.json5 +13 -0
  10. package/cli/templates/ohos-pc/AppScope/resources/base/element/string.json +8 -0
  11. package/cli/templates/ohos-pc/AppScope/resources/base/media/background.png +0 -0
  12. package/cli/templates/ohos-pc/AppScope/resources/base/media/foreground.png +0 -0
  13. package/cli/templates/ohos-pc/AppScope/resources/base/media/layered_image.json +6 -0
  14. package/cli/templates/ohos-pc/README.md +79 -0
  15. package/cli/templates/ohos-pc/build-hap.sh +190 -0
  16. package/cli/templates/ohos-pc/build-profile.json5 +46 -0
  17. package/cli/templates/ohos-pc/entry/build-profile.json5 +26 -0
  18. package/cli/templates/ohos-pc/entry/cjpm.toml +43 -0
  19. package/cli/templates/ohos-pc/entry/hvigorfile.ts +6 -0
  20. package/cli/templates/ohos-pc/entry/oh-package.json5 +9 -0
  21. package/cli/templates/ohos-pc/entry/src/main/cangjie/ability_stage.cj +17 -0
  22. package/cli/templates/ohos-pc/entry/src/main/cangjie/app_host.cj +94 -0
  23. package/cli/templates/ohos-pc/entry/src/main/cangjie/index.cj +167 -0
  24. package/cli/templates/ohos-pc/entry/src/main/cangjie/main_ability.cj +139 -0
  25. package/cli/templates/ohos-pc/entry/src/main/module.json5 +49 -0
  26. package/cli/templates/ohos-pc/entry/src/main/resources/base/element/color.json +8 -0
  27. package/cli/templates/ohos-pc/entry/src/main/resources/base/element/string.json +16 -0
  28. package/cli/templates/ohos-pc/entry/src/main/resources/base/media/background.png +0 -0
  29. package/cli/templates/ohos-pc/entry/src/main/resources/base/media/foreground.png +0 -0
  30. package/cli/templates/ohos-pc/entry/src/main/resources/base/media/layered_image.json +6 -0
  31. package/cli/templates/ohos-pc/entry/src/main/resources/base/media/startIcon.png +0 -0
  32. package/cli/templates/ohos-pc/entry/src/main/resources/base/profile/main_pages.json +2 -0
  33. package/cli/templates/ohos-pc/entry/src/main/resources/rawfile/capabilities.json +6 -0
  34. package/cli/templates/ohos-pc/entry/src/main/resources/rawfile/index.html +92 -0
  35. package/cli/templates/ohos-pc/hvigor/hvigor-config.json5 +5 -0
  36. package/cli/templates/ohos-pc/hvigorfile.ts +6 -0
  37. package/cli/templates/ohos-pc/oh-package.json5 +10 -0
  38. package/native/build_linux.sh +30 -2
  39. package/native/build_win.bat +62 -61
  40. package/native/core/bridge_core.c +1626 -0
  41. package/native/{bridge_core.h → core/bridge_core.h} +246 -6
  42. package/native/platforms/harmonyos/README.md +106 -0
  43. package/native/platforms/linux/bridge_linux.c +2485 -0
  44. package/native/platforms/windows/bridge_win.c +3248 -0
  45. package/native/tests/test_bridge_core.c +528 -1
  46. package/package.json +12 -3
  47. package/prebuilt/win32-x64/cj-tauri.exe +0 -0
  48. package/src/api_system.cj +10 -2
  49. package/src/app.cj +248 -85
  50. package/src/arkweb_proxy.cj +56 -0
  51. package/src/arkweb_script.cj +154 -0
  52. package/src/capability.cj +140 -0
  53. package/src/clipboard_bridge.cj +55 -0
  54. package/src/dragdrop.cj +26 -0
  55. package/src/embedded_assets.cj +98 -0
  56. package/src/hexutil.cj +53 -0
  57. package/src/host.cj +197 -26
  58. package/src/host_arkweb.cj +740 -0
  59. package/src/host_factory.cj +48 -0
  60. package/src/host_webkit.cj +202 -26
  61. package/src/host_webview2.cj +176 -3
  62. package/src/hotkey.cj +66 -0
  63. package/src/ipc_hub.cj +49 -0
  64. package/src/ipc_message.cj +2 -1
  65. package/src/jsutil.cj +33 -0
  66. package/src/log_bridge.cj +107 -0
  67. package/src/logging.cj +127 -0
  68. package/src/menu.cj +197 -196
  69. package/src/plugin.cj +40 -0
  70. package/src/plugins/clipboard/plugin_clipboard.cj +144 -0
  71. package/src/{plugin_dialog.cj → plugins/dialog/plugin_dialog.cj} +74 -17
  72. package/src/plugins/fs/plugin_fs.cj +194 -0
  73. package/src/plugins/http/plugin_http.cj +459 -0
  74. package/src/plugins/log/plugin_log.cj +105 -0
  75. package/src/{plugin_menu.cj → plugins/menu/plugin_menu.cj} +4 -2
  76. package/src/plugins/mqtt/mqtt_codec.cj +661 -0
  77. package/src/plugins/mqtt/plugin_mqtt.cj +1446 -0
  78. package/src/plugins/notification/plugin_notification.cj +146 -0
  79. package/src/plugins/plugins.cj +5 -0
  80. package/src/plugins/redis/plugin_redis.cj +2409 -0
  81. package/src/plugins/redis/redis_codec.cj +556 -0
  82. package/src/{plugin_serial.cj → plugins/serial/plugin_serial.cj} +442 -489
  83. package/src/{serial_bridge.cj → plugins/serial/serial_bridge.cj} +185 -184
  84. package/src/{plugin_shell.cj → plugins/shell/plugin_shell.cj} +230 -228
  85. package/src/plugins/sqlite/plugin_sqlite.cj +697 -0
  86. package/src/plugins/sqlite/sqlite_ffi.cj +206 -0
  87. package/src/plugins/store/plugin_store.cj +287 -0
  88. package/src/plugins/ws/plugin_ws.cj +470 -0
  89. package/src/process_hidden.cj +174 -0
  90. package/src/scope.cj +391 -0
  91. package/src/shell_event.cj +69 -68
  92. package/src/tests/capability_test.cj +53 -0
  93. package/src/tests/fake_host.cj +85 -20
  94. package/src/tests/ipc_hub_test.cj +13 -0
  95. package/src/tests/jsutil_test.cj +62 -0
  96. package/src/tests/logging_test.cj +62 -0
  97. package/src/tests/mqtt_codec_test.cj +405 -0
  98. package/src/tests/plugin_clipboard_test.cj +222 -0
  99. package/src/tests/plugin_dialog_test.cj +98 -4
  100. package/src/tests/plugin_http_test.cj +124 -0
  101. package/src/tests/plugin_menu_test.cj +1 -0
  102. package/src/tests/plugin_notification_test.cj +205 -0
  103. package/src/tests/plugin_serial_test.cj +269 -267
  104. package/src/tests/plugin_shell_test.cj +189 -188
  105. package/src/tests/plugin_sqlite_test.cj +230 -0
  106. package/src/tests/plugin_store_test.cj +260 -0
  107. package/src/tests/plugin_test.cj +542 -524
  108. package/src/tests/plugin_ws_test.cj +40 -0
  109. package/src/tests/process_hidden_test.cj +129 -0
  110. package/src/tests/redis_codec_test.cj +396 -0
  111. package/src/tests/scope_test.cj +350 -0
  112. package/src/tests/shell_event_test.cj +0 -1
  113. package/src/tests/system_host_test.cj +81 -79
  114. package/src/tests/urlutil_test.cj +53 -0
  115. package/src/tests/version_test.cj +75 -65
  116. package/src/tray.cj +90 -0
  117. package/src/urlutil.cj +159 -0
  118. package/src/version.cj +23 -2
  119. package/src/window.cj +51 -42
  120. package/native/bridge_core.c +0 -797
  121. package/native/bridge_linux.c +0 -1268
  122. package/native/bridge_win.c +0 -1633
  123. package/src/plugin_fs.cj +0 -118
  124. /package/native/{bridge_js.h → core/bridge_js.h} +0 -0
@@ -0,0 +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,7 +1,6 @@
1
1
  package cjTauri.tests
2
2
 
3
3
  import cjTauri.*
4
- import stdx.encoding.json.*
5
4
  import std.unittest.*
6
5
  import std.unittest.testmacro.*
7
6
 
@@ -1,79 +1,81 @@
1
- package cjTauri.tests
2
-
3
- import cjTauri.*
4
- import std.collection.*
5
- import stdx.encoding.json.*
6
- import std.unittest.*
7
- import std.unittest.testmacro.*
8
-
9
- /*
10
- * 内置命令 system:host 的用例。
11
- *
12
- * 覆盖的是「能力位 → 具名布尔 JSON」这一段:能力位从调用窗口的宿主来,经 hub 注入的取值通道
13
- * (生产环境里由 TauriApp 接到 hostCapabilities())变成 HostCapabilities。用例不建窗口,
14
- * 替身就是那条注入通道——顺带钉住「system:host 走的是通道,不是 hub 自己去摸窗口注册表」。
15
- *
16
- * 断言一律**解析 JSON 再取字段**(不靠子串):序列化格式(空白、键序)不是契约,字段值是。
17
- */
18
-
19
- /** 造一个「通道给 bits」的 hub + 会话上下文(window = main) */
20
- func hostCtxWith(bits: Int32, asked: ArrayList<String>): IpcContext {
21
- let hub = IpcHub()
22
- hub.hostCapabilitiesSink = { label =>
23
- asked.add(label)
24
- bits
25
- }
26
- return IpcContext(DEFAULT_WINDOW_LABEL, hub)
27
- }
28
-
29
- @Test
30
- func testSystemHostReportsNamedBooleansForInjectedBits(): Unit {
31
- let asked = ArrayList<String>()
32
- // 有菜单 + 有托盘、没有拖入:三位各自独立翻名,不能整体按 true/false 一刀切
33
- let ctx = hostCtxWith(CJ_CAP_MENU | CJ_CAP_TRAY, asked)
34
- let data = SystemCommands().handle("system:host", JsonObject(), ctx).asObject()
35
- @Expect(data.get("menu").getOrThrow().asBool().getValue(), true)
36
- @Expect(data.get("tray").getOrThrow().asBool().getValue(), true)
37
- @Expect(data.get("dragDrop").getOrThrow().asBool().getValue(), false)
38
- // platform 由仓颉侧条件编译给(windows / linux),不该是空串
39
- @Expect(data.get("platform").getOrThrow().asString().getValue().size > 0, true)
40
- // 取的是**发起命令那个窗口**的能力位
41
- @Expect(asked[0], DEFAULT_WINDOW_LABEL)
42
- }
43
-
44
- @Test
45
- func testSystemHostReportsAllFalseWhenPlatformHasNothing(): Unit {
46
- let asked = ArrayList<String>()
47
- let ctx = hostCtxWith(0, asked)
48
- let data = SystemCommands().handle("system:host", JsonObject(), ctx).asObject()
49
- @Expect(data.get("menu").getOrThrow().asBool().getValue(), false)
50
- @Expect(data.get("tray").getOrThrow().asBool().getValue(), false)
51
- @Expect(data.get("dragDrop").getOrThrow().asBool().getValue(), false)
52
- }
53
-
54
- @Test
55
- func testSystemHostResolvesThroughHubWhenDeclared(): Unit {
56
- // 三处联动的第三处:清单里声明了 system:host,前端才调得到(走完整 IPC 链路 + 异步分发)
57
- let out = ArrayList<String>()
58
- let hub = hubWith(["system:host"], [], out)
59
- hub.hostCapabilitiesSink = { _ => CJ_CAP_MENU }
60
- hub.register("system:host", SystemCommands())
61
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":21,\"cmd\":\"system:host\"}")
62
- waitSink(out, 1)
63
- let reply = JsonValue.fromStr(out[0]).asObject()
64
- @Expect(reply.get("ok").getOrThrow().asBool().getValue(), true)
65
- let data = reply.get("data").getOrThrow().asObject()
66
- @Expect(data.get("menu").getOrThrow().asBool().getValue(), true)
67
- }
68
-
69
- @Test
70
- func testSystemHostIsDeniedWithoutCapability(): Unit {
71
- // 三处联动缺了清单那一处:能力清单没声明 system:host → 调用线程上同步拒绝
72
- let out = ArrayList<String>()
73
- let hub = hubWith([], [], out)
74
- hub.hostCapabilitiesSink = { _ => CJ_CAP_MENU }
75
- hub.register("system:host", SystemCommands())
76
- hub.handleRawMessage("{\"type\":\"invoke\",\"id\":23,\"cmd\":\"system:host\"}")
77
- waitSink(out, 1)
78
- @Expect(out[0].contains("command not allowed"), true)
79
- }
1
+ package cjTauri.tests
2
+
3
+ import cjTauri.*
4
+ import std.collection.*
5
+ import stdx.encoding.json.*
6
+ import std.unittest.*
7
+ import std.unittest.testmacro.*
8
+
9
+ /*
10
+ * 内置命令 system:host 的用例。
11
+ *
12
+ * 覆盖的是「能力位 → 具名布尔 JSON」这一段:能力位从调用窗口的宿主来,经 hub 注入的取值通道
13
+ * (生产环境里由 TauriApp 接到 hostCapabilities())变成 HostCapabilities。用例不建窗口,
14
+ * 替身就是那条注入通道——顺带钉住「system:host 走的是通道,不是 hub 自己去摸窗口注册表」。
15
+ *
16
+ * 断言一律**解析 JSON 再取字段**(不靠子串):序列化格式(空白、键序)不是契约,字段值是。
17
+ */
18
+
19
+ /** 造一个「通道给 bits」的 hub + 会话上下文(window = main) */
20
+ func hostCtxWith(bits: Int32, asked: ArrayList<String>): IpcContext {
21
+ let hub = IpcHub()
22
+ hub.hostCapabilitiesSink = { label =>
23
+ asked.add(label)
24
+ bits
25
+ }
26
+ return IpcContext(DEFAULT_WINDOW_LABEL, hub)
27
+ }
28
+
29
+ @Test
30
+ func testSystemHostReportsNamedBooleansForInjectedBits(): Unit {
31
+ let asked = ArrayList<String>()
32
+ // 有菜单 + 有托盘、没有拖入:三位各自独立翻名,不能整体按 true/false 一刀切
33
+ let ctx = hostCtxWith(CJ_CAP_MENU | CJ_CAP_TRAY, asked)
34
+ let data = SystemCommands().handle("system:host", JsonObject(), ctx).asObject()
35
+ @Expect(data.get("menu").getOrThrow().asBool().getValue(), true)
36
+ @Expect(data.get("tray").getOrThrow().asBool().getValue(), true)
37
+ @Expect(data.get("dragDrop").getOrThrow().asBool().getValue(), false)
38
+ // platform 由仓颉侧条件编译给(windows / linux),不该是空串
39
+ @Expect(data.get("platform").getOrThrow().asString().getValue().size > 0, true)
40
+ // logFile(RFC-003 第 5 期):进程级字段,用例环境没开日志 → 空串(字段在、值才空)
41
+ @Expect(data.get("logFile").getOrThrow().asString().getValue(), "")
42
+ // 取的是**发起命令那个窗口**的能力位
43
+ @Expect(asked[0], DEFAULT_WINDOW_LABEL)
44
+ }
45
+
46
+ @Test
47
+ func testSystemHostReportsAllFalseWhenPlatformHasNothing(): Unit {
48
+ let asked = ArrayList<String>()
49
+ let ctx = hostCtxWith(0, asked)
50
+ let data = SystemCommands().handle("system:host", JsonObject(), ctx).asObject()
51
+ @Expect(data.get("menu").getOrThrow().asBool().getValue(), false)
52
+ @Expect(data.get("tray").getOrThrow().asBool().getValue(), false)
53
+ @Expect(data.get("dragDrop").getOrThrow().asBool().getValue(), false)
54
+ }
55
+
56
+ @Test
57
+ func testSystemHostResolvesThroughHubWhenDeclared(): Unit {
58
+ // 三处联动的第三处:清单里声明了 system:host,前端才调得到(走完整 IPC 链路 + 异步分发)
59
+ let out = ArrayList<String>()
60
+ let hub = hubWith(["system:host"], [], out)
61
+ hub.hostCapabilitiesSink = { _ => CJ_CAP_MENU }
62
+ hub.register("system:host", SystemCommands())
63
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":21,\"cmd\":\"system:host\"}")
64
+ waitSink(out, 1)
65
+ let reply = JsonValue.fromStr(out[0]).asObject()
66
+ @Expect(reply.get("ok").getOrThrow().asBool().getValue(), true)
67
+ let data = reply.get("data").getOrThrow().asObject()
68
+ @Expect(data.get("menu").getOrThrow().asBool().getValue(), true)
69
+ }
70
+
71
+ @Test
72
+ func testSystemHostIsDeniedWithoutCapability(): Unit {
73
+ // 三处联动缺了清单那一处:能力清单没声明 system:host → 调用线程上同步拒绝
74
+ let out = ArrayList<String>()
75
+ let hub = hubWith([], [], out)
76
+ hub.hostCapabilitiesSink = { _ => CJ_CAP_MENU }
77
+ hub.register("system:host", SystemCommands())
78
+ hub.handleRawMessage("{\"type\":\"invoke\",\"id\":23,\"cmd\":\"system:host\"}")
79
+ waitSink(out, 1)
80
+ @Expect(out[0].contains("command not allowed"), true)
81
+ }
@@ -0,0 +1,53 @@
1
+ package cjTauri.tests
2
+
3
+ import cjTauri.*
4
+ import std.unittest.*
5
+ import std.unittest.testmacro.*
6
+
7
+ /*
8
+ * SNI 的目标怎么定:`urlHostOf` 取主机名,`isIpLiteralHost` 判「是不是 IP 字面量」——
9
+ * 域名才带 `serverName`(stdx 默认不发 SNI,多租户 HTTPS 边缘会当场以 handshake failure 拒掉,
10
+ * 实测 Cloudflare 后面的 api 域名,见 D-022);IP 字面量不带(RFC 6066 不许,且直连 IP 原本可用)。
11
+ */
12
+
13
+ @Test
14
+ func testIpLiteralHostRecognizesIpv4AndIpv6(): Unit {
15
+ @Expect(isIpLiteralHost("127.0.0.1"), true)
16
+ @Expect(isIpLiteralHost("192.168.1.10"), true)
17
+ @Expect(isIpLiteralHost("255.255.255.255"), true)
18
+ @Expect(isIpLiteralHost("[::1]"), true)
19
+ @Expect(isIpLiteralHost("::1"), true)
20
+ @Expect(isIpLiteralHost("2606:4700:681a:e1a::1"), true)
21
+ }
22
+
23
+ @Test
24
+ func testIpLiteralHostRejectsHostnames(): Unit {
25
+ @Expect(isIpLiteralHost("ai.novacode.top"), false)
26
+ @Expect(isIpLiteralHost("localhost"), false)
27
+ // 全是十六进制字母的域名(含点)**不是** IP——按「四段十进制」判就不会误伤
28
+ @Expect(isIpLiteralHost("dead.beef"), false)
29
+ @Expect(isIpLiteralHost("127.0.0.1.evil.com"), false)
30
+ @Expect(isIpLiteralHost("1.2.3"), false)
31
+ @Expect(isIpLiteralHost("999.1.1.1"), false)
32
+ @Expect(isIpLiteralHost("1.2.3.4.5"), false)
33
+ @Expect(isIpLiteralHost(""), false)
34
+ }
35
+
36
+ @Test
37
+ func testSniTargetDecisionFromUrl(): Unit {
38
+ // 域名 → 带 SNI(这一条正是 ai.novacode.top 那轮修掉的东西)
39
+ let named = match (urlHostOf("https://ai.novacode.top/v1/models")) {
40
+ case Some(h) => h
41
+ case None => ""
42
+ }
43
+ @Expect(named, "ai.novacode.top")
44
+ @Expect(isIpLiteralHost(named), false)
45
+
46
+ // 直连 IP(带端口)→ 不带 SNI,连接与验证照旧
47
+ let byIp = match (urlHostOf("https://127.0.0.1:8765/api/health")) {
48
+ case Some(h) => h
49
+ case None => ""
50
+ }
51
+ @Expect(byIp, "127.0.0.1")
52
+ @Expect(isIpLiteralHost(byIp), true)
53
+ }