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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.github/workflows/rake.yml +3 -0
  4. data/Gemfile +7 -3
  5. data/Gemfile.lock +272 -0
  6. data/TODO.issue-100/00-overview.md +135 -0
  7. data/TODO.issue-100/01-build-lens-core-v1.md +92 -0
  8. data/TODO.issue-100/02-adopt-lens-core-in-svg.md +123 -0
  9. data/TODO.issue-100/03-ship-svg-1-1-full-static.md +138 -0
  10. data/TODO.issue-100/04-ship-svg-2-secure-static.md +131 -0
  11. data/TODO.issue-100/05-freeze-candidate-and-handoff.md +108 -0
  12. data/TODO.issue-100/90-progress.md +65 -0
  13. data/config/profiles/metanorma.yml +3 -3
  14. data/config/profiles/svg_1_1_full_static.yml +69 -0
  15. data/config/profiles/svg_2_secure_static.yml +55 -0
  16. data/config/requirements/processing_features_static.yml +29 -0
  17. data/data/svg_1_1/manifest.yml +7 -0
  18. data/data/svg_1_1/svg11-flat-20110816.dtd +5664 -0
  19. data/docs/api_reference.adoc +19 -1
  20. data/docs/cli_guide.adoc +35 -45
  21. data/docs/diagnostics.adoc +73 -0
  22. data/docs/lens_core_handoff.adoc +84 -0
  23. data/docs/profiles.adoc +94 -1
  24. data/lib/svg_conform/cli.rb +3 -3
  25. data/lib/svg_conform/commands/check.rb +11 -60
  26. data/lib/svg_conform/conformance_report.rb +79 -1
  27. data/lib/svg_conform/diagnostics/lens_core_adapter.rb +200 -0
  28. data/lib/svg_conform/document.rb +12 -1
  29. data/lib/svg_conform/node_helpers.rb +1 -1
  30. data/lib/svg_conform/profile.rb +11 -2
  31. data/lib/svg_conform/quality_metrics/complexity_calculator.rb +3 -3
  32. data/lib/svg_conform/references/base_reference.rb +153 -0
  33. data/lib/svg_conform/report_comparator.rb +4 -4
  34. data/lib/svg_conform/requirements/base_requirement.rb +6 -0
  35. data/lib/svg_conform/requirements/external_references_requirement.rb +234 -0
  36. data/lib/svg_conform/requirements/link_validation_requirement.rb +2 -0
  37. data/lib/svg_conform/requirements/processing_features_requirement.rb +175 -0
  38. data/lib/svg_conform/requirements/svg_1_1_document_requirement.rb +213 -0
  39. data/lib/svg_conform/requirements.rb +6 -0
  40. data/lib/svg_conform/sax_document.rb +8 -4
  41. data/lib/svg_conform/sax_validation_handler.rb +22 -1
  42. data/lib/svg_conform/validation/css2_core_grammar.rb +359 -0
  43. data/lib/svg_conform/validation/css_references.rb +122 -0
  44. data/lib/svg_conform/validation/error_tracker.rb +3 -1
  45. data/lib/svg_conform/validation/svg_1_1_document_conformance.rb +155 -0
  46. data/lib/svg_conform/validation_context.rb +25 -5
  47. data/lib/svg_conform/validation_result.rb +91 -16
  48. data/lib/svg_conform/version.rb +1 -1
  49. data/lib/svg_conform.rb +24 -0
  50. data/spec/fixtures/diagnostics/external-reference-info.svg +6 -0
  51. data/spec/fixtures/plantuml/README.adoc +11 -0
  52. data/spec/fixtures/plantuml/unit-type.svg +1 -0
  53. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/animation.svg +4 -0
  54. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/css-grammar.svg +4 -0
  55. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-attribute.svg +4 -0
  56. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-content-model.svg +4 -0
  57. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/interaction.svg +4 -0
  58. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/malformed-xml.svg +4 -0
  59. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/script.svg +5 -0
  60. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-namespace.svg +4 -0
  61. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-version.svg +4 -0
  62. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/xml-stylesheet.svg +5 -0
  63. data/spec/fixtures/profiles/svg_1_1_full_static/valid/css.svg +5 -0
  64. data/spec/fixtures/profiles/svg_1_1_full_static/valid/foreign-metadata.svg +6 -0
  65. data/spec/fixtures/profiles/svg_1_1_full_static/valid/minimal-full.svg +5 -0
  66. data/spec/fixtures/profiles/svg_1_1_full_static/valid/xml-stylesheet.svg +5 -0
  67. data/spec/fixtures/profiles/svg_2_secure_static/invalid/animation.svg +4 -0
  68. data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-import.svg +4 -0
  69. data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-url.svg +4 -0
  70. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-bad-png.svg +4 -0
  71. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-empty-png.svg +4 -0
  72. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-base64.svg +4 -0
  73. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-percent.svg +4 -0
  74. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-unsupported-type.svg +4 -0
  75. data/spec/fixtures/profiles/svg_2_secure_static/invalid/file-like-fragment.svg +4 -0
  76. data/spec/fixtures/profiles/svg_2_secure_static/invalid/http.svg +4 -0
  77. data/spec/fixtures/profiles/svg_2_secure_static/invalid/https.svg +4 -0
  78. data/spec/fixtures/profiles/svg_2_secure_static/invalid/interaction.svg +4 -0
  79. data/spec/fixtures/profiles/svg_2_secure_static/invalid/protocol-relative.svg +4 -0
  80. data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file-fragment.svg +4 -0
  81. data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file.svg +4 -0
  82. data/spec/fixtures/profiles/svg_2_secure_static/invalid/script.svg +4 -0
  83. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-excessive-nesting.svg +4 -0
  84. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-malformed-xml.svg +4 -0
  85. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-nested-external.svg +4 -0
  86. data/spec/fixtures/profiles/svg_2_secure_static/invalid/urn.svg +4 -0
  87. data/spec/fixtures/profiles/svg_2_secure_static/invalid/xml-stylesheet.svg +5 -0
  88. data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-base64.svg +4 -0
  89. data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-percent.svg +4 -0
  90. data/spec/fixtures/profiles/svg_2_secure_static/valid/png-base64.svg +4 -0
  91. data/spec/fixtures/profiles/svg_2_secure_static/valid/png-percent.svg +4 -0
  92. data/spec/fixtures/profiles/svg_2_secure_static/valid/same-document-fragment.svg +4 -0
  93. data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-base64.svg +4 -0
  94. data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-percent.svg +4 -0
  95. data/spec/svg_conform/commands/check_command_spec.rb +22 -2
  96. data/spec/svg_conform/commands/profiles_command_spec.rb +18 -0
  97. data/spec/svg_conform/conformance_report_spec.rb +90 -0
  98. data/spec/svg_conform/diagnostics/lens_core_adapter_spec.rb +136 -0
  99. data/spec/svg_conform/gemspec_spec.rb +21 -0
  100. data/spec/svg_conform/profiles/svg_1_1_full_static_profile_spec.rb +146 -0
  101. data/spec/svg_conform/profiles/svg_2_secure_static_profile_spec.rb +150 -0
  102. data/spec/svg_conform/profiles_plantuml_spec.rb +81 -0
  103. data/spec/svg_conform/references/data_uri_reference_spec.rb +97 -0
  104. data/spec/svg_conform/references/integration_spec.rb +30 -0
  105. data/spec/svg_conform/references/reference_classifier_spec.rb +17 -0
  106. data/spec/svg_conform/requirements/external_references_requirement_spec.rb +109 -0
  107. data/spec/svg_conform/requirements/link_validation_requirement_spec.rb +16 -0
  108. data/spec/svg_conform/requirements/processing_features_requirement_spec.rb +75 -0
  109. data/spec/svg_conform/requirements/svg_1_1_document_requirement_spec.rb +141 -0
  110. data/spec/svg_conform/validation/css2_core_grammar_spec.rb +116 -0
  111. data/spec/svg_conform/validation/svg_1_1_document_conformance_spec.rb +144 -0
  112. data/spec/svg_conform/validation_context_source_plumbing_spec.rb +122 -0
  113. data/spec/svg_conform/validation_context_spec.rb +39 -0
  114. data/spec/svg_conform/validation_result_spec.rb +123 -0
  115. data/svg_conform.gemspec +3 -2
  116. 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(&quot;x.svg#g&quot;)"/>', 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