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
+66 -29
View File
@@ -31,7 +31,9 @@
// route_kind 六值(html/asset/redirect/gone/dynamic/unresolved)**无 excluded 值**——查询
// 路由本身是 SP3 能力(§6.1 明示"登记不实现")。故本写入器对含 ?p=/&page_id= 的来源 URL
// **不插 route_registry 行**,以 counts.routesExcludedQueries + 逐条 issue 落报告
// (excluded_with_reason 的报告语义),行级登记随 SP3 查询路由能力一起落地。
// (excluded_with_reason 的报告语义),行级登记随 SP3 查询路由能力一起落地。内容行与
// attachment link **同口径排除**(?attachment_id= 不在排除表——attachment 页处置是 §6.1
// 明示的 301 行,照常 query_key 建模)。
//
// exact/collision 双键(ADR-007):exact_route_key 保留大小写/尾斜杠语义(/About 与 /about
// 两行并存);collision_key 折叠(前置事实 9 钉死:NFC + lowercase + 去尾斜杠)只作风险
@@ -79,7 +81,8 @@ export const DEFAULT_NEWS_PATH = '/news/'
/**
* collision_key 折叠函数(前置事实 9 钉死):NFC + lowercase + 去尾斜杠(根路径 '/' 折为 '')。
* 只用于风险检测,永不参与唯一约束与请求匹配(ADR-007)。
* 只用于风险检测,永不参与唯一约束与请求匹配(ADR-007)。折叠仅在本写入器 JS 侧计算落列
* ——勿用 PG normalize 在库侧重算(ICU 版本差异会致折叠结果漂移、预警失真)。
*/
export function foldCollisionKey(path: string): string {
return path.normalize('NFC').toLowerCase().replace(/\/+$/, '')
@@ -136,7 +139,7 @@ interface RouteWriterCounts {
routesRedirects: number
/** collision_key 折叠碰撞预警次数(不计失败——两行并存是 ADR-007 语义) */
routesCollisions: number
/** ?p=/&page_id= 查询形态排除数(不插行——excluded_with_reason 的报告落点) */
/** ?p=/&page_id= 查询形态排除数(内容行与 attachment link 同口径,不插行——excluded_with_reason 的报告落点) */
routesExcludedQueries: number
}
@@ -229,8 +232,11 @@ export function createRouteEventHandler(identity: {
}
/**
* 路径安全防线(最小集):NUL/反斜杠/控制字符/穿越段拒绝。完整校验规则(RFC 非保留字符
* 解码、Windows 保留设备名、尾部空格点、超长路径——spec §5.6)是 SP3 验证器职责。
* 路径安全防线(最小集):NUL/反斜杠/控制字符/穿越段拒绝——**全部四个写入域统一经过**
* (内容 permalink / attachment link / finish 期分类页 / finish 期 asset public_path:
* 后两者输入是 media-writer 的原样切片与 WXR 原始 slug,同携真实穿越向量)。完整校验
* 规则(RFC 非保留字符解码、Windows 保留设备名、尾部空格点、超长路径——spec §5.6)
* 是 SP3 验证器职责。
*/
function isSafePath(path: string): boolean {
if (path.includes('\0') || path.includes('\\')) return false
@@ -267,11 +273,15 @@ export function createRouteEventHandler(identity: {
return rows[0]?.contentEntryId ?? null
}
/** writeRoute 的四态产出(降级计数只认实际写入——'created'/'updated') */
type WriteOutcome = 'created' | 'updated' | 'noop' | 'conflict'
/**
* 单行写入(三分 upsert,见模块头):碰撞预警 → exact 查 → miss INSERT / 同 owner 比对
* UPDATE / 异 owner 冲突 issue 保留先到。
* UPDATE / 异 owner 冲突 issue 保留先到。返回四态(调用方按需消费——如 attachment 降级
* 计数只数实际写入,重复事件 no-op 不重复计数)。
*/
async function writeRoute(p: RoutePayload): Promise<void> {
async function writeRoute(p: RoutePayload): Promise<WriteOutcome> {
const collisionKey = foldCollisionKey(p.routePath)
// 碰撞预警(ADR-007:折叠同键而 exact 不同——两行并存,只预警不拦截)
@@ -338,7 +348,7 @@ export function createRouteEventHandler(identity: {
})
counts.routesCreated++
if (p.routeKind === 'redirect') counts.routesRedirects++
return
return 'created'
}
// owner 判据三元组(见模块头 upsert 注记)
@@ -352,7 +362,7 @@ export function createRouteEventHandler(identity: {
issues.push(
`路由 exact_key 冲突(${p.host}${p.routePath}${queryPart}):保留先到(${existing.routeKind} → ${existingTarget})——跳过后到(${p.label},${p.routeKind})——不静默覆盖`,
)
return
return 'conflict'
}
const set: Partial<typeof routeRegistry.$inferInsert> = {}
if (existing.sourcePathRaw !== p.sourcePathRaw) set.sourcePathRaw = p.sourcePathRaw
@@ -367,8 +377,10 @@ export function createRouteEventHandler(identity: {
set.updatedAt = new Date()
await db.update(routeRegistry).set(set).where(eq(routeRegistry.id, existing.id))
counts.routesUpdated++
return 'updated'
}
// 全等:幂等 no-op(重跑零计数)
return 'noop'
}
/**
@@ -429,8 +441,36 @@ export function createRouteEventHandler(identity: {
})
}
/** finish 期单条 attachment 处理:资产反查 → 301 载荷(查询/permalink 双形态) */
/**
* finish 期单条 attachment 处理:URL 层防线(link 在场 → 解析 → ?p=/&page_id= 排除 → 路径
* 安全)先行,资产反查在后 → 301 载荷(查询/permalink 双形态)。返回 true 仅当实际写入
* (created/updated——重复事件 no-op 不重复计入降级汇总)。
*/
async function writeAttachmentRoute(att: PendingAttachment, host: string): Promise<boolean> {
if (att.sourceUrl === '') {
issues.push(`attachment 缺 link(wp:attachment/${att.sourceId},${att.rawLocation}):无页面 URL 证据——301 路由不登记`)
return false
}
let parsed: URL
try {
parsed = new URL(att.sourceUrl)
} catch (err) {
issues.push(`attachment link 解析失败(wp:attachment/${att.sourceId},${att.sourceUrl}):${err instanceof Error ? err.message : String(err)}——301 路由不登记`)
return false
}
// ?p=/&page_id= 排除口径与内容行一致(Fix 对齐:attachment link 同形态同排除——
// excluded_with_reason 语义见模块头;?attachment_id= 不在排除表,照常 query_key 建模)
for (const param of EXCLUDED_QUERY_PARAMS) {
if (parsed.searchParams.has(param)) {
counts.routesExcludedQueries++
issues.push(`查询 URL 排除(wp:attachment/${att.sourceId},${att.sourceUrl}):?${param}= 形态不登记 route_registry(route_kind 无 excluded 值——查询路由 SP3;spec §6.1/§6.2 excluded_with_reason 以计数+本 issue 落报告)`)
return false
}
}
if (!isSafePath(parsed.pathname)) {
issues.push(`attachment 路径安全校验拒绝(wp:attachment/${att.sourceId},${parsed.pathname}):含 NUL/反斜杠/控制字符/穿越段——301 路由不登记(完整校验规则 SP3 验证器)`)
return false
}
const assetRows = await db
.select({ publicPath: mediaAssets.publicPath })
.from(mediaAssets)
@@ -444,31 +484,17 @@ export function createRouteEventHandler(identity: {
.orderBy(mediaAssets.sourceUrl)
.limit(2)
if (assetRows.length === 0) {
issues.push(`attachment 301 目标缺席(wp:attachment/${att.sourceId},${att.sourceUrl || att.rawLocation}):media_assets 无对应资产(媒体三级对拍 miss?)——attachment 页路由未登记`)
issues.push(`attachment 301 目标缺席(wp:attachment/${att.sourceId},${att.sourceUrl}):media_assets 无对应资产(媒体三级对拍 miss?)——attachment 页路由未登记`)
return false
}
if (assetRows.length > 1) {
issues.push(`attachment 多资产歧义(wp:attachment/${att.sourceId}):同 source_id 命中 ${assetRows.length} 行——不自动选(spec §5.5),attachment 页路由未登记`)
// LIMIT 2 探针只知下界——歧义消息按 ≥2 表述(不自动选,spec §5.5)
issues.push(`attachment 多资产歧义(wp:attachment/${att.sourceId}):同 source_id 命中 ≥2 行——不自动选(spec §5.5),attachment 页路由未登记`)
return false
}
const publicPath = assetRows[0]!.publicPath
if (att.sourceUrl === '') {
issues.push(`attachment 缺 link(wp:attachment/${att.sourceId},${att.rawLocation}):无页面 URL 证据——301 路由不登记`)
return false
}
let parsed: URL
try {
parsed = new URL(att.sourceUrl)
} catch (err) {
issues.push(`attachment link 解析失败(wp:attachment/${att.sourceId},${att.sourceUrl}):${err instanceof Error ? err.message : String(err)}——301 路由不登记`)
return false
}
if (!isSafePath(parsed.pathname)) {
issues.push(`attachment 路径安全校验拒绝(wp:attachment/${att.sourceId},${parsed.pathname})——301 路由不登记`)
return false
}
const queryKey = canonicalQueryKey(parsed)
await writeRoute({
const outcome = await writeRoute({
label: `attachment ${att.sourceId}`,
host,
sourcePathRaw: att.sourceUrl,
@@ -487,7 +513,7 @@ export function createRouteEventHandler(identity: {
priority: 0,
verificationStatus: 'verified_redirect',
})
return true
return outcome === 'created' || outcome === 'updated'
}
return {
@@ -566,6 +592,11 @@ export function createRouteEventHandler(identity: {
const disposition = TERM_ROUTE_DISPOSITIONS[t.taxonomyKey]!
const withSlug = `${t.basePath}${t.slug}`
const pagePath = withSlug.endsWith('/') ? withSlug : `${withSlug}/`
// 安全防线与内容 permalink 同口径:slug 是 WXR 原始串(穿越向量真实存在)
if (!isSafePath(pagePath)) {
issues.push(`分类页路径安全校验拒绝(${t.taxonomyKey}/${t.slug},${pagePath}):含 NUL/反斜杠/控制字符/穿越段——路由不登记(完整校验规则 SP3 验证器)`)
continue
}
if (disposition.kind === 'list') {
await writeRoute({
label: `分类页 ${t.taxonomyKey}/${t.slug}`,
@@ -622,6 +653,12 @@ export function createRouteEventHandler(identity: {
.where(eq(mediaAssets.siteId, siteId))
.orderBy(mediaAssets.publicPath)
for (const a of assetRows) {
// 安全防线与内容 permalink 同口径:public_path 是 media-writer 对来源 URL 的原样切片
//(相对路径保留 .. 段——穿越向量真实存在),不因来源是库表而豁免
if (!isSafePath(a.publicPath)) {
issues.push(`媒体路径安全校验拒绝(media_assets public_path=${a.publicPath}):含 NUL/反斜杠/控制字符/穿越段——asset 路由不登记(完整校验规则 SP3 验证器)`)
continue
}
await writeRoute({
label: `媒体 asset ${a.publicPath}`,
host,