Commit Graph

138 Commits

Author SHA1 Message Date
davide 65c2f5e6cd temp 2026-07-17 16:24:38 +02:00
davide feb765663c chore(chain): add mainnet checkpoint at height 475124 2026-07-17 15:14:24 +02:00
davide 322ce8f305 chore(release): bump version to 1.0.0
Fills in the CHANGELOG.md entry for the checkpoint-anchoring security fix,
fuzzing infrastructure and its findings, OP_RETURN/coinbase-tag decoding,
and localization fixes since 0.9.1 (see previous commits), and bumps
<Version>/versionCode across the App and Android head csproj files ahead
of the tag.
2026-07-17 14:02:52 +02:00
davide 077835a30b feat(wallet): decode OP_RETURN payloads and coinbase pool tags in tx details
Transaction detail view silently dropped OP_RETURN messages and coinbase
scriptSig text (e.g. pool tags), since TransactionInspector discarded
scriptPubKey/scriptSig bytes once no destination address could be derived.
Each output/input is decoded independently, so multiple OP_RETURN outputs
in one tx are all shown.
2026-07-09 08:35:39 +02:00
davide 663460d62e feat(ui): add user guide button to help overlay
Adds a "User guide" button next to "Report a bug" in the Help > Info
tab, linking to USERGUIDE.md on GitHub via the same Launcher pattern
used for bug reports and release pages.
2026-07-07 22:35:19 +02:00
davide be818a50a8 docs: tighten CLAUDE.md/AGENTS.md formatting for scannability
Convert dense prose paragraphs to bullet lists (Commands, Architecture,
GUI conventions, Working rules) and trim redundant wording; no content
removed. Both files kept in sync per the header sync rule.
2026-07-07 22:29:46 +02:00
davide 6f4ae679e5 docs: document fuzzing infrastructure and its security guarantees
Sync AGENTS.md/CLAUDE.md with the fuzzing workflow, add a README section
pointing to tests/PalladiumWallet.Fuzz, and record in SECURITY.md the two
guarantees the fuzz suite enforces: a bounded PBKDF2 iteration count on read
and a typed InvalidDataException for any malformed encrypted container.
2026-07-07 22:06:45 +02:00
davide cdede17683 feat(fuzz): add SharpFuzz-based fuzzing for untrusted-input parsers
New tests/PalladiumWallet.Fuzz project: one target per parser that
consumes untrusted input (block headers, Merkle proofs, peer lists,
wallet files, user-pasted mnemonics/keys/addresses/amounts), each
encoding the parser's documented error contract - any exception beyond
that contract is a finding, reported as a crash.

