canon 0.3.29 → 0.3.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CLAUDE.md +4 -0
- data/lib/canon/comparison/xml_comparator.rb +19 -0
- data/lib/canon/version.rb +1 -1
- data/lib/canon/xml/c14n.rb +44 -0
- data/lib/canon/xml/digest_gate.rb +94 -0
- data/lib/canon/xml.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c7f349890b131b4280956b65c069369fee17b526db3ffacaa4560c3b3beb485
|
|
4
|
+
data.tar.gz: dc2179259f0a7212de6b6aaddd50e61a32fba4627d4b853d60e00172cb819c10
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6e3085a46f5ae85c542a419ae35b961529fc76d1f232d72f3599eb54b1f6588e0bbc9d8f1ffcdb6e31e61a5dcf4a3d8c884ddf0999f9a1f2062e22997640052a
|
|
7
|
+
data.tar.gz: 3573d0da07ad60bd4aac8a3e3ffb149d61c400a2708c10d8dcca9280abae0dc12ac7d442786fd12be62ea584345e67dd2807ca63bc598578a4013e9159cd0ad7
|
data/CLAUDE.md
CHANGED
|
@@ -143,6 +143,10 @@ Engine parity is complete through libleptris 1.9.8 / leptris-ruby 1.9.33 / moxml
|
|
|
143
143
|
|
|
144
144
|
Engine A/B testing: `CANON_XML_BACKEND=nokogiri bundle exec rspec` (or `=moxml` to force leptris when it isn't the resolved default). The default suite must stay green under BOTH values; the only expected pendings are the upstream-tracked ones. The benchmark header (`rake performance:quick`) reports the active engine.
|
|
145
145
|
|
|
146
|
+
### C14N Engines
|
|
147
|
+
|
|
148
|
+
`Canon::Xml::C14n.canonicalize` stays on canon's Ruby C14N 1.1 processor by default. leptris' native C-side C14N 1.1 is ~50x faster on large documents and byte-identical on the main corpus, but diverges from the spec on edge cases (attribute ordering, prefix preservation, `>`/tab escaping, document-level PIs — leptris#1015, all pinned in `spec/canon/xml/c14n_engine_parity_spec.rb`). `CANON_C14N_BACKEND=leptris` opts in; when the parity gate runs clean the default flips. `canonicalize_subset` and `with_comments: true` always use the Ruby processor.
|
|
149
|
+
|
|
146
150
|
### YAML Engines
|
|
147
151
|
|
|
148
152
|
`Canon::YamlBackend` selects the YAML engine: `:psych` (default) or `:yeptris` (FFI over libyeptris, the YAML counterpart of the leptris XML stack — `CANON_YAML_BACKEND=yeptris` opts in; the optional `yeptris` Gemfile group must be enabled). `Canon::YamlParsing` is the single gateway for string loads; `Canon::JsonParsing` mirrors it for JSON (the same yeptris engine serves both — `Yeptris::YAML.load` auto-detects JSON and routes to the native C-API materializer; JSON falls back to stdlib unless `YamlBackend.yeptris_native?`, since the FFI ladder is ~29x slower than the stdlib C extension). `YAML.dump` stays on Psych everywhere — canonical output bytes are canon's product and the writers differ. **JSON defaults to the strict yeptris surface whenever the native materializer is installed** (yeptris 0.1.13.4 ships platform gems — zero compilation, zero env; parity spec-pinned to `JSON.parse` upstream). The YAML default stays `:psych` until the yeptris Psych-parity gaps close (yeptris-ruby#30 sexagesimal scalars, #31 >64-bit integers — #29 empty documents was fixed in 0.1.12); `spec/canon/yaml_engine_parity_spec.rb` is the executable gate, with upstream-tracked cases pending. Never `require "yeptris/psych"` — it rebinds the global `::Psych` constant for the whole process; only the namespaced `Yeptris::YAML` API is used.
|
|
@@ -74,6 +74,25 @@ module Canon
|
|
|
74
74
|
# Store resolved match options hash for use in comparison logic
|
|
75
75
|
opts[:match_opts] = match_opts_hash
|
|
76
76
|
|
|
77
|
+
# FAST PATH: leptris Merkle digest — equal root-subtree
|
|
78
|
+
# digests plus identical document-level skeletons prove
|
|
79
|
+
# content identity (modulo whitespace-only text nodes and
|
|
80
|
+
# attribute order), which implies equivalence wherever
|
|
81
|
+
# canon's whitespace handling is the same symmetric
|
|
82
|
+
# parse-time strip. Callers whose whitespace semantics
|
|
83
|
+
# differ from that skip the gate: strict attribute order
|
|
84
|
+
# (invisible to the digest), user-configured whitespace
|
|
85
|
+
# element lists, xml:space documents (checked inside the
|
|
86
|
+
# gate), and verbose callers who need the report.
|
|
87
|
+
if !(opts[:verbose] ||
|
|
88
|
+
match_opts_hash[:attribute_order] == :strict ||
|
|
89
|
+
match_opts_hash[:preserve_whitespace_elements] ||
|
|
90
|
+
match_opts_hash[:collapse_whitespace_elements] ||
|
|
91
|
+
match_opts_hash[:strip_whitespace_elements]) &&
|
|
92
|
+
Xml::DigestGate.equal?(n1, n2)
|
|
93
|
+
return true
|
|
94
|
+
end
|
|
95
|
+
|
|
77
96
|
# Create child_opts with resolved options
|
|
78
97
|
child_opts = opts.merge(child_opts)
|
|
79
98
|
|
data/lib/canon/version.rb
CHANGED
data/lib/canon/xml/c14n.rb
CHANGED
|
@@ -10,6 +10,10 @@ module Canon
|
|
|
10
10
|
# @param with_comments [Boolean] Include comments in canonical form
|
|
11
11
|
# @return [String] Canonical form in UTF-8
|
|
12
12
|
def self.canonicalize(xml, with_comments: false)
|
|
13
|
+
if (native = native_canonicalize(xml, with_comments))
|
|
14
|
+
return native
|
|
15
|
+
end
|
|
16
|
+
|
|
13
17
|
# Build XPath data model
|
|
14
18
|
root_node = DataModel.from_xml(xml)
|
|
15
19
|
|
|
@@ -18,6 +22,46 @@ module Canon
|
|
|
18
22
|
processor.process(root_node)
|
|
19
23
|
end
|
|
20
24
|
|
|
25
|
+
# leptris' C-side C14N 1.1 — ~50x faster than the Ruby processor
|
|
26
|
+
# but NOT yet byte-identical on canon's edge-case corpus
|
|
27
|
+
# (attribute ordering, `>` escaping, document-level PIs, one
|
|
28
|
+
# prefix case — leptris#1015; the parity spec pins
|
|
29
|
+
# each). Opt-in via CANON_C14N_BACKEND=leptris until those
|
|
30
|
+
# close; comments mode keeps the Ruby path regardless (the
|
|
31
|
+
# native seam exposes no with-comments form).
|
|
32
|
+
def self.native_canonicalize(xml, with_comments)
|
|
33
|
+
return nil if with_comments
|
|
34
|
+
return nil if RUBY_ENGINE == "opal"
|
|
35
|
+
return nil unless ENV["CANON_C14N_BACKEND"].to_s.casecmp("leptris").zero?
|
|
36
|
+
return nil unless Canon::XmlBackend.moxml? &&
|
|
37
|
+
Canon::XmlParsing.moxml_adapter_name == :leptris
|
|
38
|
+
return nil unless native_c14n_available?
|
|
39
|
+
|
|
40
|
+
doc = Canon::XmlParsing.moxml_context.parse(xml, readonly: true,
|
|
41
|
+
strict: false)
|
|
42
|
+
begin
|
|
43
|
+
doc.native.canonicalize(::Leptris::XML::FFI::C14N_1_1, nil,
|
|
44
|
+
mode: ::Leptris::XML::FFI::C14N_MODE_CANONICAL)
|
|
45
|
+
ensure
|
|
46
|
+
doc.free
|
|
47
|
+
end
|
|
48
|
+
rescue StandardError
|
|
49
|
+
# Malformed inputs are the Ruby path's domain (recovery parse
|
|
50
|
+
# + parse_errors surfacing), not the native lane's.
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.native_c14n_available?
|
|
55
|
+
return @native_c14n_available unless @native_c14n_available.nil?
|
|
56
|
+
|
|
57
|
+
@native_c14n_available = defined?(::Leptris::XML::FFI::C14N_1_1) &&
|
|
58
|
+
::Leptris::XML::FFI.constants.include?(:C14N_MODE_CANONICAL)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.reset_native_probe!
|
|
62
|
+
@native_c14n_available = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
21
65
|
# Canonicalize a document subset selected by XPath expression.
|
|
22
66
|
#
|
|
23
67
|
# Implements W3C C14N 1.1 subset canonicalization:
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canon
|
|
4
|
+
module Xml
|
|
5
|
+
# Document-level Merkle-digest equivalence gate over leptris'
|
|
6
|
+
# content-defined subtree digests (libleptris #869, leptris-ruby
|
|
7
|
+
# Node#digest since 1.9.144).
|
|
8
|
+
#
|
|
9
|
+
# Equal root digests prove the root subtrees carry identical
|
|
10
|
+
# content (whitespace-only nodes dropped), and every canon match
|
|
11
|
+
# behavior is a relaxation of content identity — so equal digests
|
|
12
|
+
# imply equivalence under any option combination EXCEPT the two
|
|
13
|
+
# restrictions that compare things the digest deliberately
|
|
14
|
+
# ignores (attribute order, comments). The caller excludes those;
|
|
15
|
+
# this module answers the digest question only.
|
|
16
|
+
#
|
|
17
|
+
# A digest miss costs two readonly engine parses (no field
|
|
18
|
+
# materialization, no canon tree) — a fraction of the comparison
|
|
19
|
+
# it precedes. Any parse failure answers false and lets the full
|
|
20
|
+
# pipeline surface the error.
|
|
21
|
+
module DigestGate
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def available?
|
|
25
|
+
return false if RUBY_ENGINE == "opal"
|
|
26
|
+
return false unless Canon::XmlBackend.moxml? &&
|
|
27
|
+
Canon::XmlParsing.moxml_adapter_name == :leptris
|
|
28
|
+
|
|
29
|
+
# Node#digest is the 1.9.144 surface; feature-detect it on a
|
|
30
|
+
# throwaway document rather than probing the class.
|
|
31
|
+
doc = Canon::XmlParsing.moxml_context.parse("<r/>", readonly: true,
|
|
32
|
+
strict: false)
|
|
33
|
+
root = doc.root
|
|
34
|
+
digestable = !root.native.digest(drop_ws: true).nil?
|
|
35
|
+
doc.free
|
|
36
|
+
digestable
|
|
37
|
+
rescue StandardError
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# True when both documents' root subtrees digest identically
|
|
42
|
+
# AND their document-level skeletons (prolog/epilog comments and
|
|
43
|
+
# PIs — the only doc-level nodes canon compares; doctype and
|
|
44
|
+
# whitespace-only text are excluded) serialize identically.
|
|
45
|
+
# Anything else — parse failure, missing root, digest
|
|
46
|
+
# unavailable — is false: the caller falls through to the full
|
|
47
|
+
# pipeline, which surfaces the real parse errors.
|
|
48
|
+
def equal?(xml1, xml2)
|
|
49
|
+
return false unless xml1.is_a?(String) && xml2.is_a?(String)
|
|
50
|
+
|
|
51
|
+
begin
|
|
52
|
+
# xml:space documents carry attribute-scoped whitespace canon
|
|
53
|
+
# makes normative and the digest cannot see — decline them.
|
|
54
|
+
# The scan also declines non-ASCII-compatible encodings
|
|
55
|
+
# (include? raises) — those are the full pipeline's to
|
|
56
|
+
# normalize.
|
|
57
|
+
return false if xml1.include?("xml:space") || xml2.include?("xml:space")
|
|
58
|
+
rescue Encoding::CompatibilityError
|
|
59
|
+
return false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
context = Canon::XmlParsing.moxml_context
|
|
63
|
+
begin
|
|
64
|
+
left = fingerprint(context, xml1)
|
|
65
|
+
right = fingerprint(context, xml2)
|
|
66
|
+
!left.nil? && left == right
|
|
67
|
+
rescue StandardError
|
|
68
|
+
# Any engine-level surprise (encodings, broken input) is the
|
|
69
|
+
# full pipeline's domain — it normalizes and surfaces errors.
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# [root digest, doc-level skeleton] or nil when unparseable.
|
|
75
|
+
def fingerprint(context, xml)
|
|
76
|
+
doc = context.parse(xml, readonly: true, strict: false)
|
|
77
|
+
root = doc.root
|
|
78
|
+
return nil unless root
|
|
79
|
+
|
|
80
|
+
skeleton = doc.children.filter_map do |child|
|
|
81
|
+
next if child.equal?(root)
|
|
82
|
+
|
|
83
|
+
case child
|
|
84
|
+
when Moxml::Comment, Moxml::ProcessingInstruction then child.to_s
|
|
85
|
+
when Moxml::Text then child.content.strip.empty? ? nil : child.to_s
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
[root.native.digest(drop_ws: true), skeleton]
|
|
89
|
+
ensure
|
|
90
|
+
doc&.free
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/canon/xml.rb
CHANGED
|
@@ -16,6 +16,7 @@ module Canon
|
|
|
16
16
|
module Xml
|
|
17
17
|
autoload :AttributeHandler, "canon/xml/attribute_handler"
|
|
18
18
|
autoload :C14n, "canon/xml/c14n"
|
|
19
|
+
autoload :DigestGate, "canon/xml/digest_gate"
|
|
19
20
|
autoload :CharacterEncoder, "canon/xml/character_encoder"
|
|
20
21
|
autoload :DataModel, "canon/xml/data_model"
|
|
21
22
|
autoload :ElementMatcher, "canon/xml/element_matcher"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: canon
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.31
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: diff-lcs
|
|
@@ -391,6 +391,7 @@ files:
|
|
|
391
391
|
- lib/canon/xml/c14n.rb
|
|
392
392
|
- lib/canon/xml/character_encoder.rb
|
|
393
393
|
- lib/canon/xml/data_model.rb
|
|
394
|
+
- lib/canon/xml/digest_gate.rb
|
|
394
395
|
- lib/canon/xml/element_matcher.rb
|
|
395
396
|
- lib/canon/xml/line_range_mapper.rb
|
|
396
397
|
- lib/canon/xml/namespace_handler.rb
|