tina4ruby 3.13.87 → 3.13.89

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 (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/tina4/frond.rb +152 -14
  3. data/lib/tina4/version.rb +1 -1
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7743996e98fff70baf3c82a0f2a26dfafb24cb0c9ddc46cfc6a3be5a1df4c92f
4
- data.tar.gz: 2b747045e71df0e69e7d6bdd3cb2b19309fcea16617770982b5743a4c6db9edc
3
+ metadata.gz: c9a4969bc3af1b2a6f203d215043c4fb9497aa40e71a0f7d1c17d92eddeecea3
4
+ data.tar.gz: 67a4a34e48ca3e167201ab06ac5cab96be36b4f4224a97f04cae0c58f700184c
5
5
  SHA512:
6
- metadata.gz: f94e1f6feeda985b8c5091c0235181bb34165f4ee42120fa808b5b8a39cd8deaabc8ae9fe68a9c05d33598d184680d3b49c40f9791b1f0d2fb57689a87b73054
7
- data.tar.gz: 6d31b432dddc04f237bddd28e6580d294c9427a22b0fda2947a9e2d9e3aba469b2fd06cdb2e0560d8edb60f1383c6916ba407a7efb257202d7cb95e381514083
6
+ metadata.gz: a89938265a10f5b8f0a48966f370c55006937a236285fe6b8f2fd9c5f75464d5c4fc0fc65cf1583f76539e24cd095701e3c092d87fb9d1197978d1d9774f552c
7
+ data.tar.gz: af228d8eebfab34598468e44f6962ac701280d4ad1207e4184f2eee6aabc631c5dda36753de7c99b1e8aea4dde7cff635fa2c44c30fff469532124cab0d9f11a
data/lib/tina4/frond.rb CHANGED
@@ -143,6 +143,26 @@ module Tina4
143
143
  IMPORT_AS_RE = /\Aimport\s+["'](.+?)["']\s+as\s+(\w+)/
144
144
  CACHE_RE = /\Acache\s+["'](.+?)["']\s*(\d+)?/
145
145
  SPACELESS_RE = />\s+</
146
+
147
+ # Every tag that OPENS a construct. An unknown tag is a typo, and 3.13.89
148
+ # makes it raise rather than render its body: a mistyped guard --
149
+ # {% iff is_admin %} instead of {% if is_admin %} -- used to render the gated
150
+ # content UNCONDITIONALLY, so a reviewer saw a guard that was not there. Twig
151
+ # and Jinja2 both raise on an unknown tag; Frond now does too. There is no
152
+ # user-extension point for tags in any of the four frameworks, so an unknown
153
+ # name is always a mistake, never a plugin.
154
+ KNOWN_TAGS = %w[
155
+ autoescape block cache extends for from if import include live macro raw set
156
+ spaceless
157
+ ].freeze
158
+
159
+ # Terminators and branch keywords. These reach the tag dispatch only when
160
+ # stray (their own collector consumes them in the normal case), and a stray
161
+ # one keeps the old render-nothing behaviour -- see the comment at the raise.
162
+ TERMINATOR_TAGS = %w[
163
+ elif else elseif endautoescape endblock endcache endfor endif endlive
164
+ endmacro endraw endset endspaceless
165
+ ].freeze
146
166
  AUTOESCAPE_RE = /\Aautoescape\s+(false|true)/
147
167
  STRIPTAGS_RE = /<[^>]+>/
148
168
  THOUSANDS_RE = /(\d)(?=(\d{3})+(?!\d))/
@@ -429,6 +449,66 @@ module Tina4
429
449
  str.to_s.gsub(HTML_ESCAPE_RE, HTML_ESCAPE_MAP)
430
450
  end
431
451
 
452
+ # Serializes a value to compact JSON text that is always valid JSON.
453
+ #
454
+ # Never raises and never returns an empty string: a non-finite float becomes
455
+ # null (the JSON spec has no Infinity or NaN) and malformed UTF-8 is scrubbed,
456
+ # so a payload always arrives, in the worst case as null.
457
+ def self.json_text(value)
458
+ JSON.generate(value)
459
+ rescue StandardError
460
+ # Only reached when the happy path raised, so a well-formed payload never
461
+ # pays for the walk.
462
+ begin
463
+ JSON.generate(json_sanitize(value))
464
+ rescue StandardError
465
+ "null"
466
+ end
467
+ end
468
+
469
+ # Replaces anything JSON.generate refuses with a JSON-representable stand-in.
470
+ def self.json_sanitize(value)
471
+ case value
472
+ when Float then value.finite? ? value : nil
473
+ when Hash then value.transform_values { |item| json_sanitize(item) }
474
+ when Array then value.map { |item| json_sanitize(item) }
475
+ when String then value.valid_encoding? ? value : value.scrub
476
+ else value
477
+ end
478
+ end
479
+
480
+ # Serializes to JSON that is valid JSON, valid JavaScript, and safe in HTML.
481
+ #
482
+ # THE cross-framework contract for json_encode / to_json / tojson. Keep the
483
+ # four implementations byte-identical; frond_expression_corpus.txt locks it.
484
+ #
485
+ # Three things this must never do, each of which was a real bug:
486
+ #
487
+ # 1. Never emit a non-finite literal. JSON.generate raises on Infinity, and
488
+ # the old `rescue v.to_s` turned that raise into Ruby inspect output --
489
+ # `{"a" => 1.0}` -- which no JSON.parse will read. Reported as
490
+ # tina4-php#184 by justin-k-bruce, who hit the same class of bug in PHP.
491
+ # 2. Never emit nothing, and never emit something that still parses and means
492
+ # something else. "var ROWS = ;" is at least a loud SyntaxError.
493
+ # 3. Never HTML-escape it. Entity-encoding JSON produces {&quot;a&quot;:1},
494
+ # a SyntaxError inside <script>, which is the filter's whole point. Escape
495
+ # only the dangerous characters, as JSON \uXXXX escapes: the result stays
496
+ # valid JSON AND valid JavaScript, </script> cannot terminate the block,
497
+ # and it is safe inside a single-quoted attribute. This is what Jinja2's
498
+ # tojson does, and it is why the result is a SafeString.
499
+ #
500
+ # U+2028 and U+2029 join that escape set. Both are legal inside a JSON string
501
+ # and both were illegal inside a JavaScript string literal before ES2019.
502
+ def self.json_safe(value)
503
+ Tina4::SafeString.new(json_text(value).gsub(JSON_ESCAPE_RE, JSON_ESCAPE_MAP))
504
+ end
505
+
506
+ JSON_ESCAPE_MAP = {
507
+ "<" => "\\u003c", ">" => "\\u003e", "&" => "\\u0026", "'" => "\\u0027",
508
+ "\u2028" => "\\u2028", "\u2029" => "\\u2029"
509
+ }.freeze
510
+ JSON_ESCAPE_RE = /[<>&'\u2028\u2029]/
511
+
432
512
  private
433
513
 
434
514
  # Keep a memo cache bounded (ADR-0004). Call immediately before inserting
@@ -653,8 +733,17 @@ module Tina4
653
733
  result, i = handle_for(tokens, i, context)
654
734
  output << result
655
735
  when "set"
656
- handle_set(content, context)
657
- i += 1
736
+ # An assignment has an "="; without one this is the BLOCK form,
737
+ # {% set name %}...{% endset %}, which captures its rendered body.
738
+ # A bare include? is exact here, not a shortcut: the block form's tag
739
+ # content is only ever "set <name>", so an "=" anywhere -- even inside
740
+ # a quoted value like {% set m = "a = b" %} -- means assignment.
741
+ if content.include?("=")
742
+ handle_set(content, context)
743
+ i += 1
744
+ else
745
+ i = handle_set_block(tokens, i, context)
746
+ end
658
747
  when "include"
659
748
  if @sandbox && @allowed_tags && !@allowed_tags.include?("include")
660
749
  i += 1
@@ -686,6 +775,15 @@ module Tina4
686
775
  i += 1
687
776
  else
688
777
  i += 1
778
+ unless tag.empty? || TERMINATOR_TAGS.include?(tag)
779
+ raise ArgumentError,
780
+ %(Frond: unknown tag "#{tag}" -- known tags are: #{KNOWN_TAGS.sort.join(", ")})
781
+ end
782
+ # An empty tag ({% %}) or a stray terminator (an {% endif %} with
783
+ # no {% if %}): no output.
784
+ # Malformed, but it has always rendered nothing, and nothing is the
785
+ # safe answer -- unlike an unknown tag it cannot expose content that
786
+ # was meant to be gated.
689
787
  end
690
788
 
691
789
  if strip_a && i < tokens.length && tokens[i][0] == TEXT
@@ -2284,6 +2382,48 @@ module Tina4
2284
2382
  )
2285
2383
  end
2286
2384
 
2385
+ # {% set name %}...{% endset %} -- render the body and bind it.
2386
+ #
2387
+ # Emits nothing itself. The captured value is a SafeString because it is
2388
+ # template output that has already been escaped on the way in; re-escaping it
2389
+ # at {{ name }} would double-encode every entity. Twig and Jinja2 both mark
2390
+ # the capture safe. Returns the index just past {% endset %}.
2391
+ def handle_set_block(tokens, start, context)
2392
+ content, _, _ = strip_tag(tokens[start][1])
2393
+ name = (content.split[1] || "").strip
2394
+
2395
+ body_tokens = []
2396
+ i = start + 1
2397
+ depth = 0
2398
+ while i < tokens.length
2399
+ if tokens[i][0] == BLOCK
2400
+ tc, _, _ = strip_tag(tokens[i][1])
2401
+ tag = tc.split[0] || ""
2402
+ if tag == "set" && !tc.include?("=")
2403
+ depth += 1
2404
+ body_tokens << tokens[i]
2405
+ elsif tag == "endset"
2406
+ if depth.zero?
2407
+ i += 1
2408
+ break
2409
+ end
2410
+ depth -= 1
2411
+ body_tokens << tokens[i]
2412
+ else
2413
+ body_tokens << tokens[i]
2414
+ end
2415
+ else
2416
+ body_tokens << tokens[i]
2417
+ end
2418
+ i += 1
2419
+ end
2420
+
2421
+ unless name.empty?
2422
+ context[name] = Tina4::SafeString.new(render_tokens(body_tokens.dup, context))
2423
+ end
2424
+ i
2425
+ end
2426
+
2287
2427
  def handle_spaceless(tokens, start, context)
2288
2428
  body_tokens = []
2289
2429
  i = start + 1
@@ -2406,7 +2546,10 @@ module Tina4
2406
2546
  "e" => ->(v, *_a) { Tina4::SafeString.new(Frond.escape_html(v.to_s)) },
2407
2547
  "raw" => ->(v, *_a) { v },
2408
2548
  "safe" => ->(v, *_a) { v },
2409
- "json_encode" => ->(v, *_a) { JSON.generate(v) rescue v.to_s },
2549
+ # Frond.json_safe, never a bare JSON.generate: generate RAISES on an
2550
+ # Infinity/NaN float, and the old `rescue v.to_s` answered that raise with
2551
+ # Ruby inspect output that no JSON.parse will read. See Frond.json_safe.
2552
+ "json_encode" => ->(v, *_a) { Frond.json_safe(v) },
2410
2553
  "json_decode" => ->(v, *_a) { v.is_a?(String) ? (JSON.parse(v) rescue v) : v },
2411
2554
  "base64_encode" => ->(v, *_a) { Base64.strict_encode64(v.is_a?(String) ? v : v.to_s) },
2412
2555
  "base64encode" => ->(v, *_a) { Base64.strict_encode64(v.is_a?(String) ? v : v.to_s) },
@@ -2425,17 +2568,12 @@ module Tina4
2425
2568
  "url_encode" => ->(v, *_a) { ERB::Util.url_encode(v.to_s) },
2426
2569
 
2427
2570
  # -- JSON / JS --
2428
- "to_json" => ->(v, *a) {
2429
- indent = a[0] ? a[0].to_i : nil
2430
- json = indent ? JSON.pretty_generate(v) : JSON.generate(v)
2431
- # Escape <, >, & for safe HTML embedding
2432
- Tina4::SafeString.new(json.gsub("<", '\u003c').gsub(">", '\u003e').gsub("&", '\u0026'))
2433
- },
2434
- "tojson" => ->(v, *a) {
2435
- indent = a[0] ? a[0].to_i : nil
2436
- json = indent ? JSON.pretty_generate(v) : JSON.generate(v)
2437
- Tina4::SafeString.new(json.gsub("<", '\u003c').gsub(">", '\u003e').gsub("&", '\u0026'))
2438
- },
2571
+ # Same serializer as json_encode -- the three names are one behaviour.
2572
+ # The old indent argument is gone: PHP cannot honour an arbitrary indent
2573
+ # (JSON_PRETTY_PRINT is fixed at four spaces), so honouring it here alone
2574
+ # broke byte-parity for the one filter whose whole job is a wire format.
2575
+ "to_json" => ->(v, *_a) { Frond.json_safe(v) },
2576
+ "tojson" => ->(v, *_a) { Frond.json_safe(v) },
2439
2577
  "js_escape" => ->(v, *_a) {
2440
2578
  Tina4::SafeString.new(
2441
2579
  v.to_s.gsub("\\", "\\\\").gsub("'", "\\'").gsub('"', '\\"')
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.87"
4
+ VERSION = "3.13.89"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.87
4
+ version: 3.13.89
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team