@telecrypt-io/storage 0.5.28 → 0.5.29

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.
@@ -5,7 +5,8 @@ import { encryptAttachment, decryptAttachment } from "matrix-encrypt-attachment"
5
5
  import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
6
6
  import { FileTooLargeError, RecoveryAlreadyConfiguredError, RecoveryRestoreError, RecoverySetupAmbiguousError, RecoverySetupError, RecoveryRestoreAmbiguousError, MutationOutcomeUnknownError, RoomCreationAmbiguousError, RoomCleanupIncompleteError, StorageError, UndecryptableFileError, } from "./core/errors.js";
7
7
  import { MAX_MEDIA_FILE_BYTES, MAX_MATRIX_IDENTIFIER_LENGTH, validateCanonicalMatrixUserId, validateMatrixDeviceId, validateMatrixEventId, validateMatrixRoomId, } from "./core/constants.js";
8
- import { raceWithAbort, readBoundedResponseBody } from "./core/http.js";
8
+ import { cancelResponseBody, raceWithAbort, readMediaResponseBody, readResponseBody, ResponseBodyReadError, } from "./core/http.js";
9
+ import { sanitizeDiagnosticText } from "./core/oidc.js";
9
10
  import { validateName } from "./core/validation.js";
10
11
  import { isTreeDeleted } from "./deletion-markers.js";
11
12
  const recoveryMutationQueues = new WeakMap();
@@ -18,11 +19,6 @@ const RECOVERY_CRYPTO_TIMEOUT_MS = 60000;
18
19
  const MAX_MEDIA_REDIRECTS = 5;
19
20
  const MAX_MATRIX_TOKEN_LENGTH = 8192;
20
21
  const MAX_MIMETYPE_LENGTH = 255;
21
- const MAX_MATRIX_STATE_RESPONSE_BYTES = 4 * 1024 * 1024;
22
- const MAX_MATRIX_RESPONSE_BYTES = 16 * 1024 * 1024;
23
- const MAX_MATRIX_STATE_EVENTS = 10000;
24
- const MAX_MATRIX_MEMBERS = 10000;
25
- const MAX_MATRIX_JOINED_ROOMS = 4096;
26
22
  function cloneCryptoCallbacks(callbacks) {
27
23
  // MatrixClient retains this object for the lifetime of the client. Keep the
28
24
  // caller's object immutable: recovery temporarily installs key callbacks on
@@ -64,26 +60,21 @@ export function withMatrixMutationAbort(_client, operation, signal, operationNam
64
60
  pending = operation();
65
61
  }
66
62
  catch (error) {
67
- finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName) : error));
63
+ finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName, { cause: error }) : error));
68
64
  return;
69
65
  }
70
- pending.then((value) => finish(() => (cancelled ? reject(new MutationOutcomeUnknownError(operationName)) : resolve(value))), (error) => finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName) : error)));
66
+ pending.then((value) => finish(() => (cancelled ? reject(new MutationOutcomeUnknownError(operationName)) : resolve(value))), (error) => finish(() => reject(cancelled ? new MutationOutcomeUnknownError(operationName, { cause: error }) : error)));
71
67
  });
72
68
  }
