8 Commits
Author SHA1 Message Date
codingetandCodex fb8290e29b fix(ftp): detach control state atomically
CI / format (pull_request) Successful in 43s
CI / lint (pull_request) Successful in 44s
CI / install (pull_request) Successful in 3m45s
CI / typetest (pull_request) Successful in 46s
CI / node-tests (pull_request) Successful in 56s
CI / typecheck (pull_request) Successful in 1m0s
CI / browser-tests (pull_request) Successful in 2m4s
Co-Authored-By: GPT-5.6 Terra <noreply@openai.com>

Co-Authored-By: GPT-5.6 Sol <noreply@openai.com>
2026-07-19 23:14:16 +00:00
codingetandCodex f8cf4c9bf3 feat(ftp): transfer client control state
Co-Authored-By: GPT-5.6 Terra <noreply@openai.com>
2026-07-19 23:13:00 +00:00
codingetandCodex a1eca114ce fix(http-static): use HTTP paths in listings
CI / lint (pull_request) Successful in 1m45s
CI / format (pull_request) Successful in 2m27s
CI / install (pull_request) Successful in 5m53s
CI / typetest (pull_request) Successful in 2m3s
CI / node-tests (pull_request) Successful in 2m23s
CI / typecheck (pull_request) Successful in 2m29s
CI / browser-tests (pull_request) Successful in 4m3s
Co-Authored-By: GPT-5.6 Luna <noreply@openai.com>
2026-07-14 00:47:56 +00:00
codingetandCodex d1803a37de test(http-static): cover precedence vectors
Co-Authored-By: GPT-5.6 Luna <noreply@openai.com>
2026-07-14 00:47:56 +00:00
codingetandCodex a5c0a66d9a fix(http-static): address review findings
Co-Authored-By: GPT-5.6 Luna <noreply@openai.com>
2026-07-14 00:47:56 +00:00
codingetandCodex 99cdd9322b feat(http-static): add VFS static handler
Co-Authored-By: GPT-5.6 Luna <noreply@openai.com>
2026-07-14 00:47:56 +00:00
codingetandCodex 6f54110fe5 feat(vfs): centralize canonical path helpers
CI / format (pull_request) Successful in 1m50s
CI / lint (pull_request) Successful in 1m53s
CI / install (pull_request) Successful in 5m15s
CI / typetest (pull_request) Successful in 2m1s
CI / node-tests (pull_request) Successful in 2m23s
CI / typecheck (pull_request) Successful in 2m25s
CI / browser-tests (pull_request) Successful in 3m57s
Co-Authored-By: GPT-5.6 Luna <noreply@openai.com>
2026-07-13 23:42:16 +00:00
codingetandCodex b2d04de758 fix(sftp): stabilize keyless server host key
CI / lint (pull_request) Successful in 1m47s
CI / format (pull_request) Successful in 2m13s
CI / install (pull_request) Successful in 6m12s
CI / typetest (pull_request) Successful in 2m4s
CI / node-tests (pull_request) Successful in 2m27s
CI / typecheck (pull_request) Successful in 2m32s
CI / browser-tests (pull_request) Successful in 4m12s
Co-Authored-By: GPT-5.6 Luna <noreply@openai.com>
2026-07-13 00:20:18 +00:00
23 changed files with 1286 additions and 92 deletions
+10
View File
@@ -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.
+32
View File
@@ -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",
+75 -2
View File
@@ -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()
}
}
+71 -2
View File
@@ -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 -1
View File
@@ -1,2 +1,2 @@
export { FTPClient } from "./client.js"
export type { FTPClientOptions } from "./types.js"
export type { FTPClientOptions, FtpTransferState } from "./types.js"
+16
View File
@@ -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
}
+4
View File
@@ -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 -25
View File
@@ -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, '""')}"`
+125 -1
View File
@@ -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 -1
View File
@@ -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"
+24
View File
@@ -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": "*"
}
}
+406
View File
@@ -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, /&lt;read me&gt;/)
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)
})
})
+360
View File
@@ -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) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;",
})[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
}
}
+2
View File
@@ -0,0 +1,2 @@
export { createStaticHandler } from "./handler.js"
export type { StaticPathResolver, StaticServerOptions } from "./handler.js"
+20
View File
@@ -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"]
}
+27 -2
View File
@@ -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)
+10 -1
View File
@@ -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
View File
@@ -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 -11
View File
@@ -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("/")
+2
View File
@@ -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 -17
View File
@@ -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
+61
View File
@@ -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")
})
})
+35
View File
@@ -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("/"))
}