☰
jscpd XML 报告器如何应对非法字节与 CDATA 终止符:xml-report 演示与源码级剖析
2026/10/8 14:08:46 网站建设 项目流程
  • 开发工具
  • 代码质量
  • 静态分析

【免费下载链接】jscpd

Copy/paste detector for source code. 220+ languages, Rust engine, SARIF/HTML/badge reporters, GitHub Action, MCP server for AI agents.

项目地址:https://gitcode.com/gh_mirrors/js/jscpd
点击查看免费下载

本指南以 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')" # ok

xml.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 个<[![CDATA[(1 个正常起始 + 2 次拆解),3 × 2 = 6。

组装过程:write_codefragment与XmlReporter::report

write_codefragment依次写出<codefragment>起始标签、经escape_cdata(sanitize_xml_text(text))处理后的 CDATA 内容、结束标签。report方法则用quick_xml以 2 空格缩进生成完整文档:

  • 文档头:<?xml version="1.0" encoding="UTF-8"?>;
  • 根元素:<pmd-cpd>;
  • 每个克隆一个<duplication>元素,带lines属性(fragment_a.end.line - fragment_a.start.line);
  • 两个<file>子元素,各带path、line属性与一个codefragment,随后再附一个共享片段的codefragment;
  • 文件路径属性同样经过sanitize_xml_text处理,转义交给quick-xml完成一次(源码注释特别说明:若此处再手动转义,路径中的&会被写成&amp;amp;双重转义)。

最终通过write_report_file写入<output_dir>/jscpd-report.xml(默认report/jscpd-report.xml)。

命令行与配置:如何选择报告器与输出目录

xml只是 jscpd 众多报告器之一。在 cli.rs](https://gitcode.com/gh_mirrors/js/jscpd?utm_source=gitcode_repo_files) 中,--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;但绝不出现&amp;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解析成功

把这一演示迁移到你的真实项目时,有三条可直接套用的经验:

  1. 始终对 XML 报告做解析校验:接入 CI 时,在jscpd命令后追加xmllint --noout report/jscpd-report.xml,或使用任意语言的 XML 解析器断言一次;
  2. 关注片段文本的"无损回读"语义:U+FFFD 替换是有损的(ANSI 字节无法恢复),但]]>拆解是无损的——解析器读回的片段文本与源码一致,这是设计上刻意区分的两类处理;
  3. 如需给下游工具(如 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.

项目地址:https://gitcode.com/gh_mirrors/js/jscpd
点击查看免费下载
上一篇:发现4种极速方案:彻底解决Obsidian美化资源下载难题
下一篇:Playnite:游戏管理终极方案,告别20+平台切换烦恼

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询