Three ways to run: the seed corpus (with regression inputs for every
crash found so far, including the two fixed in prior commits) replays
automatically inside dotnet test via FuzzCorpusTests, so a fixed
contract violation can't silently return; a built-in random-mutation
mode needs no external tooling (dotnet run -- <target> --random N);
and fuzz.sh drives real coverage-guided campaigns via afl++ +
SharpFuzz instrumentation for pre-release or post-parser-change runs.
2026-07-07 22:05:38 +02:00
davide a264669151 fix(storage): EncryptedFile.Decrypt must reject malformed containers cleanly
A tampered or corrupted wallet file could reach Decrypt with broken
JSON, missing fields, invalid base64, or a wrong nonce/tag size, all of
which previously escaped as raw JsonException/FormatException/
ArgumentNullException instead of the documented WrongPasswordException/
InvalidDataException contract. Worse, the iteration count is read
straight from the (attacker-controlled) container with no upper bound:
a tampered file demanding e.g. 2^31 PBKDF2 iterations would hang the
wallet at open - a DoS via a file the user merely tries to open. Now
every malformed shape maps to InvalidDataException and the iteration
count is clamped to a sane maximum (10,000,000, well above the current
600,000 default).
2026-07-07 22:05:08 +02:00
davide d9c75eaec2 fix(net): ParsePeers must tolerate any JSON shape from the server
server.peers.subscribe is attacker-controlled data: ParsePeers assumed
the standard [ip, hostname, [features...]] shape and threw on anything
else (non-array response, wrong element types), and GetString() on a
JSON string containing invalid UTF-8 bytes threw InvalidOperationException
instead of failing to parse — both found by fuzzing. Any unexpected
shape or unparseable string now degrades to "no usable data" instead of
propagating an exception into the sync/discovery path.
2026-07-07 21:52:00 +02:00
davide c868c17505 fix(crypto): Bip39.TryParse must not throw on unrecognisable text
Wordlist.AutoDetect lands on NBitcoin's internal "Unknown" language for
text resembling no wordlist and throws NotSupportedException instead
of failing gracefully — found by fuzzing the restore-mnemonic input.
TryParse now treats that (and the same failure mode from the Mnemonic
constructor) as "not a valid mnemonic", consistent with every other
rejection path.
2026-07-07 21:51:31 +02:00
davide d8917fbd9a docs: sync test-coverage description with the expanded suite
README's "what the suite covers" table and the CLAUDE.md/AGENTS.md test
seam notes were written before this round of coverage work (checkpoint
anchoring, PoW branch, wordlists, corrupted-key rejection, the
UpdateChecker/AppPaths seams, ...). Updated both to describe what is
actually tested now, including the two new internal seams so future
contributors reach for them instead of re-inventing or skipping
coverage of UpdateChecker/AppPaths.
2026-07-07 16:26:35 +02:00
davide 413392f608 test(storage): exercise AppPaths' full data-root precedence chain
DefaultDataRoot, the pointer-file location and the portable-mode root
all read machine-global locations (APPDATA, AppContext.BaseDirectory),
so the override→portable→pointer→default precedence in DataRoot/
IsDataLocationConfigured was previously untestable beyond the override
case. Added three internal overrides (BootstrapDirOverride,
PortableBaseOverride, DefaultRootOverride) as sandboxing seams, and a
new AppPathsResolutionTests class covering every precedence branch:
default-only, portable-beats-pointer, pointer-beats-default, a blank
pointer falling back to default, "already has data" counting as
configured, and override beating everything. Both AppPaths test
classes share the "AppPaths" xUnit collection since this state is
static.
2026-07-07 16:26:20 +02:00
davide 970253cbbe test(net): exercise UpdateChecker.CheckAsync end-to-end via a stub transport
CheckAsync built its own HttpClient inline, so nothing beyond tag
parsing was testable without hitting the real GitHub API. Added an
internal CheckAsync(currentVersion, HttpMessageHandler, ct) overload
(the public method now delegates to it) as a seam for a stub handler,
and tests for the full best-effort matrix: newer/equal/older/unparseable
tag, missing html_url, HTTP error status, malformed JSON, a thrown
HttpRequestException, and an unparseable local version — all of which
must resolve to null or the correct LatestRelease, never throw.
2026-07-07 16:26:06 +02:00
davide 31012ce825 test(net): cover ElectrumClient's multi-segment response dispatch
DispatchLine's ArrayPool-copy path (for a JSON-RPC line spanning
multiple PipeReader segments) only ran for small responses in existing
tests, which stay single-segment. A 256 KB fake response forces the
multi-segment branch and verifies the payload still arrives intact.
2026-07-07 16:25:50 +02:00
davide a46f5e6638 test(wallet): cover WalletLoader's defensive branches for corrupted files
ToAccount's InvalidDataException paths (corrupted mnemonic/xprv/xpub,
a document with no key material at all) and the equivalent guards in
NewFromXpub/NewFromXprv/NewFromWif had no coverage — these are the
checks that turn a damaged wallet file into a clear error instead of
an unhandled exception or, worse, a silently wrong account.
2026-07-07 16:25:37 +02:00
davide 61ed0c0ed0 test(chain): cover PalladiumNetworks.For and the INetworkSet plumbing
The out-of-range throw in For(NetKind) and the entire
PalladiumNetworkSet class (CryptoCode, the three network getters,
GetNetwork including its throw for an unrecognised ChainName) had no
coverage.
2026-07-07 16:25:23 +02:00
davide 9097ca4abc test(crypto): cover Bip39 empty input and all 8 wordlist languages
TryParse's empty/whitespace/null guard had no test, and ToWordlist's
language map was only exercised for English and Spanish even though
USERGUIDE §2.1 documents all 8 languages as supported on restore.
Also covers the ArgumentOutOfRangeException for a language outside
the enum.
2026-07-07 16:25:10 +02:00
davide 2ed8c04064 test(crypto): cover Slip132 rejection of malformed and corrupted keys
TryDecodePublic/TryDecodePrivate return false on several inputs that
weren't exercised: well-formed Base58Check with a payload that isn't
78 bytes, and a correct SLIP-132 header with a corrupted key body
(bad pubkey point prefix / bad private-key padding byte) — both must
fail cleanly inside the try/catch, not throw.
2026-07-07 16:24:56 +02:00
davide 56135c29f8 test(wallet): cover TransactionFactory's standardness-policy rejection
builder.Verify's failure branch (the safety net right before a signed
tx would be returned to the caller) had no test: an absurd fee rate
now proves the "Invalid transaction: ..." path fires instead of
silently returning a policy-violating transaction.
2026-07-07 16:24:42 +02:00
davide 5d74038aad test(wallet): cover ImportedKeyAccount edge cases and fund-safety fallbacks
Empty entry list rejection, GetPublicKey boundaries (mirroring the
existing GetPrivateKey tests), and explicit assertions that change and
out-of-range indices always fall back to the first imported address —
never to an address the wallet doesn't hold keys for.
2026-07-07 16:24:28 +02:00
davide db5b65ca0d test(spv): cover checkpoint-anchoring memoization and the non-generic retry path
Two branches of WalletSynchronizer had no coverage: the early-return in
AnchorToCheckpointAsync when a range is already memoized (a second sync
with a tx below the previously-anchored height must not re-walk the
header chain), and the void RetryOnBusyAsync overload used by the
transaction-download tasks (only the generic <T> overload, exercised by
get_history, had a test).
2026-07-07 16:24:11 +02:00
davide 45f7b1401e test(spv): cover the PoW-checked branch of BlockHeaderInfo.IsValidChild
Every configured ChainProfile sets SkipPowValidation=true, so the
hash<=target branch (taken only when a future profile disables the
skip) had never run in the suite. Added a genesis-hash-satisfies-target
case and a tampered-bits case that pushes the target below the hash.
2026-07-07 16:23:58 +02:00
davide e349a80ddc feat(spv): anchor header trust to hardcoded mainnet checkpoints
ChainProfiles.Mainnet.Checkpoints was an empty array with a "populate
before release" TODO, and even BlockHeaderInfo.MatchesCheckpoint()/
IsValidChild() — the methods meant to enforce it — were never called
anywhere in WalletSynchronizer. So on this LWMA chain, where PoW can't
be recomputed client-side (SkipPowValidation), a malicious or
eclipsing server could hand back any internally-consistent 80-byte
header for a Merkle proof: nothing tied it to the real Palladium
chain.

