@pandablessing/dsh-user-sync 0.0.0-stage → 1.0.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.
package/lib/paths.js ADDED
@@ -0,0 +1,63 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve, sep } from 'node:path';
3
+ export const DEFAULT_PROFILE = 'desktop';
4
+ /**
5
+ * 解析当前运行环境的路径上下文。
6
+ * 主源是 profileContext(home/name),取不到时回退 DSH_HOME 与 desktop。
7
+ * 不读 process.env:由调用方把环境对象传进来,便于单测。
8
+ */
9
+ export function resolvePaths({ profileContext = null, env = {}, userHome = homedir() } = {}) {
10
+ const home = profileContext?.home;
11
+ const name = profileContext?.name;
12
+ const fallbackHome = env.DSH_HOME;
13
+ const dshHome = home ?? fallbackHome ?? join(userHome, '.dsh');
14
+ const profileName = name ?? DEFAULT_PROFILE;
15
+ const resolvedBy = home ? 'profileContext' : fallbackHome ? 'fallback' : 'default';
16
+ return {
17
+ dshHome,
18
+ profileName,
19
+ profileDir: join(dshHome, 'profiles', profileName),
20
+ userHome,
21
+ skillsRoot: join(userHome, '.agents', 'skills'),
22
+ resolvedBy,
23
+ };
24
+ }
25
+ /** 统一归一化为 POSIX 路径(包内路径与哈希都用它)。 */
26
+ export function toPosix(p) {
27
+ return p.replace(/\\/g, '/');
28
+ }
29
+ /** 把相对路径拼到根下,越出根则抛错。 */
30
+ export function resolveInside(root, rel) {
31
+ const rootAbs = resolve(root);
32
+ const abs = resolve(rootAbs, rel);
33
+ if (!isInside(rootAbs, abs)) {
34
+ throw new Error(`路径越界: ${rel}`);
35
+ }
36
+ return abs;
37
+ }
38
+ /** 判断绝对路径是否落在根内(含根本身)。 */
39
+ export function isInside(root, abs) {
40
+ const rootAbs = resolve(root);
41
+ const target = resolve(abs);
42
+ return target === rootAbs || target.startsWith(rootAbs + sep);
43
+ }
44
+ /** Windows 长路径前缀;非 Windows 原样返回。 */
45
+ export function toLongPath(abs) {
46
+ if (process.platform !== 'win32')
47
+ return abs;
48
+ if (abs.startsWith('\\\\?\\'))
49
+ return abs;
50
+ if (abs.startsWith('\\\\'))
51
+ return `\\\\?\\UNC\\${abs.slice(2)}`;
52
+ return `\\\\?\\${resolve(abs)}`;
53
+ }
54
+ /** 包内相对路径安全检查:拒绝 `..`、空段、反斜杠与控制字符(§6.4)。 */
55
+ export function isUnsafeRelPath(p) {
56
+ if (typeof p !== 'string' || p === '')
57
+ return true;
58
+ if (p.includes('\\'))
59
+ return true;
60
+ if (/[\u0000-\u001f]/.test(p))
61
+ return true;
62
+ return p.split('/').some((segment) => segment === '..' || segment === '');
63
+ }
package/lib/plugins.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 目标机插件恢复计划(§7.8):
3
+ * - `dependencies` 里缺失的包 → 逐个 `installBundle`
4
+ * - `link:` / `file:` 本地依赖 → 手工具清单,不自动安装
5
+ */
6
+ export function planPluginRecovery({ dependencies = {}, installed = [], } = {}) {
7
+ const installedSet = new Set(installed);
8
+ const missing = [];
9
+ const linkDeps = [];
10
+ for (const [name, spec] of Object.entries(dependencies)) {
11
+ if (typeof spec !== 'string')
12
+ continue;
13
+ if (spec.startsWith('link:') || spec.startsWith('file:')) {
14
+ linkDeps.push({ name, spec, reason: 'local-path' });
15
+ continue;
16
+ }
17
+ if (!installedSet.has(name)) {
18
+ missing.push({ name, spec });
19
+ }
20
+ }
21
+ return { missing, linkDeps };
22
+ }
23
+ /** 逐个安装缺失包;单个失败不阻塞其余。 */
24
+ export async function installMissing({ plan, installBundle, }) {
25
+ const results = [];
26
+ for (const dependency of plan.missing) {
27
+ const spec = dependency.spec ? `${dependency.name}@${dependency.spec}` : dependency.name;
28
+ try {
29
+ await installBundle(spec);
30
+ results.push({ name: dependency.name, spec, ok: true });
31
+ }
32
+ catch (error) {
33
+ results.push({ name: dependency.name, spec, ok: false, error: error?.message ?? String(error) });
34
+ }
35
+ }
36
+ return results;
37
+ }
package/lib/scan.js ADDED
@@ -0,0 +1,335 @@
1
+ import { lstatSync, readdirSync, readFileSync, readlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { toPosix } from './paths.js';
4
+ export const DEFAULT_MODULES = ['skills', 'plugins', 'config', 'userMemory'];
5
+ export const ALL_MODULES = ['skills', 'plugins', 'config', 'userMemory', 'projectMemory', 'sessions'];
6
+ /** 单文件超过 8 MiB 拒绝打包。 */
7
+ export const MAX_FILE_BYTES = 8 * 1024 * 1024;
8
+ /** 包总量超过 10 MiB 拒绝打包。 */
9
+ export const MAX_TOTAL_BYTES = 10 * 1024 * 1024;
10
+ const IGNORE_DIRS = new Set(['node_modules', '.git']);
11
+ const IGNORE_FILES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);
12
+ const IGNORE_NAMES = ['.credentials.yaml', '.credentials.yml', 'credentials.json', '.anonymous-user-id'];
13
+ /** Windows 保留名(不区分大小写,`CON.txt` 也算)。 */
14
+ const RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
15
+ export function isReservedName(name) {
16
+ const base = name.split(/[\\/]/).pop() ?? name;
17
+ return RESERVED.test(base) || /[. ]$/.test(base);
18
+ }
19
+ function shouldIgnore(name, rel) {
20
+ if (IGNORE_DIRS.has(name) || IGNORE_FILES.has(name))
21
+ return true;
22
+ if (name.endsWith('.map') || name.includes('.bak-'))
23
+ return true;
24
+ if (IGNORE_NAMES.includes(name))
25
+ return true;
26
+ if (toPosix(rel).split('/').includes('storages'))
27
+ return true;
28
+ return false;
29
+ }
30
+ function globToRegExp(glob) {
31
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
32
+ return new RegExp(`^${escaped}$`);
33
+ }
34
+ /** 判断一个包内路径是否命中排除模式。 */
35
+ export function isExcluded(rel, globs) {
36
+ if (!globs || globs.length === 0)
37
+ return false;
38
+ return globs.some((glob) => globToRegExp(glob).test(rel) || globToRegExp(glob).test(rel.split('/').pop() ?? rel));
39
+ }
40
+ function walk(root, prefix, module, ctx) {
41
+ let dirents;
42
+ try {
43
+ dirents = readdirSync(root, { withFileTypes: true });
44
+ }
45
+ catch (error) {
46
+ const code = error.code ?? String(error);
47
+ ctx.skipped.push({ path: toPosix(prefix || root), reason: `unreadable: ${code}` });
48
+ return;
49
+ }
50
+ for (const dirent of dirents) {
51
+ const name = dirent.name;
52
+ const abs = join(root, name);
53
+ const rel = prefix ? `${prefix}/${name}` : name;
54
+ if (shouldIgnore(name, rel)) {
55
+ ctx.skipped.push({ path: rel, reason: 'ignored' });
56
+ continue;
57
+ }
58
+ if (isReservedName(name)) {
59
+ ctx.errors.push({ code: 'E_RESERVED_NAME', path: rel });
60
+ continue;
61
+ }
62
+ if (dirent.isSymbolicLink()) {
63
+ let target;
64
+ try {
65
+ target = readlinkSync(abs);
66
+ }
67
+ catch {
68
+ target = '';
69
+ }
70
+ if (isExcluded(rel, ctx.excludeGlobs)) {
71
+ ctx.skipped.push({ path: rel, reason: 'excluded' });
72
+ continue;
73
+ }
74
+ ctx.out.push({ module, path: toPosix(rel), absPath: abs, size: 0, mode: '120000', linkTarget: target });
75
+ continue;
76
+ }
77
+ if (dirent.isDirectory()) {
78
+ walk(abs, rel, module, ctx);
79
+ continue;
80
+ }
81
+ if (!dirent.isFile())
82
+ continue;
83
+ if (isExcluded(rel, ctx.excludeGlobs)) {
84
+ ctx.skipped.push({ path: rel, reason: 'excluded' });
85
+ continue;
86
+ }
87
+ const stat = lstatSync(abs);
88
+ if (stat.size > MAX_FILE_BYTES) {
89
+ ctx.errors.push({ code: 'E_FILE_TOO_LARGE', path: rel, size: stat.size });
90
+ continue;
91
+ }
92
+ ctx.out.push({
93
+ module,
94
+ path: toPosix(rel),
95
+ absPath: abs,
96
+ size: stat.size,
97
+ mode: (stat.mode & 0o111) !== 0 ? '100755' : '100644',
98
+ });
99
+ }
100
+ }
101
+ function collectSkills({ userHome, ctx }) {
102
+ const skillsRoot = join(userHome, '.agents', 'skills');
103
+ let dirents;
104
+ try {
105
+ dirents = readdirSync(skillsRoot, { withFileTypes: true });
106
+ }
107
+ catch {
108
+ return; // 技能根不存在:正常空态
109
+ }
110
+ for (const dirent of dirents) {
111
+ if (!dirent.isDirectory() || dirent.name.startsWith('.'))
112
+ continue;
113
+ const skillDir = join(skillsRoot, dirent.name);
114
+ const hasManifest = (() => {
115
+ try {
116
+ return readdirSync(skillDir).includes('SKILL.md');
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ })();
122
+ if (!hasManifest) {
123
+ ctx.skipped.push({ path: `skills/${dirent.name}`, reason: 'no SKILL.md' });
124
+ continue;
125
+ }
126
+ walk(skillDir, `skills/${dirent.name}`, 'skills', ctx);
127
+ }
128
+ }
129
+ function collectProfileFiles({ dshHome, profileName, modules, ctx, }) {
130
+ const profileDir = join(dshHome, 'profiles', profileName);
131
+ const read = (name) => {
132
+ try {
133
+ const stat = lstatSync(join(profileDir, name));
134
+ return stat.isFile() ? stat.size : null;
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ };
140
+ const push = (name, module) => {
141
+ const size = read(name);
142
+ if (size === null)
143
+ return;
144
+ const rel = `profiles/${profileName}/${name}`;
145
+ if (isExcluded(rel, ctx.excludeGlobs)) {
146
+ ctx.skipped.push({ path: rel, reason: 'excluded' });
147
+ return;
148
+ }
149
+ if (size > MAX_FILE_BYTES) {
150
+ ctx.errors.push({ code: 'E_FILE_TOO_LARGE', path: rel, size });
151
+ return;
152
+ }
153
+ ctx.out.push({ module, path: rel, absPath: join(profileDir, name), size, mode: '100644' });
154
+ };
155
+ if (modules.includes('plugins')) {
156
+ for (const name of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'cordis.yml'])
157
+ push(name, 'plugins');
158
+ }
159
+ if (modules.includes('config')) {
160
+ push('cordis.patch.yml', 'config');
161
+ }
162
+ }
163
+ function collectUserMemory({ dshHome, ctx }) {
164
+ // 用户级记忆主文件:AGENTS.md 放在 DSH 主目录根
165
+ const agents = join(dshHome, 'AGENTS.md');
166
+ try {
167
+ const stat = lstatSync(agents);
168
+ if (stat.isFile() && !isExcluded('AGENTS.md', ctx.excludeGlobs)) {
169
+ if (stat.size > MAX_FILE_BYTES)
170
+ ctx.errors.push({ code: 'E_FILE_TOO_LARGE', path: 'AGENTS.md', size: stat.size });
171
+ else
172
+ ctx.out.push({ module: 'userMemory', path: 'AGENTS.md', absPath: agents, size: stat.size, mode: '100644' });
173
+ }
174
+ }
175
+ catch {
176
+ // 没有 AGENTS.md 不算错误
177
+ }
178
+ const root = join(dshHome, 'memories');
179
+ let dirents;
180
+ try {
181
+ dirents = readdirSync(root, { withFileTypes: true });
182
+ }
183
+ catch {
184
+ return;
185
+ }
186
+ for (const dirent of dirents) {
187
+ if (!dirent.isFile())
188
+ continue;
189
+ if (!['MEMORY.md', 'USER.md', 'KEY.md'].includes(dirent.name)) {
190
+ ctx.skipped.push({ path: `memories/${dirent.name}`, reason: 'not user-memory whitelist' });
191
+ continue;
192
+ }
193
+ const rel = `memories/${dirent.name}`;
194
+ if (isExcluded(rel, ctx.excludeGlobs)) {
195
+ ctx.skipped.push({ path: rel, reason: 'excluded' });
196
+ continue;
197
+ }
198
+ const stat = lstatSync(join(root, dirent.name));
199
+ if (stat.size > MAX_FILE_BYTES) {
200
+ ctx.errors.push({ code: 'E_FILE_TOO_LARGE', path: rel, size: stat.size });
201
+ continue;
202
+ }
203
+ ctx.out.push({ module: 'userMemory', path: rel, absPath: join(root, dirent.name), size: stat.size, mode: '100644' });
204
+ }
205
+ }
206
+ function collectProjectMemory({ dshHome, ctx }) {
207
+ const root = join(dshHome, 'memories');
208
+ for (const sub of ['projects', 'daily']) {
209
+ let dirents;
210
+ try {
211
+ dirents = readdirSync(join(root, sub), { withFileTypes: true });
212
+ }
213
+ catch {
214
+ continue;
215
+ }
216
+ for (const dirent of dirents) {
217
+ const abs = join(root, sub, dirent.name);
218
+ const rel = `memories/${sub}/${dirent.name}`;
219
+ if (dirent.isFile()) {
220
+ if (isExcluded(rel, ctx.excludeGlobs))
221
+ continue;
222
+ const stat = lstatSync(abs);
223
+ if (stat.size > MAX_FILE_BYTES) {
224
+ ctx.errors.push({ code: 'E_FILE_TOO_LARGE', path: rel, size: stat.size });
225
+ continue;
226
+ }
227
+ ctx.out.push({ module: 'projectMemory', path: rel, absPath: abs, size: stat.size, mode: '100644' });
228
+ }
229
+ else if (dirent.isDirectory()) {
230
+ walk(abs, rel, 'projectMemory', ctx);
231
+ }
232
+ }
233
+ }
234
+ }
235
+ function collectSessions({ dshHome, ctx }) {
236
+ const sessionsRoot = join(dshHome, 'sessions');
237
+ // 工作区注册表:归档会话列表 + 会话归属工作区
238
+ const archived = new Set();
239
+ const workspaceTitles = new Map();
240
+ try {
241
+ const registry = JSON.parse(lstatSync(join(dshHome, 'storages', 'workspace.json')).isFile()
242
+ ? readFileSync(join(dshHome, 'storages', 'workspace.json'), 'utf8')
243
+ : '{}');
244
+ for (const id of registry.global?.archivedSessionIds ?? [])
245
+ archived.add(id);
246
+ for (const ws of Object.values(registry.tables?.workspaces ?? {})) {
247
+ for (const sid of ws.sessionIds ?? [])
248
+ workspaceTitles.set(sid, ws.title ?? '');
249
+ }
250
+ }
251
+ catch {
252
+ // 没有注册表时按未归档处理
253
+ }
254
+ let wsDirs;
255
+ try {
256
+ wsDirs = readdirSync(sessionsRoot, { withFileTypes: true });
257
+ }
258
+ catch {
259
+ return;
260
+ }
261
+ for (const wsDir of wsDirs) {
262
+ if (!wsDir.isDirectory())
263
+ continue;
264
+ let sessionDirs;
265
+ try {
266
+ sessionDirs = readdirSync(join(sessionsRoot, wsDir.name), { withFileTypes: true });
267
+ }
268
+ catch {
269
+ continue;
270
+ }
271
+ for (const sessionDir of sessionDirs) {
272
+ if (!sessionDir.isDirectory())
273
+ continue;
274
+ const sid = sessionDir.name;
275
+ const rel = `sessions/${wsDir.name}/${sid}`;
276
+ if (archived.has(sid)) {
277
+ ctx.skipped.push({ path: rel, reason: 'archived' });
278
+ continue;
279
+ }
280
+ // 空会话(只有元数据行、UI 不显示)不纳入同步:主日志小于 2 KiB 视为空会话
281
+ const sessionDirAbs = join(sessionsRoot, wsDir.name, sid);
282
+ let payloadBytes = 0;
283
+ try {
284
+ for (const entry of readdirSync(sessionDirAbs, { withFileTypes: true })) {
285
+ if (entry.isFile())
286
+ payloadBytes += lstatSync(join(sessionDirAbs, entry.name)).size;
287
+ }
288
+ }
289
+ catch {
290
+ payloadBytes = 0;
291
+ }
292
+ if (payloadBytes < 2048) {
293
+ ctx.skipped.push({ path: rel, reason: 'empty-session' });
294
+ continue;
295
+ }
296
+ let title = null;
297
+ try {
298
+ const cache = JSON.parse(lstatSync(join(dshHome, 'storages', 'session_projcache', 'sessions', `${sid}.json`)).isFile()
299
+ ? readFileSync(join(dshHome, 'storages', 'session_projcache', 'sessions', `${sid}.json`), 'utf8')
300
+ : '{}');
301
+ title = cache.record?.rows?.title?.val ?? null;
302
+ }
303
+ catch {
304
+ title = null;
305
+ }
306
+ const before = ctx.out.length;
307
+ walk(join(sessionsRoot, wsDir.name, sid), rel, 'sessions', ctx);
308
+ const workspaceTitle = workspaceTitles.get(sid) ?? wsDir.name;
309
+ for (let i = before; i < ctx.out.length; i++) {
310
+ ctx.out[i].title = title ?? `(无标题)${workspaceTitle}`;
311
+ }
312
+ }
313
+ }
314
+ }
315
+ /** 扫描用户级资产。 */
316
+ export function scanAssets({ dshHome, userHome, profileName = 'desktop', modules = DEFAULT_MODULES, excludeGlobs = [], }) {
317
+ const ctx = { out: [], errors: [], skipped: [], excludeGlobs };
318
+ if (modules.includes('skills'))
319
+ collectSkills({ userHome, ctx });
320
+ if (modules.some((m) => ['plugins', 'config'].includes(m))) {
321
+ collectProfileFiles({ dshHome, profileName, modules, ctx });
322
+ }
323
+ if (modules.includes('userMemory'))
324
+ collectUserMemory({ dshHome, ctx });
325
+ if (modules.includes('projectMemory'))
326
+ collectProjectMemory({ dshHome, ctx });
327
+ if (modules.includes('sessions'))
328
+ collectSessions({ dshHome, ctx });
329
+ const entries = ctx.out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
330
+ const totalBytes = entries.reduce((sum, entry) => sum + entry.size, 0);
331
+ if (totalBytes > MAX_TOTAL_BYTES) {
332
+ ctx.errors.push({ code: 'E_TOTAL_TOO_LARGE', total: totalBytes, limit: MAX_TOTAL_BYTES });
333
+ }
334
+ return { entries, errors: ctx.errors, skipped: ctx.skipped, totalBytes };
335
+ }
@@ -0,0 +1,80 @@
1
+ import { loadState, saveState } from './state.js';
2
+ /**
3
+ * 自动检查调度器:启动后检查一次,之后按间隔检查远端**元数据**(list + meta),
4
+ * 不下载、不写盘(只把检查时间与判定记进 state.json)。
5
+ * 失败不无限重试:下一次到点照常。
6
+ */
7
+ export function createScheduler({ sync, statePath, intervalHours = 6, tickMs = 60_000, now = () => Date.now(), }) {
8
+ let timer = null;
9
+ let checking = false;
10
+ function settings() {
11
+ const state = loadState(statePath);
12
+ return {
13
+ autoCheck: state.autoCheck !== false,
14
+ intervalHours: Number(state.intervalHours) > 0 ? Number(state.intervalHours) : intervalHours,
15
+ lastCheckAt: state.lastCheckAt ?? null,
16
+ lastDecision: state.lastDecision ?? null,
17
+ lastCheckError: state.lastCheckError ?? null,
18
+ };
19
+ }
20
+ function isDue(at = now()) {
21
+ const current = settings();
22
+ if (!current.autoCheck)
23
+ return false;
24
+ if (!current.lastCheckAt)
25
+ return true;
26
+ const last = Date.parse(current.lastCheckAt);
27
+ if (!Number.isFinite(last))
28
+ return true;
29
+ return at - last >= current.intervalHours * 3_600_000;
30
+ }
31
+ async function runOnce() {
32
+ if (checking)
33
+ return null;
34
+ checking = true;
35
+ try {
36
+ const result = await sync.check();
37
+ const state = loadState(statePath);
38
+ state.lastCheckAt = new Date().toISOString();
39
+ state.lastDecision = {
40
+ action: result.decision?.action ?? null,
41
+ reason: result.decision?.reason ?? null,
42
+ packageId: result.latest?.name ?? null,
43
+ createdAt: result.meta?.createdAt ?? result.latest?.modifiedAt ?? null,
44
+ };
45
+ delete state.lastCheckError;
46
+ saveState(statePath, state);
47
+ return result;
48
+ }
49
+ catch (error) {
50
+ const state = loadState(statePath);
51
+ state.lastCheckAt = new Date().toISOString();
52
+ state.lastCheckError = error?.message ?? String(error);
53
+ saveState(statePath, state);
54
+ return { decision: null, error: state.lastCheckError };
55
+ }
56
+ finally {
57
+ checking = false;
58
+ }
59
+ }
60
+ function tick() {
61
+ if (isDue()) {
62
+ // 后台检查:不阻塞调用方,错误已记录在 state
63
+ void runOnce();
64
+ }
65
+ }
66
+ function start() {
67
+ if (timer)
68
+ return;
69
+ tick();
70
+ timer = setInterval(tick, tickMs);
71
+ // 不阻止 Node 进程退出(测试与无界面场景都靠它)
72
+ timer.unref?.();
73
+ }
74
+ function stop() {
75
+ if (timer)
76
+ clearInterval(timer);
77
+ timer = null;
78
+ }
79
+ return { start, stop, runOnce, tick, isDue, settings };
80
+ }
package/lib/secret.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * 敏感数据扫描:文件名黑名单 + 内容模式。
3
+ * 设计 §5.4:命中即**整包拒绝**(不是警告、不是跳过)。
4
+ * 本模块只做检测,不决定动作;调用方根据结果拒绝打包/应用。
5
+ */
6
+ export const BLOCKED_NAMES = [
7
+ /^\.credentials\.ya?ml$/i,
8
+ /^credentials\.json$/i,
9
+ /\.token$/i,
10
+ /\.key$/i,
11
+ /^cloud-credentials\.json$/i,
12
+ /^github\.token$/i,
13
+ /^\.npmrc\.auth$/i,
14
+ /^\.netrc$/i,
15
+ /^id_rsa$/i,
16
+ /^id_ed25519$/i,
17
+ ];
18
+ export const CONTENT_PATTERNS = [
19
+ { name: 'sk-密钥', re: /sk-[A-Za-z0-9]{16,}/g },
20
+ { name: '通用密钥字段', re: /(api[_-]?key|token|secret|password|passwd)["'\s:=-]+[A-Za-z0-9_-]{20,}/gi },
21
+ { name: 'Bearer', re: /Bearer\s+[A-Za-z0-9._-]{20,}/g },
22
+ { name: 'GitHub token', re: /gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}/g },
23
+ { name: '云厂商 AK', re: /LTAI[A-Za-z0-9]{12,}|AKIA[0-9A-Z]{16}/g },
24
+ { name: '私钥', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
25
+ { name: 'npm 凭据', re: /_authToken\s*=|_auth\s*=/g },
26
+ ];
27
+ /** 单文件超过此大小进入抽样扫描。 */
28
+ export const SAMPLE_THRESHOLD = 1024 * 1024;
29
+ /** 抽样时前后各取多少字节。 */
30
+ export const SAMPLE_BYTES = 64 * 1024;
31
+ /** 文件名是否命中黑名单(只看 basename)。 */
32
+ export function isBlockedName(name) {
33
+ const base = name.replace(/\\/g, '/').split('/').pop() ?? name;
34
+ return BLOCKED_NAMES.some((re) => re.test(base));
35
+ }
36
+ /**
37
+ * 扫描文本内容,返回命中位置。
38
+ * 只返回「文件 + 行号 + 模式名」,不返回命中的值本身(§5.4 日志与 UI 不显示值)。
39
+ */
40
+ export function scanText(text, filePath = '') {
41
+ const hits = [];
42
+ const lines = text.split('\n');
43
+ for (const { name, re } of CONTENT_PATTERNS) {
44
+ for (let i = 0; i < lines.length; i++) {
45
+ re.lastIndex = 0;
46
+ if (re.test(lines[i])) {
47
+ hits.push({ file: filePath, line: i + 1, pattern: name });
48
+ }
49
+ }
50
+ }
51
+ return hits;
52
+ }
53
+ /** 扫描缓冲区。超过阈值时只扫前后各 SAMPLE_BYTES,并标记 sampled。 */
54
+ export function scanBuffer(buf, filePath = '') {
55
+ const sampled = buf.length > SAMPLE_THRESHOLD;
56
+ if (!sampled) {
57
+ return { hits: scanText(buf.toString('utf8'), filePath), sampled };
58
+ }
59
+ const head = buf.subarray(0, SAMPLE_BYTES).toString('utf8');
60
+ const tail = buf.subarray(buf.length - SAMPLE_BYTES).toString('utf8');
61
+ return { hits: [...scanText(head, filePath), ...scanText(tail, filePath)], sampled };
62
+ }