Module: Restless::Text
- Defined in:
- lib/restless/text.rb
Overview
Shared text primitives: CONTRACT.md section 2.
Everything in here exists because the obvious Ruby spelling silently disagrees with the reference implementation. Read the comments before replacing any of it with a one-liner.
Constant Summary collapse
- WS_CODEPOINTS =
PRIM-002. The whitespace set, enumerated.
Written as code points rather than literal characters because most of them are invisible and several are indistinguishable from a plain space in an editor. This is the JavaScript
\sset. Ruby's\sis ASCII-only and NARROWER (no NBSP, no Zs category, no LS/PS/ZWNBSP), so using it would split an auth-scheme header at a different place and collapse a different set of runs innormalize_message. [ 0x0009, # TAB 0x000A, # LF 0x000B, # VT 0x000C, # FF 0x000D, # CR 0x0020, # SPACE 0x00A0, # NBSP 0x1680, # OGHAM SPACE MARK 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200A, 0x2028, # LINE SEPARATOR 0x2029, # PARAGRAPH SEPARATOR 0x202F, # NARROW NO-BREAK SPACE 0x205F, # MEDIUM MATHEMATICAL SPACE 0x3000, # IDEOGRAPHIC SPACE 0xFEFF # ZERO WIDTH NO-BREAK SPACE # Deliberately NOT U+180E, which left the Zs category in Unicode 6.3. ].freeze
- WS_CHARS =
WS_CODEPOINTS.map { |cp| [cp].pack("U") }.freeze
- WS_SET =
WS_CHARS.each_with_object({}) { |c, h| h[c] = true }.freeze
- WS_CLASS =
The same set as a regex character-class body, for interpolation.
"\\u0009\\u000A\\u000B\\u000C\\u000D\\u0020" \ "\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029" \ "\\u202F\\u205F\\u3000\\uFEFF"
- WORD_CLASS =
PRIM-001. WORD is exactly [A-Za-z0-9_]. Never
\w: ASCII-only in Ruby today, which happens to be right, but spelling it out is what keeps it right. "A-Za-z0-9_"- REPLACEMENT_CHAR =
[0xFFFD].pack("U")
Class Method Summary collapse
-
.bytes_to_utf8(bytes) ⇒ Object
Decode raw bytes as UTF-8, replacing anything ill-formed with U+FFFD.
-
.code_points(str) ⇒ Object
PRIM-010.
-
.full_lower(str) ⇒ Object
PRIM-020.
-
.iso8601_millis(time) ⇒ Object
PRIM-040.
-
.to_utf8(str) ⇒ Object
PRIM-013 / SAFETY-001.
-
.utf8_length(str) ⇒ Object
PRIM-011.
- .ws?(char) ⇒ Boolean
-
.ws_trim(str) ⇒ Object
String.prototype.trimsemantics: strip exactly the PRIM-002 set.
Class Method Details
.bytes_to_utf8(bytes) ⇒ Object
Decode raw bytes as UTF-8, replacing anything ill-formed with U+FFFD.
Byte-for-byte identical to Node's Buffer.from(bytes).toString("utf8"),
including the maximal-subpart rule (a truncated 3-byte sequence yields
ONE U+FFFD, an overlong pair yields two). Verified differentially.
109 110 111 |
# File 'lib/restless/text.rb', line 109 def bytes_to_utf8(bytes) bytes.pack("C*").force_encoding(Encoding::UTF_8).scrub(REPLACEMENT_CHAR) end |
.code_points(str) ⇒ Object
PRIM-010. Code points, not UTF-16 code units and not bytes.
56 57 58 |
# File 'lib/restless/text.rb', line 56 def code_points(str) str.chars end |
.full_lower(str) ⇒ Object
PRIM-020. Unicode FULL, locale-independent lowercase mapping.
String#downcase is exactly that, and the two traps the contract calls
out both come out right: U+0130 becomes i + U+0307 (two code points,
matching JavaScript and Python, unlike Go's SIMPLE mapping), and U+212A
KELVIN SIGN becomes a plain k. It is not casefold, and it applies no
locale tailoring unless one is asked for, so no Turkish dotless-i.
This is the ONLY lowercase in the SDK. Section 4 name normalization used to have its own ASCII-only fold for names; that fold was a redaction bypass (see Redact.normalize_name) and is gone.
What String#downcase does not do is the Final_Sigma contextual rule
that String.prototype.toLowerCase applies: a word-final capital sigma
lowercases to U+03C2 in the reference and to U+03C3 here. PRIM-021
records that as a real difference between the languages and explicitly
does NOT require it, because nothing in this contract can observe it.
Greek is outside WORD, so FP-020 step 6 replaces both sigma forms with a
space before either can reach a token ("ΑΣ failed" and "ΑΣΑ failed"
both normalize to failed either way), and the REDACT-010 denylists are
ASCII. A hand-rolled \pCased / \pCase_Ignorable implementation used
to live here; it was removed rather than carried as code no vector,
fuzzer or caller can distinguish from this one-liner.
136 137 138 |
# File 'lib/restless/text.rb', line 136 def full_lower(str) str.downcase end |
.iso8601_millis(time) ⇒ Object
PRIM-040. YYYY-MM-DDTHH:MM:SS.sssZ, exactly three fractional digits
and a literal Z.
Hand-built: Time#iso8601 needs require "time" plus an explicit digit
count, and emits +00:00 rather than Z unless the receiver is already
UTC. The ingest parses this field permissively and silently falls back to
server time when it cannot, so a wrong format loses real request timing
with no error anywhere in the system.
167 168 169 |
# File 'lib/restless/text.rb', line 167 def iso8601_millis(time) time.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ") end |
.to_utf8(str) ⇒ Object
PRIM-013 / SAFETY-001. Get a valid UTF-8 String out of anything, without raising.
Ruby cannot represent an unpaired surrogate at all (see PRIM-035 and CONFORMANCE.md), so the surrogate substitution the contract describes is structurally impossible here. What IS possible is a String tagged UTF-8 that holds invalid bytes -- Rack hands those out routinely -- so scrub.
BINARY is DECODED, never transcoded. IO#read(n) always returns
ASCII-8BIT and the Rack spec requires rack.input to be opened in binary
mode, so every captured request body arrives here tagged BINARY. Ruby
transcodes BINARY to UTF-8 byte by byte and has no mapping for anything
above 0x7F, so encode turns each byte of a multi-byte character into a
separate U+FFFD: a body of {"toKen":...} with a U+212A KELVIN SIGN came
out as {"to���en":...}. That destroys every non-ASCII
body the SDK captures, and it is a redaction bypass on top of the fold
bug in Redact.normalize_name, because a mangled key no longer matches the
denylist. Bytes tagged BINARY are UTF-8 bytes; decode them like
bytes_to_utf8 does, which is Node's Buffer#toString("utf8").
A String tagged with a REAL other encoding (ISO-8859-1 and friends) is still transcoded, because there the tag is information rather than the absence of one.
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/restless/text.rb', line 88 def to_utf8(str) return "" if str.nil? s = str.is_a?(String) ? str : str.to_s if s.encoding == Encoding::UTF_8 s.valid_encoding? ? s : s.scrub(REPLACEMENT_CHAR) elsif s.encoding == Encoding::ASCII_8BIT s.dup.force_encoding(Encoding::UTF_8).scrub(REPLACEMENT_CHAR) else s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: REPLACEMENT_CHAR) end rescue StandardError "" end |
.utf8_length(str) ⇒ Object
PRIM-011. UTF-8 bytes.
61 62 63 |
# File 'lib/restless/text.rb', line 61 def utf8_length(str) to_utf8(str).bytesize end |
.ws?(char) ⇒ Boolean
140 141 142 |
# File 'lib/restless/text.rb', line 140 def ws?(char) WS_SET.key?(char) end |
.ws_trim(str) ⇒ Object
String.prototype.trim semantics: strip exactly the PRIM-002 set.
Ruby's String#strip removes ASCII whitespace plus NUL and leaves NBSP
and friends in place, which is a different set in both directions.
148 149 150 151 152 153 154 155 156 157 |
# File 'lib/restless/text.rb', line 148 def ws_trim(str) chars = str.chars first = 0 first += 1 while first < chars.length && ws?(chars[first]) last = chars.length - 1 last -= 1 while last >= first && ws?(chars[last]) return "" if last < first chars[first..last].join end |