Populated Mainnet.Checkpoints with 24 real [height, hash, bits]
checkpoints (every 20,000 blocks + one near tip), pulled via RPC from
a fully-synced palladiumd. Testnet/Regtest are left empty (no node
available to verify against, and WalletSynchronizer already treats a
missing checkpoint as a no-op, so this is safe, just unanchored).

Added WalletSynchronizer.AnchorToCheckpointAsync: for every header
used in a Merkle proof, downloads the intervening headers back to the
nearest checkpoint at or below that height and verifies an unbroken
prev-hash chain terminating in the checkpoint's exact hash, finally
wiring up the previously-dead MatchesCheckpoint/IsValidChild. Verified
ranges are memoized in-memory per sync session to avoid re-walking.

Updated SECURITY.md and USERGUIDE.md to describe what is actually
enforced now (previously the guide deliberately avoided promising
checkpoint anchoring, since the array was empty).
2026-07-07 15:12:17 +02:00
davide 69be42aba0 docs(cli): translate console output and comments to English
The CLI printed all wallet/sync/send output, error messages and usage
text in Italian by design; per the project's language policy (code,
comments and docs are English-only, Italian is reserved for user
conversation) this was a design mismatch, not a documented feature.
Translated every user-facing string and the file's Italian comments,
and updated USERGUIDE.md §17 to match.
2026-07-07 14:46:08 +02:00
davide af2fdcc894 fix(gui): route runtime status messages through Loc instead of hardcoded Italian
Send/Donate/Sync/Wizard view models wrote status/error strings
directly in Italian ("Importo non valido.", "Errore: …", the TLS
pin-mismatch message, …), so a user running the app in English/German/
etc. still saw them in Italian despite the 6-language Loc system
covering the rest of the UI.

