socketduct 0.1.18 → 0.1.20

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 (55) hide show
  1. package/.env.example +17 -5
  2. package/README.md +5 -5
  3. package/changelog.d/20260925143000-reverse-client-process-recovery.md +1 -0
  4. package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
  5. package/docs/architecture.md +11 -9
  6. package/docs/docker.md +5 -4
  7. package/docs/logging.md +131 -159
  8. package/docs/protocol.md +43 -7
  9. package/docs/reverse-gateway.md +75 -9
  10. package/docs/running.md +16 -13
  11. package/docs/security.md +16 -5
  12. package/docs/session-lifecycle.md +14 -3
  13. package/docs/socket-api.md +54 -20
  14. package/package.json +5 -1
  15. package/scripts/verify-zstd-interoperability.mjs +41 -0
  16. package/src/cli/socketduct.js +143 -0
  17. package/src/client/multiplexed-transport-set.js +12 -5
  18. package/src/client/virtual-socket.js +52 -0
  19. package/src/config/environment.js +56 -7
  20. package/src/index.js +64 -26
  21. package/src/logging/compressed-log-reader.js +256 -0
  22. package/src/logging/compressed-log-worker.js +430 -0
  23. package/src/logging/compressed-log-writer.js +538 -0
  24. package/src/logging/log-batch.js +72 -0
  25. package/src/logging/log-retention.js +241 -0
  26. package/src/logging/log-segment-index.js +176 -0
  27. package/src/logging/structured-logger.js +136 -30
  28. package/src/logging/zstd-frame-codec.js +73 -0
  29. package/src/reverse/client.js +372 -82
  30. package/src/reverse/disconnect-attribution.js +191 -0
  31. package/src/reverse/gateway.js +68 -18
  32. package/src/reverse/hub.js +215 -32
  33. package/src/reverse/runtime.js +5 -3
  34. package/src/spool/spool-file.js +136 -7
  35. package/tensorbuzz.yml +4 -2
  36. package/test/e2e/logger-shutdown-process.test.js +86 -0
  37. package/test/e2e/reverse-client-recovery-process.test.js +170 -0
  38. package/test/e2e/reverse-gateway-process.test.js +15 -14
  39. package/test/fixtures/reverse-client-recovery.mjs +123 -0
  40. package/test/integration/logging-cli.test.js +168 -0
  41. package/test/integration/logging-compressed-recovery.test.js +186 -0
  42. package/test/integration/reverse-gateway.test.js +597 -1
  43. package/test/integration/reverse-route-class.test.js +6 -1
  44. package/test/unit/client/multiplexed-transport-set.test.js +12 -1
  45. package/test/unit/config/docker-gateway-files.test.js +2 -0
  46. package/test/unit/config/environment.test.js +54 -14
  47. package/test/unit/index.test.js +3 -0
  48. package/test/unit/logging/compressed-log-worker.test.js +29 -0
  49. package/test/unit/logging/compressed-log-writer.test.js +183 -0
  50. package/test/unit/logging/log-retention.test.js +86 -0
  51. package/test/unit/logging/log-segment-index.test.js +104 -0
  52. package/test/unit/logging/structured-logger.test.js +14 -1
  53. package/test/unit/logging/zstd-frame-codec.test.js +24 -0
  54. package/test/unit/reverse/disconnect-attribution.test.js +82 -0
  55. package/test/unit/spool/spool-file.test.js +133 -0
