@tiphareth/dsh-hardssh 0.1.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/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17780 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +134 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +96 -0
- package/src/client/workspace-gate.ts +225 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- package/src/tools.ts +221 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote filesystem provider for the `ctx.fs` capability seam: paths,
|
|
3
|
+
* contents, and atomic staging files live on the remote host, reached through
|
|
4
|
+
* the dsh-ssh engine's SFTP/exec primitives. Ported and adapted from
|
|
5
|
+
* UynajGI/dsh-ssh (MIT, https://github.com/UynajGI/dsh-ssh) — the seam
|
|
6
|
+
* contract (targets, versions, atomic writes, CRLF handling, canonical path
|
|
7
|
+
* transport) is preserved; the connection owner is replaced by the shared
|
|
8
|
+
* SshEngine and the working directory follows the mode store's remote root.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-hardssh/remote-fs
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
14
|
+
import type { Readable } from 'node:stream'
|
|
15
|
+
import { posix } from 'node:path'
|
|
16
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
17
|
+
import type { SshEngine } from '../ssh/engine.ts'
|
|
18
|
+
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
|
19
|
+
import type {
|
|
20
|
+
FsDirEntry,
|
|
21
|
+
FsEditOutcome,
|
|
22
|
+
FsEditRequest,
|
|
23
|
+
FsInfo,
|
|
24
|
+
FsPathInfo,
|
|
25
|
+
FsTarget,
|
|
26
|
+
FsWriteIntent,
|
|
27
|
+
FsWriteOutcome,
|
|
28
|
+
} from '@deepseek-ai/dsh-fs'
|
|
29
|
+
import type { WorkspaceState } from '../protocol.ts'
|
|
30
|
+
import { quoteShellArg } from './environment.ts'
|
|
31
|
+
|
|
32
|
+
const BINARY_SAMPLE_BYTES = 8192
|
|
33
|
+
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
|
34
|
+
|
|
35
|
+
/** Shape of one remote stat the provider works with (engine-normalized). */
|
|
36
|
+
interface RemoteStats {
|
|
37
|
+
isFile(): boolean
|
|
38
|
+
isDirectory(): boolean
|
|
39
|
+
isSymbolicLink(): boolean
|
|
40
|
+
size: number
|
|
41
|
+
mtime: number
|
|
42
|
+
mode: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assertNotAborted(signal: AbortSignal | undefined, operation: string): void {
|
|
46
|
+
if (signal?.aborted === true) throw new FsError(`${operation} aborted`, 'FS_ABORTED')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeLineEndings(value: string): string {
|
|
50
|
+
return value.replaceAll('\r\n', '\n')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function detectsCrlf(value: string): boolean {
|
|
54
|
+
const sample = value.slice(0, 4096)
|
|
55
|
+
const crlf = sample.split('\r\n').length - 1
|
|
56
|
+
const lf = sample.split('\n').length - 1 - crlf
|
|
57
|
+
return crlf > lf
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function restoreLineEndings(value: string, crlf: boolean): string {
|
|
61
|
+
return crlf ? normalizeLineEndings(value).replaceAll('\n', '\r\n') : value
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function decodeText(bytes: Uint8Array, displayPath: string): string {
|
|
65
|
+
if (bytes.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
|
|
66
|
+
throw new FsError(`cannot read "${displayPath}": binary file`, 'FS_NOT_TEXT')
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
70
|
+
} catch (error: unknown) {
|
|
71
|
+
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Decode a base64-wrapped NUL-terminated canonical path from `realpath -mz`. */
|
|
76
|
+
function decodeCanonicalPath(encoded: string): string {
|
|
77
|
+
if (encoded.length === 0 || !BASE64.test(encoded)) {
|
|
78
|
+
throw new Error('fs-ssh: canonical path transport returned invalid base64')
|
|
79
|
+
}
|
|
80
|
+
const framed = Buffer.from(encoded, 'base64')
|
|
81
|
+
if (framed.toString('base64') !== encoded || framed.length < 2 || framed.at(-1) !== 0 || framed.subarray(0, -1).includes(0)) {
|
|
82
|
+
throw new Error('fs-ssh: canonical path transport returned invalid NUL framing')
|
|
83
|
+
}
|
|
84
|
+
let path: string
|
|
85
|
+
try {
|
|
86
|
+
path = new TextDecoder('utf-8', { fatal: true }).decode(framed.subarray(0, -1))
|
|
87
|
+
} catch (error: unknown) {
|
|
88
|
+
throw new Error('fs-ssh: canonical path is not valid UTF-8', { cause: error })
|
|
89
|
+
}
|
|
90
|
+
if (!posix.isAbsolute(path)) throw new Error('fs-ssh: canonical path is not absolute')
|
|
91
|
+
return path
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function entryType(stats: RemoteStats): FsInfo['type'] {
|
|
95
|
+
if (stats.isFile()) return 'file'
|
|
96
|
+
if (stats.isDirectory()) return 'directory'
|
|
97
|
+
return 'other'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function entryVersion(stats: RemoteStats, path: string): ReturnType<typeof FsVersion> {
|
|
101
|
+
return FsVersion(`ssh:${createHash('sha256').update(JSON.stringify([path, stats.size, stats.mtime, stats.mode])).digest('hex')}`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function mapError(error: unknown, operation: string, displayPath: string, signal?: AbortSignal): FsError {
|
|
105
|
+
if (error instanceof FsError) return error
|
|
106
|
+
if (signal?.aborted === true) return new FsError(`${operation} aborted`, 'FS_ABORTED', { cause: error })
|
|
107
|
+
const code = String((error as { code?: unknown }).code ?? '')
|
|
108
|
+
const message = String(error)
|
|
109
|
+
if (/NO_SUCH_FILE|ENOENT|no such file|does not exist/i.test(`${code} ${message}`)) {
|
|
110
|
+
return new FsError(`cannot ${operation} "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
|
|
111
|
+
}
|
|
112
|
+
if (/PERMISSION_DENIED|EACCES|permission denied/i.test(`${code} ${message}`)) {
|
|
113
|
+
return new FsError(`cannot ${operation} "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
|
|
114
|
+
}
|
|
115
|
+
return new FsError(`cannot ${operation} "${displayPath}": ${message}`, 'FS_IO_ERROR', { cause: error })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function literalEdit(content: string, request: FsEditRequest, displayPath: string): string {
|
|
119
|
+
const oldString = normalizeLineEndings(request.oldString)
|
|
120
|
+
const newString = normalizeLineEndings(request.newString)
|
|
121
|
+
if (oldString.length === 0) {
|
|
122
|
+
throw new FsError(`cannot edit "${displayPath}": old_string must be non-empty`, 'FS_EDIT_NOT_FOUND')
|
|
123
|
+
}
|
|
124
|
+
let matches = 0
|
|
125
|
+
let offset = 0
|
|
126
|
+
while (true) {
|
|
127
|
+
const found = content.indexOf(oldString, offset)
|
|
128
|
+
if (found < 0) break
|
|
129
|
+
matches += 1
|
|
130
|
+
offset = found + oldString.length
|
|
131
|
+
}
|
|
132
|
+
if (matches === 0) throw new FsError(`cannot edit "${displayPath}": old_string was not found`, 'FS_EDIT_NOT_FOUND')
|
|
133
|
+
if (!request.replaceAll && matches !== 1) {
|
|
134
|
+
throw new FsError(`cannot edit "${displayPath}": old_string matched ${matches} times`, 'FS_AMBIGUOUS_EDIT')
|
|
135
|
+
}
|
|
136
|
+
return request.replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether one SFTP/exec error means "path absent". */
|
|
140
|
+
function isNotFound(error: unknown): boolean {
|
|
141
|
+
const code = String((error as { code?: unknown }).code ?? '')
|
|
142
|
+
const message = String(error)
|
|
143
|
+
return /NO_SUCH_FILE|ENOENT|no such file|does not exist/i.test(`${code} ${message}`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Remote filesystem backend over the dsh-ssh engine. The working directory
|
|
148
|
+
* follows the mode store: relative paths resolve against the resolved remote
|
|
149
|
+
* root; a POSIX-absolute cwd override is honored; local (Windows) cwds are
|
|
150
|
+
* ignored so the model's relative-path habit keeps working remotely.
|
|
151
|
+
*/
|
|
152
|
+
export class SshFileSystem extends FileSystem {
|
|
153
|
+
private readonly locks = new Map<string, Promise<unknown>>()
|
|
154
|
+
|
|
155
|
+
constructor(
|
|
156
|
+
ctx: Context,
|
|
157
|
+
private readonly engine: SshEngine,
|
|
158
|
+
private readonly getState: () => WorkspaceState,
|
|
159
|
+
) {
|
|
160
|
+
super(ctx)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The active remote execution world (throws when not in remote mode). */
|
|
164
|
+
private current(): { alias: string; remoteRoot: string } {
|
|
165
|
+
const state = this.getState()
|
|
166
|
+
if (state.mode !== 'remote' || state.alias === undefined) {
|
|
167
|
+
throw new FsError('not in remote mode — switch the GUI to SSH mode first', 'FS_IO_ERROR')
|
|
168
|
+
}
|
|
169
|
+
if (state.remoteRoot === undefined) {
|
|
170
|
+
throw new FsError('remote workspace root is not set', 'FS_IO_ERROR')
|
|
171
|
+
}
|
|
172
|
+
return { alias: state.alias, remoteRoot: state.remoteRoot }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolve the working directory for a path: a POSIX-absolute cwd wins;
|
|
177
|
+
* anything else (relative or a local Windows path) falls back to the
|
|
178
|
+
* remote root.
|
|
179
|
+
*/
|
|
180
|
+
resolveRemoteCwd(cwd: string | undefined): string {
|
|
181
|
+
if (cwd !== undefined && posix.isAbsolute(cwd)) return cwd
|
|
182
|
+
return this.current().remoteRoot
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
|
|
186
|
+
assertNotAborted(opts?.signal, 'resolve')
|
|
187
|
+
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
|
|
188
|
+
const displayPath = posix.resolve(this.resolveRemoteCwd(opts?.cwd), path)
|
|
189
|
+
try {
|
|
190
|
+
const targetKey = await this.canonicalPath(displayPath, opts?.signal)
|
|
191
|
+
assertNotAborted(opts?.signal, 'resolve')
|
|
192
|
+
return { targetKey: FsTargetKey(targetKey), displayPath }
|
|
193
|
+
} catch (error: unknown) {
|
|
194
|
+
throw mapError(error, 'resolve', displayPath, opts?.signal)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
override processPath(target: FsTarget): string {
|
|
199
|
+
return String(target.targetKey)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
override fileUrl(target: FsTarget): string {
|
|
203
|
+
const path = this.processPath(target)
|
|
204
|
+
if (!posix.isAbsolute(path)) throw new Error(`fs-ssh: expected an absolute process path: ${JSON.stringify(path)}`)
|
|
205
|
+
return `file://${path.split('/').map(segment => encodeURIComponent(segment)).join('/')}`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
override contains(parent: FsTarget, child: FsTarget): boolean {
|
|
209
|
+
const relative = posix.relative(this.processPath(parent), this.processPath(child))
|
|
210
|
+
return relative === '' || (relative !== '..' && !relative.startsWith('../') && !posix.isAbsolute(relative))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
|
|
214
|
+
assertNotAborted(signal, 'stat')
|
|
215
|
+
const stats = await this.probe(String(target.targetKey), target.displayPath, signal)
|
|
216
|
+
if (stats === undefined) return undefined
|
|
217
|
+
return {
|
|
218
|
+
version: entryVersion(stats, String(target.targetKey)),
|
|
219
|
+
type: entryType(stats),
|
|
220
|
+
...(stats.isFile() ? { size: stats.size } : {}),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
|
|
225
|
+
assertNotAborted(signal, 'lstat')
|
|
226
|
+
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
|
|
227
|
+
const displayPath = posix.resolve(this.resolveRemoteCwd(opts?.cwd), path)
|
|
228
|
+
const { alias } = this.current()
|
|
229
|
+
try {
|
|
230
|
+
const info = await this.engine.lstat(alias, displayPath)
|
|
231
|
+
assertNotAborted(signal, 'lstat')
|
|
232
|
+
if (info === undefined) return undefined
|
|
233
|
+
const type = info.type === 'symlink' ? 'symlink' as const : info.type === 'directory' ? 'directory' as const : info.type === 'file' ? 'file' as const : 'other' as const
|
|
234
|
+
return {
|
|
235
|
+
version: entryVersion(this.asStats({ type: info.type, size: info.size, mtimeMs: info.mtimeMs, mode: info.mode }), displayPath),
|
|
236
|
+
type,
|
|
237
|
+
...(type === 'file' ? { size: info.size } : {}),
|
|
238
|
+
}
|
|
239
|
+
} catch (error: unknown) {
|
|
240
|
+
if (isNotFound(error)) return undefined
|
|
241
|
+
throw mapError(error, 'lstat', displayPath, signal)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
|
|
246
|
+
await this.requireRegular(target, signal)
|
|
247
|
+
const bytes = await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY)
|
|
248
|
+
assertNotAborted(signal, 'read')
|
|
249
|
+
return decodeText(bytes, target.displayPath)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
|
|
253
|
+
const info = await this.requireRegular(target, signal)
|
|
254
|
+
if (info.size !== undefined && info.size > maxBytes) {
|
|
255
|
+
throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
|
|
256
|
+
}
|
|
257
|
+
const bytes = await this.readBytesRaw(target, signal, maxBytes)
|
|
258
|
+
assertNotAborted(signal, 'read')
|
|
259
|
+
return bytes
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
|
|
263
|
+
await this.requireRegular(target, signal)
|
|
264
|
+
const { alias } = this.current()
|
|
265
|
+
const displayPath = target.displayPath
|
|
266
|
+
const stream = await this.engine.readStream(alias, String(target.targetKey))
|
|
267
|
+
return {
|
|
268
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<string> {
|
|
269
|
+
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
270
|
+
let sampledBytes = 0
|
|
271
|
+
try {
|
|
272
|
+
for await (const chunk of stream) {
|
|
273
|
+
assertNotAborted(signal, 'read')
|
|
274
|
+
const bytes = Buffer.from(chunk as Uint8Array)
|
|
275
|
+
if (sampledBytes < BINARY_SAMPLE_BYTES) {
|
|
276
|
+
const sample = bytes.subarray(0, BINARY_SAMPLE_BYTES - sampledBytes)
|
|
277
|
+
if (sample.includes(0)) throw new FsError(`cannot read "${displayPath}": binary file`, 'FS_NOT_TEXT')
|
|
278
|
+
sampledBytes += sample.length
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const text = decoder.decode(bytes, { stream: true })
|
|
282
|
+
if (text.length > 0) yield text
|
|
283
|
+
} catch (error: unknown) {
|
|
284
|
+
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
decoder.decode()
|
|
289
|
+
} catch (error: unknown) {
|
|
290
|
+
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
|
|
291
|
+
}
|
|
292
|
+
} catch (error: unknown) {
|
|
293
|
+
throw mapError(error, 'read', displayPath, signal)
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
|
|
300
|
+
const info = await this.stat(target, signal)
|
|
301
|
+
if (info === undefined) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
|
302
|
+
if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY')
|
|
303
|
+
const { alias } = this.current()
|
|
304
|
+
try {
|
|
305
|
+
const listed = await this.engine.ls(alias, String(target.targetKey))
|
|
306
|
+
assertNotAborted(signal, 'list')
|
|
307
|
+
const displayPaths = listed.map(entry => posix.join(target.displayPath, entry.name))
|
|
308
|
+
// One batch SFTP pass instead of one `realpath` exec per entry (P1-26);
|
|
309
|
+
// symlink canonicalization is kept — target keys stay jail-safe.
|
|
310
|
+
const canonicalPaths = await this.engine.realpaths(alias, displayPaths)
|
|
311
|
+
const entries: FsDirEntry[] = []
|
|
312
|
+
for (let i = 0; i < listed.length; i += 1) {
|
|
313
|
+
const entry = listed[i]!
|
|
314
|
+
const displayPath = displayPaths[i]!
|
|
315
|
+
const canonical = canonicalPaths[i]!
|
|
316
|
+
const stats = this.asStats({
|
|
317
|
+
type: entry.type === 'dir' ? 'directory' : entry.type === 'file' ? 'file' : 'other',
|
|
318
|
+
size: entry.size,
|
|
319
|
+
mtimeMs: entry.mtimeMs,
|
|
320
|
+
mode: entry.mode ?? 0o600,
|
|
321
|
+
})
|
|
322
|
+
entries.push({
|
|
323
|
+
name: entry.name,
|
|
324
|
+
type: entryType(stats),
|
|
325
|
+
target: { targetKey: FsTargetKey(canonical), displayPath },
|
|
326
|
+
version: entryVersion(stats, canonical),
|
|
327
|
+
...(entry.type === 'file' ? { size: entry.size } : {}),
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
return entries.sort((left, right) => left.name.localeCompare(right.name))
|
|
331
|
+
} catch (error: unknown) {
|
|
332
|
+
throw mapError(error, 'list', target.displayPath, signal)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
override async writeText(
|
|
337
|
+
target: FsTarget,
|
|
338
|
+
content: string,
|
|
339
|
+
expected?: FsWriteIntent,
|
|
340
|
+
signal?: AbortSignal,
|
|
341
|
+
): Promise<FsWriteOutcome> {
|
|
342
|
+
return this.withLock(String(target.targetKey), async () => {
|
|
343
|
+
const existing = await this.probe(String(target.targetKey), target.displayPath, signal)
|
|
344
|
+
if (existing !== undefined && !existing.isFile()) {
|
|
345
|
+
throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
|
346
|
+
}
|
|
347
|
+
this.checkWriteIntent(existing, expected, target)
|
|
348
|
+
const before = existing === undefined ? null : await this.readForDiff(target, signal)
|
|
349
|
+
const version = await this.writeAtomic(target, content, existing, expected?.kind === 'createIfAbsent', signal)
|
|
350
|
+
return {
|
|
351
|
+
operation: existing === undefined ? 'create' : 'update',
|
|
352
|
+
version,
|
|
353
|
+
before,
|
|
354
|
+
after: normalizeLineEndings(content),
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
override async editText(
|
|
360
|
+
target: FsTarget,
|
|
361
|
+
edit: FsEditRequest,
|
|
362
|
+
expected?: { version: ReturnType<typeof FsVersion> },
|
|
363
|
+
signal?: AbortSignal,
|
|
364
|
+
): Promise<FsEditOutcome> {
|
|
365
|
+
return this.withLock(String(target.targetKey), async () => {
|
|
366
|
+
const existing = await this.probe(String(target.targetKey), target.displayPath, signal)
|
|
367
|
+
if (existing === undefined) {
|
|
368
|
+
throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
|
369
|
+
}
|
|
370
|
+
if (!existing.isFile()) {
|
|
371
|
+
throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
|
372
|
+
}
|
|
373
|
+
if (expected !== undefined && entryVersion(existing, String(target.targetKey)) !== expected.version) {
|
|
374
|
+
throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
|
375
|
+
}
|
|
376
|
+
const raw = await this.readForEdit(target, signal)
|
|
377
|
+
const before = normalizeLineEndings(raw)
|
|
378
|
+
const after = literalEdit(before, edit, target.displayPath)
|
|
379
|
+
const storage = restoreLineEndings(after, detectsCrlf(raw))
|
|
380
|
+
const version = await this.writeAtomic(target, storage, existing, false, signal)
|
|
381
|
+
return { version, before, after }
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private async withLock<T>(targetKey: string, operation: () => Promise<T>): Promise<T> {
|
|
386
|
+
const prior = this.locks.get(targetKey) ?? Promise.resolve()
|
|
387
|
+
const run = prior.then(operation, operation)
|
|
388
|
+
const tail = run.then(() => undefined, () => undefined)
|
|
389
|
+
this.locks.set(targetKey, tail)
|
|
390
|
+
try {
|
|
391
|
+
return await run
|
|
392
|
+
} finally {
|
|
393
|
+
if (this.locks.get(targetKey) === tail) this.locks.delete(targetKey)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private async canonicalPath(path: string, signal?: AbortSignal): Promise<string> {
|
|
398
|
+
const { alias } = this.current()
|
|
399
|
+
const result = await this.engine.exec(alias, `set -o pipefail; realpath -mz -- ${quoteShellArg(path)} | base64 -w0`, 10_000)
|
|
400
|
+
signal?.throwIfAborted()
|
|
401
|
+
if (!result.success || result.exitCode !== 0) throw new Error(result.stderr || `realpath failed for ${path}`)
|
|
402
|
+
return decodeCanonicalPath(result.stdout.trim())
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private async probe(path: string, displayPath: string, signal?: AbortSignal): Promise<RemoteStats | undefined> {
|
|
406
|
+
assertNotAborted(signal, 'stat')
|
|
407
|
+
const { alias } = this.current()
|
|
408
|
+
try {
|
|
409
|
+
const info = await this.engine.stat(alias, path)
|
|
410
|
+
assertNotAborted(signal, 'stat')
|
|
411
|
+
return this.asStats({ type: info.type, size: info.size, mtimeMs: info.mtimeMs, mode: info.mode })
|
|
412
|
+
} catch (error: unknown) {
|
|
413
|
+
if (isNotFound(error)) return undefined
|
|
414
|
+
throw mapError(error, 'stat', displayPath, signal)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise<FsInfo> {
|
|
419
|
+
const info = await this.stat(target, signal)
|
|
420
|
+
if (info === undefined) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
|
421
|
+
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
|
422
|
+
return info
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private async readBytesRaw(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
|
|
426
|
+
const { alias } = this.current()
|
|
427
|
+
try {
|
|
428
|
+
const data = await this.engine.readFile(alias, String(target.targetKey))
|
|
429
|
+
assertNotAborted(signal, 'read')
|
|
430
|
+
if (data.content.length > maxBytes) {
|
|
431
|
+
throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
|
|
432
|
+
}
|
|
433
|
+
return data.content
|
|
434
|
+
} catch (error: unknown) {
|
|
435
|
+
throw mapError(error, 'read', target.displayPath, signal)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private checkWriteIntent(existing: RemoteStats | undefined, expected: FsWriteIntent | undefined, target: FsTarget): void {
|
|
440
|
+
if (expected?.kind === 'createIfAbsent' && existing !== undefined) {
|
|
441
|
+
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
|
|
442
|
+
}
|
|
443
|
+
if (expected?.kind === 'replaceIfVersion') {
|
|
444
|
+
if (existing === undefined || entryVersion(existing, String(target.targetKey)) !== expected.version) {
|
|
445
|
+
throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private async readForDiff(target: FsTarget, signal?: AbortSignal): Promise<string | null> {
|
|
451
|
+
try {
|
|
452
|
+
return normalizeLineEndings(decodeText(await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY), target.displayPath))
|
|
453
|
+
} catch (error: unknown) {
|
|
454
|
+
if (error instanceof FsError && error.code === 'FS_NOT_TEXT') return null
|
|
455
|
+
throw mapError(error, 'read', target.displayPath, signal)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private async readForEdit(target: FsTarget, signal?: AbortSignal): Promise<string> {
|
|
460
|
+
return decodeText(await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY), target.displayPath)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private async writeAtomic(
|
|
464
|
+
target: FsTarget,
|
|
465
|
+
content: string,
|
|
466
|
+
existing: RemoteStats | undefined,
|
|
467
|
+
createIfAbsent: boolean,
|
|
468
|
+
signal?: AbortSignal,
|
|
469
|
+
): Promise<ReturnType<typeof FsVersion>> {
|
|
470
|
+
assertNotAborted(signal, 'write')
|
|
471
|
+
const { alias } = this.current()
|
|
472
|
+
const targetPath = String(target.targetKey)
|
|
473
|
+
const stagingDirectory = posix.join(posix.dirname(targetPath), `.dsh-${randomUUID()}.tmp`)
|
|
474
|
+
const temporary = posix.join(stagingDirectory, 'content')
|
|
475
|
+
let stagingCreated = false
|
|
476
|
+
try {
|
|
477
|
+
await this.engine.mkdir(alias, stagingDirectory)
|
|
478
|
+
stagingCreated = true
|
|
479
|
+
await this.engine.writeFile(alias, temporary, Buffer.from(content, 'utf8'))
|
|
480
|
+
assertNotAborted(signal, 'write')
|
|
481
|
+
const mode = existing === undefined ? 0o600 : existing.mode & 0o777
|
|
482
|
+
await this.engine.exec(alias, `chmod ${mode.toString(8)} -- ${quoteShellArg(temporary)}`, 10_000)
|
|
483
|
+
assertNotAborted(signal, 'write')
|
|
484
|
+
if (createIfAbsent) {
|
|
485
|
+
const publication = await this.engine.exec(
|
|
486
|
+
alias,
|
|
487
|
+
`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,
|
|
489
|
+
)
|
|
490
|
+
if (publication.stdout.trim() === 'exists') {
|
|
491
|
+
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
|
|
492
|
+
}
|
|
493
|
+
if (publication.stdout.trim() !== 'created') throw new Error('guarded create returned an invalid publication result')
|
|
494
|
+
} else {
|
|
495
|
+
await this.engine.rename(alias, temporary, targetPath)
|
|
496
|
+
}
|
|
497
|
+
assertNotAborted(signal, 'write')
|
|
498
|
+
await this.removeStaging(stagingDirectory)
|
|
499
|
+
const committed = await this.probe(targetPath, target.displayPath, signal)
|
|
500
|
+
if (committed === undefined) throw new FsError(`cannot write "${target.displayPath}": commit produced no file`, 'FS_IO_ERROR')
|
|
501
|
+
return entryVersion(committed, targetPath)
|
|
502
|
+
} catch (error: unknown) {
|
|
503
|
+
if (stagingCreated) await this.removeStaging(stagingDirectory)
|
|
504
|
+
throw mapError(error, 'write', target.displayPath, signal)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private async removeStaging(directory: string): Promise<void> {
|
|
509
|
+
const { alias } = this.current()
|
|
510
|
+
try {
|
|
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
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Normalize an engine stat/ls shape into the RemoteStats the helpers expect. */
|
|
518
|
+
private asStats(info: { type: 'dir' | 'file' | 'other' | 'directory' | 'symlink'; size: number; mtimeMs: number; mode: number }): RemoteStats {
|
|
519
|
+
const isDir = info.type === 'dir' || info.type === 'directory'
|
|
520
|
+
const isFile = info.type === 'file'
|
|
521
|
+
return {
|
|
522
|
+
isFile: () => isFile,
|
|
523
|
+
isDirectory: () => isDir,
|
|
524
|
+
isSymbolicLink: () => info.type === 'symlink',
|
|
525
|
+
size: info.size,
|
|
526
|
+
mtime: Math.round(info.mtimeMs / 1000),
|
|
527
|
+
mode: info.mode,
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export default SshFileSystem
|