- 开发工具
- 代码质量
- 静态分析
【免费下载链接】jscpd
Copy/paste detector for source code. 220+ languages, Rust engine, SARIF/HTML/badge reporters, GitHub Action, MCP server for AI agents.
本指南以 jscpd 仓库中的 xml-report 演示 为线索,讲解xml报告器(PMD CPD 兼容格式)在克隆片段包含 XML 1.0 无法表示的字节(ANSI 转义符、换页符)以及]]>字面量时,如何生成仍然良构(well-formed)、可被任何 XML 解析器正常读取的报告。读完本文,你将掌握该场景下的复现命令、验证手段,并理解 Rust 引擎中sanitize_xml_text与escape_cdata两个核心函数的底层原理。
背景:一个能让 XML 报告"整份报废"的克隆
jscpd 的xml报告器负责把检测到的克隆输出为 PMD CPD 兼容的 XML 文档(见 xml_reporter.rs)。绝大多数源码都能顺利写入 XML,但有一类特殊情况例外:源码里存在 XML 1.0 语法本身不允许的字节。
XML 1.0 规范的Char产生式只允许制表符(\t)、换行符(\n)、回车符(\r)以及 U+0020 以上的绝大部分字符(除 U+FFFE、U+FFFF 两个非字符外)。这意味着:
- ANSI 转义字节(
0x1B):终端彩色输出的核心字节,在 XML 中无法合法出现; - 换页符(
0x0C):同样是合法的 UTF-8 却非法的 XML 控制字符; ]]>字面量:它是 CDATA 段的终止符,一旦出现在 CDATA 内部就会提前截断内容。
在修复(issue #375)之前,jscpd 5.2.0 及更早版本会把这类字节原样写入报告,导致xmllint直接报PCDATA invalid Char value 27并拒绝整个文件——一份克隆报告因此整体失效。本演示正是为了验证修复后(对应 CHANGELOG 中 #375、#1055 两项记录)报告始终良构而设。
演示内容:两个只差函数名的 banner 文件
演示位于 fixtures/xml-report-demo 目录下,结构如下:
| 目录 | 内容 | 默认扫描结果 |
|---|---|---|
escapes/ | 两个 JavaScript 文件,共享一个函数体,内含真实的 ANSI 转义字节(0x1B)、独立成行的换页符(0x0C)以及"]]>"字面量 | Found 1 clones. |
具体到文件本身,banner.js 与 banner-copy.js 仅在函数名上不同(paintBanner对paintBannerAgain),因此克隆覆盖整个函数体,所有"危险字节"都被包含在克隆片段内:
// banner.js(banner-copy.js 仅函数名不同) export function paintBanner(title, status, width) { const RESET = "\x1b[0m"; // ANSI 转义字节 0x1B const BOLD = "\x1b[1m"; const RED = "\x1b[31m"; const GREEN = "\x1b[32m"; const CDATA_END = "]]>"; // CDATA 终止符字面量 const line = "=".repeat(width); const color = status === "ok" ? GREEN : RED; const header = BOLD + title.padEnd(width) + RESET; const body = color + status.toUpperCase().padStart(width) + RESET; const marker = CDATA_END.repeat(2); return [line, header, body, marker, line].join("\n"); }注意换页符(0x0C)在源码中以独立一行的形式存在于字符串字面量之外——它和转义字节、]]>一起,构成了 XML 报告必须"背得动"的三道难题。
复现步骤:在仓库根目录跑一次完整验证
所有命令均从仓库根目录、使用默认阈值执行(默认阈值下这两份文件恰好构成 1 个克隆)。xmllint在 macOS 上随系统自带,在 Linux 上随 libxml2 提供。
第一步:生成 XML 与 console 报告
jscpd fixtures/xml-report-demo --reporters xml,console --output report预期输出:
Clone found (javascript) - escapes/banner-copy.js [5:33 - 18:2] (14 lines, 115 tokens) escapes/banner.js [5:28 - 18:2] Found 1 clones. XML report saved to report/jscpd-report.xml克隆从第 5 行(函数声明行)延伸到第 18 行,共 14 行、115 个 token。
第二步:用 xmllint 校验良构性
xmllint --noout report/jscpd-report.xml # (无输出:文档良构)无输出即意味着报告被完整解析通过——这正是修复前后最直观的差异。
第三步:统计 CDATA 段数量
grep -c '<!\[CDATA\[' report/jscpd-report.xml # 6第四步:用 Python 标准库再次独立校验
python3 -c "import xml.dom.minidom as m; m.parse('report/jscpd-report.xml'); print('ok')" # okxml.dom.minidom是独立于 libxml2 的另一个解析器实现,双重校验排除了"恰好被某一家解析器容忍"的偶然性。
源码级原理:三道防线如何让 XML 永远良构
报告器的完整实现位于 rust/crates/cpd-reporter/src/xml_reporter.rs。针对演示中的三类问题,代码给出了三层处理:
第一道防线:is_xml_char定义"合法字符集"
fn is_xml_char(ch: char) -> bool { matches!(ch, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}') }该函数严格对应 XML 1.0 的Char产生式;由于 Rust 字符串无法持有代理对(surrogate),因此无需额外检查那一区间。emoji 等补充平面字符(U+10000 以上)也在合法范围内。
第二道防线:sanitize_xml_text将非法字节替换为 U+FFFD
fn sanitize_xml_text(s: &str) -> Cow<'_, str> { if s.chars().all(is_xml_char) { Cow::Borrowed(s) } else { Cow::Owned( s.chars() .map(|ch| if is_xml_char(ch) { ch } else { '\u{FFFD}' }) .collect(), ) } }对纯文本(如"plain\ttext\n")直接借用原字符串零拷贝通过;一旦发现 NUL、ANSI 转义或换页符,就把它们逐个替换为 U+FFFD(替换字符)。为什么不能放进 CDATA?因为 XML 1.0 的 CDATA 段只豁免了<、&的转义需求,Char产生式的合法性约束依然生效——这正是源码注释里强调的"即使在 CDATA 内部,非法字节仍会使报告不可解析"。
第三道防线:escape_cdata拆解]]>终止符
fn escape_cdata(s: &str) -> String { s.replace("]]>", "]]]]><![CDATA[>") }每当文本中出现]]>,就把一个 CDATA 段"切断",用]]]]><![CDATA[>拼接回去,让解析器读取到的文本与源码逐字节一致。这解释了演示中grep -c '<!\[CDATA\['为什么得到 6:报告为每个克隆写出 3 个codefragment(片段 A 所在文件、片段 B 所在文件、共享片段本体),而演示源码含两个]]>字面量,每个片段内出现 3 个<[ 中,--reporters参数(短别名-r)支持逗号分隔的列表:
console, json, xml, csv, html, markdown, badge, sarif, codeclimate, openmetrics, ai, xcode, threshold, silent, console-full
--output(短别名-o)指定文件类报告器的输出目录。二者的默认值与优先级在 options.rs 中有明确解析逻辑:
reporters默认值按"CLI 参数 → 配置文件 →console"的优先级解析(cli.reporters.is_empty()时回退到配置,配置缺失时回退到["console"]);output_dir同理,最终回退到report目录;- 对应的单元测试(如
config_reporters_override_default、cli_reporters_override_config、reporters_split_by_comma)在 cli.rs 中逐一验证了这种覆盖关系。
因此--reporters xml,console --output report的含义是:同时生成report/jscpd-report.xml与终端 console 输出,两个报告器互不干扰。
回归保障:仓库内置的单元测试
除了演示目录,xml_reporter.rs 自带的测试模块把这几道防线固化成了可回归的断言:
xml_illegal_control_characters_are_replaced:构造含 NUL、ANSI 转义序列和换页符的片段,断言报告中不存在任何控制字符,且替换后的文本是"\u{FFFD}[0m";cdata_terminator_in_source_survives_a_round_trip:源码含"]]>"与']]>]]>'时,把拆解后的多个 CDATA 段拼接回来,必须与原文逐字一致;path_attributes_are_escaped_exactly_once:路径含&、<、"时,报告中出现&但绝不出现&amp;,解析回读的路径与原始路径完全相等;sanitize_keeps_legal_text_borrowed:普通文本零拷贝借用,NUL、U+FFFE 被替换,emoji 原样保留;one_clone_produces_duplication_element:同时断言 XML 中不包含tokens=与endline=属性,以保证与 TypeScript 版 jscpd 输出格式的兼容。
修复前后对比与实战建议
| 阶段 | 行为 | 结果 |
|---|---|---|
| 修复前(jscpd ≤ 5.2.0) | 非法字节原样写入、]]>提前闭合 CDATA、路径双重转义 | xmllint报PCDATA invalid Char value 27,整份报告被拒 |
| 修复后(当前仓库实现) | 非法字节替换为 U+FFFD、]]>拆分为两个 CDATA 段、路径恰好转义一次 | xmllint无输出即通过,minidom解析成功 |
把这一演示迁移到你的真实项目时,有三条可直接套用的经验:
- 始终对 XML 报告做解析校验:接入 CI 时,在
jscpd命令后追加xmllint --noout report/jscpd-report.xml,或使用任意语言的 XML 解析器断言一次; - 关注片段文本的"无损回读"语义:U+FFFD 替换是有损的(ANSI 字节无法恢复),但
]]>拆解是无损的——解析器读回的片段文本与源码一致,这是设计上刻意区分的两类处理; - 如需给下游工具(如 PMD CPD 兼容工具链)投喂报告,
pmd-cpd根结构与duplication/file/codefragment的元素布局保证了互操作性,可直接对接。
如果希望快速复现并亲手验证,只需克隆仓库后在根目录依次执行上面第 2 节的四条命令;演示本身、fixture 文件与报告器实现(演示说明、报告器源码)都可作为你本地排查 XML 报告问题的参照物。
- 开发工具
- 代码质量
- 静态分析
【免费下载链接】jscpd
Copy/paste detector for source code. 220+ languages, Rust engine, SARIF/HTML/badge reporters, GitHub Action, MCP server for AI agents.
相关推荐
XML转字典神器:xmltodict CDATA 处理终极指南 🚀
XML转字典神器:xmltodict CDATA 处理终极指南 🚀 想要在Python中像处理JSON一样轻松操作XML吗?xmltodict正是你需要的解决
序列化后端Checkov JUnit XML 报告输出:CI 集成格式详解与源码实现剖析
Checkov JUnit XML 报告输出:CI 集成格式详解与源码实现剖析 Checkov 提供 junitxml 输出格式,将基础设施即代码(IaC)扫描
应用安全静态分析供应链安全云原生Spring 源码剖析:BeanDefinitionParserDelegate 如何将 XML 标签解析为 BeanDefinition
Spring 源码剖析:BeanDefinitionParserDelegate 如何将 XML 标签解析为 BeanDefinition 导读 本文深入剖析
文档教程知识库
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考