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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e84eae72307b1f6e6bf3337cd74d1208d3c2fb58f11e7c08eaf6dd0272fbc702
4
- data.tar.gz: a2edd58bd5a1c5185bf9b4222cb0f674fcd488243b4a33c140c92577cd74cbb8
3
+ metadata.gz: b8db189359fc8003771075a598fbeed0c2205ca52604fd829d77afe3c0b3a467
4
+ data.tar.gz: e5446344e9a83c982fdfa94d4d31abd4b7511858704cc2e9f9d285ad9740b9c3
5
5
  SHA512:
6
- metadata.gz: 22c542f19b5121666bca2f3bf661ff05488c21d5b593578f704dbe9f69643d48b839deddd9898214638a21dc7fb9de56dcb750f7401082710a13a5544668a643
7
- data.tar.gz: a81c55fb957fa70907fac519b08c072521701908385227f93284219de34e0338a48276faff36ef54ba60d552511a579a734725fcbfea7b6d302edbaaf9e94c54
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
- FORMULA_PREFIXES = Set["=", "+", "-", "@", "\t", "\r"].freeze
5
- SPACE_CHARACTERS = Set[" ", "\t", "\r"].freeze
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
- unless escape_csv_injection? == true || escape_csv_injection? == false
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
- CSV injection is a security vulnerability where malicious spreadsheet formulae are used to execute code or exfiltrate data when a CSV is opened in a spreadsheet program such as Microsoft Excel or Google Sheets.
23
-
24
- For more information, see https://owasp.org/www-community/attacks/CSV_Injection
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
- If you're sure this CSV will never be opened in a spreadsheet program, you can disable CSV injection escapes:
27
-
28
- def escape_csv_injection? = false
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
- Alternatively, you can enable CSV injection escapes at the cost of data integrity:
35
+ if strip_whitespace
36
+ escape_regex = /[\n"#{delimiter}]/
37
+ else
38
+ escape_regex = /^\s|\s$|[\n"#{delimiter}]/
39
+ end
33
40
 
34
- def escape_csv_injection? = true
41
+ if has_yielder
42
+ warn <<~MESSAGE
43
+ Custom yielders are deprecated in Phlex::CSV.
35
44
 
36
- Note: Enabling the CSV injection escapes will prefix any values that start with `=`, `+`, `-`, `@`, `\\t`, or `\\r` with a single quote `'` to prevent them from being interpreted as formulae by spreadsheet programs.
45
+ Please replace your yielder with an `around_row` method.
37
46
 
38
- Unfortunately, there is no one-size-fits-all solution to CSV injection. You need to decide based on your specific use case.
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
- yielder(record) do |*args, **kwargs|
44
- view_template(*args, **kwargs)
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
- if @_first && render_headers?
47
- buffer << @_headers.join(",") << "\n"
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 << @_current_row.join(",") << "\n"
51
- @_current_column_index = 0
52
- @_current_row.clear
104
+ __escape__(buffer, value, escape_csv_injection:, strip_whitespace:, escape_regex:)
105
+ i += 1
53
106
  end
54
107
 
55
- @_first = false
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
- if @_first
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
- nil
154
+ UNDEFINED
103
155
  end
104
156
 
105
- def __escape__(value)
106
- value = trim_whitespace? ? value.to_s.strip : value.to_s
107
- first_char = value[0]
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
- if name[0] == name[0].upcase && mod.constants.include?(name) && mod.const_get(name) && methods.include?(name)
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
- if name[0] == name[0].upcase && constants.include?(name) && const_get(name) && methods.include?(name)
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)
@@ -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}" << (Phlex::ATTRIBUTE_CACHE[attributes] ||= __attributes__(attributes)) << ">"
25
-
26
- original_length = buffer.bytesize
27
- content = yield(self)
28
- if original_length == buffer.bytesize
29
- case content
30
- when ::Phlex::SGML::SafeObject
31
- buffer << content.to_s
32
- when String
33
- buffer << ::Phlex::Escape.html_escape(content)
34
- when Symbol
35
- buffer << ::Phlex::Escape.html_escape(content.name)
36
- when nil
37
- nil
38
- else
39
- if (formatted_object = format_object(content))
40
- buffer << ::Phlex::Escape.html_escape(formatted_object)
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}" << (::Phlex::ATTRIBUTE_CACHE[attributes] ||= __attributes__(attributes)) << "></#{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
- original_length = buffer.bytesize
54
- content = yield(self)
55
- if original_length == buffer.bytesize
56
- case content
57
- when ::Phlex::SGML::SafeObject
58
- buffer << content.to_s
59
- when String
60
- buffer << ::Phlex::Escape.html_escape(content)
61
- when Symbol
62
- buffer << ::Phlex::Escape.html_escape(content.name)
63
- when nil
64
- nil
65
- else
66
- if (formatted_object = format_object(content))
67
- buffer << ::Phlex::Escape.html_escape(formatted_object)
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
- state.buffer << "<#{tag}" << (::Phlex::ATTRIBUTE_CACHE[attributes] ||= __attributes__(attributes)) << ">"
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
- state.buffer << "<#{tag}>"
122
+ buffer << "<#{tag}>"
102
123
  end
103
124
 
104
125
  nil
@@ -12,10 +12,19 @@ class Phlex::SGML::State
12
12
  @output_buffer = output_buffer
13
13
  end
14
14
 
15
- attr_accessor :buffer, :capturing, :user_context
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
@@ -288,6 +288,10 @@ class Phlex::SGML
288
288
  end
289
289
  end
290
290
 
291
+ def json_escape(string)
292
+ ERB::Util.json_escape(string)
293
+ end
294
+
291
295
  private
292
296
 
293
297
  # Override this method to use a different deployment key.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phlex
4
- VERSION = "2.0.2"
4
+ VERSION = "2.1.0"
5
5
  end
data/lib/phlex.rb CHANGED
@@ -4,6 +4,8 @@ require "erb"
4
4
  require "set"
5
5
 
6
6
  module Phlex
7
+ autoload :VERSION, "phlex/version"
8
+
7
9
  autoload :Kit, "phlex/kit"
8
10
  autoload :FIFO, "phlex/fifo"
9
11
  autoload :Vanish, "phlex/vanish"
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.2
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-02-16 00:00:00.000000000 Z
11
+ date: 2025-03-05 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: Build HTML & SVG view components with Ruby classes.
13
+ description: Build HTML, SVG and CSV views with Ruby classes.
14
14
  email:
15
15
  - joel@drapper.me
16
16
  executables: []