246 lines
7.0 KiB
JavaScript
246 lines
7.0 KiB
JavaScript
/**
|
|
* Minimal SFTP over Bare OS VFS (bare-ssh2 SFTP server).
|
|
* Paths are sandboxed under `homeLogical` and `/mnt` (logical).
|
|
*/
|
|
import { S_IFDIR, S_IFREG } from './vfs-posix-meta.js'
|
|
|
|
const S_IRUSR = 0o400
|
|
const S_IWUSR = 0o200
|
|
const S_IXUSR = 0o100
|
|
|
|
/**
|
|
* @param {import('events').EventEmitter} sftp
|
|
* @param {Record<string, unknown>} vfs
|
|
* @param {string} homeLogical absolute logical home (e.g. /home/guest)
|
|
* @param {(p: string) => string} resolveLogical
|
|
* @param {{ OPEN_MODE: Record<string, number>, STATUS_CODE: Record<string, number> }} sftpConsts
|
|
*/
|
|
export function attachBareOsSftp(
|
|
sftp,
|
|
vfs,
|
|
homeLogical,
|
|
resolveLogical,
|
|
sftpConsts
|
|
) {
|
|
const { OPEN_MODE, STATUS_CODE } = sftpConsts
|
|
/** @type {Map<number, { path: string, buf?: Uint8Array | null, pos: number, write?: boolean }>} */
|
|
const handles = new Map()
|
|
/** @type {Map<number, { path: string, entries?: string[], idx: number }>} */
|
|
const dirs = new Map()
|
|
let hid = 1
|
|
let did = 1
|
|
|
|
function ok(reqid) {
|
|
sftp.status(reqid, STATUS_CODE.OK)
|
|
}
|
|
|
|
/**
|
|
* @param {string} p
|
|
* @returns {string | null}
|
|
*/
|
|
function mapPath(p) {
|
|
const norm = String(p || '').replace(/\\/g, '/').replace(/\/+/g, '/')
|
|
let logical
|
|
if (norm === '.' || norm === '') logical = homeLogical
|
|
else if (norm.startsWith('/')) {
|
|
if (norm === homeLogical || norm.startsWith(homeLogical + '/')) logical = norm
|
|
else if (norm.startsWith('/mnt') || norm === '/mnt') logical = norm
|
|
else return null
|
|
} else {
|
|
logical = resolveLogical(`${homeLogical}/${norm}`.replace(/\/+/g, '/'))
|
|
}
|
|
const abs = norm.startsWith('/') ? resolveLogical(norm) : logical
|
|
if (!abs.startsWith(homeLogical) && !abs.startsWith('/mnt')) return null
|
|
return abs
|
|
}
|
|
|
|
async function statLike(reqid, p, isLstat) {
|
|
const abs = mapPath(p)
|
|
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
|
try {
|
|
const st = await vfs.lstat(abs)
|
|
if (!st) return sftp.status(reqid, STATUS_CODE.NO_SUCH_FILE)
|
|
const mode =
|
|
(st.isDirectory() ? S_IFDIR : S_IFREG) | S_IRUSR | S_IWUSR | S_IXUSR
|
|
sftp.attrs(reqid, {
|
|
mode,
|
|
uid: 1000,
|
|
gid: 1000,
|
|
size: st.size || 0,
|
|
atime: st.mtime?.getTime?.() || Date.now(),
|
|
mtime: st.mtime?.getTime?.() || Date.now()
|
|
})
|
|
} catch {
|
|
sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
}
|
|
}
|
|
|
|
sftp.on('REALPATH', (reqid, p) => {
|
|
const abs = mapPath(p) || homeLogical
|
|
const name = [
|
|
{
|
|
filename: abs,
|
|
longname: 'drwxr-xr-x 1 user user 0 Jan 1 1970 .',
|
|
attrs: {}
|
|
}
|
|
]
|
|
sftp.name(reqid, name)
|
|
})
|
|
|
|
sftp.on('STAT', (reqid, p) => {
|
|
void statLike(reqid, p, false)
|
|
})
|
|
sftp.on('LSTAT', (reqid, p) => {
|
|
void statLike(reqid, p, true)
|
|
})
|
|
|
|
sftp.on('OPENDIR', async (reqid, p) => {
|
|
const abs = mapPath(p)
|
|
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
|
try {
|
|
const list = await vfs.readdir(abs)
|
|
if (!Array.isArray(list)) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
const h = Buffer.alloc(4)
|
|
const id = did++
|
|
h.writeUInt32BE(id, 0)
|
|
dirs.set(id, { path: abs, entries: list, idx: 0 })
|
|
sftp.handle(reqid, h)
|
|
} catch {
|
|
sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
}
|
|
})
|
|
|
|
sftp.on('READDIR', async (reqid, handle) => {
|
|
const id = handle.readUInt32BE(0)
|
|
const d = dirs.get(id)
|
|
if (!d || !d.entries) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
if (d.idx >= d.entries.length) return sftp.status(reqid, STATUS_CODE.EOF)
|
|
const chunk = d.entries.slice(d.idx, d.idx + 32)
|
|
d.idx += chunk.length
|
|
const names = []
|
|
for (const ent of chunk) {
|
|
names.push({
|
|
filename: ent,
|
|
longname: '-rw-r--r-- 1 user user 0 Jan 1 1970 ' + ent,
|
|
attrs: {}
|
|
})
|
|
}
|
|
sftp.name(reqid, names)
|
|
})
|
|
|
|
sftp.on('OPEN', async (reqid, filename, flags) => {
|
|
const abs = mapPath(filename)
|
|
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
|
const wantRead = !!(flags & OPEN_MODE.READ)
|
|
const wantWrite = !!(flags & OPEN_MODE.WRITE) || !!(flags & OPEN_MODE.APPEND)
|
|
const wantCreat = !!(flags & OPEN_MODE.CREAT)
|
|
try {
|
|
let buf = null
|
|
if (!wantCreat) {
|
|
try {
|
|
buf = await vfs.readFile(abs)
|
|
} catch {
|
|
buf = null
|
|
}
|
|
}
|
|
if (buf == null && !wantCreat && wantRead) {
|
|
return sftp.status(reqid, STATUS_CODE.NO_SUCH_FILE)
|
|
}
|
|
if (buf == null && wantCreat) buf = new Uint8Array(0)
|
|
if (buf == null) buf = new Uint8Array(0)
|
|
const h = Buffer.alloc(4)
|
|
const id = hid++
|
|
h.writeUInt32BE(id, 0)
|
|
handles.set(id, {
|
|
path: abs,
|
|
buf,
|
|
pos: 0,
|
|
write: wantWrite || wantCreat
|
|
})
|
|
sftp.handle(reqid, h)
|
|
} catch {
|
|
sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
}
|
|
})
|
|
|
|
sftp.on('READ', (reqid, handle, offset, length) => {
|
|
const id = handle.readUInt32BE(0)
|
|
const f = handles.get(id)
|
|
if (!f || !f.buf) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
const end = Math.min(offset + length, f.buf.length)
|
|
if (offset >= f.buf.length) return sftp.status(reqid, STATUS_CODE.EOF)
|
|
const slice = f.buf.subarray(offset, end)
|
|
sftp.data(reqid, slice)
|
|
})
|
|
|
|
sftp.on('WRITE', async (reqid, handle, offset, data) => {
|
|
const id = handle.readUInt32BE(0)
|
|
const f = handles.get(id)
|
|
if (!f || !f.write) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
|
try {
|
|
const u8 = data instanceof Uint8Array ? data : new Uint8Array(data)
|
|
const prev = f.buf ? new Uint8Array(f.buf) : new Uint8Array(0)
|
|
const need = offset + u8.length
|
|
const out = new Uint8Array(Math.max(prev.length, need))
|
|
out.set(prev)
|
|
out.set(u8, offset)
|
|
f.buf = out
|
|
await vfs.writeFile(f.path, out)
|
|
ok(reqid)
|
|
} catch {
|
|
sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
}
|
|
})
|
|
|
|
sftp.on('CLOSE', (reqid, handle) => {
|
|
const id = handle.readUInt32BE(0)
|
|
handles.delete(id)
|
|
dirs.delete(id)
|
|
ok(reqid)
|
|
})
|
|
|
|
sftp.on('REMOVE', async (reqid, p) => {
|
|
const abs = mapPath(p)
|
|
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
|
try {
|
|
await vfs.unlink(abs)
|
|
ok(reqid)
|
|
} catch {
|
|
sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
}
|
|
})
|
|
|
|
sftp.on('MKDIR', async (reqid, p) => {
|
|
const abs = mapPath(p)
|
|
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
|
try {
|
|
await vfs.mkdir(abs, { recursive: true })
|
|
ok(reqid)
|
|
} catch {
|
|
sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
}
|
|
})
|
|
|
|
sftp.on('RMDIR', async (reqid, p) => {
|
|
const abs = mapPath(p)
|
|
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
|
try {
|
|
await vfs.rmdir(abs)
|
|
ok(reqid)
|
|
} catch {
|
|
sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
}
|
|
})
|
|
|
|
sftp.on('RENAME', (reqid) => {
|
|
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
|
})
|
|
|
|
sftp.on('READLINK', (reqid) => {
|
|
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
|
})
|
|
sftp.on('SYMLINK', (reqid) => {
|
|
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
|
})
|
|
}
|