@geoly-ai/skills-hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin/skills-hub.mjs +26 -0
  4. package/package.json +44 -0
  5. package/src/adapters/index.mjs +832 -0
  6. package/src/artifact.mjs +376 -0
  7. package/src/atomic-fs.mjs +166 -0
  8. package/src/attestation.mjs +136 -0
  9. package/src/canonical-json.mjs +147 -0
  10. package/src/cli.mjs +208 -0
  11. package/src/commands/check.mjs +295 -0
  12. package/src/commands/context.mjs +235 -0
  13. package/src/commands/install.mjs +430 -0
  14. package/src/commands/locks.mjs +197 -0
  15. package/src/commands/output.mjs +127 -0
  16. package/src/commands/query.mjs +266 -0
  17. package/src/commands/recover.mjs +438 -0
  18. package/src/commands/registry.mjs +123 -0
  19. package/src/commands/resolve.mjs +171 -0
  20. package/src/commands/snapshot-access.mjs +91 -0
  21. package/src/commands/sync-lock.mjs +189 -0
  22. package/src/crc32c.mjs +27 -0
  23. package/src/exit-codes.mjs +265 -0
  24. package/src/fault-inject.mjs +379 -0
  25. package/src/install.mjs +732 -0
  26. package/src/journal.mjs +435 -0
  27. package/src/ledger.mjs +671 -0
  28. package/src/lock.mjs +98 -0
  29. package/src/lockfile.mjs +0 -0
  30. package/src/pack.mjs +792 -0
  31. package/src/packer.mjs +351 -0
  32. package/src/plan.mjs +519 -0
  33. package/src/recover.mjs +1345 -0
  34. package/src/safe-fs.mjs +252 -0
  35. package/src/sigstore.mjs +480 -0
  36. package/src/snapshot.mjs +528 -0
  37. package/src/stats.mjs +59 -0
  38. package/src/target.mjs +738 -0
  39. package/src/telemetry.mjs +393 -0
  40. package/src/tree-digest.mjs +103 -0
  41. package/src/trust-roots/README.md +31 -0
  42. package/src/trust-roots/sigstore-public-good.json +126 -0
  43. package/src/trust.mjs +563 -0
  44. package/src/untar.mjs +570 -0
  45. package/src/upload.mjs +268 -0
  46. package/src/vendor.mjs +465 -0
