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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/basecamp/api_error.rb +5 -3
- data/lib/basecamp/bucket_mismatch_error.rb +41 -0
- data/lib/basecamp/campfire_discovery_incomplete_error.rb +57 -0
- data/lib/basecamp/campfire_index.rb +398 -0
- data/lib/basecamp/client.rb +47 -4
- data/lib/basecamp/generated/metadata.json +728 -1
- data/lib/basecamp/generated/services/account_service.rb +2 -2
- data/lib/basecamp/generated/services/attachments_service.rb +1 -1
- data/lib/basecamp/generated/services/automation_service.rb +1 -1
- data/lib/basecamp/generated/services/bookmarks_service.rb +2 -2
- data/lib/basecamp/generated/services/boosts_service.rb +3 -3
- data/lib/basecamp/generated/services/calendars_service.rb +2 -2
- data/lib/basecamp/generated/services/campfires_service.rb +7 -7
- data/lib/basecamp/generated/services/card_columns_service.rb +6 -6
- data/lib/basecamp/generated/services/card_steps_service.rb +4 -4
- data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
- data/lib/basecamp/generated/services/cards_service.rb +3 -3
- data/lib/basecamp/generated/services/checkins_service.rb +9 -9
- data/lib/basecamp/generated/services/client_approvals_service.rb +1 -1
- data/lib/basecamp/generated/services/client_correspondences_service.rb +1 -1
- data/lib/basecamp/generated/services/client_replies_service.rb +1 -1
- data/lib/basecamp/generated/services/client_visibility_service.rb +1 -1
- data/lib/basecamp/generated/services/cloud_files_service.rb +3 -3
- data/lib/basecamp/generated/services/comments_service.rb +3 -3
- data/lib/basecamp/generated/services/documents_service.rb +3 -3
- data/lib/basecamp/generated/services/event_feed_service.rb +58 -0
- data/lib/basecamp/generated/services/everything_service.rb +2 -2
- data/lib/basecamp/generated/services/folders_service.rb +4 -4
- data/lib/basecamp/generated/services/forwards_service.rb +3 -3
- data/lib/basecamp/generated/services/gauges_service.rb +10 -6
- data/lib/basecamp/generated/services/google_documents_service.rb +3 -3
- data/lib/basecamp/generated/services/hill_charts_service.rb +2 -2
- data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
- data/lib/basecamp/generated/services/message_types_service.rb +3 -3
- data/lib/basecamp/generated/services/messages_service.rb +3 -3
- data/lib/basecamp/generated/services/my_assignments_service.rb +3 -3
- data/lib/basecamp/generated/services/my_notes_service.rb +2 -2
- data/lib/basecamp/generated/services/my_notifications_service.rb +1 -1
- data/lib/basecamp/generated/services/people_service.rb +11 -11
- data/lib/basecamp/generated/services/projects_service.rb +4 -4
- data/lib/basecamp/generated/services/recordings_service.rb +1 -1
- data/lib/basecamp/generated/services/reports_service.rb +3 -3
- data/lib/basecamp/generated/services/schedules_service.rb +6 -6
- data/lib/basecamp/generated/services/search_service.rb +1 -1
- data/lib/basecamp/generated/services/subscriptions_service.rb +3 -3
- data/lib/basecamp/generated/services/templates_service.rb +8 -8
- data/lib/basecamp/generated/services/timesheets_service.rb +4 -4
- data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
- data/lib/basecamp/generated/services/todolists_service.rb +3 -3
- data/lib/basecamp/generated/services/todos_service.rb +4 -4
- data/lib/basecamp/generated/services/todosets_service.rb +1 -1
- data/lib/basecamp/generated/services/tools_service.rb +3 -3
- data/lib/basecamp/generated/services/uploads_service.rb +4 -4
- data/lib/basecamp/generated/services/vaults_service.rb +3 -3
- data/lib/basecamp/generated/services/webhooks_service.rb +3 -3
- data/lib/basecamp/generated/services/wormholes_service.rb +2 -2
- data/lib/basecamp/generated/types.rb +108 -6
- data/lib/basecamp/http.rb +383 -60
- data/lib/basecamp/ids.rb +277 -0
- data/lib/basecamp/mentions.rb +1119 -0
- data/lib/basecamp/person_id_sites.rb +184 -0
- data/lib/basecamp/recording_routing_error.rb +50 -0
- data/lib/basecamp/recording_summary_error.rb +33 -0
- data/lib/basecamp/services/comments_extensions.rb +121 -0
- data/lib/basecamp/services/merge_safe.rb +37 -13
- data/lib/basecamp/services/recordings_extensions.rb +1305 -0
- data/lib/basecamp/services/todolists_extensions.rb +4 -3
- data/lib/basecamp/text.rb +94 -0
- data/lib/basecamp/unresolved_recording_error.rb +68 -0
- data/lib/basecamp/version.rb +1 -1
- data/lib/basecamp/webhooks/event.rb +4 -1
- data/lib/basecamp.rb +22 -4
- data/scripts/generate-metadata.rb +75 -2
- data/scripts/generate-services.rb +35 -8
- data/scripts/generate-types.rb +19 -0
- 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 "…" 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. " BAh7" is
|
|
292
|
+
# " " 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
|
+
# "Ы" 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 — "BB" is "BB", while "	B" 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;lowbar;</tt>) decodes to the
|
|
728
|
+
# literal <tt>&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 "�" 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
|