okengine 0.11.0 → 0.11.2

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.
Files changed (68) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/ai.mdx +22 -1
  3. package/site/content/docs/elements/store.mdx +3 -1
  4. package/site/content/docs/elements/vault.mdx +19 -11
  5. package/site/content/docs/get-started/installation.mdx +7 -1
  6. package/site/content/docs/recipes/llama-cpp.mdx +10 -9
  7. package/site/content/docs/reference/cli.md +6 -2
  8. package/site/content/docs/reference/environment-variables.mdx +9 -9
  9. package/src/cli/ai-setup/ai-setup.test.ts +3 -1
  10. package/src/cli/ai-setup/apply.ts +61 -1
  11. package/src/cli/ask-seed.test.ts +4 -3
  12. package/src/cli/ask-seed.ts +5 -6
  13. package/src/cli/client-add.test.ts +2 -1
  14. package/src/cli/dev.test.ts +116 -0
  15. package/src/cli/dev.ts +107 -18
  16. package/src/cli/project-state.test.ts +50 -0
  17. package/src/cli/project-state.ts +123 -0
  18. package/src/cli/vault-cmd.test.ts +47 -18
  19. package/src/cli/vault-cmd.ts +2 -1
  20. package/src/compiler/extract.ts +12 -1
  21. package/src/console/server/console.test.ts +3 -1
  22. package/src/console/server/operator-db.test.ts +48 -17
  23. package/src/console/server/operator-db.ts +5 -1
  24. package/src/docker/derive.ts +24 -3
  25. package/src/docker/docker.test.ts +4 -2
  26. package/src/docker/index.ts +1 -0
  27. package/src/docker/recipes/index.ts +1 -0
  28. package/src/docker/recipes/llama-cpp.ts +20 -4
  29. package/src/drivers/ai-openai-compatible.ts +15 -3
  30. package/src/drivers/vault-builtin.test.ts +50 -42
  31. package/src/elements/ai/declare.ts +73 -3
  32. package/src/elements/ai/errors.test.ts +35 -0
  33. package/src/elements/ai/errors.ts +139 -0
  34. package/src/elements/ai/eval.ts +26 -1
  35. package/src/elements/ai/runtime.ts +140 -80
  36. package/src/elements/ai/tools.test.ts +1 -1
  37. package/src/elements/ai.test.ts +99 -2
  38. package/src/elements/ai.ts +11 -1
  39. package/src/elements/gate/config.ts +13 -3
  40. package/src/elements/gate/declare.ts +1 -1
  41. package/src/elements/gate/strategies.ts +2 -12
  42. package/src/elements/index.ts +2 -0
  43. package/src/elements/store/index-boot.test.ts +23 -6
  44. package/src/elements/store/resource.test.ts +38 -19
  45. package/src/elements/store/sql-session.test.ts +55 -58
  46. package/src/elements/vault/builtin-adapter.test.ts +115 -58
  47. package/src/elements/vault/builtin-adapter.ts +241 -47
  48. package/src/elements/vault/chaos-child.ts +424 -0
  49. package/src/elements/vault/chaos.test.ts +651 -0
  50. package/src/elements/vault/resilience.ts +6 -1
  51. package/src/elements/vault/security-checklist.test.ts +10 -8
  52. package/src/elements/vault/storage.ts +130 -27
  53. package/src/elements/vault/test-helpers.ts +368 -0
  54. package/src/elements/vault.ts +6 -0
  55. package/src/index.ts +2 -0
  56. package/src/kernel/app-auth.ts +98 -0
  57. package/src/kernel/app.ts +120 -78
  58. package/src/kernel/auto-registry.test.ts +52 -1
  59. package/src/kernel/boot.test.ts +4 -18
  60. package/src/kernel/element-registries.ts +19 -4
  61. package/src/kernel/errors.ts +3 -3
  62. package/src/kernel/fx.test.ts +25 -0
  63. package/src/kernel/fx.ts +4 -1
  64. package/src/manifest/types.ts +4 -0
  65. package/src/release/build-lib.ts +3 -0
  66. package/src/shared/lazy-src.ts +79 -0
  67. package/src/test/create-test-app.ts +16 -11
  68. package/src/test/reset-element-registries.ts +17 -9
