basecamp-sdk 0.18.0 → 0.19.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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/basecamp/api_error.rb +5 -3
  4. data/lib/basecamp/bucket_mismatch_error.rb +41 -0
  5. data/lib/basecamp/campfire_discovery_incomplete_error.rb +57 -0
  6. data/lib/basecamp/campfire_index.rb +398 -0
  7. data/lib/basecamp/client.rb +47 -4
  8. data/lib/basecamp/generated/metadata.json +728 -1
  9. data/lib/basecamp/generated/services/account_service.rb +2 -2
  10. data/lib/basecamp/generated/services/attachments_service.rb +1 -1
  11. data/lib/basecamp/generated/services/automation_service.rb +1 -1
  12. data/lib/basecamp/generated/services/bookmarks_service.rb +2 -2
  13. data/lib/basecamp/generated/services/boosts_service.rb +3 -3
  14. data/lib/basecamp/generated/services/calendars_service.rb +2 -2
  15. data/lib/basecamp/generated/services/campfires_service.rb +7 -7
  16. data/lib/basecamp/generated/services/card_columns_service.rb +6 -6
  17. data/lib/basecamp/generated/services/card_steps_service.rb +4 -4
  18. data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
  19. data/lib/basecamp/generated/services/cards_service.rb +3 -3
  20. data/lib/basecamp/generated/services/checkins_service.rb +9 -9
  21. data/lib/basecamp/generated/services/client_approvals_service.rb +1 -1
  22. data/lib/basecamp/generated/services/client_correspondences_service.rb +1 -1
  23. data/lib/basecamp/generated/services/client_replies_service.rb +1 -1
  24. data/lib/basecamp/generated/services/client_visibility_service.rb +1 -1
  25. data/lib/basecamp/generated/services/cloud_files_service.rb +3 -3
  26. data/lib/basecamp/generated/services/comments_service.rb +3 -3
  27. data/lib/basecamp/generated/services/documents_service.rb +3 -3
  28. data/lib/basecamp/generated/services/event_feed_service.rb +58 -0
  29. data/lib/basecamp/generated/services/everything_service.rb +2 -2
  30. data/lib/basecamp/generated/services/folders_service.rb +4 -4
  31. data/lib/basecamp/generated/services/forwards_service.rb +3 -3
  32. data/lib/basecamp/generated/services/gauges_service.rb +10 -6
  33. data/lib/basecamp/generated/services/google_documents_service.rb +3 -3
  34. data/lib/basecamp/generated/services/hill_charts_service.rb +2 -2
  35. data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
  36. data/lib/basecamp/generated/services/message_types_service.rb +3 -3
  37. data/lib/basecamp/generated/services/messages_service.rb +3 -3
  38. data/lib/basecamp/generated/services/my_assignments_service.rb +3 -3
  39. data/lib/basecamp/generated/services/my_notes_service.rb +2 -2
  40. data/lib/basecamp/generated/services/my_notifications_service.rb +1 -1
  41. data/lib/basecamp/generated/services/people_service.rb +11 -11
  42. data/lib/basecamp/generated/services/projects_service.rb +4 -4
  43. data/lib/basecamp/generated/services/recordings_service.rb +1 -1
  44. data/lib/basecamp/generated/services/reports_service.rb +3 -3
  45. data/lib/basecamp/generated/services/schedules_service.rb +6 -6
  46. data/lib/basecamp/generated/services/search_service.rb +1 -1
  47. data/lib/basecamp/generated/services/subscriptions_service.rb +3 -3
  48. data/lib/basecamp/generated/services/templates_service.rb +8 -8
  49. data/lib/basecamp/generated/services/timesheets_service.rb +4 -4
  50. data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
  51. data/lib/basecamp/generated/services/todolists_service.rb +3 -3
  52. data/lib/basecamp/generated/services/todos_service.rb +4 -4
  53. data/lib/basecamp/generated/services/todosets_service.rb +1 -1
  54. data/lib/basecamp/generated/services/tools_service.rb +3 -3
  55. data/lib/basecamp/generated/services/uploads_service.rb +4 -4
  56. data/lib/basecamp/generated/services/vaults_service.rb +3 -3
  57. data/lib/basecamp/generated/services/webhooks_service.rb +3 -3
  58. data/lib/basecamp/generated/services/wormholes_service.rb +2 -2
  59. data/lib/basecamp/generated/types.rb +108 -6
  60. data/lib/basecamp/http.rb +383 -60
  61. data/lib/basecamp/ids.rb +277 -0
  62. data/lib/basecamp/mentions.rb +1119 -0
  63. data/lib/basecamp/person_id_sites.rb +184 -0
  64. data/lib/basecamp/recording_routing_error.rb +50 -0
  65. data/lib/basecamp/recording_summary_error.rb +33 -0
  66. data/lib/basecamp/services/comments_extensions.rb +121 -0
  67. data/lib/basecamp/services/merge_safe.rb +37 -13
  68. data/lib/basecamp/services/recordings_extensions.rb +1305 -0
  69. data/lib/basecamp/services/todolists_extensions.rb +4 -3
  70. data/lib/basecamp/text.rb +94 -0
  71. data/lib/basecamp/unresolved_recording_error.rb +68 -0
  72. data/lib/basecamp/version.rb +1 -1
  73. data/lib/basecamp/webhooks/event.rb +4 -1
  74. data/lib/basecamp.rb +22 -4
  75. data/scripts/generate-metadata.rb +75 -2
  76. data/scripts/generate-services.rb +35 -8
  77. data/scripts/generate-types.rb +19 -0
  78. metadata +15 -2
