SFTP
modernssh implements version 3 of the SSH File Transfer Protocol from
draft-ietf-secsh-filexfer-02, the version used by OpenSSH. SFTP runs in an SSH session channel;
authentication, host-key verification, encryption, channel flow control, and SFTP framing remain
separate layers.
Client sessions
Open and negotiate a session after connecting:
import { Client } from "@bunkerch/modernssh"
const client = new Client({ hostname: "files.example.com", username: "deploy" })
// Configure the host-key hook and authentication before connecting.
await client.connect()
const sftp = await client.sftp({}, { requestTimeout: 30_000 })The resulting SFTPClient exposes the negotiated version, the duplicate-preserving extensions
announcement list, supportsExtension(name, version?), and its immutable requestTimeout.
Each extensions read is a frozen defensive snapshot; changing an advertised data buffer cannot
enable, disable, or alter the capabilities used internally by supportsExtension.
Version matching compares the advertised opaque bytes exactly with the requested UTF-8 version;
non-ASCII bytes cannot alias an ASCII version through lossy decoding.
The timeout defaults to the connection's replyTimeout; direct SFTPClient.connect() calls default
to 30 seconds. It must be an integer from 1 through 2147483647.
Client environment and option bags must be plain objects, environment values must be strings, and
only undefined selects the inherited or 30-second timeout default. Explicit null is rejected
before a session channel is allocated or initialization is sent. Direct SFTPClient.connect()
calls also require an actual boolean compatibility flag.
The baseline operations are Promise-based:
open,close,read, andwriteoperate on opaque handles. The three-argumentreadreturns a newly allocatedBuffer; its five-argument overload reads into a selected range of a caller buffer and returns{ bytesRead, buffer }. The corresponding five-argumentwriteoverload writes only the selected caller-buffer range and returns{ bytesWritten, buffer }. These are Promise APIs and do not accept completion callbacks.writesplits large buffers into server-safe requests; independent requests may be outstanding concurrently and responses are matched by request identifier. A write snapshots its handle and selected data when it starts, so caller mutation while an earlier chunk awaits acknowledgement cannot alter later chunks. The negotiated chunk limit is also fixed for the operation, preventing overlap if public limit metadata is changed while a chunk is pending. Assigning an invalidmaxWriteLengthrejects locally before a request is sent; the value must be a positive safe integer.readapplies the same requirement tomaxReadLengthbefore treating a zero requested length as an empty result, so whole-file reads cannot mistake an invalid limit for end-of-file. Buffer offsets and lengths must be non-negative safe integers whose range fits within the supplied buffer.writeFilecopies Buffer input before opening the remote file; mutation during the open request cannot change the eventual contents.stat,lstat,fstat,setstat, andfsetstatretrieve or change attributes.chmod,fchmod,chown,fchown,utimes,futimes,truncate, andftruncateare focused helpers.opendirandreaddirexpose individual directory requests.iterateDirectoryprovides a bounded-memory async iterator, filters the conventional.and..entries, snapshots Buffer paths, and closes its handle on completion, failure, or early loop exit.readDirectorycollects that iterator with default limits of 100,000 entries and 16 MiB of retained name and extended attribute bytes. SetmaxEntriesormaxBytesto a smaller or larger non-negative safe integer; exceeding either limit rejects after closing the handle.mkdir,rmdir,unlink/remove,rename,realpath,readlink, andsymlinkcover the remaining version 3 operations.
For example:
const handle = await sftp.open("incoming/archive.bin", "wx", { permissions: 0o640 })
try {
await sftp.write(handle, archive, 0n)
const header = Buffer.alloc(16)
const { bytesRead } = await sftp.read(handle, header, 0, header.length, 0n)
await sftp.write(handle, header, 0, bytesRead, 4096n)
await sftp.ftruncate(handle, 4_294_967_297n)
const attributes = await sftp.fstat(handle)
console.log(attributes.size) // bigint | undefined
} finally {
await sftp.close(handle)
}
for await (const entry of sftp.iterateDirectory("incoming")) {
console.log(entry.filename.toString("utf8"), entry.attributes.size)
}
const boundedEntries = await sftp.readDirectory("incoming", {
maxEntries: 1_000,
maxBytes: 4 * 1024 * 1024,
})Whole-file helpers follow Node-style flags while returning Promises:
await sftp.writeFile("incoming/message.txt", "first line\n", { mode: 0o640 })
await sftp.appendFile("incoming/message.txt", "second line\n")
const message = await sftp.readFile("incoming/message.txt", "utf8")readFile returns a Buffer unless an encoding is requested. Its optional maxBytes limit rejects
oversized files before allocation when the server reports a size and while reading when it does
not. exists returns false only for SFTPStatusCode.NoSuchFile; permission and transport errors
remain visible.
fastGet(remotePath, localPath, options?) and fastPut(localPath, remotePath, options?) transfer
disjoint chunks concurrently. concurrency defaults to 64 and is bounded by the request engine;
chunkSize defaults to 32 KiB and is clamped to negotiated server limits. fastPut also accepts a
remote mode. Transfer completion is Promise-only. For observational progress, attach synchronous
downloadProgress or uploadProgress listeners to the client:
sftp.on("downloadProgress", ({ remotePath, localPath, transferred, chunk, total }) => {
console.log({ remotePath, localPath, transferred, chunk, total })
})
await sftp.fastGet("remote.bin", "local.bin")Each progress event identifies both paths and reports the cumulative bytes, completed chunk size,
and total size. The remote path is a fresh Buffer owned by the event, so listener mutation cannot
alter the transfer or later events. Progress listeners are EventEmitter observers: keep them
synchronous and do not pass an async function to on().
fastGet snapshots a Buffer remote path and its transfer options before its separate STAT and
OPEN requests.
fastPut snapshots its remote path and options before opening or inspecting the local file.
Both helpers accept only plain option objects and validate chunkSize, concurrency, an explicit
upload mode, and their public maxReadLength or maxWriteLength metadata before remote or local
I/O begins. Transfer limits must remain positive safe integers.
All workers settle before either handle is closed, and an operation error is preserved over a
secondary close failure.
For backpressure-aware pipelines, createReadStream and createWriteStream return Node streams:
const range = sftp.createReadStream("archive.bin", { start: 1024n, end: 2047n })
range.pipe(process.stdout)
const upload = sftp.createWriteStream("incoming/archive.bin", { mode: 0o640 })
source.pipe(upload)Read ranges use inclusive start and end offsets. Both stream types accept an already-open
handle, expose exact bigint bytesRead/bytesWritten counters, emit open and ready after a
path is opened, and close their handle exactly once by default. With autoClose: false, natural
completion leaves the handle open and ownership passes to the caller; calling the stream's close
method still drains pending writes and closes it explicitly. close() also closes a retained handle
after the local stream has already been destroyed, and its Promise settles when that remote close
finishes. Read requests are clamped to the negotiated limit, and writable backpressure is released
only after the corresponding SFTP write has completed.
Buffer paths and already-open handles are snapshotted when the stream is constructed. The path
and handle properties and the handle passed to the open event are defensive copies as well, so
mutating them cannot redirect later reads, writes, or close operations. A supplied handle must be
active and issued by the same SFTPClient, in addition to satisfying the protocol's 256-byte
maximum.
Call await sftp.close() to close the subsystem channel and settle after its terminal close
event. Concurrent calls share one Promise; await using invokes the same operation through
Symbol.asyncDispose. This no-argument overload is distinct from close(handle), which closes one
remote file, directory, or extension handle. Session closure rejects outstanding SFTP requests,
causes the peer to release any remaining handles, and leaves the parent SSH connection available.
The configured requestTimeout also bounds this close handshake; expiry rejects the shared Promise
and destroys only the subsystem channel.
Call sftp.end() only when intentionally sending EOF while continuing to receive from the
subsystem. sftp.destroy(error?) aborts it. An abort rejects every pending request and handle
allocation with the supplied error; subsequent operations reject because the SFTP session is
closed.
Paths, offsets, and attributes
String paths are validated and encoded as UTF-8 before a request is written. Pass a Buffer when a
server-side filename must be preserved as opaque bytes. File handles are always opaque Buffer
values and are limited to the protocol's
256-byte maximum. realpath(), readlink(), opensshExpandPath(), and homeDirectory() return a
strict UTF-8 string by default; pass "buffer" as their final argument to receive the returned name
as an owned Buffer without decoding it.
Every handle-taking client method validates the Buffer type, 256-byte limit, and live session
ownership before allocating or writing a request, including zero-length reads, empty writes,
attribute helpers, and extensions. Fabricated and closed handles reject locally. READ and WRITE
also reject directory handles, while READDIR rejects file handles; an extension-issued handle is
type-agnostic because its extension defines the resource. The method snapshots a valid handle
before retaining it across asynchronous work, so later caller mutation cannot change the request.
Text arguments to extensions receive the same strict encoding. OpenSSH user/group lookup names are strictly decoded as UTF-8; malformed replies fail instead of exposing replacement characters.
Offsets and file sizes are unsigned 64-bit wire values. The API accepts bigint positions and
returns bigint sizes so values larger than JavaScript's safe integer range remain exact. Numeric
positions are accepted only when they are non-negative safe integers. Access and modification
times are version 3's unsigned 32-bit Unix seconds. Truncation lengths accept a non-negative safe
integer or exact uint64 bigint; invalid lengths fail before a request identifier is allocated.
SFTPAttributes contains only fields present on the wire:
interface SFTPAttributes {
size?: bigint
uid?: number
gid?: number
permissions?: number
accessTime?: number
modificationTime?: number
extended?: readonly { type: Buffer; data: Buffer }[]
}The paired uid/gid and accessTime/modificationTime fields must be supplied together when
encoding an attribute update.
Client stat, lstat, and fstat results—and directory-entry attributes—are SFTPStats
instances. They retain the exact fields above and add isDirectory, isFile, isBlockDevice,
isCharacterDevice, isSymbolicLink, isFIFO, and isSocket. The convenience mode, atime,
and mtime aliases map to permissions, accessTime, and modificationTime; sizes remain bigint
rather than losing uint64 precision. Returned directory names, long names, and extended-attribute
buffers are owned snapshots rather than aliases into protocol input or caller-owned attributes.
stringToFlags and flagsToString provide nullable conversions. The legacy OPEN_MODE and
STATUS_CODE exports use uppercase keys, while SFTPOpenFlags and
SFTPStatusCode remain the typed modern enums. sftpOpenFlags is the strict converter used for
requests and throws on invalid strings, unknown bits, or invalid flag combinations.
Errors and limits
Remote failure statuses reject with SFTPStatusError. Its numeric code, requestId, and
languageTag properties preserve the complete response. Compare code with SFTPStatusCode, for
example SFTPStatusCode.NoSuchFile or SFTPStatusCode.PermissionDenied.
Malformed frames, unexpected response identifiers, wrong response types, duplicate initialization,
duplicate live handles, unsupported attribute flags, and status codes used outside their defined
request context are fatal protocol errors. Semantic reply checks use the same fatal path as framing:
empty or oversized DATA, empty baseline directory NAME, the wrong number of names for
single-name operations, empty or slash-containing READDIR filenames, non-absolute REALPATH
results, malformed negotiated-extension replies, and invalid returned UTF-8 all close the SFTP
channel before rejecting the operation. A server-sent NoConnection or
ConnectionLost is also fatal because those two pseudo-statuses are local to clients and must never
appear on the wire. A successful positive-length read must return at least one byte; end-of-file is
reported with SFTPStatusCode.EOF only for READ, READDIR, or an extension that defines it.
Rejecting no-progress responses prevents silent file truncation and unbounded directory scans.
Messages are bounded to OpenSSH's 256 KiB ceiling before allocation, handles to 256 bytes, and
outstanding client requests to 1024. The initial read and write request size is 32 KiB, which every
conforming server is expected to support. Status messages use fatal UTF-8 validation, status
language tags use the RFC 5646 grammar (with the empty protocol value retained for an unspecified
language), and extension identifiers are validated SSH names. Filenames, long names, paths, handles,
and extension payloads remain opaque bytes. The wire codec never replacement-decodes them. Decoded
opaque fields are owned buffers rather than views into the input frame. The streaming parser
accepts arbitrary fragmentation while allocating each declared frame once, so one-byte fragments
require work linear in the bounded packet size rather than repeatedly copying the accumulated
prefix.
Fatal errors, including EOF in the middle of a frame, close the SFTP channel in both peer roles and
reject pending client operations. They do not tear down an otherwise healthy SSH connection.
Initialization and every tagged request reply are bounded by requestTimeout. Expiry rejects the
operation and closes only that SFTP channel, which prevents a late response or eventually reused
request identifier from corrupting a later exchange while leaving the SSH connection available.
Local request-encoding failures reject without consuming an outstanding-request slot, so repeated
invalid calls cannot exhaust or poison an otherwise healthy SFTP session.
OpenSSH reverses the two wire arguments of the standard SSH_FXP_SYMLINK request. The client uses
the peer's SSH identification to apply that published OpenSSH behavior while preserving the draft's
ordering for other implementations.
Application extensions
Applications can use negotiated extensions without bypassing the bounded request engine. The
server must advertise the extension name; an optional version requires its payload to match
exactly. extended() copies the opaque request data and defaults to accepting
SSH_FXP_EXTENDED_REPLY:
const reply = await sftp.extended("lookup@example.com", Buffer.from("alice"), {
version: "1",
})
if (reply.type === SFTPPacketType.ExtendedReply) console.log(reply.data)Extension names follow the RFC 4251 name grammar: 1–64 printable US-ASCII characters, no comma,
and at most one non-leading @ followed by a valid DNS domain. supportsExtension() validates the
same grammar. extended() rejects malformed names before consuming a request identifier or
writing a packet.
Extensions that define another successful response packet declare it explicitly:
await sftp.extended("notify@example.com", payload, {
expectedTypes: [SFTPPacketType.Status],
})A non-success status becomes SFTPStatusError. A successful response type outside the declared
set is a protocol error and closes the SFTP session, preventing a malformed response from being
interpreted as another extension's layout. When expectedTypes includes SFTPPacketType.Handle, a
returned handle is registered as active, counts against the negotiated handle limit, and remains
valid for extension-defined operations until close(). On the server, advertise application
extensions through SFTPServerOptions.extensions and handle them with the awaited EXTENDED hook;
reply with extendedReply(), status(), or the response method specified by that extension.
Generic extension responses may use empty DATA or NAME payloads when their own protocol defines
that meaning; baseline positive-length READ and READDIR responses retain their progress
requirements.
OpenSSH extensions
Every extension method checks the exact version advertised in SSH_FXP_VERSION before sending a
request. The client supports OpenSSH's published posix-rename, statvfs, fstatvfs, hardlink,
fsync, lsetstat, limits, and expand-path extensions, plus the standardized copy-data and
home-directory extensions and OpenSSH's users-groups-by-id extension.
The main methods are opensshPosixRename, opensshStatVFS, opensshFStatVFS, opensshHardlink,
opensshFSync, opensshLSetStat, opensshLimits, opensshExpandPath, copyData, homeDirectory,
and usersGroups. The ext_openssh_* aliases preserve earlier public spellings where they differ
from the preferred method names.
Server handlers can decode the published request bodies with the exported strict helpers. Use
decodeSFTPExtensionString() for path and username requests,
decodeSFTPHandleExtension() for fstatvfs and fsync, and
decodeSFTPTwoPathExtension() for POSIX rename and hard links. Dedicated decoders cover
lsetstat, copy-data, and users-groups-by-id. Every decoder requires the complete layout,
rejects truncation and trailing bytes, and returns owned buffers. Handle decoders enforce the SFTP
256-byte handle bound. copy-data offsets and lengths remain bigint; user and group identifiers
remain unsigned 32-bit numbers.
Successful statvfs and fstatvfs hooks can pass encodeSFTPStatVFS() to
sftp.extendedReply(). A users-groups-by-id hook uses encodeSFTPUsersGroups() for its two
ordered name lists. These response encoders are exact inverses of the client decoders, retain all
unsigned 64-bit filesystem values, and reject invalid UTF-8 names before writing a response. The
client requires the reply to contain exactly one username per requested user ID and one group name
per requested group ID, including empty strings for IDs the server cannot resolve. A cardinality
mismatch makes the positional mapping ambiguous and therefore aborts the SFTP subsystem channel as
a peer protocol error; the parent SSH connection and its other channels remain usable.
The published copy-data protocol requires SFTPStatusCode.InvalidParameter when both handles are
the same. The server permits this registered status only for EXTENDED requests; baseline SFTP v3
requests retain the v3 status-code set.
When limits@openssh.com version 1 is advertised, session setup requests it automatically. The
exact unsigned 64-bit reply remains available as sftp.limits; safe request sizes are reflected in
maxReadLength and maxWriteLength, and maxOpenHandles is Infinity when the server reports no
fixed handle limit. Pending open, opendir, and extension requests that may return a handle count
toward a finite handle limit; attempts beyond it reject locally without sending a request. The
client invalidates a handle as soon as its CLOSE frame is accepted for writing, before the reply,
so concurrent reuse or a second close rejects locally. A close or flush failure does not revive the
handle. A local failure before the frame is accepted keeps it tracked. Reuse of an active opaque
value by the server is a fatal protocol error. A server status failure while negotiating limits
leaves the conservative 32 KiB defaults in place, but a malformed successful reply aborts setup as
a protocol error.
A modernssh server advertises version 1 of this extension by default and answers it internally;
the request does not enter the application EXTENDED hook. Its default reply reports a 256 KiB
packet bound, 254 KiB read and write bounds, and 256 handles. The exported encodeSFTPLimits() and
decodeSFTPLimits() helpers encode and decode the four exact uint64 fields for applications that
deliberately provide a separate extension implementation.
const handle = await sftp.open("incoming/archive.bin", "r")
try {
const filesystem = await sftp.opensshFStatVFS(handle)
console.log(filesystem.blockSize) // bigint
} finally {
await sftp.close(handle)
}
await sftp.opensshPosixRename("incoming/new", "incoming/current")Server sessions
SFTP access is denied by the ordinary session-subsystem policy hook until explicitly accepted. The server does not expose the host filesystem automatically: mapping virtual paths, enforcing a root, authorizing operations, allocating opaque handles, and translating operating-system errors are application policy.
server.hooker.hook("channelOpenRequest", (_hook, channel, decision) => {
decision.allowOpen = channel instanceof SessionChannel
})
server.on("connection", (connection) => {
connection.on("channel", (channel) => {
if (!(channel instanceof SessionChannel)) return
channel.hooker.hook("subsystemRequest", (_hook, context, decision) => {
decision.success = context.subsystem === "sftp"
})
channel.events.on("sftp", (sftp) => {
sftp.hooker.hook("STAT", async (_hook, request, operation) => {
try {
const attributes = await lookupAuthorizedAttributes(
request.path,
operation.signal,
)
await sftp.attributes(request.requestId, attributes)
} catch {
await sftp.status(request.requestId, SFTPStatusCode.NoSuchFile)
}
})
})
})
})An application-owned extension remains an awaited policy operation. Advertise the exact supported version and decode its complete request before touching the backing store:
import { decodeSFTPTwoPathExtension, SFTPServer, SFTPStatusCode } from "modernssh"
const sftp = new SFTPServer(shell, {
extensions: [{ name: "posix-rename@openssh.com", data: Buffer.from("1") }],
})
sftp.hooker.hook("EXTENDED", async (_hook, request, operation) => {
if (request.request !== "posix-rename@openssh.com") {
await sftp.status(request.requestId, SFTPStatusCode.OperationUnsupported)
return
}
const { firstPath, secondPath } = decodeSFTPTwoPathExtension(request.data)
await renameWithinAuthorizedRoot(firstPath, secondPath, operation.signal)
await sftp.status(request.requestId, SFTPStatusCode.Ok)
})SFTPServer.hooker provides the uppercase request hooks used by the protocol: OPEN, CLOSE, READ,
WRITE, LSTAT, FSTAT, SETSTAT, FSETSTAT, OPENDIR, READDIR, REMOVE, MKDIR, RMDIR,
REALPATH, STAT, RENAME, READLINK, SYMLINK, and EXTENDED. Hooks may be async and are
awaited by the request that triggered them. When no specific hook is registered, the generic
request hook is used as a fallback. An entirely unhandled request gets
SFTPStatusCode.OperationUnsupported. The EventEmitter requestReceived event is passive
observation and does not take ownership of the response. Its request object and nested metadata are
frozen, and its buffers are owned copies: changing observed bytes cannot alter the request later
delivered to an awaited Hooker handler. Every Hooker also receives a final
SFTPServerOperationContext; pass its signal to cancellable filesystem, database, and
authorization operations.
Complete each request exactly once with the appropriate method:
status(requestId, code, message?, languageTag?)reports success for operations without result data or a failure for any operation.EOFis limited toREAD,READDIR, and extensions;InvalidParameteris extension-only; client-local connection statuses are never emitted.handle,data,name, andattributesreturn the corresponding baseline result.extendedReplyreturns extension-specific bytes.
Every response method returns a Promise. Await it from the Hooker handler: it resolves only after the response has been accepted by the SSH channel write, and rejects if that write fails. The request retains its concurrency slot until both the handler and response write complete. Opaque response values are validated as Buffers and snapshotted before encoding; response handles also enforce the 256-byte protocol limit.
SFTPServerOptions.requestTimeout bounds the version response and each dispatched request from the
start of its awaited Hooker policy through completion of its response write. It defaults to 30
seconds and must be an integer from 1 through 2147483647. Expiry aborts only that subsystem
channel, rejects an in-progress response write, and
releases all queued request state; the authenticated SSH connection and its other multiplexed
channels remain usable. This deadline starts when a request reaches a concurrency slot, while the
separate 1024-request bound limits work waiting in the scheduler. The request signal aborts with
the failure when its deadline expires or the SFTP channel closes. Cancellation cannot undo a
filesystem mutation which already completed, so handlers should honor the signal before committing
state.
Call await sftp.close() when the application owns the server session lifecycle. It stops new
request dispatch immediately, asks the SSH channel to close, and settles after the channel's
terminal close event. Concurrent calls share one Promise, and Symbol.asyncDispose provides the
same operation for await using. SFTPServerOptions.closeTimeout bounds peer acknowledgement and
defaults to 30 seconds, with the same integer range; expiry force-aborts the channel and rejects the
close operation.
sftp.destroy(error?) remains the immediate abort path.
The implementation rejects duplicate outstanding identifiers, invalid response types,
out-of-context status codes, oversized read results, empty baseline name responses, server use of
client-only connection status codes, and a second response. A hook rejection becomes an SFTP
failure response and is observable through the hooker's uncaughtException event; returning
without a response also produces a failure instead of leaving the client pending. A locally invalid
or oversized response throws before claiming the request, so the handler may catch that error and
send an appropriate failure status instead.
SFTP clients pipeline tagged requests and may receive their responses out of order. The server
therefore runs up to 64 request hooks concurrently by default while keeping each response and
contained hook failure associated with its own request identifier. Set maxConcurrentRequests in
the session's decision.sftp options to a safe integer from 1 through 1024 when backend capacity
requires another bound; setting it to 1 deliberately restores serial dispatch. At most 1024 total
queued and active requests are accepted. Their decoded wire sizes are also bounded to 32 MiB by
default. Set maxOutstandingRequestBytes to a positive safe integer, up to 268439552, to lower or
raise that budget. A request consumes the exact size of its length-prefixed SFTP frame until its
response write is accepted. Exceeding either outstanding-request bound aborts the SFTP subsystem
channel while leaving the authenticated SSH connection and its other channels usable.
The scheduler preserves SFTP v3's same-file receive order. Requests sharing an opaque handle or an
exact path wait for the earlier handler and response write, while unrelated handles and paths may
run concurrently. Handles returned by OPEN and OPENDIR are associated with their requested path,
so a path operation cannot overtake an outstanding operation through that handle. Two-path
operations reserve both paths, and an opaque EXTENDED request is an ordering barrier because its
resource cannot be inferred from the baseline packet. A CLOSE response discards the handle's path
association even when it reports failure, because the protocol invalidates the handle when the
request is sent. If an application maps distinct path byte strings to the same backend object—for
example through aliases outside the virtual path model—it must additionally serialize those backend
aliases because the protocol layer cannot discover that identity.
The server rejects a fabricated, unknown, or closed handle with SFTPStatusCode.Failure before any
request Hooker handler runs. It also rejects READDIR through a file handle and READ or WRITE
through a directory handle. Handles returned by an EXTENDED request remain valid but
type-agnostic because the extension defines their semantics. Applications still own the mapping
from each issued opaque value to its backend resource and must not trust the bytes as a filesystem
identifier.
The server allows 256 active or pending OPEN, OPENDIR, or extension-issued handles by default. Set
maxOpenHandles to a non-negative safe integer to match the backend's resource budget. Once the
limit is reached, another baseline open request receives SFTPStatusCode.Failure before its Hooker
handler runs, so no backend handle should be allocated for it. An extension handler attempting to
return another handle receives a local error and can return an appropriate status instead. A
successful handle value must remain unique within the session until the corresponding CLOSE
response has been written; that response releases the capacity even when it reports failure.
Baseline READDIR responses accept only non-empty relative entry names without /, and
REALPATH responses must contain one absolute name. Invalid application responses are rejected
locally before serialization, mirroring the client-side peer checks.
An OPEN request with unknown flag bits, or with EXCL but no CREAT, receives
SFTPStatusCode.BadMessage before the OPEN Hooker handler runs. TRUNC without CREAT remains
valid for truncating an existing file. This enforces the mandatory v3 flag relationship at the wire
boundary instead of requiring every filesystem backend to repeat it.
The server's default maximum READ length and WRITE data length are both 254 KiB. Set
maxReadLength or maxWriteLength to a smaller positive safe integer when the backend needs a
tighter per-operation bound. Oversized requests receive SFTPStatusCode.Failure before their
Hooker handler runs, preventing an untrusted peer from driving a larger backend allocation. These
values and maxOpenHandles are the limits returned by the built-in extension.
A peer may reuse a numeric request identifier after receiving its response; if the prior Hooker handler is still completing, the reused identifier remains queued until that handler returns. This prevents late code in the prior handler from accidentally responding to the newer request, without serializing unrelated identifiers.
Advertise extension name/version pairs through decision.sftp.extensions only when every advertised
operation is actually implemented:
decision.success = true
decision.sftp = {
extensions: [{ name: "example@example.com", data: Buffer.from("1") }],
maxConcurrentRequests: 32,
maxOutstandingRequestBytes: 16 * 1024 * 1024,
maxOpenHandles: 128,
maxReadLength: 64 * 1024,
maxWriteLength: 64 * 1024,
}Extension names are validated when the server session is constructed. The configured array,
entries, and opaque data buffers are snapshotted before use, so later application mutation cannot
change the version advertisement already assigned to that session. The server's public
extensions getter returns a new frozen snapshot, so mutating one of its data buffers cannot change
the live advertisement either. Extension data must be supplied as a Buffer. Set
advertiseLimits: false only when the application must suppress or independently implement the
limits extension. The built-in advertisement rejects a configured extension with the same name to
avoid two authorities. A zero maxOpenHandles disables opening handles and suppresses the built-in
advertisement by default because zero has the incompatible wire meaning “no fixed limit”; explicitly
combining that capacity with advertiseLimits: true is rejected.
The server option bag must be a plain object, extensions must be an array, and
openSSHSymlinkArguments must be a boolean. Explicit null values are rejected rather than
selecting defaults, including for every limit and advertiseLimits.
For SYMLINK, call sftp.symlinkPaths(request) to obtain semantic targetPath and linkPath
values. Session integration detects OpenSSH and Dropbear identifications and normalizes OpenSSH's
published argument reversal; openSSHSymlinkArguments can be set explicitly for a proxied or
otherwise unusual peer. The returned paths are owned copies and cannot mutate the request passed to
the Hooker handler.
Never use a client-supplied path directly with a local filesystem API. Resolve it beneath an
application-owned root, reject traversal outside that root, map issued opaque handles to
application-owned per-session resources, close all remaining resources on the SFTP close event,
and apply authorization to every operation rather than only OPEN.