@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/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
+ }