@@ -44,11 +44,15 @@ import { VaultError } from "./errors.ts";
44
44
  import { canonicalizePath, canonicalizePrefix } from "./path.ts";
45
45
  import { createResilientSqlExec } from "./resilience.ts";
46
46
  import {
47
+ acquireRotateLease,
47
48
  createSqlAuditWriter,
49
+ DEFAULT_ROTATE_LEASE_MS,
48
50
  ensureVaultTables,
49
51
  purgeAuditBefore as purgeAuditRows,
50
52
  purgeExpiredSecrets,
51
53
  readAuditPage,
54
+ releaseRotateLease,
55
+ renewRotateLease,
52
56
  verifyAuditChain,
53
57
  type AuditChainResult,
54
58
  type AuditPageOptions,
@@ -76,6 +80,9 @@ export const DEFAULT_KEK_REWRAP_BATCH_SIZE = 100;
76
80
  /** Leading magic line of an {@link BuiltinVaultAdapter.exportBackup} bundle. */
77
81
  export const BACKUP_MAGIC = "oke-vault-backup-v1\n";
78
82
 
83
+ /** Trailing completeness marker — absent means the file was truncated mid-write. */
84
+ export const BACKUP_END_MARKER = "oke-vault-backup-end\n";
85
+
79
86
  /** Synthetic path bound into the backup bundle's AAD. */
80
87
  const BACKUP_AAD_PATH = "oke-vault-backup";
81
88
 
@@ -92,6 +99,11 @@ export interface CreateBuiltinVaultOptions {
92
99
  readonly auditSink?: AuditSink;
93
100
  /** DEKs re-wrapped per rotation batch. Defaults to {@link DEFAULT_KEK_REWRAP_BATCH_SIZE}. */
94
101
  readonly kekRewrapBatchSize?: number;
102
+ /**
103
+ * Rotate-master lease TTL in ms (Clock/Signal lazy-reclaim physics).
104
+ * Defaults to {@link DEFAULT_ROTATE_LEASE_MS}.
105
+ */
106
+ readonly rotateLeaseMs?: number;
95
107
  }
96
108
 
97
109
  /** Progress of a master-key rotation. */
@@ -247,6 +259,8 @@ interface BackupMetadata {
247
259
  readonly count: number;
248
260
  readonly iv: string;
249
261
  readonly tag: string;
262
+ /** Hex SHA-256 of the ciphertext bytes (completeness check before decrypt). */
263
+ readonly payloadSha256: string;
250
264
  }
251
265
 
252
266
  /** Master key held for the target generation of an in-flight rewrap. */
@@ -254,6 +268,8 @@ interface PendingRotation {
254
268
  readonly unsealer: Unsealer;
255
269
  readonly kekVersion: number;
256
270
  readonly verifyHash: string;
271
+ /** Lease holder id for SKIP LOCKED rotate exclusivity. */
272
+ readonly holderId: string;
257
273
  }
258
274
 
259
275
  /**
@@ -270,6 +286,7 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
270
286
  const rawDb = opts.db;
271
287
  const db = createResilientSqlExec(rawDb);
272
288
  const batchSize = Math.max(1, opts.kekRewrapBatchSize ?? DEFAULT_KEK_REWRAP_BATCH_SIZE);
289
+ const rotateLeaseMs = Math.max(50, opts.rotateLeaseMs ?? DEFAULT_ROTATE_LEASE_MS);
273
290
  const audit = opts.auditSink ?? createAuditSink("db", { writer: createSqlAuditWriter(rawDb) });
274
291
 
275
292
  let unsealer: Unsealer | null = opts.unsealer ?? null;
@@ -612,6 +629,7 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
612
629
  unsealer?.seal();
613
630
  unsealer = rotation.unsealer;
614
631
  pending = null;
632
+ await releaseRotateLease(rawDb, rotation.holderId);
615
633
  } else {
616
634
  await db.execute(
617
635
  `UPDATE oke_vault_status
@@ -620,6 +638,7 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
620
638
  WHERE id = 1`,
621
639
  [checkpoint, target, rotation.verifyHash],
622
640
  );
641
+ await renewRotateLease(rawDb, rotation.holderId, Date.now(), rotateLeaseMs);
623
642
  }
624
643
 
625
644
  await record("rewrap", undefined, { success: true });
@@ -836,34 +855,65 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
836
855
  if (pending) {
837
856
  throw new VaultError("UNSUPPORTED", "vault: a master rotation is already in progress");
838
857
  }
839
- const master = await readMaster();
840
- const target = toInt(master.kek_version) + 1;
841
858
 
842
- const generated = newMasterKey === undefined;
843
- const raw = newMasterKey ?? generateMasterKey();
844
- let rotation: PendingRotation;
845
- let encoded: string | undefined;
846
- try {
847
- rotation = {
848
- unsealer: createMemoryUnsealer(raw),
849
- kekVersion: target,
850
- verifyHash: await deriveVerifyHash(raw),
851
- };
852
- if (generated) encoded = masterKeyToBase64(raw);
853
- } finally {
854
- if (generated) zeroBytes(raw);
859
+ const holderId = crypto.randomUUID();
860
+ const now = Date.now();
861
+ const won = await acquireRotateLease(rawDb, holderId, now, rotateLeaseMs);
862
+ if (!won) {
863
+ throw new VaultError(
864
+ "UNSUPPORTED",
865
+ "vault: master rotation lease held by another instance",
866
+ );
855
867
  }
856
- pending = rotation;
857
868
 
858
- await db.execute(
859
- `UPDATE oke_vault_status
860
- SET rewrap_target_kek_version = $1, rewrap_checkpoint = NULL,
861
- rewrap_key_hash = $2, updated_at = now()
862
- WHERE id = 1`,
863
- [target, rotation.verifyHash],
864
- );
865
- const result = await rewrapBatch(rotation);
866
- return encoded === undefined ? result : { ...result, masterKey: encoded };
869
+ try {
870
+ const status = await readStatus();
871
+ if (status.rewrap_target_kek_version !== null) {
872
+ throw new VaultError("UNSUPPORTED", "vault: a master rotation is already in progress");
873
+ }
874
+
875
+ const master = await readMaster();
876
+ const target = toInt(master.kek_version) + 1;
877
+
878
+ const generated = newMasterKey === undefined;
879
+ const raw = newMasterKey ?? generateMasterKey();
880
+ let rotation: PendingRotation;
881
+ let encoded: string | undefined;
882
+ try {
883
+ rotation = {
884
+ unsealer: createMemoryUnsealer(raw),
885
+ kekVersion: target,
886
+ verifyHash: await deriveVerifyHash(raw),
887
+ holderId,
888
+ };
889
+ if (generated) encoded = masterKeyToBase64(raw);
890
+ } finally {
891
+ if (generated) zeroBytes(raw);
892
+ }
893
+ pending = rotation;
894
+
895
+ await db.execute(
896
+ `UPDATE oke_vault_status
897
+ SET rewrap_target_kek_version = $1, rewrap_checkpoint = NULL,
898
+ rewrap_key_hash = $2, updated_at = now()
899
+ WHERE id = 1`,
900
+ [target, rotation.verifyHash],
901
+ );
902
+ const result = await rewrapBatch(rotation);
903
+ return encoded === undefined ? result : { ...result, masterKey: encoded };
904
+ } catch (error) {
905
+ // No persisted rewrap yet → release so a peer can retry immediately.
906
+ if (pending === null) {
907
+ await releaseRotateLease(rawDb, holderId).catch(() => undefined);
908
+ } else {
909
+ const status = await readStatus().catch(() => null);
910
+ if (status?.rewrap_target_kek_version === null) {
911
+ pending = null;
912
+ await releaseRotateLease(rawDb, holderId).catch(() => undefined);
913
+ }
914
+ }
915
+ throw error;
916
+ }
867
917
  },
868
918
 
869
919
  async continueRotateMaster(
@@ -887,6 +937,14 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
887
937
  "vault: resume rotate-master with the new master key (--new-key / OKE_VAULT_NEW_MASTER_KEY)",
888
938
  );
889
939
  }
940
+ const holderId = crypto.randomUUID();
941
+ const won = await acquireRotateLease(rawDb, holderId, Date.now(), rotateLeaseMs);
942
+ if (!won) {
943
+ throw new VaultError(
944
+ "UNSUPPORTED",
945
+ "vault: master rotation lease held by another instance",
946
+ );
947
+ }
890
948
  const raw =
891
949
  typeof newMasterKey === "string"
892
950
  ? masterKeyFromBase64(newMasterKey)
@@ -894,6 +952,7 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
894
952
  try {
895
953
  const hash = await deriveVerifyHash(raw);
896
954
  if (!constantTimeEqualStrings(hash, expectedHash)) {
955
+ await releaseRotateLease(rawDb, holderId).catch(() => undefined);
897
956
  throw new VaultError(
898
957
  "INVALID_KEY",
899
958
  "vault: new master key does not match in-flight rotation",
@@ -903,11 +962,20 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
903
962
  unsealer: createMemoryUnsealer(raw),
904
963
  kekVersion: toInt(target),
905
964
  verifyHash: hash,
965
+ holderId,
906
966
  };
907
967
  pending = rotation;
908
968
  } finally {
909
969
  zeroBytes(raw);
910
970
  }
971
+ } else {
972
+ const won = await renewRotateLease(rawDb, rotation.holderId, Date.now(), rotateLeaseMs);
973
+ if (!won) {
974
+ throw new VaultError(
975
+ "UNSUPPORTED",
976
+ "vault: master rotation lease held by another instance",
977
+ );
978
+ }
911
979
  }
912
980
  return rewrapBatch(rotation);
913
981
  },
@@ -982,6 +1050,7 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
982
1050
  zeroBytes(payload);
983
1051
  }
984
1052
 
1053
+ const payloadSha256 = await sha256Hex(sealed.ciphertext);
985
1054
  const metadata: BackupMetadata = {
986
1055
  format: 1,
987
1056
  createdAt: new Date().toISOString(),
@@ -989,39 +1058,66 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
989
1058
  count: entries.length,
990
1059
  iv: toBase64(sealed.iv),
991
1060
  tag: toBase64(sealed.tag),
1061
+ payloadSha256,
992
1062
  };
993
1063
  const header = new TextEncoder().encode(`${BACKUP_MAGIC}${JSON.stringify(metadata)}\n`);
994
- const blob = new Uint8Array(header.byteLength + sealed.ciphertext.byteLength);
1064
+ const end = new TextEncoder().encode(BACKUP_END_MARKER);
1065
+ const blob = new Uint8Array(
1066
+ header.byteLength + sealed.ciphertext.byteLength + end.byteLength,
1067
+ );
995
1068
  blob.set(header, 0);
996
1069
  blob.set(sealed.ciphertext, header.byteLength);
1070
+ blob.set(end, header.byteLength + sealed.ciphertext.byteLength);
997
1071
  return blob;
998
1072
  },
999
1073
 
1000
1074
  async importBackup(blob: Uint8Array): Promise<void> {
1001
1075
  await ensureReady();
1002
1076
  const active = requireUnsealer();
1003
- const text = new TextDecoder().decode(blob.subarray(0, Math.min(blob.byteLength, 8192)));
1004
- if (!text.startsWith(BACKUP_MAGIC)) {
1077
+ const textHead = new TextDecoder().decode(blob.subarray(0, Math.min(blob.byteLength, 8192)));
1078
+ if (!textHead.startsWith(BACKUP_MAGIC)) {
1005
1079
  throw new VaultError("UNSUPPORTED", "vault: not an oke vault backup bundle");
1006
1080
  }
1007
- const headerEnd = text.indexOf("\n", BACKUP_MAGIC.length);
1081
+
1082
+ const endBytes = new TextEncoder().encode(BACKUP_END_MARKER);
1083
+ if (blob.byteLength < endBytes.byteLength || !tailEquals(blob, endBytes)) {
1084
+ throw new VaultError(
1085
+ "UNSUPPORTED",
1086
+ "vault: backup bundle is incomplete (missing end marker)",
1087
+ );
1088
+ }
1089
+ const body = blob.subarray(0, blob.byteLength - endBytes.byteLength);
1090
+
1091
+ const headerEnd = textHead.indexOf("\n", BACKUP_MAGIC.length);
1008
1092
  if (headerEnd < 0) {
1009
1093
  throw new VaultError("UNSUPPORTED", "vault: backup bundle header is truncated");
1010
1094
  }
1011
1095
 
1012
1096
  let metadata: BackupMetadata;
1013
1097
  try {
1014
- metadata = JSON.parse(text.slice(BACKUP_MAGIC.length, headerEnd)) as BackupMetadata;
1098
+ metadata = JSON.parse(textHead.slice(BACKUP_MAGIC.length, headerEnd)) as BackupMetadata;
1015
1099
  } catch {
1016
1100
  throw new VaultError("UNSUPPORTED", "vault: backup bundle header is not valid JSON");
1017
1101
  }
1018
1102
 
1019
- const offset = new TextEncoder().encode(text.slice(0, headerEnd + 1)).byteLength;
1103
+ const offset = new TextEncoder().encode(textHead.slice(0, headerEnd + 1)).byteLength;
1104
+ const ciphertext = body.subarray(offset);
1105
+ if (
1106
+ typeof metadata.payloadSha256 !== "string" ||
1107
+ metadata.payloadSha256.length === 0 ||
1108
+ !constantTimeEqualStrings(metadata.payloadSha256, await sha256Hex(ciphertext))
1109
+ ) {
1110
+ throw new VaultError(
1111
+ "UNSUPPORTED",
1112
+ "vault: backup bundle checksum mismatch (truncated or corrupt)",
1113
+ );
1114
+ }
1115
+
1020
1116
  const plain = await decryptBytes(
1021
1117
  await active.unwrapBackupKek(),
1022
1118
  {
1023
1119
  iv: fromBase64(metadata.iv),
1024
- ciphertext: blob.subarray(offset),
1120
+ ciphertext,
1025
1121
  tag: fromBase64(metadata.tag),
1026
1122
  },
1027
1123
  buildAad(BACKUP_AAD_PATH, 1, ALGORITHM, metadata.kekVersion),
@@ -1062,7 +1158,10 @@ export function createBuiltinVaultAdapter(opts: CreateBuiltinVaultOptions): Buil
1062
1158
  * object to the {@link SqlExec} surface the Vault needs.
1063
1159
  *
1064
1160
  * Accepts either `exec` (the driver contract) or `execute`, so a thin custom
1065
- * wrapper works without a shim.
1161
+ * wrapper works without a shim. Always exposes {@link SqlExec.begin} so
1162
+ * rotate-master leases (`FOR UPDATE SKIP LOCKED`) and audit appends
1163
+ * (`FOR UPDATE`) serialize — in-process via a mutex, and on real SQL via
1164
+ * `BEGIN`/`COMMIT` when the driver is not the memory fake.
1066
1165
  *
1067
1166
  * @param conn - Connection exposing `query` plus `exec` or `execute`
1068
1167
  */
@@ -1070,23 +1169,118 @@ export function sqlConnectionAsExec(conn: {
1070
1169
  query(sql: string, params?: readonly unknown[]): Promise<unknown[]>;
1071
1170
  exec?(sql: string, params?: readonly unknown[]): Promise<unknown>;
1072
1171
  execute?(sql: string, params?: readonly unknown[]): Promise<void>;
1172
+ driverId?: string;
1073
1173
  }): SqlExec {
1074
- return {
1075
- async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
1076
- return (await conn.query(sql, params ?? [])) as T[];
1077
- },
1078
- async execute(sql: string, params?: unknown[]): Promise<void> {
1079
- if (conn.exec) {
1080
- await conn.exec(sql, params ?? []);
1081
- return;
1082
- }
1083
- if (conn.execute) {
1084
- await conn.execute(sql, params ?? []);
1085
- return;
1086
- }
1087
- await conn.query(sql, params ?? []);
1174
+ let gate: Promise<unknown> = Promise.resolve();
1175
+
1176
+ async function runQuery<T>(sql: string, params?: unknown[]): Promise<T[]> {
1177
+ return (await conn.query(sql, params ?? [])) as T[];
1178
+ }
1179
+
1180
+ async function runExecute(sql: string, params?: unknown[]): Promise<void> {
1181
+ if (conn.exec) {
1182
+ await conn.exec(sql, params ?? []);
1183
+ return;
1184
+ }
1185
+ if (conn.execute) {
1186
+ await conn.execute(sql, params ?? []);
1187
+ return;
1188
+ }
1189
+ await conn.query(sql, params ?? []);
1190
+ }
1191
+
1192
+ const surface: SqlExec = {
1193
+ query: runQuery,
1194
+ execute: runExecute,
1195
+ begin<T>(fn: (tx: SqlExec) => Promise<T>): Promise<T> {
1196
+ const run = async (): Promise<T> => {
1197
+ const useSqlTx = conn.driverId !== "memory";
1198
+ if (useSqlTx) await runExecute("BEGIN");
1199
+ try {
1200
+ // Nested work shares this connection; the gate serializes begin() callers.
1201
+ const result = await fn({
1202
+ query: runQuery,
1203
+ execute: runExecute,
1204
+ });
1205
+ if (useSqlTx) await runExecute("COMMIT");
1206
+ return result;
1207
+ } catch (error) {
1208
+ if (useSqlTx) await runExecute("ROLLBACK").catch(() => undefined);
1209
+ throw error;
1210
+ }
1211
+ };
1212
+ const next = gate.then(run, run);
1213
+ gate = next.then(
1214
+ () => undefined,
1215
+ () => undefined,
1216
+ );
1217
+ return next;
1088
1218
  },
1089
1219
  };
1220
+ return surface;
1221
+ }
1222
+
1223
+ /**
1224
+ * Write a vault backup bundle atomically: temp file → fsync → rename.
1225
+ *
1226
+ * Prevents a crash mid-write from leaving a magic-prefixed partial file at
1227
+ * the final path that an operator could mistake for a complete backup.
1228
+ *
1229
+ * @param path - Final destination path
1230
+ * @param blob - Complete bundle bytes (including end marker)
1231
+ */
1232
+ export async function writeBackupFileAtomic(path: string, blob: Uint8Array): Promise<void> {
1233
+ const { open, rename, unlink } = await import("node:fs/promises");
1234
+ const tmp = `${path}.oke-tmp-${process.pid}-${Date.now()}`;
1235
+ try {
1236
+ await Bun.write(tmp, blob);
1237
+ const fh = await open(tmp, "r+");
1238
+ try {
1239
+ await fh.sync();
1240
+ } finally {
1241
+ await fh.close();
1242
+ }
1243
+ await rename(tmp, path);
1244
+ } catch (error) {
1245
+ await unlink(tmp).catch(() => undefined);
1246
+ throw error;
1247
+ }
1248
+ }
1249
+
1250
+ /**
1251
+ * Hex-encode a SHA-256 digest of `bytes`.
1252
+ *
1253
+ * @param bytes - Payload to hash
1254
+ */
1255
+ async function sha256Hex(bytes: Uint8Array): Promise<string> {
1256
+ const digest = await crypto.subtle.digest("SHA-256", toArrayBuffer(bytes));
1257
+ return Buffer.from(digest).toString("hex");
1258
+ }
1259
+
1260
+ /**
1261
+ * Copy into a real `ArrayBuffer` for Web Crypto `BufferSource` typing.
1262
+ *
1263
+ * @param view - Source bytes (may be backed by `ArrayBufferLike`)
1264
+ */
1265
+ function toArrayBuffer(view: Uint8Array): ArrayBuffer {
1266
+ const copy = new Uint8Array(view.byteLength);
1267
+ copy.set(view);
1268
+ return copy.buffer;
1269
+ }
1270
+
1271
+ /**
1272
+ * Whether `blob` ends exactly with `tail`.
1273
+ *
1274
+ * @param blob - Full buffer
1275
+ * @param tail - Expected suffix
1276
+ */
1277
+ function tailEquals(blob: Uint8Array, tail: Uint8Array): boolean {
1278
+ if (blob.byteLength < tail.byteLength) return false;
1279
+ const start = blob.byteLength - tail.byteLength;
1280
+ for (let i = 0; i < tail.byteLength; i += 1) {
1281
+ if (blob[start + i] !== tail[i]) return false;
1282
+ }
1283
+ return true;
1090
1284
  }
1091
1285
 
1092
1286
  /**