@@ -0,0 +1,1119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Basecamp
8
+ # Mention helpers over Basecamp rich text.
9
+ #
10
+ # A mention in Basecamp rich text is a +<bc-attachment>+ whose +sgid+
11
+ # attribute is the mentioned person's +attachable_sgid+
12
+ # (doc/api/sections/rich_text.md, "Inserting a mention"). BC3 renders the
13
+ # same tag back with +content-type="application/vnd.basecamp.mention"+ and an
14
+ # avatar figure inside it, but the sgid is the only part of the markup that
15
+ # names the person on both the write and the read side, so every helper here
16
+ # works from it. There are FIVE, named rather than counted, because an earlier
17
+ # version of this paragraph said "both helpers" above a list of two while the
18
+ # module exposed five — a sentence that was true when it was written and was
19
+ # not corrected when the list grew under it:
20
+ #
21
+ # * {mentioned_person_ids} reads the person ids a rich text names, by
22
+ # decoding the sgid of every +<bc-attachment>+ and keeping the ones that
23
+ # point at a Person.
24
+ # * {person_id_from_sgid} decodes one sgid on its own, and is what the reader
25
+ # above is built from.
26
+ # * {mention_markup} writes the tag for a person, from their
27
+ # +attachable_sgid+.
28
+ # * {with_mentions} places those tags into existing content, adding nothing
29
+ # for a person whose exact sgid the content already carries.
30
+ # * {bc_attachment_sgids} returns the raw sgid of every +<bc-attachment>+,
31
+ # decoding nothing. The reference keeps its equivalent UNEXPORTED; it is
32
+ # public here for a caller deduplicating its own writes, and that is the one
33
+ # piece of surface this module has that the contract does not. Read the
34
+ # trust boundary below before reaching for it: an sgid it returns is
35
+ # unsigned, so it describes what a text claims and never proves anything.
36
+ #
37
+ # An +attachable_sgid+ is a Rails SignedGlobalID: a base64 payload, then
38
+ # <tt>--</tt>, then an HMAC only BC3 can verify. The payload is an envelope
39
+ # carrying the global id — <tt>gid://bc3/Person/1049715915</tt> — as a string,
40
+ # and that string is what these helpers read. They do not (and cannot) verify
41
+ # the signature; what they decode is the same person id BC3 renders into the
42
+ # mention's avatar, read off content the API already served, and a caller that
43
+ # needs the id verified reads the person back through +people.get+.
44
+ #
45
+ # That sets a trust boundary between the two sides. READING —
46
+ # {mentioned_person_ids}, {person_id_from_sgid} — describes what a text says
47
+ # it mentions, and unsigned is fine for description: the ids are reported, not
48
+ # acted on as proof. WRITING — {with_mentions},
49
+ # +CommentsExtensions#expand_mentions+ — never treats an unsigned id as proof
50
+ # that a valid mention already exists: a forged or stale sgid in
51
+ # caller-supplied content naming the right id would otherwise make the writer
52
+ # skip the authoritative people read and post a tag Basecamp will not honour,
53
+ # so the person is silently not mentioned. +expand_mentions+ therefore
54
+ # resolves every requested person through +people.get+ and deduplicates only
55
+ # against the exact +attachable_sgid+ string that read returned. The pure
56
+ # helpers beneath it — {with_mentions}, {mention_markup} — take person hashes
57
+ # the caller built and can only check that an sgid is well-formed and names
58
+ # the person it is given, never that it is authentic: hand them people the API
59
+ # returned, not people assembled from content. Do not reuse the read-side
60
+ # helpers to decide whether a write can be skipped.
61
+ #
62
+ # The markup is read as BC3 serves it: a sanitized tree of the tags
63
+ # doc/api/sections/rich_text.md allows, which has no raw-text elements. The
64
+ # tag walk skips comments and quoted attribute values but does not model
65
+ # +<script>+ or +<style>+ content, which BC3 strips on write; a caller reading
66
+ # mentions out of content it authored itself should not put a bc-attachment
67
+ # inside such an element and expect it ignored.
68
+ #
69
+ # The envelope is decoded structurally, never searched as bytes, so a Person
70
+ # gid that merely appears inside some other value — a Document gid built from
71
+ # one, a purpose string that looks like one — is not a mention, and the
72
+ # envelope's purpose must be "attachable", the one BC3 accepts in rich text.
73
+ # Three envelopes are read: Rails' current Marshal layout
74
+ # <tt>{"_rails" => {"data" => gid, "pur" => purpose}}</tt>, the older Marshal
75
+ # layout <tt>{"gid" => gid, "purpose" => …, "expires_at" => …}</tt>, and the
76
+ # JSON spelling of either, which Rails' JSON message serializer emits.
77
+ #
78
+ # Marshal payloads are decoded by {MarshalReader}, a reader for the small
79
+ # subset of the format an envelope uses — never +Marshal.load+, which would
80
+ # instantiate arbitrary objects out of attacker-supplied rich text.
81
+ module Mentions
82
+ # The SignedGlobalID purpose BC3 mints attachable sgids with
83
+ # (doc/api/sections/rich_text.md: +attachable_sgid+). Pinned by the purpose
84
+ # cases in the mention tests, so a rename upstream breaks a test here rather
85
+ # than silently turning every mention invisible.
86
+ SGID_PURPOSE_ATTACHABLE = "attachable"
87
+
88
+ # Bounds the decoded sgid payload. A Person sgid's payload is under 200
89
+ # bytes; the cap keeps a hostile one from costing more than its own size to
90
+ # reject.
91
+ MAX_SGID_PAYLOAD_BYTES = 4096
92
+
93
+ # The same bound on the base64 form (4/3 of the payload, plus padding),
94
+ # checked before anything is allocated.
95
+ MAX_SGID_ENCODED_BYTES = (MAX_SGID_PAYLOAD_BYTES / 3 * 4) + 4
96
+
97
+ # Characters that may follow "<" in a tag name. The whole name is consumed,
98
+ # punctuation included, so "<bc-attachment:preview" or "<bc-attachment_x" is
99
+ # its own name and never compares equal to "bc-attachment".
100
+ SPACE_CHARS = [ " ", "\t", "\n", "\r", "\f" ].freeze
101
+
102
+ # A percent-escape in the authority that names an ASCII byte, which the
103
+ # reference refuses — "%25" excepted, since it names the percent itself.
104
+ # Above ASCII an escape is accepted by both.
105
+ #
106
+ # MEASURED, not derived from the reference's parser: every printable byte
107
+ # was planted mid-host and swept, which is also what showed that the rule
108
+ # has an exception at all. Without this, 95 hosts resolved here that the
109
+ # reference refuses — the accepting direction, and on the write side the
110
+ # only authenticity-adjacent check is whether an sgid names the person it
111
+ # is given.
112
+ #
113
+ # A RESIDUE remains, and it is a residue rather than a list: the two URI
114
+ # parsers disagree about which authorities are well-formed, and every
115
+ # attempt to enumerate that disagreement here has been wrong — first a
116
+ # count, then a set of categories, then an absolute ("every member") that a
117
+ # single shape falsified. What is true, and checked by the harness rather
118
+ # than asserted here, is the SHAPE of it: Ruby's parser is stricter on some
119
+ # authorities the reference tolerates, and the residue is now ENTIRELY in
120
+ # that direction. It was not always: the RFC 3986 IPvFuture literal
121
+ # <tt>[v7.x]</tt> resolved here and nowhere else, and is refused explicitly
122
+ # now (see {IPVFUTURE_AUTHORITY}). Swept afterwards for any other
123
+ # accepting-direction row across every corpus and BOTH stages — 1,917 sgids
124
+ # through {person_id_from_sgid}, and 33,745 documents through
125
+ # {mentioned_person_ids}, 35,662 in all: none.
126
+ #
127
+ # The two stages are counted separately on purpose. An earlier version of
128
+ # this sentence said "13,700 inputs", which was the walker corpora's total
129
+ # borrowed for a claim about a sweep that had only run over the sgid ones —
130
+ # a number measured through a different stage than the claim was about.
131
+ #
132
+ # This rule was verified at every POSITION an escape can occupy, because a
133
+ # rule checked in one position is not evidence about another — the reference
134
+ # runs a different validator on each. 133 escapes at six positions: one per
135
+ # ASCII byte, which is 128, plus five that exercise the rule's own edges
136
+ # (<tt>%C3</tt>, <tt>%80</tt>, <tt>%FF</tt> above ASCII, and <tt>%ff</tt> and
137
+ # <tt>%2a</tt> for hex case). userinfo, host, port, path and the id agree
138
+ # exactly. The IPv6 ZONE diverges on 86 of the 133, every one of them the
139
+ # reference resolving and this refusing, because Ruby's parser has no
140
+ # RFC 6874 zone grammar. That is the parser's gap rather than this rule's,
141
+ # and it fails closed.
142
+ #
143
+ # The composition is spelled out because the count was not derivable from
144
+ # the description before it: "one escape per ASCII byte" accounts for 128,
145
+ # and a reader rebuilding the corpus from that sentence got 128 rows and a
146
+ # zone figure of 85 — the right direction and a different denominator.
147
+ #
148
+ # What the STRICTER side costs is worse than "a mention is not reported",
149
+ # and that is worth being exact about, because the same parser is entered
150
+ # from both directions. Read side, such an sgid reports nobody. WRITE side —
151
+ # {mention_markup} asks this whether an sgid names the person it is given —
152
+ # a refusal raises, and +expand_mentions+ fails the whole comment rather
153
+ # than posting it with one mention missing. Measured from both entry points.
154
+ #
155
+ # It is left because the sgid on the write side comes from a people read, and
156
+ # BC3 mints <tt>gid://bc3/Person/N</tt> — the authority is a constant there,
157
+ # and none of the seven is a shape it produces. Closing them means replacing
158
+ # the parser with a hand-rolled one, which in a sibling port closed 4,167
159
+ # divergences and opened 1,282 in the ACCEPTING direction on its first
160
+ # attempt. That is the wrong trade for an authority the API does not emit.
161
+ #
162
+ # WHAT IS KNOWN ABOUT IT, so that whoever does close it does not start from
163
+ # scratch. The reference's rule was read off it directly, one raw byte at a
164
+ # time at each position:
165
+ #
166
+ # * host — refuses 0x00..0x20 and 0x5B <tt>[</tt>, 0x5C, 0x5E, 0x60,
167
+ # 0x7B..0x7D, 0x7F. Accepts every other byte INCLUDING 0x80 and above,
168
+ # which is the whole of the raw-non-ASCII half of the residue, and
169
+ # including <tt>" < > ]</tt>, which is the other half.
170
+ # * userinfo — alphanumerics and <tt>-._~!$&'()*+,;=%:@</tt> only; every
171
+ # other byte, non-ASCII included, is refused.
172
+ # * port — digits only.
173
+ # * a percent escape is refused unless it names a byte at or above 0x80,
174
+ # <tt>%25</tt> excepted. That rule is already ported, on {HOST_ASCII_ESCAPE}.
175
+ #
176
+ # The QUERY belongs to this residue too, and is named separately because it
177
+ # is a different rule rather than more of the same one: the reference never
178
+ # unescapes a query, so a malformed escape there parses fine and resolves,
179
+ # while Ruby's parser refuses it. Measured at 2 of the 14 QUERY rows. The
180
+ # FRAGMENT, which a sibling port got wrong in the accepting direction, is
181
+ # clean — 0 of 27 fragment rows, and 0 of 13 more carrying a fragment
182
+ # alongside a query — because the reference unescapes a fragment while
183
+ # parsing and so does Ruby, so a malformed escape fails both. 64 rows in
184
+ # all: 27 fragment, 14 query, 13 carrying both, and 10 plain and
185
+ # host-escape controls that fix the corpus against a change in the rule
186
+ # above. The denominators are per-component on purpose: an earlier version
187
+ # reported both figures against the whole corpus, which made "0 of 64" read
188
+ # as a sweep of 64 fragments when 27 of them were.
189
+ #
190
+ # EVERY COUNT in this comment came from a differential harness that is not
191
+ # in this repository, so a reader can re-derive the DIRECTIONS — which side
192
+ # resolves and which refuses, for each class named — from the reference
193
+ # itself, and cannot check the totals without rebuilding it. The directions
194
+ # are the claims; the totals are provenance. Three corrections to these
195
+ # numbers have all been to totals and none to a direction.
196
+ #
197
+ # The residue is more reachable than the seven hand-built shapes suggest:
198
+ # fuzzing 20,000 documents that corrupt one character of a real payload put
199
+ # it at 14, all of them the reference resolving where this refuses, because
200
+ # a corrupted payload decodes to an arbitrary host. Still one direction, and
201
+ # still closed.
202
+ #
203
+ # A sibling port has since closed this rather than documenting it, so the
204
+ # two now differ — worth a deliberate decision rather than drift, and worth
205
+ # doing as its own change with its own review rather than folded into one
206
+ # about something else. The IPv6 literal is separate again: bracketed hosts
207
+ # agree today except for the zone-id and the IPvFuture shape recorded below.
208
+ HOST_ASCII_ESCAPE = /%(?!25)[0-7][0-9A-Fa-f]/n
209
+
210
+ # An RFC 3986 IPvFuture literal — "[v7.x]" — which Ruby's URI parses and the
211
+ # reference's does not: a bracketed host must be an IPv6 address there, and
212
+ # its address parser has no IPvFuture grammar.
213
+ #
214
+ # This is the one shape where this port ACCEPTED an authority the reference
215
+ # refuses, and the accepting direction is the one that matters, because the
216
+ # write side's only authenticity-adjacent check is whether an sgid names the
217
+ # person it was given. It was documented in the comment above for several
218
+ # rounds while the spec waiver two commits later asserted that no accepting
219
+ # case existed. Both statements were mine and neither reader put them side
220
+ # by side until one read the claim against the source.
221
+ #
222
+ # Refusing this cannot refuse a real address: IPv6 is hex digits and colons,
223
+ # and "v" is not a hex digit. Measured after: 7 accepting shapes closed,
224
+ # every valid IPv6 literal still resolving, and zero accepting-direction
225
+ # rows left across all corpora.
226
+ IPVFUTURE_AUTHORITY = /\A\[[vV][0-9A-Fa-f]+\./n
227
+
228
+ # The largest person id an sgid may name, matching the 64-bit bound the
229
+ # reference implementation's ParseInt applies. One definition, shared with
230
+ # the id argument check.
231
+ MAX_PERSON_ID = Ids::MAX
232
+
233
+ # The character references an attribute value is decoded through.
234
+ #
235
+ # DELIBERATELY not the whole HTML5 table the reference implementation
236
+ # carries. It is the five predefined references plus every named reference
237
+ # whose expansion can change whether an sgid DECODES: a character one of the
238
+ # base64 alphabets uses, or whitespace, which the trim erases at either end.
239
+ #
240
+ # Expansions are the TRUE code points, not stand-ins. An earlier version
241
+ # folded every whitespace expansion to a plain ASCII space, which is
242
+ # verdict-equivalent only while the base64 layer skips exactly CR and LF —
243
+ # an invariant living in another method — and it got "&#133;" wrong, which
244
+ # is a C1 byte that remaps to an ellipsis rather than staying the NEL the
245
+ # fold treated as whitespace. Keeping the code points and making the trim
246
+ # match is what the Rust and Kotlin ports do, and it rests on nothing.
247
+ #
248
+ # Every other named reference expands to something outside both alphabets
249
+ # that no trim removes, so leaving it literal refuses the sgid exactly as
250
+ # expanding it would. That residue is one-directional by construction — a
251
+ # reference left literal contributes "&" and ";", which no alphabet accepts
252
+ # — so this can only ever report FEWER mentions, never more, and never
253
+ # writes a tag the reference implementation would not.
254
+ NAMED_ENTITIES = {
255
+ # The five predefined references.
256
+ "amp" => "&", "lt" => "<", "gt" => ">", "quot" => '"', "apos" => "'",
257
+ # Expansions inside a base64 alphabet.
258
+ "plus" => "+", "sol" => "/", "equals" => "=", "lowbar" => "_", "UnderBar" => "_",
259
+ # Expansions the trim erases, or that a base64 decoder skips.
260
+ "Tab" => "\t", "NewLine" => "\n",
261
+ "nbsp" => "\u00A0", "NonBreakingSpace" => "\u00A0",
262
+ "ensp" => "\u2002", "emsp" => "\u2003", "emsp13" => "\u2004", "emsp14" => "\u2005",
263
+ "numsp" => "\u2007", "puncsp" => "\u2008",
264
+ "thinsp" => "\u2009", "ThinSpace" => "\u2009",
265
+ "hairsp" => "\u200A", "VeryThinSpace" => "\u200A",
266
+ "MediumSpace" => "\u205F", "ThickSpace" => "\u205F\u200A",
267
+ # Two-character expansions that carry a base64 character. "fjlig" is the
268
+ # one that decides a verdict on its own — it expands to two of them.
269
+ "fjlig" => "fj", "bne" => "=\u20E5"
270
+ }.freeze
271
+
272
+ # The references the HTML5 legacy list also accepts without their closing
273
+ # semicolon, restricted to the ones above whose expansion can matter.
274
+ SEMICOLONLESS_ENTITIES = %w[amp AMP lt LT gt GT quot QUOT nbsp].freeze
275
+
276
+ # What a numeric reference in 0x80..0x9F expands to. Those are not code
277
+ # points in HTML: they are Windows-1252 bytes, and the reference
278
+ # implementation remaps them. It matters here because 0x85 is NEL, which IS
279
+ # whitespace, while its remapping is an ellipsis, which is not — so reading
280
+ # the number as a code point resolves a person the reference refuses. The
281
+ # table was read off the reference implementation, not off a spec.
282
+ C1_REPLACEMENTS = [
283
+ 0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021,
284
+ 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F,
285
+ 0x0090, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014,
286
+ 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178
287
+ ].freeze
288
+
289
+ # The names above, longest first, so a name is matched against the TABLE the
290
+ # way the reference scanner matches it — longest entry that fits — rather
291
+ # than by consuming the longest run of name characters. "&nbspBAh7" is
292
+ # "&nbsp" followed by text, not a name called "nbspBAh7".
293
+ NAMED_ENTITY_PATTERN = Regexp.union(NAMED_ENTITIES.keys.sort_by { |name| -name.length })
294
+
295
+ # One character reference, matching the reference scanner's own rules:
296
+ #
297
+ # * a hex reference takes its digits greedily and needs no semicolon, so
298
+ # "&#x42B" is U+042B rather than "B" followed by "B";
299
+ # * a decimal reference takes its digits greedily and needs no semicolon
300
+ # ONLY when it has two or more of them — "&#66B" is "BB", while "&#9B" is
301
+ # not a reference at all and stays literal;
302
+ # * a named reference needs its semicolon unless it is in the legacy list.
303
+ #
304
+ # The last alternative catches a terminated name that is not in the table,
305
+ # so the whole reference is stepped over as one literal rather than being
306
+ # rescanned character by character.
307
+ ENTITY_PATTERN = /
308
+ &(?:
309
+ \#(?<hex>[Xx][0-9A-Fa-f]+);?
310
+ |\#(?<decimal>[0-9]{2,});?
311
+ |\#(?<lone_digit>[0-9]);
312
+ |(?<name>#{NAMED_ENTITY_PATTERN})(?<semicolon>;)?
313
+ |(?<unknown>[A-Za-z][A-Za-z0-9]*;)
314
+ )
315
+ /x
316
+
317
+ module_function
318
+
319
+ # Returns the ids of the people a rich text mentions: the Person named by
320
+ # the sgid of each +<bc-attachment>+, in document order, with repeats
321
+ # removed. Attachments that are not mentions — files, images, embeds — are
322
+ # skipped, as is any sgid that does not decode to a Person.
323
+ #
324
+ # This is the read side: a description of what the text says, from sgids
325
+ # whose signatures cannot be checked here. Report it; do not treat an id in
326
+ # it as proof that a valid mention exists (see the trust boundary above).
327
+ #
328
+ # Every +<bc-attachment>+ in the text counts, including one inside a
329
+ # +<blockquote>+: BC3 notifies quoted mentions too, so the read matches what
330
+ # the server does with the write.
331
+ #
332
+ # @param rich_text [String, nil] rich text as BC3 serves it
333
+ # @return [Array<Integer>] mentioned person ids, in document order
334
+ def mentioned_person_ids(rich_text)
335
+ ids = []
336
+ # Membership is a hash lookup rather than a scan of `ids`: rich text comes
337
+ # from whoever wrote it, and a mention-heavy recording should not cost
338
+ # quadratic time to project.
339
+ seen = {}
340
+ bc_attachment_sgids(rich_text.to_s).each do |sgid|
341
+ id = person_id_from_sgid(sgid)
342
+ next if id.nil? || seen.key?(id)
343
+
344
+ seen[id] = true
345
+ ids << id
346
+ end
347
+ ids
348
+ end
349
+
350
+ # Decodes the Person id an +attachable_sgid+ names, or nil when the sgid
351
+ # does not decode or names something other than a Person (a file
352
+ # attachment's sgid names an +ActiveStorage::Blob+).
353
+ #
354
+ # This reads the id out of the sgid's payload; it does not verify the sgid's
355
+ # signature, which only BC3 can. It is a read-side helper: never use its
356
+ # answer to decide that a write may skip the authoritative people read (see
357
+ # the trust boundary in the module docs).
358
+ #
359
+ # @param sgid [String, nil]
360
+ # @return [Integer, nil]
361
+ def person_id_from_sgid(sgid)
362
+ gid = global_id_from_sgid(sgid)
363
+ return nil if gid.nil?
364
+
365
+ uri = begin
366
+ URI.parse(gid)
367
+ rescue URI::Error
368
+ return nil
369
+ end
370
+ return nil unless uri.scheme == "gid"
371
+
372
+ # The reference's non-empty test is on its own host field, which carries
373
+ # the PORT and has userinfo split off at the last "@". So "gid://:8080/…"
374
+ # has a host there and resolves, while "gid://user@/…" does not — and
375
+ # Ruby's URI reports an empty host for both, which refused eight shapes
376
+ # the reference accepts. Measured, not derived; nobody's report prompted
377
+ # it. The authority is taken from the gid itself because URI offers no
378
+ # field that means the same thing.
379
+ authority = gid[%r{\Agid://([^/?#]*)}i, 1].to_s
380
+ userinfo = authority.rindex("@")
381
+ host_and_port = userinfo ? authority[(userinfo + 1)..] : authority
382
+ return nil if host_and_port.empty?
383
+ return nil if host_and_port.b.match?(HOST_ASCII_ESCAPE)
384
+ return nil if host_and_port.b.match?(IPVFUTURE_AUTHORITY)
385
+
386
+ # A GlobalID path is exactly "/<Model>/<id>": no more, no less. The path is
387
+ # unescaped first, as the reference's parser hands it over unescaped, so
388
+ # the two agree on a percent-encoded gid rather than one accepting what
389
+ # the other refuses.
390
+ #
391
+ # Unescaped to BYTES, which is what makes the two envelope spellings
392
+ # agree. They hand the gid over in different encodings — the Marshal
393
+ # reader returns bytes, JSON.parse returns text — so unescaping an escape
394
+ # that names a non-ASCII byte produced a String#split that raised
395
+ # ArgumentError out of this public method from the JSON envelope and
396
+ # resolved fine from the Marshal one. This <tt>.b</tt> is the whole fix.
397
+ # The commit that made it also put one on the gid at the top of this
398
+ # method, described there as the thing that fixed this; it was not, and it
399
+ # is gone. Measured after removing it: 1,333 gid shapes through both
400
+ # envelopes, zero divergences and zero raises.
401
+ path = begin
402
+ URI::RFC2396_PARSER.unescape(uri.path.to_s).b
403
+ rescue ArgumentError
404
+ return nil
405
+ end
406
+ model, raw_id = path.delete_prefix("/").split("/", 2)
407
+ return nil unless model == "Person" && raw_id.to_s.match?(/\A\d+\z/n)
408
+
409
+ # Bounded like Go's ParseInt(rawID, 10, 64): an id past that range is not
410
+ # a Basecamp person id, and reporting a bignum as a mentioned person would
411
+ # carry it into a projection and into the write side's identity check.
412
+ # Bounded before conversion. This is the site fed by rich text other
413
+ # people wrote, so an unbounded digit run here is the most exposed of the
414
+ # five that had this shape.
415
+ id = Ids.bounded_decimal(raw_id, signed: false)
416
+ id.is_a?(Integer) && id.positive? && id <= MAX_PERSON_ID ? id : nil
417
+ end
418
+
419
+ # Renders the +<bc-attachment>+ that mentions a person, from their
420
+ # +attachable_sgid+ — the write-side form in doc/api/sections/rich_text.md,
421
+ # which BC3 expands into the avatar figure on read.
422
+ #
423
+ # Raises when the person carries no +attachable_sgid+, which is the case for
424
+ # a person projection that came from somewhere other than a people read (a
425
+ # webhook payload, say), and when the sgid does not name the person it is
426
+ # given. That is all it can check: it cannot verify the signature, so the
427
+ # person must come from the API — a +people.get+, a recording's creator or
428
+ # assignees — not be assembled from an sgid found in content.
429
+ #
430
+ # @param person [Hash] a person as the API returns one
431
+ # @return [String] the mention markup
432
+ # @raise [Basecamp::UsageError] when the person cannot be mentioned
433
+ def mention_markup(person)
434
+ raise UsageError.new("cannot mention a nil person") if person.nil?
435
+
436
+ id = field(person, "id")
437
+ sgid = field(person, "attachable_sgid").to_s
438
+ if sgid.empty?
439
+ raise UsageError.new(
440
+ "person #{id} has no attachable_sgid to mention",
441
+ hint: "read the person through people.get to obtain one"
442
+ )
443
+ end
444
+ # Scanned as bytes: an sgid whose encoding is broken is malformed, not an
445
+ # ArgumentError out of the regexp engine.
446
+ raise UsageError.new("person #{id} has a malformed attachable_sgid") if sgid.b.match?(/["'<>&]/n)
447
+
448
+ # The tag mentions whoever the sgid names. Refuse to write one that names
449
+ # someone else — or a file — under this person's id. Integer identity, not
450
+ # numeric equality: 12.0 == 12 in Ruby, and a person read that came back
451
+ # with a float id must not mint a tag on that basis.
452
+ #
453
+ # A DIGIT STRING is an integer here, though, and only here. A person's id
454
+ # is the one field in the whole generated model the reference decodes
455
+ # flexibly, so it reads "7" as 7 — and requiring an Integer refused a
456
+ # people response the reference accepts, failing the whole comment. A
457
+ # float, a boolean, an array and a non-numeric string are all still
458
+ # refused, which is what keeps this an identity check.
459
+ unless person_identity(id) && person_id_from_sgid(sgid) == person_identity(id)
460
+ raise UsageError.new(
461
+ "person #{id}'s attachable_sgid does not name that person",
462
+ hint: "read the person through people.get to obtain their own"
463
+ )
464
+ end
465
+
466
+ %(<bc-attachment sgid="#{sgid}"></bc-attachment>)
467
+ end
468
+
469
+ # A person's id as the reference decodes one, or nil when it is not an id.
470
+ #
471
+ # +Person.Id+ is the single field in the generated model typed as the
472
+ # flexible decoder, so a JSON string of digits IS an id there — and the
473
+ # sentinel it serves for system-generated entities, "basecamp", decodes to
474
+ # 0 rather than erroring. Zero never matches an sgid's person, which must be
475
+ # positive, so that one falls out as a refusal on its own.
476
+ #
477
+ # Nothing else is coerced. Matched on BYTES, since a String with a broken
478
+ # encoding would otherwise raise out of the regexp engine.
479
+ def person_identity(id)
480
+ return id if id.is_a?(Integer)
481
+ return nil unless id.is_a?(String)
482
+
483
+ # A LEADING SIGN is accepted, both of them, because the reference's
484
+ # flexible decoder hands the string to strconv.ParseInt
485
+ # (go/pkg/types/flexible_int64.go) and that accepts "+7" and "-7" alike.
486
+ # Refusing "+7" failed the whole comment where the reference mints the
487
+ # mention. A negative still gets nowhere, but at the identity comparison
488
+ # rather than here, which is also where the reference stops it: an sgid's
489
+ # person id is positive, so -7 matches nothing.
490
+ #
491
+ # Cited because "matches the reference" is ambiguous on this point — its
492
+ # own treatment of a signed integer-shaped person id differs between the
493
+ # digit walk, this decoder, and the wrapper path, and this method
494
+ # implements THIS one: the people-read field typed as the flexible int.
495
+ parsed = Ids.bounded_decimal(id)
496
+ parsed.is_a?(Integer) ? parsed : nil
497
+ end
498
+
499
+ # Returns content that mentions each of the given people, for posting as a
500
+ # comment or a Campfire line. A person whose exact +attachable_sgid+ the
501
+ # content already carries is left alone, so passing the same person twice —
502
+ # or a person the author already mentioned with that sgid — never duplicates
503
+ # the mention; the rest are added at the start of the content, inside its
504
+ # first +<p>+ or +<div>+ when it opens with one, so they render on the first
505
+ # line rather than as a block of their own.
506
+ #
507
+ # This is the write side, and it deduplicates on the sgid string alone,
508
+ # never on the person id an existing tag's sgid decodes to: that id is
509
+ # unsigned, and a forged or stale tag naming the right person must not stand
510
+ # in for the real mention (see the trust boundary in the module docs). Every
511
+ # person needs their own +attachable_sgid+, and it must be one the API
512
+ # returned: this helper can check that an sgid is well-formed and names the
513
+ # person, not that it is authentic (see {mention_markup}). The
514
+ # account-bound +CommentsExtensions#expand_mentions+ resolves ids to people
515
+ # first and is the entry point that carries that guarantee.
516
+ #
517
+ # @param content [String] rich text to mention into
518
+ # @param people [Array<Hash>] people as the API returns them
519
+ # @return [String] the content with the mentions placed
520
+ # @raise [Basecamp::UsageError] when a person cannot be mentioned
521
+ def with_mentions(content, people)
522
+ content = content.to_s
523
+ # Keyed on BYTES. The walker hands back a byte string and a person read
524
+ # hands back text; the same sgid in two encodings is neither eql? nor
525
+ # hash-equal once it holds a non-ASCII byte, which would split the set and
526
+ # write the mention a second time.
527
+ #
528
+ # The LOOKUP side below is the load-bearing one — mutation testing says so:
529
+ # removing its +.b+ fails the guard test, removing this one does not,
530
+ # because {bc_attachment_sgids} already returns bytes. Both are kept, since
531
+ # the redundant one is what holds the invariant if that ever changes; this
532
+ # note is here so the next reader deletes neither by mistaking which is
533
+ # which.
534
+ present = {}
535
+ bc_attachment_sgids(content).each { |sgid| present[sgid.b] = true }
536
+
537
+ tags = []
538
+ Array(people).each do |person|
539
+ # Rendered before the dedupe check, so an unusable sgid is refused even
540
+ # when the content already carries it.
541
+ tag = mention_markup(person)
542
+ sgid = field(person, "attachable_sgid").to_s.b
543
+ next if present.key?(sgid)
544
+
545
+ present[sgid] = true
546
+ tags << tag
547
+ end
548
+ return content if tags.empty?
549
+
550
+ prefix = "#{tags.join(" ")} "
551
+ # A byte offset (see {leading_block_end}), so the content is cut with
552
+ # byteslice. The cut lands just after a ">", which is never inside a
553
+ # character, so each half stays valid in the content's own encoding.
554
+ block_end = leading_block_end(content)
555
+ return join_bytes([ prefix, content ], content.encoding) if block_end.negative?
556
+
557
+ join_bytes([ content.byteslice(0, block_end), prefix, content.byteslice(block_end..) ], content.encoding)
558
+ end
559
+
560
+ # Joins the pieces as BYTES, then gives the result the content's own
561
+ # encoding when that is still a valid reading of it.
562
+ #
563
+ # An sgid is base64url and "--" and a hex digest, so in every case BC3
564
+ # produces the tag is ASCII and this returns exactly what "+" returned
565
+ # before. A person hash carrying a non-ASCII sgid is not such a case, and
566
+ # there "+" raised Encoding::CompatibilityError out of a public method for
567
+ # a binary tag over UTF-8 content. The reference has no encoding to
568
+ # reconcile and simply writes the bytes; so does this, and it labels them
569
+ # as the caller's encoding whenever that reading is valid.
570
+ def join_bytes(parts, encoding)
571
+ joined = parts.map(&:b).join
572
+ joined.force_encoding(encoding)
573
+ joined.valid_encoding? ? joined : joined.b
574
+ end
575
+
576
+ # Returns the sgid attribute of every +<bc-attachment>+ in the text, in
577
+ # document order.
578
+ #
579
+ # It walks the markup as a stream of tags rather than pattern-matching for
580
+ # one tag name, so a +<bc-attachment>+ inside an HTML comment or inside
581
+ # another element's quoted attribute is not an element; and it tokenizes
582
+ # each tag's attributes rather than pattern-matching them, so a ">" inside a
583
+ # quoted value does not end the tag, an "sgid=" inside another attribute's
584
+ # value is not an attribute, either quote style works, attribute order and
585
+ # case are free, the first sgid attribute wins as in HTML, and entity
586
+ # escapes in the value are decoded as a browser would.
587
+ #
588
+ # @param text [String]
589
+ # @return [Array<String>]
590
+ def bc_attachment_sgids(text)
591
+ # Scanned as BYTES. MRI indexes a multi-byte String in linear time, so
592
+ # walking one character at a time turns a document holding a single
593
+ # accent, smart quote or emoji into a quadratic scan — and this runs over
594
+ # every recording's rich text, which is whatever somebody else typed. The
595
+ # markup this recognizes is ASCII, so bytes lose nothing, and an sgid is
596
+ # ASCII too: a binary slice of one compares and hashes equal to the same
597
+ # bytes in UTF-8, so the write side's dedupe set is unaffected.
598
+ text = text.b
599
+ sgids = []
600
+ pos = 0
601
+ length = text.bytesize
602
+
603
+ while pos < length
604
+ open = text.index("<", pos)
605
+ break if open.nil?
606
+
607
+ pos = open + 1
608
+ if text[pos, 3] == "!--"
609
+ stop = text.index("-->", pos)
610
+ return sgids if stop.nil? # an unterminated comment swallows the rest
611
+
612
+ pos = stop + 3
613
+ next
614
+ elsif [ "!", "?", "/" ].include?(text[pos])
615
+ stop = text.index(">", pos)
616
+ return sgids if stop.nil?
617
+
618
+ pos = stop + 1
619
+ next
620
+ end
621
+
622
+ name_end = 0
623
+ name_end += 1 while pos + name_end < length && tag_name_char?(text[pos + name_end])
624
+ next if name_end.zero? # a bare "<" in text
625
+
626
+ name = text[pos, name_end]
627
+ attrs, tag_end, closed = parse_attributes(text, pos + name_end)
628
+ return sgids unless closed # an unterminated tag: nothing after it is markup
629
+
630
+ sgids << attrs[:sgid] if name.casecmp?("bc-attachment") && !attrs[:sgid].to_s.empty?
631
+ pos = tag_end
632
+ end
633
+
634
+ sgids
635
+ end
636
+
637
+ # Walks the attributes of an opening tag from +pos+ (just after the tag
638
+ # name) to its closing ">".
639
+ #
640
+ # The first sgid attribute wins, present-but-empty included, as HTML
641
+ # resolves a repeated attribute.
642
+ #
643
+ # @param text [String]
644
+ # @param pos [Integer] index just after the tag name
645
+ # @return [Array(Hash, Integer, Boolean)] what was found, the index after
646
+ # the ">", and whether the tag was closed at all
647
+ def parse_attributes(text, pos)
648
+ attrs = { sgid: nil, sgid_seen: false }
649
+ length = text.bytesize
650
+
651
+ while pos < length
652
+ pos += 1 while pos < length && (space?(text[pos]) || text[pos] == "/")
653
+ return [ attrs, pos, false ] if pos >= length
654
+ return [ attrs, pos + 1, true ] if text[pos] == ">"
655
+
656
+ name_start = pos
657
+ pos += 1 while pos < length && !space?(text[pos]) && ![ "=", ">", "/" ].include?(text[pos])
658
+ name = text[name_start...pos]
659
+ pos += 1 while pos < length && space?(text[pos])
660
+
661
+ value = ""
662
+ if pos < length && text[pos] == "="
663
+ pos += 1
664
+ pos += 1 while pos < length && space?(text[pos])
665
+ if pos < length && [ '"', "'" ].include?(text[pos])
666
+ quote = text[pos]
667
+ pos += 1
668
+ closing = text.index(quote, pos)
669
+ return [ attrs, length, false ] if closing.nil?
670
+
671
+ value = text[pos...closing]
672
+ pos = closing + 1
673
+ else
674
+ value_start = pos
675
+ pos += 1 while pos < length && !space?(text[pos]) && text[pos] != ">"
676
+ value = text[value_start...pos]
677
+ end
678
+ end
679
+
680
+ if name.empty?
681
+ # A stray "=" or quote where a name should be: step over it.
682
+ pos += 1
683
+ next
684
+ end
685
+
686
+ if !attrs[:sgid_seen] && name.casecmp?("sgid")
687
+ attrs[:sgid_seen] = true
688
+ attrs[:sgid] = unescape_attribute_value(value)
689
+ end
690
+ end
691
+
692
+ [ attrs, pos, false ]
693
+ end
694
+
695
+ # Returns the index just past the opening <p …> or <div …> tag a rich text
696
+ # starts with, or -1 when it starts with anything else, so mentions can be
697
+ # placed inside the first block rather than as a bare prefix in front of it.
698
+ # The tag's attributes are scanned quote-aware: a ">" inside an attribute
699
+ # value does not end it.
700
+ #
701
+ # @param content [String]
702
+ # @return [Integer]
703
+ def leading_block_end(content)
704
+ # Byte offsets, for the reason {bc_attachment_sgids} gives; the caller
705
+ # slices with byteslice to match.
706
+ content = content.b
707
+ i = 0
708
+ i += 1 while i < content.bytesize && space?(content[i])
709
+
710
+ [ "<p", "<div" ].each do |name|
711
+ next if content.bytesize < i + name.length
712
+ next unless content[i, name.length].casecmp?(name)
713
+
714
+ after = i + name.length
715
+ next if after < content.bytesize && !tag_name_end?(content[after])
716
+
717
+ _attrs, tag_end, closed = parse_attributes(content, after)
718
+ return closed ? tag_end : -1
719
+ end
720
+
721
+ -1
722
+ end
723
+
724
+ # Decodes the character references in an attribute value, through
725
+ # {NAMED_ENTITIES} and {ENTITY_PATTERN}.
726
+ #
727
+ # One pass, so an escaped reference (<tt>&amp;amp;lowbar;</tt>) decodes to the
728
+ # literal <tt>&amp;lowbar;</tt> rather than being decoded twice.
729
+ def unescape_attribute_value(value)
730
+ return value unless value.include?("&")
731
+
732
+ # Rewritten as bytes throughout, so an expansion can never be a different
733
+ # encoding from the value it lands in — and so an sgid comes back out of
734
+ # the walker as the byte string the rest of this file assumes.
735
+ value.b.gsub(ENTITY_PATTERN) do |reference|
736
+ match = Regexp.last_match
737
+ if match[:hex]
738
+ codepoint_reference(wrapped_digits(match[:hex][1..], 16), reference)
739
+ elsif (digits = match[:decimal] || match[:lone_digit])
740
+ codepoint_reference(wrapped_digits(digits, 10), reference)
741
+ elsif match[:name] && (match[:semicolon] || SEMICOLONLESS_ENTITIES.include?(match[:name]))
742
+ # As BYTES, like every other branch. The table is written as readable
743
+ # source literals, which are UTF-8, and the value being rewritten is a
744
+ # byte string: mixing the two in one gsub raises
745
+ # Encoding::CompatibilityError out of a public method.
746
+ NAMED_ENTITIES.fetch(match[:name]).b
747
+ else
748
+ reference
749
+ end
750
+ end
751
+ end
752
+
753
+ # The digits' value, accumulated modulo 2**32 as they are scanned.
754
+ #
755
+ # {codepoint_reference} wraps to a signed 32-bit rune anyway, so the answer
756
+ # is identical to reading the whole number and masking it — but `to_i` built
757
+ # the whole number FIRST, out of rich text other people wrote, and it is
758
+ # superlinear: 400,000 digits already cost 56 ms here, and nothing bounds
759
+ # how many digits one attribute value may carry. This stays O(n) on a
760
+ # constant-size integer.
761
+ #
762
+ # The character classes are guaranteed by {ENTITY_PATTERN}, so a byte is a
763
+ # digit of this base by construction.
764
+ def wrapped_digits(digits, base)
765
+ value = 0
766
+ digits.each_byte do |byte|
767
+ digit = if byte >= 97 then byte - 87 # a-f
768
+ elsif byte >= 65 then byte - 55 # A-F
769
+ else byte - 48 # 0-9
770
+ end
771
+ value = ((value * base) + digit) & 0xFFFF_FFFF
772
+ end
773
+ value
774
+ end
775
+
776
+ # One numeric reference's expansion, as the reference implementation spells
777
+ # it: a Windows-1252 remapping in 0x80..0x9F, the replacement character for
778
+ # zero, a surrogate or anything past the last code point, and otherwise the
779
+ # code point itself.
780
+ #
781
+ # The result is handed back as BYTES, so the walker can go on scanning a
782
+ # byte string whatever the expansion was.
783
+ def codepoint_reference(codepoint, reference)
784
+ # WRAPPED first, into the signed 32-bit rune the reference accumulates
785
+ # into. Its scanner says in as many words that it does not check for
786
+ # overflow, so "&#x100000041;" is "A" there — and every test below runs on
787
+ # the wrapped value, not on the digits. Ruby's integers are unbounded, so
788
+ # this port latched anything past the last code point to U+FFFD and a wrap
789
+ # that lands back on a real character spoiled a payload the reference
790
+ # decodes. Measured: 6 of 54 overflow shapes, every one of them the
791
+ # reference reporting a mention and this reporting none.
792
+ #
793
+ # Found by reading a sibling port's fix rather than by a review of this
794
+ # one; the same defect was live in Swift and in this file, and nothing in
795
+ # nine corpora had reached it.
796
+ codepoint &= 0xFFFFFFFF
797
+ codepoint -= 0x100000000 if codepoint >= 0x80000000
798
+
799
+ if codepoint.between?(0x80, 0x9F)
800
+ # A Windows-1252 byte rather than a code point, and a wrap can land here
801
+ # too, which is why this stays the first test.
802
+ codepoint = C1_REPLACEMENTS[codepoint - 0x80]
803
+ elsif codepoint.zero? || codepoint > 0x10FFFF || codepoint.between?(0xD800, 0xDFFF)
804
+ codepoint = 0xFFFD
805
+ elsif codepoint.negative?
806
+ # The reference does not test for this: a wrapped-negative rune falls
807
+ # through its checks and reaches utf8.EncodeRune, which writes U+FFFD.
808
+ # Spelled out because pack("U") would raise instead.
809
+ codepoint = 0xFFFD
810
+ end
811
+
812
+ [ codepoint ].pack("U").b
813
+ end
814
+
815
+ # Returns the global id string an sgid's envelope carries, or nil.
816
+ #
817
+ # A signed sgid is <tt><payload>--<digest></tt>, and "-" is a base64url
818
+ # character, so the payload itself may contain <tt>--</tt>. The separator is
819
+ # therefore the LAST one, as Rails' own verifier reads it; the whole value is
820
+ # tried as a bare payload when that fails, which is what an unsigned envelope
821
+ # — one that happens to contain <tt>--</tt> included — needs.
822
+ #
823
+ # @param sgid [String, nil]
824
+ # @return [String, nil]
825
+ def global_id_from_sgid(sgid)
826
+ value = Text.trim_space(sgid.to_s)
827
+ separator = value.rindex("--")
828
+ if separator && separator.positive?
829
+ gid = envelope_gid(value[0, separator])
830
+ return gid if gid
831
+ end
832
+ envelope_gid(value)
833
+ end
834
+
835
+ # Decodes one base64 payload and returns the gid its envelope carries.
836
+ #
837
+ # @param payload [String]
838
+ # @return [String, nil]
839
+ def envelope_gid(payload)
840
+ raw = decode_payload(payload)
841
+ return nil if raw.nil?
842
+
843
+ envelope =
844
+ if raw.bytesize >= 2 && raw.getbyte(0) == 0x04 && raw.getbyte(1) == 0x08
845
+ MarshalReader.parse(raw.byteslice(2..))
846
+ elsif raw.getbyte(0) == 0x7B # "{"
847
+ begin
848
+ JSON.parse(raw)
849
+ rescue JSON::ParserError
850
+ nil
851
+ end
852
+ end
853
+ return nil unless envelope.is_a?(Hash)
854
+
855
+ # A SignedGlobalID is bound to a purpose, and only an "attachable" one may
856
+ # be placed in rich text: BC3 refuses any other, so a Person sgid minted
857
+ # for bookmarking or reading is not a mention however valid its gid. Both
858
+ # layouts carry the purpose; an envelope without one is not a Rails
859
+ # envelope.
860
+ rails = envelope["_rails"]
861
+ if rails.is_a?(Hash)
862
+ # Current layout: {"_rails" => {"data" => gid, "pur" => purpose}}.
863
+ return nil unless rails["pur"] == SGID_PURPOSE_ATTACHABLE
864
+
865
+ gid = rails["data"]
866
+ return gid.is_a?(String) && !gid.empty? ? gid : nil
867
+ end
868
+
869
+ # Older layout: {"gid" => gid, "purpose" => …, "expires_at" => …}.
870
+ return nil unless envelope["purpose"] == SGID_PURPOSE_ATTACHABLE
871
+
872
+ gid = envelope["gid"]
873
+ gid.is_a?(String) && !gid.empty? ? gid : nil
874
+ end
875
+
876
+ # Decodes an sgid payload's base64, bounded on both the encoded and decoded
877
+ # forms. The bound is applied to the encoded form first, so an oversized
878
+ # sgid costs nothing to refuse — no normalization, no decode buffer.
879
+ #
880
+ # Rails' MessageVerifier emits either alphabet; base64url is current. Both
881
+ # decode through the standard alphabet once the two symbols are mapped, and
882
+ # stripping the padding lets a truncated-but-valid payload through.
883
+ #
884
+ # @param payload [String]
885
+ # @return [String, nil] binary-encoded bytes
886
+ def decode_payload(payload)
887
+ return nil if payload.nil? || payload.empty? || payload.bytesize > MAX_SGID_ENCODED_BYTES
888
+
889
+ # As bytes: String#tr and the alphabet match both raise ArgumentError on
890
+ # invalid UTF-8, and an sgid whose encoding is broken is one that does not
891
+ # decode, not one that blows up in a public method.
892
+ payload = payload.b
893
+
894
+ # CR and LF are tolerated, because the reference decoder skips exactly
895
+ # those two and a line-broken attribute value is legal HTML. They are
896
+ # dropped AFTER the padding is right-trimmed, not before: the reference
897
+ # trims the raw string, so "MA==\n" keeps its "=" and names nobody there.
898
+ # Deleting the break first would strip the padding and resolve a person
899
+ # the reference does not.
900
+ normalized = trim_padding(payload.tr("-_", "+/"))
901
+ return nil unless normalized.match?(%r{\A[A-Za-z0-9+/\r\n]+\z})
902
+
903
+ normalized = normalized.delete("\r\n")
904
+ return nil if normalized.empty? || (normalized.length % 4) == 1
905
+
906
+ # Decoded with the LENIENT unpack, deliberately. Go's RawStdEncoding is
907
+ # non-strict: it does not require the final group's unused bits to be
908
+ # zero, and `unpack1("m0")` does — it raises on "QR" where Go returns
909
+ # 0x41. A stricter decoder here does not report an error, it makes a real
910
+ # mention silently vanish. The one leniency `m` has that Go does not —
911
+ # discarding characters outside the alphabet — cannot apply, because the
912
+ # alphabet check above has already refused any such payload.
913
+ raw = begin
914
+ normalized.unpack1("m")
915
+ rescue ArgumentError
916
+ return nil
917
+ end
918
+ return nil if raw.nil? || raw.empty? || raw.bytesize > MAX_SGID_PAYLOAD_BYTES
919
+
920
+ raw
921
+ end
922
+
923
+ # Drops the trailing "=" padding with a single search rather than an
924
+ # anchored quantifier, for the reason {SGID_NON_SPACE} gives.
925
+ def trim_padding(value)
926
+ last = value.rindex(/[^=]/)
927
+ last.nil? ? "" : value[0..last]
928
+ end
929
+
930
+ # Reads a field off a person hash, string keys first, symbol keys second, so
931
+ # a hash the SDK returned and one a caller typed both work.
932
+ def field(person, key)
933
+ return nil unless person.is_a?(Hash)
934
+
935
+ person.key?(key) ? person[key] : person[key.to_sym]
936
+ end
937
+
938
+ def space?(char)
939
+ SPACE_CHARS.include?(char)
940
+ end
941
+
942
+ def tag_name_char?(char)
943
+ !space?(char) && ![ "/", ">", "<", "=", '"', "'" ].include?(char)
944
+ end
945
+
946
+ def tag_name_end?(char)
947
+ space?(char) || char == "/" || char == ">"
948
+ end
949
+
950
+ # The helpers above that exist only to serve the four public ones. They are
951
+ # module methods so the public ones can call them with an implicit receiver,
952
+ # and private so the module's documented surface is the four — plus
953
+ # {bc_attachment_sgids}, which a caller deduplicating its own writes needs.
954
+ private_class_method :parse_attributes, :leading_block_end, :global_id_from_sgid,
955
+ :envelope_gid, :decode_payload, :unescape_attribute_value, :codepoint_reference, :trim_padding,
956
+ :field, :person_identity, :join_bytes, :wrapped_digits, :space?, :tag_name_char?, :tag_name_end?
957
+
958
+ # A reader for the subset of Ruby's Marshal 4.8 format a SignedGlobalID
959
+ # payload uses — nil, booleans, fixnums, strings (with their encoding
960
+ # ivars), symbols and symbol links, arrays and hashes — decoded into plain
961
+ # values: Hash, Array, String, Integer, true/false, nil.
962
+ #
963
+ # Deliberately NOT +Marshal.load+. An sgid arrives inside rich text the API
964
+ # served, which is content other people wrote; +Marshal.load+ on it would
965
+ # instantiate arbitrary classes and run their +marshal_load+. Anything
966
+ # outside the subset is nil here, and the caller then treats the sgid as
967
+ # undecodable rather than guessing.
968
+ class MarshalReader
969
+ # Bounds nesting in a payload; an envelope is two deep.
970
+ MAX_DEPTH = 32
971
+
972
+ # Parses a complete Marshal document (the 4.8 header already stripped),
973
+ # returning nil for anything this reader does not model.
974
+ #
975
+ # @param data [String] binary bytes after the "\x04\x08" header
976
+ # @return [Object, nil]
977
+ def self.parse(data)
978
+ reader = new(data)
979
+ value = reader.read_value(0)
980
+ # A Marshal dump is exactly one value; bytes after it are corruption.
981
+ reader.exhausted? ? value : nil
982
+ rescue Error
983
+ nil
984
+ end
985
+
986
+ # Raised internally for any byte sequence outside the modeled subset.
987
+ class Error < StandardError; end
988
+
989
+ def initialize(data)
990
+ @data = data.b
991
+ @pos = 0
992
+ @symbols = []
993
+ end
994
+
995
+ def exhausted?
996
+ @pos == @data.bytesize
997
+ end
998
+
999
+ def read_value(depth)
1000
+ raise Error, "nesting too deep" if depth > MAX_DEPTH
1001
+
1002
+ case read_byte
1003
+ when 0x30 then nil # "0"
1004
+ when 0x54 then true # "T"
1005
+ when 0x46 then false # "F"
1006
+ when 0x69 then read_int # "i"
1007
+ when 0x22 then read_bytes(read_count) # '"'
1008
+ when 0x3A then read_symbol # ":"
1009
+ when 0x3B then read_symbol_link # ";"
1010
+ when 0x49 then read_ivar_object(depth) # "I"
1011
+ when 0x5B then read_array(depth) # "["
1012
+ when 0x7B then read_hash(depth) # "{"
1013
+ else raise Error, "unsupported type"
1014
+ end
1015
+ end
1016
+
1017
+ private
1018
+
1019
+ def read_byte
1020
+ raise Error, "unexpected end of data" if @pos >= @data.bytesize
1021
+
1022
+ byte = @data.getbyte(@pos)
1023
+ @pos += 1
1024
+ byte
1025
+ end
1026
+
1027
+ # Takes the next n bytes. The bound is checked against what remains, never
1028
+ # by adding n to the position, and every length reaches here through
1029
+ # {read_count}, which already rejected anything past the remaining bytes.
1030
+ def read_bytes(count)
1031
+ raise Error, "unexpected end of data" if count.negative? || count > @data.bytesize - @pos
1032
+
1033
+ bytes = @data.byteslice(@pos, count)
1034
+ @pos += count
1035
+ bytes
1036
+ end
1037
+
1038
+ # Reads Marshal's packed integer: 0 is 0; 1..4 and -1..-4 are a byte count
1039
+ # for a little-endian value; anything else is the value itself offset by 5.
1040
+ def read_int
1041
+ lead = read_byte
1042
+ # The lead byte is a signed int8; widen it into its signed meaning.
1043
+ lead -= 256 if lead > 127
1044
+
1045
+ return 0 if lead.zero?
1046
+ return lead - 5 if lead > 4
1047
+ return lead + 5 if lead < -4
1048
+
1049
+ if lead.positive?
1050
+ value = 0
1051
+ read_bytes(lead).each_byte.with_index { |byte, i| value |= byte << (8 * i) }
1052
+ value
1053
+ else
1054
+ value = -1
1055
+ read_bytes(-lead).each_byte.with_index do |byte, i|
1056
+ value &= ~(0xff << (8 * i))
1057
+ value |= byte << (8 * i)
1058
+ end
1059
+ value
1060
+ end
1061
+ end
1062
+
1063
+ # Reads a length or count — string and symbol bytes, array elements, hash
1064
+ # pairs, ivar pairs — and rejects one that cannot be honest: negative, or
1065
+ # more than the bytes left (every element takes at least one byte).
1066
+ # Allocation follows what actually decodes, so a hostile count costs its
1067
+ # own bytes to refuse, never the capacity it claims.
1068
+ def read_count
1069
+ count = read_int
1070
+ raise Error, "bad count" if count.negative? || count > @data.bytesize - @pos
1071
+
1072
+ count
1073
+ end
1074
+
1075
+ def read_symbol
1076
+ symbol = read_bytes(read_count)
1077
+ @symbols << symbol
1078
+ symbol
1079
+ end
1080
+
1081
+ def read_symbol_link
1082
+ index = read_int
1083
+ raise Error, "bad symbol link" if index.negative? || index >= @symbols.length
1084
+
1085
+ @symbols[index]
1086
+ end
1087
+
1088
+ # An object followed by its instance variables — a String's encoding.
1089
+ def read_ivar_object(depth)
1090
+ inner = read_value(depth + 1)
1091
+ read_count.times do
1092
+ read_value(depth + 1) # ivar name
1093
+ read_value(depth + 1) # ivar value
1094
+ end
1095
+ inner
1096
+ end
1097
+
1098
+ def read_array(depth)
1099
+ count = read_count
1100
+ Array.new(0).tap do |out|
1101
+ count.times { out << read_value(depth + 1) }
1102
+ end
1103
+ end
1104
+
1105
+ def read_hash(depth)
1106
+ count = read_count
1107
+ out = {}
1108
+ count.times do
1109
+ key = read_value(depth + 1)
1110
+ value = read_value(depth + 1)
1111
+ raise Error, "non-string hash key" unless key.is_a?(String)
1112
+
1113
+ out[key] = value
1114
+ end
1115
+ out
1116
+ end
1117
+ end
1118
+ end
1119
+ end