Added the missing Loc keys and routed every occurrence through
Loc.Tr(...). CertificatePinMismatchException (Core) no longer bakes an
Italian message into .Message; it now exposes Host/Port so the UI can
translate it, with an English fallback for any exception type the UI
doesn't specifically handle. Also drops the now-stale USERGUIDE.md
caveat about a few messages staying in Italian regardless of language.
2026-07-07 14:45:43 +02:00
davide 1f4421ae16 docs: add end-user guide covering GUI and CLI
Full reference for every user-facing behavior (wizard flows, script
types, fees, gap limit, TOFU cert pinning, CLI commands) with the
exact numbers the software enforces, so users don't have to guess or
read the source to understand a rule.
2026-07-07 14:44:48 +02:00
davide b304996ff5 docs(security): correct PBKDF2 iteration count and salt size
SECURITY.md still documented the pre-hardening parameters (100,000
iterations, 32-byte salt); the code has used 600,000 iterations and a
16-byte salt (EncryptedFile.DefaultIterations) since the encryption
upgrade, with the count itself stored in the file container for
forward compatibility.
2026-07-07 14:24:43 +02:00
davide c75e3921aa docs: sync AGENTS.md with CLAUDE.md and fix stale references
AGENTS.md had drifted from CLAUDE.md (ASCII dashes instead of em-dashes,
missing recent updates); both now carry identical content except for the
header, with an explicit rule to keep them in sync on future edits. Also
documents update-version.sh as the release workflow, the per-feature
partial-class split of MainWindowViewModel, UpdateChecker, and fixes a
reference to a nonexistent Core/Spv/MerkleVerifier.cs (the real file is
MerkleProof.cs) in SECURITY.md.
2026-07-02 23:45:53 +02:00
davide 50d8e7f21a docs(security): disclose AI-assisted testing methodology
Documents the concrete techniques used (adversarial fake-server simulation
of a lying indexing server, property-based fuzzing, targeted security code
review) rather than a generic "AI-reviewed" claim, and states explicitly
that this complements rather than replaces independent security review.
2026-07-02 23:38:25 +02:00
davide 15978bf564 chore(release): bump version to 0.9.1
Fills in the CHANGELOG.md entry for the test-suite expansion and bug fixes
since 0.9.0 (see previous commits), and bumps <Version>/versionCode across
the App and Android head csproj files ahead of the tag.
0.9.1
2026-07-02 23:25:48 +02:00
davide 31aabd0856 docs: document expanded test coverage and the fake ElectrumX server
README's "Running tests" section now lists what each test area covers and
how to measure coverage; CLAUDE.md/AGENTS.md point future work at extending
the fake ElectrumX server instead of mocking ElectrumClient (it isn't an
interface, by design).
2026-07-02 23:21:54 +02:00
davide 56ce1d4259 test: extend wallet/storage/property coverage and fix nullable warnings
- AppPaths: data-root resolution, per-network paths, wallet file listing.
- TransactionFactory: legacy/P2SH/segwit destinations, multi-UTXO selection,
  dust change absorbed into fee, and a golden txid for the PSBT signing path
  (RFC 6979 makes it deterministic; the builder's own output shuffling for
  privacy makes anchoring the vector at the top-level Build() unreliable).
- PropertyTests: Slip132 roundtrip for every script kind and network,
  WalletDocument JSON roundtrip with arbitrary labels/contacts, Scripthash
  cross-checked against an independent SHA-256 computation.
- StorageTests: regression cases for the IsEncrypted fix.
- Removed CS8602/CS8604 nullable warnings from existing address/loader tests.
2026-07-02 23:21:25 +02:00
davide 448f7dc65d test: cover network/SPV layer end to end (was previously untested)
ElectrumClient, WalletSynchronizer, TransactionInspector, CertificatePinStore
and peer discovery went from 0% coverage to close to full coverage, all
against the fake ElectrumX server:

- ElectrumClient: request/response correlation under load, server errors,
  notifications, disconnection, cancellation, SSL handshake with TOFU pinning.
- WalletSynchronizer: gap-limit scanning, UTXO/history reconstruction,
  unconfirmed/immature balances, busy-retry, disk-cache reuse, and the
  security-critical path where a lying server fails Merkle verification and
  the sync must abort.
- TransactionInspector: fee calculation from resolved inputs, mine/theirs
  attribution, RBF flag, coinbase handling, degraded paths when a previous
  transaction can't be resolved.
- CertificatePinStore: TOFU pin/match/mismatch/reset, corrupted-file fallback.
- ServerRegistry: peer discovery and persistence via DiscoverAsync.
- UpdateChecker: release-tag parsing and semver comparison.
2026-07-02 23:21:01 +02:00
davide e8ff99a768 test: add in-process fake ElectrumX server for network-layer testing
Real loopback TCP socket speaking newline-delimited JSON-RPC 2.0, optionally
TLS with a self-signed certificate, with per-method handlers and call
counters. Lets ElectrumClient, WalletSynchronizer, TransactionInspector, and
CertificatePinStore be exercised against the same code paths used in
production instead of being mocked.

