fix(import): finish 域路径安全守卫;attachment ?p= 排除对齐;歧义/降级计数精化;permalink 形态覆盖

- isSafePath 扩至 finish 域②(分类页 public_base_path+slug)与域④(media_assets public_path)——WXR 原始 slug 与库表原样切片同携穿越向量,拒绝即 issue+不登记
- writeAttachmentRoute 与内容行同口径排除 ?p=/&page_id=(?attachment_id= 不在排除表,照常 query_key 建模),排除计数 routesExcludedQueries
- 多资产歧义消息改"命中 ≥2 行"(LIMIT 2 探针只知下界)
- 降级汇总去虚报:writeRoute 返回四态,downgraded 只计实际写入(created/updated)——重复 attachment 事件 no-op 不重复计数
- foldCollisionKey JSDoc 钉死:折叠仅 JS 侧计算落列,勿用 PG normalize 库侧重算(ICU 版本差异面)
- 测试补 permalink 形态 attachment 301(纯路径行 query_key NULL)+ 重复事件降级计数"1 条"断言 + 双 finish 域安全守卫拒绝用例(317 全绿)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-21 19:40:35 +08:00
parent fae44a11f8
commit bb2d381308
2 changed files with 231 additions and 33 deletions
+165 -4
View File
@@ -80,6 +80,24 @@ function contentEvent(spec: { sourceId: string; url: string; sourceType?: string
return { kind: 'row', row, issues: [], sanitizeDiffs: [], changed: false }
}
/** 手构 attachment row 事件(sourceUrl = <link> 原串:查询/permalink 形态的判别输入) */
function attachmentEvent(spec: { sourceId: string; url: string }): AdapterEvent {
const row: UniversalRow = {
sourceType: 'wp:attachment',
sourceId: spec.sourceId,
status: 'inherit',
locale: 'en',
taxonomyRefs: [],
attachmentRefs: [],
seo: {},
knownFields: {},
extras: {},
rawLocation: `wxr://item/${spec.sourceId}`,
}
if (spec.url !== '') row.sourceUrl = spec.url
return { kind: 'row', row, issues: [], sanitizeDiffs: [], changed: false }
}
/** 手构 wp:term 事件(分类页/301 在 finish 期由 terms 表生成——term-writer 先物化) */
function termEvent(spec: { termId: number; taxonomy: string; slug: string; name: string }): AdapterEvent {
return {
@@ -205,6 +223,25 @@ function termRoutePipeline(paths?: { newsPath?: string }): ImportPipeline {
}
}
/** route 单写入器(手构 attachment/直插 media_assets 场景——站点页+asset 行仍在 finish 生成) */
function routeOnlyPipeline(): ImportPipeline {
return async (ctx) => {
const route = routeHandlerOf(ctx)
for await (const ev of ctx.events) {
await route.handleEvent(ev)
}
return route.finish()
}
}
/** 直插 media_assets 行(route finish 域④ 的输入——不经 media-writer 三级对拍) */
async function insertAssetRow(db: Db, siteId: string, connectionId: string, spec: { sourceId: string; sourceUrl: string; publicPath: string }): Promise<void> {
await db.execute(`insert into media_assets (site_id, source_connection_id, source_id, source_url,
source_path, storage_path, public_path, sha256, detected_mime, byte_size, status)
values ('${siteId}', '${connectionId}', '${spec.sourceId}', '${spec.sourceUrl}',
'fixture/source-path', 'fixture/storage-path', '${spec.publicPath}', 'fake-sha256', 'image/png', 1, 'ready')`)
}
/** 本文件写入的行清场:FK 反序(route_registry 先于 source_snapshots/sites;两张不可变表走触发器开关) */
async function cleanupSiteRows(db: Db, siteId: string): Promise<void> {
await db.execute(`delete from route_registry where site_id = '${siteId}'`)
@@ -486,13 +523,15 @@ describe('路由注册表写入器(§6.1 处置全表 + ADR-007 双键)', ()
})
})
it('?p=/&page_id= 查询形态:不插 route_registry 行(查询路由 SP3),routesExcludedQueries 计数 + issue 说明', async () => {
it('?p=/&page_id= 查询形态(内容行与 attachment link 同口径):不插 route_registry 行、routesExcludedQueries 计数 + issue', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-qexc-'))
try {
const events: AdapterEvent[] = [
contentEvent({ sourceId: '401', url: 'https://h.example/?p=999' }),
contentEvent({ sourceId: '402', url: 'https://h.example/?page_id=888' }),
// attachment link 的 ?p= 形态同排除(?attachment_id= 不在排除表——见下用例)
attachmentEvent({ sourceId: '501', url: 'https://h.example/?p=123' }),
]
const { key, reg, wxr } = await registerMini(db, work)
try {
@@ -513,18 +552,20 @@ describe('路由注册表写入器(§6.1 处置全表 + ADR-007 双键)', ()
expect(report.counts).toStrictEqual({
contentEntries: 2,
revisionsCreated: 2,
sourceRecords: 2,
// attachment 行经 content-writer 落 record-only 足迹(前置事实 11)
sourceRecords: 3,
skipped: 0,
routesCreated: 3,
routesUpdated: 0,
routesRedirects: 0,
routesCollisions: 0,
routesExcludedQueries: 2,
routesExcludedQueries: 3,
})
const excluded = report.issues.filter((i) => i.includes('查询 URL 排除'))
expect(excluded).toHaveLength(2)
expect(excluded).toHaveLength(3)
expect(excluded.some((i) => i.includes('p=999'))).toBe(true)
expect(excluded.some((i) => i.includes('page_id=888'))).toBe(true)
expect(excluded.some((i) => i.includes('p=123'))).toBe(true)
} finally {
await cleanupSiteRows(db, reg.siteId)
}
@@ -673,4 +714,124 @@ describe('路由注册表写入器(§6.1 处置全表 + ADR-007 双键)', ()
}
})
})
it('attachment permalink 形态 301(纯路径行)+ 同事件重复:降级汇总只计实际写入(1 条不虚报 2 条)', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-perm-'))
try {
const { key, reg, wxr } = await registerMini(db, work)
const connRow = (await db.execute(`select source_connection_id from source_snapshots
where id = '${wxr.id}'`)) as any
// 直插资产行(finish 域④ 反查输入——不经 media-writer 三级对拍)
await insertAssetRow(db, reg.siteId, connRow[0].source_connection_id, {
sourceId: '502',
sourceUrl: 'https://fixture.example/wp-content/uploads/2026/10/extra.png',
publicPath: '/wp-content/uploads/2026/10/extra.png',
})
// permalink 形态 attachment 页(无查询串)+ 同事件重复(WXR 文档内可重复出现)
const att = attachmentEvent({ sourceId: '502', url: 'https://fixture.example/news/fixture-att-page/' })
try {
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
events: eventsOf([att, att]),
pipeline: routeOnlyPipeline(),
})
expect(result.status).toBe('succeeded_with_issues')
const rows = await routesOf(db, reg.siteId)
// 3 站点页 + 1 permalink 形态 301(纯路径行 query_key NULL)+ 1 asset = 5
expect(rows).toHaveLength(5)
expect(rows.find((r) => r.path === '/news/fixture-att-page/')).toStrictEqual({
path: '/news/fixture-att-page/',
q: null,
kind: 'redirect',
tStatus: '301',
rStatus: 301,
loc: '/wp-content/uploads/2026/10/extra.png',
file: '/wp-content/uploads/2026/10/extra.png',
idx: false,
pri: 0,
ver: 'verified_redirect',
entry: null,
})
expect(rows.find((r) => r.path === '/wp-content/uploads/2026/10/extra.png')!.kind).toBe('asset')
const report = await readReport(work, result.jobId)
expect(report.counts).toStrictEqual({
routesCreated: 5,
routesUpdated: 0,
routesRedirects: 1,
routesCollisions: 0,
routesExcludedQueries: 0,
})
// 降级汇总只计实际写入:重复事件 no-op 不重复计数(1 条,不虚报 2 条)
const downgrade = report.issues.filter((i) => i.includes('attachment 页降级'))
expect(downgrade).toHaveLength(1)
expect(downgrade[0]).toContain('1 条')
expect(report.issues).toHaveLength(1)
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
it('finish 域路径安全守卫:term slug 穿越段与 media public_path 穿越段拒绝登记 + issue 如实报告', async () => {
await withTestDb(async (db) => {
const work = await mkdtemp(join(tmpdir(), 'sf-routes-guard-'))
try {
const { key, reg, wxr } = await registerMini(db, work)
const connRow = (await db.execute(`select source_connection_id from source_snapshots
where id = '${wxr.id}'`)) as any
// 直插带穿越段的 public_path(media-writer 对来源 URL 原样切片的形态)
await insertAssetRow(db, reg.siteId, connRow[0].source_connection_id, {
sourceId: '503',
sourceUrl: 'https://fixture.example/wp-content/uploads/../secret.png',
publicPath: '/wp-content/uploads/../secret.png',
})
const events: AdapterEvent[] = [
termEvent({ termId: 51, taxonomy: 'category', slug: '../../../evil', name: 'Evil' }),
]
try {
const result = await createImportJob(db, {
siteKey: key,
snapshotId: wxr.id,
reportRoot: join(work, 'artifacts', 'import'),
events: eventsOf(events),
pipeline: termRoutePipeline(),
})
expect(result.status).toBe('succeeded_with_issues')
const rows = await routesOf(db, reg.siteId)
// 仅 3 条站点页:穿越段分类页与穿越段 asset 行均被守卫拒绝
expect(rows).toHaveLength(3)
expect(rows.every((r) => r.path === '/' || r.path === '/products/' || r.path === '/news/')).toBe(true)
const report = await readReport(work, result.jobId)
expect(report.counts).toStrictEqual({
termsProcessed: 1,
autoCreatedTerms: 0,
contentTermsCreated: 0,
routesCreated: 3,
routesUpdated: 0,
routesRedirects: 0,
routesCollisions: 0,
routesExcludedQueries: 0,
})
const termGuard = report.issues.filter((i) => i.includes('分类页路径安全校验拒绝'))
expect(termGuard).toHaveLength(1)
expect(termGuard[0]).toContain('/category/../../../evil')
const assetGuard = report.issues.filter((i) => i.includes('媒体路径安全校验拒绝'))
expect(assetGuard).toHaveLength(1)
expect(assetGuard[0]).toContain('..')
} finally {
await cleanupSiteRows(db, reg.siteId)
}
} finally {
await rm(work, { recursive: true, force: true })
}
})
})
})