dsh-ssh-workbench 0.0.0-stage → 1.6.4

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +504 -0
  2. package/CONTRIBUTING.md +62 -0
  3. package/LICENSE +21 -0
  4. package/README.md +159 -2
  5. package/SECURITY.md +25 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/architecture.md +124 -0
  8. package/docs/quickstart.md +94 -0
  9. package/lib/client.js +4977 -0
  10. package/lib/core/activity.js +83 -0
  11. package/lib/core/background.js +69 -0
  12. package/lib/core/broker.js +799 -0
  13. package/lib/core/dialect.js +28 -0
  14. package/lib/core/errors.js +138 -0
  15. package/lib/core/gate-pwsh.js +88 -0
  16. package/lib/core/gate.js +572 -0
  17. package/lib/core/guard.js +71 -0
  18. package/lib/core/known-hosts.js +74 -0
  19. package/lib/core/local-file-api.js +174 -0
  20. package/lib/core/localfs.js +381 -0
  21. package/lib/core/paths.js +104 -0
  22. package/lib/core/posix-quote.js +4 -0
  23. package/lib/core/prefs.js +38 -0
  24. package/lib/core/remote-bash-tool.js +321 -0
  25. package/lib/core/remote-bash.js +337 -0
  26. package/lib/core/secrets.js +96 -0
  27. package/lib/core/server-reference.js +299 -0
  28. package/lib/core/shell-adapters.js +48 -0
  29. package/lib/core/ssh.js +761 -0
  30. package/lib/core/store.js +83 -0
  31. package/lib/core/targets.js +250 -0
  32. package/lib/core/terminals.js +299 -0
  33. package/lib/core/win32-adapter.js +141 -0
  34. package/lib/index.js +450 -0
  35. package/lib/routes.js +733 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +88 -4
  39. package/vendor/NOTICE.md +50 -0
  40. package/vendor/XTERM-LICENSE +21 -0
  41. package/vendor/node_modules/asn1/LICENSE +19 -0
  42. package/vendor/node_modules/asn1/README.md +50 -0
  43. package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
  44. package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
  45. package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
  46. package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
  47. package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
  48. package/vendor/node_modules/asn1/lib/index.js +20 -0
  49. package/vendor/node_modules/asn1/package.json +31 -0
  50. package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  51. package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  52. package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
  53. package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
  54. package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
  55. package/vendor/node_modules/safer-buffer/LICENSE +21 -0
  56. package/vendor/node_modules/safer-buffer/Readme.md +156 -0
  57. package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
  58. package/vendor/node_modules/safer-buffer/package.json +34 -0
  59. package/vendor/node_modules/safer-buffer/safer.js +77 -0
  60. package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
  61. package/vendor/node_modules/tweetnacl/LICENSE +24 -0
  62. package/vendor/node_modules/tweetnacl/README.md +459 -0
  63. package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
  64. package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
  65. package/vendor/node_modules/tweetnacl/package.json +58 -0
  66. package/vendor/ssh2/LICENSE +19 -0
  67. package/vendor/ssh2/lib/Channel.js +295 -0
  68. package/vendor/ssh2/lib/agent.js +1123 -0
  69. package/vendor/ssh2/lib/client.js +2176 -0
  70. package/vendor/ssh2/lib/http-agents.js +84 -0
  71. package/vendor/ssh2/lib/index.js +44 -0
  72. package/vendor/ssh2/lib/keygen.js +582 -0
  73. package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
  74. package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
  75. package/vendor/ssh2/lib/protocol/constants.js +356 -0
  76. package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  77. package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
  78. package/vendor/ssh2/lib/protocol/handlers.js +16 -0
  79. package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
  80. package/vendor/ssh2/lib/protocol/kex.js +1908 -0
  81. package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
  82. package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
  83. package/vendor/ssh2/lib/protocol/utils.js +356 -0
  84. package/vendor/ssh2/lib/protocol/zlib.js +255 -0
  85. package/vendor/ssh2/lib/server.js +1380 -0
  86. package/vendor/ssh2/lib/utils.js +336 -0
  87. package/vendor/ssh2/package.json +34 -0
  88. package/vendor/xterm.js +53 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Plugin-local known_hosts with strict-by-default trust.