Also exposes UpdateChecker's tag-parsing logic internally so its version
comparison can be tested without a real GitHub API call.
2026-07-02 23:20:32 +02:00
davide 27231c8eec fix(core): stop corrupted state from permanently breaking SSL and wallet loading
CertificatePinStore.Load threw on a corrupted pin file, blocking every SSL
connection until the user manually deleted it; EncryptedFile.IsEncrypted
threw on valid JSON with a non-object root or invalid UTF-16 instead of
returning false. Both now degrade gracefully. Found by property-based tests
while extending the test suite.
2026-07-02 23:19:46 +02:00
davide eb7ec9e835 chore: add update-version.sh to bump version across the project
Prompts for the new version and updates the App csproj Version (single
source of truth), mirrors it into the Android head's
ApplicationDisplayVersion, bumps the Android versionCode, and stubs a
new CHANGELOG.md entry.
2026-07-02 22:21:19 +02:00
davide f0fb3fe05d docs: link CHANGELOG.md and require updating it on new version tags
CLAUDE.md: add a working rule to update CHANGELOG.md whenever <Version> is
bumped for a new release tag. README.md: add a Changelog section pointing
to it, and fix a stale claim that release signing "is not set up yet" for
Android — it now is, via docker/build.sh android and the persistent
keystore (docker/keystore/).
0.9.0
2026-07-02 22:01:35 +02:00
davide 3f26f52f8b docs: add CHANGELOG.md for 0.9.0
Technical changelog covering the full history from the initial commit,
grouped by subsystem (Core/Chain, Crypto, Net, Spv, Storage, Wallet, App UI,
Android, CLI, Build, Testing, Docs) rather than raw commit order, since this
is the first release.
2026-07-02 22:01:17 +02:00
davide cb3ff714d9 feat(android): sign release builds with the persistent keystore
build_android now requires the keystore generated by the previous commit,
prompts for its passwords at build time, and signs the APK with it instead
of an ephemeral debug key auto-generated per container run — that was the
actual cause of every release needing a manual uninstall to update. Also
derives versionCode from <Version> instead of leaving it fixed at 1, so
version ordering stays monotonic across releases.

Docs (docker/README.md, CLAUDE.md) updated to match the new signing flow.
2026-07-02 21:54:23 +02:00
davide d0037747b5 chore(android): add persistent release-signing keystore generator
An Android APK's signature identifies the app to the OS; a new build must
carry the same one as the last install or Android refuses it as an update.
generate-keystore.sh creates the keystore once, interactively, and refuses
to run again once one exists to avoid ever changing it by accident. The
resulting file is git-ignored — it's a secret that must live outside the
repo, backed up separately.
2026-07-02 21:53:56 +02:00
davide 34b4313a36 feat(app): check GitHub releases on startup and prompt for updates
Compares the running assembly version against the latest GitHub release
tag on every app launch (desktop and Android) and shows an in-app
overlay with the new version tag when one is available. Best-effort:
network/parse failures are silent, matching the app's existing
non-blocking startup checks.
2026-07-02 21:33:18 +02:00
davide 2d017231eb chore(github): complete issue/PR templates and wire in-app bug report to them
Add a feature request template and a config.yml (blank issues disabled,
security reports routed to SECURITY.md) alongside the existing bug report
template, plus a PR template with a checklist matching the project's
security rules. Point the app's "Report a bug" button at the new GitHub
template now that the repo is hosted there instead of the old self-hosted
tracker.
2026-07-02 21:22:42 +02:00
davide e7797017f6 feat(gui): split server discovery into its own always-usable button
Previously the server settings overlay had only "Connect and sync" and
"Reset certs" — peer discovery had no button and only ran implicitly
when reopening the dialog while connected. Add a dedicated "Sincronizza"
button wired to DiscoverServersCommand, and make it work even without
an active connection by opening a short-lived connection to a candidate
server just to query its peer list, then closing it without touching
the wallet's connection state.
2026-07-02 19:24:16 +02:00
davide 6c24a8bb46 feat(wallet): surface immature balance separately from confirmed
Extract confirmation-threshold logic into UtxoSpendability (shared by
TransactionFactory and WalletSynchronizer) and use it to compute
ImmatureSats, so coinbase/under-confirmed amounts are shown separately
from spendable balance in the GUI and CLI instead of being lumped into
"confirmed".
2026-07-02 18:24:33 +02:00
davide 9aecdb1aaa feat(wallet): enforce confirmation thresholds before spending UTXOs
Coinbase outputs require COINBASE_MATURITY + 1 = 121 confirmations before
they can be spent. The consensus rule (palladiumcore tx_verify.cpp) is
nSpendHeight - nHeight >= 120; the +1 adds one block of safety margin,
matching the Qt wallet (wallet.cpp: COINBASE_MATURITY+1). Regular UTXOs
require MinConfirmations (6 on mainnet, 1 on testnet/regtest — wallet
policy, no consensus rule).

