@geoly-ai/skills-hub 0.3.5 → 0.3.7
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/package.json +6 -2
- package/scripts/submission/promotion-file.mjs +246 -0
- package/scripts/submission/run-gates.mjs +250 -0
- package/scripts/submission/scan-text.mjs +363 -0
- package/scripts/submission/structural-gates.mjs +336 -0
- package/src/artifact.mjs +19 -1
- package/src/cli.mjs +16 -2
- package/src/commands/context.mjs +45 -0
- package/src/commands/install.mjs +13 -3
- package/src/commands/output.mjs +7 -1
- package/src/commands/publish.mjs +400 -0
- package/src/commands/remove.mjs +10 -3
- package/src/commands/update.mjs +8 -3
- package/src/publish/github.mjs +223 -0
- package/src/publish/payload.mjs +241 -0
- package/src/publish/remote.mjs +813 -0
- package/src/publish/token.mjs +244 -0
- package/src/target.mjs +267 -25
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// `publish` 的 GitHub API 客户端 —— **这是 publish 唯一的出网口**。
|
|
2
|
+
//
|
|
3
|
+
// ── 🔴 为什么全走 API,一次 `git` 都不用 ────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// 带 token 的 `git push` 会让**本地** `pre-push` hook 拿到那个 token。
|
|
6
|
+
// hook 是仓库里的普通可执行文件,`git` 无条件运行它 —— 于是「投稿一个 skill」
|
|
7
|
+
// 变成了「把你的 GitHub 凭据交给一段任意代码」。
|
|
8
|
+
// 这不是理论风险:投稿者的载荷目录常常就在某个 git 仓库里。
|
|
9
|
+
//
|
|
10
|
+
// ── 🔴 为什么是 Git Data API,不是 Contents API ───────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// Contents API **没有设置 mode 的字段** —— 一个 `0755` 的文件会被写成 `0644`。
|
|
13
|
+
// 而 mode **进 `tree_digest`**(`src/tree-digest.mjs`:mode 是每个叶子的编码的一部分,
|
|
14
|
+
// 同字节不同 mode 算出来是两个不同摘要),也进 capability 语义
|
|
15
|
+
// (`structural-gates.executableEvidence` 看的就是可执行位)。
|
|
16
|
+
// 所以用 Contents API 投稿,等于**静默地改掉制品身份**。
|
|
17
|
+
//
|
|
18
|
+
// 另外 Contents API 每个文件一次 commit:中途失败会在 fork 上留下**半棵可见的
|
|
19
|
+
// 投稿树**,而我们的策略是「同名分支只读、不自动改写」—— 那棵半成品就没人能收拾了。
|
|
20
|
+
// Git Data API 的顺序是 blob → tree → commit → **最后一次性建 ref**:
|
|
21
|
+
// 失败只留下不可见的 orphan object,仓库上什么都看不见。
|
|
22
|
+
|
|
23
|
+
import { TokenError, scrub } from './token.mjs';
|
|
24
|
+
|
|
25
|
+
/** 🔴 API host 固定。不从任何输入取,不支持 GHE。 */
|
|
26
|
+
export const API_ORIGIN = 'https://api.github.com';
|
|
27
|
+
/** 🔴 上游固定。不是参数。 */
|
|
28
|
+
export const UPSTREAM_OWNER = 'geoly-ai';
|
|
29
|
+
export const UPSTREAM_REPO = 'skills-hub';
|
|
30
|
+
export const UPSTREAM_FULL = `${UPSTREAM_OWNER}/${UPSTREAM_REPO}`;
|
|
31
|
+
/** 🔴 base 分支固定。 */
|
|
32
|
+
export const BASE_BRANCH = 'main';
|
|
33
|
+
|
|
34
|
+
/** 网络类错误 → EXIT.NETWORK(6)。 */
|
|
35
|
+
export class ApiNetworkError extends Error {
|
|
36
|
+
constructor(message, extra = {}) {
|
|
37
|
+
super(message); this.name = 'ApiNetworkError'; this.exitCode = 6; Object.assign(this, extra);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* HTTP 层的失败。`status` 一定有;401/403 映射到 EXIT.AUTH(7),其余到 EXIT.NETWORK(6)。
|
|
43
|
+
*
|
|
44
|
+
* 🔴 **401/403 绝不静默换下一个 token 来源重试**(见 `commands/publish.mjs`):
|
|
45
|
+
* 那会让用户看到的身份与实际投稿身份不同 —— 而这条命令的全部风险都在身份上。
|
|
46
|
+
*/
|
|
47
|
+
export class ApiHttpError extends Error {
|
|
48
|
+
constructor(message, { status, method, path, docUrl = null } = {}) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'ApiHttpError';
|
|
51
|
+
this.status = status;
|
|
52
|
+
this.method = method;
|
|
53
|
+
this.path = path;
|
|
54
|
+
this.docUrl = docUrl;
|
|
55
|
+
this.exitCode = (status === 401 || status === 403) ? 7 : 6;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 路径参数的转义。**每一段都转**,然后 URL 里绝不出现未转义的用户输入。
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ `encodeURIComponent` 会把 `/` 转成 `%2F` —— 这正是我们要的:
|
|
63
|
+
* 一个叫 `a/b` 的分支名不该变成两层路径。调用方把**不该转义的**斜杠
|
|
64
|
+
* 写在模板字面量里,把**每一个变量**用这个函数包起来。
|
|
65
|
+
*/
|
|
66
|
+
export const seg = (s) => encodeURIComponent(String(s));
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 建客户端。
|
|
70
|
+
*
|
|
71
|
+
* @param {object} a
|
|
72
|
+
* @param {string} a.token
|
|
73
|
+
* @param {function} a.fetchImpl 🔴 **必填**。不给就抛 —— 见下。
|
|
74
|
+
* @param {string} a.userAgent
|
|
75
|
+
* @param {number} [a.timeoutMs]
|
|
76
|
+
*/
|
|
77
|
+
export function createClient({ token, fetchImpl, userAgent, timeoutMs = 30000 }) {
|
|
78
|
+
// 🔴 `fetchImpl` 必填、且**没有隐式回退到 globalThis.fetch**。
|
|
79
|
+
// 测试忘了注入时,回退版本会**真的出网**,而那次出网看起来像一次通过的测试。
|
|
80
|
+
// 这条命令会创建 fork、建分支、开 PR —— 一次意外出网就是一次真实的写操作。
|
|
81
|
+
// 调用方(`commands/publish.mjs`)显式传 `ctx.fetchImpl ?? globalThis.fetch`,
|
|
82
|
+
// 于是"用了真 fetch"是一个**写得出来的动作**,不是一个默认值。
|
|
83
|
+
if (typeof fetchImpl !== 'function') {
|
|
84
|
+
throw new ApiNetworkError('publish 的 GitHub 客户端必须显式拿到 fetch 实现(不接受隐式回退)');
|
|
85
|
+
}
|
|
86
|
+
if (typeof token !== 'string' || token === '') throw new TokenError('内部错误:客户端拿到空 token');
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {'GET'|'POST'|'PATCH'} method
|
|
90
|
+
* @param {string} path 以 `/` 开头;**变量段必须已经过 `seg()`**
|
|
91
|
+
* @param {object} [o]
|
|
92
|
+
* @param {object} [o.body]
|
|
93
|
+
* @param {number[]} [o.okStatus] 允许的状态码;默认 2xx
|
|
94
|
+
* @param {number[]} [o.softStatus] 不抛错、原样返回的状态码(如 404 探测)
|
|
95
|
+
*/
|
|
96
|
+
async function request(method, path, { body = undefined, softStatus = [] } = {}) {
|
|
97
|
+
const url = new URL(path, API_ORIGIN);
|
|
98
|
+
// 🔴 **构造完之后**再断言 origin。在拼接之前断言等于没断言:
|
|
99
|
+
// `path` 若是 `https://evil.example/x`,`new URL` 会**整个换掉** origin。
|
|
100
|
+
// 这一条不是理论上的 —— 分支名、login 都进路径,而它们来自远端响应。
|
|
101
|
+
if (url.origin !== API_ORIGIN) {
|
|
102
|
+
throw new ApiNetworkError(`拒绝向 ${url.origin} 发请求 —— publish 的 API host 固定为 ${API_ORIGIN}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const headers = {
|
|
106
|
+
// 🔴 Authorization 只在这里出现一次,且这个对象**绝不进任何错误/日志**。
|
|
107
|
+
Authorization: `Bearer ${token}`,
|
|
108
|
+
Accept: 'application/vnd.github+json',
|
|
109
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
110
|
+
'User-Agent': userAgent,
|
|
111
|
+
};
|
|
112
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
113
|
+
|
|
114
|
+
const ac = new AbortController();
|
|
115
|
+
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
116
|
+
let res;
|
|
117
|
+
try {
|
|
118
|
+
res = await fetchImpl(url.href, {
|
|
119
|
+
method,
|
|
120
|
+
headers,
|
|
121
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
122
|
+
// 🔴 `manual`,且下面对任何 3xx 都抛 —— **不跟随,一步都不跟**。
|
|
123
|
+
// 跟随会把 `Authorization` 带去一个我们没有断言过的 host。
|
|
124
|
+
// `redirect:'error'` 也行,但 `manual` 让我们能把状态码报出来。
|
|
125
|
+
redirect: 'manual',
|
|
126
|
+
signal: ac.signal,
|
|
127
|
+
});
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (ac.signal.aborted) {
|
|
130
|
+
// 🔴 超时是**特殊的**:POST 可能已经在服务端生效了。
|
|
131
|
+
// 调用方必须先查询结果再决定是否重试 —— 所以这里把 `timedOut` 标出来。
|
|
132
|
+
throw new ApiNetworkError(
|
|
133
|
+
`${method} ${path} 超时(${timeoutMs} ms)—— 🔴 若这是一次写操作,`
|
|
134
|
+
+ '它**可能已经生效**;请先查询结果再决定是否重试。',
|
|
135
|
+
{ timedOut: true, method, path },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
// undici 抛的永远是 `TypeError: fetch failed`,真因在 cause 上。
|
|
139
|
+
const cause = e?.cause?.code ?? e?.cause?.message ?? e?.message ?? '未知';
|
|
140
|
+
throw new ApiNetworkError(scrub(`${method} ${path} 失败:${cause}`, token), { method, path });
|
|
141
|
+
} finally {
|
|
142
|
+
clearTimeout(t);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 🔴 3xx 一律抛。**同 host 的重定向也抛** —— GitHub 会对改过名的仓库发 301,
|
|
146
|
+
// 而"上游改名了"是一件必须让人看见的事,不是可以静默跟随的细节。
|
|
147
|
+
if (res.status >= 300 && res.status < 400) {
|
|
148
|
+
throw new ApiNetworkError(
|
|
149
|
+
`${method} ${path} 返回了 ${res.status} 重定向 —— 拒绝跟随。\n`
|
|
150
|
+
+ ' 🔴 跟随重定向会把 Authorization 头带去一个没被断言过的 host。\n'
|
|
151
|
+
+ ' 常见成因:上游仓库改过名。请核对 `geoly-ai/skills-hub` 是否仍然有效。',
|
|
152
|
+
{ method, path },
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const text = await res.text().catch(() => '');
|
|
157
|
+
let json = null;
|
|
158
|
+
if (text !== '') { try { json = JSON.parse(text); } catch { json = null; } }
|
|
159
|
+
|
|
160
|
+
if (res.ok || softStatus.includes(res.status)) {
|
|
161
|
+
return { status: res.status, json, headers: res.headers, ok: res.ok };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 🔴 错误文案里**只**放 status / method / path / 响应体的 `message` 与 `documentation_url`。
|
|
165
|
+
// 不放 request headers,不放整个响应体(它可能回显了我们发过去的内容)。
|
|
166
|
+
// 再过一遍 `scrub` —— 那是最后一道,不是第一道。
|
|
167
|
+
const apiMsg = typeof json?.message === 'string' ? json.message : `HTTP ${res.status}`;
|
|
168
|
+
const errs = Array.isArray(json?.errors)
|
|
169
|
+
? json.errors.map((x) => (typeof x?.message === 'string' ? x.message : x?.code)).filter(Boolean)
|
|
170
|
+
: [];
|
|
171
|
+
throw new ApiHttpError(
|
|
172
|
+
scrub(`${method} ${path} → ${res.status}:${apiMsg}${errs.length ? `(${errs.join(';')})` : ''}`, token),
|
|
173
|
+
{
|
|
174
|
+
status: res.status,
|
|
175
|
+
method,
|
|
176
|
+
path,
|
|
177
|
+
docUrl: typeof json?.documentation_url === 'string' ? json.documentation_url : null,
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
request,
|
|
184
|
+
get: (p, o) => request('GET', p, o),
|
|
185
|
+
post: (p, body, o) => request('POST', p, { ...o, body }),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 分页取全部页。
|
|
191
|
+
*
|
|
192
|
+
* 🔴 `maxPages` 到顶时**不静默截断**:抛 `TruncatedError`,由调用方决定怎么说。
|
|
193
|
+
* 一个"取了前 3 页就当作全部"的重复检测,正是那种「看起来在查、其实查不全」的门。
|
|
194
|
+
*/
|
|
195
|
+
export class TruncatedError extends Error {
|
|
196
|
+
constructor(message, extra = {}) {
|
|
197
|
+
super(message); this.name = 'TruncatedError'; this.exitCode = 6; Object.assign(this, extra);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function getAllPages(client, pathWithoutPage, { perPage = 100, maxPages = 10 } = {}) {
|
|
202
|
+
const out = [];
|
|
203
|
+
const sepChar = pathWithoutPage.includes('?') ? '&' : '?';
|
|
204
|
+
for (let page = 1; page <= maxPages; page += 1) {
|
|
205
|
+
const r = await client.get(`${pathWithoutPage}${sepChar}per_page=${perPage}&page=${page}`);
|
|
206
|
+
// 🔴 200 但不是数组 → **抛**。把它当成空数组,等于把「响应形状不对」
|
|
207
|
+
// 翻译成「这一页什么都没有」,而后者会让"没有重复投稿"这个结论成立。
|
|
208
|
+
if (!Array.isArray(r.json)) {
|
|
209
|
+
throw new TruncatedError(
|
|
210
|
+
`${pathWithoutPage} 第 ${page} 页返回的不是数组 —— 拒绝把它当成"这一页是空的"`,
|
|
211
|
+
{ path: pathWithoutPage, page },
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const arr = r.json;
|
|
215
|
+
out.push(...arr);
|
|
216
|
+
if (arr.length < perPage) return out;
|
|
217
|
+
}
|
|
218
|
+
throw new TruncatedError(
|
|
219
|
+
`${pathWithoutPage} 的分页超过 ${maxPages} 页(每页 ${perPage})—— 拒绝把"取了前 ${maxPages} 页"`
|
|
220
|
+
+ '当成"全部"。',
|
|
221
|
+
{ path: pathWithoutPage, maxPages, perPage, got: out.length },
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// `publish` 的**本地**预检 —— 载荷冻结、暂存、跑既有的投稿门。
|
|
2
|
+
//
|
|
3
|
+
// ── 🔴 这一层是 UX,不是信任门 ────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// 真正的判定在服务端的 PR gate(`.github/workflows/validate-pr.yml` 从 **base**
|
|
6
|
+
// 那棵树跑 `base-tools/scripts/submission/*`)。这里做的事只有一个目的:
|
|
7
|
+
// **让投稿者在开 PR 之前就看到会红的地方**,而不是开完 PR 再被 CI 打回来。
|
|
8
|
+
//
|
|
9
|
+
// 因此本模块的纪律是:**一条自己的校验规则都不写。** 全部调 `scripts/submission/`
|
|
10
|
+
// 与 `src/` 里现成的那几个。另写一套的结局只有两个 —— 「本地绿、CI 红」,
|
|
11
|
+
// 或者两套规则各自演化然后分叉,而分叉点正好是绕过点(R-11 反复出现的形状)。
|
|
12
|
+
|
|
13
|
+
import { readFileSync, rmSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { parseStrict } from '../canonical-json.mjs';
|
|
17
|
+
import { parseArtifactId } from '../pack.mjs';
|
|
18
|
+
import { collectTree } from '../packer.mjs';
|
|
19
|
+
import { treeDigest } from '../tree-digest.mjs';
|
|
20
|
+
import { createIsolatedDir, writeEntries } from '../artifact.mjs';
|
|
21
|
+
import { runGates } from '../../scripts/submission/run-gates.mjs';
|
|
22
|
+
import { scanTree, format as formatScan } from '../../scripts/submission/scan-text.mjs';
|
|
23
|
+
import { assertArtifactPath } from '../untar.mjs';
|
|
24
|
+
|
|
25
|
+
export class PayloadError extends Error {
|
|
26
|
+
/** 「你的投稿不合规」是**用法错误**,不是制品完整性问题 —— 09-cli.md §6。 */
|
|
27
|
+
constructor(message, extra = {}) {
|
|
28
|
+
super(message); this.name = 'PayloadError'; this.exitCode = 1; Object.assign(this, extra);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `submissions/<ns>/<name>@<ver>/`。**唯一**允许写的前缀。 */
|
|
33
|
+
export const submissionPrefix = (ns, name, version) => `submissions/${ns}/${name}@${version}/`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 读 manifest,定 kind / namespace / name / version。
|
|
37
|
+
*
|
|
38
|
+
* 🔴 `skill.json` 与 `pack.json` **恰好有一个** —— 两个都在时「这是什么制品」
|
|
39
|
+
* 有两个答案,而下游每个校验器只读其中一个。这与 `run-gates.mjs` ③ 处
|
|
40
|
+
* 以及 `packer.packArtifact` 的判据是同一条,这里提前报,好让错误指向源目录
|
|
41
|
+
* 而不是暂存目录。
|
|
42
|
+
*/
|
|
43
|
+
export function readIdentity(payloadDir) {
|
|
44
|
+
const has = (f) => { try { return readFileSync(join(payloadDir, f), 'utf8'); } catch { return null; } };
|
|
45
|
+
const skill = has('skill.json');
|
|
46
|
+
const pack = has('pack.json');
|
|
47
|
+
if ((skill === null) === (pack === null)) {
|
|
48
|
+
throw new PayloadError(
|
|
49
|
+
`载荷根必须**恰好**有 skill.json 或 pack.json 其中一个`
|
|
50
|
+
+ `(现在 skill.json=${skill !== null}、pack.json=${pack !== null})——\n`
|
|
51
|
+
+ ' 两个都在时「这是什么制品」有两个答案;一个都没有时 publish 不知道该投什么。',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const kind = skill !== null ? 'skill' : 'pack';
|
|
55
|
+
let doc;
|
|
56
|
+
try {
|
|
57
|
+
// 🔴 `parseStrict` 不是 `JSON.parse`:11-wire-contract.md §2 要求拒绝重复 key。
|
|
58
|
+
// `{"version":"1.0.0","version":"9.9.9"}` 在 JSON.parse 下静默取最后一个 ——
|
|
59
|
+
// 于是本地看到的版本与服务端看到的可以不是同一个。
|
|
60
|
+
doc = parseStrict(kind === 'skill' ? skill : pack);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
throw new PayloadError(`${kind}.json 解析失败:${e.message}`);
|
|
63
|
+
}
|
|
64
|
+
for (const k of ['namespace', 'name', 'version']) {
|
|
65
|
+
if (typeof doc[k] !== 'string' || doc[k] === '') {
|
|
66
|
+
throw new PayloadError(`${kind}.json 缺少字符串字段 ${k} —— publish 靠它决定投到哪个路径。`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// 🔴🔴 **ns / name / version 的 grammar 必须在这里就钉死**,而且要用**共享**的
|
|
70
|
+
// 那一份(`pack.parseArtifactId` = 01-artifacts.md §3 的 RE_NAMESPACE /
|
|
71
|
+
// RE_NAME + 禁 `+build` 的 semver)。
|
|
72
|
+
//
|
|
73
|
+
// 为什么这条不是可有可无的整洁:这三个字段是**投稿者完全可控**的,而它们
|
|
74
|
+
// 接下来会走进
|
|
75
|
+
// · 仓库路径前缀 `submissions/<ns>/<name>@<ver>/`
|
|
76
|
+
// · **分支名**,而分支名会被拼进我们自己发出去的 API URL。
|
|
77
|
+
// 一个 `version: "1.0.0?x="` 或 `"1.0.0#y"` 经 `encodeURI` **不会**被转义
|
|
78
|
+
// (`?` `#` `&` 都是 encodeURI 的保留字符),于是它能往我们的请求里注入
|
|
79
|
+
// query / fragment —— 那是一次实打实的请求伪造,而载荷里每个文件的相对
|
|
80
|
+
// 路径看起来都完全正常。
|
|
81
|
+
// ⚠️ 光靠"路径逐段校验"抓不到它:`?` 与 `#` 在路径里是合法字符。
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = parseArtifactId(`${kind}:${doc.namespace}/${doc.name}@${doc.version}`, `${kind}.json`);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
throw new PayloadError(
|
|
87
|
+
`${kind}.json 的 namespace / name / version 不合 grammar(01-artifacts.md §3):${e.message}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
kind, namespace: parsed.namespace, name: parsed.name, version: parsed.version,
|
|
92
|
+
manifest: doc, id: parsed.id,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 目标仓库路径的逐段校验。
|
|
98
|
+
*
|
|
99
|
+
* 🔴 **`collectTree` 已经对载荷内的相对路径跑过 `assertArtifactPath` 了,
|
|
100
|
+
* 这里查的是别的东西**:拼接之后的**完整仓库相对路径**。
|
|
101
|
+
* namespace / name / version 来自 manifest(投稿者完全可控),它们没经过
|
|
102
|
+
* 载荷路径的 grammar —— 一个 `version: "../../.github"` 会让前缀跑出
|
|
103
|
+
* `submissions/` 之外,而载荷里每个文件的相对路径看起来都完全正常。
|
|
104
|
+
*
|
|
105
|
+
* 🔴 百分号解码这一格:GitHub 的 tree API 收的是**字面路径**,不做 URL 解码,
|
|
106
|
+
* 所以 `%2F` 在 git 里就是三个普通字符、不构成目录分隔。但我们仍然拒 ——
|
|
107
|
+
* 理由不是 git 会误解,而是**下游会**:PR 页面、workflow 里的 shell、
|
|
108
|
+
* `git diff --name-only` 的消费者各自有各自的解码习惯,而路径白名单是按
|
|
109
|
+
* 前缀字符串判的。一个解码后含 `/` 的段,是在给"这条路径到底是几层"留两个答案。
|
|
110
|
+
*/
|
|
111
|
+
export function assertRepoPath(repoPath, { prefix }) {
|
|
112
|
+
if (!repoPath.startsWith(prefix)) {
|
|
113
|
+
throw new PayloadError(`路径 ${JSON.stringify(repoPath)} 不在允许的前缀 ${prefix} 之下`);
|
|
114
|
+
}
|
|
115
|
+
const segs = repoPath.split('/');
|
|
116
|
+
for (const s of segs) {
|
|
117
|
+
if (s === '') throw new PayloadError(`路径 ${JSON.stringify(repoPath)} 含空段(前导/尾随/连续斜杠)`);
|
|
118
|
+
if (s === '.' || s === '..') throw new PayloadError(`路径 ${JSON.stringify(repoPath)} 含 ${JSON.stringify(s)} 段`);
|
|
119
|
+
// eslint-disable-next-line no-control-regex
|
|
120
|
+
if (/[\x00-\x1f\x7f]/.test(s)) throw new PayloadError(`路径 ${JSON.stringify(repoPath)} 含控制字符`);
|
|
121
|
+
if (s.includes('\\')) throw new PayloadError(`路径 ${JSON.stringify(repoPath)} 含反斜杠`);
|
|
122
|
+
let decoded;
|
|
123
|
+
try {
|
|
124
|
+
decoded = decodeURIComponent(s);
|
|
125
|
+
} catch {
|
|
126
|
+
// 🔴 解不开也**拒**,不是"用原样继续查"。一个含 `%` 却不是合法转义的段
|
|
127
|
+
// (`%zz`、`%e0%a4`)在不同消费者手里会得到不同结果,而这道门的全部
|
|
128
|
+
// 意义就是不留"两个答案"。
|
|
129
|
+
throw new PayloadError(
|
|
130
|
+
`路径段 ${JSON.stringify(s)} 含无法解码的百分号转义 —— 拒绝。`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (decoded.includes('/') || decoded.includes('\\') || decoded === '..') {
|
|
134
|
+
throw new PayloadError(
|
|
135
|
+
`路径段 ${JSON.stringify(s)} 百分号解码之后是 ${JSON.stringify(decoded)} —— 拒绝。\n`
|
|
136
|
+
+ ' 🔴 一条解码后含分隔符的路径段,是在给「这条路径到底是几层」留两个答案,\n'
|
|
137
|
+
+ ' 而下游的路径白名单是按前缀字符串判的。',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (repoPath.startsWith('/')) throw new PayloadError(`路径 ${JSON.stringify(repoPath)} 是绝对路径`);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 冻结载荷 → 暂存成 `submissions/<ns>/<name>@<ver>/` → 跑既有的门。
|
|
147
|
+
*
|
|
148
|
+
* 🔴 **只读一次盘。** `collectTree` 的产物(path/mode/data)就是我们**唯一**的
|
|
149
|
+
* 那份字节;之后算摘要、跑门、传 blob 全用它。
|
|
150
|
+
* 直接在源目录上算摘要、然后再从源目录读一次去上传,是两次读 ——
|
|
151
|
+
* 两次之间源文件被改(编辑器保存、另一个进程在跑),产出的 `tree_digest`
|
|
152
|
+
* 与实际上传的字节就是两棵树,而这要到安装端重算摘要时才会被发现。
|
|
153
|
+
*
|
|
154
|
+
* @param {object} [o]
|
|
155
|
+
* @param {function} [o.afterFreeze]
|
|
156
|
+
* 🔴 **测试注入点,生产路径永远不传。** 在载荷冻结(`collectTree` 返回)之后、
|
|
157
|
+
* 算摘要之前调用一次。它存在的唯一理由是:让「冻结之后源目录再怎么变都影响不到
|
|
158
|
+
* 我们要上传的那份字节」这条**可以被测试证明**,而不是只能靠读代码相信。
|
|
159
|
+
* 没有它的话,「摘要从暂存树算」与「摘要从源目录算」这两种写法在所有正常输入下
|
|
160
|
+
* 结果相同 —— 于是把它改坏也不会有任何测试变红(Codex 2026-09-05 指出,
|
|
161
|
+
* 我当时误判成"等价变异")。
|
|
162
|
+
*
|
|
163
|
+
* @returns {{entries, treeDigest, prefix, files, gateProblems, scanLines, staging}}
|
|
164
|
+
*/
|
|
165
|
+
export function stagePayload(payloadDir, identity, { parent = undefined, afterFreeze = null } = {}) {
|
|
166
|
+
const entries = collectTree(payloadDir); // ← 唯一一次读盘
|
|
167
|
+
const prefix = submissionPrefix(identity.namespace, identity.name, identity.version);
|
|
168
|
+
|
|
169
|
+
// 前缀本身要先过一遍:ns/name/version 是投稿者可控的
|
|
170
|
+
for (const s of prefix.slice(0, -1).split('/')) {
|
|
171
|
+
if (s === '' || s === '.' || s === '..') {
|
|
172
|
+
throw new PayloadError(
|
|
173
|
+
`投稿目录前缀 ${JSON.stringify(prefix)} 不合法 —— 它由 manifest 的 `
|
|
174
|
+
+ 'namespace / name / version 拼成,请检查这三个字段。',
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const files = entries.map((e) => {
|
|
180
|
+
const repoPath = prefix + e.path;
|
|
181
|
+
assertRepoPath(repoPath, { prefix });
|
|
182
|
+
// 载荷内相对路径的 grammar 再跑一遍(`collectTree` 跑过;这里是纵深,
|
|
183
|
+
// 且它覆盖的是"我们即将发出去的那个字符串",不是"我们读进来的那个")。
|
|
184
|
+
assertArtifactPath(e.path, `publish:${repoPath}`);
|
|
185
|
+
return { repoPath, relPath: e.path, mode: e.mode, data: e.data };
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// 🔴 冻结完成。从这里往下**再也不碰 `payloadDir`** —— 摘要、门、上传全用
|
|
189
|
+
// 上面那份 `entries`。测试用 `afterFreeze` 在这一刻改动源目录来证明这一点。
|
|
190
|
+
if (typeof afterFreeze === 'function') afterFreeze();
|
|
191
|
+
|
|
192
|
+
// 暂存:把**这份字节**写成一棵真的 `submissions/` 树,然后让现成的门去跑它。
|
|
193
|
+
const staging = createIsolatedDir(parent);
|
|
194
|
+
try {
|
|
195
|
+
writeEntries(staging, files.map((f) => ({ path: f.repoPath, mode: f.mode, data: f.data })));
|
|
196
|
+
} catch (e) {
|
|
197
|
+
rmSync(staging, { recursive: true, force: true });
|
|
198
|
+
throw e;
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
entries, files, prefix, staging,
|
|
202
|
+
payloadDir: join(staging, prefix.slice(0, -1)),
|
|
203
|
+
// 🔴 摘要算的是**暂存树**(= 我们即将上传的那棵),不是源目录。
|
|
204
|
+
// 与 `packer.packDirectory` 同一条纪律:判据必须落在我们手上的那份字节上。
|
|
205
|
+
treeDigest: treeDigest(join(staging, prefix.slice(0, -1))),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 跑既有的结构门 + 字符扫描。**一条规则都不在这里写。**
|
|
211
|
+
*
|
|
212
|
+
* @param {object} a
|
|
213
|
+
* @param {string} a.staging `stagePayload` 的暂存根(里面是 `submissions/…`)
|
|
214
|
+
* @param {object} a.baseline 从上游 base 树派生的事实(见 `remote.mjs`)
|
|
215
|
+
* @returns {{problems:string[], scanLines:string[], scanErrors:number}}
|
|
216
|
+
*/
|
|
217
|
+
export function runLocalGates({ staging, baseline }) {
|
|
218
|
+
const { checked, problems } = runGates({
|
|
219
|
+
submissionsRoot: join(staging, 'submissions'),
|
|
220
|
+
reserved: baseline.reserved,
|
|
221
|
+
existingIds: baseline.existingIds,
|
|
222
|
+
existingNamesByNs: baseline.existingNamesByNs,
|
|
223
|
+
registeredNamespaces: baseline.registeredNamespaces,
|
|
224
|
+
// 🔴 **恒为 false**:「是不是维护者」是 PR 侧的事实,本地无从证明。
|
|
225
|
+
// 传 true 会让保留 namespace 在本地被放行、然后在 CI 上被拒 ——
|
|
226
|
+
// 正是「本地绿、CI 红」。fail-closed 与服务端默认值一致。
|
|
227
|
+
byMaintainer: false,
|
|
228
|
+
});
|
|
229
|
+
if (checked !== 1) {
|
|
230
|
+
throw new PayloadError(`内部错误:暂存树里应当恰好有 1 个投稿,实际 ${checked} 个`);
|
|
231
|
+
}
|
|
232
|
+
const scan = scanTree(join(staging, 'submissions'));
|
|
233
|
+
return {
|
|
234
|
+
problems,
|
|
235
|
+
scanLines: formatScan(scan),
|
|
236
|
+
// 🔴 `forbidden` 是**服务端会拒**的那一档(不可见字符 / bidi / symlink);
|
|
237
|
+
// `suspicious` / `mixedScript` / `compatibility` 只是要人看一眼。
|
|
238
|
+
// 两者分开数,不要混成一个"有几条告警"。
|
|
239
|
+
scanErrors: scan.forbidden.length,
|
|
240
|
+
};
|
|
241
|
+
}
|