Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06369274dc | ||
|
|
cfc2e3ae0c | ||
|
|
e2733e5cbc | ||
|
|
c2c860b4f2 | ||
|
|
4552538533 | ||
|
|
1bf79c7c26 | ||
|
|
1970e20e56 |
No files matched your search
+7
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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">
|
||||
<!--
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Executable
+366
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding ChangeRemotePermissionsCommand}"
|
||||
IsEnabled="{Binding CanChangeRemotePermissions}"
|
||||
ToolTip.Tip="Permissions on the host">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
|
||||
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, )",
|
||||
|
||||
@@ -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 && apt-get install -y -qq swtpm >/dev/null && \
|
||||
/// 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<>/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 < 0.0.1</c> is what
|
||||
/// makes every nightly older than the first real release, and <c>~alpha.0.9 < ~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");
|
||||
|
||||
|
||||
Reference in new issue
Block a user