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:
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user