Repository navigation
Expand file tree
/
Copy pathDeveloperDiagnostics.lua
More file actions
609 lines (581 loc) · 20.6 KB
/
Copy pathDeveloperDiagnostics.lua
File metadata and controls
609 lines (581 loc) · 20.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
local QT = _G.QuestTogether
local L = QT.Translate
local PRIVATE_LOCATION_LIFETIME, MAX_PRIVATE_LOCATIONS = 120, 512
local function Hex(s)
return (s:gsub(".", function(c)
return string.format("%02x", c:byte())
end))
end
local function Unhex(s, length)
if type(s) ~= "string" or #s ~= length * 2 or s:find("[^%da-f]") then
return nil
end
return (s:gsub("..", function(pair)
return string.char(tonumber(pair, 16))
end))
end
local function SignedText(request, sender, purpose)
if purpose == "announcements" then
return table.concat({
"QTDEVFEED2",
sender,
request.requestId,
tostring(request.issuedAt),
request.active and "start" or "stop",
request.subscriptionSession,
request.targetName or "",
}, "\n")
end
return table.concat({
"QTDEV2",
sender,
request.requestId,
tostring(request.issuedAt),
request.debugRequest and "debug" or "snapshot",
request.targetName or "",
}, "\n")
end
local function Name(addon, name)
name = addon:NormalizeMemberName(name)
if not name or #name > 120 or name:find("[%c|,]") then
return nil
end
return name
end
local function PrepareRequest(addon, request, target, purpose)
local sign = rawget(addon, "SignDeveloperRequest")
if type(request) ~= "table" or rawget(addon, "isLocalDeveloper") ~= true or type(sign) ~= "function" then
return nil
end
local now = addon:GetAnnouncementServerTime()
local sender = Name(addon, addon:GetPlayerFullName())
if not now or not sender then
return nil
end
if target then
target = Name(addon, target)
if not target then
return nil
end
end
request.requestId =
string.format("dev-%.0f-%d-%.0f", now, addon.API.Random(1000, 9999), addon:NextCommunicationSequence())
request.requesterName = "" -- Transport identity is authoritative; do not spend packet bytes repeating it.
request.issuedAt, request.targetName = now, target
request.developerRequest = true
local ok, signature = pcall(sign, addon, SignedText(request, sender, purpose))
if not ok or type(signature) ~= "string" or #signature ~= 64 then
return nil
end
request.signature = Hex(signature)
return request
end
local function ValidSubscription(request)
return type(request) == "table"
and type(request.active) == "boolean"
and type(request.subscriptionSession) == "string"
and #request.subscriptionSession <= 64
and request.subscriptionSession:match("^[%w%-]+$") ~= nil
end
function QT:PrepareDeveloperPingRequest(request, target, debugRequest)
if type(request) ~= "table" then
return nil
end
request.debugRequest = debugRequest == true
return PrepareRequest(self, request, target, "ping")
end
function QT:PrepareDeveloperAnnouncementSubscription(request, target)
if not ValidSubscription(request) then
return nil
end
return PrepareRequest(self, request, target, "announcements")
end
local function VerificationEnabled(addon, purpose)
-- Public signed identity can be authenticated by every client. Diagnostic
-- requests still require consent before entering the same bounded verifier.
return addon.isEnabled
and not addon.isLoggingOut
and (purpose == "announcements" or addon:GetOption("shareDeveloperDiagnostics") == true)
end
local function ProfileMilliseconds(addon)
local read = addon.API.ProfileMilliseconds
return read and addon:SafeToNumber(read()) or nil
end
local function Measure(metrics, name, value)
value = math.max(0, value)
metrics[name .. "Last"] = value
metrics[name .. "Total"] = (metrics[name .. "Total"] or 0) + value
metrics[name .. "Max"] = math.max(metrics[name .. "Max"] or 0, value)
end
function QT:GetDeveloperCommunicationMetrics()
local state = rawget(self, "developerRequestState")
local result = { queued = 0, oldestAge = 0 }
if not state then
return result
end
for key, value in pairs(state.metrics or {}) do
result[key] = value
end
local now = self:SafeToNumber(self.API.GetTime()) or state.startedAt
result.queued = #state.jobs
for _, job in ipairs(state.jobs) do
result.oldestAge = math.max(result.oldestAge, now - job.startedAt)
end
return result
end
function QT:CancelIgnoredDeveloperRequests()
local state = rawget(self, "developerRequestState")
for _, job in ipairs(state and state.jobs or {}) do
if self:IsIgnoredPlayerName(job.sender) then
job.request.cancelled = true
end
end
end
function QT:DrainDeveloperPingVerifications()
local state = rawget(self, "developerRequestState")
if not state then
return
end
if not VerificationEnabled(self, "announcements") then
self.developerRequestState = nil
return
end
local job = state.jobs[1]
if not job then
return
end
local now, serverNow = self.API.GetTime(), self:GetAnnouncementServerTime()
if state.lastAt and now < state.lastAt then
self.developerRequestState = nil
return
end
state.lastAt = now
local expired = not serverNow
or not VerificationEnabled(self, job.request.developerPurpose)
or job.request.cancelled
or now < job.startedAt
or serverNow - job.request.issuedAt > 300
or self:IsIgnoredPlayerName(job.sender)
local done, valid = expired, false
if not done and now >= state.nextAllowed then
local started = ProfileMilliseconds(self)
state.metrics.slices = (state.metrics.slices or 0) + 1
if not job.verificationStartedAt then
job.verificationStartedAt = now
Measure(state.metrics, "queueSeconds", now - job.startedAt)
end
local lastProfile = started
-- Both bounds apply: a missing/paused/reset profiler still cannot turn
-- this into one synchronous 100ms verification. Yield between frames.
for _ = 1, 8 do
state.metrics.steps = (state.metrics.steps or 0) + 1
local ok, result = coroutine.resume(job.verifier)
if not ok or coroutine.status(job.verifier) == "dead" then
done, valid = true, ok and result == true
break
end
local current = started and ProfileMilliseconds(self)
lastProfile = current or lastProfile
if current and (current < started or current - started >= 1) then
break
end
end
if started and lastProfile and lastProfile >= started then
state.metrics.verificationMilliseconds = (state.metrics.verificationMilliseconds or 0)
+ lastProfile
- started
end
end
if done then
local outcome = valid and "verified" or (expired and "expired" or "failed")
state.metrics[outcome] = (state.metrics[outcome] or 0) + 1
if job.verificationStartedAt then
Measure(state.metrics, "verificationSeconds", now - job.verificationStartedAt)
end
table.remove(state.jobs, 1)
state.pendingSenders[job.sender] = nil
state.nextAllowed = now + 5
local failure = state.failures[job.sender]
if valid then
state.failures[job.sender] = nil
job.request.developerVerified = true
-- State identity fences disable, profile changes, opt-out, and leaving
-- the world. No diagnostic reads occur before this boundary.
if
rawget(self, "developerRequestState") == state
and VerificationEnabled(self, job.request.developerPurpose)
then
local ok = pcall(job.callback, job.request)
if not ok then
self:Debug("Developer request callback failed", "comms")
end
end
elseif not expired then
local count = math.min(5, (failure and failure.count or 0) + 1)
if not failure then
local entries, oldest, oldestAt = 0
for name, row in pairs(state.failures) do
entries = entries + 1
if not oldestAt or row.failedAt < oldestAt then
oldest, oldestAt = name, row.failedAt
end
end
if entries >= 64 then
state.failures[oldest] = nil
end
end
state.failures[job.sender] =
{ count = count, failedAt = now, untilAt = now + math.min(60, 5 * 2 ^ (count - 1)) }
end
end
end
local function QueueVerification(self, request, sender, callback, purpose)
if type(request) ~= "table" or not VerificationEnabled(self, purpose) or type(callback) ~= "function" then
return false
end
local now, senderName = self:GetAnnouncementServerTime(), Name(self, sender)
local issued = self:SafeToNumber(request.issuedAt)
local target = request.targetName
if
not senderName
or not now
or not issued
or issued ~= math.floor(issued)
or issued > now + 30
or now - issued > 300
or type(request.requestId) ~= "string"
or #request.requestId > 64
or not request.requestId:match("^dev%-%d+%-%d+%-%d+$")
or (target and target ~= self:NormalizeMemberName(self:GetPlayerFullName()))
or (purpose == "ping" and request.debugRequest and not target)
or (purpose == "announcements" and not ValidSubscription(request))
then
return false
end
local signature, public = Unhex(request.signature, 64), Unhex(self.developerPublicKeyHex, 32)
if not signature or not public then
return false
end
local state = rawget(self, "developerRequestState")
local tick = self.API.GetTime()
if not state or tick < state.startedAt or (state.lastAt and tick < state.lastAt) then
state = {
seen = {},
jobs = {},
failures = {},
pendingSenders = {},
startedAt = tick,
lastAt = tick,
nextAllowed = tick,
metrics = {},
}
self.developerRequestState = state
end
state.lastAt = tick
local count = 0
for key, at in pairs(state.seen) do
if now - at > 300 or now < at then
state.seen[key] = nil
else
count = count + 1
end
end
for name, failure in pairs(state.failures) do
if tick < failure.failedAt or tick >= failure.untilAt + 300 then
state.failures[name] = nil
end
end
local key = senderName .. "|" .. request.requestId
local failure = state.failures[senderName]
if
state.seen[key]
or count >= 64
or #state.jobs >= 4
or state.pendingSenders[senderName]
or (failure and tick < failure.untilAt)
or self:IsIgnoredPlayerName(senderName)
then
return false
end
local copy = {}
for _, field in ipairs({ "requestId", "issuedAt", "targetName", "debugRequest", "developerRequest", "signature" }) do
copy[field] = request[field]
end
if purpose == "announcements" then
copy.active, copy.subscriptionSession = request.active, request.subscriptionSession
end
copy.developerPurpose = purpose
copy.requesterName = senderName
state.metrics.admitted = (state.metrics.admitted or 0) + 1
state.seen[key], state.pendingSenders[senderName] = now, true
state.jobs[#state.jobs + 1] = {
request = copy,
sender = senderName,
callback = callback,
startedAt = tick,
verifier = self.Ed25519.NewVerification(public, SignedText(copy, senderName, purpose), signature),
}
-- RuntimeCoordinator advances verification in bounded slices.
return true
end
function QT:QueueDeveloperPingVerification(request, sender, callback)
return QueueVerification(self, request, sender, callback, "ping")
end
function QT:QueueDeveloperAnnouncementVerification(request, sender, callback)
return QueueVerification(self, request, sender, callback, "announcements")
end
function QT:BuildDeveloperDiagnosticSnapshot()
local fields = {}
-- Only QT-owned, explicitly declared primitive settings. No SavedVariables
-- dump, chat history, arbitrary globals, other addons, or private key material.
local keys = {}
for key, value in pairs(self.DEFAULTS.profile) do
if type(value) ~= "table" then
keys[#keys + 1] = key
end
end
table.sort(keys)
for _, key in ipairs(keys) do
local value = self:GetOption(key)
if
self:CanAccessValue(value)
and (type(value) == "boolean" or type(value) == "number" or type(value) == "string")
then
fields[#fields + 1] = { "option." .. key, value }
end
end
-- Settings share the report window's 32 KiB budget and explicit truncation
-- marker instead of being appended outside the diagnostic report's bound.
return self:BuildDiagnosticReport(nil, fields)
end
function QT:AddDeveloperPingMetadata(response, request)
if not request or not request.developerVerified or self:GetOption("shareDeveloperDiagnostics") ~= true then
return false
end
response.developer = true
response.locationShared = self:CanPublishPlayerLocation()
response.lookingForQuestPartners = self:GetOption("lookingForQuestPartners") == true
response.sampledAt = self:GetAnnouncementServerTime()
local location = self:ReadLocalPlayerLocation()
if location then
response.mapID, response.coordX, response.coordY =
tostring(location.mapID), string.format("%.1f", location.x * 100), string.format("%.1f", location.y * 100)
response.faction = location.faction
response.warMode = location.warMode == nil and "" or (location.warMode and "1" or "0")
end
response.party = self:BuildPartyVisualMetadata()
return true
end
local function PrivateLocations(addon, now)
local data = rawget(addon, "developerPlayerData")
if not data then data = {}; addon.developerPlayerData = data end
local count = 0
for name, row in pairs(data) do
if now < row.receivedAt or now - row.receivedAt >= PRIVATE_LOCATION_LIFETIME then
data[name] = nil
else
count = count + 1
end
end
return data, count
end
local function StorePrivateLocation(data, name, location, sampledAt)
location.name, location.receivedAt = name, sampledAt
location.lifetime, location.mask = PRIVATE_LOCATION_LIFETIME, 3
location.developerOnly, location.publicLocationHidden = true, true
data[name] = location
end
-- A signed subscription authorizes delivery, while the publisher's envelope
-- still owns location ordering. Private coordinates never enter the public
-- cache: its matching observation is only a coordinate-free withdrawal.
function QT:HandleDeveloperLocationSnapshot(record, sender, context)
if not self.isEnabled or self.isLoggingOut or type(record) ~= "table" or type(context) ~= "table"
or context.distribution ~= "WHISPER" or type(record.locationShared) ~= "boolean"
or not self:IsDeveloperLocationSubscriptionCurrent(record.subscriptionSession, record.subscriptionId) then
return false
end
local name = Name(self, sender)
local session, sequence = context.session, self:SafeToNumber(context.sequence)
local now, server = self:SafeToNumber(self.API.GetTime()), self:GetAnnouncementServerTime()
local stamp = self:SafeToNumber(context.stamp)
if not name or self:IsSelfSender(name) or self:IsIgnoredPlayerName(name)
or type(session) ~= "string" or #session > 64 or not session:match("^[%w%-]+$")
or not sequence or sequence < 1 or sequence > 1e12 or sequence ~= math.floor(sequence)
or not now or not server or not stamp or stamp <= 0 then return false end
local age = server - stamp
if age < -1 or age >= PRIVATE_LOCATION_LIFETIME then return false end
local location = self:ValidatePlayerLocationRecord(record.location)
if not location or (location.mask ~= 0 and location.mask ~= 3) then return false end
local sharedPosition = record.locationShared and location.mask == 3
-- Unavailable coordinates do not withdraw public sharing or refresh the old
-- sample. Explicit locationShared=false still clears public coordinates below.
if record.locationShared and location.mask == 0 then return false end
-- Receipt must be recent for every feed packet, but accepted public points
-- retain the normal geographic lease independently of this optional feed.
local lifetime = sharedPosition and self:GetGeographicSnapshotLifetime() or PRIVATE_LOCATION_LIFETIME
local observation = self:CreatePeerObservation(name, "LOC", {
source = "developer-feed", age = math.max(0, age), lifetime = lifetime,
session = session, sequence = sequence, stamp = stamp, sourceSampledAt = stamp,
})
if not self:CanAcceptPeerObservation(observation) then return false end
location.session, location.sequence, location.retired = session, sequence, {}
local public = sharedPosition and location
or { mask = 0, session = session, sequence = sequence, retired = {} }
if not self:StorePlayerLocationRecord(name, public, now, observation.sampledAt,
observation.expiresAt - now, observation) then return false end
self:RecordPeerPresenceFromSnapshot(name, observation.sampledAt, session, sequence)
local data, count = PrivateLocations(self, now)
local previous = data[name]
-- Clear only after admission, so an older unavailable/public snapshot cannot
-- erase a newer private point or allow a reordered private point to return.
data[name] = nil
if not record.locationShared and location.mask == 3 and (previous or count < MAX_PRIVATE_LOCATIONS) then
StorePrivateLocation(data, name, location, observation.sampledAt)
end
self:RefreshPlayerLocationPins()
return true
end
function QT:AcceptDeveloperPingResponse(response, pending, observation)
if rawget(self, "isLocalDeveloper") ~= true or not pending.developerRequest or not response.developer then
return
end
local name = Name(self, response.senderName)
if not name or self:IsSelfSender(name) or self:IsIgnoredPlayerName(name) then
return
end
-- A correlated report remains useful after departure. Its sampled player
-- metadata still has to pass the freshness fence below before updating peers.
local now = self.API.GetTime()
if observation == nil then
observation = self:CreatePingResponseObservation(response, pending)
end
local age = observation and now - observation.sampledAt
local data, count = PrivateLocations(self, now)
if age and age >= -30 and age < self:GetGeographicSnapshotLifetime() then
age = math.max(0, age)
if not self:CanAcceptPeerUpdate(name, nil, now - age) then
return
end
-- Each field keeps its own ordering. A newer location need not suppress a
-- useful party refresh, but old party data must never replace a newer one.
if response.party then
self:HandlePartyVisualMetadata(
response.party,
name,
self:CreatePeerObservation(
name,
"QTPG",
{ source = "diagnostic", age = age, lifetime = self:GetGeographicSnapshotLifetime(),
sourceSampledAt = observation.sourceSampledAt }
)
)
end
self:RefreshQuestPartnerStatusFromPing(name, response.lookingForQuestPartners, age)
local previous = data[name]
local fresh = not previous or now - age >= previous.receivedAt
local accepted = fresh and self:RefreshPlayerLocationFromPing(name, response, observation)
if accepted and response.locationShared == false and age < PRIVATE_LOCATION_LIFETIME
and (count < MAX_PRIVATE_LOCATIONS or previous) then
local map, x, y, level =
self:SafeToNumber(response.mapID),
self:SafeToNumber(response.coordX),
self:SafeToNumber(response.coordY),
self:SafeToNumber(response.level)
if
map
and map > 0
and map == math.floor(map)
and map <= 1000000
and x
and x >= 0
and x <= 100
and y
and y >= 0
and y <= 100
then
StorePrivateLocation(data, name, {
mapID = map,
x = x / 100,
y = y / 100,
classFile = response.classFile,
className = response.className,
race = response.raceName,
level = level,
faction = response.faction,
warMode = self:NormalizeAnnouncementWarModeValue(response.warMode),
lookingForQuestPartners = response.lookingForQuestPartners,
addonVersion = response.addonVersion,
}, now - age)
end
end
end
self:RefreshPlayerLocationPins()
end
function QT:IsPlayerLocationHiddenForDeveloper(name)
if not self.isEnabled or self.isLoggingOut or rawget(self, "isLocalDeveloper") ~= true then
return false
end
name = Name(self, name)
if not name or self:IsSelfSender(name) or self:IsIgnoredPlayerName(name) then
return false
end
local data = rawget(self, "developerPlayerData")
local row = data and data[name]
local now = self:SafeToNumber(self.API.GetTime())
return row ~= nil
and row.developerOnly == true
and row.publicLocationHidden == true
and now ~= nil
and now >= row.receivedAt
and now - row.receivedAt < PRIVATE_LOCATION_LIFETIME
and self:CanAcceptPeerUpdate(name, nil, row.receivedAt)
end
function QT:AppendDeveloperLocationRows(rows)
if rawget(self, "isLocalDeveloper") ~= true then
return
end
local data = rawget(self, "developerPlayerData")
local now, indices = self.API.GetTime(), {}
for index, row in ipairs(rows) do
indices[row.name] = index
end
for name, row in pairs(data or {}) do
if now < row.receivedAt or now - row.receivedAt >= PRIVATE_LOCATION_LIFETIME or self:IsIgnoredPlayerName(name) then
data[name] = nil
elseif self:ShouldShowPlayerLocation(name) then
-- A newer live public sample wins over an older diagnostic snapshot.
local index = indices[name]
if not index then
rows[#rows + 1] = row
elseif rows[index].receivedAt <= row.receivedAt then
rows[index] = row
end
end
end
end
function QT:RequestRemoteDiagnostics(input)
if rawget(self, "isLocalDeveloper") ~= true then
return false
end
local target = self:SafeTrimString(input, "")
if target:sub(1, 1) == '"' then
target = target:match('^"([^"]+)"$')
end
if not target or target == "" then
self:Print(L("Ping is unavailable."))
return false
end
local ok, detail = self:SendPingRequest(target, true)
self:Print(ok and L("Ping sent.") or detail or L("Ping is unavailable."))
return ok
end
function QT:RetireDeveloperLocationPeer(name)
local data = rawget(self, "developerPlayerData")
if data then
data[name] = nil
end
end
function QT:ResetDeveloperDiagnostics()
self.developerPlayerData = nil
self.developerRequestState = nil
end