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,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Path is fixed by the card; the cop expects svg11_ instead of svg_1_1_.
6
+ RSpec.describe SvgConform::Requirements::Svg11DocumentRequirement do # rubocop:disable RSpec/SpecFilePathFormat
7
+ let(:svg_ns) { 'xmlns="http://www.w3.org/2000/svg"' }
8
+ let(:profile_path) { "config/profiles/svg_1_1_full_static.yml" }
9
+
10
+ def requirement_for(rule)
11
+ SvgConform::Profile.load_from_file(profile_path).requirements.find do |req|
12
+ req.respond_to?(:rule) && req.rule == rule
13
+ end
14
+ end
15
+
16
+ def run(rule, xml, mode: :sax, serialized: false)
17
+ requirement = requirement_for(rule)
18
+ profile = SvgConform::Profile.new(name: "t", requirements: [requirement])
19
+ if mode == :dom
20
+ profile.validate(SvgConform::Document.from_content(xml)).errors
21
+ else
22
+ SvgConform::SaxDocument.from_content(xml, source_serialized: serialized)
23
+ .validate_with_profile(profile).errors
24
+ end
25
+ end
26
+
27
+ def doc(attrs: 'version="1.1"', body: '<rect width="1" height="1"/>', prolog: "")
28
+ %(<?xml version="1.0"?>\n#{prolog}<svg #{svg_ns} #{attrs}>#{body}</svg>)
29
+ end
30
+
31
+ it "defines one entry per matrix row with its own id and citation" do
32
+ entries = SvgConform::Profile.load_from_file(profile_path).requirements
33
+ .grep(described_class)
34
+ expect(entries.map(&:rule)).to eq(described_class::RULES)
35
+ expect(entries.map(&:id)).to eq(
36
+ %w[svg11.document.xml svg11.document.root svg11.document.version
37
+ svg11.document.grammar svg11.document.css2
38
+ svg11.document.xml_stylesheet],
39
+ )
40
+ expect(entries.map(&:citation_uri)).to all(start_with("https://www.w3.org/TR/"))
41
+ end
42
+
43
+ it "is registered in Requirements.all" do
44
+ expect(SvgConform::Requirements.all).to include(described_class)
45
+ end
46
+
47
+ it "rejects an unknown rule" do
48
+ requirement = described_class.new(id: "x", rule: "nope")
49
+ context = SvgConform::ValidationContext.new(nil, nil, source: "<svg/>")
50
+ expect { requirement.validate_source("<svg/>", context) }
51
+ .to raise_error(ArgumentError, /unknown rule/)
52
+ end
53
+
54
+ %i[sax dom].each do |mode|
55
+ context "when validating in #{mode} mode" do
56
+ it "accepts a valid document in every rule" do
57
+ described_class::RULES.each do |rule|
58
+ expect(run(rule, doc, mode: mode)).to be_empty, "rule #{rule}"
59
+ end
60
+ end
61
+
62
+ it "flags a wrong root namespace" do
63
+ errors = run("root", doc.sub(svg_ns, 'xmlns="http://example.com/x"'), mode: mode)
64
+ expect(errors.map(&:requirement_id)).to eq(["svg11.document.root"])
65
+ end
66
+
67
+ it "flags a wrong or missing version" do
68
+ expect(run("version", doc(attrs: 'version="1.0"'), mode: mode).size).to eq(1)
69
+ expect(run("version", doc(attrs: ""), mode: mode).size).to eq(1)
70
+ end
71
+
72
+ it "flags DTD content-model and attribute violations" do
73
+ model = run("grammar", doc(body: "<rect><g/></rect>"), mode: mode)
74
+ attribute = run("grammar", doc(body: '<rect width="1" height="1" bogus="1"/>'), mode: mode)
75
+ expect(model.map(&:requirement_id)).to all(eq("svg11.document.grammar"))
76
+ expect(model).not_to be_empty
77
+ expect(attribute).not_to be_empty
78
+ end
79
+
80
+ it "does not follow an external entity" do
81
+ xml = "<?xml version=\"1.0\"?>\n<!DOCTYPE svg [<!ENTITY x SYSTEM \"file:///etc/passwd\">]>\n<svg #{svg_ns} version=\"1.1\"><title>&x;</title></svg>"
82
+ errors = run("grammar", xml, mode: mode)
83
+ expect(errors.map(&:message).join).to include("external")
84
+ expect(errors.map(&:message).join).not_to include("root:")
85
+ end
86
+
87
+ it "flags CSS grammar errors in style attributes and style elements" do
88
+ attr = run("css2", doc(body: '<rect width="1" height="1" style="fill: {red"/>'), mode: mode)
89
+ sheet = run("css2", doc(body: "<style>rect { fill: red </style>"), mode: mode)
90
+ expect(attr.size).to be >= 1
91
+ expect(sheet.size).to be >= 1
92
+ end
93
+
94
+ it "accepts valid CSS and skips non-CSS style types" do
95
+ css = "<style>rect { fill: red }</style><style type=\"text/x-other\">{{</style>"
96
+ expect(run("css2", doc(body: css), mode: mode)).to be_empty
97
+ end
98
+
99
+ it "treats text/css media types case-insensitively" do
100
+ upper = run("css2", doc(body: '<style type="TEXT/CSS">rect { fill: red </style>'), mode: mode)
101
+ mixed = run("css2", doc(body: '<style type="Text/Css">rect { fill: red </style>'), mode: mode)
102
+ pi = %(<?xml-stylesheet href="a.css" type="TEXT/CSS"?>\n)
103
+ expect(upper.size).to be >= 1
104
+ expect(mixed.size).to be >= 1
105
+ expect(run("xml_stylesheet", doc(prolog: pi), mode: mode)).to be_empty
106
+ end
107
+
108
+ it "validates xml-stylesheet pseudo-attributes" do
109
+ good = doc(prolog: %(<?xml-stylesheet href="a.css" type="text/css"?>\n))
110
+ no_href = doc(prolog: %(<?xml-stylesheet type="text/css"?>\n))
111
+ bad_type = doc(prolog: %(<?xml-stylesheet href="a.xsl" type="text/xsl"?>\n))
112
+ garbled = doc(prolog: %(<?xml-stylesheet href=a.css?>\n))
113
+ expect(run("xml_stylesheet", good, mode: mode)).to be_empty
114
+ [no_href, bad_type, garbled].each do |xml|
115
+ expect(run("xml_stylesheet", xml, mode: mode).map(&:requirement_id))
116
+ .to eq(["svg11.document.xml_stylesheet"])
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ it "reports malformed XML through SAX" do
123
+ errors = run("xml", "<svg #{svg_ns}><rect></svg>")
124
+ expect(errors.map(&:requirement_id)).to include("svg11.document.xml")
125
+ end
126
+
127
+ it "cannot prove declaration claims for a re-serialized source" do
128
+ errors = run("xml", doc, serialized: true)
129
+ expect(errors.map(&:requirement_id)).to eq(["svg11.document.xml"])
130
+ expect(errors.first.message).to include("cannot be proved")
131
+ expect(run("xml", doc, serialized: false)).to be_empty
132
+ end
133
+
134
+ it "reports the same through a DOM node and a serialized SAX source" do
135
+ node = SvgConform::Document.from_content(doc).moxml_document
136
+ dom = SvgConform::Document.from_node(node)
137
+ profile = SvgConform::Profile.new(name: "t", requirements: [requirement_for("xml")])
138
+ expect(profile.validate(dom).errors.map(&:requirement_id))
139
+ .to eq(["svg11.document.xml"])
140
+ end
141
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "svg_conform/validation/css2_core_grammar"
5
+
6
+ RSpec.describe SvgConform::Validation::Css2CoreGrammar do
7
+ describe ".tokenize" do
8
+ def types(css)
9
+ described_class.tokenize(css).map(&:type)
10
+ end
11
+
12
+ it "classifies the CSS 2 token kinds" do
13
+ css = 'a:hover #id 10 50% 2em url(x.png) rgb( "s" U+0-7F ~= |= @page <!-- -->'
14
+ expect(types(css).reject { |t| t == :s }).to eq(
15
+ [:ident, ":", :ident, :hash, :number, :percentage, :dimension, :uri, :function, :string,
16
+ :unicode_range, :includes, :dashmatch, :atkeyword, :cdo, :cdc],
17
+ )
18
+ end
19
+
20
+ it "drops comments and tracks line and column" do
21
+ tokens = described_class.tokenize("a /* c\n */ b")
22
+ expect(tokens.map(&:value)).to eq(["a", " ", " ", "b"])
23
+ expect(tokens.last.line).to eq(2)
24
+ expect(tokens.last.column).to eq(5)
25
+ end
26
+
27
+ it "flags unterminated strings and comments" do
28
+ expect(types('"abc')).to eq([:bad_string])
29
+ expect(types("/* abc")).to eq([:bad_comment])
30
+ end
31
+
32
+ it "accepts escapes in identifiers" do
33
+ expect(types('\66 ill')).to eq([:ident])
34
+ end
35
+ end
36
+
37
+ describe ".parse_stylesheet" do
38
+ valid = {
39
+ "empty sheet" => "",
40
+ "comments and CDO/CDC" => "<!-- /* x */ a { fill: red } -->",
41
+ "multiple selectors" => "rect, circle.a > path[x=\"1\"]:first-child { fill: #fff }",
42
+ "empty declaration slots" => "a { ; fill: red;; stroke: blue; }",
43
+ "functions and !important" => "a { fill: rgb(1, 2, 3) !important; stroke: url(#g) }",
44
+ "empty ruleset" => "a { }",
45
+ "@import" => '@import url("x.css") screen;',
46
+ "@media with nested rules" => "@media print { a { x: y } b { x: y } }",
47
+ "@font-face" => '@font-face { font-family: "F"; src: url(f.woff) }',
48
+ "unknown at-rule block" => "@foo bar { baz { qux: 1 } }",
49
+ "selector-less ruleset" => "{ fill: red }",
50
+ }
51
+
52
+ valid.each do |name, css|
53
+ it "accepts #{name}" do
54
+ expect(described_class.parse_stylesheet(css)).to be_valid
55
+ end
56
+ end
57
+
58
+ invalid = {
59
+ "missing colon" => ["a { fill red }", /expected ':'/],
60
+ "missing value" => ["a { fill: }", /no value/],
61
+ "missing property" => ["a { : red }", /property name/],
62
+ "unclosed ruleset" => ["a { fill: red", /missing '}'/],
63
+ "unclosed string" => ["a { fill: \"red }", /unterminated string/],
64
+ "unclosed comment" => ["a { fill: red } /* x", /unterminated comment/],
65
+ "unclosed function" => ["a { fill: rgb(1,2 }", /missing '\)'/],
66
+ "selector without block" => ["a b", /missing a '\{' block/],
67
+ "unterminated at-rule" => ["@import url(x.css)", /at-rule/],
68
+ "stray closing brace" => ["a { fill: red } }", /unexpected '\}'/],
69
+ }
70
+
71
+ invalid.each do |name, (css, message)|
72
+ it "rejects #{name}" do
73
+ result = described_class.parse_stylesheet(css)
74
+ expect(result).not_to be_valid
75
+ expect(result.errors.map(&:message).join("\n")).to match(message)
76
+ end
77
+ end
78
+
79
+ it "reports the position of the first error" do
80
+ error = described_class.parse_stylesheet("a { fill: red }\nb { fill blue }")
81
+ .errors.first
82
+ expect(error.line).to eq(2)
83
+ expect(error.to_s).to include("line 2")
84
+ end
85
+ end
86
+
87
+ describe ".parse_declarations" do
88
+ it "accepts a style attribute value" do
89
+ css = "fill:red; stroke : url(#a) ; stroke-width:2px;"
90
+ expect(described_class.parse_declarations(css)).to be_valid
91
+ end
92
+
93
+ it "accepts an empty value and stray semicolons" do
94
+ expect(described_class.parse_declarations("")).to be_valid
95
+ expect(described_class.parse_declarations(" ; ;")).to be_valid
96
+ end
97
+
98
+ it "rejects a declaration without a colon" do
99
+ result = described_class.parse_declarations("fill red")
100
+ expect(result).not_to be_valid
101
+ end
102
+
103
+ it "rejects a stray closing brace in a declaration list" do
104
+ result = described_class.parse_declarations("fill:red } stroke:blue")
105
+ expect(result).not_to be_valid
106
+ end
107
+
108
+ it "rejects a ruleset where a declaration list is expected" do
109
+ expect(described_class.parse_declarations("a { fill: red }")).not_to be_valid
110
+ end
111
+
112
+ it "does not judge property names or values" do
113
+ expect(described_class.parse_declarations("bogus-prop: nonsense 12qq")).to be_valid
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "digest"
5
+ require "yaml"
6
+ require "tmpdir"
7
+ require "svg_conform/validation/svg_1_1_document_conformance"
8
+
9
+ # Path is fixed by the card; the cop expects svg11_ instead of svg_1_1_.
10
+ RSpec.describe SvgConform::Validation::Svg11DocumentConformance do # rubocop:disable RSpec/SpecFilePathFormat
11
+ subject(:conformance) { described_class.new }
12
+
13
+ let(:ns) { %(xmlns="http://www.w3.org/2000/svg" version="1.1") }
14
+ let(:dtd_path) { described_class::DTD_PATH }
15
+
16
+ def svg(body, attrs = ns)
17
+ %(<?xml version="1.0"?>\n<svg #{attrs}>#{body}</svg>)
18
+ end
19
+
20
+ # Library selection record (card 03, "Open implementation question").
21
+ # Probes: valid document, invalid content model, external-entity attempt,
22
+ # Appendix G.2 foreign content, run offline against the vendored flat DTD.
23
+ #
24
+ # Moxml/Leptris 1.9.381 (Leptris::XML::DTD): parses the DTD but ignores
25
+ # conditional sections whose keyword is a parameter entity
26
+ # (<![%svg-structure.module;[ ... ]]>), so no SVG element is declared and
27
+ # every probe, including the invalid ones, reports valid. Rejected.
28
+ # Nokogiri 1.19 (libxml2 2.13, Ruby >= 3.1 with 1.16/1.18): all four
29
+ # probes behave correctly offline, in-process, with NONET and no entity
30
+ # loading. Selected.
31
+ describe "library probes" do
32
+ let(:flat_dtd) { File.read(dtd_path) }
33
+
34
+ it "records that Leptris cannot enforce the flat DTD" do
35
+ require "leptris"
36
+ handle = Leptris::XML::DTD.parse(flat_dtd)
37
+ invalid = Leptris::XML.parse(%(<svg #{ns}><rect width="1" height="1"><g/></rect></svg>))
38
+
39
+ expect(handle.validate(invalid)).to be_empty
40
+ end
41
+
42
+ it "probe 1: Nokogiri accepts a valid document" do
43
+ expect(conformance.validate(svg(%(<rect width="1" height="1"/>))).issues).to be_empty
44
+ end
45
+
46
+ it "probe 2: Nokogiri rejects an invalid content model" do
47
+ result = conformance.validate(svg(%(<rect width="1" height="1"><g/></rect>)))
48
+
49
+ expect(result.issues.map(&:kind)).to eq([:dtd])
50
+ expect(result.issues.first.message).to include("rect content does not follow the DTD")
51
+ end
52
+
53
+ it "probe 3: an external entity attempt is rejected without reading the file" do
54
+ Dir.mktmpdir do |dir|
55
+ secret = File.join(dir, "secret.txt")
56
+ File.write(secret, "SECRET-MARKER")
57
+ source = %(<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x SYSTEM "file://#{secret}">]>) +
58
+ %(<svg #{ns}><title>&x;</title></svg>)
59
+
60
+ result = conformance.validate(source)
61
+
62
+ expect(result.issues.map(&:kind)).to eq([:external_entity])
63
+ expect(result.transformed).to be_nil
64
+ end
65
+ end
66
+
67
+ it "probe 4: foreign content validates after the Appendix G.2 transform" do
68
+ source = svg(%(<f:thing f:a="1"><g/></f:thing><metadata><f:m/></metadata>),
69
+ %(#{ns} xmlns:f="urn:foreign"))
70
+
71
+ expect(conformance.validate(source).issues).to be_empty
72
+ end
73
+ end
74
+
75
+ describe "#validate" do
76
+ it "reports a missing required attribute" do
77
+ result = conformance.validate(svg(%(<rect height="1"/>)))
78
+
79
+ expect(result.issues.first.message).to include("Element rect does not carry attribute width")
80
+ end
81
+
82
+ it "reports an undeclared attribute" do
83
+ result = conformance.validate(svg(%(<rect width="1" height="1" bogus="x"/>)))
84
+
85
+ expect(result.issues.first.message).to include("No declaration for attribute bogus")
86
+ end
87
+
88
+ it "reports malformed XML" do
89
+ result = conformance.validate(svg(%(<rect width="1" height="1">)))
90
+
91
+ expect(result.issues.first.kind).to eq(:malformed_xml)
92
+ end
93
+
94
+ it "reports a document element outside the SVG namespace" do
95
+ result = conformance.validate(%(<svg xmlns="urn:other" version="1.1"/>))
96
+
97
+ expect(result.issues.map(&:kind)).to eq([:no_svg_root])
98
+ end
99
+
100
+ it "validates xlink attributes under any prefix" do
101
+ source = svg(%(<defs><g id="a"/></defs><use xlink:href="#a"/>),
102
+ %(#{ns} xmlns:xl="http://www.w3.org/1999/xlink"))
103
+ source = source.sub("xlink:href", "xl:href")
104
+
105
+ expect(conformance.validate(source).issues).to be_empty
106
+ end
107
+
108
+ it "validates prefixed SVG elements" do
109
+ source = %(<s:svg xmlns:s="http://www.w3.org/2000/svg" version="1.1"><s:rect width="1" height="1"/></s:svg>)
110
+
111
+ expect(conformance.validate(source).issues).to be_empty
112
+ end
113
+
114
+ it "expands internal entities before validating" do
115
+ source = %(<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY w "5">]><svg #{ns}><rect width="&w;" height="1"/></svg>)
116
+
117
+ expect(conformance.validate(source).issues).to be_empty
118
+ end
119
+
120
+ it "never modifies the caller's source" do
121
+ source = svg(%(<f:x xmlns:f="urn:f"/><rect width="1" height="1"/>)).freeze
122
+
123
+ expect { conformance.validate(source) }.not_to raise_error
124
+ expect(source).to include("<f:x")
125
+ end
126
+
127
+ it "keeps the Appendix G.2 copy free of foreign content" do
128
+ copy = conformance.validate(svg(%(<f:x xmlns:f="urn:f"/>), %(#{ns} f2:a="1" xmlns:f2="urn:f"))).transformed
129
+
130
+ expect(copy).not_to include("f:x")
131
+ expect(copy).not_to include("f2:a")
132
+ expect(copy).to include(%(xmlns:xlink="http://www.w3.org/1999/xlink"))
133
+ end
134
+ end
135
+
136
+ describe "vendored DTD" do
137
+ it "matches the manifest checksum" do
138
+ entry = YAML.safe_load_file(described_class::MANIFEST_PATH, permitted_classes: [Date])["files"].first
139
+
140
+ expect(Digest::SHA256.file(dtd_path).hexdigest).to eq(entry["sha256"])
141
+ expect(entry["source_url"]).to start_with("https://www.w3.org/")
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+
6
+ RSpec.describe SvgConform::ValidationContext, "#source" do
7
+ let(:source) do
8
+ <<~XML
9
+ <?xml version="1.0" encoding="UTF-8"?>
10
+ <!-- exact bytes -->
11
+ <?xml-stylesheet href="a.css" type="text/css"?>
12
+ <svg xmlns="http://www.w3.org/2000/svg" version="1.1"><rect width="1" height="1"/></svg>
13
+ XML
14
+ end
15
+
16
+ let(:recorder_class) do
17
+ Class.new(SvgConform::Requirements::BaseRequirement) do
18
+ attr_accessor :calls
19
+
20
+ def validate_source(source, context)
21
+ (@calls ||= []) << {
22
+ source: source, path: context.source_path,
23
+ serialized: context.source_serialized?, order: :source
24
+ }
25
+ end
26
+
27
+ def validate_sax_element(_element, _context)
28
+ (@calls ||= []) << { order: :element }
29
+ end
30
+
31
+ def check(_node, _context)
32
+ (@calls ||= []) << { order: :element }
33
+ end
34
+ end
35
+ end
36
+
37
+ let(:requirement) { recorder_class.new(id: "rec") }
38
+ let(:profile) { SvgConform::Profile.new(name: "rec", requirements: [requirement]) }
39
+
40
+ def sources_seen
41
+ requirement.calls.select { |c| c[:order] == :source }
42
+ end
43
+
44
+ it "keeps the BaseRequirement hook a no-op" do
45
+ base = SvgConform::Requirements::BaseRequirement.new(id: "b")
46
+ expect(base.validate_source("x", nil)).to be_nil
47
+ end
48
+
49
+ it "stores source and path on ValidationContext" do
50
+ ctx = described_class.new(nil, nil, source: "s",
51
+ source_path: "p.svg")
52
+ expect([ctx.source, ctx.source_path, ctx.source_serialized?])
53
+ .to eq(["s", "p.svg", false])
54
+ expect(described_class.new(nil, nil).source).to be_nil
55
+ end
56
+
57
+ it "retains exact content in Document for string and file inputs" do
58
+ Dir.mktmpdir do |dir|
59
+ path = File.join(dir, "a.svg")
60
+ File.write(path, source)
61
+ expect(SvgConform::Document.from_content(source).source).to eq(source)
62
+ from_file = SvgConform::Document.from_file(path)
63
+ expect(from_file.source).to eq(source)
64
+ expect(from_file.source_serialized?).to be(false)
65
+ end
66
+ end
67
+
68
+ it "marks Document.from_node source as serialized" do
69
+ node = SvgConform::Document.from_content(source).moxml_document
70
+ doc = SvgConform::Document.from_node(node)
71
+ expect(doc.source_serialized?).to be(true)
72
+ expect(doc.source).to include("<svg")
73
+ end
74
+
75
+ it "passes SaxDocument content into the handler" do
76
+ SvgConform::SaxDocument.from_content(source).validate_with_profile(profile)
77
+ expect(sources_seen.size).to eq(1)
78
+ expect(sources_seen.first).to include(source: source, path: nil,
79
+ serialized: false)
80
+ end
81
+
82
+ it "calls validate_source exactly once, before elements, in DOM and SAX" do
83
+ profile.validate(SvgConform::Document.from_content(source))
84
+ dom_orders = requirement.calls.map { |c| c[:order] }
85
+ expect(dom_orders.first).to eq(:source)
86
+ expect(dom_orders.count(:source)).to eq(1)
87
+
88
+ requirement.calls = []
89
+ SvgConform::SaxDocument.from_content(source).validate_with_profile(profile)
90
+ sax_orders = requirement.calls.map { |c| c[:order] }
91
+ expect(sax_orders.first).to eq(:source)
92
+ expect(sax_orders.count(:source)).to eq(1)
93
+ end
94
+
95
+ it "gives file entry points identical source and path in both modes" do
96
+ Dir.mktmpdir do |dir|
97
+ path = File.join(dir, "a.svg")
98
+ File.write(path, source)
99
+
100
+ profile.validate_file(path)
101
+ sax = sources_seen.first
102
+ requirement.calls = []
103
+ profile.validate(SvgConform::Document.from_file(path))
104
+ dom = sources_seen.first
105
+
106
+ expect(sax).to include(source: source, path: path, serialized: false)
107
+ expect(dom).to eq(sax)
108
+ end
109
+ end
110
+
111
+ it "flags serialized source for DOM built from a node" do
112
+ node = SvgConform::Document.from_content(source).moxml_document
113
+ profile.validate(SvgConform::Document.from_node(node))
114
+ expect(sources_seen.first[:serialized]).to be(true)
115
+ end
116
+
117
+ it "still runs source validation when SAX parsing fails" do
118
+ SvgConform::SaxDocument.from_content("<svg><unclosed>")
119
+ .validate_with_profile(profile)
120
+ expect(sources_seen.size).to eq(1)
121
+ end
122
+ end
@@ -258,6 +258,45 @@ RSpec.describe SvgConform::ValidationContext do
258
258
 
259
259
  expect(context.infos.first.message).to eq("test notice")
260
260
  end
261
+
262
+ it "stores an explicit requirement ID on the info issue" do
263
+ context.add_notice(rule: nil, node: Object.new, message: "n",
264
+ requirement_id: "my_requirement")
265
+
266
+ expect(context.infos.first.requirement_id).to eq("my_requirement")
267
+ end
268
+
269
+ it "keeps existing calls working without a requirement ID" do
270
+ context.add_notice(rule: Struct.new(:id).new("test_rule"),
271
+ node: Object.new, message: "n")
272
+
273
+ expect(context.infos.first.requirement_id).to eq("test_rule")
274
+ end
275
+ end
276
+
277
+ describe "#add_external_reference_notice" do
278
+ let(:reference) do
279
+ SvgConform::References::ReferenceClassifier.classify(
280
+ "https://example.com/x", element_name: "a", attribute_name: "href"
281
+ )
282
+ end
283
+
284
+ it "stores the explicit requirement ID and JSON-safe data" do
285
+ context.add_external_reference_notice(node: Object.new,
286
+ reference: reference,
287
+ requirement_id: "link_validation")
288
+
289
+ info = context.infos.first
290
+ expect(info.requirement_id).to eq("link_validation")
291
+ expect(info.data[:reference][:value]).to eq("https://example.com/x")
292
+ end
293
+
294
+ it "keeps existing calls working" do
295
+ context.add_external_reference_notice(node: Object.new,
296
+ reference: reference)
297
+
298
+ expect(context.infos.size).to eq(1)
299
+ end
261
300
  end
262
301
 
263
302
  describe "#has_errors?" do