@blockcast/mmt-manifest-verify 0.1.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.
- package/dist/.build-stamp +0 -0
- package/dist/admission.d.ts +28 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/admission.js +128 -0
- package/dist/admission.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/source-auth.d.ts +37 -0
- package/dist/source-auth.d.ts.map +1 -0
- package/dist/source-auth.js +179 -0
- package/dist/source-auth.js.map +1 -0
- package/dist/verifier.d.ts +72 -0
- package/dist/verifier.d.ts.map +1 -0
- package/dist/verifier.js +376 -0
- package/dist/verifier.js.map +1 -0
- package/package.json +40 -0
- package/src/admission.ts +171 -0
- package/src/index.ts +3 -0
- package/src/source-auth.ts +230 -0
- package/src/verifier.test.ts +307 -0
- package/src/verifier.ts +520 -0
package/src/verifier.ts
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeSourceAuthManifestFrame,
|
|
3
|
+
sourceAuthMerkleRoot,
|
|
4
|
+
sourceAuthObjectDigest,
|
|
5
|
+
sourceAuthSignaturePreimage,
|
|
6
|
+
type SourceAuthGroupClose,
|
|
7
|
+
type SourceAuthObjectEntry,
|
|
8
|
+
} from './source-auth.js'
|
|
9
|
+
|
|
10
|
+
export type ManifestAuthMode = 'off' | 'shadow' | 'enforce'
|
|
11
|
+
export type MissingManifestPolicy = 'stall' | 'fallback-shadow'
|
|
12
|
+
export type ManifestFailureReason =
|
|
13
|
+
| 'sigInvalid'
|
|
14
|
+
| 'digestMismatch'
|
|
15
|
+
| 'manifestLate'
|
|
16
|
+
| 'manifestAbsent'
|
|
17
|
+
| 'verifierError'
|
|
18
|
+
|
|
19
|
+
export interface ManifestAuthConfig {
|
|
20
|
+
mode: ManifestAuthMode
|
|
21
|
+
onMissingManifest: MissingManifestPolicy
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ManifestObjectIdentity {
|
|
25
|
+
trackName: string
|
|
26
|
+
/** MoQ group ids are u62; values above 2^53 must be passed as bigint. */
|
|
27
|
+
groupId: number | bigint
|
|
28
|
+
objectId: number
|
|
29
|
+
packetId?: number
|
|
30
|
+
ssId?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ManifestFecBlockMapper {
|
|
34
|
+
readonly d: number
|
|
35
|
+
derive(groupId: number, objectId: number): { ssId: number }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ManifestTrackContext {
|
|
39
|
+
broadcastNamespaceId: string
|
|
40
|
+
trackName: string
|
|
41
|
+
keyEpoch: number
|
|
42
|
+
codec: string
|
|
43
|
+
timing: string
|
|
44
|
+
fecGeometry: string
|
|
45
|
+
manifestFormatVersion: number
|
|
46
|
+
/** Authenticated-catalog binding for the namespace, track, epoch, codec,
|
|
47
|
+
* timing, FEC geometry, and manifest version fields above. */
|
|
48
|
+
authScope: Uint8Array
|
|
49
|
+
publicKey: Uint8Array
|
|
50
|
+
bitrate: number
|
|
51
|
+
groupDurationMs: number
|
|
52
|
+
mapper: ManifestFecBlockMapper
|
|
53
|
+
manifestChannelAuthenticated: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ManifestVerificationStats {
|
|
57
|
+
verified: number
|
|
58
|
+
failures: Record<ManifestFailureReason, number>
|
|
59
|
+
quarantinedBytes: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ManifestFailure {
|
|
63
|
+
trackName: string
|
|
64
|
+
groupId: number | bigint
|
|
65
|
+
objectId: number
|
|
66
|
+
reason: ManifestFailureReason
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ManifestAdmission {
|
|
70
|
+
action: 'play' | 'stall' | 'drop'
|
|
71
|
+
verified: boolean
|
|
72
|
+
reason?: ManifestFailureReason
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type SignatureVerifier = (
|
|
76
|
+
publicKey: Uint8Array,
|
|
77
|
+
message: Uint8Array,
|
|
78
|
+
signature: Uint8Array,
|
|
79
|
+
) => boolean
|
|
80
|
+
|
|
81
|
+
interface PendingObject {
|
|
82
|
+
identity: ManifestObjectIdentity
|
|
83
|
+
data: Uint8Array
|
|
84
|
+
release: () => void
|
|
85
|
+
bytes: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface GroupState {
|
|
89
|
+
entries: Map<number, SourceAuthObjectEntry>
|
|
90
|
+
pending: Map<number, PendingObject>
|
|
91
|
+
close?: SourceAuthGroupClose
|
|
92
|
+
/** Close frame that arrived before all object entries (manifest-channel
|
|
93
|
+
* reordering); retried once the entry count matches. */
|
|
94
|
+
pendingClose?: SourceAuthGroupClose
|
|
95
|
+
rootVerified: boolean
|
|
96
|
+
failed?: ManifestFailureReason
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface TrackState {
|
|
100
|
+
context: ManifestTrackContext
|
|
101
|
+
groups: Map<bigint, GroupState>
|
|
102
|
+
quarantine: PendingObject[]
|
|
103
|
+
quarantinedBytes: number
|
|
104
|
+
capBytes: number
|
|
105
|
+
manifestEntries: number
|
|
106
|
+
maxManifestEntries: number
|
|
107
|
+
maxGroups: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const FAILURE_REASONS: readonly ManifestFailureReason[] = [
|
|
111
|
+
'sigInvalid',
|
|
112
|
+
'digestMismatch',
|
|
113
|
+
'manifestLate',
|
|
114
|
+
'manifestAbsent',
|
|
115
|
+
'verifierError',
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
const equalBytes = (left: Uint8Array, right: Uint8Array): boolean => {
|
|
119
|
+
if (left.byteLength !== right.byteLength) return false
|
|
120
|
+
let diff = 0
|
|
121
|
+
for (let index = 0; index < left.byteLength; index++) diff |= left[index]! ^ right[index]!
|
|
122
|
+
return diff === 0
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class ManifestVerifier {
|
|
126
|
+
#config: ManifestAuthConfig
|
|
127
|
+
readonly #verifySignature: SignatureVerifier
|
|
128
|
+
readonly #onFailure?: (failure: ManifestFailure) => void
|
|
129
|
+
readonly #tracks = new Map<string, TrackState>()
|
|
130
|
+
readonly #stats: ManifestVerificationStats = {
|
|
131
|
+
verified: 0,
|
|
132
|
+
failures: Object.fromEntries(FAILURE_REASONS.map((reason) => [reason, 0])) as Record<ManifestFailureReason, number>,
|
|
133
|
+
quarantinedBytes: 0,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
constructor(options: {
|
|
137
|
+
manifestAuth: ManifestAuthConfig
|
|
138
|
+
verifySignature: SignatureVerifier
|
|
139
|
+
onFailure?: (failure: ManifestFailure) => void
|
|
140
|
+
}) {
|
|
141
|
+
this.#config = { ...options.manifestAuth }
|
|
142
|
+
this.#verifySignature = options.verifySignature
|
|
143
|
+
this.#onFailure = options.onFailure
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get config(): Readonly<ManifestAuthConfig> {
|
|
147
|
+
return this.#config
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
get stats(): ManifestVerificationStats {
|
|
151
|
+
return {
|
|
152
|
+
verified: this.#stats.verified,
|
|
153
|
+
failures: { ...this.#stats.failures },
|
|
154
|
+
quarantinedBytes: this.#stats.quarantinedBytes,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
setMode(mode: ManifestAuthMode): void {
|
|
159
|
+
this.#config = { ...this.#config, mode }
|
|
160
|
+
if (mode !== 'enforce') this.#releaseAllQuarantine(false)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
registerTrack(context: ManifestTrackContext): void {
|
|
164
|
+
if (this.#tracks.has(context.trackName)) {
|
|
165
|
+
throw new Error(`manifest track ${context.trackName} is already registered`)
|
|
166
|
+
}
|
|
167
|
+
if (!Number.isFinite(context.bitrate) || context.bitrate <= 0) {
|
|
168
|
+
throw new Error('manifest track bitrate must be > 0')
|
|
169
|
+
}
|
|
170
|
+
if (!Number.isFinite(context.groupDurationMs) || context.groupDurationMs <= 0) {
|
|
171
|
+
throw new Error('manifest track groupDurationMs must be > 0')
|
|
172
|
+
}
|
|
173
|
+
if (context.authScope.byteLength !== 32) throw new Error('manifest authScope must be 32 bytes')
|
|
174
|
+
if (context.publicKey.byteLength !== 32) throw new Error('manifest publicKey must be 32 bytes')
|
|
175
|
+
for (const [name, value] of Object.entries({
|
|
176
|
+
broadcastNamespaceId: context.broadcastNamespaceId,
|
|
177
|
+
trackName: context.trackName,
|
|
178
|
+
codec: context.codec,
|
|
179
|
+
timing: context.timing,
|
|
180
|
+
fecGeometry: context.fecGeometry,
|
|
181
|
+
})) {
|
|
182
|
+
if (value.length === 0) throw new Error(`manifest ${name} must not be empty`)
|
|
183
|
+
}
|
|
184
|
+
this.#tracks.set(context.trackName, {
|
|
185
|
+
context,
|
|
186
|
+
groups: new Map(),
|
|
187
|
+
quarantine: [],
|
|
188
|
+
quarantinedBytes: 0,
|
|
189
|
+
capBytes: Math.ceil((context.bitrate * context.groupDurationMs) / 8000),
|
|
190
|
+
manifestEntries: 0,
|
|
191
|
+
maxManifestEntries: Math.ceil((context.bitrate * context.groupDurationMs) / (8000 * 52)),
|
|
192
|
+
maxGroups: context.mapper.d + 1,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
ingestManifestFrame(trackName: string, frameBytes: Uint8Array): void {
|
|
197
|
+
const track = this.#requireTrack(trackName)
|
|
198
|
+
try {
|
|
199
|
+
const frame = decodeSourceAuthManifestFrame(frameBytes)
|
|
200
|
+
const groupId = frame.groupId
|
|
201
|
+
const group = this.#group(track, groupId)
|
|
202
|
+
if (frame.kind === 'object') {
|
|
203
|
+
if (group.close || group.failed) {
|
|
204
|
+
this.#failure({ trackName, groupId, objectId: frame.objectId }, 'verifierError')
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
if (group.entries.has(frame.objectId)) {
|
|
208
|
+
this.#failure({ trackName, groupId, objectId: frame.objectId }, 'verifierError')
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
if (group.entries.size >= 4096 || track.manifestEntries >= track.maxManifestEntries) {
|
|
212
|
+
this.#failure({ trackName, groupId, objectId: frame.objectId }, 'verifierError')
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
group.entries.set(frame.objectId, frame)
|
|
216
|
+
track.manifestEntries++
|
|
217
|
+
const pending = group.pending.get(frame.objectId)
|
|
218
|
+
if (pending) {
|
|
219
|
+
this.#failure(pending.identity, 'manifestLate')
|
|
220
|
+
this.#resolvePending(track, group, pending, frame)
|
|
221
|
+
}
|
|
222
|
+
if (group.pendingClose && group.entries.size === group.pendingClose.objectCount) {
|
|
223
|
+
const close = group.pendingClose
|
|
224
|
+
group.pendingClose = undefined
|
|
225
|
+
this.#closeGroup(track, groupId, group, close)
|
|
226
|
+
}
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
this.#closeGroup(track, groupId, group, frame)
|
|
230
|
+
} catch {
|
|
231
|
+
this.#failure({ trackName, groupId: 0, objectId: 0 }, 'verifierError')
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
admitObject(
|
|
236
|
+
identity: ManifestObjectIdentity,
|
|
237
|
+
data: Uint8Array,
|
|
238
|
+
release: () => void,
|
|
239
|
+
): ManifestAdmission {
|
|
240
|
+
if (this.#config.mode === 'off') {
|
|
241
|
+
release()
|
|
242
|
+
return { action: 'play', verified: false }
|
|
243
|
+
}
|
|
244
|
+
const track = this.#tracks.get(identity.trackName)
|
|
245
|
+
if (!track) {
|
|
246
|
+
release()
|
|
247
|
+
this.#failure(identity, 'verifierError')
|
|
248
|
+
return { action: 'play', verified: false, reason: 'verifierError' }
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
if (!this.#validateIdentity(track, identity)) {
|
|
252
|
+
return this.#rejectOrShadow(identity, release, 'digestMismatch')
|
|
253
|
+
}
|
|
254
|
+
const group = this.#group(track, BigInt(identity.groupId))
|
|
255
|
+
if (group.failed === 'sigInvalid' || group.failed === 'digestMismatch') {
|
|
256
|
+
return this.#rejectOrShadow(identity, release, group.failed)
|
|
257
|
+
}
|
|
258
|
+
const entry = group.entries.get(identity.objectId)
|
|
259
|
+
if (entry) return this.#admitAgainstEntry(track, group, identity, data, release, entry)
|
|
260
|
+
if (this.#config.mode === 'shadow' || this.#config.onMissingManifest === 'fallback-shadow') {
|
|
261
|
+
release()
|
|
262
|
+
this.#failure(identity, 'manifestAbsent')
|
|
263
|
+
return { action: 'play', verified: false, reason: 'manifestAbsent' }
|
|
264
|
+
}
|
|
265
|
+
if (!this.#quarantine(track, group, { identity, data, release, bytes: data.byteLength })) {
|
|
266
|
+
return { action: 'drop', verified: false }
|
|
267
|
+
}
|
|
268
|
+
return { action: 'stall', verified: false }
|
|
269
|
+
} catch {
|
|
270
|
+
release()
|
|
271
|
+
this.#failure(identity, 'verifierError')
|
|
272
|
+
return { action: 'play', verified: false, reason: 'verifierError' }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
dispose(): void {
|
|
277
|
+
this.#releaseAllQuarantine(false)
|
|
278
|
+
this.#tracks.clear()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#requireTrack(trackName: string): TrackState {
|
|
282
|
+
const track = this.#tracks.get(trackName)
|
|
283
|
+
if (!track) throw new Error(`unknown manifest track ${trackName}`)
|
|
284
|
+
return track
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#group(track: TrackState, groupId: bigint): GroupState {
|
|
288
|
+
let group = track.groups.get(groupId)
|
|
289
|
+
if (!group) {
|
|
290
|
+
group = { entries: new Map(), pending: new Map(), rootVerified: false }
|
|
291
|
+
track.groups.set(groupId, group)
|
|
292
|
+
while (track.groups.size > track.maxGroups) {
|
|
293
|
+
const oldest = track.groups.entries().next().value as [bigint, GroupState] | undefined
|
|
294
|
+
if (!oldest) break
|
|
295
|
+
const [oldestId, oldestGroup] = oldest
|
|
296
|
+
for (const pending of [...oldestGroup.pending.values()]) {
|
|
297
|
+
this.#removePending(track, oldestGroup, pending)
|
|
298
|
+
pending.release()
|
|
299
|
+
this.#failure(pending.identity, 'verifierError')
|
|
300
|
+
}
|
|
301
|
+
track.manifestEntries -= oldestGroup.entries.size
|
|
302
|
+
track.groups.delete(oldestId)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return group
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#validateIdentity(track: TrackState, identity: ManifestObjectIdentity): boolean {
|
|
309
|
+
const coordinates = track.context.mapper.derive(Number(identity.groupId), identity.objectId)
|
|
310
|
+
return identity.ssId === undefined || coordinates.ssId === identity.ssId
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#admitAgainstEntry(
|
|
314
|
+
track: TrackState,
|
|
315
|
+
group: GroupState,
|
|
316
|
+
identity: ManifestObjectIdentity,
|
|
317
|
+
data: Uint8Array,
|
|
318
|
+
release: () => void,
|
|
319
|
+
entry: SourceAuthObjectEntry,
|
|
320
|
+
): ManifestAdmission {
|
|
321
|
+
if (entry.trueLength !== data.byteLength) {
|
|
322
|
+
return this.#rejectOrShadow(identity, release, 'digestMismatch')
|
|
323
|
+
}
|
|
324
|
+
const digestMatches = equalBytes(sourceAuthObjectDigest(data.subarray(0, entry.trueLength)), entry.digest)
|
|
325
|
+
if (!digestMatches) return this.#rejectOrShadow(identity, release, 'digestMismatch')
|
|
326
|
+
if (!track.context.manifestChannelAuthenticated && !group.rootVerified) {
|
|
327
|
+
if (this.#config.mode === 'shadow') {
|
|
328
|
+
release()
|
|
329
|
+
return { action: 'play', verified: false }
|
|
330
|
+
}
|
|
331
|
+
if (!this.#quarantine(track, group, { identity, data, release, bytes: data.byteLength })) {
|
|
332
|
+
return { action: 'drop', verified: false }
|
|
333
|
+
}
|
|
334
|
+
return { action: 'stall', verified: false }
|
|
335
|
+
}
|
|
336
|
+
release()
|
|
337
|
+
this.#stats.verified++
|
|
338
|
+
return { action: 'play', verified: true }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
#rejectOrShadow(
|
|
342
|
+
identity: ManifestObjectIdentity,
|
|
343
|
+
release: () => void,
|
|
344
|
+
reason: 'digestMismatch' | 'sigInvalid',
|
|
345
|
+
): ManifestAdmission {
|
|
346
|
+
this.#failure(identity, reason)
|
|
347
|
+
if (this.#config.mode === 'enforce') {
|
|
348
|
+
console.warn(JSON.stringify({ event: 'manifest-enforce-drop', track: identity.trackName, group: String(identity.groupId), object: identity.objectId, reason }))
|
|
349
|
+
return { action: 'drop', verified: false, reason }
|
|
350
|
+
}
|
|
351
|
+
release()
|
|
352
|
+
return { action: 'play', verified: false, reason }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
#resolvePending(
|
|
356
|
+
track: TrackState,
|
|
357
|
+
group: GroupState,
|
|
358
|
+
pending: PendingObject,
|
|
359
|
+
entry: SourceAuthObjectEntry,
|
|
360
|
+
): void {
|
|
361
|
+
this.#removePending(track, group, pending)
|
|
362
|
+
this.#admitAgainstEntry(track, group, pending.identity, pending.data, pending.release, entry)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#closeGroup(
|
|
366
|
+
track: TrackState,
|
|
367
|
+
groupId: bigint,
|
|
368
|
+
group: GroupState,
|
|
369
|
+
close: SourceAuthGroupClose,
|
|
370
|
+
): void {
|
|
371
|
+
if (group.close) return
|
|
372
|
+
const entries = [...group.entries.values()]
|
|
373
|
+
if (entries.length !== close.objectCount) {
|
|
374
|
+
// Manifest-channel reordering: the close frame beat some object entries.
|
|
375
|
+
// Stash it (latest-wins on duplicates) so the object-entry path can
|
|
376
|
+
// re-attempt the close once the count matches; quarantined objects stay
|
|
377
|
+
// quarantined, and manifestAbsent below is a signal, not an eviction.
|
|
378
|
+
group.pendingClose = close
|
|
379
|
+
const hadPending = group.pending.size > 0
|
|
380
|
+
if (this.#config.onMissingManifest === 'fallback-shadow' || this.#config.mode === 'shadow') {
|
|
381
|
+
for (const pending of [...group.pending.values()]) {
|
|
382
|
+
this.#removePending(track, group, pending)
|
|
383
|
+
pending.release()
|
|
384
|
+
this.#failure(pending.identity, 'manifestAbsent')
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
for (const pending of group.pending.values()) {
|
|
388
|
+
this.#failure(pending.identity, 'manifestAbsent')
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (!hadPending) {
|
|
392
|
+
this.#failure({ trackName: track.context.trackName, groupId, objectId: 0 }, 'manifestAbsent')
|
|
393
|
+
}
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
if (!equalBytes(sourceAuthMerkleRoot(entries), close.root)) {
|
|
397
|
+
group.failed = 'digestMismatch'
|
|
398
|
+
this.#failGroup(track, groupId, group, 'digestMismatch')
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
const preimage = sourceAuthSignaturePreimage(track.context.authScope, close.groupId, close.objectCount, close.root)
|
|
402
|
+
let signatureValid: boolean
|
|
403
|
+
try {
|
|
404
|
+
signatureValid = this.#verifySignature(track.context.publicKey, preimage, close.signature)
|
|
405
|
+
} catch {
|
|
406
|
+
this.#failOpenGroup(track, groupId, group)
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
if (!signatureValid) {
|
|
410
|
+
group.failed = 'sigInvalid'
|
|
411
|
+
this.#failGroup(track, groupId, group, 'sigInvalid')
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
group.failed = undefined
|
|
415
|
+
group.close = close
|
|
416
|
+
group.pendingClose = undefined
|
|
417
|
+
group.rootVerified = true
|
|
418
|
+
for (const pending of [...group.pending.values()]) {
|
|
419
|
+
const entry = group.entries.get(pending.identity.objectId)
|
|
420
|
+
if (entry) this.#resolvePending(track, group, pending, entry)
|
|
421
|
+
else if (this.#config.onMissingManifest === 'fallback-shadow') {
|
|
422
|
+
this.#removePending(track, group, pending)
|
|
423
|
+
pending.release()
|
|
424
|
+
this.#failure(pending.identity, 'manifestAbsent')
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#failGroup(
|
|
430
|
+
track: TrackState,
|
|
431
|
+
groupId: bigint,
|
|
432
|
+
group: GroupState,
|
|
433
|
+
reason: 'digestMismatch' | 'sigInvalid',
|
|
434
|
+
): void {
|
|
435
|
+
if (group.pending.size === 0) {
|
|
436
|
+
this.#failure({ trackName: track.context.trackName, groupId, objectId: 0 }, reason)
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
for (const pending of [...group.pending.values()]) {
|
|
440
|
+
this.#removePending(track, group, pending)
|
|
441
|
+
this.#rejectOrShadow(pending.identity, pending.release, reason)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
#removePending(track: TrackState, group: GroupState, pending: PendingObject): void {
|
|
446
|
+
if (group.pending.get(pending.identity.objectId) === pending) {
|
|
447
|
+
group.pending.delete(pending.identity.objectId)
|
|
448
|
+
}
|
|
449
|
+
const index = track.quarantine.indexOf(pending)
|
|
450
|
+
if (index >= 0) track.quarantine.splice(index, 1)
|
|
451
|
+
track.quarantinedBytes -= pending.bytes
|
|
452
|
+
this.#stats.quarantinedBytes -= pending.bytes
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
#quarantine(track: TrackState, group: GroupState, pending: PendingObject): boolean {
|
|
456
|
+
if (group.pending.has(pending.identity.objectId)) return false
|
|
457
|
+
group.pending.set(pending.identity.objectId, pending)
|
|
458
|
+
track.quarantine.push(pending)
|
|
459
|
+
track.quarantinedBytes += pending.bytes
|
|
460
|
+
this.#stats.quarantinedBytes += pending.bytes
|
|
461
|
+
this.#enforceCap(track)
|
|
462
|
+
return true
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
#enforceCap(track: TrackState): void {
|
|
466
|
+
while (track.quarantinedBytes > track.capBytes && track.quarantine.length > 0) {
|
|
467
|
+
const pending = track.quarantine.shift()!
|
|
468
|
+
const group = track.groups.get(BigInt(pending.identity.groupId))
|
|
469
|
+
if (group) group.pending.delete(pending.identity.objectId)
|
|
470
|
+
track.quarantinedBytes -= pending.bytes
|
|
471
|
+
this.#stats.quarantinedBytes -= pending.bytes
|
|
472
|
+
pending.release()
|
|
473
|
+
this.#failure(pending.identity, 'verifierError')
|
|
474
|
+
console.warn(`[ManifestVerifier] quarantine cap eviction fail-open track=${pending.identity.trackName} group=${pending.identity.groupId} object=${pending.identity.objectId}`)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
#failOpenTrack(track: TrackState, reason: 'verifierError'): void {
|
|
479
|
+
for (const pending of [...track.quarantine]) {
|
|
480
|
+
const group = track.groups.get(BigInt(pending.identity.groupId))
|
|
481
|
+
if (group) this.#removePending(track, group, pending)
|
|
482
|
+
pending.release()
|
|
483
|
+
this.#failure(pending.identity, reason)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#failOpenGroup(track: TrackState, groupId: bigint, group: GroupState): void {
|
|
488
|
+
const pending = [...group.pending.values()]
|
|
489
|
+
if (pending.length === 0) {
|
|
490
|
+
this.#failure({ trackName: track.context.trackName, groupId, objectId: 0 }, 'verifierError')
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
for (const object of pending) {
|
|
494
|
+
this.#removePending(track, group, object)
|
|
495
|
+
object.release()
|
|
496
|
+
this.#failure(object.identity, 'verifierError')
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#releaseAllQuarantine(countFailure: boolean): void {
|
|
501
|
+
for (const track of this.#tracks.values()) {
|
|
502
|
+
for (const pending of [...track.quarantine]) {
|
|
503
|
+
const group = track.groups.get(BigInt(pending.identity.groupId))
|
|
504
|
+
if (group) this.#removePending(track, group, pending)
|
|
505
|
+
pending.release()
|
|
506
|
+
if (countFailure) this.#failure(pending.identity, 'verifierError')
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#failure(identity: ManifestObjectIdentity, reason: ManifestFailureReason): void {
|
|
512
|
+
this.#stats.failures[reason]++
|
|
513
|
+
this.#onFailure?.({
|
|
514
|
+
trackName: identity.trackName,
|
|
515
|
+
groupId: identity.groupId,
|
|
516
|
+
objectId: identity.objectId,
|
|
517
|
+
reason,
|
|
518
|
+
})
|
|
519
|
+
}
|
|
520
|
+
}
|