svg_conform 0.2.4 → 0.2.6
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/.gitattributes +1 -0
- data/.github/workflows/rake.yml +3 -0
- data/Gemfile +7 -3
- data/Gemfile.lock +272 -0
- data/TODO.issue-100/00-overview.md +135 -0
- data/TODO.issue-100/01-build-lens-core-v1.md +92 -0
- data/TODO.issue-100/02-adopt-lens-core-in-svg.md +123 -0
- data/TODO.issue-100/03-ship-svg-1-1-full-static.md +138 -0
- data/TODO.issue-100/04-ship-svg-2-secure-static.md +131 -0
- data/TODO.issue-100/05-freeze-candidate-and-handoff.md +108 -0
- data/TODO.issue-100/90-progress.md +65 -0
- data/config/profiles/metanorma.yml +3 -3
- data/config/profiles/svg_1_1_full_static.yml +69 -0
- data/config/profiles/svg_2_secure_static.yml +55 -0
- data/config/requirements/processing_features_static.yml +29 -0
- data/data/svg_1_1/manifest.yml +7 -0
- data/data/svg_1_1/svg11-flat-20110816.dtd +5664 -0
- data/docs/api_reference.adoc +19 -1
- data/docs/cli_guide.adoc +35 -45
- data/docs/diagnostics.adoc +73 -0
- data/docs/lens_core_handoff.adoc +84 -0
- data/docs/profiles.adoc +94 -1
- data/lib/svg_conform/cli.rb +3 -3
- data/lib/svg_conform/commands/check.rb +11 -60
- data/lib/svg_conform/conformance_report.rb +79 -1
- data/lib/svg_conform/diagnostics/lens_core_adapter.rb +200 -0
- data/lib/svg_conform/document.rb +12 -1
- data/lib/svg_conform/node_helpers.rb +1 -1
- data/lib/svg_conform/profile.rb +11 -2
- data/lib/svg_conform/quality_metrics/complexity_calculator.rb +3 -3
- data/lib/svg_conform/references/base_reference.rb +153 -0
- data/lib/svg_conform/report_comparator.rb +4 -4
- data/lib/svg_conform/requirements/base_requirement.rb +6 -0
- data/lib/svg_conform/requirements/external_references_requirement.rb +234 -0
- data/lib/svg_conform/requirements/link_validation_requirement.rb +2 -0
- data/lib/svg_conform/requirements/processing_features_requirement.rb +175 -0
- data/lib/svg_conform/requirements/svg_1_1_document_requirement.rb +213 -0
- data/lib/svg_conform/requirements.rb +6 -0
- data/lib/svg_conform/sax_document.rb +8 -4
- data/lib/svg_conform/sax_validation_handler.rb +22 -1
- data/lib/svg_conform/validation/css2_core_grammar.rb +359 -0
- data/lib/svg_conform/validation/css_references.rb +122 -0
- data/lib/svg_conform/validation/error_tracker.rb +3 -1
- data/lib/svg_conform/validation/svg_1_1_document_conformance.rb +155 -0
- data/lib/svg_conform/validation_context.rb +25 -5
- data/lib/svg_conform/validation_result.rb +91 -16
- data/lib/svg_conform/version.rb +1 -1
- data/lib/svg_conform.rb +24 -0
- data/spec/fixtures/diagnostics/external-reference-info.svg +6 -0
- data/spec/fixtures/plantuml/README.adoc +11 -0
- data/spec/fixtures/plantuml/unit-type.svg +1 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/animation.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/css-grammar.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-attribute.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-content-model.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/interaction.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/malformed-xml.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/script.svg +5 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-namespace.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-version.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/xml-stylesheet.svg +5 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/valid/css.svg +5 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/valid/foreign-metadata.svg +6 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/valid/minimal-full.svg +5 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/valid/xml-stylesheet.svg +5 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/animation.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-import.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-url.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-bad-png.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-empty-png.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-base64.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-percent.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-unsupported-type.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/file-like-fragment.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/http.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/https.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/interaction.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/protocol-relative.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file-fragment.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/script.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-excessive-nesting.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-malformed-xml.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-nested-external.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/urn.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/xml-stylesheet.svg +5 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-base64.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-percent.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/png-base64.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/png-percent.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/same-document-fragment.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-base64.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-percent.svg +4 -0
- data/spec/svg_conform/commands/check_command_spec.rb +22 -2
- data/spec/svg_conform/commands/profiles_command_spec.rb +18 -0
- data/spec/svg_conform/conformance_report_spec.rb +90 -0
- data/spec/svg_conform/diagnostics/lens_core_adapter_spec.rb +136 -0
- data/spec/svg_conform/gemspec_spec.rb +21 -0
- data/spec/svg_conform/profiles/svg_1_1_full_static_profile_spec.rb +146 -0
- data/spec/svg_conform/profiles/svg_2_secure_static_profile_spec.rb +150 -0
- data/spec/svg_conform/profiles_plantuml_spec.rb +81 -0
- data/spec/svg_conform/references/data_uri_reference_spec.rb +97 -0
- data/spec/svg_conform/references/integration_spec.rb +30 -0
- data/spec/svg_conform/references/reference_classifier_spec.rb +17 -0
- data/spec/svg_conform/requirements/external_references_requirement_spec.rb +109 -0
- data/spec/svg_conform/requirements/link_validation_requirement_spec.rb +16 -0
- data/spec/svg_conform/requirements/processing_features_requirement_spec.rb +75 -0
- data/spec/svg_conform/requirements/svg_1_1_document_requirement_spec.rb +141 -0
- data/spec/svg_conform/validation/css2_core_grammar_spec.rb +116 -0
- data/spec/svg_conform/validation/svg_1_1_document_conformance_spec.rb +144 -0
- data/spec/svg_conform/validation_context_source_plumbing_spec.rb +122 -0
- data/spec/svg_conform/validation_context_spec.rb +39 -0
- data/spec/svg_conform/validation_result_spec.rb +123 -0
- data/svg_conform.gemspec +3 -2
- metadata +102 -6
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "SVG 2 secure-static profile" do # rubocop:disable RSpec/DescribeClass
|
|
6
|
+
let(:profile) { SvgConform::Profiles.get("svg_2_secure_static") }
|
|
7
|
+
let(:citation) do
|
|
8
|
+
"https://www.w3.org/TR/2026/CR-SVG2-20261006/conform.html#secure-static-mode"
|
|
9
|
+
end
|
|
10
|
+
let(:root) { "spec/fixtures/profiles/svg_2_secure_static" }
|
|
11
|
+
|
|
12
|
+
def dom_report(path)
|
|
13
|
+
profile.validate(SvgConform::Document.from_file(path))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def sax_report(path)
|
|
17
|
+
SvgConform::SaxDocument.from_file(path).validate_with_profile(profile)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def summary(result)
|
|
21
|
+
result.errors.map do |e|
|
|
22
|
+
[e.requirement_id, e.data[:citation]]
|
|
23
|
+
end.sort
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "is discoverable, opt-in and dated" do
|
|
27
|
+
expect(SvgConform::Profiles.list).to include("svg_2_secure_static")
|
|
28
|
+
expect(profile.specification_uri).to include("CR-SVG2-20261006")
|
|
29
|
+
default = SvgConform::Cli.commands["check"].options[:profile].default
|
|
30
|
+
expect(default).to eq("svg_1_2_rfc")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "defines the four stable requirement ids with the dated citation" do
|
|
34
|
+
categories = profile.requirements.first.categories
|
|
35
|
+
ids = categories.map(&:id) + [profile.requirements.last.id]
|
|
36
|
+
expect(ids).to eq(%w[svg2.secure_static.script svg2.secure_static.animation
|
|
37
|
+
svg2.secure_static.interaction
|
|
38
|
+
svg2.secure_static.external_reference])
|
|
39
|
+
expect(categories.map(&:citation).uniq).to eq([citation])
|
|
40
|
+
expect(profile.requirements.last.citation_uri).to eq(citation)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "uses the unchanged Card 03 requirement class and shape" do
|
|
44
|
+
expect(profile.requirements.first)
|
|
45
|
+
.to be_a(SvgConform::Requirements::ProcessingFeaturesRequirement)
|
|
46
|
+
expect(profile.requirements.first.categories.first.class.attributes.keys)
|
|
47
|
+
.to eq(%i[name id citation description elements attributes
|
|
48
|
+
attribute_patterns])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Dir.glob("spec/fixtures/profiles/svg_2_secure_static/valid/*.svg").each do |path|
|
|
52
|
+
it "accepts #{File.basename(path)} through DOM and SAX" do
|
|
53
|
+
expect(dom_report(path).errors).to be_empty
|
|
54
|
+
expect(sax_report(path).errors).to be_empty
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
"script" => "svg2.secure_static.script",
|
|
60
|
+
"animation" => "svg2.secure_static.animation",
|
|
61
|
+
"interaction" => "svg2.secure_static.interaction",
|
|
62
|
+
}.each do |name, id|
|
|
63
|
+
it "rejects #{name}.svg only with #{id}, matching DOM and SAX" do
|
|
64
|
+
path = "#{root}/invalid/#{name}.svg"
|
|
65
|
+
expect(dom_report(path).errors.map(&:requirement_id).uniq).to eq([id])
|
|
66
|
+
expect(summary(sax_report(path))).to eq(summary(dom_report(path)))
|
|
67
|
+
expect(summary(sax_report(path)).first.last).to eq(citation)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.external_fixtures
|
|
72
|
+
Dir.glob("spec/fixtures/profiles/svg_2_secure_static/invalid/*.svg")
|
|
73
|
+
.map { |f| File.basename(f, ".svg") } - %w[script animation interaction]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "enumerates the external-reference corpus" do
|
|
77
|
+
expect(self.class.external_fixtures.size).to eq(18)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
external_fixtures.each do |name|
|
|
81
|
+
it "rejects #{name}.svg with external_reference, matching DOM and SAX" do
|
|
82
|
+
path = "#{root}/invalid/#{name}.svg"
|
|
83
|
+
expect(dom_report(path).errors.map(&:requirement_id).uniq)
|
|
84
|
+
.to eq(["svg2.secure_static.external_reference"])
|
|
85
|
+
expect(summary(sax_report(path))).to eq(summary(dom_report(path)))
|
|
86
|
+
expect(dom_report(path).errors.first.data[:citation]).to eq(citation)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "emits a LensCore report with the profile citation" do
|
|
91
|
+
report = SvgConform::Diagnostics::LensCoreAdapter.call(
|
|
92
|
+
sax_report("#{root}/invalid/http.svg"),
|
|
93
|
+
)
|
|
94
|
+
expect(report.valid).to be false
|
|
95
|
+
expect(report.profile.id).to eq("svg_2_secure_static")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def inline_ids(body)
|
|
99
|
+
xml = %(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">#{body}</svg>)
|
|
100
|
+
dom = profile.validate(SvgConform::Document.from_content(xml)).errors
|
|
101
|
+
sax = SvgConform::SaxDocument.from_content(xml)
|
|
102
|
+
.validate_with_profile(profile).errors
|
|
103
|
+
expect(sax.map(&:requirement_id)).to eq(dom.map(&:requirement_id))
|
|
104
|
+
dom.map(&:requirement_id)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
{
|
|
108
|
+
"unlisted pointer handler" => ['<rect onpointerdown="x()"/>',
|
|
109
|
+
%w[svg2.secure_static.interaction]],
|
|
110
|
+
"unlisted drag handler" => ['<rect ondragstart="x()"/>',
|
|
111
|
+
%w[svg2.secure_static.interaction]],
|
|
112
|
+
"listed load handler keeps script id" => ['<rect onload="x()"/>',
|
|
113
|
+
%w[svg2.secure_static.script]],
|
|
114
|
+
"listed click handler is reported once" => ['<rect onclick="x()"/>',
|
|
115
|
+
%w[svg2.secure_static.interaction]],
|
|
116
|
+
"listed begin handler keeps animation id" => ['<rect onbegin="x()"/>',
|
|
117
|
+
%w[svg2.secure_static.animation]],
|
|
118
|
+
"style attribute animation" => ['<rect style="animation: pulse 1s infinite"/>',
|
|
119
|
+
%w[svg2.secure_static.animation]],
|
|
120
|
+
"style attribute vendor transition" => ['<rect style="fill:red;-webkit-transition: all 1s"/>',
|
|
121
|
+
%w[svg2.secure_static.animation]],
|
|
122
|
+
"style attribute escaped animation" => ['<rect style="\\61nimation-name: pulse"/>',
|
|
123
|
+
%w[svg2.secure_static.animation]],
|
|
124
|
+
"style element animation" => ["<style>rect { animation: pulse 1s }</style>",
|
|
125
|
+
%w[svg2.secure_static.animation]],
|
|
126
|
+
"style element keyframes" => ["<style>@keyframes pulse { from { opacity: 0 } }</style>",
|
|
127
|
+
%w[svg2.secure_static.animation]],
|
|
128
|
+
"style element nested media animation" => ["<style>@media print { a { transition: all 1s } }</style>",
|
|
129
|
+
%w[svg2.secure_static.animation]],
|
|
130
|
+
"static css is accepted" => ['<style>rect { fill: red } a:hover { fill: blue }</style><rect style="fill: red"/>', []],
|
|
131
|
+
"animation word in selector or value is accepted" => ['<style>.animation { fill: red }</style><rect style="font-family: animation"/>', []],
|
|
132
|
+
}.each do |label, (body, expected)|
|
|
133
|
+
it "#{label}: #{expected.inspect} through DOM and SAX" do
|
|
134
|
+
expect(inline_ids(body)).to eq(expected)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "reports external CSS references hidden by escapes through DOM and SAX" do
|
|
139
|
+
bodies = [
|
|
140
|
+
'<rect style="fill:u\\72l(https://example.com/x.png)"/>',
|
|
141
|
+
'<rect style="fill:\\75rl(https://example.com/x.png)"/>',
|
|
142
|
+
'<style>@\\69mport "https://example.com/a.css";</style>',
|
|
143
|
+
'<style>@IMP\\4frt url(https://example.com/a.css);</style>',
|
|
144
|
+
]
|
|
145
|
+
bodies.each do |body|
|
|
146
|
+
expect(inline_ids(body)).to eq(["svg2.secure_static.external_reference"]),
|
|
147
|
+
body
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
|
|
6
|
+
RSpec.describe SvgConform::Profiles do
|
|
7
|
+
let(:validator) { SvgConform::Validator.new(mode: :sax) }
|
|
8
|
+
let(:profile) { described_class.get(:metanorma) }
|
|
9
|
+
let(:plantuml_attributes) { %w[contentstyletype zoomandpan title textlength lengthadjust] }
|
|
10
|
+
let(:svg) do
|
|
11
|
+
<<~SVG
|
|
12
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
13
|
+
version="1.2" baseProfile="tiny" viewBox="0 0 120 30"
|
|
14
|
+
contentStyleType="text/css" zoomAndPan="magnify">
|
|
15
|
+
<a xlink:href="https://example.test/UnitType" title="UnitType">
|
|
16
|
+
<text id="label" x="0" y="12" textLength="100" lengthAdjust="spacing">UnitType</text>
|
|
17
|
+
</a>
|
|
18
|
+
</svg>
|
|
19
|
+
SVG
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "accepts the five explicit layout and link attributes" do
|
|
23
|
+
result = validator.validate(svg, profile: profile)
|
|
24
|
+
expect(result.errors.map(&:message)).to be_empty
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
%w[svg text a].each do |element|
|
|
28
|
+
it "still rejects unrelated attributes on #{element}" do
|
|
29
|
+
invalid = svg.sub("<#{element} ", "<#{element} unsupported-plantuml-setting=\"no\" ")
|
|
30
|
+
result = validator.validate(invalid, profile: profile)
|
|
31
|
+
expect(result.errors.map(&:message).join).to include("unsupported-plantuml-setting")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
{ "svg" => "textLength", "text" => "zoomAndPan", "a" => "lengthAdjust" }.each do |element, attribute|
|
|
36
|
+
it "rejects #{attribute} on #{element}" do
|
|
37
|
+
invalid = svg.sub("<#{element} ", "<#{element} #{attribute}=\"1\" ")
|
|
38
|
+
result = validator.validate(invalid, profile: profile)
|
|
39
|
+
expect(result.errors.map(&:message).join.downcase).to include("attribute '#{attribute.downcase}' is not allowed")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
%i[svg_1_2_rfc svg_1_2_rfc_with_rdf].each do |strict_profile|
|
|
44
|
+
it "keeps #{strict_profile} attribute restrictions unchanged" do
|
|
45
|
+
result = validator.validate(svg, profile: strict_profile)
|
|
46
|
+
messages = result.errors.map(&:message).join.downcase
|
|
47
|
+
plantuml_attributes.each do |attribute|
|
|
48
|
+
expect(messages).to include("attribute '#{attribute}' is not allowed")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "preserves native PlantUML text measurements and links during remediation" do
|
|
54
|
+
source = File.read("spec/fixtures/plantuml/unit-type.svg")
|
|
55
|
+
document = SvgConform::Document.from_content(source)
|
|
56
|
+
result = validator.validate(source, profile: profile)
|
|
57
|
+
SvgConform::RemediationEngine.new(profile).apply_remediations(document, result)
|
|
58
|
+
output = document.root.to_xml
|
|
59
|
+
errors = validator.validate(output, profile: profile).errors
|
|
60
|
+
.reject { |error| error.violation_type == :reference_violation }
|
|
61
|
+
|
|
62
|
+
# The fixture's fragment targets belong to the surrounding HTML document.
|
|
63
|
+
expect(errors.map(&:message)).to be_empty
|
|
64
|
+
expect(text_layout(output)).to eq(text_layout(source))
|
|
65
|
+
expect(text_layout(output).size).to eq(28)
|
|
66
|
+
expect(diagram_links(output)).to eq(diagram_links(source))
|
|
67
|
+
expect(diagram_links(output).size).to eq(12)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def text_layout(xml)
|
|
71
|
+
Nokogiri::XML(xml).xpath("//svg:text", "svg" => "http://www.w3.org/2000/svg").map do |node|
|
|
72
|
+
[node.text, node["x"], node["y"], node["textLength"], node["lengthAdjust"]]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def diagram_links(xml)
|
|
77
|
+
Nokogiri::XML(xml).xpath("//svg:a", "svg" => "http://www.w3.org/2000/svg").map do |node|
|
|
78
|
+
[node["title"], node.attribute_with_ns("href", "http://www.w3.org/1999/xlink")&.value]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "zlib"
|
|
6
|
+
|
|
7
|
+
RSpec.describe SvgConform::References::DataUriReference do
|
|
8
|
+
def ref(value)
|
|
9
|
+
SvgConform::References::ReferenceClassifier.classify(
|
|
10
|
+
value, element_name: "image", attribute_name: "href"
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def chunk(type, data)
|
|
15
|
+
[data.bytesize].pack("N") + type + data + [Zlib.crc32(type + data)].pack("N")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
let(:png) do
|
|
19
|
+
"\x89PNG\r\n\x1A\n".b +
|
|
20
|
+
chunk("IHDR", [1, 1, 8, 2, 0, 0, 0].pack("NNCCCCC")) +
|
|
21
|
+
chunk("IDAT", Zlib::Deflate.deflate("\x00\xFF\x00\x00".b)) +
|
|
22
|
+
chunk("IEND", "")
|
|
23
|
+
end
|
|
24
|
+
let(:jpeg) { "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xFF\xD9".b }
|
|
25
|
+
|
|
26
|
+
it "decodes base64 and percent-encoded payloads to the same bytes" do
|
|
27
|
+
pct = png.each_byte.map { |b| format("%%%02X", b) }.join
|
|
28
|
+
expect(ref("data:image/png;base64,#{Base64.strict_encode64(png)}").payload)
|
|
29
|
+
.to eq(png)
|
|
30
|
+
expect(ref("data:image/png,#{pct}").payload).to eq(png)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "reports base64? and a normalized media type" do
|
|
34
|
+
r = ref("data:IMAGE/PNG;base64,AAAA")
|
|
35
|
+
expect(r.base64?).to be true
|
|
36
|
+
expect(r.normalized_media_type).to eq("image/png")
|
|
37
|
+
expect(ref("data:,hello").normalized_media_type).to eq("text/plain")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "accepts well-formed PNG and JPEG payloads" do
|
|
41
|
+
expect(ref("data:image/png;base64,#{Base64.strict_encode64(png)}")
|
|
42
|
+
.raster_error).to be_nil
|
|
43
|
+
expect(ref("data:image/jpeg;base64,#{Base64.strict_encode64(jpeg)}")
|
|
44
|
+
.raster_error).to be_nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
"data:image/png;base64," => /empty/,
|
|
49
|
+
"data:image/png;base64,!!!" => /malformed base64/,
|
|
50
|
+
"data:image/png,%G1" => /percent/,
|
|
51
|
+
"data:image/png" => /separator/,
|
|
52
|
+
"data:image/png;base64,bm90IGEgcG5n" => /not a PNG/,
|
|
53
|
+
"data:image/jpeg;base64,bm90IGEganBn" => /not a JPEG/,
|
|
54
|
+
}.each do |value, pattern|
|
|
55
|
+
it "rejects #{value}" do
|
|
56
|
+
expect(ref(value).raster_error).to match(pattern)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
"APP0 declaring more bytes than present" => "\xFF\xD8\xFF\xE0\x00\x10\xFF\xD9",
|
|
62
|
+
"segment length below two" => "\xFF\xD8\xFF\xE0\x00\x01\xFF\xD9",
|
|
63
|
+
"marker without length" => "\xFF\xD8\xFF",
|
|
64
|
+
"stray non-marker byte" => "\xFF\xD8\xFF\xE0\x00\x02\x41\xFF\xD9",
|
|
65
|
+
"end marker inside the data before the end" => "\xFF\xD8\xFF\xD9\x00\x00",
|
|
66
|
+
"no end of image" => "\xFF\xD8\xFF\xE0\x00\x02",
|
|
67
|
+
"SOS scan without end of image" => "\xFF\xD8\xFF\xDA\x00\x02\x01\x02\x03",
|
|
68
|
+
}.each do |label, bytes|
|
|
69
|
+
it "rejects a malformed JPEG (#{label}) in base64 and percent forms" do
|
|
70
|
+
raw = bytes.b
|
|
71
|
+
pct = raw.each_byte.map { |b| format("%%%02X", b) }.join
|
|
72
|
+
expect(ref("data:image/jpeg;base64,#{Base64.strict_encode64(raw)}").raster_error)
|
|
73
|
+
.to include("JPEG")
|
|
74
|
+
expect(ref("data:image/jpeg,#{pct}").raster_error).to include("JPEG")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "accepts a JPEG with a scan, byte stuffing and restart markers" do
|
|
79
|
+
scan = "\xFF\xD8\xFF\xE0\x00\x04\x00\x00\xFF\xDA\x00\x04\x01\x02\x11\xFF\x00\x22\xFF\xD0\x33\xFF\xD9".b
|
|
80
|
+
expect(ref("data:image/jpeg;base64,#{Base64.strict_encode64(scan)}").raster_error)
|
|
81
|
+
.to be_nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "detects PNG checksum corruption and truncation" do
|
|
85
|
+
bad = png.dup
|
|
86
|
+
bad.setbyte(20, bad.getbyte(20) ^ 0xFF)
|
|
87
|
+
expect(ref("data:image/png;base64,#{Base64.strict_encode64(bad)}")
|
|
88
|
+
.raster_error).to match(/checksum|IHDR/)
|
|
89
|
+
cut = png.byteslice(0, png.bytesize - 6)
|
|
90
|
+
expect(ref("data:image/png;base64,#{Base64.strict_encode64(cut)}")
|
|
91
|
+
.raster_error).not_to be_nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "stays internal in validation_scope" do
|
|
95
|
+
expect(ref("data:image/png,x").validation_scope).to eq(:internal)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -203,4 +203,34 @@ RSpec.describe "Reference Manifest Integration" do
|
|
|
203
203
|
expect(manifest_data).to have_key(:statistics)
|
|
204
204
|
end
|
|
205
205
|
end
|
|
206
|
+
|
|
207
|
+
describe "secure-static profile shares the classifier" do
|
|
208
|
+
it "registers the same reference types the link requirement sees" do
|
|
209
|
+
svg = <<~SVG
|
|
210
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">
|
|
211
|
+
<rect id="a" width="1" height="1"/>
|
|
212
|
+
<use href="#a"/>
|
|
213
|
+
<use href="other.svg#a"/>
|
|
214
|
+
</svg>
|
|
215
|
+
SVG
|
|
216
|
+
profile = SvgConform::Profile.new(
|
|
217
|
+
name: "both",
|
|
218
|
+
requirements: [
|
|
219
|
+
SvgConform::Requirements::LinkValidationRequirement.new(id: "link"),
|
|
220
|
+
SvgConform::Requirements::ExternalReferencesRequirement.new(
|
|
221
|
+
id: "svg2.secure_static.external_reference",
|
|
222
|
+
),
|
|
223
|
+
],
|
|
224
|
+
)
|
|
225
|
+
result = SvgConform::SaxDocument.from_content(svg)
|
|
226
|
+
.validate_with_profile(profile)
|
|
227
|
+
manifest = result.reference_manifest.to_h
|
|
228
|
+
types = (manifest[:internal_references] + manifest[:external_references])
|
|
229
|
+
.map { |r| r[:type] }
|
|
230
|
+
expect(types).to contain_exactly("InternalFragmentReference",
|
|
231
|
+
"RelativePathReference")
|
|
232
|
+
expect(result.errors.map(&:requirement_id))
|
|
233
|
+
.to include("svg2.secure_static.external_reference")
|
|
234
|
+
end
|
|
235
|
+
end
|
|
206
236
|
end
|
|
@@ -138,5 +138,22 @@ RSpec.describe SvgConform::References::ReferenceClassifier do
|
|
|
138
138
|
expect(ref.line_number).to eq(42)
|
|
139
139
|
expect(ref.column_number).to eq(15)
|
|
140
140
|
end
|
|
141
|
+
|
|
142
|
+
it "keeps typed returns and the classify signature for secure-static inputs" do
|
|
143
|
+
expect(described_class.method(:classify).parameters).to eq(
|
|
144
|
+
[%i[req href_value], %i[keyreq element_name], %i[keyreq attribute_name],
|
|
145
|
+
%i[key line_number], %i[key column_number]],
|
|
146
|
+
)
|
|
147
|
+
{
|
|
148
|
+
"//example.com/a.png" => SvgConform::References::RelativePathReference,
|
|
149
|
+
"other.svg#id" => SvgConform::References::RelativePathReference,
|
|
150
|
+
"file:///etc/passwd#id" => SvgConform::References::RelativePathReference,
|
|
151
|
+
"DATA:image/png,x" => SvgConform::References::DataUriReference,
|
|
152
|
+
}.each do |value, klass|
|
|
153
|
+
ref = described_class.classify(value, element_name: "use",
|
|
154
|
+
attribute_name: "href")
|
|
155
|
+
expect(ref).to be_a(klass), value
|
|
156
|
+
end
|
|
157
|
+
end
|
|
141
158
|
end
|
|
142
159
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
RSpec.describe SvgConform::Requirements::ExternalReferencesRequirement do
|
|
7
|
+
let(:requirement) do
|
|
8
|
+
described_class.new(id: "ext", citation_uri: "https://example.test/cite")
|
|
9
|
+
end
|
|
10
|
+
let(:profile) { SvgConform::Profile.new(name: "x", requirements: [requirement]) }
|
|
11
|
+
let(:ns) { 'xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"' }
|
|
12
|
+
|
|
13
|
+
def svg(body, pre: "")
|
|
14
|
+
%(#{pre}<svg #{ns} viewBox="0 0 1 1">#{body}</svg>)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def dom(xml)
|
|
18
|
+
profile.validate(SvgConform::Document.from_content(xml))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def sax(xml)
|
|
22
|
+
SvgConform::SaxDocument.from_content(xml).validate_with_profile(profile)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def b64_svg(inner)
|
|
26
|
+
"data:image/svg+xml;base64,#{Base64.strict_encode64(inner)}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
"same-document href" => ['<use href="#a"/>', 0],
|
|
31
|
+
"same-document xlink:href" => ['<use xlink:href="#a"/>', 0],
|
|
32
|
+
"http href" => ['<image href="http://e.test/a.png"/>', 1],
|
|
33
|
+
"xlink http href" => ['<image xlink:href="https://e.test/a.png"/>', 1],
|
|
34
|
+
"anchor to web" => ['<a href="https://e.test/">x</a>', 1],
|
|
35
|
+
"presentation url" => ['<rect fill="url(other.svg#g)"/>', 1],
|
|
36
|
+
"presentation fragment" => ['<rect fill="url(#g)"/>', 0],
|
|
37
|
+
"style attribute url" => ['<rect style="fill:url("x.svg#g")"/>', 1],
|
|
38
|
+
"style element import string" => ['<style>@import "a.css";</style>', 1],
|
|
39
|
+
"style element import url counted once" => ["<style>@import url(a.css);</style>", 1],
|
|
40
|
+
"style element url" => ["<style>a{fill:url(a.svg#x)}</style>", 1],
|
|
41
|
+
"css comment ignored" => ["<style>/* url(a.svg) */ a{fill:red}</style>", 0],
|
|
42
|
+
"escaped url function" => ['<rect style="fill:u\\72l(https://e.test/x.png)"/>', 1],
|
|
43
|
+
"escaped url function uppercase hex" => ['<rect style="fill:\\55RL(https://e.test/x.png)"/>', 1],
|
|
44
|
+
"escaped url in presentation attribute" => ['<rect fill="u\\72l(https://e.test/x.png)"/>', 1],
|
|
45
|
+
"escaped url with quoted string" => ['<rect style="fill:u\\72l(\'https://e.test/x.png\')"/>', 1],
|
|
46
|
+
"escaped characters inside the url" => ['<rect style="fill:url(\\68ttps://e.test/x.png)"/>', 1],
|
|
47
|
+
"escaped import string" => ['<style>@\\69mport "a.css";</style>', 1],
|
|
48
|
+
"escaped import url" => ['<style>@imp\\6frt url(a.css);</style>', 1],
|
|
49
|
+
"escaped import with escaped url" => ['<style>@\\69mport u\\72l(a.css);</style>', 1],
|
|
50
|
+
"url text inside a css string is not a reference" => ['<style>a{content:"url(a.svg)"}</style>', 0],
|
|
51
|
+
"escaped url with same-document fragment" => ['<rect style="fill:u\\72l(#g)"/>', 0],
|
|
52
|
+
"javascript scheme" => ['<a href="javascript:x()">x</a>', 1],
|
|
53
|
+
}.each do |label, (body, count)|
|
|
54
|
+
it "#{label}: #{count} finding(s) through DOM and SAX" do
|
|
55
|
+
xml = svg(body)
|
|
56
|
+
expect(dom(xml).errors.size).to eq(count)
|
|
57
|
+
expect(sax(xml).errors.map(&:message)).to eq(dom(xml).errors.map(&:message))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "reports xml-stylesheet processing instructions from the source" do
|
|
62
|
+
xml = svg("", pre: %(<?xml-stylesheet href="a.css"?>))
|
|
63
|
+
expect(dom(xml).errors.size).to eq(1)
|
|
64
|
+
expect(sax(xml).errors.size).to eq(1)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "uses the requirement id and citation in error data" do
|
|
68
|
+
error = sax(svg('<image href="//e.test/a.png"/>')).errors.first
|
|
69
|
+
expect(error.requirement_id).to eq("ext")
|
|
70
|
+
expect(error.data).to include(citation: "https://example.test/cite",
|
|
71
|
+
reference_type: "RelativePathReference")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
describe "nested SVG data URLs" do
|
|
75
|
+
let(:plain) { '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>' }
|
|
76
|
+
|
|
77
|
+
it "accepts a self-contained SVG" do
|
|
78
|
+
expect(sax(svg(%(<image href="#{b64_svg(plain)}"/>))).errors).to be_empty
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "rejects malformed XML, non-svg roots and external entities" do
|
|
82
|
+
["<svg><g></svg>", "<html/>",
|
|
83
|
+
%(<!DOCTYPE svg SYSTEM "http://e.test/x.dtd"><svg/>)].each do |bad|
|
|
84
|
+
errors = sax(svg(%(<image href="#{b64_svg(bad)}"/>))).errors
|
|
85
|
+
expect(errors.size).to eq(1), bad
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "reports nested external content with a JSON-safe reason" do
|
|
90
|
+
inner = '<svg xmlns="http://www.w3.org/2000/svg"><image href="http://e.test/a"/></svg>'
|
|
91
|
+
error = dom(svg(%(<image href="#{b64_svg(inner)}"/>))).errors.first
|
|
92
|
+
expect(error.data[:reason]).to match(/nested href 'http:\/\/e.test\/a'/)
|
|
93
|
+
expect { JSON.generate(error.data) }.not_to raise_error
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "limits nesting depth" do
|
|
97
|
+
nest = plain
|
|
98
|
+
4.times do
|
|
99
|
+
nest = %(<svg xmlns="http://www.w3.org/2000/svg"><image href="#{b64_svg(nest)}"/></svg>)
|
|
100
|
+
end
|
|
101
|
+
error = sax(svg(%(<image href="#{b64_svg(nest)}"/>))).errors.first
|
|
102
|
+
expect(error.data[:reason]).to include("nesting exceeds 3")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "ignores empty references like the classifier does" do
|
|
107
|
+
expect(sax(svg('<image href=""/>')).errors).to be_empty
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -74,4 +74,20 @@ RSpec.describe SvgConform::Requirements::LinkValidationRequirement do
|
|
|
74
74
|
expect(requirement).to be_a(described_class)
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
|
+
|
|
78
|
+
describe "classifier sharing with ExternalReferencesRequirement" do
|
|
79
|
+
it "still reports a missing fragment target while external-reference allows the fragment" do
|
|
80
|
+
svg = '<svg xmlns="http://www.w3.org/2000/svg"><use href="#missing"/></svg>'
|
|
81
|
+
profile = SvgConform::Profile.new(
|
|
82
|
+
name: "p",
|
|
83
|
+
requirements: [
|
|
84
|
+
described_class.new(id: "link"),
|
|
85
|
+
SvgConform::Requirements::ExternalReferencesRequirement.new(id: "ext"),
|
|
86
|
+
],
|
|
87
|
+
)
|
|
88
|
+
ids = SvgConform::SaxDocument.from_content(svg)
|
|
89
|
+
.validate_with_profile(profile).errors.map(&:requirement_id)
|
|
90
|
+
expect(ids).not_to include("ext")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
77
93
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
RSpec.describe SvgConform::Requirements::ProcessingFeaturesRequirement do
|
|
7
|
+
let(:config_path) { "config/requirements/processing_features_static.yml" }
|
|
8
|
+
let(:requirement) { described_class.from_yaml(File.read(config_path)) }
|
|
9
|
+
let(:profile) do
|
|
10
|
+
SvgConform::Profile.new(name: "pf", requirements: [requirement])
|
|
11
|
+
end
|
|
12
|
+
let(:svg_ns) { 'xmlns="http://www.w3.org/2000/svg"' }
|
|
13
|
+
|
|
14
|
+
def svg(body)
|
|
15
|
+
%(<svg #{svg_ns} version="1.1" viewBox="0 0 10 10">#{body}</svg>)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def dom_ids(xml)
|
|
19
|
+
profile.validate(SvgConform::Document.from_content(xml))
|
|
20
|
+
.errors.map(&:requirement_id).sort
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def sax_ids(xml)
|
|
24
|
+
SvgConform::SaxDocument.from_content(xml).validate_with_profile(profile)
|
|
25
|
+
.errors.map(&:requirement_id).sort
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "loads the three categories from YAML with separate ids and citations" do
|
|
29
|
+
expect(requirement.categories.map(&:name))
|
|
30
|
+
.to eq(%w[script animation interaction])
|
|
31
|
+
expect(requirement.categories.map(&:id)).to eq(
|
|
32
|
+
%w[svg11.processing.script svg11.processing.animation
|
|
33
|
+
svg11.processing.interaction],
|
|
34
|
+
)
|
|
35
|
+
expect(requirement.categories.map(&:citation).uniq.size).to eq(1)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "resolves through a profile YAML via the requirement class map" do
|
|
39
|
+
entry = File.read(config_path).lines.each_with_index.map do |l, i|
|
|
40
|
+
i.zero? ? " - #{l}" : " #{l}"
|
|
41
|
+
end.join
|
|
42
|
+
yaml = "name: x\nrequirements:\n#{entry}"
|
|
43
|
+
loaded = SvgConform::Profile.from_yaml(yaml)
|
|
44
|
+
expect(loaded.requirements.first).to be_a(described_class)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "is registered in Requirements.all" do
|
|
48
|
+
expect(SvgConform::Requirements.all).to include(described_class)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
"script element" => ["<script>x()</script>", %w[svg11.processing.script]],
|
|
53
|
+
"load handler" => ['<rect onload="x()" width="1" height="1"/>',
|
|
54
|
+
%w[svg11.processing.script]],
|
|
55
|
+
"animate element" => ['<rect width="1" height="1"><animate attributeName="x" dur="1s"/></rect>',
|
|
56
|
+
%w[svg11.processing.animation svg11.processing.animation]],
|
|
57
|
+
"click handler" => ['<rect onclick="x()" width="1" height="1"/>',
|
|
58
|
+
%w[svg11.processing.interaction]],
|
|
59
|
+
"cursor attribute" => ['<rect cursor="pointer" width="1" height="1"/>',
|
|
60
|
+
%w[svg11.processing.interaction]],
|
|
61
|
+
"clean static" => ['<rect width="1" height="1"/>', []],
|
|
62
|
+
}.each do |label, (body, expected)|
|
|
63
|
+
it "reports #{label} identically through DOM and SAX" do
|
|
64
|
+
xml = svg(body)
|
|
65
|
+
expect(dom_ids(xml)).to eq(expected)
|
|
66
|
+
expect(sax_ids(xml)).to eq(expected)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "attaches the category citation to the error" do
|
|
71
|
+
error = SvgConform::SaxDocument.from_content(svg("<script/>"))
|
|
72
|
+
.validate_with_profile(profile).errors.first
|
|
73
|
+
expect(error.data[:citation]).to eq(requirement.categories.first.citation)
|
|
74
|
+
end
|
|
75
|
+
end
|