dsh-server-connector 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/LICENSE +0 -0
- package/README.md +49 -7
- package/cordis.patch.yml +6 -6
- package/icon.svg +15 -15
- package/lib/audit.js +469 -455
- package/lib/client.js +2389 -1720
- package/lib/config.js +89 -0
- package/lib/index.js +316 -266
- package/lib/known-hosts.js +377 -377
- package/lib/pending.js +766 -753
- package/lib/policy.js +590 -590
- package/lib/pool.js +1496 -1464
- package/lib/registry.js +968 -968
- package/lib/routes.js +14 -13
- package/lib/tools.js +1367 -1357
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +105 -105
package/lib/audit.js
CHANGED
|
@@ -1,455 +1,469 @@
|
|
|
1
|
-
// dsh-server-connector — execution audit log (design doc §3.4/§6.2).
|
|
2
|
-
//
|
|
3
|
-
// One JSON object per line, appended to `$DSH_HOME/server-connector/audit.log`.
|
|
4
|
-
// The module only appends and reads the tail: it never participates in a
|
|
5
|
-
// decision, never rewrites an earlier line, and never loads the whole file to
|
|
6
|
-
// answer a tail(limit) call.
|
|
7
|
-
//
|
|
8
|
-
// Record shapes (doc §3.4):
|
|
9
|
-
// exec: { ts, sessionId, serverId, serverName, command, decision,
|
|
10
|
-
// policyRule, approved, exitCode, durationMs, truncated }
|
|
11
|
-
// block: an exec record with decision="block", approved=null, exitCode=null
|
|
12
|
-
// event: { ts, event, serverId, serverName, detail? } for non-execution
|
|
13
|
-
// events such as hostkey-disabled / server-deleted / rule-disabled
|
|
14
|
-
//
|
|
15
|
-
// Rotation (doc §6.2): with `auditMaxBytes > 0`, the log is rotated once when
|
|
16
|
-
// the next append would exceed that size. The current file becomes
|
|
17
|
-
// `audit.log.1` and a fresh `audit.log` starts; a second threshold hit
|
|
18
|
-
// overwrites `audit.log.1`, so at most two files of bounded size exist.
|
|
19
|
-
|
|
20
|
-
import { mkdir, open, rename, stat, writeFile } from 'node:fs/promises'
|
|
21
|
-
import { dirname, join, resolve } from 'node:path'
|
|
22
|
-
|
|
23
|
-
import { connectorDirectory } from './registry.js'
|
|
24
|
-
|
|
25
|
-
/** File name of the audit log. */
|
|
26
|
-
export const AUDIT_FILE_NAME = 'audit.log'
|
|
27
|
-
|
|
28
|
-
/** Suffix of the single rotated file. */
|
|
29
|
-
export const AUDIT_ROTATED_SUFFIX = '.1'
|
|
30
|
-
|
|
31
|
-
/** Byte size of the read window when tail() has to grow to satisfy `limit`. */
|
|
32
|
-
const INITIAL_TAIL_WINDOW = 64 * 1024
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* One tail promise per log file: two AuditLog instances (tools and routes, say)
|
|
36
|
-
* must not interleave a line or race the rotation check.
|
|
37
|
-
* @type {Map<string, Promise<void>>}
|
|
38
|
-
*/
|
|
39
|
-
const APPEND_QUEUES = new Map()
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* One audit record, exactly as persisted. Exec records, block records and
|
|
43
|
-
* event records share this wrapper; `event` is present only on events.
|
|
44
|
-
* @typedef {object} AuditRecord
|
|
45
|
-
* @property {string} ts - ISO-8601 timestamp.
|
|
46
|
-
* @property {string} [sessionId]
|
|
47
|
-
* @property {string} [serverId]
|
|
48
|
-
* @property {string} [serverName]
|
|
49
|
-
* @property {string} [command]
|
|
50
|
-
* @property {'allow'|'confirm'|'block'|'event'} [decision]
|
|
51
|
-
* @property {string|null} [policyRule]
|
|
52
|
-
* @property {boolean|null} [approved]
|
|
53
|
-
* @property {number|null} [exitCode]
|
|
54
|
-
* @property {number} [durationMs]
|
|
55
|
-
* @property {boolean} [truncated]
|
|
56
|
-
* @property {string} [event]
|
|
57
|
-
* @property {string} [detail]
|
|
58
|
-
*/
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Non-execution events the spec names (doc §3.4, §4.2.4, §9.6). `rule-disabled`
|
|
62
|
-
* is the event that must be written when a rule id leaves a server's
|
|
63
|
-
* `disabledRules` — i.e. when the rule is switched off (doc §4.2.4).
|
|
64
|
-
* `server-enabled` / `server-disabled` record the enable switch, which applies
|
|
65
|
-
* immediately rather than through the pending queue (enable-switch spec).
|
|
66
|
-
* @typedef {'hostkey-disabled'|'server-deleted'|'rule-disabled'|'server-enabled'|'server-disabled'} AuditEvent
|
|
67
|
-
*/
|
|
68
|
-
|
|
69
|
-
/** Every accepted event name, in one place so the validator and the type agree. */
|
|
70
|
-
const AUDIT_EVENTS = Object.freeze([
|
|
71
|
-
'hostkey-disabled',
|
|
72
|
-
'server-deleted',
|
|
73
|
-
'rule-disabled',
|
|
74
|
-
'server-enabled',
|
|
75
|
-
'server-disabled',
|
|
76
|
-
])
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* What `append()` accepts. Every key except `ts` is optional; `ts` is stamped
|
|
80
|
-
* by the log so callers cannot backdate an entry.
|
|
81
|
-
* @typedef {object} AuditEntry
|
|
82
|
-
* @property {string} [sessionId]
|
|
83
|
-
* @property {string} [serverId]
|
|
84
|
-
* @property {string} [serverName]
|
|
85
|
-
* @property {string} [command]
|
|
86
|
-
* @property {'allow'|'confirm'|'block'} [decision]
|
|
87
|
-
* @property {string|null} [policyRule]
|
|
88
|
-
* @property {boolean|null} [approved]
|
|
89
|
-
* @property {number|null} [exitCode]
|
|
90
|
-
* @property {number} [durationMs]
|
|
91
|
-
* @property {boolean} [truncated]
|
|
92
|
-
* @property {AuditEvent} [event]
|
|
93
|
-
* @property {string} [detail]
|
|
94
|
-
*/
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Machine-readable audit failure. `INVALID_AUDIT` marks a malformed call (bad
|
|
98
|
-
* record, bad rotation limit); nothing is written when it is thrown.
|
|
99
|
-
* @typedef {'INVALID_AUDIT'} AuditErrorCode
|
|
100
|
-
*/
|
|
101
|
-
export class AuditError extends Error {
|
|
102
|
-
/**
|
|
103
|
-
* @param {AuditErrorCode} code
|
|
104
|
-
* @param {string} message
|
|
105
|
-
*/
|
|
106
|
-
constructor(code, message) {
|
|
107
|
-
super(message)
|
|
108
|
-
this.name = 'AuditError'
|
|
109
|
-
/** @type {AuditErrorCode} */
|
|
110
|
-
this.code = code
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Default location of `audit.log` (doc §3.5).
|
|
116
|
-
* @param {NodeJS.ProcessEnv} [env]
|
|
117
|
-
* @returns {string}
|
|
118
|
-
*/
|
|
119
|
-
export function defaultAuditFile(env = process.env) {
|
|
120
|
-
return join(connectorDirectory(env), AUDIT_FILE_NAME)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* @param {unknown} value
|
|
125
|
-
* @returns {value is Record<string, unknown>}
|
|
126
|
-
*/
|
|
127
|
-
function isPlainObject(value) {
|
|
128
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* @param {unknown} error
|
|
133
|
-
* @param {string} code
|
|
134
|
-
* @returns {boolean}
|
|
135
|
-
*/
|
|
136
|
-
function isErrno(error, code) {
|
|
137
|
-
return isPlainObject(error) && error.code === code
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Sequence one record's keys in the order of doc §3.4 instead of insertion
|
|
142
|
-
* order, so the file stays readable and greppable in a stable shape.
|
|
143
|
-
* @param {AuditRecord} record
|
|
144
|
-
* @returns {string}
|
|
145
|
-
*/
|
|
146
|
-
function serialize(record) {
|
|
147
|
-
/** @type {Record<string, unknown>} */
|
|
148
|
-
const ordered = { ts: record.ts }
|
|
149
|
-
for (const key of [
|
|
150
|
-
'event',
|
|
151
|
-
'detail',
|
|
152
|
-
'sessionId',
|
|
153
|
-
'serverId',
|
|
154
|
-
'serverName',
|
|
155
|
-
'command',
|
|
156
|
-
'decision',
|
|
157
|
-
'policyRule',
|
|
158
|
-
'approved',
|
|
159
|
-
'exitCode',
|
|
160
|
-
'durationMs',
|
|
161
|
-
'truncated',
|
|
162
|
-
]) {
|
|
163
|
-
const value = /** @type {Record<string, unknown>} */ (record)[key]
|
|
164
|
-
if (value !== undefined) ordered[key] = value
|
|
165
|
-
}
|
|
166
|
-
return `${JSON.stringify(ordered)}\n`
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Build a validated record from a caller's entry. Shorthands that the spec
|
|
171
|
-
* fixes are applied here so a block record cannot be written wrongly:
|
|
172
|
-
* a `decision: "block"` line always carries `approved: null` and
|
|
173
|
-
* `exitCode: null` (doc §3.4).
|
|
174
|
-
* @param {AuditEntry} entry
|
|
175
|
-
* @param {Date} now
|
|
176
|
-
* @returns {AuditRecord}
|
|
177
|
-
*/
|
|
178
|
-
function buildRecord(entry, now) {
|
|
179
|
-
if (!isPlainObject(entry)) throw new AuditError('INVALID_AUDIT', 'audit entry must be an object.')
|
|
180
|
-
const isEvent = entry.event !== undefined
|
|
181
|
-
if (isEvent) {
|
|
182
|
-
if (!AUDIT_EVENTS.includes(/** @type {any} */ (entry.event))) {
|
|
183
|
-
throw new AuditError(
|
|
184
|
-
'INVALID_AUDIT',
|
|
185
|
-
`event must be one of ${AUDIT_EVENTS.join(', ')}.`,
|
|
186
|
-
)
|
|
187
|
-
}
|
|
188
|
-
if (entry.decision !== undefined) {
|
|
189
|
-
throw new AuditError('INVALID_AUDIT', 'an event record carries no exec decision.')
|
|
190
|
-
}
|
|
191
|
-
} else if (entry.decision === undefined) {
|
|
192
|
-
// Never default to "allow": a forgotten decision would then be recorded as
|
|
193
|
-
// an allowed execution, which is exactly the mislabelling an audit log must
|
|
194
|
-
// not produce.
|
|
195
|
-
throw new AuditError('INVALID_AUDIT', 'decision must be allow, confirm or block (or provide event).')
|
|
196
|
-
} else if (entry.decision !== 'allow' && entry.decision !== 'confirm' && entry.decision !== 'block') {
|
|
197
|
-
throw new AuditError('INVALID_AUDIT', 'decision must be allow, confirm or block (or provide event).')
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** @type {AuditRecord} */
|
|
201
|
-
const record = { ts: now.toISOString() }
|
|
202
|
-
if (isEvent) {
|
|
203
|
-
record.event = /** @type {AuditEvent} */ (entry.event)
|
|
204
|
-
if (entry.detail !== undefined) {
|
|
205
|
-
if (typeof entry.detail !== 'string') throw new AuditError('INVALID_AUDIT', 'detail must be a string.')
|
|
206
|
-
record.detail = entry.detail
|
|
207
|
-
}
|
|
208
|
-
} else {
|
|
209
|
-
record.decision = /** @type {'allow'|'confirm'|'block'} */ (entry.decision)
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
for (const field of /** @type {const} */ (['sessionId', 'serverId', 'serverName', 'command'])) {
|
|
213
|
-
const value = entry[field]
|
|
214
|
-
if (value === undefined) continue
|
|
215
|
-
if (typeof value !== 'string') throw new AuditError('INVALID_AUDIT', `${field} must be a string.`)
|
|
216
|
-
record[field] = value
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (entry.policyRule !== undefined) {
|
|
220
|
-
if (entry.policyRule !== null && typeof entry.policyRule !== 'string') {
|
|
221
|
-
throw new AuditError('INVALID_AUDIT', 'policyRule must be a string or null.')
|
|
222
|
-
}
|
|
223
|
-
record.policyRule = entry.policyRule
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const isBlock = !isEvent && record.decision === 'block'
|
|
227
|
-
if (isBlock) {
|
|
228
|
-
// Doc §3.4: a blocked command never ran, so approval and exit code are null
|
|
229
|
-
// no matter what a caller passed. Timing/output fields are dropped for the
|
|
230
|
-
// same reason: there is no execution to describe.
|
|
231
|
-
record.approved = null
|
|
232
|
-
record.exitCode = null
|
|
233
|
-
} else if (!isEvent) {
|
|
234
|
-
if (entry.approved !== undefined) {
|
|
235
|
-
if (entry.approved !== null && typeof entry.approved !== 'boolean') {
|
|
236
|
-
throw new AuditError('INVALID_AUDIT', 'approved must be a boolean or null.')
|
|
237
|
-
}
|
|
238
|
-
record.approved = entry.approved
|
|
239
|
-
}
|
|
240
|
-
if (entry.exitCode !== undefined) {
|
|
241
|
-
if (entry.exitCode !== null && !(typeof entry.exitCode === 'number' && Number.isInteger(entry.exitCode))) {
|
|
242
|
-
throw new AuditError('INVALID_AUDIT', 'exitCode must be an integer or null.')
|
|
243
|
-
}
|
|
244
|
-
record.exitCode = entry.exitCode
|
|
245
|
-
}
|
|
246
|
-
if (entry.durationMs !== undefined) {
|
|
247
|
-
if (typeof entry.durationMs !== 'number' || !Number.isFinite(entry.durationMs) || entry.durationMs < 0) {
|
|
248
|
-
throw new AuditError('INVALID_AUDIT', 'durationMs must be a non-negative number.')
|
|
249
|
-
}
|
|
250
|
-
record.durationMs = entry.durationMs
|
|
251
|
-
}
|
|
252
|
-
if (entry.truncated !== undefined) {
|
|
253
|
-
if (typeof entry.truncated !== 'boolean') {
|
|
254
|
-
throw new AuditError('INVALID_AUDIT', 'truncated must be a boolean.')
|
|
255
|
-
}
|
|
256
|
-
record.truncated = entry.truncated
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return record
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Split a byte window into complete JSONL records, dropping a partial first
|
|
264
|
-
* line (the window started mid-record) and a partial last line (the record is
|
|
265
|
-
* still being appended). Malformed complete lines are skipped rather than
|
|
266
|
-
* aborting the whole read: a damaged log must not make the panel unusable.
|
|
267
|
-
* @param {string} text
|
|
268
|
-
* @param {boolean} windowStartsAtFileStart
|
|
269
|
-
* @returns {AuditRecord[]}
|
|
270
|
-
*/
|
|
271
|
-
function parseLines(text, windowStartsAtFileStart) {
|
|
272
|
-
const lines = text.split('\n')
|
|
273
|
-
// The window starts mid-record unless it begins at offset 0.
|
|
274
|
-
if (!windowStartsAtFileStart) lines.shift()
|
|
275
|
-
// The final element is either "" (the text ended with "\n") or an
|
|
276
|
-
// unterminated fragment still being appended; neither is a whole record.
|
|
277
|
-
lines.pop()
|
|
278
|
-
/** @type {AuditRecord[]} */
|
|
279
|
-
const records = []
|
|
280
|
-
for (const line of lines) {
|
|
281
|
-
if (line.trim() === '') continue
|
|
282
|
-
try {
|
|
283
|
-
const parsed = JSON.parse(line)
|
|
284
|
-
if (isPlainObject(parsed)) records.push(/** @type {AuditRecord} */ (parsed))
|
|
285
|
-
} catch {
|
|
286
|
-
// Skip a truncated or hand-edited line; the rest of the log is still valid.
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
return records
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Append-only audit log with bounded tail reads and optional single rotation.
|
|
294
|
-
*
|
|
295
|
-
* The log is disabled by `auditEnabled = false` (doc §10): the same methods
|
|
296
|
-
* still work, but {@link AuditLog.append} writes nothing and reports
|
|
297
|
-
* `enabled: false`, which is exactly what the caller must not treat as an
|
|
298
|
-
* error — the command itself still runs.
|
|
299
|
-
*/
|
|
300
|
-
export class AuditLog {
|
|
301
|
-
/** @type {string} */
|
|
302
|
-
#file
|
|
303
|
-
/** @type {boolean} */
|
|
304
|
-
#enabled
|
|
305
|
-
/** @type {number} */
|
|
306
|
-
#maxBytes
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* @param {{file?: string, enabled?: boolean, maxBytes?: number}} [options]
|
|
310
|
-
* `file` defaults to `$DSH_HOME/server-connector/audit.log`; `enabled`
|
|
311
|
-
* mirrors `auditEnabled` and `maxBytes` mirrors `auditMaxBytes` (0 = never
|
|
312
|
-
* rotate).
|
|
313
|
-
*/
|
|
314
|
-
constructor(options = {}) {
|
|
315
|
-
this.#file = options.file ?? defaultAuditFile()
|
|
316
|
-
this.#enabled = options.enabled ?? true
|
|
317
|
-
const maxBytes = options.maxBytes ?? 0
|
|
318
|
-
if (!(typeof maxBytes === 'number' && Number.isSafeInteger(maxBytes) && maxBytes >= 0)) {
|
|
319
|
-
throw new AuditError('INVALID_AUDIT', 'maxBytes must be a non-negative integer (0 = never rotate).')
|
|
320
|
-
}
|
|
321
|
-
this.#maxBytes = maxBytes
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/** Absolute path of the log this instance appends to. */
|
|
325
|
-
get file() {
|
|
326
|
-
return this.#file
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/** Whether appends are recorded (`auditEnabled`). */
|
|
330
|
-
get enabled() {
|
|
331
|
-
return this.#enabled
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/** Rotation threshold in bytes; 0 means the log is never rotated. */
|
|
335
|
-
get maxBytes() {
|
|
336
|
-
return this.#maxBytes
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
* @param {
|
|
346
|
-
* @returns {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
1
|
+
// dsh-server-connector — execution audit log (design doc §3.4/§6.2).
|
|
2
|
+
//
|
|
3
|
+
// One JSON object per line, appended to `$DSH_HOME/server-connector/audit.log`.
|
|
4
|
+
// The module only appends and reads the tail: it never participates in a
|
|
5
|
+
// decision, never rewrites an earlier line, and never loads the whole file to
|
|
6
|
+
// answer a tail(limit) call.
|
|
7
|
+
//
|
|
8
|
+
// Record shapes (doc §3.4):
|
|
9
|
+
// exec: { ts, sessionId, serverId, serverName, command, decision,
|
|
10
|
+
// policyRule, approved, exitCode, durationMs, truncated }
|
|
11
|
+
// block: an exec record with decision="block", approved=null, exitCode=null
|
|
12
|
+
// event: { ts, event, serverId, serverName, detail? } for non-execution
|
|
13
|
+
// events such as hostkey-disabled / server-deleted / rule-disabled
|
|
14
|
+
//
|
|
15
|
+
// Rotation (doc §6.2): with `auditMaxBytes > 0`, the log is rotated once when
|
|
16
|
+
// the next append would exceed that size. The current file becomes
|
|
17
|
+
// `audit.log.1` and a fresh `audit.log` starts; a second threshold hit
|
|
18
|
+
// overwrites `audit.log.1`, so at most two files of bounded size exist.
|
|
19
|
+
|
|
20
|
+
import { mkdir, open, rename, stat, writeFile } from 'node:fs/promises'
|
|
21
|
+
import { dirname, join, resolve } from 'node:path'
|
|
22
|
+
|
|
23
|
+
import { connectorDirectory } from './registry.js'
|
|
24
|
+
|
|
25
|
+
/** File name of the audit log. */
|
|
26
|
+
export const AUDIT_FILE_NAME = 'audit.log'
|
|
27
|
+
|
|
28
|
+
/** Suffix of the single rotated file. */
|
|
29
|
+
export const AUDIT_ROTATED_SUFFIX = '.1'
|
|
30
|
+
|
|
31
|
+
/** Byte size of the read window when tail() has to grow to satisfy `limit`. */
|
|
32
|
+
const INITIAL_TAIL_WINDOW = 64 * 1024
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One tail promise per log file: two AuditLog instances (tools and routes, say)
|
|
36
|
+
* must not interleave a line or race the rotation check.
|
|
37
|
+
* @type {Map<string, Promise<void>>}
|
|
38
|
+
*/
|
|
39
|
+
const APPEND_QUEUES = new Map()
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One audit record, exactly as persisted. Exec records, block records and
|
|
43
|
+
* event records share this wrapper; `event` is present only on events.
|
|
44
|
+
* @typedef {object} AuditRecord
|
|
45
|
+
* @property {string} ts - ISO-8601 timestamp.
|
|
46
|
+
* @property {string} [sessionId]
|
|
47
|
+
* @property {string} [serverId]
|
|
48
|
+
* @property {string} [serverName]
|
|
49
|
+
* @property {string} [command]
|
|
50
|
+
* @property {'allow'|'confirm'|'block'|'event'} [decision]
|
|
51
|
+
* @property {string|null} [policyRule]
|
|
52
|
+
* @property {boolean|null} [approved]
|
|
53
|
+
* @property {number|null} [exitCode]
|
|
54
|
+
* @property {number} [durationMs]
|
|
55
|
+
* @property {boolean} [truncated]
|
|
56
|
+
* @property {string} [event]
|
|
57
|
+
* @property {string} [detail]
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Non-execution events the spec names (doc §3.4, §4.2.4, §9.6). `rule-disabled`
|
|
62
|
+
* is the event that must be written when a rule id leaves a server's
|
|
63
|
+
* `disabledRules` — i.e. when the rule is switched off (doc §4.2.4).
|
|
64
|
+
* `server-enabled` / `server-disabled` record the enable switch, which applies
|
|
65
|
+
* immediately rather than through the pending queue (enable-switch spec).
|
|
66
|
+
* @typedef {'hostkey-disabled'|'server-deleted'|'rule-disabled'|'server-enabled'|'server-disabled'} AuditEvent
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/** Every accepted event name, in one place so the validator and the type agree. */
|
|
70
|
+
const AUDIT_EVENTS = Object.freeze([
|
|
71
|
+
'hostkey-disabled',
|
|
72
|
+
'server-deleted',
|
|
73
|
+
'rule-disabled',
|
|
74
|
+
'server-enabled',
|
|
75
|
+
'server-disabled',
|
|
76
|
+
])
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What `append()` accepts. Every key except `ts` is optional; `ts` is stamped
|
|
80
|
+
* by the log so callers cannot backdate an entry.
|
|
81
|
+
* @typedef {object} AuditEntry
|
|
82
|
+
* @property {string} [sessionId]
|
|
83
|
+
* @property {string} [serverId]
|
|
84
|
+
* @property {string} [serverName]
|
|
85
|
+
* @property {string} [command]
|
|
86
|
+
* @property {'allow'|'confirm'|'block'} [decision]
|
|
87
|
+
* @property {string|null} [policyRule]
|
|
88
|
+
* @property {boolean|null} [approved]
|
|
89
|
+
* @property {number|null} [exitCode]
|
|
90
|
+
* @property {number} [durationMs]
|
|
91
|
+
* @property {boolean} [truncated]
|
|
92
|
+
* @property {AuditEvent} [event]
|
|
93
|
+
* @property {string} [detail]
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Machine-readable audit failure. `INVALID_AUDIT` marks a malformed call (bad
|
|
98
|
+
* record, bad rotation limit); nothing is written when it is thrown.
|
|
99
|
+
* @typedef {'INVALID_AUDIT'} AuditErrorCode
|
|
100
|
+
*/
|
|
101
|
+
export class AuditError extends Error {
|
|
102
|
+
/**
|
|
103
|
+
* @param {AuditErrorCode} code
|
|
104
|
+
* @param {string} message
|
|
105
|
+
*/
|
|
106
|
+
constructor(code, message) {
|
|
107
|
+
super(message)
|
|
108
|
+
this.name = 'AuditError'
|
|
109
|
+
/** @type {AuditErrorCode} */
|
|
110
|
+
this.code = code
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Default location of `audit.log` (doc §3.5).
|
|
116
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function defaultAuditFile(env = process.env) {
|
|
120
|
+
return join(connectorDirectory(env), AUDIT_FILE_NAME)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {unknown} value
|
|
125
|
+
* @returns {value is Record<string, unknown>}
|
|
126
|
+
*/
|
|
127
|
+
function isPlainObject(value) {
|
|
128
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {unknown} error
|
|
133
|
+
* @param {string} code
|
|
134
|
+
* @returns {boolean}
|
|
135
|
+
*/
|
|
136
|
+
function isErrno(error, code) {
|
|
137
|
+
return isPlainObject(error) && error.code === code
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Sequence one record's keys in the order of doc §3.4 instead of insertion
|
|
142
|
+
* order, so the file stays readable and greppable in a stable shape.
|
|
143
|
+
* @param {AuditRecord} record
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
function serialize(record) {
|
|
147
|
+
/** @type {Record<string, unknown>} */
|
|
148
|
+
const ordered = { ts: record.ts }
|
|
149
|
+
for (const key of [
|
|
150
|
+
'event',
|
|
151
|
+
'detail',
|
|
152
|
+
'sessionId',
|
|
153
|
+
'serverId',
|
|
154
|
+
'serverName',
|
|
155
|
+
'command',
|
|
156
|
+
'decision',
|
|
157
|
+
'policyRule',
|
|
158
|
+
'approved',
|
|
159
|
+
'exitCode',
|
|
160
|
+
'durationMs',
|
|
161
|
+
'truncated',
|
|
162
|
+
]) {
|
|
163
|
+
const value = /** @type {Record<string, unknown>} */ (record)[key]
|
|
164
|
+
if (value !== undefined) ordered[key] = value
|
|
165
|
+
}
|
|
166
|
+
return `${JSON.stringify(ordered)}\n`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Build a validated record from a caller's entry. Shorthands that the spec
|
|
171
|
+
* fixes are applied here so a block record cannot be written wrongly:
|
|
172
|
+
* a `decision: "block"` line always carries `approved: null` and
|
|
173
|
+
* `exitCode: null` (doc §3.4).
|
|
174
|
+
* @param {AuditEntry} entry
|
|
175
|
+
* @param {Date} now
|
|
176
|
+
* @returns {AuditRecord}
|
|
177
|
+
*/
|
|
178
|
+
function buildRecord(entry, now) {
|
|
179
|
+
if (!isPlainObject(entry)) throw new AuditError('INVALID_AUDIT', 'audit entry must be an object.')
|
|
180
|
+
const isEvent = entry.event !== undefined
|
|
181
|
+
if (isEvent) {
|
|
182
|
+
if (!AUDIT_EVENTS.includes(/** @type {any} */ (entry.event))) {
|
|
183
|
+
throw new AuditError(
|
|
184
|
+
'INVALID_AUDIT',
|
|
185
|
+
`event must be one of ${AUDIT_EVENTS.join(', ')}.`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
if (entry.decision !== undefined) {
|
|
189
|
+
throw new AuditError('INVALID_AUDIT', 'an event record carries no exec decision.')
|
|
190
|
+
}
|
|
191
|
+
} else if (entry.decision === undefined) {
|
|
192
|
+
// Never default to "allow": a forgotten decision would then be recorded as
|
|
193
|
+
// an allowed execution, which is exactly the mislabelling an audit log must
|
|
194
|
+
// not produce.
|
|
195
|
+
throw new AuditError('INVALID_AUDIT', 'decision must be allow, confirm or block (or provide event).')
|
|
196
|
+
} else if (entry.decision !== 'allow' && entry.decision !== 'confirm' && entry.decision !== 'block') {
|
|
197
|
+
throw new AuditError('INVALID_AUDIT', 'decision must be allow, confirm or block (or provide event).')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** @type {AuditRecord} */
|
|
201
|
+
const record = { ts: now.toISOString() }
|
|
202
|
+
if (isEvent) {
|
|
203
|
+
record.event = /** @type {AuditEvent} */ (entry.event)
|
|
204
|
+
if (entry.detail !== undefined) {
|
|
205
|
+
if (typeof entry.detail !== 'string') throw new AuditError('INVALID_AUDIT', 'detail must be a string.')
|
|
206
|
+
record.detail = entry.detail
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
record.decision = /** @type {'allow'|'confirm'|'block'} */ (entry.decision)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const field of /** @type {const} */ (['sessionId', 'serverId', 'serverName', 'command'])) {
|
|
213
|
+
const value = entry[field]
|
|
214
|
+
if (value === undefined) continue
|
|
215
|
+
if (typeof value !== 'string') throw new AuditError('INVALID_AUDIT', `${field} must be a string.`)
|
|
216
|
+
record[field] = value
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (entry.policyRule !== undefined) {
|
|
220
|
+
if (entry.policyRule !== null && typeof entry.policyRule !== 'string') {
|
|
221
|
+
throw new AuditError('INVALID_AUDIT', 'policyRule must be a string or null.')
|
|
222
|
+
}
|
|
223
|
+
record.policyRule = entry.policyRule
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const isBlock = !isEvent && record.decision === 'block'
|
|
227
|
+
if (isBlock) {
|
|
228
|
+
// Doc §3.4: a blocked command never ran, so approval and exit code are null
|
|
229
|
+
// no matter what a caller passed. Timing/output fields are dropped for the
|
|
230
|
+
// same reason: there is no execution to describe.
|
|
231
|
+
record.approved = null
|
|
232
|
+
record.exitCode = null
|
|
233
|
+
} else if (!isEvent) {
|
|
234
|
+
if (entry.approved !== undefined) {
|
|
235
|
+
if (entry.approved !== null && typeof entry.approved !== 'boolean') {
|
|
236
|
+
throw new AuditError('INVALID_AUDIT', 'approved must be a boolean or null.')
|
|
237
|
+
}
|
|
238
|
+
record.approved = entry.approved
|
|
239
|
+
}
|
|
240
|
+
if (entry.exitCode !== undefined) {
|
|
241
|
+
if (entry.exitCode !== null && !(typeof entry.exitCode === 'number' && Number.isInteger(entry.exitCode))) {
|
|
242
|
+
throw new AuditError('INVALID_AUDIT', 'exitCode must be an integer or null.')
|
|
243
|
+
}
|
|
244
|
+
record.exitCode = entry.exitCode
|
|
245
|
+
}
|
|
246
|
+
if (entry.durationMs !== undefined) {
|
|
247
|
+
if (typeof entry.durationMs !== 'number' || !Number.isFinite(entry.durationMs) || entry.durationMs < 0) {
|
|
248
|
+
throw new AuditError('INVALID_AUDIT', 'durationMs must be a non-negative number.')
|
|
249
|
+
}
|
|
250
|
+
record.durationMs = entry.durationMs
|
|
251
|
+
}
|
|
252
|
+
if (entry.truncated !== undefined) {
|
|
253
|
+
if (typeof entry.truncated !== 'boolean') {
|
|
254
|
+
throw new AuditError('INVALID_AUDIT', 'truncated must be a boolean.')
|
|
255
|
+
}
|
|
256
|
+
record.truncated = entry.truncated
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return record
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Split a byte window into complete JSONL records, dropping a partial first
|
|
264
|
+
* line (the window started mid-record) and a partial last line (the record is
|
|
265
|
+
* still being appended). Malformed complete lines are skipped rather than
|
|
266
|
+
* aborting the whole read: a damaged log must not make the panel unusable.
|
|
267
|
+
* @param {string} text
|
|
268
|
+
* @param {boolean} windowStartsAtFileStart
|
|
269
|
+
* @returns {AuditRecord[]}
|
|
270
|
+
*/
|
|
271
|
+
function parseLines(text, windowStartsAtFileStart) {
|
|
272
|
+
const lines = text.split('\n')
|
|
273
|
+
// The window starts mid-record unless it begins at offset 0.
|
|
274
|
+
if (!windowStartsAtFileStart) lines.shift()
|
|
275
|
+
// The final element is either "" (the text ended with "\n") or an
|
|
276
|
+
// unterminated fragment still being appended; neither is a whole record.
|
|
277
|
+
lines.pop()
|
|
278
|
+
/** @type {AuditRecord[]} */
|
|
279
|
+
const records = []
|
|
280
|
+
for (const line of lines) {
|
|
281
|
+
if (line.trim() === '') continue
|
|
282
|
+
try {
|
|
283
|
+
const parsed = JSON.parse(line)
|
|
284
|
+
if (isPlainObject(parsed)) records.push(/** @type {AuditRecord} */ (parsed))
|
|
285
|
+
} catch {
|
|
286
|
+
// Skip a truncated or hand-edited line; the rest of the log is still valid.
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return records
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Append-only audit log with bounded tail reads and optional single rotation.
|
|
294
|
+
*
|
|
295
|
+
* The log is disabled by `auditEnabled = false` (doc §10): the same methods
|
|
296
|
+
* still work, but {@link AuditLog.append} writes nothing and reports
|
|
297
|
+
* `enabled: false`, which is exactly what the caller must not treat as an
|
|
298
|
+
* error — the command itself still runs.
|
|
299
|
+
*/
|
|
300
|
+
export class AuditLog {
|
|
301
|
+
/** @type {string} */
|
|
302
|
+
#file
|
|
303
|
+
/** @type {boolean} */
|
|
304
|
+
#enabled
|
|
305
|
+
/** @type {number} */
|
|
306
|
+
#maxBytes
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* @param {{file?: string, enabled?: boolean, maxBytes?: number}} [options]
|
|
310
|
+
* `file` defaults to `$DSH_HOME/server-connector/audit.log`; `enabled`
|
|
311
|
+
* mirrors `auditEnabled` and `maxBytes` mirrors `auditMaxBytes` (0 = never
|
|
312
|
+
* rotate).
|
|
313
|
+
*/
|
|
314
|
+
constructor(options = {}) {
|
|
315
|
+
this.#file = options.file ?? defaultAuditFile()
|
|
316
|
+
this.#enabled = options.enabled ?? true
|
|
317
|
+
const maxBytes = options.maxBytes ?? 0
|
|
318
|
+
if (!(typeof maxBytes === 'number' && Number.isSafeInteger(maxBytes) && maxBytes >= 0)) {
|
|
319
|
+
throw new AuditError('INVALID_AUDIT', 'maxBytes must be a non-negative integer (0 = never rotate).')
|
|
320
|
+
}
|
|
321
|
+
this.#maxBytes = maxBytes
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Absolute path of the log this instance appends to. */
|
|
325
|
+
get file() {
|
|
326
|
+
return this.#file
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Whether appends are recorded (`auditEnabled`). */
|
|
330
|
+
get enabled() {
|
|
331
|
+
return this.#enabled
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Rotation threshold in bytes; 0 means the log is never rotated. */
|
|
335
|
+
get maxBytes() {
|
|
336
|
+
return this.#maxBytes
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Apply an edited global configuration (doc §10, live settings).
|
|
341
|
+
*
|
|
342
|
+
* The audit log is a plain file handle plus two flags, so an edit lands in
|
|
343
|
+
* place: turning auditing off must stop *appending* without losing the file,
|
|
344
|
+
* and the rotation threshold is read on the next append anyway.
|
|
345
|
+
* @param {{enabled?: boolean, maxBytes?: number}} values
|
|
346
|
+
* @returns {void}
|
|
347
|
+
*/
|
|
348
|
+
configure(values) {
|
|
349
|
+
if (typeof values?.enabled === 'boolean') this.#enabled = values.enabled
|
|
350
|
+
if (typeof values?.maxBytes === 'number') this.#maxBytes = values.maxBytes
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Append one record. The call resolves only after the line reached the file.
|
|
355
|
+
*
|
|
356
|
+
* A block record is normalised to `approved: null, exitCode: null`; an event
|
|
357
|
+
* record carries `event` instead of an exec decision. Records are serialised
|
|
358
|
+
* per log file so two concurrent appends cannot interleave inside one line.
|
|
359
|
+
* @param {AuditEntry} entry
|
|
360
|
+
* @returns {Promise<{written: boolean, enabled: boolean, rotated: boolean}>}
|
|
361
|
+
* @throws {AuditError} for a malformed record, before anything is written.
|
|
362
|
+
*/
|
|
363
|
+
async append(entry) {
|
|
364
|
+
const now = new Date()
|
|
365
|
+
const record = buildRecord(entry, now)
|
|
366
|
+
const line = serialize(record)
|
|
367
|
+
if (!this.#enabled) return { written: false, enabled: false, rotated: false }
|
|
368
|
+
return this.#queued(async () => {
|
|
369
|
+
const rotated = await this.#rotateIfNeeded(Buffer.byteLength(line, 'utf8'))
|
|
370
|
+
await mkdir(dirname(this.#file), { recursive: true, mode: 0o700 })
|
|
371
|
+
await writeFile(this.#file, line, { encoding: 'utf8', flag: 'a', mode: 0o600 })
|
|
372
|
+
return { written: true, enabled: true, rotated }
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Read the last `limit` records, oldest first, without loading the file.
|
|
378
|
+
*
|
|
379
|
+
* The read starts from a growing window at the end of the file, so a 1 GB
|
|
380
|
+
* log costs one bounded read for a 20-record tail. A missing file is an
|
|
381
|
+
* empty log. Records are returned exactly as stored, newest last.
|
|
382
|
+
* @param {number} [limit] - how many records to return; defaults to 20.
|
|
383
|
+
* @returns {Promise<AuditRecord[]>}
|
|
384
|
+
* @throws {AuditError} when `limit` is not a positive integer.
|
|
385
|
+
*/
|
|
386
|
+
async tail(limit = 20) {
|
|
387
|
+
if (!(typeof limit === 'number' && Number.isSafeInteger(limit) && limit > 0)) {
|
|
388
|
+
throw new AuditError('INVALID_AUDIT', 'limit must be a positive integer.')
|
|
389
|
+
}
|
|
390
|
+
/** @type {import('node:fs/promises').FileHandle} */
|
|
391
|
+
let handle
|
|
392
|
+
try {
|
|
393
|
+
handle = await open(this.#file, 'r')
|
|
394
|
+
} catch (error) {
|
|
395
|
+
if (isErrno(error, 'ENOENT')) return []
|
|
396
|
+
throw error
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
const { size } = await handle.stat()
|
|
400
|
+
if (size === 0) return []
|
|
401
|
+
let window = Math.min(size, Math.max(INITIAL_TAIL_WINDOW, limit * 256))
|
|
402
|
+
/** @type {AuditRecord[]} */
|
|
403
|
+
let records = []
|
|
404
|
+
for (;;) {
|
|
405
|
+
const start = Math.max(0, size - window)
|
|
406
|
+
const buffer = Buffer.alloc(size - start)
|
|
407
|
+
// FileHandle.read may return short; keep reading until the window is full.
|
|
408
|
+
let filled = 0
|
|
409
|
+
while (filled < buffer.length) {
|
|
410
|
+
const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, start + filled)
|
|
411
|
+
if (bytesRead === 0) break
|
|
412
|
+
filled += bytesRead
|
|
413
|
+
}
|
|
414
|
+
records = parseLines(buffer.subarray(0, filled).toString('utf8'), start === 0)
|
|
415
|
+
if (records.length >= limit || start === 0) break
|
|
416
|
+
window *= 4
|
|
417
|
+
}
|
|
418
|
+
return records.slice(-limit)
|
|
419
|
+
} finally {
|
|
420
|
+
await handle.close()
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Rotate the log when the next append would pass `maxBytes` (doc §6.2).
|
|
426
|
+
* Only one rotation is ever kept: the current file replaces `audit.log.1`.
|
|
427
|
+
* @param {number} incomingBytes
|
|
428
|
+
* @returns {Promise<boolean>} whether a rotation happened.
|
|
429
|
+
*/
|
|
430
|
+
async #rotateIfNeeded(incomingBytes) {
|
|
431
|
+
if (this.#maxBytes <= 0) return false
|
|
432
|
+
let size
|
|
433
|
+
try {
|
|
434
|
+
size = (await stat(this.#file)).size
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (isErrno(error, 'ENOENT')) return false
|
|
437
|
+
throw error
|
|
438
|
+
}
|
|
439
|
+
if (size === 0 || size + incomingBytes <= this.#maxBytes) return false
|
|
440
|
+
const rotated = `${this.#file}${AUDIT_ROTATED_SUFFIX}`
|
|
441
|
+
try {
|
|
442
|
+
await rename(this.#file, rotated)
|
|
443
|
+
} catch (error) {
|
|
444
|
+
if (!isErrno(error, 'ENOENT')) throw error
|
|
445
|
+
}
|
|
446
|
+
return true
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Run an append after every earlier append to the same file finished.
|
|
451
|
+
* @template T
|
|
452
|
+
* @param {() => Promise<T>} operation
|
|
453
|
+
* @returns {Promise<T>}
|
|
454
|
+
*/
|
|
455
|
+
async #queued(operation) {
|
|
456
|
+
const key = resolve(this.#file)
|
|
457
|
+
const previous = APPEND_QUEUES.get(key) ?? Promise.resolve()
|
|
458
|
+
const running = previous.then(operation)
|
|
459
|
+
const settled = running.then(
|
|
460
|
+
() => undefined,
|
|
461
|
+
() => undefined,
|
|
462
|
+
)
|
|
463
|
+
APPEND_QUEUES.set(key, settled)
|
|
464
|
+
void settled.then(() => {
|
|
465
|
+
if (APPEND_QUEUES.get(key) === settled) APPEND_QUEUES.delete(key)
|
|
466
|
+
})
|
|
467
|
+
return running
|
|
468
|
+
}
|
|
469
|
+
}
|