Skip to content

feat(remote): complete SFTP authentication and browsing - #233

Open
spandan11106 wants to merge 8 commits into
lgse:mainfrom
spandan11106:feat/63-sftp-support
Open

spandan11106 wants to merge 8 commits into
lgse:mainfrom
spandan11106:feat/63-sftp-support

Conversation

@spandan11106

@spandan11106 spandan11106 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Description

Completes first-class SFTP support on the URI-native GIO/GVfs foundation. SMB was the only remote protocol exercised end to end, and the custom gio::MountOperation handled password prompts only — so a backend trust decision such as an unrecognized or changed SSH host key was silently dropped, and SFTP inherited SMB's prompt shape and error text.

  • Host keys are now an explicit decision. ask-question is answered through a modal that renders the backend's own text (fingerprint included) and one button per offered choice. The declining choice is focused, so Enter never trusts a key; the backdrop is inert; Escape and the close button decline explicitly. A changed key is styled as a warning. gir cannot wrap the signal's GStrv, so it is connected from the raw signal.
  • Per-attempt state moved into MountAttempt. It decides whether a password request is replayed from already-supplied credentials or prompted, tracks the last attempt for the retry prompt, and turns a finished attempt into a single outcome. A declined host key now ends the attempt where a cancelled sign-in does, instead of reopening the credential prompt.
  • Prompts fit the scheme. SFTP loses SMB's domain and anonymous options, an encrypted key's request is labelled "Passphrase" rather than "Password", and the retry message names only the fields on screen.
  • Failures map to guidance. Missing backend, host not found, connection refused, timeout, unreachable host, permission denied, authentication failure and host-key rejection each get their own actionable message. Backend text reaching a dialog has URI user-info stripped first.
  • Backend hints stop naming one package as universal. Each scheme lists the usual candidates and points at the distribution.
  • Logging stays private. INFO records the backend and outcome only; the location goes to DEBUG already redacted of user-info, query and fragment.
  • Disposable OpenSSH fixture. scripts/sftp-fixture.sh runs sshd on 127.0.0.1 with host keys, client keys (plain and encrypted), authorized_keys and served files all inside one temporary directory that is deleted on exit. Nothing is written to ~/.ssh.

The log-capture helper in test_support also had to grow a second caller. main installs that capture with set_global_default, which can only happen once, so it moves back to a per-thread subscriber; a permissive discarding global subscriber keeps the callsite-interest flake that set_global_default was working around from returning.

Split into eight commits, each of which passes cargo fmt --check, cargo clippy -D warnings and the full test suite on its own.

Visual evidence

The SFTP credential prompt, reached by opening the fixture's address — username and password only, with no domain field and no anonymous option, unlike the SMB prompt:

Strata's SFTP sign-in dialog: Authentication required, with Username, Password and Password storage fields

Note what is not there: no Domain field and no "Connect as: Registered user / Anonymous" control. The SMB prompt still shows both, and before this change SFTP inherited them too.

How to test

  1. Start the disposable server: ./scripts/sftp-fixture.sh --port 2222. It prints the sftp:// address to open, the client key paths, and the encrypted key's passphrase.
  2. In Strata, press Ctrl+L and enter the printed address.
  3. Answer the host-key question if your GVfs backend raises one, then work through the manual matrix in docs/remote-sftp.md: key and passphrase authentication, a wrong secret, a cancelled prompt, a non-default port, breadcrumbs, history, Miller descent, hover peek, and opening a remote file.
  4. Stop the fixture and reconnect to see the refused-connection message; use a name that does not resolve to see the host-not-found message.

Expected result: the address browses once authentication succeeds; every trust decision is made by you and never assumed; wrong credentials reopen the prompt without flicker; cancelling returns to the previous location without adding history; and each failure explains what to do next.

The manual matrix is manual because the crate is a binary with no library target for integration tests, and mounting through GVfs needs a live session bus and would write to the developer's known_hosts. The decision logic it covers is unit-tested: prompt and question state transitions, cancellation, error mapping, message sanitization, and log privacy.

Related issue

Closes #63

