@tiphareth/dsh-hardssh 0.1.2-alpha
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 +17794 -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 +142 -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 +99 -0
- package/src/client/workspace-gate.ts +232 -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
package/src/backend.ts
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gated filesystem backends and the production workspace file service.
|
|
3
|
+
*
|
|
4
|
+
* UI workspace routes resolve an exact ledger anchor and then create a
|
|
5
|
+
* request-scoped RemoteBackend. There is no implicit local fallback and no
|
|
6
|
+
* dependency on the legacy global SSH mode state.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Dirent, Stats } from 'node:fs'
|
|
10
|
+
import {
|
|
11
|
+
mkdir,
|
|
12
|
+
readdir,
|
|
13
|
+
readFile,
|
|
14
|
+
realpath,
|
|
15
|
+
stat,
|
|
16
|
+
writeFile,
|
|
17
|
+
} from 'node:fs/promises'
|
|
18
|
+
import {
|
|
19
|
+
dirname,
|
|
20
|
+
isAbsolute,
|
|
21
|
+
join,
|
|
22
|
+
relative,
|
|
23
|
+
resolve,
|
|
24
|
+
sep,
|
|
25
|
+
} from 'node:path'
|
|
26
|
+
import { posix } from 'node:path'
|
|
27
|
+
import type { SshEngine } from './ssh/engine.ts'
|
|
28
|
+
import { shellQuote } from './shell.ts'
|
|
29
|
+
import { RemoteSearchService, type RemoteNameSearch } from './remote-search.ts'
|
|
30
|
+
import type { SshWorkspaceLedger } from './ledger.ts'
|
|
31
|
+
import type {
|
|
32
|
+
DirListing,
|
|
33
|
+
FileRead,
|
|
34
|
+
FileWriteResult,
|
|
35
|
+
SearchHit,
|
|
36
|
+
SearchView,
|
|
37
|
+
SshWorkspaceRecord,
|
|
38
|
+
WorkspaceEntry,
|
|
39
|
+
} from './protocol.ts'
|
|
40
|
+
|
|
41
|
+
export const SEARCH_HIT_CAP = 200
|
|
42
|
+
export const SEARCH_SCAN_CAP = 20_000
|
|
43
|
+
export const SEARCH_MAX_DEPTH = 4
|
|
44
|
+
export const REMOTE_SEARCH_TIMEOUT_MS = 20_000
|
|
45
|
+
|
|
46
|
+
const SEARCH_SKIP_DIRS = new Set(['.git', 'node_modules'])
|
|
47
|
+
const TREE_SKIP_DIRS = new Set(['.git'])
|
|
48
|
+
|
|
49
|
+
export type BackendErrorCode =
|
|
50
|
+
| 'binary'
|
|
51
|
+
| 'too-large'
|
|
52
|
+
| 'conflict'
|
|
53
|
+
| 'outside-root'
|
|
54
|
+
| 'not-remote'
|
|
55
|
+
| 'root-mismatch'
|
|
56
|
+
| 'not-found'
|
|
57
|
+
| 'forbidden'
|
|
58
|
+
| 'invalid'
|
|
59
|
+
| 'io'
|
|
60
|
+
|
|
61
|
+
export class BackendError extends Error {
|
|
62
|
+
constructor(
|
|
63
|
+
public readonly code: BackendErrorCode,
|
|
64
|
+
message: string,
|
|
65
|
+
options?: { cause?: unknown },
|
|
66
|
+
) {
|
|
67
|
+
super(message, options)
|
|
68
|
+
this.name = 'BackendError'
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface WorkspaceFileContext {
|
|
73
|
+
workspaceId: string
|
|
74
|
+
requestedRoot: string
|
|
75
|
+
anchorPath: string
|
|
76
|
+
alias: string
|
|
77
|
+
remoteRoot: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface WorkspaceFileService {
|
|
81
|
+
resolveContext(root: string): Promise<WorkspaceFileContext>
|
|
82
|
+
list(root: string, rel: string): Promise<DirListing>
|
|
83
|
+
read(root: string, rel: string): Promise<FileRead>
|
|
84
|
+
write(
|
|
85
|
+
root: string,
|
|
86
|
+
rel: string,
|
|
87
|
+
content: string,
|
|
88
|
+
expectedMtime?: number,
|
|
89
|
+
): Promise<FileWriteResult>
|
|
90
|
+
search(root: string, query: string): Promise<SearchView>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface WorkspaceBackend {
|
|
94
|
+
assertRoot(root: string): void
|
|
95
|
+
list(root: string, rel: string): Promise<DirListing>
|
|
96
|
+
read(root: string, rel: string): Promise<FileRead>
|
|
97
|
+
write(
|
|
98
|
+
root: string,
|
|
99
|
+
rel: string,
|
|
100
|
+
content: string,
|
|
101
|
+
expectedMtime?: number,
|
|
102
|
+
): Promise<FileWriteResult>
|
|
103
|
+
search(root: string, query: string): Promise<SearchView>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Normalize a workspace-relative path.
|
|
108
|
+
*
|
|
109
|
+
* Backslashes are rejected instead of treated as separators: the same input
|
|
110
|
+
* would mean a separator on Windows and a filename character on POSIX.
|
|
111
|
+
* Absolute paths, drive-qualified paths, NUL and ".." are rejected.
|
|
112
|
+
*/
|
|
113
|
+
export function normalizeRel(raw: string): string {
|
|
114
|
+
if (typeof raw !== 'string') {
|
|
115
|
+
throw new BackendError('invalid', 'path must be a string')
|
|
116
|
+
}
|
|
117
|
+
if (raw.includes('\0')) {
|
|
118
|
+
throw new BackendError('invalid', 'path contains a NUL byte')
|
|
119
|
+
}
|
|
120
|
+
if (raw.includes('\\')) {
|
|
121
|
+
throw new BackendError('invalid', 'workspace-relative paths must use forward slashes')
|
|
122
|
+
}
|
|
123
|
+
if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw) || raw.startsWith('//')) {
|
|
124
|
+
throw new BackendError('outside-root', 'path must be workspace-relative')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const parts = raw.split('/').filter(part => part !== '' && part !== '.')
|
|
128
|
+
if (parts.some(part => part === '..')) {
|
|
129
|
+
throw new BackendError('outside-root', 'path escapes root: ".." is not allowed')
|
|
130
|
+
}
|
|
131
|
+
return parts.join('/')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** POSIX prefix gate. Handles "/" without producing an empty root. */
|
|
135
|
+
export function isInside(root: string, abs: string): boolean {
|
|
136
|
+
const normalizedRoot = normalizeRemoteRoot(root)
|
|
137
|
+
const normalizedAbs = posix.normalize(abs)
|
|
138
|
+
if (!posix.isAbsolute(normalizedAbs)) return false
|
|
139
|
+
if (normalizedRoot === '/') return normalizedAbs.startsWith('/')
|
|
140
|
+
return normalizedAbs === normalizedRoot || normalizedAbs.startsWith(`${normalizedRoot}/`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Resolve a normalized relative path below a POSIX root. */
|
|
144
|
+
export function relToAbs(root: string, rel: string): string {
|
|
145
|
+
const normalizedRoot = normalizeRemoteRoot(root)
|
|
146
|
+
const normalizedRel = normalizeRel(rel)
|
|
147
|
+
const abs = normalizedRel === '' ? normalizedRoot : posix.resolve(normalizedRoot, normalizedRel)
|
|
148
|
+
if (!isInside(normalizedRoot, abs)) {
|
|
149
|
+
throw new BackendError('outside-root', `path '${rel}' escapes remote root '${normalizedRoot}'`)
|
|
150
|
+
}
|
|
151
|
+
return abs
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { shellQuote }
|
|
155
|
+
|
|
156
|
+
/** Stable dir-first, case-insensitive ordering shared by every backend. */
|
|
157
|
+
export function sortWorkspaceEntries(entries: WorkspaceEntry[]): WorkspaceEntry[] {
|
|
158
|
+
return [...entries].sort((a, b) => {
|
|
159
|
+
const aDir = a.type === 'dir'
|
|
160
|
+
const bDir = b.type === 'dir'
|
|
161
|
+
if (aDir !== bDir) return aDir ? -1 : 1
|
|
162
|
+
const insensitive = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
|
163
|
+
return insensitive !== 0 ? insensitive : a.name.localeCompare(b.name)
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type WorkspacePathFlavor = 'local' | 'remote'
|
|
168
|
+
|
|
169
|
+
/** Return abs relative to root, rejecting prefix-collision/parent traversal. */
|
|
170
|
+
export function relativeWorkspacePath(
|
|
171
|
+
root: string,
|
|
172
|
+
abs: string,
|
|
173
|
+
flavor: WorkspacePathFlavor,
|
|
174
|
+
): string {
|
|
175
|
+
if (flavor === 'remote') {
|
|
176
|
+
const normalizedRoot = normalizeRemoteRoot(root)
|
|
177
|
+
const normalizedAbs = posix.normalize(abs)
|
|
178
|
+
if (!isInside(normalizedRoot, normalizedAbs)) {
|
|
179
|
+
throw new BackendError('outside-root', `path '${abs}' is outside root '${root}'`)
|
|
180
|
+
}
|
|
181
|
+
const rel = posix.relative(normalizedRoot, normalizedAbs)
|
|
182
|
+
return rel === '' ? '' : normalizeRel(rel)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const normalizedRoot = resolve(root)
|
|
186
|
+
const normalizedAbs = resolve(abs)
|
|
187
|
+
const rel = relative(normalizedRoot, normalizedAbs)
|
|
188
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
189
|
+
throw new BackendError('outside-root', `path '${abs}' is outside root '${root}'`)
|
|
190
|
+
}
|
|
191
|
+
return rel === '' ? '' : rel.split(sep).join('/')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Normalize filesystem/SFTP errors to stable route-visible codes. */
|
|
195
|
+
export function toBackendIoError(error: unknown, path: string): BackendError {
|
|
196
|
+
if (error instanceof BackendError) return error
|
|
197
|
+
const errno = error as NodeJS.ErrnoException
|
|
198
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
199
|
+
const lower = message.toLowerCase()
|
|
200
|
+
|
|
201
|
+
if (
|
|
202
|
+
errno.code === 'ENOENT'
|
|
203
|
+
|| errno.code === 'ENOTDIR'
|
|
204
|
+
|| /\bno such file\b/.test(lower)
|
|
205
|
+
|| /\bnot found\b/.test(lower)
|
|
206
|
+
) {
|
|
207
|
+
return new BackendError('not-found', `'${path}' was not found`, { cause: error })
|
|
208
|
+
}
|
|
209
|
+
if (
|
|
210
|
+
errno.code === 'EACCES'
|
|
211
|
+
|| errno.code === 'EPERM'
|
|
212
|
+
|| /\bpermission denied\b/.test(lower)
|
|
213
|
+
) {
|
|
214
|
+
return new BackendError('forbidden', `permission denied for '${path}'`, { cause: error })
|
|
215
|
+
}
|
|
216
|
+
if (/\bmtime conflict\b/.test(lower) || /\bconflict\b/.test(lower)) {
|
|
217
|
+
return new BackendError('conflict', message, { cause: error })
|
|
218
|
+
}
|
|
219
|
+
return new BackendError('io', `'${path}': ${message}`, { cause: error })
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Map a BackendError to its stable HTTP status (routes use this). */
|
|
223
|
+
export function backendErrorStatus(error: BackendError, ioStatus: 500 | 502): number {
|
|
224
|
+
switch (error.code) {
|
|
225
|
+
case 'outside-root':
|
|
226
|
+
case 'root-mismatch':
|
|
227
|
+
case 'forbidden':
|
|
228
|
+
return 403
|
|
229
|
+
case 'not-found':
|
|
230
|
+
return 404
|
|
231
|
+
case 'conflict':
|
|
232
|
+
return 409
|
|
233
|
+
case 'binary':
|
|
234
|
+
case 'invalid':
|
|
235
|
+
return 400
|
|
236
|
+
case 'too-large':
|
|
237
|
+
return 413
|
|
238
|
+
case 'not-remote':
|
|
239
|
+
return 503
|
|
240
|
+
case 'io':
|
|
241
|
+
return ioStatus
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function normalizeRemoteRoot(root: string): string {
|
|
246
|
+
if (typeof root !== 'string' || root.includes('\0')) {
|
|
247
|
+
throw new BackendError('invalid', 'remote root is invalid')
|
|
248
|
+
}
|
|
249
|
+
if (root.includes('\\') || !posix.isAbsolute(root)) {
|
|
250
|
+
throw new BackendError('root-mismatch', `remote root must be an absolute POSIX path (got '${root}')`)
|
|
251
|
+
}
|
|
252
|
+
const normalized = posix.normalize(root)
|
|
253
|
+
return normalized === '/' ? '/' : normalized.replace(/\/+$/, '')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function decodeText(buffer: Buffer, path: string): string {
|
|
257
|
+
const probe = buffer.subarray(0, Math.min(buffer.length, 8192))
|
|
258
|
+
if (probe.includes(0)) {
|
|
259
|
+
throw new BackendError('binary', `'${path}' is not a text file`)
|
|
260
|
+
}
|
|
261
|
+
return buffer.toString('utf8')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function canonicalLocalPath(path: string): string {
|
|
265
|
+
const result = resolve(path).replace(/[\\/]+$/, '')
|
|
266
|
+
return process.platform === 'win32' ? result.toLowerCase() : result
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function sameLocalPath(a: string, b: string): boolean {
|
|
270
|
+
return canonicalLocalPath(a) === canonicalLocalPath(b)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function sanitizeSearchQuery(query: string): string {
|
|
274
|
+
return query.replace(/\0/g, '').replace(/[\r\n]/g, ' ').slice(0, 128)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Local implementation retained for explicit local-only consumers. It is
|
|
279
|
+
* deliberately NOT selected by LedgerWorkspaceFileService.
|
|
280
|
+
*/
|
|
281
|
+
export class LocalBackend implements WorkspaceBackend {
|
|
282
|
+
assertRoot(root: string): void {
|
|
283
|
+
if (!isAbsolute(root)) {
|
|
284
|
+
throw new BackendError('outside-root', `root must be an absolute local path (got '${root}')`)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async list(root: string, rel: string): Promise<DirListing> {
|
|
289
|
+
const abs = await this.resolvePath(root, rel)
|
|
290
|
+
let dirents: Dirent[]
|
|
291
|
+
try {
|
|
292
|
+
dirents = await readdir(abs, { withFileTypes: true })
|
|
293
|
+
} catch (error) {
|
|
294
|
+
throw toBackendIoError(error, abs)
|
|
295
|
+
}
|
|
296
|
+
const entries = dirents
|
|
297
|
+
.filter(entry => !TREE_SKIP_DIRS.has(entry.name))
|
|
298
|
+
.map((entry): WorkspaceEntry => ({
|
|
299
|
+
name: entry.name,
|
|
300
|
+
type: entry.isDirectory() ? 'dir' : entry.isFile() ? 'file' : 'other',
|
|
301
|
+
size: 0,
|
|
302
|
+
mtimeMs: 0,
|
|
303
|
+
}))
|
|
304
|
+
return { path: abs, entries: sortWorkspaceEntries(entries) }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async read(root: string, rel: string): Promise<FileRead> {
|
|
308
|
+
const abs = await this.resolvePath(root, rel)
|
|
309
|
+
let buffer: Buffer
|
|
310
|
+
let stats: Stats
|
|
311
|
+
try {
|
|
312
|
+
;[buffer, stats] = await Promise.all([readFile(abs), stat(abs)])
|
|
313
|
+
} catch (error) {
|
|
314
|
+
throw toBackendIoError(error, abs)
|
|
315
|
+
}
|
|
316
|
+
if (!stats.isFile()) {
|
|
317
|
+
throw new BackendError('invalid', `'${abs}' is not a regular file`)
|
|
318
|
+
}
|
|
319
|
+
return { path: abs, content: decodeText(buffer, abs), size: stats.size, mtime: stats.mtimeMs }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async write(
|
|
323
|
+
root: string,
|
|
324
|
+
rel: string,
|
|
325
|
+
content: string,
|
|
326
|
+
expectedMtime?: number,
|
|
327
|
+
): Promise<FileWriteResult> {
|
|
328
|
+
const abs = await this.resolvePath(root, rel)
|
|
329
|
+
if (expectedMtime !== undefined) {
|
|
330
|
+
let stats: Stats
|
|
331
|
+
try {
|
|
332
|
+
stats = await stat(abs)
|
|
333
|
+
} catch (error) {
|
|
334
|
+
throw toBackendIoError(error, abs)
|
|
335
|
+
}
|
|
336
|
+
if (Math.round(stats.mtimeMs) !== Math.round(expectedMtime)) {
|
|
337
|
+
throw new BackendError(
|
|
338
|
+
'conflict',
|
|
339
|
+
`mtime conflict: current ${Math.round(stats.mtimeMs)} != expected ${Math.round(expectedMtime)}`,
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
await mkdir(dirname(abs), { recursive: true })
|
|
345
|
+
// Re-run the realpath-walk after mkdir: a concurrent actor may have
|
|
346
|
+
// introduced a symlink while the parent chain was being created.
|
|
347
|
+
await this.assertRealpathWalk(root, abs, rel)
|
|
348
|
+
await writeFile(abs, content, 'utf8')
|
|
349
|
+
const stats = await stat(abs)
|
|
350
|
+
return { mtime: stats.mtimeMs }
|
|
351
|
+
} catch (error) {
|
|
352
|
+
throw toBackendIoError(error, abs)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async search(root: string, query: string): Promise<SearchView> {
|
|
357
|
+
const rootAbs = await this.resolvePath(root, '')
|
|
358
|
+
const needle = query.toLocaleLowerCase()
|
|
359
|
+
const hits: SearchHit[] = []
|
|
360
|
+
let scanned = 0
|
|
361
|
+
let truncated = false
|
|
362
|
+
|
|
363
|
+
const walk = async (dir: string, depth: number): Promise<void> => {
|
|
364
|
+
if (depth > SEARCH_MAX_DEPTH) return
|
|
365
|
+
if (hits.length >= SEARCH_HIT_CAP || scanned >= SEARCH_SCAN_CAP) {
|
|
366
|
+
truncated = true
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
let dirents: Dirent[]
|
|
370
|
+
try {
|
|
371
|
+
dirents = await readdir(dir, { withFileTypes: true })
|
|
372
|
+
} catch {
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
for (const entry of dirents) {
|
|
376
|
+
scanned += 1
|
|
377
|
+
if (scanned > SEARCH_SCAN_CAP) {
|
|
378
|
+
truncated = true
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
if (entry.isDirectory() && SEARCH_SKIP_DIRS.has(entry.name)) continue
|
|
382
|
+
const abs = join(dir, entry.name)
|
|
383
|
+
// Directory symlinks are not followed (Dirent.isDirectory() is false
|
|
384
|
+
// for symlinks).
|
|
385
|
+
if (entry.isDirectory()) {
|
|
386
|
+
if (entry.name.toLocaleLowerCase().includes(needle)) {
|
|
387
|
+
hits.push({ path: abs, rel: relativeWorkspacePath(rootAbs, abs, 'local'), isDir: true })
|
|
388
|
+
}
|
|
389
|
+
await walk(abs, depth + 1)
|
|
390
|
+
} else if (entry.isFile() && entry.name.toLocaleLowerCase().includes(needle)) {
|
|
391
|
+
hits.push({ path: abs, rel: relativeWorkspacePath(rootAbs, abs, 'local'), isDir: false })
|
|
392
|
+
}
|
|
393
|
+
if (hits.length >= SEARCH_HIT_CAP) {
|
|
394
|
+
truncated = true
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
await walk(rootAbs, 0)
|
|
401
|
+
return { query, hits: hits.slice(0, SEARCH_HIT_CAP), truncated }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async resolvePath(root: string, rel: string): Promise<string> {
|
|
405
|
+
this.assertRoot(root)
|
|
406
|
+
const normalized = normalizeRel(rel)
|
|
407
|
+
const normalizedRoot = resolve(root)
|
|
408
|
+
const abs = normalized === '' ? normalizedRoot : resolve(normalizedRoot, ...normalized.split('/'))
|
|
409
|
+
relativeWorkspacePath(normalizedRoot, abs, 'local')
|
|
410
|
+
await this.assertRealpathWalk(normalizedRoot, abs, rel)
|
|
411
|
+
return abs
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Resolve the nearest existing ancestor; root and every existing target
|
|
415
|
+
* ancestor must resolve below the real workspace root. */
|
|
416
|
+
private async assertRealpathWalk(root: string, abs: string, rel: string): Promise<void> {
|
|
417
|
+
let realRoot: string
|
|
418
|
+
try {
|
|
419
|
+
realRoot = await realpath(root)
|
|
420
|
+
} catch (error) {
|
|
421
|
+
throw toBackendIoError(error, root)
|
|
422
|
+
}
|
|
423
|
+
let probe = abs
|
|
424
|
+
for (;;) {
|
|
425
|
+
try {
|
|
426
|
+
const realProbe = await realpath(probe)
|
|
427
|
+
relativeWorkspacePath(realRoot, realProbe, 'local')
|
|
428
|
+
return
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (error instanceof BackendError) throw error
|
|
431
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
432
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
433
|
+
throw toBackendIoError(error, probe)
|
|
434
|
+
}
|
|
435
|
+
const parent = dirname(probe)
|
|
436
|
+
if (parent === probe) {
|
|
437
|
+
throw new BackendError('outside-root', `path cannot be resolved below workspace root: '${rel}'`)
|
|
438
|
+
}
|
|
439
|
+
probe = parent
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** A request-scoped remote backend bound to one immutable ledger record. */
|
|
446
|
+
export class RemoteBackend implements WorkspaceBackend {
|
|
447
|
+
private readonly remoteRoot: string
|
|
448
|
+
|
|
449
|
+
constructor(
|
|
450
|
+
private readonly engine: SshEngine,
|
|
451
|
+
private readonly record: SshWorkspaceRecord,
|
|
452
|
+
private readonly searchService: RemoteSearchService,
|
|
453
|
+
) {
|
|
454
|
+
this.remoteRoot = normalizeRemoteRoot(record.remoteRoot)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
assertRoot(root: string): void {
|
|
458
|
+
if (normalizeRemoteRoot(root) !== this.remoteRoot) {
|
|
459
|
+
throw new BackendError('root-mismatch', `root '${root}' does not match remote workspace root '${this.remoteRoot}'`)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async list(root: string, rel: string): Promise<DirListing> {
|
|
464
|
+
this.assertRoot(root)
|
|
465
|
+
const abs = relToAbs(this.remoteRoot, rel)
|
|
466
|
+
try {
|
|
467
|
+
const entries = await this.engine.ls(this.record.alias, abs)
|
|
468
|
+
return {
|
|
469
|
+
path: abs,
|
|
470
|
+
entries: sortWorkspaceEntries(
|
|
471
|
+
entries
|
|
472
|
+
.filter(entry => !TREE_SKIP_DIRS.has(entry.name))
|
|
473
|
+
.map((entry): WorkspaceEntry => ({
|
|
474
|
+
name: entry.name,
|
|
475
|
+
type: entry.type,
|
|
476
|
+
size: entry.size,
|
|
477
|
+
mtimeMs: entry.mtimeMs,
|
|
478
|
+
})),
|
|
479
|
+
),
|
|
480
|
+
}
|
|
481
|
+
} catch (error) {
|
|
482
|
+
throw toBackendIoError(error, abs)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async read(root: string, rel: string): Promise<FileRead> {
|
|
487
|
+
this.assertRoot(root)
|
|
488
|
+
const abs = relToAbs(this.remoteRoot, rel)
|
|
489
|
+
try {
|
|
490
|
+
const result = await this.engine.readFile(this.record.alias, abs)
|
|
491
|
+
return {
|
|
492
|
+
path: abs,
|
|
493
|
+
content: decodeText(result.content, abs),
|
|
494
|
+
size: result.size,
|
|
495
|
+
mtime: result.mtime,
|
|
496
|
+
}
|
|
497
|
+
} catch (error) {
|
|
498
|
+
throw toBackendIoError(error, abs)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async write(
|
|
503
|
+
root: string,
|
|
504
|
+
rel: string,
|
|
505
|
+
content: string,
|
|
506
|
+
expectedMtime?: number,
|
|
507
|
+
): Promise<FileWriteResult> {
|
|
508
|
+
this.assertRoot(root)
|
|
509
|
+
const abs = relToAbs(this.remoteRoot, rel)
|
|
510
|
+
try {
|
|
511
|
+
const result = await this.engine.writeFile(
|
|
512
|
+
this.record.alias,
|
|
513
|
+
abs,
|
|
514
|
+
Buffer.from(content, 'utf8'),
|
|
515
|
+
expectedMtime,
|
|
516
|
+
)
|
|
517
|
+
return { mtime: result.mtime }
|
|
518
|
+
} catch (error) {
|
|
519
|
+
throw toBackendIoError(error, abs)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async search(root: string, query: string): Promise<SearchView> {
|
|
524
|
+
this.assertRoot(root)
|
|
525
|
+
let found: RemoteNameSearch
|
|
526
|
+
try {
|
|
527
|
+
found = await this.searchService.searchNames({ alias: this.record.alias, root: this.remoteRoot }, query)
|
|
528
|
+
} catch (error) {
|
|
529
|
+
throw toBackendIoError(error, this.remoteRoot)
|
|
530
|
+
}
|
|
531
|
+
const hits: SearchHit[] = []
|
|
532
|
+
for (const hit of found.hits) {
|
|
533
|
+
// Never trust command output as authorization evidence.
|
|
534
|
+
const rel = relativeWorkspacePath(this.remoteRoot, hit.path, 'remote')
|
|
535
|
+
hits.push({ path: posix.normalize(hit.path), rel, isDir: hit.isDir })
|
|
536
|
+
}
|
|
537
|
+
return { query: found.query, hits, truncated: found.truncated }
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Production UI service: exact ledger anchor -> request-scoped backend. */
|
|
542
|
+
export class LedgerWorkspaceFileService implements WorkspaceFileService {
|
|
543
|
+
constructor(
|
|
544
|
+
private readonly ledger: SshWorkspaceLedger,
|
|
545
|
+
private readonly engine: SshEngine,
|
|
546
|
+
private readonly searchService: RemoteSearchService,
|
|
547
|
+
) {}
|
|
548
|
+
|
|
549
|
+
async resolveContext(root: string): Promise<WorkspaceFileContext> {
|
|
550
|
+
if (
|
|
551
|
+
typeof root !== 'string'
|
|
552
|
+
|| root === ''
|
|
553
|
+
|| root.includes('\0')
|
|
554
|
+
|| !isAbsolute(root)
|
|
555
|
+
) {
|
|
556
|
+
throw new BackendError('outside-root', 'root must be an absolute workspace anchor path')
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const record = await this.ledger.findByAnchor(root)
|
|
560
|
+
if (record === undefined) {
|
|
561
|
+
throw new BackendError('not-remote', `root '${root}' is not bound to an SSH workspace`)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// findByAnchor intentionally accepts descendants for seam routing. The
|
|
565
|
+
// HTTP workspace API is narrower and requires the exact anchor.
|
|
566
|
+
if (!sameLocalPath(root, record.anchorPath)) {
|
|
567
|
+
throw new BackendError('outside-root', `root '${root}' must exactly match workspace anchor '${record.anchorPath}'`)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
workspaceId: record.id,
|
|
572
|
+
requestedRoot: root,
|
|
573
|
+
anchorPath: record.anchorPath,
|
|
574
|
+
alias: record.alias,
|
|
575
|
+
remoteRoot: normalizeRemoteRoot(record.remoteRoot),
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async list(root: string, rel: string): Promise<DirListing> {
|
|
580
|
+
const { record, backend } = await this.resolveBackend(root)
|
|
581
|
+
return backend.list(record.remoteRoot, rel)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async read(root: string, rel: string): Promise<FileRead> {
|
|
585
|
+
const { record, backend } = await this.resolveBackend(root)
|
|
586
|
+
return backend.read(record.remoteRoot, rel)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async write(
|
|
590
|
+
root: string,
|
|
591
|
+
rel: string,
|
|
592
|
+
content: string,
|
|
593
|
+
expectedMtime?: number,
|
|
594
|
+
): Promise<FileWriteResult> {
|
|
595
|
+
const { record, backend } = await this.resolveBackend(root)
|
|
596
|
+
return backend.write(record.remoteRoot, rel, content, expectedMtime)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async search(root: string, query: string): Promise<SearchView> {
|
|
600
|
+
const { record, backend } = await this.resolveBackend(root)
|
|
601
|
+
return backend.search(record.remoteRoot, query)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private async resolveBackend(root: string): Promise<{
|
|
605
|
+
record: SshWorkspaceRecord
|
|
606
|
+
backend: RemoteBackend
|
|
607
|
+
}> {
|
|
608
|
+
const context = await this.resolveContext(root)
|
|
609
|
+
const record = await this.ledger.get(context.workspaceId)
|
|
610
|
+
|
|
611
|
+
// A concurrent deletion between resolveContext and dispatch must fail
|
|
612
|
+
// closed rather than silently selecting another backend.
|
|
613
|
+
if (
|
|
614
|
+
record === undefined
|
|
615
|
+
|| !sameLocalPath(record.anchorPath, context.anchorPath)
|
|
616
|
+
|| record.alias !== context.alias
|
|
617
|
+
|| normalizeRemoteRoot(record.remoteRoot) !== context.remoteRoot
|
|
618
|
+
) {
|
|
619
|
+
throw new BackendError('not-remote', `workspace '${context.workspaceId}' is no longer available`)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return { record, backend: new RemoteBackend(this.engine, record, this.searchService) }
|
|
623
|
+
}
|
|
624
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider capability interfaces — the resource-access contracts a workspace
|
|
3
|
+
* provider may offer. Providers implement only what they support; consumers
|
|
4
|
+
* look up capabilities through `WorkspaceConnection.get(...)` and degrade
|
|
5
|
+
* gracefully when undefined.
|
|
6
|
+
*
|
|
7
|
+
* All paths in these interfaces are workspace-relative POSIX-like paths
|
|
8
|
+
* resolved against the record's `location.root` by the provider. The provider
|
|
9
|
+
* is responsible for root confinement (and any extra semantics, e.g. SFTP
|
|
10
|
+
* mode bits), so a consumer can treat every workspace uniformly.
|
|
11
|
+
*
|
|
12
|
+
* @module @tiphareth/dsh-hardssh/base/capability
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Uniform stat shape (both backends normalize to this). */
|
|
16
|
+
export interface WorkspaceStat {
|
|
17
|
+
type: 'dir' | 'file' | 'other'
|
|
18
|
+
size: number
|
|
19
|
+
mtimeMs: number
|
|
20
|
+
mode?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One directory entry. */
|
|
24
|
+
export interface WorkspaceDirEntry {
|
|
25
|
+
name: string
|
|
26
|
+
type: 'dir' | 'file' | 'other'
|
|
27
|
+
size: number
|
|
28
|
+
mtimeMs: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** File-system capability. */
|
|
32
|
+
export interface WorkspaceFileSystem {
|
|
33
|
+
stat(path: string, signal?: AbortSignal): Promise<WorkspaceStat | undefined>
|
|
34
|
+
list(path: string, signal?: AbortSignal): Promise<WorkspaceDirEntry[]>
|
|
35
|
+
readFile(path: string, signal?: AbortSignal): Promise<Uint8Array>
|
|
36
|
+
writeFile(path: string, data: Uint8Array, signal?: AbortSignal): Promise<void>
|
|
37
|
+
mkdir(path: string, options?: { recursive?: boolean; signal?: AbortSignal }): Promise<void>
|
|
38
|
+
rm(path: string, options?: { recursive?: boolean; signal?: AbortSignal }): Promise<void>
|
|
39
|
+
rename(from: string, to: string, signal?: AbortSignal): Promise<void>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One process result. */
|
|
43
|
+
export interface WorkspaceProcessResult {
|
|
44
|
+
stdout: string
|
|
45
|
+
stderr: string
|
|
46
|
+
exitCode: number
|
|
47
|
+
timedOut?: boolean
|
|
48
|
+
durationMs?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Subprocess capability. */
|
|
52
|
+
export interface WorkspaceProcessRuntime {
|
|
53
|
+
exec(command: string, options?: { timeoutMs?: number; cwd?: string; signal?: AbortSignal }): Promise<WorkspaceProcessResult>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Interactive terminal capability. */
|
|
57
|
+
export interface WorkspaceTerminalService {
|
|
58
|
+
openTerminal(cols: number, rows: number): Promise<WorkspaceTerminalHandle>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Minimal PTY handle (mirrors the ssh provider's duck-typed handle). */
|
|
62
|
+
export interface WorkspaceTerminalHandle {
|
|
63
|
+
shell: string
|
|
64
|
+
write(data: string): void
|
|
65
|
+
resize(cols: number, rows: number): void
|
|
66
|
+
kill(): void
|
|
67
|
+
onData(handler: (data: string) => void): { dispose(): void }
|
|
68
|
+
onExit(handler: (event: { exitCode: number }) => void): { dispose(): void }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One filename-search hit. */
|
|
72
|
+
export interface WorkspaceSearchHit {
|
|
73
|
+
/** Absolute path on the provider. */
|
|
74
|
+
path: string
|
|
75
|
+
/** Path relative to the search root. */
|
|
76
|
+
rel: string
|
|
77
|
+
isDir: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Filename / content search capability. */
|
|
81
|
+
export interface WorkspaceSearchService {
|
|
82
|
+
/** glob-style filename search. */
|
|
83
|
+
glob(pattern: string, options?: { root?: string; maxDepth?: number; signal?: AbortSignal }): Promise<{ hits: WorkspaceSearchHit[]; truncated: boolean }>
|
|
84
|
+
/** fixed-string content search (skips .git / node_modules). */
|
|
85
|
+
grep(fixedPhrase: string, options?: { root?: string; signal?: AbortSignal }): Promise<{ hits: WorkspaceSearchHit[]; truncated: boolean }>
|
|
86
|
+
}
|