# Architecture
This document explains how node-scp is put together: which module does what, how a transfer
moves through the code, and why some decisions were made. Diagrams use Mermaid, which GitHub
renders inline.
## The big picture
node-scp copies files over SSH with one API, whatever the server offers:
- **SFTP**, the SSH file transfer subsystem. Most Linux servers have it.
- **SCP**, the classic protocol spoken by running `scp -t` or `scp -f` on the server. Dropbear
on OpenWrt, BusyBox firmware and many network devices offer only this.
`connect()` asks the server for SFTP and falls back to SCP when it is missing. Everything above
the transport layer (progress, filters, abort, errors, the CLI) is shared by both protocols.
```mermaid
flowchart TB
subgraph entry["Entry points"]
api["node-scp
connect, upload, download"]
legacy["node-scp/legacy
0.x API"]
scp2["node-scp/scp2
scp2 drop-in"]
cli["CLI
npx node-scp"]
action["GitHub Action
action.yml"]
end
action -->|npx| cli
cli --> api
legacy --> api
scp2 --> api
api --> client["ScpClient
src/client.ts"]
client --> fs["RemoteFs
src/sftp/remote-fs.ts"]
client --> transport{{"Transport interface
src/transport.ts"}}
transport --> sftpT["SftpTransport
src/sftp/transport.ts"]
transport --> scpT["ScpTransport
src/scp/transport.ts"]
sftpT --> ssh2sftp["ssh2 SFTPWrapper
fastPut / fastGet"]
scpT --> channel["ScpChannel
src/scp/channel.ts"]
channel --> protocol["protocol.ts
encode / parse records"]
channel --> reader["ByteReader
backpressure"]
channel --> exec["ssh2 exec channel
scp -t / scp -f"]
subgraph shared["Shared helpers"]
local["transfer/local.ts
walk local tree"]
queue["transfer/queue.ts
concurrency + abort"]
progress["transfer/progress.ts"]
errors["errors.ts
ScpError + codes"]
shell["shell.ts
quoting"]
paths["remote-path.ts"]
end
sftpT -.-> local & queue & progress
scpT -.-> local & progress & shell
client -.-> errors
```
## Source layout
```text
src/
index.ts public API: connect, upload, download, types, errors
client.ts ScpClient and connect(): handshake, protocol negotiation, lifecycle
transport.ts the Transport interface both protocols implement
types.ts ConnectOptions, TransferOptions, TransferProgress, results
errors.ts ScpError, ErrorCode, mapping from ssh2, SFTP and errno errors
target.ts parseTarget / formatTarget for user@host:path and scp:// URIs
shell.ts POSIX and Windows argument quoting for remote commands
names.ts checks for names received from servers, modes for new copies
remote-path.ts path helpers for posix or win32 servers
glob.ts small glob matcher, used by the scp2 layer and CLI --exclude
exec.ts run a command and collect its output
remote-helpers.ts mkdir -p and "is a directory" over SFTP or a shell
sftp/
ops.ts promisified SFTP calls with error mapping
remote-fs.ts RemoteFs: exists, stat, list, mkdir, rm, rename, realpath
transport.ts SftpTransport: parallel file transfers with fastPut / fastGet
scp/
protocol.ts SCP control records and name validation
reader.ts ByteReader: pull based reads from a push based stream
channel.ts ScpChannel: one remote scp process, status bytes, failures
transport.ts ScpTransport: sink (upload) and source (download) state machines
transfer/
local.ts walk local trees, create local directories, apply modes and times
queue.ts bounded concurrency queue with abort
progress.ts aggregates per file progress into TransferProgress events
legacy/index.ts the 0.x Client API on top of the v1 client, SFTP only
scp2/index.ts the scp2 API on top of the v1 client, SFTP or SCP
cli/
main.ts argument parsing, auth discovery, host key pinning, output
index.ts the bin entry, calls main()
```
## Connecting and picking a protocol
```mermaid
flowchart TD
start(["connect(options)"]) --> validate["validate protocol and remoteOs"]
validate --> ssh["ssh2 Client.connect()
wait for ready or error"]
ssh -->|error| mapErr["ScpError
ERR_AUTH_FAILED, ERR_TIMEOUT,
ERR_CONNECTION_FAILED"]
ssh -->|ready| nodelay["setNoDelay(true)"]
nodelay --> which{protocol}
which -->|scp| scpClient["ScpClient with ScpTransport"]
which -->|sftp or auto| open["open the SFTP subsystem
timeout = readyTimeout"]
open -->|ok| sftpClient["ScpClient with SftpTransport
and RemoteFs"]
open -->|failed, protocol sftp| sftpErr["ERR_SFTP_UNAVAILABLE"]
open -->|failed, protocol auto| scpClient
```
Two details matter here:
- **`setNoDelay(true)`.** Both protocols send a small message and wait for a small reply. With
Nagle's algorithm on, the kernel holds those messages back, and 200 small files over SCP took
1.4 s instead of 70 ms on a local OpenSSH server. ssh2 does not set it, so node-scp does,
unless `noDelay: false` is passed.
- **The SFTP probe has a timeout.** Some servers accept the subsystem request and then never
answer. Without a timeout `auto` would hang.
## SFTP transfers
SFTP is request based, so parallel transfers are cheap. `SftpTransport`:
1. stats the source, and walks the tree when it is a directory (following symlinks, with loop
detection),
2. applies `filter`,
3. creates directories top down,
4. copies files through a bounded queue (`concurrency`, default 4) with ssh2 `fastPut` /
`fastGet`, which already pipeline 64 requests per file. Without `preserve`, a new
destination file is created first (exclusively) with the source permission bits plus owner
write, because ssh2 can only chmod after opening, which would ignore the umask and change
existing files. ssh2 then opens the file again to write it, so owner write is dropped
afterwards for read only sources. That matches what `scp` does,
5. applies full modes and times bottom up when `preserve` is set, so writing into a directory
does not reset its mtime afterwards.
Every name in a remote listing goes through the same `isSafeReceivedName` check as names in SCP
records, see [Safety](#safety).
## SCP transfers
SCP runs one remote process per transfer and streams everything through its stdin and stdout.
Every control record and every file body is answered with one status byte: `0` OK, `1` error
(followed by a message line), `2` fatal.
### Upload (the remote side is the sink)
```mermaid
sequenceDiagram
autonumber
participant C as node-scp
participant S as remote scp (sink)
C->>S: exec "scp -t -d -r /srv"
S-->>C: 0 (ready)
opt preserve
C->>S: T1700000000 0 1700000000 0
S-->>C: 0
end
C->>S: D0755 0 site
S-->>C: 0
C->>S: C0644 5 index.html
S-->>C: 0 (file opened)
C->>S: 5 bytes of data, then 0
S-->>C: 0 (file written)
C->>S: E (leave site)
S-->>C: 0
C->>S: EOF
S-->>C: exit status 0
```
`upload('dist', '/srv/site')` runs the sink in the parent `/srv` and sends the tree under the
name `site`. The `-d` flag makes the sink insist that `/srv` is a directory, so a missing parent
fails with `ERR_NOT_FOUND` instead of silently creating a file called `srv`.
### Download (the remote side is the source)
```mermaid
sequenceDiagram
autonumber
participant C as node-scp
participant S as remote scp (source)
C->>S: exec "scp -f -r /etc/config"
C->>S: 0 (ready to receive)
S-->>C: D0755 0 config
C->>C: validate name, create local directory
C->>S: 0
S-->>C: C0600 120 network
C->>C: validate name, open local file with mode 0600
C->>S: 0
S-->>C: 120 bytes of data, then 0
C->>S: 0
S-->>C: E
C->>S: 0
S-->>C: EOF, exit status 0
```
`ScpTransport` keeps a stack of open directories while receiving. Records are parsed by
`protocol.ts`, and file bodies are streamed to disk through `ByteReader`, which pauses the SSH
channel when more than 1 MiB is buffered so a slow disk slows the server down instead of filling
memory.
### How SCP failures are classified
| What happens | Error |
| --- | --- |
| The server refuses the exec request | `ERR_SCP_UNAVAILABLE` |
| The command exits with 127 or prints `scp: not found` | `ERR_SCP_UNAVAILABLE` |
| The first output is not SCP, for example `This service allows sftp connections only.` | `ERR_SCP_UNAVAILABLE` |
| No byte at all within `readyTimeout` | `ERR_SCP_UNAVAILABLE` |
| Status byte `1` or `2` with a message | mapped from the message: `ERR_NOT_FOUND`, `ERR_PERMISSION_DENIED`, `ERR_IS_A_DIRECTORY`, otherwise `ERR_REMOTE` |
| A malformed record after the handshake, or an unsafe name | `ERR_SCP_PROTOCOL` |
| The channel closes in the middle | `ERR_CONNECTION_CLOSED` |
## Safety
SCP is the protocol where most client bugs turn into security bugs, so these rules are strict.
The rules for received names apply to SFTP listings too: a server is untrusted in both.
```mermaid
flowchart LR
subgraph out["Paths we send"]
p1["remote path"] --> q["quotePosix / quoteWindows"]
q --> cmd["scp -t '...'"]
n1["names in C and D records"] --> v1["assertSendableName
no / newline NUL . .."]
end
subgraph in["Names we receive"]
r["C or D record (SCP)"] --> v2["isSafeReceivedName
single segment only,
no \ or : on Windows"]
l["readdir entry (SFTP)"] --> v2
v2 --> w["write inside the destination"]
v2 -->|"../x or /etc/x"| rej["ERR_SCP_PROTOCOL (SCP)
ERR_REMOTE (SFTP)"]
end
```
- **Remote paths reach the shell as one literal word.** POSIX paths go in single quotes (`'`
becomes `'\''`), so `$()`, backticks, globs, `~` and spaces reach `scp` literally. Paths made
only of `A-Za-z0-9_@%+=:,./-` need no quoting and are sent bare, which is what devices without
a real shell expect. Windows paths are double quoted and characters that `cmd.exe` or
PowerShell interpret even inside quotes (`"`, `%`, `!`, `^`, `$`, backtick) are refused
(`isSafeForWindowsShell`). A property test feeds random strings
through a real `/bin/sh` to check this.
- **Names from the server are one path segment**, in SCP records and in SFTP listings alike.
A malicious server could otherwise send `../../.bashrc` (CVE-2019-6111). On Windows clients `:` is refused as well, since it would
select a drive or an NTFS stream. The client also refuses a second top level entry and
directory records when `recursive` was not requested.
- **No special mode bits without `preserve`.** New files get the permission bits only, so a
server cannot plant a setuid file on a client that runs as root.
- **NUL bytes are rejected** before anything is sent.
- **The CLI pins host keys** with `--fingerprint` and warns when a key was not verified.
## Errors
Every error is a `ScpError` with a stable `code`, the failing `path` when there is one, and the
original error in `cause`. The mapping lives in `errors.ts`:
- SFTP status codes (`2` no such file, `3` permission denied, ...) and errno strings
(`ENOENT`, `EACCES`, `EISDIR`, ...) become `ERR_NOT_FOUND`, `ERR_PERMISSION_DENIED`, and so on,
so callers do not care which protocol ran.
- ssh2 connection errors are mapped by their `level` (`client-authentication`,
`client-timeout`) and `code` (`ECONNREFUSED`, `ENOTFOUND`).
- Aborting through an `AbortSignal` always produces `ERR_ABORTED`.
## Cancellation and concurrency
`TransferOptions.signal` is checked between files and wired into every wait:
- SCP destroys the exec channel, which stops the remote process right away.
- SFTP stops scheduling new files and rejects at once. Files already in flight finish in the
background because ssh2 cannot interrupt a `fastPut` midway.
`runQueue` in `transfer/queue.ts` rejects on the first failure and stops scheduling.
## Compatibility layers
```mermaid
flowchart LR
old["import { Client } from 'node-scp/legacy'"] --> L["legacy ScpClient
EventEmitter"]
L -->|"connect({ protocol: 'sftp' })"| V1["v1 ScpClient"]
L -->|"uploadDir / downloadDir"| V1
L -->|"stat, chmod, symlink, ..."| raw["raw ssh2 SFTPWrapper
(same errors as 0.x)"]
s2["require('node-scp/scp2').scp(...)"] --> S["scp2 Client"]
S -->|"connect({ protocol: 'auto' })"| V1
```
- `node-scp/legacy` keeps the 0.x API, including its return shapes (`exists` resolves to `'d'`,
`'-'`, `'l'` or `false`, `list` returns the old entry objects). It always uses SFTP, like 0.x.
File operations pass ssh2 errors through untouched so existing `catch` blocks keep working.
- `node-scp/scp2` mirrors the unmaintained `scp2` package, callbacks included, but negotiates
the protocol, so it also works with SCP only devices.
## Tests
```mermaid
flowchart BT
unit["unit
protocol, quoting through /bin/sh, targets, glob,
queue, reader, errors (fast-check)"]
e2e["e2e
in process ssh2 server piped to the real
sftp-server and scp; fault injection"]
docker["docker
OpenSSH full, SFTP only, SCP only, Dropbear,
Toxiproxy latency / reset / throttle"]
external["external
any server from NODE_SCP_TEST_* variables"]
scheduled["scheduled, every two weeks
Windows OpenSSH, fresh images, benchmarks"]
unit --> e2e --> docker --> external --> scheduled
```
- **unit** (`test/unit`) has no network.
- **e2e** (`test/e2e`) starts `test/harness/server.ts`, an ssh2 server that hands SFTP to the
system `sftp-server` and exec requests to `/bin/sh`, so real OpenSSH `scp` binaries speak to
the client. The harness can also refuse SFTP, refuse exec, or run a scripted malicious SCP
source.
- **docker** (`test/docker`) uses testcontainers with the images in `docker/`.
- **external** (`test/external`) runs the shared scenarios in `test/scenarios.ts` against
whatever server the environment points at.
The same scenario list (`defineTransferScenarios`) runs everywhere, so a server type only needs
a connection and a scratch directory to get full coverage.
## Build and release
```mermaid
flowchart LR
src["src/*.ts"] --> tsdown["tsdown"]
tsdown --> esm["dist/*.mjs + .d.mts"]
tsdown --> cjs["dist/*.cjs + .d.cts"]
tsdown --> bin["dist/cli.mjs"]
esm & cjs --> checks["publint + attw
smoke on Node 20 and 24"]
pr["PR with a changeset"] --> merge["merge to master"]
merge --> cs["changesets/action
opens 'chore: release' PR"]
cs --> merge2["merge release PR"]
merge2 --> publish["npm publish with provenance
(trusted publishing, no token)"]
publish --> ghr["GitHub release vX.Y.Z
notes from CHANGELOG.md"]
ghr --> major["move the v1 tag
for the GitHub Action"]
```
## Decisions worth knowing
- **Destinations are exact.** `upload(src, dest)` makes `dest` a copy of `src`. It never puts
things "into" `dest` because it happens to exist. That behaviour depends on server state and
differs between protocols, so the library avoids it. The CLI and the scp2 layer add the
familiar "trailing slash means into this directory" rule on top.
- **Remote parents must exist.** SCP has no way to create parent directories without a shell,
and doing it silently over SFTP only would make the protocols behave differently. Use
`client.fs.mkdir(dir, { recursive: true })` when you need it.
- **Symlinks are followed** when walking trees, with loop detection by device and inode locally
and by `realpath` remotely. Copying symlinks as links is not possible over SCP.
- **`concurrency` is SFTP only.** SCP is one ordered stream per process. Opening several SCP
processes in parallel would work against the per connection channel limits of small devices.
- **No `any`.** The code base is strict TypeScript with `noUncheckedIndexedAccess`.