3
+ *
4
+ * ssh2 verifies the host key with `hostHash: 'sha256'`; the verifier receives
5
+ * the hex digest, so this store keeps `<address> sha256:<hex>` lines. Unknown
6
+ * hosts are rejected unless the user explicitly trusts the fingerprint.
7
+ */
8
+ import { knownHostsPath, readTextFile, writePrivateFile } from './paths.js'
9
+
10
+ function addressOf(server) {
11
+ return `${server.host}:${server.port}`
12
+ }
13
+
14
+ function normalizeFingerprint(fingerprint) {
15
+ const value = String(fingerprint ?? '').trim().toLowerCase()
16
+ if (!value) return ''
17
+ return value.startsWith('sha256:') ? value : `sha256:${value}`
18
+ }
19
+
20
+ function parse(text) {
21
+ const map = new Map()
22
+ for (const rawLine of text.split(/\r?\n/)) {
23
+ const line = rawLine.trim()
24
+ if (!line || line.startsWith('#')) continue
25
+ const match = /^(\S+)\s+(sha256:[0-9a-f]+)$/i.exec(line)
26
+ if (match) map.set(match[1], match[2].toLowerCase())
27
+ }
28
+ return map
29
+ }
30
+
31
+ export function createKnownHosts({ file = knownHostsPath() } = {}) {
32
+ const load = () => parse(readTextFile(file))
33
+ const save = (map) => {
34
+ const lines = [...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([address, fp]) => `${address} ${fp}`)
35
+ writePrivateFile(file, `${lines.join('\n')}${lines.length ? '\n' : ''}`)
36
+ }
37
+ return {
38
+ addressOf,
39
+ /** `mode: 'strict'` rejects unknown hosts; `'accept-new'` records them (TOFU). */
40
+ verify(server, fingerprint, mode = 'strict') {
41
+ const address = addressOf(server)
42
+ const normalized = normalizeFingerprint(fingerprint)
43
+ if (!normalized) return false
44
+ const map = load()
45
+ const known = map.get(address)
46
+ if (known) return known === normalized
47
+ if (mode === 'accept-new') {
48
+ map.set(address, normalized)
49
+ save(map)
50
+ return true
51
+ }
52
+ return false
53
+ },
54
+ trust(server, fingerprint) {
55
+ const normalized = normalizeFingerprint(fingerprint)
56
+ if (!normalized) throw new Error('a fingerprint is required')
57
+ const map = load()
58
+ map.set(addressOf(server), normalized)
59
+ save(map)
60
+ },
61
+ revoke(server) {
62
+ const map = load()
63
+ const deleted = map.delete(addressOf(server))
64
+ save(map)
65
+ return deleted
66
+ },
67
+ list() {
68
+ return [...load().entries()].map(([address, fingerprint]) => ({ address, fingerprint }))
69
+ },
70
+ has(server) {
71
+ return load().has(addressOf(server))
72
+ },
73
+ }
74
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Local-filesystem implementation of the files-panel API.
3
+ *
4
+ * The workbench reuses the very same files component for remote SFTP and the
5
+ * local workspace. This adapter gives the local side the same method surface
6
+ * as the SSH runtime (`sftpHome`, `sftpList`, `sftpRead`, chunked open/read/
7
+ * write sessions, mkdir/remove/rename), so the panel and the file viewer stay
8
+ * provider-blind. Every path is resolved through the scoped local filesystem
9
+ * browser.
10
+ */
11
+ import fsp from 'node:fs/promises'
12
+ import { randomUUID } from 'node:crypto'
13
+
14
+ export const LOCAL_FILE_REF = '__local__'
15
+
16
+ const SYMLINK_WRITE_ERROR = 'refusing to write through a symlink'
17
+
18
+ export function createLocalFileApi(localfs) {
19
+ const server = { id: LOCAL_FILE_REF, name: LOCAL_FILE_REF, platform: 'local' }
20
+ const sessions = new Map()
21
+
22
+ const sessionFor = (id, kind) => {
23
+ const session = sessions.get(id)
24
+ if (!session || session.kind !== kind) throw new Error('local transfer session not found')
25
+ return session
26
+ }
27
+
28
+ async function lstatOrNull(target) {
29
+ try {
30
+ return await fsp.lstat(target)
31
+ } catch (error) {
32
+ if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return null
33
+ throw error
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Open a local path for a transfer session.
39
+ *
40
+ * Write sessions resolve the path as a directory entry (parents followed and
41
+ * bounded by the root, final component not followed) and refuse to write
42
+ * through a final symlink, so an uploaded file cannot escape via a link.
43
+ * Read sessions keep follow semantics (a symlink inside the root is readable;
44
+ * one leaving the root is rejected by `localfs.resolve`).
45
+ */
46
+ async function openLocal(target, flags, { noFollowFinal = false } = {}) {
47
+ const resolved = noFollowFinal
48
+ ? await localfs.resolveEntry(target)
49
+ : await localfs.resolve(target)
50
+ if (noFollowFinal) {
51
+ const info = await lstatOrNull(resolved)
52
+ if (info && info.isSymbolicLink()) throw new Error(SYMLINK_WRITE_ERROR)
53
+ }
54
+ const handle = await fsp.open(resolved, flags)
55
+ return { resolved, handle }
56
+ }
57
+
58
+ /** Canonical local workspace root, mirroring the remote login home. */
59
+ async function sftpHome(_server) {
60
+ return await localfs.rootReal()
61
+ }
62
+
63
+ async function sftpList(_server, remotePath) {
64
+ const target = typeof remotePath === 'string' && remotePath.trim() ? remotePath : await localfs.rootReal()
65
+ const result = await localfs.list(target)
66
+ return result.entries
67
+ }
68
+
69
+ async function sftpRead(_server, remotePath, options = {}) {
70
+ const result = await localfs.read(remotePath, options.maxBytes)
71
+ const buffer = Buffer.from(result.base64, 'base64')
72
+ const binary = buffer.includes(0)
73
+ const formatted = {
74
+ path: result.path,
75
+ size: result.size,
76
+ bytesRead: buffer.length,
77
+ truncated: result.truncated,
78
+ binary,
79
+ content: binary ? undefined : buffer.toString('utf8'),
80
+ }
81
+ if (options.base64 === true) formatted.base64 = result.base64
82
+ return formatted
83
+ }
84
+
85
+ async function sftpOpenWrite(_server, remotePath, truncate = true) {
86
+ let opened
87
+ try {
88
+ opened = await openLocal(remotePath, truncate === false ? 'r+' : 'w', { noFollowFinal: true })
89
+ } catch (error) {
90
+ if (truncate === false && error && error.code === 'ENOENT') {
91
+ opened = await openLocal(remotePath, 'w', { noFollowFinal: true })
92
+ } else throw error
93
+ }
94
+ const id = `locw_${randomUUID()}`
95
+ sessions.set(id, { kind: 'write', path: opened.resolved, handle: opened.handle })
96
+ return { session: id, path: opened.resolved }
97
+ }
98
+
99
+ async function sftpWriteChunk(sessionId, offset, buffer) {
100
+ const session = sessionFor(sessionId, 'write')
101
+ await session.handle.write(buffer, 0, buffer.length, offset)
102
+ return { session: sessionId, bytes: buffer.length, offset }
103
+ }
104
+
105
+ async function sftpOpenRead(_server, remotePath) {
106
+ const opened = await openLocal(remotePath, 'r')
107
+ const info = await opened.handle.stat()
108
+ if (!info.isFile()) {
109
+ await opened.handle.close()
110
+ throw new Error('not a file')
111
+ }
112
+ const id = `locr_${randomUUID()}`
113
+ sessions.set(id, { kind: 'read', path: opened.resolved, handle: opened.handle, size: info.size })
114
+ return { session: id, path: opened.resolved, size: info.size }
115
+ }
116
+
117
+ async function sftpReadChunk(sessionId, offset, length) {
118
+ const session = sessionFor(sessionId, 'read')
119
+ const buffer = Buffer.alloc(length)
120
+ const { bytesRead } = await session.handle.read(buffer, 0, length, offset)
121
+ return { session: sessionId, bytesRead, base64: buffer.subarray(0, bytesRead).toString('base64') }
122
+ }
123
+
124
+ async function sftpCloseTransfer(sessionId) {
125
+ const session = sessions.get(sessionId)
126
+ // The server ref lets routes invalidate the local directory cache and log
127
+ // local activity under the right resource instead of "unknown" (B5/B8).
128
+ if (!session) return { session: sessionId, path: null, server: LOCAL_FILE_REF }
129
+ sessions.delete(sessionId)
130
+ let bytes
131
+ try {
132
+ if (session.kind === 'write') bytes = (await session.handle.stat()).size
133
+ } catch {
134
+ // A failed stat still closes the descriptor below.
135
+ }
136
+ await session.handle.close()
137
+ return { session: sessionId, path: session.path, bytes, server: LOCAL_FILE_REF }
138
+ }
139
+
140
+ async function sftpMkdir(_server, remotePath) {
141
+ return await localfs.mkdir(remotePath)
142
+ }
143
+
144
+ async function sftpRemove(_server, remotePath) {
145
+ return await localfs.remove(remotePath)
146
+ }
147
+
148
+ async function sftpRename(_server, fromPath, toPath) {
149
+ return await localfs.rename(fromPath, toPath)
150
+ }
151
+
152
+ async function dispose() {
153
+ for (const session of [...sessions.values()]) {
154
+ try { await session.handle.close() } catch { /* already closed */ }
155
+ }
156
+ sessions.clear()
157
+ }
158
+
159
+ return {
160
+ server,
161
+ sftpHome,
162
+ sftpList,
163
+ sftpRead,
164
+ sftpOpenWrite,
165
+ sftpWriteChunk,
166
+ sftpOpenRead,
167
+ sftpReadChunk,
168
+ sftpCloseTransfer,
169
+ sftpMkdir,
170
+ sftpRemove,
171
+ sftpRename,
172
+ dispose,
173
+ }
174
+ }
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Bounded local-filesystem browser for the workbench's local side.
3
+ *
4
+ * The workbench is a user-driven surface, not a model tool, but it still must
5
+ * not become a privilege escalation around the session sandbox. Every path is
6
+ * resolved inside one root (the Host's default working directory) and escapes
7
+ * (`..`, absolute paths outside the root, symlinks leaving it) are rejected
8
+ * before any filesystem call.
9
+ *
10
+ * Two different notions of "resolve" are needed:
11
+ * - read/list follow the final component (`resolve`), because following a
12
+ * symlink inside the root is legitimate for reading;
13
+ * - remove/rename operate on the directory entry itself (`resolveEntry`), so
14
+ * they never follow a final symlink (deleting or renaming the link must not
15
+ * touch its target).
16
+ * Writes/mkdirs are the strictest case: an existing final symlink is refused
17
+ * outright and every existing path component must canonicalize inside the real
18
+ * root, including when the final component does not exist yet.
19
+ */
20
+ import fsp from 'node:fs/promises'
21
+ import os from 'node:os'
22
+ import path from 'node:path'
23
+
24
+ const MAX_READ_BYTES = 5 * 1024 * 1024
25
+ const MAX_WRITE_BYTES = 20 * 1024 * 1024
26
+
27
+ const OUTSIDE_ERROR = 'path is outside the local workspace root'
28
+ const SYMLINK_WRITE_ERROR = 'refusing to write through a symlink'
29
+ const ROOT_REMOVE_ERROR = 'refusing to remove the local workspace root'
30
+ const ROOT_RENAME_ERROR = 'refusing to rename the local workspace root'
31
+
32
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
33
+
34
+ function isMissing(error) {
35
+ return Boolean(
36
+ error &&
37
+ typeof error === 'object' &&
38
+ (error.code === 'ENOENT' || error.code === 'ENOTDIR'),
39
+ )
40
+ }
41
+
42
+ /** Whether `target` is `root` itself or a path below it (handles root === '/'). */
43
+ function withinRoot(target, root) {
44
+ if (target === root) return true
45
+ const prefix = root.endsWith(path.sep) ? root : root + path.sep
46
+ return target.startsWith(prefix)
47
+ }
48
+
49
+ /** Whether a canonical `real` path is the root itself or lives under it. */
50
+ function isUnder(real, realRoot) {
51
+ // A root that does not exist yet has no canonical form; lexical containment
52
+ // (checked by the caller) is then the strongest guarantee available.
53
+ if (realRoot == null) return true
54
+ return withinRoot(real, realRoot)
55
+ }
56
+
57
+ function assertInside(real, realRoot) {
58
+ if (!isUnder(real, realRoot)) throw new Error(OUTSIDE_ERROR)
59
+ }
60
+
61
+ /** Render the permission bits of a lstat mode as `rwxrwxrwx`. */
62
+ function permissionsOf(mode) {
63
+ const bits = []
64
+ for (let shift = 8; shift >= 0; shift -= 1) {
65
+ const bit = 1 << shift
66
+ bits.push(mode & bit ? 'rwx'[(8 - shift) % 3] : '-')
67
+ }
68
+ return bits.join('')
69
+ }
70
+
71
+ /**
72
+ * Synthesize an `ls -l`-style longname. The remote SFTP side gets this string
73
+ * from the server's readdir longname field; locally we build an equivalent so
74
+ * the shared files panel receives the same entry shape.
75
+ */
76
+ function longnameOf(info, name) {
77
+ const type = info.isSymbolicLink() ? 'l' : info.isDirectory() ? 'd' : '-'
78
+ const when = new Date(info.mtimeMs)
79
+ const day = String(when.getDate()).padStart(2, ' ')
80
+ const hour = String(when.getHours()).padStart(2, '0')
81
+ const minute = String(when.getMinutes()).padStart(2, '0')
82
+ const stamp = `${MONTHS[when.getMonth()]} ${day} ${hour}:${minute}`
83
+ return `${type}${permissionsOf(info.mode)} ${info.nlink ?? 1} ${info.uid ?? 0} ${info.gid ?? 0} ${info.size} ${stamp} ${name}`
84
+ }
85
+
86
+ export function createLocalFs({ getRoot } = {}) {
87
+ /** Configured (lexical) root; kept synchronous for route/config callers. */
88
+ function rootPath() {
89
+ const value = typeof getRoot === 'function' ? getRoot() : undefined
90
+ return path.resolve(value || os.homedir())
91
+ }
92
+
93
+ /** Canonical root, or `null` when the configured root does not exist yet. */
94
+ async function rootRealOrNull() {
95
+ try {
96
+ return await fsp.realpath(rootPath())
97
+ } catch (error) {
98
+ if (isMissing(error)) return null
99
+ throw error
100
+ }
101
+ }
102
+
103
+ /** Canonical root (`realpath`); falls back to the lexical root if missing. */
104
+ async function rootReal() {
105
+ return (await rootRealOrNull()) ?? rootPath()
106
+ }
107
+
108
+ async function lstatOrNull(target) {
109
+ try {
110
+ return await fsp.lstat(target)
111
+ } catch (error) {
112
+ if (isMissing(error)) return null
113
+ throw error
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Map a caller path onto an absolute lexical candidate.
119
+ *
120
+ * Both the configured root and its canonical realpath are accepted, so the
121
+ * canonical `entry.path` values returned by `list()` remain usable as
122
+ * navigation inputs when the configured root itself contains symlinked
123
+ * components (macOS `/tmp` -> `/private/tmp`, linked homes, etc.).
124
+ */
125
+ function candidateFor(input, realRoot) {
126
+ const lexicalRoot = rootPath()
127
+ const canonicalRoot = realRoot ?? lexicalRoot
128
+ const raw = typeof input === 'string' && input.trim() ? input.trim() : ''
129
+ const candidate =
130
+ raw === '' ? lexicalRoot : path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(lexicalRoot, raw)
131
+ const insideLexical = withinRoot(candidate, lexicalRoot)
132
+ const insideReal = withinRoot(candidate, canonicalRoot)
133
+ if (!insideLexical && !insideReal) throw new Error(OUTSIDE_ERROR)
134
+ return candidate
135
+ }
136
+
137
+ /**
138
+ * Canonicalize a path that may not exist yet by walking up to the deepest
139
+ * existing ancestor. That ancestor must canonicalize inside the real root;
140
+ * the missing remainder cannot contain symlinks and is appended lexically.
141
+ *
142
+ * A dangling symlink anywhere in the chain is never silently followed:
143
+ * `dangling: 'propagate'` surfaces ENOENT (read/list semantics) and
144
+ * `dangling: 'reject'` refuses the operation (write/mkdir semantics).
145
+ */
146
+ async function canonicalizeMissing(candidate, realRoot, dangling) {
147
+ let cursor = candidate
148
+ const missing = []
149
+ for (;;) {
150
+ const info = await lstatOrNull(cursor)
151
+ if (info) {
152
+ if (info.isSymbolicLink()) {
153
+ let real
154
+ try {
155
+ real = await fsp.realpath(cursor)
156
+ } catch (error) {
157
+ if (isMissing(error)) {
158
+ if (dangling === 'reject') throw new Error(SYMLINK_WRITE_ERROR)
159
+ throw error
160
+ }
161
+ throw error
162
+ }
163
+ assertInside(real, realRoot)
164
+ return path.join(real, ...missing)
165
+ }
166
+ const real = await fsp.realpath(cursor)
167
+ assertInside(real, realRoot)
168
+ return path.join(real, ...missing)
169
+ }
170
+ const parent = path.dirname(cursor)
171
+ if (parent === cursor) throw new Error(OUTSIDE_ERROR)
172
+ missing.unshift(path.basename(cursor))
173
+ cursor = parent
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Resolve a caller path for read/list: symlinks are followed and the result
179
+ * is the canonical real path. Non-existent final components are allowed
180
+ * through (the caller's fs call reports ENOENT) but their existing parent
181
+ * chain must still canonicalize inside the root.
182
+ */
183
+ async function resolveInside(input) {
184
+ const realRoot = await rootRealOrNull()
185
+ const candidate = candidateFor(input, realRoot)
186
+ try {
187
+ const real = await fsp.realpath(candidate)
188
+ assertInside(real, realRoot)
189
+ return real
190
+ } catch (error) {
191
+ if (isMissing(error)) return await canonicalizeMissing(candidate, realRoot, 'propagate')
192
+ throw error
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Resolve a caller path as a *directory entry*: parents are followed and
198
+ * canonicalized inside the root, but the final component is not followed.
199
+ * Used by remove/rename so a symlink is operated on as a link, never as its
200
+ * target.
201
+ */
202
+ async function resolveEntry(input) {
203
+ const realRoot = await rootRealOrNull()
204
+ const lexicalRoot = rootPath()
205
+ const candidate = candidateFor(input, realRoot)
206
+ // The root itself has no directory-entry form; hand it back so remove/
207
+ // rename can reject it explicitly (B14) instead of recursing into it.
208
+ if (candidate === lexicalRoot || (realRoot != null && candidate === realRoot)) return candidate
209
+ const realParent = await fsp.realpath(path.dirname(candidate))
210
+ assertInside(realParent, realRoot)
211
+ return path.join(realParent, path.basename(candidate))
212
+ }
213
+
214
+ /**
215
+ * Resolve a write/mkdir target.
216
+ *
217
+ * - an existing final symlink is refused (`refusing to write through a
218
+ * symlink`), even when it points inside the root;
219
+ * - parents are canonicalized through symlinks and must stay inside root;
220
+ * - non-existent targets validate their deepest existing ancestor, so a
221
+ * symlinked parent plus a brand-new child name cannot escape (S1).
222
+ */
223
+ async function resolveCreateTarget(input) {
224
+ const realRoot = await rootRealOrNull()
225
+ const candidate = candidateFor(input, realRoot)
226
+ const direct = await lstatOrNull(candidate)
227
+ if (direct) {
228
+ if (direct.isSymbolicLink()) throw new Error(SYMLINK_WRITE_ERROR)
229
+ const real = await fsp.realpath(candidate)
230
+ assertInside(real, realRoot)
231
+ return real
232
+ }
233
+ return canonicalizeMissing(candidate, realRoot, 'reject')
234
+ }
235
+
236
+ async function list(input) {
237
+ const directory = await resolveInside(input)
238
+ const realRoot = await rootReal()
239
+ const stat = await fsp.stat(directory)
240
+ if (!stat.isDirectory()) throw new Error('not a directory')
241
+ const rows = await fsp.readdir(directory, { withFileTypes: true })
242
+ const entries = []
243
+ for (const row of rows) {
244
+ const full = path.join(directory, row.name)
245
+ // A file removed mid-listing (or an entry we cannot stat) still lists by
246
+ // name, as before; type detection prefers lstat because following a
247
+ // symlink mis-typed link-to-directory entries and enabled S2 data loss.
248
+ let info = null
249
+ try {
250
+ info = await fsp.lstat(full)
251
+ } catch {
252
+ info = null
253
+ }
254
+ const type = info
255
+ ? info.isSymbolicLink()
256
+ ? 'link'
257
+ : info.isDirectory()
258
+ ? 'dir'
259
+ : 'file'
260
+ : row.isSymbolicLink()
261
+ ? 'link'
262
+ : row.isDirectory()
263
+ ? 'dir'
264
+ : 'file'
265
+ entries.push({
266
+ name: row.name,
267
+ path: full,
268
+ type,
269
+ size: info ? (info.isDirectory() ? 0 : info.size) : 0,
270
+ mtime: info ? info.mtimeMs : null,
271
+ atime: info ? info.atimeMs : null,
272
+ mode: info ? info.mode : null,
273
+ uid: info ? info.uid : null,
274
+ gid: info ? info.gid : null,
275
+ longname: info ? longnameOf(info, row.name) : row.name,
276
+ })
277
+ }
278
+ entries.sort((left, right) => {
279
+ if (left.type !== right.type) {
280
+ if (left.type === 'dir') return -1
281
+ if (right.type === 'dir') return 1
282
+ }
283
+ return left.name.localeCompare(right.name)
284
+ })
285
+ return {
286
+ root: realRoot,
287
+ path: directory,
288
+ entries,
289
+ parent: directory === realRoot ? null : path.dirname(directory),
290
+ }
291
+ }
292
+
293
+ async function read(input, maxBytes = MAX_READ_BYTES) {
294
+ const file = await resolveInside(input)
295
+ const stat = await fsp.stat(file)
296
+ if (!stat.isFile()) throw new Error('not a file')
297
+ const size = stat.size
298
+ const limit = Math.min(Math.max(Number(maxBytes) || MAX_READ_BYTES, 1), MAX_READ_BYTES)
299
+ const handle = await fsp.open(file, 'r')
300
+ try {
301
+ const length = Math.min(size, limit)
302
+ const buffer = Buffer.alloc(length)
303
+ const { bytesRead } = await handle.read(buffer, 0, length, 0)
304
+ return {
305
+ path: file,
306
+ size,
307
+ mtime: stat.mtimeMs,
308
+ truncated: size > bytesRead,
309
+ base64: buffer.subarray(0, bytesRead).toString('base64'),
310
+ }
311
+ } finally {
312
+ await handle.close()
313
+ }
314
+ }
315
+
316
+ async function write(input, contentBase64) {
317
+ if (typeof contentBase64 !== 'string') throw new Error('contentBase64 is required')
318
+ const buffer = Buffer.from(contentBase64, 'base64')
319
+ if (buffer.length > MAX_WRITE_BYTES) throw new Error(`file exceeds ${MAX_WRITE_BYTES} bytes`)
320
+ const file = await resolveCreateTarget(input)
321
+ await fsp.mkdir(path.dirname(file), { recursive: true })
322
+ await fsp.writeFile(file, buffer)
323
+ return { path: file, size: buffer.length }
324
+ }
325
+
326
+ async function mkdir(input) {
327
+ const directory = await resolveCreateTarget(input)
328
+ await fsp.mkdir(directory, { recursive: true })
329
+ return { path: directory }
330
+ }
331
+
332
+ async function remove(input) {
333
+ const target = await resolveEntry(input)
334
+ const lexicalRoot = rootPath()
335
+ const realRoot = await rootReal()
336
+ // Empty input resolves to the root; deleting it must never be possible.
337
+ if (target === lexicalRoot || target === realRoot) throw new Error(ROOT_REMOVE_ERROR)
338
+ const info = await lstatOrNull(target)
339
+ if (!info) return { path: target, removed: false }
340
+ if (info.isSymbolicLink()) {
341
+ // Unlink the link itself; never follow it (a link can point outside the
342
+ // root and still be safely removable without touching its target, B7).
343
+ await fsp.unlink(target)
344
+ } else if (info.isDirectory()) {
345
+ await fsp.rm(target, { recursive: true, force: true })
346
+ } else {
347
+ await fsp.unlink(target)
348
+ }
349
+ return { path: target, removed: true }
350
+ }
351
+
352
+ async function rename(from, to) {
353
+ // Operate on directory entries: a symlink is moved as a link, not as its
354
+ // target (B4), and the destination's parents are canonicalized inside root.
355
+ const source = await resolveEntry(from)
356
+ const destination = await resolveEntry(to)
357
+ const lexicalRoot = rootPath()
358
+ const realRoot = await rootReal()
359
+ for (const target of [source, destination]) {
360
+ if (target === lexicalRoot || target === realRoot) throw new Error(ROOT_RENAME_ERROR)
361
+ }
362
+ // Match the remote SFTP semantics: rename must not silently overwrite.
363
+ const existing = await lstatOrNull(destination)
364
+ if (existing) throw new Error(`destination already exists: ${destination}`)
365
+ await fsp.rename(source, destination)
366
+ return { path: destination }
367
+ }
368
+
369
+ return {
370
+ root: rootPath,
371
+ rootReal,
372
+ resolve: resolveInside,
373
+ resolveEntry,
374
+ list,
375
+ read,
376
+ write,
377
+ mkdir,
378
+ remove,
379
+ rename,
380
+ }
381
+ }