phlex 2.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/phlex/csv.rb +185 -54
- data/lib/phlex/kit.rb +2 -10
- data/lib/phlex/sgml/elements.rb +60 -39
- data/lib/phlex/sgml/state.rb +10 -1
- data/lib/phlex/sgml.rb +4 -0
- data/lib/phlex/svg.rb +14 -0
- data/lib/phlex/version.rb +1 -1
- data/lib/phlex.rb +2 -0
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b8db189359fc8003771075a598fbeed0c2205ca52604fd829d77afe3c0b3a467
|
4
|
+
data.tar.gz: e5446344e9a83c982fdfa94d4d31abd4b7511858704cc2e9f9d285ad9740b9c3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: db1c7e4b591440cea077fb99285552f443301564b293f2a0cf0098750d514af3ac48684d820398ac588570ba71f6f1c1284b72c3b32d6f54f48d20c457a476eb
|
7
|
+
data.tar.gz: fb9ff7728339e8edbcae2b9031c41f77ae2d28e68963b8c27368575d44a20935ff6393730d180579bb29d9a3786e43c1c6388223d0115b17c6d35b31245b64ff
|
data/lib/phlex/csv.rb
CHANGED
@@ -1,63 +1,122 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
class Phlex::CSV
|
4
|
-
|
5
|
-
|
4
|
+
FORMULA_PREFIXES_MAP = Array.new(128).tap do |map|
|
5
|
+
"=+-@\t\r".each_byte do |byte|
|
6
|
+
map[byte] = true
|
7
|
+
end
|
8
|
+
end.freeze
|
9
|
+
|
10
|
+
UNDEFINED = Object.new
|
6
11
|
|
7
12
|
def initialize(collection)
|
8
13
|
@collection = collection
|
14
|
+
@_row_buffer = []
|
9
15
|
@_headers = []
|
10
|
-
@_current_row = []
|
11
|
-
@_current_column_index = 0
|
12
|
-
@_first = true
|
13
16
|
end
|
14
17
|
|
15
18
|
attr_reader :collection
|
16
19
|
|
17
|
-
def call(buffer = +"", context: nil)
|
18
|
-
|
19
|
-
raise <<~MESSAGE
|
20
|
-
You need to define escape_csv_injection? in #{self.class.name}, returning either `true` or `false`.
|
20
|
+
def call(buffer = +"", context: nil, delimiter: self.delimiter)
|
21
|
+
ensure_escape_csv_injection_configured!
|
21
22
|
|
22
|
-
|
23
|
-
|
24
|
-
|
23
|
+
strip_whitespace = trim_whitespace?
|
24
|
+
escape_csv_injection = escape_csv_injection?
|
25
|
+
row_buffer = @_row_buffer
|
26
|
+
headers = @_headers
|
27
|
+
has_yielder = respond_to?(:yielder, true)
|
28
|
+
first_row = true
|
29
|
+
render_headers = render_headers?
|
25
30
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
This is useful when using CSVs for byte-for-byte data exchange between secure systems.
|
31
|
+
if delimiter.length != 1
|
32
|
+
raise Phlex::ArgumentError.new("Delimiter must be a single character")
|
33
|
+
end
|
31
34
|
|
32
|
-
|
35
|
+
if strip_whitespace
|
36
|
+
escape_regex = /[\n"#{delimiter}]/
|
37
|
+
else
|
38
|
+
escape_regex = /^\s|\s$|[\n"#{delimiter}]/
|
39
|
+
end
|
33
40
|
|
34
|
-
|
41
|
+
if has_yielder
|
42
|
+
warn <<~MESSAGE
|
43
|
+
Custom yielders are deprecated in Phlex::CSV.
|
35
44
|
|
36
|
-
|
45
|
+
Please replace your yielder with an `around_row` method.
|
37
46
|
|
38
|
-
|
47
|
+
You should be able to just rename your yielder method
|
48
|
+
and change `yield` to `super`.
|
39
49
|
MESSAGE
|
40
50
|
end
|
41
51
|
|
42
52
|
each_item do |record|
|
43
|
-
|
44
|
-
|
53
|
+
if has_yielder
|
54
|
+
yielder(record) { |*a, **k| row = row_template(*a, **k) }
|
55
|
+
else
|
56
|
+
around_row(record)
|
57
|
+
end
|
58
|
+
|
59
|
+
row = row_buffer
|
60
|
+
|
61
|
+
if first_row
|
62
|
+
first_row = false
|
63
|
+
|
64
|
+
i = 0
|
65
|
+
number_of_columns = row.length
|
66
|
+
first_col = true
|
67
|
+
|
68
|
+
while i < number_of_columns
|
69
|
+
header, = row[i]
|
70
|
+
headers[i] = header
|
45
71
|
|
46
|
-
|
47
|
-
|
72
|
+
if render_headers
|
73
|
+
if first_col
|
74
|
+
first_col = false
|
75
|
+
else
|
76
|
+
buffer << delimiter
|
77
|
+
end
|
78
|
+
|
79
|
+
__escape__(buffer, header, escape_csv_injection:, strip_whitespace:, escape_regex:)
|
80
|
+
end
|
81
|
+
i += 1
|
82
|
+
end
|
83
|
+
|
84
|
+
buffer << "\n" if render_headers
|
85
|
+
end
|
86
|
+
|
87
|
+
i = 0
|
88
|
+
number_of_columns = row.length
|
89
|
+
first_col = true
|
90
|
+
|
91
|
+
while i < number_of_columns
|
92
|
+
header, value = row[i]
|
93
|
+
|
94
|
+
unless headers[i] == header
|
95
|
+
raise Phlex::RuntimeError.new("Header mismatch at index #{i}: expected #{headers[i]}, got #{header}.")
|
96
|
+
end
|
97
|
+
|
98
|
+
if first_col
|
99
|
+
first_col = false
|
100
|
+
else
|
101
|
+
buffer << delimiter
|
48
102
|
end
|
49
103
|
|
50
|
-
buffer
|
51
|
-
|
52
|
-
@_current_row.clear
|
104
|
+
__escape__(buffer, value, escape_csv_injection:, strip_whitespace:, escape_regex:)
|
105
|
+
i += 1
|
53
106
|
end
|
54
107
|
|
55
|
-
|
108
|
+
buffer << "\n"
|
109
|
+
|
110
|
+
row_buffer.clear
|
56
111
|
end
|
57
112
|
|
58
113
|
buffer
|
59
114
|
end
|
60
115
|
|
116
|
+
def around_row(item)
|
117
|
+
row_template(item)
|
118
|
+
end
|
119
|
+
|
61
120
|
def filename
|
62
121
|
nil
|
63
122
|
end
|
@@ -66,27 +125,20 @@ class Phlex::CSV
|
|
66
125
|
"text/csv"
|
67
126
|
end
|
68
127
|
|
128
|
+
def delimiter
|
129
|
+
","
|
130
|
+
end
|
131
|
+
|
69
132
|
private
|
70
133
|
|
71
134
|
def column(header = nil, value)
|
72
|
-
|
73
|
-
@_headers << __escape__(header)
|
74
|
-
elsif header != @_headers[@_current_column_index]
|
75
|
-
raise "Inconsistent header."
|
76
|
-
end
|
77
|
-
|
78
|
-
@_current_row << __escape__(value)
|
79
|
-
@_current_column_index += 1
|
135
|
+
@_row_buffer << [header, value]
|
80
136
|
end
|
81
137
|
|
82
138
|
def each_item(&)
|
83
139
|
collection.each(&)
|
84
140
|
end
|
85
141
|
|
86
|
-
def yielder(record)
|
87
|
-
yield(record)
|
88
|
-
end
|
89
|
-
|
90
142
|
# Override and set to `false` to disable rendering headers.
|
91
143
|
def render_headers?
|
92
144
|
true
|
@@ -99,22 +151,101 @@ class Phlex::CSV
|
|
99
151
|
|
100
152
|
# Override and set to `false` to disable CSV injection escapes or `true` to enable.
|
101
153
|
def escape_csv_injection?
|
102
|
-
|
154
|
+
UNDEFINED
|
103
155
|
end
|
104
156
|
|
105
|
-
def __escape__(value)
|
106
|
-
value =
|
107
|
-
|
108
|
-
last_char = value[-1]
|
109
|
-
|
110
|
-
if escape_csv_injection? && FORMULA_PREFIXES.include?(first_char)
|
111
|
-
# Prefix a single quote to prevent Excel, Google Docs, etc. from interpreting the value as a formula.
|
112
|
-
# See https://owasp.org/www-community/attacks/CSV_Injection
|
113
|
-
%("'#{value.gsub('"', '""')}")
|
114
|
-
elsif (!trim_whitespace? && (SPACE_CHARACTERS.include?(first_char) || SPACE_CHARACTERS.include?(last_char))) || value.include?('"') || value.include?(",") || value.include?("\n")
|
115
|
-
%("#{value.gsub('"', '""')}")
|
116
|
-
else
|
157
|
+
def __escape__(buffer, value, escape_csv_injection:, strip_whitespace:, escape_regex:)
|
158
|
+
value = case value
|
159
|
+
when String
|
117
160
|
value
|
161
|
+
when Symbol
|
162
|
+
value.name
|
163
|
+
else
|
164
|
+
value.to_s
|
165
|
+
end
|
166
|
+
|
167
|
+
if strip_whitespace
|
168
|
+
value = value.strip
|
169
|
+
|
170
|
+
if escape_csv_injection
|
171
|
+
if FORMULA_PREFIXES_MAP[value.getbyte(0)]
|
172
|
+
value.gsub!('"', '""')
|
173
|
+
buffer << '"\'' << value << '"'
|
174
|
+
elsif value.match?(escape_regex)
|
175
|
+
value.gsub!('"', '""')
|
176
|
+
buffer << '"' << value << '"'
|
177
|
+
else
|
178
|
+
buffer << value
|
179
|
+
end
|
180
|
+
else # not escaping CSV injection
|
181
|
+
buffer << value
|
182
|
+
end
|
183
|
+
else # not stripping whitespace
|
184
|
+
if escape_csv_injection
|
185
|
+
first_byte = value.getbyte(0)
|
186
|
+
|
187
|
+
if FORMULA_PREFIXES_MAP[first_byte]
|
188
|
+
buffer << '"\'' << value.gsub('"', '""') << '"'
|
189
|
+
elsif value.match?(escape_regex)
|
190
|
+
buffer << '"' << value.gsub('"', '""') << '"'
|
191
|
+
else
|
192
|
+
buffer << value
|
193
|
+
end
|
194
|
+
else # not escaping CSV injection
|
195
|
+
if value.match?(escape_regex)
|
196
|
+
buffer << '"' << value.gsub('"', '""') << '"'
|
197
|
+
else
|
198
|
+
buffer << value
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
# Handle legacy `view_template` method
|
205
|
+
def respond_to_missing?(method_name, include_private)
|
206
|
+
(method_name == :row_template && respond_to?(:view_template)) || super
|
207
|
+
end
|
208
|
+
|
209
|
+
# Handle legacy `view_template` method
|
210
|
+
def method_missing(method_name, ...)
|
211
|
+
if method_name == :row_template && respond_to?(:view_template)
|
212
|
+
warn "Deprecated: Use `row_template` instead of `view_template` in Phlex CSVs."
|
213
|
+
self.class.alias_method :row_template, :view_template
|
214
|
+
view_template(...)
|
215
|
+
else
|
216
|
+
super
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
def ensure_escape_csv_injection_configured!
|
221
|
+
if escape_csv_injection? == UNDEFINED
|
222
|
+
raise <<~MESSAGE
|
223
|
+
You need to define `escape_csv_injection?` in #{self.class.name}.
|
224
|
+
|
225
|
+
CSV injection is a security vulnerability where malicious spreadsheet
|
226
|
+
formulae are used to execute code or exfiltrate data when a CSV is opened
|
227
|
+
in a spreadsheet program such as Microsoft Excel or Google Sheets.
|
228
|
+
|
229
|
+
For more information, see https://owasp.org/www-community/attacks/CSV_Injection
|
230
|
+
|
231
|
+
If you’re sure this CSV will never be opened in a spreadsheet program,
|
232
|
+
you can *disable* CSV injection escapes:
|
233
|
+
|
234
|
+
def escape_csv_injection? = false
|
235
|
+
|
236
|
+
This is useful when using CSVs for byte-for-byte data exchange between secure systems.
|
237
|
+
|
238
|
+
Alternatively, you can *enable* CSV injection escapes at the cost of data integrity:
|
239
|
+
|
240
|
+
def escape_csv_injection? = true
|
241
|
+
|
242
|
+
Enabling the CSV injection escapes will prefix with a single quote `'` any
|
243
|
+
values that start with: `=`, `+`, `-`, `@`, `\\t`, `\\r`
|
244
|
+
|
245
|
+
Unfortunately, there is no one-size-fits-all solution to CSV injection.
|
246
|
+
|
247
|
+
You need to decide based on your specific use case.
|
248
|
+
MESSAGE
|
118
249
|
end
|
119
250
|
end
|
120
251
|
end
|
data/lib/phlex/kit.rb
CHANGED
@@ -15,11 +15,7 @@ module Phlex::Kit
|
|
15
15
|
def respond_to_missing?(name, include_private = false)
|
16
16
|
mod = self.class
|
17
17
|
|
18
|
-
|
19
|
-
true
|
20
|
-
else
|
21
|
-
super
|
22
|
-
end
|
18
|
+
(name[0] == name[0].upcase && mod.constants.include?(name) && mod.const_get(name) && methods.include?(name)) || super
|
23
19
|
end
|
24
20
|
end
|
25
21
|
|
@@ -45,11 +41,7 @@ module Phlex::Kit
|
|
45
41
|
end
|
46
42
|
|
47
43
|
def respond_to_missing?(name, include_private = false)
|
48
|
-
|
49
|
-
true
|
50
|
-
else
|
51
|
-
super
|
52
|
-
end
|
44
|
+
(name[0] == name[0].upcase && constants.include?(name) && const_get(name) && methods.include?(name)) || super
|
53
45
|
end
|
54
46
|
|
55
47
|
def const_added(name)
|
data/lib/phlex/sgml/elements.rb
CHANGED
@@ -21,55 +21,69 @@ module Phlex::SGML::Elements
|
|
21
21
|
|
22
22
|
if attributes.length > 0 # with attributes
|
23
23
|
if block_given # with content block
|
24
|
-
buffer << "<#{tag}"
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
24
|
+
buffer << "<#{tag}"
|
25
|
+
begin
|
26
|
+
buffer << (Phlex::ATTRIBUTE_CACHE[attributes] ||= __attributes__(attributes))
|
27
|
+
ensure
|
28
|
+
buffer << ">"
|
29
|
+
end
|
30
|
+
|
31
|
+
begin
|
32
|
+
original_length = buffer.bytesize
|
33
|
+
content = yield(self)
|
34
|
+
if original_length == buffer.bytesize
|
35
|
+
case content
|
36
|
+
when ::Phlex::SGML::SafeObject
|
37
|
+
buffer << content.to_s
|
38
|
+
when String
|
39
|
+
buffer << ::Phlex::Escape.html_escape(content)
|
40
|
+
when Symbol
|
41
|
+
buffer << ::Phlex::Escape.html_escape(content.name)
|
42
|
+
when nil
|
43
|
+
nil
|
44
|
+
else
|
45
|
+
if (formatted_object = format_object(content))
|
46
|
+
buffer << ::Phlex::Escape.html_escape(formatted_object)
|
47
|
+
end
|
41
48
|
end
|
42
49
|
end
|
50
|
+
ensure
|
51
|
+
buffer << "</#{tag}>"
|
43
52
|
end
|
44
|
-
|
45
|
-
buffer << "</#{tag}>"
|
46
53
|
else # without content
|
47
|
-
buffer << "<#{tag}"
|
54
|
+
buffer << "<#{tag}"
|
55
|
+
begin
|
56
|
+
buffer << (::Phlex::ATTRIBUTE_CACHE[attributes] ||= __attributes__(attributes))
|
57
|
+
ensure
|
58
|
+
buffer << "></#{tag}>"
|
59
|
+
end
|
48
60
|
end
|
49
61
|
else # without attributes
|
50
62
|
if block_given # with content block
|
51
63
|
buffer << "<#{tag}>"
|
52
64
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
nil
|
65
|
-
|
66
|
-
|
67
|
-
|
65
|
+
begin
|
66
|
+
original_length = buffer.bytesize
|
67
|
+
content = yield(self)
|
68
|
+
if original_length == buffer.bytesize
|
69
|
+
case content
|
70
|
+
when ::Phlex::SGML::SafeObject
|
71
|
+
buffer << content.to_s
|
72
|
+
when String
|
73
|
+
buffer << ::Phlex::Escape.html_escape(content)
|
74
|
+
when Symbol
|
75
|
+
buffer << ::Phlex::Escape.html_escape(content.name)
|
76
|
+
when nil
|
77
|
+
nil
|
78
|
+
else
|
79
|
+
if (formatted_object = format_object(content))
|
80
|
+
buffer << ::Phlex::Escape.html_escape(formatted_object)
|
81
|
+
end
|
68
82
|
end
|
69
83
|
end
|
84
|
+
ensure
|
85
|
+
buffer << "</#{tag}>"
|
70
86
|
end
|
71
|
-
|
72
|
-
buffer << "</#{tag}>"
|
73
87
|
else # without content
|
74
88
|
buffer << "<#{tag}></#{tag}>"
|
75
89
|
end
|
@@ -95,10 +109,17 @@ module Phlex::SGML::Elements
|
|
95
109
|
|
96
110
|
return unless state.should_render?
|
97
111
|
|
112
|
+
buffer = state.buffer
|
113
|
+
|
98
114
|
if attributes.length > 0 # with attributes
|
99
|
-
|
115
|
+
buffer << "<#{tag}"
|
116
|
+
begin
|
117
|
+
buffer << (::Phlex::ATTRIBUTE_CACHE[attributes] ||= __attributes__(attributes))
|
118
|
+
ensure
|
119
|
+
buffer << ">"
|
120
|
+
end
|
100
121
|
else # without attributes
|
101
|
-
|
122
|
+
buffer << "<#{tag}>"
|
102
123
|
end
|
103
124
|
|
104
125
|
nil
|
data/lib/phlex/sgml/state.rb
CHANGED
@@ -12,10 +12,19 @@ class Phlex::SGML::State
|
|
12
12
|
@output_buffer = output_buffer
|
13
13
|
end
|
14
14
|
|
15
|
-
attr_accessor :
|
15
|
+
attr_accessor :capturing, :user_context
|
16
16
|
|
17
17
|
attr_reader :fragments, :fragment_depth, :output_buffer
|
18
18
|
|
19
|
+
def buffer
|
20
|
+
case @buffer
|
21
|
+
when Proc
|
22
|
+
@buffer.call
|
23
|
+
else
|
24
|
+
@buffer
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
19
28
|
def around_render(component)
|
20
29
|
stack = @stack
|
21
30
|
|
data/lib/phlex/sgml.rb
CHANGED
data/lib/phlex/svg.rb
CHANGED
@@ -15,6 +15,20 @@ class Phlex::SVG < Phlex::SGML
|
|
15
15
|
nil
|
16
16
|
end
|
17
17
|
|
18
|
+
def cdata(content = nil, &block)
|
19
|
+
state = @_state
|
20
|
+
return unless state.should_render?
|
21
|
+
|
22
|
+
if !block && String === content
|
23
|
+
state.buffer << "<![CDATA[" << content.gsub("]]>", "]]>]]<![CDATA[") << "]]>"
|
24
|
+
elsif block && nil == content
|
25
|
+
state.buffer << "<![CDATA[" << capture(&block).gsub("]]>", "]]>]]<![CDATA[") << "]]>"
|
26
|
+
else
|
27
|
+
|
28
|
+
raise Phlex::ArgumentError.new("Expected a String or block.")
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
18
32
|
def tag(name, **attributes, &)
|
19
33
|
state = @_state
|
20
34
|
block_given = block_given?
|
data/lib/phlex/version.rb
CHANGED
data/lib/phlex.rb
CHANGED
metadata
CHANGED
@@ -1,16 +1,16 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: phlex
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.0
|
4
|
+
version: 2.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Joel Drapper
|
8
8
|
- Will Cosgrove
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-03-05 00:00:00.000000000 Z
|
12
12
|
dependencies: []
|
13
|
-
description: Build HTML
|
13
|
+
description: Build HTML, SVG and CSV views with Ruby classes.
|
14
14
|
email:
|
15
15
|
- joel@drapper.me
|
16
16
|
executables: []
|