SFTP Backend Specification¶
Overview¶
SFTPBackend implements the Backend ABC for SSH File Transfer Protocol (SFTP) servers
using pure paramiko internally. It maps the Backend contract onto a real remote
filesystem accessed over SSH/SFTP.
Unlike fsspec's SFTPFileSystem (which hardcodes AutoAddPolicy), this backend
provides explicit host key policy control via a HostKeyPolicy enum, PEM key
sanitization for Azure Key Vault compatibility, and tenacity-based retry for
transient SSH errors.
Dependencies: paramiko, tenacity (optional extra: pip install "remote-store[sftp]")
Construction¶
SFTP-001: Constructor Parameters¶
Invariant: SFTPBackend is constructed with a required host and optional
connection/authentication parameters.
Signature:
SFTPBackend(
host: str,
*,
port: int = 22,
username: str | None = None,
password: str | None = None,
pkey: Any = None, # paramiko.PKey, lazy-typed
base_path: str = "/", # root on remote server
host_key_policy: HostKeyPolicy = HostKeyPolicy.STRICT,
known_host_keys: str | None = None,
host_keys_path: str | None = None, # defaults to ~/.ssh/known_hosts
config: dict | None = None, # may contain "known_host_keys"
timeout: int = 10,
connect_kwargs: dict | None = None, # extra SSHClient.connect() kwargs
)
SFTP-002: Backend Name¶
Invariant: name property returns "sftp".
SFTP-003: Capability Declaration¶
Invariant: SFTPBackend declares capabilities:
READ, WRITE, DELETE, LIST, MOVE, COPY, ATOMIC_WRITE, METADATA, WRITE_RESULT_NATIVE. Does not declare GLOB (no native pattern matching; use list_files(pattern=…) or ext.glob for client-side fallback).
Rationale:
- WRITE_RESULT_NATIVE: write() and write_atomic() populate size (counted
during upload) and source from the write path itself. Every rich field
(etag / version_id / last_modified / digest) is None — SFTP's write
response carries no metadata at all, and the backend does not stat after
upload/rename to fetch any (BK-313: that round-trip was paid on every write).
WR-001a permits None for a field the write response omits, so the declaration
rests on size / source; callers needing the metadata call get_file_info().
- ATOMIC_WRITE: Simulated via temp file + rename (see SFTP-014). Orphan temp
files are possible on connection failure — documented caveat.
- MOVE: Implemented via posix_rename with fallback (see SFTP-018).
- COPY: Implemented via read + write (no server-side copy in SFTP, see SFTP-019).
- Read-path directory rejection is lazy for read_bytes (BK-313: the eager
stat was the round-trip removed): a directory target raises InvalidPath
only because reading it fails. This assumes the server either refuses to open
a directory for reading or reports a non-zero directory st_size — both hold
on OpenSSH, where a directory reports st_size == 4096. A non-standard server
that opens a directory for reading and reports st_size == 0 would make
read_bytes return empty bytes rather than raising InvalidPath. read
(streaming) keeps an eager check instead, because a streaming read never
issues the in-band I/O that would surface the directory. Accepted as a
documented server assumption (audit-020 M3).
SFTP-004: Lazy Connection¶
Invariant: No network call occurs during __init__. The SSH/SFTP connection is
established lazily on first operation.
Rationale: Fail-fast at construction is undesirable — the backend may be created
during application wiring before the network is available. Automatic reconnection
on staleness is also supported (see SFTP-010).
SFTP-005: Construction Validation¶
Invariant: host must be a non-empty string. Passing an empty or whitespace-only
host raises ValueError at construction time.
Postconditions: No network validation of host reachability at construction time.
Connection¶
SFTP-006: HostKeyPolicy Enum¶
Invariant: HostKeyPolicy controls how unknown remote host keys are handled:
- STRICT (default): Reject unknown hosts. Requires host key in known_hosts.
- TRUST_ON_FIRST_USE: Accept and save on first connect, verify on subsequent connects.
- AUTO_ADD: Accept any key. Development/testing only — not safe for production.
String values ("strict", "tofu", "auto") passed from TOML/YAML config are
coerced to the enum in __init__ via HostKeyPolicy(value). The enum-name
forms ("STRICT", "TRUST_ON_FIRST_USE", "AUTO_ADD") are also accepted,
case-insensitive on the name ("auto_add" and "Auto_Add" both resolve to
AUTO_ADD); value-form aliasing (e.g. "AUTO" for canonical value "auto")
is not folded and continues to raise ValueError. Invalid strings, and any
non-string input, raise ValueError. See
020-credential-hygiene.md SEC-005.
SFTP-007: Host Key Resolution Chain¶
Invariant: Known host keys are resolved with first-match precedence:
1. known_host_keys constructor parameter (code-level override)
2. config["known_host_keys"] dict value
3. SFTP_KNOWN_HOST_KEYS environment variable
4. host_keys_path file on disk (default: ~/.ssh/known_hosts)
Postconditions: If none of the above yield keys and the policy is STRICT,
connection will fail with a host key verification error.
SFTP-008: PEM Key Sanitization¶
Invariant: _sanitize_pem() normalizes PEM line separators, handling the Azure
Key Vault quirk where newlines may be replaced with spaces or other characters.
Postconditions: The sanitized PEM string has standard \n line separators within
the Base64 payload. Invalid PEM structures (not 5 parts) raise ValueError.
SFTP-009: Tenacity Retry on Connect¶
Invariant: The _connect() method retries on transient SSH errors using tenacity.
When no RetryPolicy is provided, uses defaults: 3 attempts, exponential backoff
(2s min, 10s max). When a RetryPolicy is provided via the retry constructor
parameter, maps its fields to tenacity: max_attempts -> stop_after_attempt,
backoff_base -> wait_exponential(min=), backoff_max -> wait_exponential(max=),
jitter -> wait_random(0, jitter), timeout -> stop_after_delay.
See also: spec 025-retry-policy.md (RET-010).
Retried exceptions: paramiko.SSHException, OSError, EOFError.
Postconditions: After all retries are exhausted, the original exception is reraised.
SFTP-010: Staleness Detection and Reconnect¶
Invariant: Staleness is detected in two tiers, neither of which spends a
per-operation round-trip. (1) The lazy _sftp property reads the SSH
transport's is_active() flag — a local check, no bytes on the wire; a
transport that has gone inactive is reconnected before the operation runs.
(2) A drop that leaves the transport flag True but the SFTP channel dead
(idle-channel timeout, subsystem restart, half-open partition) is invisible to
tier 1; it is caught on the operation itself and mapped to BackendUnavailable,
which invalidates the cached client so the next _sftp access reconnects. The
trigger is the conclusion that the connection is unusable, not any single
signal: every mapping that concludes BackendUnavailable clears the client,
across the full dead-connection signal set (EOFError, OSError('Socket is
closed'), the socket-teardown errnos, socket.timeout, and the paramiko
SFTPError / SSHException / ChannelException families — see SFTP-023).
Anchoring recovery to that conclusion rather than an enumerated list is what
keeps a signal the list forgot (e.g. a socket.timeout from the channel
timeout) from wedging the long-lived backend. Operations outside the default
_errors() scope must still route through this mapping for the guarantee to
hold: the listing operations route their failure through _map_exception, and
open_atomic's streamed-write phase — which yields the handle outside
_errors() — routes its backend failures (a dead channel or any paramiko SSH /
protocol error surfaced during the caller's writes) through _map_exception
too, while its temp-file open and promote steps run inside _errors().
Rationale: The property is accessed several times per operation, so the
former stat('.') liveness probe multiplied each operation's RTT count — but it
doubled as a universal self-heal, reconnecting a dead client of any kind on
the next op. The transport flag costs nothing on the wire; tier 2 restores that
self-heal for a channel-only death by clearing the client on every
BackendUnavailable, without adding a probe to the happy path.
Postconditions: A healthy connection is reused with no per-operation probe
round-trip. A dropped connection surfaces as BackendUnavailable and the
following call re-establishes it — recovery may take one failed call when the
drop is channel-only (tier 2).
Filesystem Model¶
SFTP-011: Real Directories¶
Invariant: SFTP operates on a real remote filesystem with actual directories,
unlike S3's virtual prefix-based folders. is_folder() uses stat() + S_ISDIR.
Postconditions: Folders exist independently of their contents.
SFTP-012: Write Creates Intermediate Directories¶
Invariant: write("a/b/c.txt", content) creates intermediate directories a/
and a/b/ if they do not exist.
Rationale: SFTP servers reject writes to non-existent directories. Creating them
automatically matches the convenience of local and S3 backends.
SFTP-013: Empty Folders Persist¶
Invariant: Unlike S3 (where folders vanish when empty), empty directories on an
SFTP server persist after their contents are deleted.
Postconditions: is_folder("dir") returns True even after all files under
dir/ are deleted.
Operations¶
SFTP-014: Atomic Write (Simulated)¶
Invariant: write_atomic writes to a temporary file .~tmp.<name>.<uuid8> in
the same directory as the target, then renames to the target via posix_rename.
Caveat: If the connection drops between write and rename, the orphan temp file
remains. This is simulated atomicity, not true atomicity — the capability is
declared to enable the write-then-rename pattern, but the caveat must be documented.
Postconditions: On success, the temp file is gone and the target contains the
new content. On failure, the backend makes a best-effort temp-file cleanup that
never reconnects: when the failure is itself a dropped-connection signal (or the
client is already invalidated), the cleanup unlink is deliberately skipped rather
than triggering a fresh connect against a possibly-down server inside the
error-handling path — so the orphan-temp caveat above holds and the original error
propagates without a multi-second reconnect stall. An abnormal exit of an
open_atomic block (including a GeneratorExit / KeyboardInterrupt) removes the
temp file under the same best-effort guard.
SFTP-015: Atomic Write Overwrite Semantics¶
Invariant: write_atomic(path, content, overwrite=False) raises AlreadyExists
if the target already exists. With overwrite=True, the existing file is replaced.
SFTP-016: delete_folder Recursive¶
Invariant: delete_folder(path, recursive=True) walks the directory tree
bottom-up, deleting files then directories.
Raises: NotFound if the folder does not exist and missing_ok=False.
SFTP-017: delete_folder Non-Recursive¶
Invariant: delete_folder(path, recursive=False) succeeds only if the directory
is empty.
Raises: NotFound if missing. RemoteStoreError if the directory is not empty.
SFTP-018: Move Via posix_rename¶
Invariant: move(src, dst) attempts posix_rename (atomic overwrite), falls back
to rename, and falls back to copy + delete if rename fails entirely.
Raises: NotFound if src does not exist. AlreadyExists if dst exists and
overwrite=False.
SFTP-019: Copy Via Read + Write¶
Invariant: copy(src, dst) reads the source file and writes it to the destination.
There is no server-side copy operation in SFTP — data passes through the client.
Raises: NotFound if src does not exist. AlreadyExists if dst exists and
overwrite=False.
Error Mapping¶
SFTP-020: NotFound Mapping¶
Invariant: IOError with errno.ENOENT (errno 2) and FileNotFoundError are
mapped to NotFound.
Postconditions: path and backend attributes are set on the error.
SFTP-021: PermissionDenied Mapping¶
Invariant: IOError with errno.EACCES (errno 13) is mapped to PermissionDenied.
SFTP-022: AlreadyExists Mapping¶
Invariant: IOError with errno.EEXIST (errno 17) is mapped to AlreadyExists.
SFTP-023: BackendUnavailable Mapping¶
Invariant: paramiko.SSHException and its subclasses (authentication failures,
ChannelException, etc.) are mapped to BackendUnavailable. So are the dropped-
connection signals that are not SSHException subclasses: EOFError,
OSError('Socket is closed') (no errno), OSError with errno in
ECONNRESET / EPIPE / ECONNABORTED / ETIMEDOUT / ESHUTDOWN / ENOTCONN /
EBADF, socket.timeout / TimeoutError (matched by type, since a half-open
instance often carries no matching errno), and paramiko.SFTPError (an
SFTP-protocol failure that subclasses neither OSError nor SSHException).
Every BackendUnavailable this mapping returns — the SSHException family
included — invalidates the cached SFTP client so the next operation reconnects
(see SFTP-010, tier 2). The list is not the guarantee: recovery is anchored to
the BackendUnavailable conclusion, so a dead-connection signal the list has
not enumerated still clears the client rather than wedging the backend. One
exception keeps its own branch first: IncompatiblePeer (a connect-time
SSHException) is mapped with a diagnostic hint before the generic SSHException
mapping, so the hint is not lost.
SFTP-024: No Native Exception Leakage¶
Invariant: No paramiko, socket, or OS exception raised by the backend — an
operation's own I/O, including open_atomic's temp-file open, the caller-facing
handle's flush/close, and the promote — propagates to callers; all are mapped to
remote_store error types per BE-021. The only non-mapped exceptions are those
the caller raises inside an open_atomic yield block (their with body)
that are not themselves dead-connection signals: those are not the backend's and
propagate unchanged, leaving the target untouched. open_atomic distinguishes
by scope — the temp open, flush, and promote run inside _errors(), while the
yielded write does not; a dead-connection signal surfacing from that write is
still mapped to BackendUnavailable (it is indistinguishable from a real drop).
Postconditions: backend attribute is set to "sftp" on all mapped errors.
Resource Management¶
SFTP-025: close()¶
Invariant: close() closes both the SFTP client and the underlying SSH transport.
Postconditions: Safe to call multiple times (idempotent). After close, further
operations will trigger a new connection via lazy init.
SFTP-026: unwrap(SFTPClient)¶
Invariant: unwrap(paramiko.SFTPClient) returns the underlying SFTP client.
Raises: CapabilityNotSupported for any other type hint.
Rationale: Escape hatch for users who need paramiko-specific features (per ADR-0003).
SFTP-027: Idempotent Close¶
Invariant: Calling close() multiple times must not raise. Internal state is
set to None after close, and the next operation will reconnect lazily.
SFTP-028: TOFU Host Key Persistence¶
Invariant: When host_key_policy is TRUST_ON_FIRST_USE and keys are resolved
from the file-based path (not from inline known_host_keys, config, or environment),
the backend persists newly accepted host keys to disk on disconnect.
Preconditions:
_resolved_host_keysisNone(no inline keys).- Policy is
TRUST_ON_FIRST_USE.
Postconditions:
- The known_hosts file (default
~/.ssh/known_hostsorhost_keys_path) and its parent directory are created if absent, with0o700directory /0o644file permissions (best-effort on Windows). load_host_keys(path)is always called so paramiko records the filename internally.save_host_keys(path)is called in_close_clients()before SSH client closure.- On reconnection, keys saved during the previous session are loaded back.
- Save failures are suppressed — they must not prevent connection teardown.
- Inline keys (
known_host_keysparameter, config dict, or env var) are never persisted to disk.
Concurrency¶
SFTP-029: Concurrent-Use Posture¶
Invariant: SFTPBackend is single_connection (the BE-028 non-default
posture): a single instance drives one paramiko SFTPClient over one SSH
channel, which is not safe for concurrent use. Concurrent operations on one
instance race on the shared channel and may interleave or corrupt protocol
state.
Remedy: Use one instance per thread, or drive it through
AsyncBackendSyncAdapter (which funnels concurrent callers onto a single
private loop and serializes them — ASYNC-089). A single_connection backend
wrapped by SyncBackendAdapter and driven with asyncio.gather is not safe
(ASYNC-094). This pins the caveat previously stated only in the class docstring
and docs-src/guides/async.md.
See also: 003-backend-adapter-contract.md (BE-028), 029-async-store-backend-api.md (ASYNC-094).