@@ -0,0 +1,241 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns */
2
+ import {randomUUID} from "node:crypto"
3
+ import {constants, readlinkSync} from "node:fs"
4
+ import {link, lstat, open, readFile, readdir, stat, unlink} from "node:fs/promises"
5
+ import {join} from "node:path"
6
+
7
+ const SEGMENT_NAME = /^[a-z][a-z0-9-]{0,63}-[A-Za-z0-9_-]{22}-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.jsonl\.zst$/
8
+ const OWNER_TEMPORARY_NAME = /^\.socketduct-retention-owner-([1-9][0-9]*)-([0-9]+)-([1-9][0-9]*)-([0-9]+)-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.tmp$/
9
+
10
+ /**
11
+ * Enforces time and aggregate-byte bounds without deleting active/quarantined evidence.
12
+ * @param options
13
+ */
14
+ export async function enforceLogRetention(/** @type {{directory: string, maxAgeMs: number, maxBytes: number, now?: () => Date}} */ options) {
15
+ const now = options.now ?? (() => new Date())
16
+ if (!Number.isSafeInteger(options.maxAgeMs) || options.maxAgeMs < 1) throw new RangeError("maxAgeMs must be a positive safe integer")
17
+ if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 1) throw new RangeError("maxBytes must be a positive safe integer")
18
+ const timestamp = now()
19
+ if (!(timestamp instanceof Date) || !Number.isFinite(timestamp.getTime())) throw new TypeError("retention clock returned an invalid date")
20
+ const directoryStats = await lstat(options.directory)
21
+ if (!directoryStats.isDirectory() || (directoryStats.mode & 0o777) !== 0o700) throw new Error("Unsafe compressed log directory")
22
+ const lockPath = join(options.directory, ".socketduct-retention.lock")
23
+ const lock = await acquireRetentionLock(options.directory, lockPath)
24
+ if (lock === undefined) return {skipped: true, deletedSegments: 0, retainedBytes: await directoryBytes(options.directory)}
25
+ try {
26
+ const names = await readdir(options.directory)
27
+ /** @type {Array<{name: string, dataPath: string, paths: string[], bytes: number, modifiedAt: number, sealed: boolean, quarantined: boolean, deleted: boolean}>} */
28
+ const segments = []
29
+ let retainedBytes = 0
30
+ for (const name of names) {
31
+ if (!SEGMENT_NAME.test(name)) continue
32
+ const dataPath = join(options.directory, name)
33
+ const paths = [dataPath, `${dataPath}.idx`]
34
+ const sealed = await exists(`${dataPath}.sealed`)
35
+ const quarantined = await exists(`${dataPath}.quarantine`)
36
+ if (sealed) paths.push(`${dataPath}.sealed`)
37
+ if (quarantined) paths.push(`${dataPath}.quarantine`)
38
+ let bytes = 0
39
+ let modifiedAt = 0
40
+ for (const path of paths) {
41
+ const stats = await lstat(path)
42
+ if (!stats.isFile() || (stats.mode & 0o777) !== 0o600) throw new Error("Unsafe compressed log retention file")
43
+ bytes += stats.size
44
+ if (path === dataPath) modifiedAt = stats.mtimeMs
45
+ }
46
+ retainedBytes += bytes
47
+ segments.push({name, dataPath, paths, bytes, modifiedAt, sealed, quarantined, deleted: false})
48
+ }
49
+ segments.sort((left, right) => left.modifiedAt !== right.modifiedAt ? left.modifiedAt - right.modifiedAt : left.name.localeCompare(right.name))
50
+ let deletedSegments = 0
51
+ const cutoff = timestamp.getTime() - options.maxAgeMs
52
+ for (const segment of segments) {
53
+ if (!segment.sealed || segment.quarantined || segment.modifiedAt >= cutoff) continue
54
+ await deleteSegment(segment)
55
+ retainedBytes -= segment.bytes
56
+ deletedSegments += 1
57
+ }
58
+ for (const segment of segments) {
59
+ if (retainedBytes <= options.maxBytes) break
60
+ if (segment.deleted || !segment.sealed || segment.quarantined) continue
61
+ await deleteSegment(segment)
62
+ retainedBytes -= segment.bytes
63
+ deletedSegments += 1
64
+ }
65
+ if (retainedBytes > options.maxBytes) throw Object.assign(new Error("Active or quarantined compressed logs prevent quota compliance"), {code: "LOG_RETENTION_QUOTA_BLOCKED"})
66
+ if (deletedSegments > 0) await syncDirectory(options.directory)
67
+ return {skipped: false, deletedSegments, retainedBytes}
68
+ } finally {
69
+ await releaseRetentionLock(options.directory, lockPath, lock)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * @param {string} directory - Directory.
75
+ * @param {string} lockPath - Lock path.
76
+ * @param {{dev: number, ino: number}} lock - Owned inode.
77
+ */
78
+ async function releaseRetentionLock(directory, lockPath, lock) {
79
+ const current = await lstat(lockPath).catch((error) => { if (isCode(error, "ENOENT")) return undefined; throw error })
80
+ if (current !== undefined && (current.dev !== lock.dev || current.ino !== lock.ino)) throw Object.assign(new Error("Retention lock ownership changed while held"), {code: "LOG_RETENTION_LOCK_RACE"})
81
+ if (current !== undefined) await unlink(lockPath)
82
+ await syncDirectory(directory)
83
+ }
84
+
85
+ /**
86
+ * Acquires a complete, private owner record and reclaims only a provably stale
87
+ * process/thread instance. Kernel start times prevent PID/TID-reuse confusion.
88
+ * @param {string} directory - Private log directory.
89
+ * @param {string} lockPath - Exclusive lock path.
90
+ */
91
+ async function acquireRetentionLock(directory, lockPath) {
92
+ const owner = await localOwnerIdentity()
93
+ await removeStaleOwnerFiles(directory)
94
+ for (let attempt = 0; attempt < 3; attempt += 1) {
95
+ const created = await createOwnerFile(directory, lockPath, owner)
96
+ if (created !== undefined) return created
97
+ const existing = await readOwnerFile(lockPath)
98
+ if (existing === undefined) continue
99
+ if (await ownerIsLive(existing.owner)) return undefined
100
+ const current = await lstat(lockPath).catch((error) => { if (isCode(error, "ENOENT")) return undefined; throw error })
101
+ if (current === undefined) continue
102
+ if (current.dev !== existing.stats.dev || current.ino !== existing.stats.ino) return undefined
103
+ await unlink(lockPath).catch((error) => { if (!isCode(error, "ENOENT")) throw error })
104
+ await syncDirectory(directory)
105
+ }
106
+ return undefined
107
+ }
108
+
109
+ /**
110
+ * @param {string} directory - Directory.
111
+ * @param {string} lockPath - Destination.
112
+ * @param {{version: 1, pid: number, processStart: string, tid: number, threadStart: string}} owner - Owner identity.
113
+ */
114
+ async function createOwnerFile(directory, lockPath, owner) {
115
+ const temporary = join(directory, `.socketduct-retention-owner-${owner.pid}-${owner.processStart}-${owner.tid}-${owner.threadStart}-${randomUUID()}.tmp`)
116
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600)
117
+ try {
118
+ await handle.writeFile(`${JSON.stringify(owner)}\n`)
119
+ await handle.sync()
120
+ } finally {
121
+ await handle.close()
122
+ }
123
+ try {
124
+ await link(temporary, lockPath)
125
+ const stats = await lstat(lockPath)
126
+ if (!stats.isFile() || (stats.mode & 0o777) !== 0o600) throw Object.assign(new Error("Unsafe retention lock"), {code: "LOG_RETENTION_LOCK_INVALID"})
127
+ await syncDirectory(directory)
128
+ return {dev: stats.dev, ino: stats.ino}
129
+ } catch (error) {
130
+ if (isCode(error, "EEXIST")) return undefined
131
+ throw error
132
+ } finally {
133
+ await unlink(temporary).catch((error) => { if (!isCode(error, "ENOENT")) throw error })
134
+ }
135
+ }
136
+
137
+ /** @param {string} directory - Private log directory. */
138
+ async function removeStaleOwnerFiles(directory) {
139
+ let removed = false
140
+ for (const name of await readdir(directory)) {
141
+ const match = OWNER_TEMPORARY_NAME.exec(name)
142
+ if (match === null) continue
143
+ const owner = {pid: Number(match[1]), processStart: /** @type {string} */ (match[2]), tid: Number(match[3]), threadStart: /** @type {string} */ (match[4])}
144
+ if (!Number.isSafeInteger(owner.pid) || owner.pid < 1 || !Number.isSafeInteger(owner.tid) || owner.tid < 1) throw Object.assign(new Error("Invalid retention owner temporary"), {code: "LOG_RETENTION_LOCK_INVALID"})
145
+ if (await ownerIsLive(owner)) continue
146
+ const path = join(directory, name)
147
+ const stats = await lstat(path).catch((error) => { if (isCode(error, "ENOENT")) return undefined; throw error })
148
+ if (stats === undefined) continue
149
+ if (!stats.isFile() || (stats.mode & 0o777) !== 0o600) throw Object.assign(new Error("Unsafe retention owner temporary"), {code: "LOG_RETENTION_LOCK_INVALID"})
150
+ await unlink(path).catch((error) => { if (!isCode(error, "ENOENT")) throw error })
151
+ removed = true
152
+ }
153
+ if (removed) await syncDirectory(directory)
154
+ }
155
+
156
+ /** @param {string} lockPath - Existing lock path. */
157
+ async function readOwnerFile(lockPath) {
158
+ let handle
159
+ try { handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW) } catch (error) { if (isCode(error, "ENOENT")) return undefined; throw error }
160
+ try {
161
+ const stats = await handle.stat()
162
+ if (!stats.isFile() || (stats.mode & 0o777) !== 0o600 || stats.size < 2 || stats.size > 512) throw Object.assign(new Error("Invalid retention lock owner record"), {code: "LOG_RETENTION_LOCK_INVALID"})
163
+ let value
164
+ try { value = JSON.parse(await handle.readFile("utf8")) } catch { throw Object.assign(new Error("Invalid retention lock owner record"), {code: "LOG_RETENTION_LOCK_INVALID"}) }
165
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw Object.assign(new Error("Invalid retention lock owner record"), {code: "LOG_RETENTION_LOCK_INVALID"})
166
+ const candidate = /** @type {Record<string, unknown>} */ (value)
167
+ if (candidate.version !== 1 || !Number.isSafeInteger(candidate.pid) || /** @type {number} */ (candidate.pid) < 1 || typeof candidate.processStart !== "string" || !/^[0-9]+$/.test(candidate.processStart) || !Number.isSafeInteger(candidate.tid) || /** @type {number} */ (candidate.tid) < 1 || typeof candidate.threadStart !== "string" || !/^[0-9]+$/.test(candidate.threadStart)) throw Object.assign(new Error("Invalid retention lock owner record"), {code: "LOG_RETENTION_LOCK_INVALID"})
168
+ return {stats, owner: /** @type {{version: 1, pid: number, processStart: string, tid: number, threadStart: string}} */ (candidate)}
169
+ } finally {
170
+ await handle.close()
171
+ }
172
+ }
173
+
174
+ /** Returns the current Linux process/thread-instance identity. */
175
+ async function localOwnerIdentity() {
176
+ const processStart = await readProcessStart(process.pid)
177
+ if (processStart === undefined) throw Object.assign(new Error("Cannot identify retention owner process"), {code: "LOG_RETENTION_OWNER_UNAVAILABLE"})
178
+ const target = readlinkSync("/proc/thread-self")
179
+ const tidText = target.slice(target.lastIndexOf("/") + 1)
180
+ if (!/^[0-9]+$/.test(tidText)) throw Object.assign(new Error("Cannot identify retention owner thread"), {code: "LOG_RETENTION_OWNER_UNAVAILABLE"})
181
+ const tid = Number(tidText)
182
+ const threadStart = await readProcessStart(process.pid, tid)
183
+ if (!Number.isSafeInteger(tid) || tid < 1 || threadStart === undefined) throw Object.assign(new Error("Cannot identify retention owner thread"), {code: "LOG_RETENTION_OWNER_UNAVAILABLE"})
184
+ return {version: /** @type {const} */ (1), pid: process.pid, processStart, tid, threadStart}
185
+ }
186
+
187
+ /** @param {{pid: number, processStart: string, tid: number, threadStart: string}} owner - Recorded owner. */
188
+ async function ownerIsLive(owner) {
189
+ const [currentProcessStart, currentThreadStart] = await Promise.all([readProcessStart(owner.pid), readProcessStart(owner.pid, owner.tid)])
190
+ return currentProcessStart === owner.processStart && currentThreadStart === owner.threadStart
191
+ }
192
+
193
+ /**
194
+ * @param {number} pid - Linux process ID.
195
+ * @param {number} [tid] - Optional Linux task ID.
196
+ */
197
+ async function readProcessStart(pid, tid = undefined) {
198
+ let value
199
+ const path = tid === undefined ? `/proc/${pid}/stat` : `/proc/${pid}/task/${tid}/stat`
200
+ try { value = await readFile(path, "utf8") } catch (error) { if (isCode(error, "ENOENT") || isCode(error, "ESRCH")) return undefined; throw error }
201
+ const close = value.lastIndexOf(")")
202
+ if (close < 0) throw Object.assign(new Error("Invalid process identity record"), {code: "LOG_RETENTION_OWNER_UNAVAILABLE"})
203
+ const processStart = value.slice(close + 2).trim().split(/\s+/)[19]
204
+ if (processStart === undefined || !/^[0-9]+$/.test(processStart)) throw Object.assign(new Error("Invalid process identity record"), {code: "LOG_RETENTION_OWNER_UNAVAILABLE"})
205
+ return processStart
206
+ }
207
+
208
+ /** @param {{paths: string[], deleted: boolean}} segment - Closed segment files. */
209
+ async function deleteSegment(segment) {
210
+ for (const path of [...segment.paths].reverse()) await unlink(path)
211
+ segment.deleted = true
212
+ }
213
+
214
+ /** @param {string} directory - Log directory. */
215
+ async function directoryBytes(directory) {
216
+ let bytes = 0
217
+ for (const name of await readdir(directory)) {
218
+ const stats = await lstat(join(directory, name))
219
+ if (stats.isFile()) bytes += stats.size
220
+ }
221
+ return bytes
222
+ }
223
+
224
+ /** @param {string} path - Candidate path. */
225
+ async function exists(path) {
226
+ try { await stat(path); return true } catch (error) { if (isCode(error, "ENOENT")) return false; throw error }
227
+ }
228
+
229
+ /** @param {string} directory - Directory to sync. */
230
+ async function syncDirectory(directory) {
231
+ const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
232
+ try { await handle.sync() } finally { await handle.close() }
233
+ }
234
+
235
+ /**
236
+ * @param {unknown} error - Candidate system error. @param {string} code - Expected code.
237
+ * @param code
238
+ */
239
+ function isCode(error, code) {
240
+ return error !== null && typeof error === "object" && /** @type {{code?: unknown}} */ (error).code === code
241
+ }
@@ -0,0 +1,176 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns */
2
+ export const INDEX_HEADER_BYTES = 48
3
+ export const INDEX_ENTRY_BYTES = 156
4
+
5
+ const HEADER_MAGIC = Buffer.from([0x53, 0x44, 0x5a, 0x49, 0x44, 0x58, 0x31, 0x00])
6
+ const ENTRY_MAGIC = Buffer.from("FRM1", "ascii")
7
+ const UINT64_MAX = (1n << 64n) - 1n
8
+ const CRC32C_TABLE = createCrc32cTable()
9
+
10
+ /**
11
+ * Encodes the exact 48-byte v1 sidecar header.
12
+ * @param options
13
+ */
14
+ export function createIndexHeader(/** @type {{segmentUuid: Buffer | Uint8Array, writerEpoch: Buffer | Uint8Array}} */ options) {
15
+ const segmentUuid = fixedBytes(options.segmentUuid, 16, "segmentUuid")
16
+ const writerEpoch = fixedBytes(options.writerEpoch, 16, "writerEpoch")
17
+ const header = Buffer.alloc(INDEX_HEADER_BYTES)
18
+ HEADER_MAGIC.copy(header, 0)
19
+ header.writeUInt16BE(1, 8)
20
+ header.writeUInt16BE(INDEX_HEADER_BYTES, 10)
21
+ segmentUuid.copy(header, 12)
22
+ writerEpoch.copy(header, 28)
23
+ header.writeUInt32BE(crc32c(header.subarray(0, 44)), 44)
24
+ return header
25
+ }
26
+
27
+ /**
28
+ * Parses and authenticates the structural v1 header.
29
+ * @param bytes
30
+ */
31
+ export function parseIndexHeader(/** @type {Buffer | Uint8Array} */ bytes) {
32
+ const header = Buffer.from(bytes)
33
+ if (header.length !== INDEX_HEADER_BYTES) throw new Error("Invalid log index header length")
34
+ if (!header.subarray(0, 8).equals(HEADER_MAGIC)) throw new Error("Invalid log index header magic")
35
+ if (header.readUInt16BE(8) !== 1 || header.readUInt16BE(10) !== INDEX_HEADER_BYTES) throw new Error("Unsupported log index header version")
36
+ if (header.readUInt32BE(44) !== crc32c(header.subarray(0, 44))) throw new Error("Invalid log index header CRC32C")
37
+ return {version: 1, segmentUuid: Buffer.from(header.subarray(12, 28)), writerEpoch: Buffer.from(header.subarray(28, 44))}
38
+ }
39
+
40
+ /**
41
+ * Encodes one exact 156-byte v1 frame entry.
42
+ * @param options
43
+ */
44
+ export function createIndexEntry(/** @type {Record<string, unknown>} */ options) {
45
+ const segmentUuid = fixedBytes(options.segmentUuid, 16, "segmentUuid")
46
+ const writerEpoch = fixedBytes(options.writerEpoch, 16, "writerEpoch")
47
+ const batchId = fixedBytes(options.batchId, 16, "batchId")
48
+ const frameSha256 = fixedBytes(options.frameSha256, 32, "frameSha256")
49
+ const firstSequence = uint64(options.firstSequence, "firstSequence")
50
+ const lastSequence = uint64(options.lastSequence, "lastSequence")
51
+ const dataOffset = uint64(options.dataOffset, "dataOffset")
52
+ const compressedLength = uint64(options.compressedLength, "compressedLength")
53
+ const uncompressedLength = uint64(options.uncompressedLength, "uncompressedLength")
54
+ const minTimestamp = uint64(options.minTimestamp, "minTimestamp")
55
+ const maxTimestamp = uint64(options.maxTimestamp, "maxTimestamp")
56
+ const recordCount = uint32(options.recordCount, "recordCount")
57
+ if (firstSequence === 0n || lastSequence < firstSequence || BigInt(recordCount) !== lastSequence - firstSequence + 1n) throw new RangeError("Index sequence range must be contiguous with recordCount")
58
+ if (compressedLength === 0n || uncompressedLength === 0n) throw new RangeError("Index frame lengths must be positive")
59
+ if (maxTimestamp < minTimestamp) throw new RangeError("Index timestamp range is invalid")
60
+ const entry = Buffer.alloc(INDEX_ENTRY_BYTES)
61
+ ENTRY_MAGIC.copy(entry, 0)
62
+ entry.writeUInt32BE(INDEX_ENTRY_BYTES, 4)
63
+ entry.writeUInt16BE(1, 8)
64
+ entry.writeUInt16BE(0, 10)
65
+ segmentUuid.copy(entry, 12)
66
+ writerEpoch.copy(entry, 28)
67
+ batchId.copy(entry, 44)
68
+ entry.writeBigUInt64BE(firstSequence, 60)
69
+ entry.writeBigUInt64BE(lastSequence, 68)
70
+ entry.writeBigUInt64BE(dataOffset, 76)
71
+ entry.writeBigUInt64BE(compressedLength, 84)
72
+ entry.writeBigUInt64BE(uncompressedLength, 92)
73
+ entry.writeUInt32BE(recordCount, 100)
74
+ entry.writeBigUInt64BE(minTimestamp, 104)
75
+ entry.writeBigUInt64BE(maxTimestamp, 112)
76
+ frameSha256.copy(entry, 120)
77
+ entry.writeUInt32BE(crc32c(entry.subarray(0, 152)), 152)
78
+ return entry
79
+ }
80
+
81
+ /**
82
+ * Parses one entry and requires it to match the selected segment identity.
83
+ * @param bytes
84
+ * @param identity
85
+ */
86
+ export function parseIndexEntry(/** @type {Buffer | Uint8Array} */ bytes, /** @type {{segmentUuid: Buffer | Uint8Array, writerEpoch: Buffer | Uint8Array}} */ identity) {
87
+ const entry = Buffer.from(bytes)
88
+ if (entry.length !== INDEX_ENTRY_BYTES) throw new Error("Invalid log index entry length")
89
+ if (!entry.subarray(0, 4).equals(ENTRY_MAGIC) || entry.readUInt32BE(4) !== INDEX_ENTRY_BYTES) throw new Error("Invalid log index entry framing")
90
+ if (entry.readUInt16BE(8) !== 1 || entry.readUInt16BE(10) !== 0) throw new Error("Unsupported log index entry version")
91
+ if (entry.readUInt32BE(152) !== crc32c(entry.subarray(0, 152))) throw new Error("Invalid log index entry CRC32C")
92
+ const expectedSegment = fixedBytes(identity.segmentUuid, 16, "segmentUuid")
93
+ const expectedEpoch = fixedBytes(identity.writerEpoch, 16, "writerEpoch")
94
+ const segmentUuid = Buffer.from(entry.subarray(12, 28))
95
+ const writerEpoch = Buffer.from(entry.subarray(28, 44))
96
+ if (!segmentUuid.equals(expectedSegment) || !writerEpoch.equals(expectedEpoch)) throw new Error("Log index entry identity mismatch")
97
+ const parsed = {
98
+ version: 1,
99
+ flags: 0,
100
+ segmentUuid,
101
+ writerEpoch,
102
+ batchId: Buffer.from(entry.subarray(44, 60)),
103
+ firstSequence: entry.readBigUInt64BE(60),
104
+ lastSequence: entry.readBigUInt64BE(68),
105
+ dataOffset: entry.readBigUInt64BE(76),
106
+ compressedLength: entry.readBigUInt64BE(84),
107
+ uncompressedLength: entry.readBigUInt64BE(92),
108
+ recordCount: entry.readUInt32BE(100),
109
+ minTimestamp: entry.readBigUInt64BE(104),
110
+ maxTimestamp: entry.readBigUInt64BE(112),
111
+ frameSha256: Buffer.from(entry.subarray(120, 152))
112
+ }
113
+ if (parsed.firstSequence === 0n || parsed.lastSequence < parsed.firstSequence || BigInt(parsed.recordCount) !== parsed.lastSequence - parsed.firstSequence + 1n) throw new Error("Invalid log index sequence range")
114
+ if (parsed.compressedLength === 0n || parsed.uncompressedLength === 0n || parsed.maxTimestamp < parsed.minTimestamp) throw new Error("Invalid log index frame range")
115
+ return parsed
116
+ }
117
+
118
+ /**
119
+ * Converts the canonical UUID text representation to its 16 raw bytes.
120
+ * @param value
121
+ */
122
+ export function uuidBytes(/** @type {string} */ value) {
123
+ if (typeof value !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value)) throw new TypeError("segment UUID must use canonical hexadecimal form")
124
+ return Buffer.from(value.replaceAll("-", ""), "hex")
125
+ }
126
+
127
+ /**
128
+ * Computes Castagnoli CRC32C as an unsigned uint32.
129
+ * @param bytes
130
+ */
131
+ export function crc32c(/** @type {Buffer | Uint8Array} */ bytes) {
132
+ let crc = 0xffff_ffff
133
+ for (const byte of bytes) crc = /** @type {number} */ (CRC32C_TABLE[(crc ^ byte) & 0xff]) ^ (crc >>> 8)
134
+ return (crc ^ 0xffff_ffff) >>> 0
135
+ }
136
+
137
+ /**
138
+ * @param {unknown} value - Fixed byte string. @param {number} length - Exact length. @param {string} name - Field.
139
+ * @param length
140
+ * @param name
141
+ */
142
+ function fixedBytes(value, length, name) {
143
+ if (!(value instanceof Uint8Array) || value.byteLength !== length) throw new TypeError(`${name} must contain exactly ${length} bytes`)
144
+ return Buffer.from(value)
145
+ }
146
+
147
+ /**
148
+ * @param {unknown} value - Unsigned value. @param {string} name - Field.
149
+ * @param name
150
+ */
151
+ function uint64(value, name) {
152
+ if (typeof value !== "bigint" || value < 0n || value > UINT64_MAX) throw new RangeError(`${name} must be an unsigned 64-bit bigint`)
153
+ return value
154
+ }
155
+
156
+ /**
157
+ * @param {unknown} value - Unsigned value. @param {string} name - Field.
158
+ * @param name
159
+ */
160
+ function uint32(value, name) {
161
+ if (!Number.isSafeInteger(value) || /** @type {number} */ (value) < 0 || /** @type {number} */ (value) > 0xffff_ffff) throw new RangeError(`${name} must be an unsigned 32-bit integer`)
162
+ return /** @type {number} */ (value)
163
+ }
164
+
165
+ /**
166
+ *
167
+ */
168
+ function createCrc32cTable() {
169
+ const table = new Uint32Array(256)
170
+ for (let index = 0; index < 256; index += 1) {
171
+ let value = index
172
+ for (let bit = 0; bit < 8; bit += 1) value = (value & 1) === 1 ? (value >>> 1) ^ 0x82f6_3b78 : value >>> 1
173
+ table[index] = value >>> 0
174
+ }
175
+ return table
176
+ }