diff --git a/apps/api/src/modules/source/register.service.ts b/apps/api/src/modules/source/register.service.ts index b86d24c..473a75b 100644 --- a/apps/api/src/modules/source/register.service.ts +++ b/apps/api/src/modules/source/register.service.ts @@ -369,9 +369,10 @@ async function validateInputPath( label: string, ): Promise { const abs = resolve(inputPath) - let stat + // 存在性探测(lstat 本身不跟链接——断链末段在此即 ENOENT);结果只用于"存在与否", + // 类型与白名单判定统一交给下方 realpath 后的物理路径 try { - stat = await lstat(abs) + await lstat(abs) } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code === 'ENOENT' || code === 'ENOTDIR') { @@ -382,17 +383,15 @@ async function validateInputPath( `${label} 输入无法访问(${code ?? 'unknown'}):${abs}`, ) } - // 输入本身是符号链接:以 realpath 后的目标做白名单与类型判定(防白名单内链接指向外界) + // 无条件 realpath:只 realpath 末段会漏掉**中间目录段**的符号链接/junction——链接本身 + // 位于白名单内、目标在外时,词法前缀判定被绕过(Windows junction 免特权即可构造)。 + // realpath 把整条路径解析到物理位置后再做白名单与类型判定;lstat 已确认输入存在, + // realpath 仅在路径上出现断链时抛 ENOENT(归入"输入不存在")。 let real: string - if (stat.isSymbolicLink()) { - try { - real = await realpath(abs) - } catch { - // 断链(目标不存在):realpath 抛 ENOENT——归入清晰的"输入不存在",而非裸系统错误 - throw new SourceRegisterError('INPUT_NOT_FOUND', `${label} 输入不存在(符号链接目标缺失):${abs}`) - } - } else { - real = abs + try { + real = await realpath(abs) + } catch { + throw new SourceRegisterError('INPUT_NOT_FOUND', `${label} 输入不存在(或链接目标缺失):${abs}`) } let allowed = false for (const root of allowedRoots) { @@ -558,9 +557,11 @@ function makeRunId(): string { // ---- 主入口 ---- /** - * 登记来源输入:校验(全部输入先过白名单/存在性,任何失败零落库零复制)→ - * 逐输入摄取(copy+hash)→ site/connection/snapshot 落库 → 报告写盘。 - * 返回 RegisteredSnapshot[] 供 CLI 摘要与调用方断言。 + * 登记来源输入:校验(全部输入先过白名单/存在性/类型)→ 逐输入摄取(copy+hash)→ + * site/connection/snapshot 落库 → 报告写盘。 + * 失败口径:校验阶段失败零落库零复制;摄取阶段中途失败**不回滚已落位的副本** + * (content-addressed、重跑幂等无害),但 DB 写入在全部摄取完成后才开始—— + * 故任何失败都零落库。返回 RegisteredSnapshot[] 供 CLI 摘要与调用方断言。 */ export async function registerSource(db: SourceDb, input: RegisterInput): Promise { const startedAt = performance.now() @@ -568,7 +569,7 @@ export async function registerSource(db: SourceDb, input: RegisterInput): Promis const storageRoot = input.storageRoot ?? join(REPO_ROOT, 'storage') const reportRoot = input.reportRoot ?? join(REPO_ROOT, 'artifacts', 'register') - // 阶段 0:全部输入先校验(fail fast,不留下半程副本) + // 阶段 0:全部输入先校验(fail fast——校验失败零落库零复制;摄取中途失败口径见函数 JSDoc) let wxrReal: string | undefined let sitemapReal: string | undefined let mediaReal: string | undefined diff --git a/docs/superpowers/plans/2026-10-20-slice-wxr-adapter-phase2.md b/docs/superpowers/plans/2026-10-20-slice-wxr-adapter-phase2.md index 1ea6774..4a27f92 100644 --- a/docs/superpowers/plans/2026-10-20-slice-wxr-adapter-phase2.md +++ b/docs/superpowers/plans/2026-10-20-slice-wxr-adapter-phase2.md @@ -127,7 +127,7 @@ cms-one/ - 副本落 `storage/snapshots///`;原输入不再被后续任务读取 - 路径安全:register 的输入路径必须在允许目录(storage/incoming 或显式绝对路径白名单校验——拒绝越界读取);**wxr:probe 例外:只读诊断命令,接受任意可读路径(Task 8 直读 cms 即用此通道),不写任何东西** - [x] Step 2 实现服务(copy 流式+sha256 双算、媒体清单哈希、DB 写入、报告输出 **artifacts/register//reports/**——阶段 2 无 import_jobs,runId 为本次 register 运行生成)+ CLI 接线 -- [ ] **CLI 执行机制(钉死)**:根 devDependency 加 `tsx`;根 package.json scripts 加 `"sf": "tsx scripts/sf.ts"`;`commander` **定为根依赖**(CLI 在根,adapter-wp 纯函数包不消费);CLI 经 tsx 直吃 TS 源码(**无需预构建 dist**;apps/api 的 Nest 装饰器源码同样由 tsx 运行)。Task 8 命令相应为 `pnpm sf wxr:probe --wxr ...` +- [x] **CLI 执行机制(钉死)**:根 devDependency 加 `tsx`;根 package.json scripts 加 `"sf": "tsx scripts/sf.ts"`;`commander` **定为根依赖**(CLI 在根,adapter-wp 纯函数包不消费);CLI 经 tsx 直吃 TS 源码(**无需预构建 dist**;apps/api 的 Nest 装饰器源码同样由 tsx 运行)。Task 8 命令相应为 `pnpm sf wxr:probe --wxr ...` - [x] Step 3 绿(75+新增)→ commit `feat(source): 输入登记服务与 sf CLI(content-addressed 快照)` ### Task 8: 真实 WXR 冒烟(阶段 2 验收) diff --git a/tests/integration/source-register.test.ts b/tests/integration/source-register.test.ts index 3e16217..73eb815 100644 --- a/tests/integration/source-register.test.ts +++ b/tests/integration/source-register.test.ts @@ -8,7 +8,8 @@ // 媒体目录重跑哈希一致(Windows 分隔符归一化 + mtime 不参与); // 4. 副本布局:storage/snapshots///(文件)或 /(整树), // 副本内容与原输入一致(后续任务只读副本); -// 5. 路径安全:白名单外 / 越界遍历 / ENOENT / 类型不符均清晰报错且零落库; +// 5. 路径安全:白名单外 / 越界遍历 / **中间段链接逃逸(整路径 realpath 后判定)** / +// ENOENT / 类型不符均清晰报错且零落库; // 6. 报告落 artifacts/register//reports/register.json(runId=时间戳+随机)。 // 运行于 globalSetup 临时库(ADR-015);storage/报告根用 os.tmpdir 临时目录,finally 清理。 // 注:符号链接跳过用目录 junction 验证(Windows 免特权;非 Windows 退化为普通符号链接); @@ -324,6 +325,44 @@ describe('source:register 服务', () => { }) }) + it('中间段 junction 逃逸被拒:目录段含指向白名单外的链接,整路径按 realpath 判定', async () => { + await withTestDb(async (db) => { + await withCtx(async (ctx) => { + // 布局(复现评审 PoC 形态):白名单根 incoming/ 内放 junction,目标 pkg/ 在白名单外; + // 文件物理落在 pkg/escape.xml——词法前缀判定会把 incoming/junc/escape.xml 误判为界内 + const pkg = join(ctx.work, 'pkg-outside') + const incoming = join(ctx.work, 'incoming') + await mkdir(pkg, { recursive: true }) + await mkdir(incoming, { recursive: true }) + await writeFile(join(pkg, 'escape.xml'), 'escaped') + try { + await symlink(pkg, join(incoming, 'junc'), 'junction') + } catch { + return // junction 创建失败(极端沙箱):静默放行,创建能力不是被测契约 + } + const key = `sfreg-${rand()}` + const err: SourceRegisterError = await registerSource(db, { + siteKey: key, + wxrPath: join(incoming, 'junc', 'escape.xml'), + allowedRoots: [incoming], + storageRoot: ctx.storageRoot, + reportRoot: ctx.reportRoot, + }).then( + () => { + throw new Error('中间段 junction 逃逸不应成功') + }, + (e: SourceRegisterError) => e, + ) + expect(err).toBeInstanceOf(SourceRegisterError) + expect(err.code).toBe('PATH_NOT_ALLOWED') + expect(err.message).toContain('越界') + // 零落库 + const sites = (await db.execute(`select id from sites where key = '${key}'`)) as any + expect(sites).toHaveLength(0) + }) + }) + }) + it('符号链接(junction)在媒体枚举中被跳过并记录,清单不受其内容影响', async () => { await withTestDb(async (db) => { await withCtx(async (ctx) => {