@wangxiaocuo/dsh-session-trash 0.0.0-stage → 0.6.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/LICENSE +21 -0
- package/README.en.md +204 -0
- package/README.md +202 -2
- package/client.js +2231 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +10 -0
- package/index.js +129 -0
- package/lib/cleanup.js +342 -0
- package/lib/eligibility.js +310 -0
- package/lib/errors.js +91 -0
- package/lib/fsops.js +433 -0
- package/lib/http.js +351 -0
- package/lib/inventory.js +426 -0
- package/lib/members.js +95 -0
- package/lib/paths.js +39 -0
- package/lib/state.js +155 -0
- package/lib/store.js +606 -0
- package/lib/trash-service.js +1591 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +68 -4
package/lib/fsops.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
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
|
+
* Whether a session identity may name one child directory inside a trash
|
|
214
|
+
* object. Session ids are `session-<uuid>` or a bare uuid today, so the
|
|
215
|
+
* accepted alphabet is deliberately conservative.
|
|
216
|
+
*
|
|
217
|
+
* @param {unknown} sessionId candidate member identity.
|
|
218
|
+
* @returns {boolean} whether the identity is one safe path segment.
|
|
219
|
+
*/
|
|
220
|
+
export function isSafeMemberId(sessionId) {
|
|
221
|
+
return (
|
|
222
|
+
typeof sessionId === 'string' &&
|
|
223
|
+
sessionId !== '' &&
|
|
224
|
+
sessionId.length <= 256 &&
|
|
225
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(sessionId) &&
|
|
226
|
+
sessionId !== '.' &&
|
|
227
|
+
sessionId !== '..'
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Reject a path that is not one safe segment below its parent.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} directory absolute directory path.
|
|
235
|
+
* @param {{ pathImpl?: typeof path, platform?: NodeJS.Platform }} [options] path implementation and platform policy.
|
|
236
|
+
* @returns {boolean} whether the path is absolute and names one ordinary segment.
|
|
237
|
+
*/
|
|
238
|
+
export function isSafeDirectoryPath(directory, options = {}) {
|
|
239
|
+
const pathImpl = options.pathImpl ?? path
|
|
240
|
+
const platform = options.platform ?? process.platform
|
|
241
|
+
if (typeof directory !== 'string' || directory === '' || !pathImpl.isAbsolute(directory)) return false
|
|
242
|
+
const base = pathImpl.basename(directory)
|
|
243
|
+
if (base === '' || base === '.' || base === '..') return false
|
|
244
|
+
if (base.includes('/') || base.includes('\\') || base.includes('\0')) return false
|
|
245
|
+
if (/[\u0000-\u001f]/.test(base)) return false
|
|
246
|
+
if (platform === 'win32') {
|
|
247
|
+
// Windows silently strips a trailing dot or space and treats the remaining
|
|
248
|
+
// name as a device file; neither may become a session directory.
|
|
249
|
+
if (/[ .]$/.test(base)) return false
|
|
250
|
+
if (WINDOWS_RESERVED_NAMES.test(base)) return false
|
|
251
|
+
}
|
|
252
|
+
return pathImpl.dirname(directory) !== directory
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Walk one session directory without following symlinks. `session.lock` is a
|
|
257
|
+
* live backend artifact and never counts toward the payload measured for a
|
|
258
|
+
* move.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} directory session-owned directory.
|
|
261
|
+
* @returns {Promise<{ sizeBytes: number, fileCount: number, symlinkCount: number }>} aggregate regular-file metrics.
|
|
262
|
+
*/
|
|
263
|
+
export async function measureDirectory(directory) {
|
|
264
|
+
const total = { sizeBytes: 0, fileCount: 0, symlinkCount: 0 }
|
|
265
|
+
const walk = async (current, isRoot) => {
|
|
266
|
+
const entries = await fs.readdir(current, { withFileTypes: true })
|
|
267
|
+
for (const entry of entries) {
|
|
268
|
+
const absolute = path.join(current, entry.name)
|
|
269
|
+
if (entry.isSymbolicLink()) {
|
|
270
|
+
total.symlinkCount += 1
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
if (entry.isDirectory()) {
|
|
274
|
+
await walk(absolute, false)
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
if (!entry.isFile()) continue
|
|
278
|
+
if (isRoot && entry.name === SESSION_LOCK_FILENAME) continue
|
|
279
|
+
const stats = await fs.lstat(absolute)
|
|
280
|
+
total.sizeBytes += stats.size
|
|
281
|
+
total.fileCount += 1
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
await walk(directory, true)
|
|
285
|
+
return total
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} entry index entry.
|
|
290
|
+
* @param {{ sizeBytes: number, fileCount: number }} measured current metrics.
|
|
291
|
+
* @returns {boolean} whether the recorded metrics are absent or match exactly.
|
|
292
|
+
*/
|
|
293
|
+
export function metricsMatch(entry, measured) {
|
|
294
|
+
if (entry === null || entry === undefined) return true
|
|
295
|
+
if (typeof entry.sizeBytes === 'number' && entry.sizeBytes !== measured.sizeBytes) return false
|
|
296
|
+
if (typeof entry.fileCount === 'number' && entry.fileCount !== measured.fileCount) return false
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} entry index entry.
|
|
302
|
+
* @returns {boolean} whether the entry carries a complete move snapshot.
|
|
303
|
+
*/
|
|
304
|
+
export function hasMetrics(entry) {
|
|
305
|
+
return (
|
|
306
|
+
entry !== null &&
|
|
307
|
+
entry !== undefined &&
|
|
308
|
+
typeof entry.sizeBytes === 'number' &&
|
|
309
|
+
typeof entry.fileCount === 'number'
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Build a time-sortable trash identity: compact UTC timestamp plus random hex.
|
|
315
|
+
*
|
|
316
|
+
* @returns {string} a new candidate trash id.
|
|
317
|
+
*/
|
|
318
|
+
export function createTrashId() {
|
|
319
|
+
const timestamp = new Date()
|
|
320
|
+
.toISOString()
|
|
321
|
+
.replace(/[-:]/g, '')
|
|
322
|
+
.replace(/\.\d+Z$/, '')
|
|
323
|
+
return `${timestamp}-${randomBytes(4).toString('hex')}`
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Copy one directory tree into a sibling temporary directory, verify it, then
|
|
328
|
+
* publish it with a rename and remove the source. Symlinks are copied as
|
|
329
|
+
* symlinks and never dereferenced. Any failure before the source is removed
|
|
330
|
+
* deletes the temporary tree, so a disk-full or permission failure never
|
|
331
|
+
* leaves a half-published object behind.
|
|
332
|
+
*
|
|
333
|
+
* @param {string} source directory to copy.
|
|
334
|
+
* @param {string} destination final directory path.
|
|
335
|
+
* @param {{ sizeBytes: number, fileCount: number }} expected recorded metrics.
|
|
336
|
+
* @param {(from: string, to: string) => Promise<void>} rename rename implementation for tests.
|
|
337
|
+
* @param {(source: string, destination: string, options: object) => Promise<void>} copy copy implementation for tests.
|
|
338
|
+
* @returns {Promise<void>} resolution after the destination is published.
|
|
339
|
+
*/
|
|
340
|
+
async function copyAcrossDevices(source, destination, expected, rename, copy) {
|
|
341
|
+
const temporary = path.join(path.dirname(destination), `.${path.basename(destination)}.tmp-${randomBytes(4).toString('hex')}`)
|
|
342
|
+
try {
|
|
343
|
+
await copy(source, temporary, COPY_OPTIONS)
|
|
344
|
+
const measured = await measureDirectory(temporary)
|
|
345
|
+
if (!metricsMatch(expected, measured)) {
|
|
346
|
+
throw new TrashError('MOVE_VERIFY_FAILED', 'The copied session does not match the measured snapshot.', {
|
|
347
|
+
expected,
|
|
348
|
+
measured,
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
await rename(temporary, destination)
|
|
352
|
+
} catch (error) {
|
|
353
|
+
await removePathWithRetry(temporary, { recursive: true, force: true }).catch(() => {})
|
|
354
|
+
throw error
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
await fs.rm(source, { recursive: true, force: false, maxRetries: 3, retryDelay: 100 })
|
|
358
|
+
} catch (error) {
|
|
359
|
+
throw new TrashError(
|
|
360
|
+
'IO_ERROR',
|
|
361
|
+
'The session was copied to the destination, but the source could not be removed.',
|
|
362
|
+
{ source, destination, code: errorCode(error) ?? null, copied: true },
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Move one session directory. A same-filesystem rename is atomic; `EXDEV`
|
|
369
|
+
* falls back to copy → verify → publish → delete. The source must be a plain
|
|
370
|
+
* directory, never a symlink.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} source session directory to move.
|
|
373
|
+
* @param {string} destination final directory path, which must not exist.
|
|
374
|
+
* @param {{ sizeBytes?: number | null, fileCount?: number | null } | null | undefined} expected recorded metrics.
|
|
375
|
+
* @param {{
|
|
376
|
+
* logger?: { warn: Function },
|
|
377
|
+
* rename?: (from: string, to: string) => Promise<void>,
|
|
378
|
+
* copy?: (source: string, destination: string, options: object) => Promise<void>,
|
|
379
|
+
* }} [options] diagnostics sink and test seams.
|
|
380
|
+
* @returns {Promise<void>} resolution after the directory lives at `destination`.
|
|
381
|
+
*/
|
|
382
|
+
export async function moveDirectory(source, destination, expected, options = {}) {
|
|
383
|
+
const rename = options.rename ?? renameWithRetry
|
|
384
|
+
const copy = options.copy ?? fs.cp
|
|
385
|
+
const kind = await entryKind(source)
|
|
386
|
+
if (kind !== 'directory') {
|
|
387
|
+
throw new TrashError('UNSAFE_SESSION_DIR', 'The session directory is missing or is not a plain directory.', {
|
|
388
|
+
kind,
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
const targetKind = await entryKind(destination)
|
|
392
|
+
if (targetKind !== 'missing') {
|
|
393
|
+
throw new TrashError('RESTORE_TARGET_EXISTS', 'The destination directory already exists.', { targetKind })
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 })
|
|
397
|
+
} catch (error) {
|
|
398
|
+
throw new TrashError('IO_ERROR', 'The destination parent directory could not be created.', {
|
|
399
|
+
code: errorCode(error) ?? null,
|
|
400
|
+
phase: 'mkdir',
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
await rename(source, destination)
|
|
405
|
+
return
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (errorCode(error) !== 'EXDEV') {
|
|
408
|
+
throw new TrashError('IO_ERROR', 'The session directory could not be moved.', {
|
|
409
|
+
code: errorCode(error) ?? null,
|
|
410
|
+
phase: 'rename',
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (options.logger !== undefined) {
|
|
415
|
+
options.logger.warn('the session storage and the trash root are on different filesystems; copying instead of renaming')
|
|
416
|
+
}
|
|
417
|
+
const recorded =
|
|
418
|
+
expected !== null &&
|
|
419
|
+
expected !== undefined &&
|
|
420
|
+
typeof expected.sizeBytes === 'number' &&
|
|
421
|
+
typeof expected.fileCount === 'number'
|
|
422
|
+
? { sizeBytes: expected.sizeBytes, fileCount: expected.fileCount }
|
|
423
|
+
: await measureDirectory(source)
|
|
424
|
+
try {
|
|
425
|
+
await copyAcrossDevices(source, destination, recorded, rename, copy)
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error instanceof TrashError) throw error
|
|
428
|
+
throw new TrashError('IO_ERROR', 'The session directory could not be copied across filesystems.', {
|
|
429
|
+
code: errorCode(error) ?? null,
|
|
430
|
+
phase: 'copy',
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
}
|