svg_conform 0.2.4 → 0.2.7

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.github/workflows/rake.yml +4 -1
  4. data/Gemfile +6 -3
  5. data/Gemfile.lock +211 -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 +4 -4
  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 -4
  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/external_checkers/svgcheck/parser.rb +1 -3
  30. data/lib/svg_conform/node_helpers.rb +1 -1
  31. data/lib/svg_conform/profile.rb +11 -2
  32. data/lib/svg_conform/quality_metrics/complexity_calculator.rb +3 -3
  33. data/lib/svg_conform/quality_metrics/configuration.rb +4 -2
  34. data/lib/svg_conform/references/base_reference.rb +153 -0
  35. data/lib/svg_conform/references/reference_manifest.rb +1 -2
  36. data/lib/svg_conform/report_comparator.rb +4 -4
  37. data/lib/svg_conform/requirements/base_requirement.rb +6 -0
  38. data/lib/svg_conform/requirements/external_references_requirement.rb +234 -0
  39. data/lib/svg_conform/requirements/link_validation_requirement.rb +2 -0
  40. data/lib/svg_conform/requirements/processing_features_requirement.rb +175 -0
  41. data/lib/svg_conform/requirements/svg_1_1_document_requirement.rb +213 -0
  42. data/lib/svg_conform/requirements.rb +6 -0
  43. data/lib/svg_conform/sax_document.rb +8 -4
  44. data/lib/svg_conform/sax_validation_handler.rb +22 -1
  45. data/lib/svg_conform/validation/css2_core_grammar.rb +359 -0
  46. data/lib/svg_conform/validation/css_references.rb +122 -0
  47. data/lib/svg_conform/validation/error_tracker.rb +40 -9
  48. data/lib/svg_conform/validation/svg_1_1_document_conformance.rb +154 -0
  49. data/lib/svg_conform/validation_context.rb +25 -5
  50. data/lib/svg_conform/validation_result.rb +91 -16
  51. data/lib/svg_conform/version.rb +1 -1
  52. data/lib/svg_conform/yaml_io.rb +20 -0
  53. data/lib/svg_conform.rb +25 -0
  54. data/spec/fixtures/diagnostics/external-reference-info.svg +6 -0
  55. data/spec/fixtures/plantuml/README.adoc +11 -0
  56. data/spec/fixtures/plantuml/unit-type.svg +1 -0
  57. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/animation.svg +4 -0
  58. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/css-grammar.svg +4 -0
  59. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-attribute.svg +4 -0
  60. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-content-model.svg +4 -0
  61. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/interaction.svg +4 -0
  62. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/malformed-xml.svg +4 -0
  63. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/script.svg +5 -0
  64. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-namespace.svg +4 -0
  65. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-version.svg +4 -0
  66. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/xml-stylesheet.svg +5 -0
  67. data/spec/fixtures/profiles/svg_1_1_full_static/valid/css.svg +5 -0
  68. data/spec/fixtures/profiles/svg_1_1_full_static/valid/foreign-metadata.svg +6 -0
  69. data/spec/fixtures/profiles/svg_1_1_full_static/valid/minimal-full.svg +5 -0
  70. data/spec/fixtures/profiles/svg_1_1_full_static/valid/xml-stylesheet.svg +5 -0
  71. data/spec/fixtures/profiles/svg_2_secure_static/invalid/animation.svg +4 -0
  72. data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-import.svg +4 -0
  73. data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-url.svg +4 -0
  74. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-bad-png.svg +4 -0
  75. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-empty-png.svg +4 -0
  76. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-base64.svg +4 -0
  77. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-percent.svg +4 -0
  78. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-unsupported-type.svg +4 -0
  79. data/spec/fixtures/profiles/svg_2_secure_static/invalid/file-like-fragment.svg +4 -0
  80. data/spec/fixtures/profiles/svg_2_secure_static/invalid/http.svg +4 -0
  81. data/spec/fixtures/profiles/svg_2_secure_static/invalid/https.svg +4 -0
  82. data/spec/fixtures/profiles/svg_2_secure_static/invalid/interaction.svg +4 -0
  83. data/spec/fixtures/profiles/svg_2_secure_static/invalid/protocol-relative.svg +4 -0
  84. data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file-fragment.svg +4 -0
  85. data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file.svg +4 -0
  86. data/spec/fixtures/profiles/svg_2_secure_static/invalid/script.svg +4 -0
  87. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-excessive-nesting.svg +4 -0
  88. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-malformed-xml.svg +4 -0
  89. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-nested-external.svg +4 -0
  90. data/spec/fixtures/profiles/svg_2_secure_static/invalid/urn.svg +4 -0
  91. data/spec/fixtures/profiles/svg_2_secure_static/invalid/xml-stylesheet.svg +5 -0
  92. data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-base64.svg +4 -0
  93. data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-percent.svg +4 -0
  94. data/spec/fixtures/profiles/svg_2_secure_static/valid/png-base64.svg +4 -0
  95. data/spec/fixtures/profiles/svg_2_secure_static/valid/png-percent.svg +4 -0
  96. data/spec/fixtures/profiles/svg_2_secure_static/valid/same-document-fragment.svg +4 -0
  97. data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-base64.svg +4 -0
  98. data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-percent.svg +4 -0
  99. data/spec/fixtures/quality_metrics/aliases.yml +7 -0
  100. data/spec/svg_conform/commands/check_command_spec.rb +22 -2
  101. data/spec/svg_conform/commands/profiles_command_spec.rb +18 -0
  102. data/spec/svg_conform/conformance_report_spec.rb +90 -0
  103. data/spec/svg_conform/diagnostics/lens_core_adapter_spec.rb +136 -0
  104. data/spec/svg_conform/gemspec_spec.rb +34 -0
  105. data/spec/svg_conform/profiles/metanorma_profile_spec.rb +48 -0
  106. data/spec/svg_conform/profiles/svg_1_1_full_static_profile_spec.rb +146 -0
  107. data/spec/svg_conform/profiles/svg_2_secure_static_profile_spec.rb +150 -0
  108. data/spec/svg_conform/profiles_plantuml_spec.rb +81 -0
  109. data/spec/svg_conform/quality_metrics/configuration_spec.rb +82 -0
  110. data/spec/svg_conform/references/data_uri_reference_spec.rb +97 -0
  111. data/spec/svg_conform/references/integration_spec.rb +30 -0
  112. data/spec/svg_conform/references/reference_classifier_spec.rb +17 -0
  113. data/spec/svg_conform/requirements/external_references_requirement_spec.rb +109 -0
  114. data/spec/svg_conform/requirements/link_validation_requirement_spec.rb +16 -0
  115. data/spec/svg_conform/requirements/processing_features_requirement_spec.rb +75 -0
  116. data/spec/svg_conform/requirements/svg_1_1_document_requirement_spec.rb +141 -0
  117. data/spec/svg_conform/requirements/text_as_path_requirement_spec.rb +9 -5
  118. data/spec/svg_conform/validation/css2_core_grammar_spec.rb +116 -0
  119. data/spec/svg_conform/validation/svg_1_1_document_conformance_spec.rb +144 -0
  120. data/spec/svg_conform/validation_context_source_plumbing_spec.rb +122 -0
  121. data/spec/svg_conform/validation_context_spec.rb +39 -0
  122. data/spec/svg_conform/validation_result_spec.rb +123 -0
  123. data/spec/svg_conform/yaml_io_spec.rb +41 -0
  124. data/svg_conform.gemspec +4 -2
  125. metadata +120 -6
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "svg_conform/profile_compiler"
5
+
6
+ # Path is fixed by the card; the cop expects svg11_ instead of svg_1_1_.
7
+ RSpec.describe "svg_1_1_full_static profile" do # rubocop:disable RSpec/DescribeClass
8
+ let(:profile) { SvgConform::Profiles.get("svg_1_1_full_static") }
9
+ let(:fixtures) { File.expand_path("../../fixtures/profiles/svg_1_1_full_static", __dir__) }
10
+ let(:matrix) do
11
+ {
12
+ "svg11.document.xml" => "https://www.w3.org/TR/2011/REC-SVG11-20110816/conform.html#ConformingSVGDocuments",
13
+ "svg11.document.root" => "https://www.w3.org/TR/2011/REC-SVG11-20110816/struct.html#SVGElement",
14
+ "svg11.document.version" => "https://www.w3.org/TR/2011/REC-SVG11-20110816/struct.html#SVGElementVersionAttribute",
15
+ "svg11.document.grammar" => "https://www.w3.org/TR/2011/REC-SVG11-20110816/conform.html#ConformingSVGDocuments",
16
+ "svg11.document.css2" => "https://www.w3.org/TR/2008/REC-CSS2-20080411/grammar.html",
17
+ "svg11.document.xml_stylesheet" => "https://www.w3.org/TR/1999/REC-xml-stylesheet-19990629/",
18
+ "svg11.processing.script" => "https://www.w3.org/TR/2011/REC-SVG11-20110816/conform.html#ConformingSVGViewers",
19
+ "svg11.processing.animation" => "https://www.w3.org/TR/2011/REC-SVG11-20110816/conform.html#ConformingSVGViewers",
20
+ "svg11.processing.interaction" => "https://www.w3.org/TR/2011/REC-SVG11-20110816/conform.html#ConformingSVGViewers",
21
+ }
22
+ end
23
+ let(:invalid_cases) do
24
+ {
25
+ "malformed-xml" => "svg11.document.xml",
26
+ "wrong-namespace" => "svg11.document.root",
27
+ "wrong-version" => "svg11.document.version",
28
+ "dtd-content-model" => "svg11.document.grammar",
29
+ "dtd-attribute" => "svg11.document.grammar",
30
+ "css-grammar" => "svg11.document.css2",
31
+ "xml-stylesheet" => "svg11.document.xml_stylesheet",
32
+ "script" => "svg11.processing.script",
33
+ "animation" => "svg11.processing.animation",
34
+ "interaction" => "svg11.processing.interaction",
35
+ }
36
+ end
37
+
38
+ def fixture(kind, name)
39
+ File.join(fixtures, kind, "#{name}.svg")
40
+ end
41
+
42
+ def sax_errors(path)
43
+ SvgConform::SaxDocument.from_file(path).validate_with_profile(profile).errors
44
+ end
45
+
46
+ def dom_errors(path)
47
+ profile.validate(SvgConform::Document.from_file(path)).errors
48
+ end
49
+
50
+ def ids(errors)
51
+ errors.map(&:requirement_id).uniq.sort - ["parse_error"]
52
+ end
53
+
54
+ it "is listed, opt-in and does not change the default profile" do
55
+ expect(SvgConform::Profiles.available_profiles).to include(:svg_1_1_full_static)
56
+ expect(profile.name).to eq("svg_1_1_full_static")
57
+ expect(profile.specification_uri)
58
+ .to eq("https://www.w3.org/TR/2011/REC-SVG11-20110816/")
59
+ expect(SvgConform::Validator.new.method(:validate).parameters)
60
+ .to include(%i[key profile])
61
+ expect(SvgConform::Validator.instance_method(:validate_file)
62
+ .parameters).to include(%i[key profile])
63
+ expect(profile.remediations.to_a).to be_empty
64
+ end
65
+
66
+ it "covers the nine stable ids with their normative citations" do
67
+ declared = profile.requirements.flat_map do |req|
68
+ req.respond_to?(:categories) ? req.categories.map { |c| [c.id, c.citation] } : [[req.id, req.citation_uri]]
69
+ end
70
+ expect(declared.to_h).to eq(matrix)
71
+ end
72
+
73
+ it "builds through the existing class map and compiler" do
74
+ expect(SvgConform::Profile::REQUIREMENTS_CLASS_MAP)
75
+ .to include("Svg11DocumentRequirement", "ProcessingFeaturesRequirement")
76
+ compiled = SvgConform::ProfileCompiler.compile("svg_1_1_full_static")
77
+ expect(compiled.requirements.map { |req| req.class.name.split("::").last }.uniq)
78
+ .to eq(%w[Svg11DocumentRequirement ProcessingFeaturesRequirement])
79
+ end
80
+
81
+ %w[minimal-full css xml-stylesheet foreign-metadata].each do |name|
82
+ it "accepts valid/#{name}.svg identically in DOM and SAX" do
83
+ path = fixture("valid", name)
84
+ expect(dom_errors(path)).to be_empty
85
+ expect(sax_errors(path)).to be_empty
86
+ end
87
+ end
88
+
89
+ it "rejects each invalid fixture with the expected id in DOM and SAX" do
90
+ invalid_cases.each do |name, id|
91
+ path = fixture("invalid", name)
92
+ sax = ids(sax_errors(path))
93
+ expect(sax).to include(id), name
94
+ if name == "malformed-xml"
95
+ # DOM cannot build a document from malformed XML at all.
96
+ expect { SvgConform::Document.from_file(path) }
97
+ .to raise_error(SvgConform::ParseError)
98
+ else
99
+ expect(ids(dom_errors(path))).to eq(sax), name
100
+ end
101
+ end
102
+ end
103
+
104
+ it "gives every error a normative citation, through issues and LensCore" do
105
+ invalid_cases.each_key do |name|
106
+ result = SvgConform::SaxDocument.from_file(fixture("invalid", name)).validate_with_profile(profile)
107
+ cited = result.errors.reject { |e| e.requirement_id == "parse_error" }
108
+ expect(cited).not_to be_empty
109
+ cited.each do |error|
110
+ expect(matrix.values).to include(error.data[:citation]), "#{name} #{error.requirement_id}"
111
+ end
112
+ report = SvgConform::Diagnostics::LensCoreAdapter.call(result)
113
+ report.diagnostics.each do |diag|
114
+ next if diag.requirement_id == "parse_error"
115
+
116
+ expect(diag.citations.map(&:uri) & matrix.values).not_to be_empty
117
+ end
118
+ end
119
+ end
120
+
121
+ it "validates a file input and a string input identically" do
122
+ path = fixture("invalid", "wrong-version")
123
+ from_string = SvgConform::SaxDocument.from_content(File.read(path))
124
+ .validate_with_profile(profile)
125
+ expect(ids(from_string.errors)).to eq(ids(sax_errors(path)))
126
+ dom_string = profile.validate(SvgConform::Document.from_content(File.read(path)))
127
+ expect(ids(dom_string.errors)).to eq(ids(from_string.errors))
128
+ end
129
+
130
+ it "does not claim Tiny or Basic and offers no remediation" do
131
+ expect(profile.description).not_to match(/tiny|basic/i)
132
+ expect(profile.remediation_count).to eq(0)
133
+ end
134
+
135
+ it "runs through the CLI check command and prints a JSON report" do
136
+ command = SvgConform::Commands::Check.new(
137
+ fixture("valid", "minimal-full"),
138
+ { profile: "svg_1_1_full_static", format: "json" },
139
+ )
140
+ expect do
141
+ expect { command.execute }.to raise_error(SystemExit) do |error|
142
+ expect(error.status).to eq(0)
143
+ end
144
+ end.to output(/"id": "svg_1_1_full_static".*"valid": true/m).to_stdout
145
+ end
146
+ end
@@ -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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "svg_conform/quality_metrics/configuration"
5
+ require "psych"
6
+ require "tempfile"
7
+
8
+ RSpec.describe SvgConform::QualityMetrics::Configuration do
9
+ describe ".default" do
10
+ let(:hash) { described_class.default.instance_variable_get(:@hash) }
11
+
12
+ it "loads bundled violation types as Symbols" do
13
+ types = hash.fetch("severity_levels").values.flat_map do |level|
14
+ level.fetch("violation_types")
15
+ end
16
+
17
+ expect(types).to include(:reference_violation)
18
+ expect(types).to all(be_a(Symbol))
19
+ end
20
+
21
+ it "preserves the entire Psych-loaded configuration" do
22
+ expected = Psych.safe_load_file(described_class::DEFAULT_CONFIG_PATH,
23
+ permitted_classes: [Symbol],
24
+ permitted_symbols: [], aliases: true)
25
+
26
+ expect(hash).to eq(expected)
27
+ end
28
+ end
29
+
30
+ describe ".from_yaml" do
31
+ def load_yaml(source)
32
+ Tempfile.create(["quality_metrics", ".yml"]) do |file|
33
+ file.write(source)
34
+ file.flush
35
+ described_class.from_yaml(file.path)
36
+ end
37
+ end
38
+
39
+ it "preserves quoted Symbol values" do
40
+ pending "yeptris differs from Psych: quoted Symbols retain quotes; upstream issue to be filed, see PR description"
41
+
42
+ config = load_yaml('a: :"x y"')
43
+
44
+ expect(config.instance_variable_get(:@hash)).to eq("a" => :"x y")
45
+ end
46
+
47
+ it "rejects disallowed dates with the yeptris error naming Date" do
48
+ expect { load_yaml("f: 2024-01-02\n") }
49
+ .to raise_error(Yeptris::Psych::DisallowedClass, /Date/)
50
+ end
51
+
52
+ it "rejects disallowed times with the yeptris error naming Time" do
53
+ pending "yeptris differs from Psych: disallowed Time is reported as Date; upstream issue to be filed, see PR description"
54
+
55
+ expect { load_yaml("f: 2024-01-02 10:00:00\n") }
56
+ .to raise_error(Yeptris::Psych::DisallowedClass, /Time/)
57
+ end
58
+
59
+ it "rejects invalid YAML with the yeptris syntax error" do
60
+ expect { load_yaml("a: [\n") }.to raise_error(Yeptris::Psych::SyntaxError)
61
+ end
62
+
63
+ it "resolves anchors and aliases" do
64
+ path = File.expand_path("../../fixtures/quality_metrics/aliases.yml", __dir__)
65
+ hash = described_class.from_yaml(path).instance_variable_get(:@hash)
66
+
67
+ expect(hash.fetch("remediable_types")).to eq([:reference_violation])
68
+ expect(hash.dig("severity_levels", "critical", "violation_types"))
69
+ .to eq(hash.fetch("remediable_types"))
70
+ end
71
+
72
+ it "rejects a non-permitted class with the yeptris error" do
73
+ Tempfile.create(["quality_metrics", ".yml"]) do |file|
74
+ file.write("--- !ruby/object:Object {}\n")
75
+ file.flush
76
+
77
+ expect { described_class.from_yaml(file.path) }
78
+ .to raise_error(Yeptris::Psych::DisallowedClass)
79
+ end
80
+ end
81
+ end
82
+ 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