async-matrix 2.1.0-x86_64-linux → 3.0.1-x86_64-linux
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 +33 -58
- data/lib/async/matrix/3.3/async_matrix_e2ee.so +0 -0
- data/lib/async/matrix/3.4/async_matrix_e2ee.so +0 -0
- data/lib/async/matrix/4.0/async_matrix_e2ee.so +0 -0
- data/lib/async/matrix/api/chain.rb +120 -94
- data/lib/async/matrix/api/concat.rb +28 -22
- data/lib/async/matrix/api/path_tree.rb +71 -44
- data/lib/async/matrix/api.rb +6 -1
- data/lib/async/matrix/app_service_client.rb +213 -0
- data/lib/async/matrix/auth_error.rb +20 -0
- data/lib/async/matrix/bad_json_error.rb +20 -0
- data/lib/async/matrix/client/encryption.rb +413 -0
- data/lib/async/matrix/client/rooms.rb +478 -0
- data/lib/async/matrix/client/sync.rb +508 -0
- data/lib/async/matrix/client.rb +217 -113
- data/lib/async/matrix/config/vivify.rb +107 -0
- data/lib/async/matrix/config.rb +184 -0
- data/lib/async/matrix/device_store.rb +1621 -0
- data/lib/async/matrix/double_puppet_client.rb +1 -1
- data/lib/async/matrix/e2ee/pickle_key.rb +182 -0
- data/lib/async/matrix/error.rb +9 -36
- data/lib/async/matrix/{application_service/error_response.rb → error_response.rb} +10 -10
- data/lib/async/matrix/homeserver_error.rb +20 -0
- data/lib/async/matrix/invalid_endpoint_error.rb +20 -0
- data/lib/async/matrix/media_client.rb +46 -35
- data/lib/async/matrix/not_found_error.rb +20 -0
- data/lib/async/matrix/notifier.rb +7 -3
- data/lib/async/matrix/response_too_large_error.rb +20 -0
- data/lib/async/matrix/version.rb +1 -1
- data/lib/async/matrix.rb +16 -7
- data/lib/protocol/matrix/canonical_json.rb +200 -0
- data/lib/protocol/matrix/content.rb +106 -0
- data/lib/protocol/matrix/encrypted_message.rb +1018 -0
- data/lib/protocol/matrix/error.rb +49 -0
- data/lib/protocol/matrix/event.rb +208 -0
- data/lib/protocol/matrix/key_backup.rb +347 -0
- data/lib/protocol/matrix/keys.rb +381 -0
- data/lib/protocol/matrix/message_batch.rb +482 -0
- data/lib/protocol/matrix/schema/registry.rb +381 -0
- data/lib/protocol/matrix/schema/validation_error.rb +234 -0
- data/lib/protocol/matrix/schema.rb +170 -0
- data/lib/protocol/matrix/secret_storage.rb +535 -0
- data/lib/protocol/matrix/signing.rb +278 -0
- data/lib/protocol/matrix.rb +25 -0
- metadata +43 -136
- data/lib/async/discord/api/path_tree.rb +0 -127
- data/lib/async/discord/api.rb +0 -151
- data/lib/async/discord/client.rb +0 -283
- data/lib/async/discord/error.rb +0 -84
- data/lib/async/discord/gateway.rb +0 -359
- data/lib/async/discord.rb +0 -15
- data/lib/async/matrix/application_service/bot.rb +0 -232
- data/lib/async/matrix/application_service/config/schema/analytics.json +0 -21
- data/lib/async/matrix/application_service/config/schema/appservice.json +0 -82
- data/lib/async/matrix/application_service/config/schema/backfill.json +0 -91
- data/lib/async/matrix/application_service/config/schema/bridge.json +0 -209
- data/lib/async/matrix/application_service/config/schema/config.json +0 -61
- data/lib/async/matrix/application_service/config/schema/database.json +0 -38
- data/lib/async/matrix/application_service/config/schema/direct_media.json +0 -35
- data/lib/async/matrix/application_service/config/schema/double_puppet.json +0 -24
- data/lib/async/matrix/application_service/config/schema/encryption.json +0 -164
- data/lib/async/matrix/application_service/config/schema/homeserver.json +0 -58
- data/lib/async/matrix/application_service/config/schema/logging.json +0 -50
- data/lib/async/matrix/application_service/config/schema/management_room_texts.json +0 -25
- data/lib/async/matrix/application_service/config/schema/matrix.json +0 -45
- data/lib/async/matrix/application_service/config/schema/permissions.json +0 -54
- data/lib/async/matrix/application_service/config/schema/provisioning.json +0 -23
- data/lib/async/matrix/application_service/config/schema/public_media.json +0 -39
- data/lib/async/matrix/application_service/config/schema/relay.json +0 -43
- data/lib/async/matrix/application_service/config/vivify.rb +0 -109
- data/lib/async/matrix/application_service/config.rb +0 -225
- data/lib/async/matrix/application_service/dispatcher.rb +0 -185
- data/lib/async/matrix/application_service/event.rb +0 -285
- data/lib/async/matrix/application_service/server.rb +0 -430
- data/lib/async/matrix/application_service/transaction.rb +0 -66
- data/lib/async/matrix/application_service/transaction_handler.rb +0 -185
- data/lib/async/matrix/application_service/transaction_store.rb +0 -80
- data/lib/async/matrix/bridge/discord/db/connection.rb +0 -141
- data/lib/async/matrix/bridge/discord/db/file.rb +0 -118
- data/lib/async/matrix/bridge/discord/db/guild.rb +0 -120
- data/lib/async/matrix/bridge/discord/db/message.rb +0 -160
- data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +0 -14
- data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +0 -14
- data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +0 -23
- data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +0 -19
- data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +0 -20
- data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +0 -19
- data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +0 -18
- data/lib/async/matrix/bridge/discord/db/portal.rb +0 -150
- data/lib/async/matrix/bridge/discord/db/puppet.rb +0 -128
- data/lib/async/matrix/bridge/discord/db/reaction.rb +0 -165
- data/lib/async/matrix/bridge/discord/db/schema.rb +0 -18
- data/lib/async/matrix/bridge/discord/db/user.rb +0 -112
- data/lib/async/matrix/bridge/discord/db.rb +0 -138
- data/lib/async/matrix/schema/registry.rb +0 -354
- data/lib/async/matrix/schema/validation_error.rb +0 -225
- data/lib/async/matrix/schema.rb +0 -170
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
require "yaml"
|
|
7
|
+
require "json"
|
|
8
|
+
require "json_schemer"
|
|
9
|
+
require "pathname"
|
|
10
|
+
|
|
11
|
+
module Protocol
|
|
12
|
+
module Matrix
|
|
13
|
+
module Schema
|
|
14
|
+
# Loads and indexes Matrix event schemas from the official matrix-org/matrix-spec
|
|
15
|
+
# YAML files bundled in data/matrix-spec/event-schemas/schema/.
|
|
16
|
+
#
|
|
17
|
+
# Schemas are loaded lazily on first access and cached for the lifetime of the
|
|
18
|
+
# process. Each schema is a JSONSchemer::Schema instance that resolves relative
|
|
19
|
+
# $ref paths (e.g. core-event-schema/room_event.yaml) via a file-based resolver.
|
|
20
|
+
#
|
|
21
|
+
# registry = Protocol::Matrix::Schema::Registry.instance
|
|
22
|
+
# registry["m.room.message"] # => JSONSchemer::Schema
|
|
23
|
+
# registry.variant("m.room.message", "m.text") # => JSONSchemer::Schema
|
|
24
|
+
# registry.event_types # => ["m.accepted_terms", ...]
|
|
25
|
+
#
|
|
26
|
+
class Registry
|
|
27
|
+
# Custom format validators for Matrix-specific format hints.
|
|
28
|
+
MATRIX_FORMATS = {
|
|
29
|
+
"mx-user-id" => ->(value, _schema) { value.is_a?(String) && value.match?(/\A@[^:]+:.+\z/) },
|
|
30
|
+
"mx-room-id" => ->(value, _schema) { value.is_a?(String) && value.match?(/\A![^:]+:.+\z/) },
|
|
31
|
+
"mx-event-id" => ->(value, _schema) { value.is_a?(String) && value.match?(/\A\$/) },
|
|
32
|
+
"mx-room-alias" => ->(value, _schema) { value.is_a?(String) && value.match?(/\A#[^:]+:.+\z/) },
|
|
33
|
+
"mx-server-name" => ->(value, _schema) { value.is_a?(String) && !value.empty? },
|
|
34
|
+
"mx-unpadded-base64" => ->(value, _schema) { value.is_a?(String) && value.match?(/\A[A-Za-z0-9+\/]*\z/) },
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
GEM_ROOT = File.expand_path("../../../..", __dir__)
|
|
38
|
+
SCHEMA_DIR = File.join(
|
|
39
|
+
GEM_ROOT,
|
|
40
|
+
"data",
|
|
41
|
+
"matrix-spec",
|
|
42
|
+
"event-schemas",
|
|
43
|
+
"schema",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@instance = nil
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
# Returns the singleton Registry instance, creating it on first access.
|
|
50
|
+
def instance
|
|
51
|
+
@instance ||= new
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Reset the singleton (useful for testing).
|
|
55
|
+
def reset!
|
|
56
|
+
@instance = nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def initialize
|
|
61
|
+
@schemas = {}
|
|
62
|
+
@variants = {}
|
|
63
|
+
@loaded = false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Look up a schema by Matrix event type (e.g. "m.room.message").
|
|
67
|
+
# Returns a JSONSchemer::Schema or nil if no schema exists for that type.
|
|
68
|
+
def [](event_type)
|
|
69
|
+
unless @loaded
|
|
70
|
+
load_schemas!
|
|
71
|
+
end
|
|
72
|
+
@schemas[event_type]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Look up a variant schema (e.g. variant("m.room.message", "m.text")).
|
|
76
|
+
# Returns a JSONSchemer::Schema or nil.
|
|
77
|
+
def variant(event_type, subtype)
|
|
78
|
+
unless @loaded
|
|
79
|
+
load_schemas!
|
|
80
|
+
end
|
|
81
|
+
@variants[[event_type, subtype]]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# All known base event types (excludes variant subtypes).
|
|
85
|
+
# @return [Array<String>] sorted event type strings
|
|
86
|
+
def event_types
|
|
87
|
+
unless @loaded
|
|
88
|
+
load_schemas!
|
|
89
|
+
end
|
|
90
|
+
@schemas.keys.sort
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# All known variant keys as [event_type, subtype] pairs.
|
|
94
|
+
# @return [Array<Array(String, String)>]
|
|
95
|
+
def variant_types
|
|
96
|
+
unless @loaded
|
|
97
|
+
load_schemas!
|
|
98
|
+
end
|
|
99
|
+
@variants.keys.sort
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Total number of schemas loaded (base + variants).
|
|
103
|
+
def size
|
|
104
|
+
unless @loaded
|
|
105
|
+
load_schemas!
|
|
106
|
+
end
|
|
107
|
+
@schemas.size + @variants.size
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Validate an event hash against its schema.
|
|
111
|
+
# Returns an array of error hashes (empty if valid).
|
|
112
|
+
# If no schema exists for the event type, returns empty (lenient).
|
|
113
|
+
def validate(event_hash)
|
|
114
|
+
event_type = event_hash["type"]
|
|
115
|
+
if event_type
|
|
116
|
+
base = self[event_type]
|
|
117
|
+
if base
|
|
118
|
+
errors = base.validate(event_hash).to_a
|
|
119
|
+
variant_key = detect_variant_key(event_type, event_hash)
|
|
120
|
+
if variant_key
|
|
121
|
+
variant_schema = variant(event_type, variant_key)
|
|
122
|
+
if variant_schema
|
|
123
|
+
errors.concat(variant_schema.validate(event_hash).to_a)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
errors
|
|
127
|
+
else
|
|
128
|
+
[]
|
|
129
|
+
end
|
|
130
|
+
else
|
|
131
|
+
[]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Boolean validation.
|
|
136
|
+
def valid?(event_hash)
|
|
137
|
+
validate(event_hash).empty?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Content properties defined by the schema for a given event type.
|
|
141
|
+
# Returns an array of property name strings, or empty array if unknown.
|
|
142
|
+
def content_properties(event_type)
|
|
143
|
+
schema = self[event_type]
|
|
144
|
+
if schema
|
|
145
|
+
extract_content_properties(schema.value)
|
|
146
|
+
else
|
|
147
|
+
[]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# Ref resolver that loads YAML/JSON files from disk.
|
|
154
|
+
# json_schemer calls this with a URI for each $ref it encounters.
|
|
155
|
+
def ref_resolver
|
|
156
|
+
@ref_resolver ||= proc do |uri|
|
|
157
|
+
path = uri.path
|
|
158
|
+
if path && File.exist?(path)
|
|
159
|
+
content = File.read(path)
|
|
160
|
+
if path.end_with?(".yaml", ".yml")
|
|
161
|
+
YAML.safe_load(content, permitted_classes: [Symbol])
|
|
162
|
+
else
|
|
163
|
+
JSON.parse(content)
|
|
164
|
+
end
|
|
165
|
+
else
|
|
166
|
+
raise JSONSchemer::UnknownRef, uri.to_s
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Build a JSONSchemer::Schema from a parsed YAML hash, anchored at the
|
|
172
|
+
# given file path so relative $ref paths resolve correctly.
|
|
173
|
+
def build_schemer(parsed, file_path)
|
|
174
|
+
uri = URI("file://#{File.expand_path(file_path)}")
|
|
175
|
+
JSONSchemer.schema(
|
|
176
|
+
parsed,
|
|
177
|
+
base_uri: uri,
|
|
178
|
+
ref_resolver: ref_resolver,
|
|
179
|
+
insert_property_defaults: false,
|
|
180
|
+
formats: MATRIX_FORMATS,
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Scan the schema directory and load all event schemas.
|
|
185
|
+
def load_schemas!
|
|
186
|
+
if @loaded
|
|
187
|
+
nil
|
|
188
|
+
else
|
|
189
|
+
Dir.glob(File.join(SCHEMA_DIR, "*.yaml")).each do |path|
|
|
190
|
+
load_schema_file(path)
|
|
191
|
+
end
|
|
192
|
+
Dir.glob(File.join(SCHEMA_DIR, "*.json")).each do |path|
|
|
193
|
+
load_schema_file(path)
|
|
194
|
+
end
|
|
195
|
+
@loaded = true
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def load_schema_file(path)
|
|
200
|
+
filename = File.basename(path, File.extname(path))
|
|
201
|
+
content = File.read(path)
|
|
202
|
+
if path.end_with?(".json")
|
|
203
|
+
parsed = JSON.parse(content)
|
|
204
|
+
else
|
|
205
|
+
parsed = YAML.safe_load(content, permitted_classes: [Symbol])
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
schemer = build_schemer(parsed, path)
|
|
209
|
+
|
|
210
|
+
# Detect event type from schema: properties.type.enum[0]
|
|
211
|
+
declared_type = parsed.dig("properties", "type", "enum")&.first
|
|
212
|
+
|
|
213
|
+
if filename.include?("$")
|
|
214
|
+
# Variant schema: "m.room.message$m.text" -> base="m.room.message", sub="m.text"
|
|
215
|
+
parts = filename.split("$", 2)
|
|
216
|
+
base_type = declared_type || parts[0]
|
|
217
|
+
subtype = parts[1]
|
|
218
|
+
@variants[[base_type, subtype]] = schemer
|
|
219
|
+
elsif declared_type
|
|
220
|
+
@schemas[declared_type] = schemer
|
|
221
|
+
else
|
|
222
|
+
# Schemas without a type enum (like core-event-schema/) are skipped
|
|
223
|
+
# from the index -- they're only used as $ref targets.
|
|
224
|
+
end
|
|
225
|
+
rescue => e
|
|
226
|
+
# Don't let a single broken schema prevent loading the rest
|
|
227
|
+
warn "async-matrix: failed to load schema #{path}: #{e.message}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Detect the variant subtype key from an event hash.
|
|
231
|
+
# For m.room.message, the variant is the msgtype.
|
|
232
|
+
# For m.room.encrypted, the variant is the algorithm.
|
|
233
|
+
# For m.key.verification.start, the variant is the method.
|
|
234
|
+
def detect_variant_key(event_type, event_hash)
|
|
235
|
+
content = event_hash["content"]
|
|
236
|
+
if content.is_a?(Hash)
|
|
237
|
+
case event_type
|
|
238
|
+
when "m.room.message"
|
|
239
|
+
content["msgtype"]
|
|
240
|
+
when "m.room.encrypted"
|
|
241
|
+
content["algorithm"]
|
|
242
|
+
when "m.key.verification.start"
|
|
243
|
+
content["method"]
|
|
244
|
+
end
|
|
245
|
+
else
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Extract content property names from a schema's parsed YAML.
|
|
251
|
+
def extract_content_properties(schema_hash)
|
|
252
|
+
props = schema_hash.dig("properties", "content", "properties")
|
|
253
|
+
if props.is_a?(Hash)
|
|
254
|
+
props.keys
|
|
255
|
+
else
|
|
256
|
+
[]
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
__END__
|
|
265
|
+
describe "Protocol::Matrix::Schema::Registry" do
|
|
266
|
+
it "loads schemas from disk" do
|
|
267
|
+
Protocol::Matrix::Schema::Registry.instance.size.should.be > 0
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it "has known event types" do
|
|
271
|
+
types = Protocol::Matrix::Schema::Registry.instance.event_types
|
|
272
|
+
types.should.include "m.room.message"
|
|
273
|
+
types.should.include "m.room.member"
|
|
274
|
+
types.should.include "m.room.create"
|
|
275
|
+
types.should.include "m.reaction"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
it "returns a JSONSchemer::Schema for a known type" do
|
|
279
|
+
schema = Protocol::Matrix::Schema::Registry.instance["m.room.message"]
|
|
280
|
+
schema.should.not.be.nil
|
|
281
|
+
schema.should.be.kind_of JSONSchemer::Schema
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
it "returns nil for an unknown type" do
|
|
285
|
+
Protocol::Matrix::Schema::Registry.instance["m.fake.event"].should.be.nil
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
it "has variant schemas" do
|
|
289
|
+
Protocol::Matrix::Schema::Registry.instance.variant_types.should.not.be.empty
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
it "resolves variant schemas" do
|
|
293
|
+
schema = Protocol::Matrix::Schema::Registry.instance.variant("m.room.message", "m.text")
|
|
294
|
+
schema.should.not.be.nil
|
|
295
|
+
schema.should.be.kind_of JSONSchemer::Schema
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
it "validates a correct m.room.message event" do
|
|
299
|
+
event = {
|
|
300
|
+
"type" => "m.room.message",
|
|
301
|
+
"content" => {"msgtype" => "m.text", "body" => "hello"},
|
|
302
|
+
"event_id" => "$abc123",
|
|
303
|
+
"sender" => "@alice:example.org",
|
|
304
|
+
"origin_server_ts" => 1234567890,
|
|
305
|
+
"room_id" => "!room:example.org"
|
|
306
|
+
}
|
|
307
|
+
Protocol::Matrix::Schema::Registry.instance.valid?(event).should == true
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
it "rejects m.room.message missing body" do
|
|
311
|
+
event = {
|
|
312
|
+
"type" => "m.room.message",
|
|
313
|
+
"content" => {"msgtype" => "m.text"},
|
|
314
|
+
"event_id" => "$abc123",
|
|
315
|
+
"sender" => "@alice:example.org",
|
|
316
|
+
"origin_server_ts" => 1234567890,
|
|
317
|
+
"room_id" => "!room:example.org"
|
|
318
|
+
}
|
|
319
|
+
Protocol::Matrix::Schema::Registry.instance.valid?(event).should == false
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
it "is lenient with unknown event types" do
|
|
323
|
+
event = {
|
|
324
|
+
"type" => "m.room.other",
|
|
325
|
+
"content" => {"msgtype" => "m.text", "body" => "hi"},
|
|
326
|
+
"event_id" => "$abc123",
|
|
327
|
+
"sender" => "@alice:example.org",
|
|
328
|
+
"origin_server_ts" => 1234567890,
|
|
329
|
+
"room_id" => "!room:example.org"
|
|
330
|
+
}
|
|
331
|
+
Protocol::Matrix::Schema::Registry.instance.valid?(event).should == true
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
it "validates m.room.member with correct membership" do
|
|
335
|
+
event = {
|
|
336
|
+
"type" => "m.room.member",
|
|
337
|
+
"content" => {"membership" => "join"},
|
|
338
|
+
"state_key" => "@alice:example.org",
|
|
339
|
+
"event_id" => "$abc123",
|
|
340
|
+
"sender" => "@alice:example.org",
|
|
341
|
+
"origin_server_ts" => 1234567890,
|
|
342
|
+
"room_id" => "!room:example.org"
|
|
343
|
+
}
|
|
344
|
+
Protocol::Matrix::Schema::Registry.instance.valid?(event).should == true
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
it "rejects m.room.member with invalid membership" do
|
|
348
|
+
event = {
|
|
349
|
+
"type" => "m.room.member",
|
|
350
|
+
"content" => {"membership" => "invalid_state"},
|
|
351
|
+
"state_key" => "@alice:example.org",
|
|
352
|
+
"event_id" => "$abc123",
|
|
353
|
+
"sender" => "@alice:example.org",
|
|
354
|
+
"origin_server_ts" => 1234567890,
|
|
355
|
+
"room_id" => "!room:example.org"
|
|
356
|
+
}
|
|
357
|
+
Protocol::Matrix::Schema::Registry.instance.valid?(event).should == false
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
it "returns content properties for known types" do
|
|
361
|
+
props = Protocol::Matrix::Schema::Registry.instance.content_properties("m.room.message")
|
|
362
|
+
props.should.include "msgtype"
|
|
363
|
+
props.should.include "body"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
it "returns empty content properties for unknown types" do
|
|
367
|
+
Protocol::Matrix::Schema::Registry.instance.content_properties("m.fake.type").should == []
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
it "performs variant validation for m.room.message with msgtype" do
|
|
371
|
+
event = {
|
|
372
|
+
"type" => "m.room.message",
|
|
373
|
+
"content" => {"msgtype" => "m.text", "body" => "hello"},
|
|
374
|
+
"event_id" => "$abc123",
|
|
375
|
+
"sender" => "@alice:example.org",
|
|
376
|
+
"origin_server_ts" => 1234567890,
|
|
377
|
+
"room_id" => "!room:example.org"
|
|
378
|
+
}
|
|
379
|
+
Protocol::Matrix::Schema::Registry.instance.validate(event).should == []
|
|
380
|
+
end
|
|
381
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
require_relative "../error"
|
|
7
|
+
|
|
8
|
+
module Protocol
|
|
9
|
+
module Matrix
|
|
10
|
+
module Schema
|
|
11
|
+
# Raised by Event#valid! when data fails schema validation.
|
|
12
|
+
#
|
|
13
|
+
# Produces detailed, human-readable error messages that identify the exact
|
|
14
|
+
# key path and offending value.
|
|
15
|
+
#
|
|
16
|
+
# Schema validation failed for m.room.member event $abc123:
|
|
17
|
+
# - content.membership = "invalid" -- must be one of: ["invite", "join", "knock", "leave", "ban"]
|
|
18
|
+
# - sender is required but missing
|
|
19
|
+
#
|
|
20
|
+
# Carries an errcode, which the base supports: M_BAD_JSON is what a
|
|
21
|
+
# homeserver answers for a payload that fails its schema.
|
|
22
|
+
class ValidationError < Protocol::Matrix::Error
|
|
23
|
+
attr_reader :errors, :event_type, :event_id
|
|
24
|
+
|
|
25
|
+
# @param errors [Array<Hash>] raw JSONSchemer error hashes
|
|
26
|
+
# @param event_type [String, nil] Matrix event type (e.g. "m.room.member")
|
|
27
|
+
# @param event_id [String, nil] Matrix event ID (e.g. "$abc123")
|
|
28
|
+
def initialize(errors, event_type: nil, event_id: nil)
|
|
29
|
+
@errors = errors
|
|
30
|
+
@event_type = event_type
|
|
31
|
+
@event_id = event_id
|
|
32
|
+
super("M_BAD_JSON", build_message)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def build_message
|
|
38
|
+
lines = [header_line]
|
|
39
|
+
@errors.each { |e| lines.concat(Array(format_error(e))) }
|
|
40
|
+
lines.join("\n")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def header_line
|
|
44
|
+
header = "Schema validation failed"
|
|
45
|
+
if @event_type
|
|
46
|
+
header += " for #{@event_type}"
|
|
47
|
+
end
|
|
48
|
+
if @event_id
|
|
49
|
+
header += " event #{@event_id}"
|
|
50
|
+
end
|
|
51
|
+
header + ":"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def format_error(error)
|
|
55
|
+
path = pointer_to_dot(error["data_pointer"])
|
|
56
|
+
type = error["type"]
|
|
57
|
+
data = error["data"]
|
|
58
|
+
|
|
59
|
+
case type
|
|
60
|
+
when "required"
|
|
61
|
+
format_required(error, path)
|
|
62
|
+
when "string", "integer", "number", "boolean", "array", "object", "null"
|
|
63
|
+
" - #{path} = #{truncate(data.inspect)} -- expected #{type}, got #{data.class}"
|
|
64
|
+
when "minimum"
|
|
65
|
+
" - #{path} = #{truncate(data.inspect)} -- must be >= #{error.dig("schema", "minimum")}"
|
|
66
|
+
when "maximum"
|
|
67
|
+
" - #{path} = #{truncate(data.inspect)} -- must be <= #{error.dig("schema", "maximum")}"
|
|
68
|
+
when "enum"
|
|
69
|
+
allowed = error.dig("schema", "enum")
|
|
70
|
+
" - #{path} = #{truncate(data.inspect)} -- must be one of: #{truncate(allowed.inspect)}"
|
|
71
|
+
when "pattern"
|
|
72
|
+
pattern = error.dig("schema", "pattern")
|
|
73
|
+
" - #{path} = #{truncate(data.inspect)} -- does not match pattern: #{pattern}"
|
|
74
|
+
when "format"
|
|
75
|
+
fmt = error.dig("schema", "format")
|
|
76
|
+
" - #{path} = #{truncate(data.inspect)} -- invalid #{fmt} format"
|
|
77
|
+
when "minLength"
|
|
78
|
+
" - #{path} = #{truncate(data.inspect)} -- length must be >= #{error.dig("schema", "minLength")}"
|
|
79
|
+
when "maxLength"
|
|
80
|
+
" - #{path} = #{truncate(data.inspect)} -- length must be <= #{error.dig("schema", "maxLength")}"
|
|
81
|
+
when "minItems"
|
|
82
|
+
" - #{path} -- array must have >= #{error.dig("schema", "minItems")} items"
|
|
83
|
+
when "maxItems"
|
|
84
|
+
" - #{path} -- array must have <= #{error.dig("schema", "maxItems")} items"
|
|
85
|
+
when "uniqueItems"
|
|
86
|
+
" - #{path} -- array items must be unique"
|
|
87
|
+
when "const"
|
|
88
|
+
expected = error.dig("schema", "const")
|
|
89
|
+
" - #{path} = #{truncate(data.inspect)} -- must be #{truncate(expected.inspect)}"
|
|
90
|
+
when "additionalProperties"
|
|
91
|
+
" - #{path}: #{error["error"] || "has additional properties that are not allowed"}"
|
|
92
|
+
else
|
|
93
|
+
msg = error["error"] || type
|
|
94
|
+
" - #{path}: #{msg}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def format_required(error, path)
|
|
99
|
+
missing = error.dig("details", "missing_keys") || []
|
|
100
|
+
if missing.empty?
|
|
101
|
+
" - #{path}: #{error["error"] || "required"}"
|
|
102
|
+
else
|
|
103
|
+
missing.map { |key| " - #{join_path(path, key)} is required but missing" }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def pointer_to_dot(pointer)
|
|
108
|
+
if pointer.nil? || pointer.empty?
|
|
109
|
+
"root"
|
|
110
|
+
else
|
|
111
|
+
pointer.delete_prefix("/").gsub("/", ".")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def join_path(base, key)
|
|
116
|
+
base == "root" ? key.to_s : "#{base}.#{key}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def truncate(str, max: 60)
|
|
120
|
+
str.length > max ? "#{str[0...max]}..." : str
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
__END__
|
|
128
|
+
describe "Protocol::Matrix::Schema::ValidationError" do
|
|
129
|
+
def error_hash(overrides = {})
|
|
130
|
+
{
|
|
131
|
+
"data" => nil,
|
|
132
|
+
"data_pointer" => "",
|
|
133
|
+
"schema" => {},
|
|
134
|
+
"schema_pointer" => "",
|
|
135
|
+
"root_schema" => {},
|
|
136
|
+
"type" => "unknown",
|
|
137
|
+
"error" => "something went wrong"
|
|
138
|
+
}.merge(overrides)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "includes event type in header" do
|
|
142
|
+
err = Protocol::Matrix::Schema::ValidationError.new(
|
|
143
|
+
[error_hash],
|
|
144
|
+
event_type: "m.room.message"
|
|
145
|
+
)
|
|
146
|
+
err.message.should.include "Schema validation failed for m.room.message"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "includes event ID in header" do
|
|
150
|
+
err = Protocol::Matrix::Schema::ValidationError.new(
|
|
151
|
+
[error_hash],
|
|
152
|
+
event_type: "m.room.member",
|
|
153
|
+
event_id: "$abc123"
|
|
154
|
+
)
|
|
155
|
+
err.message.should.include "m.room.member event $abc123"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "formats type mismatch errors" do
|
|
159
|
+
err = Protocol::Matrix::Schema::ValidationError.new([
|
|
160
|
+
error_hash(
|
|
161
|
+
"data_pointer" => "/content/body",
|
|
162
|
+
"type" => "string",
|
|
163
|
+
"data" => 42
|
|
164
|
+
)
|
|
165
|
+
])
|
|
166
|
+
err.message.should.include "content.body = 42 -- expected string, got Integer"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it "formats required errors with missing keys" do
|
|
170
|
+
err = Protocol::Matrix::Schema::ValidationError.new([
|
|
171
|
+
error_hash(
|
|
172
|
+
"data_pointer" => "/content",
|
|
173
|
+
"type" => "required",
|
|
174
|
+
"details" => {"missing_keys" => ["msgtype", "body"]}
|
|
175
|
+
)
|
|
176
|
+
])
|
|
177
|
+
err.message.should.include "content.msgtype is required but missing"
|
|
178
|
+
err.message.should.include "content.body is required but missing"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "formats enum errors" do
|
|
182
|
+
err = Protocol::Matrix::Schema::ValidationError.new([
|
|
183
|
+
error_hash(
|
|
184
|
+
"data_pointer" => "/content/membership",
|
|
185
|
+
"type" => "enum",
|
|
186
|
+
"data" => "invalid",
|
|
187
|
+
"schema" => {"enum" => %w[invite join knock leave ban]}
|
|
188
|
+
)
|
|
189
|
+
])
|
|
190
|
+
err.message.should.include 'content.membership = "invalid" -- must be one of:'
|
|
191
|
+
err.message.should.include "invite"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it "formats pattern errors" do
|
|
195
|
+
err = Protocol::Matrix::Schema::ValidationError.new([
|
|
196
|
+
error_hash(
|
|
197
|
+
"data_pointer" => "/state_key",
|
|
198
|
+
"type" => "pattern",
|
|
199
|
+
"data" => "bad",
|
|
200
|
+
"schema" => {"pattern" => "^@"}
|
|
201
|
+
)
|
|
202
|
+
])
|
|
203
|
+
err.message.should.include 'state_key = "bad" -- does not match pattern: ^@'
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it "formats format errors" do
|
|
207
|
+
err = Protocol::Matrix::Schema::ValidationError.new([
|
|
208
|
+
error_hash(
|
|
209
|
+
"data_pointer" => "/content/avatar_url",
|
|
210
|
+
"type" => "format",
|
|
211
|
+
"data" => "not-a-uri",
|
|
212
|
+
"schema" => {"format" => "uri"}
|
|
213
|
+
)
|
|
214
|
+
])
|
|
215
|
+
err.message.should.include "invalid uri format"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it "truncates long values" do
|
|
219
|
+
err = Protocol::Matrix::Schema::ValidationError.new([
|
|
220
|
+
error_hash(
|
|
221
|
+
"data_pointer" => "/content/body",
|
|
222
|
+
"type" => "integer",
|
|
223
|
+
"data" => "a" * 200
|
|
224
|
+
)
|
|
225
|
+
])
|
|
226
|
+
err.message.should.include "..."
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
it "exposes the raw errors array" do
|
|
230
|
+
raw = [error_hash]
|
|
231
|
+
err = Protocol::Matrix::Schema::ValidationError.new(raw)
|
|
232
|
+
err.errors.should.equal raw
|
|
233
|
+
end
|
|
234
|
+
end
|