@oj-module/cli 0.2.2 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/README.md +14 -3
- package/devkit/SKILL.md +16 -4
- package/devkit/framework-dev.md +8 -6
- package/devkit/manual.md +243 -23
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +143 -8
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/args.ts +67 -0
- package/src/build.ts +11 -3
- package/src/bundle.ts +197 -0
- package/src/contract/__tests__/handler-scan.test.ts +82 -0
- package/src/contract/check.ts +2 -1
- package/src/contract/handler-scan.ts +32 -0
- package/src/deterministic-tar.ts +120 -0
- package/src/dev.ts +4 -2
- package/src/enable.ts +28 -0
- package/src/index.ts +63 -2
- package/src/info.ts +3 -2
- package/src/init.ts +147 -14
- package/src/install.ts +268 -0
- package/src/keygen.ts +88 -0
- package/src/layout.ts +31 -0
- package/src/lifecycle/__tests__/authoring.test.ts +109 -0
- package/src/lifecycle/__tests__/bundle.test.ts +150 -0
- package/src/lifecycle/__tests__/deterministic-tar.test.ts +145 -0
- package/src/lifecycle/__tests__/fixtures/README.md +59 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/EXPECTED-VIOLATIONS.md +27 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/api/src/Demo/manifest.yaml +6 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/manifest.yaml +40 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/entry.ts +14 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/en-US.json +12 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/zh-CN.json +12 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +6 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/ping/api.ts +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +96 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/entry.ts +23 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +29 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +29 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz +0 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +21 -0
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +196 -0
- package/src/lifecycle/__tests__/helpers.ts +78 -0
- package/src/lifecycle/__tests__/install.test.ts +514 -0
- package/src/lifecycle/__tests__/keygen.test.ts +61 -0
- package/src/lifecycle/__tests__/manifest-contract.test.ts +228 -0
- package/src/lifecycle/__tests__/publish.test.ts +80 -0
- package/src/lifecycle/__tests__/sig-fixtures.test.ts +100 -0
- package/src/lifecycle/__tests__/sign.test.ts +131 -0
- package/src/lifecycle/__tests__/smoke.test.ts +61 -0
- package/src/lifecycle/__tests__/trust.test.ts +177 -0
- package/src/lifecycle/__tests__/uninstall.test.ts +87 -0
- package/src/lifecycle/keygen.ts +40 -0
- package/src/lifecycle/sign.ts +74 -0
- package/src/manifest-contract.ts +696 -0
- package/src/oj-cert.ts +19 -0
- package/src/oj-cmd.ts +3 -3
- package/src/preview.ts +2 -2
- package/src/publish.ts +150 -0
- package/src/sign.ts +128 -0
- package/src/site.ts +146 -0
- package/src/trust.ts +116 -0
- package/src/uninstall.ts +29 -0
- package/src/usage.ts +35 -1
- package/templates/README.md +1 -1
- package/templates/api/config.yaml +47 -0
- package/templates/api/seed.mysql.sql +1 -1
- package/templates/api/seed.pg.sql +1 -1
- package/templates/api/src/_platform/_lifecycle/atomic.ts +98 -0
- package/templates/api/src/_platform/_lifecycle/history.ts +31 -0
- package/templates/api/src/_platform/_lifecycle/installer.ts +1080 -0
- package/templates/api/src/_platform/_lifecycle/lock.ts +148 -0
- package/templates/api/src/_platform/_lifecycle/semver.ts +61 -0
- package/templates/api/src/_platform/_lifecycle/sha384.ts +187 -0
- package/templates/api/src/_platform/_lifecycle/state.ts +256 -0
- package/templates/api/src/_platform/_lifecycle/steps/deploy-web.ts +182 -0
- package/templates/api/src/_platform/_lifecycle/steps/download.ts +90 -0
- package/templates/api/src/_platform/_lifecycle/steps/extract.ts +239 -0
- package/templates/api/src/_platform/_lifecycle/steps/migrate.ts +363 -0
- package/templates/api/src/_platform/_lifecycle/steps/preflight.ts +590 -0
- package/templates/api/src/_platform/_lifecycle/steps/register.ts +211 -0
- package/templates/api/src/_platform/_lifecycle/steps/reload.ts +81 -0
- package/templates/api/src/_platform/_lifecycle/steps/smoke.ts +216 -0
- package/templates/api/src/_platform/_lifecycle/steps/verify.ts +207 -0
- package/templates/api/src/_platform/_lifecycle/tasks.ts +119 -0
- package/templates/api/src/_platform/_lifecycle/text-edit.ts +110 -0
- package/templates/api/src/_platform/_lifecycle/tgz-guard.ts +41 -0
- package/templates/api/src/_platform/_lifecycle/trust.ts +229 -0
- package/templates/api/src/_platform/_lifecycle/types.ts +94 -0
- package/templates/api/src/_platform/_lifecycle/uninstall.ts +283 -0
- package/templates/api/src/_platform/_lifecycle/verify-checks.ts +228 -0
- package/templates/api/src/_platform/_lifecycle/verify-report.ts +92 -0
- package/templates/api/src/_platform/_shared/service/access.ts +2 -2
- package/templates/api/src/_platform/_shared/service/settings.ts +10 -1
- package/templates/api/src/_platform/_shared/service/solution-perms.ts +14 -7
- package/templates/api/src/_platform/_shared/site-facade.ts +44 -0
- package/templates/api/src/_platform/manifest.yaml +1 -0
- package/templates/api/src/_platform/market/api.ts +883 -0
- package/templates/api/src/_platform/market/bootstrap.ts +39 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.mysql.sql +17 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.pg.sql +16 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.sql +18 -0
- package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.mysql.sql +2 -0
- package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.sql +4 -0
- package/templates/api/src/_platform/migrations/0012__market_history.mysql.sql +13 -0
- package/templates/api/src/_platform/migrations/0012__market_history.pg.sql +12 -0
- package/templates/api/src/_platform/migrations/0012__market_history.sql +14 -0
- package/templates/api/src/_platform/schema.yaml +21 -4
- package/templates/api/src/_platform/seed.sql +2 -2
- package/templates/api/src/_platform/solutions/api.ts +212 -0
- package/templates/api/src/demo/manifest.yaml +5 -0
- package/templates/api/src/demo/migrations/0001__create_todos.sql +9 -0
- package/templates/api/src/demo/schema.yaml +11 -0
- package/templates/api/src/demo/seed.sql +5 -0
- package/templates/api/src/demo/todos/api.ts +59 -0
- package/templates/api/src/home/line/api.ts +57 -0
- package/templates/api/src/home/manifest.yaml +6 -0
- package/templates/api/src/home/migrations/0001__create_home_tables.sql +13 -0
- package/templates/api/src/home/pie/api.ts +40 -0
- package/templates/api/src/home/schema.yaml +14 -0
- package/templates/api/src/home/seed.sql +70 -0
- package/templates/api/src/system/oplog/api.ts +44 -2
- package/templates/api/tests/fixture.ts +16 -0
- package/templates/api/tests/lifecycle/atomic-state.spec.ts +183 -0
- package/templates/api/tests/lifecycle/bootstrap.spec.ts +116 -0
- package/templates/api/tests/lifecycle/deploy-smoke.spec.ts +492 -0
- package/templates/api/tests/lifecycle/installer.spec.ts +1218 -0
- package/templates/api/tests/lifecycle/market-api.spec.ts +971 -0
- package/templates/api/tests/lifecycle/migrate.spec.ts +575 -0
- package/templates/api/tests/lifecycle/preflight.spec.ts +255 -0
- package/templates/api/tests/lifecycle/register.spec.ts +194 -0
- package/templates/api/tests/lifecycle/semver.spec.ts +43 -0
- package/templates/api/tests/lifecycle/sha384.spec.ts +48 -0
- package/templates/api/tests/lifecycle/sig-fixtures.spec.ts +65 -0
- package/templates/api/tests/lifecycle/solutions-api.spec.ts +98 -0
- package/templates/api/tests/lifecycle/steps-fs.spec.ts +623 -0
- package/templates/api/tests/lifecycle/text-edit.spec.ts +177 -0
- package/templates/api/tests/lifecycle/tgz-guard.spec.ts +103 -0
- package/templates/api/tests/lifecycle/trust.spec.ts +310 -0
- package/templates/api/tests/lifecycle/uninstall.spec.ts +386 -0
- package/templates/api/tests/lifecycle/verify-checks.spec.ts +385 -0
- package/templates/api/tests/lifecycle/verify-report.spec.ts +290 -0
- package/templates/api/tests/rbac-settings.spec.ts +8 -0
- package/templates/init.include.yaml +56 -0
- package/templates/openapi.json +527 -0
- package/templates/package.json +52 -0
- package/templates/web/src/_platform/__client/api.schemas.ts +8 -0
- package/templates/web/src/_platform/__client/api.ts +335 -0
- package/templates/web/src/about/entry.ts +42 -0
- package/templates/web/src/about/locales/en-US.json +16 -0
- package/templates/web/src/about/locales/zh-CN.json +16 -0
- package/templates/web/src/about/pages/constants.ts +20 -0
- package/templates/web/src/about/pages/index.tsx +135 -0
- package/templates/web/src/access/entry.ts +74 -0
- package/templates/web/src/access/locales/en-US.json +46 -0
- package/templates/web/src/access/locales/zh-CN.json +46 -0
- package/templates/web/src/access/pages/access-mode/index.tsx +16 -0
- package/templates/web/src/access/pages/admin-visible/index.tsx +16 -0
- package/templates/web/src/access/pages/button-control/index.tsx +181 -0
- package/templates/web/src/access/pages/common-visible/index.tsx +16 -0
- package/templates/web/src/access/pages/page-control/index.tsx +142 -0
- package/templates/web/src/demo/__client/api.schemas.ts +20 -0
- package/templates/web/src/demo/__client/api.ts +89 -0
- package/templates/web/src/exception/entry.ts +80 -0
- package/templates/web/src/exception/locales/en-US.json +13 -0
- package/templates/web/src/exception/locales/zh-CN.json +13 -0
- package/templates/web/src/exception/pages/403/index.tsx +30 -0
- package/templates/web/src/exception/pages/404/index.tsx +30 -0
- package/templates/web/src/exception/pages/500/index.tsx +30 -0
- package/templates/web/src/exception/pages/unknown-component/index.tsx +38 -0
- package/templates/web/src/home/__client/api.schemas.ts +22 -0
- package/templates/web/src/home/__client/api.ts +113 -0
- package/templates/web/src/login/locales/en-US.json +6 -0
- package/templates/web/src/login/locales/zh-CN.json +6 -0
- package/templates/web/src/login/pages/login.tsx +67 -18
- package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +93 -0
- package/templates/web/src/oj-market/__tests__/HistoryDrawer.test.tsx +28 -0
- package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +69 -0
- package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +43 -0
- package/templates/web/src/oj-market/__tests__/UninstallConfirm.test.tsx +30 -0
- package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +70 -0
- package/templates/web/src/oj-market/__tests__/poll.test.ts +24 -0
- package/templates/web/src/oj-market/__tests__/render.tsx +14 -0
- package/templates/web/src/oj-market/__tests__/runtime-stub.ts +14 -0
- package/templates/web/src/oj-market/__tests__/semver.test.ts +26 -0
- package/templates/web/src/oj-market/api/MarketClientProvider.tsx +12 -0
- package/templates/web/src/oj-market/api/client.ts +135 -0
- package/templates/web/src/oj-market/api/context.ts +11 -0
- package/templates/web/src/oj-market/api/defaultClient.ts +7 -0
- package/templates/web/src/oj-market/components/CardGrid.tsx +76 -0
- package/templates/web/src/oj-market/components/CardItem.tsx +102 -0
- package/templates/web/src/oj-market/components/DetailDrawer.tsx +53 -0
- package/templates/web/src/oj-market/components/HistoryDrawer.tsx +75 -0
- package/templates/web/src/oj-market/components/InstallDialog.tsx +112 -0
- package/templates/web/src/oj-market/components/RollbackDialog.tsx +79 -0
- package/templates/web/src/oj-market/components/UninstallConfirm.tsx +74 -0
- package/templates/web/src/oj-market/entry.ts +27 -0
- package/templates/web/src/oj-market/hooks/useMarket.ts +91 -0
- package/templates/web/src/oj-market/locales/en-US.json +100 -0
- package/templates/web/src/oj-market/locales/zh-CN.json +100 -0
- package/templates/web/src/oj-market/pages/MarketApp.tsx +136 -0
- package/templates/web/src/oj-market/types.ts +84 -0
- package/templates/web/src/oj-market/utils/poll.ts +36 -0
- package/templates/web/src/oj-market/utils/semver.ts +14 -0
- package/templates/web/src/outside/entry.ts +80 -0
- package/templates/web/src/outside/locales/en-US.json +10 -0
- package/templates/web/src/outside/locales/zh-CN.json +10 -0
- package/templates/web/src/personal-center/entry.ts +1 -10
- package/templates/web/src/personal-center/locales/en-US.json +1 -5
- package/templates/web/src/personal-center/locales/zh-CN.json +1 -5
- package/templates/web/src/route-nest/entry.ts +73 -0
- package/templates/web/src/route-nest/locales/en-US.json +9 -0
- package/templates/web/src/route-nest/locales/zh-CN.json +9 -0
- package/templates/web/src/route-nest/pages/menu1/menu1-1/index.tsx +5 -0
- package/templates/web/src/route-nest/pages/menu1/menu1-2/index.tsx +5 -0
- package/templates/web/src/route-nest/pages/menu2/index.tsx +5 -0
- package/templates/web/src/system/__client/api.schemas.ts +12 -0
- package/templates/web/src/system/__client/api.ts +25 -1
- package/templates/web/src/system/pages/oplog/index.tsx +1 -1
- package/templates/web/src/system/pages/roles/index.tsx +1 -0
- package/templates/web/src/system/pages/settings/index.tsx +1 -1
- package/templates/web/src/system/pages/users/index.tsx +1 -1
- package/templates/web.config.ts +32 -40
- package/vendor/host-versions.json +3 -3
- package/templates/pnpm-workspace.yaml +0 -14
- package/templates/web/src/personal-center/pages/settings/index.tsx +0 -5
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// oj 原生扁平锁 manifests.yaml(D12)+ 安装器任务互斥锁 .install-tasks/.lock(R17/R31)。
|
|
2
|
+
// 锁格式严格 = `name: version` 字符串表(嵌套结构/额外键会解析失败拒启——禁加字段,S1-1.2/S1-3);
|
|
3
|
+
// 写 = 全量重写经 atomicWriteText,改写前备份原锁至 <lock>.bak(恢复 = 回拷 .bak,用例钉住);
|
|
4
|
+
// 写后读回校验重试收敛(S1-3 实测:oj build 的 load→save 窗口会吞并发外部写——不能假设 oj
|
|
5
|
+
// 尊重安装器锁,oj 锁本体无互斥是 S1 实测结论,互斥是安装器自己的纪律)。
|
|
6
|
+
// 任务互斥 = mkdir EEXIST 语义(O_CREAT|O_EXCL 的 POSIX 等价原语):.lock 目录存在即占用;
|
|
7
|
+
// owner.json 落持有者与时间戳,超时(默认 30min,R13 同款阈值)判 stale 可接管(持有者崩溃自愈)。
|
|
8
|
+
|
|
9
|
+
import { atomicWriteText, fsFace, isMissingPath, randomId } from "./atomic";
|
|
10
|
+
|
|
11
|
+
/** 解析扁平锁文本为 {name: version} 表。任何非 `name: version` 行都算坏锁(fail-closed,不猜)。 */
|
|
12
|
+
export function parseLock(text: string): Record<string, string> {
|
|
13
|
+
const out: Record<string, string> = {};
|
|
14
|
+
for (const [i, line] of text.split("\n").entries()) {
|
|
15
|
+
if (line.trim() === "") {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const m = /^([\w.-]+): "?([\w.-]+)"?$/.exec(line);
|
|
19
|
+
if (m === null) {
|
|
20
|
+
throw new Error(`lock: manifests.yaml 第 ${i + 1} 行不是合法扁平锁条目(name: version):「${line}」`);
|
|
21
|
+
}
|
|
22
|
+
out[m[1]] = m[2];
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 序列化为 oj 原生扁平形态:键序排序、`name: version` 逐行(name/version 字符集均 yaml 安全可裸写) */
|
|
28
|
+
export function serializeLock(entries: Record<string, string>): string {
|
|
29
|
+
const keys = Object.keys(entries).sort();
|
|
30
|
+
return keys.length === 0 ? "" : `${keys.map(k => `${k}: ${entries[k]}`).join("\n")}\n`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 读锁:文件缺失视同空表(首装);坏锁(解析失败)原样上抛由调用方给可读报错。 */
|
|
34
|
+
export async function readLock(lockPath: string): Promise<Record<string, string>> {
|
|
35
|
+
try {
|
|
36
|
+
return parseLock(new TextDecoder().decode(await fsFace().readFile(lockPath)));
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
if (isMissingPath(e)) {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 全量重写锁(D12):改写前备份原锁至 <lock>.bak(原锁不存在则无备份可做);拒绝空锁
|
|
48
|
+
* (空锁拒启,S1-1.2 实测 oj exit 1)——摘除最后一个条目必须被拒绝,站点至少保留一个已锁模块。
|
|
49
|
+
*/
|
|
50
|
+
export async function writeLock(lockPath: string, entries: Record<string, string>): Promise<void> {
|
|
51
|
+
if (Object.keys(entries).length === 0) {
|
|
52
|
+
throw new Error("lock: 拒绝写入空锁(空锁拒启,S1-1.2)——站点至少保留一个已锁模块");
|
|
53
|
+
}
|
|
54
|
+
const f = fsFace();
|
|
55
|
+
try {
|
|
56
|
+
await f.readFile(lockPath);
|
|
57
|
+
await f.copyFile(lockPath, `${lockPath}.bak`);
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
if (!isMissingPath(e)) {
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
await atomicWriteText(lockPath, serializeLock(entries));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface LockCommitResult {
|
|
68
|
+
ok: boolean
|
|
69
|
+
message: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* upsert 单条目并读回校验(S1-3 读回收敛):读最新 → upsert → .bak → 原子写 → 读回,条目被
|
|
74
|
+
* 并发写者吞掉则重试(默认 3 次),仍失败给附录 A lock_commit 语义(retryable:存在并发构建或安装)。
|
|
75
|
+
*/
|
|
76
|
+
export async function commitLockEntry(lockPath: string, name: string, version: string, retries = 3): Promise<LockCommitResult> {
|
|
77
|
+
for (let i = 1; i <= retries; i++) {
|
|
78
|
+
const entries = await readLock(lockPath);
|
|
79
|
+
entries[name] = version;
|
|
80
|
+
await writeLock(lockPath, entries);
|
|
81
|
+
const back = await readLock(lockPath);
|
|
82
|
+
if (back[name] === version) {
|
|
83
|
+
return { ok: true, message: `manifests.yaml upsert ${name}: ${version}(写后读回校验通过)` };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
message: `生效切换失败:锁写后读回 ${retries} 次均不符(条目 ${name} 被并发写者吞写,S1-3)——附录 A lock_commit:存在并发构建或安装,请稍后重试`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface TaskLock {
|
|
93
|
+
release: () => Promise<void>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** stale 阈值:与任务超时判 failed 同款 30min(R13/B9/R31 口径一致) */
|
|
97
|
+
export const LOCK_STALE_MS = 30 * 60_000;
|
|
98
|
+
|
|
99
|
+
export interface TaskLockOptions {
|
|
100
|
+
/** 占用判定过期阈值(默认 30min;持有者崩溃后可接管) */
|
|
101
|
+
staleMs?: number
|
|
102
|
+
now?: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 取安装任务互斥锁(R17/R31):.lock 目录 mkdir EEXIST 为原子闸;owner.json 记持有者与时间戳。
|
|
107
|
+
* 并发第二取锁抛 busy;持有者崩溃后 owner 时间戳超 staleMs 可接管(stale takeover)。
|
|
108
|
+
* owner.json 缺失/不可读(mkdir 与写 owner 之间的崩溃窗口)无法证明 stale,按占用拒绝——
|
|
109
|
+
* 窗口极窄且可人工清除(删 .lock 目录),不给并发误判留口。
|
|
110
|
+
*/
|
|
111
|
+
export async function acquireTaskLock(taskDir: string, opts: TaskLockOptions = {}): Promise<TaskLock> {
|
|
112
|
+
const f = fsFace();
|
|
113
|
+
const dir = `${taskDir}/.lock`;
|
|
114
|
+
await f.mkdir(taskDir, { recursive: true });
|
|
115
|
+
const now = opts.now ?? Date.now();
|
|
116
|
+
const staleMs = opts.staleMs ?? LOCK_STALE_MS;
|
|
117
|
+
let owner: { holder?: string, acquiredAt?: number } | undefined;
|
|
118
|
+
try {
|
|
119
|
+
owner = JSON.parse(new TextDecoder().decode(await f.readFile(`${dir}/owner.json`)));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// 无 owner.json:目录裸建或不存在——不可证明 stale,走 mkdir 闸判定
|
|
123
|
+
}
|
|
124
|
+
if (typeof owner?.acquiredAt === "number") {
|
|
125
|
+
if (now - owner.acquiredAt < staleMs) {
|
|
126
|
+
throw new Error(`install busy:已有安装任务持锁(${dir},holder ${owner.holder ?? "?"})——R17/R31 互斥;持有者崩溃后约 ${Math.round(staleMs / 60000)} 分钟可接管`);
|
|
127
|
+
}
|
|
128
|
+
await f.remove(dir, { recursive: true }); // stale 接管
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await f.mkdir(dir);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
throw new Error(`install busy:已有安装任务持锁(${dir})——R17/R31 互斥`);
|
|
135
|
+
}
|
|
136
|
+
await f.writeFile(`${dir}/owner.json`, new TextEncoder().encode(JSON.stringify({ holder: randomId(), acquiredAt: now })));
|
|
137
|
+
return {
|
|
138
|
+
async release(): Promise<void> {
|
|
139
|
+
// 释放失败不掩盖主流程结果(.lock 残留由 stale 接管自愈)
|
|
140
|
+
try {
|
|
141
|
+
await f.remove(dir, { recursive: true });
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// ponytail: 单持有者纪律,不校验 owner 归属;多写者场景需先验 holder 再删
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// semver 比较器(R25):纯字符串解析、无依赖。
|
|
2
|
+
// 容错口径:允许缺省段(「1.0」=「1.0.0.0」,缺省视为 0)。
|
|
3
|
+
// prerelease 遵循 semver 2.0.0:数字标识符按数值、字母按字典序、数字 < 字母、
|
|
4
|
+
// 标识符列表前缀相等时多者大;无 prerelease > 有 prerelease。
|
|
5
|
+
|
|
6
|
+
function splitVersion(v: string): { core: number[], pre: string[] | null } {
|
|
7
|
+
const hyphen = v.indexOf("-");
|
|
8
|
+
const core = hyphen === -1 ? v : v.slice(0, hyphen);
|
|
9
|
+
const prePart = hyphen === -1 ? null : v.slice(hyphen + 1);
|
|
10
|
+
const coreParts = core.split(".");
|
|
11
|
+
const pre = prePart == null ? null : prePart.split(".");
|
|
12
|
+
return {
|
|
13
|
+
// 容错口径:非数字段(如 "1.0.x" 的 "x")静默归零,不抛错
|
|
14
|
+
core: coreParts.map(seg => Number.parseInt(seg, 10) || 0),
|
|
15
|
+
pre,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function comparePrerelease(a: string[] | null, b: string[] | null): -1 | 0 | 1 {
|
|
20
|
+
// 无 prerelease > 有 prerelease
|
|
21
|
+
if (a === null || b === null) {
|
|
22
|
+
return a === b ? 0 : a === null ? 1 : -1;
|
|
23
|
+
}
|
|
24
|
+
const len = Math.max(a.length, b.length);
|
|
25
|
+
for (let i = 0; i < len; i++) {
|
|
26
|
+
const ia = a[i];
|
|
27
|
+
const ib = b[i];
|
|
28
|
+
// 前缀相等时,标识符多者大
|
|
29
|
+
if (ia === undefined)
|
|
30
|
+
return -1;
|
|
31
|
+
if (ib === undefined)
|
|
32
|
+
return 1;
|
|
33
|
+
const na = /^\d+$/.test(ia);
|
|
34
|
+
const nb = /^\d+$/.test(ib);
|
|
35
|
+
if (na && nb) {
|
|
36
|
+
const diff = Number.parseInt(ia, 10) - Number.parseInt(ib, 10);
|
|
37
|
+
if (diff !== 0)
|
|
38
|
+
return diff < 0 ? -1 : 1;
|
|
39
|
+
}
|
|
40
|
+
else if (na !== nb) {
|
|
41
|
+
// 数字 < 字母
|
|
42
|
+
return na ? -1 : 1;
|
|
43
|
+
}
|
|
44
|
+
else if (ia !== ib) {
|
|
45
|
+
return ia < ib ? -1 : 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
|
|
52
|
+
const va = splitVersion(a);
|
|
53
|
+
const vb = splitVersion(b);
|
|
54
|
+
const len = Math.max(va.core.length, vb.core.length);
|
|
55
|
+
for (let i = 0; i < len; i++) {
|
|
56
|
+
const diff = (va.core[i] ?? 0) - (vb.core[i] ?? 0);
|
|
57
|
+
if (diff !== 0)
|
|
58
|
+
return diff < 0 ? -1 : 1;
|
|
59
|
+
}
|
|
60
|
+
return comparePrerelease(va.pre, vb.pre);
|
|
61
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// 过渡实现:上游 crypto.sha384 原语落地后替换(需求稿已提,见
|
|
2
|
+
// docs/prd/solution/202610161500-oj-bin-feature-requests-pr.md)。
|
|
3
|
+
// 纯 JS SHA-384(B-11 真机补面):oj isolate 的 crypto 面(0.1.78 实测)只有
|
|
4
|
+
// sha256Hex/md5Hex/aesGcm/verifyEd25519,无 sha384 原语;而契约 C2-28 的 web.integrity
|
|
5
|
+
// 钉死 `sha384-<base64>`(ojm build 于 Node 侧写入),安装器必须同算法重算比对(R10
|
|
6
|
+
// fail-closed)——真机冒烟首轮死于 `crypto.sha384B64 is not a function`。实现取 SHA-512
|
|
7
|
+
// 核心(sha384 = 截断 48 字节 + 独立 IV),hi/lo 双 Uint32 字实现(BigInt 版逐块 80 轮在
|
|
8
|
+
// MB 级产物上不可接受)。K/IV 常量表不在代码里摊开:模块加载时由素数平方根/立方根小数
|
|
9
|
+
// 部分推导(FIPS 180-4 定义式,一次性 <1ms,逐字等于手抄表)。
|
|
10
|
+
|
|
11
|
+
/** 前 count 个素数(2 起)——K/IV 的种子,N=80 时最坏几百次试除,加载期一次性 */
|
|
12
|
+
function primes(count: number): bigint[] {
|
|
13
|
+
const out: bigint[] = [];
|
|
14
|
+
for (let n = 2n; out.length < count; n++) {
|
|
15
|
+
let isPrime = true;
|
|
16
|
+
for (const p of out) {
|
|
17
|
+
if (p * p > n)
|
|
18
|
+
break;
|
|
19
|
+
if (n % p === 0n) {
|
|
20
|
+
isPrime = false;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (isPrime)
|
|
25
|
+
out.push(n);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** floor((x << (64·root)) ** (1/root)) 的低 64 位:FIPS 180-4 常量 = 素数 root 次根小数部分前 64 位。二分整根(≤66 步),加载期一次性 */
|
|
31
|
+
function frac64(x: bigint, root: bigint): [number, number] {
|
|
32
|
+
const shift = x << (64n * root);
|
|
33
|
+
let lo = 0n;
|
|
34
|
+
let hi = 16n << 64n; // 上界:素数 ≤ 409 ⇒ root 次根 = cbrt(p)·2^64 < 16·2^64
|
|
35
|
+
while (lo < hi) {
|
|
36
|
+
const mid = (lo + hi + 1n) >> 1n;
|
|
37
|
+
if (mid ** root <= shift)
|
|
38
|
+
lo = mid;
|
|
39
|
+
else
|
|
40
|
+
hi = mid - 1n;
|
|
41
|
+
}
|
|
42
|
+
const low64 = lo & ((1n << 64n) - 1n);
|
|
43
|
+
return [Number(low64 >> 32n) >>> 0, Number(low64 & 0xFFFFFFFFn) >>> 0];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// K = 前 80 个素数的立方根小数部分前 64 位(FIPS 180-4 §4.2.3 定义式推导,逐字等于手抄表)。
|
|
47
|
+
const K: Array<[number, number]> = primes(80).map(p => frac64(p, 3n));
|
|
48
|
+
// sha384 IV(FIPS 180-4 §5.3.4 逐字):8 × 64bit,hi/lo 对
|
|
49
|
+
const IV: Array<[number, number]> = [
|
|
50
|
+
[0xCBBB9D5D, 0xC1059ED8],
|
|
51
|
+
[0x629A292A, 0x367CD507],
|
|
52
|
+
[0x9159015A, 0x3070DD17],
|
|
53
|
+
[0x152FECD8, 0xF70E5939],
|
|
54
|
+
[0x67332667, 0xFFC00B31],
|
|
55
|
+
[0x8EB44A87, 0x68581511],
|
|
56
|
+
[0xDB0C2E0D, 0x64F98FA7],
|
|
57
|
+
[0x47B5481D, 0xBEFA4FA4],
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const MASK = 0xFFFFFFFF;
|
|
61
|
+
|
|
62
|
+
/** rotr(x, n) 作用在 hi/lo 合成 64 位上(拆移位组合,无 BigInt 热路径) */
|
|
63
|
+
function rotrHi(hi: number, lo: number, n: number): number {
|
|
64
|
+
if (n === 32)
|
|
65
|
+
return hi;
|
|
66
|
+
if (n < 32)
|
|
67
|
+
return ((hi >>> n) | (lo << (32 - n))) & MASK;
|
|
68
|
+
return ((lo >>> (n - 32)) | (hi << (64 - n))) & MASK;
|
|
69
|
+
}
|
|
70
|
+
function rotrLo(hi: number, lo: number, n: number): number {
|
|
71
|
+
if (n === 32)
|
|
72
|
+
return lo;
|
|
73
|
+
if (n < 32)
|
|
74
|
+
return ((lo >>> n) | (hi << (32 - n))) & MASK;
|
|
75
|
+
return ((hi >>> (n - 32)) | (lo << (64 - n))) & MASK;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 64 位加法(hi/lo 对,进位取模 2^32)。入参低位先 >>> 0 归一:上游 XOR/rotr 链会漏出
|
|
80
|
+
* 负 int32(& MASK 不改符号位),进位判定 lo < alo 在负数输入下恒假 → 丢进位(真机向量首轮实测)
|
|
81
|
+
*/
|
|
82
|
+
function add64(a: [number, number], b: [number, number]): [number, number] {
|
|
83
|
+
const alo = a[1] >>> 0;
|
|
84
|
+
const blo = b[1] >>> 0;
|
|
85
|
+
const lo = (alo + blo) >>> 0;
|
|
86
|
+
const carry = lo < alo ? 1 : 0;
|
|
87
|
+
return [(a[0] + b[0] + carry) & MASK, lo];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** SHA-384 字节摘要(48 字节);data = 任意长度字节序列 */
|
|
91
|
+
export function sha384Bytes(data: Uint8Array): Uint8Array {
|
|
92
|
+
// 消息填充:0x80 + 0x00... + 128bit 长度(bit 数)
|
|
93
|
+
const bitLenHi = Math.floor((data.length / 0x20000000)) >>> 0;
|
|
94
|
+
const bitLenLo = (data.length << 3) >>> 0;
|
|
95
|
+
const padded = (((data.length + 16) >> 7) + 1) << 7;
|
|
96
|
+
const buf = new Uint8Array(padded);
|
|
97
|
+
buf.set(data);
|
|
98
|
+
buf[data.length] = 0x80;
|
|
99
|
+
const view = new DataView(buf.buffer);
|
|
100
|
+
view.setUint32(padded - 16, bitLenHi);
|
|
101
|
+
view.setUint32(padded - 4, bitLenLo);
|
|
102
|
+
|
|
103
|
+
const h: Array<[number, number]> = IV.map(x => [x[0], x[1]] as [number, number]);
|
|
104
|
+
const w = Array.from({ length: 80 });
|
|
105
|
+
for (let off = 0; off < padded; off += 128) {
|
|
106
|
+
for (let t = 0; t < 16; t++) {
|
|
107
|
+
w[t] = [view.getUint32(off + t * 8), view.getUint32(off + t * 8 + 4)];
|
|
108
|
+
}
|
|
109
|
+
for (let t = 16; t < 80; t++) {
|
|
110
|
+
// σ0 = ROTR1 ^ ROTR8 ^ SHR7;σ1 = ROTR19 ^ ROTR61 ^ SHR6。SHR 低字必须从 hi 的低位
|
|
111
|
+
// 补进 hi<<(32-n)(64 位逻辑右移跨字,非两次独立 32 位移位——差此项向量不过,差分实测)
|
|
112
|
+
const a15 = w[t - 15];
|
|
113
|
+
const a2 = w[t - 2];
|
|
114
|
+
const s0h = rotrHi(a15[0], a15[1], 1) ^ rotrHi(a15[0], a15[1], 8) ^ (a15[0] >>> 7);
|
|
115
|
+
const s0l = rotrLo(a15[0], a15[1], 1) ^ rotrLo(a15[0], a15[1], 8) ^ ((a15[1] >>> 7) | (a15[0] << 25));
|
|
116
|
+
const s1h = rotrHi(a2[0], a2[1], 19) ^ rotrHi(a2[0], a2[1], 61) ^ (a2[0] >>> 6);
|
|
117
|
+
const s1l = rotrLo(a2[0], a2[1], 19) ^ rotrLo(a2[0], a2[1], 61) ^ ((a2[1] >>> 6) | (a2[0] << 26));
|
|
118
|
+
w[t] = add64(add64(add64(w[t - 16], [s0h, s0l]), w[t - 7]), [s1h, s1l]);
|
|
119
|
+
}
|
|
120
|
+
let [ah, al] = h[0];
|
|
121
|
+
let [bh, bl] = h[1];
|
|
122
|
+
let [ch, cl] = h[2];
|
|
123
|
+
let [dh, dl] = h[3];
|
|
124
|
+
let [eh, el] = h[4];
|
|
125
|
+
let [fh, fl] = h[5];
|
|
126
|
+
let [gh, gl] = h[6];
|
|
127
|
+
let [hh, hl] = h[7];
|
|
128
|
+
for (let t = 0; t < 80; t++) {
|
|
129
|
+
// S1 = Σ6(e),ch = (e&f)^(~e&g),T1 = h + S1 + ch + K[t] + w[t]
|
|
130
|
+
const s1h = rotrHi(eh, el, 14) ^ rotrHi(eh, el, 18) ^ rotrHi(eh, el, 41);
|
|
131
|
+
const s1l = rotrLo(eh, el, 14) ^ rotrLo(eh, el, 18) ^ rotrLo(eh, el, 41);
|
|
132
|
+
const chh = (eh & fh) ^ (~eh & gh) & MASK;
|
|
133
|
+
const chl = (el & fl) ^ (~el & gl) & MASK;
|
|
134
|
+
const t1 = add64(add64(add64(add64([hh, hl], [s1h, s1l]), [chh, chl]), K[t]), w[t]);
|
|
135
|
+
// S0 = Σ0(a),maj = (a&b)^(a&c)^(b&c),T2 = S0 + maj
|
|
136
|
+
const s0h = rotrHi(ah, al, 28) ^ rotrHi(ah, al, 34) ^ rotrHi(ah, al, 39);
|
|
137
|
+
const s0l = rotrLo(ah, al, 28) ^ rotrLo(ah, al, 34) ^ rotrLo(ah, al, 39);
|
|
138
|
+
const majh = (ah & bh) ^ (ah & ch) ^ (bh & ch);
|
|
139
|
+
const majl = (al & bl) ^ (al & cl) ^ (bl & cl);
|
|
140
|
+
const t2 = add64([s0h, s0l], [majh, majl]);
|
|
141
|
+
hh = gh;
|
|
142
|
+
hl = gl;
|
|
143
|
+
gh = fh;
|
|
144
|
+
gl = fl;
|
|
145
|
+
fh = eh;
|
|
146
|
+
fl = el;
|
|
147
|
+
[eh, el] = add64([dh, dl], t1);
|
|
148
|
+
dh = ch;
|
|
149
|
+
dl = cl;
|
|
150
|
+
ch = bh;
|
|
151
|
+
cl = bl;
|
|
152
|
+
bh = ah;
|
|
153
|
+
bl = al;
|
|
154
|
+
[ah, al] = add64(t1, t2);
|
|
155
|
+
}
|
|
156
|
+
h[0] = add64(h[0], [ah, al]);
|
|
157
|
+
h[1] = add64(h[1], [bh, bl]);
|
|
158
|
+
h[2] = add64(h[2], [ch, cl]);
|
|
159
|
+
h[3] = add64(h[3], [dh, dl]);
|
|
160
|
+
h[4] = add64(h[4], [eh, el]);
|
|
161
|
+
h[5] = add64(h[5], [fh, fl]);
|
|
162
|
+
h[6] = add64(h[6], [gh, gl]);
|
|
163
|
+
h[7] = add64(h[7], [hh, hl]);
|
|
164
|
+
}
|
|
165
|
+
// 截断 48 字节 = 前 6 个字
|
|
166
|
+
const out = new Uint8Array(48);
|
|
167
|
+
const ov = new DataView(out.buffer);
|
|
168
|
+
for (let i = 0; i < 6; i++) {
|
|
169
|
+
ov.setUint32(i * 8, h[i][0]);
|
|
170
|
+
ov.setUint32(i * 8 + 4, h[i][1]);
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** bytes → base64(btoa 双端可用:oj isolate 全局 atob/btoa,Node 18+ 同名全局) */
|
|
176
|
+
export function bytesToBase64(bytes: Uint8Array): string {
|
|
177
|
+
let bin = "";
|
|
178
|
+
for (const b of bytes) {
|
|
179
|
+
bin += String.fromCharCode(b);
|
|
180
|
+
}
|
|
181
|
+
return btoa(bin);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** C2-28/R10 完整 integrity 值:`sha384-<base64(digest)>` */
|
|
185
|
+
export function sha384B64(data: Uint8Array): string {
|
|
186
|
+
return `sha384-${bytesToBase64(sha384Bytes(data))}`;
|
|
187
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// sidecar 状态存储(D12):.solutions/state.yaml 读写——enabled/时间戳/GC 元数据
|
|
2
|
+
// 不进 oj 原生扁平锁(manifests.yaml 嵌套结构会拒启),全部落这里。
|
|
3
|
+
// YAML 采用受限子集(两层 map + 标量,键序固定、方案键排序输出),格式形如:
|
|
4
|
+
//
|
|
5
|
+
// solutions:
|
|
6
|
+
// demo:
|
|
7
|
+
// version: "1.0.0"
|
|
8
|
+
// enabled: true
|
|
9
|
+
// installedAt: 1730000000000
|
|
10
|
+
// archive: "{\"sha256\":\"…\",\"key_id\":\"…\",\"signature\":{…}}" # P2-6 复验真源(JSON 字符串标量)
|
|
11
|
+
// trust_state: trusted # P2-6 发布方 key 状态缓存
|
|
12
|
+
//
|
|
13
|
+
// 只保证读回本模块自己写出的内容,不引入 yaml 依赖;任何不符格式的字节一律视为
|
|
14
|
+
// 损坏 → 空状态,损坏上下文并入读结果(corruptNote/corruptReason;禁 console——release
|
|
15
|
+
// isolate console 抛 BadResource,Finding A;不炸,write 会以 patch 为基自愈重写)。
|
|
16
|
+
// read() 返回工厂 hydrate 时的同步快照(逐条目克隆,调用方改动不影响 store);
|
|
17
|
+
// write() 每次从盘上最新内容做读-改-写合并(多写者不丢彼此的方案键),
|
|
18
|
+
// patch 值为 null 表示删除该方案键(GC/卸载的删除出口)。
|
|
19
|
+
// fs 依赖同 atomic.ts(globalThis.fs 面)。
|
|
20
|
+
|
|
21
|
+
import { atomicWriteText, fsFace } from "./atomic";
|
|
22
|
+
|
|
23
|
+
export interface SolutionEntry {
|
|
24
|
+
version: string
|
|
25
|
+
enabled: boolean
|
|
26
|
+
installedAt: number
|
|
27
|
+
/**
|
|
28
|
+
* P2-6 复验真源:安装时落的包摘要 + 签名记录(D11③ 切换前复验 / 同版本重装摘要比对)。
|
|
29
|
+
* JSON 字符串标量落盘(两层 YAML 约束保持);P1 时代条目无此字段 = 无验签真源。
|
|
30
|
+
*/
|
|
31
|
+
archive?: { sha256: string, key_id: string, signature: Record<string, unknown> }
|
|
32
|
+
/** 发布方 key 状态缓存(P2-6:安装=trusted;启用时 key 已吊销=untrusted;P1 时代条目=absent) */
|
|
33
|
+
trust_state?: "trusted" | "untrusted" | "absent"
|
|
34
|
+
/** P3-2 版本时间序记录(GC 依据):成功安装的版本按 installedAt 倒序(最新在前),保留最近 N=3 + 锁版本强制保留 */
|
|
35
|
+
history?: Array<{ version: string, installedAt: number }>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SolutionState {
|
|
39
|
+
solutions: Record<string, SolutionEntry>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface StateStore {
|
|
43
|
+
read: () => SolutionState
|
|
44
|
+
write: (patch: Partial<SolutionState>) => Promise<void>
|
|
45
|
+
/** 最近一次盘上读到的损坏 note(null = 未损坏;诊断观测出口,禁 console——Finding A;可选,测试 stub 免实现) */
|
|
46
|
+
corruptNote?: () => string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const EMPTY: SolutionState = { solutions: {} };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 快照克隆:oj isolate 无 structuredClone(P1-13 真机冒烟实测 500,契约附录 B-8);
|
|
53
|
+
* 状态形状是受限子集(string/bool/number 的两层 map),逐条目浅拷贝即达「调用方改动不影响 store」。
|
|
54
|
+
*/
|
|
55
|
+
function snapshot(state: SolutionState): SolutionState {
|
|
56
|
+
const solutions: Record<string, SolutionEntry> = {};
|
|
57
|
+
for (const [name, entry] of Object.entries(state.solutions)) {
|
|
58
|
+
// archive 是嵌套对象:逐层克隆(调用方改动不得影响 store 缓存;P2-6 复验真源)
|
|
59
|
+
const cloned: SolutionEntry = { ...entry };
|
|
60
|
+
if (entry.archive !== undefined) {
|
|
61
|
+
cloned.archive = { ...entry.archive, signature: { ...entry.archive.signature } };
|
|
62
|
+
}
|
|
63
|
+
// P3-2 history 数组:浅克隆(条目为标量态,调用方改动不影响 store 缓存)
|
|
64
|
+
if (entry.history !== undefined) {
|
|
65
|
+
cloned.history = entry.history.map(h => ({ ...h }));
|
|
66
|
+
}
|
|
67
|
+
solutions[name] = cloned;
|
|
68
|
+
}
|
|
69
|
+
return { solutions };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 工厂为 async:构造时从盘上 hydrate 一次,之后 read() 走同步快照(平台 fs 面纯异步,同步 read 只能基于缓存)。
|
|
74
|
+
* 需要观察外部更新的场景重建工厂即可(安装流程一次性构造,无长生命周期需求)。
|
|
75
|
+
*/
|
|
76
|
+
export async function createStateStore(path: string): Promise<StateStore> {
|
|
77
|
+
let disk = await readDisk(path);
|
|
78
|
+
let cache = disk.state;
|
|
79
|
+
let note = disk.corruptReason;
|
|
80
|
+
return {
|
|
81
|
+
read: () => snapshot(cache),
|
|
82
|
+
corruptNote: () => note,
|
|
83
|
+
async write(patch: Partial<SolutionState>): Promise<void> {
|
|
84
|
+
disk = await readDisk(path);
|
|
85
|
+
note = disk.corruptReason;
|
|
86
|
+
const solutions: Record<string, SolutionEntry> = { ...disk.state.solutions };
|
|
87
|
+
// patch 值运行时放宽允许 null(类型收窄见调用侧):null = 删除该方案键
|
|
88
|
+
const patchSolutions: Record<string, SolutionEntry | null> | undefined = patch.solutions;
|
|
89
|
+
if (patchSolutions !== undefined) {
|
|
90
|
+
for (const [name, entry] of Object.entries(patchSolutions)) {
|
|
91
|
+
if (entry === null) {
|
|
92
|
+
delete solutions[name];
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
solutions[name] = entry;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const next: SolutionState = { solutions };
|
|
100
|
+
const text = serializeState(next); // 守卫先跑:任一条目非法即整次拒绝,盘与缓存都不动
|
|
101
|
+
await atomicWriteText(path, text);
|
|
102
|
+
note = null; // 写成功即自愈:损坏 note 清零(下次读以盘上真身为准)
|
|
103
|
+
cache = next;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readDisk(path: string): Promise<{ state: SolutionState, corrupted: boolean, corruptReason: string | null }> {
|
|
109
|
+
let text: string;
|
|
110
|
+
try {
|
|
111
|
+
text = new TextDecoder().decode(await fsFace().readFile(path));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// 不存在/不可读一律按空状态(读缺失文件不炸)
|
|
115
|
+
return { state: EMPTY, corrupted: false, corruptReason: null };
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return { state: parseState(text), corrupted: false, corruptReason: null };
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
// 损坏上下文并入读结果(store.corruptNote 出口;禁 console——release isolate console 抛 BadResource,Finding A)
|
|
122
|
+
return { state: EMPTY, corrupted: true, corruptReason: `${path} 不是本模块写出的合法状态文件,按空状态处理(${String(e)})` };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function serializeState(state: SolutionState): string {
|
|
127
|
+
const names = Object.keys(state.solutions).sort();
|
|
128
|
+
if (names.length === 0) {
|
|
129
|
+
return "solutions: {}\n";
|
|
130
|
+
}
|
|
131
|
+
const lines = ["solutions:"];
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
const e = state.solutions[name];
|
|
134
|
+
assertEntry(name, e);
|
|
135
|
+
lines.push(
|
|
136
|
+
` ${name}:`,
|
|
137
|
+
` version: ${JSON.stringify(e.version)}`,
|
|
138
|
+
` enabled: ${e.enabled}`,
|
|
139
|
+
` installedAt: ${e.installedAt}`,
|
|
140
|
+
);
|
|
141
|
+
if (e.archive !== undefined) {
|
|
142
|
+
lines.push(` archive: ${JSON.stringify(JSON.stringify(e.archive))}`);
|
|
143
|
+
}
|
|
144
|
+
if (e.trust_state !== undefined) {
|
|
145
|
+
lines.push(` trust_state: ${e.trust_state}`);
|
|
146
|
+
}
|
|
147
|
+
if (e.history !== undefined) {
|
|
148
|
+
lines.push(` history: ${JSON.stringify(JSON.stringify(e.history))}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return `${lines.join("\n")}\n`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 落盘前的格式边界守卫:字段类型/字符集不合规直接拒绝,
|
|
156
|
+
* 宁可让本次 write 失败也不把不可解析的字节写进状态文件(损坏 = 下次读全丢)。
|
|
157
|
+
*/
|
|
158
|
+
function assertEntry(name: string, e: SolutionEntry): void {
|
|
159
|
+
if (!/^[\w.-]+$/.test(name)) {
|
|
160
|
+
throw new Error(`state: 方案名「${name}」含格式外字符,拒绝落盘`);
|
|
161
|
+
}
|
|
162
|
+
if (typeof e.version !== "string" || hasQuoteOrControlChar(e.version)) {
|
|
163
|
+
throw new Error(`state: 方案「${name}」version 必须是不含引号/反斜杠/控制字符的字符串`);
|
|
164
|
+
}
|
|
165
|
+
if (typeof e.enabled !== "boolean") {
|
|
166
|
+
throw new TypeError(`state: 方案「${name}」enabled 必须是布尔值`);
|
|
167
|
+
}
|
|
168
|
+
if (typeof e.installedAt !== "number" || !Number.isInteger(e.installedAt)) {
|
|
169
|
+
throw new TypeError(`state: 方案「${name}」installedAt 必须是整数毫秒时间戳`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 引号/反斜杠会破坏 version 的双引号定界,控制字符(< 0x20)在 YAML 双引号标量里非法——一律拒绝落盘。 */
|
|
174
|
+
function hasQuoteOrControlChar(s: string): boolean {
|
|
175
|
+
return /["\\]/.test(s) || [...s].some(ch => ch.charCodeAt(0) < 32);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** history 行校验用:判断未知值是否为普通对象(非数组/非 null) */
|
|
179
|
+
function isObj(v: unknown): v is Record<string, unknown> {
|
|
180
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 严格解析:逐行匹配固定形状,任何偏差都算损坏(只读回自己写的内容,不做通用 YAML)。 */
|
|
184
|
+
function parseState(text: string): SolutionState {
|
|
185
|
+
const lines = text.split("\n");
|
|
186
|
+
if (lines[lines.length - 1] === "") {
|
|
187
|
+
lines.pop();
|
|
188
|
+
}
|
|
189
|
+
if (lines.length === 0) {
|
|
190
|
+
throw new Error("空文件");
|
|
191
|
+
}
|
|
192
|
+
if (lines[0] === "solutions: {}") {
|
|
193
|
+
if (lines.length !== 1) {
|
|
194
|
+
throw new Error("「solutions: {}」之后存在多余内容");
|
|
195
|
+
}
|
|
196
|
+
return EMPTY;
|
|
197
|
+
}
|
|
198
|
+
if (lines[0] !== "solutions:") {
|
|
199
|
+
throw new Error(`首行应为「solutions:」,实为「${lines[0]}」`);
|
|
200
|
+
}
|
|
201
|
+
const solutions: Record<string, SolutionEntry> = {};
|
|
202
|
+
let i = 1;
|
|
203
|
+
while (i < lines.length) {
|
|
204
|
+
const name = /^ {2}([\w.-]+):$/.exec(lines[i])?.[1];
|
|
205
|
+
if (name === undefined) {
|
|
206
|
+
throw new Error(`第 ${i + 1} 行应为方案名行,实为「${lines[i]}」`);
|
|
207
|
+
}
|
|
208
|
+
const version = /^ {4}version: "(.*)"$/.exec(lines[i + 1] ?? "")?.[1];
|
|
209
|
+
const enabled = /^ {4}enabled: (true|false)$/.exec(lines[i + 2] ?? "")?.[1];
|
|
210
|
+
const installedAt = /^ {4}installedAt: (-?\d+)$/.exec(lines[i + 3] ?? "")?.[1];
|
|
211
|
+
if (version === undefined || enabled === undefined || installedAt === undefined) {
|
|
212
|
+
throw new Error(`方案「${name}」条目格式不完整或字段损坏`);
|
|
213
|
+
}
|
|
214
|
+
const entry: SolutionEntry = { version, enabled: enabled === "true", installedAt: Number(installedAt) };
|
|
215
|
+
let j = i + 4;
|
|
216
|
+
// P2-6 可选行(顺序固定:archive 先、trust_state 后;缺即无——P1 时代条目只有四行)
|
|
217
|
+
const archiveLiteral = /^ {4}archive: "((?:[^"\\]|\\.)*)"$/.exec(lines[j] ?? "")?.[1];
|
|
218
|
+
if (archiveLiteral !== undefined) {
|
|
219
|
+
try {
|
|
220
|
+
const parsed = JSON.parse(JSON.parse(`"${archiveLiteral}"`)) as unknown;
|
|
221
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
222
|
+
throw new Error("非对象");
|
|
223
|
+
const a = parsed as Record<string, unknown>;
|
|
224
|
+
if (typeof a.sha256 !== "string" || typeof a.key_id !== "string" || typeof a.signature !== "object" || a.signature === null)
|
|
225
|
+
throw new Error("缺 sha256/key_id/signature");
|
|
226
|
+
entry.archive = { sha256: a.sha256, key_id: a.key_id, signature: a.signature as Record<string, unknown> };
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
throw new Error(`方案「${name}」archive 行不是合法复验真源 JSON`);
|
|
230
|
+
}
|
|
231
|
+
j += 1;
|
|
232
|
+
}
|
|
233
|
+
const trust = /^ {4}trust_state: (trusted|untrusted|absent)$/.exec(lines[j] ?? "")?.[1];
|
|
234
|
+
if (trust !== undefined) {
|
|
235
|
+
entry.trust_state = trust as "trusted" | "untrusted" | "absent";
|
|
236
|
+
j += 1;
|
|
237
|
+
}
|
|
238
|
+
// P3-2 history:JSON 字符串标量(双串化,同 archive 口径);非法即损坏
|
|
239
|
+
const historyLiteral = /^ {4}history: "((?:[^"\\]|\\.)*)"$/.exec(lines[j] ?? "")?.[1];
|
|
240
|
+
if (historyLiteral !== undefined) {
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(JSON.parse(`"${historyLiteral}"`)) as unknown;
|
|
243
|
+
if (!Array.isArray(parsed) || !parsed.every(x => isObj(x) && typeof x.version === "string" && typeof x.installedAt === "number"))
|
|
244
|
+
throw new Error("history 非版本时间序数组");
|
|
245
|
+
entry.history = parsed as Array<{ version: string, installedAt: number }>;
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
throw new Error(`方案「${name}」history 行不是合法版本时间序 JSON`);
|
|
249
|
+
}
|
|
250
|
+
j += 1;
|
|
251
|
+
}
|
|
252
|
+
solutions[name] = entry;
|
|
253
|
+
i = j;
|
|
254
|
+
}
|
|
255
|
+
return { solutions };
|
|
256
|
+
}
|