Compare commits

...
Author SHA1 Message Date
laurensandClaude Opus 5 06369274dc Let the session address be selected, and copied whole
ci / build and test (pull_request) Successful in 2m49s
ci / android head (pull_request) Successful in 3m10s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 5s
The address at the head of the session column is the one place the desktop
head prints the endpoint it actually dialled, and it was a TextBlock — which
in Avalonia is a drawing of a string rather than a string. The next thing
people do with an endpoint is paste it somewhere: into a second client, into
a ticket, into the ssh command being written in the window beside this one.
Retyping is how a digit goes missing from an address that is mostly digits.

SelectableTextBlock, which is what the rest of this application already
reaches for wherever a string is on screen to be taken away — a fingerprint,
a public key, a recovery code, a snippet's command. Nothing else about the
row moves: same font, weight, colour and trimming, same tooltip, same close
button beside it. The sidebar is one control drawn on both surfaces, so SFTP
gets it for the same reason it gets everything else in that block.

The trimming and the selection do disagree at the long end, and the fix for
that is not to drop the trimming: the ellipsis is there because a long
account@host:port would otherwise push the close button off the edge of a
panel whose whole point is that it can be got rid of. A drag reaches only the
glyphs that were drawn, so a trimmed address has to be taken with select-all
— and select-all takes the whole thing, because the ellipsis is a drawing and
not a shorter value. That is the half a test can quietly lose, so it is the
half the new test asserts, on the longest address the suite has rather than
on one that fits and would pass either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 15:18:49 +02:00
jaap-jan cfc2e3ae0c Notice when a file session stops, and give the file browser the four things it was short of
ci / android head (push) Successful in 3m33s
ci / desktop nightly (push) Successful in 1m26s
ci / api image (push) Successful in 24s
ci / build and test (push) Successful in 3m22s
The commit before this one taught a terminal to notice its machine going away.
The file browser had the same defect and three of the same symptoms, and it is
also the screen people leave soonest — so the disconnect work and four things it
was missing arrive together, because they are the same complaint: this screen
knows less about the host than the host does.

◆ THE DISCONNECT HALF, WHICH IS THE OTHER SURFACE OF THE LAST COMMIT.

IsConnected was set at connect and cleared only by the DISCONNECT button. A host
that went away therefore left this screen saying CONNECTED over a session that
was gone, and every action after it failing with the same server message and
nothing saying why they all did.

IRemoteFileStore now carries a latched TransportFailure and a ConnectionLost
event. The latch is the shell's own — read ISshShellSession.TransportFailure for
the argument, which is unchanged. The event is not, and it is the part this
surface needs: a file browser is idle between operations, so without something
raised at the moment the transport fails, nobody finds out until they click. A
bucket implements both and raises neither, which is honest rather than a gap:
S3 is request-per-operation with no connection to lose.

  - THE LISTING STAYS ON SCREEN, and that is the half that makes the rest worth
    having. SftpEntry is a record of what the server said rather than a handle,
    so the pane can go on showing where the user was — which is what RECONNECT
    is offering to put them back into. That needed a new ShowsRemoteContents on
    both heads: every "is there anything here" binding read IsConnected, which
    was right while the only way to stop being connected was to press a button
    that empties the pane, and wrong the moment a session could be lost.

  - RECONNECT re-dials through the ordinary connect path — same host key gate,
    same refusals, same log entry — and then walks back to the directory the
    pane was showing. The path is read off RemotePath rather than captured,
    because the pane already keeps it up to date and a copy is a second thing to
    get wrong. A host wanting a typed password is asked for one rather than
    dialled with an empty box.

  - SessionEndedBar is now one control on both surfaces, with the shell
    resolving which it is drawn over: a terminal has two endings to tell apart
    and a keychain entry to check, a file session has one ending and already
    captured what it would dial. The phone's Files screen has its own copy,
    bound to TransfersViewModel directly, because its DataContext is that rather
    than the shell.

◆ PERMISSIONS: THE PERMS COLUMN STOPS BEING READ-ONLY.

It has been a true fact about a file with nothing to be done about it since it
arrived, and the commonest next act after using this screen was opening a
terminal — a whole second authenticated connection — to chmod what had just been
uploaded.

Nine checkboxes rather than a box to type 755 into, because the mode is already
drawn as -rwxr-xr-x in the column the form was opened from: a who-can-do-what
grid is the same representation the reader is holding, where an octal field asks
them to translate something they can see into something they cannot check. The
octal is printed underneath as it changes, so the two readings check each other.
The EXEC column is headed "enter" over a directory, which is what that bit means
there and the commonest misreading of a POSIX mode. The listing is re-read after
APPLY, because a umask, an ACL or a filesystem mounted without permissions can
all land somewhere other than what was asked for.

Beside it, mode and mtime carried across a transfer — which is what makes an
uploaded deploy.sh arrive executable instead of with the server's umask on it.
THE MODE IS ONLY SENT FROM A PLATFORM THAT HAS ONE. A file on Windows has no
POSIX mode; this screen already says so by leaving the local pane's PERMS column
empty rather than filling it with a plausible -rw-r--r--, and inventing 0644, or
0755 for anything called .sh, would be this client deciding a fact about
somebody's file that nothing on the machine it came from ever said. The
timestamp goes either way, because every platform has one. A store with neither
refuses in its own words and is never asked.

◆ MULTI-SELECT: A TICK IS NOT THE LIST'S SELECTION.

The two answer different questions and the split is the whole design. The
selection is "the row I am looking at", which rename, move and permissions act
on — each takes a form naming one path and is one-at-a-time by construction. The
tick is "the rows I have gathered up", which download, upload and delete take,
and it survives the highlight moving.

With nothing ticked the batch falls back to the selection, which is what keeps
the commonest use of this screen from growing a step — the usual way a
multi-select makes a tool worse. Ticks are released once a set is queued, or a
second press sends everything again.

Deleting several names every path rather than a count, because agreeing to "3
items" is agreeing to a number, and this is the one act on this screen that
nothing walks back. Each goes on its own and a refusal does not stop the rest:
the host turns down a directory that still has something in it, and in a set of
five that must not silently cancel the four it would have removed — nor hide
which ones it did not.

A drawn checkbox rather than ctrl-click, and not for taste: the phone shares
this view model and has neither ctrl nor shift, so a multi-selection has to be a
tap target there whatever the desktop does. One shape on both heads, and a state
somebody can see.

◆ RECURSIVE FOLDERS: THE TREE BECOMES FILES BEFORE THE QUEUE SEES IT.

A directory used to be counted and skipped. It is now walked, and the queue
still knows nothing about trees — which is what keeps every property it has:
each file resumable on its own, each written to a part file and renamed into
place, none able to overwrite anything already there.

The plan is collected whole and then queued, because directories have to exist
before the files that go in them and shortest-first is the only order that
guarantees a parent before its child. It also means a walk refused halfway has
queued nothing yet, so the sentence it produces describes one outcome rather
than an outcome and a queue that half happened.

Links are counted, not followed, in both directions. One pointing at an ancestor
is a walk with no end, and on the remote side there is a second reason: an lstat
listing says a name is a link and not what it leads to, so descending would be a
round trip per entry to find out. Locally that is AttributesToSkip on the one
enumeration that decides what to descend into. A 5000-file ceiling is a backstop
for the accidental drag of a home directory, and it says so rather than
truncating quietly.

The status line grew four more ways to be partly what was asked for, and states
each: gone, refused, left alone, not a file.

◆ EDITING IN PLACE: FETCH, OPEN, WATCH, PUSH BACK.

The round trip this replaces is download, find it, open it, edit, come back,
delete the old one because nothing here overwrites, upload, check the mode
survived.

DELIBERATELY NOT THROUGH THE TRANSFER QUEUE, in either direction. The queue
refuses to overwrite, which is the right rule for a file somebody dragged and
exactly the wrong one for a file they are editing, where replacing the original
is the entire intent. The write-back deletes first: OpenWriteAsync at offset
zero writes over the beginning and leaves any tail that is longer — its own
contract says so — which for a config that got shorter means a file ending in
the back half of its previous version. Deleting is the only truncation this
interface offers, and the mode goes back on afterwards because deleting took it.

A stat before every save, compared against what the server said when the copy
was taken, catches somebody else writing while the file is open — which is how a
config gets silently reverted. There is no merge and no button pretending
otherwise; the three answers offered are the three that exist. Length and
timestamp rather than a digest, because a digest is a second full read of a file
there is otherwise no reason to fetch.

Both platform halves are injected: what opens a .conf is a registry
association, a desktop entry or a Launch Services binding depending on the
desktop, and watching a file is a platform object with its own thread. The
watcher watches the directory and filters the name, because a great many editors
write a temporary file and rename it over the top — which to a watcher pointed
at an inode is the file disappearing. The phone gets neither delegate, so the
button is not live there rather than there and disappointing.

◆ TWO THINGS FOUND ON THE WAY, BOTH WORTH STATING.

The shell suite's inline `post` was not serialising the way a dispatcher does.
Routing TransfersViewModel through it exposed a check-then-add in
OnTransferChanged that two threads turn into two rows for one transfer. Harmless
in production, where the UI thread serialises — the fixture was under-specifying
what it stood in for, and now posts under a lock.

And the edit session replaced its own record with `with` on each save, which
left the watcher holding an instance that was no longer the current one: every
save after the first quietly did nothing. The stamp moves in place now, and the
test saves twice.

◆ WHAT IS VERIFIED.

901 across the five suites this touches, 19 of them new. The layout suite earned
its keep three times over: the batch deletion card's path list overflowed the
204-pixel pane at 120, at 72 and at 56 before 32 held, and the edit strip
overflowed by 131 until the whole strip — rather than the log inside it — was
bounded. Each number in those files is measured and says which failure it came
from, because the next person to add a line to either card will move them again.

What no suite covers is the platform halves of editing: Process.Start with
UseShellExecute and a live FileSystemWatcher are the operating system, and a test
waiting on one would be measuring it. The shell's own half — when a save is
written back, when it is refused, what stopping releases — is covered against a
watcher that fires on demand.
2026-08-24 11:48:23 +02:00
jaap-jan e2733e5cbc Notice when a shell ends, say which way it ended, and offer the way back
ci / build and test (push) Successful in 3m28s
ci / android head (push) Successful in 4m48s
ci / desktop nightly (push) Successful in 1m17s
ci / api image (push) Successful in 28s
A terminal whose machine rebooted stayed green for ever. Not "went stale and
recovered late" — for ever: the tab claimed CONNECTED, the elapsed timer went
on counting, and the only thing that ever corrected it was closing the
application. That is the defect this fixes, and it turned out to be two
separate failures wearing one symptom.

◆ THE HANG IS IN SSH.NET, AND KEEP-ALIVE ALONE WOULD NOT HAVE FIXED IT.

ShellStream ends a blocked read from exactly two places: its channel being
closed, and the session raising Disconnected. A shell that exits gives the
first. But Session raises Disconnected from one method only — OnDisconnectReceived,
for a server that politely says goodbye — and a socket that simply fails goes
down a different path entirely: RaiseError fires the error event, sets the
disconnecting flag and disposes the socket, without ever raising Disconnected.
So the stream was never disposed, its Monitor.Wait was never pulsed, and the
read blocked until the process ended. The transport was gone, the client knew
the transport was gone, and nothing woke the one thread that was waiting.

That is why the first attempt at a test for this sat at thirty seconds and
failed. Both halves are needed and neither is sufficient:

  - Keep-alive makes the failure HAPPEN. An idle SSH session is a socket
    nobody writes to, and TCP has no way to discover that the far end has gone
    without writing to it — nothing was asked, so nothing could fail. Thirty
    seconds, matching OpenSSH's own ServerAliveInterval examples. It is the
    write that matters and not a reply: SSH_MSG_IGNORE need not be answered,
    so this is traffic rather than a heartbeat with a deadline, and there is
    no timeout invented here to be wrong about a slow link.

  - Disposing the stream from the error handler makes it ARRIVE. Queued to the
    thread pool and not done inline, which is not tidiness: this runs on
    SSH.NET's message-listener thread, and closing a channel waits for the
    server's close — processed by that same thread — so an inline dispose is
    the listener thread waiting on itself.

SFTP gets the keep-alive too. Its operations are request/response and bounded
by ConnectionInfo.Timeout already, so it needs no equivalent wake-up.

◆ exit AND reboot ARE TOLD APART BY A LATCH, BECAUSE THE OBVIOUS WAY IS A RACE.

Wait for the read to end and then ask whether the transport is still up, and
against a real server the answer is wrong about half the time: this was the
only channel on the connection, so sshd hangs up a millisecond after closing
it. By the time anything could ask, the transport is gone either way — and a
poll would report every ordinary `exit` as a dropped connection.

So ISshShellSession.TransportFailure is set at the moment SSH.NET raises the
failure, which its own ordering puts strictly before the disconnect that wakes
the read. A read that has just returned zero consults a settled value: set if
the transport failed, still null if the remote merely closed the channel. The
pump turns that into a TerminalSessionOutcome — RemoteClosed, ConnectionLost,
ClosedLocally — carried on SessionEnded, which used to hand over a session id
and nothing else. A subscriber cannot work this out afterwards; by the time it
hears, there is no transport left to interrogate.

Deliberately no fourth value for "the remote rebooted". Nothing on the wire
distinguishes a reboot from a cable being pulled, and a name this client
cannot check would be a guess dressed as a fact.

◆ WHAT IS DRAWN, AND WHY IT IS NOT DRAWN OVER THE PANE.

The status bar says DISCONNECTED or SHELL EXITED where it used to say NOT
CONNECTED for both — a word that is honest about a tab still dialling and
useless about one whose shell has stopped. Red for the first and grey for the
second: one is a machine that stopped answering and the other is work
finished, and crying wolf on the ordinary end of every session is its own bug.
The tab strip's dot gains the same fourth state, on both heads.

Under the terminal, a strip carrying the reason and two buttons. It takes its
own Auto grid row rather than overlaying the renderer, and that is forced
rather than chosen: the WebView is a native child view composited above
everything Avalonia draws, so the alternative is collapsing the terminal — and
the scrollback is precisely what the user wants to read, because the last
thing the remote said is usually why it went. terminal.js already writes the
same sentence into the pane; this is the copy that does not scroll away.

RECONNECT reuses the tab in place, which is what the word means to somebody
who pressed it: the terminal they were working in comes back, where it was.
It dials through the vault's ordinary connect path, so every refusal — a key
that has left the keychain, an unknown host key, a group default that has
changed since — is decided in the one place that already decides them; what
this adds is which tab the attempt belongs to. The old session is closed as
part of it, because a dead shell still holds an authenticated connection this
process has stopped reading, and its pane still holds an xterm, a scrollback
and a WebGL context, of which a browser hands out about sixteen.

Two refusals it makes rather than pretending: a host reached by typing an
address has no keychain entry to redial, and one that wants a password typed
goes to the keychain with the sheet raised rather than being dialled with an
empty box. Both say so on the strip where the button was pressed, not only in
the vault's status on a screen that surface is not.

TRY AGAIN appears on a refused connection for the same reason, and it is worth
as much: the commonest refusal is a host that was not up yet.

◆ WHAT IS ACTUALLY VERIFIED.

Two tests against a real sshd in a container. `exit` is reported as the remote
closing the session — the one a polling implementation fails, and a fake shell
cannot show it because a fake has no server behind it deciding to hang up. And
a live shell losing its transport is reported as a lost connection, produced
by dialling through a plain TCP relay the test owns and then cutting it; the
container is shared by the collection, so killing sshd was never an option.

The first draft of that second test used LoopbackProxyTests' SOCKS forward and
timed out: disconnecting the carrier of a ForwardedPortDynamic does not close
the loopback socket it accepted. Recorded in the test, because it is a
property of SSH.NET's forwarder rather than of anything here, and it is not
what a dying machine looks like.

What no test reproduces is the waiting. A relay that closes sends a FIN, so
the socket reports itself at once; a machine switched off sends nothing, and
finding that out is the keep-alive's business and the OS's retransmit budget's
— neither of which a test can hurry along without a clock this code does not
own. A reboot is prompt in practice because the machine comes back and RSTs.

Beside those: three pump outcomes, three workspace announcements, and seven
shell-flow tests covering the drop, the clean exit, reuse of the tab, retry
after a refusal, and both ways a reconnect can honestly come to nothing.

Also fixed in passing: SshNetShellSession.IsOpen read ShellStream.CanRead,
which is documented as always true — the stream stays readable after disposal
so buffered output can be drained — so it had answered "yes" for every shell
that ever existed. It reads CanWrite now. And MainWindowViewModel's session-
ended handler moved from Dispatcher.UIThread.Post to the injected post
delegate, which is what makes any of the shell half testable at all; the
remark listing the three unreachable posts is one shorter.
2026-08-24 10:36:15 +02:00
jaap-jan c2c860b4f2 Give the seated user the TPM, so nobody has to join the tss group
ci / build and test (push) Successful in 3m0s
ci / android head (push) Successful in 3m24s
ci / api image (push) Successful in 24s
ci / desktop nightly (push) Successful in 1m4s
ADR 0007 decision 4 refused this a commit ago, on the grounds that widening
access to the machine's TPM is a system-wide decision an application package
should not make for an administrator. That refusal is overturned here, and the
reasoning it is replaced with is narrower rather than louder — because the
refusal had treated "widen access" as one thing when it is two.

Adding a person to `tss` is what the refusal was really about, and it stays the
wrong answer: permanent, effective over SSH, applying whether or not they are at
the keyboard, and needing an administrator and a fresh login before the feature
appears at all.

Tagging /dev/tpmrm0 with `uaccess` is a different thing. It is systemd's own
mechanism for this exact question — the one /dev/dri, webcams, scanners and FIDO
keys already use — and logind grants the ACL to the user of the *active local
session*, then takes it back when that session ends. Not over SSH, not on an
inactive VT, not to daemons. The rule changes neither the mode nor the group, so
everything that worked before still works and the ACL is layered on top.

tpmrm and never tpm, which is what makes it defensible at all: /dev/tpm0 is the
raw device and is exclusive, where /dev/tpmrm0 is the kernel's resource manager,
which virtualises handles and isolates callers precisely so that more than one
unprivileged program can use the chip.

◆ THE COST IS A DENIAL OF SERVICE AND NOT A DISCLOSURE, AND IT IS WRITTEN DOWN.

TPM objects are protected by their own auth values, so this lets nobody read
anybody else's sealed data. What it allows is *failed* attempts — and a TPM
answers enough of those by locking out everyone, which is the same anti-hammering
this project relies on to make a four-digit PIN safe. On a machine that unlocks
its disk with the TPM at boot, somebody sitting at it can trip that and stop the
next boot from unlocking until it recovers.

An administrator who does not accept that masks the file:

    sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules

which is the standard override, survives upgrades, and puts DodoSSH back to
behaving as though the machine had no TPM. That is in the rule's own comments,
because the person who needs it is reading the file rather than the ADR.

Numbered 71 because 60-tpm-udev.rules is what creates the node's ownership and
73-seat-late.rules is what turns the tag into an ACL; verified in that order on
Fedora 44, along with `udevadm verify` accepting the file and the mask being
reported as "masked by previous entry". %post reloads and replays it against the
tpmrm subsystem, because a rule that waits for a reboot is a feature that does
not work on the day it is installed.

Both channels install the same path deliberately: this is about the machine's
TPM rather than about either build, the file is byte-identical from both, and
giving each its own copy would mean removing one package silently revoked the
other's access.

Phase 19 grew a check that the ACL exists for the local session and does *not*
exist for an SSH one, and another that masking the rule really does put the
machine back — then tells you to undo it before the checks that follow.

Also recorded, after it cost five confusing failures: swtpm serves exactly one
client, so a shell readiness probe sitting on the port makes the whole device-key
suite fail with "The TPM closed the connection" and nothing pointing at why.
2026-08-22 20:38:22 +02:00
jaap-jan 4552538533 Let the Linux head check for updates, and seal its device key in the TPM
ci / build and test (push) Successful in 2m55s
ci / android head (push) Successful in 3m28s
ci / desktop nightly (push) Successful in 1m10s
ci / api image (push) Successful in 49s
Two things the RPM shipped without, and the reason they arrive together is
that both are the same question — what may this platform ask of the machine,
and what must it not ask of the user.

◆ UPDATES: THIS CLIENT CHECKS AND FETCHES, AND PACKAGEKIT INSTALLS.

ADR 0013 rule 11 said there was no updater at all, and that went too far. It
conflated noticing that a newer build exists with installing it: the second is
what the trust argument is about, and the first is not — a client that checks
a fixed address and tells you what it found has taken no capability from
anybody. Written as a flat refusal, the rule bought its safety by paying a cost
that was avoidable, and the ADR now says so in its own words.

So LinuxUpdateChannel reads the rolling nightly release, compares versions the
way rpm compares them, downloads the package, verifies its digest and hands the
file to PackageKit over gdbus. Polkit asks; the user may say no; nothing is
torn down before the answer, because ApplyingEndsTheProcess is false and the
shell already knows what that means — it is the phone's shape, and declining an
update must not cost somebody their open shells.

Three details that are load-bearing rather than incidental:

  - The comparison is rpmvercmp and not SemVer, because rpm is what will accept
    or refuse the install. Same argument ADR 0014 makes for versionCode. The
    installed side is read out of the rpm database rather than derived from the
    assembly, so the SemVer-to-rpm translation exists in exactly one place. All
    28 expected values in RpmVersionTests came out of rpm itself rather than out
    of a reading of the algorithm.

  - The digest is checked and is not a signature. Velopack verifies its own
    downloads and Android checks a signature; an RPM over HTTPS has neither. The
    manifest names a SHA-256 and a mismatch deletes the file rather than leaving
    something installable-by-hand in the profile directory.

  - ONLY_TRUSTED is cleared, which is what routes the request to polkit's
    package-install-untrusted action. The package is unsigned, so requiring
    trust would fail every update at the last step.

◆ THE DEVICE KEY: SEALED IN THE TPM, BEHIND A PIN THE TPM CHECKS.

ADR 0007's amendment is the bar: a gate inside the process is not a gate. The
key is sealed into a TPM 2.0 keyedhash object whose userAuth comes from a PIN,
so unsealing is a command the chip refuses; the blob is encrypted to the storage
hierarchy and is useless on any other machine; nothing here ever compares a PIN.

The sealed object deliberately carries no NoDA attribute, and that one omission
is the whole reason four digits is enough: every wrong answer advances the TPM's
dictionary-attack counter and the chip stops answering entirely once it trips.
Measured at four wrong tries against a software TPM, after which the correct PIN
gets nothing either. It is asserted by a test rather than left to a reading of
the template, because removing the attribute leaves every other assertion
passing.

This is weaker than Hello and Touch ID and the difference is invisible, so it is
written down in three places rather than smoothed over: those raise their prompt
from inside the platform and this one is a window we draw. The prompt is
deliberately plain for that reason — teaching people that a convincing DodoSSH
PIN box is a system dialog is the one thing this design must not do.

Rejected, each for failing the amendment: the Secret Service API, which has no
per-use consent and is DPAPI's shape exactly; fprintd, which answers a question
and holds nothing, so skipping the call skips the gate; and a polkit-gated root
helper, which would work and puts a privileged component in a package that
installs nothing setuid.

Availability is narrower than "has a TPM": on Fedora /dev/tpmrm0 is root:tss and
desktop users are not in tss, so the ordinary case reports unavailable and keeps
asking for the passphrase. Shipping a udev rule to widen that is refused — it is
the administrator's decision, not an application package's.

◆ WHAT IS ACTUALLY VERIFIED, SINCE THAT IS THE PART THAT USUALLY ISN'T.

This is the only device key store in the repository with real coverage, and it
is only possible because the PIN is a delegate and the TPM is a device: seal,
unseal, wrong PIN, lockout, decline and a foreign blob all run unattended
against a software TPM. Windows and macOS cannot be tested at all — their
prompts block a suite forever, which WindowsDeviceKeyStoreTests records.

TSS.Net's own TcpTpmDevice cannot reach swtpm: it opens the platform socket too
and exchanges a handshake only the Microsoft reference simulator answers, so
Connect() blocks with no error and no timeout. SoftwareTpm speaks the data
channel and nothing else.

The suite also lied once and was fixed: the lockout test trips shared state, and
without a DictionaryAttackLockReset in Dispose every later test got null from a
correct PIN — six of them assert null and passed for entirely the wrong reason.

End to end on a real Fedora 44: a genuine linux-x64 publish packages, installs,
and every DT_NEEDED in the installed tree resolves except liblttng-ust, which is
the one this spec deliberately excludes. What no runner covers is /dev/tpmrm0,
PackageKit and polkit, and Phase 19 grew five checks for exactly those.

Also fixed: the magic-bytes literal picked up raw control characters, which made
LinuxDeviceKeyStore.cs binary to git — no diffs and no line-ending normalisation.
2026-08-22 20:02:30 +02:00
jaap-jan 1bf79c7c26 Package the Linux desktop head as an RPM, and publish a nightly one
ci / build and test (push) Successful in 3m1s
ci / desktop nightly (push) Successful in 1m7s
ci / api image (push) Successful in 22s
ci / android head (push) Successful in 3m39s
The third desktop platform shipped as nothing: a checkout somebody built.
VelopackUpdateChannel already named the two platforms it serves rather than
negating the one it does not, so that a Linux package would be an addition
there rather than something that had silently half-happened. This is it, and
it is deliberately not a Velopack channel.

There is no updater in the package at all. On this platform dnf is already an
updater the user trusts, audits and controls, and a second one answering to
this project instead is the capability ADR 0013 rule 4 spends a page keeping
unsettable. The cost is real and is written down rather than glossed: nothing
tells a Linux user that a newer build exists, because there is no repository
to tell them.

Rule 3 is untouched. The build job packages the release-shaped RPM on every
main build and every tag and uploads nothing, exactly as it does for Windows
and macOS; the desktop job packages the nightly-shaped one and puts it on the
rolling nightly-desktop release beside the Windows feed. Two RPMs with
different names owning different paths cannot replace one another, so rule 9's
separations come almost free here — the profile split still belongs to the
build, through DodoChannel.

vpk verifies that what it packs actually calls VelopackApp.Build().Run(), and
nothing here does that for us, so scripts/ci-linux-rpm.sh makes its own
assertions: it asks the finished package what it requires, what it provides,
what it holds and what modes those carry. Two of those checks exist because
the first realistic payload tripped them.

  - .NET's libmscordbi.so links libmscordaccore.so. With bundled provides
    excluded — which they must be, or this package would advertise libcoreclr
    to the whole system — that produced a dependency no host on earth can
    satisfy. The requires exclusion is computed from the payload rather than
    listed, because a list of Microsoft's internal file names goes stale in
    the direction that yields an uninstallable package.

  - cp -a inherits the publish directory's modes, and off the wrong filesystem
    that meant a world-writable /opt/DodoSSH: any local account able to
    replace the SSH client every other account runs. For an application
    holding a team's credentials in plaintext that is the trust model walked
    around rather than attacked. Every mode is now set absolutely.

Verified as far as a runner can: both packages install side by side on Fedora
44, resolve webkit2gtk4.1 2.52.5 — the backend MainWindow records as measured
— keep separate launchers, icons and profiles, and dnf remove takes one tree
and leaves the other. 0.0.1~alpha.0.143 sorts below both 0.0.1 and the next
height. What no runner can answer is Phase 19, which is new and unwalked.

RHEL 9 is deliberately not claimed. It ships WebKitGTK's 4.0 line under
libwebkit2gtk-4.0.so.37, and an or-branch for it was written and then removed:
nobody has run the head there, and dnf refusing with "nothing provides
libwebkit2gtk-4.1.so.0" is a better answer than an install that draws a blank
terminal. Same sentence, and the same reason, as the missing Intel Mac.

x86_64 only, for that reason and not a different one.
2026-08-22 16:43:38 +02:00
jaap-jan 1970e20e56 Merge pull request 'Type a path on the SFTP pane, and put a file right without deleting it' (#15) from claude/dodossh-sftp-improvements-3b6305 into main
ci / desktop nightly (push) Successful in 48s
ci / build and test (push) Successful in 2m29s
ci / android head (push) Successful in 3m15s
ci / api image (push) Successful in 32s
Reviewed-on: #15
2026-08-21 12:03:50 +00:00
67 changed files with 9410 additions and 281 deletions

No files matched your search

+7 -1
View File
@@ -20,8 +20,14 @@
*.sql text
# Files that must keep LF regardless of platform — shell scripts run in Linux
# containers, and Dockerfiles are read by a Linux daemon.
# containers, Dockerfiles are read by a Linux daemon, and an RPM spec is a shell
# script in disguise: rpmbuild feeds its %install section to /bin/sh, where a
# carriage return at the end of a line is an argument rather than nothing. A udev
# rule is parsed by udev's own lexer, which treats a stray carriage return as part
# of the value it is reading.
*.sh text eol=lf
*.spec text eol=lf
*.rules text eol=lf
Dockerfile text eol=lf
*.Dockerfile text eol=lf
Caddyfile text eol=lf
+142 -1
View File
@@ -462,6 +462,55 @@ jobs:
echo "Bundled DodoSSH ${{ steps.winpack.outputs.packVersion }} for osx-arm64."
# ◆ AND THE THIRD DESKTOP PLATFORM, WHICH IS THE ONE THIS RUNNER ACTUALLY IS.
#
# Same argument as the two steps above and with none of their cross-compiling: this job runs on
# Linux, so the linux-x64 publish here is a native one. What it proves that the others cannot is
# the linux-x64 restore graph — libsodium, SkiaSharp and HarfBuzzSharp all ship native assets per
# RID, and a package that resolves for win-x64 and has no linux-x64 asset fails here.
#
# The runner is still Alpine and the package targets glibc distributions, which is fine and is not
# glossed over: scripts/ci-linux-rpm.sh explains why rpmbuild composing an RPM for one libc on a
# host running another is correct rather than lucky.
#
# ◆ IT IS ALSO WHERE THE PACKAGING ITSELF IS PROVED, WHICH ON THIS PLATFORM IS NOT A FORMALITY.
#
# The Windows and macOS steps hand a directory to vpk and vpk decides what the package needs.
# There is no vpk here — Velopack's Linux target is an AppImage and this is a distribution
# package, updated by dnf rather than by the application — so the dependency metadata is
# generated by rpm from the published tree, and scripts/ci-linux-rpm.sh interrogates the result
# rather than trusting an exit code. Every check in there is a way a package can be built
# successfully and then refuse to install on every host in the world; one of them was written
# because .NET's own libmscordbi.so links libmscordaccore.so, which is exactly that.
#
# Release-shaped rather than nightly-shaped — package name dodossh, tree at /opt/DodoSSH — so
# that what is proved on every main build is the packaging the release channel would use. The
# desktop job below builds the nightly-shaped one and is the only job that publishes either.
#
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
#
# RUNNER_TEMP, dying with the job. An RPM is if anything the easiest of the three to pass on
# casually — one file, no installer to click through, `dnf install` and done — which is a reason
# to keep it off a downloads page rather than a reason to put it on one. ADR 0013 rule 3.
- name: publish and package the linux desktop client
if: github.event_name != 'pull_request'
run: |
set -euo pipefail
# RestoreLockedMode=false for the RID, exactly as the two publishes above do — see the long
# note on the win-x64 one. This checkout is thrown away, so the lock files it rewrites go
# nowhere.
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
-p:RestoreLockedMode=false \
--output "$RUNNER_TEMP/linux-x64"
scripts/ci-linux-rpm.sh \
--payload "$RUNNER_TEMP/linux-x64" \
--version '${{ steps.winpack.outputs.packVersion }}' \
--channel release \
--out "$RUNNER_TEMP/linux-rpm"
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
# server through Testcontainers and runs the API as a child process — so it needs a
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
@@ -1048,6 +1097,45 @@ jobs:
ls -la "$RUNNER_TEMP/nightly-releases"
# ◆ AND THE LINUX NIGHTLY, WHICH IS A PACKAGE RATHER THAN A FEED.
#
# Everything above this step is Velopack: an installer, an index file and a client that polls it.
# This one is an RPM, and the difference is not a gap in the work. VelopackUpdateChannel refuses
# every platform that is not Windows or macOS — deliberately, and its comment says why — so a
# Linux build has no self-updater to give a channel to. What it has instead is dnf, which is a
# mechanism the user already trusts and already controls, and build/linux/dodossh.spec argues
# that at length.
#
# So there is no rolling *feed* here, only a rolling *file*: the newest nightly RPM sits on the
# same release as the Windows nightly and somebody who wants it downloads it. That is a worse
# deal than the Windows nightly gets and it is the honest one — the alternative is a package
# repository, which is a deployment this project does not have.
#
# DodoChannel=nightly for the same reason the win-x64 publish above sets it: it is what
# ClientPaths reads to suffix the profile directory, so the nightly RPM and a release RPM
# installed beside it share no cache, no outbox and no device key. The package name, the tree
# under /opt and the desktop entry are the packaging half of that separation and are the
# script's `--channel nightly` — see the note there for why it is four things and not one.
- name: publish and package the linux nightly
id: linuxpack
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
-p:RestoreLockedMode=false \
-p:DodoChannel=nightly \
-p:MinVerVersionOverride="$VERSION" \
--output "$RUNNER_TEMP/nightly-linux-x64"
scripts/ci-linux-rpm.sh \
--payload "$RUNNER_TEMP/nightly-linux-x64" \
--version "$VERSION" \
--channel nightly \
--out "$RUNNER_TEMP/nightly-rpm"
# ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS.
#
# Whoever can write a release here can put a build on every nightly desktop, because Velopack fetches
@@ -1145,7 +1233,60 @@ jobs:
fi
done
echo "Published nightly desktop $VERSION as $TAG"
# ◆ THE RPM GOES ONTO THE RELEASE vpk JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
#
# One rolling release carrying both heads, because they are one nightly: the same commit,
# the same version, and one page a person looks at to find out what is on main. A second tag
# would double the delete-and-recreate dance this step already does, and would let the two
# drift apart on a run where one of them failed.
#
# It has to happen here, after `vpk upload`, and not in a step before it. vpk deletes and
# recreates this release, so an RPM uploaded first would be attached to something that no
# longer exists by the time the job ends — silently, because the delete succeeds.
#
# curl rather than vpk, unlike the assets above, for the reason the android job gives about
# its APK: this is one file the release simply carries. There is no index to write and no
# format that belongs to a tool.
release="$(printf '%s' "$published" | release_id)"
if [ -z "$release" ]; then
echo "The release was published and its id could not be read back:" >&2
printf '%s\n' "$published" >&2
exit 1
fi
# ◆ THE PACKAGE FIRST AND THE MANIFEST LAST, WHICH IS THE ORDER THE CLIENT DEPENDS ON.
#
# LinuxUpdateChannel reads linux-<channel>.json and then fetches what it names, exactly as the
# phone reads android-nightly.json. A manifest published before its package is a window in
# which every installed client is told to download something that is not there yet — and the
# client's answer to a missing asset is to throw, which a pressed CHECK NOW reports as a
# failure. Same rule, same reason, and the android job's comment says it first.
rpm='${{ steps.linuxpack.outputs.rpm }}'
manifest='${{ steps.linuxpack.outputs.manifest }}'
linuxNames=""
for file in "$rpm" "$manifest"; do
name="$(basename "$file")"
echo "Uploading $name ($(wc -c < "$file") bytes)"
curl -fsS -X POST -H "$auth" \
-F "attachment=@$file" \
"$api/releases/$release/assets?name=$name" >/dev/null
linuxNames="$linuxNames $name"
done
# Asked for back, exactly as the Velopack assets above are and for the same history: the
# android job once created a release, uploaded nothing, and reported success for every
# upload it never made.
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
for name in $linuxNames; do
if ! printf '%s' "$withRpm" | grep -qF "\"name\":\"$name\""; then
echo "The release was created but $name is not on it:" >&2
printf '%s\n' "$withRpm" >&2
exit 1
fi
done
echo "Published nightly desktop $VERSION as $TAG, with$linuxNames"
image:
name: api image
+22
View File
@@ -173,6 +173,28 @@
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
-->
<PackageVersion Include="Velopack" Version="1.2.0" />
<!--
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
Windows needs nothing here: CNG is in the framework and the Platform Crypto Provider is the TPM
behind it, and macOS reaches the Secure Enclave through the Security framework that
MacSymbols.cs binds by hand. Linux has neither — the platform's own stack is tpm2-tss, a C
library with a large surface — so this is the one head that needs a package to talk to its own
hardware.
Chosen over P/Invoking libtss2-esys for two reasons that both hold at the same time. It is
managed all the way down, so there is no native dependency to add to the RPM and nothing to
resolve at load time; and it works against a software TPM over a socket exactly as it does
against /dev/tpmrm0, which is what makes LinuxDeviceKeyStoreTests able to run the whole seal,
unseal and lockout path unattended. Neither of the other two device key stores can be tested at
all — see WindowsDeviceKeyStoreTests for the ten minutes that took to find out.
It carries a runtimes/linux-x64 asset, like SkiaSharp and libsodium, so the RID-specific publish
picks it up and the RID-free locked restore does not. See the RuntimeIdentifiers note in
DodoSSH.Client.App.csproj for why that distinction is load-bearing here.
-->
<PackageVersion Include="Microsoft.TSS" Version="2.1.1" />
</ItemGroup>
<ItemGroup Label="Versioning">
<!--
+107 -4
View File
@@ -71,7 +71,7 @@ src/
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
DodoSSH.Client.Import reading ~/.ssh/config, and the keys it names when asked
DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
DodoSSH.Client.App the desktop head: its views, and its Windows integration
DodoSSH.Client.App the desktop head: its views, and its Windows, macOS and Linux integration
DodoSSH.Client.Android the phone head: its views, and its Android integration
tests/ one test project per source project
docs/adr/ architecture decision records
@@ -81,7 +81,9 @@ docs/manual-checks.md what no test can reach, and what to look for when che
docs/android-port.md the Android head: what was decided, what is built, what is left
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
release-macos.sh — the same, signed and notarized, on a Mac
ci-linux-rpm.sh — builds the Linux .rpm, then interrogates it
build/macos/ the entitlements and Info.plist template the macOS bundle is built from
build/linux/ the RPM spec the Linux package is built from
```
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
@@ -177,6 +179,74 @@ passphrase screen, always. The application lives at `/Applications/DodoSSH.Deskt
at `~/Library/Application Support/DodoSSH`, which are deliberately two different places so that removing the
first never touches the second.
## Installing on Linux
An `.rpm` for x86_64, and the one platform where you get it from `main` rather than from a release — see
[the nightly build](#the-nightly-desktop-build) below, which is where the Linux package is published today.
```bash
sudo dnf install ./dodossh-nightly-*.x86_64.rpm
```
Everything above about where a client may come from applies unchanged. Four things differ, and the first
two are the ones to read before installing.
**It checks for updates and it installs nothing.** The Windows and macOS builds carry Velopack and replace
their own files; this one never does. It checks the project's forge every six hours, and when there is a
newer build it downloads it, checks its digest, and hands the file to **PackageKit** — which raises the
usual "authentication is required to install software" prompt. You may say no, and nothing is replaced until
you say yes. Nothing restarts either: replacing files under a running process is ordinary here, so the build
you have keeps running until you close it.
That split is the point rather than a compromise. Installing software is your package manager's job and it
already answers to you; noticing that a build exists is the part `dnf` cannot do for a package that is not
in a repository. What DodoSSH must never become is a second thing that can put software on your machine on
somebody else's say-so, which is why the address it checks is a constant in the binary and can never be a
DodoSSH server — [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) rules 4 and 11.
The gap that remains: there is **no package repository**, so `dnf upgrade` alone will never bring you a
newer DodoSSH. If you turn the check off on PREFERENCES → UPDATES, nothing else will tell you.
**Your PIN can stand in for your passphrase, if this machine has a TPM you can reach.** The device key is
sealed into the TPM and released only against a PIN that **the TPM itself checks** — it counts wrong answers
and stops responding after a few, which is what makes four digits enough and what makes a stolen disk
useless. Two honest caveats, both in
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4:
- **It is weaker than Windows Hello or Touch ID**, and the difference does not show. Those draw their prompt
from inside the operating system; this one is a window DodoSSH draws, so something already running as you
could imitate it. It could imitate the passphrase screen too, which is worth far more — but the guarantee
is not the same and this page will not pretend it is.
- **The package grants your session access to the TPM, and you should know what that means.** Fedora leaves
`/dev/tpmrm0` readable only by the `tss` group, so this would otherwise need an administrator and a fresh
login. Instead the package installs a udev rule tagging the device `uaccess` — the same mechanism your
webcam and your FIDO key already use, which grants an ACL to **whoever is logged in at the machine** and
takes it away when they log out. Not over SSH, not to a user switched away on another VT, not to daemons.
The cost is not secrecy — TPM objects are protected by their own PINs — it is that anyone at the keyboard
can make failed attempts, and a TPM answers enough of those by locking out *everyone*. **If this machine
unlocks its disk with the TPM at boot**, that means somebody sitting at it could stop the next boot from
unlocking until the lockout recovers. If you would rather not have that, one command puts it back:
```bash
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
```
DodoSSH then behaves as though the machine had no TPM: no PIN offered, passphrase every time.
See [docs/unlocking-without-the-passphrase.md](docs/unlocking-without-the-passphrase.md).
**It needs WebKitGTK, because the terminal is a WebView.** Specifically `webkit2gtk4.1`, which `dnf` pulls
in for you. Fedora 44 is the distribution the head has actually been run on. RHEL 9 and its rebuilds ship
the older 4.0 API line under a different library name and the package deliberately does not claim them —
`dnf` will refuse with *nothing provides libwebkit2gtk-4.1.so.0*, which is a better answer than installing
and drawing a blank terminal. This project does not ship desktop builds nobody has run, on any platform.
**Where things go.** The application at `/opt/DodoSSH`, launched as `dodossh` or from your menu; your vault
cache at `$XDG_DATA_HOME/dodossh`, usually `~/.local/share/dodossh`. Two different places on purpose:
`dnf remove` takes the first and never touches the second, so reinstalling asks for your passphrase rather
than starting over. Use **Sign out** inside the application to make the machine genuinely forget.
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine, and
`scripts/release-macos.sh` on a Mac. Deliberately not a CI job; ADR 0013 decision 3 explains why, and it is
not only that the runners are Linux.
@@ -189,6 +259,18 @@ Nightly*, its own install directory, and its own profile at `%LOCALAPPDATA%\Dodo
costs nothing you are relying on. It signs in separately and appears to your account as a new device,
because it genuinely is a second installation.
**The Linux `.rpm` is on the same release**, built from the same commit and versioned the same way, as
`dodossh-nightly`. It installs beside a release package for the same reasons and by the same four
separations — a different package name, a tree at `/opt/DodoSSH.Nightly`, a `dodossh-nightly` launcher with
its own menu entry, and a profile at `~/.local/share/dodossh.nightly`. Two RPMs owning different paths
cannot replace one another, so `dnf` will keep both rather than treating one as an upgrade of the other.
It is the only Linux package this project publishes at all today: there is no `v*`-tagged Linux release
yet, because no person has walked a Linux build through
[docs/manual-checks.md](docs/manual-checks.md). What CI proves on every push to `main` is that the release
package still *builds* — the same arrangement Windows and macOS have had since the beginning, where the
package is built on every commit and uploaded nowhere.
**Installing it means trusting everyone who can write a release on this repository, including CI.** The
updater applies what the feed serves without verifying a signature, so anyone who can change a workflow
file here can put a build on every nightly machine. That is an acceptable trade for a build you are trying
@@ -920,10 +1002,31 @@ keychain plus a terminal — and the spike that gates all of it.
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and
[Installing on macOS](#installing-on-macos).
**The Linux half checks, and the package manager installs.** An `.rpm` for x86_64, built and checked on
every push to `main` and published to the nightly release, installing to `/opt` with a menu entry and a
`dodossh` launcher. It carries no Velopack and replaces nothing itself: it notices a newer build on the
project's forge, downloads it, verifies its digest and hands the file to PackageKit, which asks before it
installs. Splitting it that way keeps the part that can put software on your machine where it already
was — with your package manager — while fixing the half `dnf` cannot do for a package that is in no
repository. The device key is sealed into the TPM behind a PIN **the TPM checks and counts failures
against**, which is what makes four digits enough. Reaching the TPM needs no administrator and no group:
the package installs a udev rule tagging `/dev/tpmrm0` with `uaccess`, so the ACL follows whoever is
logged in at the machine and goes away with them — the same mechanism a webcam uses. What that costs, and
the one symlink that undoes it, is in the rule's own comments. See
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4,
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 11 and
[Installing on Linux](#installing-on-linux).
Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a
certificate is the first release aimed at strangers), macOS on Intel, Linux packaging, and Phase 18 of the
manual checks — the macOS build has never actually run, because there is no macOS runner in CI and
everything above is verified only as far as the bundle.
certificate is the first release aimed at strangers), macOS on Intel, a `v*`-tagged Linux release and a
signed package repository to serve it from, and Phases 18 and 19 of the manual checks — neither the macOS
nor the Linux build has been walked through by a person. There is no macOS runner in CI at all. The Linux
package is verified as far as what it *says* about itself — CI builds it and interrogates its
dependencies, its file list and its modes, and installs nothing — and the two pieces of Linux behaviour
that are code rather than packaging do have tests: the update channel's version comparison and digest
check run in CI, and the TPM store's seal, unseal, wrong-PIN and lockout paths run against a software TPM
anybody can start with one `docker run`. What no runner covers is `/dev/tpmrm0`, PackageKit and polkit,
which is what Phase 19 is for.
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
## Licence
+54
View File
@@ -0,0 +1,54 @@
# Lets the person sitting at this machine use its TPM, without joining a group.
#
# ◆ WHAT THIS CHANGES, AND WHAT IT DELIBERATELY DOES NOT.
#
# Fedora's 60-tpm-udev.rules leaves /dev/tpmrm0 as root:tss mode 0660, so using it means being in
# the tss group — which means an administrator, a usermod and a fresh login before DodoSSH will
# offer to seal a device key. This adds the uaccess tag, and nothing else: the mode and the group
# are untouched, so everything that worked before still works and an ACL is layered on top.
#
# uaccess is systemd's own mechanism for exactly this question and is what /dev/dri, webcams,
# scanners and FIDO keys already use. logind grants a POSIX ACL to the user of the *active local
# session* and takes it away again when that session ends. So:
#
# - somebody logged in at the physical machine gets access;
# - somebody logged in over SSH does not;
# - a user switched away to another VT does not;
# - a system daemon running as its own user does not.
#
# That is a much narrower grant than adding a person to tss, which is permanent, applies over SSH,
# and applies whether or not they are the one at the keyboard.
#
# ◆ tpmrm AND NOT tpm, WHICH IS THE WHOLE REASON THIS IS DEFENSIBLE.
#
# /dev/tpm0 is the raw device and access to it is exclusive — two users cannot share it, and a
# process holding it can leave the chip in a state that breaks everything else. /dev/tpmrm0 is the
# kernel's resource manager: it virtualises handles, swaps contexts and isolates callers from each
# other, and it exists *because* the TPM is meant to be usable by more than one unprivileged
# program. Granting a seat's user the manager is the use it was built for. Granting the raw device
# would not be, and this rule never names it.
#
# ◆ THE COST, WHICH IS REAL AND IS NOT ABOUT SECRECY.
#
# TPM keys are protected by their own auth values, so this grants nobody the ability to read
# anybody else's sealed data. What it does grant is the ability to make failed authorisation
# attempts, and a TPM answers those by counting them and eventually refusing everyone — the same
# dictionary-attack lockout DodoSSH relies on to make a four-digit PIN safe.
#
# On a machine whose disk is unlocked by the TPM at boot (systemd-cryptenroll, Clevis), a local user
# who trips that lockout can stop the next boot from unlocking until it recovers. That is a denial
# of service against the machine's owner by somebody already sitting at the machine, and it is worth
# knowing rather than discovering. An administrator who does not accept it can mask this file:
#
# sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
#
# which is the standard override and survives upgrades. DodoSSH then behaves exactly as it did
# before this file existed: no device key is offered, and unlocking asks for the passphrase.
#
# Numbered 71 because 73-seat-late.rules is what turns the tag into an ACL, and 60-tpm-udev.rules
# is what creates the device node's ownership in the first place. Anything outside that gap either
# never runs or runs too late to matter.
ACTION=="remove", GOTO="dodossh_tpm_end"
SUBSYSTEM=="tpmrm", KERNEL=="tpmrm[0-9]*", TAG+="uaccess"
LABEL="dodossh_tpm_end"
+342
View File
@@ -0,0 +1,342 @@
# ==================================================================================================
# The Linux desktop head, as an RPM.
#
# Built by scripts/ci-linux-rpm.sh, which is what supplies every dodo_* macro below and what checks
# the result. Nothing here is invoked by hand; `rpmbuild -bb` on this file with no --define set will
# fail on the Version line, which is the intended way round.
#
# ◆ WHY THIS IS AN RPM AND NOT A VELOPACK RELEASE, WHICH IS THE FIRST QUESTION.
#
# Both desktop platforms that ship today are Velopack: the client polls the project's forge, fetches
# a newer build and swaps itself over. ADR 0013 rule 4 is what makes that safe to hold — the feed
# address is a const, so no operator can answer "is there a newer version" on the user's behalf.
#
# This package replaces nothing and installs nothing, and that is deliberate rather than unfinished.
# On Linux the package manager is already an updater the user trusts, audits and controls, so putting
# a second one inside the application — answering to this project instead — would be building the
# capability ADR 0013 rule 4 spends a page making unsettable, on the one platform where nobody had to.
#
# What the client does do is the half a package manager cannot: LinuxUpdateChannel notices that a
# newer build exists on the project's forge, fetches it, checks its digest, and hands the file to
# PackageKit — which raises polkit and asks. The user may say no, and nothing is replaced until they
# say yes. See ADR 0013 rule 11.
#
# The consequence to know: discovery is worse here than on the other two desktops, because there is
# no package repository to subscribe to and the in-app check is what stands in for one.
# ==================================================================================================
# ◆ THE THREE THINGS THAT MUST BE TURNED OFF, AND WHAT EACH ONE WOULD DO.
#
# __os_install_post is the chain of brp-* scripts rpm runs over the buildroot: strip, strip-static,
# compress, elfperms. Every one of them is wrong here. The payload is a published .NET tree whose
# managed PDBs ADR 0013 rule 6 keeps on purpose — a stack with file names and line numbers is this
# project's entire diagnostic channel — and brp-strip would take the native symbols with them for a
# saving nobody asked for. It also runs the host's binutils over glibc binaries on a build host that
# is musl, which is a class of breakage with no good failure message.
#
# debug_package would then try to build a -debuginfo subpackage out of what strip extracted, from
# third-party binaries nobody here will ever step through.
#
# _build_id_links is the one that surprises people, because turning the other two off does not turn
# it off: rpm still writes /usr/lib/.build-id/<xx>/<hash> symlinks for every ELF file in the payload.
# For a bundled tree that means dozens of links in a system directory this package has no business
# owning, and two files with the same build id — which a .NET publish does produce — make it a build
# *warning* about duplicates rather than an error, so it goes unnoticed.
%global __os_install_post %{nil}
%global debug_package %{nil}
%global _build_id_links none
# ◆ AUTO-REQUIRES STAYS ON. AUTO-PROVIDES DOES NOT. THE ASYMMETRY IS THE WHOLE POINT.
#
# rpm's elfdeps reads DT_NEEDED and the symbol-version table out of every ELF file in the buildroot,
# and the requires half of that is worth much more than a hand-written list: it produces
# `libc.so.6(GLIBC_2.38)(64bit)` rather than `glibc`, so the package refuses to install on a host
# whose glibc is too old instead of installing and dying at the first launch. A hand list cannot
# express that and would go stale the first time a dependency changed underneath it.
#
# It reads ELF directly rather than by loading anything, so it does this correctly for x86_64 glibc
# binaries on a musl build host — which is what lets this package be built on the same Alpine runner
# as everything else in ci.yml.
#
# The provides half is the opposite. Everything under /opt here is a *bundled* copy: libSkiaSharp,
# libcoreclr, libe_sqlite3 and the rest belong to this application and to nothing else. Left on,
# this package would advertise `libcoreclr.so()(64bit)` to the whole system, and some other package's
# dependency could then be satisfied by a private copy inside a directory `dnf remove dodossh` takes
# away. Excluding the tree by path is the standard shape for that and costs nothing, because nothing
# outside this package is meant to link against anything inside it.
#
# ◆ WHICH IS WHY THE REQUIRES SIDE NEEDS AN EXCLUSION TOO, AND WHY IT IS COMPUTED.
#
# The two halves are not independent. A bundled library with a DT_NEEDED on another bundled library
# produces a requirement for a soname this package carries and — after the exclusion above — no
# longer advertises, which no host on earth can satisfy. It is not hypothetical and it is not rare:
# .NET's own libmscordbi.so links libmscordaccore.so, so the very first realistic payload this was
# tried against produced a package `dnf install` refuses outright.
#
# dodo_bundled is the alternation of every lib*.so* filename in the payload, worked out by
# scripts/ci-linux-rpm.sh from the tree it was handed rather than listed here. A list in this file
# would be a list of Microsoft's and SkiaSharp's internal file names, kept in step by hand, and its
# failure mode is the uninstallable package above.
#
# liblttng-ust is excluded on its own terms and not because it is bundled — it is not. It is what
# libcoreclrtraceptprovider.so links, and that file exists so that CoreCLR can emit LTTng trace
# events if a host has the tracing stack. Nothing here asks it to. The file is kept and the
# requirement is dropped, which is the arrangement that works both ways round: where liblttng-ust is
# present the dlopen succeeds and tracing is available, and where it is absent CoreCLR carries on, as
# it does on every machine that runs a .NET application without it. Requiring it instead would put
# the package out of reach on RHEL, where lttng-ust is in EPEL rather than in the base repositories —
# an install failure on a supported distribution, in exchange for a tracing integration this
# application does not use.
#
# scripts/ci-linux-rpm.sh checks the finished package against its own file list afterwards, because
# an exclusion that silently stopped matching would put the uninstallable package back with no
# warning anywhere.
%global __provides_exclude_from ^/opt/.*$
%global __requires_exclude ^(liblttng-ust\.so|%{dodo_bundled})
Name: %{dodo_name}
Version: %{dodo_version}
Release: %{dodo_release}
Summary: SSH client with an end-to-end encrypted credential vault
License: MIT
URL: https://git.dodotech.cloud/DodoTech-Public/DodoSSH
BuildArch: x86_64
# ◆ THE DEPENDENCIES elfdeps CANNOT SEE, WHICH ARE THE ONES THE PRODUCT IS MADE OF.
#
# Everything below is reached through dlopen, by a name composed at runtime, so it appears in no
# DT_NEEDED entry anywhere in the payload. Left to the automatic scan the package would install
# cleanly on a host with none of it and fail at the first window.
#
# GTK and WebKitGTK are the terminal. Avalonia.Controls.WebView P/Invokes a bare `libwebkit2gtk` and
# a bare `libgtk` and resolves them itself, and MainWindow.OnTerminalEnvironmentRequested documents
# the backend it was measured against: WebKitGTK 2.52.5 on Fedora 44, which is the 4.1 API line — the
# assembly's one fully-spelled soname, libjavascriptcoregtk-4.1.so.0, is that line's other half.
#
# ◆ THE 4.0 LINE IS DELIBERATELY NOT CLAIMED, AND THE COST OF THAT IS KNOWN.
#
# The control's own diagnostic says "webkit2gtk 4.0+", so a dependency permitting either line is
# tempting and was written first. RHEL 9 and its rebuilds are what it would buy: they ship
# webkit2gtk3, which is the 4.0 API under the soname libwebkit2gtk-4.0.so.37 — not .so.0, which is
# what makes this a thing to look up rather than guess. Requiring only 4.1 means `dnf install` on
# AlmaLinux 9 refuses with "nothing provides libwebkit2gtk-4.1.so.0", by design.
#
# It is refused because nobody has run it. This project does not ship an Intel macOS package for the
# same reason and says so in ADR 0013 decision 10 — the engineering is small and the verification is
# the scarce thing — and an install that succeeds and then draws a blank terminal is a worse answer
# than one that refuses in a sentence naming the missing library. The day somebody runs the head on
# a 4.0 host, this becomes a rich `or` and a line in the README.
#
# libX11 is Avalonia's own backend, P/Invoked the same way. On Wayland this is satisfied by XWayland,
# which every desktop shipping this package has.
#
# libicu is the one that looks removable and is not. DodoSSH.Client.App sets InvariantGlobalization
# to false — deliberately, and the csproj says why: this process prints timestamps and host names to
# a person who expects their own locale's conventions. A .NET process configured that way and given
# no ICU does not degrade to invariant, it refuses to start. The bare package name is the Fedora and
# RHEL spelling; it is the one name here that is a distribution's rather than a soname's, because ICU
# bumps its soname on every release and pinning one would make this package expire.
Requires: libwebkit2gtk-4.1.so.0()(64bit)
Requires: libgtk-3.so.0()(64bit)
Requires: libgdk-3.so.0()(64bit)
Requires: libgio-2.0.so.0()(64bit)
Requires: libglib-2.0.so.0()(64bit)
Requires: libgobject-2.0.so.0()(64bit)
Requires: libX11.so.6()(64bit)
Requires: libicu
# The size directories under /usr/share/icons/hicolor belong to hicolor-icon-theme and not to this
# package, which is the right way round — every application drops icons into them. Depending on the
# owner is how those directories come to exist at all on a minimal host, and how they get removed
# again when the last thing using them goes.
Requires: hicolor-icon-theme
# ◆ RECOMMENDED RATHER THAN REQUIRED, WHICH IS A JUDGEMENT ABOUT WHAT THE PACKAGE IS FOR.
#
# PackageKit is what LinuxUpdateChannel hands a downloaded update to, so without it the update path
# stops at the last step. It is not required, for two reasons that both point the same way: dnf
# installs weak dependencies by default, so the ordinary Fedora install gets it anyway; and a machine
# deliberately built without it — a minimal or an immutable one — should still be able to install an
# SSH client. The failure is not silent, which is what makes this safe: the channel names the missing
# service and the file it fetched, so the answer is one command the user was going to run anyway.
#
# Nothing is required for the TPM. LinuxDeviceKeyStore talks to /dev/tpmrm0 through a managed stack,
# so there is no library to depend on, and a machine with no TPM — or one whose device node this user
# may not open, which is the ordinary Fedora case — simply keeps asking for the passphrase.
Recommends: PackageKit
%description
DodoSSH is an SSH and SFTP client for teams whose credentials are shared and whose infrastructure is
not. Hosts, keys and passphrases live in a vault that is encrypted on this machine and stays
encrypted on the server, so the deployment holding a team's secrets can never read them.
This package carries its own .NET runtime. It checks the project's own forge for newer builds and can
download one, but it installs nothing itself: an update is handed to PackageKit, which asks before it
replaces anything. The address it checks is fixed and is never a DodoSSH server.
%install
rm -rf %{buildroot}
# ◆ /opt, AND NOT /usr/lib64/dodossh, WHICH IS THE MORE CONVENTIONAL PLACE.
#
# The Filesystem Hierarchy Standard reserves /opt for exactly this — an add-on package that arrives
# as one self-contained tree — and that is what a published .NET application is: a runtime, thirty
# managed assemblies and half a dozen native libraries that only make sense together and are only
# ever loaded by each other. Scattering them into the system library directory would mix a bundled
# libSkiaSharp in with the system's shared objects, which is the arrangement the provides exclusion
# above exists to keep this package out of.
#
# The directory name carries the channel, so a release and a nightly are two trees rather than one
# that overwrites the other. That is the same separation ADR 0013 rule 9 builds on Windows out of
# pack ids, arrived at here for free: two RPMs with different names owning different paths cannot
# replace one another, and dnf will say so rather than doing it. The profile directory is separated
# by the build itself — DodoChannel=nightly is what ClientPaths reads to suffix it — so the two
# installs share no state at all.
mkdir -p %{buildroot}/opt/%{dodo_dir}
cp -a %{dodo_payload}/. %{buildroot}/opt/%{dodo_dir}/
# ◆ AND THE MODES ARE SET HERE RATHER THAN INHERITED, WHICH IS NOT TIDINESS.
#
# `cp -a` preserves what the publish directory happened to have, and what it happened to have is a
# property of the filesystem it was written on rather than of anything decided here. rpmlint caught
# the consequence on the first tree this was tried against: every file in the package was mode 777,
# because the tree had come off a mount that reports everything that way.
#
# For this application that is not a lint warning, it is a hole. A world-writable /opt/DodoSSH lets
# any local account replace the SSH client belonging to every other account on the machine — the
# binary that holds a team's infrastructure credentials in plaintext while it runs, which is the
# thing ADR 0001 spends its whole trust model on. Malware running as nobody-in-particular would not
# need to attack the crypto; it would edit the client.
#
# Stated absolutely and in this order, so the answer does not depend on the publish at all:
# directories and the things that are executed or loaded get 0755, and everything else — assemblies,
# PDBs, the runtime config, the terminal's HTML and JavaScript — gets 0644. Owned by root, which is
# rpm's default for a package that names no %attr user, and is what makes the modes mean anything.
find %{buildroot}/opt/%{dodo_dir} -type d -exec chmod 0755 {} +
find %{buildroot}/opt/%{dodo_dir} -type f -exec chmod 0644 {} +
find %{buildroot}/opt/%{dodo_dir} -type f -name '*.so*' -exec chmod 0755 {} +
# The two files a .NET publish means to be executed. createdump is the runtime's crash-dump helper
# and is only present on some publishes, so it is conditional; the apphost never is, and the check
# above this section has already refused a publish without one.
chmod 0755 %{buildroot}/opt/%{dodo_dir}/DodoSSH
[ -f %{buildroot}/opt/%{dodo_dir}/createdump ] && chmod 0755 %{buildroot}/opt/%{dodo_dir}/createdump
true
# The apphost is the thing to launch and every other file in the tree is loaded relative to it, so
# what goes on PATH is a symlink rather than a copy or a wrapper script. .NET resolves its own
# directory through /proc/self/exe, which follows the link, so a symlinked apphost finds its runtime
# with no help — a wrapper exporting DOTNET_ROOT or cd-ing first would be solving a problem that
# does not exist here, and would be one more file to keep in step with the layout.
#
# Relative — ../../opt from /usr/bin — rather than the absolute /opt path it resolves to. Both work
# on a running system and only one works inside a chroot or an image being assembled somewhere other
# than /, which is where absolute symlinks out of a package are checked and warned about.
mkdir -p %{buildroot}%{_bindir}
ln -s ../../opt/%{dodo_dir}/DodoSSH %{buildroot}%{_bindir}/%{dodo_bin}
# ◆ THE DESKTOP ENTRY IS WRITTEN HERE RATHER THAN SHIPPED AS A SOURCE FILE.
#
# Three of its seven meaningful lines differ between the release and the nightly, and the two files
# that would result are the kind of pair that drifts: somebody adds a MIME type to one of them and
# nobody notices for a release. Composed from the same macros the rest of the layout uses, they
# cannot.
#
# StartupWMClass is the line that is easy to leave out and visibly wrong without: Avalonia sets
# WM_CLASS from the assembly name, which is DodoSSH for both channels, so without this a launched
# window is a second, unlabelled icon in the dock beside the one that launched it. It is the assembly
# name deliberately and not the package's binary name — the window does not know which package
# started it.
mkdir -p %{buildroot}%{_datadir}/applications
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
[Desktop Entry]
Type=Application
Version=1.0
Name=%{dodo_title}
GenericName=SSH Client
Comment=SSH and SFTP with an end-to-end encrypted credential vault
Exec=%{dodo_bin}
Icon=%{dodo_bin}
Terminal=false
StartupNotify=true
StartupWMClass=DodoSSH
Categories=Network;RemoteAccess;System;
Keywords=SSH;SFTP;Terminal;Remote;Shell;
DESKTOP
# One file per size into the hicolor theme, named for the package so the two channels show different
# entries rather than fighting over one path. The sizes are the ones the icon script draws and its
# comment argues for; adding one there means adding it here.
for size in 48 64 128 256; do
mkdir -p %{buildroot}%{_datadir}/icons/hicolor/${size}x${size}/apps
install -m 0644 %{dodo_icons}/dodossh-${size}.png \
%{buildroot}%{_datadir}/icons/hicolor/${size}x${size}/apps/%{dodo_bin}.png
done
# ◆ THE UDEV RULE, WHICH IS THE ONE FILE THIS PACKAGE PUTS OUTSIDE ITS OWN TREE.
#
# It tags /dev/tpmrm0 with uaccess so the person at the machine can use the TPM without being put in
# the tss group by an administrator. The rule itself carries the whole argument, including what it
# costs and the one-line way to mask it.
#
# Installed for both channels, and that is not a mistake to be tidied up. The two packages own
# different paths for the same reason ADR 0013 rule 9 gives, but this file is about the machine's
# TPM rather than about either build — a nightly and a release on one machine want exactly the same
# access, and giving each its own copy would mean removing one package silently revoked the other's.
# rpm refuses to let two packages own one path with different content, which is what makes this
# safe: the file is byte-identical from both, so it is shared rather than duplicated.
#
# /usr/lib/udev/rules.d hardcoded rather than %{_udevrulesdir}, which comes from systemd-rpm-macros
# and is not present on the Alpine host CI builds this on. Hardcoding a path that has been stable
# for a decade beats a macro that expands to nothing and silently installs into the buildroot's root.
mkdir -p %{buildroot}/usr/lib/udev/rules.d
install -m 0644 %{dodo_udev} %{buildroot}/usr/lib/udev/rules.d/71-dodossh-tpm.rules
# Installed rather than listed straight out of the source tree. `%license` is a %doc-style marker and
# resolves its argument against the build directory, which this spec does not have — there is no
# %prep and no %build, because the payload arrives already published. Putting the file where it
# belongs first and marking the installed path second is the form that works with neither.
mkdir -p %{buildroot}%{_licensedir}/%{name}
install -m 0644 %{dodo_licence} %{buildroot}%{_licensedir}/%{name}/LICENSE
# Modern rpm on Fedora fires file triggers that do both of these, so on the distribution this was
# measured against they are redundant. They are here for the ones that do not, and every line ends in
# `|| :` so that a host without the tool installed gets a package that installs rather than a
# scriptlet failure at the end of a 130 MB transaction.
%post
/usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || :
/usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || :
# Reloaded and replayed, because a rule that only takes effect at the next boot is a feature that
# does not work on the day it is installed — and "reboot and try again" is the worst possible answer
# to "why is the PIN option missing". The trigger is scoped to the tpmrm subsystem so this re-runs
# rules for one device rather than for every device on the machine.
/usr/bin/udevadm control --reload &>/dev/null || :
/usr/bin/udevadm trigger --subsystem-match=tpmrm --action=change &>/dev/null || :
%postun
/usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || :
/usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || :
# On the way out too, so removing the package takes the ACL with it rather than leaving the last
# session's user holding access to the TPM until the next reboot.
/usr/bin/udevadm control --reload &>/dev/null || :
/usr/bin/udevadm trigger --subsystem-match=tpmrm --action=change &>/dev/null || :
%files
# The whole tree, owned by this package, which is what makes `dnf remove` take all of it. The user's
# vault cache is not in here and is not touched by an uninstall — ClientPaths puts it under
# $XDG_DATA_HOME, which is the Linux spelling of the same decision ADR 0013's consequences record for
# Windows: the install moves, the profile does not, and an uninstall is not a sign-out.
/opt/%{dodo_dir}
%{_bindir}/%{dodo_bin}
%{_datadir}/applications/%{dodo_bin}.desktop
%{_datadir}/icons/hicolor/*/apps/%{dodo_bin}.png
/usr/lib/udev/rules.d/71-dodossh-tpm.rules
%license %{_licensedir}/%{name}/LICENSE
%changelog
# Deliberately empty. The changelog of record is the git history and the release notes on the forge,
# and a %changelog generated per build from a version number carries no information that `rpm -q`
# does not already answer — while a hand-written one is a third place a release has to remember to
# edit. rpmlint says W: no-changelog and that warning is accepted.
+80 -3
View File
@@ -107,6 +107,81 @@ almost nil. Two things to check when it is taken up: `EnrollmentValidation` pins
`DevicePublicKey` to `CryptoSpec.PublicKeySize` (32 bytes; a P-256 public key is 33 or 65), and the
envelope's minimum-length rule.
### 4. Linux: sealed into the TPM behind a PIN the TPM checks
Added after the Linux head was packaged ([ADR 0013](0013-desktop-distribution-and-updates.md) decision 11).
The third desktop platform reached this file the way the first two did — by having to answer the amendment
above — and it is the only one where the answer is not a clean yes.
**The mechanism.** The 32-byte device key is sealed into a TPM 2.0 keyedhash object whose `userAuth` is
derived from a PIN. Unsealing is a command the TPM refuses without it. The blob on disk is encrypted to the
machine's storage hierarchy, so it is useless on any other machine, and nothing in this application ever
compares the PIN. The parent is an ECC P-256 primary rederived from a fixed template on every use rather
than made persistent — a persistent handle would occupy an NV slot forever, need cleaning up on withdrawal,
and collide with whatever else on the machine chose the same index. ECC rather than RSA because creating an
RSA primary on a discrete TPM takes seconds and this runs on the unlock path.
**The sealed object deliberately does not carry `NoDA`, and that single attribute is what makes a
four-digit PIN defensible.** Every wrong answer advances the TPM's dictionary-attack counter and the chip
stops answering entirely once it trips — measured against a software TPM at four wrong tries, after which
the *correct* PIN gets nothing either. That is the property a short secret has here and cannot have in a
file, and it is asserted by a test rather than left to a reading of the template, because removing the
attribute would leave every other assertion in that file passing.
◆ **This is weaker than Windows Hello and Touch ID, and the difference is invisible to the user.** Both of
those raise their prompt from inside the platform — a secure desktop, a system sheet — so the process asking
never sees the secret. Linux offers nothing to borrow, so the PIN is typed into a window this application
draws, and an attacker already running as the user could draw a convincing copy of it. Two things bound
that, and neither erases it: the same attacker could counterfeit the passphrase screen, which is a much
larger prize, so the feature creates no capability that did not exist; and what they would obtain is a PIN
that is worthless without this machine's TPM, where the passphrase is worth every machine. **The parity is
not claimed anywhere in the interface**, which is why `DesktopDeviceKeyStores` documents the ordering and
why the prompt is deliberately plain rather than dressed to look like a system dialog — teaching people
that a convincing DodoSSH PIN box is a system dialog is the one thing this design must not do.
**Reaching the TPM at all is the other half, and the package now arranges it.** On Fedora `/dev/tpmrm0` is
`root:tss` mode 0660 and desktop users are not in `tss`, so without help the ordinary case is a working TPM
this process may not open — which reports unavailable and keeps asking for the passphrase, exactly as a
machine with no TPM does.
◆ **This originally said shipping a udev rule to widen that was refused, on the grounds that it is a
system-wide decision an application package should not make. That refusal was overturned deliberately, and
the reasoning it was replaced with is narrower rather than louder.** The refusal treated "widen access" as
one thing. It is two, and they are not close:
- *Adding a person to `tss`* is what the refusal was really about, and it remains the wrong answer. It is
permanent, it applies whether or not they are at the machine, it applies over SSH, and it needs an
administrator and a fresh login before the feature appears at all.
- *Tagging `/dev/tpmrm0` with `uaccess`* is the mechanism systemd already uses for `/dev/dri`, webcams,
scanners and FIDO keys: logind grants a POSIX ACL to the user of the **active local session** and removes
it when that session ends. Not over SSH, not on an inactive VT, not to daemons. `build/linux/71-dodossh-tpm.rules`
is that one line, and it changes neither the mode nor the group, so anything that worked before still does.
**`tpmrm` and never `tpm`, which is what makes it defensible at all.** `/dev/tpm0` is the raw device and is
exclusive; a process holding it can leave the chip in a state that breaks every other user. `/dev/tpmrm0` is
the kernel's resource manager, which virtualises handles and isolates callers — it exists *because* the TPM
is meant to be usable by more than one unprivileged program. Granting a seat's user the manager is the use
it was designed for.
**The cost is a denial of service and not a disclosure, and it is stated because it is easy to miss.** TPM
objects are protected by their own auth values, so this lets nobody read anybody else's sealed data. What it
does allow is *failed* authorisation attempts — and the chip answers those by counting them and eventually
refusing everyone, which is the same lockout decision 4 relies on. On a machine whose disk is unlocked by
the TPM at boot (`systemd-cryptenroll`, Clevis), somebody sitting at it can trip that lockout and stop the
next boot from unlocking until it recovers. An administrator who does not accept that masks the file with
`ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules`, which is the standard override, survives upgrades,
and puts the behaviour back to the passphrase-only path. The rule says so in its own comments, because the
person who needs to know is reading the file rather than this document.
**What was rejected, and why each would have failed the amendment above.** The Secret Service API —
gnome-keyring or KWallet — is out of process and OS-managed and has *no per-use consent*: once the session
keyring is unlocked anything running as the user reads the key silently, which is DPAPI's shape exactly and
what "Why not DPAPI alone" already refuses. `fprintd` is an authentication oracle rather than a key store:
it answers a question and holds nothing, so skipping the call skips the gate — the amendment's mistake with
a fingerprint reader attached. A polkit-gated root helper would genuinely work and was declined on surface
area: it puts a privileged component in a package that installs nothing setuid, to reach a property the TPM
already provides.
## Consequences
### The cache key had to move, and the spec changed
@@ -138,9 +213,11 @@ would have become false under DPAPI alone. A gesture is still something the atta
### Operational
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
is therefore required, not a nicety.
- **A TPM is not always there, and on Linux it is often there and out of reach.** A machine without one gets
a store that reports itself unavailable, so unlock keeps asking for the passphrase and neither affordance
appears in the interface. The passphrase path is therefore required, not a nicety — and on Linux it is the
default rather than the exception, because the device node is not readable by an ordinary desktop user
until somebody says it should be. See decision 4.
- **macOS reaches the same decision through different hardware, and the argument transfers intact.**
`MacDeviceKeyStore` puts the wrapping key in the Secure Enclave under an access control requiring user
presence, so Touch ID or the login password is a condition of *using* it and the enforcement is the
@@ -316,6 +316,106 @@ preferences screen, which is the screen somebody reads when asked which nightly
across the floor holds: heights rise within a floored version, and the first real tag moves past all of
them. The release script gets no floor and must not have one; there, `0.0.0` should be refused.
### 11. Linux is a third desktop platform, and it is a package rather than an updater
The Linux head existed and shipped as nothing: a checkout somebody built. `VelopackUpdateChannel` already
named the two platforms it serves rather than negating the one it does not, on the reasoning that "a future
AppImage should be an addition here rather than a thing that silently already half-happened". This is that
addition, and it is not an AppImage.
**The package is an RPM and nothing inside it replaces anything.** That is the decision, and everything
else follows from it. Velopack does have a Linux path, so the shape of decisions 1 through 9 could have been
repeated a third time — one more channel, one more index, one more client swapping its own files over. It
should not be, because this platform already has the mechanism that whole apparatus is a substitute for.
`dnf` is an updater the user already trusts, already audits and already controls, and it updates the
runtime's own dependencies besides. Adding a second one that answers to this project instead would be
building the capability decision 4 spends a page keeping unsettable, on the one platform where nobody had
to.
◆ **The first version of this rule said "there is no updater in it at all", and that went too far.** It
conflated two things the other two platforms happen to do together: *noticing* that a newer build exists,
and *installing* it. The second is what the argument above is about, and the first is not — a client that
checks a fixed address and tells you what it found has taken no capability from anybody. Written as a flat
refusal, the rule bought its safety by paying the cost in the paragraph below, and the cost was avoidable.
**So the split is: this client checks and fetches, and PackageKit installs.** `LinuxUpdateChannel` reads
the same rolling release the Windows nightly feed lives on, compares versions the way `rpm` compares them,
downloads the package, verifies its digest, and hands the file to PackageKit — which raises polkit and asks
the user, who may say no. `ApplyingEndsTheProcess` is false and nothing is torn down before the answer, which
is the phone's shape rather than the desktop's, and is already what `MainWindowViewModel` does with that
flag. Four things follow that are worth stating because each is a property the Velopack channels do not have
to think about:
- **The comparison is `rpmvercmp` and not SemVer.** Decision 7 made one version of record for the whole
repository, and this is the one place it is not the string that decides: `rpm` will accept or refuse the
install, so the client must compare the way `rpm` does or it can offer an update PackageKit then declines.
It is the same argument [ADR 0014](0014-android-updates.md) makes for comparing `versionCode`. The
manifest therefore carries both — the RPM version to compare and the SemVer to show a person — and the
installed side is read out of the rpm database rather than derived from the assembly, so the translation
`scripts/ci-linux-rpm.sh` performs exists in exactly one place.
- **The digest is checked, and it is not a signature.** Velopack verifies what it downloads and Android
refuses a package whose signature does not match the installed one. An RPM fetched over HTTPS and handed
to a package manager has neither, so the manifest names a SHA-256 and the client refuses anything else.
That catches a truncated download and a caching proxy and says nothing whatever about provenance, because
the same job writes the package and the manifest. Provenance here is decision 4's constant address.
- **`ONLY_TRUSTED` is cleared, deliberately.** The package is unsigned, so requiring PackageKit to trust it
would fail every update at the last step. Clearing the flag routes the request to polkit's
`package-install-untrusted` action, which asks for an administrator rather than proceeding quietly — the
user is told what they are agreeing to, which is the honest form of an unsigned package.
- **Nothing restarts.** Replacing files under a running process is ordinary on Linux and the old inodes stay
mapped, so the build keeps running until somebody closes it. That is the same courtesy decision 1's
consequences argue for on Windows, arrived at for free.
The honest cost that remains, recorded because it is the part that will be argued with: **discovery is still
worse here than on either other desktop, for anybody who declines the in-app check.** There is no package
repository to subscribe to, so `dnf upgrade` on its own will never bring a newer DodoSSH; the in-app check is
what stands in for one, and a user who turns it off on PREFERENCES → UPDATES has no other signal. A signed
repository is the fix, it is not built, and decision 11's last paragraph says what it would cost.
**CI publishes it, and only to the nightly channel.** Rule 3 is untouched and applies with its full force:
the token that writes a release is the capability to ship somebody a build, and it stays off runners. What
changes is nothing, because rule 9 already established a channel CI may publish to and this is a second
artefact on it. The release-shaped package is built on every `main` build and on every tag and uploaded
nowhere, exactly as the Windows and macOS packages are, which proves the `linux-x64` restore graph and the
packaging without putting an installer anybody has run in a place that invites passing it on.
Note what CI's check is and is not. `vpk` verifies that the main executable actually calls
`VelopackApp.Build().Run()` — a real assertion about the application, made for free by the packager. There
is no equivalent here, so `scripts/ci-linux-rpm.sh` makes its own: it asks the finished package what it
requires, what it provides, what files it holds and what modes they carry, and fails on each way a package
can build cleanly and then be uninstallable or unsafe. Two of those checks exist because the first realistic
payload tripped them — .NET's `libmscordbi.so` links `libmscordaccore.so`, which with bundled provides
excluded produces a dependency no host can satisfy; and `cp -a` off the wrong filesystem produced a
world-writable `/opt/DodoSSH`, which is a local privilege escalation against an application holding
plaintext credentials.
**The separations of rule 9 survive translation, and cost less here.** Two RPMs with different names owning
different paths cannot replace one another and `dnf` says so, where on Windows the same property had to be
built out of pack ids. The profile split is unchanged and still belongs to the build: `DodoChannel=nightly`
is what `ClientPaths` reads, and the packaging must not answer that question a second time.
**The device key store is settled separately and is not nothing.** The first version of this rule said this
platform had none and that the passphrase was asked for at every unlock. It has one:
[ADR 0007](0007-device-key-protection.md) decision 4 adds a TPM-sealed key released against a PIN the TPM
itself checks and counts failures against. It is weaker than Hello and Touch ID in a way that ADR names
precisely. Reaching the chip needs no group membership, because this package ships the udev rule that gives
the seated user an ACL on `/dev/tpmrm0` — a decision that ADR overturned from its own first answer, and
whose cost it states.
**What this platform does not have is a signature on the package**, which is not decision 8's "unsigned for
now": nothing on this platform
refuses or warns about an unsigned RPM, so there is no dialog to buy a certificate to remove. What stands in
for one is the same thing that stands in for it on the forge — TLS, and a project that publishes from one
address. An RPM repository would change that, because repository metadata is signed and a repository is a
thing users add once and then trust; that is the point at which a key becomes necessary rather than optional,
and it is the reason building one is a decision and not a chore.
**x86_64 only, for decision 10's reason and not a different one.** aarch64 is one argument to the publish
and one to the script. What is missing is somebody to run Phase 19 on such a machine, and this project does
not ship desktop builds nobody has run. The same sentence rules out claiming RHEL 9, which ships WebKitGTK's
4.0 API line under a soname the package deliberately does not accept: `dnf` refusing with *nothing provides
libwebkit2gtk-4.1.so.0* is a better answer than an install that draws a blank terminal.
## Consequences
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
@@ -369,6 +469,10 @@ ADR 0011 changes.
genuinely the nicest onboarding available and will be proposed again.
- **Conditioning updates on `MinClientVersion`.** See decision 5. It would create the attack rather than
inherit it.
- **A Velopack channel for Linux.** The mechanism exists — Velopack builds an AppImage — and repeating
decisions 1 through 9 a third time would have been the consistent-looking move. Declined in decision 11
because this is the one desktop platform that already has an updater the user controls, and a second one
answering to this project is the capability decision 4 exists to make unsettable.
- **Azure Trusted Signing**, for now, on custody grounds rather than on mechanics. See decision 8.
- **A hand-edited version property.** Considered seriously against MinVer, and it loses on the thing that
matters here: the tag is already the version of record for the container image, and two places to write
+202
View File
@@ -2841,3 +2841,205 @@ Register a device, sync something, then remove the application.
**Failure means:** an empty directory is the pack id having been changed to `DodoSSH`, which puts Velopack's
install root on top of `ClientPaths.DataDirectory` and makes an uninstall delete a user's un-synced work.
This is the single reason the bundle is named `DodoSSH.Desktop.app`.
## Phase 19 — Installing the Linux client, and the things dnf does not check
The Linux counterpart of phases 16 and 18. One of their questions is off the table — there is no signing,
because nothing on this platform refuses an unsigned package, which is a distinct statement from "unsigned
is fine"; `build/linux/dodossh.spec` says what stands in for a signature, which is that the package comes
from the project's own forge over TLS and from nowhere else.
Everything else is here, and three things are here that neither other phase has. **The update does not
install itself**, so 19.8 is about watching a polkit prompt appear and saying no to it, where phases 16 and
18 watch an application replace itself. **The device key's gate is a PIN rather than a gesture**, with the
TPM's own lockout behind it — so 19.7 deliberately breaks something and then tells you how to put it back.
And **this package changes a permission on the machine**: 19.4 and 19.5 are about the udev rule that gives
the seated user the TPM, checking both that it works and that masking it undoes it completely.
What this phase is for is the set nobody else can answer. **CI builds this package and installs nothing**,
and every check in `scripts/ci-linux-rpm.sh` is a check on what the package *says* — its dependencies, its
file list, its modes. The code does better: `LinuxDeviceKeyStoreTests` runs the whole seal, unseal,
wrong-PIN and lockout path against a software TPM, and `LinuxUpdateChannelTests` covers the version
comparison and the digest. What none of them touch is `/dev/tpmrm0`, PackageKit, polkit and a real desktop
session, and the failures below are all of the shape "installs perfectly and then does not work".
Needs a Fedora 44 machine or newer with a graphical session. Build the package first:
```bash
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
--configuration Release --runtime linux-x64 --self-contained true \
-p:RestoreLockedMode=false --output /tmp/linux-x64
scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm
sudo dnf install /tmp/rpm/dodossh-1.2.3-1.x86_64.rpm
```
### 19.1 It launches from the menu, and not only from a shell · **do this one first**
Log out and back in, or wait for the desktop to notice the new `.desktop` file, then start DodoSSH from the
applications menu rather than from a terminal.
**Pass:** the window opens, and the running application's icon in the dock or task list is the same entry
that launched it — one icon, not two.
**Failure means:** a second unlabelled icon appearing beside the launcher is `StartupWMClass` not matching
what Avalonia sets `WM_CLASS` to. The spec sets it to `DodoSSH`, which is the assembly name and is the same
for both channels; if Avalonia's answer has changed, `xprop WM_CLASS` on the window is what to compare
against. Nothing at all in the menu is the desktop database not having been rebuilt — `%post` runs
`update-desktop-database` and swallows its failure on purpose, so try it by hand before concluding anything.
### 19.2 The terminal draws, which is the WebKitGTK question
Connect to a host and use the shell: type, run something that scrolls, resize the window.
**Pass:** the terminal attaches within a second or two and behaves as it does on Windows.
**Failure means:** **a blank pane where everything else works is the known one**, and it is the reason
`MainWindow.OnTerminalEnvironmentRequested` exists — the GTK WebView adapter in its default mode can only
open a window of its own, so a control asked to host it in place has nothing to draw. If that method has
stopped setting `ExperimentalOffscreen`, or the vendor has withdrawn the flag, this is what it looks like.
A pane that reports a renderer timeout instead is the loopback WebSocket rather than the backend.
### 19.3 The icon is the mark, at the sizes the desktop actually asks for
Look at it in the applications menu, in the dock, and in the window switcher.
**Pass:** the accent tile and the `>_` mark, filling its square, legible at the small end.
**Failure means:** a generic icon is `Icon=` in the desktop entry not matching an installed file name — both
are the package's binary name, so they can only disagree if one was edited. A blurry small icon is the
48-pixel file missing and the 256 being scaled down; `dodossh-icon.ps1` draws four sizes and the spec
installs four.
### 19.4 The udev rule gives *this* session the TPM, and gives nobody else it · **needs a real TPM**
Installing the package should be the whole setup — no group, no administrator, no fresh login. Check it
from a graphical session on the machine itself:
```bash
getfacl /dev/tpmrm0 | grep "^user:"
id -nG | tr ' ' '
' | grep -x tss # expect nothing: you are not in the group
```
**Pass:** `getfacl` shows a `user:<you>:rw-` entry that is **not** there for any other account, and you are
not in `tss`. Now `ssh` into the same machine from elsewhere and run the same `getfacl`: the entry for you
is still the *local* session's, and a shell that is not on the seat cannot open the device — `head -c1
/dev/tpmrm0` answers "Permission denied".
**Failure means:** no ACL at all is the rule not having been applied. `udevadm test --action=change
/sys/class/tpmrm/tpmrm0 | grep uaccess` says whether the tag is being set, and the load order matters —
71 has to fall between `60-tpm-udev.rules` and `73-seat-late.rules`, which is what turns the tag into an
ACL. If the file is present and untagged, check nothing in `/etc/udev/rules.d` is masking it. **An ACL
granted to a remote session would be the serious one**, and would mean the tag had been put on `tpm` rather
than `tpmrm` or the mode widened instead — neither of which this rule does.
### 19.5 Masking the rule puts the machine back the way it was
```bash
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
sudo udevadm control --reload && sudo udevadm trigger --subsystem-match=tpmrm --action=change
```
Log out and back in, then open the security settings page.
**Pass:** registering a device key is **not** offered, and unlocking asks for the passphrase every time —
the same behaviour as a machine with no TPM. `udevadm test` reports the file as "masked by previous entry".
**Failure means:** still being offered it means `LinuxDeviceKeyStore.IsSupported` answered from the presence
of the device node rather than from opening it. Accepting that offer would put a wrap on the server that
nothing can ever open, and the failure would arrive at the next unlock rather than now.
**Then undo it** — `sudo rm /etc/udev/rules.d/71-dodossh-tpm.rules` and reload — before 19.6.
### 19.6 The PIN unlocks, and the TPM is what checks it
Register a device key from the security settings page and choose a PIN. Lock the vault and unlock it again.
**Pass:** registering asks for a PIN twice and shows no other prompt. Unlocking asks for it once and opens
the vault. Cancelling that prompt lands on the passphrase screen, which works.
**Failure means:** a PIN prompt that opens the vault whatever you type would mean the unseal is not reaching
the TPM at all, which is the one failure that would make this whole feature decorative.
### 19.7 A wrong PIN gets nothing, and the TPM stops answering
With a device key registered, lock the vault and enter a wrong PIN. Do it several times.
**Pass:** each attempt lands on the passphrase screen. After a handful, even the **correct** PIN lands
there too — the TPM has entered lockout and is refusing everyone, which is the point.
**Failure means:** wrong PINs that never stop being accepted for the attempt is the `NoDA` attribute having
been set on the sealed object, which would make a four-digit PIN guessable at full speed by anything holding
the blob and the machine. `LinuxDeviceKeyStoreTests` asserts this against a software TPM; this is the same
assertion against real firmware, where the lockout is real and recovery takes hours.
**Then put it back:** `sudo tpm2_dictionarylockout --clear-lockout`, or wait out the recovery time. Note
which, because a user who trips this will ask — and the honest answer is that it needs an administrator.
### 19.8 An update is noticed, fetched, and installed by PackageKit and not by DodoSSH
With the nightly installed, wait for a newer one to be published — or press **CHECK NOW** on
PREFERENCES → UPDATES once there is one.
**Pass:** the banner names the new version and downloads it. Pressing install raises the desktop's own
*"Authentication is required to install software"* prompt, drawn by polkit and not by DodoSSH. Accepting it
installs the package; declining it changes nothing and leaves the vault unlocked and every shell open.
DodoSSH does **not** restart, and the version on the preferences screen does not change until you close and
reopen it.
**Failure means:** an update that is offered and then refused by PackageKit is the version comparison
disagreeing with rpm — `rpm -q --queryformat '%{VERSION}-%{RELEASE}' dodossh-nightly` against the
`rpmVersion` in the release's `linux-nightly.json` is the comparison being made, and `RpmVersionTests`
covers the algorithm. A message naming PackageKit means it is not installed; it is a weak dependency, so a
minimal install legitimately lacks it. **The vault locking or shells closing when you decline is the
serious one** — it means `ApplyingEndsTheProcess` has been set true, which punishes somebody for saying no.
### 19.9 A tampered package is refused before PackageKit ever sees it
Not reachable without editing the feed, so this is the one to reason about rather than perform unless you
are changing that code: the manifest's `sha256` is checked after the download and the file is deleted if it
does not match.
**Pass:** `~/.local/share/dodossh.nightly/updates/` holds a package only when its digest matched.
**Failure means:** a leftover `.rpm` there after a failed update is a file somebody could reasonably install
by hand, named as though the application had endorsed it.
### 19.10 The nightly installs beside the release rather than over it
With the release package installed, install the nightly one too.
**Pass:** `dnf` installs it as a second package rather than as an upgrade. Both entries appear in the menu,
named *DodoSSH* and *DodoSSH Nightly*. Both run at once. Signing into the nightly asks for the passphrase
again and appears on the account as a separate device.
**Failure means:** `dnf` offering to *upgrade* rather than install is the two packages sharing a name. A
nightly that opens the release's vault already unlocked is the profile separation gone — that comes from
`DodoChannel=nightly` reaching `ClientPaths`, not from the packaging, so check
`~/.local/share/dodossh.nightly` exists rather than looking at the spec.
### 19.11 Nothing in the installed tree is writable by anyone but root
```bash
find /opt/DodoSSH ! -user root -o -perm -o+w
```
**Pass:** no output.
**Failure means:** any line here is a local account being able to replace the SSH client every other account
on the machine runs, which for an application holding a team's credentials in plaintext is the whole trust
model walked around rather than attacked. `ci-linux-rpm.sh` refuses to produce such a package and the spec
sets every mode absolutely, so a hit here means one of those two was changed — it cannot arrive by accident
from a publish directory.
### 19.12 Removing it leaves the vault, and leaves the other channel alone
Register a device, sync something, then `sudo dnf remove dodossh`.
**Pass:** `~/.local/share/dodossh` still holds the cache and the outbox. `/opt/DodoSSH` is gone entirely.
`/opt/DodoSSH.Nightly` and its menu entry are untouched.
**Failure means:** a missing cache is `ClientPaths` having been pointed somewhere under `/opt`, which would
make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2
exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means
something wrote into the install directory at runtime and would survive a reinstall.
+366
View File
@@ -0,0 +1,366 @@
#!/usr/bin/env bash
#
# Turns a published linux-x64 tree into an RPM, and then checks the RPM rather than the exit code.
#
# Called from two jobs in .github/workflows/ci.yml and written once for the reason the four copies of
# "ensure node and git" in that file are not: those cannot be factored out — a composite action would
# need the checkout it exists to unblock — and this can. The build job packages the release-shaped
# RPM and throws it away, exactly as it does for Windows and macOS; the desktop job packages the
# nightly-shaped one and publishes it. Everything that differs between those two is an argument.
#
# It is also runnable at a desk, which is the point of it being a script:
#
# dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
# --configuration Release --runtime linux-x64 --self-contained true \
# -p:RestoreLockedMode=false --output /tmp/linux-x64
# scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm
#
# Nothing here holds a secret and nothing here uploads anything. Publishing is the calling job's, and
# ADR 0013 rule 3 is what decides which channel may do it.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
payload=""
version=""
channel="release"
out=""
while [ $# -gt 0 ]; do
case "$1" in
--payload) payload="$2"; shift 2 ;;
--version) version="$2"; shift 2 ;;
--channel) channel="$2"; shift 2 ;;
--out) out="$2"; shift 2 ;;
*) echo "Unknown argument: $1" >&2; exit 2 ;;
esac
done
for required in payload version out; do
if [ -z "${!required}" ]; then
echo "--$required is required." >&2
exit 2
fi
done
case "$channel" in
release|nightly) ;;
*) echo "--channel must be release or nightly, not '$channel'." >&2; exit 2 ;;
esac
# The apphost, named rather than globbed. A publish that produced no apphost at all would otherwise
# package happily into an RPM whose /usr/bin symlink points at nothing, and the first thing anybody
# would learn about it is a shell saying "No such file or directory" for a file that is right there.
if [ ! -s "$payload/DodoSSH" ]; then
echo "There is no apphost at $payload/DodoSSH, so there is nothing to package." >&2
ls -la "$payload" >&2 || true
exit 1
fi
# ◆ THE FOUR THINGS THAT DIFFER BETWEEN THE CHANNELS, AND WHY IT IS FOUR AND NOT ONE.
#
# This is ADR 0013 rule 9's table, in the spelling this platform uses. The package name keeps dnf from
# treating one as an upgrade of the other; the directory under /opt keeps the two trees apart on disk;
# the /usr/bin name and the desktop entry keep them apart in front of a person, who would otherwise
# have two identical launchers and no way to tell which was which.
#
# The fifth separation — the profile directory holding the vault cache, the outbox and the device key
# — is not here and must not be, because it belongs to the build rather than to the package: the
# nightly is published with DodoChannel=nightly, and ClientPaths reads that to suffix the directory.
# Doing it here as well would be a second answer to a question that already has one.
if [ "$channel" = nightly ]; then
name=dodossh-nightly
dir=DodoSSH.Nightly
bin=dodossh-nightly
title="DodoSSH Nightly"
else
name=dodossh
dir=DodoSSH
bin=dodossh
title="DodoSSH"
fi
# ◆ SEMVER IS NOT AN RPM VERSION, AND THE TRANSLATION IS THE ONE PLACE THIS CAN GO QUIETLY WRONG.
#
# rpm forbids `-` in a version, which is exactly the character SemVer uses to introduce a prerelease.
# MinVer's answer on main is 0.0.1-alpha.0.143, so the naive move — strip the suffix — would give
# every nightly the version 0.0.1 and make each one look like a reinstall of the last.
#
# `~` is rpm's own answer and has been since 4.10: it sorts *before* the empty string, so
# 0.0.1~alpha.0.143 is older than 0.0.1 the way alpha.143 is older than the release. Heights keep
# rising within a floored version and the first real tag moves past all of them, which is the same
# ordering property the Velopack channels have and is worth having on both.
#
# Build metadata after `+` is dropped rather than translated. It is the one part of a SemVer string
# that is defined as not participating in ordering, so keeping it would put a character rpm has no
# rule for into the field ordering is done on.
rpm_version="${version%%+*}"
rpm_version="${rpm_version/-/\~}"
case "$rpm_version" in
*-*)
echo "The version $version translates to $rpm_version, which still contains a hyphen." >&2
echo "rpm cannot order a version with one in it; this needs a rule rather than a retry." >&2
exit 1
;;
esac
# Always 1. The release field distinguishes several packagings of one upstream version, and there is
# only ever one packaging here — the version already carries the build height, because upstream and
# packager are the same repository.
rpm_release=1
echo "==> $name $rpm_version-$rpm_release.x86_64, from $payload"
# ◆ rpmbuild ON A MUSL HOST, WHICH IS FINE, AND WHY IT IS WORTH SAYING SO.
#
# The runner is Alpine. Nothing about that shows up in the package: rpmbuild composes an archive and
# some metadata, and the one part of it that reads the payload — elfdeps, which the spec leaves on for
# requires — parses ELF headers with libelf rather than loading anything, so it reads x86_64 glibc
# binaries on a musl host correctly. Building in a Fedora container was the alternative and would
# have meant `docker cp`-ing a 130 MB publish tree in and the package back out, because this runner is
# itself a container and a bind mount resolves on the daemon's host rather than here — see the note on
# the android job for the hour that cost.
if ! command -v rpmbuild >/dev/null 2>&1; then
SUDO=""
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
echo "==> installing rpm"
if command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache rpm
elif command -v dnf >/dev/null 2>&1; then
$SUDO dnf install -y rpm-build
elif command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -qq
$SUDO apt-get install -y --no-install-recommends rpm
else
echo "No apk, dnf or apt-get here, so rpmbuild cannot be installed from inside the job." >&2
echo "Add rpm to the runner's image; the Linux package cannot be built without it." >&2
exit 1
fi
fi
rpmbuild --version
# ◆ THE LIST OF PRIVATE SONAMES, WORKED OUT FROM THE TREE RATHER THAN WRITTEN DOWN.
#
# build/linux/dodossh.spec turns rpm's automatic provides off for everything under /opt, because the
# libraries there are this application's private copies and have no business satisfying anybody
# else's dependency. The requires side has to be told the same thing about the same set, or a bundled
# library that links another bundled library asks for a soname nothing provides and the package
# becomes uninstallable on every host — which .NET's own libmscordbi.so, linking libmscordaccore.so,
# does on every publish.
#
# Derived here because the set belongs to the payload. A list in the spec would be Microsoft's and
# SkiaSharp's internal file names, maintained by hand, going stale on the first runtime bump — and
# going stale in the direction that produces a package nobody can install.
#
# Only the top level is scanned, which is where a .NET publish puts its native libraries. The dots
# are escaped because this becomes a regular expression; nothing else in a soname is a metacharacter.
#
# A shell glob rather than `find -printf`, which is GNU find's and not busybox's — the runner is
# Alpine, where that flag is a usage message and an empty list, which would silently disable the whole
# exclusion and put the uninstallable package back.
bundled="$(
for library in "$payload"/lib*.so*; do
[ -f "$library" ] && basename "$library"
done |
sed 's/\./\\./g' |
sort -u |
paste -sd '|' -
)"
if [ -z "$bundled" ]; then
echo "There are no native libraries at the top of $payload." >&2
echo "A self-contained .NET publish always has some, so this is a publish that went wrong" >&2
echo "rather than a package with nothing to exclude." >&2
exit 1
fi
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$out"
# --target rather than trusting the host's arch, because the host's arch here is whatever the runner
# happens to be and the payload's is decided by the RID the publish asked for. They agree today and
# an aarch64 runner would silently make them disagree.
#
# `dist` is emptied deliberately. It is the %{?dist} tag that puts .fc42 in a filename, and it names
# the distribution the package was *built* on — which for a self-contained tree built on Alpine would
# be both meaningless and, if rpm guessed something plausible, actively misleading.
rpmbuild -bb "$repo_root/build/linux/dodossh.spec" \
--target x86_64 \
--define "_topdir $work" \
--define "_rpmdir $out" \
--define "dist %{nil}" \
--define "dodo_name $name" \
--define "dodo_version $rpm_version" \
--define "dodo_release $rpm_release" \
--define "dodo_dir $dir" \
--define "dodo_bin $bin" \
--define "dodo_title $title" \
--define "dodo_payload $payload" \
--define "dodo_bundled $bundled" \
--define "dodo_icons $repo_root/src/DodoSSH.Client.App/Assets" \
--define "dodo_udev $repo_root/build/linux/71-dodossh-tpm.rules" \
--define "dodo_licence $repo_root/LICENSE"
# rpmbuild writes into <_rpmdir>/<arch>/, and every caller wants the file rather than the tree.
rpm_file="$out/x86_64/$name-$rpm_version-$rpm_release.x86_64.rpm"
if [ ! -s "$rpm_file" ]; then
echo "rpmbuild reported success and there is no package at $rpm_file." >&2
find "$out" >&2 || true
exit 1
fi
mv "$rpm_file" "$out/"
rmdir "$out/x86_64" 2>/dev/null || true
rpm_file="$out/$(basename "$rpm_file")"
echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes"
# ◆ AND NOW THE PART THAT IS NOT PACKAGING: ASKING THE PACKAGE WHAT IT SAYS ABOUT ITSELF.
#
# Everything above can succeed and still produce something nobody can install, and none of the ways
# that happens are visible in an exit code. Each check below is one of them.
requires="$(rpm -qp --requires "$rpm_file")"
provides="$(rpm -qp --provides "$rpm_file")"
files="$(rpm -qp --list "$rpm_file")"
# 1. The bundled-soname trap the spec's provides exclusion sets. If some library under /opt ever
# gains a DT_NEEDED on another library under /opt, elfdeps will put that soname in the requires
# while the exclusion keeps it out of the provides, and the result is a package no host on earth
# can satisfy. Nothing about it looks wrong until an install fails, so it is checked here against
# the actual contents rather than assumed.
carried="$(printf '%s\n' "$files" | sed -n 's|^/opt/.*/\(lib[^/]*\.so[^/]*\)$|\1|p' | sort -u)"
unsatisfiable=""
for soname in $carried; do
if printf '%s\n' "$requires" | grep -qF "$soname("; then
unsatisfiable="$unsatisfiable $soname"
fi
done
if [ -n "$unsatisfiable" ]; then
echo "This package requires sonames it also carries and does not provide:$unsatisfiable" >&2
echo "That is unsatisfiable on every host, and it means the requires exclusion computed above" >&2
echo "no longer matches what rpm generated — a bundled library outside the top level, or an rpm" >&2
echo "that has stopped honouring __requires_exclude. See build/linux/dodossh.spec." >&2
exit 1
fi
# 2. The provides exclusion still doing its job. A package advertising libcoreclr.so to the whole
# system is the thing that exclusion exists to prevent, and a future rpm that stopped honouring
# the macro would break it silently and in the direction nobody looks.
if printf '%s\n' "$provides" | grep -qE '^lib.*\.so'; then
echo "This package advertises bundled libraries to the system:" >&2
printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&2
echo "__provides_exclude_from in build/linux/dodossh.spec is meant to stop that." >&2
exit 1
fi
# 3. elfdeps having actually run. If it silently did nothing — a runner image whose rpm was built
# without libelf would do exactly that — the package would carry no glibc requirement at all and
# would install cheerfully onto a host too old to run it. libc.so.6 is the one dependency a
# self-contained .NET tree cannot fail to have.
if ! printf '%s\n' "$requires" | grep -q '^libc\.so\.6'; then
echo "This package does not require libc, which a self-contained .NET tree cannot be true of." >&2
echo "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2
printf '%s\n' "$requires" >&2
exit 1
fi
# 4. The four files a person interacts with, named individually. The tree under /opt is thousands of
# entries and its absence would be caught by size alone; these are the small ones whose absence
# would leave a package that installs, occupies 130 MB and puts nothing on the menu.
for expected in \
"/opt/$dir/DodoSSH" \
"/usr/bin/$bin" \
"/usr/share/applications/$bin.desktop" \
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
do
if ! printf '%s\n' "$files" | grep -qxF "$expected"; then
echo "The package was built and $expected is not in it." >&2
printf '%s\n' "$files" | head -40 >&2
exit 1
fi
done
# 5. Nothing world-writable, which on this package is a security property rather than a lint rule.
# The spec sets every mode explicitly and says why; this is the check that it worked, because the
# failure it guards against arrives by inheritance from a publish directory rather than by anybody
# changing this. A world-writable /opt/DodoSSH lets any local account replace the SSH client every
# other account on the machine runs.
#
# Symlinks are excluded because they are always 777 on Linux and the mode means nothing; what the
# link points at is what has permissions.
writable="$(
rpm -qp --queryformat '[%{FILEMODES:perms} %{FILENAMES}\n]' "$rpm_file" |
grep -v '^l' |
awk '$1 ~ /^.{8}w/ { print }'
)"
if [ -n "$writable" ]; then
echo "This package contains world-writable files:" >&2
printf '%s\n' "$writable" >&2
echo "Any local account could replace them. See the mode block in build/linux/dodossh.spec." >&2
exit 1
fi
echo "==> requires:"
printf '%s\n' "$requires" | sed 's/^/ /'
echo "==> $(basename "$rpm_file") is a $name $rpm_version package and says so."
# ◆ THE MANIFEST, WHICH IS WHAT AN INSTALLED CLIENT READS AND NOT WHAT A PERSON READS.
#
# The same arrangement the android job publishes, for the same reason its comment gives: a release's own
# JSON is a large document that changes shape when the forge is upgraded, and the client fetches this on a
# six-hourly timer. A small file this repository writes is a contract; a large one the forge writes is not.
#
# ◆ THE VERSION IN HERE IS THE RPM VERSION, AND THAT IS THE WHOLE POINT OF THE FILE.
#
# LinuxUpdateChannel compares with rpmvercmp, because rpm is what will accept or refuse the install — the
# same argument AndroidChannelManifest makes for versionCode. Publishing the SemVer instead would mean the
# client comparing one thing and the package manager another, and they disagree exactly where it matters:
# 0.0.1-alpha.0.143 and 0.0.1~alpha.0.143 are the same build, and only the second one sorts below 0.0.1.
#
# The SemVer goes in too and is used for nothing but the banner. A person asked which nightly they are on
# should be able to answer with the string the preferences screen prints, and that screen reads the
# assembly.
#
# ◆ AND THE DIGEST, WHICH THE OTHER TWO CHANNELS HAVE NO NEED OF.
#
# Velopack verifies what it downloads and Android refuses a package whose signature does not match the
# installed one. Nothing about an RPM fetched over HTTPS and handed to PackageKit checks that it is the
# file this release published — TLS authenticates the forge and says nothing about the bytes surviving a
# proxy, a cache or a partial write. So the manifest names the digest and the client refuses anything else.
#
# It is not a signature and must not be read as one: it is written by the same job that writes the package,
# so it proves integrity and not provenance. Provenance here is that the address is a constant pointing at
# the project's own forge — ADR 0013 rule 4 — and a signed repository is what would improve on it.
manifest="$out/linux-$channel.json"
digest="$(sha256sum "$rpm_file" | cut -d' ' -f1)"
cat > "$manifest" <<JSON
{
"rpmVersion": "$rpm_version-$rpm_release",
"version": "$version",
"rpm": "$(basename "$rpm_file")",
"sha256": "$digest"
}
JSON
echo "==> $(basename "$manifest"):"
sed 's/^/ /' "$manifest"
# The lines the calling job reads. Written to stdout as well as to GITHUB_OUTPUT when there is one, so the
# desk use above still tells you where the file went.
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "rpm=$rpm_file"
echo "rpmName=$(basename "$rpm_file")"
echo "manifest=$manifest"
echo "manifestName=$(basename "$manifest")"
} >> "$GITHUB_OUTPUT"
fi
echo "$rpm_file"
@@ -450,6 +450,17 @@
<Setter Property="Fill" Value="{StaticResource Warn}" />
</Style>
<!--
Red for a shell whose connection broke, splitting the grey that used to cover every dead tab. A shell
somebody exited and a shell that was cut off are the two dead states with the least in common, and on a
phone the difference matters more than it does on a desk: a mobile link drops on its own, and the tab
that went quiet while the screen was off is the one worth dialling again. See
TerminalTabViewModel.IsDropped.
-->
<Style Selector="Ellipse.dot.dropped">
<Setter Property="Fill" Value="{StaticResource Danger}" />
</Style>
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
<Style Selector="TextBlock.mono">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
@@ -48,7 +48,12 @@
the refusal has no continue button here either.
-->
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto">
<!--
Nine rows since the disconnect strip arrived; it is row 7, between the listing and the action bar, so
that what it says is next to the buttons it replaces the point of. Auto and empty except while the
session has been lost, so an ordinary browse pays nothing for it.
-->
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto,Auto">
<!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
@@ -78,7 +83,13 @@
</Grid>
<!-- ============ opening one ============ -->
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding !IsConnected}">
<!--
ShowsRemoteInvitation rather than !IsConnected, which stopped being the same question when a
connection could be lost rather than only closed: a session that went away leaves its listing on
screen, and this card springing back over it would hide the one thing the strip below is offering to
put the user back into. See TransfersViewModel.ShowsRemoteContents.
-->
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding ShowsRemoteInvitation}">
<StackPanel Spacing="10">
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsHostPicker}"
@@ -186,7 +197,8 @@
IME action, and a path box whose only commit was a key that may say "newline" would be a box nothing
could be submitted from. The Enter binding stays for the hardware keyboards this head does meet.
-->
<Grid Grid.Row="3" ColumnDefinitions="*,8,Auto" Margin="14,0,14,6" IsVisible="{Binding IsConnected}">
<Grid Grid.Row="3" ColumnDefinitions="*,8,Auto" Margin="14,0,14,6"
IsVisible="{Binding ShowsRemoteContents}">
<TextBox Grid.Column="0" Classes="field" Text="{Binding RemotePathDraft}"
PlaceholderText="/path/to/somewhere">
<TextBox.KeyBindings>
@@ -197,9 +209,24 @@
Command="{Binding GoToTypedRemotePathCommand}" />
</Grid>
<!--
◆ How many are gathered up, and the one gesture that gathers or releases them all. Only while
something is — an always-present row would spend 30 pixels of a listing on a count of zero.
-->
<Grid Grid.Row="3" ColumnDefinitions="Auto,*" Margin="14,0,14,6" VerticalAlignment="Bottom"
IsVisible="{Binding HasCheckedRemote}">
<Button Grid.Column="0" Classes="secondary" MinHeight="36" Padding="12,0"
Command="{Binding ToggleAllRemoteCommand}">
<TextBlock Classes="label" FontSize="9" Text="ALL / NONE" />
</Button>
<TextBlock Grid.Column="1" Classes="mono" FontSize="12" Margin="10,0,0,0"
VerticalAlignment="Center" Foreground="{StaticResource AccentText}"
Text="{Binding CheckedRemoteLabel}" />
</Grid>
<!-- ============ where we are ============ -->
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding IsConnected}" Margin="0,0,0,4">
IsVisible="{Binding ShowsRemoteContents}" Margin="0,0,0,4">
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="10"
Command="{Binding RemoteUpCommand}">
@@ -265,7 +292,7 @@
<!-- ============ the listing ============ -->
<Panel Grid.Row="6">
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
<TextBlock Classes="body" IsVisible="{Binding ShowsRemoteInvitation}" Margin="24,12"
VerticalAlignment="Top" Text="{Binding Status}" />
<!--
@@ -282,7 +309,7 @@
selection navigate would send a single desktop click walking into a directory.
-->
<ListBox ItemsSource="{Binding RemoteEntries}" SelectedItem="{Binding SelectedRemoteEntry}"
Background="Transparent" BorderThickness="0" IsVisible="{Binding IsConnected}"
Background="Transparent" BorderThickness="0" IsVisible="{Binding ShowsRemoteContents}"
Tapped="OnRemoteEntryTapped">
<ListBox.Styles>
<Style Selector="ListBoxItem">
@@ -299,15 +326,27 @@
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
<Border MinHeight="52" Padding="10,7">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
<!--
◆ The tick, which on this head is the only multi-selection there can be: a phone has no
ctrl-click and no shift-click. It is not the list's selection — that stays "the row I am
looking at", which RENAME, MOVE and PERMS act on one at a time — and this is "the rows I
have gathered up", which SAVE and DELETE take. See RemoteEntryRowViewModel.IsChecked.
Its own 40-pixel column so a thumb aimed at it does not open the directory beside it: the
row's own tap navigates, which is this head's one behaviour the desktop does not share.
-->
<CheckBox Grid.Column="0" Width="40" MinWidth="0" VerticalAlignment="Center"
IsChecked="{Binding IsChecked}" Click="OnRemoteRowChecked" />
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
VerticalAlignment="Center" FontSize="12" Text="▸"
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
VerticalAlignment="Center" FontSize="12" Text="·"
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
<StackPanel Grid.Column="1" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
<StackPanel Grid.Column="2" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="7">
@@ -318,7 +357,7 @@
</StackPanel>
</StackPanel>
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Modified}"
<TextBlock Grid.Column="3" Classes="detail" Text="{Binding Modified}"
VerticalAlignment="Center" />
</Grid>
</Border>
@@ -327,9 +366,50 @@
</ListBox>
</Panel>
<!-- ============ what the screen says once the session has stopped ============ -->
<!--
The phone's half of the desktop's SessionEndedBar, and it binds this screen's own view model rather
than the shell's — this control's DataContext is TransfersViewModel, where the desktop's strip sits in
MainWindow beside a status bar and can ask which surface it is on. The facts are the same two:
TransfersViewModel.LostConnection and the reconnect that goes back to the directory showing.
A phone needs it more than a desk does, and for the reason its terminal does: a mobile link drops on
its own and the screen goes off, so this session is discovered later with a listing on it and no
account of why nothing works. The listing stays deliberately — see ShowsRemoteContents.
-->
<Border Grid.Row="7" IsVisible="{Binding IsConnectionLost}" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Danger}" BorderThickness="0,1,0,0" Padding="12,10">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<Ellipse Classes="dot" Classes.dropped="True" Width="7" Height="7" VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="9" Text="DISCONNECTED" />
</StackPanel>
<!--
Two lines maximum, for the reason the terminal's strip caps its own: a transport error can hand
over whatever the operating system called it, and a strip that grew to fit one would eat the
listing above it on a screen that has none to spare.
-->
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
Text="{Binding LostConnection}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="row" MinHeight="44" Padding="14,0" Command="{Binding ReconnectCommand}">
<TextBlock Classes="label" FontSize="9" Text="RECONNECT" />
</Button>
<Button Classes="row" MinHeight="44" Padding="14,0" Command="{Binding DisconnectCommand}">
<TextBlock Classes="label" FontSize="9" Text="CLOSE" />
</Button>
</StackPanel>
</StackPanel>
</Border>
<!-- ============ what to do with the chosen entry ============ -->
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
<Border Grid.Row="7" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
<Border Grid.Row="8" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
<StackPanel Spacing="9">
@@ -381,6 +461,75 @@
</StackPanel>
</Border>
<!--
◆ The mode, as nine boxes and the number they add up to — in the place the other two questions
already occupy and never at the same time as either. See RemotePermissionsRequest for why it is
boxes rather than a field to type 755 into, and TransfersScreen.axaml for the desktop's own copy
of the same form.
Compact rather than a second layout: the three columns are the mode's own three triples, and at
360dp they are exactly what fits. The octal is printed underneath as it changes, which is what
lets somebody who came here to type chmod check the boxes they just ticked.
-->
<Border IsVisible="{Binding IsChangingRemotePermissions}" Background="{StaticResource Raised}"
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
Padding="13,11">
<StackPanel Spacing="6">
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingRemotePermissions.Question}" />
<TextBlock Classes="detail" TextWrapping="Wrap"
Text="{Binding PendingRemotePermissions.FullPath}" />
<Grid ColumnDefinitions="Auto,*,*,*" RowDefinitions="Auto,44,44,44" Margin="0,2">
<TextBlock Grid.Row="0" Grid.Column="1" Classes="detail" Text="read"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="2" Classes="detail" Text="write"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="3" Classes="detail"
Text="{Binding PendingRemotePermissions.ExecuteLabel}"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="1" Grid.Column="0" Classes="mono" FontSize="12" Text="owner"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerRead}" />
<CheckBox Grid.Row="1" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerWrite}" />
<CheckBox Grid.Row="1" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerExecute}" />
<TextBlock Grid.Row="2" Grid.Column="0" Classes="mono" FontSize="12" Text="group"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupRead}" />
<CheckBox Grid.Row="2" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupWrite}" />
<CheckBox Grid.Row="2" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupExecute}" />
<TextBlock Grid.Row="3" Grid.Column="0" Classes="mono" FontSize="12" Text="others"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersRead}" />
<CheckBox Grid.Row="3" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersWrite}" />
<CheckBox Grid.Row="3" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersExecute}" />
</Grid>
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource AccentText}"
Text="{Binding PendingRemotePermissions.Preview}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="primary" Height="44" Content="APPLY"
Command="{Binding ConfirmRemotePermissionsCommand}"
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelRemotePermissionsCommand}" />
</Grid>
</StackPanel>
</Border>
<!--
◆ The queue, above the actions and only when it has something in it. Bounded and scrolling rather
than growing: five files queued must not push the buttons off the bottom of the screen, which on a
@@ -457,11 +606,23 @@
Command="{Binding MoveRemoteCommand}" IsEnabled="{Binding CanMoveRemote}" />
</Grid>
<!--
◆ PERMS beside DELETE rather than on the row above, which is full: it is the fourth thing that can
be done to a chosen row and the third that puts one right. Dark on a bucket, where an object has an
access policy rather than nine bits — see CanChangeRemotePermissions.
-->
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
<Button Grid.Column="0" Classes="secondary" Height="44" Content="PERMS"
Command="{Binding ChangeRemotePermissionsCommand}"
IsEnabled="{Binding CanChangeRemotePermissions}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="DELETE"
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CLOSE"
</Grid>
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CLOSE"
Command="{Binding DisconnectCommand}" />
<Panel Grid.Column="2" />
</Grid>
<TextBlock Classes="body" IsVisible="{Binding !IsAskingAboutRemoteEntry}"
@@ -35,6 +35,15 @@ internal sealed partial class FilesScreen : UserControl
/// read its size the same gesture as fetching it.
/// </para>
/// </remarks>
/// <summary>Tells the screen a row's tick moved, so the count above the listing keeps up.</summary>
/// <remarks>
/// The desktop's own handler, for the reason given there: the count is a property of the collection
/// rather than of any row, and each listing replaces every row it has. See
/// <c>TransfersViewModel.RemoteChecksChanged</c>.
/// </remarks>
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) =>
(DataContext as TransfersViewModel)?.RemoteChecksChanged();
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
{
if (DataContext is TransfersViewModel { SelectedRemoteEntry.IsNavigable: true } transfers)
@@ -114,7 +114,13 @@
<Panel>
<Grid RowDefinitions="Auto,*,Auto">
<!--
Four rows since the disconnect strip arrived. It is Auto and empty except while the selected tab's shell
has stopped, so an ordinary session pays nothing for it — and when it is up it takes its height off the
renderer rather than drawing over it, which on this head is not optional either: the renderer is a native
child view composited above everything Avalonia draws, so a banner over its rectangle would be invisible.
-->
<Grid RowDefinitions="Auto,*,Auto,Auto">
<!-- ============ the bar ============ -->
<!--
@@ -177,11 +183,13 @@
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!--
Green only while there is a shell behind it, amber while one is being made; see
the same dot in PhoneShell, and Phone.axaml for why amber is not a rule broken.
Green only while there is a shell behind it, amber while one is being made, and
red where a connection broke rather than a shell exiting; see the same dot in
PhoneShell, and Phone.axaml for why neither of the last two is a rule broken.
-->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
Classes.connecting="{Binding IsConnecting}"
Classes.dropped="{Binding IsDropped}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
Text="{Binding Label}" />
@@ -421,6 +429,18 @@
ordinary way, through ShowScreenCommand, exactly as the rail and MORE reach it.
-->
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Left">
<!--
Only for a refusal, and it runs the same command the disconnect strip below the renderer does —
the vault's own connect path, back into this same tab. Worth more on a phone than on a desk: the
commonest refusal here is a mobile link that was not up yet, and the alternative is going back
to the hosts screen to find a machine whose name is already on this card.
-->
<Button Classes="row" MinHeight="44" Padding="14,0"
Command="{Binding ReconnectTabCommand}" CommandParameter="{Binding SelectedTab}"
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
IsVisible="{Binding SelectedTab.IsFailed}">
<TextBlock Classes="label" FontSize="9" Text="TRY AGAIN" />
</Button>
<Button Classes="row" MinHeight="44" Padding="14,0"
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
@@ -470,7 +490,56 @@
and a terminal whose last line of output is flush against a row of grey keys reads as one surface
that has gone wrong rather than as two that are different things.
-->
<Border Grid.Row="2" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
<!-- ============ what a terminal says once its shell has stopped ============ -->
<!--
The phone's half of SessionEndedBar, and it needs it more than the desk does: a mobile link drops on
its own, the screen goes off, and the tab that went quiet is discovered later with no idea whether the
shell was exited or cut off. So it names which, prints the sentence that came with it, and offers the
one useful answer.
Two lines rather than the desktop's one, because 360dp cannot hold a word, a sentence and two buttons
across. The word and the buttons share the top line — the buttons are where a thumb already is — and
the sentence wraps underneath at two lines maximum, which is enough for anything sshd or a socket
error actually says and short of a strip that eats the terminal.
RECONNECT is disabled rather than hidden when there is nothing to dial: a machine typed in by hand has
no keychain entry to redial, and a control visibly refusing answers "why not" better than an absent one.
See MainWindowViewModel.CanReconnectSelectedTab.
-->
<Border Grid.Row="2" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="16,8"
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<Ellipse Classes="dot" Classes.dropped="{Binding IsSessionDropped}" Width="7" Height="7"
VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="9" Text="{Binding SessionEndedText}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Classes="row" MinHeight="40" Padding="12,0"
Command="{Binding ReconnectTabCommand}" CommandParameter="{Binding SelectedTab}"
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}">
<TextBlock Classes="label" FontSize="9" Text="RECONNECT" />
</Button>
<Button Classes="row" MinHeight="40" Padding="12,0"
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
<TextBlock Classes="label" FontSize="9" Text="CLOSE" />
</Button>
</StackPanel>
</Grid>
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
Text="{Binding SessionEndedDetail}"
IsVisible="{Binding SessionEndedDetail, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</Border>
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
<Grid ColumnDefinitions="*,Auto">
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
+11
View File
@@ -1282,6 +1282,17 @@
<Setter Property="Fill" Value="{StaticResource Warn}" />
</Style>
<!--
Red, and it is the fourth state this dot has rather than a recolouring of the grey one. Grey is a
shell that ended — somebody typed `exit`, and nothing is wrong with the machine. This is a connection
that went out from under a session that had not finished, which is the one worth looking at: the two
used to be the same dot, so a tab that had been cut off and a tab somebody had closed down properly
were indistinguishable in the strip. See TerminalTabViewModel.EndedSummary.
-->
<Style Selector="Ellipse.dot.dropped">
<Setter Property="Fill" Value="{StaticResource Danger}" />
</Style>
<!--
The accent strip a selected row carries, drawn by the row template rather than by the item, because the
item's presenter is the thing the theme keeps repainting.
+110 -12
View File
@@ -113,14 +113,43 @@ internal sealed partial class DodoSshApp : Application
var browser = new SystemBrowserLauncher();
// Both chosen once, here, because each is a property of the machine rather than of any session. A
// computer with a usable TPM gets the store that keeps a device key behind a Windows consent prompt;
// anything else gets one that reports itself unavailable, so unlock keeps asking for the passphrase
// Windows box with a usable TPM gets a consent prompt, a Mac with a Secure Enclave gets Touch ID, a
// Linux box whose TPM this user may open gets a PIN the TPM checks — see PinPrompt — and anything
// else gets a store that reports itself unavailable, so unlock keeps asking for the passphrase
// (ADR 0007). The update channel answers the same shape of question about how this copy was
// installed, and a build run from a checkout likewise gets one that says so. See ADR 0013.
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths, PinPrompt(desktop));
var updates = UpdateChannels.ForThisMachine();
var viewModel = new MainWindowViewModel(
var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections,
browser, updates);
desktop.MainWindow = new MainWindow { DataContext = viewModel };
// Started rather than awaited: the framework's initialisation must not block on a schema
// migration. The view model shows its own progress and handles its own failures, which is why
// discarding the task here is safe rather than merely convenient.
_ = viewModel.StartAsync(CancellationToken.None);
WireShutdown(desktop, viewModel, workspace, caches);
}
/// <summary>Composes the shell itself, with everything this platform answers for it.</summary>
/// <remarks>
/// Split out of <see cref="Compose"/> for length rather than for reuse — it has one caller, and the
/// method it came out of is at the limit the analyzers set.
/// </remarks>
private static MainWindowViewModel BuildShell(
IClassicDesktopStyleApplicationLifetime desktop,
ClientPaths paths,
ClientCacheFactory caches,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
IDeviceKeyStore deviceKeys,
SshNetConnectionFactory connections,
SystemBrowserLauncher browser,
IUpdateChannel updates) =>
new(
paths,
caches,
workspace,
@@ -140,18 +169,87 @@ internal sealed partial class DodoSshApp : Application
.ConfigureAwait(false),
copyToClipboard: ClipboardWriter(desktop),
updates: updates);
updates: updates,
openInEditor: OpenInDefaultEditorAsync,
watchFile: WatchFile);
desktop.MainWindow = new MainWindow { DataContext = viewModel };
/// <summary>
/// Hands a local file to whatever this machine opens that kind of file with.
/// </summary>
/// <remarks>
/// <para>
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
/// Services binding depending on which desktop this is, and watching a file is a platform object with
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
/// </para>
/// <para>
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
/// </para>
/// <para>
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
/// </para>
/// </remarks>
private static Task OpenInDefaultEditorAsync(string path)
{
using var opened = System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
// Started rather than awaited: the framework's initialisation must not block on a schema
// migration. The view model shows its own progress and handles its own failures, which is why
// discarding the task here is safe rather than merely convenient.
_ = viewModel.StartAsync(CancellationToken.None);
WireShutdown(desktop, viewModel, workspace, caches);
return Task.CompletedTask;
}
/// <summary>
/// Watches one file for saves, until the handle is disposed.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The directory is watched and the name filtered, which is not the same as watching the file.</b>
/// A great many editors do not write in place: they write a temporary file beside the original and
/// rename it over the top, which to a watcher pointed at an inode is the file disappearing. Watching the
/// containing directory for that name catches the rename as a <c>Created</c>, which is what makes this
/// work with the editors people actually use.
/// </para>
/// <para>
/// <b>Every kind of change is one callback, and the shell debounces nothing.</b> One save can raise two
/// or three events; what the shell does with each is stat the host and compare, which is a round trip
/// but a small one, and the alternative — a timer here — is a delay before somebody's edit reaches the
/// server for the sake of saving it.
/// </para>
/// </remarks>
private static FileSystemWatcher WatchFile(string path, Action onChanged)
{
var watcher = new FileSystemWatcher(
Path.GetDirectoryName(path)!, Path.GetFileName(path))
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName,
};
watcher.Changed += (_, _) => onChanged();
watcher.Created += (_, _) => onChanged();
watcher.Renamed += (_, _) => onChanged();
watcher.EnableRaisingEvents = true;
return watcher;
}
/// <summary>How the Linux device key store asks for a PIN, and the only thing that ever calls it.</summary>
/// <remarks>
/// The lifetime is captured rather than the window, so this can be composed before there is one —
/// composition runs first, and <c>DevicePinWindow.AskAsync</c> answers null when there is nothing to
/// sit over. Windows and macOS never reach it: on those platforms the consent dialog is the operating
/// system's and this process is not in the conversation. See ADR 0007.
/// </remarks>
private static Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> PinPrompt(
IClassicDesktopStyleApplicationLifetime desktop) =>
(purpose, cancellationToken) =>
DevicePinWindow.AskAsync(() => desktop.MainWindow, purpose, cancellationToken);
/// <remarks>
/// Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening socket, and
/// blocking the UI thread on their disposal is how an application comes to take several seconds to close —
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 B

@@ -225,3 +225,37 @@ $icnsTarget = Join-Path $PSScriptRoot 'dodossh.icns'
$icns.Dispose()
Write-Output "Wrote $icnsTarget ($($icnsTypes.Count) entries, $((Get-Item $icnsTarget).Length) bytes)"
# ---- dodossh-<size>.png, for the Linux desktop entry ---------------------------------------------
#
# The third platform, and the only one that wants the raster loose rather than in a container. A
# freedesktop icon theme is a directory tree — hicolor/48x48/apps/dodossh.png and friends — and the
# RPM built by scripts/ci-linux-rpm.sh installs one file per size into it.
#
# Written here for the reason this whole file exists: one geometry, regenerated in one command. The
# alternative was to have the packaging script pull the 256px frame out of dodossh.ico at build time,
# which works — the .ico's entries really are PNGs — and puts a second place that has to know how an
# ICO directory is laid out. It also silently ties the Linux icon to whatever sizes Windows happens
# to want.
#
# ◆ THE TILE BLEEDS TO THE EDGE HERE, AS IT DOES ON WINDOWS.
#
# tileFraction stays 1.0. The 0.8047 inset above is Apple's icon grid and nothing else — GNOME, KDE
# and every panel that reads hicolor draw the icon at the size they asked for, with no shadow and no
# magnification to leave air for. An inset icon would simply be the small one in the launcher.
#
# ◆ AND THE SIZES ARE NOT A ROUND NUMBER OF THEM.
#
# 48 is the size a GNOME dash and most panels ask for, 128 and 256 are what a large-icon file manager
# and GNOME Software use, and 64 is between them because the 48 upscaled to it is visibly soft. There
# is deliberately nothing below 48: the hairline floor in New-MarkPng keeps a 16px mark legible, but
# no Linux desktop asks this theme for one — window titlebars there draw no icon at all.
$linuxSizes = @(48, 64, 128, 256)
foreach ($size in $linuxSizes)
{
[byte[]]$png = New-MarkPng $size
$pngTarget = Join-Path $PSScriptRoot "dodossh-$size.png"
[System.IO.File]::WriteAllBytes($pngTarget, $png)
Write-Output "Wrote $pngTarget ($((Get-Item $pngTarget).Length) bytes)"
}
@@ -151,6 +151,14 @@
interface declared in DodoSSH.Client.Session.
-->
<PackageReference Include="Velopack" />
<!--
Here for the same reason and with the same boundary as Velopack above: LinuxDeviceKeyStore is a
Linux-only implementation of an interface declared in DodoSSH.Client.Session, and it lives in
Platform/ beside the Windows and macOS stores. The Android head has its own keystore and must
never see this one.
-->
<PackageReference Include="Microsoft.TSS" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,491 @@
using System.Buffers.Binary;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.Session;
using Tpm2Lib;
namespace DodoSSH.Client.App.Platform;
/// <summary>Why the store is asking for a PIN.</summary>
/// <remarks>
/// The two cases need different words in front of a person — one is choosing a PIN and the other is
/// entering one — and the store is the only thing that knows which is happening. Passing an enum rather
/// than two delegates keeps the prompt a single seam.
/// </remarks>
public enum DevicePinPurpose
{
/// <summary>A PIN is being chosen, as part of registering this machine.</summary>
Choosing,
/// <summary>A PIN is being asked for, to unlock.</summary>
Entering,
}
/// <summary>
/// Keeps the device key sealed inside the TPM, released only against a PIN the TPM itself checks.
/// </summary>
/// <remarks>
/// <para>
/// The Linux answer to <see cref="WindowsDeviceKeyStore"/> and <c>MacDeviceKeyStore</c>, and ADR 0007's
/// bar is the one it has to clear: <b>the enforcement must be the platform's rather than this process's.</b>
/// A store that drew its own prompt over a file it could read anyway would be the mistake that ADR's
/// 2026-07-30 amendment exists to record — malware running as the user skips the prompt and reads the file.
/// </para>
/// <para>
/// <b>Here the TPM holds the key and the TPM checks the PIN.</b> The 32-byte device key is sealed into a
/// keyedhash object whose <c>userAuth</c> is derived from the PIN, so unsealing is a command the TPM
/// refuses without it. The blob on disk is encrypted to the TPM's storage hierarchy and is useless on any
/// other machine, and the PIN is never compared by anything in this assembly. Malware running as the user
/// can ask; it cannot answer.
/// </para>
/// <para>
/// <b>And the TPM counts the failures, which is what makes a short PIN safe.</b> The sealed object
/// deliberately does not carry <c>NoDA</c>, so every wrong answer advances the dictionary-attack counter
/// and the TPM stops answering at all once it trips. Measured against a software TPM: four wrong PINs and
/// it refuses the right one too, until the lockout recovers. That is the property a four-digit secret has
/// on this platform and cannot have in a file.
/// </para>
/// <para>
/// ◆ <b>What this does NOT match, stated because the difference is real and invisible.</b> Windows Hello
/// and Touch ID raise their prompt from the platform — a secure desktop on one, a system sheet on the
/// other — so the process asking never sees the secret. Linux has no equivalent to borrow, so the PIN is
/// typed into a window this application draws, and a sufficiently determined attacker already running as
/// the user could draw a convincing copy of it. The mitigation is that they could do the same for the
/// passphrase screen, which is a much larger prize, so this is not a capability the feature creates. It is
/// still a weaker guarantee than the other two desktops have and ADR 0007 says so rather than implying
/// parity.
/// </para>
/// <para>
/// <b>Availability still depends on reaching the device, and the package arranges that rather than asking
/// the user to.</b> Fedora leaves <c>/dev/tpmrm0</c> as <c>root:tss</c> mode 0660, so without help this
/// process could not open it on an ordinary desktop account. The RPM installs
/// <c>build/linux/71-dodossh-tpm.rules</c>, which tags the device <c>uaccess</c> — logind then grants an
/// ACL to whoever is logged in at the machine, and takes it away when they log out. Not over SSH and not
/// to a user on an inactive VT.
/// <para>
/// So a build run from a checkout, or one on a machine where an administrator has masked that rule, still
/// finds the device unreadable and reports unavailable — which keeps the passphrase path exactly as it was.
/// The rule carries its own reasoning, including the lockout denial-of-service it accepts; see ADR 0007
/// decision 4.
/// </para>
/// </remarks>
[SupportedOSPlatform("linux")]
public sealed class LinuxDeviceKeyStore : IDeviceKeyStore
{
/// <summary>What the blob on disk starts with, so a file from elsewhere is refused rather than parsed.</summary>
private static readonly byte[] Magic = "DSHTPMv1"u8.ToArray();
/// <summary>
/// Domain separation for the PIN, so the value handed to the TPM is this application's and not the
/// user's PIN as typed.
/// </summary>
/// <remarks>
/// The TPM compares an authValue of at most one digest in length, so something has to reduce a typed
/// string to bytes; hashing with a label rather than truncating means a PIN reused elsewhere does not
/// arrive at the TPM as the same bytes it does there. It buys nothing against an attacker who has the
/// blob and the machine — they have this constant too — and it costs one line.
/// </remarks>
private const string PinLabel = "dodossh/devicekey/pin/v1";
private readonly ClientPaths paths;
private readonly Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin;
private readonly Func<Tpm2Device> openDevice;
/// <summary>Creates a store against this machine's TPM.</summary>
/// <param name="paths">Where the sealed blob lives.</param>
/// <param name="askForPin">
/// Shows the PIN prompt and answers with what was typed, or null if the user declined. Declining is
/// ordinary — see <see cref="IDeviceKeyStore"/> — and lands on the passphrase.
/// </param>
public LinuxDeviceKeyStore(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
: this(paths, askForPin, static () => new LinuxTpmDevice())
{
}
/// <summary>Creates a store against a named TPM, which is how the tests reach a simulator.</summary>
/// <remarks>
/// The seam exists because this class is testable in a way the other two stores are not, and that is
/// worth keeping. Windows and macOS put the prompt inside the platform, so a test that exercised
/// sealing would block forever on a dialog — <c>WindowsDeviceKeyStoreTests</c> records the ten minutes
/// that took to discover. Here the prompt is a delegate and the TPM is a device, so both can be
/// supplied and the whole path runs unattended.
/// </remarks>
internal LinuxDeviceKeyStore(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin,
Func<Tpm2Device> openDevice)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(askForPin);
ArgumentNullException.ThrowIfNull(openDevice);
this.paths = paths;
this.askForPin = askForPin;
this.openDevice = openDevice;
}
/// <summary>
/// The parent every sealed object hangs off, as a template rather than as a stored handle.
/// </summary>
/// <remarks>
/// <para>
/// <b>Recreated from a fixed template on every use, and not made persistent.</b> A persistent handle
/// would occupy one of the TPM's small number of NV slots forever, would have to be cleaned up by
/// <see cref="ForgetAsync"/>, and would collide with whatever else on the machine had chosen the same
/// index. Deriving it costs a command and leaves nothing behind.
/// </para>
/// <para>
/// <b>ECC P-256 rather than RSA-2048, and that is a user-visible choice.</b> The two are equally
/// standard as storage roots. Creating an RSA primary on a discrete TPM takes seconds — the key is
/// generated, not derived — and this runs on the unlock path, so it would show up as the application
/// hanging every time somebody opened their vault. The prototype this was built from used RSA and that
/// is exactly what it was changed for.
/// </para>
/// <para>
/// <c>NoDA</c> is set <em>here</em> and deliberately not on the sealed object. The parent has no
/// authValue to guess, so counting failures against it would only give an attacker a way to lock the
/// machine's TPM out by asking for something they were never going to get.
/// </para>
/// </remarks>
private static TpmPublic PrimaryTemplate => new(
TpmAlgId.Sha256,
ObjectAttr.Restricted | ObjectAttr.Decrypt | ObjectAttr.FixedTPM | ObjectAttr.FixedParent
| ObjectAttr.SensitiveDataOrigin | ObjectAttr.UserWithAuth | ObjectAttr.NoDA,
null,
new EccParms(
new SymDefObject(TpmAlgId.Aes, 128, TpmAlgId.Cfb),
new NullAsymScheme(),
EccCurve.TpmEccNistP256,
new NullKdfScheme()),
new EccPoint());
/// <summary>
/// The sealed object: bytes the TPM holds, released against an authValue the TPM checks.
/// </summary>
/// <remarks>
/// Keyedhash with a null scheme is TPM 2.0's "just keep these bytes" object. <c>UserWithAuth</c> is
/// what makes the authValue the thing that opens it, and the deliberate <em>absence</em> of
/// <c>NoDA</c> is what puts every wrong PIN on the dictionary-attack counter. Removing that one
/// attribute would leave a four-digit secret that could be guessed at full speed by anything holding
/// the blob and the machine, which is the whole of the protection this class offers.
/// </remarks>
private static TpmPublic SealedTemplate => new(
TpmAlgId.Sha256,
ObjectAttr.FixedTPM | ObjectAttr.FixedParent | ObjectAttr.UserWithAuth,
null,
new KeyedhashParms(new NullSchemeKeyedhash()),
new Tpm2bDigestKeyedhash());
/// <summary>Whether this machine has a TPM this process may use.</summary>
/// <remarks>
/// Probes by creating and flushing a primary rather than by looking for the device node, for the
/// reason <c>WindowsDeviceKeyStore.IsSupported</c> gives about its own provider: the node can be
/// present on a machine where the first real command then fails. Permissions are the common case here
/// and they fail at open; an owner hierarchy with an authValue set is the rarer one and fails at
/// CreatePrimary. Neither is a fault and both mean the same thing.
/// </remarks>
internal static bool IsSupported() => IsSupported(static () => new LinuxTpmDevice());
/// <inheritdoc cref="IsSupported()" />
internal static bool IsSupported(Func<Tpm2Device> openDevice)
{
ArgumentNullException.ThrowIfNull(openDevice);
try
{
using var device = openDevice();
device.Connect();
var tpm = new Tpm2(device);
var primary = CreatePrimary(tpm);
tpm.FlushContext(primary);
return true;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Every way this fails means the same thing to the caller: no device key here, so keep asking
// for the passphrase. A missing device node throws IOException, a device this user may not
// open throws UnauthorizedAccessException, and a TPM that refuses the command throws
// TpmException — and on a machine with no TPM at all the constructor itself may throw before
// any of that.
return false;
}
}
/// <inheritdoc />
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(IsSupported(openDevice));
/// <inheritdoc />
/// <remarks>
/// Asks for a PIN first and writes nothing if the user declines, which matters more here than the
/// symmetry suggests: <c>SaveAsync</c> is called as part of registering this machine, and a half-done
/// registration would leave a wrap on the server that nothing can open.
/// </remarks>
public async ValueTask SaveAsync(
ReadOnlyMemory<byte> devicePrivateKey,
CancellationToken cancellationToken)
{
var pin = await askForPin(DevicePinPurpose.Choosing, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(pin))
{
throw new OperationCanceledException("No PIN was chosen, so no device key was stored.");
}
var key = devicePrivateKey.ToArray();
// Off the calling thread, because every command below blocks on the chip. Creating the primary
// alone is hundreds of milliseconds on real hardware, and this is called from the unlock path —
// which on the desktop head is the UI thread.
var packed = await Task.Run(() => Seal(pin, key), cancellationToken).ConfigureAwait(false);
paths.EnsureCreated();
await File.WriteAllBytesAsync(paths.DeviceKeyFile, packed, cancellationToken).ConfigureAwait(false);
}
/// <summary>Seals the key into the TPM and answers with the blob to store.</summary>
private byte[] Seal(string pin, byte[] devicePrivateKey)
{
var auth = PinAuth(pin);
try
{
using var device = openDevice();
device.Connect();
var tpm = new Tpm2(device);
var primary = CreatePrimary(tpm);
try
{
var priv = tpm.Create(
primary,
new SensitiveCreate(auth, devicePrivateKey),
SealedTemplate,
null,
[],
out var pub,
out _,
out _,
out _);
return Pack(pub, priv);
}
finally
{
tpm.FlushContext(primary);
}
}
finally
{
CryptographicOperations.ZeroMemory(auth);
CryptographicOperations.ZeroMemory(devicePrivateKey);
}
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// <b>One attempt per call, and a wrong PIN is not retried here.</b> Every wrong answer advances the
/// TPM's lockout counter, so a loop offering three tries would spend a third of the machine's budget
/// on one mistyped digit. Returning null lands on the passphrase, which always works; somebody who
/// wants to try the PIN again asks for a device unlock again, which is a deliberate act rather than a
/// loop.
/// </para>
/// <para>
/// <b>A locked-out TPM is deliberately indistinguishable from a wrong PIN.</b>
/// <see cref="IDeviceKeyStore.TryLoadAsync"/> asks for exactly that and the reasoning holds here: the
/// caller does the same thing in every case, and "your TPM is in lockout" describes the keystore
/// rather than telling somebody something they can act on — the answer is still to type the
/// passphrase.
/// </para>
/// </remarks>
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(paths.DeviceKeyFile))
{
return null;
}
var stored = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false);
if (!TryUnpack(stored, out var pub, out var priv))
{
return null;
}
// After the blob is known to be readable and before anything is asked of the user, because a
// prompt raised over a file this cannot use is a question with no useful answer.
var pin = await askForPin(DevicePinPurpose.Entering, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(pin))
{
return null;
}
return await Task.Run(() => Unseal(pin, pub, priv), cancellationToken).ConfigureAwait(false);
}
/// <summary>Asks the TPM to release the key, and answers null however it declines.</summary>
private byte[]? Unseal(string pin, TpmPublic pub, TpmPrivate priv)
{
var auth = PinAuth(pin);
try
{
using var device = openDevice();
device.Connect();
var tpm = new Tpm2(device);
var primary = CreatePrimary(tpm);
try
{
var loaded = tpm.Load(primary, priv, pub);
try
{
return tpm[auth].Unseal(loaded);
}
finally
{
tpm.FlushContext(loaded);
}
}
finally
{
tpm.FlushContext(primary);
}
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// TpmException covers the wrong PIN and the lockout; the rest covers a TPM that has gone away
// between the availability check and now, which a suspend or a firmware update can do.
return null;
}
finally
{
CryptographicOperations.ZeroMemory(auth);
}
}
/// <inheritdoc />
/// <remarks>
/// Deleting the blob is the whole of it, and that is a property of sealing rather than an omission.
/// The object lives nowhere but in that file — it was never made persistent inside the TPM, see
/// <see cref="PrimaryTemplate"/> — so the bytes are unrecoverable the moment it is gone. There is no
/// TPM state to clean up and nothing left behind for a later install to trip over.
/// </remarks>
public ValueTask ForgetAsync(CancellationToken cancellationToken)
{
if (File.Exists(paths.DeviceKeyFile))
{
File.Delete(paths.DeviceKeyFile);
}
return ValueTask.CompletedTask;
}
/// <summary>Derives the TPM authValue from the PIN as typed.</summary>
private static byte[] PinAuth(string pin) =>
SHA256.HashData(Encoding.UTF8.GetBytes($"{PinLabel} {pin}"));
private static TpmHandle CreatePrimary(Tpm2 tpm) =>
tpm.CreatePrimary(
TpmRh.Owner,
new SensitiveCreate(),
PrimaryTemplate,
null,
[],
out _,
out _,
out _,
out _);
/// <summary>Puts the two halves of the sealed object in one file.</summary>
/// <remarks>
/// Both are needed to load it and neither is secret — the private half is encrypted to this TPM's
/// storage hierarchy, which is what makes the file useless anywhere else. Length-prefixed rather than
/// concatenated because their sizes vary with the TPM's algorithms, and magic-prefixed so that a file
/// left by another version, or by the Windows store on a dual-boot profile directory, is refused
/// rather than fed to the TPM as a malformed structure.
/// </remarks>
private static byte[] Pack(TpmPublic pub, TpmPrivate priv)
{
var publicPart = pub.GetTpmRepresentation();
var privatePart = priv.GetTpmRepresentation();
var packed = new byte[Magic.Length + 4 + publicPart.Length + 4 + privatePart.Length];
var at = Magic.Length;
Magic.CopyTo(packed.AsSpan());
BinaryPrimitives.WriteInt32BigEndian(packed.AsSpan(at), publicPart.Length);
at += 4;
publicPart.CopyTo(packed.AsSpan(at));
at += publicPart.Length;
BinaryPrimitives.WriteInt32BigEndian(packed.AsSpan(at), privatePart.Length);
at += 4;
privatePart.CopyTo(packed.AsSpan(at));
return packed;
}
/// <summary>Reads back what <see cref="Pack"/> wrote, or answers false.</summary>
private static bool TryUnpack(byte[] stored, out TpmPublic pub, out TpmPrivate priv)
{
pub = new TpmPublic();
priv = new TpmPrivate();
try
{
if (stored.Length < Magic.Length + 8 || !stored.AsSpan(0, Magic.Length).SequenceEqual(Magic))
{
return false;
}
var at = Magic.Length;
var publicLength = BinaryPrimitives.ReadInt32BigEndian(stored.AsSpan(at));
at += 4;
if (publicLength < 0 || at + publicLength + 4 > stored.Length)
{
return false;
}
var publicPart = stored.AsSpan(at, publicLength).ToArray();
at += publicLength;
var privateLength = BinaryPrimitives.ReadInt32BigEndian(stored.AsSpan(at));
at += 4;
if (privateLength < 0 || at + privateLength != stored.Length)
{
return false;
}
var privatePart = stored.AsSpan(at, privateLength).ToArray();
pub = Marshaller.FromTpmRepresentation<TpmPublic>(publicPart);
priv = Marshaller.FromTpmRepresentation<TpmPrivate>(privatePart);
return true;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// A blob that does not parse is a blob this machine cannot use, which is the same answer as no
// blob at all. Throwing here would mean a corrupted file stopped the application from starting.
return false;
}
}
}
@@ -0,0 +1,495 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>What a channel's release says it is publishing.</summary>
/// <remarks>
/// <para>
/// Written by <c>scripts/ci-linux-rpm.sh</c> and read here, and it is a small file this repository
/// controls rather than the forge's own release JSON for the reason the android manifest gives: this is
/// fetched on a six-hourly timer, and a document whose shape changes when the forge is upgraded is not a
/// contract.
/// </para>
/// <para>
/// <b>The comparison is <see cref="RpmVersion"/> and never <see cref="Version"/>.</b> That is the string
/// <c>rpm</c> will order the install against, so comparing anything else would let this offer an update
/// PackageKit then refuses — the same argument <c>AndroidChannelManifest</c> makes for <c>versionCode</c>.
/// The two differ exactly where it matters: <c>0.0.1-alpha.0.143</c> and <c>0.0.1~alpha.0.143</c> are one
/// build, and only the second sorts below <c>0.0.1</c>.
/// </para>
/// </remarks>
/// <param name="RpmVersion">The version-release string rpm knows the package by.</param>
/// <param name="Version">What that build calls itself, for a human.</param>
/// <param name="Rpm">The asset on the same release that holds it.</param>
/// <param name="Sha256">The package's digest, lower-case hex.</param>
internal sealed record LinuxChannelManifest(
[property: JsonPropertyName("rpmVersion")] string RpmVersion,
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("rpm")] string Rpm,
[property: JsonPropertyName("sha256")] string Sha256);
/// <summary>One release as the forge describes it. Only the assets are read.</summary>
internal sealed record LinuxForgeRelease(
[property: JsonPropertyName("assets")] IReadOnlyList<LinuxForgeAsset>? Assets);
/// <summary>One file attached to a release.</summary>
internal sealed record LinuxForgeAsset(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("browser_download_url")] string? DownloadUrl);
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(LinuxChannelManifest))]
[JsonSerializable(typeof(LinuxForgeRelease))]
internal sealed partial class LinuxForgeJsonContext : JsonSerializerContext;
/// <summary>
/// Fetches a newer RPM from the project's own forge and asks the system's package manager to install it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing here replaces anything.</b> That is the difference from
/// <see cref="VelopackUpdateChannel"/> and it is the decision ADR 0013 rule 11 records rather than an
/// unfinished half: on this platform the package manager is already an updater the user trusts, audits and
/// controls, so this class does the two things the package manager cannot — notice that a build exists and
/// fetch it — and then hands the file to PackageKit, which raises polkit and asks. The shape is the
/// phone's, not the desktop's: <see cref="ApplyingEndsTheProcess"/> is false, and the answer may be no.
/// </para>
/// <para>
/// <b>The feed is a constant and the deployment is never asked.</b> ADR 0011 rule 2, carried unchanged
/// from both other channels: an operator who could answer the update check could pin a chosen user to a
/// build with a known hole, without holding any key at all.
/// </para>
/// <para>
/// <b>The digest is checked and is not a signature.</b> Velopack verifies what it downloads and Android
/// refuses a package whose signature does not match the installed one; an RPM fetched over HTTPS and
/// handed to PackageKit has neither. So the manifest names a digest and this refuses anything else —
/// which catches a truncated download, a caching proxy and a corrupted mirror, and catches nothing at all
/// about who built the file, because the same job wrote both. Provenance here is the constant address; a
/// signed repository is what would improve on it, and ADR 0013 rule 11 says why there is not one yet.
/// </para>
/// </remarks>
[SupportedOSPlatform("linux")]
internal sealed class LinuxUpdateChannel : IUpdateChannel
{
/// <summary>The project's own forge, and the one address in this file.</summary>
/// <remarks>
/// The owner half of the path matters for the reason <c>AndroidUpdateChannel</c> records: the
/// repository moved between organisations and Gitea leaves a 301 behind, so a client naming the old
/// one works until somebody deletes the redirect.
/// </remarks>
private const string RepositoryApi = "https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH";
private readonly string channel;
private readonly string packageName;
private readonly string dataDirectory;
private readonly HttpClient http;
private readonly Func<string, string?> installedVersion;
private readonly Action<string> handToInstaller;
private (string Version, string RpmVersion, string Url, string Sha256)? found;
private string? fetched;
/// <summary>Creates a channel for the running build.</summary>
internal LinuxUpdateChannel(string channel)
: this(
channel,
ClientPaths.ForChannel(DesktopChannel.Name).DataDirectory,
new HttpClient(),
InstalledRpmVersion,
HandToPackageKit)
{
}
/// <summary>Creates a channel with its outside world supplied, which is how the tests reach it.</summary>
internal LinuxUpdateChannel(
string channel,
string dataDirectory,
HttpClient http,
Func<string, string?> installedVersion,
Action<string> handToInstaller)
{
ArgumentException.ThrowIfNullOrEmpty(channel);
ArgumentException.ThrowIfNullOrEmpty(dataDirectory);
ArgumentNullException.ThrowIfNull(http);
ArgumentNullException.ThrowIfNull(installedVersion);
ArgumentNullException.ThrowIfNull(handToInstaller);
this.channel = channel;
this.dataDirectory = dataDirectory;
this.http = http;
this.installedVersion = installedVersion;
this.handToInstaller = handToInstaller;
packageName = string.Equals(channel, "nightly", StringComparison.Ordinal)
? "dodossh-nightly"
: "dodossh";
}
/// <inheritdoc />
/// <remarks>
/// True unconditionally, because whether this copy is one that can be updated was already decided by
/// <see cref="UpdateChannels.ForThisMachine"/> — a build run from a checkout never reaches this class
/// at all. The same split Velopack's side uses, where <c>IsInstalled</c> is checked once at
/// composition rather than at every call site.
/// </remarks>
public bool IsSupported => true;
/// <inheritdoc />
public string CurrentVersion => ClientVersion.Current;
/// <inheritdoc />
/// <remarks>
/// False, and for the phone's reason rather than the desktop's. Applying hands a file to PackageKit
/// and returns; polkit then asks, and the user may say no. A caller that had already torn the vault
/// down would answer "not now" with a locked vault and every shell gone.
/// </remarks>
public bool ApplyingEndsTheProcess => false;
/// <inheritdoc />
/// <exception cref="HttpRequestException">The forge could not be reached, or refused.</exception>
/// <exception cref="InvalidOperationException">The release is there and does not carry a usable build.</exception>
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
{
var release = await ReadAsync(
ReleaseUrl(),
LinuxForgeJsonContext.Default.LinuxForgeRelease,
cancellationToken)
.ConfigureAwait(false);
var manifestName = $"linux-{channel}.json";
if (Asset(release, manifestName) is not { } manifestAsset)
{
throw new InvalidOperationException(
$"The {channel} release carries no {manifestName}, so there is nothing saying what it "
+ "publishes.");
}
var manifest = await ReadAsync(
manifestAsset,
LinuxForgeJsonContext.Default.LinuxChannelManifest,
cancellationToken)
.ConfigureAwait(false);
if (manifest is null)
{
throw new InvalidOperationException(
$"The {channel} release's {manifestName} could not be read as a manifest.");
}
// The one place null is returned, and it means what null is documented to mean: this build is
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
if (RpmVersion.Compare(manifest.RpmVersion, installedVersion(packageName) ?? "0") <= 0)
{
return null;
}
if (Asset(release, manifest.Rpm) is not { } rpm)
{
throw new InvalidOperationException(
$"The {channel} release advertises {manifest.Version} but carries no {manifest.Rpm}.");
}
found = (manifest.Version, manifest.RpmVersion, rpm, manifest.Sha256);
return new AvailableUpdate(manifest.Version);
}
/// <inheritdoc />
public async Task DownloadAsync(
AvailableUpdate update,
IProgress<int> progress,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(update);
ArgumentNullException.ThrowIfNull(progress);
if (found is not { } target
|| !string.Equals(target.Version, update.Version, StringComparison.Ordinal))
{
throw new InvalidOperationException(
"That update did not come from this channel's last check. Check again before downloading.");
}
// Under the profile directory rather than /tmp, because a package this size on a tmpfs is a
// machine that runs out of memory downloading an update. One name, overwritten, so a client that
// fetched three updates it never installed is not holding three hundred megabytes.
var directory = Path.Combine(dataDirectory, "updates");
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, $"{packageName}-update.rpm");
await FetchAsync(target.Url, path, progress, cancellationToken).ConfigureAwait(false);
// ◆ Verified before it is ever named to the installer, and the file is removed if it does not
// match. Leaving a package that failed its digest on disk would put a file somebody could install
// by hand next to a filename that says it is an update.
var digest = Convert.ToHexStringLower(await Sha256OfAsync(path, cancellationToken).ConfigureAwait(false));
if (!string.Equals(digest, target.Sha256, StringComparison.OrdinalIgnoreCase))
{
File.Delete(path);
throw new InvalidOperationException(
$"The downloaded package is not the one {channel} published: expected {target.Sha256}, "
+ $"got {digest}.");
}
fetched = path;
}
/// <summary>Streams one URL to one path, reporting progress as it goes.</summary>
private async Task FetchAsync(
string url,
string path,
IProgress<int> progress,
CancellationToken cancellationToken)
{
using var response = await http
.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var total = response.Content.Headers.ContentLength ?? 0;
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var destination = File.Create(path);
await using (stream.ConfigureAwait(false))
await using (destination.ConfigureAwait(false))
{
var buffer = new byte[81920];
long copied = 0;
int read;
while ((read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
.ConfigureAwait(false);
copied += read;
if (total > 0)
{
progress.Report((int)(copied * 100 / total));
}
}
}
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// <b>This installs nothing and returns immediately.</b> It asks PackageKit — the freedesktop service
/// every RPM distribution runs — to install a local file, which raises a polkit prompt drawn by the
/// desktop rather than by this application. The user may refuse, and refusing is not a failure.
/// </para>
/// <para>
/// <b>The process is not restarted and must not be.</b> Replacing files under a running process is
/// ordinary on Linux: the old inodes stay mapped and this build keeps running until somebody closes
/// it. That is the right behaviour for an application that deliberately keeps shells open across a
/// lock — the same argument ADR 0013 makes for waiting for a restart the user asks for — and it is why
/// the name inherited from the interface reads oddly here. There is no equivalent of Android's
/// "the platform ends the process", so nothing ends it.
/// </para>
/// </remarks>
public void ApplyAndRestart(AvailableUpdate update)
{
ArgumentNullException.ThrowIfNull(update);
if (fetched is not { } path || !File.Exists(path))
{
throw new InvalidOperationException("There is no downloaded update to install.");
}
handToInstaller(path);
}
/// <summary>Which release this build's channel reads.</summary>
/// <remarks>
/// <c>releases/latest</c> skips prereleases, which keeps the nightly — published as one — out of the
/// release channel's answer even though both live on the same forge.
/// </remarks>
private string ReleaseUrl() =>
string.Equals(channel, "nightly", StringComparison.Ordinal)
? $"{RepositoryApi}/releases/tags/nightly-desktop"
: $"{RepositoryApi}/releases/latest";
private static string? Asset(LinuxForgeRelease? release, string name) =>
release?.Assets?.FirstOrDefault(
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
private async Task<T?> ReadAsync<T>(
string url,
JsonTypeInfo<T> shape,
CancellationToken cancellationToken)
{
var stream = await http.GetStreamAsync(url, cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
return await JsonSerializer
.DeserializeAsync(stream, shape, cancellationToken)
.ConfigureAwait(false);
}
}
private static async Task<byte[]> Sha256OfAsync(string path, CancellationToken cancellationToken)
{
var stream = File.OpenRead(path);
await using (stream.ConfigureAwait(false))
{
return await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// What rpm records for the installed package, which is the number a newer build must beat.
/// </summary>
/// <remarks>
/// <para>
/// Read from the package database rather than from the assembly, for the reason
/// <c>AndroidUpdateChannel.ReadInstalledVersionCode</c> gives about reading it from the platform: the
/// assembly's version is a SemVer string and this comparison has to be the one the installer will
/// make. Deriving the rpm version from the assembly here would mean a second copy of the translation
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
/// </para>
/// <para>
/// A machine that cannot answer gets null, which the caller reads as <c>0</c> and makes every
/// published build look newer. That is the wrong way to fail, chosen deliberately: the failure is then
/// an install PackageKit declines rather than a security fix that never arrives.
/// </para>
/// </remarks>
private static string? InstalledRpmVersion(string packageName)
{
try
{
using var process = Process.Start(new ProcessStartInfo("rpm")
{
ArgumentList = { "-q", "--queryformat", "%{VERSION}-%{RELEASE}", packageName },
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
});
if (process is null)
{
return null;
}
var answer = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(TimeSpan.FromSeconds(10));
return process.ExitCode == 0 && answer.Length > 0 ? answer : null;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
return null;
}
}
/// <summary>Asks PackageKit to install a local file, and lets polkit do the asking.</summary>
/// <remarks>
/// <para>
/// <b>Over <c>gdbus</c> rather than a D-Bus library, and that is a dependency argument rather than a
/// stylistic one.</b> <c>gdbus</c> ships in glib2, which this package already depends on twice over —
/// GTK and WebKitGTK are the terminal — so it is present on every machine that can run this at all.
/// Adding a managed D-Bus stack to reach two method calls would be a package, a lock file entry and a
/// second thing to keep current, for no capability. <c>BrowserLauncher</c> settles the same trade the
/// same way one layer down.
/// </para>
/// <para>
/// <b>Two calls, because PackageKit's interface is a factory.</b> <c>CreateTransaction</c> returns an
/// object path and the work is asked of that; a transaction is single-use, so this cannot be cached.
/// </para>
/// <para>
/// <b>The flags word is zero, and zero is load-bearing.</b> Bit 0 is <c>ONLY_TRUSTED</c>. This
/// package is not signed — ADR 0013 rule 11 says why, and why that is not decision 8's "unsigned for
/// now" — so requiring trust would make every update fail at the last step. Clearing it is what routes
/// the request to polkit's <c>package-install-untrusted</c> action, which asks for an administrator
/// rather than proceeding quietly. The user sees what they are agreeing to.
/// </para>
/// </remarks>
private static void HandToPackageKit(string path)
{
var transaction = Gdbus(
"--system",
"--dest", "org.freedesktop.PackageKit",
"--object-path", "/org/freedesktop/PackageKit",
"--method", "org.freedesktop.PackageKit.CreateTransaction");
// gdbus prints a GVariant tuple — (objectpath '/17_beadcfae',) — and the path is the only thing
// in it. Parsed by taking what is between the quotes rather than with a regular expression over a
// format nobody has promised to keep.
var opened = transaction.IndexOf('\'', StringComparison.Ordinal);
var closed = transaction.LastIndexOf('\'');
if (opened < 0 || closed <= opened)
{
throw new InvalidOperationException(
$"PackageKit did not return a transaction to install into: {transaction}");
}
var objectPath = transaction[(opened + 1)..closed];
Gdbus(
"--system",
"--dest", "org.freedesktop.PackageKit",
"--object-path", objectPath,
"--method", "org.freedesktop.PackageKit.Transaction.InstallFiles",
"0",
$"['{path}']");
}
/// <summary>Runs one gdbus call and answers with its output.</summary>
private static string Gdbus(params string[] arguments)
{
var start = new ProcessStartInfo("gdbus")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var argument in arguments)
{
start.ArgumentList.Add(argument);
}
using var process = Process.Start(start)
?? throw new InvalidOperationException(
"gdbus could not be started, so the package could not be handed to PackageKit.");
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit(TimeSpan.FromSeconds(30));
if (process.ExitCode != 0)
{
// The message is shown to the user by UpdateViewModel, so it says what to do rather than what
// failed. A missing PackageKit is the likely cause on a minimal install and is the one thing
// they can fix.
throw new InvalidOperationException(
string.Format(
CultureInfo.CurrentCulture,
"PackageKit would not take the package. Install it with `sudo dnf install PackageKit`, "
+ "or install the file yourself: {0}. ({1})",
arguments[^1],
error.Trim()));
}
return output;
}
}
@@ -0,0 +1,213 @@
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Compares two RPM version strings the way <c>rpm</c> itself does.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists rather than a SemVer comparison.</b> <c>AndroidUpdateChannel</c> settles the same
/// question and its reasoning carries over exactly: the comparison has to be the one the installer will
/// make, because anything else can offer an update the platform then refuses. On the phone that number is
/// Android's <c>versionCode</c>; here it is the RPM version, and the authority that reads it is
/// <c>rpm</c> — so this is <c>rpmvercmp</c> and not an approximation of it.
/// </para>
/// <para>
/// It matters in a specific direction. The nightly channel is rolling and CI replaces it on every push, so
/// the published version normally only rises — but a revert, a force-push or a re-run of an older commit
/// can lower it, and a client comparing "is this different from mine" would then offer a downgrade that
/// PackageKit declines. The user sees an update that fails, every six hours, forever.
/// </para>
/// <para>
/// <b>The algorithm is rpm's, including the parts that look like bugs.</b> Segments of digits and segments
/// of letters are compared separately and never against each other; everything that is neither is a
/// separator, so <c>1.0_1</c> and <c>1.0.1</c> are equal. Leading zeros are stripped, so <c>1.01</c> and
/// <c>1.1</c> are equal too. <c>~</c> sorts before anything including the end of the string, which is what
/// makes <c>0.0.1~alpha.0.143</c> older than <c>0.0.1</c> and is the whole reason
/// <c>scripts/ci-linux-rpm.sh</c> translates a SemVer prerelease to one. <c>^</c> sorts after everything
/// except the end of the string.
/// </para>
/// <para>
/// Verified against <c>rpm</c>'s own answers rather than against a reading of the source: the cases in
/// <c>RpmVersionTests</c> were generated by running <c>rpm.vercmp</c> in a container, so a disagreement is
/// a disagreement with the program that will actually decide.
/// </para>
/// </remarks>
internal static class RpmVersion
{
/// <summary>
/// Orders two RPM version strings.
/// </summary>
/// <param name="left">The left version.</param>
/// <param name="right">The right version.</param>
/// <returns>-1, 0 or 1, as <c>rpmvercmp</c> would answer.</returns>
/// <remarks>
/// Null and empty are accepted and treated as equal to each other and older than anything else, which
/// <c>rpm</c>'s own Lua binding refuses outright. The refusal is right for a packaging tool and wrong
/// here: this compares a version read off a feed, and a feed that answered with an empty string should
/// leave the client on the build it has rather than throwing on a background timer.
/// </remarks>
internal static int Compare(string? left, string? right)
{
var one = left ?? string.Empty;
var two = right ?? string.Empty;
if (string.Equals(one, two, StringComparison.Ordinal))
{
return 0;
}
int i = 0, j = 0;
while (i < one.Length || j < two.Length)
{
// Separators. Anything that is not alphanumeric, ~ or ^ divides segments and is skipped on
// both sides independently — which is why 1.0_1 and 1.0.1 compare equal.
Skip(one, ref i);
Skip(two, ref j);
if (CompareMarkers(one, i, two, j) is { } marker)
{
if (marker != 0)
{
return marker;
}
// Both sides carried the same marker, so step over it and keep going.
i++;
j++;
continue;
}
if (i >= one.Length || j >= two.Length)
{
break;
}
if (CompareSegments(one, ref i, two, ref j) is { } ordering)
{
return ordering;
}
}
// Both ran out together, so every segment matched and the only differences were separators.
if (i >= one.Length && j >= two.Length)
{
return 0;
}
// Whichever still has characters left has more version, and more version is newer.
return i < one.Length ? 1 : -1;
}
/// <summary>Advances past anything that divides segments rather than taking part in them.</summary>
private static void Skip(string value, ref int at)
{
while (at < value.Length && !IsVersionCharacter(value[at]))
{
at++;
}
}
/// <summary>
/// Orders the two sides when either is at a <c>~</c> or a <c>^</c>.
/// </summary>
/// <returns>
/// Null when neither side is at a marker, so the caller should compare segments; 0 when both are at
/// the same one, so the caller should step over it; otherwise the ordering.
/// </returns>
/// <remarks>
/// <c>~</c> sorts before everything including the end of the string, which is what carries the whole
/// prerelease ordering. <c>^</c> sorts after everything <em>except</em> the end of the string, so
/// 1.0^post is newer than 1.0 and 1.2.3 is older than 1.2.3^ — the end-of-string checks coming first
/// is the difference between the two halves of that sentence.
/// </remarks>
private static int? CompareMarkers(string one, int i, string two, int j)
{
var oneTilde = i < one.Length && one[i] == '~';
var twoTilde = j < two.Length && two[j] == '~';
if (oneTilde || twoTilde)
{
return oneTilde && twoTilde ? 0 : oneTilde ? -1 : 1;
}
var oneCaret = i < one.Length && one[i] == '^';
var twoCaret = j < two.Length && two[j] == '^';
if (!oneCaret && !twoCaret)
{
return null;
}
if (i >= one.Length)
{
return -1;
}
if (j >= two.Length)
{
return 1;
}
return oneCaret && twoCaret ? 0 : oneCaret ? -1 : 1;
}
/// <summary>
/// Takes one segment from each side and orders them, advancing past both.
/// </summary>
/// <returns>The ordering, or null if the two segments were equal and the walk should continue.</returns>
private static int? CompareSegments(string one, ref int i, string two, ref int j)
{
var numeric = char.IsAsciiDigit(one[i]);
var start1 = i;
var start2 = j;
Take(one, ref i, numeric);
Take(two, ref j, numeric);
var segment1 = one.AsSpan(start1, i - start1);
var segment2 = two.AsSpan(start2, j - start2);
// The two sides are at segments of different kinds — digits against letters. rpm calls the
// numeric one newer, always, on the reasoning that a version that has started counting is further
// along than one still spelling. segment1 is never empty: its kind was chosen from its own first
// character.
if (segment2.IsEmpty)
{
return numeric ? 1 : -1;
}
if (numeric)
{
segment1 = segment1.TrimStart('0');
segment2 = segment2.TrimStart('0');
// Longer, once the zeros are gone, is larger. Compared before the characters are, because a
// string comparison would call "9" larger than "10".
if (segment1.Length != segment2.Length)
{
return segment1.Length > segment2.Length ? 1 : -1;
}
}
var ordering = segment1.CompareTo(segment2, StringComparison.Ordinal);
return ordering == 0 ? null : ordering > 0 ? 1 : -1;
}
/// <summary>Advances over one run of digits, or one run of letters.</summary>
private static void Take(string value, ref int at, bool numeric)
{
while (at < value.Length
&& (numeric ? char.IsAsciiDigit(value[at]) : char.IsAsciiLetter(value[at])))
{
at++;
}
}
/// <summary>Whether this character takes part in the comparison rather than dividing it.</summary>
private static bool IsVersionCharacter(char value) =>
char.IsAsciiLetterOrDigit(value) || value == '~' || value == '^';
}
@@ -1,3 +1,4 @@
using System.Runtime.Versioning;
using DodoSSH.Client.Session;
using Velopack;
using Velopack.Sources;
@@ -31,11 +32,18 @@ internal static class UpdateChannels
/// </remarks>
internal static IUpdateChannel ForThisMachine()
{
// Two platforms now, and the check is a list rather than a negation for a reason: Linux reaches
// this too. Velopack has a Linux path — AppImage — but this repository does not build one, so a
// Linux build is a checkout somebody ran, and handing it an UpdateManager would have it poll a
// feed carrying nothing it could apply. Naming the platforms that are packaged keeps a future
// AppImage an addition here rather than a thing that silently already half-happened.
// ◆ Linux is answered first and separately, because it is not a Velopack platform at all.
//
// This used to be the place that said so — a negation over the two packaged platforms, with a note
// that a future AppImage should be an addition here rather than something that had silently
// half-happened. It is an addition, and it is not an AppImage: the Linux head ships as an RPM and
// is updated by the machine's own package manager, so its channel fetches and hands off rather
// than replacing anything. See LinuxUpdateChannel and ADR 0013 rule 11.
if (OperatingSystem.IsLinux())
{
return LinuxChannel();
}
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
{
return new UnavailableUpdateChannel();
@@ -57,6 +65,35 @@ internal static class UpdateChannels
return new UnavailableUpdateChannel();
}
}
/// <summary>The Linux channel, if this copy is one a package manager put here.</summary>
/// <remarks>
/// <para>
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
/// lives.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the spec puts it there
/// and nothing else writes to <c>/opt</c> under those names — so a build running from anywhere else is
/// a checkout somebody started, an extracted archive, or a tree copied by hand. None of those can be
/// updated by installing a package over them, and offering it would be an offer that cannot be carried
/// out.
/// </para>
/// <para>
/// Checked by path rather than by asking rpm which package owns the executable, which would be the
/// more thorough answer and costs a process launch on every start for a distinction nothing acts on
/// differently. The install root is a constant in two places — here and the packaging script — and
/// they are named in each other's comments.
/// </para>
/// </remarks>
[SupportedOSPlatform("linux")]
private static IUpdateChannel LinuxChannel()
{
var root = DesktopChannel.IsNightly ? "/opt/DodoSSH.Nightly" : "/opt/DodoSSH";
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
return string.Equals(here, root, StringComparison.Ordinal)
? new LinuxUpdateChannel(DesktopChannel.Name)
: new UnavailableUpdateChannel();
}
}
/// <summary>
@@ -9,17 +9,26 @@ namespace DodoSSH.Client.App.Platform;
/// </summary>
/// <remarks>
/// <para>
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware,
/// or one that is neither Windows nor macOS, gets <see cref="UnavailableDeviceKeyStore"/> and therefore
/// keeps asking for the passphrase — which is the honest answer rather than a degraded one.
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware
/// gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the passphrase — which is
/// the honest answer rather than a degraded one.
/// </para>
/// <para>
/// <b>Both real stores are asked whether they work rather than told that they do.</b> Each
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and deleting it — because on
/// both platforms the provider is present and reports itself present on machines where creating a key
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, and on macOS also every
/// unsigned development build, since enclave keys need a signing identity. Inferring from the OS would
/// mean each of those discovering the truth at the moment somebody tried to unlock.
/// <b>Every real store is asked whether it works rather than told that it does.</b> Each
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and discarding it — because on
/// all three platforms the provider is present and reports itself present on machines where creating a key
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, on macOS also every unsigned
/// development build since enclave keys need a signing identity, and on Linux the ordinary case of a
/// working TPM whose device node this user may not open. Inferring from the OS would mean each of those
/// discovering the truth at the moment somebody tried to unlock.
/// </para>
/// <para>
/// <b>The three are not equally strong, and the ordering here does not imply they are.</b> Windows and
/// macOS put the consent gesture inside the platform — a secure desktop and a system Touch ID sheet — so
/// the process asking never sees the secret. The Linux store's PIN is typed into a window this
/// application draws, and only the <em>checking</em> is the TPM's. That is a real difference, it is
/// recorded in ADR 0007 rather than smoothed over, and it is why <see cref="LinuxDeviceKeyStore"/> is
/// tried last rather than first among platforms that could in principle overlap.
/// </para>
/// <para>
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
@@ -33,9 +42,20 @@ namespace DodoSSH.Client.App.Platform;
public static class DesktopDeviceKeyStores
{
/// <summary>The best store this machine supports.</summary>
public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
/// <param name="paths">Where a stored key lives.</param>
/// <param name="askForPin">
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
/// raises its own dialog and this process is not in the conversation. A caller with no way to draw one
/// may pass a delegate answering null, which makes the Linux store decline every unlock; that is the
/// right shape for a headless host and the wrong one for the application, which supplies a real dialog
/// in <c>App.axaml.cs</c>.
/// </param>
public static IDeviceKeyStore ForThisMachine(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(askForPin);
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
{
@@ -47,6 +67,11 @@ public static class DesktopDeviceKeyStores
return new MacDeviceKeyStore(paths);
}
if (OperatingSystem.IsLinux() && LinuxDeviceKeyStore.IsSupported())
{
return new LinuxDeviceKeyStore(paths, askForPin);
}
return new UnavailableDeviceKeyStore();
}
}
@@ -181,6 +181,19 @@
in the strip and the card is still here on the way back.
-->
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
<!--
TRY AGAIN rather than RECONNECT, because nothing was ever connected: this tab is a handshake that
was refused. It runs the same command the disconnect strip's RECONNECT does — the vault's own
connect path, into this same tab — and it is worth having here for the reason the commonest
refusal is worth retrying at all: a host that was not up yet is a host that will be in a minute.
Disabled rather than hidden when there is nothing to dial, exactly as the strip's is. See
MainWindowViewModel.CanReconnectSelectedTab.
-->
<Button Classes="ghost" Content="TRY AGAIN" Command="{Binding ReconnectTabCommand}"
CommandParameter="{Binding SelectedTab}"
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
IsVisible="{Binding SelectedTab.IsFailed}" />
<Button Classes="ghost" Content="SHOW LOGS" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}" />
<Button Classes="ghost" Content="GIVE UP" Command="{Binding CloseTabCommand}"
@@ -0,0 +1,62 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="DodoSSH.Client.App.Views.DevicePinWindow"
Width="420" SizeToContent="Height"
CanResize="False" ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
Background="{StaticResource Surface}"
Title="DodoSSH">
<!--
The PIN that unseals this machine's device key, on Linux and nowhere else.
◆ A WINDOW, WHERE EVERY OTHER QUESTION THIS APPLICATION ASKS IS A CARD.
ConfirmDeleteCard, HostKeyCard and the rest are overlays bound to view-model state, and that is the
right shape for a question about the vault: the shell owns the moment and the Android head shows the
same one. This is not that kind of question. It belongs to a platform integration — LinuxDeviceKeyStore
— and the shell it would otherwise live in is shared with the phone, which has a fingerprint and must
never grow a PIN box. Keeping it here keeps DodoSSH.Client.Shell platform-neutral, which is the
property IDeviceKeyStore exists to protect.
◆ AND IT IS DELIBERATELY PLAIN.
Windows and macOS draw their equivalent themselves — a secure desktop and a system sheet — and this one
cannot be either. Dressing it up to look like a system dialog would be teaching people that a
convincing DodoSSH PIN box is a system dialog, which is the phishing this platform's design cannot
prevent and should not rehearse. It says what it is, it names the application, and it stops there.
ADR 0007 records the difference rather than smoothing it over.
-->
<StackPanel Margin="20" Spacing="12">
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
<TextBlock Foreground="{StaticResource MutedText}" FontSize="12" TextWrapping="Wrap"
x:Name="Explanation" />
<!--
RevealPassword is off and there is no eye button. A PIN is short enough to retype and this box is
open on a screen somebody may be sharing.
-->
<TextBox x:Name="Pin" PasswordChar="•" PlaceholderText="PIN" FontSize="14" />
<!--
Only when choosing. A mistyped PIN at registration is not recoverable in any useful sense: the key is
sealed to it, and the only way back is to withdraw the device and register again. Asking twice costs
one box.
-->
<TextBox x:Name="Confirm" PasswordChar="•" PlaceholderText="PIN again" FontSize="14"
IsVisible="False" />
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
x:Name="Problem" IsVisible="False" />
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button x:Name="Cancel" Content="CANCEL" />
<Button x:Name="Accept" Classes="accent" Content="UNLOCK" />
</StackPanel>
</StackPanel>
</Window>
@@ -0,0 +1,203 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Asks for the PIN that unseals this machine's device key.
/// </summary>
/// <remarks>
/// <para>
/// Raised by <see cref="LinuxDeviceKeyStore"/> through the delegate <c>App.axaml.cs</c> supplies, and by
/// nothing else. See the markup for why this is a window rather than a card, and why it is plain.
/// </para>
/// <para>
/// <b>Cancelling is an ordinary answer, not an error.</b> Escape, the close button and CANCEL all resolve
/// to null, which the store reads as "no PIN" and the unlock path answers with the passphrase screen. That
/// is the same contract <c>IDeviceKeyStore.TryLoadAsync</c> documents for a declined fingerprint.
/// </para>
/// </remarks>
public sealed partial class DevicePinWindow : Window
{
/// <summary>
/// The shortest PIN this will accept, and the reason it is this short rather than longer.
/// </summary>
/// <remarks>
/// Four, because the TPM counts wrong answers and stops — which is exactly the property that makes a
/// short secret safe here and does not exist for anything kept in a file. A PIN long enough to be safe
/// without hardware counting would be a second passphrase, and somebody asked for a second passphrase
/// reuses the first.
/// </remarks>
private const int ShortestPin = 4;
private TaskCompletionSource<string?>? answer;
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
public DevicePinWindow()
{
AvaloniaXamlLoader.Load(this);
Accept.Click += OnAccept;
Cancel.Click += OnCancel;
KeyDown += OnKeyDown;
Pin.KeyDown += OnKeyDown;
Confirm.KeyDown += OnKeyDown;
}
/// <summary>
/// Shows the prompt over the main window and answers with what was typed, or null.
/// </summary>
/// <remarks>
/// <para>
/// <b>Marshalled onto the UI thread here rather than at the call site.</b> The store is called from
/// wherever the unlock happens to be running — a background task on launch, a command handler on a
/// button — and none of those should have to know that this ends in a window. Avalonia throws rather
/// than misbehaving if it is wrong, which would turn a device unlock into a crash.
/// </para>
/// <para>
/// A missing owner answers null rather than showing an ownerless window. It means the main window is
/// gone or not up yet, and a modal dialog with nothing behind it is a prompt the user cannot place —
/// which is precisely the shape of the thing this application must not train people to accept.
/// </para>
/// </remarks>
/// <param name="owner">The window to sit over.</param>
/// <param name="purpose">Whether a PIN is being chosen or entered.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static ValueTask<string?> AskAsync(
Func<Window?> owner,
DevicePinPurpose purpose,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(owner);
return new ValueTask<string?>(
Dispatcher.UIThread.InvokeAsync(
async () =>
{
if (owner() is not { } parent)
{
return null;
}
var window = new DevicePinWindow();
window.Prepare(purpose);
using var registration = cancellationToken.Register(window.Close);
return await window.ShowFor(parent).ConfigureAwait(true);
}));
}
private void Prepare(DevicePinPurpose purpose)
{
var choosing = purpose == DevicePinPurpose.Choosing;
Heading.Text = choosing ? "Choose a PIN for this computer" : "Enter your DodoSSH PIN";
Explanation.Text = choosing
? "Your device key is sealed into this computer's TPM. The PIN is checked by the TPM itself, "
+ "which stops answering after a few wrong tries — so a short one is enough, and it only "
+ "ever works on this computer."
: "This unlocks the key sealed into this computer's TPM. Cancel to use your passphrase "
+ "instead.";
Confirm.IsVisible = choosing;
Accept.Content = choosing ? "SET PIN" : "UNLOCK";
}
private async Task<string?> ShowFor(Window parent)
{
answer = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
// Resolves the pending answer however the window goes away — the close button, Escape, or the
// cancellation registration above. Without it a closed window leaves an await that never returns,
// and the unlock path hangs rather than falling back to the passphrase.
Closed += (_, _) => answer.TrySetResult(null);
Pin.Focus();
await ShowDialog(parent).ConfigureAwait(true);
return await answer.Task.ConfigureAwait(true);
}
private void OnKeyDown(object? sender, KeyEventArgs e)
{
switch (e.Key)
{
case Key.Escape:
Complete(null);
e.Handled = true;
break;
case Key.Enter:
Submit();
e.Handled = true;
break;
default:
break;
}
}
private void OnAccept(object? sender, RoutedEventArgs e) => Submit();
private void OnCancel(object? sender, RoutedEventArgs e) => Complete(null);
/// <summary>
/// Checks what was typed and either answers with it or says what is wrong.
/// </summary>
/// <remarks>
/// Both checks are about a PIN being <em>chosen</em>. Nothing validates a PIN being entered, because
/// the only thing that can judge that is the TPM — refusing a four-character entry here because the
/// stored one was six would be this process deciding something it has no way to know.
/// </remarks>
private void Submit()
{
var pin = Pin.Text ?? string.Empty;
if (!Confirm.IsVisible)
{
if (pin.Length > 0)
{
Complete(pin);
}
return;
}
if (pin.Length < ShortestPin)
{
Show($"A PIN needs at least {ShortestPin} characters.");
return;
}
if (!string.Equals(pin, Confirm.Text ?? string.Empty, StringComparison.Ordinal))
{
Show("Those two do not match.");
return;
}
Complete(pin);
}
private void Show(string problem)
{
Problem.Text = problem;
Problem.IsVisible = true;
}
private void Complete(string? pin)
{
answer?.TrySetResult(pin);
Close();
}
}
+22 -4
View File
@@ -156,9 +156,19 @@
either: TransfersScreen draws that same string itself, both in its own empty state and
beside the remote pane's DISCONNECT once something is open.
-->
<Grid Grid.Column="0" RowDefinitions="*,Auto">
<!--
Three rows since the disconnect strip arrived, and it is the same control the terminal
surface draws — see SessionEndedBar.axaml, which resolves which surface it is about
through the shell rather than through a binding of its own. Auto and empty except while
the file session has been lost, so an ordinary browse pays nothing for it.
Note the DataContext: this row is the shell's rather than the transfers screen's, which
is why the strip sits here and not inside TransfersScreen.axaml.
-->
<Grid Grid.Column="0" RowDefinitions="*,Auto,Auto">
<views:TransfersScreen Grid.Row="0" DataContext="{Binding Transfers}" />
<views:SessionStatusBar Grid.Row="1" />
<views:SessionEndedBar Grid.Row="1" />
<views:SessionStatusBar Grid.Row="2" />
</Grid>
<!--
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
@@ -279,7 +289,14 @@
this Grid is only drawn on the terminal surface, and the surface with no tab open already
answers for itself in the tab row's own "+" and in the connecting card below.
-->
<Grid Grid.Column="0" RowDefinitions="*,Auto">
<!--
Three rows since the disconnect strip arrived, where v5c-4 left two. The extra one is Auto
and empty except while the selected tab's shell has stopped, so it costs the terminal
nothing in the ordinary case — and when it is up it takes its 34 pixels off the pane rather
than drawing over it, which is the only way Avalonia content can sit near the renderer at
all. See SessionEndedBar.axaml.
-->
<Grid Grid.Column="0" RowDefinitions="*,Auto,Auto">
<Panel Grid.Row="0" Background="{StaticResource Pane}">
@@ -306,7 +323,8 @@
</Panel>
<views:SessionStatusBar Grid.Row="1" ShowsEncoding="True" />
<views:SessionEndedBar Grid.Row="1" />
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" />
</Grid>
<!--
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
@@ -0,0 +1,106 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.SessionEndedBar"
x:DataType="vm:MainWindowViewModel">
<!--
── What a session says once it has stopped ──────────────────────────────────────────────────────────
A strip between the pane and the status bar, shown only while the session in front of the user has
ended. It names which ending it was, prints the sentence that came with it, and offers the one thing
worth offering: dialling the machine again.
◆ ONE CONTROL ON BOTH SURFACES, and the shell is what resolves which it is drawn over. A terminal has
two endings to tell apart and a keychain entry to check before it can offer a redial; a file session has
one ending and already captured what it would dial. None of that is a decision a view should be making,
so every binding here is a MainWindowViewModel property that has already asked which surface is showing
— see SessionEndedText, CanReconnectSession and SessionEndedDismissLabel. The second button is even
labelled from the shell, because CLOSE TAB and DISCONNECT are noticeably different acts and one vague
word covering both would be the wrong kind of reuse.
◆ IT IS A ROW OF ITS OWN RATHER THAN SOMETHING DRAWN OVER THE PANE, and on the terminal surface that is
not a style choice. The renderer is a native child view that composites above everything Avalonia draws,
so a banner overlapping its rectangle would either be invisible or would have to collapse the terminal —
and collapsing it is exactly wrong here, because the scrollback is what the user is reading. A dead
shell's last words are usually why it died. So this takes its own Auto row and the pane simply gets 34
fewer pixels while it is up. The file surface has no such constraint and takes the same row anyway,
because a listing left on screen is worth the same as a scrollback: it is where the user was, and it is
what makes RECONNECT mean something. See MainWindow.axaml for both placements.
◆ THE SENTENCE IS HERE AS WELL AS IN THE PANE ON PURPOSE. terminal.js writes the same string into the
scrollback as a grey rule, which is right and is not enough: a machine that printed a page of shutdown
messages on its way down has pushed that line off the screen, and a session that ended while the user
was on another tab was never on screen at all. This copy does not scroll.
── the two endings, and why they are not one colour ─────────────────────────────────────────────────
SHELL EXITED is grey. Somebody typed `exit`, or a login shell ended; nothing is wrong and an alarm
would be noise. DISCONNECTED is the palette's red, because the connection went out from under a session
that had not finished — a reboot, a network drop, an sshd that was killed — and it is the one of the two
that means the machine may still be there and worth dialling. The dot beside the word says the same
thing a second way, for anybody who cannot tell this design's red from its grey.
── the buttons ──────────────────────────────────────────────────────────────────────────────────────
RECONNECT is disabled rather than hidden when there is nothing to dial — a tab typed in by hand, or one
whose host has left the keychain — because the answer to "why can I not reconnect this" is better given
by a control that is visibly refusing than by one that is not there. See
MainWindowViewModel.CanReconnectSession, which is what draws that line on each surface, and
ReconnectSessionAsync for what the button actually does: each surface's own connect path, back into the
thing that stopped. On the file side that includes walking back to the directory the pane was showing,
which is what makes it a reconnect rather than a connect.
The second button is the moment the other reasonable answer is "I have read it, take it away" — and
hunting for a 16-pixel cross in the strip above is not the shape of that answer.
-->
<UserControl.Styles>
<Style Selector="TextBlock.endword">
<Setter Property="FontFamily" Value="Montserrat" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="9.5" />
<Setter Property="LetterSpacing" Value="1.2" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="TextBlock.endword.dropped">
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
</Style>
</UserControl.Styles>
<Border Height="34" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<Ellipse Classes="dot" Width="7" Height="7" Classes.dropped="{Binding IsSessionDropped}"
VerticalAlignment="Center" />
<TextBlock Classes="endword" Classes.dropped="{Binding IsSessionDropped}"
Text="{Binding SessionEndedText}" />
</StackPanel>
<!--
Trimmed rather than wrapped: this row is one line tall and the sentences it draws are short, but a
transport error can hand over whatever the operating system called it, and a bar that grew to fit
one would take the height out of the terminal above it.
-->
<SelectableTextBlock Grid.Column="1" Classes="mono" FontSize="11.5" Margin="16,0"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
Text="{Binding SessionEndedDetail}"
ToolTip.Tip="{Binding SessionEndedDetail}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<Button Classes="ghost" Content="RECONNECT"
Command="{Binding ReconnectSessionCommand}"
IsEnabled="{Binding CanReconnectSession, FallbackValue=False}"
ToolTip.Tip="Opens this connection again, in place." />
<Button Classes="ghost" Content="{Binding SessionEndedDismissLabel}"
Command="{Binding DismissEndedSessionCommand}" />
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,15 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>What the session shell says once the session in front of the user has stopped.</summary>
/// <remarks>
/// No code of its own: the word, the sentence and both buttons are the shell's, including which surface
/// they are about — see the markup. In its own file for the reason every other card here is: nothing inside
/// <c>MainWindow</c> can be laid out by a test, because WebView2's adapter refuses the headless session's
/// thread.
/// </remarks>
internal sealed partial class SessionEndedBar : UserControl
{
public SessionEndedBar() => InitializeComponent();
}
@@ -70,12 +70,25 @@
The address, and the button that closes the column. Both on one row, and the address is the
trimming one: a long account@host:port is exactly the string that would otherwise push the
close button off the edge of a panel whose whole point is that it can be got rid of.
SelectableTextBlock rather than TextBlock, which is what the rest of this application already
reaches for whenever a string is on screen to be taken somewhere else — a fingerprint, a public
key, a recovery code. This is the one place the shell prints the endpoint it actually dialled,
and the endpoint is a thing people paste: into another client, into a ticket, into the ssh
command they are writing beside this window. Retyping it off the screen is how a digit gets
dropped from an address that is mostly digits.
The trimming and the selection do disagree at the long end, and the ToolTip is why the
disagreement is tolerable rather than a trap: a drag reaches only the glyphs that were drawn,
so an address wearing an ellipsis has to be taken with select-all — Ctrl+A, then Ctrl+C — and
the hover already shows what the full string is, so nobody is copying half of one without
having been told there is a half.
-->
<Grid ColumnDefinitions="*,Auto" Margin="8,0,0,0">
<TextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
FontSize="12.5" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}" />
<SelectableTextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
FontSize="12.5" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}" />
<Button Grid.Column="1" Classes="flat sidebargrip"
Command="{Binding ToggleSessionSidebarCommand}"
ToolTip.Tip="Close this column. The terminal takes the width, and the rail it leaves behind brings it back.">
@@ -42,14 +42,29 @@
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
Classes.dropped="{Binding IsSessionDropped}"
VerticalAlignment="Center" />
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
IsVisible="{Binding IsSessionConnected}" />
<!--
Three words where there were two, and the third replaces rather than joins the second — see
MainWindowViewModel.IsSessionIdle, which is what keeps them from ever being drawn together.
NOT CONNECTED is honest about a tab still dialling and useless about one whose shell has
stopped: it says nothing about which of the two endings it was, and the difference between them
is the difference between work that is finished and a machine that stopped answering. The strip
below carries the sentence and the way back; this is the same fact in the one line that is
always on screen.
-->
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
IsVisible="{Binding !IsSessionConnected}" />
IsVisible="{Binding IsSessionIdle}" />
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
VerticalAlignment="Center" Text="{Binding SessionEndedText}"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
@@ -74,9 +74,17 @@
Worth the third colour because the two it replaces were the same one: a tab still
dialling and a tab whose shell has exited both drew grey, which are the two states in
this strip with the least in common — one is worth waiting for and the other is over.
── and a fourth since, splitting that grey ────────────────────────────────────────────
A shell that ended and a connection that broke were still one dot, and they are the two
dead states with the least in common: one is work finished, the other is a machine that
stopped answering and may well still be there. Red for the second, matching the strip
under the terminal — see TerminalTabViewModel.IsDropped and SessionEndedBar.axaml, which
is where the sentence and the way back live.
-->
<Ellipse Classes="dot" Width="8" Height="8" Classes.live="{Binding IsLive}"
Classes.connecting="{Binding IsConnecting}"
Classes.dropped="{Binding IsDropped}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
@@ -319,20 +319,25 @@
SelectedItem="{Binding SelectedLocalEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
<Grid Height="38" Margin="14,0,10,0">
<Grid Height="38" Margin="6,0,10,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="24" />
<ColumnDefinition Width="20" />
<ColumnDefinition Width="3*" />
<ColumnDefinition Width="*" MaxWidth="66" />
<ColumnDefinition Width="1.6*" MaxWidth="124" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
<!-- The remote pane's own tick, on this side and for the same reasons. -->
<CheckBox Grid.Column="0" MinWidth="0" VerticalAlignment="Center"
IsChecked="{Binding IsChecked}"
Click="OnLocalRowChecked" />
<TextBlock Grid.Column="1" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="2" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Text="{Binding Name}" FontSize="13.5"
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
<TextBlock Grid.Column="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
<TextBlock Grid.Column="4" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
@@ -495,10 +500,50 @@
<Border Grid.Row="2" Padding="12,5" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1" IsVisible="{Binding ShowsRemoteItemActions}">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" FontSize="11.5" VerticalAlignment="Center"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
Text="{Binding SelectedRemoteName}" />
<!--
The chosen row's name, or the count once anything is ticked. The count wins because it is the
one the buttons beside it are actually about: with three rows gathered up, a name would be
describing whichever of them happens to be highlighted while DELETE takes all three.
-->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Classes="ghost" Content="ALL" Command="{Binding ToggleAllRemoteCommand}"
ToolTip.Tip="Tick every row, or untick them all" />
<TextBlock Classes="mono" FontSize="11.5" VerticalAlignment="Center"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
Text="{Binding SelectedRemoteName}"
IsVisible="{Binding !HasCheckedRemote}" />
<TextBlock Classes="mono" FontSize="11.5" VerticalAlignment="Center"
Foreground="{StaticResource AccentText}"
Text="{Binding CheckedRemoteLabel}"
IsVisible="{Binding HasCheckedRemote}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
<!--
◆ FOUR NOW, and this is the one that was missing: the PERMS column has been a true fact
about a file with nothing to be done about it since it arrived, so the commonest next act
after using this screen was opening a terminal to chmod what had just been uploaded. Dark on
a bucket rather than live-and-refused — an object has an access policy rather than nine bits;
see CanChangeRemotePermissions.
A lock rather than a key glyph: a key is what this application's keychain uses, and one here
would read as "which credential" over a row that has nothing to do with one.
-->
<!--
◆ EDIT, and it is first because it is the only one of the five that changes nothing on the
host by pressing it: it fetches a copy and opens it. Dark on a head with no way to open a
file or watch one — see CanEditRemote, which is what the phone fails rather than this
button being absent there for a reason nobody can see.
-->
<Button Classes="paneicon" Command="{Binding EditRemoteCommand}"
IsEnabled="{Binding CanEditRemote}"
ToolTip.Tip="Open in this machine's editor, and send each save back">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE3C9;" />
</Button>
<Button Classes="paneicon" Command="{Binding ChangeRemotePermissionsCommand}"
IsEnabled="{Binding CanChangeRemotePermissions}"
ToolTip.Tip="Permissions on the host">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE897;" />
</Button>
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE9AD;" />
@@ -515,6 +560,80 @@
</Grid>
</Border>
<!--
◆ WHAT AN EDIT SESSION SAYS FOR ITSELF, and it is a strip rather than a question: nothing is
waiting on an answer, a file is simply open somewhere else and each save is going back. It shares
the questions' Grid row because it is exclusive with them — opening any of the three needs a
selection, and while one is up ShowsRemoteItemActions has taken the buttons away.
The log is newest-first and bounded. Watching means saving happens more than once, and each one is
a round trip somebody may want to see happened; an editor that autosaves would otherwise grow this
without limit. See TransfersViewModel.RemoteEditLog.
The conflict card inside it is the one state where a save is not written back. Three answers,
because those are the three that exist: send it anyway, throw away what was typed and take the
host's copy, or stop. There is deliberately no merge — this client has no way to do one, and a
button that pretended otherwise would be the fabrication this project's honesty rule forbids.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsEditingRemote}">
<!--
◆ THE WHOLE STRIP SCROLLS, not just the log inside it, and that is what makes this safe at any
width. It is the tallest thing that can be in this Grid row — a heading, a path, a log, a
conflict card and a button — and unlike the three questions beside it, it is not modal: the
listing underneath stays live, because a file being open in an editor is no reason to stop
being able to browse. So it cannot be allowed to take the pane's height, and bounding the
container rather than each part means nothing added inside it later can undo that.
134 is measured. Inside the session shell this pane is 204x377, and ScreenLayoutTests draws
this in its worst case — the conflict card open with a session's worth of saves behind it.
-->
<ScrollViewer MaxHeight="134" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding RemoteEdit.Question}" />
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource TextFaint}"
Text="{Binding RemoteEdit.RemotePath}" />
<!-- Bounded inside a bounded strip: this keeps a long session's log from pushing the conflict
card and STOP EDITING out of view within the scroller. -->
<ScrollViewer MaxHeight="48" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding RemoteEditLog}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Classes="mono" FontSize="11" Margin="0,1"
Foreground="{StaticResource TextGhost}" TextTrimming="CharacterEllipsis"
Text="{Binding}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Border Padding="10,8" CornerRadius="8" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1"
IsVisible="{Binding HasRemoteEditConflict}">
<StackPanel Spacing="6">
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding RemoteEditConflict}" />
<WrapPanel>
<Button Classes="ghost" Content="UPLOAD ANYWAY" Margin="0,0,8,4"
Command="{Binding ForceSaveEditCommand}" />
<Button Classes="ghost" Content="RELOAD" Margin="0,0,0,4"
Command="{Binding ReloadEditCommand}" />
</WrapPanel>
</StackPanel>
</Border>
<Button Classes="ghost" Content="STOP EDITING" HorizontalAlignment="Left"
Command="{Binding StopEditingCommand}" />
</StackPanel>
</ScrollViewer>
</Border>
<!--
The question DELETE asks. It names the full path rather than the file, because a name is the half
that does not identify anything — and that is now the whole of how the question says which row it
@@ -537,9 +656,28 @@
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Question}" />
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource Danger}"
Text="{Binding PendingRemoteDeletion.FullPath}" />
<!--
Every path, one per line, and never a count on its own: agreeing to "3 items" is agreeing to a
number. See RemoteDeletionRequest.Listing.
◆ 32, AND THE NUMBER IS MEASURED RATHER THAN CHOSEN. A ticked set has no ceiling, so this list
has to scroll, and the budget it scrolls inside is small: within the session shell this pane is
204 wide, where one remote path wraps to two lines, and the card also carries a heading, the
consequence and two buttons that wrap onto two rows at that width. 120 put the buttons outside
the window; 72 left CANCEL three pixels under it; 56 and 40 pushed the listing's own squeezed
row past the bottom — each caught by ScreenLayoutTests, which is why the five-path test exists
beside the single-path one rather than instead of it. 32 is one wrapped path, which is exactly
what the single-path question has always shown.
What that buys is a scrollbar rather than a taller card, and the heading carries the count so
the number is never the thing being scrolled for.
-->
<ScrollViewer MaxHeight="32" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource Danger}"
Text="{Binding PendingRemoteDeletion.Listing}" />
</ScrollViewer>
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Consequence}" />
<WrapPanel>
@@ -603,6 +741,90 @@
</StackPanel>
</Border>
<!--
◆ THE MODE, as nine boxes and the number they add up to.
In the same Grid row as the other two questions and never at the same time as either — each one's
own opening clears the others, which is what makes one row enough for all three.
Boxes rather than a field to type 755 into, and that is the design rather than a preference: the
mode is already drawn as -rwxr-xr-x in the column this form was opened from, so a who-can-do-what
grid is the same representation the reader is already holding. An octal field would ask them to
translate something they can see into something they cannot check. The octal is printed underneath
as it changes, so the two readings check each other rather than one replacing the other.
The EXEC column is headed from the request rather than fixed, because on a directory that bit is
permission to enter it — the commonest misreading of a POSIX mode there is, and one word fixes it.
See RemotePermissionsRequest.ExecuteLabel.
APPLY is dead until something has actually changed. A round trip that sets a mode to the mode it
already has is not wrong, only pointless, and a live button over an untouched form invites it.
Not painted in the deletion card's red. Changing a mode is undone by changing it back; the red is
spent on the one thing here that nothing walks back, and spending it twice would make it stop
meaning anything — the same argument the rename form above makes for itself.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsChangingRemotePermissions}">
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding PendingRemotePermissions.Question}" />
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource TextFaint}"
Text="{Binding PendingRemotePermissions.FullPath}" />
<Grid ColumnDefinitions="Auto,*,*,*" RowDefinitions="Auto,Auto,Auto,Auto" Margin="0,2">
<TextBlock Grid.Row="0" Grid.Column="1" Classes="hint" FontSize="10.5" Text="read"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="2" Classes="hint" FontSize="10.5" Text="write"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="3" Classes="hint" FontSize="10.5"
Text="{Binding PendingRemotePermissions.ExecuteLabel}"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="1" Grid.Column="0" Classes="mono" FontSize="11.5" Text="owner"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerRead}" />
<CheckBox Grid.Row="1" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerWrite}" />
<CheckBox Grid.Row="1" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerExecute}" />
<TextBlock Grid.Row="2" Grid.Column="0" Classes="mono" FontSize="11.5" Text="group"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupRead}" />
<CheckBox Grid.Row="2" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupWrite}" />
<CheckBox Grid.Row="2" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupExecute}" />
<TextBlock Grid.Row="3" Grid.Column="0" Classes="mono" FontSize="11.5" Text="others"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersRead}" />
<CheckBox Grid.Row="3" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersWrite}" />
<CheckBox Grid.Row="3" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersExecute}" />
</Grid>
<SelectableTextBlock Classes="mono" FontSize="12.5"
Foreground="{StaticResource AccentText}"
Text="{Binding PendingRemotePermissions.Preview}" />
<WrapPanel>
<Button Classes="accent" Content="APPLY" Margin="0,0,8,4"
Command="{Binding ConfirmRemotePermissionsCommand}"
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
Command="{Binding CancelRemotePermissionsCommand}" />
</WrapPanel>
</StackPanel>
</Border>
<!--
Away while a question is up, for the reason the item row above states: the trail is where the pane
is and MKDIR makes a directory, and neither is what somebody answering a question about one row
@@ -681,24 +903,42 @@
SelectedItem="{Binding SelectedRemoteEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
<Grid Height="38" Margin="14,0,10,0">
<Grid Height="38" Margin="6,0,10,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="24" />
<ColumnDefinition Width="20" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="*" MaxWidth="66" />
<ColumnDefinition Width="1.6*" MaxWidth="124" />
<ColumnDefinition Width="*" MaxWidth="66" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
<!--
◆ THE TICK IS NOT THE LIST'S SELECTION, and the two answer different questions. The
selection is "the row I am looking at", which is what rename, move and permissions act on
— each takes a form naming one path and is one-at-a-time by construction. This is "the
rows I have gathered up", which is what DOWNLOAD and DELETE act on, and it survives the
highlight moving.
It is drawn rather than left to ctrl-click for a reason beyond taste: the phone head shares
this view model and has neither ctrl nor shift, so a multi-selection has to be a tap target
there whatever this pane does. One shape on both heads, and a state somebody can see.
Click is handled rather than bound one-way, because the count above the list is a property
of the collection rather than of any row — see TransfersViewModel.RemoteChecksChanged.
-->
<CheckBox Grid.Column="0" MinWidth="0" VerticalAlignment="Center"
IsChecked="{Binding IsChecked}"
Click="OnRemoteRowChecked" />
<TextBlock Grid.Column="1" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="2" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Classes.exec="{Binding IsExecutable}"
Text="{Binding Name}" FontSize="13.5"
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
<TextBlock Grid.Column="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
<TextBlock Grid.Column="4" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
<TextBlock Grid.Column="5" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
Text="{Binding Permissions}" FontSize="10" Margin="6,0,0,0"
HorizontalAlignment="Right" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
</Grid>
@@ -732,8 +972,14 @@
the picker arrives, so the tall shape is the form rather than form-plus-prose, and neither shape
reaches the floor. Anything added here has to hold that; the layout suite measures both.
-->
<!--
ShowsRemoteInvitation rather than !IsConnected, which are no longer the same question. Pressing
DISCONNECT empties this pane, so an invitation over it covers nothing; a connection lost
underneath somebody leaves the listing exactly where it was, and covering that would hide the one
thing the strip below is offering to put them back into. See TransfersViewModel.ShowsRemoteContents.
-->
<Border Grid.Row="1" Grid.RowSpan="6" Background="{StaticResource Canvas}"
IsVisible="{Binding !IsConnected}">
IsVisible="{Binding ShowsRemoteInvitation}">
<StackPanel Spacing="10" Margin="20" MaxWidth="320"
HorizontalAlignment="Center" VerticalAlignment="Center">
@@ -79,6 +79,20 @@ internal sealed partial class TransfersScreen : UserControl
private TransfersViewModel? Transfers => DataContext as TransfersViewModel;
/// <summary>
/// Tells the screen a row's tick moved, so the count and the buttons that read it keep up.
/// </summary>
/// <remarks>
/// The tick itself is two-way bound and needs nothing from here; what does is the count above the list,
/// which is a property of the collection rather than of any row. Subscribing per row would be a
/// subscription to attach and detach on every navigation, since each listing replaces every row it has.
/// See <c>TransfersViewModel.RemoteChecksChanged</c>.
/// </remarks>
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) => Transfers?.RemoteChecksChanged();
/// <inheritdoc cref="OnRemoteRowChecked" />
private void OnLocalRowChecked(object? sender, RoutedEventArgs e) => Transfers?.LocalChecksChanged();
private void OnLocalActivated(object? sender, TappedEventArgs e)
{
if (Transfers is { } transfers)
@@ -72,6 +72,12 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"Microsoft.TSS": {
"type": "Direct",
"requested": "[2.1.1, )",
"resolved": "2.1.1",
"contentHash": "87DBN+4ANSFO8TDRmFjjTBl6PL7tmrfofmxki4LUnE34gOdyQiBfJK/X1QSu0UfCGUtpoBVv/tD985s4FDpKiA=="
},
"MinVer": {
"type": "Direct",
"requested": "[7.0.0, )",
@@ -55,6 +55,24 @@ internal sealed class S3FileStore : IRemoteFileStore
/// </remarks>
public bool IsConnected => Volatile.Read(ref disposed) == 0;
/// <inheritdoc />
/// <remarks>
/// Null forever, for the reason <see cref="IsConnected"/> is always true: there is no connection under
/// this to be lost. A request that fails reports itself, and nothing here outlives one.
/// </remarks>
public string? TransportFailure => null;
/// <inheritdoc />
/// <remarks>
/// Never raised; see <see cref="TransportFailure"/>. Declared rather than left off the type because the
/// screen holds whichever store it opened as an <c>IRemoteFileStore</c> and subscribes without asking
/// which kind it got — an event that is simply never raised is what makes that honest rather than a
/// cast and a branch.
/// </remarks>
#pragma warning disable CS0067 // The event is never used
public event EventHandler? ConnectionLost;
#pragma warning restore CS0067
/// <inheritdoc />
public string HomeDirectory => ObjectKeys.Root;
@@ -383,6 +401,32 @@ internal sealed class S3FileStore : IRemoteFileStore
/// a rename's clothing, and the failure mode is a half-moved directory.
/// </para>
/// </remarks>
/// <inheritdoc />
/// <remarks>
/// Refused rather than approximated. An object is governed by an ACL and a bucket policy, which are not
/// nine bits and do not map onto them — writing something plausible here would put a mode in a listing
/// that no request would ever be decided by.
/// </remarks>
public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken) =>
throw new SftpPathException(
path,
"An object in a bucket has an access policy rather than a POSIX mode, so there is nothing here to set.");
/// <inheritdoc />
/// <remarks>
/// Refused for the reason above: an object's timestamp is when the service stored it, and there is no
/// request that says otherwise. A copy that claimed the source's time would be a date the bucket does
/// not agree with.
/// </remarks>
public Task SetLastWriteTimeAsync(
string path,
DateTimeOffset lastWriteTimeUtc,
CancellationToken cancellationToken) =>
throw new SftpPathException(
path,
"An object's timestamp is when the bucket stored it, and cannot be set to anything else.");
/// <inheritdoc />
public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fromPath);
@@ -370,6 +370,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];
/// <summary>
/// The tab the next connection attempt belongs to, while a reconnect is being started.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>A field rather than a parameter because the two ends of this are not the same call.</b> Dialling
/// goes through the vault's own connect command — which is the whole point, since that is where
/// credentials, host keys and every refusal live — and what comes back is a <c>ConnectionStarting</c>
/// event that has never heard of tabs. This is how the attempt that event announces is recognised as
/// belonging to a tab that already exists instead of getting a new one.
/// </para>
/// <para>
/// It cannot be stale by the time it is read. <c>ConnectionStarting</c> is raised before the vault's
/// first await, on this thread, so the set and the read are the same turn of the dispatcher — and
/// <see cref="ReconnectTabAsync"/> clears it afterwards for the paths that never got that far, which is
/// every refusal the vault makes before it dials.
/// </para>
/// </remarks>
private TerminalTabViewModel? reconnecting;
/// <summary>How often the hosts screen's ago-text is restrung while it is visible.</summary>
/// <remarks>
/// A minute, to match the words it is restringing into: "1 min ago" is the shortest gap the wording
@@ -473,6 +493,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
/// the Android head's distribution separately, so it must never acquire one by accident.
/// </param>
/// <param name="openInEditor">
/// Opens a local path with whatever this machine opens that kind of file with, or null on a head that
/// has no such notion. Forwarded straight to <see cref="TransfersViewModel"/>, which is the only thing
/// that uses it — see its own remark for why this is a delegate and what null means.
/// </param>
/// <param name="watchFile">
/// Watches one local file for changes until the handle it returns is disposed, or null where nothing
/// can. Forwarded with <paramref name="openInEditor"/> and useless without it.
/// </param>
/// <param name="post">
/// Runs an action on the thread this shell's view models are read from. Defaults to the UI thread's
/// dispatcher, which is the answer in every real head.
@@ -500,7 +529,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Func<string, Task>? copyToClipboard = null,
string? deviceName = null,
IUpdateChannel? updates = null,
Action<Action>? post = null)
Action<Action>? post = null,
Func<string, Task>? openInEditor = null,
Func<string, Action, IDisposable>? watchFile = null)
{
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
@@ -523,7 +554,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// The fourth argument is the route out of the S3 screen's empty state. A bucket is made on the
// keychain screen, which is a different tab and two clicks away from somebody who has gone to S3
// to add one — so the screen that needs a bucket is given a way to reach the screen that makes one.
transfers = new TransfersViewModel(sftpSessions, clock, addBucket: ShowNewBucket);
transfers = CreateFileBrowser(sftpSessions, clock, openInEditor, watchFile);
// Built once, like the workspace it writes for, and given a vault only while one is open. It has to
// outlive every lock for the same reason the workspace does: a shell opened before a lock is still
@@ -592,6 +623,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
_ = RunSessionElapsedTickAsync(sessionElapsedTick.Token);
}
/// <summary>Builds the file browser, which is composed once and outlives every lock.</summary>
/// <remarks>
/// A method rather than four more lines in the constructor, which is already at the length the analyzers
/// allow — the same reason <see cref="CreateUpdateScreen"/> is one.
/// <para>
/// It is handed this shell's own posting delegate rather than left to default to the UI dispatcher,
/// which is what it did while nothing it posted was worth asserting on. A connection lost underneath the
/// file browser is raised on SSH.NET's message-listener thread and has to cross onto the thread these
/// view models are read from — the same crossing connection phases already make — and a suite with no
/// window has no way to drain a dispatcher it does not own. Both defaults are the UI dispatcher, so it
/// changes nothing in either head.
/// </para>
/// </remarks>
private TransfersViewModel CreateFileBrowser(
ISftpSessionFactory sftpSessions,
TimeProvider clock,
Func<string, Task>? openInEditor,
Func<string, Action, IDisposable>? watchFile) =>
new(
sftpSessions,
clock,
post: post,
addBucket: ShowNewBucket,
openInEditor: openInEditor,
watchFile: watchFile);
/// <summary>
/// Builds the updater, kept for the life of the process like the workspace and the transfer queue.
/// </summary>
@@ -3471,6 +3528,23 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
{
if (reconnecting is { } existing)
{
// A reconnect. The tab is already in the strip, already selected, and the user is looking at it —
// so it goes back to connecting in place rather than a second tab for the same machine appearing
// beside the one they pressed the button on. See ReconnectTabAsync.
reconnecting = null;
existing.Reconnecting();
attempts[e.AttemptId] = existing;
// The card replaces the pane the moment the session id is dropped, and both flags turn on that.
RaiseTerminalState();
RaiseSessionState();
return;
}
var tab = new TerminalTabViewModel(e.Label, e.Address);
attempts[e.AttemptId] = tab;
@@ -3745,6 +3819,313 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Surface = ShellSurface.Terminal;
}
/// <summary>
/// The host this tab was opened against, or null when the keychain has nothing under that name.
/// </summary>
/// <remarks>
/// <para>
/// Matched on the label, which is the same handle <see cref="RefreshConnectedHosts"/> matches on and for
/// the same reason: a tab outlives the vault that opened it, so it cannot hold an entity id that would
/// still mean anything after a lock.
/// </para>
/// <para>
/// Null is the ordinary answer for two real cases, and both of them are why the reconnect below has to be
/// able to say no: a machine reached by typing an address rather than from the keychain, which has no row
/// to dial again, and a tab whose host was deleted or whose vault is locked.
/// </para>
/// </remarks>
private HostRowViewModel? HostFor(TerminalTabViewModel? tab) =>
tab is null || Vault is not { } vault
? null
: vault.Hosts.FirstOrDefault(
row => string.Equals(row.Label, tab.Label, StringComparison.Ordinal));
/// <summary>
/// Whether the selected tab has stopped and there is something to dial again.
/// </summary>
/// <remarks>
/// Both halves are needed and neither implies the other. The tab has to be finished — offering a
/// reconnect beside a live shell is offering to throw it away — and the machine has to still be in the
/// keychain, because that is the only place the credentials to redial it exist. A tab typed in by hand
/// fails the second half, which is honest: this client cannot dial it again without the password, and a
/// button that raised a password box under the word RECONNECT would be a different promise.
/// </remarks>
internal bool CanReconnectSelectedTab =>
IsTerminalSurface
&& SelectedTab is { CanOfferReconnect: true }
&& HostFor(SelectedTab) is not null;
/// <summary>
/// The word the status bar shows in place of CONNECTED once the selected tab's shell has stopped, or
/// null while it has not.
/// </summary>
/// <remarks>
/// <para>
/// Both surfaces, and only one word on the file side. A shell has two endings worth telling apart —
/// somebody typed <c>exit</c>, or the machine stopped answering — and a file session has one: this
/// screen closing it is a disconnect nobody needs told about, so the only ending left to word is the one
/// that happened on its own.
/// </para>
/// <para>
/// A bucket never reaches this. It is HTTP per request with no connection to lose, so
/// <see cref="TransfersViewModel.IsConnectionLost"/> is false for one however badly a request goes —
/// which is honest rather than a gap: a failed request against a bucket is a failed request, and the
/// screen already reports it as one.
/// </para>
/// </remarks>
internal string? SessionEndedText => Surface switch
{
ShellSurface.Terminal => SelectedTab?.EndedSummary,
_ when IsTransfersShowing => Transfers.IsConnectionLost ? "DISCONNECTED" : null,
_ => null,
};
/// <summary>Whether the selected tab's session ended because the connection broke.</summary>
/// <remarks>
/// Drawn in the palette's red where a shell that merely exited is drawn in grey, which is the whole
/// reason this is a second question rather than "did it end". One of the two is somebody having finished
/// what they were doing and the other is a machine that stopped answering.
/// </remarks>
internal bool IsSessionDropped => Surface switch
{
ShellSurface.Terminal => SelectedTab?.EndReason is SshSessionEnd.ConnectionLost,
// The file side's one ending is the lost one, so where the terminal has to ask which it was, this
// only has to ask whether there was one.
_ when IsTransfersShowing => Transfers.IsConnectionLost,
_ => false,
};
/// <summary>
/// Whether the status bar should say NOT CONNECTED rather than name an ending.
/// </summary>
/// <remarks>
/// The third of three, and it exists so that no two of them are ever drawn at once. A tab still dialling
/// is this; a tab whose shell has stopped has a word of its own, and printing NOT CONNECTED beside
/// DISCONNECTED would answer the same question twice in two vocabularies.
/// </remarks>
internal bool IsSessionIdle => !IsSessionConnected && SessionEndedText is null;
/// <summary>
/// The sentence under that word — what the remote or the transport actually said, or why a reconnect was
/// just refused.
/// </summary>
/// <remarks>
/// Usually the same string the renderer has already written into the pane. Drawn outside it as well
/// because the pane scrolls: a shell that printed a page of shutdown messages before it went has pushed
/// the line explaining itself off the screen, and this is the copy that stays put. See
/// <see cref="TerminalTabViewModel.EndedDetail"/> for the case where it is not that string.
/// </remarks>
internal string? SessionEndedDetail => Surface switch
{
ShellSurface.Terminal => SelectedTab?.EndedDetail,
_ when IsTransfersShowing => Transfers.LostConnection,
_ => null,
};
/// <summary>
/// Whether the strip under a stopped session may offer to dial again.
/// </summary>
/// <remarks>
/// One question resolved by surface, for the reason <see cref="SessionAddress"/> is: the strip is the
/// same markup on both, and a view branching on which one it is drawn over would be the shell's own job
/// done in XAML. The two halves are genuinely different questions — a terminal needs a keychain entry to
/// redial, and a file session already captured what it would dial when it connected.
/// </remarks>
internal bool CanReconnectSession => Surface switch
{
ShellSurface.Terminal => CanReconnectSelectedTab,
_ when IsTransfersShowing => Transfers.IsConnectionLost,
_ => false,
};
/// <summary>What the strip's second button says, which is not the same act on the two surfaces.</summary>
/// <remarks>
/// A terminal's tab is a thing that can be taken away and a file session's pane is not — there is one of
/// it, and what "dismiss" means there is putting the screen back to its invitation. Two labels rather
/// than one vague one, because a button that said CLOSE on both would be doing noticeably different
/// things.
/// </remarks>
internal string SessionEndedDismissLabel => IsTerminalSurface ? "CLOSE TAB" : "DISCONNECT";
/// <summary>Dials again, whichever kind of session stopped.</summary>
/// <remarks>
/// Each half is the surface's own reconnect rather than a third path: the terminal's goes through the
/// vault's connect command into the tab that stopped, and the file side's re-opens on the remote it
/// captured and walks back to the directory it was showing.
/// </remarks>
[RelayCommand]
private async Task ReconnectSessionAsync(CancellationToken cancellationToken)
{
if (IsTerminalSurface)
{
await ReconnectTabAsync(SelectedTab).ConfigureAwait(true);
return;
}
if (IsTransfersShowing)
{
await Transfers.ReconnectCommand.ExecuteAsync(null).ConfigureAwait(true);
}
}
/// <summary>Takes the stopped session away, whichever kind it is.</summary>
/// <inheritdoc cref="SessionEndedDismissLabel" path="/remarks" />
[RelayCommand]
private async Task DismissEndedSessionAsync()
{
if (IsTerminalSurface)
{
if (SelectedTab is { } tab)
{
await CloseTabAsync(tab).ConfigureAwait(true);
}
return;
}
if (IsTransfersShowing)
{
await Transfers.DisconnectCommand.ExecuteAsync(null).ConfigureAwait(true);
}
}
/// <summary>
/// Opens a new shell on the machine a stopped tab was pointed at, in that tab.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>It dials through the vault's ordinary connect path, and that is the whole design.</b> Every
/// refusal a connection can meet — a key that will not resolve, an unknown host key, a mismatch, a group
/// default that has changed since the first attempt — is decided in one place, and a reconnect that
/// reached past it into <c>TerminalWorkspace</c> would be a second connect path that got all of those
/// subtly wrong. What this method adds to that path is one thing: which tab the attempt belongs to.
/// </para>
/// <para>
/// <b>The old session is closed before the new one is asked for.</b> A dead shell still holds an
/// authenticated connection this process has stopped reading, and its pane still holds an xterm, a
/// scrollback and a WebGL context — of which a browser hands out about sixteen. Reconnecting without
/// closing it would leak one of each per reconnect, invisibly, inside the WebView.
/// </para>
/// <para>
/// <b>A host that asks for a typed password is sent to the keychain rather than refused.</b> The vault
/// raises its password sheet for one, and that sheet is drawn on the hosts screen — so this navigates
/// there instead of arming the tab, and the session that results is a new tab. That is a smaller wrong
/// than a button that appears to do nothing because the thing it raised is on a screen nobody is looking
/// at.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ReconnectTabAsync(TerminalTabViewModel? tab)
{
if (tab is null || Vault is not { } vault || !tab.CanOfferReconnect)
{
return;
}
if (HostFor(tab) is not { } host)
{
Refuse(tab, $"{tab.Label} is not in this keychain any more, so there is nothing here to dial again.");
return;
}
// Selected first, so that a reconnect started from the tab strip while another tab was showing puts
// the user in front of the attempt they just asked for.
SelectTab(tab);
// Before the question below, because the question is about the selection: SelectedHostAsksForAPassword
// reads whichever row is lit, and asking it about a host it has not been pointed at yet would answer
// for the previous one. The connect command assigns the same row again, so this is not a second
// selection — it is the same one, made early enough to be asked about.
vault.SelectedHost = host;
// The same pair of conditions ConnectToRowAsync itself branches on: a machine that needs a secret and
// has not been given one is a sheet being raised rather than a connection being made, and a sheet is
// drawn on a screen this surface is not.
if (vault.SelectedHostAsksForAPassword && vault.ConnectPassword.Length == 0)
{
ShowScreen(ShellScreen.Hosts);
// The command's own second half raises the sheet and says which machine it is for; running it
// rather than setting the flag here keeps that wording in one place.
await vault.ConnectToRowCommand.ExecuteAsync(host).ConfigureAwait(true);
return;
}
await RedialIntoAsync(vault, host, tab).ConfigureAwait(true);
}
/// <summary>Dials <paramref name="host"/> with the attempt bound to <paramref name="tab"/>.</summary>
/// <remarks>
/// Split from <see cref="ReconnectTabAsync"/> so that the arming, the dial and the old session's release
/// read as the one sequence they are — the refusals above it are a separate concern and each of them
/// returns before reaching here.
/// </remarks>
private async Task RedialIntoAsync(VaultViewModel vault, HostRowViewModel host, TerminalTabViewModel tab)
{
var previous = tab.SessionId;
reconnecting = tab;
bool dialled;
try
{
await vault.ConnectToRowCommand.ExecuteAsync(host).ConfigureAwait(true);
}
finally
{
// The field being gone means OnVaultConnectionStarting took it, which is the one and only proof
// that an attempt actually began. Asking the attempts map instead does not work: a handshake that
// has already finished — succeeded or been refused — has been taken out of it again, and in this
// suite's fakes that happens before this line is reached.
dialled = reconnecting is null;
// Cleared however it went, so a refusal the vault made before dialling cannot leave the field
// armed to capture somebody else's next connection into this tab.
reconnecting = null;
}
if (!dialled)
{
// The vault turned it down before starting one — a key that has left the keychain, a group
// binding that no longer resolves. Its own Status carries the reason and is drawn on a screen
// this surface is not, so it is repeated where the button was pressed. The old session is left
// alone: there is nothing to replace it with.
Refuse(
tab,
vault.Status is { Length: > 0 } refusal
? refusal
: $"{tab.Label} could not be dialled again.");
return;
}
if (previous != 0)
{
// After the attempt has started, not before: the pane the user was reading stays up for as long
// as there is nothing to replace it with, and closing it first would blank the rectangle for the
// length of a handshake.
await workspace.CloseSessionAsync(previous).ConfigureAwait(true);
}
}
/// <summary>Says why a reconnect is not happening, where the strip that offered it is looking.</summary>
/// <remarks>
/// On the tab rather than in a field of this shell's own, because it is a fact about that tab: switching
/// to another terminal and back should not lose it, and switching to a different one should not show it.
/// See <see cref="TerminalTabViewModel.EndedDetail"/>.
/// </remarks>
private void Refuse(TerminalTabViewModel tab, string reason)
{
tab.Status = reason;
RaiseSessionState();
}
/// <summary>
/// Marks a tab dead when its shell ends on its own.
/// </summary>
@@ -3764,17 +4145,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// gap rather than worth blocking this handler on the recorder's queue.
/// </para>
/// </remarks>
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
Dispatcher.UIThread.Post(() =>
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => post(() =>
{
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
{
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
{
tab.IsLive = false;
}
tab.Ended(e.Outcome.End, e.Outcome.Description);
RefreshConnectedHosts();
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
});
// Only for the tab being looked at, which is what these three already ask. The rectangle does not
// change — a dead tab keeps its pane and its scrollback — but the status bar under it does, and
// the reconnect beside CONNECTED appears from here.
RaiseSessionState();
}
RefreshConnectedHosts();
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
});
/// <summary>
/// Repaints the host list's status dots from the tab list, and with them the session sidebar's contents.
@@ -4125,7 +4510,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
internal string? SessionAddress => Surface switch
{
ShellSurface.Terminal => SelectedTab?.Address,
_ when IsTransfersShowing => Transfers.IsConnected ? Transfers.ConnectedTo : null,
// Connected, or connected until the link went. A session that has just been lost still has a host
// worth naming — it is the one the strip is offering to dial again — and requiring IsConnected here
// collapsed the entire left half of the status bar at exactly the moment it had the most to say.
_ when IsTransfersShowing =>
Transfers.IsConnected || Transfers.IsConnectionLost ? Transfers.ConnectedTo : null,
_ => null,
};
@@ -4258,6 +4649,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
OnPropertyChanged(nameof(SessionAddress));
OnPropertyChanged(nameof(IsSessionConnected));
OnPropertyChanged(nameof(SessionEndedText));
OnPropertyChanged(nameof(SessionEndedDetail));
OnPropertyChanged(nameof(IsSessionDropped));
OnPropertyChanged(nameof(IsSessionIdle));
OnPropertyChanged(nameof(CanReconnectSelectedTab));
OnPropertyChanged(nameof(CanReconnectSession));
OnPropertyChanged(nameof(SessionEndedDismissLabel));
OnPropertyChanged(nameof(SessionElapsedText));
OnPropertyChanged(nameof(SessionCipher));
OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
@@ -4281,6 +4679,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
or nameof(TransfersViewModel.IsConnected)
or nameof(TransfersViewModel.IsConnectionLost)
or nameof(TransfersViewModel.LostConnection)
or nameof(TransfersViewModel.Remote)
or nameof(TransfersViewModel.ConnectedTo)
or nameof(TransfersViewModel.ConnectedCipher)
@@ -351,6 +351,95 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
[ObservableProperty]
private bool isShowing;
/// <summary>
/// How the shell behind this tab ended, or null while it has not.
/// </summary>
/// <remarks>
/// Null covers three states that need no telling apart here — still connecting, still running, and a
/// connection that never happened — because the only thing anything asks this is which ending to word,
/// and none of the three has one.
/// </remarks>
internal SshSessionEnd? EndReason { get; private set; }
/// <summary>
/// The sentence the shell's end came with, or null while there is none.
/// </summary>
/// <remarks>
/// The same string the renderer wrote into the pane, so the detail under a status line and the line in
/// the scrollback are one sentence rather than two accounts that can drift.
/// </remarks>
internal string? EndDescription { get; private set; }
/// <summary>Whether this tab has a session that has stopped, and something to say about it.</summary>
internal bool HasEnded => State is TerminalTabState.Open && !IsLive;
/// <summary>
/// The sentence the strip under a stopped terminal prints: why a reconnect was refused if one just was,
/// and otherwise how the session ended.
/// </summary>
/// <remarks>
/// <para>
/// The refusal wins because it is the newer answer and it is about something the user just did. A tab
/// whose reconnect was turned down — the machine has left the keychain, the key it was bound to is gone —
/// would otherwise go on printing how the shell ended, which is true and is not what was just asked.
/// </para>
/// <para>
/// <see cref="Status"/> is where the refusal lands because that is already what this property means:
/// what the tab has to say for itself. It is empty for the whole of a session's life —
/// <see cref="Opened"/> clears it — so a non-empty one on a stopped tab can only be something that
/// happened after the shell did.
/// </para>
/// </remarks>
internal string? EndedDetail => Status is { Length: > 0 } spoken ? spoken : EndDescription;
/// <summary>Whether this tab's session ended because the connection broke rather than because the shell did.</summary>
/// <remarks>
/// The tab strip's dot binds this, so the two endings are told apart at a glance in the one place every
/// terminal is listed. They used to draw the same grey, which made a tab that had been cut off
/// indistinguishable from one somebody had closed down properly.
/// </remarks>
internal bool IsDropped => EndReason is SshSessionEnd.ConnectionLost;
/// <summary>
/// What the status bar says instead of CONNECTED, or null while nothing has ended.
/// </summary>
/// <remarks>
/// <para>
/// Two words rather than one, because the two endings are read completely differently. A shell that
/// exited is the ordinary end of a day's work and wants no alarm; a connection that broke is a machine
/// that may well still be there, and is the whole reason the reconnect beside it exists.
/// </para>
/// <para>
/// <see cref="SshSessionEnd.ClosedLocally"/> has no word here on purpose. This client closed the session,
/// which means a tab is being removed or reconnected — there is no moment at which somebody reads a
/// status line about it.
/// </para>
/// </remarks>
internal string? EndedSummary => EndReason switch
{
SshSessionEnd.ConnectionLost => "DISCONNECTED",
SshSessionEnd.RemoteClosed => "SHELL EXITED",
_ => null,
};
/// <summary>
/// Whether this tab is in a state where dialling again would mean something.
/// </summary>
/// <remarks>
/// <para>
/// True for a shell that ended however it ended, and for a connection that was refused. All three are a
/// tab with no live session and a machine it was pointed at — and offering the retry on a refusal is
/// worth as much as offering it on a drop, because the commonest refusal is a host that was not up yet.
/// </para>
/// <para>
/// <b>It says the tab is willing, not that the shell can do it.</b> Whether there is anything to dial —
/// a keychain entry under this tab's name, and one that does not need a password typed first — is the
/// shell's question, because this view model has never heard of a vault. See
/// <c>MainWindowViewModel.CanReconnectSelectedTab</c>.
/// </para>
/// </remarks>
internal bool CanOfferReconnect => HasEnded || IsFailed;
/// <summary>Whether there is a pane behind this tab.</summary>
internal bool HasSession => State is TerminalTabState.Open;
@@ -413,6 +502,13 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
{
SessionId = sessionId;
Status = string.Empty;
// Cleared before the flags below raise off them. A tab reconnecting carries one of these from the
// session that just ended, and a live terminal that still says how the last one stopped is the
// stale-fact failure this pair exists to remove.
EndReason = null;
EndDescription = null;
IsLive = true;
State = TerminalTabState.Open;
@@ -432,6 +528,12 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
Status = reason;
IsLive = false;
// A refusal is not an ending of the kind Ended records — there was never a session to end — so
// anything a previous attempt on this tab left behind goes with it. Without this, a reconnect that
// is itself refused would draw the refusal and the old shell's ending at once.
EndReason = null;
EndDescription = null;
// Before the state change, so the list is already correct the first time a view asks. The step that
// was running is where it stopped, and the ones behind it stay done: how far a refused connection
// got is the most useful thing the card still knows, and it is the difference between "that host is
@@ -447,6 +549,96 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
State = TerminalTabState.Failed;
}
/// <summary>
/// Records that the shell behind this tab has stopped, and how.
/// </summary>
/// <param name="end">Which of the three ways it ended.</param>
/// <param name="description">The sentence the pump wrote into the pane, for the detail line.</param>
/// <remarks>
/// <para>
/// The tab stays and its pane stays, exactly as before: the last thing the remote said is usually why the
/// shell ended, and the renderer has already written this same sentence into the scrollback. What is new
/// is that the tab now knows <em>which</em> ending it was, which is the difference between a shell that is
/// finished and a machine that is probably still there — see <see cref="CanOfferReconnect"/>.
/// </para>
/// <para>
/// Ignored for a tab that is connecting or failed. A session end can only be about a session, and a late
/// announcement for one this tab has already been reconnected past would otherwise reach back and mark a
/// live attempt dead. <see cref="Advance"/> guards its own reports for the same reason.
/// </para>
/// </remarks>
internal void Ended(SshSessionEnd end, string description)
{
if (State is not TerminalTabState.Open)
{
return;
}
EndReason = end;
EndDescription = description;
IsLive = false;
OnPropertyChanged(nameof(EndedDetail));
OnPropertyChanged(nameof(EndedSummary));
OnPropertyChanged(nameof(EndDescription));
OnPropertyChanged(nameof(IsDropped));
}
/// <summary>
/// Puts this tab back to connecting, for an attempt that is about to be made on the same machine.
/// </summary>
/// <remarks>
/// <para>
/// The tab is reused rather than a second one opened beside it, because reconnecting is what a person
/// means when they say it: the tab they were working in comes back, in the place it was, still selected.
/// A new tab would leave the strip holding two entries for one machine, one of them a headstone.
/// </para>
/// <para>
/// <b>The session id is dropped here, and the pane it named is the caller's to reclaim.</b> Nothing in
/// this view model can send a frame; what it can do is stop claiming a session that is about to stop
/// being this tab's. See <c>MainWindowViewModel.ReconnectTabAsync</c>, which closes the old session for
/// its pane's sake — a pane holds an xterm, its scrollback and a WebGL context, and browsers hand out
/// about sixteen of the last.
/// </para>
/// <para>
/// The step list is rebuilt to how it looked at the start of the first attempt rather than left as it
/// was. A list still showing where the last attempt got to would read as progress this one had already
/// made, which is the one thing the card promises never to do.
/// </para>
/// </remarks>
internal void Reconnecting()
{
SessionId = 0;
EndReason = null;
EndDescription = null;
IsLive = false;
// Cleared with the session they were facts about. Keeping them would put the previous connection's
// cipher in the status bar beside a handshake that has not negotiated one yet.
StartedAt = null;
Cipher = null;
HostKeyAlgorithm = null;
IdentityLabel = null;
foreach (var row in Steps)
{
row.State = ConnectionStepState.Pending;
}
// The same opening position the constructor sets, and for the same reason: the renderer wait starts
// in this turn, so a list of five pending rows would show an attempt that had not begun.
Steps[0].State = ConnectionStepState.Running;
Status = Steps[0].Caption;
State = TerminalTabState.Connecting;
OnPropertyChanged(nameof(StepsDone));
OnPropertyChanged(nameof(EndedDetail));
OnPropertyChanged(nameof(EndedSummary));
OnPropertyChanged(nameof(EndDescription));
OnPropertyChanged(nameof(IsDropped));
}
/// <summary>Marks every step done, for a connection that is no longer being waited on.</summary>
private void CompleteSteps()
{
@@ -463,5 +655,24 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
OnPropertyChanged(nameof(HasSession));
OnPropertyChanged(nameof(IsConnecting));
OnPropertyChanged(nameof(IsFailed));
OnPropertyChanged(nameof(HasEnded));
OnPropertyChanged(nameof(CanOfferReconnect));
// Opened and Failed both clear the previous ending and then land here, so this is where a dot that
// was red for the last session goes back to whatever the new state deserves.
OnPropertyChanged(nameof(IsDropped));
}
/// <remarks>
/// <see cref="HasEnded"/> and <see cref="CanOfferReconnect"/> are both partly this flag, and the strip
/// binds them — so a shell that stops has to move them in the same notification that greys its dot.
/// </remarks>
/// <remarks>The strip under a stopped terminal reads this as well as the ending; see <see cref="EndedDetail"/>.</remarks>
partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(EndedDetail));
partial void OnIsLiveChanged(bool value)
{
OnPropertyChanged(nameof(HasEnded));
OnPropertyChanged(nameof(CanOfferReconnect));
}
}
File diff suppressed because it is too large. Load diff
+121
View File
@@ -140,6 +140,54 @@ public static class PosixMode
/// <summary>Whether the others triple carries the write bit.</summary>
public static bool IsWorldWritable(string mode) => At(mode, OthersWrite) == 'w';
/// <summary>
/// Reads the nine permission bits back out of a formatted mode, as an octal number.
/// </summary>
/// <remarks>
/// The inverse of <see cref="Format"/>, and it exists because the editor that changes a mode has to
/// start from the one that is there. Anything that is not a mode this type wrote answers zero — the same
/// forgiving reading <see cref="At"/> already takes, and for the same reason: this feeds a form, and a
/// listing is not worth failing over one unrecognised string.
/// </remarks>
public static int ToOctal(string mode)
{
if (mode is not { Length: 10 })
{
return 0;
}
var bits = 0;
for (var index = 1; index < 10; index++)
{
bits <<= 1;
if (mode[index] != '-')
{
bits |= 1;
}
}
return bits;
}
/// <summary>Formats nine permission bits the way <c>chmod</c> takes them, e.g. <c>0755</c>.</summary>
/// <remarks>
/// Four digits with a leading zero, which is how every <c>chmod</c> example on earth writes one — and
/// the leading zero is the part that says out loud that the three bits this client does not write are
/// being sent as clear. See the type's own remark.
/// </remarks>
public static string FormatOctal(int mode) =>
string.Create(CultureInfo.InvariantCulture, $"0{(mode >> 6) & 7}{(mode >> 3) & 7}{mode & 7}");
/// <summary>The nine bits, and nothing above them.</summary>
/// <remarks>
/// Applied to everything on its way to a server. It is what keeps a caller from setting setuid through
/// an interface that does not admit to offering it — see the type's own remark on why those three bits
/// are neither rendered nor written.
/// </remarks>
public static int Sanitise(int mode) => mode & 0b111_111_111;
private const int OwnerExecute = 3;
private const int GroupExecute = 6;
private const int OthersWrite = 8;
@@ -393,6 +441,43 @@ public interface IRemoteFileStore : IAsyncDisposable
/// <summary>Whether the transport is still up.</summary>
bool IsConnected { get; }
/// <summary>
/// Why the connection under this store went away, or null if it did not.
/// </summary>
/// <remarks>
/// <para>
/// The same fact, latched the same way and for the same reason, as
/// <see cref="ISshShellSession.TransportFailure"/> — read that member for the argument. What differs is
/// what it is read <em>for</em>: a shell has a blocked read this releases, and a file browser has no
/// loop at all. Here it is the sentence a screen prints beside the word DISCONNECTED, and the thing that
/// tells a failed listing about a path apart from a failed listing about a connection.
/// </para>
/// <para>
/// Null forever on a store with no connection to lose. A bucket is HTTP, request per operation, and has
/// nothing that can be up between two of them.
/// </para>
/// </remarks>
string? TransportFailure { get; }
/// <summary>
/// Raised once, when the connection goes away without this client having closed it.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Because otherwise nobody finds out until they click.</b> A file browser is idle between
/// operations, so the alternative to an event is a screen that goes on saying CONNECTED over a host that
/// is gone until somebody happens to ask it for a listing — which is the same dishonesty a terminal
/// showed and the same one this is here to remove. <see cref="TransportFailure"/> is already set by the
/// time this is raised.
/// </para>
/// <para>
/// Raised on whichever thread the failure was noticed on, which is SSH.NET's message-listener thread; a
/// handler that touches a view model has to marshal. Never raised by a store with no connection to lose,
/// and never raised for a session this client disposed itself.
/// </para>
/// </remarks>
event EventHandler? ConnectionLost;
/// <summary>
/// Where the session starts, which is the account's home directory.
/// </summary>
@@ -453,6 +538,42 @@ public interface IRemoteFileStore : IAsyncDisposable
/// <summary>Renames or moves a path within the same host.</summary>
Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken);
/// <summary>
/// Sets a path's POSIX mode.
/// </summary>
/// <param name="path">What to change.</param>
/// <param name="mode">
/// The nine permission bits, as an octal number reads them — <c>0o755</c> is <c>rwxr-xr-x</c>. Only the
/// low nine bits are sent; see <see cref="PosixMode"/> for why setuid, setgid and sticky are not this
/// client's to write.
/// </param>
/// <param name="cancellationToken">Cancellation.</param>
/// <remarks>
/// ◆ <b>The one operation on this interface that writes something the listing already shows.</b> The
/// <c>PERMS</c> column has been a read-only fact since it arrived, and the commonest thing anybody does
/// straight after using this screen is open a terminal to make the file they just uploaded executable.
/// <para>
/// Refused by a store with no such thing. An object has an ACL rather than a mode, and mapping one onto
/// the other would be inventing a fact about a bucket.
/// </para>
/// </remarks>
/// <exception cref="SftpPathException">The path is not there, is not writable, or has no mode to set.</exception>
Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken);
/// <summary>
/// Sets a path's last-write time.
/// </summary>
/// <remarks>
/// What makes an upload land carrying the timestamp its source had, which is the half of "preserve" that
/// every platform can answer — a mode is not a fact about a file on Windows and a modification time is.
/// Refused by a store that has no way to say when an object was written other than when it was put.
/// </remarks>
/// <exception cref="SftpPathException">The path is not there, or its time cannot be set.</exception>
Task SetLastWriteTimeAsync(
string path,
DateTimeOffset lastWriteTimeUtc,
CancellationToken cancellationToken);
}
/// <summary>
+64
View File
@@ -66,12 +66,76 @@ public sealed record SshConnectionRequest(
TimeSpan? ConnectTimeout = null,
SshLoopbackProxy? Proxy = null);
/// <summary>
/// How a shell session came to an end.
/// </summary>
/// <remarks>
/// <para>
/// Three, because they are the three a user would answer differently. A shell that exited is over and there
/// is nothing wrong; a connection that was lost is a machine that may well still be there, and is the one
/// worth offering to dial again; a session this client ended is a tab being closed, which nobody needs told.
/// </para>
/// <para>
/// ◆ <b>Deliberately not a fourth for "the remote rebooted".</b> Nothing on the wire distinguishes a reboot
/// from a cable being pulled — both are a transport that stops answering — and a name this client cannot
/// check would be a guess dressed as a fact. <see cref="ConnectionLost"/> is what is actually known.
/// </para>
/// </remarks>
public enum SshSessionEnd
{
/// <summary>
/// The remote ended it in an orderly way: the shell exited, or the server hung up cleanly.
/// </summary>
/// <remarks>
/// What <c>exit</c> and <c>logout</c> produce, and what a login shell being killed produces. The channel
/// closed while the transport under it was still healthy.
/// </remarks>
RemoteClosed = 0,
/// <summary>
/// The transport failed underneath it, with no channel close first.
/// </summary>
/// <remarks>
/// A reboot, a network that went away, an <c>sshd</c> that was killed, a laptop lid. See
/// <see cref="ISshShellSession.TransportFailure"/> for how this is told apart from
/// <see cref="RemoteClosed"/> without a race.
/// </remarks>
ConnectionLost = 1,
/// <summary>This client ended it: a tab was closed, a reconnect began, or the process is shutting down.</summary>
ClosedLocally = 2,
}
/// <summary>An interactive shell over a pseudo-terminal.</summary>
public interface ISshShellSession : IAsyncDisposable
{
/// <summary>Whether the channel is still usable.</summary>
bool IsOpen { get; }
/// <summary>
/// Why the transport under this shell went away, or null if it did not.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>A latched value rather than a question asked afterwards, and that is the whole point of it.</b>
/// The obvious way to tell <c>exit</c> from a dropped connection is to wait for the read to report the end
/// of the shell and then ask whether the transport is still up — and it is wrong about half the time,
/// because a server whose only channel just closed hangs the connection up a millisecond later. The
/// answer to that question races the teardown; this does not.
/// </para>
/// <para>
/// What makes it safe is an ordering SSH.NET guarantees: a transport failure is raised on its
/// message-listener thread <em>before</em> that thread goes on to disconnect the session, and it is the
/// disconnect that wakes a blocked read. So a read that has just returned zero can consult this and get a
/// settled answer — set if the transport failed, still null if the remote merely closed the channel.
/// </para>
/// <para>
/// The message rather than the exception: this is read to be shown to somebody, and nothing above this
/// interface branches on a type.
/// </para>
/// </remarks>
string? TransportFailure { get; }
/// <summary>Reads whatever output is available, blocking until at least one byte arrives.</summary>
/// <returns>Bytes read, or 0 once the remote closes the channel.</returns>
ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken);
@@ -18,6 +18,34 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
{
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15);
/// <summary>
/// How often to put a byte on the wire for a connection that is otherwise idle.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Without this a rebooted machine is never noticed, and the tab claims to be connected for ever.</b>
/// An idle SSH session is a socket nobody is writing to, and a TCP connection that is not written to has
/// no way to discover that the far end has gone: the read simply never returns. That is the whole of the
/// "reboot leaves it hanging" failure — nothing was broken, nothing had been asked, so nothing could
/// fail. This is what makes the question get asked.
/// </para>
/// <para>
/// <b>It is the write that matters, not the reply.</b> SSH.NET's keep-alive is <c>SSH_MSG_IGNORE</c>,
/// which a server is not obliged to answer, so this is not a heartbeat with a deadline — it is traffic.
/// A machine that has rebooted answers the first probe with a RST, and the session is torn down at once;
/// a network that has vanished is discovered when the OS gives up retransmitting. Neither of those is a
/// timeout this client sets, which is why there is no "no reply within N seconds" rule here to be wrong
/// about a slow link.
/// </para>
/// <para>
/// Thirty seconds, which is what OpenSSH's own <c>ServerAliveInterval</c> examples use. It costs a packet
/// a minute per idle session in each direction and it bounds how long a dead session can go on looking
/// alive; shorter would not make the RST arrive sooner, because what is being waited for is the first
/// probe after the machine came back.
/// </para>
/// </remarks>
private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(30);
/// <summary>
/// How much of a file SSH.NET reads or writes per SFTP request.
/// </summary>
@@ -47,7 +75,7 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
{
ArgumentNullException.ThrowIfNull(request);
var client = new SshClient(BuildConnectionInfo(request));
var client = new SshClient(BuildConnectionInfo(request)) { KeepAliveInterval = KeepAliveInterval };
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
.ConfigureAwait(false);
@@ -67,7 +95,14 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
{
ArgumentNullException.ThrowIfNull(request);
var client = new SftpClient(BuildConnectionInfo(request)) { BufferSize = SftpBufferSize };
// Keep-alive here for the reason it is on the shell client: a transfer connection left open while
// somebody browses is idle in exactly the same way, and a file listing against a machine that has
// since rebooted would otherwise wait on a socket nothing will ever answer.
var client = new SftpClient(BuildConnectionInfo(request))
{
BufferSize = SftpBufferSize,
KeepAliveInterval = KeepAliveInterval,
};
// No progress for the file-transfer path. The screen that waits on one is the file browser, which
// reports itself, and a second connection opened behind an already-open shell has nothing the user
@@ -327,7 +362,7 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
effective.PixelHeight,
4096);
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell));
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell, client));
}
/// <inheritdoc />
@@ -339,10 +374,96 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
}
/// <summary>An SSH.NET-backed shell session.</summary>
internal sealed class SshNetShellSession(ShellStream shell) : ISshShellSession
internal sealed class SshNetShellSession : ISshShellSession
{
private readonly ShellStream shell;
private readonly SshClient client;
private string? transportFailure;
/// <param name="shell">The channel this shell reads and writes.</param>
/// <param name="client">
/// The connection underneath it, subscribed to for the one thing the stream cannot report on its own —
/// see <see cref="TransportFailure"/>.
/// </param>
internal SshNetShellSession(ShellStream shell, SshClient client)
{
this.shell = shell;
this.client = client;
client.ErrorOccurred += OnTransportError;
}
/// <inheritdoc />
public bool IsOpen => shell.CanRead;
/// <remarks>
/// <c>CanWrite</c> rather than <c>CanRead</c>, which is what this used to read and which is a constant:
/// <c>ShellStream.CanRead</c> is documented as always true — it stays readable after disposal so that
/// buffered output can still be drained — so this property answered "yes" for every shell that had ever
/// existed. <c>CanWrite</c> is the one that goes false when the stream is disposed, which is what closing
/// the channel and losing the session both do.
/// </remarks>
public bool IsOpen => shell.CanWrite;
/// <inheritdoc />
/// <remarks>
/// Written on SSH.NET's message-listener thread and read on the pump's, which is why it is a
/// <see cref="Volatile"/> pair rather than a plain field: the two threads are ordered by the wake-up the
/// interface's own remark describes, but nothing in the language says so, and a torn or cached read here
/// would report a clean exit for a connection that had just died.
/// </remarks>
public string? TransportFailure => Volatile.Read(ref transportFailure);
/// <summary>
/// Latches the first transport failure and ends the read that is waiting on a connection which is
/// no longer there.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>THE SECOND HALF OF THIS IS WHY A REBOOTED MACHINE USED TO HANG FOR EVER, AND IT IS A DEFECT IN
/// SSH.NET RATHER THAN IN ANYTHING ABOVE IT.</b> <c>ShellStream</c> ends a blocked read from exactly two
/// places: its channel being closed, and the session raising <c>Disconnected</c>. A shell that exits
/// gives the first. But <c>Session</c> raises <c>Disconnected</c> from one method only —
/// <c>OnDisconnectReceived</c>, for a server that politely says goodbye — and a socket that simply fails
/// goes down a different path: it raises the error event, sets its disconnecting flag and disposes the
/// socket, without ever raising <c>Disconnected</c>. So the stream was never disposed, its
/// <c>Monitor.Wait</c> was never pulsed, and the read blocked until the process ended. The transport was
/// gone, the client knew the transport was gone, and the terminal went on looking connected.
/// </para>
/// <para>
/// Keep-alive is what makes the failure <em>happen</em> on an idle connection; this is what makes it
/// <em>arrive</em>. Neither is sufficient alone, which is why both are in this file.
/// </para>
/// <para>
/// <b>Queued rather than disposed inline, and that is not tidiness.</b> This runs on SSH.NET's own
/// message-listener thread. Closing a channel waits for the server's close to come back — which is
/// processed by that same thread — so disposing here would be the listener thread waiting on itself, and
/// the best case is that it unwinds when the wait times out.
/// </para>
/// <para>
/// The latch is set before the queue, so <see cref="TransportFailure"/> is already true by the time the
/// read it releases can look at it. That ordering is the whole of the promise this property makes.
/// </para>
/// </remarks>
private void OnTransportError(object? sender, ExceptionEventArgs e)
{
Interlocked.CompareExchange(ref transportFailure, e.Exception.Message, comparand: null);
ThreadPool.UnsafeQueueUserWorkItem(
static state =>
{
try
{
state.Dispose();
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Best-effort. The connection is already gone; what matters is that the read was
// released, and a fault tearing down a dead channel has no caller to report it to.
}
},
shell,
preferLocal: false);
}
/// <inheritdoc />
/// <remarks>
@@ -388,6 +509,10 @@ internal sealed class SshNetShellSession(ShellStream shell) : ISshShellSession
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
// Unsubscribed before the stream goes, so a connection that outlives this shell — the workspace
// disposes the two in that order — is not still holding a reference to it through its own event.
client.ErrorOccurred -= OnTransportError;
await shell.DisposeAsync().ConfigureAwait(false);
}
}
+103 -5
View File
@@ -11,14 +11,35 @@ namespace DodoSSH.Client.Ssh;
/// this type does beyond forwarding calls is translate SSH.NET's several path failures into the single one a
/// caller can act on.
/// </remarks>
internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
: ISftpSession
internal sealed class SshNetSftpSession : ISftpSession
{
private readonly SftpClient client;
private string? transportFailure;
private int announced;
internal SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
{
this.client = client;
HostKey = hostKey;
HomeDirectory = homeDirectory;
Cipher = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
client.ErrorOccurred += OnTransportError;
}
/// <inheritdoc />
public bool IsConnected => client.IsConnected;
/// <inheritdoc />
public HostKeyPresentation HostKey { get; } = hostKey;
public string? TransportFailure => Volatile.Read(ref transportFailure);
/// <inheritdoc />
public event EventHandler? ConnectionLost;
/// <inheritdoc />
public HostKeyPresentation HostKey { get; }
/// <inheritdoc />
/// <remarks>
@@ -27,10 +48,36 @@ internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation h
/// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from. The coalesce
/// is there for SSH.NET's nullable annotation, which covers the pre-key-exchange window this never sees.
/// </remarks>
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
public string Cipher { get; }
/// <inheritdoc />
public string HomeDirectory { get; } = homeDirectory;
public string HomeDirectory { get; }
/// <summary>
/// Latches the failure and says so once.
/// </summary>
/// <remarks>
/// <para>
/// No blocked read to release, unlike the shell's own handler: every SFTP call is a request and a reply,
/// so a connection that has gone is discovered by the next one throwing. What this adds is that somebody
/// finds out <em>without</em> making that next call — a file browser sits idle between operations, and
/// waiting for a click to reveal a host that went away half an hour ago is exactly the stale CONNECTED
/// this exists to remove.
/// </para>
/// <para>
/// Announced once. SSH.NET can raise several errors as one connection comes apart, and a screen told
/// three times would report the second and third against a session it had already replaced.
/// </para>
/// </remarks>
private void OnTransportError(object? sender, ExceptionEventArgs e)
{
Interlocked.CompareExchange(ref transportFailure, e.Exception.Message, comparand: null);
if (Interlocked.Exchange(ref announced, 1) == 0)
{
ConnectionLost?.Invoke(this, EventArgs.Empty);
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
@@ -141,10 +188,61 @@ internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation h
}
}
/// <inheritdoc />
/// <remarks>
/// ◆ <b>On a thread-pool thread, because SSH.NET offers no asynchronous <c>SETSTAT</c>.</b>
/// <c>ChangePermissions</c> is a blocking round trip and every caller here is on the thread the
/// interface is drawn from — so calling it directly would freeze the window for a link's latency, which
/// on the connections this product exists for is the difference between instant and noticeably not.
/// Every other operation on this type has an async form and uses it.
/// </remarks>
public async Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken)
{
// Sanitised here rather than trusted from the caller, so that the promise the interface makes about
// the three bits it does not write holds at the last point before the wire.
var bits = (short)PosixMode.Sanitise(mode);
try
{
await Task.Run(() => client.ChangePermissions(path, bits), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception) when (IsPathFailure(exception))
{
throw Translate(path, exception);
}
}
/// <inheritdoc />
/// <remarks>See <see cref="SetPermissionsAsync"/>; the same blocking round trip, moved off the caller's thread for the same reason.</remarks>
public async Task SetLastWriteTimeAsync(
string path,
DateTimeOffset lastWriteTimeUtc,
CancellationToken cancellationToken)
{
var when = lastWriteTimeUtc.UtcDateTime;
try
{
await Task.Run(() => client.SetLastWriteTimeUtc(path, when), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception) when (IsPathFailure(exception))
{
throw Translate(path, exception);
}
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
// Before the client goes, and it also settles the flag: a disposal that trips SSH.NET's own error
// path must not reach a screen as a connection this client did not mean to lose.
Interlocked.Exchange(ref announced, 1);
client.ErrorOccurred -= OnTransportError;
client.Dispose();
return ValueTask.CompletedTask;
}
@@ -16,6 +16,21 @@ public interface ITerminalTransport
ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken);
}
/// <summary>
/// How one session ended, and the sentence that says so.
/// </summary>
/// <param name="End">Which of the three ways it was.</param>
/// <param name="Description">
/// What to show a person. Written into the pane by the renderer, and available to the shell beside the
/// <paramref name="End"/> it belongs to.
/// </param>
/// <remarks>
/// The pair travels together because either alone is not enough. The enum is what code branches on — whether
/// to offer a reconnect, whether a tab is worth keeping — and the sentence is the detail underneath it, which
/// for a lost connection is the only place the actual cause appears.
/// </remarks>
public sealed record TerminalSessionOutcome(SshSessionEnd End, string Description);
/// <summary>Tuning for one session's output path.</summary>
public sealed class TerminalPumpOptions
{
@@ -100,6 +115,24 @@ public sealed class TerminalSessionPump : IAsyncDisposable
/// <summary>Total frames sent to the renderer, for the throughput harness.</summary>
public long FramesSent { get; private set; }
/// <summary>
/// How this session ended and what to say about it, once <see cref="RunAsync"/> has returned.
/// </summary>
/// <remarks>
/// <para>
/// Meaningless before the run completes, and deliberately not nullable for it: a session that has not
/// ended is not a session that ended in an unknown way, and every caller here already knows whether the
/// run is over — the workspace awaits it before reading this.
/// </para>
/// <para>
/// The same description that goes to the renderer in the <see cref="TerminalServerOpcode.SessionClosed"/>
/// frame, so the line written into the pane and whatever the shell draws around the tab are the one
/// sentence rather than two that can disagree.
/// </para>
/// </remarks>
public TerminalSessionOutcome Outcome { get; private set; } =
new(SshSessionEnd.ClosedLocally, "The session was closed.");
/// <summary>
/// Runs until the remote closes the channel or the token is cancelled.
/// </summary>
@@ -119,21 +152,36 @@ public sealed class TerminalSessionPump : IAsyncDisposable
var reader = ReadLoopAsync(linked.Token);
var flusher = FlushLoopAsync(linked.Token);
string reason;
TerminalSessionOutcome outcome;
try
{
await reader.ConfigureAwait(false);
reason = "The remote closed the session.";
// The read loop finished of its own accord, which means the shell is over. Which of the two ways
// it is over is not a question asked of the transport now — by now the server has very likely hung
// up behind a clean exit, and asking would call `exit` a dropped connection — but of what the shell
// latched at the moment it happened. See ISshShellSession.TransportFailure.
outcome = session.TransportFailure is { Length: > 0 } failure
? new TerminalSessionOutcome(SshSessionEnd.ConnectionLost, failure)
: new TerminalSessionOutcome(SshSessionEnd.RemoteClosed, "The remote closed the session.");
}
catch (OperationCanceledException)
{
reason = "The session was closed.";
outcome = new TerminalSessionOutcome(SshSessionEnd.ClosedLocally, "The session was closed.");
}
catch (Exception exception)
{
reason = exception.Message;
// A read that threw rather than returning zero. Whatever it was, it was not this client asking —
// that arrives as the cancellation above — so it is the connection going out from under the read.
outcome = new TerminalSessionOutcome(
SshSessionEnd.ConnectionLost,
session.TransportFailure is { Length: > 0 } failure ? failure : exception.Message);
}
// Before the drain and the last frame, so that anything reading this off a completed run — the
// workspace's own announcement — cannot see the default.
Outcome = outcome;
// Stop the flusher, but only after draining what the reader already produced — the last thing
// a remote writes is often the most important, and dropping it makes a clean exit look like a
// crash.
@@ -150,7 +198,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
await SendAsync(
TerminalServerOpcode.SessionClosed,
Encoding.UTF8.GetBytes(reason),
Encoding.UTF8.GetBytes(outcome.Description),
CancellationToken.None)
.ConfigureAwait(false);
}
@@ -31,10 +31,21 @@ public sealed class TerminalWorkspaceOptions
/// <summary>A session whose shell has ended.</summary>
/// <param name="sessionId">The session, as the renderer and the workspace know it.</param>
public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs
/// <param name="outcome">How it ended, and the sentence that says so.</param>
/// <remarks>
/// ◆ <b>The outcome is here because a subscriber cannot work it out afterwards.</b> By the time this event
/// is raised the session is over: there is no transport left to interrogate, and the difference between a
/// shell that exited and a connection that broke is exactly the difference between a tab that is finished
/// and a tab worth offering to dial again. It used to carry the id alone, so the only place that difference
/// existed was a line of grey text inside the WebView.
/// </remarks>
public sealed class TerminalSessionEndedEventArgs(uint sessionId, TerminalSessionOutcome outcome) : EventArgs
{
/// <summary>The session that ended.</summary>
public uint SessionId { get; } = sessionId;
/// <inheritdoc cref="TerminalSessionOutcome" />
public TerminalSessionOutcome Outcome { get; } = outcome;
}
/// <summary>
@@ -108,6 +119,10 @@ public sealed class TerminalWorkspace : IAsyncDisposable
/// </summary>
private static readonly byte[] ReplayMarker = [1];
/// <summary>What <see cref="CloseSessionAsync"/> announces, whatever the pump made of the same end.</summary>
private static readonly TerminalSessionOutcome ClosedByThisClient =
new(SshSessionEnd.ClosedLocally, "The session was closed.");
private uint nextSessionId = 1;
private Task? server;
private int disposed;
@@ -434,7 +449,7 @@ public sealed class TerminalWorkspace : IAsyncDisposable
// The announcement's own continuation — see AnnounceEndedAsync. Started after the entry is stored,
// so the containment check inside it can never run against a dictionary the session had not reached.
_ = AnnounceEndedAsync(sessionId, run);
_ = AnnounceEndedAsync(sessionId, pump, run);
return sessionId;
}
@@ -611,9 +626,13 @@ public sealed class TerminalWorkspace : IAsyncDisposable
// After the drain, so a handler reading LiveSessionCount sees this session already gone — the
// event's own remark carries why a deliberate close is announced at all, and why shutdown is not:
// DisposeAsync sets the flag before its closing loop, and is dismantling every subscriber anyway.
//
// Reported as this client's own doing whatever the pump made of it. A shell that had already ended on
// its own before the tab was closed still carries that older reason, and re-announcing it here as
// though it had just happened is how closing a tab comes to look like a dropped connection.
if (Volatile.Read(ref disposed) == 0)
{
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, ClosedByThisClient));
}
}
@@ -743,6 +762,13 @@ public sealed class TerminalWorkspace : IAsyncDisposable
}
/// <summary>Announces a session's end once its run task has actually completed.</summary>
/// <param name="sessionId">The session, as the renderer knows it.</param>
/// <param name="pump">
/// The pump, for its <see cref="TerminalSessionPump.Outcome"/> — which is only settled once
/// <paramref name="run"/> has completed, and is therefore read after the await below rather than captured
/// alongside it.
/// </param>
/// <param name="run">The session's run task.</param>
/// <remarks>
/// <para>
/// A continuation rather than a line in <see cref="RunSessionAsync"/>'s finally, and the difference is
@@ -758,7 +784,7 @@ public sealed class TerminalWorkspace : IAsyncDisposable
/// removes the entry before it disposes the pump, and makes its own announcement after its own drain.
/// </para>
/// </remarks>
private async Task AnnounceEndedAsync(uint sessionId, Task run)
private async Task AnnounceEndedAsync(uint sessionId, TerminalSessionPump pump, Task run)
{
try
{
@@ -779,7 +805,9 @@ public sealed class TerminalWorkspace : IAsyncDisposable
if (announce)
{
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
// Read after the await, which is the only point at which the pump has settled it — see
// TerminalSessionPump.Outcome.
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, pump.Outcome));
}
}
@@ -87,6 +87,41 @@ public sealed record TransferSnapshot(
State is TransferState.Failed or TransferState.Cancelled && Transferred > 0 && Transferred < Length;
}
/// <summary>
/// What a transfer should carry across with the bytes.
/// </summary>
/// <param name="Mode">
/// The nine POSIX permission bits to set on the destination, or null to leave whatever it lands with.
/// </param>
/// <param name="LastWriteTimeUtc">
/// The timestamp to set on the destination, or null to leave the one the write produced.
/// </param>
/// <remarks>
/// <para>
/// ◆ <b>Both nullable, because both are facts the source may not have.</b> A local file on Windows has no
/// POSIX mode — this codebase already says so, in <c>LocalEntryRowViewModel</c>, and prints an empty
/// <c>PERMS</c> column rather than a plausible <c>-rw-r--r--</c>. So the caller passes what it actually
/// knows and null for the rest, and nothing here invents a default: a file that arrives with the server's
/// umask on it is the honest outcome of an upload from a machine with no mode to send.
/// </para>
/// <para>
/// <b>Applied after the rename into place, never before.</b> The part file is what the transfer writes and
/// it is not the file anybody asked for; setting a mode on it would put the right bits on the wrong name
/// and leave the destination with whatever the rename gave it.
/// </para>
/// <para>
/// <b>A failure here does not fail the transfer.</b> The bytes are the transfer and they arrived; a server
/// that refuses <c>SETSTAT</c> — some do, and every object store does — must not turn a completed copy into
/// a failed one. It is reported through <see cref="TransferSnapshot.Failure"/> on a row that still says
/// <see cref="TransferState.Completed"/>, which is the one shape that says both true things at once.
/// </para>
/// </remarks>
public sealed record TransferAttributes(int? Mode, DateTimeOffset? LastWriteTimeUtc)
{
/// <summary>Whether there is anything here worth a round trip.</summary>
public bool IsEmpty => Mode is null && LastWriteTimeUtc is null;
}
/// <summary>A transfer whose state or progress has moved.</summary>
/// <param name="transfer">The transfer, as it now is.</param>
public sealed class TransferChangedEventArgs(TransferSnapshot transfer) : EventArgs
@@ -216,18 +251,23 @@ public sealed class FileTransferQueue : IAsyncDisposable
/// rather than measured here, because the pane the user dragged from has just read it and asking again
/// would be a round trip to learn something already on screen.
/// </param>
/// <param name="preserve">
/// What to carry across with the bytes, or null to carry nothing. See <see cref="TransferAttributes"/>.
/// </param>
/// <returns>The transfer's id.</returns>
public Guid Enqueue(
TransferDirection direction,
string localPath,
string remotePath,
long length)
long length,
TransferAttributes? preserve = null)
{
ArgumentException.ThrowIfNullOrEmpty(localPath);
ArgumentException.ThrowIfNullOrEmpty(remotePath);
var entry = new Entry
{
Preserve = preserve is { IsEmpty: false } wanted ? wanted : null,
Id = Guid.CreateVersion7(),
Direction = direction,
LocalPath = localPath,
@@ -549,6 +589,75 @@ public sealed class FileTransferQueue : IAsyncDisposable
// block here. On Windows a move of a file still open for writing fails, and it is the one step whose
// failure would leave a complete transfer looking like an incomplete one.
File.Move(part, destination);
ApplyLocally(entry, destination);
}
/// <summary>
/// Puts the remote file's mode and timestamp on the copy that has just landed here.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The mode is only set where the platform has one.</b> <c>File.SetUnixFileMode</c> throws on
/// Windows, where a POSIX mode is not a fact about a file at all — so it is asked for rather than tried,
/// and a download onto Windows quietly carries the timestamp alone. That is not a gap being papered
/// over: there is nothing on that filesystem for those nine bits to mean.
/// </para>
/// <para>
/// Best-effort, and its failures are recorded on a row that still says the transfer completed. See
/// <see cref="TransferAttributes"/>.
/// </para>
/// </remarks>
private static void ApplyLocally(Entry entry, string destination)
{
if (entry.Preserve is not { } preserve)
{
return;
}
try
{
if (preserve.LastWriteTimeUtc is { } when)
{
File.SetLastWriteTimeUtc(destination, when.UtcDateTime);
}
if (preserve.Mode is { } mode && !OperatingSystem.IsWindows())
{
File.SetUnixFileMode(destination, (UnixFileMode)PosixModeBits(mode));
}
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException
or PlatformNotSupportedException)
{
entry.Failure = $"The file arrived, but its permissions could not be set: {exception.Message}";
}
}
/// <summary>
/// The nine permission bits, as <see cref="UnixFileMode"/> numbers them.
/// </summary>
/// <remarks>
/// A translation rather than a cast, and it is load-bearing: <see cref="UnixFileMode"/> is a flags enum
/// whose values happen to line up with the octal mode for the low nine bits, which is true today and is
/// not something the BCL promises. Writing the nine out means a change there is a compile error rather
/// than a file that lands with the wrong bits on it.
/// </remarks>
private static UnixFileMode PosixModeBits(int mode)
{
var bits = UnixFileMode.None;
if ((mode & 0b100_000_000) != 0) { bits |= UnixFileMode.UserRead; }
if ((mode & 0b010_000_000) != 0) { bits |= UnixFileMode.UserWrite; }
if ((mode & 0b001_000_000) != 0) { bits |= UnixFileMode.UserExecute; }
if ((mode & 0b000_100_000) != 0) { bits |= UnixFileMode.GroupRead; }
if ((mode & 0b000_010_000) != 0) { bits |= UnixFileMode.GroupWrite; }
if ((mode & 0b000_001_000) != 0) { bits |= UnixFileMode.GroupExecute; }
if ((mode & 0b000_000_100) != 0) { bits |= UnixFileMode.OtherRead; }
if ((mode & 0b000_000_010) != 0) { bits |= UnixFileMode.OtherWrite; }
if ((mode & 0b000_000_001) != 0) { bits |= UnixFileMode.OtherExecute; }
return bits;
}
private async Task ReadIntoPartAsync(
@@ -602,6 +711,50 @@ public sealed class FileTransferQueue : IAsyncDisposable
await WriteIntoPartAsync(entry, session, part, offset, cancellationToken).ConfigureAwait(false);
await session.RenameAsync(part, destination, cancellationToken).ConfigureAwait(false);
await ApplyRemotelyAsync(entry, session, destination, cancellationToken).ConfigureAwait(false);
}
/// <summary>Puts the source file's mode and timestamp on the copy that has just landed on the host.</summary>
/// <remarks>
/// ◆ <b>This is the one that makes an uploaded <c>deploy.sh</c> arrive executable.</b> Without it a file
/// lands with whatever the server's umask gives it, which is the reason people reach for a terminal
/// straight after using this screen.
/// <para>
/// A refusal is recorded and does not fail the transfer — a store that has no mode to set says so, and
/// the bytes are still there. See <see cref="TransferAttributes"/>.
/// </para>
/// </remarks>
private static async Task ApplyRemotelyAsync(
Entry entry,
IRemoteFileStore session,
string destination,
CancellationToken cancellationToken)
{
if (entry.Preserve is not { } preserve)
{
return;
}
try
{
if (preserve.Mode is { } mode)
{
await session.SetPermissionsAsync(destination, mode, cancellationToken)
.ConfigureAwait(false);
}
if (preserve.LastWriteTimeUtc is { } when)
{
await session.SetLastWriteTimeAsync(destination, when, cancellationToken)
.ConfigureAwait(false);
}
}
catch (Exception exception) when (exception is not OutOfMemoryException
and not OperationCanceledException)
{
entry.Failure = $"The file arrived, but its permissions could not be set: {exception.Message}";
}
}
private async Task WriteIntoPartAsync(
@@ -865,6 +1018,9 @@ public sealed class FileTransferQueue : IAsyncDisposable
/// <summary>Whether the next run may carry on from a part file rather than starting again.</summary>
public bool Resume { get; set; }
/// <inheritdoc cref="TransferAttributes" />
public TransferAttributes? Preserve { get; init; }
public CancellationTokenSource? Cancellation { get; set; }
}
}
@@ -1325,9 +1325,61 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
transfers.IsConnected = true;
transfers.RemotePath = "/srv/releases/site/backups/nightly";
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
"2026-07-30",
"/srv/releases/site/backups/nightly/2026-07-30",
IsDirectory: true);
[
new RemoteDeletionTarget(
"2026-07-30",
"/srv/releases/site/backups/nightly/2026-07-30",
IsDirectory: true),
]);
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// The edit strip, which shares the questions' Grid row and is the tallest thing that can be in it: a
/// heading, a path, a scrolling log of saves, the conflict card and a button. Its log is bounded for the
/// same reason the deletion card's path list is — see TransfersScreen.axaml — and this is what says that
/// bound holds with the conflict card open underneath it, which is the worst case.
/// </remarks>
[Fact]
public async Task TheTransfersScreenFitsWithAFileBeingEdited()
{
transfers.IsConnected = true;
transfers.RemotePath = "/etc/nginx/conf.d";
transfers.RemoteEdit = new RemoteEditSession(
"site.conf",
"/etc/nginx/conf.d/site.conf",
Path.Combine(Path.GetTempPath(), "dodossh-edits", "abc", "site.conf"),
0b110_100_100);
transfers.RemoteEditConflict = "The server's copy has changed since you opened it.";
for (var save = 0; save < 6; save++)
{
transfers.RemoteEditLog.Add($"14:2{save}:07 saved → uploaded (2.4 KB)");
}
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// The same card with a whole ticked set in it, which is the shape that can grow without limit. Its
/// path list scrolls inside a bounded height rather than pushing the buttons under it off the pane —
/// see TransfersScreen.axaml — and this is what says that bound actually holds. Five is past the point
/// where an unbounded list would have overflowed the 204-pixel pane inside the session shell.
/// </remarks>
[Fact]
public async Task TheTransfersScreenFitsWithSeveralDeletionsInQuestion()
{
transfers.IsConnected = true;
transfers.RemotePath = "/srv/releases/site/backups/nightly";
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
[
.. Enumerable.Range(24, 5).Select(day => new RemoteDeletionTarget(
$"2026-07-{day}",
$"/srv/releases/site/backups/nightly/2026-07-{day}",
IsDirectory: true)),
]);
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
}
@@ -128,6 +128,31 @@ public sealed class SessionSidebarTests : IAsyncLifetime
});
}
/// <remarks>
/// The address is the one string in this column somebody has to be able to take away with them, and the
/// claim under test is the half of that which the trimming could quietly break: an ellipsis is a drawing,
/// not a shorter string, so select-all reaches the whole address and not the part that happened to fit.
/// Asserted on the longest address this suite has — the case where the two could disagree — rather than
/// on one that fits, which would pass whether the trimming were render-only or not.
/// </remarks>
[Fact]
public async Task TheAddressSelectsAndCopiesWholeEvenWhereItIsTrimmed()
{
await OnTheSidebarAsync((sidebar, _) =>
{
// Single rather than First: this column prints exactly one string that is there to be copied, and
// a second selectable one appearing beside it is a question this test should ask rather than skip.
var address = sidebar.GetVisualDescendants().OfType<SelectableTextBlock>().Single();
address.Text.ShouldBe(LongAddress);
address.SelectAll();
address.SelectedText.ShouldBe(LongAddress);
address.CanCopy.ShouldBeTrue("Ctrl+C and the right-click Copy item are both gated on this");
});
}
/// <remarks>
/// Closed, the control is still drawn and is still the thing the window asks for a width — see
/// <c>MainWindow.axaml</c>'s own "Auto" column. What it must not be is nothing: a rail with the way back
@@ -490,6 +490,7 @@
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )",
"DodoSSH.Client.Transfer": "[1.0.0, )",
"Microsoft.TSS": "[2.1.1, )",
"Velopack": "[1.2.0, )"
}
},
@@ -729,6 +730,12 @@
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.TSS": {
"type": "CentralTransitive",
"requested": "[2.1.1, )",
"resolved": "2.1.1",
"contentHash": "87DBN+4ANSFO8TDRmFjjTBl6PL7tmrfofmxki4LUnE34gOdyQiBfJK/X1QSu0UfCGUtpoBVv/tD985s4FDpKiA=="
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
+175 -13
View File
@@ -29,6 +29,14 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
/// <summary>Requests this factory was asked for, in order.</summary>
internal List<SshConnectionRequest> Requests { get; } = [];
/// <summary>Every shell this factory has opened, in order.</summary>
/// <remarks>
/// Exists so a test can reach one and drop it — see <see cref="FakeSshShellSession.Drop"/>. There was no
/// reason to hold them while the only ending a session had was the tab being closed, which the shell can
/// ask for directly.
/// </remarks>
internal List<FakeSshShellSession> Shells { get; } = [];
/// <summary>Requests for a file-transfer session, in order.</summary>
/// <remarks>
/// Kept apart from <see cref="Requests"/> deliberately: file transfer is a separate connection, and a
@@ -65,7 +73,7 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
progress?.Report(SshConnectionPhase.CheckingHostKey);
progress?.Report(SshConnectionPhase.Authenticating);
return new FakeSshConnection(request);
return new FakeSshConnection(request, Shells);
}
/// <summary>The file-transfer session this factory last handed out, or null when it never has.</summary>
@@ -107,6 +115,27 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
/// <inheritdoc />
public bool IsConnected { get; private set; } = true;
/// <inheritdoc />
public string? TransportFailure { get; private set; }
/// <inheritdoc />
public event EventHandler? ConnectionLost;
/// <summary>
/// Ends this session the way a rebooted host does: the connection is gone and it says so.
/// </summary>
/// <remarks>
/// Latched before the announcement, which is the ordering the real session promises — a screen reading
/// the reason out of the event would see null if a fake did it the other way round.
/// </remarks>
internal void Drop(string reason)
{
TransportFailure = reason;
IsConnected = false;
ConnectionLost?.Invoke(this, EventArgs.Empty);
}
/// <inheritdoc />
public HostKeyPresentation HostKey { get; } =
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
@@ -131,6 +160,35 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
/// <summary>Every path this session has been asked to delete.</summary>
internal List<string> Deleted { get; } = [];
/// <summary>Every mode this session has been asked to set, as it was asked.</summary>
/// <remarks>
/// The evidence the screen's own status line cannot be: that sentence is code describing itself, and
/// what matters is which nine bits actually went to the host.
/// </remarks>
internal List<(string Path, int Mode)> PermissionsSet { get; } = [];
/// <summary>Every timestamp this session has been asked to set.</summary>
internal List<(string Path, DateTimeOffset When)> TimesSet { get; } = [];
/// <inheritdoc />
public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken)
{
PermissionsSet.Add((path, mode));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SetLastWriteTimeAsync(
string path,
DateTimeOffset lastWriteTimeUtc,
CancellationToken cancellationToken)
{
TimesSet.Add((path, lastWriteTimeUtc));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
{
@@ -148,21 +206,67 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
]);
}
/// <summary>
/// Paths this session admits to already having, and what it says about each.
/// </summary>
/// <remarks>
/// ◆ Empty by default, and that is load-bearing rather than lazy: the transfer queue asks
/// <see cref="StatAsync"/> whether a destination is taken and refuses to overwrite one that is, so a
/// fake answering yes for everything would make every upload in this suite fail. What needs a real
/// answer is editing, which stats the file before each write-back to see whether it moved — so a test
/// about that puts the one path it is about in here.
/// </remarks>
internal Dictionary<string, SftpEntry> Existing { get; } = new(StringComparer.Ordinal);
/// <summary>What the last write-back actually sent, so an edit's bytes are observable.</summary>
internal byte[]? Written { get; private set; }
/// <inheritdoc />
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
Task.FromResult<SftpEntry?>(null);
Task.FromResult(Existing.TryGetValue(path, out var entry) ? entry : null);
/// <inheritdoc />
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
Task.FromResult<Stream>(new MemoryStream("hello there\n"u8.ToArray(), writable: false));
/// <inheritdoc />
/// <remarks>
/// ◆ A write updates <see cref="Existing"/> only for a path already in it, which is the difference
/// between modelling a server and breaking every other test in the suite: the transfer queue writes to
/// part files and stats them to decide whether there is anything to resume from, so a fake that
/// remembered every write would start answering yes to questions it currently answers no to. What is in
/// <see cref="Existing"/> is what a test deliberately put there.
/// </remarks>
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
Task.FromResult<Stream>(new MemoryStream());
Task.FromResult<Stream>(new RecordingStream(bytes =>
{
Written = bytes;
if (Existing.TryGetValue(path, out var before))
{
Existing[path] = before with
{
Length = bytes.Length,
LastWriteTimeUtc = before.LastWriteTimeUtc.AddSeconds(1),
};
}
}));
/// <summary>Every directory this session was asked to make, in the order it was asked.</summary>
/// <remarks>
/// The evidence a folder upload actually built the tree before it queued the files. Order matters and is
/// asserted on: a parent has to exist before its child is asked for, and shortest-first is what
/// guarantees it.
/// </remarks>
internal List<string> Created { get; } = [];
/// <inheritdoc />
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) =>
Task.CompletedTask;
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
{
Created.Add(path);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteAsync(string path, CancellationToken cancellationToken)
@@ -188,7 +292,8 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
}
}
internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConnection
internal sealed class FakeSshConnection(SshConnectionRequest request, List<FakeSshShellSession> shells)
: ISshConnection
{
/// <inheritdoc />
public bool IsConnected { get; private set; } = true;
@@ -203,8 +308,13 @@ internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConn
/// <inheritdoc />
public Task<ISshShellSession> OpenShellAsync(
TerminalSize size,
CancellationToken cancellationToken) =>
Task.FromResult<ISshShellSession>(new FakeSshShellSession());
CancellationToken cancellationToken)
{
var shell = new FakeSshShellSession();
shells.Add(shell);
return Task.FromResult<ISshShellSession>(shell);
}
/// <inheritdoc />
public ValueTask DisposeAsync()
@@ -214,11 +324,33 @@ internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConn
}
}
/// <summary>A shell that is open, silent and never closes on its own.</summary>
/// <summary>A write stream that hands its contents over when it is closed.</summary>
/// <remarks>
/// A <see cref="MemoryStream"/> cannot be read after disposal, and disposal is exactly when a write is
/// finished — so a fake that handed one back could observe a transfer starting and never what it sent.
/// </remarks>
internal sealed class RecordingStream(Action<byte[]> onClosed) : MemoryStream
{
private bool handed;
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing && !handed)
{
handed = true;
onClosed(ToArray());
}
base.Dispose(disposing);
}
}
/// <summary>A shell that is open, silent and closes only when it is told to.</summary>
/// <remarks>
/// <see cref="ReadAsync"/> blocks rather than returning 0. Returning 0 means the remote closed the
/// channel, which would end the session the moment it was opened and make the test assert against a
/// connection that had already gone.
/// connection that had already gone. <see cref="Drop"/> and <see cref="Exit"/> are the two ways out.
/// </remarks>
internal sealed class FakeSshShellSession : ISshShellSession
{
@@ -227,13 +359,43 @@ internal sealed class FakeSshShellSession : ISshShellSession
/// <inheritdoc />
public bool IsOpen { get; private set; } = true;
/// <inheritdoc />
public string? TransportFailure { get; private set; }
/// <summary>
/// Ends this shell the way a rebooted machine does: the read stops, with a transport failure latched.
/// </summary>
/// <remarks>
/// Latched before the read is released, which is the ordering the real session promises and the whole
/// reason the distinction is race-free — see <see cref="ISshShellSession.TransportFailure"/>. A fake
/// that set it afterwards would be testing a guarantee the product does not make.
/// </remarks>
internal void Drop(string reason)
{
TransportFailure = reason;
closed.Cancel();
}
/// <summary>Ends this shell the way <c>exit</c> does: the read stops, with nothing wrong underneath.</summary>
internal void Exit() => closed.Cancel();
/// <inheritdoc />
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, closed.Token);
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, closed.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// This shell ending, not the pump being asked to stop. Reported as end of stream rather than
// rethrown, because that is what a real read does for both of the endings above: the channel
// stops producing bytes, and what happened to the transport is told by the latch, not by an
// exception coming out of here.
return 0;
}
await Task.Delay(Timeout.InfiniteTimeSpan, linked.Token).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return 0;
}
@@ -0,0 +1,230 @@
using System.Security.Cryptography;
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.Session;
using Tpm2Lib;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// The TPM-backed store, driven end to end against a software TPM.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the only device key store in the repository with real coverage, and the reason is worth
/// knowing.</b> <c>WindowsDeviceKeyStoreTests</c> records that the Windows store cannot be exercised at
/// all: the consent policy prompts at creation as well as at use, so anything calling <c>SaveAsync</c>
/// blocks the suite forever waiting for a person. macOS is the same shape with a Touch ID sheet. Here the
/// PIN arrives through a delegate and the TPM through a device, so both can be supplied and the whole path
/// — seal, unseal, wrong PIN, lockout — runs unattended.
/// </para>
/// <para>
/// <b>Skipped unless a software TPM is running, which CI does not start.</b> The same arrangement the
/// Windows tests have on a machine with no TPM, and for the same reason: a suite that fails because the
/// hardware is absent is a suite people learn to ignore. Start one and they run:
/// </para>
/// <code>
/// docker run --rm -p 2321:2321 debian:bookworm-slim sh -c \
/// 'apt-get update -qq &amp;&amp; apt-get install -y -qq swtpm &gt;/dev/null &amp;&amp; \
/// swtpm socket --tpm2 --tpmstate dir=/tmp --server type=tcp,port=2321,bindaddr=0.0.0.0 \
/// --ctrl type=tcp,port=2322 --flags not-need-init,startup-clear'
/// DODOSSH_TPM_TCP=127.0.0.1 dotnet test tests/DodoSSH.Client.App.Tests
/// </code>
/// <para>
/// ◆ <b>swtpm serves exactly one client, and anything else holding the port makes this suite fail in a
/// way that reads like a bug in the store.</b> A second connection is accepted and then closed
/// immediately, so five tests fail at once with <c>IOException: The TPM closed the connection</c> and
/// nothing points at the cause. It was a shell readiness probe — <c>exec 3&lt;&gt;/dev/tcp/…</c> — sitting
/// on the socket. Wait for the container by looking at the container, not by opening the port.
/// </para>
/// <para>
/// <b>What this cannot cover is the transport.</b> Production opens <c>/dev/tpmrm0</c> through TSS.Net's
/// <c>LinuxTpmDevice</c> and these tests open a socket, so everything above the device is proved here and
/// the device itself is proved by Phase 19 of <c>docs/manual-checks.md</c> on a machine with a real chip.
/// The split is deliberate: the commands, the templates and the attributes are where the security argument
/// lives, and they are identical either way.
/// </para>
/// </remarks>
public sealed class LinuxDeviceKeyStoreTests : IDisposable
{
private readonly string directory =
Path.Combine(Path.GetTempPath(), $"dodossh-tpm-{Guid.CreateVersion7():N}");
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <summary>Where a software TPM is listening, if the person running this started one.</summary>
private static string? Simulator => Environment.GetEnvironmentVariable("DODOSSH_TPM_TCP");
/// <summary>
/// Clears the profile directory, and clears the TPM's lockout counter behind it.
/// </summary>
/// <remarks>
/// ◆ <b>The second half is not tidiness, and leaving it out made this suite lie.</b> A software TPM is
/// one piece of shared mutable state and its dictionary-attack counter is the most global thing in it:
/// <see cref="WrongPinsEventuallyLockTheTpmOut"/> deliberately trips it, and every test that ran
/// afterwards then got null from a correct PIN. Six of them assert null and passed for entirely the
/// wrong reason; the one that asserts a successful unseal failed, which is the only reason this was
/// noticed at all.
/// </remarks>
public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
if (string.IsNullOrEmpty(Simulator))
{
return;
}
try
{
using var device = new SoftwareTpm(Simulator, 2321);
device.Connect();
// The lockout hierarchy has no auth value on a fresh simulator, which is exactly the state a
// real machine is not in — there, recovery is a timer measured in hours and an administrator.
// That difference is the point of Phase 19 rather than of this file.
new Tpm2(device).DictionaryAttackLockReset(TpmRh.Lockout);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// A simulator that has gone away between the test and here. The next test's own connect will
// report it far more clearly than a throw out of Dispose would.
}
}
[Fact]
public async Task WithNothingSaved_LoadingReturnsNullWithoutAsking()
{
// No TPM needed and no prompt allowed: a machine that has never registered must not raise a PIN
// box on the unlock screen. The delegate fails the test rather than answering, which is the only
// way to assert "was not asked" — see the ordering this depends on in TryLoadAsync.
var store = new LinuxDeviceKeyStore(
new ClientPaths(directory),
(_, _) => throw new InvalidOperationException("A PIN was asked for with nothing stored."),
() => throw new InvalidOperationException("The TPM was opened with nothing stored."));
(await store.TryLoadAsync(Token)).ShouldBeNull();
}
[Fact]
public async Task ARoundTripReturnsTheSameKey()
{
SkipUnlessSimulated();
var key = RandomNumberGenerator.GetBytes(32);
var store = Store("1234");
await store.SaveAsync(key, Token);
// A second store over the same directory, because the point is that nothing survives in memory:
// the blob on disk and the TPM are between these two lines, and nothing else.
var loaded = await Store("1234").TryLoadAsync(Token);
loaded.ShouldNotBeNull();
loaded.ShouldBe(key);
}
[Fact]
public async Task TheWrongPinGetsNothing()
{
SkipUnlessSimulated();
await Store("1234").SaveAsync(RandomNumberGenerator.GetBytes(32), Token);
// Null rather than an exception, and rather than the key. This is the assertion the whole design
// exists for: the blob is readable by anyone who has the file, and it opens for nobody without the
// PIN, because the TPM and not this process is what checks it.
(await Store("9999").TryLoadAsync(Token)).ShouldBeNull();
}
[Fact]
public async Task DecliningThePromptFallsBackWithoutTouchingTheTpm()
{
SkipUnlessSimulated();
await Store("1234").SaveAsync(RandomNumberGenerator.GetBytes(32), Token);
// Cancelling is ordinary — IDeviceKeyStore says so — and it must not spend one of the TPM's
// lockout attempts. The device factory throwing is what proves the TPM was never opened.
var declined = new LinuxDeviceKeyStore(
new ClientPaths(directory),
(_, _) => ValueTask.FromResult<string?>(null),
() => throw new InvalidOperationException("The TPM was opened after the prompt was declined."));
(await declined.TryLoadAsync(Token)).ShouldBeNull();
}
[Fact]
public async Task WrongPinsEventuallyLockTheTpmOut()
{
SkipUnlessSimulated();
var key = RandomNumberGenerator.GetBytes(32);
await Store("1234").SaveAsync(key, Token);
// ◆ The property that makes a four-digit PIN safe here, asserted rather than assumed.
//
// The sealed object deliberately carries no NoDA attribute, so every wrong answer advances the
// TPM's dictionary-attack counter. Remove that one attribute and this test still passes every
// other assertion in this file while the PIN becomes guessable at full speed by anything holding
// the blob — which is why the lockout is asserted by observing the *correct* PIN stop working.
for (var attempt = 0; attempt < 8; attempt++)
{
(await Store("9999").TryLoadAsync(Token)).ShouldBeNull();
}
(await Store("1234").TryLoadAsync(Token)).ShouldBeNull(
"the TPM should have stopped answering, so even the right PIN gets nothing");
}
[Fact]
public async Task ForgettingLeavesNothingThatCanBeOpened()
{
SkipUnlessSimulated();
var store = Store("1234");
await store.SaveAsync(RandomNumberGenerator.GetBytes(32), Token);
await store.ForgetAsync(Token);
// Deleting the blob is the whole of it: the sealed object was never made persistent inside the
// TPM, so there is no chip state left to clean up and nothing for a later install to trip over.
File.Exists(new ClientPaths(directory).DeviceKeyFile).ShouldBeFalse();
(await Store("1234").TryLoadAsync(Token)).ShouldBeNull();
}
[Fact]
public async Task ABlobFromSomewhereElseIsRefusedRatherThanParsed()
{
var paths = new ClientPaths(directory);
paths.EnsureCreated();
// What the Windows store writes is a bare RSA ciphertext with no header, and a dual-boot machine
// or a copied profile directory can put one here. Feeding it to the TPM as a marshalled structure
// is the failure this refuses; answering null lands on the passphrase, which works.
await File.WriteAllBytesAsync(paths.DeviceKeyFile, RandomNumberGenerator.GetBytes(256), Token);
var store = new LinuxDeviceKeyStore(
paths,
(_, _) => throw new InvalidOperationException("A PIN was asked for over an unusable blob."),
() => throw new InvalidOperationException("The TPM was opened for an unusable blob."));
(await store.TryLoadAsync(Token)).ShouldBeNull();
}
private LinuxDeviceKeyStore Store(string pin) =>
new(
new ClientPaths(directory),
(_, _) => ValueTask.FromResult<string?>(pin),
() => new SoftwareTpm(Simulator ?? "127.0.0.1", 2321));
private static void SkipUnlessSimulated() =>
Assert.SkipWhen(
string.IsNullOrEmpty(Simulator),
"No software TPM. See the remarks on this class for the one command that starts one.");
}
@@ -0,0 +1,243 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// The RPM update channel: what it offers, what it refuses, and what it hands on.
/// </summary>
/// <remarks>
/// <para>
/// Driven against a stubbed <c>HttpMessageHandler</c> rather than a server, because every question here is
/// about what this class decides from what the forge said — and a real socket would add a port to bind and
/// nothing to the assertions.
/// </para>
/// <para>
/// <b>The two that matter are the version comparison and the digest.</b> The first is what keeps a rolling
/// nightly from offering a downgrade PackageKit would decline, forever, every six hours. The second is the
/// only thing standing between a truncated download and a file handed to the machine's package manager —
/// Velopack verifies its own downloads and Android checks a signature, and an RPM over HTTPS has neither.
/// </para>
/// </remarks>
public sealed class LinuxUpdateChannelTests : IDisposable
{
private const string ReleaseUrl =
"https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH/releases/tags/nightly-desktop";
private const string ManifestUrl = "https://forge.invalid/linux-nightly.json";
private const string RpmUrl = "https://forge.invalid/dodossh-nightly.rpm";
private readonly string directory =
Path.Combine(Path.GetTempPath(), $"dodossh-updates-{Guid.CreateVersion7():N}");
private static CancellationToken Token => TestContext.Current.CancellationToken;
public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public async Task ANewerPublishedVersionIsOffered()
{
var package = RandomNumberGenerator.GetBytes(2048);
var channel = Channel(package, published: "0.0.1~alpha.0.200-1", installed: "0.0.1~alpha.0.143-1");
var update = await channel.CheckAsync(Token);
update.ShouldNotBeNull();
update.Version.ShouldBe("0.0.1-alpha.0.200");
}
[Fact]
public async Task TheSameVersionIsNotOffered()
{
var channel = Channel(
RandomNumberGenerator.GetBytes(16),
published: "0.0.1~alpha.0.143-1",
installed: "0.0.1~alpha.0.143-1");
(await channel.CheckAsync(Token)).ShouldBeNull();
}
/// <summary>
/// An older published build is not offered, which is the failure this channel is most exposed to.
/// </summary>
/// <remarks>
/// The nightly release is rolling and CI replaces it on every push, so a revert or a re-run of an older
/// commit publishes a lower version. Comparing "is it different from mine" would offer it, PackageKit
/// would decline the downgrade, and the user would see a failing update every six hours until somebody
/// pushed again.
/// </remarks>
[Fact]
public async Task AnOlderPublishedVersionIsNotOffered()
{
var channel = Channel(
RandomNumberGenerator.GetBytes(16),
published: "0.0.1~alpha.0.99-1",
installed: "0.0.1~alpha.0.143-1");
(await channel.CheckAsync(Token)).ShouldBeNull();
}
/// <summary>
/// A machine that cannot say what it has treats everything as newer.
/// </summary>
/// <remarks>
/// The wrong way to fail, chosen deliberately and for the reason the android channel gives: the cost is
/// an install the package manager declines, and the cost of the other direction is a security fix that
/// never arrives.
/// </remarks>
[Fact]
public async Task AnUnknownInstalledVersionTreatsEverythingAsNewer()
{
var channel = Channel(RandomNumberGenerator.GetBytes(16), published: "0.0.1~alpha.0.1-1", installed: null);
(await channel.CheckAsync(Token)).ShouldNotBeNull();
}
[Fact]
public async Task AGoodDownloadIsKeptAndHandedToTheInstaller()
{
var package = RandomNumberGenerator.GetBytes(4096);
var handed = new List<string>();
var channel = Channel(
package,
published: "0.0.1~alpha.0.200-1",
installed: "0.0.1~alpha.0.143-1",
handToInstaller: handed.Add);
var update = await channel.CheckAsync(Token);
await channel.DownloadAsync(update!, new Progress<int>(), Token);
channel.ApplyAndRestart(update!);
handed.Count.ShouldBe(1);
(await File.ReadAllBytesAsync(handed[0], Token)).ShouldBe(package);
}
/// <summary>
/// A package whose digest does not match is refused, deleted, and never named to the installer.
/// </summary>
/// <remarks>
/// Deleted rather than merely refused, because the alternative is a file called
/// <c>dodossh-nightly-update.rpm</c> sitting in the profile directory that somebody could reasonably
/// install by hand.
/// </remarks>
[Fact]
public async Task ADownloadThatDoesNotMatchIsRefusedAndRemoved()
{
var handed = new List<string>();
var channel = Channel(
RandomNumberGenerator.GetBytes(4096),
published: "0.0.1~alpha.0.200-1",
installed: "0.0.1~alpha.0.143-1",
digest: Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32)),
handToInstaller: handed.Add);
var update = await channel.CheckAsync(Token);
await Should.ThrowAsync<InvalidOperationException>(
async () => await channel.DownloadAsync(update!, new Progress<int>(), Token));
handed.ShouldBeEmpty();
Directory.Exists(Path.Combine(directory, "updates")).ShouldBeTrue();
Directory.GetFiles(Path.Combine(directory, "updates")).ShouldBeEmpty();
}
[Fact]
public async Task AnUpdateFromNoCheckIsRefused()
{
var channel = Channel(RandomNumberGenerator.GetBytes(16), "0.0.1~alpha.0.2-1", "0.0.1~alpha.0.1-1");
await Should.ThrowAsync<InvalidOperationException>(
async () => await channel.DownloadAsync(
new DodoSSH.Client.Session.AvailableUpdate("0.0.1-alpha.0.2"),
new Progress<int>(),
Token));
}
/// <summary>
/// A release that is reachable and carries no manifest throws rather than answering "up to date".
/// </summary>
/// <remarks>
/// Half a published release is a fact worth an answer — <c>IUpdateChannel.CheckAsync</c> spends several
/// paragraphs on why null must mean exactly one thing, after a private repository turned a total outage
/// into a reassuring sentence on every client for weeks.
/// </remarks>
[Fact]
public async Task AReleaseWithNoManifestThrows()
{
var channel = new LinuxUpdateChannel(
"nightly",
directory,
new HttpClient(new StubForge(new Dictionary<string, byte[]>(StringComparer.Ordinal)
{
[ReleaseUrl] = Encoding.UTF8.GetBytes("""{"assets":[]}"""),
})),
_ => "0.0.1~alpha.0.1-1",
_ => { });
await Should.ThrowAsync<InvalidOperationException>(async () => await channel.CheckAsync(Token));
}
private LinuxUpdateChannel Channel(
byte[] package,
string published,
string? installed,
string? digest = null,
Action<string>? handToInstaller = null)
{
// The SemVer the banner shows is derived from the rpm version the comparison uses, so the two
// cannot disagree in a way this suite would miss.
var semver = published[..published.LastIndexOf('-')].Replace('~', '-');
var manifest = $$"""
{
"rpmVersion": "{{published}}",
"version": "{{semver}}",
"rpm": "dodossh-nightly.rpm",
"sha256": "{{digest ?? Convert.ToHexStringLower(SHA256.HashData(package))}}"
}
""";
var release = $$"""
{"assets":[
{"name":"linux-nightly.json","browser_download_url":"{{ManifestUrl}}"},
{"name":"dodossh-nightly.rpm","browser_download_url":"{{RpmUrl}}"}
]}
""";
return new LinuxUpdateChannel(
"nightly",
directory,
new HttpClient(new StubForge(new Dictionary<string, byte[]>(StringComparer.Ordinal)
{
[ReleaseUrl] = Encoding.UTF8.GetBytes(release),
[ManifestUrl] = Encoding.UTF8.GetBytes(manifest),
[RpmUrl] = package,
})),
_ => installed,
handToInstaller ?? (_ => { }));
}
/// <summary>Answers the three URLs this channel asks for, and 404s everything else.</summary>
private sealed class StubForge(IReadOnlyDictionary<string, byte[]> content) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
Task.FromResult(
content.TryGetValue(request.RequestUri!.AbsoluteUri, out var bytes)
? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }
: new HttpResponseMessage(HttpStatusCode.NotFound));
}
}
@@ -0,0 +1,105 @@
using DodoSSH.Client.App.Platform;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// <c>RpmVersion.Compare</c> against the answers <c>rpm</c> itself gives.
/// </summary>
/// <remarks>
/// <para>
/// <b>Every expected value here was produced by rpm and not by reading the algorithm.</b> They came out of
/// <c>rpm --eval "%{lua:print(rpm.vercmp(a, b))}"</c> in a Fedora-era container, which is the same
/// comparison PackageKit will make when it decides whether to accept the update this class caused to be
/// offered. A test written from the specification would agree with a misreading of the specification.
/// </para>
/// <para>
/// The awkward cases are the point of the list. <c>1a</c> equals <c>1.a</c> because separators are skipped
/// on each side independently; <c>1.01</c> equals <c>1.1</c> because leading zeros are stripped;
/// <c>1.0_1</c> equals <c>1.0.1</c> for the first reason again; and <c>a</c> is older than <c>1</c> because
/// rpm calls a numeric segment newer than an alphabetic one whatever the letters say.
/// </para>
/// <para>
/// The four rows that carry the feature are the <c>~</c> ones. <c>0.0.1~alpha.0.143 &lt; 0.0.1</c> is what
/// makes every nightly older than the first real release, and <c>~alpha.0.9 &lt; ~alpha.0.10</c> is what
/// keeps them in order among themselves — a string comparison would get that backwards and a client would
/// stop offering updates after the ninth nightly of a version.
/// </para>
/// </remarks>
public sealed class RpmVersionTests
{
[Theory]
// Ordinary
[InlineData("1.2.3", "1.2.3", 0)]
[InlineData("1.2.3", "1.2.4", -1)]
[InlineData("1.2.3", "1.2.3.1", -1)]
[InlineData("1.2.10", "1.2.9", 1)]
[InlineData("2.0", "10.0", -1)]
[InlineData("1.0.0", "1.0", 1)]
[InlineData("1.0.0.1", "1.0.0", 1)]
[InlineData("20101121", "20101122", -1)]
// Tilde: the prerelease ordering this feature depends on
[InlineData("0.0.1~alpha.0.143", "0.0.1", -1)]
[InlineData("0.0.1~alpha.0.143", "0.0.1~alpha.0.144", -1)]
[InlineData("0.0.1~alpha.0.143", "0.0.1~alpha.0.99", 1)]
[InlineData("0.0.1~alpha.0.9", "0.0.1~alpha.0.10", -1)]
[InlineData("1.0", "1.0~rc1", 1)]
[InlineData("1~~", "1~", -1)]
[InlineData("1.0~beta", "1.0~alpha", 1)]
// Caret: after everything except the end of the string
[InlineData("1.0^post", "1.0", 1)]
[InlineData("1.0^post", "1.0^post2", -1)]
[InlineData("1.2.3", "1.2.3^", -1)]
// Separators and leading zeros, which are the surprising ones
[InlineData("1a", "1.a", 0)]
[InlineData("1.01", "1.1", 0)]
[InlineData("1.0_1", "1.0.1", 0)]
[InlineData("1.0-1", "1.0-2", -1)]
// Segments of different kinds
[InlineData("1.a", "1.b", -1)]
[InlineData("a", "1", -1)]
[InlineData("1", "a", 1)]
[InlineData("5.5p1", "5.5p2", -1)]
[InlineData("xyz10", "xyz10.1", -1)]
[InlineData("MDK", "MDK1", -1)]
public void ItAgreesWithRpm(string left, string right, int expected) =>
RpmVersion.Compare(left, right).ShouldBe(expected);
/// <summary>
/// The reverse of every case above, which rpm does not need asserting and this does.
/// </summary>
/// <remarks>
/// The marker handling is the half of the algorithm with a left and a right branch for each case, so an
/// asymmetry there would pass every row above and reverse the answer for a client whose installed
/// version happened to be the newer one — which is the direction that offers a downgrade.
/// </remarks>
[Theory]
[InlineData("0.0.1~alpha.0.143", "0.0.1")]
[InlineData("1.0^post", "1.0")]
[InlineData("1.2.3", "1.2.3^")]
[InlineData("a", "1")]
[InlineData("1.2.3", "1.2.4")]
public void ItIsAntisymmetric(string left, string right) =>
RpmVersion.Compare(right, left).ShouldBe(-RpmVersion.Compare(left, right));
/// <summary>
/// Nothing and an empty string are older than anything, and equal to each other.
/// </summary>
/// <remarks>
/// rpm's own binding refuses these outright. Here they arrive off a feed rather than out of a spec
/// file, and the answer that leaves a client on the build it already has is better than an exception
/// thrown on a six-hourly timer.
/// </remarks>
[Theory]
[InlineData(null, "1.0", -1)]
[InlineData("", "1.0", -1)]
[InlineData("1.0", null, 1)]
[InlineData(null, null, 0)]
[InlineData("", null, 0)]
public void ItTreatsNothingAsOldest(string? left, string? right, int expected) =>
RpmVersion.Compare(left, right).ShouldBe(expected);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,115 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using Tpm2Lib;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// Talks to a software TPM over its TCP data channel, so the real store can be driven without hardware.
/// </summary>
/// <remarks>
/// <para>
/// <b>TSS.Net ships a TcpTpmDevice and it cannot be used here.</b> It also opens the simulator's
/// <em>platform</em> socket and exchanges a handshake that the Microsoft reference simulator answers and
/// swtpm does not, so <c>Connect()</c> blocks forever — with no error and no timeout. That was found by
/// watching it hang, and it is the whole reason this file exists.
/// </para>
/// <para>
/// The data channel is the framing <c>tcti-mssim</c> uses and swtpm does implement: a command word, a
/// locality byte, a length and the bytes, answered by a length, the bytes and an acknowledgement word.
/// Power management is unnecessary when swtpm is started with <c>--flags not-need-init,startup-clear</c>,
/// which is the invocation <see cref="LinuxDeviceKeyStoreTests"/> documents.
/// </para>
/// </remarks>
internal sealed class SoftwareTpm : Tpm2Device
{
/// <summary>tcti-mssim's "here is a command for the TPM".</summary>
private const uint SendCommand = 8;
private readonly string host;
private readonly int port;
private TcpClient? client;
private NetworkStream? stream;
internal SoftwareTpm(string host, int port)
{
this.host = host;
this.port = port;
}
public override void Connect()
{
client = new TcpClient();
client.Connect(host, port);
stream = client.GetStream();
}
public override void Close()
{
stream?.Dispose();
client?.Dispose();
stream = null;
client = null;
}
public override void DispatchCommand(CommandModifier active, byte[] inBuf, out byte[] outBuf)
{
ArgumentNullException.ThrowIfNull(active);
ArgumentNullException.ThrowIfNull(inBuf);
if (stream is null)
{
throw new InvalidOperationException("Not connected.");
}
Span<byte> header = stackalloc byte[9];
BinaryPrimitives.WriteUInt32BigEndian(header, SendCommand);
header[4] = active.ActiveLocality;
BinaryPrimitives.WriteUInt32BigEndian(header[5..], (uint)inBuf.Length);
stream.Write(header);
stream.Write(inBuf, 0, inBuf.Length);
stream.Flush();
outBuf = ReadExactly((int)ReadUInt32());
// The trailing acknowledgement word. It has to be consumed even though nothing reads it, or every
// response after this one is misframed by four bytes.
_ = ReadUInt32();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
Close();
}
base.Dispose(disposing);
}
private uint ReadUInt32() => BinaryPrimitives.ReadUInt32BigEndian(ReadExactly(4));
private byte[] ReadExactly(int count)
{
var buffer = new byte[count];
var read = 0;
while (read < count)
{
var got = stream!.Read(buffer, read, count - read);
if (got <= 0)
{
throw new IOException("The TPM closed the connection.");
}
read += got;
}
return buffer;
}
}
@@ -91,25 +91,6 @@ public sealed class TransferQueueingTests : IDisposable
transfers.Status.ShouldContain("/srv/app");
}
/// <remarks>
/// The queue moves files. There is no recursive upload, and a folder dragged in and silently ignored
/// looks exactly like a transfer that failed to start — so it is counted and reported.
/// </remarks>
[Fact]
public void DroppingAFolderAmongFiles_SkipsItAndSaysSo()
{
Connected();
var folder = Path.Combine(directory, "a-folder");
Directory.CreateDirectory(folder);
transfers.QueueUploads([File("one.txt"), folder]);
Queued().ShouldHaveSingleItem();
transfers.Status.ShouldContain("1 file");
transfers.Status.ShouldContain("1 folder was skipped");
}
/// <remarks>
/// The paths in an operating-system drop come from another process and are not obliged to still be
/// right by the time the drop lands.
@@ -125,21 +106,6 @@ public sealed class TransferQueueingTests : IDisposable
transfers.Status.ShouldContain("1 item was no longer there");
}
[Fact]
public void DroppingOnlyFolders_QueuesNothingAndDoesNotClaimOtherwise()
{
Connected();
var folder = Path.Combine(directory, "a-folder");
Directory.CreateDirectory(folder);
transfers.QueueUploads([folder]);
Queued().ShouldBeEmpty();
transfers.Status.ShouldContain("Nothing was queued");
transfers.Status.ShouldContain("1 folder was skipped");
}
[Fact]
public void DroppingRemoteRowsOnTheLocalPane_QueuesDownloads()
{
@@ -152,21 +118,18 @@ public sealed class TransferQueueingTests : IDisposable
transfers.Status.ShouldContain("download into");
}
[Fact]
public void DroppingARemoteDirectory_SkipsItAndSaysSo()
{
Connected();
transfers.QueueDownloads([RemoteFile("one.log"), RemoteDirectory("logs")]);
Queued().ShouldHaveSingleItem();
transfers.Status.ShouldContain("1 folder was skipped");
}
/// <remarks>
/// <para>
/// The buttons were the only way to queue anything before drag and drop, and they now go through the
/// same two methods — so there is one set of rules rather than two that have to agree. This is what
/// says they still do.
/// </para>
/// <para>
/// Files only, since folders stopped being skipped: walking one needs a session to list and to make
/// directories on, and this fixture deliberately has none — everything here is about what may be
/// queued, which needs no server. The recursive half is exercised in <c>ShellFlowTests</c>, against the
/// SFTP fake that a real connect produces.
/// </para>
/// </remarks>
[Fact]
public void TheDownloadButton_GoesThroughTheSamePathAsADrop()
@@ -177,13 +140,7 @@ public sealed class TransferQueueingTests : IDisposable
transfers.DownloadCommand.Execute(null);
Queued().ShouldHaveSingleItem();
// And refuses a directory in the same words, rather than with the button's own message.
transfers.SelectedRemoteEntry = RemoteDirectory("logs");
transfers.DownloadCommand.Execute(null);
Queued().Count.ShouldBe(1);
transfers.Status.ShouldContain("1 folder was skipped");
transfers.Status.ShouldContain("download into");
}
/// <remarks>
@@ -229,14 +186,11 @@ public sealed class TransferQueueingTests : IDisposable
{
Connected();
var folder = Path.Combine(directory, "a-folder");
Directory.CreateDirectory(folder);
transfers.QueueStagedUploads([File("picked.txt"), folder]);
transfers.QueueStagedUploads([File("picked.txt"), Path.Combine(directory, "never-existed.txt")]);
Queued().ShouldHaveSingleItem();
transfers.Status.ShouldContain("1 file");
transfers.Status.ShouldContain("1 folder was skipped");
transfers.Status.ShouldContain("1 item was no longer there");
}
/// <remarks>
@@ -31,6 +31,18 @@ public sealed class WindowsDeviceKeyStoreTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <summary>
/// The PIN prompt this platform never reaches, because Windows raises its own.
/// </summary>
/// <remarks>
/// <c>ForThisMachine</c> takes one so that the Linux store has somewhere to ask from — see
/// <c>LinuxDeviceKeyStore</c>. On Windows nothing calls it, and answering null rather than throwing is
/// the honest stand-in: if the selection ever did reach for it here, the result would be a store that
/// declines rather than a test that fails somewhere unrelated.
/// </remarks>
private static ValueTask<string?> NoPin(DevicePinPurpose purpose, CancellationToken cancellationToken) =>
ValueTask.FromResult<string?>(null);
[Fact]
public async Task OnAMachineWithATpm_TheStoreOffersItself()
{
@@ -39,7 +51,7 @@ public sealed class WindowsDeviceKeyStoreTests
// provider reports itself present on machines where creating a key then fails.
SkipUnlessSupported();
var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()));
var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin);
store.ShouldBeOfType<WindowsDeviceKeyStore>();
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
@@ -66,7 +78,7 @@ public sealed class WindowsDeviceKeyStoreTests
private static void SkipUnlessSupported()
{
var supported = OperatingSystem.IsWindows()
&& DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()))
&& DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin)
is WindowsDeviceKeyStore;
if (!supported)
@@ -479,6 +479,7 @@
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Terminal": "[1.0.0, )",
"DodoSSH.Client.Transfer": "[1.0.0, )",
"Microsoft.TSS": "[2.1.1, )",
"Velopack": "[1.2.0, )"
}
},
@@ -718,6 +719,12 @@
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.TSS": {
"type": "CentralTransitive",
"requested": "[2.1.1, )",
"resolved": "2.1.1",
"contentHash": "87DBN+4ANSFO8TDRmFjjTBl6PL7tmrfofmxki4LUnE34gOdyQiBfJK/X1QSu0UfCGUtpoBVv/tD985s4FDpKiA=="
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
@@ -1,3 +1,5 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using DodoSSH.Client.Terminal;
@@ -34,6 +36,253 @@ public sealed class PumpOverRealSshTests(SshServerFixture fixture)
return await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken);
}
/// <summary>
/// <c>exit</c> against a real sshd is reported as the remote closing the session, not as a lost
/// connection.
/// </summary>
/// <remarks>
/// <para>
/// ◆ THIS IS THE TEST THE RACE WOULD FAIL. The obvious way to tell an ordinary exit from a dropped
/// connection is to wait for the read to end and then ask whether the transport is still up — and against
/// a real server it is wrong most of the time, because this was the only channel on the connection and
/// sshd hangs up within a millisecond of closing it. By the time anything here could ask, the transport
/// is gone. A fake shell cannot show that: it has no server behind it deciding to disconnect.
/// </para>
/// <para>
/// What makes the answer settled instead is the failure being latched when it happens rather than
/// inferred afterwards — see <see cref="ISshShellSession.TransportFailure"/>. Nothing latches one here,
/// because nothing failed.
/// </para>
/// </remarks>
[Fact]
public async Task AShellThatExits_IsReportedAsTheRemoteClosingItRatherThanAsALostConnection()
{
await using var connection = await ConnectTrustedAsync();
var shell = await connection.OpenShellAsync(
new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken);
var transport = new CountingTransport();
await using var pump = new TerminalSessionPump(
1,
shell,
transport,
TimeProvider.System,
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(20) });
transport.Pump = pump;
var run = pump.RunAsync(TestContext.Current.CancellationToken);
await pump.WriteInputAsync("exit\n"u8.ToArray(), TestContext.Current.CancellationToken);
// No disposal and no cancellation: the run has to end because the shell did, which is the whole
// point. A timeout here would mean the session hangs on `exit`.
await run.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
pump.Outcome.End.ShouldBe(
SshSessionEnd.RemoteClosed,
$"exit is not a dropped connection; got <{pump.Outcome.Description}>");
}
/// <summary>
/// A transport that goes away under a live shell is reported as a lost connection, and the read does not
/// hang.
/// </summary>
/// <remarks>
/// <para>
/// ◆ THE REBOOT CASE, produced without rebooting anything. The connection is dialled through a plain TCP
/// relay this test owns and then the relay is torn down, which takes the socket out from under a live
/// shell exactly as a machine going down does. The container is shared by every test in this collection,
/// so killing <c>sshd</c> itself was never an option; killing something this test owns is.
/// </para>
/// <para>
/// <b>What is not reproduced here is the waiting.</b> A relay that is closed sends a FIN, so the socket
/// reports itself at once; a machine that has been switched off sends nothing at all, and finding that
/// out requires writing to it — which is what the keep-alive in <see cref="SshNetConnectionFactory"/>
/// exists to do, and which no test can hurry along without a clock this code does not own. So this covers
/// the half that can be checked, and it is the half everything above the socket depends on: a transport
/// that has failed is <em>reported as one</em> rather than being called an ordinary exit or leaving the
/// read blocked for ever.
/// </para>
/// <para>
/// A relay rather than the SOCKS forward <c>LoopbackProxyTests</c> uses, which was the first attempt:
/// disconnecting the carrier of a <c>ForwardedPortDynamic</c> does not close the loopback socket it
/// accepted, so the inner connection was left holding a socket with nothing behind it and the read did
/// not return within thirty seconds. That is a property of SSH.NET's forwarder rather than of anything
/// this repository ships, and it is not what a dying machine looks like.
/// </para>
/// </remarks>
[Fact]
public async Task AShellWhoseTransportDies_IsReportedAsALostConnection()
{
using var relay = new TcpRelay(fixture.Host, fixture.Port);
await using var connection = await ConnectThroughAsync(relay);
var shell = await connection.OpenShellAsync(
new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken);
var transport = new CountingTransport();
await using var pump = new TerminalSessionPump(
1,
shell,
transport,
TimeProvider.System,
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(20) });
transport.Pump = pump;
var run = pump.RunAsync(TestContext.Current.CancellationToken);
// The shell is up and talking before anything is broken, so what follows is a live session losing its
// transport rather than one that never started.
var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(20);
while (TimeProvider.System.GetUtcNow() < deadline && pump.BytesRead == 0)
{
await Task.Delay(50, TestContext.Current.CancellationToken);
}
pump.BytesRead.ShouldBeGreaterThan(0, "the shell should have produced a prompt before the relay goes");
// And the ground goes out from under it.
relay.Kill();
// No disposal and no cancellation: the run has to end because the transport did.
await run.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
pump.Outcome.End.ShouldBe(
SshSessionEnd.ConnectionLost,
$"a transport that died is not a shell that exited; got <{pump.Outcome.Description}>");
pump.Outcome.Description.ShouldNotBeNullOrWhiteSpace(
"the strip under the terminal prints this sentence, so an empty one is a blank status line");
}
/// <summary>Trusts the relay's identity, then connects through it.</summary>
/// <remarks>
/// Pinned under the relay's own address, which is the honest thing here: what is under examination is a
/// transport dying, and this store has one entry in it either way.
/// </remarks>
private static async Task<ISshConnection> ConnectThroughAsync(TcpRelay relay)
{
var knownHosts = new InMemoryKnownHostStore();
var factory = new SshNetConnectionFactory(knownHosts);
var request = new SshConnectionRequest(
"127.0.0.1",
relay.Port,
SshServerFixture.Username,
new SshPasswordCredential(SshServerFixture.Password));
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken));
await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken);
return await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken);
}
/// <summary>A loopback TCP forwarder that can be cut, standing in for a machine that goes away.</summary>
/// <remarks>
/// Deliberately dumb: it accepts one connection, opens one to the far end and copies bytes both ways
/// until something stops. SSH is opaque to it, which is the point — what is being simulated is the wire
/// disappearing, and a relay that understood the protocol could not simulate that without also deciding
/// how to say goodbye.
/// </remarks>
private sealed class TcpRelay : IDisposable
{
private readonly TcpListener listener;
private readonly CancellationTokenSource lifetime = new();
private readonly List<TcpClient> sockets = [];
private readonly Lock gate = new();
internal TcpRelay(string host, int port)
{
listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
Port = ((IPEndPoint)listener.LocalEndpoint).Port;
_ = AcceptAsync(host, port);
}
/// <summary>The loopback port to dial instead of the far end's.</summary>
internal int Port { get; }
/// <summary>
/// Drops every socket, in both directions, without warning either end first.
/// </summary>
/// <remarks>
/// Disposing the sockets is what closes them, and closing is what puts a FIN on the wire. Nothing
/// here speaks to the SSH session running over it, which is the whole idea: this is the network
/// going away, not a disconnect being negotiated.
/// </remarks>
internal void Kill()
{
lifetime.Cancel();
listener.Stop();
lock (gate)
{
foreach (var socket in sockets)
{
socket.Dispose();
}
sockets.Clear();
}
}
public void Dispose()
{
Kill();
lifetime.Dispose();
}
private async Task AcceptAsync(string host, int port)
{
try
{
while (!lifetime.Token.IsCancellationRequested)
{
var inbound = await listener.AcceptTcpClientAsync(lifetime.Token).ConfigureAwait(false);
var outbound = new TcpClient();
await outbound.ConnectAsync(host, port, lifetime.Token).ConfigureAwait(false);
lock (gate)
{
sockets.Add(inbound);
sockets.Add(outbound);
}
_ = CopyAsync(inbound, outbound);
_ = CopyAsync(outbound, inbound);
}
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Killed, or the listener stopped. There is no caller on this thread to hand anything to.
}
}
private async Task CopyAsync(TcpClient from, TcpClient to)
{
try
{
await from.GetStream().CopyToAsync(to.GetStream(), lifetime.Token).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Either end going is the ordinary way this ends, and is what the test is arranging.
}
}
}
[Fact]
public async Task ThePump_ForwardsRealShellOutput()
{
@@ -35,6 +35,14 @@ internal sealed class FakeShellSession : ISshShellSession
/// <inheritdoc />
public bool IsOpen { get; private set; } = true;
/// <inheritdoc />
/// <remarks>
/// Settable, because it is the whole of what tells a shell that exited apart from one whose connection
/// broke — a test sets it for the second case and leaves it null for the first, which is exactly what the
/// real session's latch does off SSH.NET's error event.
/// </remarks>
public string? TransportFailure { get; set; }
/// <summary>Reads issued against this session, to detect a pump that kept reading.</summary>
public int ReadCount { get; private set; }
@@ -133,6 +141,9 @@ internal sealed class FakeShellSession : ISshShellSession
internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue, bool blockShellReads = false)
: ISshConnectionFactory
{
/// <summary>Armed on every shell handed out, so a workspace-level test can produce a dropped connection.</summary>
internal string? TransportFailure { get; set; }
/// <summary>Connections handed out, in order.</summary>
internal List<FakeConnection> Connections { get; } = [];
@@ -148,7 +159,11 @@ internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue,
progress?.Report(SshConnectionPhase.Reaching);
progress?.Report(SshConnectionPhase.Authenticating);
var connection = new FakeConnection(request, bytesPerShell, blockShellReads);
var connection = new FakeConnection(request, bytesPerShell, blockShellReads)
{
TransportFailure = TransportFailure,
};
Connections.Add(connection);
return Task.FromResult<ISshConnection>(connection);
@@ -172,13 +187,17 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer
/// <summary>The shell this connection opened, if it opened one.</summary>
internal FakeShellSession? Shell { get; private set; }
/// <inheritdoc cref="ISshShellSession.TransportFailure" />
/// <remarks>Handed to whatever shell this connection opens, so the arming happens before the pump runs.</remarks>
internal string? TransportFailure { get; init; }
/// <summary>Whether the connection was disposed, which is what closing a session must do.</summary>
internal bool IsDisposed { get; private set; }
/// <inheritdoc />
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
{
Shell = new FakeShellSession(bytesPerShell, blockShellReads);
Shell = new FakeShellSession(bytesPerShell, blockShellReads) { TransportFailure = TransportFailure };
return Task.FromResult<ISshShellSession>(Shell);
}
@@ -217,6 +217,80 @@ public sealed class TerminalSessionPumpTests
System.Text.Encoding.UTF8.GetString(payload).ShouldNotBeNullOrWhiteSpace();
}
/// <remarks>
/// <c>exit</c>, as the pump sees it: the read loop runs out and nothing latched a transport failure on
/// the way. This is the ending that must not be reported as a dropped connection, because it is the
/// ordinary end of a session and there is nothing wrong with the machine.
/// </remarks>
[Fact]
public async Task WhenTheShellExits_TheOutcomeIsTheRemoteClosingIt()
{
await using var session = new FakeShellSession(bytesToProduce: 32);
var transport = new RecordingTransport();
await using var pump = CreatePump(session, transport);
transport.AutoAcknowledge = pump;
await pump.RunAsync(TestContext.Current.CancellationToken);
pump.Outcome.End.ShouldBe(SshSessionEnd.RemoteClosed);
pump.Outcome.Description.ShouldBe("The remote closed the session.");
}
/// <remarks>
/// ◆ A reboot, as the pump sees it. The shell's read ends exactly as it does for <c>exit</c> — there is
/// no separate signal on that path — so the only thing telling the two apart is the failure the session
/// latched off the transport before the read was woken. Without it, a machine that went away is reported
/// as somebody having typed <c>exit</c>, which is the state this whole wave exists to stop drawing.
/// </remarks>
[Fact]
public async Task WhenTheTransportFailed_TheOutcomeIsALostConnectionCarryingWhatFailed()
{
await using var session = new FakeShellSession(bytesToProduce: 32)
{
TransportFailure = "An established connection was aborted by the server.",
};
var transport = new RecordingTransport();
await using var pump = CreatePump(session, transport);
transport.AutoAcknowledge = pump;
await pump.RunAsync(TestContext.Current.CancellationToken);
pump.Outcome.End.ShouldBe(SshSessionEnd.ConnectionLost);
pump.Outcome.Description.ShouldBe("An established connection was aborted by the server.");
// And the renderer is told the same sentence, so the line in the scrollback and whatever the shell
// draws around the tab cannot disagree.
TerminalFrame.TryRead(transport.Frames[^1], out var opcode, out _, out var payload).ShouldBeTrue();
opcode.ShouldBe((byte)TerminalServerOpcode.SessionClosed);
System.Text.Encoding.UTF8.GetString(payload)
.ShouldBe("An established connection was aborted by the server.");
}
/// <remarks>
/// The third ending, and the one nothing draws a status for: this client asked. A tab being closed and a
/// reconnect starting both arrive here, and neither is news to whoever caused it.
/// </remarks>
[Fact]
public async Task WhenThisClientEndsIt_TheOutcomeSaysSoRatherThanBlamingTheRemote()
{
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
var transport = new RecordingTransport();
var pump = CreatePump(session, transport);
transport.AutoAcknowledge = pump;
var run = pump.RunAsync(TestContext.Current.CancellationToken);
await pump.DisposeAsync();
await run;
pump.Outcome.End.ShouldBe(SshSessionEnd.ClosedLocally);
}
[Fact]
public async Task Input_ReachesTheRemote()
{
@@ -344,6 +344,84 @@ public sealed class TerminalWorkspaceTests
liveAtAnnouncement.ShouldBe(0, "announced after the drain, so the count already excludes it");
}
/// <remarks>
/// ◆ The announcement carries how it ended, not just that it did. A subscriber cannot work that out for
/// itself afterwards — by the time it hears, there is no transport left to interrogate — and the
/// difference between a shell that exited and a connection that broke is the difference between a tab
/// that is finished and one worth offering to dial again.
/// </remarks>
[Fact]
public async Task ASessionWhoseConnectionBroke_IsAnnouncedAsALostConnection()
{
var connections = new FakeConnectionFactory(bytesPerShell: 0)
{
TransportFailure = "An established connection was aborted by the server.",
};
await using var workspace = CreateWorkspace(connections);
TerminalSessionOutcome? outcome = null;
workspace.SessionEnded += (_, e) => Volatile.Write(ref outcome, e.Outcome);
await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
await WaitUntilAsync(() => Volatile.Read(ref outcome) is not null);
var announced = Volatile.Read(ref outcome)!;
announced.End.ShouldBe(SshSessionEnd.ConnectionLost);
announced.Description.ShouldBe("An established connection was aborted by the server.");
}
/// <remarks>
/// The same shell ending with nothing wrong underneath it, which is what <c>exit</c> looks like from
/// here. Its own test rather than an assertion tacked onto the one above, because the pair is the point:
/// one fake differs from the other by a latched failure and nothing else, and the two must not report
/// the same thing.
/// </remarks>
[Fact]
public async Task ASessionWhoseShellExited_IsAnnouncedAsTheRemoteClosingIt()
{
var connections = new FakeConnectionFactory(bytesPerShell: 0);
await using var workspace = CreateWorkspace(connections);
TerminalSessionOutcome? outcome = null;
workspace.SessionEnded += (_, e) => Volatile.Write(ref outcome, e.Outcome);
await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
await WaitUntilAsync(() => Volatile.Read(ref outcome) is not null);
Volatile.Read(ref outcome)!.End.ShouldBe(SshSessionEnd.RemoteClosed);
}
/// <remarks>
/// A tab being closed, announced as this client's own doing however the pump unwound. It matters for the
/// shell reusing a tab: a reconnect closes the session it is replacing, and an announcement blaming the
/// remote for that would draw a dropped connection over an attempt that is already under way.
/// </remarks>
[Fact]
public async Task ClosingASession_IsAnnouncedAsThisClientsDoing()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
TerminalSessionOutcome? outcome = null;
workspace.SessionEnded += (_, e) => outcome = e.Outcome;
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
await workspace.CloseSessionAsync(sessionId);
outcome.ShouldNotBeNull();
outcome.End.ShouldBe(SshSessionEnd.ClosedLocally);
}
/// <remarks>
/// Disposal is the other path that closes sessions, because it is process shutdown. Asserted so
/// that the SSH connections are known to be released rather than assumed to be — and that these closes,
@@ -20,6 +20,63 @@ internal sealed class FakeSftpSession : ISftpSession
/// <inheritdoc />
public bool IsConnected => true;
/// <inheritdoc />
/// <remarks>
/// Null, and the event below is never raised: this fake breaks reads rather than connections — see
/// <see cref="FailReadAfter"/> — because what the queue has to get right is a transfer that stops
/// halfway, which is the same shape whichever caused it.
/// </remarks>
public string? TransportFailure => null;
/// <inheritdoc />
#pragma warning disable CS0067 // The event is never used
public event EventHandler? ConnectionLost;
#pragma warning restore CS0067
/// <summary>Modes this session was asked to set, in order, so preservation is observable.</summary>
public List<(string Path, int Mode)> PermissionsSet { get; } = [];
/// <summary>Timestamps this session was asked to set, in order.</summary>
public List<(string Path, DateTimeOffset When)> TimesSet { get; } = [];
/// <summary>Set to make both setters refuse, which is what a server that will not do SETSTAT looks like.</summary>
public bool RefuseAttributes { get; set; }
/// <inheritdoc />
public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken)
{
if (RefuseAttributes)
{
throw new SftpPathException(path, "This server will not set a mode.");
}
lock (gate)
{
PermissionsSet.Add((path, mode));
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SetLastWriteTimeAsync(
string path,
DateTimeOffset lastWriteTimeUtc,
CancellationToken cancellationToken)
{
if (RefuseAttributes)
{
throw new SftpPathException(path, "This server will not set a timestamp.");
}
lock (gate)
{
TimesSet.Add((path, lastWriteTimeUtc));
}
return Task.CompletedTask;
}
/// <inheritdoc />
public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");