Module: SpecGuard::RSpec::PayloadNormalizer

Defined in:
lib/specguard/rspec/payload_normalizer.rb

Overview

Relaxes PROTOCOL.md §1's permissive annotation syntax into strict JSON.

§1 promises "the linter normalizes before validating" and lists three equivalent forms. This converts all of them:

* unquoted keys         `{entity:"Order"}`   -> `{"entity":"Order"}`
* single-quoted strings `{'entity':'Order'}` -> `{"entity":"Order"}`
* arbitrary whitespace  (`JSON.parse` already tolerates it)

plus a trailing comma before }/] as a courtesy.

Why this is a character scanner, not a gsub

It is a character-scanner rather than a regex substitution so quoted content is never rewritten — a behavior sentence containing it's or { is passed through untouched. Any gsub-then-JSON.parse port corrupts exactly those values; the examples under "quoted content is never rewritten" in payload_normalizer_spec.rb pin them.

The payload is never eval'd. It is attacker-controllable text taken from a comment in someone's spec file, so it only ever reaches JSON.parse, which cannot execute anything.

The bare-word rule

A bare word is quoted only in key position. A bare word used as a value ({layer: request}) is left alone so it still fails downstream — the protocol relaxes keys and quote style, never value quoting.

Ported from open-test-intent's bin/validate-intent (normalize_payload / _requote).

Constant Summary collapse

BARE_WORD =

Anchored at the scan position (\G), so it matches AT i rather than searching from it. Explicit ASCII ranges, per PROTOCOL.md §1 — a Unicode word class would make the accepted surface syntax depend on the regex engine, which is what one specification exists to prevent.

/\G[A-Za-z_$][A-Za-z0-9_$]*/.freeze

Class Method Summary collapse

Class Method Details

.closes_after?(raw, pos) ⇒ Boolean

True when the next non-space character at or after pos closes a bracket, i.e. the comma just scanned was a trailing comma.

Returns:

  • (Boolean)


132
133
134
135
# File 'lib/specguard/rspec/payload_normalizer.rb', line 132

def closes_after?(raw, pos)
  probe = skip_space(raw, pos)
  probe < raw.length && (raw[probe] == "}" || raw[probe] == "]")
end

.key_position?(raw, pos) ⇒ Boolean

True when the next non-space character after pos is a :, i.e. the bare word just scanned is a key rather than a value.

Returns:

  • (Boolean)


125
126
127
128
# File 'lib/specguard/rspec/payload_normalizer.rb', line 125

def key_position?(raw, pos)
  probe = skip_space(raw, pos)
  probe < raw.length && raw[probe] == ":"
end

.normalize(raw) ⇒ String

Returns strict JSON, ready for JSON.parse.

Parameters:

  • raw (String)

    the object literal as written in the source

Returns:

  • (String)

    strict JSON, ready for JSON.parse

Raises:

  • (ScanError)

    on an unterminated string literal



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/specguard/rspec/payload_normalizer.rb', line 50

def normalize(raw)
  out = +""
  i = 0
  length = raw.length

  while i < length
    char = raw[i]

    # Already-strict double-quoted string: copy verbatim, contents and all.
    if char == '"'
      finish = AnnotationScanner.scan_string(raw, i, '"')
      out << raw[i...finish]
      i = finish
      next
    end

    # Single-quoted string: re-emit its body as a JSON string.
    if char == "'"
      finish = AnnotationScanner.scan_string(raw, i, "'")
      out << requote(raw[(i + 1)...(finish - 1)])
      i = finish
      next
    end

    if (match = BARE_WORD.match(raw, i))
      word = match[0]
      after = match.end(0)
      out << (key_position?(raw, after) ? JSON.generate(word) : word)
      i = after
      next
    end

    # Trailing comma before a closing bracket: drop it.
    if char == "," && closes_after?(raw, i + 1)
      i += 1
      next
    end

    out << char
    i += 1
  end

  out
end

.requote(body) ⇒ String

Re-emits the body of a single-quoted string as a double-quoted JSON string, preserving every escape that JSON understands.

Parameters:

  • body (String)

    the string's contents, without its surrounding quotes

Returns:

  • (String)

    a complete JSON string literal, quotes included



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/specguard/rspec/payload_normalizer.rb', line 100

def requote(body)
  out = +""
  i = 0
  length = body.length

  while i < length
    char = body[i]

    if char == "\\"
      nxt = i + 1 < length ? body[i + 1] : ""
      # `\'` is meaningless in JSON — unescape it; keep every other escape.
      out << (nxt == "'" ? "'" : char + nxt)
      i += 2
      next
    end

    out << (char == '"' ? '\"' : char)
    i += 1
  end

  %("#{out}")
end

.skip_space(raw, pos) ⇒ Object



137
138
139
140
# File 'lib/specguard/rspec/payload_normalizer.rb', line 137

def skip_space(raw, pos)
  pos += 1 while pos < raw.length && raw[pos].match?(/\s/)
  pos
end