@tiphareth/dsh-hardssh 0.1.2 → 0.2.2
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 +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +58 -25
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/remote/remote-fs.ts
CHANGED
|
@@ -30,6 +30,13 @@ import type { WorkspaceState } from '../protocol.ts'
|
|
|
30
30
|
import { quoteShellArg } from './environment.ts'
|
|
31
31
|
|
|
32
32
|
const BINARY_SAMPLE_BYTES = 8192
|
|
33
|
+
/** Hard cap on a whole-file `readText` (default reads are bounded so no
|
|
34
|
+
* caller can force an unbounded `Buffer.concat` of a huge remote file). */
|
|
35
|
+
const FULL_READ_MAX_BYTES = 32 * 1024 * 1024
|
|
36
|
+
/** Exclusive bound for the contextual-diff basis read; mirrors the local
|
|
37
|
+
* backend's `diffBasisMaxBytes` (an over-limit file skips the diff basis and
|
|
38
|
+
* falls back to a whole-file diff instead of failing the write). */
|
|
39
|
+
const DIFF_BASIS_MAX_BYTES = 10 * 1024 * 1024
|
|
33
40
|
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
|
34
41
|
|
|
35
42
|
/** Shape of one remote stat the provider works with (engine-normalized). */
|
|
@@ -73,7 +80,7 @@ function decodeText(bytes: Uint8Array, displayPath: string): string {
|
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
/** Decode a base64-wrapped NUL-terminated canonical path from `realpath -mz`. */
|
|
76
|
-
function decodeCanonicalPath(encoded: string): string {
|
|
83
|
+
export function decodeCanonicalPath(encoded: string): string {
|
|
77
84
|
if (encoded.length === 0 || !BASE64.test(encoded)) {
|
|
78
85
|
throw new Error('fs-ssh: canonical path transport returned invalid base64')
|
|
79
86
|
}
|
|
@@ -151,13 +158,31 @@ function isNotFound(error: unknown): boolean {
|
|
|
151
158
|
*/
|
|
152
159
|
export class SshFileSystem extends FileSystem {
|
|
153
160
|
private readonly locks = new Map<string, Promise<unknown>>()
|
|
161
|
+
/** When set, every resolved target must stay under this root (the SSH
|
|
162
|
+
* workspace's `location.root`). Absent for the legacy/global SSH seam,
|
|
163
|
+
* which intentionally addresses the whole host. */
|
|
164
|
+
private readonly confineRoot: string | undefined
|
|
154
165
|
|
|
155
166
|
constructor(
|
|
156
167
|
ctx: Context,
|
|
157
168
|
private readonly engine: SshEngine,
|
|
158
169
|
private readonly getState: () => WorkspaceState,
|
|
170
|
+
confineRoot?: string,
|
|
159
171
|
) {
|
|
160
172
|
super(ctx)
|
|
173
|
+
this.confineRoot = confineRoot === undefined ? undefined : posix.normalize(confineRoot)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Root-confinement gate for the workspace capability: reject any canonical
|
|
177
|
+
* path that escapes `confineRoot` (including via an in-root symlink whose
|
|
178
|
+
* realpath lands outside). Mirrors the local provider's fail-closed rule. */
|
|
179
|
+
private confine(path: string): string {
|
|
180
|
+
if (this.confineRoot === undefined) return path
|
|
181
|
+
const root = this.confineRoot.endsWith('/') ? this.confineRoot : `${this.confineRoot}/`
|
|
182
|
+
if (path !== this.confineRoot && !path.startsWith(root)) {
|
|
183
|
+
throw new Error(`workspace.ssh-outside-root: '${path}' is outside '${this.confineRoot}'`)
|
|
184
|
+
}
|
|
185
|
+
return path
|
|
161
186
|
}
|
|
162
187
|
|
|
163
188
|
/** The active remote execution world (throws when not in remote mode). */
|
|
@@ -189,6 +214,9 @@ export class SshFileSystem extends FileSystem {
|
|
|
189
214
|
try {
|
|
190
215
|
const targetKey = await this.canonicalPath(displayPath, opts?.signal)
|
|
191
216
|
assertNotAborted(opts?.signal, 'resolve')
|
|
217
|
+
// Root confinement is checked on the CANONICAL target so a symlink that
|
|
218
|
+
// escapes the workspace root fails closed instead of being followed.
|
|
219
|
+
this.confine(targetKey)
|
|
192
220
|
return { targetKey: FsTargetKey(targetKey), displayPath }
|
|
193
221
|
} catch (error: unknown) {
|
|
194
222
|
throw mapError(error, 'resolve', displayPath, opts?.signal)
|
|
@@ -196,7 +224,7 @@ export class SshFileSystem extends FileSystem {
|
|
|
196
224
|
}
|
|
197
225
|
|
|
198
226
|
override processPath(target: FsTarget): string {
|
|
199
|
-
return String(target.targetKey)
|
|
227
|
+
return this.confine(String(target.targetKey))
|
|
200
228
|
}
|
|
201
229
|
|
|
202
230
|
override fileUrl(target: FsTarget): string {
|
|
@@ -212,10 +240,11 @@ export class SshFileSystem extends FileSystem {
|
|
|
212
240
|
|
|
213
241
|
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
|
|
214
242
|
assertNotAborted(signal, 'stat')
|
|
215
|
-
const
|
|
243
|
+
const targetPath = String((await this.canonicalTarget(target, signal)).targetKey)
|
|
244
|
+
const stats = await this.probe(targetPath, target.displayPath, signal)
|
|
216
245
|
if (stats === undefined) return undefined
|
|
217
246
|
return {
|
|
218
|
-
version: entryVersion(stats,
|
|
247
|
+
version: entryVersion(stats, targetPath),
|
|
219
248
|
type: entryType(stats),
|
|
220
249
|
...(stats.isFile() ? { size: stats.size } : {}),
|
|
221
250
|
}
|
|
@@ -227,7 +256,8 @@ export class SshFileSystem extends FileSystem {
|
|
|
227
256
|
const displayPath = posix.resolve(this.resolveRemoteCwd(opts?.cwd), path)
|
|
228
257
|
const { alias } = this.current()
|
|
229
258
|
try {
|
|
230
|
-
|
|
259
|
+
this.confine(displayPath)
|
|
260
|
+
const info = await this.engine.lstat(alias, displayPath, signal)
|
|
231
261
|
assertNotAborted(signal, 'lstat')
|
|
232
262
|
if (info === undefined) return undefined
|
|
233
263
|
const type = info.type === 'symlink' ? 'symlink' as const : info.type === 'directory' ? 'directory' as const : info.type === 'file' ? 'file' as const : 'other' as const
|
|
@@ -243,8 +273,11 @@ export class SshFileSystem extends FileSystem {
|
|
|
243
273
|
}
|
|
244
274
|
|
|
245
275
|
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
|
|
246
|
-
await this.requireRegular(target, signal)
|
|
247
|
-
|
|
276
|
+
const info = await this.requireRegular(target, signal)
|
|
277
|
+
if (info.size !== undefined && info.size > FULL_READ_MAX_BYTES) {
|
|
278
|
+
throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${FULL_READ_MAX_BYTES}-byte read limit`, 'FS_TOO_LARGE')
|
|
279
|
+
}
|
|
280
|
+
const bytes = await this.readBytesRaw(target, signal, FULL_READ_MAX_BYTES)
|
|
248
281
|
assertNotAborted(signal, 'read')
|
|
249
282
|
return decodeText(bytes, target.displayPath)
|
|
250
283
|
}
|
|
@@ -260,15 +293,27 @@ export class SshFileSystem extends FileSystem {
|
|
|
260
293
|
}
|
|
261
294
|
|
|
262
295
|
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
|
|
263
|
-
|
|
264
|
-
const { alias } = this.current()
|
|
296
|
+
const filesystem = this
|
|
265
297
|
const displayPath = target.displayPath
|
|
266
|
-
const stream = await this.engine.readStream(alias, String(target.targetKey))
|
|
267
298
|
return {
|
|
268
299
|
async *[Symbol.asyncIterator](): AsyncGenerator<string> {
|
|
300
|
+
let stream: import('node:stream').Readable | undefined
|
|
301
|
+
let onAbort: (() => void) | undefined
|
|
269
302
|
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
270
303
|
let sampledBytes = 0
|
|
271
304
|
try {
|
|
305
|
+
// Acquire both the stat/read lease lazily: merely receiving the
|
|
306
|
+
// iterable owns no network resource. Every iterator owns and closes
|
|
307
|
+
// its own stream, including break/throw/abort paths.
|
|
308
|
+
const safeTarget = await filesystem.canonicalTarget(target, signal)
|
|
309
|
+
await filesystem.requireRegular(safeTarget, signal)
|
|
310
|
+
const { alias } = filesystem.current()
|
|
311
|
+
stream = await filesystem.engine.readStream(alias, String(safeTarget.targetKey), signal)
|
|
312
|
+
if (signal !== undefined) {
|
|
313
|
+
onAbort = () => { try { stream?.destroy(signal.reason instanceof Error ? signal.reason : undefined) } catch { /* already closed */ } }
|
|
314
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
315
|
+
if (signal.aborted) onAbort()
|
|
316
|
+
}
|
|
272
317
|
for await (const chunk of stream) {
|
|
273
318
|
assertNotAborted(signal, 'read')
|
|
274
319
|
const bytes = Buffer.from(chunk as Uint8Array)
|
|
@@ -284,6 +329,9 @@ export class SshFileSystem extends FileSystem {
|
|
|
284
329
|
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
|
|
285
330
|
}
|
|
286
331
|
}
|
|
332
|
+
// destroy() may end quietly when AbortSignal.reason is not an Error;
|
|
333
|
+
// classification must still come from the caller-owned signal.
|
|
334
|
+
assertNotAborted(signal, 'read')
|
|
287
335
|
try {
|
|
288
336
|
decoder.decode()
|
|
289
337
|
} catch (error: unknown) {
|
|
@@ -291,28 +339,33 @@ export class SshFileSystem extends FileSystem {
|
|
|
291
339
|
}
|
|
292
340
|
} catch (error: unknown) {
|
|
293
341
|
throw mapError(error, 'read', displayPath, signal)
|
|
342
|
+
} finally {
|
|
343
|
+
if (signal !== undefined && onAbort !== undefined) signal.removeEventListener('abort', onAbort)
|
|
344
|
+
if (stream !== undefined && !stream.destroyed) stream.destroy()
|
|
294
345
|
}
|
|
295
346
|
},
|
|
296
347
|
}
|
|
297
348
|
}
|
|
298
349
|
|
|
299
350
|
override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
|
|
300
|
-
const
|
|
351
|
+
const safeTarget = await this.canonicalTarget(target, signal)
|
|
352
|
+
const info = await this.stat(safeTarget, signal)
|
|
301
353
|
if (info === undefined) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
|
302
354
|
if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY')
|
|
303
355
|
const { alias } = this.current()
|
|
304
356
|
try {
|
|
305
|
-
const listed = await this.engine.ls(alias, String(
|
|
357
|
+
const listed = await this.engine.ls(alias, String(safeTarget.targetKey), signal)
|
|
306
358
|
assertNotAborted(signal, 'list')
|
|
307
359
|
const displayPaths = listed.map(entry => posix.join(target.displayPath, entry.name))
|
|
308
360
|
// One batch SFTP pass instead of one `realpath` exec per entry (P1-26);
|
|
309
361
|
// symlink canonicalization is kept — target keys stay jail-safe.
|
|
310
|
-
const canonicalPaths = await this.engine.realpaths(alias, displayPaths)
|
|
362
|
+
const canonicalPaths = await this.engine.realpaths(alias, displayPaths, signal)
|
|
311
363
|
const entries: FsDirEntry[] = []
|
|
312
364
|
for (let i = 0; i < listed.length; i += 1) {
|
|
313
365
|
const entry = listed[i]!
|
|
314
366
|
const displayPath = displayPaths[i]!
|
|
315
367
|
const canonical = canonicalPaths[i]!
|
|
368
|
+
this.confine(canonical)
|
|
316
369
|
const stats = this.asStats({
|
|
317
370
|
type: entry.type === 'dir' ? 'directory' : entry.type === 'file' ? 'file' : 'other',
|
|
318
371
|
size: entry.size,
|
|
@@ -339,7 +392,9 @@ export class SshFileSystem extends FileSystem {
|
|
|
339
392
|
expected?: FsWriteIntent,
|
|
340
393
|
signal?: AbortSignal,
|
|
341
394
|
): Promise<FsWriteOutcome> {
|
|
342
|
-
|
|
395
|
+
target = await this.canonicalTarget(target, signal)
|
|
396
|
+
const targetPath = this.processPath(target)
|
|
397
|
+
return this.withLock(targetPath, async () => {
|
|
343
398
|
const existing = await this.probe(String(target.targetKey), target.displayPath, signal)
|
|
344
399
|
if (existing !== undefined && !existing.isFile()) {
|
|
345
400
|
throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
|
@@ -362,7 +417,9 @@ export class SshFileSystem extends FileSystem {
|
|
|
362
417
|
expected?: { version: ReturnType<typeof FsVersion> },
|
|
363
418
|
signal?: AbortSignal,
|
|
364
419
|
): Promise<FsEditOutcome> {
|
|
365
|
-
|
|
420
|
+
target = await this.canonicalTarget(target, signal)
|
|
421
|
+
const targetPath = this.processPath(target)
|
|
422
|
+
return this.withLock(targetPath, async () => {
|
|
366
423
|
const existing = await this.probe(String(target.targetKey), target.displayPath, signal)
|
|
367
424
|
if (existing === undefined) {
|
|
368
425
|
throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
|
@@ -394,9 +451,15 @@ export class SshFileSystem extends FileSystem {
|
|
|
394
451
|
}
|
|
395
452
|
}
|
|
396
453
|
|
|
454
|
+
private async canonicalTarget(target: FsTarget, signal?: AbortSignal): Promise<FsTarget> {
|
|
455
|
+
const canonical = await this.canonicalPath(String(target.targetKey), signal)
|
|
456
|
+
this.confine(canonical)
|
|
457
|
+
return { targetKey: FsTargetKey(canonical), displayPath: target.displayPath }
|
|
458
|
+
}
|
|
459
|
+
|
|
397
460
|
private async canonicalPath(path: string, signal?: AbortSignal): Promise<string> {
|
|
398
461
|
const { alias } = this.current()
|
|
399
|
-
const result = await this.engine.exec(alias, `set -o pipefail; realpath -mz -- ${quoteShellArg(path)} | base64 -w0`, 10_000)
|
|
462
|
+
const result = await this.engine.exec(alias, `set -o pipefail; realpath -mz -- ${quoteShellArg(path)} | base64 -w0`, { timeoutMs: 10_000, signal })
|
|
400
463
|
signal?.throwIfAborted()
|
|
401
464
|
if (!result.success || result.exitCode !== 0) throw new Error(result.stderr || `realpath failed for ${path}`)
|
|
402
465
|
return decodeCanonicalPath(result.stdout.trim())
|
|
@@ -406,7 +469,7 @@ export class SshFileSystem extends FileSystem {
|
|
|
406
469
|
assertNotAborted(signal, 'stat')
|
|
407
470
|
const { alias } = this.current()
|
|
408
471
|
try {
|
|
409
|
-
const info = await this.engine.stat(alias, path)
|
|
472
|
+
const info = await this.engine.stat(alias, path, signal)
|
|
410
473
|
assertNotAborted(signal, 'stat')
|
|
411
474
|
return this.asStats({ type: info.type, size: info.size, mtimeMs: info.mtimeMs, mode: info.mode })
|
|
412
475
|
} catch (error: unknown) {
|
|
@@ -425,7 +488,8 @@ export class SshFileSystem extends FileSystem {
|
|
|
425
488
|
private async readBytesRaw(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
|
|
426
489
|
const { alias } = this.current()
|
|
427
490
|
try {
|
|
428
|
-
const
|
|
491
|
+
const safeTarget = await this.canonicalTarget(target, signal)
|
|
492
|
+
const data = await this.engine.readFile(alias, String(safeTarget.targetKey), maxBytes, signal)
|
|
429
493
|
assertNotAborted(signal, 'read')
|
|
430
494
|
if (data.content.length > maxBytes) {
|
|
431
495
|
throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
|
|
@@ -449,15 +513,17 @@ export class SshFileSystem extends FileSystem {
|
|
|
449
513
|
|
|
450
514
|
private async readForDiff(target: FsTarget, signal?: AbortSignal): Promise<string | null> {
|
|
451
515
|
try {
|
|
452
|
-
return normalizeLineEndings(decodeText(await this.readBytesRaw(target, signal,
|
|
516
|
+
return normalizeLineEndings(decodeText(await this.readBytesRaw(target, signal, DIFF_BASIS_MAX_BYTES), target.displayPath))
|
|
453
517
|
} catch (error: unknown) {
|
|
454
|
-
|
|
518
|
+
// A non-text or over-limit prior file yields no contextual diff basis but
|
|
519
|
+
// the write still succeeds (presentation falls back to a whole-file diff).
|
|
520
|
+
if (error instanceof FsError && (error.code === 'FS_NOT_TEXT' || error.code === 'FS_TOO_LARGE')) return null
|
|
455
521
|
throw mapError(error, 'read', target.displayPath, signal)
|
|
456
522
|
}
|
|
457
523
|
}
|
|
458
524
|
|
|
459
525
|
private async readForEdit(target: FsTarget, signal?: AbortSignal): Promise<string> {
|
|
460
|
-
return decodeText(await this.readBytesRaw(target, signal,
|
|
526
|
+
return decodeText(await this.readBytesRaw(target, signal, FULL_READ_MAX_BYTES), target.displayPath)
|
|
461
527
|
}
|
|
462
528
|
|
|
463
529
|
private async writeAtomic(
|
|
@@ -473,45 +539,68 @@ export class SshFileSystem extends FileSystem {
|
|
|
473
539
|
const stagingDirectory = posix.join(posix.dirname(targetPath), `.dsh-${randomUUID()}.tmp`)
|
|
474
540
|
const temporary = posix.join(stagingDirectory, 'content')
|
|
475
541
|
let stagingCreated = false
|
|
542
|
+
let published = false
|
|
476
543
|
try {
|
|
477
|
-
await this.engine.mkdir(alias, stagingDirectory)
|
|
544
|
+
await this.engine.mkdir(alias, stagingDirectory, signal)
|
|
478
545
|
stagingCreated = true
|
|
479
|
-
await this.engine.writeFile(alias, temporary, Buffer.from(content, 'utf8'))
|
|
546
|
+
await this.engine.writeFile(alias, temporary, Buffer.from(content, 'utf8'), undefined, signal)
|
|
480
547
|
assertNotAborted(signal, 'write')
|
|
481
548
|
const mode = existing === undefined ? 0o600 : existing.mode & 0o777
|
|
482
|
-
await this.engine.exec(alias, `chmod ${mode.toString(8)} -- ${quoteShellArg(temporary)}`, 10_000)
|
|
549
|
+
const chmod = await this.engine.exec(alias, `chmod ${mode.toString(8)} -- ${quoteShellArg(temporary)}`, { timeoutMs: 10_000, signal })
|
|
550
|
+
if (!chmod.success || chmod.exitCode !== 0) {
|
|
551
|
+
throw new Error(chmod.stderr || `chmod failed with exit code ${chmod.exitCode}`)
|
|
552
|
+
}
|
|
483
553
|
assertNotAborted(signal, 'write')
|
|
484
554
|
if (createIfAbsent) {
|
|
485
555
|
const publication = await this.engine.exec(
|
|
486
556
|
alias,
|
|
487
557
|
`if ln -T -- ${quoteShellArg(temporary)} ${quoteShellArg(targetPath)}; then printf created; elif test -e ${quoteShellArg(targetPath)} || test -L ${quoteShellArg(targetPath)}; then printf exists; else exit 1; fi`,
|
|
488
|
-
10_000,
|
|
558
|
+
{ timeoutMs: 10_000, signal },
|
|
489
559
|
)
|
|
560
|
+
if (!publication.success || publication.exitCode !== 0) {
|
|
561
|
+
throw new Error(publication.stderr || `guarded create failed with exit code ${publication.exitCode}`)
|
|
562
|
+
}
|
|
490
563
|
if (publication.stdout.trim() === 'exists') {
|
|
491
564
|
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
|
|
492
565
|
}
|
|
493
566
|
if (publication.stdout.trim() !== 'created') throw new Error('guarded create returned an invalid publication result')
|
|
567
|
+
published = true
|
|
494
568
|
} else {
|
|
495
|
-
await this.engine.rename(alias, temporary, targetPath)
|
|
569
|
+
await this.engine.rename(alias, temporary, targetPath, signal)
|
|
570
|
+
published = true
|
|
496
571
|
}
|
|
497
|
-
|
|
572
|
+
// Publication is the commit point. Cancellation after it must not report
|
|
573
|
+
// rollback while the target already contains the new content.
|
|
498
574
|
await this.removeStaging(stagingDirectory)
|
|
499
|
-
|
|
575
|
+
stagingCreated = false
|
|
576
|
+
const committed = await this.probe(targetPath, target.displayPath)
|
|
500
577
|
if (committed === undefined) throw new FsError(`cannot write "${target.displayPath}": commit produced no file`, 'FS_IO_ERROR')
|
|
501
578
|
return entryVersion(committed, targetPath)
|
|
502
579
|
} catch (error: unknown) {
|
|
503
|
-
|
|
580
|
+
let cleanupError: unknown
|
|
581
|
+
if (stagingCreated) {
|
|
582
|
+
try { await this.removeStaging(stagingDirectory) } catch (cleanup) { cleanupError = cleanup }
|
|
583
|
+
}
|
|
584
|
+
if (cleanupError !== undefined) {
|
|
585
|
+
const state = published ? 'write was committed but staging cleanup failed' : 'write failed and staging cleanup also failed'
|
|
586
|
+
throw new FsError(`cannot write "${target.displayPath}": ${state}`, 'FS_IO_ERROR', {
|
|
587
|
+
cause: new AggregateError([error, cleanupError], state),
|
|
588
|
+
})
|
|
589
|
+
}
|
|
590
|
+
if (published) {
|
|
591
|
+
// Once publication succeeded, cancellation or a first transient cleanup
|
|
592
|
+
// error cannot truthfully turn the operation into an uncommitted failure.
|
|
593
|
+
const reconciled = await this.probe(targetPath, target.displayPath)
|
|
594
|
+
if (reconciled !== undefined) return entryVersion(reconciled, targetPath)
|
|
595
|
+
throw new FsError(`cannot write "${target.displayPath}": publication could not be reconciled`, 'FS_IO_ERROR', { cause: error })
|
|
596
|
+
}
|
|
504
597
|
throw mapError(error, 'write', target.displayPath, signal)
|
|
505
598
|
}
|
|
506
599
|
}
|
|
507
600
|
|
|
508
601
|
private async removeStaging(directory: string): Promise<void> {
|
|
509
602
|
const { alias } = this.current()
|
|
510
|
-
|
|
511
|
-
await this.engine.rm(alias, directory, true)
|
|
512
|
-
} catch {
|
|
513
|
-
// The target is already committed; an empty private directory cannot turn that write into a failure.
|
|
514
|
-
}
|
|
603
|
+
await this.engine.rm(alias, directory, true)
|
|
515
604
|
}
|
|
516
605
|
|
|
517
606
|
/** Normalize an engine stat/ls shape into the RemoteStats the helpers expect. */
|