socketduct 0.1.19 → 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.
- package/.env.example +17 -5
- package/README.md +3 -3
- package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
- package/docs/docker.md +5 -4
- package/docs/logging.md +131 -159
- package/docs/protocol.md +27 -4
- package/docs/reverse-gateway.md +34 -0
- package/docs/running.md +14 -11
- package/docs/security.md +7 -3
- package/package.json +5 -1
- package/scripts/verify-zstd-interoperability.mjs +41 -0
- package/src/cli/socketduct.js +143 -0
- package/src/client/virtual-socket.js +10 -0
- package/src/config/environment.js +56 -7
- package/src/index.js +64 -26
- package/src/logging/compressed-log-reader.js +256 -0
- package/src/logging/compressed-log-worker.js +430 -0
- package/src/logging/compressed-log-writer.js +538 -0
- package/src/logging/log-batch.js +72 -0
- package/src/logging/log-retention.js +241 -0
- package/src/logging/log-segment-index.js +176 -0
- package/src/logging/structured-logger.js +136 -30
- package/src/logging/zstd-frame-codec.js +73 -0
- package/src/reverse/client.js +71 -11
- package/src/reverse/disconnect-attribution.js +191 -0
- package/src/reverse/gateway.js +58 -8
- package/src/reverse/hub.js +215 -32
- package/src/reverse/runtime.js +5 -3
- package/tensorbuzz.yml +2 -1
- package/test/e2e/logger-shutdown-process.test.js +86 -0
- package/test/e2e/reverse-gateway-process.test.js +15 -14
- package/test/integration/logging-cli.test.js +168 -0
- package/test/integration/logging-compressed-recovery.test.js +186 -0
- package/test/integration/reverse-gateway.test.js +176 -0
- package/test/integration/reverse-route-class.test.js +6 -1
- package/test/unit/config/docker-gateway-files.test.js +2 -0
- package/test/unit/config/environment.test.js +54 -14
- package/test/unit/index.test.js +3 -0
- package/test/unit/logging/compressed-log-worker.test.js +29 -0
- package/test/unit/logging/compressed-log-writer.test.js +183 -0
- package/test/unit/logging/log-retention.test.js +86 -0
- package/test/unit/logging/log-segment-index.test.js +104 -0
- package/test/unit/logging/structured-logger.test.js +14 -1
- package/test/unit/logging/zstd-frame-codec.test.js +24 -0
- package/test/unit/reverse/disconnect-attribution.test.js +82 -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
|
+
}
|