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.
- package/CHANGELOG.md +504 -0
- package/CONTRIBUTING.md +62 -0
- package/LICENSE +21 -0
- package/README.md +159 -2
- package/SECURITY.md +25 -0
- package/cordis.patch.yml +5 -0
- package/docs/architecture.md +124 -0
- package/docs/quickstart.md +94 -0
- package/lib/client.js +4977 -0
- package/lib/core/activity.js +83 -0
- package/lib/core/background.js +69 -0
- package/lib/core/broker.js +799 -0
- package/lib/core/dialect.js +28 -0
- package/lib/core/errors.js +138 -0
- package/lib/core/gate-pwsh.js +88 -0
- package/lib/core/gate.js +572 -0
- package/lib/core/guard.js +71 -0
- package/lib/core/known-hosts.js +74 -0
- package/lib/core/local-file-api.js +174 -0
- package/lib/core/localfs.js +381 -0
- package/lib/core/paths.js +104 -0
- package/lib/core/posix-quote.js +4 -0
- package/lib/core/prefs.js +38 -0
- package/lib/core/remote-bash-tool.js +321 -0
- package/lib/core/remote-bash.js +337 -0
- package/lib/core/secrets.js +96 -0
- package/lib/core/server-reference.js +299 -0
- package/lib/core/shell-adapters.js +48 -0
- package/lib/core/ssh.js +761 -0
- package/lib/core/store.js +83 -0
- package/lib/core/targets.js +250 -0
- package/lib/core/terminals.js +299 -0
- package/lib/core/win32-adapter.js +141 -0
- package/lib/index.js +450 -0
- package/lib/routes.js +733 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +88 -4
- package/vendor/NOTICE.md +50 -0
- package/vendor/XTERM-LICENSE +21 -0
- package/vendor/node_modules/asn1/LICENSE +19 -0
- package/vendor/node_modules/asn1/README.md +50 -0
- package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
- package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
- package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
- package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
- package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
- package/vendor/node_modules/asn1/lib/index.js +20 -0
- package/vendor/node_modules/asn1/package.json +31 -0
- package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
- package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
- package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
- package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
- package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
- package/vendor/node_modules/safer-buffer/LICENSE +21 -0
- package/vendor/node_modules/safer-buffer/Readme.md +156 -0
- package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
- package/vendor/node_modules/safer-buffer/package.json +34 -0
- package/vendor/node_modules/safer-buffer/safer.js +77 -0
- package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
- package/vendor/node_modules/tweetnacl/LICENSE +24 -0
- package/vendor/node_modules/tweetnacl/README.md +459 -0
- package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
- package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
- package/vendor/node_modules/tweetnacl/package.json +58 -0
- package/vendor/ssh2/LICENSE +19 -0
- package/vendor/ssh2/lib/Channel.js +295 -0
- package/vendor/ssh2/lib/agent.js +1123 -0
- package/vendor/ssh2/lib/client.js +2176 -0
- package/vendor/ssh2/lib/http-agents.js +84 -0
- package/vendor/ssh2/lib/index.js +44 -0
- package/vendor/ssh2/lib/keygen.js +582 -0
- package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
- package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
- package/vendor/ssh2/lib/protocol/constants.js +356 -0
- package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
- package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
- package/vendor/ssh2/lib/protocol/handlers.js +16 -0
- package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
- package/vendor/ssh2/lib/protocol/kex.js +1908 -0
- package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
- package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
- package/vendor/ssh2/lib/protocol/utils.js +356 -0
- package/vendor/ssh2/lib/protocol/zlib.js +255 -0
- package/vendor/ssh2/lib/server.js +1380 -0
- package/vendor/ssh2/lib/utils.js +336 -0
- package/vendor/ssh2/package.json +34 -0
- 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
|
+
}
|