feat(services/sftp): port from openssh to russh - #7977
Conversation
The openssh crate drives the system ssh binary over a Unix control socket, so the service could not build on Windows and required an ssh client on PATH. russh is a pure-Rust SSH implementation. - Host key checks move to russh's callback; strict, add, and accept keep their OpenSSH StrictHostKeyChecking semantics. - rename uses posix-rename@openssh.com, since SFTP v3 rename fails when the destination already exists. - Reads, writes, and copies pipeline packets sized to the limits@openssh.com values; listing streams readdir batches. - Streams hold a detached session reference rather than a pool slot, and handshakes are throttled to stay under sshd MaxStartups. Public API is unchanged. Refs apache#7968
The unix-only gates existed because openssh could not build on Windows. Java and Python drop the gate; dotnet keeps it for services-monoiofs, which is unrelated. Refs apache#7968
Follow-up to review on apache#7977. - Release open/opendir handles on drop. The old client closed them in OwnedHandle::drop; without it a ranged read or a truncated listing leaked a server-side handle per call. - Resync copy on a short read instead of treating it as EOF, matching the reader. A mid-file short read silently truncated the target. - Try rename before removing the destination. The pre-delete destroyed the target when the source was missing or unreadable. - Clamp read and write payloads against packet_len, which the server applies to the serialized packet, not the payload. - Reject unbracketed IPv6 endpoints; "::1" parsed as host ":" port 1. - Warn when a host key cannot be recorded, since add then behaves as accept for every later connection. - Cap agent identity attempts to stay under MaxAuthTries.
MAX_AGENT_IDENTITIES is only referenced from the cfg(unix) agent path, so the constant was dead code on Windows and failed the -D warnings build.
OpenSSH on Windows omits "." and ".." from readdir, so keying the directory's own entry off a "." record dropped it entirely there, and empty subdirectories vanished from recursive listings. Take the metadata from fstat on the open handle instead and ignore both dot entries.
A trailing separator carries no meaning in SFTP. OpenSSH on Windows rejects such a path outright rather than reporting that it does not exist, so stat on a missing directory surfaced as Unexpected instead of NotFound.
| /// in flight. | ||
| /// | ||
| /// SFTP has no server-side copy, so the payload has to make the round trip. | ||
| async fn stream_copy( |
There was a problem hiding this comment.
Don't need to simulate copy at backend side.
| /// | ||
| /// Accepts both `[user@]host[:port]` and `ssh://[user@]host[:port]`, and | ||
| /// defaults the port to 22. | ||
| fn parse_endpoint(endpoint: &str) -> Result<(Option<String>, String, u16)> { |
| conn: SftpSessionRef, | ||
| handle: String, | ||
| prefix: String, | ||
| buffer: VecDeque<(String, FileAttributes)>, |
| return Ok(Buffer::new()); | ||
| } | ||
|
|
||
| let results = futures::future::join_all(inflight).await; |
There was a problem hiding this comment.
Why we need to do this? Should this be covered by external concurrent logic?
| } | ||
|
|
||
| /// Validates the remote host key according to [`KnownHostsStrategy`]. | ||
| struct SshHandler { |
| let config = russh_sftp::client::Config { | ||
| // Every request is a single round trip, so allow slow servers more | ||
| // room than the crate default of 10 seconds. | ||
| request_timeout_secs: 60, |
There was a problem hiding this comment.
magical number :) Move to a constant.
| strategy: self.known_hosts_strategy, | ||
| }; | ||
|
|
||
| let mut handle = client::connect( |
There was a problem hiding this comment.
We could parse lots of config ahead of time in core.
| } | ||
| } | ||
|
|
||
| /// Resolves an OpenDAL path against the configured root. |
There was a problem hiding this comment.
should use path.rs in raw lib.
| } | ||
| } | ||
|
|
||
| /// A detachable reference to a live SFTP session. |
There was a problem hiding this comment.
I understand your intention is to keep reference to hold Handle so that you can receive session. But it's a bit hard to see how russh process or manage client/channel/handle lifetime and event from the documentation.
The documentation also explains more but we are building connections in readers, writers and listers.
|
@Xuanwo @erickguan this is draft right now, I've got something to fix |
Which issue does this PR close?
Closes #2963
Closes #3963
Refs #7968
Partially addresses #7391.
Rationale for this change
The SFTP service used the
opensshcrate, which drives the systemsshbinary over a Unix control socket. That made the service Unix-only and
required an
sshclient onPATH.russhis a pure-Rust SSH client, soneither constraint remains.
control_directory(#3963) only existed to place that control socket.russh has no control socket, so the option is obsolete rather than
implemented.
sftperrors (#7391) now carry the SFTP status code, soNotFound,PermissionDenied, andUnsupportedare distinguishable instead ofcollapsing into a generic failure. The 10s pool-acquisition timeout is
unchanged, so this only partially addresses that issue.
What changes are included in this PR?
openssh+openssh-sftp-clientwithrussh+russh-sftp.strict,add,and
acceptkeep their OpenSSHStrictHostKeyCheckingsemantics.posix-rename@openssh.comforrename, since SFTP v3renamefails when the destination exists.
services-sftpgates in the Java, Python, anddotnet bindings.
Are there any user-facing changes?
SFTP now builds and runs on Windows, and no longer needs an
sshbinary.No API changes: the
sftp://scheme,SftpConfig, andOperatorbehavior are unchanged. Password login (#2966) is still unsupported.
Two behavior differences worth noting:
~/.sshkeys when
keyis unset; previously the systemsshclient resolvedthose implicitly.
~/.ssh/configand/etc/ssh/ssh_known_hostsare no longer consulted.Only
~/.ssh/known_hostsis read. Setups relying onHostaliases,ProxyJump, or a system-wide known_hosts file must configure thebuilder explicitly.
Validation
Upstream behavior tests need 1Password-backed credentials, so this was
validated with a throwaway workflow in my fork that stands up its own
OpenSSH server on each runner
(workflow,
green run).
It is not part of this PR.
Also covered: the dockerised
atmoz/sftpsetup upstream CI uses, and thestrict/add/accepthost key strategies, which upstream neverexercises beyond
accept. Two consecutive green runs.Three defects surfaced only on Windows and are fixed here:
MAX_AGENT_IDENTITIESwas dead code off Unix and failed-D warnings..and..fromreaddir, so the listeddirectory itself was dropped and empty subdirectories disappeared from
recursive listings. The entry now comes from
fstaton the open handle.staton a missing directory intoUnexpectedinstead ofNotFound.AI Usage Statement
Written with OpenCode (Claude Opus 4.6). All changes were reviewed and
verified locally against the SFTP behavior tests.