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