@shieldfive/mcp 0.2.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 +200 -0
- package/LICENSE +201 -0
- package/README.md +365 -0
- package/SECURITY.md +144 -0
- package/package.json +49 -0
- package/src/format.mjs +157 -0
- package/src/fsops.mjs +361 -0
- package/src/limits.mjs +55 -0
- package/src/plans.mjs +188 -0
- package/src/roots.mjs +336 -0
- package/src/scan.mjs +269 -0
- package/src/server.mjs +363 -0
- package/src/tools/mutate.mjs +780 -0
- package/src/tools/read.mjs +515 -0
- package/src/trash.mjs +264 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
// The four tools that change the filesystem.
|
|
2
|
+
//
|
|
3
|
+
// Three rules hold across all of them, and they are the reason this server is
|
|
4
|
+
// safe to point at a real home directory.
|
|
5
|
+
//
|
|
6
|
+
// 1. Nothing happens without `confirm: true`. Called without it, each tool
|
|
7
|
+
// resolves the paths, checks containment, reports exactly what it WOULD do —
|
|
8
|
+
// including what it would displace — and returns.
|
|
9
|
+
//
|
|
10
|
+
// 2. Nothing is ever unlinked. Not by trash_local, and not by an overwriting
|
|
11
|
+
// move. An earlier version of move_local called
|
|
12
|
+
// `rm(finalPath, {recursive: true, force: true})` when overwrite was set,
|
|
13
|
+
// which made "this server deletes nothing" false in the one case where it
|
|
14
|
+
// mattered most: overwriting a directory destroyed every file underneath it,
|
|
15
|
+
// unrecoverably, with only the SOURCE's byte count shown in the preview.
|
|
16
|
+
// Overwriting now MOVES the existing destination into the trash first, so
|
|
17
|
+
// the bytes survive and the manifest records where they were. trash.mjs
|
|
18
|
+
// keeps the trash a real directory, on the item's own volume, with a
|
|
19
|
+
// manifest written before anything moves.
|
|
20
|
+
//
|
|
21
|
+
// 3. A tool acts on the entry it was given. A symlink passed as the thing to
|
|
22
|
+
// move, rename or trash is moved, renamed or trashed itself; its target is
|
|
23
|
+
// never touched.
|
|
24
|
+
//
|
|
25
|
+
// The only thing of the user's ever removed is the source of a move that
|
|
26
|
+
// crosses a device, and only once its copy has been flushed and verified; see
|
|
27
|
+
// fsops.mjs. Everything else removed is this server's own temporary state.
|
|
28
|
+
//
|
|
29
|
+
// Rule 1 has a second half, added after the pre-publish review (D3): a
|
|
30
|
+
// confirmed call must carry the `plan_token` its own preview returned, and the
|
|
31
|
+
// tool refuses if the tree no longer matches what that preview described. Each
|
|
32
|
+
// tool therefore builds a fingerprint next to its plan — the same fields, plus
|
|
33
|
+
// the identity of every entry involved — and plans.mjs compares the two.
|
|
34
|
+
|
|
35
|
+
import { lstat, mkdir, readdir } from 'node:fs/promises'
|
|
36
|
+
import { basename, dirname, join, sep } from 'node:path'
|
|
37
|
+
|
|
38
|
+
import { formatBytes, quote, toolResult } from '../format.mjs'
|
|
39
|
+
import {
|
|
40
|
+
describeSpecial,
|
|
41
|
+
moveFileAcrossDevices,
|
|
42
|
+
moveTreeAcrossDevices,
|
|
43
|
+
renameNoReplace,
|
|
44
|
+
} from '../fsops.mjs'
|
|
45
|
+
import { boundedList, LIMITS } from '../limits.mjs'
|
|
46
|
+
import { entryId, requireApprovedPlan } from '../plans.mjs'
|
|
47
|
+
import { isInside, resolveDestination, resolveEntry, resolveTarget, ToolError } from '../roots.mjs'
|
|
48
|
+
import { TRASH_DIR_NAME } from '../scan.mjs'
|
|
49
|
+
import {
|
|
50
|
+
batchName,
|
|
51
|
+
discardBatch,
|
|
52
|
+
inspectTrashDir,
|
|
53
|
+
inTrash,
|
|
54
|
+
makeParents,
|
|
55
|
+
moveIntoTrash,
|
|
56
|
+
openBatch,
|
|
57
|
+
trashBaseFor,
|
|
58
|
+
trashPaths,
|
|
59
|
+
writeManifest,
|
|
60
|
+
} from '../trash.mjs'
|
|
61
|
+
|
|
62
|
+
export { trashStamp } from '../trash.mjs'
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Refuse to start changing anything once the request has been cancelled.
|
|
66
|
+
*
|
|
67
|
+
* The signal reached every tool, and the mutating ones never looked at it, so
|
|
68
|
+
* a move or a trash the user had already called off ran to completion.
|
|
69
|
+
*/
|
|
70
|
+
function refuseIfCancelled(ctx) {
|
|
71
|
+
if (ctx.signal?.aborted) {
|
|
72
|
+
throw new ToolError('cancelled', 'Cancelled before anything was changed.')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What an lstat says an entry is, in the words the previews use. */
|
|
77
|
+
function kindOf(stats) {
|
|
78
|
+
if (stats.isSymbolicLink()) return 'symlink'
|
|
79
|
+
if (stats.isDirectory()) return 'directory'
|
|
80
|
+
if (stats.isFile()) return 'file'
|
|
81
|
+
return 'special'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Total bytes and file count beneath a path, for reporting before a move.
|
|
86
|
+
*
|
|
87
|
+
* lstat throughout: an item that is a symlink is measured as the link that
|
|
88
|
+
* will move, not as the tree it points to.
|
|
89
|
+
*/
|
|
90
|
+
async function measure(path) {
|
|
91
|
+
let st
|
|
92
|
+
try {
|
|
93
|
+
st = await lstat(path)
|
|
94
|
+
} catch {
|
|
95
|
+
return { files: 0, bytes: 0, kind: 'missing', symlinks: [], special: [] }
|
|
96
|
+
}
|
|
97
|
+
if (!st.isDirectory()) {
|
|
98
|
+
return {
|
|
99
|
+
files: st.isFile() ? 1 : 0,
|
|
100
|
+
bytes: st.isFile() ? st.size : 0,
|
|
101
|
+
kind: kindOf(st),
|
|
102
|
+
symlinks: [],
|
|
103
|
+
special: [],
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let files = 0
|
|
108
|
+
let bytes = 0
|
|
109
|
+
const symlinks = []
|
|
110
|
+
const special = []
|
|
111
|
+
const queue = [path]
|
|
112
|
+
while (queue.length) {
|
|
113
|
+
const dir = queue.shift()
|
|
114
|
+
let entries
|
|
115
|
+
try {
|
|
116
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
117
|
+
} catch {
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
for (const e of entries) {
|
|
121
|
+
const full = join(dir, e.name)
|
|
122
|
+
if (e.isSymbolicLink()) {
|
|
123
|
+
symlinks.push(full)
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (e.isDirectory()) queue.push(full)
|
|
127
|
+
else if (!e.isFile()) special.push(full)
|
|
128
|
+
else {
|
|
129
|
+
try {
|
|
130
|
+
const s = await lstat(full)
|
|
131
|
+
files++
|
|
132
|
+
bytes += s.size
|
|
133
|
+
} catch {
|
|
134
|
+
// Undercounts silently, as does the readdir catch above, which skips
|
|
135
|
+
// a whole subtree. These figures go into the move and trash previews
|
|
136
|
+
// the user approves, so a permission-denied subtree makes a move look
|
|
137
|
+
// smaller than it is. It never makes one look safer: nothing is
|
|
138
|
+
// deleted either way.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { files, bytes, kind: 'directory', symlinks, special }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Move without replacing anything, and across a device only by a verified copy.
|
|
148
|
+
*
|
|
149
|
+
* Returns how the item moved and, after a copy, the source entries left in
|
|
150
|
+
* place because they changed once they had been copied.
|
|
151
|
+
*/
|
|
152
|
+
async function relocate(from, to, stats, signal) {
|
|
153
|
+
try {
|
|
154
|
+
await renameNoReplace(from, to, stats)
|
|
155
|
+
return { method: 'rename', leftInPlace: [] }
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err.code !== 'EXDEV') throw err
|
|
158
|
+
}
|
|
159
|
+
if (stats.isFile()) {
|
|
160
|
+
return { method: 'copy+remove', leftInPlace: await moveFileAcrossDevices(from, to, stats, signal) }
|
|
161
|
+
}
|
|
162
|
+
if (stats.isDirectory()) {
|
|
163
|
+
return { method: 'copy+remove', leftInPlace: await moveTreeAcrossDevices(from, to, signal) }
|
|
164
|
+
}
|
|
165
|
+
throw new ToolError(
|
|
166
|
+
'special_file',
|
|
167
|
+
`Refused: ${quote(from)} is a ${describeSpecial(stats)}, and this move crosses a filesystem ` +
|
|
168
|
+
'boundary, where it would have to be copied. Nothing was moved.',
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The device of `path`, or of its nearest existing ancestor. */
|
|
173
|
+
async function nearestDevice(path) {
|
|
174
|
+
for (let dir = path; ; dir = dirname(dir)) {
|
|
175
|
+
try {
|
|
176
|
+
return (await lstat(dir)).dev
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (dirname(dir) === dir) throw err
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function moveLocal(ctx, args) {
|
|
184
|
+
const source = await resolveEntry(ctx.roots, args.source, { what: 'source' })
|
|
185
|
+
const dest = await resolveDestination(ctx.roots, args.destination, { what: 'destination' })
|
|
186
|
+
|
|
187
|
+
// A real directory at the destination means "move into it". A symlink there
|
|
188
|
+
// is an entry in its own right: the source can replace the link, with
|
|
189
|
+
// overwrite, but never lands wherever the link points.
|
|
190
|
+
//
|
|
191
|
+
// The final path is resolved before any guard runs. Checking the destination
|
|
192
|
+
// argument alone missed the worst case: moving /root/sub onto its parent
|
|
193
|
+
// /root gives a final path of /root/sub — the source itself — which the old
|
|
194
|
+
// guard passed and the old overwrite branch then deleted.
|
|
195
|
+
const finalPath = dest.stats?.isDirectory()
|
|
196
|
+
? join(dest.realPath, basename(source.realPath))
|
|
197
|
+
: dest.realPath
|
|
198
|
+
|
|
199
|
+
if (finalPath === source.realPath) {
|
|
200
|
+
throw new ToolError(
|
|
201
|
+
'destination_is_source',
|
|
202
|
+
`Refused: that resolves to ${quote(finalPath)}, which is the source itself. ` +
|
|
203
|
+
'Nothing to do.',
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
if (isInside(finalPath, source.realPath)) {
|
|
207
|
+
throw new ToolError(
|
|
208
|
+
'destination_inside_source',
|
|
209
|
+
`Refused: ${quote(finalPath)} is inside ${quote(source.realPath)}. Moving a directory ` +
|
|
210
|
+
'into its own subtree is not a move.',
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const final = await resolveDestination(ctx.roots, finalPath, { what: 'destination' })
|
|
215
|
+
const collision = final.stats !== null
|
|
216
|
+
|
|
217
|
+
if (collision && !args.overwrite) {
|
|
218
|
+
throw new ToolError(
|
|
219
|
+
'destination_exists',
|
|
220
|
+
`Refused: ${quote(finalPath)} already exists` +
|
|
221
|
+
(final.stats.isSymbolicLink()
|
|
222
|
+
? ' as a symlink. The link itself would be replaced, not what it points to; to ' +
|
|
223
|
+
'move into a directory a link points to, give that directory’s real path'
|
|
224
|
+
: '') +
|
|
225
|
+
'. Pass overwrite: true to move it to the trash and take its place, or choose a ' +
|
|
226
|
+
'different destination.',
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const size = await measure(source.realPath)
|
|
231
|
+
const displaced = collision
|
|
232
|
+
? await measure(finalPath)
|
|
233
|
+
: { files: 0, bytes: 0, kind: 'none', symlinks: [] }
|
|
234
|
+
|
|
235
|
+
// Where a displaced item would go, checked now so that an unsafe trash
|
|
236
|
+
// directory is a refusal in the preview and not a failure halfway through.
|
|
237
|
+
let trash = null
|
|
238
|
+
if (collision) {
|
|
239
|
+
const base = await trashBaseFor(final.root.realPath, finalPath, final.stats)
|
|
240
|
+
await inspectTrashDir(base)
|
|
241
|
+
const batch = batchName(ctx.now())
|
|
242
|
+
trash = { base, batch, destination: trashPaths(base, batch, finalPath).destination }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// A symlink or a special file inside the source cannot survive a
|
|
246
|
+
// cross-device move, and the copy refuses when it reaches one. Refusing HERE
|
|
247
|
+
// is the difference between a clean refusal and one raised partway through.
|
|
248
|
+
// It is checked for every move that displaces a destination, not only
|
|
249
|
+
// cross-device ones, because whether two paths share a device is not
|
|
250
|
+
// something the caller can see; and for any move that is known to cross one.
|
|
251
|
+
const crossesDevice = source.stats.dev !== (await nearestDevice(dirname(finalPath)))
|
|
252
|
+
const uncopyable = size.symlinks.length
|
|
253
|
+
? { code: 'symlink_in_tree', what: 'symlink(s)', list: size.symlinks }
|
|
254
|
+
: size.special.length
|
|
255
|
+
? { code: 'special_file_in_tree', what: 'FIFO(s), socket(s) or device file(s)', list: size.special }
|
|
256
|
+
: null
|
|
257
|
+
if (uncopyable && (collision || crossesDevice)) {
|
|
258
|
+
throw new ToolError(
|
|
259
|
+
uncopyable.code,
|
|
260
|
+
`Refused before changing anything: ${quote(source.realPath)} contains ` +
|
|
261
|
+
`${uncopyable.list.length} ${uncopyable.what}, starting with ${quote(uncopyable.list[0])}. ` +
|
|
262
|
+
(crossesDevice
|
|
263
|
+
? 'This move crosses a filesystem boundary, where it has to be a copy, and a copy ' +
|
|
264
|
+
'cannot carry those. '
|
|
265
|
+
: 'A move that also displaces an existing destination is not attempted with them ' +
|
|
266
|
+
'in the tree, because a failure partway would leave both sides disturbed. ') +
|
|
267
|
+
'Move them yourself, or move the rest without them.',
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
if (crossesDevice && size.kind === 'special') {
|
|
271
|
+
throw new ToolError(
|
|
272
|
+
'special_file',
|
|
273
|
+
`Refused: ${quote(source.realPath)} is a ${describeSpecial(source.stats)}, and this move ` +
|
|
274
|
+
'crosses a filesystem boundary, where it would have to be copied. Nothing was moved.',
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const plan = {
|
|
279
|
+
action: 'move',
|
|
280
|
+
source: source.realPath,
|
|
281
|
+
destination: finalPath,
|
|
282
|
+
kind: size.kind,
|
|
283
|
+
files: size.files,
|
|
284
|
+
bytes: size.bytes,
|
|
285
|
+
bytes_human: formatBytes(size.bytes),
|
|
286
|
+
replaces_existing: collision,
|
|
287
|
+
displaced: collision
|
|
288
|
+
? {
|
|
289
|
+
kind: displaced.kind,
|
|
290
|
+
files: displaced.files,
|
|
291
|
+
bytes: displaced.bytes,
|
|
292
|
+
bytes_human: formatBytes(displaced.bytes),
|
|
293
|
+
moved_to_trash: trash.destination,
|
|
294
|
+
}
|
|
295
|
+
: null,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The trash destination carries a timestamp that differs between the preview
|
|
299
|
+
// and the confirmation by design, so the fingerprint describes what is being
|
|
300
|
+
// moved and what it displaces, not where the displaced copy will land.
|
|
301
|
+
const fingerprint = {
|
|
302
|
+
action: 'move',
|
|
303
|
+
source: source.realPath,
|
|
304
|
+
destination: finalPath,
|
|
305
|
+
kind: size.kind,
|
|
306
|
+
files: size.files,
|
|
307
|
+
bytes: size.bytes,
|
|
308
|
+
replaces_existing: collision,
|
|
309
|
+
source_entry: entryId(source.stats),
|
|
310
|
+
destination_entry: entryId(final.stats),
|
|
311
|
+
displaced_files: collision ? displaced.files : 0,
|
|
312
|
+
displaced_bytes: collision ? displaced.bytes : 0,
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!args.confirm) {
|
|
316
|
+
const plan_token = ctx.plans.issue(fingerprint)
|
|
317
|
+
return toolResult(
|
|
318
|
+
`Planned (nothing changed): move ${size.kind} ${source.realPath} → ${finalPath} ` +
|
|
319
|
+
`(${formatBytes(size.bytes)})` +
|
|
320
|
+
(collision
|
|
321
|
+
? `. This DISPLACES an existing ${displaced.kind} of ${displaced.files} file(s), ` +
|
|
322
|
+
`${formatBytes(displaced.bytes)}, which would be moved to the trash, not deleted`
|
|
323
|
+
: '') +
|
|
324
|
+
'. Call again with confirm: true and this plan_token to perform it.',
|
|
325
|
+
{ performed: false, ...plan, plan_token },
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
refuseIfCancelled(ctx)
|
|
330
|
+
requireApprovedPlan(ctx, args.plan_token, fingerprint)
|
|
331
|
+
|
|
332
|
+
// Re-resolve immediately before the write. It does not close the
|
|
333
|
+
// time-of-check/time-of-use window — nothing path-based can, and SECURITY.md
|
|
334
|
+
// says so — but it narrows it from "however long measure() took on a large
|
|
335
|
+
// tree" to the gap between these two statements.
|
|
336
|
+
const current = await resolveDestination(ctx.roots, finalPath, { what: 'destination' })
|
|
337
|
+
if ((current.stats !== null) !== collision) {
|
|
338
|
+
throw new ToolError(
|
|
339
|
+
'destination_changed',
|
|
340
|
+
`Refused: ${quote(finalPath)} ${collision ? 'disappeared' : 'appeared'} while this move ` +
|
|
341
|
+
'was being planned. Nothing was moved; call again to see the new plan.',
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let displacedTo = null
|
|
346
|
+
let batch = null
|
|
347
|
+
if (collision) {
|
|
348
|
+
// The manifest entry is written before the displaced item moves, so there
|
|
349
|
+
// is no moment at which it is in the trash and recorded nowhere.
|
|
350
|
+
batch = await openBatch(trash.base, trash.batch)
|
|
351
|
+
batch.entries = [
|
|
352
|
+
{
|
|
353
|
+
original_path: finalPath,
|
|
354
|
+
trashed_to: trash.destination,
|
|
355
|
+
kind: displaced.kind,
|
|
356
|
+
bytes: displaced.bytes,
|
|
357
|
+
reason: 'displaced by a move',
|
|
358
|
+
},
|
|
359
|
+
]
|
|
360
|
+
try {
|
|
361
|
+
await writeManifest(batch, ctx.now())
|
|
362
|
+
await makeParents(batch, trash.destination)
|
|
363
|
+
await moveIntoTrash(finalPath, trash.destination, current.stats)
|
|
364
|
+
} catch (err) {
|
|
365
|
+
await discardBatch(batch)
|
|
366
|
+
throw err
|
|
367
|
+
}
|
|
368
|
+
displacedTo = trash.destination
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let outcome
|
|
372
|
+
try {
|
|
373
|
+
// A cancellation that arrived while the destination was being displaced
|
|
374
|
+
// stops the move here, and the displaced item is put back below. One that
|
|
375
|
+
// arrives during a copy across devices stops it before the copy is put in
|
|
376
|
+
// place. Once the source has started to be removed, the move completes.
|
|
377
|
+
refuseIfCancelled(ctx)
|
|
378
|
+
await mkdir(dirname(finalPath), { recursive: true })
|
|
379
|
+
outcome = await relocate(source.realPath, finalPath, source.stats, ctx.signal)
|
|
380
|
+
} catch (err) {
|
|
381
|
+
// The destination was displaced a moment ago and the replacement did not
|
|
382
|
+
// arrive. Put it back rather than leaving the user with an empty
|
|
383
|
+
// destination and an error that reads as though nothing happened.
|
|
384
|
+
if (displacedTo) {
|
|
385
|
+
try {
|
|
386
|
+
await renameNoReplace(displacedTo, finalPath, current.stats)
|
|
387
|
+
} catch (restoreErr) {
|
|
388
|
+
throw new ToolError(
|
|
389
|
+
'move_failed_destination_in_trash',
|
|
390
|
+
`The move ${err.code === 'cancelled' ? 'was cancelled' : `failed (${err.message})`} and the ` +
|
|
391
|
+
`item that was at ${quote(finalPath)} could not be put back (${restoreErr.message}). ` +
|
|
392
|
+
`It is NOT lost -- it is at ${quote(displacedTo)} and recorded in the manifest beside it.`,
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
await discardBatch(batch)
|
|
396
|
+
if (err.code === 'cancelled') {
|
|
397
|
+
throw new ToolError(
|
|
398
|
+
'cancelled',
|
|
399
|
+
`Cancelled before the move finished. The item that was at ${quote(finalPath)} has ` +
|
|
400
|
+
'been put back, and the source is untouched.',
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
throw new ToolError(
|
|
404
|
+
'move_failed_destination_restored',
|
|
405
|
+
`The move failed (${err.message}). The item that was at ${quote(finalPath)} has ` +
|
|
406
|
+
'been put back, and the source is untouched.',
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
throw err
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const left = outcome.leftInPlace
|
|
413
|
+
return toolResult(
|
|
414
|
+
`Moved ${size.kind} ${source.realPath} → ${finalPath} (${formatBytes(size.bytes)}).` +
|
|
415
|
+
(outcome.method === 'copy+remove'
|
|
416
|
+
? ' It crossed a filesystem boundary, so it was copied, and every file was flushed and ' +
|
|
417
|
+
'verified by SHA-256 before its original was removed.'
|
|
418
|
+
: '') +
|
|
419
|
+
(left.length
|
|
420
|
+
? ` ${left.length} source item(s) changed or appeared during the move and were left ` +
|
|
421
|
+
`where they were, starting with ${left[0]}.`
|
|
422
|
+
: '') +
|
|
423
|
+
(displacedTo
|
|
424
|
+
? ` The ${displaced.kind} that was there (${displaced.files} file(s), ` +
|
|
425
|
+
`${formatBytes(displaced.bytes)}) was moved to ${displacedTo}, not deleted.`
|
|
426
|
+
: '') +
|
|
427
|
+
(ctx.signal?.aborted
|
|
428
|
+
? ' The request was cancelled after the move had started, so it was completed rather ' +
|
|
429
|
+
'than left half-done.'
|
|
430
|
+
: ''),
|
|
431
|
+
{
|
|
432
|
+
performed: true,
|
|
433
|
+
cancelled_after_start: Boolean(ctx.signal?.aborted),
|
|
434
|
+
method: outcome.method,
|
|
435
|
+
source_left_in_place: left,
|
|
436
|
+
displaced_to: displacedTo,
|
|
437
|
+
...plan,
|
|
438
|
+
},
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* new_name, exactly as given.
|
|
444
|
+
*
|
|
445
|
+
* Not trimmed: "b.txt " is a different name from "b.txt", and trimming turned a
|
|
446
|
+
* rename to the first into a refusal about the second -- or, where no "b.txt"
|
|
447
|
+
* existed, into a rename nobody asked for. Bounded in bytes, because a name
|
|
448
|
+
* longer than any filesystem accepts used to come back whole in the error.
|
|
449
|
+
*/
|
|
450
|
+
function requireName(value) {
|
|
451
|
+
if (typeof value !== 'string' || value === '' || value === '.' || value === '..') {
|
|
452
|
+
throw new ToolError('invalid_name', 'new_name must be a non-empty filename.')
|
|
453
|
+
}
|
|
454
|
+
const bytes = Buffer.byteLength(value, 'utf8')
|
|
455
|
+
if (bytes > LIMITS.nameBytes) {
|
|
456
|
+
throw new ToolError(
|
|
457
|
+
'invalid_name',
|
|
458
|
+
`new_name is ${bytes.toLocaleString('en-US')} bytes; filesystems accept at most ` +
|
|
459
|
+
`${LIMITS.nameBytes}. It starts ${quote(value, 60)}.`,
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
if (value.includes(sep) || value.includes('/') || value.includes('\0')) {
|
|
463
|
+
throw new ToolError(
|
|
464
|
+
'invalid_name',
|
|
465
|
+
`new_name must be a bare filename, not a path; got ${quote(value)}. ` +
|
|
466
|
+
'Use move_local to change a location.',
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
return value
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export async function renameLocal(ctx, args) {
|
|
473
|
+
const source = await resolveEntry(ctx.roots, args.path, { what: 'path' })
|
|
474
|
+
const newName = requireName(args.new_name)
|
|
475
|
+
|
|
476
|
+
const finalPath = join(dirname(source.realPath), newName)
|
|
477
|
+
const final = await resolveDestination(ctx.roots, finalPath, { what: 'new name' })
|
|
478
|
+
|
|
479
|
+
if (final.stats) {
|
|
480
|
+
throw new ToolError(
|
|
481
|
+
'destination_exists',
|
|
482
|
+
`Refused: ${quote(finalPath)} already exists. Rename never replaces another file; ` +
|
|
483
|
+
'move it out of the way first.',
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const plan = { action: 'rename', from: source.realPath, to: finalPath, kind: kindOf(source.stats) }
|
|
488
|
+
const fingerprint = { ...plan, from_entry: entryId(source.stats) }
|
|
489
|
+
if (!args.confirm) {
|
|
490
|
+
const plan_token = ctx.plans.issue(fingerprint)
|
|
491
|
+
return toolResult(
|
|
492
|
+
`Planned (nothing changed): rename ${basename(source.realPath)} → ${newName} ` +
|
|
493
|
+
`in ${dirname(source.realPath)}. Call again with confirm: true and this plan_token.`,
|
|
494
|
+
{ performed: false, ...plan, plan_token },
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
refuseIfCancelled(ctx)
|
|
499
|
+
requireApprovedPlan(ctx, args.plan_token, fingerprint)
|
|
500
|
+
|
|
501
|
+
// The check above is for the preview. This is what keeps the promise: it
|
|
502
|
+
// refuses if anything has appeared at the new name since.
|
|
503
|
+
await renameNoReplace(source.realPath, finalPath, source.stats)
|
|
504
|
+
return toolResult(`Renamed to ${finalPath}.`, { performed: true, ...plan })
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export async function createLocalFolder(ctx, args) {
|
|
508
|
+
const dest = await resolveTarget(ctx.roots, args.path, { what: 'folder' })
|
|
509
|
+
|
|
510
|
+
if (dest.exists) {
|
|
511
|
+
const st = await lstat(dest.realPath)
|
|
512
|
+
if (st.isDirectory()) {
|
|
513
|
+
return toolResult(`${dest.realPath} already exists and is a directory.`, {
|
|
514
|
+
performed: false,
|
|
515
|
+
action: 'create_folder',
|
|
516
|
+
path: dest.realPath,
|
|
517
|
+
already_existed: true,
|
|
518
|
+
})
|
|
519
|
+
}
|
|
520
|
+
throw new ToolError(
|
|
521
|
+
'destination_exists',
|
|
522
|
+
`Refused: ${quote(dest.realPath)} exists and is not a directory.`,
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const plan = { action: 'create_folder', path: dest.realPath }
|
|
527
|
+
if (!args.confirm) {
|
|
528
|
+
const plan_token = ctx.plans.issue(plan)
|
|
529
|
+
return toolResult(
|
|
530
|
+
`Planned (nothing changed): create directory ${dest.realPath}. ` +
|
|
531
|
+
'Call again with confirm: true and this plan_token.',
|
|
532
|
+
{ performed: false, ...plan, plan_token },
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
refuseIfCancelled(ctx)
|
|
537
|
+
requireApprovedPlan(ctx, args.plan_token, plan)
|
|
538
|
+
await mkdir(dest.realPath, { recursive: true })
|
|
539
|
+
return toolResult(`Created ${dest.realPath}.`, { performed: true, ...plan })
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Refuse a trash call that lists a path inside another path it also lists.
|
|
544
|
+
*
|
|
545
|
+
* The preview counted such an item twice, and a confirmed call moved the
|
|
546
|
+
* folder and then failed on the file that had already gone with it.
|
|
547
|
+
*/
|
|
548
|
+
function refuseOverlaps(entries) {
|
|
549
|
+
const listed = new Set(entries.map((e) => e.realPath))
|
|
550
|
+
for (const e of entries) {
|
|
551
|
+
for (let dir = dirname(e.realPath); isInside(dir, e.root.realPath); dir = dirname(dir)) {
|
|
552
|
+
if (listed.has(dir)) {
|
|
553
|
+
throw new ToolError(
|
|
554
|
+
'overlapping_paths',
|
|
555
|
+
`Refused before moving anything: ${quote(e.realPath)} is inside ${quote(dir)}, ` +
|
|
556
|
+
'which is also listed. Trashing a folder takes everything in it; list one or the other.',
|
|
557
|
+
)
|
|
558
|
+
}
|
|
559
|
+
if (dir === e.root.realPath) break
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Move items into this server's trash.
|
|
566
|
+
*
|
|
567
|
+
* Not a delete. The bytes stay on the same volume, which means this does not
|
|
568
|
+
* free space until the user empties the trash themselves — stated in the
|
|
569
|
+
* result, because a tool whose purpose is reclaiming space must not let anyone
|
|
570
|
+
* believe it already has.
|
|
571
|
+
*/
|
|
572
|
+
export async function trashLocal(ctx, args) {
|
|
573
|
+
const inputs = boundedList(args.paths, { name: 'paths', max: LIMITS.paths })
|
|
574
|
+
const batch = batchName(ctx.now())
|
|
575
|
+
|
|
576
|
+
// Every path is resolved before any is planned. A path given twice is taken
|
|
577
|
+
// once.
|
|
578
|
+
const entries = []
|
|
579
|
+
const seen = new Set()
|
|
580
|
+
for (const input of inputs) {
|
|
581
|
+
const entry = await resolveEntry(ctx.roots, input, { what: 'path' })
|
|
582
|
+
if (seen.has(entry.realPath)) continue
|
|
583
|
+
seen.add(entry.realPath)
|
|
584
|
+
entries.push(entry)
|
|
585
|
+
}
|
|
586
|
+
refuseOverlaps(entries)
|
|
587
|
+
|
|
588
|
+
const planned = []
|
|
589
|
+
for (const entry of entries) {
|
|
590
|
+
if (entry.realPath === entry.root.realPath) {
|
|
591
|
+
throw new ToolError(
|
|
592
|
+
'cannot_trash_root',
|
|
593
|
+
`Refused: ${quote(entry.realPath)} is a configured root. Trashing a root would ` +
|
|
594
|
+
'move the whole allowed tree into a directory inside itself.',
|
|
595
|
+
)
|
|
596
|
+
}
|
|
597
|
+
if (inTrash(entry.root.realPath, entry.realPath)) {
|
|
598
|
+
throw new ToolError(
|
|
599
|
+
'already_trashed',
|
|
600
|
+
`Refused: ${quote(entry.realPath)} is already in this server's trash.`,
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
const base = await trashBaseFor(entry.root.realPath, entry.realPath, entry.stats)
|
|
604
|
+
await inspectTrashDir(base)
|
|
605
|
+
planned.push({
|
|
606
|
+
entry,
|
|
607
|
+
base,
|
|
608
|
+
destination: trashPaths(base, batch, entry.realPath).destination,
|
|
609
|
+
size: await measure(entry.realPath),
|
|
610
|
+
})
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const items = planned.map((p) => ({
|
|
614
|
+
source: p.entry.realPath,
|
|
615
|
+
root: p.entry.root.realPath,
|
|
616
|
+
trash_directory: join(p.base, TRASH_DIR_NAME),
|
|
617
|
+
destination: p.destination,
|
|
618
|
+
kind: p.size.kind,
|
|
619
|
+
files: p.size.files,
|
|
620
|
+
bytes: p.size.bytes,
|
|
621
|
+
bytes_human: formatBytes(p.size.bytes),
|
|
622
|
+
}))
|
|
623
|
+
const totalBytes = items.reduce((n, i) => n + i.bytes, 0)
|
|
624
|
+
const totalFiles = items.reduce((n, i) => n + i.files, 0)
|
|
625
|
+
const repeated = inputs.length - entries.length
|
|
626
|
+
const places = [...new Set(items.map((i) => i.trash_directory))]
|
|
627
|
+
|
|
628
|
+
// The batch name is a timestamp taken per call, so neither it nor the
|
|
629
|
+
// destinations under it belong in the fingerprint; what the user approved is
|
|
630
|
+
// this set of sources, each still the entry they were shown, going to this
|
|
631
|
+
// set of trash directories.
|
|
632
|
+
const fingerprint = {
|
|
633
|
+
action: 'trash',
|
|
634
|
+
items: planned.map((p) => ({
|
|
635
|
+
source: p.entry.realPath,
|
|
636
|
+
trash_directory: join(p.base, TRASH_DIR_NAME),
|
|
637
|
+
kind: p.size.kind,
|
|
638
|
+
files: p.size.files,
|
|
639
|
+
bytes: p.size.bytes,
|
|
640
|
+
entry: entryId(p.entry.stats),
|
|
641
|
+
})),
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (!args.confirm) {
|
|
645
|
+
const plan_token = ctx.plans.issue(fingerprint)
|
|
646
|
+
return toolResult(
|
|
647
|
+
`Planned (nothing changed): move ${items.length} item(s), ${totalFiles} file(s), ` +
|
|
648
|
+
`${formatBytes(totalBytes)} into a new batch in ${places.join(', ')}. Nothing is ` +
|
|
649
|
+
'deleted and no space is freed until you empty that directory yourself.' +
|
|
650
|
+
(repeated ? ` ${repeated} repeated path(s) are counted once.` : '') +
|
|
651
|
+
' Call again with confirm: true and this plan_token.',
|
|
652
|
+
{
|
|
653
|
+
performed: false,
|
|
654
|
+
action: 'trash',
|
|
655
|
+
trash_stamp: batch,
|
|
656
|
+
repeated_paths_ignored: repeated,
|
|
657
|
+
items,
|
|
658
|
+
plan_token,
|
|
659
|
+
note:
|
|
660
|
+
'The batch directory is named when the move is performed, so a confirmed ' +
|
|
661
|
+
'call puts items in a batch with a different name from the one shown here.',
|
|
662
|
+
},
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
refuseIfCancelled(ctx)
|
|
667
|
+
requireApprovedPlan(ctx, args.plan_token, fingerprint)
|
|
668
|
+
|
|
669
|
+
// One batch per trash directory, each with a manifest that lists its items
|
|
670
|
+
// before any of them moves.
|
|
671
|
+
const batches = new Map()
|
|
672
|
+
const moved = []
|
|
673
|
+
let failure = null
|
|
674
|
+
for (const p of planned) {
|
|
675
|
+
// Checked between items, never inside one, so a cancelled batch stops with
|
|
676
|
+
// every item either moved and recorded or untouched.
|
|
677
|
+
if (ctx.signal?.aborted) {
|
|
678
|
+
failure = new ToolError('cancelled', 'The request was cancelled.')
|
|
679
|
+
break
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
let b = batches.get(p.base)
|
|
683
|
+
if (!b) {
|
|
684
|
+
b = await openBatch(p.base, batch)
|
|
685
|
+
batches.set(p.base, b)
|
|
686
|
+
b.entries = planned.filter((q) => q.base === p.base).map(manifestEntry)
|
|
687
|
+
await writeManifest(b, ctx.now())
|
|
688
|
+
}
|
|
689
|
+
await makeParents(b, p.destination)
|
|
690
|
+
await moveIntoTrash(p.entry.realPath, p.destination, p.entry.stats)
|
|
691
|
+
moved.push(p)
|
|
692
|
+
} catch (err) {
|
|
693
|
+
failure = err
|
|
694
|
+
break
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (failure) throw await trashFailure(failure, planned, moved, batches, ctx)
|
|
699
|
+
|
|
700
|
+
return toolResult(
|
|
701
|
+
`Moved ${moved.length} item(s), ${totalFiles} file(s), ${formatBytes(totalBytes)} into ` +
|
|
702
|
+
`${TRASH_DIR_NAME}/${batch}${places.length > 1 ? ` in ${places.join(', ')}` : ''}. ` +
|
|
703
|
+
'NOTHING WAS DELETED and no disk space has been freed — the files are still on the ' +
|
|
704
|
+
'same volume. Delete that directory in your file manager when you are satisfied. A ' +
|
|
705
|
+
'manifest.json beside them records where each came from.',
|
|
706
|
+
{
|
|
707
|
+
performed: true,
|
|
708
|
+
action: 'trash',
|
|
709
|
+
trash_stamp: batch,
|
|
710
|
+
space_freed_bytes: 0,
|
|
711
|
+
space_recoverable_bytes: totalBytes,
|
|
712
|
+
repeated_paths_ignored: repeated,
|
|
713
|
+
manifests: [...batches.values()].map((b) => b.manifest),
|
|
714
|
+
items: items.map((i) => ({ ...i, trashed_to: i.destination, method: 'rename' })),
|
|
715
|
+
},
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function manifestEntry(p) {
|
|
720
|
+
return { original_path: p.entry.realPath, trashed_to: p.destination, kind: p.size.kind, bytes: p.size.bytes }
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* The error for a trash call that stopped partway, after the manifests have
|
|
725
|
+
* been brought into line with what moved.
|
|
726
|
+
*
|
|
727
|
+
* It says what moved and where, and `detail` carries the same as JSON. The old
|
|
728
|
+
* message could say that a moved item "IS recorded in no manifest (nothing
|
|
729
|
+
* moved)", and the detail never reached the client.
|
|
730
|
+
*/
|
|
731
|
+
async function trashFailure(failure, planned, moved, batches, ctx) {
|
|
732
|
+
const stale = []
|
|
733
|
+
for (const b of batches.values()) {
|
|
734
|
+
const here = moved.filter((p) => p.base === b.base)
|
|
735
|
+
try {
|
|
736
|
+
if (here.length === 0) {
|
|
737
|
+
await discardBatch(b)
|
|
738
|
+
} else if (here.length !== b.entries.length) {
|
|
739
|
+
b.entries = here.map(manifestEntry)
|
|
740
|
+
await writeManifest(b, ctx.now())
|
|
741
|
+
}
|
|
742
|
+
} catch {
|
|
743
|
+
stale.push(b.manifest)
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const manifests = [...batches.values()]
|
|
748
|
+
.filter((b) => moved.some((p) => p.base === b.base))
|
|
749
|
+
.map((b) => b.manifest)
|
|
750
|
+
const detail = {
|
|
751
|
+
moved: moved.map((p) => ({ original_path: p.entry.realPath, trashed_to: p.destination })),
|
|
752
|
+
not_moved: planned.filter((p) => !moved.includes(p)).map((p) => p.entry.realPath),
|
|
753
|
+
manifests,
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const cancelled = failure instanceof ToolError && failure.code === 'cancelled'
|
|
757
|
+
if (moved.length === 0) {
|
|
758
|
+
return new ToolError(
|
|
759
|
+
failure instanceof ToolError ? failure.code : 'trash_failed',
|
|
760
|
+
`Nothing was moved to the trash: ${failure.message}`,
|
|
761
|
+
detail,
|
|
762
|
+
)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const shown = detail.moved.slice(0, 10).map((m) => `${m.original_path} → ${m.trashed_to}`)
|
|
766
|
+
return new ToolError(
|
|
767
|
+
cancelled ? 'cancelled_partially_applied' : 'trash_partially_applied',
|
|
768
|
+
(cancelled
|
|
769
|
+
? `Cancelled after moving ${moved.length} of ${planned.length} item(s). `
|
|
770
|
+
: `Stopped after moving ${moved.length} of ${planned.length} item(s): ${failure.message}. `) +
|
|
771
|
+
`Moved, and recorded in ${manifests.join(', ')}: ${shown.join('; ')}` +
|
|
772
|
+
`${moved.length > shown.length ? `; and ${moved.length - shown.length} more` : ''}. ` +
|
|
773
|
+
(stale.length
|
|
774
|
+
? `${stale.join(', ')} could not be brought up to date and also lists items that ` +
|
|
775
|
+
'were not moved; an entry whose trashed_to does not exist was not moved. '
|
|
776
|
+
: '') +
|
|
777
|
+
'Nothing was deleted, and the items that were not moved are where they were.',
|
|
778
|
+
detail,
|
|
779
|
+
)
|
|
780
|
+
}
|