Compare commits
8
Commits
35bd210c20
...
fb8290e29b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb8290e29b | ||
|
|
f8cf4c9bf3 | ||
|
|
a1eca114ce | ||
|
|
d1803a37de | ||
|
|
a5c0a66d9a | ||
|
|
99cdd9322b | ||
|
|
6f54110fe5 | ||
|
|
b2d04de758 |
@@ -2,8 +2,16 @@
|
||||
|
||||
This file records all work in this repository that was assisted or authored by an AI agent. It is append-only; merge conflicts are resolved by keeping all lines (see `.gitattributes`).
|
||||
|
||||
- **`@webnet/sftp` — stable keyless server identity**: GPT-5.6 Luna fixed `SFTPServer` to lazily generate and cache one ephemeral host key per server instance, and added a regression test covering sequential connections and host-key fingerprints.
|
||||
- **`@webnet/vfs` — canonical path helpers**: Codex (GPT-5.6 Luna) added public POSIX VFS path utilities for normalization, resolution, parents, basenames, and joining. The helpers clamp traversal at the VFS root, preserve backslashes and Unicode names, and are covered by focused edge-case tests. MemoryVFS, FsaVFS, FTP, and SFTP now reuse the shared implementation; NodeVFS retains its separate traversal-rejection guard.
|
||||
|
||||
This project was set up with the assistance of [Claude Code](https://claude.ai/code) (Anthropic). The following were written by Claude Code:
|
||||
|
||||
- **`@webnet/http-static` — VFS-backed static HTTP handler**: GPT-5.6 Luna added a new workspace package exposing `createStaticHandler`, with request/context path resolution, prefix and suffix lookup, index files, HTML/JSON directory listings, fallback paths, metadata and conditional responses, HEAD support, and single-byte range streaming. Added focused unit coverage and package build/test/typecheck scaffolding.
|
||||
- **`@webnet/http-static` — review fixes**: GPT-5.6 Luna addressed review findings by rejecting requests outside a configured prefix, returning 416 for ranges on empty files, normalizing method matching, and honoring `If-None-Match: *`.
|
||||
- **`@webnet/http-static` — precedence test vectors**: GPT-5.6 Luna added a seeded `MemoryVFS` matrix covering every requested path across default/custom/disabled indexes, suffix probing, and fallback configurations.
|
||||
- **`@webnet/http-static` — HTTP-relative directory listings**: GPT-5.6 Luna updated generated JSON paths and HTML links to use the request URL pathname, including configured HTTP prefixes, and added prefixed listing coverage.
|
||||
|
||||
- The `tailscale` submodule fork and its Go-side `tsconnect` patches (the `webnet` branch)
|
||||
- The `packages/tsconnect` TypeScript SDK
|
||||
- The `packages/test-app` Vite test application
|
||||
@@ -101,3 +109,5 @@ The test suite for `packages/http` was mostly generated by Claude Code, which al
|
||||
- **`@webnet/sftp` — new package (SFTP v3 client and server)**: Claude Code (Claude Fable 5, coordinating; Claude Opus 4.8 subagents implemented the SSH transport, auth/channels, and SFTP client/server layers, and a Claude Sonnet 5 subagent scaffolded the package and pure codecs) authored a new `@webnet/sftp` package: an SFTP v3 client (`SFTPClient implements AsyncVFS`, mirroring `DAVClient`/`FTPClient`) over a `RawDialer`, and an `SFTPServer` serving any `AsyncVFS` over a `RawListener` via an http-style `listen(listener, opts)` accept loop. Both run in the browser (via tsconnect's `IPNDialer`) and Node with no Node APIs in package source and no new dependencies. The package implements a full SSH-2 transport from scratch on Web Crypto (`crypto.subtle`/`crypto.getRandomValues`) with zero hand-written primitives: version-banner exchange, binary packet framing with per-direction sequence numbers, `curve25519-sha256` key exchange with `ssh-ed25519`/`rsa-sha2-256`/`rsa-sha2-512` host-key verification, RFC 4253 key derivation, `aes128/256-gcm@openssh.com` and `aes128/256-ctr` with `hmac-sha2-256`/`hmac-sha2-256-etm@openssh.com` ciphers (continuous CTR counter and GCM invocation nonce tracked across packets), and transparent peer- or self-initiated rekey. On top of that: ssh-userauth (client offers a direct signed Ed25519 publickey request then falls back to password; server drives none/publickey(PK_OK)/password through a pluggable `authenticate` callback returning a per-user `AsyncVFS`), a connection-protocol mux with a session channel and bidirectional window flow control giving end-to-end backpressure, and the SFTP v3 layer. The client pipelines requests over one channel (request-id dispatch map, serialized wire writes so a split WRITE stays contiguous), maps AsyncVFS verbs onto FXP operations with pull-driven read streams (≤8 outstanding 32 KiB READs) and bounded-inflight writes, client-side recursive delete, and `posix-rename@openssh.com` for overwriting `move`. The server maps FXP back onto the VFS with a handle table, 100-entry READDIR batches with unix `ls -l` longnames, sequential streaming reads/writes, `SETSTAT`/`FSETSTAT` no-ops (so OpenSSH `put` succeeds), REALPATH, and v3 rename semantics; responses are serialized per session and handler errors reply a status without tearing down the connection. Ed25519 auth keys are parsed from the unencrypted `openssh-key-v1` format (encrypted keys are out of scope); host keys are optionally verified via a `verifyHostKey({ type, key, fingerprint })` callback and can be generated with the exported `generateHostKey()`. 203 tests: crypto/kex/cipher/codec/key/path units, loopback transport suites (incl. a 1000-packet encrypted echo and mid-stream rekey), per-layer auth/channel/client/server suites, and a real-client-against-real-server end-to-end suite (4 MiB windowed transfer, ranged reads, pipelined concurrency, cancel-then-continue, password/publickey/per-user auth, host-key verification), plus an env-gated suite against a real OpenSSH sshd. Verified interoperable against the OpenSSH `sftp` CLI (which surfaced and fixed a pre-subsystem `env` channel-request handling gap). An autonomous code review (Claude Sonnet 5) followed by fixes (Claude Fable 5) hardened the server against a malformed post-handshake packet leaking the transport, a write-queue deadlock when a backing `vfs.writeFile` fails mid-stream, an unenforced channel receive window, and a zero-length SFTP packet. A second independent review (Claude Fable 5) found and fixed a client-side data-corruption bug: the pipelined reader trusted requested offsets, so a spec-legal short mid-file READ (pipes/special files, some non-OpenSSH servers) left an un-requested gap; the reader now reconciles against the bytes actually returned and re-issues from the true offset. It also now cancels the source stream on a `writeFile` error. Known intentional limitations (streaming model over `AsyncVFS`, which has no chmod/utimes/positioned-write primitives): SETSTAT/FSETSTAT are accepted as no-ops, and writes must be sequential from offset 0.
|
||||
- **`@webnet/sftp` — new package (SFTP v3 client and server)**: Claude Code (Claude Fable 5, coordinating; Claude Opus 4.8 subagents implemented the SSH transport, auth/channels, and SFTP client/server layers, and a Claude Sonnet 5 subagent scaffolded the package and pure codecs) authored a new `@webnet/sftp` package: an SFTP v3 client (`SFTPClient implements AsyncVFS`, mirroring `DAVClient`/`FTPClient`) over a `RawDialer`, and an `SFTPServer` serving any `AsyncVFS` over a `RawListener` via an http-style `listen(listener, opts)` accept loop. Both run in the browser (via tsconnect's `IPNDialer`) and Node with no Node APIs in package source and no new dependencies. The package implements a full SSH-2 transport from scratch on Web Crypto (`crypto.subtle`/`crypto.getRandomValues`) with zero hand-written primitives: version-banner exchange, binary packet framing with per-direction sequence numbers, `curve25519-sha256` key exchange with `ssh-ed25519`/`rsa-sha2-256`/`rsa-sha2-512` host-key verification, RFC 4253 key derivation, `aes128/256-gcm@openssh.com` and `aes128/256-ctr` with `hmac-sha2-256`/`hmac-sha2-256-etm@openssh.com` ciphers (continuous CTR counter and GCM invocation nonce tracked across packets), and transparent peer- or self-initiated rekey. On top of that: ssh-userauth (client offers a direct signed Ed25519 publickey request then falls back to password; server drives none/publickey(PK_OK)/password through a pluggable `authenticate` callback returning a per-user `AsyncVFS`), a connection-protocol mux with a session channel and bidirectional window flow control giving end-to-end backpressure, and the SFTP v3 layer. The client pipelines requests over one channel (request-id dispatch map, serialized wire writes so a split WRITE stays contiguous), maps AsyncVFS verbs onto FXP operations with pull-driven read streams (≤8 outstanding 32 KiB READs) and bounded-inflight writes, client-side recursive delete, and `posix-rename@openssh.com` for overwriting `move`. The server maps FXP back onto the VFS with a handle table, 100-entry READDIR batches with unix `ls -l` longnames, sequential streaming reads/writes, `SETSTAT`/`FSETSTAT` no-ops (so OpenSSH `put` succeeds), REALPATH, and v3 rename semantics; responses are serialized per session and handler errors reply a status without tearing down the connection. Ed25519 auth keys are parsed from the unencrypted `openssh-key-v1` format (encrypted keys are out of scope); host keys are optionally verified via a `verifyHostKey({ type, key, fingerprint })` callback and can be generated with the exported `generateHostKey()`. 202 tests: crypto/kex/cipher/codec/key/path units, loopback transport suites (incl. a 1000-packet encrypted echo and mid-stream rekey), per-layer auth/channel/client/server suites, and a real-client-against-real-server end-to-end suite (4 MiB windowed transfer, ranged reads, pipelined concurrency, cancel-then-continue, password/publickey/per-user auth, host-key verification), plus an env-gated suite against a real OpenSSH sshd. Verified interoperable against the OpenSSH `sftp` CLI (which surfaced and fixed a pre-subsystem `env` channel-request handling gap). An autonomous code review (Claude Sonnet 5) followed by fixes (Claude Fable 5) hardened the server against a malformed post-handshake packet leaking the transport, a write-queue deadlock when a backing `vfs.writeFile` fails mid-stream, an unenforced channel receive window, and a zero-length SFTP packet. A second independent review (Claude Fable 5) found and fixed a client-side data-corruption bug: the pipelined reader trusted requested offsets, so a spec-legal short mid-file READ (pipes/special files, some non-OpenSSH servers) left an un-requested gap; the reader now reconciles against the bytes actually returned and re-issues from the true offset. It also now cancels the source stream on a `writeFile` error. Known intentional limitations (streaming model over `AsyncVFS`, which has no chmod/utimes/positioned-write primitives): SETSTAT/FSETSTAT are accepted as no-ops, and writes must be sequential from offset 0.
|
||||
- **`@webnet/ssh` — SSH-2 package split out of `@webnet/sftp`, plus TCP port forwarding**: Claude Code (Claude Fable 5) extracted the hand-written SSH-2 stack (transport, curve25519 kex, ciphers, host keys, userauth, connection-protocol channels) out of `@webnet/sftp` into a new standalone `@webnet/ssh` package via pure `git mv`s, so it can be reused independently; `@webnet/sftp` now depends on `@webnet/ssh` and consumes its low-level pieces through `@webnet/ssh/_internals`. The server auth path was decoupled from `@webnet/vfs`: `authenticateServer<T>` is now generic over an authentication context and rejects with a new `SSHAuthError` rather than `VFSError`, which `@webnet/sftp` maps back to `VFSError("forbidden")` at its own boundary to preserve behaviour. On top of the split, TCP forwarding primitives were added. The channel mux (`ConnectionMux`) gained configurable accepted channel types and a global-request handler, generic `CHANNEL_OPEN` handling that surfaces incoming opens (session / direct-tcpip / forwarded-tcpip with parsed endpoints) as an `IncomingOpen` the consumer can `accept()`/`reject()`, outbound `openDirectTcpip`/`openForwardedTcpip`, and an in-order `globalRequest`/`onGlobalRequest` path for `tcpip-forward`/`cancel-tcpip-forward` (RFC 4254 §7). A `channelTransport()` adapter wraps a `Channel` as a `@webnet/transport` `RawTransport` (EOF surfaced as a throw with `readEnded`, `halfClose` as `CHANNEL_EOF`, non-blocking `close()` so a forwarded stream can't deadlock on the peer close handshake), and an internal `pipe()` bridges two transports. The public API exposes `SSHClientConnection` (connect over a `RawTransport`, `openSubsystem`, `openSession`, `openDirectTcpip`, `dialer()` returning a `RawDialer` for local forwarding, and `requestRemoteForward()` returning a `RemoteForward` that implements `RawListener` for remote forwarding) and `SSHServerConnection<T>` (auth hook returning the context, `acceptSession()`, and optional `directTcpip`/`tcpipForward` hooks that plug arbitrary `RawTransport`/`RawListener` implementations into the forwarding paths). The design deliberately keeps `openSession()`/session-channel handling generic so later PRs can add exec/shell/pty request plumbing (for scp/rsync clients and a shell-delegating server) without reshaping the connection API. `@webnet/sftp`'s client and server were refactored onto the new connection classes with no behaviour change (its full suite passes unmodified). New tests cover direct-tcpip open round-trips and accept/reject, global-request ordering/failure, the channel/RawTransport adapter (EOF, half-close, windowed backpressure), and loopback end-to-end forwarding in both directions; an env-gated (`SSH_TEST_*`) interop suite exercises `ssh -L`/`ssh -R` equivalents against a real OpenSSH sshd (direct-tcpip to the sshd banner, and a remote-forward loop dialed back through direct-tcpip). Two autonomous code-review rounds followed. A Sonnet review found port-only remote-forward keying (two forwards on the same port collided), unguarded `CHANNEL_OPEN_CONFIRMATION` sends in the accept loops, and unrejected pending `RemoteForward.accept()` waiters on close — all fixed. A second Fable review then found three more serious issues, all fixed by Claude Fable 5 with regression tests: (1) `Channel.send()` deadlocked forever if the peer closed the channel while the sender was blocked on window exhaustion (`_deliverClose` now wakes window waiters and `send()` aborts on a remotely-closed channel); (2) `pipe()`'s copy loop only guarded reads, so a write failure on a forwarded socket became an `unhandledRejection` that crashes the Node process by default — a remotely-triggerable DoS — now the write is guarded and both `void pipe()` call sites swallow; (3) connect-phase failures leaked the underlying socket (`SFTPClient.#doConnect` and `SSHClientConnection.connect` now close on `openSubsystem`/auth failure; verified against an sshd with no sftp subsystem, which previously hung the process to the runner timeout). Four smaller fixes: `authenticateServer` accepts a falsy auth context (uid `0` etc.) instead of rejecting it, `#matchForward`'s port fallback fails closed on an ambiguous port rather than misrouting, `acceptSession()` rejects instead of hanging once the accept loop has died, and a duplicate `tcpip-forward` closes the superseded listener (and `RemoteForward` teardown closes queued-but-unaccepted transports).
|
||||
- **`@webnet/ftp` — FTP client state transfer**: GPT-5.6 Terra implemented transferable FTP control connections: `FTPClient.transferState()` exports worker-backed connections plus buffered reply bytes and negotiated session state; `FTPClient.adopt()` claims them or reconnects and authenticates on expiry. Active data transfers are refused, and focused tests cover claim, fallback, and the safety guard.
|
||||
- **`@webnet/ftp` — state-transfer ownership fix**: GPT-5.6 Terra addressed a GPT-5.6 Sol review finding by making detachment atomic under the control lock; queued commands now reject after handoff and a regression test covers that race.
|
||||
|
||||
Generated
+32
@@ -4902,6 +4902,10 @@
|
||||
"resolved": "packages/http",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@webnet/http-static": {
|
||||
"resolved": "packages/http-static",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@webnet/react": {
|
||||
"resolved": "packages/react",
|
||||
"link": true
|
||||
@@ -12719,6 +12723,34 @@
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
},
|
||||
"packages/http-static": {
|
||||
"name": "@webnet/http-static",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@webnet/http": "*",
|
||||
"@webnet/vfs": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"c8": "^11.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
},
|
||||
"packages/http-static/node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"packages/http/node_modules/typescript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RawTransport } from "@webnet/transport"
|
||||
import type { RawDialer, RawTransport } from "@webnet/transport"
|
||||
import { VFSError, type AsyncVFS, type Stat } from "@webnet/vfs"
|
||||
import { mlsxToStat, parseMLSX, parseUnixList } from "../common/listing.js"
|
||||
import { baseName, parentPath, resolvePath } from "../common/paths.js"
|
||||
@@ -6,7 +6,7 @@ import { FTPError, replyToVFSError, type Reply } from "../common/replies.js"
|
||||
import { pumpToTransport, transportToStream } from "../common/stream.js"
|
||||
import { parseTimeval } from "../common/time.js"
|
||||
import { ControlConnection } from "./control.js"
|
||||
import type { FTPClientOptions } from "./types.js"
|
||||
import type { FTPClientOptions, FtpTransferState } from "./types.js"
|
||||
|
||||
function listEntryToStat(
|
||||
entry: { name: string; isDirectory: boolean; size?: bigint; modifiedAt?: Date },
|
||||
@@ -39,6 +39,76 @@ export class FTPClient implements AsyncVFS {
|
||||
this.#options = options
|
||||
}
|
||||
|
||||
async transferState(): Promise<FtpTransferState> {
|
||||
const conn = this.#conn ? await this.#conn : null
|
||||
if (!conn) {
|
||||
return {
|
||||
...this.#transferOptions(),
|
||||
cwd: "/",
|
||||
type: "I",
|
||||
features: [],
|
||||
noEpsv: false,
|
||||
dataProtected:
|
||||
this.#options.security === "implicit" || this.#options.security === "explicit",
|
||||
prefix: new Uint8Array(),
|
||||
}
|
||||
}
|
||||
if (conn.dataActive)
|
||||
throw new Error("Cannot transfer FTP state while a data transfer is active")
|
||||
try {
|
||||
return await conn.transferState()
|
||||
} finally {
|
||||
this.#conn = null
|
||||
}
|
||||
}
|
||||
|
||||
static async adopt(
|
||||
state: FtpTransferState,
|
||||
opts: { claim(token: unknown): Promise<RawTransport>; dialer: RawDialer },
|
||||
): Promise<FTPClient> {
|
||||
const options: FTPClientOptions = { ...FTPClient.#optionsFromState(state), dialer: opts.dialer }
|
||||
const client = new FTPClient(options)
|
||||
if (state.token === undefined) return client
|
||||
try {
|
||||
const transport = await opts.claim(state.token)
|
||||
client.#conn = Promise.resolve(ControlConnection.adopt(options, state, transport))
|
||||
} catch {
|
||||
// A claimed worker resource may have expired; #control reconnects and logs in on demand.
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
#transferOptions(): Pick<
|
||||
FtpTransferState,
|
||||
"host" | "port" | "security" | "tls" | "user" | "pass"
|
||||
> {
|
||||
return FTPClient.#optionsToState(this.#options)
|
||||
}
|
||||
|
||||
static #optionsToState(
|
||||
options: FTPClientOptions,
|
||||
): Pick<FtpTransferState, "host" | "port" | "security" | "tls" | "user" | "pass"> {
|
||||
return {
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
security: options.security,
|
||||
tls: options.tls,
|
||||
user: options.user,
|
||||
pass: options.pass,
|
||||
}
|
||||
}
|
||||
|
||||
static #optionsFromState(state: FtpTransferState): Omit<FTPClientOptions, "dialer"> {
|
||||
return {
|
||||
host: state.host,
|
||||
port: state.port,
|
||||
security: state.security,
|
||||
tls: state.tls,
|
||||
user: state.user,
|
||||
pass: state.pass,
|
||||
}
|
||||
}
|
||||
|
||||
async #control(): Promise<ControlConnection> {
|
||||
// reuse a live connection; drop and reconnect a dead one (FTP servers
|
||||
// commonly drop idle control connections) so long-lived clients self-heal
|
||||
@@ -192,6 +262,7 @@ export class FTPClient implements AsyncVFS {
|
||||
for (const chunk of chunks) text += decoder.decode(chunk, { stream: true })
|
||||
return text + decoder.decode()
|
||||
} finally {
|
||||
conn.finishDataTransfer()
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -264,6 +335,7 @@ export class FTPClient implements AsyncVFS {
|
||||
} catch {
|
||||
// control-connection failures surface on the next command
|
||||
} finally {
|
||||
conn.finishDataTransfer()
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -319,6 +391,7 @@ export class FTPClient implements AsyncVFS {
|
||||
if (final.code !== 226 && final.code !== 250) throw replyToVFSError(final, path, "STOR")
|
||||
if (pumpError) throw pumpError
|
||||
} finally {
|
||||
conn.finishDataTransfer()
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { RawTransport } from "@webnet/transport"
|
||||
import { isStateTransferable, type RawTransport } from "@webnet/transport"
|
||||
import { prependTransport } from "@webnet/transport/buffer"
|
||||
import { parseEpsv229, parsePasv227 } from "../common/addr.js"
|
||||
import { ControlReader, ControlWriter } from "../common/codec.js"
|
||||
import { FTPError, type Reply } from "../common/replies.js"
|
||||
import type { FTPClientOptions } from "./types.js"
|
||||
import type { FTPClientOptions, FtpTransferState } from "./types.js"
|
||||
|
||||
export class ControlConnection {
|
||||
readonly features: Set<string>
|
||||
@@ -13,6 +14,8 @@ export class ControlConnection {
|
||||
#tail: Promise<void> = Promise.resolve()
|
||||
#noEpsv = false
|
||||
#broken = false
|
||||
#dataActive = false
|
||||
#detached = false
|
||||
|
||||
private constructor(options: FTPClientOptions, transport: RawTransport) {
|
||||
this.#options = options
|
||||
@@ -40,6 +43,20 @@ export class ControlConnection {
|
||||
return conn
|
||||
}
|
||||
|
||||
static adopt(
|
||||
options: FTPClientOptions,
|
||||
state: FtpTransferState,
|
||||
transport: RawTransport,
|
||||
): ControlConnection {
|
||||
const conn = new ControlConnection(
|
||||
options,
|
||||
state.prefix.length ? prependTransport(state.prefix, transport) : transport,
|
||||
)
|
||||
for (const feature of state.features) conn.features.add(feature)
|
||||
conn.#noEpsv = state.noEpsv
|
||||
return conn
|
||||
}
|
||||
|
||||
async #login(): Promise<void> {
|
||||
const greeting = await this.#reader.readReply()
|
||||
if (greeting.code !== 220) throw new FTPError(greeting.code, greeting.text)
|
||||
@@ -83,6 +100,10 @@ export class ControlConnection {
|
||||
return this.#broken || this.#transport.closed || this.#transport.readEnded === true
|
||||
}
|
||||
|
||||
get dataActive(): boolean {
|
||||
return this.#dataActive
|
||||
}
|
||||
|
||||
async acquire(): Promise<() => void> {
|
||||
const prev = this.#tail
|
||||
let release!: () => void
|
||||
@@ -91,8 +112,50 @@ export class ControlConnection {
|
||||
return release
|
||||
}
|
||||
|
||||
async transferState(): Promise<FtpTransferState> {
|
||||
if (this.#detached) throw new Error("Cannot transfer a detached FTP control connection")
|
||||
if (this.#dataActive)
|
||||
throw new Error("Cannot transfer FTP state while a data transfer is active")
|
||||
const release = await this.acquire()
|
||||
try {
|
||||
if (this.#dataActive)
|
||||
throw new Error("Cannot transfer FTP state while a data transfer is active")
|
||||
if (this.closed) throw new Error("Cannot transfer a closed FTP control connection")
|
||||
this.#detached = true
|
||||
const state: Omit<FtpTransferState, "token"> = {
|
||||
host: this.#options.host,
|
||||
port: this.#options.port,
|
||||
security: this.#options.security,
|
||||
tls: this.#options.tls,
|
||||
user: this.#options.user,
|
||||
pass: this.#options.pass,
|
||||
cwd: "/",
|
||||
type: "I",
|
||||
features: [...this.features],
|
||||
noEpsv: this.#noEpsv,
|
||||
dataProtected:
|
||||
this.#options.security === "implicit" || this.#options.security === "explicit",
|
||||
prefix: this.#reader.drain(),
|
||||
}
|
||||
if (!isStateTransferable(this.#transport)) {
|
||||
await this.#transport.close()
|
||||
return state
|
||||
}
|
||||
try {
|
||||
return { ...state, token: await this.#transport.transferState() }
|
||||
} catch (error) {
|
||||
this.#broken = true
|
||||
await this.#transport.close()
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/** Only call while holding the lock from {@link acquire}. */
|
||||
async exchangeUnlocked(verb: string, arg?: string): Promise<Reply> {
|
||||
if (this.#detached) throw new Error("Cannot use a detached FTP control connection")
|
||||
try {
|
||||
await this.#writer.command(verb, arg)
|
||||
return await this.#reader.readReply()
|
||||
@@ -134,6 +197,7 @@ export class ControlConnection {
|
||||
* server accepts.
|
||||
*/
|
||||
async openDataUnlocked(): Promise<{ dial: Promise<RawTransport> }> {
|
||||
this.#dataActive = true
|
||||
if (!this.#noEpsv && (this.features.size === 0 || this.features.has("EPSV"))) {
|
||||
const reply = await this.exchangeUnlocked("EPSV")
|
||||
if (reply.code === 229) {
|
||||
@@ -165,6 +229,10 @@ export class ControlConnection {
|
||||
}
|
||||
}
|
||||
|
||||
finishDataTransfer(): void {
|
||||
this.#dataActive = false
|
||||
}
|
||||
|
||||
#dialData(host: string, port: number): Promise<RawTransport> {
|
||||
if (this.#options.security === "implicit") return this.#options.dialer.dialTls!(host, port)
|
||||
const dial = this.#options.dialer.dial(host, port)
|
||||
@@ -189,6 +257,7 @@ export class ControlConnection {
|
||||
async close(): Promise<void> {
|
||||
const release = await this.acquire()
|
||||
try {
|
||||
if (this.#detached) return
|
||||
await this.exchangeUnlocked("QUIT")
|
||||
} catch {
|
||||
// best effort: the server may already have gone away
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { FTPClient } from "./client.js"
|
||||
export type { FTPClientOptions } from "./types.js"
|
||||
export type { FTPClientOptions, FtpTransferState } from "./types.js"
|
||||
|
||||
@@ -30,3 +30,19 @@ export type FTPClientOptions = {
|
||||
*/
|
||||
pass?: string
|
||||
}
|
||||
|
||||
export type FtpTransferState = {
|
||||
host: string
|
||||
port?: number
|
||||
security?: "none" | "implicit" | "explicit"
|
||||
tls?: FTPClientOptions["tls"]
|
||||
user?: string
|
||||
pass?: string
|
||||
cwd: string
|
||||
type: "I"
|
||||
features: string[]
|
||||
noEpsv: boolean
|
||||
dataProtected: boolean
|
||||
prefix: Uint8Array
|
||||
token?: unknown
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ export class ControlReader {
|
||||
return { code, lines, text: lines.join("\n") }
|
||||
}
|
||||
|
||||
drain(): Uint8Array {
|
||||
return this.#buffer.drain()
|
||||
}
|
||||
|
||||
async readCommand(): Promise<{ verb: string; arg: string }> {
|
||||
let line = await this.#buffer.readLine()
|
||||
// classic ftp clients may prefix urgent commands (e.g. ABOR) with telnet
|
||||
|
||||
@@ -1,28 +1,4 @@
|
||||
export function resolvePath(cwd: string, arg: string): string {
|
||||
const base = arg.startsWith("/") ? arg : cwd + "/" + arg
|
||||
const segments = base.split("/")
|
||||
const resolved: string[] = []
|
||||
for (const seg of segments) {
|
||||
if (seg === "" || seg === ".") continue
|
||||
if (seg === "..") {
|
||||
if (resolved.length > 0) resolved.pop()
|
||||
continue
|
||||
}
|
||||
resolved.push(seg)
|
||||
}
|
||||
return "/" + resolved.join("/")
|
||||
}
|
||||
|
||||
export function parentPath(path: string): string {
|
||||
if (path === "/") return "/"
|
||||
const idx = path.lastIndexOf("/")
|
||||
return idx === 0 ? "/" : path.slice(0, idx)
|
||||
}
|
||||
|
||||
export function baseName(path: string): string {
|
||||
if (path === "/") return ""
|
||||
return path.slice(path.lastIndexOf("/") + 1)
|
||||
}
|
||||
export { baseName, parentPath, resolvePath } from "@webnet/vfs"
|
||||
|
||||
export function quotePath(path: string): string {
|
||||
return `"${path.replace(/"/g, '""')}"`
|
||||
|
||||
@@ -3,7 +3,13 @@ import assert from "node:assert/strict"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { loopbackListener } from "@webnet/transport/loopback"
|
||||
import { nodeDialer, nodeListen } from "@webnet/transport/node"
|
||||
import type { RawDialer, RawListener, TlsUpgradeOptions } from "@webnet/transport"
|
||||
import type {
|
||||
RawDialer,
|
||||
RawListener,
|
||||
RawTransport,
|
||||
StateTransferable,
|
||||
TlsUpgradeOptions,
|
||||
} from "@webnet/transport"
|
||||
import { MemoryVFS } from "@webnet/vfs/memory"
|
||||
import { VFSError, type AsyncVFS } from "@webnet/vfs"
|
||||
import { FTPServer } from "./server/server.js"
|
||||
@@ -71,6 +77,33 @@ function rejectsVfs(code: string): (e: unknown) => boolean {
|
||||
return (e) => e instanceof VFSError && e.code === code
|
||||
}
|
||||
|
||||
class TransferableTransport implements RawTransport, StateTransferable<RawTransport> {
|
||||
readonly #transport: RawTransport
|
||||
readonly #onTransfer: (() => Promise<void>) | undefined
|
||||
|
||||
constructor(transport: RawTransport, onTransfer?: () => Promise<void>) {
|
||||
this.#transport = transport
|
||||
this.#onTransfer = onTransfer
|
||||
}
|
||||
|
||||
get closed() {
|
||||
return this.#transport.closed
|
||||
}
|
||||
close() {
|
||||
return this.#transport.close()
|
||||
}
|
||||
read() {
|
||||
return this.#transport.read()
|
||||
}
|
||||
write(data: Uint8Array) {
|
||||
return this.#transport.write(data)
|
||||
}
|
||||
async transferState(): Promise<RawTransport> {
|
||||
await this.#onTransfer?.()
|
||||
return this.#transport
|
||||
}
|
||||
}
|
||||
|
||||
// Control dials go to the control listener; dataListen mints a loopback pair
|
||||
// per advertised port and the routing dialer sends data dials to it.
|
||||
function makeNet() {
|
||||
@@ -219,6 +252,97 @@ async function seed(vfs: AsyncVFS, files: Record<string, string>) {
|
||||
// -- suites --
|
||||
|
||||
suite("FTPClient + FTPServer over loopback", () => {
|
||||
test("transfers a live control connection and preserves negotiated features", async () => {
|
||||
const { net, close } = makeTestPair()
|
||||
const dialer: RawDialer = {
|
||||
async dial(host, port) {
|
||||
return new TransferableTransport(await net.dialer.dial(host, port))
|
||||
},
|
||||
}
|
||||
const transferable = new FTPClient({ dialer, host: "127.0.0.1" })
|
||||
try {
|
||||
await transferable.rawCommand("NOOP")
|
||||
const state = await transferable.transferState()
|
||||
assert.ok(state.token)
|
||||
assert.ok(state.features.includes("EPSV"))
|
||||
const adopted = await FTPClient.adopt(state, {
|
||||
claim: async (token) => token as RawTransport,
|
||||
dialer: net.dialer,
|
||||
})
|
||||
assert.equal((await adopted.rawCommand("NOOP")).code, 200)
|
||||
await adopted.close()
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("adopt falls back to a fresh authenticated connection when claiming fails", async () => {
|
||||
const { net, close } = makeTestPair()
|
||||
const dialer: RawDialer = {
|
||||
async dial(host, port) {
|
||||
return new TransferableTransport(await net.dialer.dial(host, port))
|
||||
},
|
||||
}
|
||||
const transferable = new FTPClient({ dialer, host: "127.0.0.1" })
|
||||
try {
|
||||
await transferable.rawCommand("NOOP")
|
||||
const state = await transferable.transferState()
|
||||
const adopted = await FTPClient.adopt(state, {
|
||||
claim: async (token) => {
|
||||
await (token as RawTransport).close()
|
||||
throw new Error("expired")
|
||||
},
|
||||
dialer: net.dialer,
|
||||
})
|
||||
assert.equal((await adopted.rawCommand("NOOP")).code, 200)
|
||||
await adopted.close()
|
||||
} finally {
|
||||
net.ctrlListener.close()
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects a control command queued behind state transfer", async () => {
|
||||
const { net, close } = makeTestPair()
|
||||
let signalTransfer!: () => void
|
||||
const transferStarted = new Promise<void>((resolve) => (signalTransfer = resolve))
|
||||
let allowTransfer!: () => void
|
||||
const transferAllowed = new Promise<void>((resolve) => (allowTransfer = resolve))
|
||||
const dialer: RawDialer = {
|
||||
async dial(host, port) {
|
||||
return new TransferableTransport(await net.dialer.dial(host, port), async () => {
|
||||
signalTransfer()
|
||||
await transferAllowed
|
||||
})
|
||||
},
|
||||
}
|
||||
const client = new FTPClient({ dialer, host: "127.0.0.1" })
|
||||
try {
|
||||
await client.rawCommand("NOOP")
|
||||
const exported = client.transferState()
|
||||
await transferStarted
|
||||
const queued = client.rawCommand("NOOP")
|
||||
allowTransfer()
|
||||
const state = await exported
|
||||
await assert.rejects(queued, /detached FTP control connection/)
|
||||
await (state.token as RawTransport).close()
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("refuses to transfer while a data transfer is active", async () => {
|
||||
const { vfs, client, close } = makeTestPair()
|
||||
try {
|
||||
await seed(vfs, { "/file": "data" })
|
||||
const stream = await client.readFile("/file")
|
||||
await assert.rejects(client.transferState(), /data transfer is active/)
|
||||
await stream.cancel()
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
})
|
||||
|
||||
test("write then read round-trip", async () => {
|
||||
const { client, close } = makeTestPair()
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { FTPClient } from "./client/index.js"
|
||||
export type { FTPClientOptions } from "./client/index.js"
|
||||
export type { FTPClientOptions, FtpTransferState } from "./client/index.js"
|
||||
export { FTPServer } from "./server/index.js"
|
||||
export type { FTPServerOptions, ListenOptions } from "./server/index.js"
|
||||
export { FTPError } from "./common/replies.js"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@webnet/http-static",
|
||||
"version": "0.1.0",
|
||||
"description": "Static file HTTP handler backed by an async VFS",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc --project tsconfig.json",
|
||||
"test": "tsx --test --test-timeout=10000 'src/**/*.test.ts'",
|
||||
"test:coverage": "c8 --src src --exclude 'src/**/*.test.ts' --reporter text --reporter lcov node --enable-source-maps --import tsx --test-timeout=10000 --test 'src/**/*.test.ts'",
|
||||
"typecheck": "tsc --project tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"c8": "^11.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@webnet/http": "*",
|
||||
"@webnet/vfs": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { suite, test } from "node:test"
|
||||
import type { Body } from "@webnet/http"
|
||||
import { MemoryVFS } from "@webnet/vfs/memory"
|
||||
import type { AsyncVFS } from "@webnet/vfs"
|
||||
import { createStaticHandler } from "./handler.js"
|
||||
|
||||
function streamOf(value: string): ReadableStream<Uint8Array> {
|
||||
const bytes = new TextEncoder().encode(value)
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(bytes)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function readBody(body: Body): Promise<string> {
|
||||
if (typeof body === "string") return body
|
||||
if (!(body instanceof ReadableStream)) return ""
|
||||
const reader = body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
for (;;) {
|
||||
const result = await reader.read()
|
||||
if (result.done) break
|
||||
chunks.push(result.value)
|
||||
}
|
||||
const bytes = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.length, 0))
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
function makeContext(method: string, path: string, headers: Record<string, string> = {}) {
|
||||
const responseHeaders: Record<string, string | readonly string[]> = {}
|
||||
const response = {
|
||||
request: undefined as never,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: responseHeaders,
|
||||
body: null as Body,
|
||||
setStatus(status: number, statusText?: string) {
|
||||
this.status = status
|
||||
this.statusText = statusText ?? this.statusText
|
||||
},
|
||||
setHeader(name: string, value: string | readonly string[]) {
|
||||
responseHeaders[name.toLowerCase()] = value
|
||||
},
|
||||
addHeader(name: string, value: string) {
|
||||
responseHeaders[name.toLowerCase()] = value
|
||||
},
|
||||
getHeader(name: string) {
|
||||
return responseHeaders[name.toLowerCase()]
|
||||
},
|
||||
hasHeader(name: string) {
|
||||
return name.toLowerCase() in responseHeaders
|
||||
},
|
||||
}
|
||||
const requestHeaders = Object.fromEntries(
|
||||
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]),
|
||||
)
|
||||
const request = {
|
||||
method,
|
||||
url: new URL(path, "http://localhost"),
|
||||
headers: requestHeaders,
|
||||
getHeader(name: string) {
|
||||
return requestHeaders[name.toLowerCase()]
|
||||
},
|
||||
}
|
||||
response.request = request as never
|
||||
return { req: request, res: response, transport: {} as never }
|
||||
}
|
||||
|
||||
async function write(vfs: MemoryVFS, path: string, value: string): Promise<void> {
|
||||
await vfs.writeFile(path, streamOf(value))
|
||||
}
|
||||
|
||||
const vectorPaths = ["/", "/index", "/app", "/fallback", "/test", "/hello", "/bye"]
|
||||
|
||||
async function vectorVfs(): Promise<MemoryVFS> {
|
||||
const vfs = new MemoryVFS()
|
||||
for (const directory of ["/app", "/fallback", "/test", "/bye"]) await vfs.mkdir(directory)
|
||||
for (const path of [
|
||||
"/index.html",
|
||||
"/index.json",
|
||||
"/app/index.html",
|
||||
"/app.html",
|
||||
"/fallback/index.html",
|
||||
"/test/index.html",
|
||||
"/test.html",
|
||||
"/hello.html",
|
||||
"/bye/index.html",
|
||||
]) {
|
||||
await write(vfs, path, path.slice(1))
|
||||
}
|
||||
return vfs
|
||||
}
|
||||
|
||||
type PrecedenceVector = {
|
||||
name: string
|
||||
options: Parameters<typeof createStaticHandler>[1]
|
||||
expected: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
const precedenceVectors: PrecedenceVector[] = [
|
||||
{
|
||||
name: "default index files",
|
||||
options: {},
|
||||
expected: {
|
||||
"/": "index.html",
|
||||
"/index": undefined,
|
||||
"/app": "app/index.html",
|
||||
"/fallback": "fallback/index.html",
|
||||
"/test": "test/index.html",
|
||||
"/hello": undefined,
|
||||
"/bye": "bye/index.html",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "suffixes after direct directory lookup",
|
||||
options: { suffixes: [".html"] },
|
||||
expected: {
|
||||
"/": "index.html",
|
||||
"/index": "index.html",
|
||||
"/app": "app/index.html",
|
||||
"/fallback": "fallback/index.html",
|
||||
"/test": "test/index.html",
|
||||
"/hello": "hello.html",
|
||||
"/bye": "bye/index.html",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "index files disabled",
|
||||
options: { index: false },
|
||||
expected: {
|
||||
"/": undefined,
|
||||
"/index": undefined,
|
||||
"/app": undefined,
|
||||
"/fallback": undefined,
|
||||
"/test": undefined,
|
||||
"/hello": undefined,
|
||||
"/bye": undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom index file",
|
||||
options: { index: ["index.json"] },
|
||||
expected: {
|
||||
"/": "index.json",
|
||||
"/index": undefined,
|
||||
"/app": undefined,
|
||||
"/fallback": undefined,
|
||||
"/test": undefined,
|
||||
"/hello": undefined,
|
||||
"/bye": undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fallback file after normal lookup",
|
||||
options: { fallback: "/fallback/index.html" },
|
||||
expected: {
|
||||
"/": "index.html",
|
||||
"/index": "fallback/index.html",
|
||||
"/app": "app/index.html",
|
||||
"/fallback": "fallback/index.html",
|
||||
"/test": "test/index.html",
|
||||
"/hello": "fallback/index.html",
|
||||
"/bye": "bye/index.html",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disabled indexes with suffixes and fallback",
|
||||
options: { index: false, suffixes: [".html"], fallback: "/fallback/index.html" },
|
||||
expected: {
|
||||
"/": "fallback/index.html",
|
||||
"/index": "index.html",
|
||||
"/app": "fallback/index.html",
|
||||
"/fallback": "fallback/index.html",
|
||||
"/test": "fallback/index.html",
|
||||
"/hello": "hello.html",
|
||||
"/bye": "fallback/index.html",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disabled indexes with suffixes and no fallback",
|
||||
options: { index: false, suffixes: [".html"] },
|
||||
expected: {
|
||||
"/": undefined,
|
||||
"/index": "index.html",
|
||||
"/app": undefined,
|
||||
"/fallback": undefined,
|
||||
"/test": undefined,
|
||||
"/hello": "hello.html",
|
||||
"/bye": undefined,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
suite("createStaticHandler", () => {
|
||||
test("applies index, extension, and fallback precedence vectors", async () => {
|
||||
for (const vector of precedenceVectors) {
|
||||
const handler = createStaticHandler(await vectorVfs(), vector.options)
|
||||
for (const path of vectorPaths) {
|
||||
const context = makeContext("GET", path, { accept: "text/plain" })
|
||||
await handler(context)
|
||||
const expected = vector.expected[path]
|
||||
assert.equal(
|
||||
context.res.status,
|
||||
expected === undefined ? 404 : 200,
|
||||
`${vector.name}: unexpected status for ${path}`,
|
||||
)
|
||||
assert.equal(
|
||||
await readBody(context.res.body),
|
||||
expected ?? "",
|
||||
`${vector.name}: unexpected file for ${path}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("serves a file from the request path and supports HEAD", async () => {
|
||||
const vfs = new MemoryVFS()
|
||||
await write(vfs, "/hello.txt", "hello")
|
||||
const handler = createStaticHandler(vfs)
|
||||
|
||||
const get = makeContext("GET", "/hello.txt")
|
||||
await handler(get)
|
||||
assert.equal(get.res.status, 200)
|
||||
assert.equal(await readBody(get.res.body), "hello")
|
||||
assert.equal(get.res.getHeader("content-length"), "5")
|
||||
|
||||
const head = makeContext("HEAD", "/hello.txt")
|
||||
await handler(head)
|
||||
assert.equal(head.res.status, 200)
|
||||
assert.equal(head.res.body, null)
|
||||
assert.equal(head.res.getHeader("content-length"), "5")
|
||||
})
|
||||
|
||||
test("supports fixed paths, resolvers, and a segment-safe prefix", async () => {
|
||||
const vfs = new MemoryVFS()
|
||||
await write(vfs, "/fixed.txt", "fixed")
|
||||
await write(vfs, "/ctx.txt", "context")
|
||||
await write(vfs, "/app.js", "app")
|
||||
const fixed = createStaticHandler(vfs, { path: "/fixed.txt" })
|
||||
const fixedContext = makeContext("GET", "/anything")
|
||||
await fixed(fixedContext)
|
||||
assert.equal(await readBody(fixedContext.res.body), "fixed")
|
||||
|
||||
const resolved = createStaticHandler(vfs, { path: () => "/ctx.txt" })
|
||||
const resolvedContext = makeContext("GET", "/anything")
|
||||
await resolved(resolvedContext)
|
||||
assert.equal(await readBody(resolvedContext.res.body), "context")
|
||||
|
||||
const prefixed = createStaticHandler(vfs, { prefix: "/assets" })
|
||||
const prefixedContext = makeContext("GET", "/assets/app.js")
|
||||
await prefixed(prefixedContext)
|
||||
assert.equal(await readBody(prefixedContext.res.body), "app")
|
||||
const boundaryContext = makeContext("GET", "/assets-extra/app.js")
|
||||
await prefixed(boundaryContext)
|
||||
assert.equal(boundaryContext.res.status, 404)
|
||||
await write(vfs, "/secret", "private")
|
||||
const outsideContext = makeContext("GET", "/secret")
|
||||
await prefixed(outsideContext)
|
||||
assert.equal(outsideContext.res.status, 404)
|
||||
})
|
||||
|
||||
test("returns 405 and 404 responses", async () => {
|
||||
const handler = createStaticHandler(new MemoryVFS())
|
||||
const method = makeContext("POST", "/")
|
||||
await handler(method)
|
||||
assert.equal(method.res.status, 405)
|
||||
assert.equal(method.res.getHeader("allow"), "GET, HEAD")
|
||||
const missing = makeContext("GET", "/missing")
|
||||
await handler(missing)
|
||||
assert.equal(missing.res.status, 404)
|
||||
})
|
||||
|
||||
test("generates HTML and JSON directory listings", async () => {
|
||||
const vfs = new MemoryVFS()
|
||||
await vfs.mkdir("/docs")
|
||||
await write(vfs, "/docs/<read me>.txt", "x")
|
||||
const handler = createStaticHandler(vfs)
|
||||
|
||||
const html = makeContext("GET", "/docs", { accept: "text/html" })
|
||||
await handler(html)
|
||||
const htmlBody = await readBody(html.res.body)
|
||||
assert.equal(html.res.status, 200)
|
||||
assert.match(htmlBody, /<read me>/)
|
||||
assert.match(htmlBody, /%3Cread%20me%3E\.txt/)
|
||||
|
||||
const json = makeContext("GET", "/docs", { accept: "application/json" })
|
||||
await handler(json)
|
||||
const entries = JSON.parse(await readBody(json.res.body))
|
||||
assert.equal(entries.length, 1)
|
||||
assert.deepEqual(entries[0], {
|
||||
name: "<read me>.txt",
|
||||
path: "/docs/%3Cread%20me%3E.txt",
|
||||
isDirectory: false,
|
||||
size: "1",
|
||||
modifiedAt: entries[0].modifiedAt,
|
||||
etag: entries[0].etag,
|
||||
})
|
||||
})
|
||||
|
||||
test("uses the HTTP path, including prefixes, in directory listings", async () => {
|
||||
const vfs = new MemoryVFS()
|
||||
await vfs.mkdir("/app")
|
||||
await write(vfs, "/app/<read me>.txt", "x")
|
||||
const handler = createStaticHandler(vfs, { prefix: "/static" })
|
||||
|
||||
const html = makeContext("GET", "/static/app/", { accept: "text/html" })
|
||||
await handler(html)
|
||||
const htmlBody = await readBody(html.res.body)
|
||||
assert.match(htmlBody, /href="\/static\/app\/%3Cread%20me%3E\.txt"/)
|
||||
|
||||
const json = makeContext("GET", "/static/app/", { accept: "application/json" })
|
||||
await handler(json)
|
||||
const entries = JSON.parse(await readBody(json.res.body))
|
||||
assert.equal(entries[0].path, "/static/app/%3Cread%20me%3E.txt")
|
||||
})
|
||||
|
||||
test("prefers index files, supports suffixes, and uses fallback", async () => {
|
||||
const vfs = new MemoryVFS()
|
||||
await vfs.mkdir("/site")
|
||||
await write(vfs, "/site/index.html", "index")
|
||||
await write(vfs, "/about.html", "about")
|
||||
await write(vfs, "/fallback.html", "fallback")
|
||||
const handler = createStaticHandler(vfs, { suffixes: [".html"], fallback: "/fallback.html" })
|
||||
|
||||
const index = makeContext("GET", "/site/")
|
||||
await handler(index)
|
||||
assert.equal(await readBody(index.res.body), "index")
|
||||
const suffix = makeContext("GET", "/about")
|
||||
await handler(suffix)
|
||||
assert.equal(await readBody(suffix.res.body), "about")
|
||||
const fallback = makeContext("GET", "/missing")
|
||||
await handler(fallback)
|
||||
assert.equal(await readBody(fallback.res.body), "fallback")
|
||||
})
|
||||
|
||||
test("supports conditional requests and byte ranges", async () => {
|
||||
const vfs = new MemoryVFS()
|
||||
await write(vfs, "/data.txt", "0123456789")
|
||||
const handler = createStaticHandler(vfs)
|
||||
|
||||
const first = makeContext("GET", "/data.txt")
|
||||
await handler(first)
|
||||
const etag = first.res.getHeader("etag") as string
|
||||
const notModified = makeContext("GET", "/data.txt", { "if-none-match": etag })
|
||||
await handler(notModified)
|
||||
assert.equal(notModified.res.status, 304)
|
||||
assert.equal(notModified.res.body, null)
|
||||
|
||||
const range = makeContext("GET", "/data.txt", { range: "bytes=2-5" })
|
||||
await handler(range)
|
||||
assert.equal(range.res.status, 206)
|
||||
assert.equal(await readBody(range.res.body), "2345")
|
||||
assert.equal(range.res.getHeader("content-range"), "bytes 2-5/10")
|
||||
|
||||
const suffix = makeContext("GET", "/data.txt", { range: "bytes=-3" })
|
||||
await handler(suffix)
|
||||
assert.equal(await readBody(suffix.res.body), "789")
|
||||
|
||||
const invalid = makeContext("GET", "/data.txt", { range: "bytes=20-30" })
|
||||
await handler(invalid)
|
||||
assert.equal(invalid.res.status, 416)
|
||||
|
||||
await write(vfs, "/empty.txt", "")
|
||||
const empty = makeContext("GET", "/empty.txt", { range: "bytes=-1" })
|
||||
await handler(empty)
|
||||
assert.equal(empty.res.status, 416)
|
||||
})
|
||||
|
||||
test("uses a bounded stream when the VFS lacks readFileRange", async () => {
|
||||
const base = new MemoryVFS()
|
||||
await write(base, "/data", "abcdef")
|
||||
const vfs: AsyncVFS = {
|
||||
stat: (path) => base.stat(path),
|
||||
readdir: (path) => base.readdir(path),
|
||||
readFile: (path) => base.readFile(path),
|
||||
writeFile: (path, stream, size) => base.writeFile(path, stream, size),
|
||||
delete: (path, recursive) => base.delete(path, recursive),
|
||||
mkdir: (path) => base.mkdir(path),
|
||||
}
|
||||
const handler = createStaticHandler(vfs)
|
||||
const context = makeContext("GET", "/data", { range: "bytes=1-3" })
|
||||
await handler(context)
|
||||
assert.equal(await readBody(context.res.body), "bcd")
|
||||
})
|
||||
|
||||
test("returns 400 for malformed encoded paths and respects unacceptable listings", async () => {
|
||||
const vfs = new MemoryVFS()
|
||||
const handler = createStaticHandler(vfs)
|
||||
const malformed = makeContext("GET", "/%E0%A4%A")
|
||||
await handler(malformed)
|
||||
assert.equal(malformed.res.status, 400)
|
||||
const unacceptable = makeContext("GET", "/", {
|
||||
accept: "text/plain;q=1, text/html;q=0, application/json;q=0",
|
||||
})
|
||||
await handler(unacceptable)
|
||||
assert.equal(unacceptable.res.status, 404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,360 @@
|
||||
import type { Context, Handler } from "@webnet/http"
|
||||
import { VFSError, type AsyncVFS, type Stat } from "@webnet/vfs"
|
||||
|
||||
export type StaticPathResolver = (ctx: Context) => string | Promise<string>
|
||||
|
||||
export interface StaticServerOptions {
|
||||
path?: string | StaticPathResolver
|
||||
prefix?: string
|
||||
index?: readonly string[] | false
|
||||
suffixes?: readonly string[]
|
||||
fallback?: string
|
||||
}
|
||||
|
||||
type Range = { start: bigint; end: bigint }
|
||||
type Lookup = { path: string; stat: Stat }
|
||||
|
||||
const INDEX_FILES = ["index.html"] as const
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
if (!path.startsWith("/")) path = "/" + path
|
||||
const parts: string[] = []
|
||||
for (const part of path.split("/")) {
|
||||
if (!part || part === ".") continue
|
||||
if (part === "..") {
|
||||
if (parts.length) parts.pop()
|
||||
continue
|
||||
}
|
||||
parts.push(part)
|
||||
}
|
||||
return "/" + parts.join("/")
|
||||
}
|
||||
|
||||
function requestPath(pathname: string, prefix: string | undefined): string | null {
|
||||
let path = decodeURIComponent(pathname)
|
||||
if (prefix) {
|
||||
const normalizedPrefix = normalizePath(prefix).replace(/\/$/, "")
|
||||
if (normalizedPrefix === "") return normalizePath(path)
|
||||
if (path === normalizedPrefix) path = "/"
|
||||
else if (path.startsWith(normalizedPrefix + "/")) path = path.slice(normalizedPrefix.length)
|
||||
else return null
|
||||
}
|
||||
return normalizePath(path)
|
||||
}
|
||||
|
||||
function headerValue(value: string | readonly string[] | undefined): string | undefined {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function formatHttpDate(date: Date): string {
|
||||
return date.toUTCString()
|
||||
}
|
||||
|
||||
function parseRange(value: string | undefined, size: bigint): Range | null | undefined {
|
||||
if (!value || !value.startsWith("bytes=")) return null
|
||||
const spec = value.slice(6)
|
||||
if (spec.includes(",")) return null
|
||||
const match = /^(\d*)-(\d*)$/.exec(spec)
|
||||
if (!match || (!match[1] && !match[2])) return null
|
||||
try {
|
||||
if (size === 0n) return undefined
|
||||
if (!match[1]) {
|
||||
const suffix = BigInt(match[2]!)
|
||||
if (suffix <= 0n) return undefined
|
||||
return { start: suffix >= size ? 0n : size - suffix, end: size - 1n }
|
||||
}
|
||||
const start = BigInt(match[1])
|
||||
if (start >= size) return undefined
|
||||
const requestedEnd = match[2] ? BigInt(match[2]) : size - 1n
|
||||
if (requestedEnd < start) return undefined
|
||||
return { start, end: requestedEnd >= size ? size - 1n : requestedEnd }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function matchesEtag(value: string | undefined, etag: string | undefined): boolean {
|
||||
if (!value) return false
|
||||
return value
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.some((part) => part === "*" || (etag !== undefined && (part === etag || part === `W/${etag}`)))
|
||||
}
|
||||
|
||||
function isNotModified(ctx: Context, stat: Stat): boolean {
|
||||
const etag = stat.etag
|
||||
const ifNoneMatch = headerValue(ctx.req.getHeader("if-none-match"))
|
||||
if (matchesEtag(ifNoneMatch, etag)) return true
|
||||
if (ifNoneMatch) return false
|
||||
const modified = stat.modifiedAt?.getTime()
|
||||
const ifModifiedSince = headerValue(ctx.req.getHeader("if-modified-since"))
|
||||
if (modified === undefined || !ifModifiedSince) return false
|
||||
const parsed = Date.parse(ifModifiedSince)
|
||||
return !Number.isNaN(parsed) && Math.floor(modified / 1000) <= Math.floor(parsed / 1000)
|
||||
}
|
||||
|
||||
function setMetadata(ctx: Context, stat: Stat): void {
|
||||
if (stat.contentType) ctx.res.setHeader("Content-Type", stat.contentType)
|
||||
if (stat.etag) ctx.res.setHeader("ETag", stat.etag)
|
||||
if (stat.modifiedAt) ctx.res.setHeader("Last-Modified", formatHttpDate(stat.modifiedAt))
|
||||
}
|
||||
|
||||
function boundedStream(stream: ReadableStream<Uint8Array>, start: bigint, length: bigint) {
|
||||
const reader = stream.getReader()
|
||||
let offset = 0n
|
||||
let remaining = length
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
while (remaining > 0n) {
|
||||
const result = await reader.read()
|
||||
if (result.done) {
|
||||
controller.close()
|
||||
reader.releaseLock()
|
||||
return
|
||||
}
|
||||
const chunk = result.value
|
||||
const chunkStart = offset
|
||||
offset += BigInt(chunk.byteLength)
|
||||
const chunkEnd = offset
|
||||
if (chunkEnd <= start) continue
|
||||
const from = Number(start > chunkStart ? start - chunkStart : 0n)
|
||||
const count = Math.min(Number(remaining), chunk.byteLength - from)
|
||||
if (count > 0) {
|
||||
controller.enqueue(chunk.slice(from, from + count))
|
||||
remaining -= BigInt(count)
|
||||
}
|
||||
if (remaining === 0n) {
|
||||
await reader.cancel()
|
||||
reader.releaseLock()
|
||||
return
|
||||
}
|
||||
}
|
||||
controller.close()
|
||||
reader.releaseLock()
|
||||
},
|
||||
cancel() {
|
||||
void reader.cancel()
|
||||
reader.releaseLock()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function quality(value: string, type: string): number {
|
||||
let best = -1
|
||||
for (const part of value.split(",")) {
|
||||
const [media, ...parameters] = part.trim().toLowerCase().split(";")
|
||||
const q = Number(
|
||||
parameters
|
||||
.find((p) => p.trim().startsWith("q="))
|
||||
?.trim()
|
||||
.slice(2) ?? "1",
|
||||
)
|
||||
if (media === type || media === "*/*" || (type === "text/html" && media === "text/*")) {
|
||||
if (Number.isFinite(q)) best = Math.max(best, q)
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function listingType(ctx: Context): "html" | "json" | null {
|
||||
const accept = headerValue(ctx.req.getHeader("accept"))
|
||||
if (!accept) return "html"
|
||||
const html = quality(accept, "text/html")
|
||||
const json = quality(accept, "application/json")
|
||||
if (html <= 0 && json <= 0) return null
|
||||
return json > html ? "json" : "html"
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(
|
||||
/[&<>'"]/g,
|
||||
(char) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"'": "'",
|
||||
'"': """,
|
||||
})[char]!,
|
||||
)
|
||||
}
|
||||
|
||||
function httpDirectoryPath(pathname: string): string {
|
||||
if (pathname === "/") return "/"
|
||||
return "/" + pathname.replace(/^\/+|\/+$/g, "")
|
||||
}
|
||||
|
||||
function httpEntryPath(path: string, name: string, isDirectory: boolean): string {
|
||||
const base = path === "/" ? "/" : path + "/"
|
||||
return base + encodeURIComponent(name) + (isDirectory ? "/" : "")
|
||||
}
|
||||
|
||||
function listingBody(httpPath: string, entries: Stat[], type: "html" | "json"): string {
|
||||
if (type === "json") {
|
||||
return JSON.stringify(
|
||||
entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: httpEntryPath(httpPath, entry.name, entry.isDirectory),
|
||||
isDirectory: entry.isDirectory,
|
||||
size: entry.size.toString(),
|
||||
modifiedAt: entry.modifiedAt?.toISOString(),
|
||||
etag: entry.etag,
|
||||
contentType: entry.contentType,
|
||||
})),
|
||||
)
|
||||
}
|
||||
const rows = entries
|
||||
.map((entry) => {
|
||||
const name = entry.name + (entry.isDirectory ? "/" : "")
|
||||
const href = httpEntryPath(httpPath, entry.name, entry.isDirectory)
|
||||
return `<li><a href="${escapeHtml(href)}">${escapeHtml(name)}</a></li>`
|
||||
})
|
||||
.join("")
|
||||
return `<!doctype html><html><head><title>Index of ${escapeHtml(httpPath)}</title></head><body><h1>Index of ${escapeHtml(httpPath)}</h1><ul>${rows}</ul></body></html>`
|
||||
}
|
||||
|
||||
async function statFile(vfs: AsyncVFS, path: string): Promise<Lookup | null> {
|
||||
try {
|
||||
const stat = await vfs.stat(path)
|
||||
return { path, stat }
|
||||
} catch (error) {
|
||||
if (error instanceof VFSError && error.code === "not-found") return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFile(
|
||||
vfs: AsyncVFS,
|
||||
path: string,
|
||||
suffixes: readonly string[],
|
||||
explicit: boolean,
|
||||
) {
|
||||
const direct = await statFile(vfs, path)
|
||||
if (direct) return direct
|
||||
if (explicit) return null
|
||||
for (const suffix of suffixes) {
|
||||
const candidate = await statFile(vfs, path + suffix)
|
||||
if (candidate && !candidate.stat.isDirectory) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function serveFile(ctx: Context, vfs: AsyncVFS, lookup: Lookup): Promise<void> {
|
||||
const { path, stat } = lookup
|
||||
if (stat.isDirectory) throw new Error("serveFile called for directory")
|
||||
setMetadata(ctx, stat)
|
||||
if (isNotModified(ctx, stat)) {
|
||||
ctx.res.setStatus(304, "Not Modified")
|
||||
ctx.res.body = null
|
||||
return
|
||||
}
|
||||
ctx.res.setHeader("Accept-Ranges", "bytes")
|
||||
const range = parseRange(headerValue(ctx.req.getHeader("range")), stat.size)
|
||||
if (range === undefined) {
|
||||
ctx.res.setStatus(416, "Range Not Satisfiable")
|
||||
ctx.res.setHeader("Content-Range", `bytes */${stat.size}`)
|
||||
ctx.res.body = null
|
||||
return
|
||||
}
|
||||
if (range) {
|
||||
const length = range.end - range.start + 1n
|
||||
ctx.res.setStatus(206, "Partial Content")
|
||||
ctx.res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`)
|
||||
ctx.res.setHeader("Content-Length", length.toString())
|
||||
if (ctx.req.method === "HEAD") {
|
||||
ctx.res.body = null
|
||||
} else {
|
||||
ctx.res.body = vfs.readFileRange
|
||||
? await vfs.readFileRange(path, range.start, range.end)
|
||||
: boundedStream(await vfs.readFile(path), range.start, length)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx.res.setStatus(200, "OK")
|
||||
ctx.res.setHeader("Content-Length", stat.size.toString())
|
||||
ctx.res.body = ctx.req.method === "HEAD" ? null : await vfs.readFile(path)
|
||||
}
|
||||
|
||||
async function serveDirectory(
|
||||
ctx: Context,
|
||||
vfs: AsyncVFS,
|
||||
lookup: Lookup,
|
||||
index: readonly string[] | false,
|
||||
): Promise<boolean> {
|
||||
const names = index === false ? [] : index
|
||||
for (const name of names) {
|
||||
const candidate = await statFile(vfs, normalizePath(lookup.path + "/" + name))
|
||||
if (candidate && !candidate.stat.isDirectory) {
|
||||
await serveFile(ctx, vfs, candidate)
|
||||
return true
|
||||
}
|
||||
}
|
||||
const type = listingType(ctx)
|
||||
if (!type) return false
|
||||
const entries = await vfs.readdir(lookup.path)
|
||||
const body = listingBody(httpDirectoryPath(ctx.req.url.pathname), entries, type)
|
||||
ctx.res.setStatus(200, "OK")
|
||||
ctx.res.setHeader(
|
||||
"Content-Type",
|
||||
type === "html" ? "text/html; charset=utf-8" : "application/json",
|
||||
)
|
||||
ctx.res.setHeader("Content-Length", String(new TextEncoder().encode(body).length))
|
||||
ctx.res.body = ctx.req.method === "HEAD" ? null : body
|
||||
return true
|
||||
}
|
||||
|
||||
export function createStaticHandler(vfs: AsyncVFS, options: StaticServerOptions = {}): Handler {
|
||||
const index = options.index === undefined ? INDEX_FILES : options.index
|
||||
const suffixes = options.suffixes ?? []
|
||||
return async (ctx) => {
|
||||
const method = ctx.req.method.toUpperCase()
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
ctx.res.setStatus(405, "Method Not Allowed")
|
||||
ctx.res.setHeader("Allow", "GET, HEAD")
|
||||
ctx.res.body = null
|
||||
return
|
||||
}
|
||||
let path: string | null
|
||||
try {
|
||||
path =
|
||||
typeof options.path === "function"
|
||||
? normalizePath(await options.path(ctx))
|
||||
: typeof options.path === "string"
|
||||
? normalizePath(options.path)
|
||||
: requestPath(ctx.req.url.pathname, options.prefix)
|
||||
} catch (error) {
|
||||
if (error instanceof URIError) {
|
||||
ctx.res.setStatus(400, "Bad Request")
|
||||
ctx.res.body = null
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const explicit =
|
||||
typeof options.path === "string" ||
|
||||
typeof options.path === "function" ||
|
||||
ctx.req.url.pathname.endsWith("/")
|
||||
if (path === null) {
|
||||
ctx.res.setStatus(404, "Not Found")
|
||||
ctx.res.body = null
|
||||
return
|
||||
}
|
||||
const lookup = await resolveFile(vfs, path, suffixes, explicit)
|
||||
if (lookup?.stat.isDirectory && (await serveDirectory(ctx, vfs, lookup, index))) return
|
||||
if (lookup && !lookup.stat.isDirectory) {
|
||||
await serveFile(ctx, vfs, lookup)
|
||||
return
|
||||
}
|
||||
if (options.fallback) {
|
||||
const fallback = await statFile(vfs, normalizePath(options.fallback))
|
||||
if (fallback?.stat.isDirectory) {
|
||||
if (await serveDirectory(ctx, vfs, fallback, index)) return
|
||||
} else if (fallback) {
|
||||
await serveFile(ctx, vfs, fallback)
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx.res.setStatus(404, "Not Found")
|
||||
ctx.res.body = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createStaticHandler } from "./handler.js"
|
||||
export type { StaticPathResolver, StaticServerOptions } from "./handler.js"
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"verbatimModuleSyntax": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.typetest.ts"]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SSHTransport,
|
||||
authenticateClient,
|
||||
SSHAuthError,
|
||||
type HostKeyVerifier,
|
||||
} from "@webnet/ssh/_internals"
|
||||
import {
|
||||
FXP,
|
||||
@@ -51,10 +52,13 @@ class MockClient {
|
||||
|
||||
static async connect(
|
||||
dialer: RawDialer,
|
||||
opts: { user: string; password?: string },
|
||||
opts: { user: string; password?: string; verifyHostKey?: HostKeyVerifier },
|
||||
): Promise<MockClient> {
|
||||
const raw = await dialer.dial("localhost", 22)
|
||||
const t = await SSHTransport.create(raw, { role: "client" })
|
||||
const t = await SSHTransport.create(raw, {
|
||||
role: "client",
|
||||
verifyHostKey: opts.verifyHostKey,
|
||||
})
|
||||
await authenticateClient(t, { user: opts.user, password: opts.password })
|
||||
const mux = new ConnectionMux(t)
|
||||
mux.start()
|
||||
@@ -495,6 +499,27 @@ suite("SFTPServer", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("keyless server yields a stable fingerprint across connections", async () => {
|
||||
const [listener, dialer] = loopbackListener()
|
||||
const server = new SFTPServer({ vfs: new MemoryVFS() })
|
||||
const running = server.listen(listener, { onError: () => {} })
|
||||
const seen: string[] = []
|
||||
try {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const client = await MockClient.connect(dialer, {
|
||||
user: "u",
|
||||
password: "p",
|
||||
verifyHostKey: (key) => (seen.push(key.fingerprint), true),
|
||||
})
|
||||
await client.close()
|
||||
}
|
||||
} finally {
|
||||
listener.close()
|
||||
await running
|
||||
}
|
||||
assert.equal(seen[0], seen[1])
|
||||
})
|
||||
|
||||
test("a malformed auth request closes the connection instead of leaking it", async () => {
|
||||
await withServer({ vfs: new MemoryVFS() }, async (dialer) => {
|
||||
const raw = await dialer.dial("localhost", 22)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RawListener, RawTransport } from "@webnet/transport"
|
||||
import { generateHostKey } from "@webnet/ssh"
|
||||
import { Session } from "./session.js"
|
||||
import type { ListenOptions, SFTPServerOptions } from "./types.js"
|
||||
|
||||
@@ -8,11 +9,18 @@ const defaultOnError: NonNullable<ListenOptions["onError"]> = (transport, error)
|
||||
|
||||
export class SFTPServer {
|
||||
readonly #options: SFTPServerOptions
|
||||
#hostKey?: Promise<string>
|
||||
|
||||
constructor(options: SFTPServerOptions) {
|
||||
this.#options = options
|
||||
}
|
||||
|
||||
#getHostKey(): Promise<string> {
|
||||
return (this.#hostKey ??= this.#options.hostKey
|
||||
? Promise.resolve(this.#options.hostKey)
|
||||
: generateHostKey())
|
||||
}
|
||||
|
||||
async listen(
|
||||
listener: RawListener,
|
||||
{ onConnect, onError = defaultOnError }: ListenOptions = {},
|
||||
@@ -23,7 +31,8 @@ export class SFTPServer {
|
||||
await transport.close()
|
||||
return
|
||||
}
|
||||
await new Session(transport, this.#options).run()
|
||||
const hostKey = await this.#getHostKey()
|
||||
await new Session(transport, { ...this.#options, hostKey }).run()
|
||||
} catch (e) {
|
||||
onError(transport, e)
|
||||
}
|
||||
|
||||
@@ -1,29 +1 @@
|
||||
export function resolvePath(cwd: string, arg: string): string {
|
||||
const base = arg.startsWith("/") ? arg : cwd + "/" + arg
|
||||
const segments = base.split("/")
|
||||
const resolved: string[] = []
|
||||
for (const seg of segments) {
|
||||
if (seg === "" || seg === ".") continue
|
||||
if (seg === "..") {
|
||||
if (resolved.length > 0) resolved.pop()
|
||||
continue
|
||||
}
|
||||
resolved.push(seg)
|
||||
}
|
||||
return "/" + resolved.join("/")
|
||||
}
|
||||
|
||||
export function normalizePath(path: string): string {
|
||||
return resolvePath("/", path)
|
||||
}
|
||||
|
||||
export function parentPath(path: string): string {
|
||||
if (path === "/") return "/"
|
||||
const idx = path.lastIndexOf("/")
|
||||
return idx === 0 ? "/" : path.slice(0, idx)
|
||||
}
|
||||
|
||||
export function baseName(path: string): string {
|
||||
if (path === "/") return ""
|
||||
return path.slice(path.lastIndexOf("/") + 1)
|
||||
}
|
||||
export { baseName, normalizePath, parentPath, resolvePath } from "@webnet/vfs"
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
/// <reference lib="dom" />
|
||||
import { baseName, normalizePath } from "../path.js"
|
||||
import { VFSError, type AsyncVFS, type Stat } from "../index.js"
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
if (!p.startsWith("/")) p = "/" + p
|
||||
if (p !== "/" && p.endsWith("/")) p = p.slice(0, -1)
|
||||
return p
|
||||
}
|
||||
|
||||
function baseName(p: string): string {
|
||||
if (p === "/") return "/"
|
||||
return p.slice(p.lastIndexOf("/") + 1)
|
||||
}
|
||||
|
||||
function pathParts(p: string): string[] {
|
||||
if (p === "/") return []
|
||||
return p.slice(1).split("/")
|
||||
|
||||
@@ -28,6 +28,8 @@ export class VFSError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export { baseName, joinPath, normalizePath, parentPath, resolvePath } from "./path.js"
|
||||
|
||||
export interface AsyncVFS {
|
||||
stat(path: string): Promise<Stat>
|
||||
readdir(path: string): Promise<Stat[]>
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
import { baseName, normalizePath, parentPath } from "../path.js"
|
||||
import { VFSError, type AsyncVFS, type Stat } from "../index.js"
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
if (!p.startsWith("/")) p = "/" + p
|
||||
if (p !== "/" && p.endsWith("/")) p = p.slice(0, -1)
|
||||
return p
|
||||
}
|
||||
|
||||
function parentPath(p: string): string {
|
||||
if (p === "/") return "/"
|
||||
const idx = p.lastIndexOf("/")
|
||||
return idx === 0 ? "/" : p.slice(0, idx)
|
||||
}
|
||||
|
||||
function baseName(p: string): string {
|
||||
if (p === "/") return ""
|
||||
return p.slice(p.lastIndexOf("/") + 1)
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
isDirectory: boolean
|
||||
data: Uint8Array
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, suite } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { baseName, joinPath, normalizePath, parentPath, resolvePath } from "./path.js"
|
||||
|
||||
suite("normalizePath", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["", "/"],
|
||||
["/", "/"],
|
||||
["a//b/", "/a/b"],
|
||||
["/a/./b/../c", "/a/c"],
|
||||
["../../a", "/a"],
|
||||
["/a\\b", "/a\\b"],
|
||||
["/.config/file", "/.config/file"],
|
||||
["/日本語/é", "/日本語/é"],
|
||||
]
|
||||
for (const [input, expected] of cases) {
|
||||
test(`${JSON.stringify(input)} => ${expected}`, () => {
|
||||
assert.equal(normalizePath(input), expected)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
suite("resolvePath", () => {
|
||||
test("resolves relative paths against cwd", () => {
|
||||
assert.equal(resolvePath("/a/b", "../c"), "/a/c")
|
||||
})
|
||||
|
||||
test("absolute paths ignore cwd", () => {
|
||||
assert.equal(resolvePath("/a/b", "/c/../d"), "/d")
|
||||
})
|
||||
|
||||
test("clamps traversal at root", () => {
|
||||
assert.equal(resolvePath("/", "../../a"), "/a")
|
||||
})
|
||||
})
|
||||
|
||||
suite("parentPath", () => {
|
||||
test("normalizes before finding the parent", () => {
|
||||
assert.equal(parentPath("/a//b/"), "/a")
|
||||
})
|
||||
|
||||
test("root has itself as its parent", () => {
|
||||
assert.equal(parentPath(""), "/")
|
||||
})
|
||||
})
|
||||
|
||||
suite("baseName", () => {
|
||||
test("returns the final segment", () => {
|
||||
assert.equal(baseName("/a//b/"), "b")
|
||||
})
|
||||
|
||||
test("root has no basename", () => {
|
||||
assert.equal(baseName("/"), "")
|
||||
})
|
||||
})
|
||||
|
||||
suite("joinPath", () => {
|
||||
test("joins and normalizes segments", () => {
|
||||
assert.equal(joinPath("/a", "b", "..", "c/"), "/a/c")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
function segments(path: string): string[] {
|
||||
const result: string[] = []
|
||||
for (const segment of path.split("/")) {
|
||||
if (segment === "" || segment === ".") continue
|
||||
if (segment === "..") {
|
||||
result.pop()
|
||||
} else {
|
||||
result.push(segment)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function normalizePath(path: string): string {
|
||||
return "/" + segments(path).join("/")
|
||||
}
|
||||
|
||||
export function resolvePath(cwd: string, path: string): string {
|
||||
return normalizePath(path.startsWith("/") ? path : normalizePath(cwd) + "/" + path)
|
||||
}
|
||||
|
||||
export function parentPath(path: string): string {
|
||||
const normalized = normalizePath(path)
|
||||
if (normalized === "/") return "/"
|
||||
return normalized.slice(0, normalized.lastIndexOf("/")) || "/"
|
||||
}
|
||||
|
||||
export function baseName(path: string): string {
|
||||
const normalized = normalizePath(path)
|
||||
return normalized === "/" ? "" : normalized.slice(normalized.lastIndexOf("/") + 1)
|
||||
}
|
||||
|
||||
export function joinPath(...paths: string[]): string {
|
||||
return normalizePath(paths.join("/"))
|
||||
}
|
||||
Reference in New Issue
Block a user