@@ -0,0 +1,252 @@
1
+ // 路径与文件系统安全 —— 规范见 01-artifacts.md §4.1/§5、04-install.md §2.2/§3.4/§3.5/§3.6
2
+ //
3
+ // 这个模块回答四个问题,每个都是「不做就会出安全或正确性事故」的:
4
+ // 1. 这个名字合法吗(D9:ASCII-only)
5
+ // 2. 这条路径链上有 symlink 吗(有就拒绝,不跟随)
6
+ // 3. 这个文件系统撑得住我们依赖的语义吗(rename 原子性 / advisory lock / fsync 持久性)
7
+ // 4. 这个目录是挂载点吗,它下面有挂载点吗
8
+ import { lstatSync, realpathSync, accessSync, constants, readFileSync, statSync } from 'node:fs';
9
+ import { execFileSync } from 'node:child_process';
10
+ import { isAbsolute, join, parse, sep } from 'node:path';
11
+ import { platform } from 'node:os';
12
+
13
+ // ── 1. 路径字符集(D9) ──────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * 🔴 segment 的合法字节只有 `[A-Za-z0-9._-]`(01-artifacts.md §4.1)。
17
+ *
18
+ * 这不是洁癖。允许 Unicode 会同时引入三个问题:macOS 的 APFS 枚举出的归一形式
19
+ * 可能与写入时不同(装完重算树摘要就对不上)、USTAR 的 name/prefix 没有字符集
20
+ * 声明、同形字混淆只能靠人眼审查。一刀切最省事。
21
+ */
22
+ const RE_SEGMENT = /^[A-Za-z0-9._-]+$/;
23
+
24
+ export function isSafeSegment(seg) {
25
+ if (typeof seg !== 'string' || seg.length === 0) return false;
26
+ // 🔴 `.` 与 `..` 完全符合 [A-Za-z0-9._-]+,字符集挡不住它们。
27
+ // 早先只在 parseSafeRelPath 里按 segment 单独挡 —— 但**单独调用
28
+ // isSafeSegment 的人拿不到那层保护**,而这个名字听起来像是拿得到的。
29
+ // 事务内核那块差点就这么把路径穿越放进去(2026-08-27 抓到)。
30
+ // 判据:一个叫「安全」的谓词必须自己就是安全的,不能依赖调用方再加一道。
31
+ if (seg === '.' || seg === '..') return false;
32
+ return RE_SEGMENT.test(seg);
33
+ }
34
+
35
+ /**
36
+ * 校验一条**制品内的相对路径**。返回规范化后的 segment 数组。
37
+ *
38
+ * 拒绝:绝对路径、`.`、`..`、空 segment、反斜杠、非 ASCII、超深。
39
+ * ⚠️ `..` 必须在**分段之后**判断,不能只做字符串 includes:`a..b` 是合法文件名。
40
+ */
41
+ export function parseSafeRelPath(p, { maxDepth = 12 } = {}) {
42
+ if (typeof p !== 'string' || p.length === 0) throw new Error('路径为空');
43
+ if (isAbsolute(p) || p.startsWith('/')) throw new Error(`路径必须是相对路径:${p}`);
44
+ if (p.includes('\\')) throw new Error(`路径含反斜杠:${p}`);
45
+ if (p.includes('\0')) throw new Error('路径含 NUL');
46
+ const segs = p.split('/');
47
+ if (segs.length > maxDepth) throw new Error(`路径深度 ${segs.length} 超过上限 ${maxDepth}:${p}`);
48
+ for (const s of segs) {
49
+ if (s === '' ) throw new Error(`路径含空 segment:${p}`);
50
+ if (s === '.' || s === '..') throw new Error(`路径含 ${s}:${p}`);
51
+ if (!isSafeSegment(s)) throw new Error(`路径 segment 不是 ASCII-only [A-Za-z0-9._-]:${s}`);
52
+ }
53
+ return segs;
54
+ }
55
+
56
+ // ── 2. symlink ───────────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * 🔴 从一个**可信基准**往下逐层 `lstat`,任一层是 symlink 就拒绝(04-install.md §3.4)。
60
+ *
61
+ * 为什么不能只 `lstat` 末端:`base/a/b` 里 `a` 是 symlink 时,对 `b` 的 lstat
62
+ * 已经跟随过 `a` 了 —— 末端不是 symlink,但路径整体已经被重定向。
63
+ *
64
+ * 🔴 **为什么要有 base,而不是从 `/` 开始查**:macOS 上 `/var` 本身就是指向
65
+ * `/private/var` 的系统 symlink,`$TMPDIR` 也在它下面;从根开始查会把每一个
66
+ * 正常路径都判死。规格要防的是**我们管辖范围之内**被重定向(有人把
67
+ * `<target>/.geoly` 换成软链),不是操作系统自己的布局。
68
+ * 所以 base 先 `realpath`(接受它之上的 OS 级 symlink),只查 base 之下的层。
69
+ *
70
+ * 不存在的层直接停止 —— 还没创建的目录不构成风险。
71
+ */
72
+ export function assertNoSymlinkInChain(base, relPath = '') {
73
+ if (!isAbsolute(base)) throw new Error(`base 需要绝对路径:${base}`);
74
+ let cur = realpathSync(base);
75
+ for (const seg of String(relPath).split(/[\\/]/).filter(Boolean)) {
76
+ cur = join(cur, seg);
77
+ let st;
78
+ try { st = lstatSync(cur); } catch { return; } // 还不存在,后面也不会存在
79
+ if (st.isSymbolicLink()) throw new Error(`路径链上有符号链接,拒绝:${cur}`);
80
+ }
81
+ }
82
+
83
+ /** 制品/target 里允许的文件类型(01-artifacts.md §5)。一律拒绝其余所有类型。 */
84
+ export function assertPlainFileOrDir(path) {
85
+ const st = lstatSync(path);
86
+ if (st.isSymbolicLink()) throw new Error(`拒绝符号链接:${path}`);
87
+ if (st.isFIFO()) throw new Error(`拒绝 FIFO:${path}`);
88
+ if (st.isSocket()) throw new Error(`拒绝 socket:${path}`);
89
+ if (st.isBlockDevice() || st.isCharacterDevice()) throw new Error(`拒绝设备文件:${path}`);
90
+ if (st.isDirectory()) return st;
91
+ if (!st.isFile()) throw new Error(`拒绝未知文件类型:${path}`);
92
+ // 🔴 只对**普通文件**判 hardlink。对目录判会把每一棵正常的树都判死:
93
+ // 目录的 nlink 天然是 2 + 子目录数。
94
+ if (st.nlink !== 1) throw new Error(`拒绝硬链接(nlink=${st.nlink}):${path}`);
95
+ return st;
96
+ }
97
+
98
+ // ── 3. 文件系统类型 ──────────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * 🔴 拒绝清单(04-install.md §2.2)。这些文件系统不提供本规范依赖的
102
+ * advisory lock 语义、`rename` 原子性或 `fsync` 崩溃持久性;
103
+ * SQLite 的锁在网络文件系统上同样不可靠(D11′)。
104
+ */
105
+ export const REJECTED_FSTYPES = new Set([
106
+ 'nfs', 'nfs3', 'nfs4', 'autofs',
107
+ 'smbfs', 'cifs', 'smb2', 'afpfs',
108
+ 'fuse', 'osxfuse', 'macfuse', 'fusefs', 'sshfs', 'fuse.sshfs', 'fuse.s3fs',
109
+ 'overlay', 'overlayfs',
110
+ 'webdav', 'ftp', '9p',
111
+ ]);
112
+
113
+ let _mountCache = null;
114
+ /** 读取挂载表。Linux 用 /proc/self/mountinfo,macOS 用 `mount` 输出。 */
115
+ export function mountTable({ refresh = false } = {}) {
116
+ if (_mountCache && !refresh) return _mountCache;
117
+ const rows = platform() === 'linux' ? readMountinfo() : readBsdMount();
118
+ // 长路径在前:查某个路径属于哪个挂载点时,取最长前缀匹配
119
+ rows.sort((a, b) => b.mountPoint.length - a.mountPoint.length);
120
+ _mountCache = rows;
121
+ return rows;
122
+ }
123
+
124
+ function readMountinfo() {
125
+ let text;
126
+ try { text = readFileSync('/proc/self/mountinfo', 'utf8'); } catch { return []; }
127
+ const out = [];
128
+ for (const line of text.split('\n')) {
129
+ if (!line) continue;
130
+ // …… 挂载点在第 5 字段;`-` 之后是 fstype
131
+ const sepIdx = line.indexOf(' - ');
132
+ if (sepIdx === -1) continue;
133
+ const left = line.slice(0, sepIdx).split(' ');
134
+ const right = line.slice(sepIdx + 3).split(' ');
135
+ if (left.length < 5 || right.length < 1) continue;
136
+ out.push({ mountPoint: unescapeMountinfo(left[4]), fstype: right[0] });
137
+ }
138
+ return out;
139
+ }
140
+ // mountinfo 用八进制转义空格等字符
141
+ const unescapeMountinfo = (s) => s.replace(/\\(\d{3})/g, (_, o) => String.fromCharCode(parseInt(o, 8)));
142
+
143
+ function readBsdMount() {
144
+ let text;
145
+ try {
146
+ text = execFileSync('/sbin/mount', [], { encoding: 'utf8', timeout: 5000 });
147
+ } catch { return []; }
148
+ const out = [];
149
+ // 形如:/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)
150
+ for (const line of text.split('\n')) {
151
+ const m = /^(.*?) on (.*?) \(([^,)]+)/.exec(line);
152
+ if (!m) continue;
153
+ out.push({ mountPoint: m[2], fstype: m[3].trim() });
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /** 某个路径落在哪个挂载点上 */
159
+ export function mountEntryFor(path) {
160
+ const abs = resolveExisting(path);
161
+ for (const row of mountTable()) {
162
+ if (abs === row.mountPoint) return row;
163
+ const withSep = row.mountPoint.endsWith(sep) ? row.mountPoint : row.mountPoint + sep;
164
+ if (abs.startsWith(withSep)) return row;
165
+ }
166
+ return null;
167
+ }
168
+
169
+ /** 往上找到第一个已存在的祖先并 realpath(还没创建的 target 也要能预检) */
170
+ function resolveExisting(path) {
171
+ let cur = isAbsolute(path) ? path : join(process.cwd(), path);
172
+ for (;;) {
173
+ try { return realpathSync(cur); } catch { /* 继续往上 */ }
174
+ const parent = parse(cur).dir;
175
+ if (!parent || parent === cur) return cur;
176
+ cur = parent;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * 🔴 拒绝时必须**报出检出的 fstype**,不笼统报错(§2.2 明文要求)——
182
+ * 用户得知道是 sshfs 还是 NFS 才知道怎么办。
183
+ */
184
+ export function assertSupportedFilesystem(path) {
185
+ const entry = mountEntryFor(path);
186
+ if (!entry) return null; // 读不到挂载表:不因此拒绝安装,但也不谎称验过
187
+ const t = entry.fstype.toLowerCase();
188
+ if (REJECTED_FSTYPES.has(t) || t.startsWith('fuse')) {
189
+ throw new Error(
190
+ `不支持在 ${entry.fstype} 上安装(挂载点 ${entry.mountPoint}):` +
191
+ '该文件系统不保证 rename 原子性、advisory lock 语义或 fsync 崩溃持久性',
192
+ );
193
+ }
194
+ return entry;
195
+ }
196
+
197
+ // ── 4. 挂载点 ────────────────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * 🔴 `<target>/.geoly` 自身不得是挂载点,其下也不得含挂载点(§3.4)。
201
+ *
202
+ * 为什么比 `st_dev` 严:bind mount 的 `st_dev` **可以相同**,
203
+ * 只比 dev 会漏掉「在某个别名的 .geoly 上再挂一个目录」——
204
+ * 那样 payload 还是同一棵 target,锁和 journal 却分裂了。
205
+ * 所以判据以挂载表为准。
206
+ */
207
+ export function assertNotMountPoint(path) {
208
+ const abs = resolveExisting(path);
209
+ for (const row of mountTable()) {
210
+ if (row.mountPoint === abs) {
211
+ throw new Error(`${path} 本身是挂载点(${row.fstype}),拒绝`);
212
+ }
213
+ }
214
+ }
215
+
216
+ export function assertNoMountPointsUnder(path) {
217
+ const abs = resolveExisting(path);
218
+ const prefix = abs.endsWith(sep) ? abs : abs + sep;
219
+ for (const row of mountTable()) {
220
+ if (row.mountPoint.startsWith(prefix)) {
221
+ throw new Error(`${path} 之下存在挂载点 ${row.mountPoint}(${row.fstype}),拒绝`);
222
+ }
223
+ }
224
+ }
225
+
226
+ // ── 5. 可写性 ────────────────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * target 不可写(只读挂载、只读仓库)直接拒绝,并报明原因(§3.6)。
230
+ * 报错要说清「需要在这里创建 .geoly/」,否则用户不知道为什么装个 skill 要写目录。
231
+ */
232
+ export function assertWritableDir(dir) {
233
+ try {
234
+ accessSync(dir, constants.W_OK | constants.X_OK);
235
+ } catch {
236
+ throw new Error(`${dir} 不可写:安装需要在其中创建 .geoly/ 状态目录`);
237
+ }
238
+ const entry = mountEntryFor(dir);
239
+ if (entry && /(^|,|\s)read-only(,|\s|$)/.test(entry.fstype)) {
240
+ throw new Error(`${dir} 位于只读挂载 ${entry.mountPoint} 上`);
241
+ }
242
+ }
243
+
244
+ /** 两个路径是否同设备(stage 与 target 必须同设备,否则 rename 会 EXDEV) */
245
+ export function assertSameDevice(a, b) {
246
+ const da = statSync(a).dev;
247
+ const db = statSync(b).dev;
248
+ if (da !== db) throw new Error(`${a} 与 ${b} 不在同一设备上(${da} vs ${db}),rename 会 EXDEV`);
249
+ }
250
+
251
+ /** 测试用:挂载表有缓存,改过挂载后要清 */
252
+ export const _resetMountCache = () => { _mountCache = null; };