The missing-backend message named gvfs-smb as though every distribution
packaged it that way, and knew nothing about the other schemes Strata
accepts. Distributions split GVfs up differently, so each scheme now lists
the usual candidates and points at the distribution rather than asserting
one universal package name.
The directory-logging privacy test installs its capturing subscriber with
`set_global_default`, which can only ever happen once, so a second module
asserting on what it logs would panic. Move the capture into `test_support`
and install it per thread again, which is what more than one caller needs.

Doing that alone would bring back the flake `set_global_default` was working
around: `tracing` caches a callsite's interest the first time it is reached,
and a callsite first reached while the current thread has no subscriber is
cached as uninteresting for every thread, so an unrelated test enumerating a
directory could silence the DEBUG event inside a capture running beside it.
Keeping a permissive discarding subscriber installed globally for the test
binary means no thread is ever without one, so callsites stay interesting and
each event is resolved against whichever subscriber its own thread has.
A failed mount reported the backend's own wording verbatim, which tells a
user nothing about what to do next and can carry a password that was typed
into the address bar.

Name resolution failures, refused connections, timeouts and unreachable
hosts now each explain what to check. Host-key rejection gets its own
message and, importantly, no longer counts as an authentication failure:
the two arrive as the same generic error, so a rejected key would otherwise
reopen the sign-in prompt for credentials that were never the problem. The
SFTP backend's own wordings for rejected credentials are recognized too, and
anything still falling through has URI user-info stripped before display.
Every sign-in prompt was shaped for SMB. When a mount failed before the
backend had asked for anything, the fallback offered a domain field and an
anonymous option regardless of scheme, defaulted the domain to WORKGROUP,
and the retry message told the user to check a domain that SFTP has no
concept of.

The fallback now offers the fields the scheme actually uses, the retry
message names only the fields on screen, and a request whose wording is
about a key passphrase is labelled and titled as one, since SFTP asks for an
encrypted key's passphrase through the same password request.
`mount_location` carried the state of an attempt in five separate cells and
handed four loosely related values to its callers, who each re-derived what
had happened from the raw error. Collect that state in `MountAttempt`, which
decides whether a password request is replayed or prompted and turns a
finished attempt into one `MountOutcome`, so both call sites read as a match
over what happened rather than a chain of error inspection.

No behaviour changes here beyond one gap this makes visible: a cancelled
navigation now restores the location text, which the descend path already
did and the navigate path had left showing the address that failed.
`GMountOperation` asks a question when the backend will not decide something
on its own, above all whether to trust an unrecognized or changed SSH host
key. Nothing was connected to that signal, so the decision was dropped and
the mount was left waiting on an answer that never came.

Answer it with a modal carrying the backend's own text, fingerprint
included, and one button per choice the backend offered. The declining
choice takes focus so Enter cannot trust a key, the backdrop is inert, and
Escape or the close button decline explicitly. A changed key is styled as a
warning, since it can mean interception rather than a reinstalled server.
Declining ends the attempt where a cancelled sign-in ends, rather than
looping back into another prompt.

gir cannot wrap the signal's GStrv argument, so it is connected from the raw
signal rather than through a generated binding.
Mounting was the one path with no logging at all, which left connection
problems with nothing to diagnose from. Record the backend and the outcome
at INFO and the location at DEBUG, following the redaction the directory
loader already uses: the default level names no host, path, username or
secret, and the diagnostic level logs a location already stripped of
user-info, query and fragment.
`scripts/sftp-fixture.sh` runs an OpenSSH server on 127.0.0.1 whose host key,
client keys, authorized_keys and served files all live in one temporary
directory that is deleted when it stops, so exercising SFTP writes nothing
to the developer's ~/.ssh.

docs/remote-sftp.md covers the addresses, authentication methods, host-key
handling and failure messages, and carries the manual matrix the fixture
supports. That matrix stays manual because the crate is a binary with no
library target for integration tests, and mounting through GVfs needs a live
session bus and would write to the developer's known_hosts; the decision
logic underneath it is unit-tested.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(remote): complete SFTP authentication and browsing

2 participants