73
- async function readBoundedMatrixResponse(response, maxBytes, signal, abortError) {
74
- const body = await readBoundedResponseBody(response, maxBytes, signal, {
69
+ async function readMatrixResponse(response, signal, abortError) {
70
+ const body = await readResponseBody(response, signal, {
75
71
  abortError,
76
- deferReaderRelease: true,
77
72
  });
78
- if (body.truncated)
79
- throw new Error("Matrix response body is too large");
80
73
  if (!response.body)
81
74
  return response;
82
75
  // Fetch forbids a response body for 204, 205, and 304 responses. Some
83
76
  // browser implementations nevertheless expose an empty response stream for
84
- // a 204 from a proxy. Reconstructing that response with even an empty
85
- // Uint8Array then throws before matrix-js-sdk can accept the successful
86
- // response. Preserve the bodyless status after boundedly consuming the
77
+ // a 204 from a proxy. Preserve the bodyless status after consuming the
87
78
  // stream so callers such as FetchHttpApi can still read an empty Blob.
88
79
  const bodylessStatus = response.status === 204 || response.status === 205 || response.status === 304;
89
80
  if (bodylessStatus) {
@@ -102,14 +93,6 @@ async function readBoundedMatrixResponse(response, maxBytes, signal, abortError)
102
93
  headers: response.headers,
103
94
  });
104
95
  }
105
- function isMatrixStateOrMembershipUrl(input) {
106
- const url = typeof input === "string"
107
- ? new URL(input, "https://matrix.invalid")
108
- : input instanceof URL
109
- ? input
110
- : new URL(input.url, "https://matrix.invalid");
111
- return /\/rooms\/[^/]+\/(?:state|members)(?:\/|$)/.test(url.pathname);
112
- }
113
96
  export function boundedMatrixFetch(fetchFn) {
114
97
  const wrapped = ((input, init) => {
115
98
  const pending = (async () => {
@@ -126,25 +109,21 @@ export function boundedMatrixFetch(fetchFn) {
126
109
  else
127
110
  externalSignal?.addEventListener("abort", abortExternal, { once: true });
128
111
  try {
129
- const response = await raceWithAbort(fetchFn(input, { ...init, redirect: "manual", signal: controller.signal }), controller.signal, () => undefined, () => (timedOut ? new Error("Matrix request timed out") : new DOMException("The operation was aborted", "AbortError")));
112
+ const response = await raceWithAbort(fetchFn(input, { ...init, redirect: "manual", signal: controller.signal }), controller.signal, () => (timedOut ? new Error("Matrix request timed out") : new DOMException("The operation was aborted", "AbortError")));
130
113
  if (response.redirected ||
131
114
  response.type === "opaqueredirect" ||
132
115
  (response.status >= 300 && response.status < 400)) {
133
- response.body?.cancel().catch(() => undefined);
134
- throw new Error("Matrix redirect rejected");
116
+ const failure = new Error("Matrix redirect rejected");
117
+ await cancelResponseBody(response, "Matrix redirect", failure);
118
+ throw failure;
135
119
  }
136
- return await readBoundedMatrixResponse(response, isMatrixStateOrMembershipUrl(input)
137
- ? MAX_MATRIX_STATE_RESPONSE_BYTES
138
- : MAX_MATRIX_RESPONSE_BYTES, controller.signal, () => (timedOut ? new Error("Matrix request timed out") : new DOMException("The operation was aborted", "AbortError")));
120
+ return await readMatrixResponse(response, controller.signal, () => (timedOut ? new Error("Matrix request timed out") : new DOMException("The operation was aborted", "AbortError")));
139
121
  }
140
122
  finally {
141
123
  clearTimeout(timeout);
142
124
  externalSignal?.removeEventListener("abort", abortExternal);
143
125
  }
144
126
  })();
145
- // Preserve fetch-style rejection for callers while observing an immediate
146
- // abort until the caller has had a chance to attach its own handler.
147
- void pending.catch(() => undefined);
148
127
  return pending;
149
128
  });
150
129
  return wrapped;
@@ -181,8 +160,8 @@ function validateHomeserverUrl(value) {
181
160
  try {
182
161
  url = new URL(value);
183
162
  }
184
- catch {
185
- throw new Error("invalid Matrix homeserver URL");
163
+ catch (error) {
164
+ throw new Error("invalid Matrix homeserver URL", { cause: error });
186
165
  }
187
166
  if ((url.protocol !== "http:" && url.protocol !== "https:") ||
188
167
  (url.protocol === "http:" && !isLoopbackHostname(url.hostname)) ||
@@ -224,9 +203,50 @@ function validateMimetype(value) {
224
203
  throw new Error("invalid MIME type");
225
204
  }
226
205
  }
206
+ /** Reads the event-owned metadata shared by details and download operations. */
207
+ export function readFileEventMetadata(content) {
208
+ if (!isRecord(content))
209
+ throw new StorageError("media metadata is invalid");
210
+ const rawInfo = content["info"];
211
+ if (rawInfo !== undefined && !isRecord(rawInfo)) {
212
+ throw new StorageError("media metadata is invalid");
213
+ }
214
+ const info = isRecord(rawInfo) ? rawInfo : undefined;
215
+ const rawSize = info?.["size"];
216
+ if (rawSize !== undefined) {
217
+ if (typeof rawSize !== "number" || !Number.isSafeInteger(rawSize) || rawSize < 0) {
218
+ throw new StorageError("media metadata is invalid");
219
+ }
220
+ if (rawSize > MAX_MEDIA_FILE_BYTES)
221
+ throw new FileTooLargeError();
222
+ }
223
+ const rawMimetype = info?.["mimetype"];
224
+ if (rawMimetype !== undefined && typeof rawMimetype !== "string") {
225
+ throw new StorageError("media metadata is invalid");
226
+ }
227
+ const mimetype = rawMimetype ?? null;
228
+ if (mimetype !== null) {
229
+ try {
230
+ validateMimetype(mimetype);
231
+ }
232
+ catch (error) {
233
+ throw new StorageError("media metadata is invalid", { cause: error });
234
+ }
235
+ }
236
+ return {
237
+ mimetype,
238
+ size: typeof rawSize === "number" ? rawSize : null,
239
+ };
240
+ }
227
241
  function isRecord(value) {
228
242
  return typeof value === "object" && value !== null && !Array.isArray(value);
229
243
  }
244
+ async function isUndecryptableFilePlaceholder(branch) {
245
+ // matrix-js-sdk replaces failed encrypted event content with m.bad.encrypted;
246
+ // its public failure flag is the authoritative state and avoids guessing
247
+ // from that display content (or misclassifying malformed plaintext events).
248
+ return (await branch.getFileEvent()).isDecryptionFailure();
249
+ }
230
250
  function validateSecretStorageStatus(value) {
231
251
  if (!isRecord(value))
232
252
  throw new RecoverySetupAmbiguousError();
@@ -295,7 +315,7 @@ async function withRecoveryCryptoDeadline(operation, signal, operationName) {
295
315
  signal?.addEventListener("abort", abortExternal, { once: true });
296
316
  const pending = Promise.resolve().then(operation);
297
317
  try {
298
- return await raceWithAbort(pending, controller.signal, () => undefined, () => timedOut
318
+ return await raceWithAbort(pending, controller.signal, () => timedOut
299
319
  ? new RecoveryCryptoTimeoutError(operationName)
300
320
  : new StorageError("operation cancelled"));
301
321
  }
@@ -308,9 +328,6 @@ function parseMatrixMembersResponse(value) {
308
328
  if (!isRecord(value) || !Array.isArray(value.chunk)) {
309
329
  throw new Error("invalid Matrix members response");
310
330
  }
311
- if (value.chunk.length > MAX_MATRIX_MEMBERS) {
312
- throw new Error("Matrix members response is too large");
313
- }
314
331
  return value.chunk.map((entry) => {
315
332
  if (!isRecord(entry) ||
316
333
  typeof entry.state_key !== "string" ||
@@ -329,9 +346,6 @@ function parseMatrixMembersResponse(value) {
329
346
  function parseMatrixStateResponse(value) {
330
347
  if (!Array.isArray(value))
331
348
  throw new Error("invalid Matrix room state response");
332
- if (value.length > MAX_MATRIX_STATE_EVENTS) {
333
- throw new Error("Matrix room state response is too large");
334
- }
335
349
  return value.map((event) => {
336
350
  if (!isRecord(event) ||
337
351
  typeof event.type !== "string" ||
@@ -362,9 +376,6 @@ function parseMatrixPowerLevels(value) {
362
376
  continue;
363
377
  if (!isRecord(field))
364
378
  throw new Error("invalid Matrix power-level response");
365
- if (Object.keys(field).length > MAX_MATRIX_MEMBERS) {
366
- throw new Error("Matrix power-level response is too large");
367
- }
368
379
  for (const key of Object.keys(field))
369
380
  validateMatrixIdentifier(key, `${name} key`);
370
381
  for (const level of Object.values(field)) {
@@ -379,8 +390,11 @@ function isGoneRoomError(error) {
379
390
  return (error instanceof MatrixError &&
380
391
  (error.errcode === "M_NOT_FOUND" || error.errcode === "M_UNKNOWN"));
381
392
  }
382
- function throwWithCleanupDetail(error, roomId) {
383
- const cleanup = new RoomCleanupIncompleteError(roomId);
393
+ function throwWithCleanupDetail(error, roomId, cleanupError) {
394
+ const cleanupCause = cleanupError === undefined
395
+ ? error
396
+ : new AggregateError([error, cleanupError], "operation and room cleanup failed", { cause: error });
397
+ const cleanup = new RoomCleanupIncompleteError(roomId, cleanupCause);
384
398
  if (error instanceof Error) {
385
399
  try {
386
400
  Object.defineProperty(error, "cleanupIncomplete", {
@@ -393,9 +407,10 @@ function throwWithCleanupDetail(error, roomId) {
393
407
  });
394
408
  }
395
409
  catch {
396
- // Preserve the original mutation error even if an unusual Error object
397
- // is non-extensible; the cleanup detail remains in the cause chain only
398
- // when it could be attached safely.
410
+ // A non-extensible provider error cannot carry the cleanup marker. Throw
411
+ // the existing typed cleanup error and retain the provider error as cause
412
+ // so callers cannot mistake the mutation for a safe retry.
413
+ throw cleanup;
399
414
  }
400
415
  throw error;
401
416
  }
@@ -465,9 +480,6 @@ export class TeleCryptIOStorage {
465
480
  for (const [stateKey, event] of byStateKey) {
466
481
  if (present.has(`${eventType}\u0000${stateKey}`))
467
482
  continue;
468
- if (refreshed.length >= MAX_MATRIX_STATE_EVENTS) {
469
- throw new Error("Matrix room state response is too large");
470
- }
471
483
  const original = event.getEffectiveEvent();
472
484
  refreshed.push(new MatrixEvent({
473
485
  ...original,
@@ -567,8 +579,10 @@ export class TeleCryptIOStorage {
567
579
  try {
568
580
  client.stopClient();
569
581
  }
570
- catch {
571
- // Preserve the original bootstrap failure.
582
+ catch (cleanupError) {
583
+ throw new AggregateError([error, cleanupError], "storage bootstrap and client cleanup failed", {
584
+ cause: error,
585
+ });
572
586
  }
573
587
  throw error;
574
588
  }
@@ -769,7 +783,7 @@ export class TeleCryptIOStorage {
769
783
  if (signal?.aborted && error instanceof StorageError) {
770
784
  throw error;
771
785
  }
772
- throw new RecoverySetupAmbiguousError();
786
+ throw new RecoverySetupAmbiguousError(error);
773
787
  }
774
788
  if (status.defaultKeyId || status.ready || backupVersion !== null) {
775
789
  throw new RecoveryAlreadyConfiguredError();
@@ -789,11 +803,11 @@ export class TeleCryptIOStorage {
789
803
  authUploadDeviceSigningKeys: async () => undefined,
790
804
  }), signal, "cross-signing bootstrap");
791
805
  }
792
- catch {
806
+ catch (error) {
793
807
  // Cross-signing setup can commit server-side state before a transport
794
808
  // failure is observed. Do not turn that uncertainty into a retry that
795
809
  // could replace recovery state.
796
- throw new RecoverySetupAmbiguousError();
810
+ throw new RecoverySetupAmbiguousError(error);
797
811
  }
798
812
  // CryptoApi mutations do not accept an AbortSignal. Once one has settled,
799
813
  // do not cross the next one-way mutation boundary: the caller may already
@@ -804,8 +818,8 @@ export class TeleCryptIOStorage {
804
818
  try {
805
819
  generated = await withRecoveryCryptoDeadline(() => crypto.createRecoveryKeyFromPassphrase(), signal, "recovery-key generation");
806
820
  }
807
- catch {
808
- throw new RecoverySetupAmbiguousError();
821
+ catch (error) {
822
+ throw new RecoverySetupAmbiguousError(error);
809
823
  }
810
824
  if (!generated.encodedPrivateKey) {
811
825
  throw new RecoverySetupAmbiguousError();
@@ -858,15 +872,22 @@ export class TeleCryptIOStorage {
858
872
  throw new RecoverySetupAmbiguousError();
859
873
  }
860
874
  }
861
- catch (error) {
862
- if (error instanceof RecoverySetupAmbiguousError || error instanceof RecoverySetupError)
863
- throw error;
875
+ catch (statusError) {
876
+ if (statusError instanceof RecoverySetupAmbiguousError) {
877
+ throw new RecoverySetupAmbiguousError(new AggregateError([error, statusError], "recovery setup and status verification were both inconclusive", {
878
+ cause: error,
879
+ }));
880
+ }
881
+ if (statusError instanceof RecoverySetupError)
882
+ throw statusError;
864
883
  // A failed status probe cannot prove that the one-way operation did
865
884
  // not commit. Never turn that uncertainty into an apparently safe
866
885
  // retry that could replace account recovery state.
867
- throw new RecoverySetupAmbiguousError();
886
+ throw new RecoverySetupAmbiguousError(new AggregateError([error, statusError], "recovery setup and status verification failed", {
887
+ cause: error,
888
+ }));
868
889
  }
869
- throw new RecoverySetupError();
890
+ throw new RecoverySetupError(error);
870
891
  }
871
892
  try {
872
893
  const [rawAfterStatus, rawAfterBackup] = await Promise.all([
@@ -881,7 +902,7 @@ export class TeleCryptIOStorage {
881
902
  catch (error) {
882
903
  if (error instanceof RecoverySetupAmbiguousError)
883
904
  throw error;
884
- throw new RecoverySetupAmbiguousError();
905
+ throw new RecoverySetupAmbiguousError(error);
885
906
  }
886
907
  // This is the last boundary before exposing the only recovery credential
887
908
  // to the caller. Cancellation here must not return a key that the caller
@@ -911,8 +932,8 @@ export class TeleCryptIOStorage {
911
932
  const backupVersion = validateBackupVersion(rawBackupVersion);
912
933
  return storageStatus.ready && backupVersion !== null;
913
934
  }
914
- catch {
915
- throw new RecoverySetupAmbiguousError();
935
+ catch (error) {
936
+ throw new RecoverySetupAmbiguousError(error);
916
937
  }
917
938
  }
918
939
  /** Returns one consistent recovery topology snapshot without exposing raw crypto callbacks. */
@@ -952,8 +973,8 @@ export class TeleCryptIOStorage {
952
973
  backupVersion,
953
974
  };
954
975
  }
955
- catch {
956
- throw new RecoverySetupAmbiguousError();
976
+ catch (error) {
977
+ throw new RecoverySetupAmbiguousError(error);
957
978
  }
958
979
  }, signal);
959
980
  }
@@ -983,8 +1004,8 @@ export class TeleCryptIOStorage {
983
1004
  try {
984
1005
  privateKey = decodeRecoveryKey(recoveryKey);
985
1006
  }
986
- catch {
987
- throw new RecoveryRestoreError();
1007
+ catch (error) {
1008
+ throw new RecoveryRestoreError(error);
988
1009
  }
989
1010
  if (signal?.aborted)
990
1011
  throw new StorageError("operation cancelled");
@@ -997,7 +1018,7 @@ export class TeleCryptIOStorage {
997
1018
  catch (error) {
998
1019
  if (error instanceof RecoveryRestoreAmbiguousError)
999
1020
  throw error;
1000
- throw new RecoveryRestoreError();
1021
+ throw new RecoveryRestoreError(error);
1001
1022
  }
1002
1023
  try {
1003
1024
  const result = await withRecoveryCryptoDeadline(() => crypto.restoreKeyBackup(), signal, "key-backup restore");
@@ -1011,7 +1032,9 @@ export class TeleCryptIOStorage {
1011
1032
  catch (error) {
1012
1033
  if (error instanceof RecoveryRestoreAmbiguousError)
1013
1034
  throw error;
1014
- throw new RecoveryRestoreError();
1035
+ if (error instanceof RecoveryRestoreError)
1036
+ throw error;
1037
+ throw new RecoveryRestoreError(error);
1015
1038
  }
1016
1039
  }, signal);
1017
1040
  }
@@ -1075,6 +1098,7 @@ export class TeleCryptIOStorage {
1075
1098
  }
1076
1099
  async waitForTreeSpace(roomId, operation, signal) {
1077
1100
  const deadline = Date.now() + TREE_SYNC_TIMEOUT_MS;
1101
+ let lastError;
1078
1102
  for (;;) {
1079
1103
  if (signal?.aborted)
1080
1104
  throw new StorageError("operation cancelled");
@@ -1083,12 +1107,15 @@ export class TeleCryptIOStorage {
1083
1107
  if (tree)
1084
1108
  return this.decorateTreeSpace(tree);
1085
1109
  }
1086
- catch {
1110
+ catch (error) {
1111
+ lastError = error;
1087
1112
  // The room can be present before its complete state has been applied
1088
1113
  // to this client's local store. Keep polling the exact returned ID.
1089
1114
  }
1090
1115
  if (Date.now() >= deadline) {
1091
- throw new Error(`${operation}: created room did not become a file tree space in local sync state`);
1116
+ throw new Error(`${operation}: created room did not become a file tree space in local sync state`, {
1117
+ cause: lastError,
1118
+ });
1092
1119
  }
1093
1120
  await new Promise((resolve, reject) => {
1094
1121
  let timer;
@@ -1129,12 +1156,14 @@ export class TeleCryptIOStorage {
1129
1156
  const cleanupSignal = new AbortController().signal;
1130
1157
  let incomplete = false;
1131
1158
  let safeToForget = true;
1159
+ const failures = [];
1132
1160
  try {
1133
1161
  await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.leave(roomId), cleanupSignal), CLEANUP_TIMEOUT_MS, "room leave cleanup", cleanupSignal);
1134
1162
  }
1135
1163
  catch (error) {
1136
1164
  if (!isGoneRoomError(error)) {
1137
1165
  incomplete = true;
1166
+ failures.push(error);
1138
1167
  // A timed-out or failed leave may still be in flight. Do not race a
1139
1168
  // forget request against it; the caller must retry cleanup later.
1140
1169
  safeToForget = false;
@@ -1145,12 +1174,17 @@ export class TeleCryptIOStorage {
1145
1174
  await TeleCryptIOStorage.withTimeout(withMatrixMutationAbort(this.client, () => this.client.forget(roomId), cleanupSignal), CLEANUP_TIMEOUT_MS, "room forget cleanup", cleanupSignal);
1146
1175
  }
1147
1176
  catch (error) {
1148
- if (!isGoneRoomError(error))
1177
+ if (!isGoneRoomError(error)) {
1149
1178
  incomplete = true;
1179
+ failures.push(error);
1180
+ }
1150
1181
  }
1151
1182
  }
1152
1183
  if (incomplete) {
1153
- throw new RoomCleanupIncompleteError(roomId);
1184
+ const cause = failures.length === 1
1185
+ ? failures[0]
1186
+ : new AggregateError(failures, "room cleanup failed");
1187
+ throw new RoomCleanupIncompleteError(roomId, cause);
1154
1188
  }
1155
1189
  }
1156
1190
  async removeRelationLink(roomId, eventType, stateKey, signal) {
@@ -1188,14 +1222,14 @@ export class TeleCryptIOStorage {
1188
1222
  // retryable generic error that could orphan a second room.
1189
1223
  if (error instanceof RoomCreationAmbiguousError)
1190
1224
  throw error;
1191
- throw new RoomCreationAmbiguousError(operation);
1225
+ throw new RoomCreationAmbiguousError(operation, error);
1192
1226
  });
1193
1227
  let roomId;
1194
1228
  try {
1195
1229
  roomId = validateMatrixRoomId(response?.room_id, "room creation response");
1196
1230
  }
1197
- catch {
1198
- throw new RoomCreationAmbiguousError(operation);
1231
+ catch (error) {
1232
+ throw new RoomCreationAmbiguousError(operation, error);
1199
1233
  }
1200
1234
  try {
1201
1235
  return await this.waitForTreeSpace(roomId, operation, signal);
@@ -1204,13 +1238,13 @@ export class TeleCryptIOStorage {
1204
1238
  try {
1205
1239
  await this.cleanupCreatedRoom(roomId, signal);
1206
1240
  }
1207
- catch {
1208
- throwWithCleanupDetail(error, roomId);
1241
+ catch (cleanupError) {
1242
+ throwWithCleanupDetail(error, roomId, cleanupError);
1209
1243
  }
1210
1244
  // The room existed even if cleanup happened to succeed. Keep the
1211
1245
  // caller on the reconciliation path: a retry based on a local timeout
1212
1246
  // or malformed response must never assume that no room was created.
1213
- throw new RoomCreationAmbiguousError(operation);
1247
+ throw new RoomCreationAmbiguousError(operation, error);
1214
1248
  }
1215
1249
  }
1216
1250
  /**
@@ -1224,8 +1258,6 @@ export class TeleCryptIOStorage {
1224
1258
  if (signal?.aborted)
1225
1259
  throw new StorageError("operation cancelled");
1226
1260
  const rooms = this.client.getRooms();
1227
- if (rooms.length > MAX_MATRIX_JOINED_ROOMS)
1228
- throw new StorageError("room list is too large");
1229
1261
  const trees = [];
1230
1262
  for (const room of rooms) {
1231
1263
  if (signal?.aborted)
@@ -1280,6 +1312,7 @@ export class TeleCryptIOStorage {
1280
1312
  // signal, then report incomplete cleanup if they cannot be verified.
1281
1313
  const compensationSignal = new AbortController().signal;
1282
1314
  let rollbackIncomplete = false;
1315
+ const cleanupFailures = [];
1283
1316
  // A sendStateEvent rejection is ambiguous: the homeserver may have
1284
1317
  // committed the event before the client observed a transport error.
1285
1318
  // Refresh the parent before probing even when no event ID was
@@ -1303,6 +1336,7 @@ export class TeleCryptIOStorage {
1303
1336
  Object.keys(content).length > 0);
1304
1337
  })) {
1305
1338
  rollbackIncomplete = true;
1339
+ cleanupFailures.push(new Error("parent link rollback could not be verified"));
1306
1340
  }
1307
1341
  }
1308
1342
  else {
@@ -1310,12 +1344,15 @@ export class TeleCryptIOStorage {
1310
1344
  const removed = await this.removeChildLink(currentParent.id, tree.id, compensationSignal);
1311
1345
  // Without an event ID, a missing link after one refresh does not
1312
1346
  // prove that a request which timed out will never commit.
1313
- if (!removed)
1347
+ if (!removed) {
1314
1348
  rollbackIncomplete = true;
1349
+ cleanupFailures.push(new Error("parent link rollback could not be verified"));
1350
+ }
1315
1351
  }
1316
1352
  }
1317
- catch {
1353
+ catch (cleanupError) {
1318
1354
  rollbackIncomplete = true;
1355
+ cleanupFailures.push(cleanupError);
1319
1356
  }
1320
1357
  }
1321
1358
  if (childLinkAttempted) {
@@ -1335,27 +1372,35 @@ export class TeleCryptIOStorage {
1335
1372
  Object.keys(content).length > 0);
1336
1373
  })) {
1337
1374
  rollbackIncomplete = true;
1375
+ cleanupFailures.push(new Error("child link rollback could not be verified"));
1338
1376
  }
1339
1377
  }
1340
1378
  else {
1341
1379
  await this.refreshRoomState(tree.id, { signal: compensationSignal });
1342
1380
  const removed = await this.removeParentLink(tree.id, currentParent.id, compensationSignal);
1343
- if (!removed)
1381
+ if (!removed) {
1344
1382
  rollbackIncomplete = true;
1383
+ cleanupFailures.push(new Error("child link rollback could not be verified"));
1384
+ }
1345
1385
  }
1346
1386
  }
1347
- catch {
1387
+ catch (cleanupError) {
1348
1388
  rollbackIncomplete = true;
1389
+ cleanupFailures.push(cleanupError);
1349
1390
  }
1350
1391
  }
1351
1392
  try {
1352
1393
  await this.cleanupCreatedRoom(tree.id, effectiveSignal);
1353
1394
  }
1354
- catch {
1355
- throwWithCleanupDetail(error, tree.id);
1395
+ catch (cleanupError) {
1396
+ cleanupFailures.push(cleanupError);
1397
+ }
1398
+ if (rollbackIncomplete || cleanupFailures.length > 0) {
1399
+ const cleanupCause = cleanupFailures.length === 1
1400
+ ? cleanupFailures[0]
1401
+ : new AggregateError(cleanupFailures, "tree creation cleanup failed");
1402
+ throwWithCleanupDetail(error, tree.id, cleanupCause);
1356
1403
  }
1357
- if (rollbackIncomplete)
1358
- throwWithCleanupDetail(error, tree.id);
1359
1404
  throw error;
1360
1405
  }
1361
1406
  }, signal);
@@ -1453,17 +1498,17 @@ export class TeleCryptIOStorage {
1453
1498
  // mutation outcome is safe to propagate unchanged.
1454
1499
  if (error instanceof MutationOutcomeUnknownError)
1455
1500
  throw error;
1456
- throw new MutationOutcomeUnknownError("file upload");
1501
+ throw new MutationOutcomeUnknownError("file upload", { cause: error });
1457
1502
  }
1458
1503
  if (signal?.aborted)
1459
1504
  throw new StorageError("operation cancelled");
1460
1505
  try {
1461
1506
  return validateMatrixEventId(response?.event_id, "file upload response event ID");
1462
1507
  }
1463
- catch {
1508
+ catch (error) {
1464
1509
  // An invalid response can still follow a committed event. Treat it as
1465
1510
  // unknown rather than presenting a retryable generic upload failure.
1466
- throw new MutationOutcomeUnknownError("file upload");
1511
+ throw new MutationOutcomeUnknownError("file upload", { cause: error });
1467
1512
  }
1468
1513
  }
1469
1514
  async downloadFile(branch, signal) {
@@ -1473,26 +1518,38 @@ export class TeleCryptIOStorage {
1473
1518
  try {
1474
1519
  ({ info } = await branch.getFileInfo());
1475
1520
  }
1476
- catch {
1477
- // matrix-js-sdk's MSC3089Branch.getFileInfo() reads `file["url"]` off
1478
- // the raw event content; when the event is undecryptable on this
1479
- // device it hands back a placeholder with no `file` block, so that
1480
- // read throws an opaque "Cannot read properties of undefined" instead
1481
- // of a useful error. Surface the real cause.
1482
- throw new UndecryptableFileError();
1483
- }
1484
- // Also reject an incomplete placeholder if matrix-js-sdk returns one.
1521
+ catch (error) {
1522
+ if (signal?.aborted)
1523
+ throw new StorageError("operation cancelled");
1524
+ if (error instanceof StorageError || error instanceof MatrixError)
1525
+ throw error;
1526
+ // matrix-js-sdk's file-info accessor reads an attachment absent from
1527
+ // failed-decryption events. Use its explicit decryption status, not a
1528
+ // guessed content shape; preserve other SDK/transport failures.
1529
+ let isPlaceholder = false;
1530
+ try {
1531
+ isPlaceholder = await isUndecryptableFilePlaceholder(branch);
1532
+ }
1533
+ catch (placeholderError) {
1534
+ throw new AggregateError([error, placeholderError], "file metadata lookup and placeholder inspection both failed", { cause: error });
1535
+ }
1536
+ if (isPlaceholder) {
1537
+ throw new UndecryptableFileError();
1538
+ }
1539
+ throw error;
1540
+ }
1541
+ // Missing metadata alone does not establish a decryption failure.
1485
1542
  if (!info || typeof info.url !== "string") {
1486
- throw new UndecryptableFileError();
1487
- }
1488
- const declaredSize = info.size;
1489
- if (declaredSize !== undefined &&
1490
- (typeof declaredSize !== "number" ||
1491
- !Number.isSafeInteger(declaredSize) ||
1492
- declaredSize < 0 ||
1493
- declaredSize > MAX_MEDIA_FILE_BYTES)) {
1494
- throw new FileTooLargeError();
1543
+ throw new StorageError("file metadata is missing a media URL");
1495
1544
  }
1545
+ // `info` is the encrypted attachment descriptor. Its shape is owned by
1546
+ // matrix-encrypt-attachment and does not carry the plaintext size. The
1547
+ // event's `info` block is the sole source of user-facing file metadata.
1548
+ const eventMetadata = readFileEventMetadata((await branch.getFileEvent()).getContent());
1549
+ if (signal?.aborted)
1550
+ throw new StorageError("operation cancelled");
1551
+ const declaredSize = eventMetadata.size;
1552
+ const mimetype = eventMetadata.mimetype ?? "application/octet-stream";
1496
1553
  const mxcUrl = info.url;
1497
1554
  const clientAny = this.client;
1498
1555
  const accessToken = clientAny.getAccessToken();
@@ -1501,8 +1558,8 @@ export class TeleCryptIOStorage {
1501
1558
  try {
1502
1559
  trustedOrigin = validateHomeserverUrl(clientAny.getHomeserverUrl()).origin;
1503
1560
  }
1504
- catch {
1505
- throw new Error("failed to build media URL");
1561
+ catch (error) {
1562
+ throw new Error("failed to build media URL", { cause: error });
1506
1563
  }
1507
1564
  const downloadUrl = clientAny.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, false, true, true);
1508
1565
  if (!downloadUrl)
@@ -1511,8 +1568,8 @@ export class TeleCryptIOStorage {
1511
1568
  try {
1512
1569
  currentUrl = new URL(downloadUrl);
1513
1570
  }
1514
- catch {
1515
- throw new Error("failed to build media URL");
1571
+ catch (error) {
1572
+ throw new Error("failed to build media URL", { cause: error });
1516
1573
  }
1517
1574
  if ((currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") ||
1518
1575
  currentUrl.username !== "" ||
@@ -1535,67 +1592,92 @@ export class TeleCryptIOStorage {
1535
1592
  redirect: "manual",
1536
1593
  signal: controller.signal,
1537
1594
  headers: { Authorization: `Bearer ${accessToken}` },
1538
- }), controller.signal, () => undefined, () => signal?.aborted
1595
+ }), controller.signal, () => signal?.aborted
1539
1596
  ? new StorageError("operation cancelled")
1540
1597
  : new Error("media download timed out"));
1541
1598
  if ([301, 302, 303, 307, 308].includes(res.status)) {
1542
- try {
1543
- void res.body?.cancel().catch(() => undefined);
1544
- }
1545
- catch {
1546
- // The redirect response is already being rejected or discarded.
1547
- }
1548
- if (redirect >= MAX_MEDIA_REDIRECTS)
1549
- throw new Error("media download redirect limit exceeded");
1550
- const location = res.headers.get("location");
1551
- if (!location)
1552
- throw new Error("media download redirect missing location");
1553
- try {
1554
- currentUrl = new URL(location, currentUrl);
1599
+ let redirectError;
1600
+ let nextUrl;
1601
+ if (redirect >= MAX_MEDIA_REDIRECTS) {
1602
+ redirectError = new Error("media download redirect limit exceeded");
1555
1603
  }
1556
- catch {
1557
- throw new Error("media download redirect is invalid");
1558
- }
1559
- if ((currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") ||
1560
- currentUrl.username !== "" ||
1561
- currentUrl.password !== "") {
1562
- throw new Error("media download redirect is invalid");
1563
- }
1564
- if (currentUrl.origin !== trustedOrigin) {
1565
- throw new Error("media download redirect crossed origin");
1604
+ else {
1605
+ const location = res.headers.get("location");
1606
+ if (!location) {
1607
+ redirectError = new Error("media download redirect missing location");
1608
+ }
1609
+ else {
1610
+ try {
1611
+ nextUrl = new URL(location, currentUrl);
1612
+ }
1613
+ catch (error) {
1614
+ redirectError = new Error("media download redirect is invalid", { cause: error });
1615
+ }
1616
+ if (nextUrl &&
1617
+ ((nextUrl.protocol !== "http:" && nextUrl.protocol !== "https:") ||
1618
+ nextUrl.username !== "" ||
1619
+ nextUrl.password !== "")) {
1620
+ redirectError = new Error("media download redirect is invalid");
1621
+ }
1622
+ if (nextUrl && nextUrl.origin !== trustedOrigin) {
1623
+ redirectError = new Error("media download redirect crossed origin");
1624
+ }
1625
+ }
1566
1626
  }
1627
+ await cancelResponseBody(res, "media redirect", redirectError);
1628
+ if (redirectError)
1629
+ throw redirectError;
1630
+ currentUrl = nextUrl;
1567
1631
  continue;
1568
1632
  }
1569
1633
  if (!res.ok) {
1634
+ const statusFailure = new Error(`media download failed: ${res.status}`);
1635
+ let detail = "";
1570
1636
  try {
1571
- void res.body?.cancel().catch(() => undefined);
1637
+ // Error responses are diagnostics, not product media. Consume the
1638
+ // complete body so no provider detail is discarded, then apply
1639
+ // the SDK's shared secret/control-character sanitizer before it
1640
+ // crosses the SDK boundary.
1641
+ const body = await readResponseBody(res, controller.signal);
1642
+ detail = sanitizeDiagnosticText(new TextDecoder().decode(body.bytes));
1572
1643
  }
1573
- catch {
1574
- // The error response is already being rejected.
1644
+ catch (bodyError) {
1645
+ const partial = bodyError instanceof ResponseBodyReadError
1646
+ ? sanitizeDiagnosticText(new TextDecoder().decode(bodyError.bytes))
1647
+ : "";
1648
+ const readFailure = bodyError instanceof ResponseBodyReadError
1649
+ ? new Error(`media error response body could not be read${partial === "" ? "" : `: ${partial}`}`, { cause: bodyError })
1650
+ : bodyError;
1651
+ throw new AggregateError([statusFailure, readFailure], "media download failed while reading the response body", { cause: statusFailure });
1575
1652
  }
1576
- throw new Error(`media download failed: ${res.status}`);
1653
+ throw new Error(`${statusFailure.message}${detail === "" ? "" : `: ${detail}`}`);
1577
1654
  }
1578
1655
  if (!res.body) {
1579
- // A bodyless Response has no bounded reader. Even a plausible
1580
- // Content-Length is only advisory and must not authorize an
1581
- // unbounded arrayBuffer() allocation.
1582
- throw new Error("media download has no bounded response body");
1656
+ throw new Error("media download has no response body");
1583
1657
  }
1584
- const body = await readBoundedResponseBody(res, MAX_MEDIA_FILE_BYTES, controller.signal);
1585
- if (body.truncated)
1586
- throw new FileTooLargeError();
1658
+ const body = await readMediaResponseBody(res, MAX_MEDIA_FILE_BYTES, controller.signal);
1587
1659
  ciphertext = body.bytes.buffer;
1588
1660
  break;
1589
1661
  }
1590
1662
  }
1591
1663
  catch (error) {
1664
+ if (error instanceof AggregateError) {
1665
+ if (signal?.aborted)
1666
+ throw new StorageError("operation cancelled", { cause: error });
1667
+ if (controller.signal.aborted)
1668
+ throw new Error("media download timed out", { cause: error });
1669
+ throw error;
1670
+ }
1671
+ if (error instanceof ResponseBodyReadError) {
1672
+ throw new Error("media download failed", { cause: error.cause ?? new Error(error.message) });
1673
+ }
1592
1674
  if (signal?.aborted)
1593
- throw new StorageError("operation cancelled");
1675
+ throw new StorageError("operation cancelled", { cause: error });
1594
1676
  if (controller.signal.aborted)
1595
- throw new Error("media download timed out");
1677
+ throw new Error("media download timed out", { cause: error });
1596
1678
  if (error instanceof FileTooLargeError)
1597
1679
  throw error;
1598
- throw new Error("media download failed");
1680
+ throw new Error("media download failed", { cause: error });
1599
1681
  }
1600
1682
  finally {
1601
1683
  clearTimeout(timeout);
@@ -1603,41 +1685,20 @@ export class TeleCryptIOStorage {
1603
1685
  }
1604
1686
  let data;
1605
1687
  try {
1606
- // matrix-encrypt-attachment's supported AES-CTR v1/v2 formats are
1607
- // size-preserving: decryption allocates exactly the ciphertext length.
1608
- // The ciphertext reader above therefore bounds the allocation before
1609
- // entering the non-streaming third-party decryptor; the authenticated
1610
- // event size is also rejected before decryption when present.
1611
- if (ciphertext.byteLength > MAX_MEDIA_FILE_BYTES)
1612
- throw new FileTooLargeError();
1613
1688
  data = await decryptAttachment(ciphertext, info);
1614
1689
  }
1615
1690
  catch (error) {
1616
1691
  if (error instanceof FileTooLargeError)
1617
1692
  throw error;
1618
- throw new Error("media decryption failed");
1693
+ throw new Error("media decryption failed", { cause: error });
1619
1694
  }
1695
+ if (data.byteLength > MAX_MEDIA_FILE_BYTES)
1696
+ throw new FileTooLargeError();
1620
1697
  if (signal?.aborted)
1621
1698
  throw new StorageError("operation cancelled");
1622
- const eventContent = (await branch.getFileEvent()).getContent();
1623
- if (signal?.aborted)
1624
- throw new StorageError("operation cancelled");
1625
- if (!isRecord(eventContent))
1626
- throw new Error("media metadata is invalid");
1627
- const infoBlock = isRecord(eventContent["info"])
1628
- ? eventContent["info"]
1629
- : undefined;
1630
- const mimetype = typeof infoBlock?.["mimetype"] === "string"
1631
- ? infoBlock["mimetype"]
1632
- : "application/octet-stream";
1633
- try {
1634
- validateMimetype(mimetype);
1635
- }
1636
- catch {
1637
- throw new Error("media metadata is invalid");
1699
+ if (declaredSize !== null && data.byteLength !== declaredSize) {
1700
+ throw new StorageError("media metadata size does not match decrypted file");
1638
1701
  }
1639
- if (data.byteLength > MAX_MEDIA_FILE_BYTES)
1640
- throw new FileTooLargeError();
1641
1702
  if (signal?.aborted)
1642
1703
  throw new StorageError("operation cancelled");
1643
1704
  return { data, mimetype };