@wangxiaocuo/dsh-session-trash 0.0.0-stage → 0.5.1

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/lib/errors.js ADDED
@@ -0,0 +1,91 @@
1
+ import path from 'node:path'
2
+
3
+ /**
4
+ * Error codes shared by the Host API and the Client UI, with their HTTP status.
5
+ * `null` marks a startup-only failure that never reaches HTTP.
6
+ */
7
+ export const ERROR_STATUS = {
8
+ INVALID_REQUEST: 400,
9
+ FORBIDDEN: 403,
10
+ METHOD_NOT_ALLOWED: 405,
11
+ SESSION_NOT_FOUND: 404,
12
+ SESSION_LIVE: 409,
13
+ SESSION_ACTIVE: 409,
14
+ SESSION_SUBAGENT: 409,
15
+ SESSION_HAS_DESCENDANTS: 409,
16
+ SESSION_LINEAGE_UNRESOLVED: 409,
17
+ SESSION_NOT_MATERIALIZED: 409,
18
+ STORAGE_UNSUPPORTED: 409,
19
+ ALREADY_TRASHED: 409,
20
+ TRASH_ENTRY_NOT_FOUND: 404,
21
+ TRASH_STATE_INVALID: 409,
22
+ FORGET_NOT_ALLOWED: 409,
23
+ TRASH_DIR_MISSING: 409,
24
+ RESTORE_TARGET_EXISTS: 409,
25
+ WORKSPACE_MISSING: 409,
26
+ RESTORE_VERIFY_FAILED: 500,
27
+ MOVE_VERIFY_FAILED: 500,
28
+ MOVE_CONFLICT: 409,
29
+ INDEX_WRITE_FAILED: 500,
30
+ INDEX_NOT_CORRUPT: 409,
31
+ UNSAFE_SESSION_DIR: 409,
32
+ UNSAFE_RESTORE_PATH: 409,
33
+ REVEAL_UNAVAILABLE: 409,
34
+ REVEAL_FAILED: 500,
35
+ LOCKED: 409,
36
+ INDEX_CORRUPT: 500,
37
+ IO_ERROR: 500,
38
+ HOME_UNRESOLVED: null,
39
+ }
40
+
41
+ /** A failure with a stable machine code and an owner-facing message. */
42
+ export class TrashError extends Error {
43
+ /**
44
+ * @param {string} code stable error code.
45
+ * @param {string} message owner-facing explanation.
46
+ * @param {object} [details] optional structured context.
47
+ */
48
+ constructor(code, message, details) {
49
+ super(message)
50
+ this.name = 'TrashError'
51
+ this.code = code
52
+ this.details = details
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @param {unknown} error arbitrary thrown value.
58
+ * @returns {number} HTTP status for this error.
59
+ */
60
+ export function statusOf(error) {
61
+ const code = error instanceof TrashError ? error.code : 'IO_ERROR'
62
+ return ERROR_STATUS[code] ?? 500
63
+ }
64
+
65
+ /**
66
+ * @param {unknown} error arbitrary thrown value.
67
+ * @returns {{ code: string, message: string, details?: object }} the wire error shape.
68
+ */
69
+ export function toWireError(error) {
70
+ if (error instanceof TrashError) {
71
+ return {
72
+ code: error.code,
73
+ message: error.message,
74
+ ...(error.details === undefined ? {} : { details: error.details }),
75
+ }
76
+ }
77
+ const message = error instanceof Error ? error.message : String(error)
78
+ return { code: 'IO_ERROR', message }
79
+ }
80
+
81
+ /**
82
+ * @param {string} sessionDir a session-owned directory.
83
+ * @returns {boolean} whether the directory name is a single safe path segment.
84
+ */
85
+ export function isSafeSegment(sessionDir) {
86
+ const base = path.basename(sessionDir)
87
+ if (base === '' || base === '.' || base === '..') return false
88
+ if (base !== sessionDir.split(path.sep).pop()) return false
89
+ if (base.includes('/') || base.includes('\\') || base.includes('\0')) return false
90
+ return path.dirname(sessionDir) !== sessionDir
91
+ }
package/lib/fsops.js ADDED
@@ -0,0 +1,414 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { randomBytes } from 'node:crypto'
4
+ import { TrashError } from './errors.js'
5
+
6
+ /** Kernel lock file the JSONL persistence backend keeps inside a session directory. */
7
+ export const SESSION_LOCK_FILENAME = 'session.lock'
8
+
9
+ /**
10
+ * Error codes that commonly mean “another process is touching this entry right
11
+ * now”. Windows returns `EPERM`/`EACCES`/`EBUSY` for an antivirus scan or an
12
+ * open handle; network filesystems return `EAGAIN`/`EMFILE`/`ENFILE`.
13
+ */
14
+ const TRANSIENT_FS_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'ENOTEMPTY', 'EMFILE', 'ENFILE', 'EAGAIN'])
15
+
16
+ /** Rename attempts before a transient failure is reported. */
17
+ const RENAME_ATTEMPTS = process.platform === 'win32' ? 10 : 3
18
+
19
+ /** Base delay between transient rename attempts. */
20
+ const RENAME_RETRY_DELAY_MS = 50
21
+
22
+ /** Windows device names a directory segment must never become. */
23
+ const WINDOWS_RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
24
+
25
+ /** macOS and Windows default filesystems compare paths case-insensitively. */
26
+ export const CASE_INSENSITIVE_PATHS = process.platform === 'win32' || process.platform === 'darwin'
27
+
28
+ /** Options shared by every directory copy performed for a cross-device move. */
29
+ const COPY_OPTIONS = {
30
+ recursive: true,
31
+ force: false,
32
+ errorOnExist: true,
33
+ dereference: false,
34
+ verbatimSymlinks: true,
35
+ preserveTimestamps: true,
36
+ }
37
+
38
+ /**
39
+ * @param {unknown} error thrown filesystem failure.
40
+ * @returns {string | undefined} the Node error code.
41
+ */
42
+ export function errorCode(error) {
43
+ return error !== null && typeof error === 'object' && typeof error.code === 'string' ? error.code : undefined
44
+ }
45
+
46
+ /**
47
+ * @param {number} milliseconds delay length.
48
+ * @returns {Promise<void>} resolution after the delay.
49
+ */
50
+ function sleep(milliseconds) {
51
+ return new Promise((resolve) => {
52
+ setTimeout(resolve, milliseconds)
53
+ })
54
+ }
55
+
56
+ /**
57
+ * Retry an idempotent filesystem operation for the short window in which
58
+ * another process holds the entry.
59
+ *
60
+ * @param {() => Promise<T>} operation filesystem operation to retry.
61
+ * @param {{ attempts?: number, delayMs?: number }} [options] retry policy.
62
+ * @returns {Promise<T>} the operation result.
63
+ * @template T
64
+ */
65
+ async function retryTransient(operation, options = {}) {
66
+ const attempts = options.attempts ?? RENAME_ATTEMPTS
67
+ const delayMs = options.delayMs ?? RENAME_RETRY_DELAY_MS
68
+ let lastError
69
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
70
+ try {
71
+ return await operation()
72
+ } catch (error) {
73
+ lastError = error
74
+ if (attempt === attempts - 1 || !TRANSIENT_FS_CODES.has(errorCode(error))) throw error
75
+ await sleep(delayMs * (attempt + 1))
76
+ }
77
+ }
78
+ throw lastError
79
+ }
80
+
81
+ /**
82
+ * Rename one path, retrying the transient sharing violations Windows and
83
+ * network volumes report.
84
+ *
85
+ * @param {string} from current path.
86
+ * @param {string} to target path.
87
+ * @param {{ attempts?: number, delayMs?: number, operation?: () => Promise<void> }} [options] retry policy and test seam.
88
+ * @returns {Promise<void>} resolution once the rename succeeded.
89
+ */
90
+ export function renameWithRetry(from, to, options = {}) {
91
+ return retryTransient(options.operation ?? (() => fs.rename(from, to)), options)
92
+ }
93
+
94
+ /**
95
+ * Remove one file or directory, retrying transient sharing violations. Missing
96
+ * targets are ignored when `force` is set.
97
+ *
98
+ * @param {string} target path to remove.
99
+ * @param {{ recursive?: boolean, force?: boolean, attempts?: number, delayMs?: number, operation?: () => Promise<void> }} [options] removal policy and test seam.
100
+ * @returns {Promise<void>} resolution once the target is gone.
101
+ */
102
+ export function removePathWithRetry(target, options = {}) {
103
+ const recursive = options.recursive ?? true
104
+ const force = options.force ?? true
105
+ return retryTransient(
106
+ options.operation ?? (() => fs.rm(target, { recursive, force, maxRetries: 0 })),
107
+ options,
108
+ )
109
+ }
110
+
111
+ /**
112
+ * Normalize a path for identity comparisons. The comparison is lowercased on
113
+ * platforms whose default filesystem is case-insensitive; the returned path is
114
+ * only ever compared, never used for filesystem access.
115
+ *
116
+ * @param {string} target path to normalize.
117
+ * @param {{ pathImpl?: typeof path, caseInsensitive?: boolean }} [options] path implementation and platform policy.
118
+ * @returns {string} the comparable form.
119
+ */
120
+ export function comparablePath(target, options = {}) {
121
+ const pathImpl = options.pathImpl ?? path
122
+ const caseInsensitive = options.caseInsensitive ?? CASE_INSENSITIVE_PATHS
123
+ const resolved = pathImpl.resolve(target)
124
+ return caseInsensitive ? resolved.toLowerCase() : resolved
125
+ }
126
+
127
+ /**
128
+ * @param {string} left first path.
129
+ * @param {string} right second path.
130
+ * @param {{ pathImpl?: typeof path, caseInsensitive?: boolean }} [options] path implementation and platform policy.
131
+ * @returns {boolean} whether both paths name the same location.
132
+ */
133
+ export function samePath(left, right, options = {}) {
134
+ if (typeof left !== 'string' || typeof right !== 'string') return false
135
+ return comparablePath(left, options) === comparablePath(right, options)
136
+ }
137
+
138
+ /**
139
+ * @param {string} target path to inspect.
140
+ * @returns {Promise<'directory' | 'file' | 'symlink' | 'other' | 'missing'>} the entry type without following symlinks.
141
+ */
142
+ export async function entryKind(target) {
143
+ try {
144
+ const stats = await fs.lstat(target)
145
+ if (stats.isSymbolicLink()) return 'symlink'
146
+ if (stats.isDirectory()) return 'directory'
147
+ if (stats.isFile()) return 'file'
148
+ return 'other'
149
+ } catch (error) {
150
+ if (errorCode(error) === 'ENOENT') return 'missing'
151
+ throw error
152
+ }
153
+ }
154
+
155
+ /**
156
+ * @param {string} target path to inspect.
157
+ * @returns {Promise<boolean>} whether a plain directory exists at `target`.
158
+ */
159
+ export async function directoryExists(target) {
160
+ if (typeof target !== 'string' || target === '') return false
161
+ try {
162
+ const stats = await fs.stat(target)
163
+ return stats.isDirectory()
164
+ } catch {
165
+ return false
166
+ }
167
+ }
168
+
169
+ /**
170
+ * @param {typeof path} pathImpl path implementation.
171
+ * @param {string} root containing directory.
172
+ * @param {string} target path that must stay inside `root`.
173
+ * @param {boolean} caseInsensitive whether this platform compares paths case-insensitively.
174
+ * @returns {boolean} whether `target` is `root` itself or a descendant of it.
175
+ */
176
+ export function isInsideUsing(pathImpl, root, target, caseInsensitive) {
177
+ if (typeof root !== 'string' || typeof target !== 'string') return false
178
+ const left = comparablePath(root, { pathImpl, caseInsensitive })
179
+ const right = comparablePath(target, { pathImpl, caseInsensitive })
180
+ const relative = pathImpl.relative(left, right)
181
+ return (
182
+ relative === '' ||
183
+ (!relative.startsWith(`..${pathImpl.sep}`) && relative !== '..' && !pathImpl.isAbsolute(relative))
184
+ )
185
+ }
186
+
187
+ /**
188
+ * @param {string} root containing directory.
189
+ * @param {string} target path that must stay inside `root`.
190
+ * @returns {boolean} whether `target` is `root` itself or a descendant of it.
191
+ */
192
+ export function isInside(root, target) {
193
+ return isInsideUsing(path, root, target, CASE_INSENSITIVE_PATHS)
194
+ }
195
+
196
+ /**
197
+ * @param {unknown} trashId candidate object identity.
198
+ * @returns {boolean} whether the identity is one safe path segment.
199
+ */
200
+ export function isSafeTrashId(trashId) {
201
+ return (
202
+ typeof trashId === 'string' &&
203
+ trashId !== '' &&
204
+ /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trashId) &&
205
+ !trashId.includes('/') &&
206
+ !trashId.includes('\\') &&
207
+ trashId !== '.' &&
208
+ trashId !== '..'
209
+ )
210
+ }
211
+
212
+ /**
213
+ * Reject a path that is not one safe segment below its parent.
214
+ *
215
+ * @param {string} directory absolute directory path.
216
+ * @param {{ pathImpl?: typeof path, platform?: NodeJS.Platform }} [options] path implementation and platform policy.
217
+ * @returns {boolean} whether the path is absolute and names one ordinary segment.
218
+ */
219
+ export function isSafeDirectoryPath(directory, options = {}) {
220
+ const pathImpl = options.pathImpl ?? path
221
+ const platform = options.platform ?? process.platform
222
+ if (typeof directory !== 'string' || directory === '' || !pathImpl.isAbsolute(directory)) return false
223
+ const base = pathImpl.basename(directory)
224
+ if (base === '' || base === '.' || base === '..') return false
225
+ if (base.includes('/') || base.includes('\\') || base.includes('\0')) return false
226
+ if (/[\u0000-\u001f]/.test(base)) return false
227
+ if (platform === 'win32') {
228
+ // Windows silently strips a trailing dot or space and treats the remaining
229
+ // name as a device file; neither may become a session directory.
230
+ if (/[ .]$/.test(base)) return false
231
+ if (WINDOWS_RESERVED_NAMES.test(base)) return false
232
+ }
233
+ return pathImpl.dirname(directory) !== directory
234
+ }
235
+
236
+ /**
237
+ * Walk one session directory without following symlinks. `session.lock` is a
238
+ * live backend artifact and never counts toward the payload measured for a
239
+ * move.
240
+ *
241
+ * @param {string} directory session-owned directory.
242
+ * @returns {Promise<{ sizeBytes: number, fileCount: number, symlinkCount: number }>} aggregate regular-file metrics.
243
+ */
244
+ export async function measureDirectory(directory) {
245
+ const total = { sizeBytes: 0, fileCount: 0, symlinkCount: 0 }
246
+ const walk = async (current, isRoot) => {
247
+ const entries = await fs.readdir(current, { withFileTypes: true })
248
+ for (const entry of entries) {
249
+ const absolute = path.join(current, entry.name)
250
+ if (entry.isSymbolicLink()) {
251
+ total.symlinkCount += 1
252
+ continue
253
+ }
254
+ if (entry.isDirectory()) {
255
+ await walk(absolute, false)
256
+ continue
257
+ }
258
+ if (!entry.isFile()) continue
259
+ if (isRoot && entry.name === SESSION_LOCK_FILENAME) continue
260
+ const stats = await fs.lstat(absolute)
261
+ total.sizeBytes += stats.size
262
+ total.fileCount += 1
263
+ }
264
+ }
265
+ await walk(directory, true)
266
+ return total
267
+ }
268
+
269
+ /**
270
+ * @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} entry index entry.
271
+ * @param {{ sizeBytes: number, fileCount: number }} measured current metrics.
272
+ * @returns {boolean} whether the recorded metrics are absent or match exactly.
273
+ */
274
+ export function metricsMatch(entry, measured) {
275
+ if (entry === null || entry === undefined) return true
276
+ if (typeof entry.sizeBytes === 'number' && entry.sizeBytes !== measured.sizeBytes) return false
277
+ if (typeof entry.fileCount === 'number' && entry.fileCount !== measured.fileCount) return false
278
+ return true
279
+ }
280
+
281
+ /**
282
+ * @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} entry index entry.
283
+ * @returns {boolean} whether the entry carries a complete move snapshot.
284
+ */
285
+ export function hasMetrics(entry) {
286
+ return (
287
+ entry !== null &&
288
+ entry !== undefined &&
289
+ typeof entry.sizeBytes === 'number' &&
290
+ typeof entry.fileCount === 'number'
291
+ )
292
+ }
293
+
294
+ /**
295
+ * Build a time-sortable trash identity: compact UTC timestamp plus random hex.
296
+ *
297
+ * @returns {string} a new candidate trash id.
298
+ */
299
+ export function createTrashId() {
300
+ const timestamp = new Date()
301
+ .toISOString()
302
+ .replace(/[-:]/g, '')
303
+ .replace(/\.\d+Z$/, '')
304
+ return `${timestamp}-${randomBytes(4).toString('hex')}`
305
+ }
306
+
307
+ /**
308
+ * Copy one directory tree into a sibling temporary directory, verify it, then
309
+ * publish it with a rename and remove the source. Symlinks are copied as
310
+ * symlinks and never dereferenced. Any failure before the source is removed
311
+ * deletes the temporary tree, so a disk-full or permission failure never
312
+ * leaves a half-published object behind.
313
+ *
314
+ * @param {string} source directory to copy.
315
+ * @param {string} destination final directory path.
316
+ * @param {{ sizeBytes: number, fileCount: number }} expected recorded metrics.
317
+ * @param {(from: string, to: string) => Promise<void>} rename rename implementation for tests.
318
+ * @param {(source: string, destination: string, options: object) => Promise<void>} copy copy implementation for tests.
319
+ * @returns {Promise<void>} resolution after the destination is published.
320
+ */
321
+ async function copyAcrossDevices(source, destination, expected, rename, copy) {
322
+ const temporary = path.join(path.dirname(destination), `.${path.basename(destination)}.tmp-${randomBytes(4).toString('hex')}`)
323
+ try {
324
+ await copy(source, temporary, COPY_OPTIONS)
325
+ const measured = await measureDirectory(temporary)
326
+ if (!metricsMatch(expected, measured)) {
327
+ throw new TrashError('MOVE_VERIFY_FAILED', 'The copied session does not match the measured snapshot.', {
328
+ expected,
329
+ measured,
330
+ })
331
+ }
332
+ await rename(temporary, destination)
333
+ } catch (error) {
334
+ await removePathWithRetry(temporary, { recursive: true, force: true }).catch(() => {})
335
+ throw error
336
+ }
337
+ try {
338
+ await fs.rm(source, { recursive: true, force: false, maxRetries: 3, retryDelay: 100 })
339
+ } catch (error) {
340
+ throw new TrashError(
341
+ 'IO_ERROR',
342
+ 'The session was copied to the destination, but the source could not be removed.',
343
+ { source, destination, code: errorCode(error) ?? null, copied: true },
344
+ )
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Move one session directory. A same-filesystem rename is atomic; `EXDEV`
350
+ * falls back to copy → verify → publish → delete. The source must be a plain
351
+ * directory, never a symlink.
352
+ *
353
+ * @param {string} source session directory to move.
354
+ * @param {string} destination final directory path, which must not exist.
355
+ * @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} expected recorded metrics.
356
+ * @param {{
357
+ * logger?: { warn: Function },
358
+ * rename?: (from: string, to: string) => Promise<void>,
359
+ * copy?: (source: string, destination: string, options: object) => Promise<void>,
360
+ * }} [options] diagnostics sink and test seams.
361
+ * @returns {Promise<void>} resolution after the directory lives at `destination`.
362
+ */
363
+ export async function moveDirectory(source, destination, expected, options = {}) {
364
+ const rename = options.rename ?? renameWithRetry
365
+ const copy = options.copy ?? fs.cp
366
+ const kind = await entryKind(source)
367
+ if (kind !== 'directory') {
368
+ throw new TrashError('UNSAFE_SESSION_DIR', 'The session directory is missing or is not a plain directory.', {
369
+ kind,
370
+ })
371
+ }
372
+ const targetKind = await entryKind(destination)
373
+ if (targetKind !== 'missing') {
374
+ throw new TrashError('RESTORE_TARGET_EXISTS', 'The destination directory already exists.', { targetKind })
375
+ }
376
+ try {
377
+ await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 })
378
+ } catch (error) {
379
+ throw new TrashError('IO_ERROR', 'The destination parent directory could not be created.', {
380
+ code: errorCode(error) ?? null,
381
+ phase: 'mkdir',
382
+ })
383
+ }
384
+ try {
385
+ await rename(source, destination)
386
+ return
387
+ } catch (error) {
388
+ if (errorCode(error) !== 'EXDEV') {
389
+ throw new TrashError('IO_ERROR', 'The session directory could not be moved.', {
390
+ code: errorCode(error) ?? null,
391
+ phase: 'rename',
392
+ })
393
+ }
394
+ }
395
+ if (options.logger !== undefined) {
396
+ options.logger.warn('the session storage and the trash root are on different filesystems; copying instead of renaming')
397
+ }
398
+ const recorded =
399
+ expected !== null &&
400
+ expected !== undefined &&
401
+ typeof expected.sizeBytes === 'number' &&
402
+ typeof expected.fileCount === 'number'
403
+ ? { sizeBytes: expected.sizeBytes, fileCount: expected.fileCount }
404
+ : await measureDirectory(source)
405
+ try {
406
+ await copyAcrossDevices(source, destination, recorded, rename, copy)
407
+ } catch (error) {
408
+ if (error instanceof TrashError) throw error
409
+ throw new TrashError('IO_ERROR', 'The session directory could not be copied across filesystems.', {
410
+ code: errorCode(error) ?? null,
411
+ phase: 'copy',
412
+ })
413
+ }
414
+ }