@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
package/src/trash.mjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Where trashed items go, and the manifest that says where they came from.
|
|
2
|
+
//
|
|
3
|
+
// Three properties, each of which used to be false.
|
|
4
|
+
//
|
|
5
|
+
// 1. The trash is a real directory inside the root. `.shieldfive-mcp-trash`
|
|
6
|
+
// was joined onto the root and handed to mkdir -p and rename(2), which both
|
|
7
|
+
// follow a symlink, so a link planted at that name sent the user's files
|
|
8
|
+
// and the manifest out of the root. The directory is now lstat'd before
|
|
9
|
+
// anything is planned, created one level at a time without following
|
|
10
|
+
// anything, and checked again before anything moves into it.
|
|
11
|
+
//
|
|
12
|
+
// 2. The trash is on the same volume as the item. It used to sit at the root
|
|
13
|
+
// whatever volume an item was on, so for a root such as /Volumes, trashing
|
|
14
|
+
// from an external drive copied the tree onto the boot volume while the
|
|
15
|
+
// result said the bytes had stayed put. An item's trash is now in the
|
|
16
|
+
// highest directory between it and its root that is on its own device, and
|
|
17
|
+
// a move into the trash is a rename, never a copy.
|
|
18
|
+
//
|
|
19
|
+
// 3. The manifest is complete. Concurrent calls in one millisecond shared a
|
|
20
|
+
// batch and a manifest, and the later write dropped the earlier entries; a
|
|
21
|
+
// corrupt manifest was silently reset. Each call now gets a batch directory
|
|
22
|
+
// of its own, created exclusively. Its manifest lists every item before any
|
|
23
|
+
// of them moves, is written atomically and one write at a time, and no
|
|
24
|
+
// manifest this server did not write is ever read, merged or replaced.
|
|
25
|
+
|
|
26
|
+
import { lstat, mkdir, open, readdir, realpath, rename, rmdir, unlink } from 'node:fs/promises'
|
|
27
|
+
import { dirname, join, relative, sep } from 'node:path'
|
|
28
|
+
|
|
29
|
+
import { quote } from './format.mjs'
|
|
30
|
+
import { renameNoReplace, syncDirectory } from './fsops.mjs'
|
|
31
|
+
import { isInside, ToolError } from './roots.mjs'
|
|
32
|
+
import { TRASH_DIR_NAME } from './scan.mjs'
|
|
33
|
+
|
|
34
|
+
const MANIFEST_NOTE =
|
|
35
|
+
'Written by @shieldfive/mcp before anything in this batch was moved. Nothing here ' +
|
|
36
|
+
'is deleted. To restore an entry, move trashed_to back to original_path. An entry ' +
|
|
37
|
+
'whose trashed_to does not exist was planned but not moved.'
|
|
38
|
+
|
|
39
|
+
export function trashStamp(now) {
|
|
40
|
+
return new Date(now).toISOString().replace(/[:.]/g, '-')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let batchCounter = 0
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A batch name no other call will use: the timestamp, this process and a
|
|
47
|
+
* counter. The batch directory is also created exclusively, so a name another
|
|
48
|
+
* process happens to pick is refused rather than shared.
|
|
49
|
+
*/
|
|
50
|
+
export function batchName(now) {
|
|
51
|
+
batchCounter += 1
|
|
52
|
+
return `${trashStamp(now)}-${process.pid}-${batchCounter}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when `path` is inside one of this server's trash directories below its root. */
|
|
56
|
+
export function inTrash(rootRealPath, path) {
|
|
57
|
+
return relative(rootRealPath, path).split(sep).includes(TRASH_DIR_NAME)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The directory whose trash an entry goes into: the highest directory between
|
|
62
|
+
* the entry and its root that is on the entry's own device.
|
|
63
|
+
*/
|
|
64
|
+
export async function trashBaseFor(rootRealPath, entryPath, entryStats) {
|
|
65
|
+
let base = null
|
|
66
|
+
for (let dir = dirname(entryPath); isInside(dir, rootRealPath); dir = dirname(dir)) {
|
|
67
|
+
if ((await lstat(dir)).dev !== entryStats.dev) break
|
|
68
|
+
base = dir
|
|
69
|
+
if (dir === rootRealPath) break
|
|
70
|
+
}
|
|
71
|
+
if (!base) {
|
|
72
|
+
throw new ToolError(
|
|
73
|
+
'trash_no_same_volume',
|
|
74
|
+
`Refused: ${quote(entryPath)} is the top of a volume mounted inside the root ` +
|
|
75
|
+
`${quote(rootRealPath)}, so no directory on that volume and inside the root can ` +
|
|
76
|
+
'hold its trash. Putting it anywhere else would copy it onto another volume. ' +
|
|
77
|
+
'Nothing was moved; trash what is inside it instead.',
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
return base
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Where an entry lands in a batch, and the batch's own paths. */
|
|
84
|
+
export function trashPaths(base, batch, entryPath) {
|
|
85
|
+
const batchDir = join(base, TRASH_DIR_NAME, batch)
|
|
86
|
+
return {
|
|
87
|
+
batchDir,
|
|
88
|
+
destination: join(batchDir, relative(base, entryPath)),
|
|
89
|
+
manifest: join(batchDir, 'manifest.json'),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function unsafe(path, why) {
|
|
94
|
+
return new ToolError(
|
|
95
|
+
'trash_unsafe',
|
|
96
|
+
`Refused: ${quote(path)} ${why}. Trashed items and their manifest go inside it, so it ` +
|
|
97
|
+
'must be a real directory in the root, and this server moves nothing through a link. ' +
|
|
98
|
+
'Nothing was moved. Look at what is there and move it out of the way first.',
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function requireRealDirectory(path, device) {
|
|
103
|
+
const st = await lstat(path)
|
|
104
|
+
if (st.isSymbolicLink()) throw unsafe(path, 'is a symlink')
|
|
105
|
+
if (!st.isDirectory()) throw unsafe(path, 'is not a directory')
|
|
106
|
+
if (st.dev !== device) throw unsafe(path, 'is on a different volume from the directory holding it')
|
|
107
|
+
const real = await realpath(path)
|
|
108
|
+
if (real !== path) throw unsafe(path, `resolves to ${quote(real)}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Refuse, while planning, a trash directory that is not a real directory.
|
|
113
|
+
*
|
|
114
|
+
* Read-only, so a preview reports the refusal the confirmed call would hit.
|
|
115
|
+
*/
|
|
116
|
+
export async function inspectTrashDir(base) {
|
|
117
|
+
const dir = join(base, TRASH_DIR_NAME)
|
|
118
|
+
try {
|
|
119
|
+
await lstat(dir)
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (err.code === 'ENOENT') return
|
|
122
|
+
throw err
|
|
123
|
+
}
|
|
124
|
+
await requireRealDirectory(dir, (await lstat(base)).dev)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create, or check, the trash directory and create this call's batch in it.
|
|
129
|
+
*
|
|
130
|
+
* mkdir without `recursive` makes exactly one directory and fails when
|
|
131
|
+
* anything, a link included, already has that name, so neither can be
|
|
132
|
+
* redirected; both are checked afterwards regardless.
|
|
133
|
+
*/
|
|
134
|
+
export async function openBatch(base, batch) {
|
|
135
|
+
const device = (await lstat(base)).dev
|
|
136
|
+
const dir = join(base, TRASH_DIR_NAME)
|
|
137
|
+
try {
|
|
138
|
+
await mkdir(dir)
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err.code !== 'EEXIST') throw err
|
|
141
|
+
}
|
|
142
|
+
await requireRealDirectory(dir, device)
|
|
143
|
+
|
|
144
|
+
const { batchDir, manifest } = trashPaths(base, batch, base)
|
|
145
|
+
try {
|
|
146
|
+
await mkdir(batchDir)
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (err.code === 'EEXIST') {
|
|
149
|
+
throw new ToolError(
|
|
150
|
+
'trash_batch_exists',
|
|
151
|
+
`Refused: a trash batch named ${quote(batchDir)} already exists, and this call will ` +
|
|
152
|
+
'not share it. Nothing was moved; call again.',
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
throw err
|
|
156
|
+
}
|
|
157
|
+
await requireRealDirectory(batchDir, device)
|
|
158
|
+
return { base, dir: batchDir, manifest, device, entries: [], written: false }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Create the directories between a batch and one destination in it, following no link. */
|
|
162
|
+
export async function makeParents(batch, destination) {
|
|
163
|
+
let dir = batch.dir
|
|
164
|
+
for (const part of relative(batch.dir, dirname(destination)).split(sep).filter(Boolean)) {
|
|
165
|
+
dir = join(dir, part)
|
|
166
|
+
try {
|
|
167
|
+
await mkdir(dir)
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (err.code !== 'EEXIST') throw err
|
|
170
|
+
}
|
|
171
|
+
await requireRealDirectory(dir, batch.device)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Rename an entry into the trash. Never a copy: a move that crosses a device is refused. */
|
|
176
|
+
export async function moveIntoTrash(from, to, stats) {
|
|
177
|
+
try {
|
|
178
|
+
await renameNoReplace(from, to, stats)
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (err.code === 'EXDEV') {
|
|
181
|
+
throw new ToolError(
|
|
182
|
+
'trash_cross_device',
|
|
183
|
+
`Refused: moving ${quote(from)} to ${quote(to)} would cross a filesystem boundary, ` +
|
|
184
|
+
'and a move into the trash is never a copy. It was not moved.',
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
throw err
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const manifestWrites = new Map()
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Write a batch's manifest from `batch.entries`.
|
|
195
|
+
*
|
|
196
|
+
* Serialised per manifest, so two writes never interleave. Atomic: the text
|
|
197
|
+
* goes to a temporary file that is flushed and then renamed over the manifest,
|
|
198
|
+
* so a crash leaves the old manifest or the new one and never half of either.
|
|
199
|
+
* The first write refuses to replace anything at the manifest's name.
|
|
200
|
+
*/
|
|
201
|
+
export function writeManifest(batch, now) {
|
|
202
|
+
const write = () => writeAtomically(batch, now)
|
|
203
|
+
const previous = manifestWrites.get(batch.manifest) ?? Promise.resolve()
|
|
204
|
+
const next = previous.then(write, write)
|
|
205
|
+
manifestWrites.set(batch.manifest, next)
|
|
206
|
+
const settle = () => {
|
|
207
|
+
if (manifestWrites.get(batch.manifest) === next) manifestWrites.delete(batch.manifest)
|
|
208
|
+
}
|
|
209
|
+
next.then(settle, settle)
|
|
210
|
+
return next
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let tempCounter = 0
|
|
214
|
+
|
|
215
|
+
async function writeAtomically(batch, now) {
|
|
216
|
+
const text = JSON.stringify(
|
|
217
|
+
{ format: 1, created: new Date(now).toISOString(), note: MANIFEST_NOTE, items: batch.entries },
|
|
218
|
+
null,
|
|
219
|
+
2,
|
|
220
|
+
)
|
|
221
|
+
tempCounter += 1
|
|
222
|
+
const temp = `${batch.manifest}.${process.pid}-${tempCounter}.tmp`
|
|
223
|
+
const handle = await open(temp, 'wx', 0o644)
|
|
224
|
+
let flushed = false
|
|
225
|
+
try {
|
|
226
|
+
await handle.writeFile(text, 'utf8')
|
|
227
|
+
await handle.sync()
|
|
228
|
+
flushed = true
|
|
229
|
+
} finally {
|
|
230
|
+
await handle.close().catch(() => {})
|
|
231
|
+
if (!flushed) await unlink(temp).catch(() => {})
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
if (batch.written) await rename(temp, batch.manifest)
|
|
235
|
+
else await renameNoReplace(temp, batch.manifest)
|
|
236
|
+
} catch (err) {
|
|
237
|
+
await unlink(temp).catch(() => {})
|
|
238
|
+
throw err
|
|
239
|
+
}
|
|
240
|
+
batch.written = true
|
|
241
|
+
await syncDirectory(batch.dir)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Take back a batch that nothing ended up in: its manifest and its empty
|
|
246
|
+
* directories. Only what this call made, and a directory only if it is empty.
|
|
247
|
+
*/
|
|
248
|
+
export async function discardBatch(batch) {
|
|
249
|
+
if (batch.written) await unlink(batch.manifest).catch(() => {})
|
|
250
|
+
await removeEmptyDirectories(batch.dir)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function removeEmptyDirectories(dir) {
|
|
254
|
+
let entries = []
|
|
255
|
+
try {
|
|
256
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
257
|
+
} catch {
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
for (const e of entries) {
|
|
261
|
+
if (e.isDirectory()) await removeEmptyDirectories(join(dir, e.name))
|
|
262
|
+
}
|
|
263
|
+
await rmdir(dir).catch(() => {})
|
|
264
|
+
}
|