585 lines
17 KiB
JavaScript
585 lines
17 KiB
JavaScript
/**
|
|
* Minimal SFTP over Bare OS VFS (bare-ssh2 SFTP server).
|
|
* Readable paths match interactive shell / VFS (home, /mnt, system image, pseudo, …).
|
|
* Writes are denied on typical read-only system prefixes; other paths defer to VFS ACLs.
|
|
*/
|
|
import {
|
|
S_IFDIR,
|
|
S_IFLNK,
|
|
S_IFREG,
|
|
formatLsMtime,
|
|
formatModeString
|
|
} from './vfs-posix-meta.js'
|
|
|
|
/**
|
|
* @typedef {{ type: string, mode?: number, size?: number, uid?: number, gid?: number, user?: string, group?: string, mtimeMs?: number }} VfsStatRow
|
|
*/
|
|
|
|
/**
|
|
* Logical paths visible over SFTP (parity with shell `createVfs` routing).
|
|
* @param {string} abs resolved absolute logical path
|
|
* @param {string} homeLogical e.g. /home/guest
|
|
*/
|
|
export function bareOsSftpAllowedLogicalPath(abs, homeLogical) {
|
|
const h = String(homeLogical || '/').replace(/\/+$/, '') || '/'
|
|
const a = String(abs || '').replace(/\/+/g, '/') || '/'
|
|
if (a === h || a.startsWith(h + '/')) return true
|
|
/** @type {string[]} */
|
|
const roots = [
|
|
'/mnt',
|
|
'/media',
|
|
'/mount',
|
|
'/mirror',
|
|
'/bin',
|
|
'/boot',
|
|
'/lib',
|
|
'/etc',
|
|
'/usr',
|
|
'/var',
|
|
'/proc',
|
|
'/sys',
|
|
'/dev',
|
|
'/run',
|
|
'/tmp',
|
|
'/snapshots',
|
|
'/root',
|
|
'/home',
|
|
'/opt'
|
|
]
|
|
for (const pre of roots) {
|
|
if (a === pre || a.startsWith(pre + '/')) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Deny OPEN when the client requests mutating access on typical system-image paths.
|
|
* Personal home, /tmp, /var, /root, and /mnt are left to VFS (mounts may be writable).
|
|
* @param {string} abs
|
|
* @param {string} homeLogical
|
|
* @param {number} flags SFTP OPEN_MODE bitmask
|
|
* @param {{ READ: number, WRITE: number, APPEND: number, CREAT: number, TRUNC: number }} OPEN_MODE
|
|
*/
|
|
export function bareOsSftpDeniesWriteOpen(abs, homeLogical, flags, OPEN_MODE) {
|
|
const h = String(homeLogical || '/').replace(/\/+$/, '') || '/'
|
|
const a = String(abs || '').replace(/\/+/g, '/') || '/'
|
|
const wantMutate =
|
|
!!(flags & OPEN_MODE.WRITE) ||
|
|
!!(flags & OPEN_MODE.APPEND) ||
|
|
!!(flags & OPEN_MODE.CREAT) ||
|
|
!!(flags & OPEN_MODE.TRUNC)
|
|
if (!wantMutate) return false
|
|
if (a === h || a.startsWith(h + '/')) return false
|
|
if (a === '/tmp' || a.startsWith('/tmp/')) return false
|
|
if (a === '/var' || a.startsWith('/var/')) return false
|
|
if (a === '/root' || a.startsWith('/root/')) return false
|
|
const readOnlyRoots = [
|
|
'/bin',
|
|
'/boot',
|
|
'/lib',
|
|
'/etc',
|
|
'/usr',
|
|
'/proc',
|
|
'/sys',
|
|
'/dev',
|
|
'/run',
|
|
'/snapshots',
|
|
'/opt',
|
|
'/mirror'
|
|
]
|
|
for (const pre of readOnlyRoots) {
|
|
if (a === pre || a.startsWith(pre + '/')) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* @param {VfsStatRow} st
|
|
* @param {number} [sizeOverride]
|
|
*/
|
|
function vfsStatToSftpAttrs(st, sizeOverride) {
|
|
const t = st.type
|
|
const isDir = t === 'directory'
|
|
const isLink = t === 'symlink'
|
|
const mode =
|
|
(isDir ? S_IFDIR : isLink ? S_IFLNK : S_IFREG) |
|
|
((typeof st.mode === 'number' ? st.mode : 0) & 0o777)
|
|
const sz =
|
|
sizeOverride !== undefined && sizeOverride !== null
|
|
? Number(sizeOverride)
|
|
: Number(st.size || 0)
|
|
const mtimeMs =
|
|
typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs)
|
|
? st.mtimeMs
|
|
: Date.now()
|
|
return {
|
|
mode,
|
|
uid: typeof st.uid === 'number' ? st.uid : 1000,
|
|
gid: typeof st.gid === 'number' ? st.gid : 1000,
|
|
size: sz,
|
|
atime: mtimeMs,
|
|
mtime: mtimeMs
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {VfsStatRow} st
|
|
* @param {string} filename
|
|
* @param {number} [sizeOverride]
|
|
*/
|
|
function vfsStatToSftpLongname(st, filename, sizeOverride) {
|
|
const t = st.type
|
|
const modeStr = formatModeString(
|
|
typeof st.mode === 'number' ? st.mode : 0o644,
|
|
t === 'directory' ? 'directory' : t === 'symlink' ? 'symlink' : 'file'
|
|
)
|
|
const u = String(st.user || 'user')
|
|
const g = String(st.group || 'user')
|
|
const sz =
|
|
sizeOverride !== undefined && sizeOverride !== null
|
|
? Number(sizeOverride)
|
|
: Number(st.size || 0)
|
|
const mtimeMs =
|
|
typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs)
|
|
? st.mtimeMs
|
|
: Date.now()
|
|
const mt = formatLsMtime(mtimeMs)
|
|
return `${modeStr} 1 ${u} ${g} ${String(sz).padStart(8, ' ')} ${mt} ${filename}`
|
|
}
|
|
|
|
/**
|
|
* @param {string} dirAbs
|
|
* @param {string} name
|
|
*/
|
|
function joinDirEntry(dirAbs, name) {
|
|
const d = String(dirAbs || '/').replace(/\/+$/, '') || '/'
|
|
const n = String(name || '')
|
|
if (!n) return d
|
|
return d === '/' ? '/' + n : d + '/' + n
|
|
}
|
|
|
|
/**
|
|
* Coerce vfs.readFile results for SFTP OPEN/READ. Empty string is falsy and used to make
|
|
* READ return SSH_FX_FAILURE; ArrayBuffer and non-Buffer views may lack `.subarray`.
|
|
* @param {unknown} x
|
|
* @returns {Buffer | null}
|
|
*/
|
|
function normalizeVfsBytesForSftp(x) {
|
|
if (x == null) return null
|
|
if (typeof x === 'string') return Buffer.from(x, 'utf8')
|
|
if (Buffer.isBuffer(x)) return x
|
|
if (x instanceof ArrayBuffer) return Buffer.from(x)
|
|
if (ArrayBuffer.isView(x))
|
|
return Buffer.from(x.buffer, x.byteOffset, x.byteLength)
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* SFTP inbound handles come from bare-ssh2 bufferSlice → often Uint8Array / safer-buffer views
|
|
* that do not satisfy `Buffer.isBuffer` (see vendor utils.js bufferSlice comment).
|
|
* @param {unknown} h
|
|
* @returns {number | null}
|
|
*/
|
|
function sftpHandleId(h) {
|
|
if (h == null) return null
|
|
if (Buffer.isBuffer(h) && h.length >= 4) return h.readUInt32BE(0)
|
|
if (ArrayBuffer.isView(h) && h.byteLength >= 4) {
|
|
return new DataView(h.buffer, h.byteOffset, 4).getUint32(0, false)
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* @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?: Buffer | null, pos: number, write?: boolean }>} */
|
|
const handles = new Map()
|
|
/** @type {Map<number, { path: string, entries?: string[], idx: number }>} */
|
|
const dirs = new Map()
|
|
/** Single counter so directory and file handles never share an id */
|
|
let nextHandleId = 1
|
|
|
|
/**
|
|
* Strict SFTP clients (e.g. FileZilla) expect outbound replies in the same order as
|
|
* requests were received; overlapping async handlers otherwise reorder packets and
|
|
* trigger "request ID mismatch". After serializing replies here, a rejected handler
|
|
* that sent no packet could stall clients; thrown errors during SFTP encode have been
|
|
* seen as native `bad_optional_access` (-fno-exceptions) killing sshd — always emit
|
|
* STATUS FAILURE on handler failure.
|
|
*/
|
|
/** @type {Promise<void>} */
|
|
let sftpReplyChain = Promise.resolve()
|
|
|
|
/**
|
|
* @param {number} reqid
|
|
* @param {() => void | Promise<void>} fn
|
|
*/
|
|
function enqueueSftpReply(reqid, fn) {
|
|
const next = sftpReplyChain.then(async () => {
|
|
try {
|
|
await Promise.resolve().then(fn)
|
|
} catch {
|
|
try {
|
|
sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
})
|
|
sftpReplyChain = next.catch(() => {})
|
|
}
|
|
|
|
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('/')) logical = resolveLogical(norm)
|
|
else logical = resolveLogical(`${homeLogical}/${norm}`.replace(/\/+/g, '/'))
|
|
const abs = logical
|
|
if (!bareOsSftpAllowedLogicalPath(abs, homeLogical)) 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 attrs = vfsStatToSftpAttrs(/** @type {VfsStatRow} */ (st), undefined)
|
|
sftp.attrs(reqid, attrs)
|
|
} catch {
|
|
sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} abs already-resolved logical path (no mapPath)
|
|
*/
|
|
async function statAbsolute(reqid, abs, sizeOverride) {
|
|
try {
|
|
const st = await vfs.lstat(abs)
|
|
if (!st) return sftp.status(reqid, STATUS_CODE.NO_SUCH_FILE)
|
|
const attrs = vfsStatToSftpAttrs(/** @type {VfsStatRow} */ (st), sizeOverride)
|
|
sftp.attrs(reqid, attrs)
|
|
} catch {
|
|
sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
}
|
|
}
|
|
|
|
sftp.on('REALPATH', (reqid, p) => {
|
|
enqueueSftpReply(reqid, async () => {
|
|
const abs = mapPath(p) || homeLogical
|
|
try {
|
|
const st = await vfs.lstat(abs)
|
|
const attrs = st
|
|
? vfsStatToSftpAttrs(/** @type {VfsStatRow} */ (st), undefined)
|
|
: {}
|
|
const name = [
|
|
{
|
|
filename: abs,
|
|
longname: st
|
|
? vfsStatToSftpLongname(
|
|
/** @type {VfsStatRow} */ (st),
|
|
abs.split('/').pop() || abs,
|
|
undefined
|
|
)
|
|
: 'drwxr-xr-x 1 user user 0 Jan 1 00:00 ' + abs,
|
|
attrs
|
|
}
|
|
]
|
|
sftp.name(reqid, name)
|
|
} catch {
|
|
sftp.name(reqid, [
|
|
{
|
|
filename: abs,
|
|
longname: 'drwxr-xr-x 1 user user 0 Jan 1 00:00 ' + abs,
|
|
attrs: {}
|
|
}
|
|
])
|
|
}
|
|
})
|
|
})
|
|
|
|
sftp.on('STAT', (reqid, p) => {
|
|
enqueueSftpReply(reqid, () => statLike(reqid, p, false))
|
|
})
|
|
sftp.on('LSTAT', (reqid, p) => {
|
|
enqueueSftpReply(reqid, () => statLike(reqid, p, true))
|
|
})
|
|
|
|
sftp.on('OPENDIR', (reqid, p) => {
|
|
enqueueSftpReply(reqid, async () => {
|
|
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 = nextHandleId++
|
|
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', (reqid, handle) => {
|
|
enqueueSftpReply(reqid, async () => {
|
|
const id = sftpHandleId(handle)
|
|
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
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) {
|
|
const childAbs = joinDirEntry(d.path, ent)
|
|
try {
|
|
const st = await vfs.lstat(childAbs)
|
|
if (!st) {
|
|
names.push({
|
|
filename: ent,
|
|
longname: '-rw-r--r-- 1 user user 0 Jan 1 00:00 ' + ent,
|
|
attrs: {}
|
|
})
|
|
continue
|
|
}
|
|
const bl = /** @type {VfsStatRow} */ (st)
|
|
names.push({
|
|
filename: ent,
|
|
longname: vfsStatToSftpLongname(bl, ent, undefined),
|
|
attrs: vfsStatToSftpAttrs(bl, undefined)
|
|
})
|
|
} catch {
|
|
names.push({
|
|
filename: ent,
|
|
longname: '-rw-r--r-- 1 user user 0 Jan 1 00:00 ' + ent,
|
|
attrs: {}
|
|
})
|
|
}
|
|
}
|
|
sftp.name(reqid, names)
|
|
})
|
|
})
|
|
|
|
sftp.on('OPEN', (reqid, filename, flags) => {
|
|
enqueueSftpReply(reqid, async () => {
|
|
const abs = mapPath(filename)
|
|
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
|
if (bareOsSftpDeniesWriteOpen(abs, homeLogical, flags, OPEN_MODE)) {
|
|
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
|
|
}
|
|
}
|
|
buf = normalizeVfsBytesForSftp(buf)
|
|
if (buf == null && !wantCreat && wantRead) {
|
|
return sftp.status(reqid, STATUS_CODE.NO_SUCH_FILE)
|
|
}
|
|
if (buf == null && wantCreat) buf = Buffer.alloc(0)
|
|
if (buf == null) buf = Buffer.alloc(0)
|
|
const h = Buffer.alloc(4)
|
|
const id = nextHandleId++
|
|
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) => {
|
|
enqueueSftpReply(reqid, () => {
|
|
try {
|
|
const id = sftpHandleId(handle)
|
|
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
const f = handles.get(id)
|
|
if (!f || f.buf == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
const raw = f.buf
|
|
const body =
|
|
Buffer.isBuffer(raw) || ArrayBuffer.isView(raw)
|
|
? raw
|
|
: typeof raw === 'string'
|
|
? Buffer.from(raw, 'utf8')
|
|
: null
|
|
if (!body) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
const off = Number(offset)
|
|
const len = Number(length)
|
|
if (!Number.isFinite(off) || !Number.isFinite(len)) {
|
|
return sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
}
|
|
const end = Math.min(off + len, body.length)
|
|
if (off >= body.length) return sftp.status(reqid, STATUS_CODE.EOF)
|
|
let slice = body.subarray(off, end)
|
|
if (!Buffer.isBuffer(slice)) {
|
|
slice = Buffer.from(slice.buffer, slice.byteOffset, slice.byteLength)
|
|
}
|
|
sftp.data(reqid, slice)
|
|
} catch {
|
|
sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
}
|
|
})
|
|
})
|
|
|
|
sftp.on('FSTAT', (reqid, handle) => {
|
|
enqueueSftpReply(reqid, async () => {
|
|
const id = sftpHandleId(handle)
|
|
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
const f = handles.get(id)
|
|
if (f) {
|
|
const hint = f.buf ? f.buf.length : undefined
|
|
return statAbsolute(reqid, f.path, hint)
|
|
}
|
|
const d = dirs.get(id)
|
|
if (d && d.path) return statAbsolute(reqid, d.path, undefined)
|
|
sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
})
|
|
})
|
|
|
|
sftp.on('WRITE', (reqid, handle, offset, data) => {
|
|
enqueueSftpReply(reqid, async () => {
|
|
const id = sftpHandleId(handle)
|
|
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
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) => {
|
|
enqueueSftpReply(reqid, () => {
|
|
const id = sftpHandleId(handle)
|
|
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
|
handles.delete(id)
|
|
dirs.delete(id)
|
|
ok(reqid)
|
|
})
|
|
})
|
|
|
|
sftp.on('REMOVE', (reqid, p) => {
|
|
enqueueSftpReply(reqid, async () => {
|
|
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', (reqid, p) => {
|
|
enqueueSftpReply(reqid, async () => {
|
|
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', (reqid, p) => {
|
|
enqueueSftpReply(reqid, async () => {
|
|
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('SETSTAT', (reqid) => {
|
|
enqueueSftpReply(reqid, () => {
|
|
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
|
})
|
|
})
|
|
|
|
sftp.on('FSETSTAT', (reqid) => {
|
|
enqueueSftpReply(reqid, () => {
|
|
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
|
})
|
|
})
|
|
|
|
sftp.on('EXTENDED', (reqid) => {
|
|
enqueueSftpReply(reqid, () => {
|
|
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
|
})
|
|
})
|
|
|
|
sftp.on('RENAME', (reqid) => {
|
|
enqueueSftpReply(reqid, () => {
|
|
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
|
})
|
|
})
|
|
|
|
sftp.on('READLINK', (reqid) => {
|
|
enqueueSftpReply(reqid, () => {
|
|
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
|
})
|
|
})
|
|
sftp.on('SYMLINK', (reqid) => {
|
|
enqueueSftpReply(reqid, () => {
|
|
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
|
})
|
|
})
|
|
}
|