From 59972083160088009545a0f7a1b6371d068480d6 Mon Sep 17 00:00:00 2001 From: Oliver Schantz Date: Tue, 10 Nov 2026 08:59:16 +0100 Subject: [PATCH] Add AGENTS.md with guidance for AI coding agents Documents solution layout, build/test commands, DI and MVVM patterns, coding conventions, security invariants (TOFU, key file permissions, passphrase handling, SFTP), testing guidelines, branch/versioning/CI conventions and known quirks. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 246 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8606e1a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,246 @@ +# AGENTS.md + +Instructions for AI coding agents working in this repository. Paths are relative to the repo root. Security issues are reported privately as described in `SECURITY.md` (GitHub Private Security Advisory preferred). Do not open public issues or PRs that disclose an unfixed vulnerability. + +## Project overview + +OpenSSH-GUI is a cross-platform desktop app (Avalonia, .NET 10) for managing SSH keys and SSH files. It can: +- find, load, generate, re-encrypt, rename, convert (OpenSSH/PuTTY) and delete SSH keys; +- edit `known_hosts` and `authorized_keys` locally, and on remote servers over SFTP; +- verify server host keys against `known_hosts` (TOFU); +- parse `ssh_config`/`sshd_config` and expose them through `Microsoft.Extensions.Configuration`. + +### Solution layout (`OpenSSH_GUI.slnx`) + +| Project | Purpose | +|---|---| +| `OpenSSH_GUI/` | The Avalonia app: `Program.cs` (host and bootstrap), `App.axaml(.cs)`, `Views/`, `ViewModels/`, `Services/` (e.g. `HostKeyTrustPrompt`), `Converters/`, `Extensions/DependencyInjectionExtensions.cs` (app service registrations), `Logging/Enricher/`, `Resources/{Controls,Styles}`, resx strings | +| `OpenSSH_GUI.Core/` | Domain and services: keys (`Lib/Keys`), host keys and TOFU (`Lib/HostKeys`, `Services/KnownHostKeyStore.cs`, `Services/ServerConnectionService.cs`), `known_hosts`/`authorized_keys` models, `ServerConnection` (SSH.NET + SFTP), configuration, MVVM base classes (`MVVM/`, `Resources/Wrapper/WindowBase.cs`), DI helpers (`Extensions/DependencyInjectionExtensions.cs`) | +| `OpenSSH_GUI.Dialogs/` | Modal dialogs: `IMessageBoxProvider`/`MessageBoxProvider`, `MessageBoxDialog`, `SecureInputDialog`, `ValidatedInputDialog`. Plain Avalonia windows, no ViewModels, no ReactiveUI | +| `OpenSSH_GUI.SshConfig/` | Pure library, no Avalonia dependency: `ssh_config(5)` parser and serializer (with `Include`), `SshWildcardMatcher`, `SshTokenExpander`, `SshConfigFileService`, and the `AddSshConfig(...)` configuration provider | +| `OpenSSH_GUI.Tests/` | xUnit v3 tests for Core and SshConfig, plus a trivial headless smoke test | + +Reference graph: App → Core → (Dialogs, SshConfig). Tests reference all four. + +`images/` is a build input (`../images/openssh-gui.ico`, ``, AppImage and NSIS icons from `images/openssh-gui.svg`). Do not move or rename those files. + +## Tech stack + +- **Platform:** .NET 10 (`net10.0` via `Directory.Build.props`). `global.json` requires SDK >= 10.0.100 (`rollForward: latestMajor`, `allowPrerelease: false`). This is a minimum, not a pin: the highest installed non-preview SDK is used, and CI's `actions/setup-dotnet` reads `global.json`. `LangVersion=default`, so on SDK 10 C# 14 features are in use: `extension(...)` blocks, the `field` keyword, `System.Threading.Lock`. +- **UI:** Avalonia 12.1 with the Fluent theme (Compact), DataGrid, Material.Icons, Xaml.Behaviors. +- **MVVM:** ReactiveUI, ReactiveUI.SourceGenerators (`[Reactive]`, `[ObservableAsProperty]`, `[ReactiveCommand]`), ReactiveUI.Validation, ReactiveUI.Avalonia, System.Reactive. **No CommunityToolkit.Mvvm.** +- **Infrastructure:** Microsoft.Extensions.Hosting / DI / Options (DataAnnotations validation), Serilog (console sink in DEBUG only, plus a daily rolling file). +- **SSH:** SSH.NET 2025.1.0, SshNet.Keygen, SshNet.PuttyKeyFile. +- **Tests:** xunit.v3, Avalonia.Headless.XUnit, NSubstitute, Shouldly, coverlet.collector. Tests run in VSTest mode (Microsoft.NET.Test.Sdk + xunit.runner.visualstudio; no Microsoft.Testing.Platform opt-in in `global.json`). `--filter` and `--collect` below depend on this. The test project is `OutputType=Exe`, `SelfContained=true`. + +## Commands + +Run from the repo root. **Always pass `OpenSSH_GUI.slnx` or an explicit `.csproj` path.** The root has no project file, and `OpenSSH_GUI.Tests/` contains two project files, so bare `dotnet build`/`dotnet run` in either place fails (MSB1011 in the Tests folder). + +```bash +dotnet restore OpenSSH_GUI.slnx +dotnet build OpenSSH_GUI.slnx --configuration Release --no-restore +dotnet test OpenSSH_GUI.slnx --configuration Release --no-build --verbosity normal # same as CI + +dotnet run --project OpenSSH_GUI # run the app +dotnet test OpenSSH_GUI.Tests/OpenSSH_GUI.Tests.csproj # tests only +dotnet test OpenSSH_GUI.slnx --filter "FullyQualifiedName~KnownHostKeyStoreTests" +dotnet test OpenSSH_GUI.slnx --collect:"XPlat Code Coverage" + +# Release-style publish (RIDs: linux-x64, win-x64, osx-x64) +dotnet publish OpenSSH_GUI/OpenSSH_GUI.csproj -c Release -r linux-x64 --output ./publish \ + -p:PublishSingleFile=true -p:PublishReadyToRun=true -p:IncludeNativeLibrariesForSelfExtract=true +``` + +CI (`build-and-package.yml`) additionally runs `dotnet restore --runtime ` first and passes `-p:Version=`. All RIDs are cross-published on `ubuntu-latest`. The app is self-contained by default (`true` in `OpenSSH_GUI.csproj`). The output `publish/OpenSSH_GUI[.exe]` is renamed to `OpenSSH-GUI-[.exe]`. Only linux-x64 also produces the AppImage, its `.zsync`, `appicon.png` and the `.desktop` file; the osx-x64 asset is a raw binary, not an `.app` bundle. + +There are no test traits or categories; filter by name. + +## Architecture and key patterns + +### Startup (`OpenSSH_GUI/Program.cs`) +1. Writes the default config if it is missing. +2. Reads a bootstrap config without DI to get the log level and creates a `LoggingLevelSwitch` from it. +3. Builds the generic host: + - `AddMutableConfiguration(...)` (JSON app config); + - `ConfigureAppConfiguration`: `AddSshConfig(...)` for `config` and `sshd_config`, the app JSON again via `PhysicalFileProvider`, an in-memory `RUNNING_VERSION`; + - `services.AddSingleton(levelSwitch)` (the instance Serilog uses); + - `RegisterOpenSshGuiServices()`; + - `UseSerilog` (`MinimumLevel.ControlledBy(levelSwitch)`). +4. Starts Avalonia with `App` resolved **from DI** (`App` is a singleton, `Application` forwards to it), using `UseReactiveUI(... WithExceptionHandler(ExceptionHandler))`. + +`App.OnFrameworkInitializationCompleted`: +- initializes SSH.NET logging (`SshNetLoggingConfiguration.InitializeLogging`); +- renders the app icons into `AppIconStore`, then calls `Freeze()` (`AddBitmap`/`AddWindowIcon` throw afterwards); +- applies theme, font size and log level from config (`IMutableConfiguration.Current`); +- resolves `MainWindow` with `ResolveViewAsync`; +- runs `SshKeyManager.InitialSearchAsync` when the window opens. + +### Dependency injection +- App service registrations live in `OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs` (`RegisterOpenSshGuiServices`). Exceptions: `Program.cs` registers the bootstrap `LoggingLevelSwitch`, and Core's `AddMutableConfiguration()` registers options (with `ValidateOnStart`), `JsonFileConfigurationWriter` and `IMutableConfiguration`. Core has no `AddCore()`. +- Services are mostly singletons (`SshKeyManager`, `ServerConnectionService`, `KnownHostKeyStore`, writer, backup, generator, `DirectoryCrawler`). `SshKeyFile` and `IMessageBoxProvider` are transient. `FileSystemAnalyzer` is a hosted service. +- **Views and ViewModels** are registered with `services.RegisterViewWithViewModel(lifetime)` (`OpenSSH_GUI.Core/Extensions/DependencyInjectionExtensions.cs`): + - Both are **keyed services**, keyed by `typeof(T).Name`. Default lifetime is `Transient`; only the `MainWindow`/`MainWindowViewModel` pair is `Singleton`. + - The view is built with `ActivatorUtilities`, then its public `required` properties (`Logger`, `Services`, `AppIconStore` on `WindowBase`) are filled from DI when available. + - The VM is resolved in `WindowBase.WindowInitialize` via `GetRequiredKeyedService(typeof(TViewModel).Name)`, which also sets `ViewModel.GetTopLevel`. + - **Naming is enforced at startup** (`InvalidOperationException` otherwise): `XWindow` ↔ `XViewModel` (e.g. `ApplicationSettingsWindow`/`ApplicationSettingsViewModel`), or a VM name that starts with the view name and ends in `ViewModel` (e.g. `FileInfoWindow`/`FileInfoWindowViewModel`). Identical view and VM names are rejected. +- **Opening windows:** `serviceProvider.ResolveViewAsync()` or `ResolveViewAsync(param)`. Each call creates a DI scope, calls `InitializeAsync`, checks `IsInitialized`, and disposes the scope on `Window.Closed`. There is no ViewLocator, no routing, no `ViewModelViewHost`. +- **Runtime parameters** go through `InitializeAsync(TParam)` (VM derives from `ViewModelBase`, view from `WindowBase`; e.g. `ExportWindow` uses `(string WindowTitle, string Export)`, `FileInfoWindow` uses `SshKeyFileSource`), `Load(...)` (`SshKeyFile`) or `WithCredentials(...)` (`ServerConnection`). Never through constructors. +- `Window` (singleton) and `IDialogHost` (transient) both forward to the keyed `MainWindow`, which owns dialogs. Use `IMessageBoxProvider`; never construct dialogs from ViewModels. + +### MVVM +- `ViewModelBase` (`OpenSSH_GUI.Core/MVVM/ViewModelBase.cs`): `ReactiveObject`, `IActivatableViewModel`, `IDisposable`, `IAsyncDisposable`, `IInitializableViewModel`. Provides `CompositeDisposable Disposables`, `ViewModelActivator`, `Close` (a source-generated `[Reactive]` `EventHandler` property, not a C# event) / `RequestClose()`, `IsInitialized`, `BooleanSubmitCommand`, `OpenFlyoutCommand`, `GetTopLevel`. + - Overrides of `InitializeAsync` must call the base implementation (it sets `IsInitialized` and activates the `Activator`), otherwise `ResolveViewAsync` throws `InvalidOperationException` ("ViewModel not properly initialized"). `ViewModelBase` seals the parameterless overload. + - `CloseOnBooleanSubmit` defaults to `true`, so executing `BooleanSubmitCommand` closes the window. +- `WindowBase` / `WindowBase` (`OpenSSH_GUI.Core/Resources/Wrapper/WindowBase.cs`) derive from ReactiveUI.Avalonia `ReactiveWindow`, resolve the keyed VM, wire `Close`, and handle the theme-dependent icon. +- Code-behind is mostly just a constructor calling `InitializeComponent()`; views are marked `[UsedImplicitly]`. Exceptions: `AddKeyWindow` binds validation in `WhenActivated` (`BindValidation`), and `MainWindow` takes `ILogger` via its constructor and implements `IDialogHost` (`OpenSSH_GUI.Core/Interfaces/Hosts/IDialogHost.cs`). +- Use source-generated reactive properties on `partial` classes and records. Dispose subscriptions with `.DisposeWith(Disposables)`. Marshal to the UI thread with `ObserveOn(AvaloniaScheduler.Instance)`. + +### Configuration +- App config is `ApplicationConfiguration` (`OpenSSH_GUI.Core/Configuration/`): `required` properties with DataAnnotations, validated on start. +- JSON uses the source-generated `SourceGenerationContext`, declared at the bottom of `OpenSSH_GUI.Core/Configuration/ApplicationConfiguration.cs`. Add new serializable types there as `[JsonSerializable(typeof(X))]`. +- Read app config via `IMutableConfiguration.Current` or `IOptionsMonitor` (`MutableConfiguration` wraps the monitor). Write only through `IMutableConfiguration` (`ExecuteConfigurationUpdateAsync` / `SetPropertyValueAsync`), which writes a temp file (`Path.GetTempFileName()`) and moves it over the target under a semaphore. React to the `ConfigurationChanged` event. +- SSH config is **not** registered as options: inject `IConfiguration` and call `GetSection("SshConfig").Get()` (as in `DirectoryCrawler`, `ConnectToServerViewModel`). + +### Logging +- Inject `ILogger` (MEL), backed by Serilog. SSH.NET logs are bridged by `SshNetLoggingConfiguration.InitializeLogging`. +- Known issue: `LoggingLevelSwitch` is registered twice (see Known quirks), so the instance `App` and `ApplicationSettingsViewModel` change is not the one Serilog uses. Do not assume the settings window controls the Serilog level. + +### Errors +- `SshKeyManager` operations return `KeyManagerOperationResult` (Success, Failure, Conflict, Cancelled) instead of throwing. +- Services log, then rethrow. +- `ExceptionHandler` is the ReactiveUI global handler. + +## Coding conventions + +- **Nullable** is enabled solution-wide (`Directory.Build.props`). Use `?` annotations, never `[CanBeNull]`-style attributes. +- **Primary constructors** for injected dependencies in new code (examples: `KeyFileWriterService`, `SshKeyGenerator`, `HostKeyTrustPrompt`, `EditKnownHostsWindowViewModel`). Many existing classes use classic constructors (`SshKeyManager`, `ServerConnectionService`, `KnownHostKeyStore` (which also has a `(logger, path)` overload for tests), `KeyFileBackupService`, and 6 of 8 ViewModels). Convert only when already touching them. Constructors take only injected dependencies. +- **Register services in DI.** Do not `new` up services inside classes; value and resource objects are fine. +- **Central Package Management** in `Directory.Packages.props` (`ManagePackageVersionsCentrally`, `CentralPackageTransitivePinningEnabled`, `NoWarn` NU1507). To add a package: + 1. Add `` to `Directory.Packages.props`. + 2. Add a **version-less** `` in the csproj. + 3. For analyzers/source generators use child elements `all` and `runtime; build; native; contentfiles; analyzers; buildtransitive` (as in `OpenSSH_GUI.Core.csproj`). +- **Compiled bindings** are on by default (`AvaloniaUseCompiledBindingsByDefault=true`). Every view `.axaml` must declare `x:DataType="viewModels:..."`. Converters go in `OpenSSH_GUI/Converters/Converter.cs` (static `FuncValueConverter`s) or `OpenSSH_GUI.Core/Resources/Converter/`. +- **Strings:** + - Use resx (`OpenSSH_GUI/Resources/StringsAndTexts.resx`) for user-facing text, and add every new key to **both** `StringsAndTexts.resx` and `StringsAndTexts.de.resx`. Known gap: `ConnectToServerStatusBase` is missing from `de.resx` (129 vs 128 keys). + - Window-specific keys use the window-name prefix (e.g. `MainWindow...`); shared keys (e.g. `Error`, `CancelAndClose`, `SaveAndClose`, `HostKeyUnknownTitle`) may be unprefixed. + - The generated class lives in `OpenSSH_GUI.Resources`: `using OpenSSH_GUI.Resources;` in C#; `xmlns:openSshGui="clr-namespace:OpenSSH_GUI.Resources"` and `{x:Static openSshGui:StringsAndTexts.Key}` in XAML. +- **Namespaces and files:** file-scoped namespaces matching the folder. Views in `Views/*Window.axaml`, ViewModels in `ViewModels/*ViewModel.cs`, reusable controls in `OpenSSH_GUI/Resources/Controls/`. +- **Async:** pass `CancellationToken` through; use `IAsyncEnumerable` with `[EnumeratorCancellation]`. `ValueTask` is used in `SshKeyManager`, the generator, `InitializeAsync` and `ResolveViewAsync`; dialog (`IMessageBoxProvider`), config (`IMutableConfiguration`), trust-prompt (`IHostKeyTrustPrompt`) interfaces and `BooleanSubmitAsync` use `Task`. Match the signature of the interface you implement. +- **Code style** follows `OpenSSH_GUI.sln.DotSettings` (no `.editorconfig`): expression bodies where possible; `string.Empty` instead of `""`; no `this.`; remove redundant parentheses; joined attribute lists (`[A, B]`); wrap limit 182; object and collection initializers always chopped. +- **Documentation:** `OpenSSH_GUI.SshConfig` sets `GenerateDocumentationFile=true`, so missing public XML docs are flagged there. Dialogs carries XML docs by convention only. Keep both documented. +- **SshConfig:** document-model types (`SshConfigDocument`, `SshBlock` and subtypes, `SshLineItem` and subtypes, `SshHostSettings`, `SshMatchCriterion`) are immutable records using `ImmutableArray`, with static `Create` factories on the concrete block and line types. `SshConfiguration` is a mutable bindable class (`Global`, `Hosts`, `Blocks` as `List<>`). Option records (`SshConfigParserOptions`, `SshSerializerOptions`) have `Default` presets. Lookup tables use `FrozenSet`/`FrozenDictionary`. Parser, serializer, wildcard matcher, token expander and `SshConfigFileService` are static and not in DI. + +## Security-sensitive areas and invariants (do not break) + +Changes here need tests and careful review. + +1. **Host key verification (TOFU):** `Lib/Misc/ServerConnection.cs`, `Services/ServerConnectionService.cs`, `Services/KnownHostKeyStore.cs`, `Lib/HostKeys/`. + - `ServerConnection` defaults to rejecting every key (validator fallback `_ => false`). **Both** `SshClient` and `SftpClient` must use the validator; both must connect in `ConnectToServerAsync`. + - The only public factory is `WithCredentials(credentials, hostKeyValidator)`, with a required validator. Do not add a public constructor or overload without one. `ServerConnection.Empty` is a shared sentinel and must never be disposed (`DisposeCurrentConnection` skips it). + - Only `HostKeyVerificationStatus.Trusted` may accept a key. `Mismatch` and `Revoked` throw `HostKeyVerificationException`: no prompt, no override, no auto-add. + - Only `Unknown` goes to `IHostKeyTrustPrompt`. `IKnownHostKeyStore.Add` runs only after explicit user confirmation, then the connection is verified again. + - `Verify` classification: `Mismatch` only for an entry with the same key type and a different blob; a known host presenting a different key type is `Unknown` (TOFU prompt). `@revoked` is matched by exact blob. `@cert-authority` lines are ignored; invalid base64 lines are skipped. Hashed hosts use `|1|salt|hash` (HMAC-SHA1). + - Keep the `CryptographicOperations.FixedTimeEquals` comparisons for key blobs and hashed hosts. + - Keep `HostKeyInfo.GetKnownHostsName` canonicalization (lowercase host, `[host]:port` when port ≠ 22), matching OpenSSH. +2. **Local file permissions** (`Services/KeyFileWriterService.cs`, `KnownHostKeyStore`, `KnownHostsFile`, `Services/Hosted/FileSystemAnalyzer.cs`, `SshConfigFilesExtension.ValidateDirectories`): + - Private keys: `FileMode.CreateNew` (or `Create` only when overwrite is explicit), `FileShare.None`, `0600` on Unix applied on **both** create and overwrite. + - Never write key material with `File.WriteAllText` or similar. `SshConfigSerializer` writes with `File.WriteAllText(Async)` (umask-default mode, no atomic replace); never use it for key files. + - `0600` file / `0700` directory for `known_hosts` applies only when `KnownHostKeyStore.Add` / `KnownHostsFile` create them. `ValidateDirectories` creates `~/.ssh` as `0700`. Existing file modes are never tightened. + - Known gap: at startup `FileSystemAnalyzer` pre-creates missing `authorized_keys`, `known_hosts`, `config`, `sshd_config` with `new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write)` (no `UnixCreateMode`, umask default, typically 0644) and tries to create `/etc/ssh` as `0755`. +3. **Passphrase handling:** + - Passphrases stay **in-process**. Never shell out to `ssh-keygen -P/-N` or put a secret on a command line. `SshKeyManager.ChangePasswordOfKeyAsync` re-encrypts from the decrypted `PrivateKeyFile`. + - Keep passphrase bytes in `SshKeyFilePassword` (a `ReactiveBufferWriter`). + - Library boundaries (`PrivateKeyFile`, `SshKeyEncryptionAes256`, `PasswordAuthenticationMethod`) require strings. Convert from bytes only at the call site, as late as possible; never store the string in a field or log it. Known gap: `PasswordConnectionCredentials` stores the SSH login password as a `string`. + - Clear temporary buffers: `ArrayPool.Return(buf, clearArray: true)`, and `.Clear()` on `stackalloc` spans. Known gap: `KeyFileWriterService.WriteToFile` does not clear its `stackalloc byte[256]` buffer (only the rented buffer is cleared); fix with `buffer[..writtenBytes].Clear()` in `finally` when touching it. + - Dispose `SecureInputResult` (`using var`); its `Dispose` zeroes the memory. + - Do not log passphrases or key material. +4. **Backup and restore** (`SshKeyManager`, `KeyFileBackupService`): new or changed mutating key operations must run under the `SshKeyManager` semaphore (and abort if acquisition fails), be bracketed by `BeginOperationLog()` / `EndOperationLog(errorsOccurred)`, back up files first and restore on failure. + - Fully implemented only in `ChangePasswordOfKeyAsync` and `RenameKeyAsync`. Known gaps: `TryDeleteKeyAsync` makes no backup and continues deleting when the semaphore wait fails; `GenerateNewKey` has no operation log or backup; `ChangeFormatOfKeyAsync` backs up before acquiring the semaphore. + - Backups are `FileInfo.CopyTo` copies named `.bak` in `~/.ssh//` (directory `0700`), next to `operation_log.log`. Do not log key material: the log stays on disk when `errorsOccurred` is true. The directory is deleted best-effort on success; cleanup exceptions are swallowed. +5. **Config-provided keys:** keys from `ssh_config` `IdentityFile` (`SshKeyFileSource.FromConfig`, `ProvidedByConfig`) stay read-only in the UI (`FileChangesAllowed`, `CanChangeFileName`). +6. **SFTP and remote files** (`ServerConnection`): + - Remote paths are relative to the SFTP home directory (`.ssh/`). + - `RemoteSshDirectoryMode = 700` and `RemoteSshFileMode = 600` are SSH.NET "decimal-octal" literals. **Do not change them to `0x1C0` or other octal forms.** They are applied only when the server OS is Unix/MacOSX and only on newly created entries; existing remote modes are left unchanged. + - Use SFTP for remote file access. The only remote shell commands are the fixed strings `uname -s` and `ver` in `GetServerOsAsync`. Never interpolate user or file data into `CreateCommand`. + +## Testing guidelines + +- **Location:** `OpenSSH_GUI.Tests/`, folders mirror the source (`Core/Extensions`, `Core/Lib/Keys`, `Core/MVVM`, `Core/Services`, `SshConfig/`, `ViewModels/`). Namespaces like `OpenSSH_GUI.Tests.Core.Services`. Note: `Core/Extensions/ServiceCollectionExtensionsTests.cs` holds class `DependencyInjectionExtensionsTests`. +- **Preferred conventions for new tests:** classes `Tests`, `public sealed class`; methods `Method_Scenario_ExpectedResult`. Existing tests mostly use plain `public class` (only `KeyFileWriterServiceTests`, `KnownHostKeyStoreTests`, `SshKeyManagerPasswordTests` are sealed) and some `X_Tests` method names. +- **Attributes:** `[AvaloniaFact]` / `[AvaloniaTheory]` are required for anything touching Avalonia or ReactiveUI schedulers; existing tests use them even for pure logic. Plain `[Fact]`/`[Theory]` are fine for pure logic (as in `KeyFileWriterServiceTests`, `KnownHostKeyStoreTests`). Data via `[InlineData]`. +- **Headless bootstrap:** `OpenSSH_GUI.Tests/App.axaml.cs` (`[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]`, `UseReactiveUI` + `UseHeadless`). `ReactiveUiInitFixture.cs` and `TestAssemblySetup.cs` are commented-out dead code. +- **Assertions:** Shouldly (`ShouldBe`, `Should.ThrowAsync`); mocks with NSubstitute; loggers via `NullLogger.Instance`. +- **File system:** use `Directory.CreateTempSubdirectory(...)` and delete it in `Dispose()`. Never touch the real `~/.ssh`; `KnownHostKeyStore` has a `(logger, path)` constructor for tests. +- **Platform-specific:** skip Unix-only permission tests with `Assert.SkipWhen(OperatingSystem.IsWindows(), "...")` and wrap the code in `#pragma warning disable CA1416`. +- **Cancellation:** `TestContext.Current.CancellationToken`. +- **Fixtures:** `OpenSSH_GUI.Tests/Assets/Testfiles/`, embedded as `OpenSSH_GUI.Tests.Assets.Testfiles.`. +- **Real keys:** generate with SshNet.Keygen (`SshKey.Generate`); see `BasicSshKeyFileInformationTests`, `SshKeyManagerPasswordTests`. +- **Cross-platform:** CI runs tests on Ubuntu, Windows and macOS; tests must pass on all three. +- **Coverage gaps:** no tests for `ServerConnection`, `KnownHostsFile`, `AuthorizedKeysFile`, `KeyFileBackupService`, `DirectoryCrawler`, the Dialogs project, or app ViewModels (`ViewModels/_SmokeTests.cs` is only a clipboard round-trip on a bare `Window`). Add tests when you change these areas. + +## Branches, versioning, commits and PRs + +- **Branches:** + - `master`: releases. It is also the GitHub default branch, so PRs may open against it; retarget day-to-day PRs to `development`. + - `development`: integration and default work target. + - `release/vX.Y.Z`: release PRs into `master` (`version-guard.yml` and `auto-tag.yml` only check the `release/` prefix). + - Feature branches (`feature/...`) get PRs into `development`. +- **Version:** single source is `` in `Directory.Build.props` (currently `3.2.4`). It is read by `.github/actions/determine-version` (`dotnet msbuild OpenSSH_GUI/OpenSSH_GUI.csproj -getProperty:Version`) and must be plain `X.Y.Z`. `version-guard.yml` runs on PRs into `master` whose head branch starts with `release/` and fails unless the version is greater than master's. Never tag by hand: `auto-tag.yml` tags `vX.Y.Z` when the release PR is merged. +- **Commits:** free-form imperative summaries (no Conventional Commits). Merged PRs appear as single squash commits titled ` (#NN)`. Security work uses a `Security: ` prefix; an issue reference may lead the title (e.g. `#37 ...`). Direct commits on `development` are free-form. +- **PRs:** fill out `.github/PULL_REQUEST_TEMPLATE.md` and reference the issue (`Closes #123`). Keep PRs small and focused. `dotnet build` and `dotnet test` must pass with no new warnings. + +## CI and release (`.github/`) + +- **Workflows:** + - `test.yml` ("Unit Tests"): `pull_request` (opened/reopened/synchronize, any base) and `workflow_call` from `build.yml`. **No push trigger**, so direct pushes are not tested. Matrix: `ubuntu-latest`, `windows-latest`, `macOS-latest`. Runs restore/build/test on `OpenSSH_GUI.slnx` (Release). + - `build.yml`: push of a tag matching `v[0-9]*.[0-9]*.[0-9]*` (created by `auto-tag.yml` with `secrets.PAT_TOKEN` so it triggers this workflow). Steps: tests, `build-and-package.yml`, NSIS installer (`installer/OpenSSH-GUI.nsi`), GitHub Release (the raw `OpenSSH-GUI-win-x64.exe` is removed in favor of the installer), then a `repository_dispatch` `deploy` with payload `{tag, version}`. + - `deploy-release.yml`: `repository_dispatch: deploy`. Publishes AUR `openssh-gui-bin` via composite `.github/actions/deploy-aur` (KSX action plus manual-push fallback), then Winget (`frequency403.OpenSSHGUI`, runs on `ubuntu-slim`). + - `staging.yml`: any push to `development` that changes `openssh-gui-git/PKGBUILD` republishes the AUR `-git` package (KSX action directly, reusing the push's head commit message). Edit that file deliberately. + - `version-guard.yml`, `auto-tag.yml`: see Versioning. +- **Permissions and secrets:** workflows default to `contents: read`; only `build.yml` (`contents: write`) and `auto-tag.yml` (`contents: write`, `pull-requests: write`) have write access. Secrets: `PAT_TOKEN`, `AUR_SSH_PRIVATE_KEY`, `WINGET_PUBLISH_TOKEN`. Do not expose them to PR-controlled code. `version-guard.yml` checks out untrusted PR head code; keep it read-only and secret-free. +- **Pinned actions:** every third-party `uses:` (including inside `.github/actions/*`) is pinned to a commit SHA, mostly with a version comment (`winget-releaser` is `# main`). Local composite actions are referenced by path. `build-appimage` downloads appimagetool 1.9.1 and verifies it with `sha256sum --check --strict`; update the `appimagetool-tag` and `appimagetool-sha256` inputs together. Keep everything pinned. +- **Release asset names are a contract.** Keep in sync: + - `build.yml` expected assets: `OpenSSH-GUI-linux-x64`, `OpenSSH-GUI-${VERSION}-Setup.exe`, `OpenSSH-GUI-osx-x64`, `OpenSSH-GUI-x86_64.AppImage`, `OpenSSH-GUI-x86_64.AppImage.zsync`, `appicon.png`, `io.github.frequency403.openssh_gui.desktop`; and its `asset_name_prefix: OpenSSH-GUI`; + - `OutFile` in `installer/OpenSSH-GUI.nsi`; + - the publish output name `OpenSSH_GUI[.exe]` expected by `build-and-package.yml`; + - the zsync string `gh-releases-zsync|frequency403|OpenSSH-GUI|latest|OpenSSH-GUI-x86_64.AppImage.zsync` in `.github/actions/build-appimage`; + - `openssh-gui-bin/PKGBUILD` source URLs (including `LICENSE` from raw.githubusercontent at tag `v${pkgver}`); + - the winget `Setup\.exe$` regex; + - `images/openssh-gui.svg` (AppImage and NSIS icons). + + The released `.desktop` file is generated by `appstreamcli make-desktop-file` from `appimage/io.github.frequency403.openssh_gui.metainfo.xml` (its app id), not taken from the repo-root `openssh-gui.desktop` (which is only copied to publish output). +- **Injected by CI, not manual:** metainfo (`` placeholder), NSIS (`@@VERSION@@`, `@@SOURCE_EXE@@`) and AppImage versions. `openssh-gui-bin` `pkgver`/`pkgrel` are rewritten by `deploy-release.yml` (repo value 3.1.6 is stale by design). `openssh-gui-git` derives its version in `pkgver()` and builds `#branch=development`. `CFBundleVersion` (1.0.0) in `OpenSSH_GUI.csproj` is static and not updated by CI. + +## Known quirks (don't "fix" these unasked, and don't trip over them) + +- `LoggingLevelSwitch` is registered both in `Program.cs` (`services.AddSingleton(levelSwitch)`, the instance Serilog uses) and in `OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs:36` (`AddSingleton()`, a new instance that wins resolution). Runtime level changes from `App.ApplyConfiguration` / `ApplicationSettingsViewModel` may therefore not reach Serilog. +- `OpenSSH_GUI.Tests/OpenSSH_GUI.Testss.csproj` is a stray Avalonia template file: not in the solution, inline `Version="11.3.12"` Avalonia references (CPM uses 12.1.0), and an `ApplicationManifest` (`app.manifest`) that does not exist. +- `OpenSSH_GUI.Dialogs.csproj` has machine-specific `` entries; they appear unused (Dialogs has no ReactiveUI code). +- `OpenSSH_GUI.csproj` names `OpenSSH-GUI.snk`, but the file is not tracked and signing is not enabled. +- `CONTRIBUTING.md` lists ".NET SDK 8.0+ (or .NET 10 for latest)"; SDK >= 10.0.100 is required. Its bare `dotnet run` fails from the repo root (use `dotnet run --project OpenSSH_GUI`), and its `dotnet publish ... --self-contained false` contradicts `true`. +- `NoWarn=AVLN3001` (`Directory.Build.props`) is intentional: `App` and `MainWindow` are XAML classes without parameterless constructors because they are built from DI. + +## Do / Don't checklist + +**Do** +- Build and test through `OpenSSH_GUI.slnx` or an explicit `.csproj` path. +- Register new services in `RegisterOpenSshGuiServices` and inject them with primary constructors. +- Add windows with `RegisterViewWithViewModel` using valid names, open them with `ResolveViewAsync`, set `x:DataType` in the XAML, and call base `InitializeAsync` in overrides. +- Pass runtime data through `InitializeAsync(TParam)`. +- Add UI strings to both `.resx` files. +- Add package versions only in `Directory.Packages.props`. +- Use `IMessageBoxProvider` for dialogs and `SecureInputDialog` (`ShowSecureInputAsync`) for secrets. +- Write cross-platform tests in temp directories, with Unix-only skips where needed. +- Keep all security invariants intact, and add tests when you touch them. + +**Don't** +- Accept host keys by default, auto-add on `Mismatch`/`Revoked`, bypass `IHostKeyTrustPrompt`, or add a `ServerConnection` factory without a validator. +- Write key files without `CreateNew`/`0600`, or loosen directory modes (`0700`). +- Pass passphrases to external processes, log them, or keep them in long-lived strings. +- Skip backup and restore, the semaphore, or the operation log in new or changed mutating key operations. +- Allow edits to `ProvidedByConfig` keys. +- "Fix" the SFTP `700`/`600` decimal-octal constants, or interpolate data into remote shell commands. +- Add `Version=` to `PackageReference`s, add a ViewLocator or routing, or `new` up services inside classes. +- Tag releases manually, bump the version outside `Directory.Build.props`, or rename release assets without updating every consumer. +- Unpin GitHub Actions from commit SHAs, or expose secrets to PR-controlled code.