Changes:
- ChainProfile: add CoinbaseMaturity and MinConfirmations fields
- ChainProfiles: mainnet CoinbaseMaturity=120 / MinConfirmations=6;
  testnet/regtest inherit 120, override MinConfirmations=1
- PalladiumNetworks: align NBitcoin Consensus.CoinbaseMaturity to 120
- CachedUtxo: add IsCoinbase flag (ElectrumX listunspent does not expose
  this; derived from Transaction.IsCoinBase on the raw tx)
- WalletSynchronizer: set IsCoinbase during local UTXO reconstruction
- TransactionFactory.Build: add tipHeight param; filter by threshold;
  error reports reason (immature coinbase with X/121 counter, under-
  confirmed regulars with best-seen count, mempool amounts)
- All Build() call sites updated (App, Donate, CLI)
- Two new tests: coinbase maturity boundary (119→220 tip) and mainnet
  min-conf boundary (5→6 confirmations)
2026-07-02 16:25:42 +02:00
davide 2be43d3177 fix(sync): allow server change at any time, even during an active sync
When IsSyncing and the user requests a different server, cancel the
running CancellationTokenSource instead of silently ignoring the click.
OperationCanceledException is caught separately (not an error), and
ConnectAndSync restarts automatically with the new server after the
cancelled task unwinds.

The "Connect" button no longer has IsEnabled="{Binding !IsSyncing}":
it is always clickable, and clicking it with the same server sets
_resyncRequested as before. CancellationToken is now passed to both
ElectrumClient.ConnectAsync and WalletSynchronizer.SyncOnceAsync.
2026-07-02 14:56:58 +02:00
davide ef60ec0330 loc: expand About description to include non-custodial and local security details
All six languages updated: the one-liner was replaced with a two-sentence
description that names the network (PLM), the lightweight SPV design,
the non-custodial model, and the local encryption guarantee.
2026-07-02 12:29:38 +02:00
davide 567c0de501 docs: document reproducible builds and fix Windows publish command
README.md: add "Reproducible builds (Docker)" section at the top of
Building, explaining why it matters for a wallet (auditable toolchain,
no drift between releases, anyone can verify binaries from source).
Fix the manual Windows publish command to include
IncludeNativeLibrariesForSelfExtract=true — the previous command produced
an exe that silently failed to start because Avalonia's native DLLs were
left outside it.

CLAUDE.md: document docker/ in the project tree, promote ./docker/build.sh
as the primary release path, record the two non-obvious gotchas (native
libs flag for single-file desktop; XA5207 / API level coupling for Android).
2026-07-02 10:45:57 +02:00
davide 6a5daa0c18 feat(build): add Docker-based reproducible build system
Adds docker/ with two Dockerfiles and a build.sh interactive script that
builds all three release targets (Windows exe, Linux binary, Android APK)
inside pinned Docker containers — no SDK required on the host.

Key design decisions:
- Source mounted read-only; build runs on an in-container copy so bin/obj
  never pollute the working tree.
- NuGet packages cached in a named Docker volume (plm-nuget-cache) across
  runs to avoid re-downloading on each build.
- Single-file desktop publishes use IncludeNativeLibrariesForSelfExtract so
  Avalonia's native libs (Skia, HarfBuzz, ANGLE) are embedded — without this
  flag the exe/binary silently fails to start.
- Android Dockerfile pins platform API 36 (dictated by the .NET 10 android
  workload, error XA5207 if mismatched) and bakes the full Android SDK +
  workload into the image layer.
- Artifacts are chown'd back to the host user so dist/ files are never
  owned by root.

All three targets verified end-to-end from a clean docker system prune -a:
Windows PE32+ exe (103 MB), Linux single-file binary launches on this host,
Android APK installs and renders UI on API-34 x86_64 emulator.
2026-07-02 10:45:43 +02:00